73 Commits

Author SHA1 Message Date
Aaron D. Lee
e4256cd037 Catch ValueError in has_dct_support() for numpy incompatibility
The jpegio package raises ValueError when compiled against numpy 2.x
but numpy 1.x is installed at runtime. This catches the error gracefully
so tests don't fail on Python 3.10 environments with mismatched numpy.

Also removes stale steganography.py_old backup file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:55:26 -05:00
Aaron D. Lee
948a582e5d Fix numpy binary incompatibility on Python 3.10
Add explicit numpy>=2.0.0 constraint to dct, web, and api extras.
Scipy/jpegio wheels are built against numpy 2.x, so we need to ensure
numpy 2.x is installed to avoid dtype size mismatch errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:47:44 -05:00
Aaron D. Lee
afa88bc73b Apply black formatter to all Python files
Reformatted 29 files for consistent code style and CI compliance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:44:41 -05:00
Aaron D. Lee
221678d934 Moved all docs to root. 2026-01-02 17:41:27 -05:00
Aaron D. Lee
faf3efac0b Update documentation to v4.0.1
- README.md: Add v4.0.1 to version history
- API.md: Update title and version in examples to v4.0.1
- CLI.md: Update title to v4.0.1
- WEB_UI.md: Update to v4.0.1, document channel key dropdown and LED indicators

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:36:24 -05:00
Aaron D. Lee
9c45e0d0f8 Fix BatchCredentials tests: add required reference_photo
- Add sample_reference_photo fixture for test data
- Update sample_credentials fixture to include reference_photo
- Update all BatchCredentials test dicts to include reference_photo
- Add 'phrase' as legacy key in BatchCredentials.from_dict()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:28:14 -05:00
Aaron D. Lee
6b21190f97 Lint cleanup: ruff fixes across entire codebase
- Strip trailing whitespace from all Python files
- Fix import sorting (I001) across all modules
- Convert Optional[X] to X | None syntax (UP045)
- Remove unused imports (F401)
- Convert lambda assignments to def functions (E731)
- Add TYPE_CHECKING import for forward references
- Update pyproject.toml ruff config:
  - Move select/ignore to [tool.ruff.lint] section
  - Add per-file ignores for DCT colorspace naming (N803/N806)
  - Add per-file ignores for __init__.py import structure (E402)
  - Exclude defunct test_routes.py
- Remove frontends/web/test_routes.py (defunct debug snippet)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:17:38 -05:00
Aaron D. Lee
d94ee7be90 Bump version to 4.0.1 with Web UI improvements
- Update version to 4.0.1 across constants.py, __init__.py, pyproject.toml, README
- Refactor channel key UI from radio buttons to select dropdown
- Add LED indicator and key capsule CSS styles
- Reorganize encode/decode forms: RSA key section moved up, PIN + Channel in row
- Streamline channel key JavaScript for dropdown-based selection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:43:25 -05:00
Aaron D. Lee
6fa4b447db More snazzy 4.0 Web UI improvements. 2026-01-02 15:45:43 -05:00
Aaron D. Lee
1bb3589baf Lots of snazzy ui updates. 2026-01-02 13:18:58 -05:00
Aaron D. Lee
cfd1d8fb66 Snazzy ui updates. 2026-01-01 22:52:06 -05:00
Aaron D. Lee
c1beaf3611 Snazzy ui updates. 2026-01-01 22:51:53 -05:00
Aaron D. Lee
ef7478b30a A whoooole lotta 4.0.x fixes. 2026-01-01 22:18:13 -05:00
Aaron D. Lee
12929bf326 Release checklist and updated test scripts. 2026-01-01 14:04:55 -05:00
Aaron D. Lee
a001f227ec Bug fixes, CLI updates, docs. 2026-01-01 13:40:27 -05:00
Aaron D. Lee
3898031480 WebUI Fixes for 3.2 2026-01-01 03:39:44 -05:00
Aaron D. Lee
657cae0ae6 3.2.0 Big revamp 2026-01-01 03:14:35 -05:00
Aaron D. Lee
11fc8aab27 3.2.0 Big revamp 2026-01-01 03:14:27 -05:00
Aaron D. Lee
6d64c69f08 Home/about revamps. 2025-12-31 18:39:14 -05:00
Aaron D. Lee
6bd38ccf57 Added UNDER_THE_HOOD.md doc to explain the stego process. 2025-12-31 17:33:09 -05:00
Aaron D. Lee
d8fe25a121 Clean up old files. 2025-12-31 17:27:09 -05:00
Aaron D. Lee
3869391336 Updated screenshot for README.md 2025-12-31 17:24:23 -05:00
Aaron D. Lee
1b914f0409 Updated screenshot for README.md 2025-12-31 17:23:06 -05:00
Aaron D. Lee
2b7abc52c1 Updated screenshot for README.md 2025-12-31 17:21:07 -05:00
Aaron D. Lee
66f7d54db5 Updated encode page to not hide DCT/LSB selector, format tweaks. 2025-12-31 17:16:51 -05:00
Aaron D. Lee
34376b2dfe Version 3.0.2 full expirimental DCT support, jpegio for better jpg manipulation, etc. 2025-12-31 15:43:29 -05:00
Aaron D. Lee
4eefc946c4 Version 3.1.0 now with experimental DCT support. 2025-12-31 13:11:34 -05:00
Aaron D. Lee
e4a4a5e074 Small fixes, 2.2.1 2025-12-30 23:40:39 -05:00
Aaron D. Lee
50a7b10c63 Pinned the container, some other resiliancy stuff. 2025-12-30 23:31:17 -05:00
Aaron D. Lee
6de8130c8b Teeeeeweeeak. 2025-12-30 12:15:25 -05:00
Aaron D. Lee
5394967dce Homepage tweaks. 2025-12-30 12:02:31 -05:00
Aaron D. Lee
5274dd20ec Homepage tweaks. 2025-12-30 01:43:10 -05:00
Aaron D. Lee
1e98a13edf Fix logo stuff 2025-12-30 01:24:47 -05:00
Aaron D. Lee
a74c0b70ea Clean up tasks homie. 2025-12-30 01:00:20 -05:00
Aaron D. Lee
cf55acaf5a Back to prior logo. 2025-12-30 00:52:32 -05:00
Aaron D. Lee
aa9729b3b1 ok 2025-12-30 00:45:05 -05:00
Aaron D. Lee
5ed25f706f More CI/CD fixes and stuff (automation goodness). 2025-12-30 00:28:58 -05:00
Aaron D. Lee
72468e7972 Mypy fixes galore. 2025-12-30 00:23:09 -05:00
Aaron D. Lee
37a60d7174 Add CI/CD workflows and security policy 2025-12-30 00:08:22 -05:00
Aaron D. Lee
a7c2fcc1da Fixed container bugy nightmare (somehow). 2025-12-29 23:01:12 -05:00
Aaron D. Lee
1b9405389c Fixing container disaster. 2025-12-29 21:34:06 -05:00
Aaron D. Lee
ee44cfd46e I don't even know anymore. 2025-12-29 21:01:42 -05:00
Aaron D. Lee
40ce6d663c Revert "2.1.4 - Manual code cleanup stuff, version configued in 1ish place, etc."
This reverts commit c784140cde.
2025-12-29 21:00:44 -05:00
Aaron D. Lee
0aaeb7c6c7 Removed old dupe of dockerfile. 2025-12-29 18:57:33 -05:00
Aaron D. Lee
c34bc9ef78 Lil stuff 2025-12-29 18:20:19 -05:00
Aaron D. Lee
33dc69ce63 typo 2025-12-29 18:19:07 -05:00
Aaron D. Lee
c784140cde 2.1.4 - Manual code cleanup stuff, version configued in 1ish place, etc. 2025-12-29 18:16:10 -05:00
Aaron D. Lee
00763de780 Tagline update. 2025-12-29 17:03:51 -05:00
Aaron D. Lee
f35acfed06 Fixed QR functionality in the API container. 2025-12-29 15:08:48 -05:00
Aaron D. Lee
5217e86ca9 More README nonsense. 2025-12-29 14:50:03 -05:00
Aaron D. Lee
b1c343bfe3 More README nonsense. 2025-12-29 14:49:19 -05:00
Aaron D. Lee
e2a2d979f8 More README nonsense. 2025-12-29 14:47:57 -05:00
Aaron D. Lee
9aef50dbed More README nonsense. 2025-12-29 14:43:41 -05:00
Aaron D. Lee
b836692635 More README nonsense. 2025-12-29 14:40:48 -05:00
Aaron D. Lee
e8b23b0a87 More README nonsense. 2025-12-29 14:39:50 -05:00
Aaron D. Lee
79fb9f21f1 More README nonsense. 2025-12-29 14:37:38 -05:00
Aaron D. Lee
9ce4c3e385 More README nonsense. 2025-12-29 14:36:43 -05:00
Aaron D. Lee
63e2735d96 More README nonsense. 2025-12-29 14:31:38 -05:00
Aaron D. Lee
fdaffbd3bb More README nonsense. 2025-12-29 14:30:21 -05:00
Aaron D. Lee
0c7fa647f1 More README nonsense. 2025-12-29 14:29:07 -05:00
Aaron D. Lee
a5ee25b297 More README nonsense. 2025-12-29 14:25:48 -05:00
Aaron D. Lee
6bd18fd013 More README nonsense. 2025-12-29 12:44:29 -05:00
Aaron D. Lee
7c84e25378 More README nonsense. 2025-12-29 12:42:03 -05:00
Aaron D. Lee
e43b4defdd More hardcoded crap. 2025-12-29 12:32:24 -05:00
Aaron D. Lee
a7df211242 README formatting. 2025-12-29 12:28:30 -05:00
Aaron D. Lee
c9741c1da6 README formatting. 2025-12-29 12:23:04 -05:00
Aaron D. Lee
a318f16a0d New README 2025-12-29 12:19:03 -05:00
Aaron D. Lee
3bad80361a New README 2025-12-29 12:14:13 -05:00
Aaron D. Lee
9559a3c39f About page enhancements. 2025-12-29 12:08:15 -05:00
Aaron D. Lee
8b69c5d9e9 QR tweaks: WebUI only creates QR's with zipped keys now. 2025-12-29 11:45:01 -05:00
Aaron D. Lee
0dc44e2d7b UI tweaks: gently nudges user to leverage rotating daily phrases. 2025-12-29 11:31:01 -05:00
Aaron D. Lee
5bf477f2ad Other tweaks and such. 2025-12-29 09:13:24 -05:00
Aaron D. Lee
3c759c15d7 Other tweaks and such. 2025-12-29 00:04:47 -05:00
84 changed files with 25395 additions and 6105 deletions

228
.github/CI_CD_PRIMER.md vendored Normal file
View File

@@ -0,0 +1,228 @@
# CI/CD Primer for Stegasoo
## What is CI/CD?
**CI** = Continuous Integration
**CD** = Continuous Deployment
Think of it as a robot assistant that automatically:
1. **Tests your code** every time you push
2. **Checks formatting** so code stays consistent
3. **Publishes releases** when you tag a version
```
You push code → GitHub runs workflows → You get ✓ or ✗
```
---
## How It Works
### The Trigger
When you `git push` or create a Pull Request, GitHub looks for workflow files in:
```
.github/workflows/*.yml
```
Each `.yml` file defines a workflow - a series of steps to run.
### The Runners
GitHub provides free Linux/Mac/Windows VMs that:
1. Clone your repo
2. Set up Python
3. Run your commands
4. Report success/failure
You don't manage servers - GitHub does.
---
## Your Workflows
### 1. `test.yml` - Run Tests on Every Push
**When it runs:** Every push, every PR
**What it does:**
```
1. Spins up Ubuntu VM
2. Installs Python 3.10, 3.11, 3.12
3. Installs your package + dependencies
4. Runs pytest
5. Reports pass/fail
```
**You'll see:** Green ✓ or red ✗ on your commits
### 2. `lint.yml` - Check Code Style
**When it runs:** Every push, every PR
**What it does:**
```
1. Runs ruff (fast Python linter)
2. Checks black formatting
3. Fails if code isn't formatted
```
**Why:** Keeps code consistent, catches common bugs
### 3. `release.yml` - Publish to PyPI
**When it runs:** Only when you create a version tag
**What it does:**
```
1. Builds the package (wheel + sdist)
2. Uploads to PyPI
```
**You trigger it by:**
```bash
git tag v2.2.0
git push origin v2.2.0
```
---
## Day-to-Day Usage
### Normal Development
```bash
# Make changes
git add .
git commit -m "Add new feature"
git push
```
Then check GitHub → Actions tab → See if tests pass.
### If Tests Fail
1. Click the failed workflow
2. Click the failed job
3. Read the error log
4. Fix locally, push again
### Making a Release
```bash
# 1. Update version in pyproject.toml and constants.py
# 2. Commit the version bump
git add .
git commit -m "Bump version to 2.2.1"
git push
# 3. Create and push a tag
git tag v2.2.1
git push origin v2.2.1
# 4. GitHub automatically publishes to PyPI
```
---
## Reading the GitHub UI
### Actions Tab
```
Repository → Actions → [List of workflow runs]
```
Each run shows:
- ✓ Green checkmark = passed
- ✗ Red X = failed
- 🟡 Yellow dot = running
### Pull Request Checks
When you open a PR, you'll see:
```
┌─────────────────────────────────────────┐
│ All checks have passed │
│ ✓ test (3.10) — 45s │
│ ✓ test (3.11) — 42s │
│ ✓ test (3.12) — 44s │
│ ✓ lint — 12s │
└─────────────────────────────────────────┘
```
---
## Setting Up PyPI Publishing
For `release.yml` to work, you need to add a PyPI API token:
### One-Time Setup
1. **Create PyPI account** at https://pypi.org/account/register/
2. **Generate API token:**
- PyPI → Account Settings → API tokens
- Create token (scope: entire account or just stegasoo)
- Copy the token (starts with `pypi-`)
3. **Add to GitHub:**
- GitHub repo → Settings → Secrets and variables → Actions
- New repository secret
- Name: `PYPI_API_TOKEN`
- Value: paste the token
Now `release.yml` can publish automatically.
---
## Common Scenarios
### "Tests pass locally but fail in CI"
Usually means:
- Missing dependency in `pyproject.toml`
- Hardcoded path that doesn't exist in CI
- Test relies on local file
### "Lint is failing"
Run locally to see/fix:
```bash
# Check issues
ruff check src/
# Auto-fix what's possible
ruff check --fix src/
# Format code
black src/
```
### "I want to skip CI for a commit"
Add `[skip ci]` to commit message:
```bash
git commit -m "Update README [skip ci]"
```
---
## Costs
GitHub Actions is **free** for public repos.
For private repos: 2,000 minutes/month free, then paid.
---
## Summary
| Action | What Happens |
|--------|--------------|
| `git push` | Tests + lint run automatically |
| Open PR | Tests must pass before merge |
| `git tag v*` | Publishes to PyPI |
| Check results | GitHub → Actions tab |
That's it! Push code, check for green checkmarks.

63
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
# Check code style and formatting
name: Lint
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
lint:
runs-on: ubuntu-latest
steps:
# 1. Get the code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Python
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
# 3. Install linting tools
- name: Install linters
run: |
python -m pip install --upgrade pip
pip install ruff black
# 4. Run ruff (fast linter - catches bugs and style issues)
- name: Run ruff
run: |
ruff check src/ tests/ frontends/
# 5. Check black formatting (doesn't modify, just checks)
- name: Check black formatting
run: |
black --check src/ tests/ frontends/
# Type checking (optional but helpful)
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install mypy
- name: Run mypy
run: |
mypy src/stegasoo --ignore-missing-imports
continue-on-error: true # Don't fail build on type errors (yet)

95
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
# Publish to PyPI when a version tag is pushed
name: Release
on:
push:
tags:
- 'v*' # Triggers on v1.0.0, v2.1.0, etc.
jobs:
# First, run tests to make sure everything works
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libzbar0
- name: Install package
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests
run: pytest
# Then build and publish
publish:
needs: test # Only run if tests pass
runs-on: ubuntu-latest
# Required for PyPI trusted publishing (recommended)
permissions:
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
# Option 1: Trusted Publishing (recommended, no token needed)
# Set this up at: https://pypi.org/manage/project/stegasoo/settings/publishing/
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@release/v1
# No token needed if you configure trusted publishing on PyPI
# Option 2: API Token (uncomment if not using trusted publishing)
# - name: Publish to PyPI (API Token)
# env:
# TWINE_USERNAME: __token__
# TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
# run: twine upload dist/*
# Create GitHub Release with changelog
github-release:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # Needed to create releases
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
files: |
dist/*

53
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
# Run tests on every push and pull request
name: Tests
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
# 1. Get the code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
# 3. Install system dependencies (for pyzbar QR reading)
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libzbar0
# 4. Install the package with all dependencies
- name: Install package
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
# 5. Run tests with coverage
- name: Run tests
run: |
pytest --cov=stegasoo --cov-report=xml --cov-report=term-missing
# 6. Upload coverage report (optional - integrates with codecov.io)
- name: Upload coverage
uses: codecov/codecov-action@v4
if: matrix.python-version == '3.11' # Only upload once
with:
files: ./coverage.xml
fail_ci_if_error: false # Don't fail if codecov is down

6
.gitignore vendored
View File

@@ -56,4 +56,8 @@ htmlcov/
*.spec
# Output test files.
*.png
test_data/*.png
#Project root scripts.
rbld_containers.sh
quick_web.sh

40
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,40 @@
# Pre-commit hooks - run formatting/linting before each commit
# Install: pip install pre-commit && pre-commit install
# Manual run: pre-commit run --all-files
repos:
# Ruff - fast Python linter (replaces flake8, isort, etc.)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.6
hooks:
- id: ruff
args: [--fix] # Auto-fix what's possible
- id: ruff-format # Ruff's formatter (alternative to black)
# Black - code formatter (comment out if using ruff-format above)
# - repo: https://github.com/psf/black
# rev: 23.11.0
# hooks:
# - id: black
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace # Remove trailing spaces
- id: end-of-file-fixer # Ensure newline at EOF
- id: check-yaml # Validate YAML
- id: check-toml # Validate TOML
- id: check-added-large-files # Prevent giant files
args: ['--maxkb=1000']
- id: check-merge-conflict # No merge conflict markers
- id: debug-statements # No print() or pdb left behind
# Security checks
- repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
additional_dependencies: ["bandit[toml]"]
exclude: tests/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12.0

882
API.md Normal file
View File

@@ -0,0 +1,882 @@
# Stegasoo REST API Documentation (v4.0.1)
Complete REST API reference for Stegasoo steganography operations.
## Table of Contents
- [Overview](#overview)
- [What's New in v4.0.0](#whats-new-in-v400)
- [Installation](#installation)
- [Base URL](#base-url)
- [Endpoints](#endpoints)
- [GET /](#get--status)
- [GET /modes](#get-modes)
- [GET /channel/status](#get-channelstatus)
- [POST /channel/generate](#post-channelgenerate)
- [POST /channel/set](#post-channelset)
- [DELETE /channel](#delete-channel)
- [POST /generate](#post-generate)
- [POST /encode](#post-encode-json)
- [POST /encode/file](#post-encodefile)
- [POST /encode/multipart](#post-encodemultipart)
- [POST /decode](#post-decode-json)
- [POST /decode/multipart](#post-decodemultipart)
- [POST /compare](#post-compare)
- [POST /will-fit](#post-will-fit)
- [POST /image/info](#post-imageinfo)
- [Channel Keys](#channel-keys)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Code Examples](#code-examples)
---
## Overview
The Stegasoo REST API provides programmatic access to all steganography operations:
- **Generate** credentials (passphrase, PINs, RSA keys)
- **Encode** messages or files into images (LSB or DCT mode)
- **Decode** messages or files from images (auto-detects mode)
- **Channel keys** for deployment/group isolation (v4.0.0)
- **Analyze** image capacity and compare modes
The API supports both JSON (base64-encoded images) and multipart form data (direct file uploads).
---
## What's New in v4.0.0
Version 4.0.0 adds **channel key** support for deployment/group isolation:
| Feature | Description |
|---------|-------------|
| Channel keys | 256-bit keys that isolate message groups |
| New endpoints | `/channel/status`, `/channel/generate`, `/channel/set`, `DELETE /channel` |
| Encode/decode param | `channel_key` parameter on all encode/decode endpoints |
| Response headers | `X-Stegasoo-Channel-Mode` and `X-Stegasoo-Channel-Fingerprint` |
**Key benefits:**
- ✅ Isolate messages between teams, deployments, or groups
- ✅ Same credentials can't decode messages from different channels
- ✅ Backward compatible (public mode = no channel key)
**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations.
---
## Installation
### From PyPI
```bash
pip install stegasoo[api]
```
### Running the Server
**Development:**
```bash
cd frontends/api
python main.py
```
**Production:**
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```
**Docker with channel key:**
```bash
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose up api
```
---
## Base URL
| Environment | URL |
|-------------|-----|
| Local Development | `http://localhost:8000` |
| Docker | `http://localhost:8000` |
| Production | Configure as needed |
---
## Endpoints
### GET / (Status)
Check API status and configuration.
#### Response
```json
{
"version": "4.0.1",
"has_argon2": true,
"has_qrcode_read": true,
"has_dct": true,
"max_payload_kb": 500,
"available_modes": ["lsb", "dct"],
"dct_features": {
"output_formats": ["png", "jpeg"],
"color_modes": ["grayscale", "color"]
},
"channel": {
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"source": "~/.stegasoo/channel.key"
},
"breaking_changes": {
"v4_channel_key": "Messages encoded with channel key require same key to decode",
"format_version": 5,
"backward_compatible": false
}
}
```
---
### GET /modes
Get available embedding modes and channel status.
#### Response
```json
{
"lsb": {
"available": true,
"name": "Spatial LSB",
"description": "Embed in pixel LSBs, outputs PNG/BMP",
"output_format": "PNG (color)",
"capacity_ratio": "100%"
},
"dct": {
"available": true,
"name": "DCT Domain",
"output_formats": ["png", "jpeg"],
"color_modes": ["grayscale", "color"],
"capacity_ratio": "~20% of LSB",
"requires": "scipy"
},
"channel": {
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456"
}
}
```
---
### GET /channel/status
Get current channel key status. **New in v4.0.0.**
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `reveal` | boolean | `false` | Include full key in response |
#### Response
```json
{
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"source": "~/.stegasoo/channel.key",
"key": null
}
```
With `reveal=true`:
```json
{
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"source": "~/.stegasoo/channel.key",
"key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
}
```
#### cURL Example
```bash
# Show status
curl http://localhost:8000/channel/status
# Reveal full key
curl "http://localhost:8000/channel/status?reveal=true"
```
---
### POST /channel/generate
Generate a new channel key. **New in v4.0.0.**
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `save` | boolean | `false` | Save to user config |
| `save_project` | boolean | `false` | Save to project config |
#### Response
```json
{
"key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456",
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456",
"saved": true,
"save_location": "~/.stegasoo/channel.key"
}
```
#### cURL Examples
```bash
# Just generate (don't save)
curl -X POST http://localhost:8000/channel/generate
# Generate and save to user config
curl -X POST "http://localhost:8000/channel/generate?save=true"
# Generate and save to project config
curl -X POST "http://localhost:8000/channel/generate?save_project=true"
```
---
### POST /channel/set
Set/save a channel key to config. **New in v4.0.0.**
#### Request Body
```json
{
"key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456",
"location": "user"
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `key` | string | required | Channel key |
| `location` | string | `"user"` | `"user"` or `"project"` |
#### Response
```json
{
"success": true,
"location": "~/.stegasoo/channel.key",
"fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456"
}
```
---
### DELETE /channel
Clear channel key from config. **New in v4.0.0.**
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `location` | string | `"user"` | `"user"`, `"project"`, or `"all"` |
#### Response
```json
{
"success": true,
"mode": "public",
"still_configured": false,
"remaining_source": null
}
```
#### cURL Example
```bash
# Clear user config
curl -X DELETE http://localhost:8000/channel
# Clear project config
curl -X DELETE "http://localhost:8000/channel?location=project"
# Clear all
curl -X DELETE "http://localhost:8000/channel?location=all"
```
---
### POST /generate
Generate credentials for encoding/decoding.
#### Request Body
```json
{
"use_pin": true,
"use_rsa": false,
"pin_length": 6,
"rsa_bits": 2048,
"words_per_passphrase": 4
}
```
#### Response
```json
{
"passphrase": "abandon ability able about",
"pin": "847293",
"rsa_key_pem": null,
"entropy": {
"passphrase": 44,
"pin": 19,
"rsa": 0,
"total": 63
}
}
```
---
### POST /encode (JSON)
Encode a text message into an image.
#### Request Body
```json
{
"message": "Secret message here",
"reference_photo_base64": "iVBORw0KGgo...",
"carrier_image_base64": "iVBORw0KGgo...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_base64": null,
"rsa_password": null,
"channel_key": null,
"embed_mode": "lsb",
"dct_output_format": "png",
"dct_color_mode": "grayscale"
}
```
#### Channel Key Parameter (v4.0.0)
| Value | Effect |
|-------|--------|
| `null` | Auto mode - use server-configured key |
| `""` (empty string) | Public mode - no channel isolation |
| `"XXXX-XXXX-..."` | Explicit key - use this specific key |
#### Response
```json
{
"stego_image_base64": "iVBORw0KGgo...",
"filename": "a1b2c3d4.png",
"capacity_used_percent": 12.4,
"embed_mode": "lsb",
"output_format": "png",
"color_mode": "color",
"channel_mode": "private",
"channel_fingerprint": "ABCD-••••-••••-••••-••••-••••-••••-3456"
}
```
---
### POST /encode/file
Encode a file into an image (JSON with base64).
Same parameters as `/encode`, plus:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file_data_base64` | string | ✓ | Base64-encoded file data |
| `filename` | string | ✓ | Original filename |
| `mime_type` | string | | MIME type |
---
### POST /encode/multipart
Encode using multipart form data (file uploads).
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `passphrase` | string | ✓ | Passphrase |
| `reference_photo` | file | ✓ | Reference photo |
| `carrier` | file | ✓ | Carrier image |
| `message` | string | * | Text message |
| `payload_file` | file | * | Binary file to embed |
| `pin` | string | | Static PIN |
| `rsa_key` | file | | RSA key (.pem) |
| `rsa_key_qr` | file | | RSA key (QR code image) |
| `rsa_password` | string | | RSA key password |
| `channel_key` | string | | `"auto"` (default), `"none"=public`, or explicit key |
| `embed_mode` | string | | `"lsb"` or `"dct"` |
| `dct_output_format` | string | | `"png"` or `"jpeg"` |
| `dct_color_mode` | string | | `"grayscale"` or `"color"` |
\* Provide either `message` or `payload_file`
#### Channel Key in Multipart
For form data, the channel_key field uses strings:
| Value | Effect |
|-------|--------|
| `"auto"` | Use server config (default) |
| `"none"` | Public mode |
| `"XXXX-XXXX-..."` | Explicit key |
#### Response
Returns the stego image directly with headers:
```http
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: attachment; filename=a1b2c3d4.png
X-Stegasoo-Capacity-Percent: 12.4
X-Stegasoo-Embed-Mode: lsb
X-Stegasoo-Channel-Mode: private
X-Stegasoo-Channel-Fingerprint: ABCD-••••-...-3456
X-Stegasoo-Version: 4.0.1
<binary image data>
```
#### cURL Examples
```bash
# Encode with auto channel key (default)
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "message=Secret message" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
# Encode with explicit channel key
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=words here" \
-F "pin=123456" \
-F "message=Team message" \
-F "channel_key=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
# Encode in public mode (no channel isolation)
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=words here" \
-F "pin=123456" \
-F "message=Public message" \
-F "channel_key=none" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
---
### POST /decode (JSON)
Decode a message or file from a stego image.
#### Request Body
```json
{
"stego_image_base64": "iVBORw0KGgo...",
"reference_photo_base64": "iVBORw0KGgo...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_base64": null,
"rsa_password": null,
"channel_key": null,
"embed_mode": "auto"
}
```
#### Response (Text)
```json
{
"payload_type": "text",
"message": "Secret message here",
"file_data_base64": null,
"filename": null,
"mime_type": null
}
```
#### Response (File)
```json
{
"payload_type": "file",
"message": null,
"file_data_base64": "UEsDBBQAAAA...",
"filename": "document.pdf",
"mime_type": "application/pdf"
}
```
---
### POST /decode/multipart
Decode using multipart form data.
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `passphrase` | string | ✓ | Passphrase |
| `reference_photo` | file | ✓ | Reference photo |
| `stego_image` | file | ✓ | Stego image to decode |
| `pin` | string | | Static PIN |
| `rsa_key` | file | | RSA key (.pem) |
| `rsa_key_qr` | file | | RSA key (QR code image) |
| `rsa_password` | string | | RSA key password |
| `channel_key` | string | | `"auto"` (default), `"none"=public`, or explicit key |
| `embed_mode` | string | | `"auto"`, `"lsb"`, or `"dct"` |
---
## Channel Keys
### Overview
Channel keys provide **deployment/group isolation**. Messages encoded with a channel key can only be decoded with the same key.
### Key Format
```
ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘
8 groups of 4 alphanumeric characters (256 bits)
```
### Storage Locations
Keys are checked in order:
| Priority | Location | Best For |
|----------|----------|----------|
| 1 | `STEGASOO_CHANNEL_KEY` env var | Docker, CI/CD |
| 2 | `./config/channel.key` | Project-specific |
| 3 | `~/.stegasoo/channel.key` | User default |
### API Parameter Values
#### JSON Endpoints (`/encode`, `/decode`)
| Value | Effect |
|-------|--------|
| `null` | Auto - use server config |
| `""` | Public mode |
| `"XXXX-..."` | Explicit key |
#### Multipart Endpoints (`/encode/multipart`, `/decode/multipart`)
| Value | Effect |
|-------|--------|
| `"auto"` | Use server config (default) |
| `"none"` | Public mode |
| `"XXXX-..."` | Explicit key |
### Workflow Example
```bash
# 1. Generate a channel key for the team
KEY=$(curl -s -X POST http://localhost:8000/channel/generate | jq -r '.key')
echo "Team key: $KEY"
# 2. Distribute to team members (securely!)
# 3. Each deployment sets the key
export STEGASOO_CHANNEL_KEY=$KEY
# 4. Encode - automatically uses server key
curl -X POST http://localhost:8000/encode/multipart \
-F "passphrase=team passphrase" \
-F "pin=123456" \
-F "message=Team secret" \
-F "reference_photo=@ref.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
# 5. Decode - automatically uses server key
curl -X POST http://localhost:8000/decode/multipart \
-F "passphrase=team passphrase" \
-F "pin=123456" \
-F "reference_photo=@ref.jpg" \
-F "stego_image=@stego.png"
```
---
## Data Models
### ChannelStatusResponse
```json
{
"mode": "private",
"configured": true,
"fingerprint": "ABCD-••••-...-3456",
"source": "~/.stegasoo/channel.key",
"key": "ABCD-1234-..."
}
```
### EncodeResponse (v4.0.0)
```json
{
"stego_image_base64": "string",
"filename": "string",
"capacity_used_percent": 12.4,
"embed_mode": "lsb",
"output_format": "png",
"color_mode": "color",
"channel_mode": "private",
"channel_fingerprint": "ABCD-••••-...-3456"
}
```
### DecodeResponse
```json
{
"payload_type": "text",
"message": "string",
"file_data_base64": null,
"filename": null,
"mime_type": null
}
```
---
## Error Handling
### HTTP Status Codes
| Code | Meaning | Use Case |
|------|---------|----------|
| 200 | OK | Successful operation |
| 400 | Bad Request | Invalid input, capacity error, invalid channel key |
| 401 | Unauthorized | Decryption failed, channel key mismatch |
| 500 | Internal Error | Unexpected server error |
| 501 | Not Implemented | Feature unavailable |
### Channel Key Errors
| Status | Error | Cause |
|--------|-------|-------|
| 400 | "Invalid channel key format" | Key doesn't match `XXXX-XXXX-...` pattern |
| 401 | "Message encoded with channel key but none configured" | Need to provide channel key |
| 401 | "Message encoded without channel key" | Use `channel_key=""` or `"none"` |
---
## Code Examples
### Python
```python
import requests
BASE_URL = "http://localhost:8000"
# Check channel status
status = requests.get(f"{BASE_URL}/channel/status").json()
print(f"Channel mode: {status['mode']}")
print(f"Fingerprint: {status.get('fingerprint', 'N/A')}")
# Generate channel key
response = requests.post(f"{BASE_URL}/channel/generate?save=true")
key_info = response.json()
print(f"Generated: {key_info['fingerprint']}")
# Encode with channel key (auto from server)
with open("ref.jpg", "rb") as ref, open("carrier.png", "rb") as carrier:
response = requests.post(f"{BASE_URL}/encode/multipart", files={
"reference_photo": ref,
"carrier": carrier,
}, data={
"message": "Team secret",
"passphrase": "apple forest thunder",
"pin": "123456",
# channel_key defaults to "auto" (use server config)
})
with open("stego.png", "wb") as f:
f.write(response.content)
print(f"Channel mode: {response.headers.get('X-Stegasoo-Channel-Mode')}")
# Encode with explicit channel key
with open("ref.jpg", "rb") as ref, open("carrier.png", "rb") as carrier:
response = requests.post(f"{BASE_URL}/encode/multipart", files={
"reference_photo": ref,
"carrier": carrier,
}, data={
"message": "Using explicit key",
"passphrase": "words here",
"pin": "123456",
"channel_key": "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456",
})
# Decode
with open("ref.jpg", "rb") as ref, open("stego.png", "rb") as stego:
response = requests.post(f"{BASE_URL}/decode/multipart", files={
"reference_photo": ref,
"stego_image": stego,
}, data={
"passphrase": "apple forest thunder",
"pin": "123456",
# channel_key defaults to "auto"
})
result = response.json()
print(f"Decoded: {result.get('message')}")
```
### JavaScript
```javascript
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const BASE_URL = 'http://localhost:8000';
async function main() {
// Check channel status
const status = await axios.get(`${BASE_URL}/channel/status`);
console.log('Channel:', status.data.mode);
// Encode with auto channel key
const form = new FormData();
form.append('passphrase', 'apple forest thunder');
form.append('pin', '123456');
form.append('message', 'Secret');
form.append('reference_photo', fs.createReadStream('ref.jpg'));
form.append('carrier', fs.createReadStream('carrier.png'));
// channel_key defaults to "auto" (use server config)
const response = await axios.post(`${BASE_URL}/encode/multipart`, form, {
headers: form.getHeaders(),
responseType: 'arraybuffer'
});
fs.writeFileSync('stego.png', response.data);
console.log('Channel mode:', response.headers['x-stegasoo-channel-mode']);
}
main();
```
### cURL / Bash
```bash
#!/bin/bash
BASE_URL="http://localhost:8000"
# Check channel status
echo "Channel status:"
curl -s "$BASE_URL/channel/status" | jq .
# Generate and save channel key
echo "Generating channel key..."
curl -s -X POST "$BASE_URL/channel/generate?save=true" | jq .
# Encode (channel_key defaults to "auto")
echo "Encoding..."
curl -s -X POST "$BASE_URL/encode/multipart" \
-F "passphrase=apple forest thunder" \
-F "pin=123456" \
-F "message=Secret message" \
-F "reference_photo=@ref.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
echo "Encoded to stego.png"
# Decode
echo "Decoding..."
curl -s -X POST "$BASE_URL/decode/multipart" \
-F "passphrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@ref.jpg" \
-F "stego_image=@stego.png" | jq .
```
---
## Docker Configuration
### docker-compose.yml
```yaml
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
services:
api:
build:
context: .
target: api
ports:
- "8000:8000"
environment:
<<: *common-env
```
### .env (gitignored)
```bash
STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
```
### Generate key for .env
```bash
curl -s -X POST http://localhost:8000/channel/generate | \
jq -r '"STEGASOO_CHANNEL_KEY=\(.key)"' >> .env
```
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [Web UI Documentation](WEB_UI.md) - Browser interface
- [README](../README.md) - Project overview

759
CLI.md Normal file
View File

@@ -0,0 +1,759 @@
# Stegasoo CLI Documentation (v4.0.1)
Complete command-line interface reference for Stegasoo steganography operations.
## Table of Contents
- [Installation](#installation)
- [What's New in v4.0.0](#whats-new-in-v400)
- [Quick Start](#quick-start)
- [Commands](#commands)
- [generate](#generate-command)
- [encode](#encode-command)
- [decode](#decode-command)
- [verify](#verify-command)
- [channel](#channel-command)
- [info](#info-command)
- [compare](#compare-command)
- [modes](#modes-command)
- [strip-metadata](#strip-metadata-command)
- [Channel Keys](#channel-keys)
- [Embedding Modes](#embedding-modes)
- [Security Factors](#security-factors)
- [Workflow Examples](#workflow-examples)
- [Piping & Scripting](#piping--scripting)
- [Error Handling](#error-handling)
- [Exit Codes](#exit-codes)
---
## Installation
### From PyPI
```bash
# CLI only
pip install stegasoo[cli]
# CLI with DCT support
pip install stegasoo[cli,dct]
# With all extras
pip install stegasoo[all]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[cli,dct]"
```
### Verify Installation
```bash
stegasoo --version
stegasoo --help
# Check DCT support
python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if has_dct_support() else 'requires scipy')"
# Check channel key status
stegasoo channel show
```
---
## What's New in v4.0.0
Version 4.0.0 adds **channel key** support for deployment/group isolation:
| Feature | Description |
|---------|-------------|
| Channel keys | 256-bit keys that isolate message groups |
| Deployment isolation | Different deployments can't read each other's messages |
| CLI management | New `stegasoo channel` command group |
| Flexible override | Use server config, explicit key, or public mode |
**Key benefits:**
- ✅ Isolate messages between teams, deployments, or groups
- ✅ Same credentials can't decode messages from different channels
- ✅ Backward compatible (public mode = no channel key)
- ✅ Easy key distribution via environment variables or config files
**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations.
---
## Quick Start
```bash
# 1. Generate credentials (do this once, memorize results)
stegasoo generate
# 2. (Optional) Set up channel key for deployment isolation
stegasoo channel generate --save
# 3. Encode a message (uses configured channel key automatically)
stegasoo encode \
--ref secret_photo.jpg \
--carrier meme.png \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Meet at midnight"
# 4. Decode a message (uses same channel key)
stegasoo decode \
--ref secret_photo.jpg \
--stego stego_abc123.png \
--passphrase "apple forest thunder mountain" \
--pin 123456
# 5. Decode without channel key (public mode)
stegasoo decode \
--ref secret_photo.jpg \
--stego public_stego.png \
--passphrase "words here now" \
--pin 123456 \
--no-channel
```
---
## Commands
### Generate Command
Generate credentials for encoding/decoding operations.
#### Synopsis
```bash
stegasoo generate [OPTIONS]
```
#### Options
| Option | Short | Type | Default | Description |
|--------|-------|------|---------|-------------|
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
| `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
| `--json` | | flag | | Output as JSON |
#### Examples
```bash
# Basic generation with PIN (default)
stegasoo generate
# Generate with more words for higher security
stegasoo generate --words 6
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096
# Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
```
---
### Encode Command
Encode a secret message or file into an image.
#### Synopsis
```bash
stegasoo encode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Default | Description |
|--------|-------|------|----------|---------|-------------|
| `--ref` | `-r` | path | ✓ | | Reference photo |
| `--carrier` | `-c` | path | ✓ | | Carrier image |
| `--passphrase` | `-p` | string | ✓ | | Passphrase |
| `--message` | `-m` | string | | | Message to encode |
| `--message-file` | `-f` | path | | | Read message from file |
| `--embed-file` | `-e` | path | | | Embed a binary file |
| `--pin` | | string | * | | Static PIN (6-9 digits) |
| `--key` | `-k` | path | * | | RSA key file |
| `--key-qr` | | path | * | | RSA key from QR code |
| `--key-password` | | string | | | RSA key password |
| `--channel` | | string | | auto | Channel key (v4.0.0) |
| `--channel-file` | | path | | | Read channel key from file |
| `--no-channel` | | flag | | | Force public mode |
| `--output` | `-o` | path | | | Output filename |
| `--mode` | | choice | | `lsb` | Embedding mode |
| `--dct-format` | | choice | | `png` | DCT output format |
| `--dct-color` | | choice | | `grayscale` | DCT color mode |
| `--quiet` | `-q` | flag | | | Suppress output |
\* At least one of `--pin`, `--key`, or `--key-qr` is required.
#### Channel Key Options
| Option | Effect |
|--------|--------|
| *(none)* | Use server-configured key (auto mode) |
| `--channel KEY` | Use explicit channel key |
| `--channel auto` | Same as no option |
| `--channel-file F` | Read channel key from file |
| `--no-channel` | Force public mode (no isolation) |
#### Examples
```bash
# Basic encoding (uses server channel key if configured)
stegasoo encode \
-r photo.jpg -c meme.png \
-p "correct horse battery staple" \
--pin 847293 \
-m "The package arrives Tuesday"
# With explicit channel key
stegasoo encode \
-r photo.jpg -c meme.png \
-p "correct horse battery staple" \
--pin 847293 \
-m "Secret message" \
--channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
# Public mode (no channel isolation)
stegasoo encode \
-r photo.jpg -c meme.png \
-p "correct horse battery staple" \
--pin 847293 \
-m "Public message" \
--no-channel
# DCT mode for social media
stegasoo encode \
-r photo.jpg -c meme.png \
-p "words here" --pin 847293 \
-m "Secret" \
--mode dct --dct-format jpeg
```
---
### Decode Command
Decode a secret message or file from a stego image.
#### Synopsis
```bash
stegasoo decode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Default | Description |
|--------|-------|------|----------|---------|-------------|
| `--ref` | `-r` | path | ✓ | | Reference photo |
| `--stego` | `-s` | path | ✓ | | Stego image |
| `--passphrase` | `-p` | string | ✓ | | Passphrase |
| `--pin` | | string | * | | Static PIN |
| `--key` | `-k` | path | * | | RSA key file |
| `--key-qr` | | path | * | | RSA key from QR code |
| `--key-password` | | string | | | RSA key password |
| `--channel` | | string | | auto | Channel key (v4.0.0) |
| `--channel-file` | | path | | | Read channel key from file |
| `--no-channel` | | flag | | | Force public mode |
| `--output` | `-o` | path | | | Save output to file |
| `--mode` | | choice | | `auto` | Extraction mode |
| `--quiet` | `-q` | flag | | | Minimal output |
| `--force` | | flag | | | Overwrite existing file |
#### Examples
```bash
# Basic decoding (uses server channel key)
stegasoo decode \
-r photo.jpg -s stego.png \
-p "correct horse battery staple" \
--pin 847293
# With explicit channel key
stegasoo decode \
-r photo.jpg -s stego.png \
-p "words here" --pin 847293 \
--channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
# Decode public image (no channel key was used)
stegasoo decode \
-r photo.jpg -s stego.png \
-p "words here" --pin 847293 \
--no-channel
# Save to file
stegasoo decode \
-r photo.jpg -s stego.png \
-p "words" --pin 123456 \
-o decoded.txt
```
---
### Verify Command
Verify credentials without extracting the message.
#### Synopsis
```bash
stegasoo verify [OPTIONS]
```
#### Options
Same as `decode`, minus `--output` and `--force`. Adds `--json` for JSON output.
#### Examples
```bash
# Quick verification
stegasoo verify -r photo.jpg -s stego.png -p "phrase" --pin 123456
# With explicit channel key
stegasoo verify -r photo.jpg -s stego.png -p "phrase" --pin 123456 \
--channel ABCD-1234-...
# JSON output
stegasoo verify -r photo.jpg -s stego.png -p "phrase" --pin 123456 --json
```
---
### Channel Command
Manage channel keys for deployment/group isolation.
#### Subcommands
| Subcommand | Description |
|------------|-------------|
| `generate` | Create a new channel key |
| `show` | Display current channel key status |
| `set` | Save a channel key to config |
| `clear` | Remove channel key from config |
#### channel generate
```bash
stegasoo channel generate [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--save` | `-s` | Save to user config (~/.stegasoo/channel.key) |
| `--save-project` | | Save to project config (./config/channel.key) |
| `--env` | `-e` | Output as environment variable export |
| `--quiet` | `-q` | Output only the key |
**Examples:**
```bash
# Just display a new key
stegasoo channel generate
# Save to user config
stegasoo channel generate --save
# Add to .env file
stegasoo channel generate --env >> .env
# For scripts
KEY=$(stegasoo channel generate -q)
```
#### channel show
```bash
stegasoo channel show [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--reveal` | `-r` | Show full key (not just fingerprint) |
| `--json` | | Output as JSON |
**Examples:**
```bash
# Show status (fingerprint only)
stegasoo channel show
# Reveal full key
stegasoo channel show --reveal
# JSON for scripts
stegasoo channel show --json
```
**Output:**
```
─── CHANNEL KEY STATUS ───
Mode: PRIVATE
Fingerprint: ABCD-••••-••••-••••-••••-••••-••••-3456
Source: ~/.stegasoo/channel.key
Messages require this channel key to decode.
```
#### channel set
```bash
stegasoo channel set [KEY] [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--file` | `-f` | Read key from file |
| `--project` | `-p` | Save to project config instead of user |
**Examples:**
```bash
# Set from command line
stegasoo channel set ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
# Set from file
stegasoo channel set --file channel.key
# Set in project config
stegasoo channel set XXXX-... --project
```
#### channel clear
```bash
stegasoo channel clear [OPTIONS]
```
| Option | Short | Description |
|--------|-------|-------------|
| `--project` | `-p` | Clear project config |
| `--all` | | Clear both user and project configs |
| `--force` | `-f` | Skip confirmation |
**Examples:**
```bash
# Clear user config (with confirmation)
stegasoo channel clear
# Clear project config
stegasoo channel clear --project
# Clear all configs without confirmation
stegasoo channel clear --all --force
```
---
### Info Command
Show information about an image file.
```bash
stegasoo info IMAGE [OPTIONS]
```
---
### Compare Command
Compare embedding mode capacities for an image.
```bash
stegasoo compare IMAGE [OPTIONS]
```
---
### Modes Command
Show available embedding modes and their status.
```bash
stegasoo modes
```
Now also displays channel key status.
---
### Strip-Metadata Command
Remove all metadata from an image.
```bash
stegasoo strip-metadata IMAGE [OPTIONS]
```
---
## Channel Keys
Channel keys provide **deployment/group isolation** - messages encoded with a channel key can only be decoded by systems with the same key.
### Key Format
```
ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘
8 groups of 4 alphanumeric characters (256 bits)
```
### Storage Locations
Channel keys are checked in this order:
| Priority | Location | Best For |
|----------|----------|----------|
| 1 | `STEGASOO_CHANNEL_KEY` env var | Docker, CI/CD |
| 2 | `./config/channel.key` | Project-specific |
| 3 | `~/.stegasoo/channel.key` | User default |
### Modes
| Mode | Description | CLI Option |
|------|-------------|------------|
| **Auto** | Use server-configured key | *(default)* |
| **Explicit** | Use specific key | `--channel KEY` |
| **Public** | No channel isolation | `--no-channel` |
### Fingerprints
For security, full keys aren't displayed by default. Instead, a fingerprint is shown:
```
Full key: ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
Fingerprint: ABCD-••••-••••-••••-••••-••••-••••-3456
```
### Use Cases
**Team isolation:**
```bash
# Team A
export STEGASOO_CHANNEL_KEY=AAAA-1111-...
# Team B
export STEGASOO_CHANNEL_KEY=BBBB-2222-...
# Messages from Team A can only be decoded by Team A
```
**Development vs Production:**
```bash
# Development
./config/channel.key contains DEV-KEY-...
# Production
STEGASOO_CHANNEL_KEY=PROD-KEY-... in Docker
# Dev messages can't be decoded in production
```
**Public messages:**
```bash
# Anyone with credentials can decode
stegasoo encode ... --no-channel
stegasoo decode ... --no-channel
```
---
## Embedding Modes
### LSB Mode (Default)
```bash
stegasoo encode ... --mode lsb
```
| Aspect | Details |
|--------|---------|
| **Capacity** | ~375 KB for 1920×1080 |
| **Output** | PNG only |
| **Best For** | Maximum capacity |
### DCT Mode
```bash
stegasoo encode ... --mode dct --dct-format jpeg --dct-color color
```
| Aspect | Details |
|--------|---------|
| **Capacity** | ~65 KB for 1920×1080 |
| **Output** | PNG or JPEG |
| **Best For** | Social media, stealth |
---
## Security Factors
| Factor | Description | Entropy |
|--------|-------------|---------|
| Reference Photo | Shared image | ~80-256 bits |
| Passphrase | BIP-39 words | ~44 bits (4 words) |
| Static PIN | Numeric (6-9) | ~20 bits (6 digits) |
| RSA Key | Shared key file | ~128 bits |
| Channel Key (v4.0.0) | Deployment isolation | ~256 bits |
---
## Workflow Examples
### Team Setup with Channel Key
**Initial setup (team lead):**
```bash
# Generate team channel key
stegasoo channel generate -q > team_channel.key
# Distribute to team members securely
# (encrypted email, secure file share, etc.)
```
**Team member setup:**
```bash
# Save received key
stegasoo channel set --file team_channel.key
# Verify
stegasoo channel show
```
**Daily use:**
```bash
# Channel key is used automatically
stegasoo encode -r ref.jpg -c meme.png -p "phrase" --pin 123456 -m "Team message"
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
```
### Docker Deployment
**docker-compose.yml:**
```yaml
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
services:
web:
environment:
<<: *common-env
api:
environment:
<<: *common-env
```
**.env (gitignored):**
```bash
STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
```
### CI/CD Pipeline
```bash
# Generate key for CI
CHANNEL_KEY=$(stegasoo channel generate -q)
# Use in pipeline
STEGASOO_CHANNEL_KEY=$CHANNEL_KEY stegasoo encode ...
```
---
## Piping & Scripting
### Extract channel key for scripts
```bash
# Get just the key
KEY=$(stegasoo channel show --json | jq -r '.key // empty')
# Get fingerprint
FINGERPRINT=$(stegasoo channel show --json | jq -r '.fingerprint // "none"')
# Check if configured
if stegasoo channel show --json | jq -e '.configured' > /dev/null; then
echo "Channel key is configured"
fi
```
### Generate and use immediately
```bash
# Generate, save, and use
stegasoo channel generate --save
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "message"
```
---
## Error Handling
### Channel Key Errors
| Error | Cause | Solution |
|-------|-------|----------|
| "Invalid channel key format" | Key doesn't match pattern | Use `stegasoo channel generate` |
| "Message encoded with channel key but none configured" | Missing channel key | Set key or use `--channel` |
| "Message encoded without channel key" | Used `--no-channel` to encode | Decode with `--no-channel` |
| "Channel key mismatch" | Wrong key | Verify correct key |
### Troubleshooting
```bash
# Check current channel status
stegasoo channel show
# Try decoding with explicit key
stegasoo decode ... --channel XXXX-XXXX-...
# Try decoding without channel key
stegasoo decode ... --no-channel
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error / decryption failed |
| 2 | Invalid arguments/options |
---
## Environment Variables
| Variable | Description |
|----------|-------------|
| `STEGASOO_CHANNEL_KEY` | Channel key for deployment isolation (v4.0.0) |
| `PYTHONPATH` | Include `src/` for development |
| `STEGASOO_DEBUG` | Enable debug output (set to `1`) |
---
## See Also
- [API Documentation](API.md) - Python API reference
- [Web UI Documentation](WEB_UI.md) - Browser interface guide
- [README](../README.md) - Project overview and security model

View File

@@ -1,46 +1,63 @@
# Stegasoo Docker Image
# Multi-stage build for smaller image size
# Uses pre-built base image for fast rebuilds
#
# First time setup:
# docker build -f Dockerfile.base -t stegasoo-base:latest .
#
# Then build normally (fast!):
# docker-compose build
#
# Or if you don't have the base image, this falls back to building deps
# (slow, but works)
FROM python:3.11-slim as base
# ============================================================================
# ARG to switch between base image and full build
# ============================================================================
ARG USE_BASE_IMAGE=true
# ============================================================================
# Base stage - use pre-built image if available
# ============================================================================
FROM stegasoo-base:latest AS base-prebuilt
FROM python:3.12-slim AS base-full
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_ROOT_USER_ACTION=ignore
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libc-dev \
libffi-dev \
libzbar0 \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Install ALL dependencies (slow path)
RUN pip install --no-cache-dir \
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
flask>=3.0.0 gunicorn>=21.0.0 \
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
# ============================================================================
# Builder stage - install Python packages
# Select which base to use (default: prebuilt)
# ============================================================================
FROM base as builder
WORKDIR /build
# Copy package files (including README.md which pyproject.toml references)
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
# Install the package with web extras
RUN pip install --no-cache-dir ".[web]"
FROM base-prebuilt AS base
# ============================================================================
# Production stage - Web UI
# ============================================================================
FROM base as web
FROM base AS web
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application files
# Copy application files (this is all that rebuilds normally!)
COPY src/ src/
COPY data/ data/
COPY frontends/web/ frontends/web/
@@ -64,22 +81,18 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
# Run with gunicorn
WORKDIR /app/frontends/web
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"]
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
# ============================================================================
# API stage - REST API
# ============================================================================
FROM base as api
FROM base AS api
WORKDIR /app
# Install API extras
COPY pyproject.toml README.md ./
# Copy application files
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
@@ -103,17 +116,13 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# ============================================================================
# CLI stage - Command line tool
# ============================================================================
FROM base as cli
FROM base AS cli
WORKDIR /app
# Install CLI extras
COPY pyproject.toml README.md ./
# Copy application files
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[cli]"
# Copy CLI files
COPY frontends/cli/ frontends/cli/
# Create non-root user

55
Dockerfile.base Normal file
View File

@@ -0,0 +1,55 @@
# Stegasoo Base Image
# Contains all slow-to-compile dependencies (jpegio, scipy, argon2)
# Build once: docker build -f Dockerfile.base -t stegasoo-base:latest .
# Push to registry for team use: docker push yourregistry/stegasoo-base:latest
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_ROOT_USER_ACTION=ignore
# Install system dependencies
# NOTE: g++ is required for jpegio C++ compilation
# NOTE: libjpeg-dev is required for jpegio
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libc-dev \
libffi-dev \
libzbar0 \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Install the slow-to-compile packages
# These rarely change, so they get cached in this base image
RUN pip install --no-cache-dir \
cython \
numpy \
scipy>=1.10.0 \
jpegio>=0.2.0 \
argon2-cffi>=23.0.0 \
pillow>=10.0.0 \
cryptography>=41.0.0
# Install web/api framework packages (also stable)
RUN pip install --no-cache-dir \
flask>=3.0.0 \
gunicorn>=21.0.0 \
fastapi>=0.100.0 \
"uvicorn[standard]>=0.20.0" \
python-multipart>=0.0.6 \
qrcode>=7.3.0 \
pyzbar>=0.1.9 \
click>=8.0.0 \
lz4>=4.0.0
# Verify key packages work
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
# Label for tracking
LABEL org.opencontainers.image.title="Stegasoo Base"
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
LABEL org.opencontainers.image.version="4.0.0"

707
INSTALL.md Normal file
View File

@@ -0,0 +1,707 @@
# Stegasoo Installation Guide
Complete installation instructions for all platforms and deployment methods.
## Table of Contents
- [Requirements](#requirements)
- [Quick Install](#quick-install)
- [Installation Methods](#installation-methods)
- [From Source (Development)](#from-source-development)
- [From PyPI](#from-pypi)
- [Docker](#docker)
- [Docker Compose](#docker-compose)
- [Optional Dependencies](#optional-dependencies)
- [Platform-Specific Notes](#platform-specific-notes)
- [Verification](#verification)
- [Troubleshooting](#troubleshooting)
---
## Requirements
### ⚠️ Python Version Requirements
| Python Version | Status | Notes |
|----------------|--------|-------|
| 3.10 | ✅ Supported | |
| 3.11 | ✅ Supported | Recommended |
| 3.12 | ✅ Supported | Recommended |
| 3.13 | ❌ **Not Supported** | jpegio C extension incompatible |
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
### Minimum Requirements
| Requirement | Value |
|-------------|-------|
| Python | 3.10-3.12 |
| RAM | 512 MB minimum (256MB for Argon2) |
| Disk | ~100 MB |
### System Dependencies
**Linux (Debian/Ubuntu):**
```bash
sudo apt-get update
sudo apt-get install -y \
python3.12 \
python3.12-venv \
python3-pip \
python3-dev \
libzbar0 \
libjpeg-dev \
build-essential
```
**Linux (Arch):**
```bash
# Use pyenv for Python version management
curl https://pyenv.run | bash
pyenv install 3.12
pyenv local 3.12
sudo pacman -S zbar libjpeg-turbo base-devel
```
**macOS:**
```bash
brew install python@3.12 zbar jpeg
xcode-select --install # For compilation
```
**Windows:**
- Install Python 3.12 from [python.org](https://python.org)
- Install Visual Studio Build Tools for compilation
---
## Quick Install
```bash
# Clone and install everything
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
# Create venv with Python 3.12 (critical!)
python3.12 -m venv venv
source venv/bin/activate # Linux/macOS
# or: venv\Scripts\activate # Windows
# Install all dependencies
pip install -e ".[all]"
# Verify
stegasoo --version
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
```
---
## Installation Methods
### From Source (Development)
Best for development or customization.
```bash
# Clone the repository
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
# Create virtual environment with Python 3.12 (recommended)
python3.12 -m venv venv
source venv/bin/activate # Linux/macOS
# or: venv\Scripts\activate # Windows
# Verify Python version
python -V # Should show 3.12.x
# Install core library only
pip install -e .
# Install with specific extras
pip install -e ".[cli]" # Command-line interface
pip install -e ".[web]" # Flask web UI + DCT support
pip install -e ".[api]" # FastAPI REST API + DCT support
pip install -e ".[dct]" # DCT steganography only
pip install -e ".[compression]" # LZ4 compression
# Install everything
pip install -e ".[all]"
# Install with development tools
pip install -e ".[dev]"
```
### From PyPI
```bash
# Core only
pip install stegasoo
# With extras
pip install stegasoo[cli]
pip install stegasoo[web]
pip install stegasoo[api]
pip install stegasoo[all]
```
### Docker
Build and run individual containers.
#### Build Images
```bash
# Build all targets
docker build -t stegasoo-web --target web .
docker build -t stegasoo-api --target api .
docker build -t stegasoo-cli --target cli .
```
#### Run Web UI
```bash
docker run -d \
--name stegasoo-web \
-p 5000:5000 \
--memory=768m \
stegasoo-web
# Visit http://localhost:5000
```
#### Run REST API
```bash
docker run -d \
--name stegasoo-api \
-p 8000:8000 \
--memory=768m \
stegasoo-api
# Docs at http://localhost:8000/docs
```
#### Run CLI
```bash
# Interactive shell
docker run -it --rm stegasoo-cli /bin/bash
# Run commands directly
docker run --rm stegasoo-cli --help
docker run --rm stegasoo-cli generate --pin --words 4
# With volume for files
docker run --rm \
-v $(pwd)/images:/data \
stegasoo-cli encode \
-r /data/ref.jpg \
-c /data/carrier.png \
-p "passphrase words here more" \
--pin 123456 \
-m "Secret message" \
-o /data/stego.png
```
### Docker Compose
The easiest way to run all services.
#### Start All Services
```bash
# Start in background
docker-compose up -d
# Start specific service
docker-compose up -d web
docker-compose up -d api
# View logs
docker-compose logs -f
# Stop all
docker-compose down
```
#### Services
| Service | URL | Description |
|---------|-----|-------------|
| `web` | http://localhost:5000 | Flask Web UI |
| `api` | http://localhost:8000 | FastAPI REST API |
#### Build and Start
```bash
# Build images and start
docker-compose up -d --build
# Force rebuild (no cache)
docker-compose build --no-cache
docker-compose up -d
```
#### Resource Configuration
The `docker-compose.yml` includes resource limits:
```yaml
services:
web:
deploy:
resources:
limits:
memory: 768M # For Argon2 + scipy
reservations:
memory: 384M
```
Adjust based on your available RAM:
| Available RAM | Recommended Limit | Workers |
|---------------|-------------------|---------|
| 2 GB | 768M | 2 |
| 4 GB | 1G | 3 |
| 8 GB+ | 1.5G | 4 |
---
## Optional Dependencies
### DCT Steganography (scipy + jpegio)
DCT mode enables JPEG-resilient steganography. It's automatically included with `[web]`, `[api]`, and `[all]` extras.
#### Install via pip
```bash
# scipy is straightforward
pip install scipy numpy
# jpegio - MUST use Python 3.12 or earlier!
pip install jpegio
# If pip fails, build from source
pip install cython numpy
git clone https://github.com/dwgoon/jpegio.git
cd jpegio
python setup.py install
```
#### Linux Build Dependencies
```bash
sudo apt-get install -y \
build-essential \
python3-dev \
libjpeg-dev \
cython3
```
#### macOS Build Dependencies
```bash
brew install jpeg cython
```
#### Verify DCT Support
```python
from stegasoo import has_dct_support
from stegasoo.dct_steganography import has_jpegio_support
print(f"DCT support (scipy): {has_dct_support()}")
print(f"JPEG native (jpegio): {has_jpegio_support()}")
```
Expected output:
```
DCT support (scipy): True
JPEG native (jpegio): True
```
### Compression (lz4)
Optional LZ4 compression for messages:
```bash
pip install lz4
```
---
## Platform-Specific Notes
### Linux
Most straightforward installation. Use your package manager for system dependencies.
**Ubuntu/Debian:**
```bash
sudo apt-get install python3.12 python3.12-venv python3-dev libzbar0 libjpeg-dev
python3.12 -m venv venv
source venv/bin/activate
pip install stegasoo[all]
```
**Fedora/RHEL:**
```bash
sudo dnf install python3.12 python3-devel zbar libjpeg-devel
python3.12 -m venv venv
source venv/bin/activate
pip install stegasoo[all]
```
**Arch (using pyenv):**
```bash
# Install pyenv
curl https://pyenv.run | bash
# Add to ~/.bashrc or ~/.zshrc
export PATH="$HOME/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
# Install Python 3.12
pyenv install 3.12
cd ~/Sources/stegasoo
pyenv local 3.12
# Create venv and install
python -m venv venv
source venv/bin/activate
pip install stegasoo[all]
```
### macOS
```bash
# Install Homebrew if needed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install dependencies
brew install python@3.12 zbar jpeg
# Create venv
python3.12 -m venv venv
source venv/bin/activate
# Install Stegasoo
pip install stegasoo[all]
```
**Apple Silicon (M1/M2/M3):**
jpegio may need native compilation:
```bash
# Ensure you have native Python
arch -arm64 brew install python@3.12
arch -arm64 python3.12 -m venv venv
source venv/bin/activate
pip install jpegio
```
### Windows
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
2. Install Visual Studio Build Tools
3. Install from pip:
```powershell
python -m venv venv
.\venv\Scripts\activate
pip install stegasoo[all]
```
### Raspberry Pi
Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
```bash
# System dependencies
sudo apt-get install python3-dev libzbar0 libjpeg-dev
# Install (may take a while to compile)
pip install stegasoo[cli]
# For web/api, ensure enough RAM
pip install stegasoo[web] # Needs ~768MB free
```
**Note:** Argon2 operations will be slower on Pi due to memory-hardness.
---
## Verification
### Check Installation
```bash
# CLI version
stegasoo --version
# Python import
python -c "import stegasoo; print(stegasoo.__version__)"
# Check Python version (must be 3.10-3.12)
python -V
```
### Check All Features
```python
#!/usr/bin/env python3
"""Verify Stegasoo installation."""
import sys
def check_feature(name, check_fn):
try:
result = check_fn()
status = "" if result else ""
print(f" {status} {name}: {result}")
return result
except Exception as e:
print(f"{name}: Error - {e}")
return False
print("Stegasoo Installation Check")
print("=" * 40)
# Python version check
py_version = sys.version_info
print(f"\nPython: {py_version.major}.{py_version.minor}.{py_version.micro}")
if py_version >= (3, 13):
print(" ⚠️ WARNING: Python 3.13+ not supported!")
print(" jpegio will not work. Use Python 3.12.")
elif py_version >= (3, 10):
print(" ✓ Python version OK")
else:
print(" ✗ Python 3.10+ required")
# Core
import stegasoo
print(f"\nStegasoo Version: {stegasoo.__version__}")
print("\nCore Features:")
check_feature("Argon2", lambda: stegasoo.has_argon2())
check_feature("Pillow", lambda: True) # Required, would fail import
print("\nOptional Features:")
check_feature("DCT (scipy)", stegasoo.has_dct_support)
try:
from stegasoo.dct_steganography import has_jpegio_support
check_feature("JPEG native (jpegio)", has_jpegio_support)
except ImportError:
print(" ✗ JPEG native (jpegio): Not installed")
try:
import lz4
check_feature("Compression (lz4)", lambda: True)
except ImportError:
print(" - Compression (lz4): Not installed (optional)")
try:
import pyzbar
check_feature("QR codes (pyzbar)", lambda: True)
except ImportError:
print(" - QR codes (pyzbar): Not installed (optional)")
print("\nInterfaces:")
try:
import click
check_feature("CLI", lambda: True)
except ImportError:
print(" ✗ CLI: Not installed")
try:
import flask
check_feature("Web UI", lambda: True)
except ImportError:
print(" - Web UI: Not installed")
try:
import fastapi
check_feature("REST API", lambda: True)
except ImportError:
print(" - REST API: Not installed")
print("\n" + "=" * 40)
print("Installation check complete!")
```
Save as `check_install.py` and run:
```bash
python check_install.py
```
### Test Encoding/Decoding
```bash
# Quick test with CLI
stegasoo generate --pin --words 4 --json > /tmp/creds.json
# Create test image
python -c "
from PIL import Image
img = Image.new('RGB', (256, 256), 'blue')
img.save('/tmp/test_carrier.png')
img.save('/tmp/test_ref.jpg')
"
# Encode
stegasoo encode \
-r /tmp/test_ref.jpg \
-c /tmp/test_carrier.png \
-p "test phrase words here" \
--pin 123456 \
-m "Hello, Stegasoo!" \
-o /tmp/test_stego.png
# Decode
stegasoo decode \
-r /tmp/test_ref.jpg \
-s /tmp/test_stego.png \
-p "test phrase words here" \
--pin 123456
```
---
## Troubleshooting
### Common Issues
#### "jpegio crashes" / "free(): invalid size" / Core dump
**This is the #1 issue!** You're using Python 3.13.
```bash
# Check your Python version
python -V
# If it shows 3.13, you need to use 3.12
# Option 1: Use pyenv
pyenv install 3.12
pyenv local 3.12
# Option 2: Use system Python 3.12
python3.12 -m venv venv
source venv/bin/activate
pip install -e ".[all]"
```
#### "No module named 'stegasoo'"
```bash
# Ensure you're in the right environment
which python
pip list | grep stegasoo
# Reinstall
pip install -e ".[all]"
```
#### "Argon2 not available"
```bash
# Install argon2-cffi
pip install argon2-cffi
# On Linux, may need:
sudo apt-get install libffi-dev
pip install --force-reinstall argon2-cffi
```
#### "jpegio not available" (not crash, just missing)
```bash
# Install build dependencies first
sudo apt-get install libjpeg-dev # Linux
brew install jpeg # macOS
# Then install jpegio
pip install cython numpy
pip install jpegio
# If still fails, build from source
git clone https://github.com/dwgoon/jpegio.git
cd jpegio
python setup.py install
```
#### "libzbar not found" (QR codes)
```bash
# Linux
sudo apt-get install libzbar0
# macOS
brew install zbar
# Then reinstall pyzbar
pip install --force-reinstall pyzbar
```
#### Docker: "Cannot allocate memory"
Argon2 needs 256MB per operation. Increase container memory:
```bash
# Docker run
docker run --memory=768m ...
# Docker Compose - edit docker-compose.yml
deploy:
resources:
limits:
memory: 768M
```
#### Slow performance
- **Argon2 is intentionally slow** - This is a security feature
- Expected encode/decode time: 2-5 seconds
- DCT mode adds ~1-2 seconds for transforms
- Large images (10MB+) may take 15-30 seconds
#### "Carrier image too small"
- LSB needs ~3 bits per pixel
- DCT needs ~0.25 bits per pixel
- For 50KB message: LSB needs ~136K pixels, DCT needs ~1.6M pixels
- Use larger carrier images or shorter messages
### Getting Help
1. Check the documentation:
- [README.md](README.md)
- [CLI.md](CLI.md)
- [API.md](API.md)
- [WEB_UI.md](WEB_UI.md)
2. Check existing issues on GitHub
3. Open a new issue with:
- Python version (`python --version`)
- OS and version
- Installation method
- Full error message
- Steps to reproduce
---
## Next Steps
After installation:
1. **Generate credentials**: `stegasoo generate --pin --words 4`
2. **Read the CLI docs**: [CLI.md](CLI.md)
3. **Try the Web UI**: `cd frontends/web && python app.py`
4. **Explore the API**: `cd frontends/api && python main.py`
Happy steganography! 🦕

475
README.md
View File

@@ -2,202 +2,251 @@
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
![Python](https://img.shields.io/badge/Python-3.10+-blue)
![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
![License](https://img.shields.io/badge/License-MIT-green)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
![Version](https://img.shields.io/badge/Version-4.0.1-purple)
## Features
- 🔐 **AES-256-GCM** authenticated encryption
- 🧠 **Argon2id** memory-hard key derivation (256MB RAM requirement)
- 🎲 **Pseudo-random pixel selection** defeats steganalysis
- 📅 **Daily key rotation** with BIP-39 passphrases
- 🔑 **Multi-factor authentication**: PIN, RSA key, or both
- 🖼️ **Reference photo** as "something you have"
- 🌐 **Multiple interfaces**: CLI, Web UI, REST API
- 📁 **File embedding** - Hide any file type (PDF, ZIP, documents)
- 📱 **QR code support** - Encode/decode RSA keys via QR codes
- 🆕 **DCT steganography** - JPEG-resilient embedding for social media
- 🆕 **Large image support** - Process images up to 14MB+
## Installation
## What's New in v4.0.0
### From PyPI (coming soon)
| Feature | Description |
|---------|-------------|
| **Simplified Auth** | Removed date dependency - encode/decode anytime without tracking dates |
| **Passphrase** | Renamed from "day phrase" to "passphrase" (no more daily rotation) |
| **Python 3.12** | Requires Python 3.10-3.12 (jpegio incompatible with 3.13) |
| **Large Image Fix** | JPEG normalization prevents crashes with quality=100 images |
| **Subprocess Isolation** | WebUI runs encode/decode in subprocesses for stability |
| **4-Word Default** | Default passphrase increased from 3 to 4 words |
```bash
# Core library only
pip install stegasoo
### Breaking Changes from v3.x
# With CLI
pip install stegasoo[cli]
- `day_phrase` parameter renamed to `passphrase` in all APIs
- `date_str` parameter removed from encode/decode functions
- Python 3.13 not supported (jpegio C extension incompatibility)
# With Web UI
pip install stegasoo[web]
### Embedding Mode Comparison
# With REST API
pip install stegasoo[api]
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | ✅ Yes | Social media, messaging apps |
| **LSB** | ~750 KB | ❌ No | Email, file transfer |
# Everything
pip install stegasoo[all]
```
## WebUI Preview
### 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
```
| Front Page | Encode | Decode | Generate |
|:----------:|:------:|:------:|:--------:|
| ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Encode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Decode.webp) | ![Screenshot](https://github.com/adlee-was-taken/stegasoo/blob/main/data/WebUI_Generate.webp) |
## Quick Start
### Python Library
```python
import stegasoo
# 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"
```
### CLI
```bash
# Generate credentials
stegasoo generate --pin --words 3
# Install with all features (requires Python 3.10-3.12)
pip install -e ".[all]"
# With RSA key
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "secretpassword"
# Generate credentials (memorize these!)
stegasoo generate --pin --words 4
# Encode
# Encode a message (DCT mode - default, best for social media)
stegasoo encode \
--ref photo.jpg \
--carrier meme.png \
--phrase "apple forest thunder" \
--carrier meme.jpg \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Secret message"
# Decode
# Encode with LSB mode (higher capacity, for email/file transfer)
stegasoo encode \
--ref photo.jpg \
--carrier meme.png \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Secret message" \
--mode lsb
# Decode (auto-detects mode)
stegasoo decode \
--ref photo.jpg \
--stego stego.png \
--phrase "apple forest thunder" \
--passphrase "apple forest thunder mountain" \
--pin 123456
# Pipe-friendly
echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 > stego.png
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -q
```
### Web UI
For detailed installation instructions, see **[INSTALL.md](INSTALL.md)**.
```bash
# Development
cd frontends/web
python app.py
# Production
gunicorn --bind 0.0.0.0:5000 app:app
```
Visit http://localhost:5000
### REST API
```bash
# Development
cd frontends/api
python main.py
# Production
uvicorn main:app --host 0.0.0.0 --port 8000
```
API docs at http://localhost:8000/docs
#### Example API Calls
```bash
# Generate credentials
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "use_rsa": false}'
# Encode (multipart)
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
--output stego.png
# Decode (multipart)
curl -X POST http://localhost:8000/decode/multipart \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "stego_image=@stego.png"
```
---
## Security Model
Stegasoo uses multiple authentication factors combined with strong cryptography:
```
┌─────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION LAYERS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Reference Photo ──┐ │
│ (~80-256 bits) │ │
│ ├──► Argon2id KDF ──► AES-256-GCM Key │
│ Passphrase ───────┤ (256MB RAM) │
│ (~43-132 bits) │ │
│ │ │
│ Static PIN ───────┤ │
│ (~20-30 bits) │ │
│ │ │
│ RSA Key ──────────┘ │
│ (~128 bits) (optional, adds another factor) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Entropy Summary
| Component | Entropy | Purpose |
|-----------|---------|---------|
| Reference Photo | ~80-256 bits | Something you have |
| Day Phrase (3 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 |
| Passphrase (3-12 words) | ~33-132 bits | Something you know |
| PIN (6-9 digits) | ~20-30 bits | Something you know |
| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) |
| **Combined** | **133-400+ bits** | **Beyond brute force** |
### Attack Resistance
| Attack | Protection |
|--------|------------|
| Brute force | 2^133+ combinations |
| Rainbow tables | Random salt per message |
| Steganalysis | Random pixel selection |
| Brute force | 2^133+ combinations minimum |
| Rainbow tables | Random 16-byte salt per message |
| Steganalysis | Pseudo-random pixel/coefficient selection |
| GPU cracking | Argon2id requires 256MB RAM per attempt |
| Side-channel | Constant-time operations in crypto |
| Side-channel | Constant-time operations in cryptography library |
| JPEG recompression | DCT mode embeds in frequency domain |
### Security Configurations
| Configuration | Entropy | Use Case |
|--------------|---------|----------|
| 3-word passphrase + 6-digit PIN | ~133 bits | Casual private messaging |
| 4-word passphrase + 9-digit PIN | ~176 bits | Standard security (recommended) |
| 4-word passphrase + RSA 2048 | ~241 bits | File-based authentication |
| 6-word passphrase + PIN + RSA 4096 | ~304 bits | Maximum security |
---
## Interfaces
### Command-Line Interface (CLI)
Full-featured CLI with piping support:
```bash
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "password"
# Encode (DCT mode is now default)
stegasoo encode -r ref.jpg -c carrier.jpg -p "passphrase words here" --pin 123456 -m "Message"
# Encode with LSB mode for higher capacity
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 \
-m "Message" --mode lsb
# Encode a file
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 -f secret.txt
# Decode to stdout (quiet mode)
stegasoo decode -r ref.jpg -s stego.png -p "passphrase words here" --pin 123456 -q
# Compare LSB vs DCT capacity for an image
stegasoo compare carrier.png
# Check available modes
stegasoo modes
```
📖 Full documentation: **[CLI.md](CLI.md)**
### Web UI
Browser-based interface with drag-and-drop uploads:
```bash
# Start the server
cd frontends/web
python app.py
# Visit http://localhost:5000
```
Features:
- Drag-and-drop image uploads with scan animations
- Real-time entropy calculator
- Native mobile sharing (Web Share API)
- DCT mode default with compact mode selector
- Subprocess isolation for stability
- Large image support (14MB+ tested)
- Streamlined form flow (v3.3.0)
📖 Full documentation: **[WEB_UI.md](WEB_UI.md)**
### REST API
FastAPI-powered REST API with OpenAPI documentation:
```bash
# Start the server
cd frontends/api
uvicorn main:app --host 0.0.0.0 --port 8000
# Docs at http://localhost:8000/docs
```
Example API calls:
```bash
# Generate credentials
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "passphrase_words": 4}'
# Encode (DCT mode is default)
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret" \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.jpg" \
--output stego.jpg
# Encode with LSB mode
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret" \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "embed_mode=lsb" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
--output stego.png
# Decode (auto-detects mode)
curl -X POST http://localhost:8000/decode/multipart \
-F "passphrase=apple forest thunder mountain" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "stego_image=@stego.jpg"
```
📖 Full documentation: **[API.md](API.md)**
---
## Project Structure
@@ -207,15 +256,20 @@ stegasoo/
│ ├── __init__.py # Public API
│ ├── constants.py # Configuration
│ ├── crypto.py # Encryption/decryption
│ ├── steganography.py # Image embedding
│ ├── steganography.py # LSB image embedding
│ ├── dct_steganography.py # DCT embedding
│ ├── keygen.py # Credential generation
│ ├── validation.py # Input validation
│ ├── models.py # Data classes
│ ├── exceptions.py # Custom exceptions
│ ├── qr_utils.py # QR code utilities
│ └── utils.py # Utilities
├── frontends/
│ ├── web/ # Flask web UI
│ │ ├── app.py
│ │ ├── subprocess_stego.py # Subprocess isolation
│ │ └── stego_worker.py # Worker script
│ ├── cli/ # Command-line interface
│ └── api/ # FastAPI REST API
@@ -223,29 +277,63 @@ stegasoo/
│ └── bip39-words.txt # BIP-39 wordlist
├── pyproject.toml # Package configuration
├── requirements.txt # Dependencies
├── Dockerfile # Multi-stage Docker build
── docker-compose.yml # Container orchestration
── docker-compose.yml # Container orchestration
├── README.md # This file
├── INSTALL.md # Installation guide
├── CLI.md # CLI documentation
├── API.md # API documentation
├── WEB_UI.md # Web UI documentation
├── SECURITY.md # Security documentation
└── UNDER_THE_HOOD.md # Technical deep-dive
```
---
## Requirements
| Requirement | Version | Notes |
|-------------|---------|-------|
| Python | 3.10-3.12 | **3.13 not supported** (jpegio incompatibility) |
| RAM | 512 MB+ | 256MB for Argon2 operations |
| Disk | ~100 MB | |
### Key Dependencies
| Package | Purpose |
|---------|---------|
| `cryptography` | AES-256-GCM encryption |
| `Pillow` | Image processing |
| `argon2-cffi` | Memory-hard key derivation |
| `scipy` | DCT transforms |
| `jpegio` | JPEG coefficient manipulation |
| `numpy` | Array operations |
---
## Configuration
### Limits
| Limit | Value |
|-------|-------|
| Max image size | Tested up to 14MB |
| Max message size | 50 KB |
| Max file upload | 5 MB |
| PIN length | 6-9 digits |
| Passphrase length | 3-12 words |
| RSA key sizes | 2048, 3072, 4096 bits |
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include src/ for development |
| `PYTHONPATH` | - | Include `src/` for development |
### Limits
| 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 |
---
## Development
@@ -262,12 +350,81 @@ ruff check src/ frontends/
# Type checking
mypy src/
# Check DCT support
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(f'jpegio: {has_jpegio_support()}')"
```
---
## Version History
| Version | Changes |
|---------|---------|
| **4.0.1** | Lint cleanup, test fixes, Web UI improvements (channel key dropdown, LED indicators) |
| **4.0.0** | Channel key support for deployment isolation, removed date dependency, renamed day_phrase→passphrase, Python 3.12 requirement, JPEG normalization fix, subprocess isolation, large image support |
| **3.2.x** | DCT color mode, JPEG output fixes |
| **3.0.x** | Added DCT steganography mode |
| **2.2.x** | QR code support, file embedding |
| **2.0.x** | Web UI, REST API, RSA keys |
| **1.0.x** | Initial release, CLI only |
---
## Upgrading from v3.x
### Code Changes Required
```python
# Old (v3.x)
result = encode(
message="secret",
day_phrase="apple forest thunder",
date_str="2024-01-15",
...
)
# New (v4.0)
result = encode(
message="secret",
passphrase="apple forest thunder mountain",
# No date_str needed!
...
)
```
### CLI Changes
```bash
# Old (v3.x)
stegasoo encode --phrase "words" --date 2024-01-15 ...
# New (v4.0)
stegasoo encode --passphrase "words here more" ...
# or short form
stegasoo encode -p "words here more" ...
```
---
## License
MIT License - Use responsibly.
---
## ⚠️ Disclaimer
This tool is for educational and legitimate privacy purposes only. Users are responsible for complying with applicable laws in their jurisdiction.
---
## See Also
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions
- **[CLI.md](CLI.md)** - Command-line interface reference
- **[API.md](API.md)** - REST API documentation
- **[WEB_UI.md](WEB_UI.md)** - Web interface guide
- **[SECURITY.md](SECURITY.md)** - Security model and threat analysis
- **[UNDER_THE_HOOD.md](UNDER_THE_HOOD.md)** - Technical implementation details

274
SECURITY.md Normal file
View File

@@ -0,0 +1,274 @@
# Security Policy
## Supported Versions
| Version | Supported | Notes |
| ------- | ------------------ | ----- |
| 4.x.x | ✅ Active | Current release |
| 3.x.x | ⚠️ Security fixes only | Upgrade recommended |
| 2.x.x | ❌ End of life | |
| 1.x.x | ❌ End of life | |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please email: **security@example.com** (replace with your email)
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes
You should receive a response within 48 hours. We'll work with you to understand and address the issue.
---
# Threat Model
## What Stegasoo Protects
Stegasoo is designed to hide the **existence** of a secret message within an ordinary-looking image, protected by multi-factor authentication.
### Protection Goals
| Goal | How It's Achieved |
|------|-------------------|
| **Confidentiality** | AES-256-GCM encryption with Argon2id key derivation |
| **Steganography** | LSB/DCT embedding with pseudo-random pixel/coefficient selection |
| **Authentication** | Multi-factor: reference photo + passphrase + PIN (or RSA key) |
| **Integrity** | GCM authentication tag detects tampering |
### Security Factors
Stegasoo combines multiple authentication factors:
```
┌─────────────────────────────────────────────────────────────────┐
│ Key Derivation │
│ │
│ Reference Photo ───────┐ │
│ (something you have) │ │
│ ├──► Argon2id ──► AES-256 Key │
│ Passphrase ────────────┤ (256MB RAM) │
│ (something you know) │ │
│ │ │
│ PIN or RSA Key ────────┘ │
│ (second factor) │
└─────────────────────────────────────────────────────────────────┘
```
## Changes in v4.0
### Removed: Date-Based Key Rotation
**Previous versions (v3.x and earlier):**
- Required a date parameter for encode/decode
- Keys rotated daily based on "day phrase"
- Users had to remember which date they used
**Version 4.0:**
- No date dependency
- Single passphrase (no rotation)
- Simpler but slightly reduced entropy per-message
**Security Impact:**
- Minimal - the date only added ~10 bits of entropy
- Passphrase default increased from 3 to 4 words to compensate (+11 bits)
- Overall entropy remains similar or higher with 4-word default
### Renamed: day_phrase → passphrase
Terminology change only. No security impact.
## What Stegasoo Does NOT Protect Against
### 1. Statistical Steganalysis
**Risk:** Advanced analysis can detect that an image contains hidden data.
**Reality:** LSB steganography is detectable by:
- Chi-square analysis
- RS analysis
- Machine learning classifiers
**DCT mode is more resilient** but not undetectable.
**Mitigation:** Stegasoo uses pseudo-random pixel/coefficient selection, which helps but doesn't eliminate detectability.
**Recommendation:** Don't rely on Stegasoo if your adversary has:
- Access to the original carrier image
- Sophisticated forensic tools
- Motivation to analyze your specific images
### 2. Compromised Endpoints
**Risk:** If your device is compromised, the attacker can capture credentials.
**Not protected:**
- Keyloggers capturing your PIN/passphrase
- Screen capture of decoded messages
- Memory scraping during encode/decode
- Malware on sender or receiver device
**Recommendation:** Use on trusted devices only.
### 3. Reference Photo Exposure
**Risk:** The reference photo is a critical secret.
**If leaked:** Attacker only needs to guess/brute-force the passphrase + PIN.
**Recommendation:**
- Never share the reference photo
- Use a unique photo (not posted online)
- Store securely (encrypted drive, password manager)
### 4. Weak Credentials
**Risk:** Short PINs or common passphrases can be brute-forced.
| PIN Length | Combinations | Time to Brute Force* |
|------------|--------------|----------------------|
| 4 digits | 10,000 | Seconds |
| 6 digits | 1,000,000 | Minutes |
| 8 digits | 100,000,000 | Hours |
| 9 digits | 1,000,000,000| Days |
*With Argon2 (256MB RAM, 4 iterations), each attempt takes ~1 second, making brute force slow but not impossible for short PINs.
**Recommendation:**
- Use 8+ digit PINs
- Use 4+ word passphrases (v4.0 default)
- Consider RSA keys for high-security use cases
### 5. Image Modification
**Risk:** Lossy compression destroys hidden data.
**LSB mode - data is destroyed by:**
- JPEG compression
- Resizing
- Filters/effects
- Screenshots
- Social media upload
**DCT mode - more resilient but not immune:**
- Survives moderate JPEG recompression
- May fail with aggressive compression (quality < 70)
- Still destroyed by resizing, filters, screenshots
**Recommendation:**
- LSB: Always use lossless formats (PNG, BMP), direct transfer
- DCT: Use for social media, but test with your specific platform
### 6. Metadata Leakage
**Risk:** The stego image itself may reveal information.
**Potential leaks:**
- File creation timestamp
- Camera EXIF data (if carrier has it)
- File size changes
**Mitigation:** Stegasoo strips EXIF on output, but timestamps remain.
### 7. Traffic Analysis
**Risk:** The act of sending an image may be suspicious.
**Not protected:**
- Network observers seeing you send image files
- Email metadata showing sender/receiver
- Frequency analysis of communications
**Recommendation:** Use alongside normal image-sharing behavior.
## Cryptographic Details
### Encryption
| Component | Algorithm | Parameters |
|-----------|-----------|------------|
| Key Derivation | Argon2id | 256MB RAM, 4 iterations, 4 parallelism |
| Fallback KDF | PBKDF2-SHA256 | 600,000 iterations |
| Encryption | AES-256-GCM | 12-byte IV, 16-byte tag |
| Photo Hash | SHA-256 | Full image bytes |
### Pixel/Coefficient Selection
Selection key is derived from:
```
selection_key = SHA256(photo_hash || passphrase || pin/rsa_signature)
```
This prevents:
- Sequential embedding patterns
- Statistical detection of modified regions
### Message Format (v4.0)
```
┌──────────────────────────────────────────────────────────────────┐
│ Magic (4B) │ Version (1B) │ Salt (32B) │ IV (12B) │
├──────────────────────────────────────────────────────────────────┤
│ Encrypted Payload (AES-256-GCM) │
│ ├── Type (1B): 0x01=text, 0x02=file │
│ ├── Length (4B) │
│ ├── Data (variable) │
│ └── [Filename if file] (variable) │
├──────────────────────────────────────────────────────────────────┤
│ GCM Auth Tag (16B) │
└──────────────────────────────────────────────────────────────────┘
```
**Note:** v4.0 removed the date field from the header, reducing overhead by 10 bytes.
## Best Practices
### For Maximum Security
1. **Use RSA keys** instead of PINs for authentication
2. **Use unique reference photos** not available online
3. **Use long passphrases** (4+ random words, recommend 6+)
4. **Transfer via secure channels** (Signal, encrypted email)
5. **Delete stego images** after message is read
6. **Keep software updated** for security fixes
7. **Use DCT mode** for social media sharing
### For Casual Privacy
1. **6-digit PIN** is sufficient for non-adversarial use
2. **4-word passphrase** provides reasonable security (v4.0 default)
3. **PNG format** for LSB mode output
4. **Direct file transfer** (email attachment, AirDrop)
## Known Limitations
| Limitation | Impact | Status |
|------------|--------|--------|
| LSB is detectable | Statistical analysis can detect hidden data | By design (tradeoff for capacity) |
| No forward secrecy | Compromised key decrypts all messages | Use different keys per message for high security |
| No deniability | Single password = single message | Future: plausible deniability layers |
| Python 3.13 incompatible | jpegio C extension crashes | Use Python 3.12 or earlier |
## Security Audit Status
This software has **not** been professionally audited. Use at your own risk for sensitive applications.
If you're a security researcher interested in auditing Stegasoo, please reach out.
---
## Version History (Security Relevant)
| Version | Security Changes |
|---------|------------------|
| 4.0.0 | Removed date dependency, increased default passphrase to 4 words, added JPEG normalization |
| 3.2.0 | DCT color mode added |
| 3.0.0 | Added DCT steganography mode |
| 2.2.0 | Added compression (no security impact) |
| 2.1.0 | Upgraded to Argon2id, increased iterations |
| 2.0.0 | Added RSA key support |
| 1.0.0 | Initial release |

805
UNDER_THE_HOOD.md Normal file
View File

@@ -0,0 +1,805 @@
# Stegasoo Technical Deep Dive: Encoding & Decoding
A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work under the hood.
**Version 4.0** - Updated for simplified authentication (no date dependency)
---
## Table of Contents
1. [High-Level Overview](#high-level-overview)
2. [The Encoding Pipeline](#the-encoding-pipeline)
3. [The Decoding Pipeline](#the-decoding-pipeline)
4. [LSB Mode Deep Dive](#lsb-mode-deep-dive)
5. [DCT Mode Deep Dive](#dct-mode-deep-dive)
6. [Comparison Table](#comparison-table)
7. [Security Considerations](#security-considerations)
---
## High-Level Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEGASOO ARCHITECTURE (v4.0) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INPUTS PROCESSING OUTPUT │
│ ─────── ────────── ────── │
│ │
│ Reference Photo ─┐ │
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
│ PIN/RSA Key ─────┘ │ │
│ ▼ │
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
│ Encryption │ │
│ ▼ │
│ Carrier Image ───────────────────────────────────────► Embedding ──► Stego│
│ (LSB/DCT) Image │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### v4.0 Changes
| Change | v3.x | v4.0 |
|--------|------|------|
| Authentication | day_phrase + date | passphrase (no date) |
| Default words | 3 | 4 |
| Header size | 75 bytes | 65 bytes (no date field) |
| Python support | 3.10+ | 3.10-3.12 only |
### Module Responsibilities
| Module | File | Purpose |
|--------|------|---------|
| **Crypto** | `crypto.py` | Key derivation (Argon2id), AES-256-GCM encryption/decryption |
| **Steganography** | `steganography.py` | LSB pixel manipulation, capacity calculation |
| **DCT Steganography** | `dct_steganography.py` | Frequency-domain embedding, jpegio integration |
| **Compression** | `compression.py` | Optional LZ4 compression of payload |
| **Validation** | `validation.py` | Input validation, size limits |
| **Utils** | `utils.py` | Image hashing, format detection |
---
## The Encoding Pipeline
### Step 1: Input Collection & Validation
```python
# validation.py
def validate_encode_inputs(reference_photo, carrier, message, passphrase, pin, rsa_key):
# Check image dimensions (max 24 megapixels)
# Validate PIN format (6-9 digits)
# Validate passphrase (3-12 words from BIP-39)
# Check payload size vs carrier capacity
# Ensure reference != carrier (security)
```
### Step 2: Reference Photo Processing
```python
# utils.py
def get_image_hash(image_bytes: bytes) -> bytes:
"""
Generate deterministic hash from reference photo.
This is the 'something you have' factor.
"""
# Resize to 256x256 (normalize different resolutions)
# Convert to grayscale (normalize color variations)
# Apply slight blur (reduce JPEG artifact sensitivity)
# SHA-256 hash of processed pixels
return hashlib.sha256(processed_pixels).digest() # 32 bytes
```
**Why process the image?** Minor variations (JPEG recompression, slight crops) in the reference photo between sender and receiver would produce different hashes, breaking decryption. The preprocessing makes the hash more resilient.
### Step 3: Key Derivation (Argon2id)
```python
# crypto.py
def derive_key(reference_hash: bytes, passphrase: str, pin: str,
rsa_signature: bytes = None) -> bytes:
"""
Combine all authentication factors into one AES key.
v4.0: No date parameter - simplified authentication.
"""
# Concatenate all factors
key_material = reference_hash + passphrase.encode() + pin.encode()
if rsa_signature:
key_material += rsa_signature
# Argon2id parameters (memory-hard to resist GPU attacks)
# - Memory: 256 MB
# - Iterations: 4
# - Parallelism: 4
# - Output: 32 bytes (256 bits)
key = argon2.hash_password_raw(
password=key_material,
salt=random_salt, # 16 bytes, stored with ciphertext
time_cost=4,
memory_cost=262144, # 256 MB
parallelism=4,
hash_len=32,
type=argon2.Type.ID
)
return key # 32-byte AES-256 key
```
**Why Argon2id?**
- **Memory-hard**: Requires 256MB RAM per attempt, defeating GPU/ASIC attacks
- **Time-hard**: ~2-3 seconds per derivation
- **Side-channel resistant**: ID variant protects against timing attacks
### Step 4: Payload Preparation
```python
# compression.py (optional)
def prepare_payload(data: bytes, filename: str = None) -> bytes:
"""
Prepare the payload with metadata header.
"""
# Header format (variable length):
# [1 byte] - Flags (compression, file mode, etc.)
# [4 bytes] - Original data length (big-endian)
# [2 bytes] - Filename length (if file mode)
# [N bytes] - Filename (if file mode)
# [N bytes] - Data (possibly compressed)
header = struct.pack('>BI', flags, len(data))
if filename:
header += struct.pack('>H', len(filename)) + filename.encode()
# Optional LZ4 compression
if should_compress(data):
data = lz4.frame.compress(data)
flags |= FLAG_COMPRESSED
return header + data
```
### Step 5: AES-256-GCM Encryption
```python
# crypto.py
def encrypt(plaintext: bytes, key: bytes) -> bytes:
"""
Encrypt payload with AES-256-GCM.
Returns: salt + nonce + ciphertext + tag
"""
salt = os.urandom(16) # Random salt for key derivation
nonce = os.urandom(12) # Random nonce for GCM
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
# Final encrypted blob:
# [16 bytes] Salt
# [12 bytes] Nonce
# [16 bytes] Auth Tag
# [N bytes] Ciphertext
return salt + nonce + tag + ciphertext
```
**Why GCM?**
- **Authenticated encryption**: Detects tampering
- **No padding oracle**: Stream cipher mode
- **Built-in integrity**: 128-bit authentication tag
### Step 6: Stego Header Construction
```python
# steganography.py / dct_steganography.py
def build_stego_header(encrypted_data: bytes, mode: str) -> bytes:
"""
Build the header that precedes embedded data.
v4.0: Simplified header (no date field)
"""
# Header format:
# [4 bytes] - Magic number: "STGO" (v4)
# [1 byte] - Version (0x04)
# [1 byte] - Mode (0x01=LSB, 0x02=DCT)
# [4 bytes] - Payload length
# [N bytes] - Encrypted payload
if mode == 'lsb':
magic = b'STGO\x04\x01' # v4, mode 1 (LSB)
else:
magic = b'STGO\x04\x02' # v4, mode 2 (DCT)
length = struct.pack('>I', len(encrypted_data))
return magic + length + encrypted_data
```
### Step 7: Embedding (Mode-Specific)
This is where LSB and DCT diverge. See detailed sections below.
---
## The Decoding Pipeline
### Step 1: Mode Detection
```python
def detect_mode(stego_image: bytes) -> str:
"""
Detect which embedding mode was used.
Checks format and magic bytes.
"""
img = Image.open(io.BytesIO(stego_image))
# JPEG images with JPGS magic = DCT mode with jpegio
if img.format == 'JPEG':
# Check for jpegio magic
return 'dct'
# PNG/BMP: Read first few bytes from LSB
# Check for STGO or DCTS magic
magic = extract_header_lsb(stego_image, 6)
if magic.startswith(b'STGO'):
mode_byte = magic[5]
return 'lsb' if mode_byte == 0x01 else 'dct'
elif magic.startswith(b'DCTS'):
return 'dct'
return 'lsb' # Default fallback
```
### Step 2: Key Re-derivation
```python
# Same process as encoding
def derive_key_for_decode(reference_hash, passphrase, pin, rsa_signature=None):
# Must use SAME parameters as encoding
# No date parameter in v4.0
return derive_key(reference_hash, passphrase, pin, rsa_signature)
```
### Step 3: Data Extraction
```python
def extract_data(stego_image: bytes, mode: str) -> bytes:
"""
Extract raw bytes from stego image.
Mode-specific extraction.
"""
if mode == 'dct':
return extract_from_dct(stego_image, pixel_key)
else:
return extract_from_lsb(stego_image, pixel_key)
```
### Step 4: Decryption & Payload Recovery
```python
def decrypt_and_recover(encrypted_data: bytes, key: bytes) -> Union[str, bytes]:
"""
Decrypt and extract original message/file.
"""
# Parse header
salt = encrypted_data[:16]
nonce = encrypted_data[16:28]
tag = encrypted_data[28:44]
ciphertext = encrypted_data[44:]
# Decrypt
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
# Decompress if needed
if plaintext[0] & FLAG_COMPRESSED:
plaintext = lz4.frame.decompress(plaintext[5:])
# Extract payload
return parse_payload(plaintext)
```
---
## LSB Mode Deep Dive
### How LSB Embedding Works
LSB (Least Significant Bit) embedding modifies the lowest bit of each color channel in selected pixels.
```
Original Pixel (RGB):
R: 11010110 G: 01101001 B: 10110100
↓ ↓ ↓
└─────────┴─────────┘
3 bits available
After embedding "101":
R: 1101011[1] G: 0110100[0] B: 1011010[1]
↑ ↑ ↑
modified modified modified
```
### Pixel Selection Algorithm
```python
def select_pixels(carrier_shape, num_bits, seed: bytes) -> List[Tuple[int, int, int]]:
"""
Generate pseudo-random pixel coordinates.
Distributes modifications across entire image.
"""
height, width, channels = carrier_shape
total_positions = height * width * 3 # RGB channels
# Use seed to generate reproducible random order
rng = np.random.RandomState(int.from_bytes(seed[:4], 'big'))
all_positions = np.arange(total_positions)
rng.shuffle(all_positions)
# Convert flat indices to (y, x, channel)
selected = []
for idx in all_positions[:num_bits]:
y = idx // (width * 3)
x = (idx % (width * 3)) // 3
c = idx % 3
selected.append((y, x, c))
return selected
```
### Embedding Process
```python
def embed_lsb(carrier: np.ndarray, data: bytes, seed: bytes) -> np.ndarray:
"""
Embed data using LSB substitution.
"""
bits = bytes_to_bits(data)
positions = select_pixels(carrier.shape, len(bits), seed)
stego = carrier.copy()
for i, (y, x, c) in enumerate(positions):
# Clear LSB and set to our bit
stego[y, x, c] = (stego[y, x, c] & 0xFE) | bits[i]
return stego
```
### Capacity Calculation
```python
def calculate_lsb_capacity(width: int, height: int) -> int:
"""
Calculate maximum payload size for LSB mode.
"""
total_bits = width * height * 3 # 3 bits per pixel (RGB)
header_bits = 10 * 8 # 10-byte stego header
available_bits = total_bits - header_bits
return available_bits // 8 # Convert to bytes
```
**Example capacities:**
- 1920×1080: ~770 KB
- 4000×3000: ~4.5 MB
- 800×600: ~180 KB
---
## DCT Mode Deep Dive
### How DCT Embedding Works
DCT (Discrete Cosine Transform) mode embeds data in the frequency-domain coefficients, making it resilient to JPEG compression.
```
Image Block (8×8 pixels)
DCT Transform
DCT Coefficients (8×8)
┌────────────────────┐
│ DC AC₁ AC₂ AC₃ ...│ ← Lower frequencies (top-left)
│ AC₄ AC₅ AC₆ ... │
│ ... ... │ ← Mid frequencies (embed here)
│ ... ... │
│ AC₆₃ ────│ ← Higher frequencies (bottom-right)
└────────────────────┘
Modify select ACs
IDCT Transform
Modified Image Block
```
### Coefficient Selection
```python
# dct_steganography.py
EMBED_POSITIONS = [
(0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0),
(4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2),
(4, 1), (5, 0), (5, 1), (4, 2), (3, 3), (2, 4), (1, 5), (0, 6), (0, 7),
(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (7, 0),
]
# Use positions 4-20 (mid-frequency, good balance)
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 positions per block
```
**Why mid-frequency?**
- DC coefficient (0,0): Too visible, contains brightness
- Low AC: Visible changes, but survives compression
- Mid AC: Best balance of invisibility + resilience
- High AC: Invisible but destroyed by compression
### Block Processing
```python
def embed_in_block(block: np.ndarray, bits: List[int]) -> np.ndarray:
"""
Embed bits in a single 8×8 block.
"""
# Forward DCT
dct_block = dct_2d(block)
# Embed using quantization
for i, pos in enumerate(DEFAULT_EMBED_POSITIONS):
if i >= len(bits):
break
coef = dct_block[pos[0], pos[1]]
# Quantize and modify LSB
quantized = round(coef / QUANT_STEP)
if (quantized % 2) != bits[i]:
quantized += 1 if coef > 0 else -1
dct_block[pos[0], pos[1]] = quantized * QUANT_STEP
# Inverse DCT
return idct_2d(dct_block)
```
### jpegio Integration (Native JPEG Output)
```python
def embed_jpegio(data: bytes, carrier_jpeg: bytes, seed: bytes) -> bytes:
"""
Embed directly in JPEG DCT coefficients using jpegio.
Preserves JPEG structure perfectly.
Note: Requires Python 3.12 or earlier (jpegio incompatible with 3.13)
"""
import jpegio as jio
# Normalize problematic JPEGs (quality=100 causes crashes)
carrier_jpeg = normalize_jpeg_for_jpegio(carrier_jpeg)
# Read existing JPEG coefficients
jpeg = jio.read(temp_file_from_bytes(carrier_jpeg))
coef_array = jpeg.coef_arrays[0] # Y channel
# Find usable coefficients (magnitude >= 2, non-DC)
positions = get_usable_positions(coef_array)
order = generate_order(len(positions), seed)
# Embed by modifying coefficient LSBs
bits = bytes_to_bits(data)
for i, pos_idx in enumerate(order[:len(bits)]):
row, col = positions[pos_idx]
coef = coef_array[row, col]
if (coef & 1) != bits[i]:
# Flip LSB while preserving sign
if coef > 0:
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
else:
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
# Write modified JPEG
jio.write(jpeg, output_path)
return read_bytes(output_path)
```
### JPEG Normalization (v4.0)
```python
def normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
"""
Normalize problematic JPEGs before jpegio processing.
JPEGs with quality=100 have quantization tables with all values=1,
which causes jpegio to crash. Re-save at quality 95.
"""
img = Image.open(io.BytesIO(image_data))
if img.format != 'JPEG':
return image_data
# Check if any quantization table has all values <= 1
needs_normalization = False
if hasattr(img, 'quantization'):
for table in img.quantization.values():
if max(table) <= 1:
needs_normalization = True
break
if not needs_normalization:
return image_data
# Re-save at safe quality
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=95, subsampling=0)
return buffer.getvalue()
```
### DCT Capacity Calculation
```python
def calculate_dct_capacity(width: int, height: int) -> int:
"""
Calculate maximum payload for DCT mode.
"""
blocks_x = width // 8
blocks_y = height // 8
total_blocks = blocks_x * blocks_y
bits_per_block = len(DEFAULT_EMBED_POSITIONS) # 16
total_bits = total_blocks * bits_per_block
header_bits = 10 * 8 # Stego header
available_bits = total_bits - header_bits
return available_bits // 8
```
**Example capacities:**
- 1920×1080: ~64 KB
- 4000×3000: ~375 KB
- 800×600: ~14 KB
### Why DCT Survives JPEG Compression
```
Original JPEG: Stego JPEG: Re-compressed:
DCT coefficients Modified DCT Coefficients
preserved in coefficients re-quantized
file format still valid
│ │ │
▼ ▼ ▼
[DCT] ──────► [Modified] ──────► [Still
[coefs] [DCT coefs] Modified!]
LSB changes survive because they're embedded in
the frequency domain, not spatial pixel values.
```
### DCT Advantages
| Advantage | Description |
|-----------|-------------|
| **JPEG resilient** | Survives social media upload |
| **Better steganalysis resistance** | Harder to detect statistically |
| **Natural-looking output** | JPEG artifacts expected |
### DCT Limitations
| Limitation | Description |
|------------|-------------|
| **Lower capacity** | ~10% of LSB capacity |
| **Slower processing** | DCT transforms are compute-intensive |
| **Requires scipy/jpegio** | Additional dependencies |
| **Quality-dependent** | Heavy recompression still degrades data |
| **Python version** | jpegio requires Python 3.12 or earlier |
---
## Comparison Table
| Aspect | LSB Mode | DCT Mode |
|--------|----------|----------|
| **Capacity (1080p)** | ~770 KB | ~50 KB |
| **Output Format** | PNG only | PNG or JPEG |
| **Survives JPEG** | ❌ No | ✅ Yes |
| **Social Media** | ❌ Broken | ✅ Works |
| **Processing Speed** | Fast (~0.5s) | Slower (~2s) |
| **Dependencies** | Pillow, NumPy | + scipy, jpegio |
| **Color Support** | Full color | Color or Grayscale |
| **Detection Resistance** | Moderate | Better |
| **Best For** | Email, cloud storage | Social media, messaging |
| **Max Tested Image** | 14MB+ | 14MB+ |
---
## Security Considerations
### What Makes Stegasoo Secure?
```
MULTI-FACTOR AUTHENTICATION (v4.0)
──────────────────────────────────
Factor 1: Reference Photo ─┐
• 80-256 bits entropy │
• "Something you have" │
├──► Combined entropy: 133-400+ bits
Factor 2: Passphrase │ (Beyond brute force)
• 43-132 bits entropy │
• "Something you know" │
• 4 words default (v4.0) │
Factor 3: PIN │
• 20-30 bits entropy │
• "Something you know" │
Factor 4: RSA Key (optional) ─┘
• 112-128 bits entropy
• "Something you have"
MEMORY-HARD KDF (Argon2id)
──────────────────────────
• 256 MB RAM per attempt
• ~3 seconds per attempt
• Defeats GPU/ASIC attacks
• 10 attempts = 30 seconds, not 0.00001 seconds
AUTHENTICATED ENCRYPTION (AES-256-GCM)
──────────────────────────────────────
• 256-bit key (unbreakable)
• Built-in integrity check
• Detects tampering
• No padding oracle attacks
```
### Attack Surface Analysis
| Attack | LSB Protection | DCT Protection |
|--------|----------------|----------------|
| Visual inspection | ✅ Imperceptible | ✅ Imperceptible |
| File size analysis | ⚠️ PNG larger | ✅ JPEG natural |
| Histogram analysis | ⚠️ Slight anomalies | ✅ Normal JPEG |
| Chi-square attack | ⚠️ Detectable at scale | ✅ Resistant |
| RS steganalysis | ⚠️ Detectable | ✅ Resistant |
| JPEG recompression | ❌ Destroyed | ✅ Survives |
### Threat Model
**Stegasoo protects against:**
- ✅ Passive eavesdropping
- ✅ Casual inspection of images
- ✅ Basic forensic analysis
- ✅ Brute force key guessing
- ✅ JPEG recompression (DCT mode)
**Stegasoo does NOT protect against:**
- ⚠️ Targeted forensic analysis with original carrier
- ⚠️ Nation-state level steganalysis
- ⚠️ Rubber hose cryptanalysis (coercion)
- ⚠️ Compromise of reference photo or credentials
---
## Data Flow Diagrams
### Complete Encode Flow (v4.0)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ENCODE FLOW (v4.0) │
└──────────────────────────────────────────────────────────────────────────────┘
User Inputs Processing Output
─────────── ────────── ──────
Reference Photo ──────┐
├──► get_image_hash() ──► ref_hash (32 bytes)
│ │
Passphrase ───────────┤ ▼
├──► derive_key() ──────► aes_key (32 bytes)
PIN ──────────────────┤ (Argon2id) │
│ │
RSA Key (optional) ───┘ │
Message/File ──────────► prepare_payload() ──► encrypt() ──► ciphertext
(compress, header) (AES-GCM) │
build_stego_header()
(magic + length)
Carrier Image ─────────────────────────────────────────► embed()
│ │
┌───────────┴─────┴────────────┐
│ │
LSB Mode DCT Mode
│ │
▼ ▼
embed_lsb() embed_in_dct()
(pixel LSBs) (DCT coefficients)
│ │
▼ ▼
PNG Output PNG or JPEG
│ │
└──────────┬───────────────────┘
Stego Image
(downloadable)
```
### Complete Decode Flow (v4.0)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ DECODE FLOW (v4.0) │
└──────────────────────────────────────────────────────────────────────────────┘
User Inputs Processing Output
─────────── ────────── ──────
Reference Photo ──────┐
├──► get_image_hash() ──► ref_hash (32 bytes)
│ │
Passphrase ───────────┤ ▼
├──► derive_key() ──────► aes_key (32 bytes)
PIN ──────────────────┤ (Argon2id) │
│ (MUST MATCH!) │
RSA Key (optional) ───┘ │
Stego Image ──────────► detect_mode() ──────► extract()
(read magic) │ │
│ ┌─────────┴─────┴──────────┐
│ │ │
│ LSB Mode DCT Mode
│ │ │
│ ▼ ▼
│ extract_lsb() extract_from_dct()
│ │ │
│ └────────┬─────────────────┘
│ │
│ ▼
│ parse_stego_header()
│ (magic, length)
│ │
│ ▼
└────────► decrypt()
(AES-GCM)
decompress()
(if compressed)
extract_payload()
(handle file/text)
Original Message
or File
```
---
## Summary
**LSB Mode** is simpler, faster, and higher capacity - perfect for controlled channels where images won't be modified.
**DCT Mode** is more complex but survives real-world image processing - essential for social media and messaging apps.
Both modes share the same cryptographic foundation (Argon2id + AES-256-GCM) and multi-factor authentication, ensuring security regardless of embedding method.
The choice comes down to your use case:
- **Private channel?** → LSB (maximum capacity)
- **Public platform?** → DCT (maximum compatibility)
### v4.0 Simplifications
- **No more date tracking** - encode/decode anytime without remembering dates
- **Single passphrase** - no daily rotation to manage
- **Default 4 words** - better security out of the box
- **JPEG normalization** - handles quality=100 images automatically
- **Large image support** - tested with 14MB+ images

990
WEB_UI.md Normal file
View File

@@ -0,0 +1,990 @@
# Stegasoo Web UI Documentation (v4.0.1)
Complete guide for the Stegasoo web-based steganography interface.
## Table of Contents
- [Overview](#overview)
- [What's New in v4.0.1](#whats-new-in-v401)
- [Installation & Setup](#installation--setup)
- [Pages & Features](#pages--features)
- [Home Page](#home-page)
- [Generate Credentials](#generate-credentials)
- [Encode Message](#encode-message)
- [Decode Message](#decode-message)
- [About Page](#about-page)
- [Embedding Modes](#embedding-modes)
- [DCT Mode (Default)](#dct-mode-default)
- [LSB Mode](#lsb-mode)
- [User Interface Guide](#user-interface-guide)
- [Workflow Examples](#workflow-examples)
- [Security Features](#security-features)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Mobile Support](#mobile-support)
---
## Overview
The Stegasoo Web UI provides a user-friendly browser-based interface for:
- **Generating** secure credentials (passphrase, PINs, RSA keys)
- **Encoding** secret messages or files into images
- **Decoding** hidden messages or files from images
- **Learning** about the security model
Built with Flask, Bootstrap 5, and a modern dark theme.
### Features
- ✅ Drag-and-drop file uploads
- ✅ Image previews with scan animations
- ✅ Native sharing (Web Share API)
- ✅ Responsive design (mobile-friendly)
- ✅ Password-protected RSA key downloads
- ✅ Real-time entropy calculations
- ✅ Automatic file cleanup
-**DCT steganography mode** - Now the default for social media resilience
-**Color mode selection** - Preserve carrier colors
-**File embedding** - Hide files, not just text
-**QR code RSA keys** - Scan to import keys
-**v3.3.0: Streamlined UI** - Compact mode selection, improved form flow
---
## What's New in v4.0.1
Version 4.0.1 adds channel key support and UI improvements:
| Feature | Description |
|---------|-------------|
| Channel keys | 256-bit keys for deployment/group isolation |
| Channel dropdown | Select channel mode (Auto/Public/Custom) |
| LED indicators | Visual status indicators for form fields |
| Key capsule styling | Improved RSA key display |
| Streamlined layout | PIN + Channel key in same row |
**Key benefits:**
- ✅ Channel key isolation - Different teams/deployments can't read each other's messages
- ✅ Dropdown selection for channel mode instead of radio buttons
- ✅ Visual LED indicators show field status
- ✅ Cleaner form layout with improved spacing
- ✅ Backward compatible - public mode works without channel key
---
## Installation & Setup
### From PyPI
```bash
pip install stegasoo[web]
```
This automatically installs DCT dependencies (scipy) for full functionality.
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[web]"
```
### Running the Server
**Development:**
```bash
cd frontends/web
python app.py
```
Server starts at http://localhost:5000
**Production with Gunicorn:**
```bash
cd frontends/web
gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
```
**Docker:**
```bash
docker-compose up web
```
### First-Time Setup
1. Navigate to http://localhost:5000
2. Click "Generate" to create your credentials
3. **Memorize** your passphrase and PIN
4. Share credentials securely with your communication partner
---
## Pages & Features
### Home Page
**URL:** `/`
The landing page introduces Stegasoo and provides quick access to all features.
#### Main Actions
| Card | Description | Link |
|------|-------------|------|
| **Encode Message** | Hide a secret in an image | `/encode` |
| **Decode Message** | Extract a hidden message | `/decode` |
| **Generate Keys** | Create new credentials | `/generate` |
#### "How It Works" Section
Explains the three key components:
1. **Reference Photo** - Shared secret image
2. **Passphrase** - Your secret phrase (v3.2.0: same every time!)
3. **Static PIN** - Same every day
---
### Generate Credentials
**URL:** `/generate`
Create a new set of credentials for steganography operations.
#### Configuration Options
| Option | Range | Default | Description |
|--------|-------|---------|-------------|
| Words per passphrase | 3-12 | 4 | BIP-39 words in passphrase |
| Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair |
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
#### Entropy Calculator
The UI displays real-time entropy calculations:
```
Estimated entropy: ~63 bits
[=============> ] Good for most use cases
• Reference photo adds ~80-256 bits more
```
#### Generated Output (v3.2.0)
After clicking "Generate Credentials":
**Static PIN** (if enabled):
```
┌─────────────────────┐
│ 8 4 7 2 9 3 │
└─────────────────────┘
Use this 6-digit PIN every time
```
**Passphrase** (v3.2.0: single passphrase, no daily rotation):
```
┌─────────────────────────────────────────┐
│ abandon ability able about │
│ │
│ Use this passphrase to encode and │
│ decode messages - no date needed! │
└─────────────────────────────────────────┘
```
**RSA Key** (if enabled):
- Copy to clipboard button
- Download as password-protected .pem file
- Download as QR code image
**Security Summary:**
```
Passphrase entropy: 44 bits (4 words)
PIN entropy: 19 bits
RSA entropy: 128 bits
─────────────────────────────
Total: 191 bits
+ reference photo (~80-256 bits) = 271+ bits combined
```
#### RSA Key Download
1. Click "Download as .pem"
2. Enter a password (minimum 8 characters)
3. Click "Download Protected Key"
4. Save the file securely
5. Share with your communication partner through a secure channel
#### RSA Key QR Code
For easier sharing, you can also:
1. Click "Download QR Code"
2. Save the QR code image
3. Your partner can scan it to import the key
---
### Encode Message
**URL:** `/encode`
Hide a secret message or file inside an image.
#### Form Flow (v3.3.0)
The encode form follows a logical flow:
1. **Load Images** - Reference photo and carrier image
2. **View Capacity** - Shows available capacity for DCT and LSB modes
3. **Select Mode** - DCT (default) or LSB with inline tooltips
4. **Enter Payload** - Text message or file
5. **Add Security** - Passphrase, PIN, and/or RSA key
#### Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Reference Photo | Image file | ✓ | Your shared secret photo |
| Carrier Image | Image file | ✓ | Image to hide message in |
| Embedding Mode | Toggle | ✓ | DCT (default) or LSB |
| Payload Type | Toggle | ✓ | Text message or file |
| Secret Message | Text | * | Message to hide (max 50KB) |
| File to Embed | File | * | File to hide (max 2MB) |
| Passphrase | Text | ✓ | Your passphrase |
| PIN | Number | ** | Your static PIN |
| RSA Key | .pem file | ** | Your shared RSA key |
| RSA Key QR | Image file | ** | QR code containing RSA key |
| RSA Key Password | Password | | Password for encrypted key |
\* One of message or file required.
\*\* At least one security factor (PIN or RSA Key) required.
#### Embedding Mode Selection (v3.3.0)
The mode selector is now a compact inline toggle:
```
┌────────────────────────────────────────────────────────────┐
│ ◉ 🔊 DCT · Social Media ⓘ │ ○ ⊞ LSB · Email & Files ⓘ │
└────────────────────────────────────────────────────────────┘
```
- **DCT** - Default, best for social media sharing
- **LSB** - Higher capacity, for lossless channels
- **ⓘ** - Hover for details (capacity, output format, etc.)
#### DCT Options
When DCT mode is selected, additional options appear:
| Option | Values | Default | Description |
|--------|--------|---------|-------------|
| Output Format | PNG / JPEG | JPEG | Output image format |
| Color Mode | Color / Grayscale | Color | Carrier color handling |
#### Drag-and-Drop Upload
Both image upload zones support:
- Click to browse
- Drag and drop files
- Instant image preview with scan animation
- Status indicators ("Hash Acquired", "Carrier Loaded")
#### Capacity Info Panel
After loading a carrier image, a capacity panel appears:
```
┌─────────────────────────────────────────────────────────┐
│ 📏 Carrier: 1920 × 1080 (2.1 MP) DCT: 150 KB LSB: 750 KB │
└─────────────────────────────────────────────────────────┘
```
#### Character Counter
```
Message: [ ]
1,234 / 50,000 characters 2%
```
Shows warning at 80% capacity.
#### Encoding Process
1. Upload reference photo and carrier image
2. View capacity info panel
3. Select embedding mode (DCT default)
4. Choose payload type and enter content
5. Enter passphrase and security factors
6. Click "Encode Message"
7. Wait for processing (shows spinner)
8. Redirected to result page
#### Result Page
**URL:** `/encode/result/<file_id>`
After successful encoding:
```
┌────────────────────────────────────────┐
│ ✓ Message Encoded Successfully! │
│ │
│ 📄 a1b2c3d4.png │
│ Your secret is hidden │
│ in this image │
│ │
│ Mode: DCT (Color, JPEG) │
│ Capacity used: 45.2% │
│ │
│ [ Download Image ] │
│ [ Share Image ] │
│ │
│ ⚠️ File expires in 5 minutes. │
│ Download or share now. │
│ │
│ [ Encode Another Message ] │
└────────────────────────────────────────┘
```
**Share Options:**
1. **Native Share** (mobile/supported browsers):
- Uses Web Share API
- Opens system share sheet
- Can share directly to apps
2. **Fallback Share** (desktop):
- Email link
- Telegram link
- WhatsApp link
- Copy link to clipboard
---
### Decode Message
**URL:** `/decode`
Extract a hidden message or file from a stego image.
#### Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Reference Photo | Image file | ✓ | Same photo used for encoding |
| Stego Image | Image file | ✓ | Image containing hidden message |
| Passphrase | Text | ✓ | Same passphrase used for encoding |
| PIN | Number | * | Same PIN used for encoding |
| RSA Key | .pem file | * | Same RSA key used for encoding |
| RSA Key QR | Image file | * | QR code containing RSA key |
| RSA Key Password | Password | | Password for encrypted key |
\* Must match security factors used during encoding.
#### Automatic Mode Detection
The decoder automatically detects whether a stego image uses LSB or DCT mode. You don't need to specify the mode manually—it just works!
#### Decoding Process (v3.2.0 Simplified)
1. Upload the same reference photo
2. Upload the received stego image
3. Enter your passphrase (no date needed!)
4. Enter your PIN and/or RSA key
5. Click "Decode Message"
6. View decoded message or download decoded file
#### Successful Decode (Text)
```
┌────────────────────────────────────────┐
│ ✓ Message Decrypted Successfully! │
│ │
│ Decoded Message: │
│ ┌──────────────────────────────────┐ │
│ │ Meet at midnight. The package │ │
│ │ will be under the bridge. │ │
│ └──────────────────────────────────┘ │
│ │
│ [ Decode Another Message ] │
└────────────────────────────────────────┘
```
#### Successful Decode (File)
```
┌────────────────────────────────────────┐
│ ✓ File Extracted Successfully! │
│ │
│ 📄 secret_document.pdf │
│ Size: 245 KB │
│ Type: application/pdf │
│ │
│ [ Download File ] │
│ │
│ ⚠️ File expires in 5 minutes. │
│ │
│ [ Decode Another Message ] │
└────────────────────────────────────────┘
```
#### Troubleshooting Tips
If decryption fails:
1. **Check passphrase** - Must be exact match (case-sensitive)
2. **Same reference photo** - Must be identical file
3. **Correct PIN/RSA** - Match what was used for encoding
4. **Image integrity** - Ensure no resizing/recompression (LSB mode)
---
### About Page
**URL:** `/about`
Information about the Stegasoo project, security model, and credits.
Includes:
- Version information (v3.3.0)
- Recent UI improvements
- Security model overview
- Dependency status (Argon2, QR code support)
---
## Embedding Modes
Stegasoo offers two steganography algorithms, each with different trade-offs.
### DCT Mode (Default)
**Discrete Cosine Transform** embedding hides data in frequency domain coefficients. This is now the default mode when scipy is available.
| Aspect | Details |
|--------|---------|
| **Capacity** | ~0.5 bits/pixel (~75 KB/MP) |
| **Output Formats** | PNG or JPEG |
| **Resilience** | ✅ Survives JPEG recompression |
| **Best For** | Social media, messaging apps |
**When to use DCT:**
- Sharing via social media (Instagram, WhatsApp, Telegram)
- When image may be recompressed
- When stealth is important
- Smaller messages that fit in reduced capacity
#### DCT Output Formats
| Format | Pros | Cons |
|--------|------|------|
| **JPEG** | Native format, natural, smaller, resilient | Slightly lower capacity |
| **PNG** | Lossless, predictable | Larger file |
#### DCT Color Modes
| Mode | Description | Use Case |
|------|-------------|----------|
| **Color** | Embeds in luminance (Y), preserves chrominance | Most images, photos |
| **Grayscale** | Converts to grayscale before embedding | Black & white images |
### LSB Mode
**Least Significant Bit** embedding modifies the least significant bits of pixel values.
| Aspect | Details |
|--------|---------|
| **Capacity** | ~3 bits/pixel (~375 KB/MP) |
| **Output Format** | PNG only (lossless required) |
| **Resilience** | ❌ Destroyed by JPEG compression |
| **Best For** | Maximum capacity, controlled sharing |
**When to use LSB:**
- Sharing via lossless channels (email attachment, file transfer, cloud storage)
- Maximum message capacity needed
- Recipient won't modify/recompress the image
### Capacity Comparison
For a 1920×1080 image (~2 MP):
| Mode | Approximate Capacity |
|------|---------------------|
| LSB (PNG) | ~750 KB |
| DCT (PNG, Color) | ~150 KB |
| DCT (JPEG) | ~150 KB |
### Choosing the Right Mode
```
┌─────────────────────────────────────────────────────────────┐
│ Mode Selection Guide │
├─────────────────────────────────────────────────────────────┤
│ │
│ Sharing via social media / messaging app? │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ Use DCT Need maximum capacity? │
│ (default) │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ Use LSB Use DCT │
│ (default) │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## User Interface Guide
### Layout Structure
```
┌──────────────────────────────────────────────────────────────┐
│ 🦕 Stegasoo [Encode] [Decode] [Generate] │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Page Content │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Upload Zone │ │ Upload Zone │ │ │
│ │ │ (Reference) │ │ (Carrier) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ Passphrase: [________________________] │ │
│ │ PIN: [____________] │ │
│ │ │ │
│ │ [Advanced Options ▼] │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Embedding Mode: [LSB ▼] │ │ │
│ │ │ Output Format: [PNG ▼] (DCT only) │ │ │
│ │ │ Color Mode: [Color ▼] (DCT only) │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [ Encode Message ] │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────────┤
│ Footer │
└──────────────────────────────────────────────────────────────┘
```
### Color Scheme
| Element | Color | Purpose |
|---------|-------|---------|
| Background | Dark gradient | Reduce eye strain |
| Cards | Semi-transparent | Visual hierarchy |
| Headers | Purple gradient | Brand identity |
| Success | Green | Positive actions |
| Warning | Yellow | Caution messages |
| Error | Red | Error states |
### Form Validation
- Real-time validation feedback
- Clear error messages in alerts
- Required field indicators
- Input constraints (max length, format)
- Passphrase word count validation (v3.2.0)
### Loading States
During long operations:
- Button shows spinner
- Button text changes (e.g., "Encoding...")
- Button is disabled to prevent double-submit
### Flash Messages
```
┌──────────────────────────────────────────────┐
│ ✓ Credentials Generated! [×] │
└──────────────────────────────────────────────┘
```
Types:
- Success (green) - Operation completed
- Error (red) - Operation failed
- Warning (yellow) - Caution needed (e.g., short passphrase)
---
## Workflow Examples
### First-Time Setup (Both Parties)
**Party A:**
1. Go to `/generate`
2. Configure: PIN ✓, 4 words, 6 digits
3. Click "Generate Credentials"
4. **Write down** passphrase and PIN on paper
5. **Memorize** over the next few days
6. Destroy the paper
**Share with Party B (in person or secure channel):**
- The passphrase (just one phrase now!)
- The PIN
- The reference photo file (if not already shared)
### Sending a Secret Message (LSB - Default)
1. Go to `/encode`
2. Upload your shared reference photo
3. Upload any carrier image (meme, vacation photo, etc.)
4. Type your secret message
5. Enter your passphrase
6. Enter your PIN
7. Click "Encode Message"
8. Download or share the resulting image
9. Send via any channel (email, file transfer)
### Sending with DCT Mode
1. Go to `/encode`
2. Upload your shared reference photo
3. Upload carrier image
4. Type your secret message
5. Enter your passphrase and PIN
6. **Expand "Advanced Options"**
7. **Select "DCT" embedding mode**
8. **Select "JPEG" output format** (optional)
9. Click "Encode Message"
10. Download and share
### Receiving a Secret Message (v3.2.0 Simplified)
1. Receive the stego image through any channel
2. Go to `/decode`
3. Upload the same reference photo
4. Upload the received stego image
5. Enter your passphrase (no date needed!)
6. Enter your PIN
7. Click "Decode Message"
8. Read the secret message or download the file
### Embedding a File
1. Go to `/encode`
2. Upload reference photo and carrier image
3. Select "File" as payload type
4. Upload the file to embed (max 2MB)
5. Enter passphrase and PIN
6. Click "Encode Message"
7. Download the stego image
### Extracting a File
1. Go to `/decode`
2. Upload reference photo and stego image
3. Enter passphrase and PIN
4. Click "Decode Message"
5. Click "Download File" to save the extracted file
### Changing Credentials
To rotate to new credentials:
1. Both parties generate new credentials together
2. Agree on a cutover date
3. Messages encoded before cutover use old credentials
4. Messages encoded after cutover use new credentials
---
## Security Features
### Client-Side Security
| Feature | Implementation |
|---------|----------------|
| No credential storage | Nothing saved in browser |
| Automatic cleanup | Files deleted after 5 minutes |
| HTTPS support | Configure at server level |
### Server-Side Security
| Feature | Implementation |
|---------|----------------|
| Memory-hard KDF | Argon2id (256MB RAM) |
| Authenticated encryption | AES-256-GCM |
| Random salt | Per-message salt |
| Temporary storage | In-memory, auto-expiring |
| Input validation | All inputs validated |
| File size limits | 5MB max upload |
### File Security
| Aspect | Protection |
|--------|------------|
| Upload location | `/tmp/stego_uploads` (Docker) |
| Storage duration | 5 minutes maximum |
| Access control | Random 16-byte file ID |
| Cleanup | Automatic + manual |
### Embedding Mode Security
| Mode | Security Consideration |
|------|----------------------|
| LSB | Full capacity, but fragile to modification |
| DCT | Lower capacity, frequency domain hiding |
Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivation).
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include `src/` for development |
### Application Limits
| Limit | Value | Config Location |
|-------|-------|-----------------|
| Max file upload | 5 MB | `app.config['MAX_CONTENT_LENGTH']` |
| File expiry | 5 minutes | `TEMP_FILE_EXPIRY` |
| Max image pixels | 4 MP | `stegasoo.constants` |
| Max message size | 50 KB | `stegasoo.constants` |
| Max file payload | 2 MB | `stegasoo.constants` |
| PIN length | 6-9 digits | `stegasoo.constants` |
| Passphrase words | 3-12 | `stegasoo.constants` |
### Production Deployment
**With Gunicorn:**
```bash
gunicorn \
--bind 0.0.0.0:5000 \
--workers 2 \
--threads 4 \
--timeout 60 \
app:app
```
**Worker Calculation:**
- Each encode/decode uses ~256MB RAM (Argon2) + ~100MB for scipy (DCT mode)
- Formula: `workers = (available_RAM - 512MB) / 350MB`
**With Nginx (reverse proxy):**
```nginx
server {
listen 80;
server_name stegasoo.example.com;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
}
```
**With Docker Compose:**
```yaml
services:
web:
build:
context: .
target: web
ports:
- "5000:5000"
deploy:
resources:
limits:
memory: 768M
reservations:
memory: 384M
```
---
## Troubleshooting
### Common Issues
#### "Decryption failed"
**Causes:**
- Wrong passphrase
- Wrong PIN
- Different reference photo
- Stego image was modified
**Solutions:**
1. Verify exact passphrase (case-sensitive)
2. Verify you're using the original reference photo
3. Ensure the stego image wasn't resized/recompressed (LSB mode)
#### "Invalid or missing Stegasoo header"
**Causes:**
- Image was heavily recompressed
- Wrong credentials
- Corrupted during transfer
**Solutions:**
1. Verify credentials match
2. Try obtaining original file
3. If using DCT mode, some modification is expected to work
#### "Carrier image too small"
**Cause:** Message too large for carrier capacity
**Solutions:**
1. Use a larger carrier image (more pixels)
2. Shorten the message
3. Use LSB mode for more capacity
#### "Passphrase should have at least 4 words"
**Cause:** Passphrase too short (v3.2.0 warning)
**Solutions:**
1. Use a longer passphrase for better security
2. Can still proceed with shorter passphrase (warning only)
#### "You must provide at least a PIN or RSA Key"
**Cause:** No security factor selected
**Solution:** Enter a PIN and/or upload an RSA key
#### Upload fails silently
**Causes:**
- File too large (>5MB)
- Invalid file type
- Browser issue
**Solutions:**
1. Reduce file size
2. Use PNG, JPG, or BMP formats
3. Try a different browser
#### RSA key password error
**Causes:**
- Wrong password
- Unencrypted key with password provided
- Corrupted key file
**Solutions:**
1. Verify the correct password
2. If key is unencrypted, leave password blank
3. Re-download or regenerate the key
#### DCT mode shows "requires scipy"
**Cause:** scipy library not installed
**Solution:**
```bash
pip install scipy
# Or rebuild Docker image
docker-compose build --no-cache
```
### Browser Compatibility
| Browser | Status | Notes |
|---------|--------|-------|
| Chrome 80+ | ✓ Full | Web Share API supported |
| Firefox 80+ | ✓ Full | Limited Web Share |
| Safari 14+ | ✓ Full | Web Share on iOS |
| Edge 80+ | ✓ Full | Web Share API supported |
| IE 11 | ✗ None | Not supported |
### Performance Issues
**Slow encoding/decoding:**
- Normal: Argon2 is intentionally slow (security feature)
- DCT mode adds ~1-2 seconds for transform operations
- Expected time: 3-7 seconds per operation
**High memory usage:**
- Normal: Argon2 requires 256MB RAM
- DCT mode adds scipy memory overhead (~100MB)
- Configure worker count based on available RAM
---
## Mobile Support
### Responsive Design
The UI adapts to mobile screens:
- Single-column layout on small screens
- Touch-friendly buttons (48px minimum)
- Readable text without zooming
- Scrollable tables
- Collapsible "Advanced Options" for cleaner mobile view
### Mobile-Specific Features
**Native Sharing:**
On supported mobile browsers, the "Share Image" button opens the native share sheet, allowing you to share directly to:
- Messaging apps (iMessage, WhatsApp, Telegram)
- Social media (Instagram, Twitter)
- Email
- Other installed apps
**Camera Upload:**
File input accepts camera capture:
- Take a new photo as reference
- Capture carrier image directly
### PWA Support (Future)
The web app can be added to home screen on mobile devices for quick access.
---
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Tab` | Navigate between fields |
| `Enter` | Submit form (when focused) |
| `Esc` | Close modal/alert |
---
## Accessibility
| Feature | Implementation |
|---------|----------------|
| Screen readers | ARIA labels on interactive elements |
| Keyboard navigation | Full tab support |
| Color contrast | WCAG AA compliant |
| Focus indicators | Visible focus rings |
| Form labels | All inputs labeled |
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [API Documentation](API.md) - REST API reference
- [Web Frontend Update Summary](web/WEB_FRONTEND_UPDATE_SUMMARY_V3.2.0.md) - Migration details
- [README](../README.md) - Project overview

61
build.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/bin/bash
# Stegasoo Build Script
# Usage: ./build.sh [base|fast|full|clean]
set -e
case "${1:-fast}" in
base)
# Build base image with all dependencies (run once, or when deps change)
echo "🔨 Building base image (this takes 5-10 minutes)..."
docker build -f Dockerfile.base -t stegasoo-base:latest .
echo "✅ Base image built! Future builds will be fast."
echo ""
echo "Optional: Push to registry for team use:"
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
echo " docker push yourregistry/stegasoo-base:latest"
;;
fast)
# Fast build using pre-built base image
if ! docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
echo "⚠️ Base image not found. Building it first (one-time)..."
$0 base
fi
echo "🚀 Fast build using base image..."
docker-compose build
echo "✅ Done! Start with: docker-compose up -d"
;;
full)
# Full rebuild from scratch (slow, but no base image needed)
echo "🐢 Full build from scratch (slow)..."
docker-compose build --no-cache
echo "✅ Done! Start with: docker-compose up -d"
;;
clean)
# Clean up everything
echo "🧹 Cleaning up..."
docker-compose down --rmi local -v 2>/dev/null || true
docker rmi stegasoo-base:latest 2>/dev/null || true
echo "✅ Cleaned!"
;;
*)
echo "Stegasoo Build Script"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " base Build the base image (one-time, 5-10 min)"
echo " fast Fast build using base image (default, ~10 sec)"
echo " full Full rebuild from scratch (slow, no base needed)"
echo " clean Remove all images and volumes"
echo ""
echo "Typical workflow:"
echo " 1. First time: $0 base"
echo " 2. Daily dev: $0 fast (or just 'docker-compose build')"
echo " 3. Deps change: $0 base (rebuild base image)"
;;
esac

170
check_scipy.py Normal file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
Diagnostic script to check for scipy/numpy issues.
Run this BEFORE starting the web app.
Usage:
python check_scipy.py
"""
import sys
print(f"Python version: {sys.version}")
print()
# Check numpy
try:
import numpy as np
print(f"NumPy version: {np.__version__}")
print(f"NumPy config:")
np.show_config()
except ImportError as e:
print(f"NumPy not installed: {e}")
except Exception as e:
print(f"NumPy error: {e}")
print()
print("-" * 50)
print()
# Check scipy
try:
import scipy
print(f"SciPy version: {scipy.__version__}")
except ImportError as e:
print(f"SciPy not installed: {e}")
print()
# Check PIL
try:
from PIL import Image
print(f"Pillow version: {Image.__version__}")
except ImportError as e:
print(f"Pillow not installed: {e}")
print()
print("-" * 50)
print()
# Test scipy DCT directly
print("Testing scipy DCT...")
try:
from scipy.fftpack import dct, idct
import numpy as np
# Create test array
test = np.random.rand(8, 8).astype(np.float64)
print(f"Input array shape: {test.shape}, dtype: {test.dtype}")
# Test 1D DCT
row = test[0, :]
result = dct(row, norm='ortho')
print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}")
# Test 2D DCT (the potentially problematic operation)
result2d = dct(dct(test.T, norm='ortho').T, norm='ortho')
print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}")
# Test inverse
recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho')
error = np.max(np.abs(test - recovered))
print(f"Round-trip error: {error}")
if error < 1e-10:
print("✓ scipy DCT working correctly")
else:
print("⚠ scipy DCT has precision issues")
except Exception as e:
print(f"✗ scipy DCT failed: {e}")
import traceback
traceback.print_exc()
print()
print("-" * 50)
print()
# Test with larger array (more like real image processing)
print("Testing with larger arrays (512x512)...")
try:
from scipy.fftpack import dct, idct
import numpy as np
import gc
# Simulate processing many 8x8 blocks
large_array = np.random.rand(512, 512).astype(np.float64)
print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes")
count = 0
for y in range(0, 512, 8):
for x in range(0, 512, 8):
block = large_array[y:y+8, x:x+8].copy()
dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho')
recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
large_array[y:y+8, x:x+8] = recovered
count += 1
print(f"Processed {count} blocks successfully")
del large_array
gc.collect()
print("✓ Large array processing completed")
except Exception as e:
print(f"✗ Large array processing failed: {e}")
import traceback
traceback.print_exc()
print()
print("-" * 50)
print()
# Test PIL with large image
print("Testing PIL with large image...")
try:
from PIL import Image
import io
# Create a large test image
img = Image.new('RGB', (4000, 3000), color=(128, 128, 128))
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_bytes = buffer.getvalue()
print(f"Test image size: {len(img_bytes)} bytes")
# Re-open and process
buffer2 = io.BytesIO(img_bytes)
img2 = Image.open(buffer2)
print(f"Re-opened image: {img2.size}, mode: {img2.mode}")
# Convert to numpy array
import numpy as np
arr = np.array(img2)
print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}")
# Clean up
img.close()
img2.close()
buffer.close()
buffer2.close()
del arr
gc.collect()
print("✓ PIL large image test completed")
except Exception as e:
print(f"✗ PIL test failed: {e}")
import traceback
traceback.print_exc()
print()
print("=" * 50)
print("Diagnostics complete")
print()
print("If no errors above but web app still crashes, try:")
print("1. pip install --upgrade scipy numpy pillow")
print("2. pip install scipy==1.11.4 numpy==1.26.4 # Known stable versions")
print("3. Check if using conda vs pip (mixing can cause issues)")

BIN
data/WebUI.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
data/WebUI_Decode.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
data/WebUI_Encode.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
data/WebUI_Generate.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

215
debug_jpegio.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
Debug script for DCT/jpegio extraction issues.
Run from the stegasoo directory.
"""
import sys
import struct
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / 'src'))
import hashlib
import numpy as np
# Check for jpegio
try:
import jpegio as jio
print("✓ jpegio available")
except ImportError:
print("✗ jpegio NOT available")
sys.exit(1)
def get_usable_positions(coef_array, min_magnitude=2):
"""Get positions of usable coefficients."""
positions = []
h, w = coef_array.shape
for row in range(h):
for col in range(w):
# Skip DC coefficients (top-left of each 8x8 block)
if (row % 8 == 0) and (col % 8 == 0):
continue
if abs(coef_array[row, col]) >= min_magnitude:
positions.append((row, col))
return positions
def generate_order(num_positions, seed):
"""Generate pseudo-random order for coefficient selection."""
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
order = list(range(num_positions))
rng.shuffle(order)
return order
def extract_bits(coef_array, positions, order, num_bits):
"""Extract bits from coefficients."""
bits = []
for i, pos_idx in enumerate(order):
if i >= num_bits:
break
row, col = positions[pos_idx]
coef = coef_array[row, col]
bits.append(coef & 1)
return bits
def bits_to_bytes(bits):
"""Convert list of bits to bytes."""
result = []
for i in range(0, len(bits), 8):
byte_bits = bits[i:i+8]
if len(byte_bits) == 8:
byte_val = sum(byte_bits[j] << (7-j) for j in range(8))
result.append(byte_val)
return bytes(result)
def main():
if len(sys.argv) < 3:
print("Usage: python debug_jpegio.py <stego_image.jpg> <reference_photo>")
print("\nOptional: add passphrase, pin, key path")
print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem")
sys.exit(1)
stego_path = sys.argv[1]
ref_path = sys.argv[2]
passphrase = sys.argv[3] if len(sys.argv) > 3 else "test"
pin = sys.argv[4] if len(sys.argv) > 4 else ""
key_path = sys.argv[5] if len(sys.argv) > 5 else None
print(f"\n{'='*60}")
print("JPEGIO DCT EXTRACTION DEBUG")
print(f"{'='*60}")
print(f"Stego image: {stego_path}")
print(f"Reference: {ref_path}")
print(f"Passphrase: '{passphrase}'")
print(f"PIN: '{pin}'")
print(f"Key: {key_path}")
# Load stego image with jpegio
print(f"\n[1] Loading stego image with jpegio...")
try:
jpeg = jio.read(stego_path)
print(f" ✓ jpegio.read() succeeded")
print(f" Number of components: {len(jpeg.coef_arrays)}")
for i, arr in enumerate(jpeg.coef_arrays):
print(f" Component {i}: shape={arr.shape}, dtype={arr.dtype}")
except Exception as e:
print(f" ✗ Failed: {e}")
sys.exit(1)
# Get coefficient array (channel 0)
coef_array = jpeg.coef_arrays[0]
print(f"\n[2] Coefficient array analysis...")
print(f" Shape: {coef_array.shape}")
print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}")
print(f" Min value: {coef_array.min()}")
print(f" Max value: {coef_array.max()}")
# Get usable positions
print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...")
positions = get_usable_positions(coef_array)
print(f" Usable positions: {len(positions)}")
print(f" Capacity: ~{len(positions) // 8} bytes")
# Generate seed (this needs to match the encode seed!)
print(f"\n[4] Generating seed...")
# Load reference photo
ref_data = Path(ref_path).read_bytes()
ref_hash = hashlib.sha256(ref_data).digest()
print(f" Reference hash: {ref_hash[:8].hex()}...")
# Load RSA key if provided
rsa_component = b""
if key_path:
try:
from stegasoo import load_rsa_key
key_data = Path(key_path).read_bytes()
# Try without password first
try:
rsa_key = load_rsa_key(key_data, password=None)
except:
rsa_key = load_rsa_key(key_data, password="testpass")
# Get public key bytes for seed
from cryptography.hazmat.primitives import serialization
pub_bytes = rsa_key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
rsa_component = hashlib.sha256(pub_bytes).digest()
print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...")
except Exception as e:
print(f" ✗ Could not load RSA key: {e}")
# Build seed like stegasoo does
# This is the critical part - must match encoding!
seed_parts = [
ref_hash,
passphrase.encode('utf-8'),
pin.encode('utf-8') if pin else b"",
rsa_component,
]
seed = hashlib.sha256(b"".join(seed_parts)).digest()
print(f" Combined seed: {seed[:8].hex()}...")
# Generate order
print(f"\n[5] Generating coefficient order...")
order = generate_order(len(positions), seed)
print(f" First 10 indices: {order[:10]}")
# Try to extract header
print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...")
HEADER_SIZE = 10
header_bits = extract_bits(coef_array, positions, order, HEADER_SIZE * 8)
header_bytes = bits_to_bytes(header_bits)
print(f" Raw header bytes: {header_bytes.hex()}")
print(f" As ASCII (if printable): {repr(header_bytes)}")
# Check for JPGS magic
JPEGIO_MAGIC = b'JPGS'
if header_bytes[:4] == JPEGIO_MAGIC:
print(f" ✓ Found JPEGIO magic bytes!")
version = header_bytes[4]
flags = header_bytes[5]
data_length = struct.unpack('>I', header_bytes[6:10])[0]
print(f" Version: {version}")
print(f" Flags: {flags}")
print(f" Data length: {data_length} bytes")
if data_length > 0 and data_length < len(positions) // 8:
print(f"\n[7] Extracting payload ({data_length} bytes)...")
total_bits = (HEADER_SIZE + data_length) * 8
all_bits = extract_bits(coef_array, positions, order, total_bits)
data_bits = all_bits[HEADER_SIZE * 8:]
payload = bits_to_bytes(data_bits)
print(f" Payload (first 64 bytes): {payload[:64].hex()}")
print(f" This should be encrypted data starting with salt/IV")
else:
print(f" ✗ Invalid data length: {data_length}")
else:
print(f" ✗ No JPEGIO magic found")
print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')")
print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')")
# Try alternate interpretations
print(f"\n[7] Trying alternate header interpretations...")
# Maybe it's scipy DCT format?
DCT_MAGIC = b'DCTS'
if header_bytes[:4] == DCT_MAGIC:
print(f" Found SCIPY DCT magic - wrong extraction method!")
else:
# Show bit distribution
print(f" First 32 extracted bits: {header_bits[:32]}")
# Check if bits look random or patterned
ones = sum(header_bits[:80])
print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)")
print(f"\n{'='*60}")
print("DEBUG COMPLETE")
print(f"{'='*60}\n")
if __name__ == '__main__':
main()

View File

@@ -1,5 +1,9 @@
version: '3.8'
# Shared environment variables
x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
services:
# ============================================================================
# Web UI (Flask)
@@ -12,14 +16,15 @@ services:
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
<<: *common-env
FLASK_ENV: production
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M # Argon2 needs 256MB per operation
memory: 768M
reservations:
memory: 256M
memory: 384M
# ============================================================================
# REST API (FastAPI)
@@ -31,32 +36,12 @@ services:
container_name: stegasoo-api
ports:
- "8000:8000"
environment:
<<: *common-env
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
memory: 768M
reservations:
memory: 256M
# ============================================================================
# Nginx Reverse Proxy (optional, for production)
# ============================================================================
# nginx:
# image: nginx:alpine
# container_name: stegasoo-nginx
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
# - ./certs:/etc/nginx/certs:ro
# depends_on:
# - web
# - api
# restart: unless-stopped
# ============================================================================
# Development overrides
# ============================================================================
# Use: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
memory: 384M

View File

@@ -1,952 +0,0 @@
# Stegasoo REST API Documentation
Complete REST API reference for Stegasoo steganography operations.
## Table of Contents
- [Overview](#overview)
- [Installation](#installation)
- [Authentication](#authentication)
- [Base URL](#base-url)
- [Endpoints](#endpoints)
- [GET /](#get--status)
- [POST /generate](#post-generate)
- [POST /encode](#post-encode-json)
- [POST /encode/multipart](#post-encodemultipart)
- [POST /decode](#post-decode-json)
- [POST /decode/multipart](#post-decodemultipart)
- [POST /image/info](#post-imageinfo)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Code Examples](#code-examples)
- [Rate Limiting](#rate-limiting)
- [Security Considerations](#security-considerations)
---
## Overview
The Stegasoo REST API provides programmatic access to all steganography operations:
- **Generate** credentials (phrases, PINs, RSA keys)
- **Encode** messages into images
- **Decode** messages from images
- **Analyze** image capacity
The API supports both JSON (base64-encoded images) and multipart form data (direct file uploads).
---
## Installation
### From PyPI
```bash
pip install stegasoo[api]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[api]"
```
### Running the Server
**Development:**
```bash
cd frontends/api
python main.py
```
**Production:**
```bash
cd frontends/api
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```
**Docker:**
```bash
docker-compose up api
```
---
## Authentication
The API currently operates without authentication. For production deployments, implement authentication at the reverse proxy level (nginx, Caddy) or add API key middleware.
---
## Base URL
| Environment | URL |
|-------------|-----|
| Local Development | `http://localhost:8000` |
| Docker | `http://localhost:8000` |
| Production | Configure as needed |
---
## Endpoints
### GET / (Status)
Check API status and configuration.
#### Request
```http
GET / HTTP/1.1
Host: localhost:8000
```
#### Response
```json
{
"version": "2.0.1",
"has_argon2": true,
"day_names": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Stegasoo library version |
| `has_argon2` | boolean | Whether Argon2id is available |
| `day_names` | array | Day names for phrase mapping |
#### cURL Example
```bash
curl http://localhost:8000/
```
---
### POST /generate
Generate credentials for encoding/decoding.
#### Request
```http
POST /generate HTTP/1.1
Host: localhost:8000
Content-Type: application/json
```
#### Request Body
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `use_pin` | boolean | `true` | Generate a PIN |
| `use_rsa` | boolean | `false` | Generate an RSA key |
| `pin_length` | integer | `6` | PIN length (6-9) |
| `rsa_bits` | integer | `2048` | RSA key size (2048, 3072, 4096) |
| `words_per_phrase` | integer | `3` | Words per phrase (3-12) |
#### Response
```json
{
"phrases": {
"Monday": "abandon ability able",
"Tuesday": "actor actress actual",
"Wednesday": "advice aerobic affair",
"Thursday": "afraid again age",
"Friday": "agree ahead aim",
"Saturday": "airport aisle alarm",
"Sunday": "album alcohol alert"
},
"pin": "847293",
"rsa_key_pem": null,
"entropy": {
"phrase": 33,
"pin": 19,
"rsa": 0,
"total": 52
}
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `phrases` | object | Day-to-phrase mapping |
| `pin` | string\|null | Generated PIN (if requested) |
| `rsa_key_pem` | string\|null | PEM-encoded RSA key (if requested) |
| `entropy.phrase` | integer | Entropy from phrases (bits) |
| `entropy.pin` | integer | Entropy from PIN (bits) |
| `entropy.rsa` | integer | Entropy from RSA key (bits) |
| `entropy.total` | integer | Combined entropy (bits) |
#### cURL Examples
**PIN only:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "use_rsa": false}'
```
**RSA only:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": false, "use_rsa": true, "rsa_bits": 4096}'
```
**Both with custom settings:**
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"use_pin": true,
"use_rsa": true,
"pin_length": 9,
"rsa_bits": 4096,
"words_per_phrase": 6
}'
```
---
### POST /encode (JSON)
Encode a message using base64-encoded images.
#### Request
```http
POST /encode HTTP/1.1
Host: localhost:8000
Content-Type: application/json
```
#### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message` | string | ✓ | Message to encode |
| `reference_photo_base64` | string | ✓ | Base64-encoded reference photo |
| `carrier_image_base64` | string | ✓ | Base64-encoded carrier image |
| `day_phrase` | string | ✓ | Today's passphrase |
| `pin` | string | * | Static PIN (6-9 digits) |
| `rsa_key_base64` | string | * | Base64-encoded RSA key PEM |
| `rsa_password` | string | | Password for RSA key |
| `date_str` | string | | Date override (YYYY-MM-DD) |
\* At least one of `pin` or `rsa_key_base64` required.
#### Response
```json
{
"stego_image_base64": "iVBORw0KGgo...",
"filename": "a1b2c3d4_20251227.png",
"capacity_used_percent": 12.4,
"date_used": "2025-12-27",
"day_of_week": "Saturday"
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `stego_image_base64` | string | Base64-encoded stego PNG |
| `filename` | string | Suggested filename |
| `capacity_used_percent` | float | Percentage of capacity used |
| `date_used` | string | Date embedded in image (YYYY-MM-DD) |
| `day_of_week` | string | Day name for passphrase rotation |
#### cURL Example
```bash
# Prepare base64-encoded images
REF_B64=$(base64 -w0 reference.jpg)
CARRIER_B64=$(base64 -w0 carrier.png)
curl -X POST http://localhost:8000/encode \
-H "Content-Type: application/json" \
-d "{
\"message\": \"Secret message\",
\"reference_photo_base64\": \"$REF_B64\",
\"carrier_image_base64\": \"$CARRIER_B64\",
\"day_phrase\": \"apple forest thunder\",
\"pin\": \"123456\"
}" | jq -r '.stego_image_base64' | base64 -d > stego.png
```
---
### POST /encode/multipart
Encode a message using direct file uploads. Returns the stego image directly.
#### Request
```http
POST /encode/multipart HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data; boundary=----FormBoundary
------FormBoundary
Content-Disposition: form-data; name="message"
Secret message here
------FormBoundary
Content-Disposition: form-data; name="day_phrase"
apple forest thunder
------FormBoundary
Content-Disposition: form-data; name="pin"
123456
------FormBoundary
Content-Disposition: form-data; name="reference_photo"; filename="ref.jpg"
Content-Type: image/jpeg
<binary image data>
------FormBoundary
Content-Disposition: form-data; name="carrier"; filename="carrier.png"
Content-Type: image/png
<binary image data>
------FormBoundary--
```
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message` | string | ✓ | Message to encode |
| `reference_photo` | file | ✓ | Reference photo file |
| `carrier` | file | ✓ | Carrier image file |
| `day_phrase` | string | ✓ | Today's passphrase |
| `pin` | string | * | Static PIN |
| `rsa_key` | file | * | RSA key file (.pem) |
| `rsa_password` | string | | Password for RSA key |
| `date_str` | string | | Date override (YYYY-MM-DD) |
\* At least one of `pin` or `rsa_key` required.
#### Response
Returns the PNG image directly with headers:
- `Content-Type: image/png`
- `Content-Disposition: attachment; filename=<generated_filename>.png`
- `X-Stegasoo-Date: 2025-12-27` (date used for encoding)
- `X-Stegasoo-Day: Saturday` (day of week for passphrase rotation)
- `X-Stegasoo-Capacity-Percent: 12.4` (capacity used)
#### cURL Examples
**With PIN:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret message" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
**With RSA key:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Secret message" \
-F "day_phrase=apple forest thunder" \
-F "rsa_key=@mykey.pem" \
-F "rsa_password=keypassword" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
**With both PIN and RSA:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Maximum security message" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "rsa_key=@mykey.pem" \
-F "rsa_password=keypassword" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
**With custom date:**
```bash
curl -X POST http://localhost:8000/encode/multipart \
-F "message=Backdated message" \
-F "day_phrase=monday phrase here" \
-F "pin=123456" \
-F "date_str=2025-12-29" \
-F "reference_photo=@reference.jpg" \
-F "carrier=@carrier.png" \
--output stego.png
```
---
### POST /decode (JSON)
Decode a message using base64-encoded images.
#### Request
```http
POST /decode HTTP/1.1
Host: localhost:8000
Content-Type: application/json
```
#### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stego_image_base64` | string | ✓ | Base64-encoded stego image |
| `reference_photo_base64` | string | ✓ | Base64-encoded reference photo |
| `day_phrase` | string | ✓ | Passphrase for encoding day |
| `pin` | string | * | Static PIN |
| `rsa_key_base64` | string | * | Base64-encoded RSA key |
| `rsa_password` | string | | Password for RSA key |
\* Must match the security factors used during encoding.
#### Response
```json
{
"message": "Secret message here"
}
```
#### cURL Example
```bash
# Prepare base64-encoded images
STEGO_B64=$(base64 -w0 stego.png)
REF_B64=$(base64 -w0 reference.jpg)
curl -X POST http://localhost:8000/decode \
-H "Content-Type: application/json" \
-d "{
\"stego_image_base64\": \"$STEGO_B64\",
\"reference_photo_base64\": \"$REF_B64\",
\"day_phrase\": \"apple forest thunder\",
\"pin\": \"123456\"
}"
```
---
### POST /decode/multipart
Decode a message using direct file uploads.
#### Request
```http
POST /decode/multipart HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data
```
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stego_image` | file | ✓ | Stego image file |
| `reference_photo` | file | ✓ | Reference photo file |
| `day_phrase` | string | ✓ | Passphrase for encoding day |
| `pin` | string | * | Static PIN |
| `rsa_key` | file | * | RSA key file |
| `rsa_password` | string | | Password for RSA key |
#### Response
```json
{
"message": "Secret message here"
}
```
#### cURL Examples
**With PIN:**
```bash
curl -X POST http://localhost:8000/decode/multipart \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@reference.jpg" \
-F "stego_image=@stego.png"
```
**With RSA key:**
```bash
curl -X POST http://localhost:8000/decode/multipart \
-F "day_phrase=apple forest thunder" \
-F "rsa_key=@mykey.pem" \
-F "rsa_password=keypassword" \
-F "reference_photo=@reference.jpg" \
-F "stego_image=@stego.png"
```
---
### POST /image/info
Get information about an image's capacity.
#### Request
```http
POST /image/info HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data
```
#### Form Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `image` | file | ✓ | Image file to analyze |
#### Response
```json
{
"width": 1920,
"height": 1080,
"pixels": 2073600,
"capacity_bytes": 776970,
"capacity_kb": 758
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `width` | integer | Image width in pixels |
| `height` | integer | Image height in pixels |
| `pixels` | integer | Total pixel count |
| `capacity_bytes` | integer | Maximum message capacity (bytes) |
| `capacity_kb` | integer | Maximum message capacity (KB) |
#### cURL Example
```bash
curl -X POST http://localhost:8000/image/info \
-F "image=@myimage.png"
```
---
## Data Models
### GenerateRequest
```json
{
"use_pin": true,
"use_rsa": false,
"pin_length": 6,
"rsa_bits": 2048,
"words_per_phrase": 3
}
```
### GenerateResponse
```json
{
"phrases": {"Monday": "...", "Tuesday": "...", ...},
"pin": "123456",
"rsa_key_pem": "-----BEGIN PRIVATE KEY-----...",
"entropy": {"phrase": 33, "pin": 19, "rsa": 0, "total": 52}
}
```
### EncodeRequest
```json
{
"message": "string",
"reference_photo_base64": "string",
"carrier_image_base64": "string",
"day_phrase": "string",
"pin": "string",
"rsa_key_base64": "string",
"rsa_password": "string",
"date_str": "YYYY-MM-DD"
}
```
### EncodeResponse
```json
{
"stego_image_base64": "string",
"filename": "string",
"capacity_used_percent": 12.4,
"date_used": "YYYY-MM-DD",
"day_of_week": "Saturday"
}
```
### DecodeRequest
```json
{
"stego_image_base64": "string",
"reference_photo_base64": "string",
"day_phrase": "string",
"pin": "string",
"rsa_key_base64": "string",
"rsa_password": "string"
}
```
### DecodeResponse
```json
{
"message": "string"
}
```
### ImageInfoResponse
```json
{
"width": 1920,
"height": 1080,
"pixels": 2073600,
"capacity_bytes": 776970,
"capacity_kb": 758
}
```
### ErrorResponse
```json
{
"error": "ErrorType",
"detail": "Error description"
}
```
---
## Error Handling
### HTTP Status Codes
| Code | Meaning | Use Case |
|------|---------|----------|
| 200 | OK | Successful operation |
| 400 | Bad Request | Invalid input, capacity error |
| 401 | Unauthorized | Decryption failed (wrong credentials) |
| 500 | Internal Error | Unexpected server error |
### Error Response Format
```json
{
"detail": "Error message describing the problem"
}
```
### Common Errors
| Status | Error | Solution |
|--------|-------|----------|
| 400 | "Must enable at least one of use_pin or use_rsa" | Set `use_pin` or `use_rsa` to true |
| 400 | "rsa_bits must be one of [2048, 3072, 4096]" | Use valid RSA key size |
| 400 | "Carrier image too small" | Use larger carrier image |
| 400 | "PIN must be 6-9 digits" | Fix PIN format |
| 401 | "Decryption failed. Check credentials." | Verify phrase, PIN, ref photo |
| 400 | "Message too long" | Reduce message size or use larger carrier |
---
## Code Examples
### Python with requests
```python
import base64
import requests
BASE_URL = "http://localhost:8000"
# Generate credentials
response = requests.post(f"{BASE_URL}/generate", json={
"use_pin": True,
"use_rsa": False,
"words_per_phrase": 3
})
creds = response.json()
print(f"PIN: {creds['pin']}")
print(f"Monday phrase: {creds['phrases']['Monday']}")
# Encode using multipart
with open("reference.jpg", "rb") as ref, open("carrier.png", "rb") as carrier:
response = requests.post(f"{BASE_URL}/encode/multipart", files={
"reference_photo": ref,
"carrier": carrier,
}, data={
"message": "Secret message",
"day_phrase": "apple forest thunder",
"pin": "123456"
})
with open("stego.png", "wb") as f:
f.write(response.content)
# Decode using multipart
with open("reference.jpg", "rb") as ref, open("stego.png", "rb") as stego:
response = requests.post(f"{BASE_URL}/decode/multipart", files={
"reference_photo": ref,
"stego_image": stego,
}, data={
"day_phrase": "apple forest thunder",
"pin": "123456"
})
print(f"Decoded: {response.json()['message']}")
```
### JavaScript/Node.js
```javascript
const FormData = require('form-data');
const fs = require('fs');
const axios = require('axios');
const BASE_URL = 'http://localhost:8000';
async function encode() {
const form = new FormData();
form.append('message', 'Secret message');
form.append('day_phrase', 'apple forest thunder');
form.append('pin', '123456');
form.append('reference_photo', fs.createReadStream('reference.jpg'));
form.append('carrier', fs.createReadStream('carrier.png'));
const response = await axios.post(`${BASE_URL}/encode/multipart`, form, {
headers: form.getHeaders(),
responseType: 'arraybuffer'
});
fs.writeFileSync('stego.png', response.data);
console.log('Encoded successfully');
}
async function decode() {
const form = new FormData();
form.append('day_phrase', 'apple forest thunder');
form.append('pin', '123456');
form.append('reference_photo', fs.createReadStream('reference.jpg'));
form.append('stego_image', fs.createReadStream('stego.png'));
const response = await axios.post(`${BASE_URL}/decode/multipart`, form, {
headers: form.getHeaders()
});
console.log('Decoded:', response.data.message);
}
encode().then(decode);
```
### Go
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
func main() {
// Encode
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("message", "Secret message")
writer.WriteField("day_phrase", "apple forest thunder")
writer.WriteField("pin", "123456")
ref, _ := os.Open("reference.jpg")
refPart, _ := writer.CreateFormFile("reference_photo", "reference.jpg")
io.Copy(refPart, ref)
ref.Close()
carrier, _ := os.Open("carrier.png")
carrierPart, _ := writer.CreateFormFile("carrier", "carrier.png")
io.Copy(carrierPart, carrier)
carrier.Close()
writer.Close()
resp, _ := http.Post(
"http://localhost:8000/encode/multipart",
writer.FormDataContentType(),
body,
)
stego, _ := os.Create("stego.png")
io.Copy(stego, resp.Body)
stego.Close()
resp.Body.Close()
fmt.Println("Encoded successfully")
}
```
### Shell Script (Bash)
```bash
#!/bin/bash
BASE_URL="http://localhost:8000"
REF_PHOTO="reference.jpg"
CARRIER="carrier.png"
PHRASE="apple forest thunder"
PIN="123456"
MESSAGE="Secret message"
# Encode
echo "Encoding..."
curl -s -X POST "$BASE_URL/encode/multipart" \
-F "message=$MESSAGE" \
-F "day_phrase=$PHRASE" \
-F "pin=$PIN" \
-F "reference_photo=@$REF_PHOTO" \
-F "carrier=@$CARRIER" \
--output stego.png
echo "Encoded to stego.png"
# Decode
echo "Decoding..."
DECODED=$(curl -s -X POST "$BASE_URL/decode/multipart" \
-F "day_phrase=$PHRASE" \
-F "pin=$PIN" \
-F "reference_photo=@$REF_PHOTO" \
-F "stego_image=@stego.png" | jq -r '.message')
echo "Decoded message: $DECODED"
```
---
## Rate Limiting
The API does not implement rate limiting by default. For production:
1. **Reverse Proxy**: Use nginx or Caddy rate limiting
2. **Application Level**: Add FastAPI middleware
Example nginx rate limiting:
```nginx
limit_req_zone $binary_remote_addr zone=stegasoo:10m rate=10r/s;
location /api/ {
limit_req zone=stegasoo burst=20 nodelay;
proxy_pass http://localhost:8000/;
}
```
---
## Security Considerations
### In Transit
- Use HTTPS in production
- Configure TLS at reverse proxy level
### Memory Usage
- Argon2id requires 256MB RAM per operation
- Concurrent requests can exhaust memory
- Limit workers based on available RAM
### Input Validation
The API validates:
- PIN format (6-9 digits, no leading zero)
- Message size (max 50KB)
- Image size (max 5MB file, ~4MP dimensions)
- RSA key validity
### Credential Handling
- Credentials are never logged
- No persistent storage of secrets
- Memory cleared after operations
---
## Interactive Documentation
When the API is running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [Web UI Documentation](WEB_UI.md) - Browser interface
- [README](README.md) - Project overview
- Image size (max 5MB file, ~4MP dimensions)
- RSA key validity
### Credential Handling
- Credentials are never logged
- No persistent storage of secrets
- Memory cleared after operations
---
## Interactive Documentation
When the API is running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [Web UI Documentation](WEB_UI.md) - Browser interface
- [README](README.md) - Project overview

View File

@@ -1,634 +0,0 @@
# Stegasoo CLI Documentation
Complete command-line interface reference for Stegasoo steganography operations.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Commands](#commands)
- [generate](#generate-command)
- [encode](#encode-command)
- [decode](#decode-command)
- [info](#info-command)
- [Security Factors](#security-factors)
- [Workflow Examples](#workflow-examples)
- [Piping & Scripting](#piping--scripting)
- [Error Handling](#error-handling)
- [Exit Codes](#exit-codes)
---
## Installation
### From PyPI
```bash
# CLI only
pip install stegasoo[cli]
# With all extras
pip install stegasoo[all]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[cli]"
```
### Verify Installation
```bash
stegasoo --version
stegasoo --help
```
---
## Quick Start
```bash
# 1. Generate credentials (do this once, memorize results)
stegasoo generate --pin --words 3
# 2. Encode a message
stegasoo encode \
--ref secret_photo.jpg \
--carrier meme.png \
--phrase "apple forest thunder" \
--pin 123456 \
--message "Meet at midnight"
# 3. Decode a message
stegasoo decode \
--ref secret_photo.jpg \
--stego stego_abc123_20251227.png \
--phrase "apple forest thunder" \
--pin 123456
```
---
## Commands
### Generate Command
Generate credentials for encoding/decoding operations. Creates daily passphrases and optionally a PIN and/or RSA key.
#### Synopsis
```bash
stegasoo generate [OPTIONS]
```
#### Options
| Option | Short | Type | Default | Description |
|--------|-------|------|---------|-------------|
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
| `--words` | | 3-12 | 3 | Words per daily phrase |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
| `--json` | | flag | | Output as JSON |
#### Examples
**Basic generation with PIN (default):**
```bash
stegasoo generate
```
Output:
```
════════════════════════════════════════════════════════════
STEGASOO CREDENTIALS
════════════════════════════════════════════════════════════
⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW
Do not screenshot or save to file!
─── STATIC PIN ───
847293
─── DAILY PHRASES ───
Monday │ abandon ability able
Tuesday │ actor actress actual
Wednesday │ advice aerobic affair
Thursday │ afraid again age
Friday │ agree ahead aim
Saturday │ airport aisle alarm
Sunday │ album alcohol alert
─── SECURITY ───
Phrase entropy: 33 bits
PIN entropy: 19 bits
Combined: 52 bits
+ photo entropy: 80-256 bits
```
**Generate with RSA key:**
```bash
stegasoo generate --rsa --rsa-bits 4096
```
**Save RSA key to encrypted file:**
```bash
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
```
**Maximum security (longer phrases + both factors):**
```bash
stegasoo generate --pin --rsa --words 6 --pin-length 9
```
**JSON output for scripting:**
```bash
stegasoo generate --json | jq '.phrases.Monday'
```
**RSA only (no PIN):**
```bash
stegasoo generate --no-pin --rsa -o key.pem -p "password123"
```
---
### Encode Command
Encode a secret message into an image using steganography.
#### Synopsis
```bash
stegasoo encode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Description |
|--------|-------|------|----------|-------------|
| `--ref` | `-r` | path | ✓ | Reference photo (shared secret) |
| `--carrier` | `-c` | path | ✓ | Carrier image to hide message in |
| `--phrase` | `-p` | string | ✓ | Today's passphrase |
| `--message` | `-m` | string | | Message to encode |
| `--message-file` | `-f` | path | | Read message from file |
| `--pin` | | string | * | Static PIN (6-9 digits) |
| `--key` | `-k` | path | * | RSA key file |
| `--key-password` | | string | | Password for RSA key |
| `--output` | `-o` | path | | Output filename |
| `--date` | | YYYY-MM-DD | | Date override |
| `--quiet` | `-q` | flag | | Suppress output |
\* At least one of `--pin` or `--key` is required.
#### Message Input Methods
1. **Command line argument:**
```bash
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "Secret message"
```
2. **From file:**
```bash
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -f message.txt
```
3. **From stdin (pipe):**
```bash
echo "Secret message" | stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456
```
#### Examples
**Basic encoding with PIN:**
```bash
stegasoo encode \
--ref photos/vacation.jpg \
--carrier memes/funny_cat.png \
--phrase "correct horse battery" \
--pin 847293 \
--message "The package arrives Tuesday"
```
Output:
```
✓ Encoded successfully!
Output: a1b2c3d4_20251227.png
Size: 245,832 bytes
Capacity used: 12.4%
Date: 2025-12-27
```
**With RSA key:**
```bash
stegasoo encode \
-r reference.jpg \
-c carrier.png \
-p "apple forest thunder" \
-k mykey.pem \
--key-password "secretpassword" \
-m "Encrypted with RSA"
```
**Both PIN and RSA (maximum security):**
```bash
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "word1 word2 word3" \
--pin 123456 \
-k mykey.pem \
--key-password "pass" \
-m "Double-locked message"
```
**Custom output filename:**
```bash
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "phrase words here" \
--pin 123456 \
-m "Message" \
-o holiday_photo.png
```
**Encoding with specific date (for testing):**
```bash
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "monday phrase here" \
--pin 123456 \
-m "Message" \
--date 2025-12-29
```
**Long message from file:**
```bash
stegasoo encode \
-r ref.jpg \
-c large_image.png \
-p "phrase" \
--pin 123456 \
-f secret_document.txt \
-o output.png
```
**Quiet mode for scripting:**
```bash
stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -m "msg" -q -o out.png
# No output, just creates the file
```
---
### Decode Command
Decode a secret message from a stego image.
#### Synopsis
```bash
stegasoo decode [OPTIONS]
```
#### Options
| Option | Short | Type | Required | Description |
|--------|-------|------|----------|-------------|
| `--ref` | `-r` | path | ✓ | Reference photo (same as encoding) |
| `--stego` | `-s` | path | ✓ | Stego image to decode |
| `--phrase` | `-p` | string | ✓ | Passphrase for the encoding day |
| `--pin` | | string | * | Static PIN |
| `--key` | `-k` | path | * | RSA key file |
| `--key-password` | | string | | Password for RSA key |
| `--output` | `-o` | path | | Save message to file |
| `--quiet` | `-q` | flag | | Output only the message |
\* Must provide the same security factors used during encoding.
#### Examples
**Basic decoding with PIN:**
```bash
stegasoo decode \
--ref photos/vacation.jpg \
--stego received_image.png \
--phrase "correct horse battery" \
--pin 847293
```
Output:
```
✓ Decoded successfully!
The package arrives Tuesday
```
**With RSA key:**
```bash
stegasoo decode \
-r reference.jpg \
-s stego_image.png \
-p "apple forest thunder" \
-k mykey.pem \
--key-password "secretpassword"
```
**Save decoded message to file:**
```bash
stegasoo decode \
-r ref.jpg \
-s stego.png \
-p "phrase" \
--pin 123456 \
-o decoded_message.txt
```
Output:
```
✓ Decoded successfully!
Saved to: decoded_message.txt
```
**Quiet mode (message only):**
```bash
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q
```
Output:
```
The package arrives Tuesday
```
**Pipe to another command:**
```bash
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | gpg --decrypt
```
---
### Info Command
Display information about an image's capacity and embedded date.
#### Synopsis
```bash
stegasoo info IMAGE
```
#### Arguments
| Argument | Type | Description |
|----------|------|-------------|
| `IMAGE` | path | Path to image file |
#### Examples
**Check carrier image capacity:**
```bash
stegasoo info vacation_photo.png
```
Output:
```
Image: vacation_photo.png
Dimensions: 1920 × 1080
Pixels: 2,073,600
Mode: RGB
Format: PNG
Capacity: ~776,970 bytes (758 KB)
```
**Check stego image (shows encoding date):**
```bash
stegasoo info stego_a1b2c3d4_20251227.png
```
Output:
```
Image: stego_a1b2c3d4_20251227.png
Dimensions: 1920 × 1080
Pixels: 2,073,600
Mode: RGB
Format: PNG
Capacity: ~776,970 bytes (758 KB)
Embed date: 2025-12-27 (Saturday)
```
---
## Security Factors
Stegasoo uses multiple authentication factors:
| Factor | Description | Entropy |
|--------|-------------|---------|
| Reference Photo | A photo both parties have | ~80-256 bits |
| Day Phrase | Changes daily (e.g., 3 BIP-39 words) | ~33 bits (3 words) |
| Static PIN | Same every day (6-9 digits) | ~20 bits (6 digits) |
| RSA Key | Shared key file | ~128 bits effective |
### Minimum Requirements
- At least one of PIN or RSA key must be provided
- Reference photo is always required
- Day phrase is always required
### Security Configurations
| Configuration | Entropy (excl. photo) | Use Case |
|--------------|----------------------|----------|
| 3-word phrase + 6-digit PIN | ~53 bits | Casual use |
| 6-word phrase + 9-digit PIN | ~96 bits | Standard security |
| 3-word phrase + RSA 2048 | ~161 bits | File-based auth |
| 6-word phrase + PIN + RSA | ~224 bits | Maximum security |
---
## Workflow Examples
### Daily Secure Communication
**Setup (once):**
```bash
# Both parties generate same credentials
stegasoo generate --pin --words 3
# Or share RSA key securely
stegasoo generate --rsa -o shared_key.pem -p "agreedpassword"
# Securely transfer shared_key.pem to recipient
```
**Sender (daily):**
```bash
# Get today's phrase from your memorized list
TODAY_PHRASE="monday phrase words"
# Encode message
stegasoo encode \
-r our_shared_photo.jpg \
-c random_meme.png \
-p "$TODAY_PHRASE" \
--pin 847293 \
-m "Meeting moved to 3pm"
# Share output image via normal channels (email, chat, etc.)
```
**Recipient (daily):**
```bash
# Use the phrase for the day the message was SENT
stegasoo decode \
-r our_shared_photo.jpg \
-s received_image.png \
-p "monday phrase words" \
--pin 847293
```
### Batch Processing
**Encode multiple messages:**
```bash
#!/bin/bash
PHRASE="apple forest thunder"
PIN="123456"
REF="reference.jpg"
for file in messages/*.txt; do
name=$(basename "$file" .txt)
stegasoo encode \
-r "$REF" \
-c "carriers/${name}.png" \
-p "$PHRASE" \
--pin "$PIN" \
-f "$file" \
-o "output/${name}_stego.png" \
-q
echo "Encoded: $name"
done
```
### Archive with Date Preservation
```bash
# Encode with specific date for archival
stegasoo encode \
-r ref.jpg \
-c carrier.png \
-p "archive phrase words" \
--pin 123456 \
-m "Historical record" \
--date 2025-01-15 \
-o archive_2025-01-15.png
```
---
## Piping & Scripting
### Stdin/Stdout Support
**Encode from pipe:**
```bash
cat secret.txt | stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456 -o out.png
```
**Decode to pipe:**
```bash
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | less
```
**Chain with encryption:**
```bash
# Encode GPG-encrypted content
gpg -e -r recipient@email.com secret.txt
cat secret.txt.gpg | base64 | stegasoo encode -r ref.jpg -c carrier.png -p "phrase" --pin 123456
# Decode and decrypt
stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q | base64 -d | gpg -d
```
### JSON Output for Scripts
```bash
# Get credentials as JSON
creds=$(stegasoo generate --json)
# Extract specific fields
pin=$(echo "$creds" | jq -r '.pin')
monday=$(echo "$creds" | jq -r '.phrases.Monday')
entropy=$(echo "$creds" | jq -r '.entropy.total')
echo "PIN: $pin"
echo "Monday phrase: $monday"
echo "Total entropy: $entropy bits"
```
### Error Handling in Scripts
```bash
#!/bin/bash
set -e
if ! stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456 -q 2>/dev/null; then
echo "Decryption failed - check credentials"
exit 1
fi
```
---
## Error Handling
### Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| "Must provide --pin or --key" | No security factor given | Add `--pin` or `--key` option |
| "PIN must be 6-9 digits" | Invalid PIN format | Use numeric PIN, 6-9 chars |
| "PIN cannot start with zero" | Leading zero in PIN | Use PIN starting with 1-9 |
| "Carrier image too small" | Message exceeds capacity | Use larger carrier image |
| "Decryption failed" | Wrong credentials | Verify phrase, PIN, ref photo |
| "RSA key is password-protected" | Missing key password | Add `--key-password` option |
### Troubleshooting Decryption Failures
1. **Check the encoding date:** The filename often contains the date (e.g., `_20251227`)
2. **Use correct phrase:** The phrase must match the day the message was encoded, not today
3. **Verify reference photo:** Must be the exact same file, not a resized copy
4. **Check stego image:** Ensure it wasn't resized, recompressed, or converted
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error |
| 2 | Invalid arguments/options |
---
## Environment Variables
| Variable | Description |
|----------|-------------|
| `PYTHONPATH` | Include `src/` for development |
---
## See Also
- [API Documentation](API.md) - REST API reference
- [Web UI Documentation](WEB_UI.md) - Browser interface guide
- [README](README.md) - Project overview and security model

View File

@@ -1,739 +0,0 @@
# Stegasoo Web UI Documentation
Complete guide for the Stegasoo web-based steganography interface.
## Table of Contents
- [Overview](#overview)
- [Installation & Setup](#installation--setup)
- [Pages & Features](#pages--features)
- [Home Page](#home-page)
- [Generate Credentials](#generate-credentials)
- [Encode Message](#encode-message)
- [Decode Message](#decode-message)
- [About Page](#about-page)
- [User Interface Guide](#user-interface-guide)
- [Workflow Examples](#workflow-examples)
- [Security Features](#security-features)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Mobile Support](#mobile-support)
---
## Overview
The Stegasoo Web UI provides a user-friendly browser-based interface for:
- **Generating** secure credentials (phrases, PINs, RSA keys)
- **Encoding** secret messages into images
- **Decoding** hidden messages from images
- **Learning** about the security model
Built with Flask, Bootstrap 5, and a modern dark theme.
### Features
- ✅ Drag-and-drop file uploads
- ✅ Image previews
- ✅ Client-side date detection
- ✅ Native sharing (Web Share API)
- ✅ Responsive design (mobile-friendly)
- ✅ Password-protected RSA key downloads
- ✅ Real-time entropy calculations
- ✅ Automatic file cleanup
---
## Installation & Setup
### From PyPI
```bash
pip install stegasoo[web]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
pip install -e ".[web]"
```
### Running the Server
**Development:**
```bash
cd frontends/web
python app.py
```
Server starts at http://localhost:5000
**Production with Gunicorn:**
```bash
cd frontends/web
gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
```
**Docker:**
```bash
docker-compose up web
```
### First-Time Setup
1. Navigate to http://localhost:5000
2. Click "Generate" to create your credentials
3. **Memorize** your phrases and PIN
4. Share credentials securely with your communication partner
---
## Pages & Features
### Home Page
**URL:** `/`
The landing page introduces Stegasoo and provides quick access to all features.
#### Main Actions
| Card | Description | Link |
|------|-------------|------|
| **Encode Message** | Hide a secret in an image | `/encode` |
| **Decode Message** | Extract a hidden message | `/decode` |
| **Generate Keys** | Create new credentials | `/generate` |
#### "How It Works" Section
Explains the three key components:
1. **Reference Photo** - Shared secret image
2. **Day Phrase** - Changes daily
3. **Static PIN** - Same every day
---
### Generate Credentials
**URL:** `/generate`
Create a new set of credentials for steganography operations.
#### Configuration Options
| Option | Range | Default | Description |
|--------|-------|---------|-------------|
| Words per phrase | 3-12 | 3 | BIP-39 words per daily phrase |
| Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair |
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
#### Entropy Calculator
The UI displays real-time entropy calculations:
```
Estimated entropy: ~53 bits
[==========> ] Good for most use cases
• Reference photo adds ~80-256 bits more
```
#### Generated Output
After clicking "Generate Credentials":
**Static PIN** (if enabled):
```
┌─────────────────────┐
│ 8 4 7 2 9 3 │
└─────────────────────┘
Use this 6-digit PIN every day
```
**Daily Phrases:**
```
Day │ Phrase
─────────────────────────────────────────
Monday │ abandon ability able
Tuesday │ actor actress actual
Wednesday │ advice aerobic affair
Thursday │ afraid again age
Friday │ agree ahead aim
Saturday │ airport aisle alarm
Sunday │ album alcohol alert
```
**RSA Key** (if enabled):
- Copy to clipboard button
- Download as password-protected .pem file
**Security Summary:**
```
Phrase entropy: 33 bits/phrase
PIN entropy: 19 bits/PIN
RSA entropy: 128 bits/RSA
─────────────────────────────
Total: 180 bits
+ reference photo (~80-256 bits) = 260+ bits combined
```
#### RSA Key Download
1. Click "Download as .pem"
2. Enter a password (minimum 8 characters)
3. Click "Download Protected Key"
4. Save the file securely
5. Share with your communication partner through a secure channel
---
### Encode Message
**URL:** `/encode`
Hide a secret message inside an image.
#### Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Reference Photo | Image file | ✓ | Your shared secret photo |
| Carrier Image | Image file | ✓ | Image to hide message in |
| Secret Message | Text | ✓ | Message to hide (max 50KB) |
| Day Phrase | Text | ✓ | Today's passphrase |
| PIN | Number | * | Your static PIN |
| RSA Key | .pem file | * | Your shared RSA key |
| RSA Key Password | Password | | Password for encrypted key |
\* At least one security factor (PIN or RSA Key) required.
#### Drag-and-Drop Upload
Both image upload zones support:
- Click to browse
- Drag and drop files
- Instant image preview
- File name display
#### Character Counter
```
Message: [ ]
1,234 / 50,000 characters 2%
```
Shows warning at 80% capacity.
#### Day Detection
The page automatically detects your local day of week and updates the label:
```
Saturday's Phrase: [ ]
```
#### Encoding Process
1. Fill in all required fields
2. Click "Encode Message"
3. Wait for processing (shows spinner)
4. Redirected to result page
#### Result Page
**URL:** `/encode/result/<file_id>`
After successful encoding:
```
┌────────────────────────────────────────┐
│ ✓ Message Encoded Successfully! │
│ │
│ 📄 a1b2c3d4_20251227.png │
│ Your secret message is hidden │
│ in this image │
│ │
│ [ Download Image ] │
│ [ Share Image ] │
│ │
│ ⚠️ File expires in 5 minutes. │
│ Download or share now. │
│ │
│ [ Encode Another Message ] │
└────────────────────────────────────────┘
```
**Share Options:**
1. **Native Share** (mobile/supported browsers):
- Uses Web Share API
- Opens system share sheet
- Can share directly to apps
2. **Fallback Share** (desktop):
- Email link
- Telegram link
- WhatsApp link
- Copy link to clipboard
---
### Decode Message
**URL:** `/decode`
Extract a hidden message from a stego image.
#### Input Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Reference Photo | Image file | ✓ | Same photo used for encoding |
| Stego Image | Image file | ✓ | Image containing hidden message |
| Day Phrase | Text | ✓ | Phrase for the **encoding** day |
| PIN | Number | * | Same PIN used for encoding |
| RSA Key | .pem file | * | Same RSA key used for encoding |
| RSA Key Password | Password | | Password for encrypted key |
\* Must match security factors used during encoding.
#### Date Detection from Filename
When you upload a stego image with a date in the filename (e.g., `stego_20251227.png`), the UI:
1. Extracts the date
2. Determines the day of week
3. Updates the phrase label: "Saturday's Phrase"
This helps you use the correct daily phrase.
#### Decoding Process
1. Fill in all required fields
2. Click "Decode Message"
3. Wait for processing
4. View decoded message on same page
#### Successful Decode
```
┌────────────────────────────────────────┐
│ ✓ Message Decrypted Successfully! │
│ │
│ Decoded Message: │
│ ┌──────────────────────────────────┐ │
│ │ Meet at midnight. The package │ │
│ │ will be under the bridge. │ │
│ └──────────────────────────────────┘ │
│ │
│ [ Decode Another Message ] │
└────────────────────────────────────────┘
```
#### Troubleshooting Tips
The page includes built-in troubleshooting guidance:
- ✓ Use the **exact same reference photo** file
- ✓ Use the phrase for the **encoding day**, not today
- ✓ Provide the **same security factors** used during encoding
- ✓ Ensure the stego image hasn't been **resized or recompressed**
- ✓ If using RSA key, verify the **password is correct**
---
### About Page
**URL:** `/about`
Learn about Stegasoo's security model and best practices.
#### Sections
**System Status:**
- Argon2id availability (vs PBKDF2 fallback)
- AES-256-GCM encryption status
**Security Model Table:**
| Component | Entropy | Purpose |
|-----------|---------|---------|
| Reference Photo | ~80-256 bits | Something you have |
| 3-Word Phrase | ~33 bits | Something you know (daily) |
| 6-Digit PIN | ~20 bits | Something you know (static) |
| Date | N/A | Automatic key rotation |
| **Combined** | **133+ bits** | **Beyond brute force** |
**Attack Resistance:**
What attackers can't do:
- Brute force (2^133 combinations)
- Use rainbow tables (random salt)
- Detect hidden data (random pixels)
- Use GPU farms (256MB RAM per attempt)
Real threats:
- Social engineering
- Physical device access
- Malware/keyloggers
- Shoulder surfing
**Best Practices:**
Do:
- Memorize phrases and PIN
- Use reference photo both parties have
- Use different carrier images each time
- Share stego images through normal channels
Don't:
- Transmit the reference photo
- Reuse carrier images
- Store credentials digitally
- Resize/recompress stego images
---
## User Interface Guide
### Navigation
The navbar provides quick access to all pages:
```
[Logo] Stegasoo Home | Encode | Decode | Generate | About
```
### Color Scheme
| Element | Color | Purpose |
|---------|-------|---------|
| Background | Dark gradient | Reduce eye strain |
| Cards | Semi-transparent | Visual hierarchy |
| Headers | Purple gradient | Brand identity |
| Success | Green | Positive actions |
| Warning | Yellow | Caution messages |
| Error | Red | Error states |
### Form Validation
- Real-time validation feedback
- Clear error messages in alerts
- Required field indicators
- Input constraints (max length, format)
### Loading States
During long operations:
- Button shows spinner
- Button text changes (e.g., "Encoding...")
- Button is disabled to prevent double-submit
### Flash Messages
```
┌──────────────────────────────────────────────┐
│ ✓ Credentials Generated! [×] │
└──────────────────────────────────────────────┘
```
Types:
- Success (green) - Operation completed
- Error (red) - Operation failed
- Warning (yellow) - Caution needed
---
## Workflow Examples
### First-Time Setup (Both Parties)
**Party A:**
1. Go to `/generate`
2. Configure: PIN ✓, 3 words, 6 digits
3. Click "Generate Credentials"
4. **Write down** phrases and PIN on paper
5. **Memorize** over the next few days
6. Destroy the paper
**Share with Party B (in person or secure channel):**
- The 7 daily phrases
- The PIN
- The reference photo file (if not already shared)
### Sending a Secret Message
1. Go to `/encode`
2. Upload your shared reference photo
3. Upload any carrier image (meme, vacation photo, etc.)
4. Type your secret message
5. Enter today's phrase (check your memory!)
6. Enter your PIN
7. Click "Encode Message"
8. Download or share the resulting image
9. Send via any channel (email, social media, chat)
### Receiving a Secret Message
1. Receive the stego image through any channel
2. Go to `/decode`
3. Upload the same reference photo
4. Upload the received stego image
5. Note the date in the filename (e.g., `_20251227`)
6. Enter the phrase for **that day** (not today!)
7. Enter the PIN
8. Click "Decode Message"
9. Read the secret message
### Changing Credentials
To rotate to new credentials:
1. Both parties generate new credentials together
2. Agree on a cutover date
3. Messages encoded before cutover use old credentials
4. Messages encoded after cutover use new credentials
---
## Security Features
### Client-Side Security
| Feature | Implementation |
|---------|----------------|
| Local date detection | JavaScript `Date()` object |
| No credential storage | Nothing saved in browser |
| Automatic cleanup | Files deleted after 5 minutes |
| HTTPS support | Configure at server level |
### Server-Side Security
| Feature | Implementation |
|---------|----------------|
| Memory-hard KDF | Argon2id (256MB RAM) |
| Authenticated encryption | AES-256-GCM |
| Random salt | Per-message salt |
| Temporary storage | In-memory, auto-expiring |
| Input validation | All inputs validated |
| File size limits | 5MB max upload |
### File Security
| Aspect | Protection |
|--------|------------|
| Upload location | `/tmp/stego_uploads` (Docker) |
| Storage duration | 5 minutes maximum |
| Access control | Random 16-byte file ID |
| Cleanup | Automatic + manual |
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONPATH` | - | Include `src/` for development |
### Application Limits
| Limit | Value | Config Location |
|-------|-------|-----------------|
| Max file upload | 5 MB | `app.config['MAX_CONTENT_LENGTH']` |
| File expiry | 5 minutes | `TEMP_FILE_EXPIRY` |
| Max image pixels | 4 MP | `stegasoo.constants` |
| Max message size | 50 KB | `stegasoo.constants` |
| PIN length | 6-9 digits | `stegasoo.constants` |
### Production Deployment
**With Gunicorn:**
```bash
gunicorn \
--bind 0.0.0.0:5000 \
--workers 2 \
--threads 4 \
--timeout 60 \
app:app
```
**Worker Calculation:**
- Each encode/decode uses ~256MB RAM (Argon2)
- Formula: `workers = (available_RAM - 512MB) / 256MB`
**With Nginx (reverse proxy):**
```nginx
server {
listen 80;
server_name stegasoo.example.com;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
}
```
**With Docker Compose:**
```yaml
services:
web:
build:
context: .
target: web
ports:
- "5000:5000"
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
```
---
## Troubleshooting
### Common Issues
#### "Decryption failed"
**Causes:**
- Wrong day phrase
- Wrong PIN
- Different reference photo
- Stego image was modified
**Solutions:**
1. Check the date in the stego filename
2. Use the phrase for that specific day
3. Verify you're using the original reference photo
4. Ensure the stego image wasn't resized/recompressed
#### "Carrier image too small"
**Cause:** Message too large for carrier capacity
**Solutions:**
1. Use a larger carrier image (more pixels)
2. Shorten the message
3. Check capacity with `/info` command (CLI)
#### "You must provide at least a PIN or RSA Key"
**Cause:** No security factor selected
**Solution:** Enter a PIN and/or upload an RSA key
#### Upload fails silently
**Causes:**
- File too large (>5MB)
- Invalid file type
- Browser issue
**Solutions:**
1. Reduce file size
2. Use PNG, JPG, or BMP formats
3. Try a different browser
#### RSA key password error
**Causes:**
- Wrong password
- Unencrypted key with password provided
- Corrupted key file
**Solutions:**
1. Verify the correct password
2. If key is unencrypted, leave password blank
3. Re-download or regenerate the key
### Browser Compatibility
| Browser | Status | Notes |
|---------|--------|-------|
| Chrome 80+ | ✓ Full | Web Share API supported |
| Firefox 80+ | ✓ Full | Limited Web Share |
| Safari 14+ | ✓ Full | Web Share on iOS |
| Edge 80+ | ✓ Full | Web Share API supported |
| IE 11 | ✗ None | Not supported |
### Performance Issues
**Slow encoding/decoding:**
- Normal: Argon2 is intentionally slow (security feature)
- Expected time: 2-5 seconds per operation
**High memory usage:**
- Normal: Argon2 requires 256MB RAM
- Configure worker count based on available RAM
---
## Mobile Support
### Responsive Design
The UI adapts to mobile screens:
- Single-column layout on small screens
- Touch-friendly buttons (48px minimum)
- Readable text without zooming
- Scrollable tables
### Mobile-Specific Features
**Native Sharing:**
On supported mobile browsers, the "Share Image" button opens the native share sheet, allowing you to share directly to:
- Messaging apps (iMessage, WhatsApp, Telegram)
- Social media (Instagram, Twitter)
- Email
- Other installed apps
**Camera Upload:**
File input accepts camera capture:
- Take a new photo as reference
- Capture carrier image directly
### PWA Support (Future)
The web app can be added to home screen on mobile devices for quick access.
---
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Tab` | Navigate between fields |
| `Enter` | Submit form (when focused) |
| `Esc` | Close modal/alert |
---
## Accessibility
| Feature | Implementation |
|---------|----------------|
| Screen readers | ARIA labels on interactive elements |
| Keyboard navigation | Full tab support |
| Color contrast | WCAG AA compliant |
| Focus indicators | Visible focus rings |
| Form labels | All inputs labeled |
---
## See Also
- [CLI Documentation](CLI.md) - Command-line interface
- [API Documentation](API.md) - REST API reference
- [README](README.md) - Project overview

View File

@@ -0,0 +1,500 @@
# API Update Summary for v3.2.0
## Overview
The FastAPI REST API has been updated to align with Stegasoo v3.2.0's breaking changes:
1. **Removed date dependency** - No `date_str` field in requests
2. **Renamed day_phrase → passphrase** - Updated all request/response models
3. **Updated generation** - Now generates single passphrase instead of daily phrases
## Breaking Changes
### Request Model Changes
#### 1. EncodeRequest & EncodeFileRequest
**Before (v3.1.0):**
```python
class EncodeRequest(BaseModel):
message: str
reference_photo_base64: str
carrier_image_base64: str
day_phrase: str # ← Changed to passphrase
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
date_str: Optional[str] = None # ← REMOVED
embed_mode: EmbedModeType = "lsb"
```
**After (v3.2.0):**
```python
class EncodeRequest(BaseModel):
message: str
reference_photo_base64: str
carrier_image_base64: str
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
# date_str removed in v3.2.0
embed_mode: EmbedModeType = "lsb"
dct_output_format: DctOutputFormatType = "png"
dct_color_mode: DctColorModeType = "grayscale"
```
#### 2. DecodeRequest
**Before (v3.1.0):**
```python
class DecodeRequest(BaseModel):
stego_image_base64: str
reference_photo_base64: str
day_phrase: str # ← Changed to passphrase
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
embed_mode: ExtractModeType = "auto"
```
**After (v3.2.0):**
```python
class DecodeRequest(BaseModel):
stego_image_base64: str
reference_photo_base64: str
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
embed_mode: ExtractModeType = "auto"
```
#### 3. GenerateRequest
**Before (v3.1.0):**
```python
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)
```
**After (v3.2.0):**
```python
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_passphrase: int = Field(
default=DEFAULT_PASSPHRASE_WORDS, # = 4, was 3
ge=MIN_PASSPHRASE_WORDS,
le=MAX_PASSPHRASE_WORDS,
description="Words per passphrase (v3.2.0: default increased to 4)"
)
```
### Response Model Changes
#### 1. GenerateResponse
**Before (v3.1.0):**
```python
class GenerateResponse(BaseModel):
phrases: dict[str, str] # Monday -> phrase, Tuesday -> phrase, etc.
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
entropy: dict[str, int]
```
**After (v3.2.0):**
```python
class GenerateResponse(BaseModel):
passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
entropy: dict[str, int]
# Legacy field for compatibility
phrases: Optional[dict[str, str]] = Field(
default=None,
description="Deprecated: Use 'passphrase' instead"
)
```
#### 2. EncodeResponse
**Before (v3.1.0):**
```python
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
capacity_used_percent: float
date_used: str
day_of_week: str
embed_mode: str
output_format: str = "png"
color_mode: str = "color"
```
**After (v3.2.0):**
```python
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
capacity_used_percent: float
embed_mode: str
output_format: str = "png"
color_mode: str = "color"
# Legacy fields (no longer used in crypto)
date_used: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
day_of_week: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
```
### Endpoint Changes
#### 1. POST /encode
**Before (v3.1.0):**
```json
{
"message": "Secret message",
"reference_photo_base64": "...",
"carrier_image_base64": "...",
"day_phrase": "apple forest thunder",
"date_str": "2025-01-15",
"pin": "123456",
"embed_mode": "lsb"
}
```
**After (v3.2.0):**
```json
{
"message": "Secret message",
"reference_photo_base64": "...",
"carrier_image_base64": "...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"embed_mode": "lsb"
}
```
#### 2. POST /decode
**Before (v3.1.0):**
```json
{
"stego_image_base64": "...",
"reference_photo_base64": "...",
"day_phrase": "apple forest thunder",
"pin": "123456",
"embed_mode": "auto"
}
```
**After (v3.2.0):**
```json
{
"stego_image_base64": "...",
"reference_photo_base64": "...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"embed_mode": "auto"
}
```
#### 3. POST /generate
**Response Before (v3.1.0):**
```json
{
"phrases": {
"Monday": "apple forest thunder",
"Tuesday": "banana river lightning",
...
},
"pin": "123456",
"rsa_key_pem": null,
"entropy": {
"phrase": 33,
"pin": 20,
"rsa": 0,
"total": 53
}
}
```
**Response After (v3.2.0):**
```json
{
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_pem": null,
"entropy": {
"passphrase": 44,
"pin": 20,
"rsa": 0,
"total": 64
},
"phrases": null
}
```
#### 4. POST /encode/multipart
**Form Fields Before (v3.1.0):**
- `day_phrase` (required)
- `date_str` (optional)
- `reference_photo` (file)
- `carrier` (file)
- ...
**Form Fields After (v3.2.0):**
- `passphrase` (required) ← renamed from day_phrase
- `reference_photo` (file)
- `carrier` (file)
- ... (date_str removed)
**Response Headers Before (v3.1.0):**
```
X-Stegasoo-Date: 2025-01-15
X-Stegasoo-Day: Wednesday
X-Stegasoo-Capacity-Percent: 25.5
X-Stegasoo-Embed-Mode: lsb
```
**Response Headers After (v3.2.0):**
```
X-Stegasoo-Capacity-Percent: 25.5
X-Stegasoo-Embed-Mode: lsb
X-Stegasoo-Output-Format: png
X-Stegasoo-Color-Mode: color
X-Stegasoo-Version: 3.2.0
```
### New Status Endpoint Information
#### GET /
**Added to response:**
```json
{
"version": "3.2.0",
...
"breaking_changes": {
"date_removed": "No date_str parameter needed - encode/decode anytime",
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
"format_version": 4,
"backward_compatible": false
}
}
```
## Migration Guide for API Clients
### 1. Update Request Bodies
**Find and replace in client code:**
```javascript
// Before
{
day_phrase: "apple forest thunder",
date_str: "2025-01-15"
}
// After
{
passphrase: "apple forest thunder mountain"
}
```
### 2. Update Response Handling
**Before:**
```javascript
const response = await fetch('/encode', {
method: 'POST',
body: JSON.stringify({
message: "secret",
day_phrase: "words",
date_str: "2025-01-15",
...
})
});
const data = await response.json();
console.log(data.date_used); // "2025-01-15"
console.log(data.day_of_week); // "Wednesday"
```
**After:**
```javascript
const response = await fetch('/encode', {
method: 'POST',
body: JSON.stringify({
message: "secret",
passphrase: "longer words here now",
// date_str removed
...
})
});
const data = await response.json();
// date_used and day_of_week are null in v3.2.0
```
### 3. Update Generate Endpoint Usage
**Before:**
```javascript
const creds = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({ use_pin: true })
}).then(r => r.json());
// Use Monday's phrase
const mondayPhrase = creds.phrases['Monday'];
```
**After:**
```javascript
const creds = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({ use_pin: true })
}).then(r => r.json());
// Use single passphrase
const passphrase = creds.passphrase;
```
### 4. Update Multipart Requests
**Before (JavaScript fetch):**
```javascript
const formData = new FormData();
formData.append('day_phrase', 'apple forest thunder');
formData.append('date_str', '2025-01-15');
formData.append('reference_photo', refPhotoFile);
formData.append('carrier', carrierFile);
formData.append('message', 'secret');
formData.append('pin', '123456');
const response = await fetch('/encode/multipart', {
method: 'POST',
body: formData
});
```
**After (JavaScript fetch):**
```javascript
const formData = new FormData();
formData.append('passphrase', 'apple forest thunder mountain');
// date_str removed
formData.append('reference_photo', refPhotoFile);
formData.append('carrier', carrierFile);
formData.append('message', 'secret');
formData.append('pin', '123456');
const response = await fetch('/encode/multipart', {
method: 'POST',
body: formData
});
```
## Testing Checklist
### Endpoints to Test
- [ ] GET / - Returns v3.2.0 with breaking_changes info
- [ ] GET /modes - Returns mode information
- [ ] POST /generate - Returns single passphrase
- [ ] POST /encode - Works without date_str
- [ ] POST /encode/file - Works without date_str
- [ ] POST /decode - Works without date_str
- [ ] POST /encode/multipart - Accepts passphrase instead of day_phrase
- [ ] POST /decode/multipart - Accepts passphrase instead of day_phrase
- [ ] POST /compare - Still works
- [ ] POST /will-fit - Still works
- [ ] POST /image/info - Still works
- [ ] POST /extract-key-from-qr - Still works
### Validation Tests
- [ ] Reject requests with `day_phrase` field (should get validation error)
- [ ] Reject requests with `date_str` field (should be ignored or error)
- [ ] Accept requests with `passphrase` field
- [ ] Generate response includes `passphrase` field
- [ ] Generate response has `phrases` as null
- [ ] Encode response has `date_used` and `day_of_week` as null
- [ ] Multipart encode works with new field names
- [ ] Response headers updated correctly
## OpenAPI/Swagger Documentation
The FastAPI auto-generated documentation (/docs and /redoc) will automatically reflect the changes:
1. **Models updated** - Request/response schemas show new field names
2. **Descriptions updated** - Field descriptions mention v3.2.0 changes
3. **Examples updated** - Interactive API explorer uses new field names
Users can browse to `/docs` to see the updated API specification.
## Backward Compatibility
**Breaking Change:** API v3.2.0 is NOT backward compatible with v3.1.0
Clients using the old API will encounter:
1. **Validation errors** - Missing required `passphrase` field
2. **Unexpected responses** - `phrases` field will be null
3. **Changed behavior** - Date fields no longer populated
### Migration Timeline Recommendation
1. **Deploy v3.2.0 API** to staging
2. **Update client applications** to use new field names
3. **Test thoroughly** with staging API
4. **Deploy v3.2.0 API** to production
5. **Notify users** of breaking changes
Alternatively, run v3.1.0 and v3.2.0 APIs side-by-side on different paths:
- `/api/v3.1/` - Old API
- `/api/v3.2/` - New API
## Constants Updates
Used in validation:
```python
from stegasoo.constants import (
MIN_PASSPHRASE_WORDS, # = 3
MAX_PASSPHRASE_WORDS, # = 12
DEFAULT_PASSPHRASE_WORDS, # = 4 (increased from 3)
)
```
## Error Messages
All error messages updated:
- "day_phrase is required" → "passphrase is required"
- References to "phrase" now mean "passphrase"
## Implementation Status
✅ All request models updated
✅ All response models updated
✅ All endpoints updated
✅ Multipart endpoints updated
✅ Status endpoint shows breaking changes
✅ Constants imported correctly
✅ Error handling updated
✅ No references to day_phrase in user-facing text
✅ No date_str parameters accepted
Ready for deployment!

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1073
frontends/cli/main.py_old Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
# Subprocess Isolation for Stegasoo WebUI
This update runs encode/decode/compare operations in isolated subprocesses
to prevent jpegio/scipy crashes from taking down the Flask server.
## Files
- **app.py** - Updated Flask app using subprocess isolation
- **subprocess_stego.py** - Flask-side wrapper with clean API
- **stego_worker.py** - Subprocess script that does actual stegasoo operations
## Setup
1. Place all three files in your `webui/` directory (same level as templates/)
2. Make sure stego_worker.py is executable (optional):
```bash
chmod +x stego_worker.py
```
3. Run the Flask app:
```bash
python app.py
```
## How It Works
Instead of calling stegasoo functions directly in the Flask process:
```python
# OLD (crashes could kill Flask)
result = encode(...)
```
We now run them in subprocesses:
```python
# NEW (crashes only kill the subprocess)
result = subprocess_stego.encode(...)
```
If jpegio or scipy crashes due to memory corruption, only the subprocess
dies. Flask logs the error and continues running. The next request spawns
a fresh subprocess.
## Configuration
In `app.py`, you can adjust the timeout:
```python
subprocess_stego = SubprocessStego(timeout=180) # 3 minutes
```
Larger images may need longer timeouts.
## Troubleshooting
If you see "Worker script not found" errors, make sure `stego_worker.py`
is in the same directory as `app.py`.
If subprocess operations fail, check the Flask logs for error details.
The subprocess wrapper captures both stdout and stderr from the worker.

View File

@@ -0,0 +1,426 @@
# Web Frontend Update Summary for v3.2.0
## Overview
The Flask web frontend has been updated to align with Stegasoo v3.2.0's breaking changes:
1. **Removed date dependency** - No date selection or tracking in UI
2. **Renamed day_phrase → passphrase** - Updated all forms and templates
3. **Increased default words** - From 3 to 4 for better security
## Key Changes
### 1. Form Parameter Changes
#### Generate Page
**Before (v3.1.0):**
```python
words_per_phrase = int(request.form.get('words_per_phrase', 3))
# Generated daily phrases for all days of the week
```
**After (v3.2.0):**
```python
words_per_passphrase = int(request.form.get('words_per_passphrase', 4))
# Generates single passphrase
```
**Template variables changed:**
- `phrases``passphrase` (single string instead of dict)
- `words_per_phrase``words_per_passphrase`
- `phrase_entropy``passphrase_entropy`
- Removed `days` variable (no longer needed)
#### Encode Page
**Before (v3.1.0):**
```python
day_phrase = request.form.get('day_phrase', '')
client_date = request.form.get('client_date', '').strip()
day_of_week = get_today_day() # Used in template
encode_result = encode(
...,
day_phrase=day_phrase,
date_str=date_str,
...
)
```
**After (v3.2.0):**
```python
passphrase = request.form.get('passphrase', '')
# No client_date or day_of_week needed
encode_result = encode(
...,
passphrase=passphrase, # Renamed
# date_str removed
...
)
```
#### Decode Page
**Before (v3.1.0):**
```python
day_phrase = request.form.get('day_phrase', '')
stego_date = request.form.get('stego_date', '').strip()
decode_result = decode(
...,
day_phrase=day_phrase,
date_str=stego_date if stego_date else None,
...
)
```
**After (v3.2.0):**
```python
passphrase = request.form.get('passphrase', '')
# No stego_date needed
decode_result = decode(
...,
passphrase=passphrase, # Renamed
# date_str removed
...
)
```
### 2. Template Context Updates
**inject_globals() changes:**
**Added:**
```python
'min_passphrase_words': MIN_PASSPHRASE_WORDS,
'recommended_passphrase_words': RECOMMENDED_PASSPHRASE_WORDS,
'default_passphrase_words': DEFAULT_PASSPHRASE_WORDS,
```
**Used for:**
- Showing passphrase length requirements
- Default values in generate form
- Validation messages
### 3. Validation Updates
**Added passphrase validation:**
```python
from stegasoo import validate_passphrase
# In encode_page()
result = validate_passphrase(passphrase)
if not result.is_valid:
flash(result.error_message, 'error')
return ...
# Show warning if passphrase is short
if result.warning:
flash(result.warning, 'warning')
```
### 4. Error Message Updates
**Before:**
```python
flash('Day phrase is required', 'error')
flash('Decryption failed. Check your phrase, PIN...', 'error')
```
**After:**
```python
flash('Passphrase is required', 'error')
flash('Decryption failed. Check your passphrase, PIN...', 'error')
```
## Template Changes Needed
These Flask routes will need corresponding template updates:
### generate.html
**Changes needed:**
```html
<!-- Before -->
<label for="words_per_phrase">Words per phrase</label>
<input type="number" name="words_per_phrase" value="3">
{% if generated %}
<h3>Daily Phrases</h3>
{% for day in days %}
<tr>
<td>{{ day }}</td>
<td>{{ phrases[day] }}</td>
</tr>
{% endfor %}
{% endif %}
<!-- After -->
<label for="words_per_passphrase">Words per passphrase</label>
<input type="number" name="words_per_passphrase" value="{{ default_passphrase_words }}">
{% if generated %}
<h3>Passphrase</h3>
<div class="passphrase-display">
<code>{{ passphrase }}</code>
<p class="help-text">Use this passphrase to encode and decode messages (no date needed!)</p>
</div>
{% endif %}
```
**Entropy display:**
```html
<!-- Before -->
<li>Phrase entropy: {{ phrase_entropy }} bits</li>
<!-- After -->
<li>Passphrase entropy: {{ passphrase_entropy }} bits ({{ words_per_passphrase }} words)</li>
```
### encode.html
**Changes needed:**
```html
<!-- Before -->
<label for="day_phrase">Day Phrase</label>
<input type="text" name="day_phrase" required>
<label for="client_date">Encoding Date (Optional)</label>
<input type="date" name="client_date">
<p class="help-text">Defaults to today: {{ day_of_week }}</p>
<!-- After -->
<label for="passphrase">Passphrase</label>
<input type="text" name="passphrase" required
placeholder="Enter at least {{ recommended_passphrase_words }} words">
<p class="help-text">
v3.2.0: No date needed! Use your passphrase anytime.
</p>
```
### decode.html
**Changes needed:**
```html
<!-- Before -->
<label for="day_phrase">Day Phrase</label>
<input type="text" name="day_phrase" required>
<label for="stego_date">Encoding Date</label>
<input type="date" name="stego_date" id="stego_date">
<p class="help-text">Will be auto-detected from filename if possible</p>
<script>
// Auto-detect date from filename
stegoInput.addEventListener('change', function() {
const filename = this.files[0]?.name || '';
const dateMatch = filename.match(/_(\d{4})(\d{2})(\d{2})/);
if (dateMatch) {
document.getElementById('stego_date').value =
`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
}
});
</script>
<!-- After -->
<label for="passphrase">Passphrase</label>
<input type="text" name="passphrase" required
placeholder="Enter your passphrase">
<p class="help-text">
v3.2.0: No date needed to decode!
</p>
<!-- Remove date detection script -->
```
### index.html
**Changes needed:**
```html
<!-- Before -->
<p>Generate daily passphrases and security credentials</p>
<p>Hide messages using day-specific phrases</p>
<!-- After -->
<p>Generate passphrases and security credentials</p>
<p>v3.2.0: Simplified - no more daily rotation!</p>
```
### about.html
**Add v3.2.0 section:**
```html
<h2>Version 3.2.0 Changes</h2>
<ul>
<li><strong>No date dependency</strong> - Encode and decode anytime without tracking dates</li>
<li><strong>Single passphrase</strong> - No more daily rotation, just remember one strong passphrase</li>
<li><strong>Better security</strong> - Default passphrase length increased to 4 words</li>
<li><strong>Asynchronous ready</strong> - Perfect for dead drops and delayed delivery</li>
</ul>
```
## JavaScript Changes Needed
### Remove date-related code:
```javascript
// REMOVE THIS (date detection from filename)
function detectDateFromFilename(filename) {
const match = filename.match(/_(\d{4})(\d{2})(\d{2})/);
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`;
}
return null;
}
// REMOVE THIS (day-of-week display)
function updateDayOfWeek() {
const dateInput = document.getElementById('client_date');
const dayDisplay = document.getElementById('day_display');
// ...
}
```
### Update validation:
```javascript
// Before
const dayPhrase = document.getElementById('day_phrase').value;
if (!dayPhrase || dayPhrase.trim().length === 0) {
alert('Day phrase is required');
return false;
}
// After
const passphrase = document.getElementById('passphrase').value;
if (!passphrase || passphrase.trim().length === 0) {
alert('Passphrase is required');
return false;
}
// Add word count validation
const words = passphrase.trim().split(/\s+/);
if (words.length < {{ min_passphrase_words }}) {
alert(`Passphrase should have at least {{ recommended_passphrase_words }} words`);
return false;
}
```
## CSS Updates
Add styling for passphrase warnings:
```css
.passphrase-display {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.passphrase-display code {
font-size: 1.2em;
color: #2c3e50;
word-break: break-word;
}
.help-text.v3-2-0 {
color: #3498db;
font-weight: bold;
}
.flash.warning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
color: #856404;
}
```
## Migration Notes for Users
Add to templates:
```html
<div class="alert alert-info">
<h4>⚠️ v3.2.0 Breaking Changes</h4>
<p>If you have messages encoded with v3.1.0:</p>
<ul>
<li>They cannot be decoded with v3.2.0</li>
<li>You need the original v3.1.0 installation to decode them</li>
<li>After decoding, you can re-encode with v3.2.0</li>
</ul>
</div>
```
## Form Field Summary
### Changed Field Names
| Old Name (v3.1.0) | New Name (v3.2.0) | Type |
|-------------------|-------------------|------|
| `day_phrase` | `passphrase` | text input |
| `words_per_phrase` | `words_per_passphrase` | number input |
| `client_date` | (removed) | date input |
| `stego_date` | (removed) | date input |
### New Validation Attributes
```html
<input type="text" name="passphrase"
required
minlength="{{ min_passphrase_words * 4 }}"
placeholder="Enter at least {{ recommended_passphrase_words }} words"
pattern="^\s*\S+(\s+\S+){3,}.*$"
title="Please enter at least 4 words">
```
## Testing Checklist
- [ ] Generate page creates single passphrase
- [ ] Generate page shows correct entropy (4 words = 44 bits)
- [ ] Generate page doesn't show day names
- [ ] Encode page accepts passphrase (not day_phrase)
- [ ] Encode page doesn't have date selection
- [ ] Encode page shows v3.2.0 help text
- [ ] Decode page accepts passphrase
- [ ] Decode page doesn't have date input
- [ ] Decode page doesn't auto-detect date from filename
- [ ] Error messages say "passphrase" not "day phrase"
- [ ] Validation shows warnings for short passphrases
- [ ] QR code functionality still works
- [ ] DCT mode options still work
- [ ] All flash messages updated
## Implementation Status
✅ Flask routes updated
✅ Form parameter names changed
✅ Function calls updated
✅ Validation added for passphrases
✅ Error messages updated
✅ Template context updated
⏳ Templates need updating (generate.html, encode.html, decode.html, index.html, about.html)
⏳ JavaScript needs updating
⏳ CSS styling for v3.2.0 features
## Quick Reference
**To test the Flask app:**
```bash
cd frontends/web
python app.py
# Visit http://localhost:5000
```
**Key user-facing changes:**
1. Generate: Shows one passphrase, not 7 daily phrases
2. Encode: No date selection, just passphrase
3. Decode: No date needed, just passphrase
**Benefits to highlight:**
- ✅ Simpler UI (fewer fields)
- ✅ No date tracking needed
- ✅ Encode today, decode anytime
- ✅ Perfect for asynchronous communications

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,939 @@
/**
* Stegasoo Frontend JavaScript
* Shared functionality across encode, decode, and generate pages
*/
const Stegasoo = {
// ========================================================================
// PASSWORD/PIN VISIBILITY TOGGLES
// ========================================================================
initPasswordToggles() {
document.querySelectorAll('[data-toggle-password]').forEach(btn => {
btn.addEventListener('click', function() {
const targetId = this.dataset.togglePassword;
const input = document.getElementById(targetId);
const icon = this.querySelector('i');
if (!input) return;
if (input.type === 'password') {
input.type = 'text';
icon?.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon?.classList.replace('bi-eye-slash', 'bi-eye');
}
});
});
},
// ========================================================================
// RSA INPUT METHOD TOGGLE (File vs QR)
// ========================================================================
initRsaMethodToggle() {
const fileRadio = document.getElementById('rsaMethodFile');
const qrRadio = document.getElementById('rsaMethodQr');
const fileSection = document.getElementById('rsaFileSection');
const qrSection = document.getElementById('rsaQrSection');
if (!fileRadio || !qrRadio || !fileSection || !qrSection) return;
const update = () => {
const isFile = fileRadio.checked;
fileSection.classList.toggle('d-none', !isFile);
qrSection.classList.toggle('d-none', isFile);
};
fileRadio.addEventListener('change', update);
qrRadio.addEventListener('change', update);
},
// ========================================================================
// DROP ZONES (Drag & Drop + Preview)
// ========================================================================
initDropZones(options = {}) {
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
if (!input) return;
// Check if this is a special zone type
const isPayloadZone = zone.id === 'payloadDropZone';
const isCarrierZone = zone.id === 'carrierDropZone';
const isQrZone = zone.id === 'qrDropZone';
// Drag events
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.remove('drag-over');
});
});
// Drop handler
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
}
});
// Change handler for preview (skip payload and QR zones - they have special handling)
if (!isPayloadZone && !isQrZone) {
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
}
});
}
});
},
showImagePreview(file, previewEl, labelEl, zone = null) {
if (!file.type.startsWith('image/')) return;
const isScanContainer = zone && zone.classList.contains('scan-container');
const isPixelContainer = zone && zone.classList.contains('pixel-container');
const reader = new FileReader();
reader.onload = e => {
if (previewEl) {
previewEl.src = e.target.result;
previewEl.classList.remove('d-none');
}
// For scan/pixel containers, hide the label entirely (filename will appear in data panel)
if (labelEl) {
if (isScanContainer || isPixelContainer) {
labelEl.classList.add('d-none');
} else {
labelEl.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
}
}
// Trigger appropriate animation
if (isScanContainer) {
Stegasoo.triggerScanAnimation(zone, file);
} else if (isPixelContainer) {
Stegasoo.triggerPixelReveal(zone, file);
}
};
reader.readAsDataURL(file);
},
// ========================================================================
// REFERENCE PHOTO SCAN ANIMATION
// ========================================================================
triggerScanAnimation(container, file, duration = 700) {
// Reset any previous state
container.classList.remove('scan-complete');
container.classList.add('scanning');
const preview = container.querySelector('.drop-zone-preview');
// Create hash blocks for the "hashing" visual effect
const createHashBlocks = () => {
// Remove old hash blocks
const oldBlocks = container.querySelector('.hash-blocks');
if (oldBlocks) oldBlocks.remove();
const hashContainer = document.createElement('div');
hashContainer.className = 'hash-blocks';
// Size and position to match preview image exactly
const imgWidth = preview.offsetWidth;
const imgHeight = preview.offsetHeight;
const imgTop = preview.offsetTop;
const imgLeft = preview.offsetLeft;
hashContainer.style.width = imgWidth + 'px';
hashContainer.style.height = imgHeight + 'px';
hashContainer.style.top = imgTop + 'px';
hashContainer.style.left = imgLeft + 'px';
// Create grid of hash blocks (10x8 for better coverage)
const cols = 10;
const rows = 8;
hashContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
hashContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
// Create blocks with staggered delays for wave disappearance
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const block = document.createElement('div');
block.className = 'hash-block';
// Diagonal wave pattern for disappearance
const delay = (row + col) * 25 + Math.random() * 30;
block.style.animationDelay = delay + 'ms';
hashContainer.appendChild(block);
}
}
container.appendChild(hashContainer);
};
// Wait for image to be ready
if (preview.complete && preview.naturalWidth) {
createHashBlocks();
} else {
preview.onload = createHashBlocks;
}
// After animation duration, switch to complete state
setTimeout(() => {
container.classList.remove('scanning');
container.classList.add('scan-complete');
// Remove hash blocks container
const hashBlocks = container.querySelector('.hash-blocks');
if (hashBlocks) hashBlocks.remove();
// Populate data panel if file provided
if (file) {
const nameEl = container.querySelector('#refFileName') || container.querySelector('.scan-data-filename span');
const sizeEl = container.querySelector('#refFileSize') || container.querySelector('.scan-data-value');
const hashEl = container.querySelector('#refHashPreview') || container.querySelector('.scan-hash-preview');
if (nameEl) {
nameEl.textContent = file.name;
}
if (sizeEl) {
const sizeKB = (file.size / 1024).toFixed(1);
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
}
if (hashEl) {
// Generate a deterministic fake hash preview from filename + size
const fakeHash = Stegasoo.generateFakeHash(file.name + file.size);
hashEl.textContent = `SHA256: ${fakeHash.substring(0, 8)}····${fakeHash.substring(56)}`;
}
}
}, duration);
},
generateFakeHash(input) {
// Simple deterministic hash-like string for display purposes
let hash = '';
const chars = '0123456789abcdef';
let seed = 0;
for (let i = 0; i < input.length; i++) {
seed = ((seed << 5) - seed) + input.charCodeAt(i);
seed = seed & seed;
}
for (let i = 0; i < 64; i++) {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
hash += chars[seed % 16];
}
return hash;
},
// ========================================================================
// CARRIER/STEGO PIXEL REVEAL ANIMATION
// ========================================================================
triggerPixelReveal(container, file, duration = 700) {
// Reset any previous state
container.classList.remove('load-complete');
container.classList.add('loading');
const preview = container.querySelector('.drop-zone-preview');
// Create embed traces container sized to image
const createTraces = () => {
// Remove old elements
let tracesContainer = container.querySelector('.embed-traces');
if (tracesContainer) tracesContainer.remove();
let oldGrid = container.querySelector('.embed-grid');
if (oldGrid) oldGrid.remove();
// Add grid overlay (covers whole panel like ref does)
const grid = document.createElement('div');
grid.className = 'embed-grid';
container.appendChild(grid);
// Create traces container
tracesContainer = document.createElement('div');
tracesContainer.className = 'embed-traces';
container.appendChild(tracesContainer);
// Size and position traces to match preview image exactly
const imgWidth = preview.offsetWidth;
const imgHeight = preview.offsetHeight;
const imgTop = preview.offsetTop;
const imgLeft = preview.offsetLeft;
tracesContainer.style.width = imgWidth + 'px';
tracesContainer.style.height = imgHeight + 'px';
tracesContainer.style.top = imgTop + 'px';
tracesContainer.style.left = imgLeft + 'px';
// Generate Tron-style circuit traces covering the image
Stegasoo.generateEmbedTraces(tracesContainer, imgWidth, imgHeight);
};
// Wait for image to be ready
if (preview.complete && preview.naturalWidth) {
createTraces();
} else {
preview.onload = createTraces;
}
setTimeout(() => {
container.classList.remove('loading');
container.classList.add('load-complete');
// Remove traces and grid
const traces = container.querySelector('.embed-traces');
if (traces) traces.remove();
const grid = container.querySelector('.embed-grid');
if (grid) grid.remove();
// Populate data panel
Stegasoo.populatePixelDataPanel(container, file, preview);
}, duration);
},
generateEmbedTraces(container, width, height) {
// Color classes for variety
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
// Generate 6-8 snake paths spread across the whole image
const numPaths = 6 + Math.floor(Math.random() * 3);
for (let p = 0; p < numPaths; p++) {
// Each path gets a random color
const pathColor = colors[Math.floor(Math.random() * colors.length)];
// Distribute starting points across the image
let x = (width * 0.1) + (Math.random() * width * 0.8);
let y = (height * 0.1) + (Math.random() * height * 0.8);
let delay = p * 40;
// Each path has 3-5 segments for more coverage
const numSegments = 3 + Math.floor(Math.random() * 3);
let horizontal = Math.random() > 0.5;
for (let s = 0; s < numSegments; s++) {
const trace = document.createElement('div');
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
const length = 30 + Math.random() * 60;
trace.style.left = x + 'px';
trace.style.top = y + 'px';
trace.style.animationDelay = delay + 'ms';
if (horizontal) {
trace.style.width = length + 'px';
} else {
trace.style.height = length + 'px';
}
container.appendChild(trace);
// Move position for next segment
if (horizontal) {
x += length;
} else {
y += length;
}
// Wrap around if out of bounds to keep traces in view
if (x > width - 20) x = 10 + Math.random() * 40;
if (y > height - 20) y = 10 + Math.random() * 40;
if (x < 10) x = width - 60 + Math.random() * 40;
if (y < 10) y = height - 60 + Math.random() * 40;
// Alternate direction (90 degree turn)
horizontal = !horizontal;
delay += 30;
}
}
},
populatePixelDataPanel(container, file, preview) {
const nameEl = container.querySelector('.pixel-data-filename span');
const sizeEl = container.querySelector('.pixel-data-value');
const dimsEl = container.querySelector('.pixel-dimensions');
if (nameEl) {
nameEl.textContent = file.name;
}
if (sizeEl) {
const sizeKB = (file.size / 1024).toFixed(1);
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
}
if (dimsEl && preview) {
dimsEl.textContent = `${preview.naturalWidth} × ${preview.naturalHeight} px`;
}
},
initReferenceScanAnimation() {
// Find all scan containers and wire up their inputs
document.querySelectorAll('.scan-container').forEach(container => {
const input = container.querySelector('input[type="file"]');
const preview = container.querySelector('.drop-zone-preview');
const label = container.querySelector('.drop-zone-label');
if (!input) return;
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
Stegasoo.showImagePreview(this.files[0], preview, label, container);
}
});
});
},
// ========================================================================
// CLIPBOARD PASTE
// ========================================================================
initClipboardPaste(imageInputSelectors) {
document.addEventListener('paste', function(e) {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const blob = items[i].getAsFile();
// Find first empty input from the list
let targetInput = null;
for (const selector of imageInputSelectors) {
const input = document.querySelector(selector);
if (input && (!input.files || !input.files.length)) {
targetInput = input;
break;
}
}
// Fallback to first input if all have files
if (!targetInput) {
targetInput = document.querySelector(imageInputSelectors[0]);
}
if (targetInput) {
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
}
break;
}
}
});
},
// ========================================================================
// QR CODE CROP ANIMATION WITH SECTION SCANNING
// ========================================================================
initQrCropAnimation(inputId = 'rsaKeyQrInput') {
const input = document.getElementById(inputId);
const container = document.getElementById('qrCropContainer');
const original = document.getElementById('qrOriginal');
const cropped = document.getElementById('qrCropped');
const dropZone = document.getElementById('qrDropZone');
if (!input || !container || !original || !cropped) return;
input.addEventListener('change', function() {
if (!this.files || !this.files[0]) return;
const file = this.files[0];
if (!file.type.startsWith('image/')) return;
const label = dropZone?.querySelector('.drop-zone-label');
// Reset animation state
container.classList.remove('scan-complete', 'scanning');
container.classList.add('d-none');
// Remove old overlay if exists
const oldOverlay = container.querySelector('.qr-section-overlay');
if (oldOverlay) oldOverlay.remove();
// Show loading state immediately
container.classList.remove('d-none');
container.classList.add('loading');
label?.classList.add('d-none');
// Add loading indicator if not present
let loader = container.querySelector('.qr-loader');
if (!loader) {
loader = document.createElement('div');
loader.className = 'qr-loader';
loader.innerHTML = `
<i class="bi bi-qr-code-scan"></i>
<span>Detecting QR code...</span>
`;
container.appendChild(loader);
}
// Fetch cropped version
const formData = new FormData();
formData.append('image', file);
fetch('/qr/crop', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) throw new Error('No QR detected');
return response.blob();
})
.then(blob => {
// Hide loader, show cropped image
container.classList.remove('loading');
cropped.src = URL.createObjectURL(blob);
return new Promise((resolve) => {
cropped.onload = () => {
// Start scanning animation
container.classList.add('scanning');
// Add scanner overlay - will be positioned via CSS to cover the image
let overlay = container.querySelector('.qr-scanner-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'qr-scanner-overlay';
['tl', 'tr', 'bl', 'br'].forEach(pos => {
const bracket = document.createElement('div');
bracket.className = `qr-finder-bracket ${pos}`;
overlay.appendChild(bracket);
});
// Add data panel inside overlay
const dataPanel = document.createElement('div');
dataPanel.className = 'qr-data-panel';
dataPanel.innerHTML = `
<div class="qr-data-row">
<span class="qr-status-badge">KEY LOADED</span>
<span class="qr-data-value">--</span>
</div>
`;
overlay.appendChild(dataPanel);
container.appendChild(overlay);
}
// Let CSS handle overlay positioning (inset with padding)
resolve();
};
});
})
.then(() => {
// Now verify key extraction
const keyFormData = new FormData();
keyFormData.append('qr_image', file);
return fetch('/extract-key-from-qr', {
method: 'POST',
body: keyFormData
});
})
.then(response => response.json())
.then(data => {
// Extraction complete - stop animation
container.classList.remove('scanning');
container.classList.add('scan-complete');
// Update data panel (inside overlay)
const overlay = container.querySelector('.qr-scanner-overlay');
const sizeEl = overlay?.querySelector('.qr-data-value');
if (data.success && sizeEl) {
const sizeKB = (file.size / 1024).toFixed(1);
sizeEl.textContent = sizeKB + ' KB';
}
})
.catch(err => {
console.log('QR crop/extract error:', err);
container.classList.remove('loading', 'scanning');
container.classList.add('error');
// Update loader to show error
const loader = container.querySelector('.qr-loader');
if (loader) {
loader.innerHTML = `
<i class="bi bi-exclamation-triangle-fill"></i>
<span>No QR code detected</span>
`;
}
});
});
},
// ========================================================================
// COLLAPSE CHEVRON ANIMATION
// ========================================================================
initCollapseChevrons() {
document.querySelectorAll('[data-chevron]').forEach(collapse => {
const chevronId = collapse.dataset.chevron;
const chevron = document.getElementById(chevronId);
if (!chevron) return;
collapse.addEventListener('show.bs.collapse', () => {
chevron.classList.add('bi-chevron-up');
chevron.classList.remove('bi-chevron-down');
});
collapse.addEventListener('hide.bs.collapse', () => {
chevron.classList.remove('bi-chevron-up');
chevron.classList.add('bi-chevron-down');
});
});
},
// ========================================================================
// FORM LOADING STATE
// ========================================================================
initFormLoading(formId, buttonId, loadingText = 'Processing...') {
const form = document.getElementById(formId);
const btn = document.getElementById(buttonId);
if (!form || !btn) return;
form.addEventListener('submit', () => {
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${loadingText}`;
});
},
// ========================================================================
// COPY TO CLIPBOARD
// ========================================================================
copyToClipboard(text, iconEl, textEl) {
navigator.clipboard.writeText(text).then(() => {
const origIcon = iconEl?.className;
const origText = textEl?.textContent;
if (iconEl) iconEl.className = 'bi bi-check';
if (textEl) textEl.textContent = 'Copied!';
setTimeout(() => {
if (iconEl) iconEl.className = origIcon;
if (textEl) textEl.textContent = origText;
}, 2000);
});
},
// ========================================================================
// MODE CARD HIGHLIGHTING
// ========================================================================
initModeCards(config) {
// config: { radioName: 'embed_mode', cards: { 'lsb': { id: 'lsbCard', borderClass: 'border-primary' }, ... } }
const radios = document.querySelectorAll(`input[name="${config.radioName}"]`);
const update = () => {
radios.forEach(radio => {
const cardConfig = config.cards[radio.value];
if (!cardConfig) return;
const card = document.getElementById(cardConfig.id);
if (!card) return;
card.classList.toggle(cardConfig.borderClass, radio.checked);
card.classList.toggle('border-2', radio.checked);
});
};
radios.forEach(radio => radio.addEventListener('change', update));
update(); // Initial state
},
// ========================================================================
// PASSPHRASE FONT SIZE AUTO-ADJUST
// ========================================================================
initPassphraseFontResize(inputId = 'passphraseInput') {
const input = document.getElementById(inputId);
if (!input) return;
const steps = [
{ maxChars: 30, size: 1.1 },
{ maxChars: 45, size: 1.0 },
{ maxChars: 60, size: 0.95 },
{ maxChars: Infinity, size: 0.9 }
];
const adjust = () => {
const len = input.value.length;
for (const step of steps) {
if (len <= step.maxChars) {
input.style.fontSize = step.size + 'rem';
break;
}
}
};
input.addEventListener('input', adjust);
adjust();
},
// ========================================================================
// CHANNEL KEY HANDLING (v4.0.0)
// ========================================================================
/**
* Generate a random channel key in format XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
* @returns {string} Generated key
*/
generateChannelKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let key = '';
for (let i = 0; i < 8; i++) {
if (i > 0) key += '-';
for (let j = 0; j < 4; j++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
}
return key;
},
/**
* Validate channel key format
* @param {string} key - Key to validate
* @returns {boolean} True if valid
*/
validateChannelKey(key) {
const pattern = /^[A-Z0-9]{4}(-[A-Z0-9]{4}){7}$/;
return pattern.test(key);
},
/**
* Format channel key input (auto-add dashes, uppercase)
* @param {HTMLInputElement} input - Input element
*/
formatChannelKeyInput(input) {
let value = input.value.toUpperCase();
const clean = value.replace(/-/g, '');
if (clean.length > 0 && clean.length <= 32) {
const formatted = clean.match(/.{1,4}/g)?.join('-') || clean;
if (formatted !== value && formatted.length <= 39) {
input.value = formatted;
} else {
input.value = value;
}
}
// Validate and show/hide error state
const isValid = this.validateChannelKey(input.value);
input.classList.toggle('is-invalid', input.value.length > 0 && !isValid);
},
/**
* Initialize channel key UI for encode/decode pages
* @param {Object} config - Configuration object
* @param {string} config.selectId - ID of channel select dropdown
* @param {string} config.customInputId - ID of custom key input container
* @param {string} config.keyInputId - ID of key input field
* @param {string} config.generateBtnId - ID of generate button (optional)
*/
initChannelKey(config = {}) {
const selectId = config.selectId || 'channelSelect';
const customInputId = config.customInputId || 'channelCustomInput';
const keyInputId = config.keyInputId || 'channelKeyInput';
const generateBtnId = config.generateBtnId;
const select = document.getElementById(selectId);
const customInput = document.getElementById(customInputId);
const keyInput = document.getElementById(keyInputId);
const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null;
// Show/hide custom input based on selection
const updateVisibility = () => {
const isCustom = select?.value === 'custom';
customInput?.classList.toggle('d-none', !isCustom);
if (isCustom && keyInput) {
keyInput.focus();
}
};
select?.addEventListener('change', updateVisibility);
// Initial state
updateVisibility();
// Format and validate key input
keyInput?.addEventListener('input', () => {
this.formatChannelKeyInput(keyInput);
});
// Generate button (if present)
generateBtn?.addEventListener('click', () => {
if (keyInput) {
keyInput.value = this.generateChannelKey();
keyInput.classList.remove('is-invalid');
}
});
},
/**
* Handle form submission with channel key validation
* @param {HTMLFormElement} form - Form element
* @param {string} selectId - ID of channel select dropdown
* @param {string} keyInputId - ID of key input field
* @returns {boolean} True if valid, false to prevent submission
*/
validateChannelKeyOnSubmit(form, selectId, keyInputId) {
const select = document.getElementById(selectId);
const keyInput = document.getElementById(keyInputId);
if (select?.value === 'custom' && keyInput) {
if (!this.validateChannelKey(keyInput.value)) {
keyInput.classList.add('is-invalid');
keyInput.focus();
return false;
}
// Set the select value to the actual key for form submission
select.value = keyInput.value;
}
return true;
},
/**
* Initialize standalone channel key generator (for generate page)
* @param {string} inputId - ID of generated key input
* @param {string} generateBtnId - ID of generate button
* @param {string} copyBtnId - ID of copy button
*/
initChannelKeyGenerator(inputId, generateBtnId, copyBtnId) {
const input = document.getElementById(inputId);
const generateBtn = document.getElementById(generateBtnId);
const copyBtn = document.getElementById(copyBtnId);
generateBtn?.addEventListener('click', () => {
if (input) {
input.value = this.generateChannelKey();
}
if (copyBtn) {
copyBtn.disabled = false;
}
});
copyBtn?.addEventListener('click', () => {
if (input?.value) {
navigator.clipboard.writeText(input.value).then(() => {
const icon = copyBtn.querySelector('i');
if (icon) {
icon.className = 'bi bi-check';
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000);
}
});
}
});
},
// ========================================================================
// INITIALIZATION HELPERS
// ========================================================================
initEncodePage() {
this.initPasswordToggles();
this.initRsaMethodToggle();
this.initDropZones();
this.initClipboardPaste(['input[name="carrier"]', 'input[name="reference_photo"]']);
this.initQrCropAnimation('rsaQrInput');
this.initCollapseChevrons();
this.initPassphraseFontResize();
// Channel key (v4.0.0) - uses select dropdown
this.initChannelKey({
selectId: 'channelSelect',
customInputId: 'channelCustomInput',
keyInputId: 'channelKeyInput',
generateBtnId: 'channelKeyGenerate'
});
// Form submission with channel key validation
const form = document.getElementById('encodeForm');
const btn = document.getElementById('encodeBtn');
form?.addEventListener('submit', (e) => {
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
e.preventDefault();
return false;
}
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...';
}
});
},
initDecodePage() {
this.initPasswordToggles();
this.initRsaMethodToggle();
this.initDropZones();
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
this.initQrCropAnimation('rsaKeyQrInput');
this.initCollapseChevrons();
this.initPassphraseFontResize();
// Channel key (v4.0.0) - uses select dropdown
this.initChannelKey({
selectId: 'channelSelectDec',
customInputId: 'channelCustomInputDec',
keyInputId: 'channelKeyInputDec'
});
// Form submission with channel key validation and mode display
const form = document.getElementById('decodeForm');
const btn = document.getElementById('decodeBtn');
form?.addEventListener('submit', (e) => {
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
e.preventDefault();
return false;
}
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
if (btn) {
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})...`;
}
});
},
initGeneratePage() {
this.initPasswordToggles();
// Channel key generator (v4.0.0)
this.initChannelKeyGenerator('channelKeyGenerated', 'generateChannelKeyBtn', 'copyChannelKeyBtn');
}
};
// Auto-init based on page
document.addEventListener('DOMContentLoaded', () => {
// Detect page and initialize
if (document.getElementById('encodeForm')) {
Stegasoo.initEncodePage();
} else if (document.getElementById('decodeForm')) {
Stegasoo.initDecodePage();
} else if (document.querySelector('[data-page="generate"]')) {
Stegasoo.initGeneratePage();
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -15,6 +15,99 @@
--border-light: rgba(255, 255, 255, 0.1);
--overlay-dark: rgba(0, 0, 0, 0.3);
--overlay-light: rgba(255, 255, 255, 0.05);
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
}
/* ----------------------------------------------------------------------------
Day of Week Highlight - Simple
---------------------------------------------------------------------------- */
.day-of-week-highlight {
color: var(--day-highlight) !important;
font-weight: 700 !important;
}
/* ----------------------------------------------------------------------------
Channel Card Icons (About page) - Contrast fix for gradient backgrounds
---------------------------------------------------------------------------- */
#channel-keys .card-header i.bi {
/* Add outline/shadow for visibility on gradient backgrounds */
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
}
/* Override green Auto icon to white for better contrast */
#channel-keys .card-header i.bi-gear-fill.text-success {
color: #ffffff !important;
filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.8))
drop-shadow(0 0 6px rgba(34, 197, 94, 0.5))
drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
}
/* ----------------------------------------------------------------------------
Mode Selection Buttons (Compact)
---------------------------------------------------------------------------- */
.mode-btn {
background: var(--overlay-light);
border: 2px solid var(--border-light);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
padding-left: 2.75rem; /* Make room for absolutely positioned radio */
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative; /* For absolute positioning of radio */
}
.mode-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
.mode-btn.active {
border-color: var(--gradient-start);
background: rgba(102, 126, 234, 0.1);
}
.mode-btn .form-check-input {
position: absolute;
left: 15px; /* Fixed distance from left edge of card */
top: 50%;
transform: translateY(-50%);
margin: 0;
flex-shrink: 0;
}
/* Remove ms-2 margin from first icon after radio since radio is now absolute */
.mode-btn > i.bi:first-of-type {
margin-left: 0 !important;
}
/* Equal-width mode buttons (ignores content length) */
.mode-btn.equal-width {
flex: 1 1 0;
min-width: 0;
}
/* ----------------------------------------------------------------------------
Security Factor Boxes - Matches drop-zone dashed border style
---------------------------------------------------------------------------- */
.security-box {
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
padding: 1rem;
height: 100%;
}
.mode-info-icon {
cursor: help;
opacity: 0.6;
font-size: 0.85rem;
}
.mode-info-icon:hover {
opacity: 1;
}
/* ----------------------------------------------------------------------------
@@ -398,3 +491,890 @@ footer {
transform: translateY(-5px);
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3);
}
/* ----------------------------------------------------------------------------
Reference Photo Scan Animation
---------------------------------------------------------------------------- */
.scan-container {
position: relative;
overflow: hidden;
}
.scan-container .drop-zone-preview {
position: relative;
z-index: 1;
margin-bottom: 45px;
}
.scan-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity 0.3s ease;
}
.scan-container.scanning .scan-overlay {
opacity: 1;
}
/* Scan line that moves down */
.scan-line {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
rgba(0, 255, 170, 0.4) 20%,
rgba(0, 255, 170, 1) 50%,
rgba(0, 255, 170, 0.4) 80%,
transparent 100%
);
box-shadow: 0 0 10px rgba(0, 255, 170, 0.6), 0 0 20px rgba(0, 255, 170, 0.3);
opacity: 0;
}
.scan-container.scanning .scan-line {
opacity: 1;
animation: scanDown 0.7s ease-out forwards;
}
@keyframes scanDown {
0% { top: 0; opacity: 1; }
100% { top: 100%; opacity: 0; }
}
/* Grid overlay - subtle */
.scan-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(0, 255, 170, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 170, 0.03) 1px, transparent 1px);
background-size: 8px 8px;
opacity: 0;
}
.scan-container.scanning .scan-grid {
opacity: 0.5;
}
/* Hash blocks container - masked to image, positioned over it */
.hash-blocks {
position: absolute;
z-index: 3;
pointer-events: none;
overflow: hidden;
border-radius: 0.375rem;
display: grid;
gap: 2px;
}
.hash-block {
background: rgba(0, 255, 170, 0.6);
border-radius: 1px;
box-shadow: 0 0 4px rgba(0, 255, 170, 0.5);
opacity: 0;
}
/* Hash blocks fade in then out one by one */
.scan-container.scanning .hash-block {
animation: hashBlockPulse 0.7s ease-in-out forwards;
}
@keyframes hashBlockPulse {
0% {
opacity: 0;
transform: scale(0.8);
}
15% {
opacity: 0.7;
transform: scale(1);
background: rgba(0, 255, 170, 0.6);
}
40% {
opacity: 0.8;
background: rgba(0, 255, 170, 0.7);
box-shadow: 0 0 6px rgba(0, 255, 170, 0.6);
}
70% {
opacity: 0.6;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* Corner brackets - hidden during scan, shown after */
.scan-corners {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.scan-corner {
position: absolute;
width: 16px;
height: 16px;
border-color: rgba(0, 255, 170, 0.8);
border-style: solid;
border-width: 0;
}
.scan-corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; }
.scan-corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; }
.scan-corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; }
.scan-corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; }
/* Scan complete state */
.scan-container.scan-complete .scan-overlay {
opacity: 0;
}
.scan-container.scan-complete .scan-corners {
opacity: 1;
}
.scan-container.scan-complete .drop-zone-preview {
animation: scanPop 0.3s ease-out;
}
@keyframes scanPop {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
/* Data panel that appears after scan */
.scan-data-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 3;
background: linear-gradient(to top,
rgba(10, 15, 30, 0.98) 0%,
rgba(12, 20, 40, 0.9) 50%,
rgba(15, 25, 50, 0.6) 75%,
transparent 100%);
padding: 35px 10px 8px 10px;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.scan-container.scan-complete .scan-data-panel {
opacity: 1;
transform: translateY(0);
}
.scan-data-filename {
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #fff;
text-align: center;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-data-filename i {
color: rgba(0, 255, 170, 1);
margin-right: 4px;
}
.scan-data-row {
display: flex;
justify-content: space-between;
align-items: center;
font-family: 'Courier New', monospace;
font-size: 0.6rem;
color: rgba(0, 255, 170, 0.9);
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.scan-data-label {
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
font-size: 0.55rem;
}
.scan-data-value {
color: rgba(0, 255, 170, 1);
font-weight: 600;
}
.scan-hash-preview {
font-family: 'Courier New', monospace;
font-size: 0.55rem;
color: rgba(0, 255, 170, 0.6);
letter-spacing: 0.5px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scan-status-badge {
display: inline-flex;
align-items: center;
background: rgba(0, 255, 170, 0.15);
border: 1px solid rgba(0, 255, 170, 0.4);
border-radius: 3px;
padding: 2px 6px;
font-size: 0.5rem;
color: rgba(0, 255, 170, 1);
text-transform: uppercase;
letter-spacing: 0.3px;
line-height: 1;
}
/* ----------------------------------------------------------------------------
Carrier/Stego Embed Animation - Hidden data threads
---------------------------------------------------------------------------- */
.pixel-container {
position: relative;
overflow: hidden;
}
.pixel-container .drop-zone-preview {
position: relative;
z-index: 1;
margin-bottom: 45px;
}
/* Scan line effect - smooth single pass */
.pixel-scan-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
top: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(212, 225, 87, 0.4) 20%,
rgba(212, 225, 87, 1) 50%,
rgba(212, 225, 87, 0.4) 80%,
transparent 100%
);
box-shadow: 0 0 10px rgba(212, 225, 87, 0.6), 0 0 20px rgba(212, 225, 87, 0.3);
opacity: 0;
z-index: 5;
pointer-events: none;
}
.pixel-container.loading .pixel-scan-line {
opacity: 1;
animation: embedScanDown 0.7s ease-out forwards;
}
@keyframes embedScanDown {
0% { top: 0; opacity: 1; }
100% { top: calc(100% - 45px); opacity: 0; }
}
/* Grid overlay - matches reference scan-grid exactly */
.embed-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
pointer-events: none;
background-image:
linear-gradient(rgba(0, 255, 170, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 170, 0.03) 1px, transparent 1px);
background-size: 8px 8px;
opacity: 0;
}
.pixel-container.loading .embed-grid {
opacity: 0.5;
}
/* Tron-style circuit traces container - masked to image */
.embed-traces {
position: absolute;
z-index: 4;
pointer-events: none;
overflow: hidden;
border-radius: 0.375rem;
}
.embed-trace {
position: absolute;
border-radius: 1px;
opacity: 0;
}
/* Color variants - 60% opacity */
.embed-trace.color-yellow {
background: rgba(212, 225, 87, 0.6);
box-shadow: 0 0 6px rgba(212, 225, 87, 0.5), 0 0 12px rgba(212, 225, 87, 0.3);
}
.embed-trace.color-cyan {
background: rgba(0, 255, 170, 0.6);
box-shadow: 0 0 6px rgba(0, 255, 170, 0.5), 0 0 12px rgba(0, 255, 170, 0.3);
}
.embed-trace.color-purple {
background: rgba(167, 139, 250, 0.6);
box-shadow: 0 0 6px rgba(167, 139, 250, 0.5), 0 0 12px rgba(167, 139, 250, 0.3);
}
.embed-trace.color-blue {
background: rgba(102, 126, 234, 0.6);
box-shadow: 0 0 6px rgba(102, 126, 234, 0.5), 0 0 12px rgba(102, 126, 234, 0.3);
}
/* Vertical segments shrink from top */
.embed-trace.v {
width: 2px;
transform-origin: top center;
}
/* Horizontal segments shrink from left */
.embed-trace.h {
height: 2px;
transform-origin: left center;
}
/* Animation - appear, glow, shrink away completely */
.pixel-container.loading .embed-trace.h {
animation: traceHShrink 0.65s ease-in-out forwards;
}
.pixel-container.loading .embed-trace.v {
animation: traceVShrink 0.65s ease-in-out forwards;
}
@keyframes traceHShrink {
0% {
transform: scaleX(0);
opacity: 0;
}
15% {
transform: scaleX(1);
opacity: 1;
}
40% {
transform: scaleX(1);
opacity: 1;
}
100% {
transform: scaleX(0);
opacity: 0;
}
}
@keyframes traceVShrink {
0% {
transform: scaleY(0);
opacity: 0;
}
15% {
transform: scaleY(1);
opacity: 1;
}
40% {
transform: scaleY(1);
opacity: 1;
}
100% {
transform: scaleY(0);
opacity: 0;
}
}
/* Ensure traces are gone after animation */
.pixel-container.load-complete .embed-traces {
display: none;
}
/* Corner brackets for pixel container - purple/blue theme */
.pixel-corners {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.pixel-corner {
position: absolute;
width: 16px;
height: 16px;
border-color: rgba(212, 225, 87, 0.8);
border-style: solid;
border-width: 0;
}
.pixel-corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; }
.pixel-corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; }
.pixel-corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; }
.pixel-corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; }
.pixel-container.load-complete .pixel-corners {
opacity: 1;
}
/* Data panel for pixel container - purple/blue theme */
.pixel-data-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 3;
background: linear-gradient(to top,
rgba(10, 15, 30, 0.98) 0%,
rgba(12, 20, 40, 0.9) 50%,
rgba(15, 25, 50, 0.6) 75%,
transparent 100%);
padding: 35px 10px 8px 10px;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.pixel-container.load-complete .pixel-data-panel {
opacity: 1;
transform: translateY(0);
}
.pixel-data-filename {
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #fff;
text-align: center;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pixel-data-filename i {
color: #d4e157;
margin-right: 4px;
}
.pixel-data-row {
display: flex;
justify-content: space-between;
align-items: center;
font-family: 'Courier New', monospace;
font-size: 0.6rem;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.pixel-data-value {
color: #d4e157;
font-weight: 600;
}
.pixel-status-badge {
display: inline-flex;
align-items: center;
background: rgba(212, 225, 87, 0.15);
border: 1px solid rgba(212, 225, 87, 0.4);
border-radius: 3px;
padding: 2px 6px;
font-size: 0.55rem;
color: #d4e157;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1;
}
.pixel-dimensions {
font-family: 'Courier New', monospace;
font-size: 0.55rem;
color: rgba(212, 225, 87, 0.7);
letter-spacing: 0.5px;
text-align: center;
}
/* ----------------------------------------------------------------------------
QR Code Section - Square Inner Panel Layout
---------------------------------------------------------------------------- */
#rsaQrSection {
display: flex;
justify-content: center;
}
#rsaQrSection .drop-zone {
width: 200px;
height: 200px;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
transition: all 0.3s ease;
}
/* Expand drop zone when showing scanned QR result */
#rsaQrSection .drop-zone:has(.qr-scan-container:not(.d-none)) {
width: auto;
min-width: 200px;
max-width: 280px;
height: auto;
min-height: 200px;
aspect-ratio: auto;
}
#rsaQrSection .drop-zone-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
/* ----------------------------------------------------------------------------
QR Code Section Scan Animation - Simplified: Loading → Scan → Complete
---------------------------------------------------------------------------- */
.qr-scan-container {
position: relative;
overflow: visible;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
min-height: 160px;
min-width: 160px;
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.qr-scan-container img {
display: block;
margin: 0 auto;
}
/* Hide original image - we don't use it anymore */
.qr-scan-container .qr-original {
display: none;
}
/* Cropped image - hidden until loaded, scales UP to fill container */
.qr-scan-container .qr-cropped {
max-height: 180px;
max-width: 180px;
min-width: 140px;
min-height: 140px;
width: auto;
height: auto;
object-fit: contain;
display: none;
border-radius: 6px;
}
/* ===========================================
PHASE 1: Loading - spinner while cropping
=========================================== */
.qr-loader {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: rgba(0, 255, 170, 0.9);
padding: 30px 20px;
width: 100%;
}
.qr-loader i {
font-size: 2.5rem;
animation: qrLoaderPulse 1.5s ease-in-out infinite;
}
.qr-loader span {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
letter-spacing: 0.5px;
opacity: 0.8;
}
@keyframes qrLoaderPulse {
0%, 100% { opacity: 0.5; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1.05); }
}
.qr-scan-container.loading .qr-loader {
display: flex;
}
/* Error state */
.qr-scan-container.error .qr-loader {
display: flex;
color: #fbbf24;
}
.qr-scan-container.error .qr-loader i {
animation: none;
}
/* ===========================================
PHASE 2: Scanning - cropped QR with brackets
=========================================== */
.qr-scan-container.scanning .qr-cropped,
.qr-scan-container.scan-complete .qr-cropped {
display: block;
}
/* Scanner overlay - covers the container, padded to match image area */
.qr-scanner-overlay {
position: absolute;
inset: 10px;
pointer-events: none;
z-index: 5;
border-radius: 6px;
overflow: visible;
}
/* Four corner finder brackets */
.qr-finder-bracket {
position: absolute;
width: 18px;
height: 18px;
border-color: rgba(0, 255, 170, 0.9);
border-style: solid;
border-width: 0;
opacity: 0;
transition: all 0.3s ease;
}
.qr-finder-bracket.tl { top: 0; left: 0; border-top-width: 3px; border-left-width: 3px; }
.qr-finder-bracket.tr { top: 0; right: 0; border-top-width: 3px; border-right-width: 3px; }
.qr-finder-bracket.bl { bottom: 0; left: 0; border-bottom-width: 3px; border-left-width: 3px; }
.qr-finder-bracket.br { bottom: 0; right: 0; border-bottom-width: 3px; border-right-width: 3px; }
/* Scanning state - brackets pulse */
.qr-scan-container.scanning .qr-finder-bracket {
opacity: 1;
box-shadow: 0 0 8px rgba(0, 255, 170, 0.6);
animation: bracketPulse 1s ease-in-out infinite;
}
.qr-scan-container.scanning .qr-finder-bracket.tl { animation-delay: 0s; }
.qr-scan-container.scanning .qr-finder-bracket.tr { animation-delay: 0.15s; }
.qr-scan-container.scanning .qr-finder-bracket.br { animation-delay: 0.3s; }
.qr-scan-container.scanning .qr-finder-bracket.bl { animation-delay: 0.45s; }
@keyframes bracketPulse {
0%, 100% { opacity: 0.6; border-color: rgba(0, 255, 170, 0.6); box-shadow: 0 0 4px rgba(0, 255, 170, 0.4); }
50% { opacity: 1; border-color: rgba(0, 255, 170, 1); box-shadow: 0 0 12px rgba(0, 255, 170, 0.8); }
}
/* Scan line during scanning phase */
.qr-scan-container.scanning .qr-scanner-overlay::before {
content: '';
position: absolute;
left: 2px;
right: 2px;
height: 2px;
top: 2px;
z-index: 6;
background: linear-gradient(90deg,
transparent 0%,
rgba(0, 255, 170, 0.5) 20%,
rgba(0, 255, 170, 1) 50%,
rgba(0, 255, 170, 0.5) 80%,
transparent 100%
);
box-shadow: 0 0 8px rgba(0, 255, 170, 0.8);
animation: qrScanLine 1s ease-in-out infinite;
}
@keyframes qrScanLine {
0% { top: 2px; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { top: calc(100% - 2px); opacity: 0; }
}
/* Grid overlay during scanning */
.qr-scan-container.scanning .qr-scanner-overlay::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image:
linear-gradient(rgba(0, 255, 170, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 170, 0.03) 1px, transparent 1px);
background-size: 6px 6px;
opacity: 0.5;
z-index: 4;
}
/* ===========================================
PHASE 3: Complete - brackets lock solid
=========================================== */
.qr-scan-container.scan-complete .qr-finder-bracket {
opacity: 1;
border-color: rgba(0, 255, 170, 1);
box-shadow: 0 0 8px rgba(0, 255, 170, 0.6);
animation: none;
}
/* Data panel at bottom of overlay (relative to image) */
.qr-scanner-overlay .qr-data-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
background: linear-gradient(to top,
rgba(10, 15, 30, 0.95) 0%,
rgba(10, 15, 30, 0.6) 80%,
transparent 100%);
padding: 4px 6px 3px 6px;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 0 0 6px 6px;
}
.qr-scan-container.scan-complete .qr-data-panel {
opacity: 1;
}
/* Hide the static data panel in HTML */
.qr-scan-container > .qr-data-panel {
display: none;
}
/* Hide elements we don't use */
.qr-scan-container .crop-badge {
display: none;
}
/* QR Data Panel text styles */
.qr-data-filename {
font-family: 'Courier New', monospace;
font-size: 0.6rem;
color: #fff;
text-align: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qr-data-filename i {
color: rgba(0, 255, 170, 1);
margin-right: 3px;
}
.qr-data-row {
display: flex;
justify-content: space-between;
align-items: center;
font-family: 'Courier New', monospace;
font-size: 0.5rem;
white-space: nowrap;
}
.qr-status-badge {
display: inline-flex;
align-items: center;
background: rgba(0, 255, 170, 0.15);
border: 1px solid rgba(0, 255, 170, 0.4);
border-radius: 2px;
padding: 1px 4px;
font-size: 0.45rem;
color: rgba(0, 255, 170, 1);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.qr-data-value {
color: rgba(0, 255, 170, 1);
font-weight: 600;
font-size: 0.5rem;
}
/* ----------------------------------------------------------------------------
LED Indicator
---------------------------------------------------------------------------- */
.led-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
vertical-align: middle;
}
.led-yellow {
background: #fbbf24;
box-shadow: 0 0 3px #fbbf24, 0 0 6px rgba(251, 191, 36, 0.4);
}
.led-green {
background: #22c55e;
box-shadow: 0 0 3px #22c55e, 0 0 6px rgba(34, 197, 94, 0.4);
}
.led-red {
background: #ef4444;
box-shadow: 0 0 3px #ef4444, 0 0 6px rgba(239, 68, 68, 0.4);
}
/* LED Badge backgrounds */
.led-badge-yellow {
background: rgba(251, 191, 36, 0.2);
border: 1px solid rgba(251, 191, 36, 0.4);
color: #fbbf24;
}
.led-badge-green {
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.4);
color: #22c55e;
}
.led-badge-red {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #ef4444;
}
/* Key capsule container */
.key-capsule {
display: inline-flex;
align-items: center;
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 0.375rem;
padding: 0.35rem 0.75rem;
background: rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Stegasoo Subprocess Worker (v4.0.0)
This script runs in a subprocess and handles encode/decode operations.
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
CHANGES in v4.0.0:
- Added channel_key support for encode/decode operations
- New channel_status operation
Communication is via JSON over stdin/stdout:
- Input: JSON object with operation parameters
- Output: JSON object with results or error
Usage:
echo '{"operation": "encode", ...}' | python stego_worker.py
"""
import base64
import json
import sys
import traceback
from pathlib import Path
# Ensure stegasoo is importable
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
def _resolve_channel_key(channel_key_param):
"""
Resolve channel_key parameter to value for stegasoo.
Args:
channel_key_param: 'auto', 'none', explicit key, or None
Returns:
None (auto), "" (public), or explicit key string
"""
if channel_key_param is None or channel_key_param == "auto":
return None # Auto mode - use server config
elif channel_key_param == "none":
return "" # Public mode
else:
return channel_key_param # Explicit key
def _get_channel_info(resolved_key):
"""
Get channel mode and fingerprint for response.
Returns:
(mode, fingerprint) tuple
"""
from stegasoo import get_channel_status, has_channel_key
if resolved_key == "":
return "public", None
if resolved_key is not None:
# Explicit key
fingerprint = f"{resolved_key[:4]}-••••-••••-••••-••••-••••-••••-{resolved_key[-4:]}"
return "private", fingerprint
# Auto mode - check server config
if has_channel_key():
status = get_channel_status()
return "private", status.get("fingerprint")
return "public", None
def encode_operation(params: dict) -> dict:
"""Handle encode operation."""
from stegasoo import FilePayload, encode
# Decode base64 inputs
carrier_data = base64.b64decode(params["carrier_b64"])
reference_data = base64.b64decode(params["reference_b64"])
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
# Determine payload type
if params.get("file_b64"):
file_data = base64.b64decode(params["file_b64"])
payload = FilePayload(
data=file_data,
filename=params.get("file_name", "file"),
mime_type=params.get("file_mime", "application/octet-stream"),
)
else:
payload = params.get("message", "")
# Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Call encode with correct parameter names
result = encode(
message=payload,
reference_photo=reference_data,
carrier_image=carrier_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "lsb"),
dct_output_format=params.get("dct_output_format", "png"),
dct_color_mode=params.get("dct_color_mode", "color"),
channel_key=resolved_channel_key, # v4.0.0
)
# Build stats dict if available
stats = None
if hasattr(result, "stats") and result.stats:
stats = {
"pixels_modified": getattr(result.stats, "pixels_modified", 0),
"capacity_used": getattr(result.stats, "capacity_used", 0),
"bytes_embedded": getattr(result.stats, "bytes_embedded", 0),
}
# Get channel info for response (v4.0.0)
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return {
"success": True,
"stego_b64": base64.b64encode(result.stego_image).decode("ascii"),
"filename": getattr(result, "filename", None),
"stats": stats,
"channel_mode": channel_mode,
"channel_fingerprint": channel_fingerprint,
}
def decode_operation(params: dict) -> dict:
"""Handle decode operation."""
from stegasoo import decode
# Decode base64 inputs
stego_data = base64.b64decode(params["stego_b64"])
reference_data = base64.b64decode(params["reference_b64"])
# Optional RSA key
rsa_key_data = None
if params.get("rsa_key_b64"):
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
# Resolve channel key (v4.0.0)
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
# Call decode with correct parameter names
result = decode(
stego_image=stego_data,
reference_photo=reference_data,
passphrase=params.get("passphrase", ""),
pin=params.get("pin"),
rsa_key_data=rsa_key_data,
rsa_password=params.get("rsa_password"),
embed_mode=params.get("embed_mode", "auto"),
channel_key=resolved_channel_key, # v4.0.0
)
if result.is_file:
return {
"success": True,
"is_file": True,
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
"filename": result.filename,
"mime_type": result.mime_type,
}
else:
return {
"success": True,
"is_file": False,
"message": result.message,
}
def compare_operation(params: dict) -> dict:
"""Handle compare_modes operation."""
from stegasoo import compare_modes
carrier_data = base64.b64decode(params["carrier_b64"])
result = compare_modes(carrier_data)
return {
"success": True,
"comparison": result,
}
def capacity_check_operation(params: dict) -> dict:
"""Handle will_fit_by_mode operation."""
from stegasoo import will_fit_by_mode
carrier_data = base64.b64decode(params["carrier_b64"])
result = will_fit_by_mode(
payload=params["payload_size"],
carrier_image=carrier_data,
embed_mode=params.get("embed_mode", "lsb"),
)
return {
"success": True,
"result": result,
}
def channel_status_operation(params: dict) -> dict:
"""Handle channel status check (v4.0.0)."""
from stegasoo import get_channel_status
status = get_channel_status()
reveal = params.get("reveal", False)
return {
"success": True,
"status": {
"mode": status["mode"],
"configured": status["configured"],
"fingerprint": status.get("fingerprint"),
"source": status.get("source"),
"key": status.get("key") if reveal and status["configured"] else None,
},
}
def main():
"""Main entry point - read JSON from stdin, write JSON to stdout."""
try:
# Read all input
input_text = sys.stdin.read()
if not input_text.strip():
output = {"success": False, "error": "No input provided"}
else:
params = json.loads(input_text)
operation = params.get("operation")
if operation == "encode":
output = encode_operation(params)
elif operation == "decode":
output = decode_operation(params)
elif operation == "compare":
output = compare_operation(params)
elif operation == "capacity":
output = capacity_check_operation(params)
elif operation == "channel_status":
output = channel_status_operation(params)
else:
output = {"success": False, "error": f"Unknown operation: {operation}"}
except json.JSONDecodeError as e:
output = {"success": False, "error": f"Invalid JSON: {e}"}
except Exception as e:
output = {
"success": False,
"error": str(e),
"error_type": type(e).__name__,
"traceback": traceback.format_exc(),
}
# Write output as JSON
print(json.dumps(output), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,498 @@
"""
Subprocess Steganography Wrapper (v4.0.0)
Runs stegasoo operations in isolated subprocesses to prevent crashes
from taking down the Flask server.
CHANGES in v4.0.0:
- Added channel_key parameter to encode() and decode() methods
- Channel keys enable deployment/group isolation
Usage:
from subprocess_stego import SubprocessStego
stego = SubprocessStego()
# Encode with channel key
result = stego.encode(
carrier_data=carrier_bytes,
reference_data=ref_bytes,
message="secret message",
passphrase="my passphrase",
pin="123456",
embed_mode="dct",
channel_key="auto", # or "none", or explicit key
)
if result.success:
stego_bytes = result.stego_data
extension = result.extension
else:
error_message = result.error
# Decode
result = stego.decode(
stego_data=stego_bytes,
reference_data=ref_bytes,
passphrase="my passphrase",
pin="123456",
channel_key="auto",
)
# Compare modes (capacity)
result = stego.compare_modes(carrier_bytes)
"""
import base64
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Default timeout for operations (seconds)
DEFAULT_TIMEOUT = 120
# Path to worker script - adjust if needed
WORKER_SCRIPT = Path(__file__).parent / "stego_worker.py"
@dataclass
class EncodeResult:
"""Result from encode operation."""
success: bool
stego_data: bytes | None = None
filename: str | None = None
stats: dict[str, Any] | None = None
# Channel info (v4.0.0)
channel_mode: str | None = None
channel_fingerprint: str | None = None
error: str | None = None
error_type: str | None = None
@dataclass
class DecodeResult:
"""Result from decode operation."""
success: bool
is_file: bool = False
message: str | None = None
file_data: bytes | None = None
filename: str | None = None
mime_type: str | None = None
error: str | None = None
error_type: str | None = None
@dataclass
class CompareResult:
"""Result from compare_modes operation."""
success: bool
width: int = 0
height: int = 0
lsb: dict[str, Any] | None = None
dct: dict[str, Any] | None = None
error: str | None = None
@dataclass
class CapacityResult:
"""Result from capacity check operation."""
success: bool
fits: bool = False
payload_size: int = 0
capacity: int = 0
usage_percent: float = 0.0
headroom: int = 0
mode: str = ""
error: str | None = None
@dataclass
class ChannelStatusResult:
"""Result from channel status check (v4.0.0)."""
success: bool
mode: str = "public"
configured: bool = False
fingerprint: str | None = None
source: str | None = None
key: str | None = None
error: str | None = None
class SubprocessStego:
"""
Subprocess-isolated steganography operations.
All operations run in a separate Python process. If jpegio or scipy
crashes, only the subprocess dies - Flask keeps running.
"""
def __init__(
self,
worker_path: Path | None = None,
python_executable: str | None = None,
timeout: int = DEFAULT_TIMEOUT,
):
"""
Initialize subprocess wrapper.
Args:
worker_path: Path to stego_worker.py (default: same directory)
python_executable: Python interpreter to use (default: same as current)
timeout: Default timeout in seconds
"""
self.worker_path = worker_path or WORKER_SCRIPT
self.python = python_executable or sys.executable
self.timeout = timeout
if not self.worker_path.exists():
raise FileNotFoundError(f"Worker script not found: {self.worker_path}")
def _run_worker(self, params: dict[str, Any], timeout: int | None = None) -> dict[str, Any]:
"""
Run the worker subprocess with given parameters.
Args:
params: Dictionary of parameters (will be JSON-encoded)
timeout: Operation timeout in seconds
Returns:
Dictionary with results from worker
"""
timeout = timeout or self.timeout
input_json = json.dumps(params)
try:
result = subprocess.run(
[self.python, str(self.worker_path)],
input=input_json,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(self.worker_path.parent),
)
if result.returncode != 0:
# Worker crashed
return {
"success": False,
"error": f"Worker crashed (exit code {result.returncode})",
"stderr": result.stderr,
}
if not result.stdout.strip():
return {
"success": False,
"error": "Worker returned empty output",
"stderr": result.stderr,
}
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
return {
"success": False,
"error": f"Operation timed out after {timeout} seconds",
"error_type": "TimeoutError",
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON from worker: {e}",
"raw_output": result.stdout if "result" in dir() else None,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"error_type": type(e).__name__,
}
def encode(
self,
carrier_data: bytes,
reference_data: bytes,
message: str | None = None,
file_data: bytes | None = None,
file_name: str | None = None,
file_mime: str | None = None,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "lsb",
dct_output_format: str = "png",
dct_color_mode: str = "color",
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
) -> EncodeResult:
"""
Encode a message or file into an image.
Args:
carrier_data: Carrier image bytes
reference_data: Reference photo bytes
message: Text message to encode (if not file)
file_data: File bytes to encode (if not message)
file_name: Original filename (for file payload)
file_mime: MIME type (for file payload)
passphrase: Encryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'lsb' or 'dct'
dct_output_format: 'png' or 'jpeg' (for DCT mode)
dct_color_mode: 'grayscale' or 'color' (for DCT mode)
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
timeout: Operation timeout in seconds
Returns:
EncodeResult with stego_data and extension on success
"""
params = {
"operation": "encode",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"message": message,
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"dct_output_format": dct_output_format,
"dct_color_mode": dct_color_mode,
"channel_key": channel_key, # v4.0.0
}
if file_data:
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
params["file_name"] = file_name
params["file_mime"] = file_mime
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
result = self._run_worker(params, timeout)
if result.get("success"):
return EncodeResult(
success=True,
stego_data=base64.b64decode(result["stego_b64"]),
filename=result.get("filename"),
stats=result.get("stats"),
channel_mode=result.get("channel_mode"),
channel_fingerprint=result.get("channel_fingerprint"),
)
else:
return EncodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def decode(
self,
stego_data: bytes,
reference_data: bytes,
passphrase: str = "",
pin: str | None = None,
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = "auto",
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
) -> DecodeResult:
"""
Decode a message or file from a stego image.
Args:
stego_data: Stego image bytes
reference_data: Reference photo bytes
passphrase: Decryption passphrase
pin: Optional PIN
rsa_key_data: Optional RSA key PEM bytes
rsa_password: RSA key password if encrypted
embed_mode: 'auto', 'lsb', or 'dct'
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
timeout: Operation timeout in seconds
Returns:
DecodeResult with message or file_data on success
"""
params = {
"operation": "decode",
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
"passphrase": passphrase,
"pin": pin,
"embed_mode": embed_mode,
"channel_key": channel_key, # v4.0.0
}
if rsa_key_data:
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
params["rsa_password"] = rsa_password
result = self._run_worker(params, timeout)
if result.get("success"):
if result.get("is_file"):
return DecodeResult(
success=True,
is_file=True,
file_data=base64.b64decode(result["file_b64"]),
filename=result.get("filename"),
mime_type=result.get("mime_type"),
)
else:
return DecodeResult(
success=True,
is_file=False,
message=result.get("message"),
)
else:
return DecodeResult(
success=False,
error=result.get("error", "Unknown error"),
error_type=result.get("error_type"),
)
def compare_modes(
self,
carrier_data: bytes,
timeout: int | None = None,
) -> CompareResult:
"""
Compare LSB and DCT capacity for a carrier image.
Args:
carrier_data: Carrier image bytes
timeout: Operation timeout in seconds
Returns:
CompareResult with capacity information
"""
params = {
"operation": "compare",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
}
result = self._run_worker(params, timeout)
if result.get("success"):
comparison = result.get("comparison", {})
return CompareResult(
success=True,
width=comparison.get("width", 0),
height=comparison.get("height", 0),
lsb=comparison.get("lsb"),
dct=comparison.get("dct"),
)
else:
return CompareResult(
success=False,
error=result.get("error", "Unknown error"),
)
def check_capacity(
self,
carrier_data: bytes,
payload_size: int,
embed_mode: str = "lsb",
timeout: int | None = None,
) -> CapacityResult:
"""
Check if a payload will fit in the carrier.
Args:
carrier_data: Carrier image bytes
payload_size: Size of payload in bytes
embed_mode: 'lsb' or 'dct'
timeout: Operation timeout in seconds
Returns:
CapacityResult with fit information
"""
params = {
"operation": "capacity",
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
"payload_size": payload_size,
"embed_mode": embed_mode,
}
result = self._run_worker(params, timeout)
if result.get("success"):
r = result.get("result", {})
return CapacityResult(
success=True,
fits=r.get("fits", False),
payload_size=r.get("payload_size", 0),
capacity=r.get("capacity", 0),
usage_percent=r.get("usage_percent", 0.0),
headroom=r.get("headroom", 0),
mode=r.get("mode", embed_mode),
)
else:
return CapacityResult(
success=False,
error=result.get("error", "Unknown error"),
)
def get_channel_status(
self,
reveal: bool = False,
timeout: int | None = None,
) -> ChannelStatusResult:
"""
Get current channel key status (v4.0.0).
Args:
reveal: Include full key in response
timeout: Operation timeout in seconds
Returns:
ChannelStatusResult with channel info
"""
params = {
"operation": "channel_status",
"reveal": reveal,
}
result = self._run_worker(params, timeout)
if result.get("success"):
status = result.get("status", {})
return ChannelStatusResult(
success=True,
mode=status.get("mode", "public"),
configured=status.get("configured", False),
fingerprint=status.get("fingerprint"),
source=status.get("source"),
key=status.get("key") if reveal else None,
)
else:
return ChannelStatusResult(
success=False,
error=result.get("error", "Unknown error"),
)
# Convenience function for quick usage
_default_stego: SubprocessStego | None = None
def get_subprocess_stego() -> SubprocessStego:
"""Get or create default SubprocessStego instance."""
global _default_stego
if _default_stego is None:
_default_stego = SubprocessStego()
return _default_stego

View File

@@ -11,33 +11,32 @@
</div>
<div class="card-body">
<p class="lead">
Stegasoo is a secure steganography tool that hides encrypted messages and files
inside ordinary images using multi-factor authentication.
Stegasoo hides encrypted messages and files inside images using multi-factor authentication.
</p>
<h6 class="text-primary mt-4 mb-3"><i class="bi bi-stars me-2"></i>Key Features</h6>
<h6 class="text-primary mt-4 mb-3">Features</h6>
<div class="row">
<div class="col-md-6">
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Text &amp; File Embedding</strong>
<br/>Hide messages or any file type (PDF, ZIP, documents)
<br><small class="text-muted">Any file type: PDF, ZIP, documents</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Multi-Factor Security</strong>
<br/>Combines photo + phrase + PIN/RSA key
<br><small class="text-muted">Photo + passphrase + PIN/RSA key</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>AES-256-GCM Encryption</strong>
<br/>Military-grade authenticated encryption
<br><small class="text-muted">Authenticated encryption with integrity check</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Daily Rotating Phrases</strong>
<br/>Different passphrase each day of the week
<strong>DCT &amp; LSB Modes</strong>
<br><small class="text-muted">JPEG resilience (DCT) or high capacity (LSB)</small>
</li>
</ul>
</div>
@@ -46,22 +45,28 @@
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Random Pixel Embedding</strong>
<br/>Defeats statistical steganalysis
<br><small class="text-muted">Defeats statistical analysis</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Format Preservation</strong>
<br/>Maintains PNG/BMP lossless formats
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Large Capacity</strong>
<br/>Up to {{ max_payload_kb }} KB payload, 24MP images
<strong>Large Image Support</strong>
<br><small class="text-muted">Up to {{ max_payload_kb }} KB, tested with 14MB+ images</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Zero Server Storage</strong>
<br/>Nothing saved, files auto-expire and are scrubbed from disk.
<br><small class="text-muted">Nothing saved, files auto-expire</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>QR Code Keys</strong>
<br><small class="text-muted">Import/export RSA keys via QR</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Channel Keys</strong>
<span class="badge bg-info ms-1">v4.0</span>
<br><small class="text-muted">Group/deployment isolation</small>
</li>
</ul>
</div>
@@ -69,243 +74,314 @@
</div>
</div>
<!-- Embedding Modes -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
</div>
<div class="card-body">
<p>Two modes optimized for different use cases.</p>
<div class="row mt-4">
<!-- DCT Mode -->
<div class="col-md-6 mb-4">
<div class="card bg-dark h-100">
<div class="card-header">
<i class="bi bi-soundwave text-warning me-2"></i>
<strong>DCT Mode</strong>
<span class="badge bg-success ms-2">Default</span>
</div>
<div class="card-body">
<p class="small">
<strong>DCT (Discrete Cosine Transform)</strong> embeds data in frequency coefficients. Survives JPEG recompression.
</p>
<ul class="small mb-0">
<li><strong>Capacity:</strong> ~75 KB/MP</li>
<li><strong>Output:</strong> JPEG or PNG</li>
<li><strong>Color:</strong> Color or grayscale</li>
<li><strong>Speed:</strong> ~2s</li>
</ul>
<hr>
<div class="small">
<i class="bi bi-check-circle text-success me-1"></i> Instagram, Facebook<br>
<i class="bi bi-check-circle text-success me-1"></i> WhatsApp, Signal, Telegram<br>
<i class="bi bi-check-circle text-success me-1"></i> Twitter/X<br>
<i class="bi bi-check-circle text-success me-1"></i> Any recompressing platform
</div>
</div>
</div>
</div>
<!-- LSB Mode -->
<div class="col-md-6 mb-4">
<div class="card bg-dark h-100">
<div class="card-header">
<i class="bi bi-grid-3x3-gap text-primary me-2"></i>
<strong>LSB Mode</strong>
</div>
<div class="card-body">
<p class="small">
<strong>LSB (Least Significant Bit)</strong> embeds data in the lowest bit of each color channel. Imperceptible to the eye.
</p>
<ul class="small mb-0">
<li><strong>Capacity:</strong> ~375 KB/MP</li>
<li><strong>Output:</strong> PNG (lossless)</li>
<li><strong>Color:</strong> Full color</li>
<li><strong>Speed:</strong> ~0.5s</li>
</ul>
<hr>
<div class="small">
<i class="bi bi-check-circle text-success me-1"></i> Email attachments<br>
<i class="bi bi-check-circle text-success me-1"></i> Cloud storage<br>
<i class="bi bi-check-circle text-success me-1"></i> Direct file transfer<br>
<i class="bi bi-x-circle text-danger me-1"></i> Social media
</div>
</div>
</div>
</div>
</div>
<!-- Mode Comparison Table -->
<h6 class="mt-3"><i class="bi bi-table me-2"></i>Comparison</h6>
<div class="table-responsive">
<table class="table table-dark table-sm small">
<thead>
<tr>
<th>Aspect</th>
<th>DCT Mode <span class="badge bg-success ms-1">Default</span></th>
<th>LSB Mode</th>
</tr>
</thead>
<tbody>
<tr>
<td>Capacity (1080p)</td>
<td class="text-warning">~50 KB</td>
<td class="text-success">~770 KB</td>
</tr>
<tr>
<td>Survives JPEG</td>
<td class="text-success">✅ Yes</td>
<td class="text-danger">❌ No</td>
</tr>
<tr>
<td>Social Media</td>
<td class="text-success">✅ Works</td>
<td class="text-danger">❌ Broken</td>
</tr>
<tr>
<td>Detection Resistance</td>
<td>Better</td>
<td>Moderate</td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-lightbulb me-2"></i>
<strong>Auto-Detection:</strong> Mode is detected automatically when decoding.
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>How Security Works</h5>
</div>
<div class="card-body">
<p>Stegasoo uses <strong>hybrid multi-factor authentication</strong> to derive encryption keys:</p>
<p>Multi-factor authentication derives encryption keys:</p>
<div class="row text-center my-4">
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-image text-info fs-2 d-block mb-2"></i>
<strong>Reference Photo</strong>
<div class="small text-muted mt-1">Something you have</div>
<div class="small text-success">~80-256 bits</div>
<div class="small text-success mt-auto pt-2">~80-256 bits</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-chat-quote text-warning fs-2 d-block mb-2"></i>
<strong>Daily Phrase</strong>
<div class="small text-muted mt-1">Something you know (rotates)</div>
<div class="small text-success">~33 bits (3 words)</div>
<strong>Passphrase</strong>
<div class="small text-muted mt-1">Something you know</div>
<div class="small text-success mt-auto pt-2">~44 bits (4 words)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-123 text-danger fs-2 d-block mb-2"></i>
<strong>Static PIN</strong>
<div class="small text-muted mt-1">Something you know (fixed)</div>
<div class="small text-success">~20 bits (6 digits)</div>
<div class="small text-muted mt-1">Something you know</div>
<div class="small text-success mt-auto pt-2">~20 bits (6 digits)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<div class="col-6 col-lg-3 mb-3">
<div class="p-3 bg-dark rounded h-100 d-flex flex-column align-items-center">
<i class="bi bi-key text-primary fs-2 d-block mb-2"></i>
<strong>RSA Key</strong>
<div class="small text-muted mt-1">Something you have (optional)</div>
<div class="small text-success">~128 bits (2048-bit)</div>
<div class="small text-muted mt-1">Optional</div>
<div class="small text-success mt-auto pt-2">~128 bits</div>
</div>
</div>
</div>
<div class="alert alert-secondary">
<i class="bi bi-calculator me-2"></i>
<strong>Combined entropy:</strong> 130-400+ bits depending on configuration.
For reference, 128 bits is considered computationally infeasible to brute force.
<strong>Combined entropy:</strong> 144-424+ bits. 128 bits is infeasible to brute force.
</div>
<h6 class="mt-4">Key Derivation</h6>
<p>
{% if has_argon2 %}
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id Available</span>
Using <strong>Argon2id</strong> with 256MB memory cost — the winner of the Password Hashing Competition
and current best practice for key derivation.
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id</span>
256MB memory cost. Memory-hard KDF defeats GPU/ASIC attacks.
{% else %}
<span class="badge bg-warning text-dark me-1"><i class="bi bi-exclamation-triangle"></i> Argon2 Not Available</span>
Falling back to <strong>PBKDF2-SHA512</strong> with 600,000 iterations.
Using PBKDF2-SHA512 with 600k iterations.
Install <code>argon2-cffi</code> for stronger security.
{% endif %}
</p>
<h6 class="mt-4">Steganography Technique</h6>
<p>
Uses <strong>LSB (Least Significant Bit)</strong> embedding with pseudo-random pixel selection.
The pixel locations are determined by a key derived from your credentials, making the
hidden data's location unpredictable without the correct inputs.
</p>
</div>
</div>
<div class="card mb-4">
<!-- Channel Keys (v4.0.0) -->
<div class="card mb-4" id="channel-keys">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-earmark-binary me-2"></i>File Embedding</h5>
<h5 class="mb-0">
<i class="bi bi-broadcast me-2"></i>Channel Keys
<span class="badge bg-info ms-2">v4.0</span>
</h5>
</div>
<div class="card-body">
<p>
<span class="badge bg-info me-1">New in v2.1</span>
Stegasoo now supports embedding <strong>any file type</strong>, not just text messages.
Channel keys provide <strong>deployment/group isolation</strong>. Messages encoded with one channel key
cannot be decoded with a different key, even if all other credentials match.
</p>
<div class="row">
<div class="col-md-6">
<h6><i class="bi bi-check2-square text-success me-2"></i>Supported</h6>
<ul class="small">
<li>PDF documents</li>
<li>ZIP/RAR archives</li>
<li>Office documents (DOCX, XLSX, PPTX)</li>
<li>Source code files</li>
<li>Any binary file up to {{ max_payload_kb }} KB</li>
</ul>
<div class="row mt-4">
<!-- Auto Mode -->
<div class="col-md-4 mb-3">
<div class="card bg-dark h-100">
<div class="card-header text-center">
<i class="bi bi-gear-fill text-success fs-2 d-block mb-2"></i>
<strong>Auto</strong>
</div>
<div class="card-body">
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
<ul class="small mb-0">
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
<li>Or <code>channel_key</code> in config file</li>
<li>All users share the same channel</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<h6><i class="bi bi-info-circle text-info me-2"></i>How It Works</h6>
<ul class="small">
<li>Original filename is preserved</li>
<li>MIME type is stored for proper handling</li>
<li>File is encrypted identically to text</li>
<li>Decoding auto-detects text vs. file</li>
</ul>
<!-- Public Mode -->
<div class="col-md-4 mb-3">
<div class="card bg-dark h-100">
<div class="card-header text-center">
<i class="bi bi-globe text-info fs-2 d-block mb-2"></i>
<strong>Public</strong>
</div>
<div class="card-body">
<p class="small mb-2">No channel key. Compatible with other public installations.</p>
<ul class="small mb-0">
<li>Default if no server key configured</li>
<li>Anyone can decode (with credentials)</li>
<li>Interoperable between deployments</li>
</ul>
</div>
</div>
</div>
<!-- Custom Mode -->
<div class="col-md-4 mb-3">
<div class="card bg-dark h-100">
<div class="card-header text-center">
<i class="bi bi-key-fill text-warning fs-2 d-block mb-2"></i>
<strong>Custom</strong>
</div>
<div class="card-body">
<p class="small mb-2">Your own group key. Share with recipients.</p>
<ul class="small mb-0">
<li>Format: <code>XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX</code></li>
<li>32 chars (128 bits entropy)</li>
<li>Private group communication</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-info small mt-3">
<i class="bi bi-lightbulb me-2"></i>
<strong>Tip:</strong> For larger files, compress them first (ZIP) to maximize capacity.
A 16MP carrier image can hold approximately 6MB of raw data, but we limit payloads
to {{ max_payload_kb }} KB for reasonable processing times.
{% if channel_configured %}
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-shield-lock me-2"></i>
<strong>This server has a channel key configured:</strong>
<code class="ms-2">{{ channel_fingerprint }}</code>
<span class="text-muted ms-2">({{ channel_source }})</span>
</div>
{% else %}
<div class="alert alert-info mt-3 mb-0">
<i class="bi bi-info-circle me-2"></i>
This server is running in <strong>public mode</strong>.
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
</div>
{% endif %}
</div>
</div>
<!-- REST API Card - UPDATED BASED ON CURRENT IMPLEMENTATION -->
<!-- Version History -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>REST API</h5>
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
</div>
<div class="card-body">
<p>
<span class="badge bg-success me-1"><i class="bi bi-check-circle"></i> FastAPI</span>
Stegasoo includes a complete REST API built with FastAPI, featuring automatic documentation,
type validation, and comprehensive error handling.
</p>
<h6 class="mt-4"><i class="bi bi-layers me-2"></i>API Endpoints</h6>
<div class="row">
<div class="col-md-6">
<ul class="small">
<li><code>POST /generate</code> Generate credentials</li>
<li><code>POST /encode</code> Encode text message (JSON)</li>
<li><code>POST /encode/file</code> Encode binary file (JSON)</li>
<li><code>POST /encode/multipart</code> Encode with file uploads</li>
<li><code>POST /decode</code> Decode message (JSON)</li>
</ul>
</div>
<div class="col-md-6">
<ul class="small">
<li><code>POST /decode/multipart</code> Decode with file uploads</li>
<li><code>POST /extract-key-from-qr</code> Extract RSA key from QR</li>
<li><code>POST /image/info</code> Get image capacity</li>
<li><code>GET /</code> API status and capabilities</li>
</ul>
</div>
<div class="table-responsive">
<table class="table table-dark table-sm small">
<thead>
<tr>
<th>Version</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>4.0.0</strong></td>
<td>
<strong>Channel keys</strong> for group/deployment isolation,
DCT default, simplified auth, passphrase replaces day_phrase,
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
</td>
</tr>
<tr>
<td>3.2.0</td>
<td>Single passphrase, more default words</td>
</tr>
<tr>
<td>3.0.0</td>
<td>DCT mode, JPEG output, color preservation</td>
</tr>
<tr>
<td>2.2.0</td>
<td>QR code RSA key import/export</td>
</tr>
<tr>
<td>2.1.0</td>
<td>File embedding, compression</td>
</tr>
<tr>
<td>2.0.0</td>
<td>Web UI, REST API, RSA keys</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
</div>
<h6 class="mt-4"><i class="bi bi-file-earmark-code me-2"></i>JSON API Examples</h6>
<pre class="bg-dark p-3 rounded"><code>// Generate credentials
curl -X POST "http://localhost:8000/generate" \
-H "Content-Type: application/json" \
-d '{"use_pin": true, "use_rsa": false, "pin_length": 6, "words_per_phrase": 3}'
// Encode text message
curl -X POST "http://localhost:8000/encode" \
-H "Content-Type: application/json" \
-d '{
"message": "secret message",
"reference_photo_base64": "BASE64_ENCODED_PHOTO",
"carrier_image_base64": "BASE64_ENCODED_IMAGE",
"day_phrase": "apple forest thunder",
"pin": "123456"
}'
// Encode file (base64)
curl -X POST "http://localhost:8000/encode/file" \
-H "Content-Type: application/json" \
-d '{
"file_data_base64": "BASE64_ENCODED_FILE",
"filename": "document.pdf",
"reference_photo_base64": "BASE64_ENCODED_PHOTO",
"carrier_image_base64": "BASE64_ENCODED_IMAGE",
"day_phrase": "apple forest thunder",
"pin": "123456"
}'</code></pre>
<h6 class="mt-4"><i class="bi bi-upload me-2"></i>Multipart API Examples</h6>
<pre class="bg-dark p-3 rounded"><code># Encode text with file uploads
curl -X POST "http://localhost:8000/encode/multipart" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@carrier.png" \
-F "message=secret" \
--output stego.png
# Encode file with QR code key
curl -X POST "http://localhost:8000/encode/multipart" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
-F "reference_photo=@photo.jpg" \
-F "carrier=@carrier.png" \
-F "payload_file=@document.pdf" \
-F "rsa_key_qr=@keyqr.png" \
--output stego.png
# Decode with file uploads
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" \
--output result.json</code></pre>
<h6 class="mt-4"><i class="bi bi-qr-code me-2"></i>QR Code Support</h6>
<p class="small">
The API can extract RSA keys from QR code images. QR code reading requires
<code>pyzbar</code> and <code>libzbar</code> system library.
</p>
<pre class="bg-dark p-3 rounded"><code># Extract key from QR code
curl -X POST "http://localhost:8000/extract-key-from-qr" \
-F "qr_image=@keyqr.png"</code></pre>
<div class="alert alert-info small mt-3">
<i class="bi bi-journal-text me-2"></i>
<strong>Interactive Documentation:</strong> When running the API server, visit
<code>/docs</code> for Swagger UI or <code>/redoc</code> for ReDoc documentation.
All endpoints include detailed schemas and example requests.
</div>
<h6 class="mt-4"><i class="bi bi-terminal me-2"></i>Command Line Interface</h6>
<p class="small">
Stegasoo also includes a full-featured CLI. Install with <code>pip install stegasoo[cli]</code>
or see the <a href="/cli">CLI documentation</a> for complete usage.
</p>
<pre class="bg-dark p-3 rounded"><code># CLI Examples
stegasoo generate --pin --words 3
stegasoo encode -r photo.jpg -c meme.png -p "phrase" --pin 123456 -m "secret"
stegasoo decode -r photo.jpg -s stego.png -p "phrase" --pin 123456
stegasoo info image.png</code></pre>
<p class="small text-muted mt-3 mb-0">
API version: {{ version }} &bull;
<span class="badge bg-{% if has_argon2 %}success{% else %}warning{% endif %} me-1">
{% if has_argon2 %}Argon2 Available{% else %}PBKDF2 Fallback{% endif %}
</span>
<span class="badge bg-{% if has_qrcode_read %}success{% else %}secondary{% endif %}">
{% if has_qrcode_read %}QR Reading Available{% else %}QR Reading Not Available{% endif %}
</span>
</p>
</div>
</div>
@@ -325,11 +401,11 @@ stegasoo info image.png</code></pre>
<div id="setup" class="accordion-collapse collapse show" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Both parties agree on a <strong>reference photo</strong> (shared secretly, never transmitted)</li>
<li>Go to <a href="/generate">Generate</a> and create credentials</li>
<li><strong>Memorize</strong> the 7 daily phrases and PIN</li>
<li>If using RSA, download and securely store the key file</li>
<li>Share credentials with your contact through a secure channel</li>
<li>Agree on a <strong>reference photo</strong> (never transmitted)</li>
<li>Go to <a href="/generate">Generate</a> to create credentials</li>
<li>Memorize passphrase and PIN</li>
<li>If using RSA, store the key file securely</li>
<li>Share credentials via secure channel</li>
</ol>
</div>
</div>
@@ -339,20 +415,23 @@ stegasoo info image.png</code></pre>
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#encoding">
<i class="bi bi-2-circle me-2"></i>Encoding a Message or File
<i class="bi bi-2-circle me-2"></i>Encoding
</button>
</h2>
<div id="encoding" class="accordion-collapse collapse" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Go to <a href="/encode">Encode</a></li>
<li>Upload your <strong>reference photo</strong></li>
<li>Upload a <strong>carrier image</strong> (the image to hide data in)</li>
<li>Choose <strong>Text</strong> or <strong>File</strong> mode</li>
<li>Enter your message or select a file to embed</li>
<li>Enter <strong>today's phrase</strong> and your PIN/key</li>
<li>Download the resulting stego image</li>
<li>Send the stego image through any channel (email, social media, etc.)</li>
<li>Upload <strong>reference photo</strong> and <strong>carrier image</strong></li>
<li>Choose mode:
<ul>
<li><strong>DCT</strong> (default): social media</li>
<li><strong>LSB</strong>: email, cloud, direct transfer</li>
</ul>
</li>
<li>Enter message or select file</li>
<li>Enter passphrase and PIN/key</li>
<li>Download stego image</li>
</ol>
</div>
</div>
@@ -362,23 +441,21 @@ stegasoo info image.png</code></pre>
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#decoding">
<i class="bi bi-3-circle me-2"></i>Decoding a Message or File
<i class="bi bi-3-circle me-2"></i>Decoding
</button>
</h2>
<div id="decoding" class="accordion-collapse collapse" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Go to <a href="/decode">Decode</a></li>
<li>Upload your <strong>reference photo</strong> (same one used for encoding)</li>
<li>Upload the <strong>stego image</strong> you received</li>
<li>Enter the phrase for <strong>the day it was encoded</strong> (check the filename for date)</li>
<li>Enter your PIN and/or RSA key</li>
<li>View the decoded message or download the extracted file</li>
<li>Upload <strong>reference photo</strong></li>
<li>Upload <strong>stego image</strong></li>
<li>Enter passphrase and PIN/key</li>
<li>View message or download file</li>
</ol>
<div class="alert alert-warning small mt-3 mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
The stego image filename contains the encoding date (e.g., <code>abc123_20251228.png</code>).
Use this to determine which day's phrase to use!
<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-magic me-2"></i>
Mode is auto-detected.
</div>
</div>
</div>
@@ -387,65 +464,66 @@ stegasoo info image.png</code></pre>
</div>
</div>
<div class="card">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits &amp; Specifications</h5>
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits &amp; Specs</h5>
</div>
<div class="card-body">
<table class="table table-dark table-striped">
<table class="table table-dark table-striped small">
<tbody>
<tr>
<td><i class="bi bi-file-text me-2"></i>Max text message</td>
<td><strong>2 million characters</strong> (~2 MB)</td>
<td><i class="bi bi-file-text me-2"></i>Max text</td>
<td><strong>2M characters</strong></td>
</tr>
<tr>
<td><i class="bi bi-file-earmark me-2"></i>Max file payload</td>
<td><i class="bi bi-file-earmark me-2"></i>Max file</td>
<td><strong>{{ max_payload_kb }} KB</strong></td>
</tr>
<tr>
<td><i class="bi bi-image me-2"></i>Max carrier image</td>
<td><strong>16 megapixels</strong> (~6000×4000)</td>
<td><i class="bi bi-image me-2"></i>Max carrier</td>
<td><strong>24 MP</strong> (~6000x4000)</td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload size</td>
<td><i class="bi bi-soundwave me-2"></i>DCT capacity</td>
<td><strong>~75 KB/MP</strong></td>
</tr>
<tr>
<td><i class="bi bi-grid-3x3 me-2"></i>LSB capacity</td>
<td><strong>~375 KB/MP</strong></td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload</td>
<td><strong>30 MB</strong></td>
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>Temp file expiry</td>
<td><strong>5 minutes</strong></td>
<td><i class="bi bi-clock me-2"></i>File expiry</td>
<td><strong>5 min</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN length</td>
<td><i class="bi bi-key me-2"></i>PIN</td>
<td><strong>6-9 digits</strong></td>
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA key sizes</td>
<td><strong>2048, 3072, 4096 bits</strong></td>
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Phrase length</td>
<td><strong>3-12 words</strong> (BIP-39 wordlist)</td>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
<td><strong>3-12 words</strong> (BIP-39)</td>
</tr>
<tr>
<td><i class="bi bi-cpu me-2"></i>API documentation</td>
<td><strong>/docs (Swagger)</strong> and <strong>/redoc</strong></td>
<td><i class="bi bi-code me-2"></i>Python Version</td>
<td><strong>3.10-3.12</strong></td>
</tr>
<tr>
<td><i class="bi bi-qr-code me-2"></i>QR code support</td>
<td><strong>RSA key encoding/extraction </strong>(up to 3072 bit keys)</td>
<td><i class="bi bi-box me-2"></i>Built with</td>
<td>Flask, Pillow, NumPy, SciPy, jpegio, cryptography, argon2-cffi</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="text-center mt-4 text-muted small">
<p>
Stegasoo v2.1.0 &bull;
<i class="bi bi-github me-1"></i>Open Source &bull;
Built with Python, FastAPI, and cryptography
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -47,8 +47,8 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else 'check-circle' }} me-2"></i>
<div class="alert alert-{{ 'danger' if category == 'error' else ('warning' if category == 'warning' else 'success') }} alert-dismissible fade show" role="alert">
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@@ -63,7 +63,7 @@
<div class="container text-center text-muted">
<small>
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
Stegasoo v2.1.3 — Hybrid Photo + Day-Phrase + PIN Steganography
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
</small>
</div>
</footer>

View File

@@ -3,6 +3,110 @@
{% block title %}Decode Message - Stegasoo{% endblock %}
{% block content %}
<style>
/* Glowing passphrase input */
.passphrase-input {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(99, 179, 237, 0.3) !important;
color: #63b3ed !important;
font-family: 'Courier New', monospace;
font-size: 1.1rem;
letter-spacing: 0.5px;
padding: 12px 16px;
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.passphrase-input:focus {
border-color: rgba(99, 179, 237, 0.8) !important;
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.passphrase-input::placeholder {
color: rgba(99, 179, 237, 0.4);
}
/* Glowing PIN input */
.pin-input-container .form-control {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(246, 173, 85, 0.3) !important;
color: #f6ad55 !important;
font-family: 'Courier New', monospace;
font-size: 1.2rem;
letter-spacing: 3px;
text-align: center;
transition: all 0.3s ease;
}
.pin-input-container .form-control:focus {
border-color: rgba(246, 173, 85, 0.8) !important;
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.pin-input-container .form-control::placeholder {
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
.qr-crop-container img {
display: block;
max-height: 180px;
max-width: 180px;
width: auto;
margin: 0 auto;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.qr-crop-container .qr-original {
opacity: 1;
}
.qr-crop-container .qr-cropped {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
max-height: 160px;
min-width: 140px;
min-height: 140px;
object-fit: contain;
}
.qr-crop-container.scan-complete .qr-original {
opacity: 0;
transform: scale(1.1);
filter: blur(4px);
}
.qr-crop-container.scan-complete .qr-cropped {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.qr-crop-container .crop-badge {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 0.65rem;
opacity: 0;
transition: opacity 0.3s ease 0.4s;
}
.qr-crop-container.scan-complete .crop-badge {
opacity: 1;
}
</style>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
@@ -17,14 +121,14 @@
</div>
<label class="form-label text-muted">Decoded Message:</label>
<div class="position-relative">
<div class="alert-message p-3 rounded bg-dark border border-secondary" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
<button class="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => this.innerHTML = '<i class=\'bi bi-check\'></i>').catch(() => alert('Failed to copy'))">
<i class="bi bi-clipboard"></i> Copy
</button>
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-2" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-sm btn-outline-light" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => { this.innerHTML = '<i class=\'bi bi-check\'></i> Copied!'; setTimeout(() => this.innerHTML = '<i class=\'bi bi-clipboard\'></i> Copy', 2000); }).catch(() => alert('Failed to copy'))">
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
<a href="/decode" class="btn btn-outline-light w-100 mt-3">
<a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a>
@@ -64,13 +168,37 @@
<label class="form-label">
<i class="bi bi-image me-1"></i> Reference Photo
</label>
<div class="drop-zone">
<div class="drop-zone scan-container" id="refDropZone">
<input type="file" name="reference_photo" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none">
<img class="drop-zone-preview d-none" id="refPreview">
<!-- Scan overlay elements -->
<div class="scan-overlay">
<div class="scan-grid"></div>
<div class="scan-line"></div>
</div>
<!-- Corner brackets (shown after scan) -->
<div class="scan-corners">
<div class="scan-corner tl"></div>
<div class="scan-corner tr"></div>
<div class="scan-corner bl"></div>
<div class="scan-corner br"></div>
</div>
<!-- Data panel (shown after scan) -->
<div class="scan-data-panel">
<div class="scan-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="refFileName">image.jpg</span>
</div>
<div class="scan-data-row">
<span class="scan-status-badge">Hash Acquired</span>
<span class="scan-data-value" id="refFileSize">--</span>
</div>
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
</div>
</div>
<div class="form-text">
The same reference photo used for encoding
@@ -81,13 +209,36 @@
<label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
</label>
<div class="drop-zone" id="stegoDropZone">
<div class="drop-zone pixel-container" id="stegoDropZone">
<input type="file" name="stego_image" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none">
<img class="drop-zone-preview d-none" id="stegoPreview">
<!-- Pixel blocks overlay - populated by JS -->
<div class="pixel-blocks"></div>
<!-- Pixel scan line -->
<div class="pixel-scan-line"></div>
<!-- Corner brackets -->
<div class="pixel-corners">
<div class="pixel-corner tl"></div>
<div class="pixel-corner tr"></div>
<div class="pixel-corner bl"></div>
<div class="pixel-corner br"></div>
</div>
<!-- Data panel -->
<div class="pixel-data-panel">
<div class="pixel-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="stegoFileName">image.png</span>
</div>
<div class="pixel-data-row">
<span class="pixel-status-badge">Stego Loaded</span>
<span class="pixel-data-value" id="stegoFileSize">--</span>
</div>
<div class="pixel-dimensions" id="stegoDims">-- × -- px</div>
</div>
</div>
<div class="form-text">
The image containing the hidden message/file
@@ -96,13 +247,13 @@
</div>
<div class="mb-3">
<label class="form-label" id="dayPhraseLabel">
<i class="bi bi-chat-quote me-1"></i> Day Phrase
<label class="form-label">
<i class="bi bi-chat-quote me-1"></i> Passphrase
</label>
<input type="text" name="day_phrase" class="form-control"
placeholder="e.g., correct horse battery" required>
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
placeholder="e.g., correct horse battery staple" required>
<div class="form-text">
The phrase for the day the message was encoded
The passphrase used during encoding (typically 4 words)
</div>
</div>
@@ -113,62 +264,172 @@
<span class="text-warning small">(provide same factors used during encoding)</span>
</h6>
<div class="row">
<div class="col-md-6 mb-3">
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle -->
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
<i class="bi bi-file-earmark me-1"></i>.pem File
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
<i class="bi bi-qr-code me-1"></i>QR Code
</label>
</div>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-4 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
<button class="btn btn-outline-secondary" type="button" id="togglePin">
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">
If PIN was used during encoding
</div>
<div class="form-text">If PIN was used during encoding</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="col-md-8 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
<i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
</label>
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button">
<i class="bi bi-file-earmark me-1"></i>.pem File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTabDec" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTabDec" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<div class="tab-pane fade" id="rsaQrTabDec" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div>
</div>
<select class="form-select" name="channel_key" id="channelSelectDec">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
<option value="none">Public</option>
<option value="custom">Custom</option>
</select>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2">
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
</div>
<div class="form-text">
If RSA key was used during encoding (file or QR image)
{% endif %}
</div>
</div>
</div>
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInputDec">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInputDec">
</div>
</div>
</div>
<!-- RSA Key Password (shown when key selected) -->
<div class="mb-3 d-none" id="rsaPasswordGroup">
<label class="form-label">
<i class="bi bi-key me-1"></i> RSA Key Password
</label>
<input type="password" name="rsa_password" class="form-control"
placeholder="Password for the .pem file (if encrypted)">
<div class="form-text">
Leave blank if your key file is not password-protected
<!-- ================================================================
ADVANCED OPTIONS (v3.0) - Extraction Mode
================================================================ -->
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Advanced Options
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
</a>
<div class="collapse" id="advancedOptionsDec">
<div class="card card-body mt-2 bg-dark border-secondary">
<!-- Extraction Mode Selection -->
<div class="mb-0">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Extraction Mode
<span class="badge bg-info ms-1">v3.0</span>
</label>
<div class="d-flex gap-2">
<!-- Auto Mode -->
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
<i class="bi bi-magic text-success"></i>
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
</label>
<!-- LSB Mode -->
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
<i class="bi bi-grid-3x3-gap text-primary"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
</label>
<!-- DCT Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
</label>
</div>
<div class="form-text mt-2">
<i class="bi bi-lightbulb me-1"></i>
<strong>Auto</strong> tries LSB first, then DCT.
{% if not has_dct %}
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@@ -187,24 +448,36 @@
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
<ul class="list-unstyled text-muted small mb-0">
<li class="mb-2">
<i class="bi bi-dot"></i>
Make sure you're using the <strong>exact same reference photo</strong> file
<i class="bi bi-check-circle-fill text-success me-1"></i>
Use the <strong>exact same reference photo</strong> file (byte-for-byte identical)
</li>
<li class="mb-2">
<i class="bi bi-dot"></i>
Use the phrase for the <strong>day the message was encoded</strong>, not today
<i class="bi bi-check-circle-fill text-success me-1"></i>
Enter the <strong>exact passphrase</strong> used during encoding (case-sensitive, spacing matters)
</li>
<li class="mb-2">
<i class="bi bi-dot"></i>
<i class="bi bi-check-circle-fill text-success me-1"></i>
Provide the <strong>same security factors</strong> (PIN and/or RSA key) used during encoding
</li>
<li class="mb-2">
<i class="bi bi-dot"></i>
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
Ensure the stego image hasn't been <strong>resized, cropped, or recompressed</strong>
</li>
<li class="mb-2">
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
<strong>Format compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format)
</li>
<li class="mb-2">
<i class="bi bi-broadcast text-info me-1"></i>
<strong>Channel key:</strong> Use the same channel (Auto/Public/Custom) that was used during encoding
</li>
<li class="mb-2">
<i class="bi bi-info-circle-fill text-info me-1"></i>
If using an RSA key, verify the <strong>password is correct</strong> (if key is encrypted)
</li>
<li class="mb-0">
<i class="bi bi-dot"></i>
If using an RSA key, make sure the <strong>password is correct</strong>
<i class="bi bi-info-circle-fill text-info me-1"></i>
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong> in Advanced Options
</li>
</ul>
</div>
@@ -215,158 +488,30 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// Form submit loading state
document.getElementById('decodeForm')?.addEventListener('submit', function() {
const btn = document.getElementById('decodeBtn');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Decoding...';
btn.disabled = true;
// Extraction mode button active state toggle
const extractModeRadios = document.querySelectorAll('input[name="embed_mode"]');
const extractModeBtns = {
'auto': document.getElementById('autoModeCard'),
'lsb': document.getElementById('lsbModeCardDec'),
'dct': document.getElementById('dctModeCardDec')
};
extractModeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
extractModeBtns[radio.value]?.classList.add('active');
});
});
// Show RSA password field when key is selected (only for .pem files, not QR)
const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
if (rsaKeyInput) {
rsaKeyInput.addEventListener('change', function() {
// Show password field only for .pem files
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
// Clear QR input if file is selected
if (rsaKeyQrInput && this.files.length) {
rsaKeyQrInput.value = '';
}
});
}
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
// Hide password field for QR codes (they're unencrypted)
rsaPasswordGroup.classList.add('d-none');
// Clear file input if QR is selected
if (rsaKeyInput && this.files.length) {
rsaKeyInput.value = '';
}
});
}
// Day names for date detection
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
// Detect day from filename
function detectDayFromFilename(filename) {
const dateMatch = filename.match(/_(\d{4})[-]?(\d{2})[-]?(\d{2})/);
if (dateMatch) {
const [, year, month, day] = dateMatch;
const date = new Date(year, month - 1, day);
return dayNames[date.getDay()];
}
return null;
}
// Update day phrase label
function updateDayLabel(dayName) {
const label = document.getElementById('dayPhraseLabel');
if (label && dayName) {
label.innerHTML = `<i class="bi bi-chat-quote me-1"></i> ${dayName}'s Phrase`;
}
}
// PIN Toggle
document.getElementById('togglePin')?.addEventListener('click', function() {
const input = document.getElementById('pinInput');
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
// Advanced options chevron
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
});
// Paste from Clipboard
document.addEventListener('paste', function(e) {
if (!document.getElementById('decodeForm')) return;
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
const stegoInput = document.querySelector('input[name="stego_image"]');
const refInput = document.querySelector('input[name="reference_photo"]');
const targetInput = (!stegoInput.files.length) ? stegoInput : refInput;
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
break;
}
}
});
// Drag & drop with preview
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
const isStegoZone = zone.id === 'stegoDropZone';
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.remove('drag-over');
});
});
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
const file = e.dataTransfer.files[0];
showPreview(file);
if (isStegoZone) {
const dayName = detectDayFromFilename(file.name);
updateDayLabel(dayName);
}
}
});
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
showPreview(file);
if (isStegoZone) {
const dayName = detectDayFromFilename(file.name);
updateDayLabel(dayName);
}
}
});
function showPreview(file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
preview.src = e.target.result;
preview.classList.remove('d-none');
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
};
reader.readAsDataURL(file);
}
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
});
</script>
{% endblock %}

View File

@@ -3,6 +3,114 @@
{% block title %}Encode Message - Stegasoo{% endblock %}
{% block content %}
<style>
/* Glowing passphrase input */
.passphrase-input-container {
position: relative;
}
.passphrase-input {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(99, 179, 237, 0.3) !important;
color: #63b3ed !important;
font-family: 'Courier New', monospace;
font-size: 1.1rem;
letter-spacing: 0.5px;
padding: 12px 16px;
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.passphrase-input:focus {
border-color: rgba(99, 179, 237, 0.8) !important;
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.passphrase-input::placeholder {
color: rgba(99, 179, 237, 0.4);
}
/* Glowing PIN input */
.pin-input-container .form-control {
background: rgba(30, 40, 50, 0.8) !important;
border: 2px solid rgba(246, 173, 85, 0.3) !important;
color: #f6ad55 !important;
font-family: 'Courier New', monospace;
font-size: 1.2rem;
letter-spacing: 3px;
text-align: center;
transition: all 0.3s ease;
}
.pin-input-container .form-control:focus {
border-color: rgba(246, 173, 85, 0.8) !important;
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
background: rgba(30, 40, 50, 0.95) !important;
}
.pin-input-container .form-control::placeholder {
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
/* QR Crop Animation */
.qr-crop-container {
position: relative;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
.qr-crop-container img {
display: block;
max-height: 180px;
max-width: 180px;
width: auto;
margin: 0 auto;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.qr-crop-container .qr-original {
opacity: 1;
}
.qr-crop-container .qr-cropped {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
max-height: 160px;
min-width: 140px;
min-height: 140px;
object-fit: contain;
}
.qr-crop-container.scan-complete .qr-original {
opacity: 0;
transform: scale(1.1);
filter: blur(4px);
}
.qr-crop-container.scan-complete .qr-cropped {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.qr-crop-container .crop-badge {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 0.65rem;
opacity: 0;
transition: opacity 0.3s ease 0.4s;
}
.qr-crop-container.scan-complete .crop-badge {
opacity: 1;
}
</style>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
@@ -11,20 +119,44 @@
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data" id="encodeForm">
<input type="hidden" name="client_date" id="clientDate" value="">
<!-- Removed client_date hidden field -->
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-image me-1"></i> Reference Photo
</label>
<div class="drop-zone" id="refDropZone">
<div class="drop-zone scan-container" id="refDropZone">
<input type="file" name="reference_photo" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none" id="refPreview">
<!-- Scan overlay elements -->
<div class="scan-overlay">
<div class="scan-grid"></div>
<div class="scan-line"></div>
</div>
<!-- Corner brackets (shown after scan) -->
<div class="scan-corners">
<div class="scan-corner tl"></div>
<div class="scan-corner tr"></div>
<div class="scan-corner bl"></div>
<div class="scan-corner br"></div>
</div>
<!-- Data panel (shown after scan) -->
<div class="scan-data-panel">
<div class="scan-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="refFileName">image.jpg</span>
</div>
<div class="scan-data-row">
<span class="scan-status-badge">Hash Acquired</span>
<span class="scan-data-value" id="refFileSize">--</span>
</div>
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
</div>
</div>
<div class="form-text">
The secret photo both parties have (NOT transmitted)
@@ -35,13 +167,36 @@
<label class="form-label">
<i class="bi bi-file-image me-1"></i> Carrier Image
</label>
<div class="drop-zone" id="carrierDropZone">
<input type="file" name="carrier" accept="image/*" required>
<div class="drop-zone pixel-container" id="carrierDropZone">
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none" id="carrierPreview">
<!-- Pixel blocks overlay - populated by JS -->
<div class="pixel-blocks"></div>
<!-- Pixel scan line -->
<div class="pixel-scan-line"></div>
<!-- Corner brackets -->
<div class="pixel-corners">
<div class="pixel-corner tl"></div>
<div class="pixel-corner tr"></div>
<div class="pixel-corner bl"></div>
<div class="pixel-corner br"></div>
</div>
<!-- Data panel -->
<div class="pixel-data-panel">
<div class="pixel-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="carrierFileName">image.jpg</span>
</div>
<div class="pixel-data-row">
<span class="pixel-status-badge">Carrier Loaded</span>
<span class="pixel-data-value" id="carrierFileSize">--</span>
</div>
<div class="pixel-dimensions" id="carrierDims">-- × -- px</div>
</div>
</div>
<div class="form-text">
The image to hide your message in (e.g., a meme)
@@ -49,6 +204,48 @@
</div>
</div>
<!-- Capacity Info Panel (shown when carrier loaded) -->
<div class="alert alert-info small d-none" id="capacityPanel">
<div class="row align-items-center">
<div class="col">
<i class="bi bi-rulers me-1"></i>
<strong>Carrier:</strong> <span id="carrierDimensions">-</span>
</div>
<div class="col-auto">
<span class="badge bg-warning text-dark me-1" id="dctCapacityBadge">DCT: -</span>
<span class="badge bg-primary" id="lsbCapacityBadge">LSB: -</span>
</div>
</div>
</div>
<!-- Embedding Mode Selection -->
<div class="mb-4">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Embedding Mode
</label>
<div class="d-flex gap-2">
<!-- DCT Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %} {% if has_dct %}active{% endif %}" id="dctModeCard" for="modeDct">
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
<i class="bi bi-soundwave text-warning ms-2"></i>
<span class="ms-2"><strong>DCT</strong> <span class="text-muted">· Social Media</span></span>
<i class="bi bi-info-circle text-muted mode-info-icon ms-2" data-bs-toggle="tooltip" data-bs-html="true" title="<b>DCT Mode</b><br>• JPEG output<br>• Survives recompression<br>• ~75 KB/MP capacity"></i>
{% if not has_dct %}
<span class="small text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>Requires scipy</span>
{% endif %}
</label>
<!-- LSB Mode -->
<label class="mode-btn flex-fill {% if not has_dct %}active{% endif %}" id="lsbModeCard" for="modeLsb">
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted">· Email & Files</span></span>
<i class="bi bi-info-circle text-muted mode-info-icon ms-2" data-bs-toggle="tooltip" data-bs-html="true" title="<b>LSB Mode</b><br>• Full color PNG output<br>• Higher capacity (~375 KB/MP)"></i>
</label>
</div>
</div>
<!-- Payload Type Selector -->
<div class="mb-3">
<label class="form-label">
@@ -108,14 +305,22 @@
</div>
</div>
<!-- Passphrase input with glow styling -->
<div class="mb-3">
<label class="form-label" id="dayPhraseLabel">
<i class="bi bi-chat-quote me-1"></i> {{ day_of_week }}'s Phrase
<label class="form-label" id="passphraseLabel">
<i class="bi bi-chat-quote me-1"></i> Passphrase
</label>
<input type="text" name="day_phrase" class="form-control"
placeholder="e.g., correct horse battery" required>
<div class="passphrase-input-container">
<input type="text" name="passphrase" class="form-control passphrase-input"
placeholder="e.g., apple forest thunder mountain" required
id="passphraseInput">
</div>
<div class="form-text">
Your phrase for <strong>today</strong> (based on your local timezone)
Your passphrase for this message
</div>
<div class="form-text mt-1" id="passphraseWarning" style="display: none;">
<i class="bi bi-exclamation-triangle text-warning me-1"></i>
Passphrase should have at least {{ recommended_passphrase_words }} words for good security
</div>
</div>
@@ -126,56 +331,175 @@
<span class="text-warning small">(provide at least one: PIN or RSA Key)</span>
</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
<button class="btn btn-outline-secondary" type="button" id="togglePin">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Your static 6-9 digit PIN (if configured)</div>
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle -->
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
<i class="bi bi-file-earmark me-1"></i>.pem File
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
<i class="bi bi-qr-code me-1"></i>QR Code
</label>
</div>
<div class="col-md-6 mb-3">
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-4 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Static 6-9 digit PIN</div>
</div>
</div>
<div class="col-md-8 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
<i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
</label>
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTab" type="button">
<i class="bi bi-file-earmark me-1"></i>.pem File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTab" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTab" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
<div class="form-text small">Shared .pem format key file.</div>
</div>
<div class="tab-pane fade" id="rsaQrTab" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div>
</div>
<select class="form-select" name="channel_key" id="channelSelect">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
<option value="none">Public</option>
<option value="custom">Custom</option>
</select>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2" id="channelServerInfo">
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInput">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInput">
<button class="btn btn-outline-secondary" type="button" id="channelKeyGenerate" title="Generate random key">
<i class="bi bi-shuffle"></i>
</button>
</div>
<div class="invalid-feedback" id="channelKeyError">
Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
</div>
</div>
</div>
<!-- RSA Key Password (shown when key selected) -->
<div class="mb-3 d-none" id="rsaPasswordGroup">
<label class="form-label">
<i class="bi bi-key me-1"></i> RSA Key Password
</label>
<input type="password" name="rsa_password" class="form-control"
placeholder="Password for the .pem file (if encrypted)">
<div class="form-text">
Leave blank if your key file is not password-protected (not needed for QR codes)
<!-- Advanced Options (DCT sub-options only) -->
<div class="mb-4 {% if not has_dct %}d-none{% endif %}" id="advancedOptionsContainer">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptions" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> DCT Options
<i class="bi bi-chevron-down ms-1" id="advancedChevron"></i>
</a>
<div class="collapse" id="advancedOptions">
<div class="card card-body mt-2 bg-dark border-secondary py-3">
<!-- DCT Color Mode - Compact -->
<div class="mb-3">
<label class="form-label small mb-2">
<i class="bi bi-palette me-1"></i> Color
</label>
<div class="d-flex gap-2">
<label class="mode-btn equal-width active" id="dctColorCard" for="dctColorColor">
<input class="form-check-input" type="radio" name="dct_color_mode" id="dctColorColor" value="color" checked>
<i class="bi bi-palette-fill text-success"></i>
<span class="ms-2"><strong>Color</strong> <span class="badge bg-success ms-1">Default</span></span>
</label>
<label class="mode-btn equal-width" id="dctGrayscaleCard" for="dctColorGrayscale">
<input class="form-check-input" type="radio" name="dct_color_mode" id="dctColorGrayscale" value="grayscale">
<i class="bi bi-circle-half text-secondary"></i>
<span class="ms-2"><strong>Grayscale</strong></span>
</label>
</div>
</div>
<!-- DCT Output Format - Compact -->
<div class="mb-0">
<label class="form-label small mb-2">
<i class="bi bi-file-image me-1"></i> Format
</label>
<div class="d-flex gap-2">
<label class="mode-btn equal-width active" id="dctJpegCard" for="dctFormatJpeg">
<input class="form-check-input" type="radio" name="dct_output_format" id="dctFormatJpeg" value="jpeg" checked>
<i class="bi bi-file-earmark-richtext text-warning"></i>
<span class="ms-2"><strong>JPEG</strong> <span class="badge bg-warning text-dark ms-1">Default</span></span>
</label>
<label class="mode-btn equal-width" id="dctPngCard" for="dctFormatPng">
<input class="form-check-input" type="radio" name="dct_output_format" id="dctFormatPng" value="png">
<i class="bi bi-file-earmark-image text-primary"></i>
<span class="ms-2"><strong>PNG</strong> <span class="text-muted d-none d-sm-inline">· Lossless</span></span>
</label>
</div>
</div>
</div>
</div>
</div>
@@ -204,7 +528,7 @@
<div class="alert alert-secondary mt-4 small">
<i class="bi bi-info-circle me-1"></i>
<strong>Limits:</strong>
Carrier image max ~24 megapixels (6000×4000).
Carrier image max ~24 megapixels (6000x4000).
Files max 30MB upload.
Payload max {{ max_payload_kb }} KB.
</div>
@@ -215,28 +539,12 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// Detect client's local date and day
const now = new Date();
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const localDay = dayNames[now.getDay()];
const localDate = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0');
// ============================================================================
// ENCODE PAGE - Payload type switching
// ============================================================================
// Update day label to client's local day
const dayLabel = document.getElementById('dayPhraseLabel');
if (dayLabel) {
dayLabel.innerHTML = `<i class="bi bi-chat-quote me-1"></i> ${localDay}'s Phrase`;
}
// Set hidden field with client's local date for server
const dateInput = document.getElementById('clientDate');
if (dateInput) {
dateInput.value = localDate;
}
// Payload type switching
const payloadTextRadio = document.getElementById('payloadText');
const payloadFileRadio = document.getElementById('payloadFile');
const textSection = document.getElementById('textPayloadSection');
@@ -249,203 +557,197 @@ function updatePayloadSection() {
textSection.classList.toggle('d-none', !isText);
fileSection.classList.toggle('d-none', isText);
// Update required attribute
if (isText) {
messageInput.required = true;
payloadFileInput.required = false;
messageInput.setAttribute('required', '');
payloadFileInput.removeAttribute('required');
} else {
messageInput.required = false;
payloadFileInput.required = true;
messageInput.removeAttribute('required');
payloadFileInput.setAttribute('required', '');
}
}
payloadTextRadio.addEventListener('change', updatePayloadSection);
payloadFileRadio.addEventListener('change', updatePayloadSection);
payloadTextRadio?.addEventListener('change', updatePayloadSection);
payloadFileRadio?.addEventListener('change', updatePayloadSection);
// File payload info display
const fileInfo = document.getElementById('fileInfo');
const fileInfoName = document.getElementById('fileInfoName');
const fileInfoSize = document.getElementById('fileInfoSize');
const payloadDropLabel = document.getElementById('payloadDropLabel');
// ============================================================================
// ENCODE PAGE - Passphrase validation
// ============================================================================
const passphraseInput = document.getElementById('passphraseInput');
const passphraseWarning = document.getElementById('passphraseWarning');
passphraseInput?.addEventListener('input', function() {
const words = this.value.trim().split(/\s+/).filter(w => w.length > 0);
const recommendedWords = {{ recommended_passphrase_words }};
if (passphraseWarning) {
passphraseWarning.style.display = (words.length > 0 && words.length < recommendedWords) ? 'block' : 'none';
}
});
// ============================================================================
// ENCODE PAGE - Payload file info
// ============================================================================
payloadFileInput?.addEventListener('change', function() {
const fileInfo = document.getElementById('fileInfo');
const fileInfoName = document.getElementById('fileInfoName');
const fileInfoSize = document.getElementById('fileInfoSize');
payloadFileInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
fileInfoName.textContent = file.name;
fileInfoSize.textContent = formatFileSize(file.size);
fileInfo.classList.remove('d-none');
payloadDropLabel.innerHTML = `<i class="bi bi-check-circle text-success fs-3 d-block mb-2"></i><span>${file.name}</span>`;
fileInfo?.classList.remove('d-none');
if (fileInfoName) fileInfoName.textContent = file.name;
if (fileInfoSize) fileInfoSize.textContent = (file.size / 1024).toFixed(1) + ' KB';
const label = document.getElementById('payloadDropLabel');
if (label) label.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${file.name}`;
} else {
fileInfo.classList.add('d-none');
payloadDropLabel.innerHTML = `<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i><span class="text-muted">Drop any file or click to browse</span><div class="small text-muted mt-1">Max {{ max_payload_kb }} KB</div>`;
fileInfo?.classList.add('d-none');
}
});
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
// ============================================================================
// ENCODE PAGE - Character counter
// ============================================================================
messageInput?.addEventListener('input', function() {
const count = this.value.length;
const max = 250000;
const percent = Math.round((count / max) * 100);
const charCount = document.getElementById('charCount');
const charPercent = document.getElementById('charPercent');
const charWarning = document.getElementById('charWarning');
if (charCount) charCount.textContent = count.toLocaleString();
if (charPercent) charPercent.textContent = percent + '%';
charWarning?.classList.toggle('d-none', percent < 80);
});
// ============================================================================
// ENCODE PAGE - Carrier capacity
// ============================================================================
const capacityPanel = document.getElementById('capacityPanel');
const carrierInput = document.getElementById('carrierInput');
carrierInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
fetchCapacityComparison(this.files[0]);
}
});
function fetchCapacityComparison(file) {
const formData = new FormData();
formData.append('carrier', file);
fetch('/api/compare-capacity', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.error) return;
const dims = document.getElementById('carrierDimensions');
const lsbBadge = document.getElementById('lsbCapacityBadge');
const dctBadge = document.getElementById('dctCapacityBadge');
if (dims) dims.textContent = `${data.width} × ${data.height} (${(data.width * data.height / 1000000).toFixed(1)} MP)`;
if (lsbBadge) lsbBadge.textContent = `LSB: ${data.lsb.capacity_kb} KB`;
if (dctBadge) dctBadge.textContent = `DCT: ${data.dct.capacity_kb} KB`;
capacityPanel?.classList.remove('d-none');
})
.catch(err => console.error('Capacity fetch failed:', err));
}
// Show RSA password field when key is selected (only for .pem files, not QR)
const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
// ============================================================================
// ENCODE PAGE - Mode switching (LSB/DCT)
// ============================================================================
if (rsaKeyInput) {
rsaKeyInput.addEventListener('change', function() {
// Show password field only for .pem files
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
// Clear QR input if file is selected
if (rsaKeyQrInput && this.files.length) {
rsaKeyQrInput.value = '';
}
});
}
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
// Hide password field for QR codes (they're unencrypted)
rsaPasswordGroup.classList.add('d-none');
// Clear file input if QR is selected
if (rsaKeyInput && this.files.length) {
rsaKeyInput.value = '';
}
});
}
// Form submit loading state
document.getElementById('encodeForm').addEventListener('submit', function(e) {
const btn = document.getElementById('encodeBtn');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...';
btn.disabled = true;
// Initialize tooltips for mode info icons
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
// Character counter for text
const charCount = document.getElementById('charCount');
const charWarning = document.getElementById('charWarning');
const charPercent = document.getElementById('charPercent');
const maxChars = 250000;
// Mode button active state toggle
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'dct': document.getElementById('dctModeCard'), 'lsb': document.getElementById('lsbModeCard') };
messageInput.addEventListener('input', function() {
const len = this.value.length;
charCount.textContent = len.toLocaleString();
const pct = Math.round((len / maxChars) * 100);
charPercent.textContent = pct + '%';
charWarning.classList.toggle('d-none', len < maxChars * 0.8);
charCount.classList.toggle('text-danger', len > maxChars * 0.95);
modeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
});
});
// Drag & drop with preview for images
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
const isPayloadZone = zone.id === 'payloadDropZone';
// Show/hide DCT options
const modeDct = document.getElementById('modeDct');
const advancedOptionsContainer = document.getElementById('advancedOptionsContainer');
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.add('drag-over');
});
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
radio.addEventListener('change', () => {
advancedOptionsContainer?.classList.toggle('d-none', !modeDct?.checked);
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
zone.classList.remove('drag-over');
});
});
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
if (!isPayloadZone) {
showPreview(e.dataTransfer.files[0]);
}
}
});
if (!isPayloadZone) {
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
showPreview(this.files[0]);
}
});
}
function showPreview(file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
if (preview) {
preview.src = e.target.result;
preview.classList.remove('d-none');
}
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
};
reader.readAsDataURL(file);
}
});
// PIN Toggle Logic
document.getElementById('togglePin').addEventListener('click', function() {
const input = document.getElementById('pinInput');
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
// DCT color mode button active state toggle
const colorModeRadios = document.querySelectorAll('input[name="dct_color_mode"]');
const colorModeBtns = { 'color': document.getElementById('dctColorCard'), 'grayscale': document.getElementById('dctGrayscaleCard') };
colorModeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(colorModeBtns).forEach(btn => btn?.classList.remove('active'));
colorModeBtns[radio.value]?.classList.add('active');
});
});
// Prevent Same File Selection
// DCT format button active state toggle
const formatRadios = document.querySelectorAll('input[name="dct_output_format"]');
const formatBtns = { 'png': document.getElementById('dctPngCard'), 'jpeg': document.getElementById('dctJpegCard') };
formatRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(formatBtns).forEach(btn => btn?.classList.remove('active'));
formatBtns[radio.value]?.classList.add('active');
});
});
// Advanced options chevron
const advancedOptionsEl = document.getElementById('advancedOptions');
advancedOptionsEl?.addEventListener('show.bs.collapse', () => {
document.getElementById('advancedChevron')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
});
advancedOptionsEl?.addEventListener('hide.bs.collapse', () => {
document.getElementById('advancedChevron')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
});
// ============================================================================
// ENCODE PAGE - Duplicate file check
// ============================================================================
function checkDuplicateFiles() {
const refInput = document.querySelector('input[name="reference_photo"]');
const carInput = document.querySelector('input[name="carrier"]');
if (refInput.files[0] && carInput.files[0]) {
if (refInput?.files[0] && carInput?.files[0]) {
if (refInput.files[0].name === carInput.files[0].name &&
refInput.files[0].size === carInput.files[0].size) {
alert("Security Warning: You cannot use the same image for both Reference and Carrier!");
carInput.value = '';
document.getElementById('carrierPreview').classList.add('d-none');
document.querySelector('#carrierDropZone .drop-zone-label').innerHTML =
'<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' +
'<span class="text-muted">Drop image or click to browse</span>';
document.getElementById('carrierPreview')?.classList.add('d-none');
const label = document.querySelector('#carrierDropZone .drop-zone-label');
if (label) {
label.innerHTML = '<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i><span class="text-muted">Drop image or click to browse</span>';
}
capacityPanel?.classList.add('d-none');
}
}
}
document.querySelector('input[name="reference_photo"]').addEventListener('change', checkDuplicateFiles);
document.querySelector('input[name="carrier"]').addEventListener('change', checkDuplicateFiles);
// Paste from Clipboard
document.addEventListener('paste', function(e) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
const carrierInput = document.querySelector('input[name="carrier"]');
const refInput = document.querySelector('input[name="reference_photo"]');
const targetInput = (!carrierInput.files.length) ? carrierInput : refInput;
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
break;
}
}
});
document.querySelector('input[name="reference_photo"]')?.addEventListener('change', checkDuplicateFiles);
document.querySelector('input[name="carrier"]')?.addEventListener('change', checkDuplicateFiles);
</script>
{% endblock %}

View File

@@ -7,7 +7,9 @@
<div class="col-lg-6">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-check-circle me-2"></i>Encoding Successful!</h5>
<h5 class="mb-0">
<i class="bi bi-check-circle me-2"></i>Encoding Successful!
</h5>
</div>
<div class="card-body text-center">
<div class="my-4">
@@ -34,6 +36,81 @@
<code class="fs-5">{{ filename }}</code>
</div>
<!-- Mode and format badges -->
<div class="mb-4">
{% if embed_mode == 'dct' %}
<span class="badge bg-info fs-6">
<i class="bi bi-soundwave me-1"></i>DCT Mode
</span>
<!-- Color mode badge (v3.0.1) -->
{% if color_mode == 'color' %}
<span class="badge bg-success fs-6 ms-1">
<i class="bi bi-palette-fill me-1"></i>Color
</span>
{% else %}
<span class="badge bg-secondary fs-6 ms-1">
<i class="bi bi-circle-half me-1"></i>Grayscale
</span>
{% endif %}
<!-- Output format badge -->
{% if output_format == 'jpeg' %}
<span class="badge bg-warning text-dark fs-6 ms-1">
<i class="bi bi-file-earmark-richtext me-1"></i>JPEG
</span>
<div class="small text-muted mt-2">
{% if color_mode == 'color' %}
Color JPEG, frequency domain embedding (Q=95)
{% else %}
Grayscale JPEG, frequency domain embedding (Q=95)
{% endif %}
</div>
{% else %}
<span class="badge bg-primary fs-6 ms-1">
<i class="bi bi-file-earmark-image me-1"></i>PNG
</span>
<div class="small text-muted mt-2">
{% if color_mode == 'color' %}
Color PNG, frequency domain embedding (lossless)
{% else %}
Grayscale PNG, frequency domain embedding (lossless)
{% endif %}
</div>
{% endif %}
{% else %}
<span class="badge bg-primary fs-6">
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
</span>
<span class="badge bg-success fs-6 ms-1">
<i class="bi bi-palette-fill me-1"></i>Full Color
</span>
<span class="badge bg-primary fs-6 ms-1">
<i class="bi bi-file-earmark-image me-1"></i>PNG
</span>
<div class="small text-muted mt-2">Full color PNG, spatial LSB embedding</div>
{% endif %}
<!-- Channel info (v4.0.0) -->
<div class="mt-3">
{% if channel_mode == 'private' %}
<span class="badge bg-warning text-dark fs-6">
<i class="bi bi-shield-lock me-1"></i>Private Channel
</span>
{% if channel_fingerprint %}
<div class="small text-muted mt-1">
<code>{{ channel_fingerprint }}</code>
</div>
{% endif %}
{% else %}
<span class="badge bg-info fs-6">
<i class="bi bi-globe me-1"></i>Public Channel
</span>
{% endif %}
</div>
</div>
<div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn">
@@ -53,7 +130,20 @@
<ul class="mb-0 mt-2">
<li>This file expires in <strong>5 minutes</strong></li>
<li>Do <strong>not</strong> resize or recompress the image</li>
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
<li>JPEG format is lossy - avoid re-saving or editing</li>
{% else %}
<li>PNG format preserves your hidden data</li>
{% endif %}
{% if embed_mode == 'dct' %}
<li>Recipient needs <strong>DCT mode</strong> or <strong>Auto</strong> detection to decode</li>
{% if color_mode == 'color' %}
<li>Color preserved - extraction works on both color and grayscale</li>
{% endif %}
{% endif %}
{% if channel_mode == 'private' %}
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
{% endif %}
</ul>
</div>
@@ -72,13 +162,14 @@
const shareBtn = document.getElementById('shareBtn');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}";
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}";
if (navigator.share && navigator.canShare) {
// Check if we can share files
fetch(fileUrl)
.then(response => response.blob())
.then(blob => {
const file = new File([blob], fileName, { type: 'image/png' });
const file = new File([blob], fileName, { type: mimeType });
if (navigator.canShare({ files: [file] })) {
shareBtn.style.display = 'block';
shareBtn.addEventListener('click', async () => {

View File

@@ -3,7 +3,7 @@
{% block title %}Generate Credentials - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="row justify-content-center" data-page="generate">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
@@ -14,14 +14,18 @@
<!-- Generation Form -->
<form method="POST">
<div class="mb-4">
<label class="form-label">Words per Phrase</label>
<input type="range" class="form-range" name="words_per_phrase"
min="3" max="12" value="3" id="wordsRange">
<label class="form-label">Words per Passphrase</label>
<input type="range" class="form-range" name="words_per_passphrase"
min="{{ min_passphrase_words }}" max="12" value="{{ default_passphrase_words }}" id="wordsRange">
<div class="d-flex justify-content-between small text-muted">
<span>3 (33 bits)</span>
<span id="wordsValue" class="text-primary fw-bold">3 words (~33 bits)</span>
<span>{{ min_passphrase_words }} (~33 bits)</span>
<span id="wordsValue" class="text-primary fw-bold">{{ default_passphrase_words }} words (~44 bits)</span>
<span>12 (132 bits)</span>
</div>
<div class="form-text">
<i class="bi bi-shield-check me-1"></i>
Recommended: <strong>{{ recommended_passphrase_words }}+ words</strong> for good security
</div>
</div>
<hr>
@@ -58,15 +62,44 @@
</div>
<div class="mt-2 d-none" id="rsaOptions">
<label class="form-label small">Key Size</label>
<select name="rsa_bits" class="form-select form-select-sm">
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</select>
<div class="form-text text-warning d-none" id="rsaQrWarning">
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys &gt;3072 bits
</div>
</div>
</div>
</div>
<hr class="my-4">
<!-- Channel Key Generation (v4.0.0) -->
<div class="mb-4">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel Key
<span class="badge bg-info ms-1">v4.0</span>
<a href="{{ url_for('about') }}#channel-keys" class="text-muted ms-2" title="Learn about channel keys">
<i class="bi bi-question-circle"></i>
</a>
</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
placeholder="Click Generate" readonly>
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
<i class="bi bi-shuffle me-1"></i>Generate
</button>
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text">For private groups: generate, then use <strong>Custom</strong> mode when encoding/decoding.</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
<i class="bi bi-shuffle me-2"></i>Generate Credentials
</button>
@@ -106,25 +139,57 @@
{% endif %}
<div class="mb-4">
<h6 class="text-muted"><i class="bi bi-chat-quote me-2"></i>DAILY PHRASES</h6>
<div class="table-responsive">
<table class="table table-dark table-striped mb-0">
<tbody>
{% for day in days %}
<tr>
<td class="text-muted" style="width: 100px;">{{ day }}</td>
<td>
<span class="font-monospace phrase-display" id="phrase{{ loop.index }}">{{ phrases[day] }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h6 class="text-muted">
<i class="bi bi-chat-quote me-2"></i>PASSPHRASE
</h6>
<div class="passphrase-container">
<div class="passphrase-display" id="passphraseDisplay">
<code class="passphrase-text">{{ passphrase }}</code>
</div>
<div class="passphrase-buttons mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="togglePassphraseVisibility()">
<i class="bi bi-eye-slash" id="passphraseToggleIcon"></i>
<span id="passphraseToggleText">Hide</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="copyPassphrase()">
<i class="bi bi-clipboard" id="passphraseCopyIcon"></i>
<span id="passphraseCopyText">Copy</span>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleMemoryAid()">
<i class="bi bi-lightbulb" id="memoryAidIcon"></i>
<span id="memoryAidText">Memory Aid</span>
</button>
</div>
</div>
<div class="text-end mt-2">
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAllPhrases()">
<i class="bi bi-eye-slash me-1"></i>Toggle Visibility
</button>
<!-- Memory Aid Story -->
<div class="memory-aid-container mt-3 d-none" id="memoryAidContainer">
<div class="card bg-dark border-primary">
<div class="card-header bg-primary text-white">
<i class="bi bi-book me-2"></i>Memory Story
</div>
<div class="card-body">
<p class="memory-story mb-3" id="memoryStory">
<!-- Story will be generated by JavaScript -->
</p>
<div class="form-text">
<i class="bi bi-info-circle me-1"></i>
This story is generated from your passphrase to help you remember it.
The words appear in order within the narrative.
</div>
<button type="button" class="btn btn-sm btn-outline-light mt-2" onclick="regenerateStory()">
<i class="bi bi-arrow-repeat me-1"></i>Generate Different Story
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3 mb-0">
<small class="text-muted">
({{ words_per_passphrase }} words = ~{{ passphrase_entropy }} bits entropy)
</small>
</div>
</div>
@@ -229,8 +294,9 @@
<div class="row text-center">
<div class="col">
<div class="p-2 bg-dark rounded">
<div class="small text-muted">Phrase</div>
<div class="fs-5 text-info">{{ phrase_entropy }} bits</div>
<div class="small text-muted">Passphrase</div>
<div class="fs-5 text-info">{{ passphrase_entropy }} bits</div>
<div class="small text-muted">{{ words_per_passphrase }} words</div>
</div>
</div>
{% if pin_entropy %}
@@ -279,7 +345,7 @@
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-2"></i>About Credentials</h6>
<ul class="small text-muted mb-0">
<li class="mb-2">
<strong>Daily phrases</strong> rotate each day of the week for forward secrecy
<strong>Passphrase</strong> is a single phrase you use each time
</li>
<li class="mb-2">
<strong>PIN</strong> is static and adds another factor both parties must know
@@ -343,9 +409,69 @@
min-width: 80px;
}
/* Passphrase Container */
.passphrase-container {
background: linear-gradient(145deg, #1e1e2e 0%, #2d2d44 100%);
border: 1px solid #0dcaf0;
border-radius: 16px;
padding: 1.5rem 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3), 0 0 40px rgba(13, 202, 240, 0.1);
}
.passphrase-display {
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(13, 202, 240, 0.3);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
transition: filter 0.3s ease;
}
.passphrase-display.blurred {
filter: blur(8px);
user-select: none;
}
.passphrase-text {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 1.5rem;
font-weight: bold;
color: #0dcaf0;
text-shadow: 0 0 10px rgba(13, 202, 240, 0.5);
word-wrap: break-word;
display: block;
line-height: 1.6;
}
.passphrase-buttons {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.passphrase-buttons .btn {
min-width: 100px;
}
/* Memory Aid */
.memory-story {
font-size: 1.1rem;
line-height: 1.8;
color: #e9ecef;
}
.memory-story .passphrase-word {
font-weight: bold;
color: #0dcaf0;
text-decoration: underline;
text-decoration-style: wavy;
text-decoration-color: rgba(13, 202, 240, 0.5);
}
/* Responsive */
@media (max-width: 576px) {
.pin-container {
.pin-container, .passphrase-container {
padding: 1rem 1.25rem;
}
@@ -358,40 +484,59 @@
.pin-digits-row {
gap: 0.35rem;
}
.passphrase-text {
font-size: 1.2rem;
}
.memory-story {
font-size: 1rem;
}
}
</style>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
<script>
// ============================================================================
// GENERATE PAGE - Form Controls
// ============================================================================
// Words range slider
const wordsRange = document.getElementById('wordsRange');
const wordsValue = document.getElementById('wordsValue');
if (wordsRange) {
wordsRange.addEventListener('input', function() {
const bits = this.value * 11;
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
});
}
wordsRange?.addEventListener('input', function() {
const bits = this.value * 11;
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
});
// Toggle PIN/RSA options
// Toggle PIN/RSA options visibility
const usePinCheck = document.getElementById('usePinCheck');
const useRsaCheck = document.getElementById('useRsaCheck');
const pinOptions = document.getElementById('pinOptions');
const rsaOptions = document.getElementById('rsaOptions');
const rsaQrWarning = document.getElementById('rsaQrWarning');
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
if (usePinCheck) {
usePinCheck.addEventListener('change', function() {
pinOptions.classList.toggle('d-none', !this.checked);
});
}
usePinCheck?.addEventListener('change', function() {
pinOptions?.classList.toggle('d-none', !this.checked);
});
if (useRsaCheck) {
useRsaCheck.addEventListener('change', function() {
rsaOptions.classList.toggle('d-none', !this.checked);
});
}
useRsaCheck?.addEventListener('change', function() {
rsaOptions?.classList.toggle('d-none', !this.checked);
});
// RSA key size QR warning (>3072 bits)
rsaBitsSelect?.addEventListener('change', function() {
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
});
{% if generated %}
// ============================================================================
// GENERATE PAGE - Credential Display
// ============================================================================
// PIN visibility toggle
let pinHidden = false;
@@ -401,41 +546,155 @@ function togglePinVisibility() {
const text = document.getElementById('pinToggleText');
pinHidden = !pinHidden;
pinDigits?.classList.toggle('blurred', pinHidden);
if (pinHidden) {
pinDigits.classList.add('blurred');
icon.className = 'bi bi-eye';
text.textContent = 'Show';
} else {
pinDigits.classList.remove('blurred');
icon.className = 'bi bi-eye-slash';
text.textContent = 'Hide';
}
if (icon) icon.className = pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
if (text) text.textContent = pinHidden ? 'Show' : 'Hide';
}
// Copy PIN
function copyPin() {
const pin = '{{ pin|default("", true) }}';
const icon = document.getElementById('pinCopyIcon');
const text = document.getElementById('pinCopyText');
navigator.clipboard.writeText(pin).then(() => {
icon.className = 'bi bi-check';
text.textContent = 'Copied!';
setTimeout(() => {
icon.className = 'bi bi-clipboard';
text.textContent = 'Copy';
}, 2000);
});
Stegasoo.copyToClipboard(
'{{ pin|default("", true) }}',
document.getElementById('pinCopyIcon'),
document.getElementById('pinCopyText')
);
}
// Toggle all phrases visibility
let phrasesHidden = false;
function toggleAllPhrases() {
phrasesHidden = !phrasesHidden;
document.querySelectorAll('.phrase-display').forEach(el => {
el.style.filter = phrasesHidden ? 'blur(8px)' : 'none';
});
// Passphrase visibility toggle
let passphraseHidden = false;
function togglePassphraseVisibility() {
const display = document.getElementById('passphraseDisplay');
const icon = document.getElementById('passphraseToggleIcon');
const text = document.getElementById('passphraseToggleText');
passphraseHidden = !passphraseHidden;
display?.classList.toggle('blurred', passphraseHidden);
if (icon) icon.className = passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
if (text) text.textContent = passphraseHidden ? 'Show' : 'Hide';
}
// Copy passphrase
function copyPassphrase() {
Stegasoo.copyToClipboard(
'{{ passphrase|default("", true) }}',
document.getElementById('passphraseCopyIcon'),
document.getElementById('passphraseCopyText')
);
}
// ============================================================================
// Memory Aid Story Generation - Templates by word count
// ============================================================================
const passphrase = '{{ passphrase|default("", true) }}';
const passphraseWords = passphrase.split(' ').filter(w => w.length > 0);
let currentStoryTemplate = 0;
// Templates organized by word count (3-12 words supported)
const storyTemplatesByLength = {
3: [
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}.`,
w => `${hl(w[0])} loves ${hl(w[1])} and ${hl(w[2])}.`,
w => `A ${hl(w[0])} found a ${hl(w[1])} near the ${hl(w[2])}.`,
w => `${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])} — never forget.`,
w => `The ${hl(w[0])} hid the ${hl(w[1])} under the ${hl(w[2])}.`,
],
4: [
w => `${hl(w[0])} and ${hl(w[1])} discovered a ${hl(w[2])} made of ${hl(w[3])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} ate ${hl(w[2])} for ${hl(w[3])}.`,
w => `In the ${hl(w[0])}, a ${hl(w[1])} met a ${hl(w[2])} carrying ${hl(w[3])}.`,
w => `${hl(w[0])} said "${hl(w[1])}" while holding a ${hl(w[2])} ${hl(w[3])}.`,
w => `The secret: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}.`,
],
5: [
w => `${hl(w[0])} traveled to ${hl(w[1])} seeking the ${hl(w[2])} of ${hl(w[3])} and ${hl(w[4])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} lived in a ${hl(w[2])} house with ${hl(w[3])} ${hl(w[4])}.`,
w => `"${hl(w[0])}!" shouted ${hl(w[1])} as the ${hl(w[2])} ${hl(w[3])} flew toward ${hl(w[4])}.`,
w => `Captain ${hl(w[0])} sailed the ${hl(w[1])} ${hl(w[2])} searching for ${hl(w[3])} ${hl(w[4])}.`,
w => `In ${hl(w[0])} kingdom, ${hl(w[1])} guards protected the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])}.`,
],
6: [
w => `${hl(w[0])} met ${hl(w[1])} at the ${hl(w[2])}. Together they found ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} wore a ${hl(w[2])} hat while eating ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
w => `Detective ${hl(w[0])} found ${hl(w[1])} ${hl(w[2])} near the ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
w => `In the ${hl(w[0])} ${hl(w[1])}, a ${hl(w[2])} ${hl(w[3])} sang about ${hl(w[4])} ${hl(w[5])}.`,
w => `Chef ${hl(w[0])} combined ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
],
7: [
w => `${hl(w[0])} and ${hl(w[1])} walked through the ${hl(w[2])} ${hl(w[3])} to find the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
w => `The ${hl(w[0])} professor studied ${hl(w[1])} ${hl(w[2])} while drinking ${hl(w[3])} ${hl(w[4])} with ${hl(w[5])} ${hl(w[6])}.`,
w => `"${hl(w[0])} ${hl(w[1])}!" yelled ${hl(w[2])} as ${hl(w[3])} ${hl(w[4])} attacked the ${hl(w[5])} ${hl(w[6])}.`,
w => `In ${hl(w[0])}, King ${hl(w[1])} decreed that ${hl(w[2])} ${hl(w[3])} must honor ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
],
8: [
w => `${hl(w[0])} ${hl(w[1])} and ${hl(w[2])} ${hl(w[3])} met at the ${hl(w[4])} ${hl(w[5])} to discuss ${hl(w[6])} ${hl(w[7])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} traveled from ${hl(w[3])} to ${hl(w[4])} carrying ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
w => `${hl(w[0])} discovered that ${hl(w[1])} ${hl(w[2])} plus ${hl(w[3])} ${hl(w[4])} equals ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
],
9: [
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} watched as ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} danced with ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
w => `In the ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}, three friends — ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])} — found ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
w => `The recipe: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}.`,
],
10: [
w => `${hl(w[0])} ${hl(w[1])} told ${hl(w[2])} ${hl(w[3])} about the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])} hidden in ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} lived beside ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
],
11: [
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} and ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} discovered ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
w => `In ${hl(w[0])} ${hl(w[1])}, the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} sang of ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
],
12: [
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} met ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} at the ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])} ${hl(w[11])}.`,
w => `The twelve treasures: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}, ${hl(w[9])}, ${hl(w[10])}, ${hl(w[11])}.`,
],
};
function hl(word) {
return `<span class="passphrase-word">${word}</span>`;
}
function generateStory(idx = null) {
const count = passphraseWords.length;
if (count === 0) return '';
// Clamp to supported range (3-12)
const templateKey = Math.max(3, Math.min(12, count));
const templates = storyTemplatesByLength[templateKey];
if (!templates || templates.length === 0) {
// Fallback: just list the words
return passphraseWords.map(w => hl(w)).join(' &mdash; ');
}
const templateIdx = (idx ?? currentStoryTemplate) % templates.length;
return templates[templateIdx](passphraseWords);
}
function toggleMemoryAid() {
const container = document.getElementById('memoryAidContainer');
const icon = document.getElementById('memoryAidIcon');
const text = document.getElementById('memoryAidText');
const isHidden = container?.classList.contains('d-none');
container?.classList.toggle('d-none', !isHidden);
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
if (isHidden) {
document.getElementById('memoryStory').innerHTML = generateStory();
}
}
function regenerateStory() {
const count = passphraseWords.length;
const templateKey = Math.max(3, Math.min(12, count));
const templates = storyTemplatesByLength[templateKey] || [];
currentStoryTemplate = (currentStoryTemplate + 1) % Math.max(1, templates.length);
document.getElementById('memoryStory').innerHTML = generateStory(currentStoryTemplate);
}
// Print QR code
@@ -444,46 +703,29 @@ function printQrCode() {
if (!qrImg) return;
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Stegasoo RSA Key QR Code</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: sans-serif;
}
img { max-width: 400px; }
.warning {
margin-top: 20px;
padding: 10px;
border: 2px solid #ff9800;
background: #fff3e0;
max-width: 400px;
text-align: center;
font-size: 12px;
}
</style>
</head>
<body>
<h2>Stegasoo RSA Private Key</h2>
<img src="${qrImg.src}" alt="RSA Key QR Code">
<div class="warning">
<strong>⚠️ SECURITY WARNING</strong><br>
This QR code contains your unencrypted RSA private key.<br>
Store securely and destroy after use.
</div>
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>
`);
printWindow.document.write(`<!DOCTYPE html>
<html>
<head>
<title>Stegasoo RSA Key QR Code</title>
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
img { max-width: 400px; }
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<h2>Stegasoo RSA Private Key</h2>
<img src="${qrImg.src}" alt="RSA Key QR Code">
<div class="warning">
<strong>⚠️ SECURITY WARNING</strong><br>
This QR code contains your unencrypted RSA private key.<br>
Store securely and destroy after use.
</div>
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>`);
printWindow.document.close();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -3,12 +3,38 @@
{% block title %}Stegasoo - Secure Steganography{% endblock %}
{% block content %}
<div class="text-center mb-5">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="120" class="mb-3">
<h1 class="display-4 fw-bold">Stegasoo</h1>
<p class="lead text-muted">Create hidden encrypted messages in images and photos using advanced steganography.</p>
<div class="row mb-4">
<div class="col-12">
<div class="d-flex align-items-end justify-content-center gap-4">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
<div style="margin-bottom: 40px;">
<h1 class="display-4 fw-bold mb-2">
Stegasoo
<span class="badge bg-success fs-6 ms-2">v4.0</span>
</h1>
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
</div>
</div>
</div>
</div>
<!-- Channel Status Banner (v4.0.0) -->
{% if channel_configured %}
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-shield-lock me-2"></i>
<strong>Private Channel Mode</strong>
</div>
<div class="key-capsule">
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
<code class="small ms-2">{{ channel_fingerprint }}</code>
</div>
</div>
</div>
{% endif %}
<div class="row g-4 mb-5">
<!-- Encode Card -->
<div class="col-md-4">
@@ -18,9 +44,9 @@
<i class="bi bi-lock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Encode Message</h5>
<h5 class="card-title">Encode</h5>
<p class="card-text text-muted">
Hide and enrypt secret data in an image like a photo or meme.
Hide encrypted messages or files inside images
</p>
</div>
</div>
@@ -35,9 +61,9 @@
<i class="bi bi-unlock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Decode Message</h5>
<h5 class="card-title">Decode</h5>
<p class="card-text text-muted">
Extract and decrypt data from Stegasoo-encoded images
Extract and decrypt hidden data from stego images
</p>
</div>
</div>
@@ -52,9 +78,9 @@
<i class="bi bi-key-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Generate Keys</h5>
<h5 class="card-title">Generate</h5>
<p class="card-text text-muted">
Create weekly phrase card with PIN and/or RSA key.
Create passphrases, PINs, and RSA keys
</p>
</div>
</div>
@@ -62,51 +88,81 @@
</div>
</div>
<div class="card">
<!-- Embedding Modes -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-6 mb-3 mb-md-0">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-soundwave text-warning fs-2 d-block mb-2"></i>
<strong>DCT Mode</strong>
<span class="badge bg-success ms-1">Default</span>
<div class="small text-muted mt-2">
Survives JPEG recompression<br>
Best for social media
</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-grid-3x3-gap text-primary fs-2 d-block mb-2"></i>
<strong>LSB Mode</strong>
<div class="small text-muted mt-2">
Higher capacity (~375 KB/MP)<br>
Best for email &amp; file transfer
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
<a href="/about" class="btn btn-sm btn-outline-light">Learn More</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-1-circle me-2"></i>Key Components</h6>
<ul class="list-unstyled">
<li class="mb-2">
<h6 class="text-primary"><i class="bi bi-key me-2"></i>You Provide</h6>
<ul class="list-unstyled small">
<li class="mb-1">
<i class="bi bi-image text-info me-2"></i>
<strong>Reference Photo:</strong> Any photo you and recipient both have
<strong>Reference Photo</strong>: shared secret
</li>
<li class="mb-2">
<li class="mb-1">
<i class="bi bi-chat-quote text-info me-2"></i>
<strong>Day Phrase:</strong> 3 to 12 words, one for each day of the week
<strong>Passphrase</strong>: 4+ words
</li>
<li class="mb-2">
<i class="bi bi-key text-info me-2"></i>
<strong>RSA Key:</strong> 2048, 3072, or 4096 bit PEM or printable QR code
</li>
<li class="mb-2">
<li class="mb-1">
<i class="bi bi-123 text-info me-2"></i>
<strong>Static PIN:</strong> 6-9 digits, same every day
<strong>PIN</strong>: 6-9 digits (or RSA key)
</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-2-circle me-2"></i>Security Features</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-shield-check text-success me-2"></i>
Perfect for async communication and use on air-gapped devices
</li>
<li class="mb-2">
<i class="bi bi-shield-check text-success me-2"></i>
Argon2id memory-hard key derivation (256MB)
</li>
<li class="mb-2">
<i class="bi bi-shuffle text-success me-2"></i>
Pseudo-random pixel selection (defeats steganalysis)
</li>
<li class="mb-2">
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>Security</h6>
<ul class="list-unstyled small">
<li class="mb-1">
<i class="bi bi-lock text-success me-2"></i>
AES-256-GCM authenticated encryption
AES-256-GCM encryption
</li>
<li class="mb-1">
<i class="bi bi-memory text-success me-2"></i>
Argon2id key derivation (256MB)
</li>
<li class="mb-1">
<i class="bi bi-shuffle text-success me-2"></i>
Pseudo-random embedding
</li>
<li class="mb-1">
<i class="bi bi-broadcast text-success me-2"></i>
<strong>Channel keys</strong> for group isolation
<span class="badge bg-info ms-1">v4.0</span>
</li>
</ul>
</div>

289
minimal_flask_crash.py Normal file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
Minimal Flask app to isolate the crash.
Run with: python minimal_flask_crash.py
Then test with:
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test2
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test3
"""
import io
import gc
import os
import sys
import tempfile
# Minimal imports first
from flask import Flask, request, jsonify
from PIL import Image
import numpy as np
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
# Check for jpegio
try:
import jpegio as jio
HAS_JPEGIO = True
print("jpegio: available")
except ImportError:
HAS_JPEGIO = False
print("jpegio: NOT available")
@app.route('/test1', methods=['POST'])
def test1_pil_only():
"""Test 1: PIL only, no jpegio, no scipy"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test1] Read {len(data)} bytes")
img = Image.open(io.BytesIO(data))
width, height = img.size
fmt = img.format
img.close()
print(f"[test1] Image: {width}x{height} {fmt}")
gc.collect()
print("[test1] Returning response...")
return jsonify({
'test': 'pil_only',
'width': width,
'height': height,
'format': fmt,
})
@app.route('/test2', methods=['POST'])
def test2_multiple_opens():
"""Test 2: Open image multiple times like compare_modes does"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test2] Read {len(data)} bytes")
# First open
img1 = Image.open(io.BytesIO(data))
width, height = img1.size
img1.close()
print(f"[test2] Open 1: {width}x{height}")
# Second open
img2 = Image.open(io.BytesIO(data))
pixels = img2.size[0] * img2.size[1]
img2.close()
print(f"[test2] Open 2: {pixels} pixels")
# Third open
img3 = Image.open(io.BytesIO(data))
blocks = (img3.size[0] // 8) * (img3.size[1] // 8)
img3.close()
print(f"[test2] Open 3: {blocks} blocks")
gc.collect()
print("[test2] Returning response...")
return jsonify({
'test': 'multiple_opens',
'width': width,
'height': height,
'pixels': pixels,
'blocks': blocks,
})
@app.route('/test3', methods=['POST'])
def test3_with_jpegio():
"""Test 3: Include jpegio operations"""
if not HAS_JPEGIO:
return jsonify({'error': 'jpegio not available'}), 501
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test3] Read {len(data)} bytes")
# Check if JPEG
img = Image.open(io.BytesIO(data))
is_jpeg = img.format == 'JPEG'
width, height = img.size
img.close()
print(f"[test3] Image: {width}x{height}, JPEG: {is_jpeg}")
if not is_jpeg:
return jsonify({'error': 'Not a JPEG'}), 400
# Write to temp file
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
os.write(fd, data)
os.close(fd)
print(f"[test3] Temp file: {temp_path}")
try:
# Read with jpegio
jpeg = jio.read(temp_path)
print(f"[test3] jpegio.read() OK")
coef = jpeg.coef_arrays[0]
coef_shape = coef.shape
print(f"[test3] Coef shape: {coef_shape}")
# Count positions like the real code does
positions = 0
h, w = coef.shape
for row in range(h):
for col in range(w):
if (row % 8 == 0) and (col % 8 == 0):
continue
if abs(coef[row, col]) >= 2:
positions += 1
print(f"[test3] Usable positions: {positions}")
# Cleanup
del coef
del jpeg
print(f"[test3] Deleted jpegio objects")
finally:
os.unlink(temp_path)
print(f"[test3] Removed temp file")
gc.collect()
print("[test3] Returning response...")
return jsonify({
'test': 'with_jpegio',
'width': width,
'height': height,
'coef_shape': list(coef_shape),
'positions': positions,
})
@app.route('/test4', methods=['POST'])
def test4_numpy_array_from_pil():
"""Test 4: Create numpy array from PIL image (like DCT does)"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
data = carrier.read()
print(f"[test4] Read {len(data)} bytes")
img = Image.open(io.BytesIO(data))
width, height = img.size
print(f"[test4] Image: {width}x{height}")
# Convert to grayscale and numpy array
gray = img.convert('L')
arr = np.array(gray, dtype=np.float64, copy=True)
print(f"[test4] Array: {arr.shape} {arr.dtype}")
# Close PIL images
gray.close()
img.close()
print(f"[test4] PIL closed")
# Do some numpy operations
mean_val = float(np.mean(arr))
std_val = float(np.std(arr))
print(f"[test4] Stats: mean={mean_val:.2f}, std={std_val:.2f}")
# Clear array
del arr
gc.collect()
print("[test4] Returning response...")
return jsonify({
'test': 'numpy_from_pil',
'width': width,
'height': height,
'mean': mean_val,
'std': std_val,
})
@app.route('/test5', methods=['POST'])
def test5_file_read_keep_reference():
"""Test 5: Keep reference to file data in request scope"""
carrier = request.files.get('carrier')
if not carrier:
return jsonify({'error': 'No carrier'}), 400
# Don't read into local variable - read directly each time
# This mimics potential issues with Flask's file handling
print(f"[test5] File object: {carrier}")
# Read once
carrier.seek(0)
data1 = carrier.read()
print(f"[test5] First read: {len(data1)} bytes")
img = Image.open(io.BytesIO(data1))
width, height = img.size
img.close()
# Try to read again (should be empty or need seek)
data2 = carrier.read()
print(f"[test5] Second read (no seek): {len(data2)} bytes")
carrier.seek(0)
data3 = carrier.read()
print(f"[test5] Third read (after seek): {len(data3)} bytes")
gc.collect()
print("[test5] Returning response...")
return jsonify({
'test': 'file_handling',
'width': width,
'height': height,
'read1': len(data1),
'read2': len(data2),
'read3': len(data3),
})
@app.after_request
def after_request(response):
"""Log after each request"""
print(f"[after_request] Response status: {response.status}")
return response
@app.teardown_request
def teardown_request(exception):
"""Log during teardown"""
if exception:
print(f"[teardown] Exception: {exception}")
else:
print("[teardown] Clean teardown")
gc.collect()
if __name__ == '__main__':
print("\n" + "=" * 60)
print("MINIMAL FLASK CRASH TEST")
print("=" * 60)
print("\nTest endpoints:")
print(" /test1 - PIL only")
print(" /test2 - Multiple PIL opens")
print(" /test3 - With jpegio")
print(" /test4 - NumPy array from PIL")
print(" /test5 - File handling test")
print("\nUsage:")
print(' curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1')
print("=" * 60 + "\n")
app.run(host='0.0.0.0', port=5001, debug=False, threaded=False)

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "2.0.1"
version = "4.0.1"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -43,24 +43,42 @@ dependencies = [
]
[project.optional-dependencies]
# DCT steganography support (v3.0+)
dct = [
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
]
cli = [
"click>=8.0.0",
"qrcode>=7.30"
]
compression = [
"lz4>=4.0.0",
]
web = [
"flask>=3.0.0",
"gunicorn>=21.0.0",
"qrcode>=7.3.0",
"pyzbar",
"pyzbar>=0.1.9",
# Include DCT support for web UI
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
]
api = [
"fastapi>=0.100.0",
"uvicorn[standard]>=0.20.0",
"python-multipart>=0.0.6",
"qrcode>=7.30",
"pyzbar>=0.1.9",
# Include DCT support for API
"numpy>=2.0.0",
"scipy>=1.10.0",
"jpegio>=0.2.0",
]
all = [
"stegasoo[cli,web,api]",
"stegasoo[cli,web,api,dct,compression]",
]
dev = [
"stegasoo[all]",
@@ -99,9 +117,18 @@ target-version = ["py310", "py311", "py312"]
[tool.ruff]
line-length = 100
exclude = ["frontends/web/test_routes.py"] # Debug snippet, not a real module
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
# YCbCr colorspace variables (R, G, B, Y, Cb, Cr) are standard names
"src/stegasoo/dct_steganography.py" = ["N803", "N806"]
# Package __init__.py has imports after try/except and aliases - intentional structure
"src/stegasoo/__init__.py" = ["E402"]
[tool.mypy]
python_version = "3.10"
warn_return_any = true

3
quick_web.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
cd ./frontends/web/
python app.py

4
rbld_containers.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
sudo docker-compose down
sudo docker-compose build
sudo docker-compose up -d

View File

@@ -27,6 +27,11 @@ click>=8.1.0
pytest>=7.4.0
pytest-cov>=4.1.0
scipy>=1.16.3
jpegio>=0.2.8
numpy>=2.4.0
# Optional: Better performance for Pillow
# pillow-simd>=9.0.0 # Uncomment if available for your platform

View File

@@ -1,10 +1,53 @@
#!/usr/bin/env python3
"""Main entry point."""
"""
Stegasoo - Main Entry Point
This module provides the main entry point for the stegasoo package.
It can be run directly or via the installed console script.
Usage:
python -m stegasoo --help
python src/main.py --help
stegasoo --help (if installed via pip)
"""
import sys
def main():
"""Main function."""
print("Hello, World!")
"""
Main entry point for Stegasoo CLI.
Delegates to the CLI module for command parsing and execution.
"""
try:
from stegasoo.cli import main as cli_main
cli_main()
except ImportError as e:
# Provide helpful error if dependencies are missing
print(f"Error: Could not import stegasoo package: {e}", file=sys.stderr)
print("\nMake sure stegasoo is installed:", file=sys.stderr)
print(" pip install -e .", file=sys.stderr)
print("\nOr run from the src directory:", file=sys.stderr)
print(" PYTHONPATH=src python -m stegasoo", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def version():
"""Print version and exit."""
try:
from stegasoo import __version__
print(f"stegasoo {__version__}")
except ImportError:
print("stegasoo (version unknown)")
if __name__ == "__main__":

View File

@@ -0,0 +1,374 @@
# Stegasoo v3.2.0 - Complete Change Summary
## Overview
This update makes two major breaking changes to Stegasoo:
1. **Remove date dependency** - Date no longer used in cryptographic operations
2. **Rename day_phrase → passphrase** - Reflects removal of daily rotation requirement
## Version Information
- **Previous**: v3.1.0 (date-dependent, day_phrase)
- **Current**: v3.2.0 (date-independent, passphrase)
- **Format Version**: 3 → 4 (breaking change)
- **Compatibility**: NOT backward compatible with v3.1.0
## Files Modified
### Core Files (MUST UPDATE)
1. **crypto.py** ✅ Updated
- Removed `date_str` parameter from all functions
- Renamed `day_phrase``passphrase` in all functions
- Removed date from key derivation material
- Simplified header format (no date field)
- Updated error messages
2. **constants.py** ✅ Updated
- Version: `__version__ = "3.2.0"`
- Format: `FORMAT_VERSION = 4`
- Added passphrase constants:
- `MIN_PASSPHRASE_WORDS = 3`
- `MAX_PASSPHRASE_WORDS = 12`
- `DEFAULT_PASSPHRASE_WORDS = 4` (increased from 3)
- `RECOMMENDED_PASSPHRASE_WORDS = 4`
- Kept legacy aliases for transition
3. **models.py** ✅ Updated
- `Credentials`: Changed from `phrases: dict``passphrase: str`
- `EncodeInput`: Renamed `day_phrase``passphrase`, removed `date_str`
- `DecodeInput`: Renamed `day_phrase``passphrase`
- `EncodeResult`: Made `date_used` optional (cosmetic only)
- `DecodeResult`: `date_encoded` always None in v3.2.0
- `ValidationResult`: Added `warning` field
4. **validation.py** ✅ Updated
- Renamed `validate_phrase()``validate_passphrase()`
- Added word count validation with warnings
- Recommends 4+ words for good security
- Updated error messages
### Files Needing Updates
5. **__init__.py** - Public API
- [ ] `encode()`: Remove `date_str`, rename `day_phrase``passphrase`
- [ ] `encode_file()`: Same changes
- [ ] `encode_bytes()`: Same changes
- [ ] `decode()`: Remove `date_str`, rename `day_phrase``passphrase`
- [ ] `decode_text()`: Same changes
- [ ] Update all docstrings
6. **keygen.py** - Key generation
- [ ] `generate_day_phrases()``generate_passphrases()` or keep with new implementation
- [ ] `generate_credentials()`: Update to use single passphrase
- [ ] Update `Credentials` creation
7. **batch.py** - Batch operations
- [ ] `BatchCredentials`: Rename `day_phrase``passphrase`
- [ ] Update all batch functions
8. **cli.py** - Command line
- [ ] `--phrase``--passphrase` (or keep `--phrase` for simplicity)
- [ ] Update help text
- [ ] Update credentials dict creation
9. **steganography.py** - No changes needed
- Uses keys from crypto module, doesn't directly handle phrases/dates
10. **dct_steganography.py** - No changes needed
- Uses keys from crypto module
### Optional/Documentation Files
11. **utils.py** - Keep as-is (organizational functions)
12. **debug.py** - No changes needed
13. **exceptions.py** - No changes needed
14. **compression.py** - No changes needed
15. **qr_utils.py** - No changes needed
## Key Changes Breakdown
### 1. Function Signatures
**Before (v3.1.0):**
```python
def derive_hybrid_key(
photo_data: bytes,
day_phrase: str,
date_str: str,
salt: bytes,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> bytes:
```
**After (v3.2.0):**
```python
def derive_hybrid_key(
photo_data: bytes,
passphrase: str,
salt: bytes,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> bytes:
```
### 2. Key Derivation Material
**Before:**
```python
key_material = (
photo_hash +
day_phrase.lower().encode() +
pin.encode() +
date_str.encode() + # ← REMOVED
salt
)
```
**After:**
```python
key_material = (
photo_hash +
passphrase.lower().encode() +
pin.encode() +
salt
)
```
### 3. Header Format
**Before (v3.1.0):** 66+ bytes
```
[Magic:4][Version:1][DateLen:1][Date:10][Salt:32][IV:12][Tag:16][Ciphertext]
```
**After (v3.2.0):** 65 bytes
```
[Magic:4][Version:1][Salt:32][IV:12][Tag:16][Ciphertext]
```
### 4. Public API
**Before:**
```python
# Encoding
result = encode(
message="Secret",
reference_photo=photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456",
date_str="2025-01-15"
)
# Decoding
decoded = decode(
stego_image=stego,
reference_photo=photo,
day_phrase="apple forest thunder",
pin="123456",
date_str="2025-01-15"
)
```
**After:**
```python
# Encoding
result = encode(
message="Secret",
reference_photo=photo,
carrier_image=carrier,
passphrase="apple forest thunder mountain",
pin="123456"
)
# Decoding
decoded = decode(
stego_image=stego,
reference_photo=photo,
passphrase="apple forest thunder mountain",
pin="123456"
)
```
## Migration Path
### For Users with v3.1.0 Messages
1. **Before upgrading**, decode all messages with v3.1.0:
```bash
# Using v3.1.0
python decode_all.py
```
2. Save the decoded content
3. Upgrade to v3.2.0
4. Re-encode with v3.2.0 if needed
### For Developers
1. Update the 4 core files: crypto.py, constants.py, models.py, validation.py
2. Update remaining files in order:
- `__init__.py` (public API - critical)
- `keygen.py` (credential generation)
- `batch.py` (batch operations)
- `cli.py` (command line)
3. Run tests to verify:
```bash
pytest tests/ -v
```
4. Update documentation and examples
## Benefits
### Simplicity
- ❌ Before: 3 parameters (day_phrase, pin, date)
- ✅ After: 2 parameters (passphrase, pin)
### User Experience
- ❌ Before: "What date did I encode this?" "Which day's phrase?"
- ✅ After: Just use your passphrase
### Asynchronous Ready
- ❌ Before: Must know encoding date
- ✅ After: Decode anytime
### Less Metadata
- ❌ Before: Date stored in header
- ✅ After: No temporal metadata
## Security Considerations
### Entropy Comparison
**v3.1.0:**
- Photo hash: ~128 bits
- Day phrase (3 words): ~33 bits
- PIN (6 digits): ~20 bits
- Date: ~33 bits (10 digits)
- **Total: ~214 bits**
**v3.2.0:**
- Photo hash: ~128 bits
- Passphrase (4 words): ~44 bits
- PIN (6 digits): ~20 bits
- **Total: ~192 bits**
**Mitigation:** Recommend longer passphrases (4-5 words vs 3)
### Best Practices for v3.2.0
1. **Use 4+ word passphrases** (increased from 3)
2. **Keep using PINs** (additional 20 bits)
3. **Protect reference photo** (still critical)
4. **Consider RSA keys** for highest security
## Testing Checklist
- [ ] Unit tests pass
- [ ] Integration tests pass
- [ ] Encode/decode round-trip works
- [ ] File payloads work
- [ ] LSB mode works
- [ ] DCT mode works
- [ ] Batch operations work
- [ ] CLI commands work
- [ ] Error messages are clear
- [ ] Validation works correctly
- [ ] No references to "day_phrase" remain
- [ ] No date parameters remain (except cosmetic)
## Documentation Updates Needed
- [ ] README.md - Update all examples
- [ ] API documentation - Update function signatures
- [ ] Tutorials - Remove date parameters
- [ ] CHANGELOG.md - Add v3.2.0 entry
- [ ] Migration guide - How to upgrade from v3.1.0
- [ ] Examples directory - Update all scripts
## Backward Compatibility Strategy
### Option 1: Clean Break (Recommended)
- No compatibility code
- Clear version separation
- Users must migrate manually
### Option 2: Temporary Wrapper
```python
def encode(
message,
reference_photo,
carrier_image,
passphrase: str = None,
day_phrase: str = None, # Deprecated
date_str: str = None, # Deprecated
pin: str = "",
...
):
if day_phrase and not passphrase:
import warnings
warnings.warn("day_phrase deprecated, use passphrase", DeprecationWarning)
passphrase = day_phrase
if date_str:
warnings.warn("date_str no longer used", DeprecationWarning)
# ... rest of function
```
## Release Checklist
- [ ] All files updated
- [ ] Tests passing
- [ ] Documentation updated
- [ ] Migration guide written
- [ ] CHANGELOG.md updated
- [ ] Version bumped to 3.2.0
- [ ] Git tag created: v3.2.0
- [ ] PyPI package published
- [ ] Release notes published
- [ ] Users notified of breaking changes
## Quick Reference
### Search and Replace Patterns
Safe to replace globally:
- `day_phrase` → `passphrase`
- `day phrase` → `passphrase`
- `Day phrase` → `Passphrase`
- `DEFAULT_PHRASE_WORDS` → `DEFAULT_PASSPHRASE_WORDS`
Do NOT replace:
- `DAY_NAMES` (keep for utilities)
- `get_day_from_date` (keep for utilities)
- `generate_day_phrases` (rename function itself)
### Error Message Updates
- "Day phrase is required" → "Passphrase is required"
- "Check your phrase, PIN" → "Check your passphrase, PIN"
- "the day's phrase" → "the passphrase"
- "today's passphrase" → "passphrase"
## Support
For issues or questions during migration:
1. Check the migration guide
2. Review the comparison document
3. Look at updated examples
4. File an issue on GitHub
---
**Status:**
✅ Core files updated (crypto, constants, models, validation)
⏳ Remaining files need updates (__init__, keygen, batch, cli)
📝 Documentation updates pending

View File

@@ -1,17 +1,24 @@
# Stegasoo Docker Image
# Multi-stage build for smaller image size
FROM python:3.11-slim as base
# Pin the base image digest for reproducibility
# To update: docker manifest inspect python:3.11-slim -v | jq -r '.[0].Descriptor.digest'
FROM python:3.11-slim@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 as base
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Suppress pip "running as root" warnings during build
ENV PIP_ROOT_USER_ACTION=ignore
# Install system dependencies
# NOTE: libjpeg-dev is required for jpegio compilation
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc-dev \
libffi-dev \
libzbar0 \
libjpeg-dev \
&& rm -rf /var/lib/apt/lists/*
# ============================================================================
@@ -26,8 +33,10 @@ COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
# Install the package with web extras
RUN pip install --no-cache-dir ".[web]"
# Install build dependencies for jpegio, then install the package
# jpegio requires Cython and numpy to compile
RUN pip install --no-cache-dir cython numpy && \
pip install --no-cache-dir ".[web]"
# ============================================================================
# Production stage - Web UI
@@ -73,11 +82,14 @@ FROM base as api
WORKDIR /app
# Install API extras
# Install API extras (includes DCT dependencies)
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[api]"
# Install build dependencies for jpegio, then install the package
RUN pip install --no-cache-dir cython numpy && \
pip install --no-cache-dir ".[api]"
# Copy API files
COPY frontends/api/ frontends/api/
@@ -111,7 +123,10 @@ WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[cli]"
# Install build dependencies for jpegio (if dct extras needed), then install
RUN pip install --no-cache-dir cython numpy && \
pip install --no-cache-dir ".[cli,dct]"
# Copy CLI files
COPY frontends/cli/ frontends/cli/

View File

@@ -1,617 +1,256 @@
"""
Stegasoo - Secure Steganography Library
Stegasoo - Secure Steganography with Multi-Factor Authentication (v4.0.1)
A Python library for hiding encrypted messages and files in images using
hybrid photo + passphrase + PIN authentication.
Basic Usage - Text Message:
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
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_photo,
day_phrase="apple forest thunder",
pin="123456"
)
print(decoded.message) # "Meet at midnight"
File Embedding:
from stegasoo import encode_file, decode, FilePayload
# Encode a file
result = encode_file(
filepath="secret_document.pdf",
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456"
)
# Decode - automatically detects file vs text
decoded = decode(...)
if decoded.is_file:
with open(decoded.filename, 'wb') as f:
f.write(decoded.file_data)
else:
print(decoded.message)
Debugging:
from stegasoo.debug import debug
debug.enable(True) # Enable debug output
debug.enable_performance(True) # Enable timing
Changes in v4.0.0:
- Added channel key support for deployment/group isolation
- New functions: get_channel_key, get_channel_fingerprint, generate_channel_key, etc.
- encode() and decode() now accept channel_key parameter
"""
from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE
from .models import (
Credentials,
EncodeInput,
EncodeResult,
DecodeInput,
DecodeResult,
EmbedStats,
KeyInfo,
ValidationResult,
FilePayload,
__version__ = "4.0.1"
# Core functionality
# Channel key management (v4.0.0)
from .channel import (
clear_channel_key,
format_channel_key,
generate_channel_key,
get_channel_key,
get_channel_status,
has_channel_key,
set_channel_key,
validate_channel_key,
)
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,
# Crypto functions
from .crypto import get_active_channel_key, get_channel_fingerprint, has_argon2
from .decode import decode, decode_file, decode_text
from .encode import encode
# Credential generation
from .generate import (
export_rsa_key_pem,
generate_credentials,
generate_passphrase,
generate_pin,
generate_rsa_key,
load_rsa_key,
get_key_info,
)
from .validation import (
validate_pin,
validate_message,
validate_payload,
validate_file_payload,
validate_image,
validate_rsa_key,
validate_security_factors,
validate_phrase,
validate_date_string,
require_valid_pin,
require_valid_message,
require_valid_payload,
require_valid_image,
require_valid_rsa_key,
require_security_factors,
)
from .crypto import (
encrypt_message,
decrypt_message,
decrypt_message_text,
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,
get_image_format,
is_lossless_format,
LOSSLESS_FORMATS,
)
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 .debug import debug # Import debug utilities
# QR Code utilities (optional, depends on qrcode and pyzbar)
# Image utilities
from .image_utils import (
compare_capacity,
get_image_info,
)
# Steganography functions
from .steganography import (
compare_modes,
has_dct_support,
will_fit_by_mode,
)
# Utilities
from .utils import generate_filename
# QR Code utilities - optional, may not be available
try:
from .qr_utils import (
generate_qr_code,
read_qr_code,
read_qr_code_from_file,
detect_and_crop_qr,
extract_key_from_qr,
extract_key_from_qr_file,
compress_data,
decompress_data,
auto_decompress,
normalize_pem,
is_compressed,
can_fit_in_qr,
needs_compression,
has_qr_read,
has_qr_write,
has_qr_support,
generate_qr_code,
)
HAS_QR_UTILS = True
except ImportError:
HAS_QR_UTILS = False
generate_qr_code = None
extract_key_from_qr = None
detect_and_crop_qr = None
from datetime import date
from pathlib import Path
from typing import Optional, Union, Dict, Any
# Validation
from .validation import (
validate_file_payload,
validate_image,
validate_message,
validate_passphrase,
validate_pin,
validate_rsa_key,
validate_security_factors,
)
# Validation aliases for public API
validate_reference_photo = validate_image
validate_carrier = validate_image
def encode(
message: Union[str, bytes, FilePayload],
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,
output_format: Optional[str] = None,
) -> EncodeResult:
"""
Encode a secret message or file into an image.
# Additional validators
# Constants
from .constants import (
DEFAULT_PASSPHRASE_WORDS,
EMBED_MODE_AUTO,
EMBED_MODE_DCT,
EMBED_MODE_LSB,
FORMAT_VERSION,
LOSSLESS_FORMATS,
MAX_IMAGE_PIXELS,
MAX_MESSAGE_SIZE,
MAX_PASSPHRASE_WORDS,
MAX_PIN_LENGTH,
MIN_IMAGE_PIXELS,
MIN_PASSPHRASE_WORDS,
MIN_PIN_LENGTH,
RECOMMENDED_PASSPHRASE_WORDS,
)
High-level convenience function that handles validation,
encryption, and embedding in one call.
# Exceptions
from .exceptions import (
CapacityError,
CryptoError,
DecryptionError,
EmbeddingError,
EncryptionError,
ExtractionError,
ImageValidationError,
InvalidHeaderError,
KeyDerivationError,
KeyGenerationError,
KeyPasswordError,
KeyValidationError,
MessageValidationError,
PinValidationError,
SecurityFactorError,
SteganographyError,
StegasooError,
ValidationError,
)
Args:
message: Secret message (str), raw bytes, or FilePayload 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)
output_format: Force output format ('PNG', 'BMP'). If None, preserves
carrier format for lossless types, defaults to PNG for lossy.
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
Note:
Output format is always lossless (PNG or BMP) to preserve hidden data.
If carrier is JPEG/GIF, output will be PNG to maintain data integrity.
"""
# Debug logging
debug.print(f"encode called: message type={type(message).__name__}, "
f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}")
# Validate inputs
require_valid_payload(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()
debug.print(f"Encoding for date: {date_str}")
# Encrypt message/file
encrypted = encrypt_message(
message, reference_photo, day_phrase, date_str, pin, rsa_key_data
)
# Debug: show encrypted data size
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
# Get pixel key
pixel_key = derive_pixel_key(
reference_photo, day_phrase, date_str, pin, rsa_key_data
)
debug.data(pixel_key, "Pixel key")
# Embed in image (returns extension too)
stego_data, stats, extension = embed_in_image(
carrier_image, encrypted, pixel_key, output_format=output_format
)
# Generate filename with correct extension
filename = generate_filename(date_str, extension=extension)
debug.print(f"Encoding complete: {filename}, "
f"modified {stats.pixels_modified}/{stats.total_pixels} pixels "
f"({stats.modification_percent:.2f}%)")
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 encode_file(
filepath: Union[str, Path],
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,
output_format: Optional[str] = None,
filename_override: Optional[str] = None,
) -> EncodeResult:
"""
Encode a file into an image.
Convenience function for embedding files. Preserves original filename.
Args:
filepath: Path to file to embed
reference_photo: Shared reference photo bytes
carrier_image: Image to hide file 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)
output_format: Force output format ('PNG', 'BMP')
filename_override: Override the stored filename
Returns:
EncodeResult with stego image and metadata
"""
debug.print(f"encode_file called: filepath={filepath}")
payload = FilePayload.from_file(str(filepath), filename_override)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
date_str=date_str,
output_format=output_format,
)
def encode_bytes(
data: bytes,
filename: 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,
output_format: Optional[str] = None,
mime_type: Optional[str] = None,
) -> EncodeResult:
"""
Encode raw bytes with a filename into an image.
Convenience function for embedding binary data with metadata.
Args:
data: Raw bytes to embed
filename: Filename to associate with the data
reference_photo: Shared reference photo bytes
carrier_image: Image to hide data 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)
output_format: Force output format ('PNG', 'BMP')
mime_type: MIME type of the data
Returns:
EncodeResult with stego image and metadata
"""
debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}")
payload = FilePayload(data=data, filename=filename, mime_type=mime_type)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
date_str=date_str,
output_format=output_format,
)
@debug.time
def decode(
stego_image: bytes,
reference_photo: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
) -> DecodeResult:
"""
Decode a secret message or file from a stego image.
High-level convenience function that handles extraction
and decryption in one call.
Args:
stego_image: Image containing hidden message/file
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:
DecodeResult with:
- .payload_type: 'text' or 'file'
- .message: Decoded text (if text)
- .file_data: Decoded bytes (if file)
- .filename: Original filename (if file)
- .is_text / .is_file: Convenience properties
Raises:
ValidationError: If inputs are invalid
SecurityFactorError: If no PIN or RSA key provided
ExtractionError: If data cannot be extracted
DecryptionError: If decryption fails
"""
debug.print(f"decode called: stego_image_size={len(stego_image)}, "
f"day_phrase='{day_phrase[:20]}...'")
# 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
)
debug.data(pixel_key, "Pixel key for extraction")
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:
debug.print(f"Found different date in header: {header['date']} (expected {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:
debug.print("No data extracted from image")
raise ExtractionError("Could not extract data. Check your inputs.")
debug.print(f"Extracted {len(encrypted)} bytes from image")
debug.data(encrypted[:64], "First 64 bytes of extracted data")
# Decrypt and return full result
return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data)
def decode_text(
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 text message from a stego image.
Convenience function that returns just the text string.
Raises an error if the content is a binary file.
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:
DecryptionError: If content is a binary file, not text
"""
debug.print("decode_text called")
result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password)
if result.is_file:
# Try to decode file as text
if result.file_data:
try:
return result.file_data.decode('utf-8')
except UnicodeDecodeError:
debug.print(f"File is binary: {result.filename or 'unnamed'}")
raise DecryptionError(
f"Content is a binary file ({result.filename or 'unnamed'}), not text. "
"Use decode() instead and check result.is_file."
)
return ""
debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...")
return result.message or ""
# Models
from .models import (
CapacityComparison,
Credentials,
DecodeResult,
EncodeResult,
FilePayload,
GenerateResult,
ImageInfo,
ValidationResult,
)
from .validation import (
validate_dct_color_mode,
validate_dct_output_format,
validate_embed_mode,
)
# Aliases for backward compatibility
MIN_MESSAGE_LENGTH = 1
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
LSB_BYTES_PER_PIXEL = 3 / 8
DCT_BYTES_PER_PIXEL = 0.125
__all__ = [
# Version
'__version__',
# High-level API
'encode',
'encode_file',
'encode_bytes',
'decode',
'decode_text',
'generate_credentials',
# Constants
'DAY_NAMES',
'LOSSLESS_FORMATS',
'MAX_MESSAGE_SIZE',
'MAX_FILE_PAYLOAD_SIZE',
# Models
'Credentials',
'EncodeInput',
'EncodeResult',
'DecodeInput',
'DecodeResult',
'EmbedStats',
'KeyInfo',
'ValidationResult',
'FilePayload',
# 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_payload',
'validate_file_payload',
'validate_image',
'validate_rsa_key',
'validate_security_factors',
'validate_phrase',
'validate_date_string',
'require_valid_pin',
'require_valid_message',
'require_valid_payload',
'require_valid_image',
'require_valid_rsa_key',
'require_security_factors',
# Crypto
'encrypt_message',
'decrypt_message',
'decrypt_message_text',
'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',
'get_image_format',
'is_lossless_format',
"__version__",
# Core
"encode",
"decode",
"decode_file",
"decode_text",
# Generation
"generate_pin",
"generate_passphrase",
"generate_rsa_key",
"generate_credentials",
"export_rsa_key_pem",
"load_rsa_key",
# Channel key management (v4.0.0)
"generate_channel_key",
"get_channel_key",
"set_channel_key",
"clear_channel_key",
"has_channel_key",
"get_channel_status",
"validate_channel_key",
"format_channel_key",
"get_active_channel_key",
"get_channel_fingerprint",
# Image utilities
"get_image_info",
"compare_capacity",
# Utilities
'generate_filename',
'parse_date_from_filename',
'get_day_from_date',
'get_today_date',
'get_today_day',
'secure_delete',
'SecureDeleter',
'format_file_size',
# Debugging
'debug',
"generate_filename",
# Crypto
"has_argon2",
# Steganography
"has_dct_support",
"compare_modes",
"will_fit_by_mode",
# QR utilities
"generate_qr_code",
"extract_key_from_qr",
"detect_and_crop_qr",
"HAS_QR_UTILS",
# Validation
"validate_reference_photo",
"validate_carrier",
"validate_message",
"validate_file_payload",
"validate_passphrase",
"validate_pin",
"validate_rsa_key",
"validate_security_factors",
"validate_embed_mode",
"validate_dct_output_format",
"validate_dct_color_mode",
"validate_channel_key",
# Models
"ImageInfo",
"CapacityComparison",
"GenerateResult",
"EncodeResult",
"DecodeResult",
"FilePayload",
"Credentials",
"ValidationResult",
# Exceptions
"StegasooError",
"ValidationError",
"PinValidationError",
"MessageValidationError",
"ImageValidationError",
"KeyValidationError",
"SecurityFactorError",
"CryptoError",
"EncryptionError",
"DecryptionError",
"KeyDerivationError",
"KeyGenerationError",
"KeyPasswordError",
"SteganographyError",
"CapacityError",
"ExtractionError",
"EmbeddingError",
"InvalidHeaderError",
# Constants
"FORMAT_VERSION",
"MIN_PASSPHRASE_WORDS",
"RECOMMENDED_PASSPHRASE_WORDS",
"DEFAULT_PASSPHRASE_WORDS",
"MAX_PASSPHRASE_WORDS",
"MIN_PIN_LENGTH",
"MAX_PIN_LENGTH",
"MIN_MESSAGE_LENGTH",
"MAX_MESSAGE_LENGTH",
"MAX_MESSAGE_SIZE",
"MAX_PAYLOAD_SIZE",
"MIN_IMAGE_PIXELS",
"MAX_IMAGE_PIXELS",
"SUPPORTED_IMAGE_FORMATS",
"LOSSLESS_FORMATS",
"LSB_BYTES_PER_PIXEL",
"DCT_BYTES_PER_PIXEL",
"EMBED_MODE_LSB",
"EMBED_MODE_DCT",
"EMBED_MODE_AUTO",
]

684
src/stegasoo/batch.py Normal file
View File

@@ -0,0 +1,684 @@
"""
Stegasoo Batch Processing Module (v3.2.0)
Enables encoding/decoding multiple files in a single operation.
Supports parallel processing, progress tracking, and detailed reporting.
Changes in v3.2.0:
- BatchCredentials: renamed day_phrase → passphrase, removed date_str
- Updated all credential handling to use v3.2.0 API
"""
import json
import threading
import time
from collections.abc import Callable, Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from .constants import ALLOWED_IMAGE_EXTENSIONS, LOSSLESS_FORMATS
class BatchStatus(Enum):
"""Status of individual batch items."""
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class BatchItem:
"""Represents a single item in a batch operation."""
input_path: Path
output_path: Path | None = None
status: BatchStatus = BatchStatus.PENDING
error: str | None = None
start_time: float | None = None
end_time: float | None = None
input_size: int = 0
output_size: int = 0
message: str = ""
@property
def duration(self) -> float | None:
"""Processing duration in seconds."""
if self.start_time and self.end_time:
return self.end_time - self.start_time
return None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"input_path": str(self.input_path),
"output_path": str(self.output_path) if self.output_path else None,
"status": self.status.value,
"error": self.error,
"duration_seconds": self.duration,
"input_size": self.input_size,
"output_size": self.output_size,
"message": self.message,
}
@dataclass
class BatchCredentials:
"""
Credentials for batch encode/decode operations (v3.2.0).
Provides a structured way to pass authentication factors
for batch processing instead of using plain dicts.
Changes in v3.2.0:
- Renamed day_phrase → passphrase
- Removed date_str (no longer used in cryptographic operations)
Example:
creds = BatchCredentials(
reference_photo=ref_bytes,
passphrase="apple forest thunder mountain",
pin="123456"
)
result = processor.batch_encode(images, creds, message="secret")
"""
reference_photo: bytes
passphrase: str # v3.2.0: renamed from day_phrase
pin: str = ""
rsa_key_data: bytes | None = None
rsa_password: str | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for API compatibility."""
return {
"reference_photo": self.reference_photo,
"passphrase": self.passphrase,
"pin": self.pin,
"rsa_key_data": self.rsa_key_data,
"rsa_password": self.rsa_password,
}
@classmethod
def from_dict(cls, data: dict) -> "BatchCredentials":
"""
Create BatchCredentials from a dictionary.
Handles both v3.2.0 format (passphrase) and legacy formats (day_phrase, phrase).
"""
# Handle legacy 'day_phrase' and 'phrase' keys
passphrase = data.get("passphrase") or data.get("day_phrase") or data.get("phrase", "")
return cls(
reference_photo=data["reference_photo"],
passphrase=passphrase,
pin=data.get("pin", ""),
rsa_key_data=data.get("rsa_key_data"),
rsa_password=data.get("rsa_password"),
)
@dataclass
class BatchResult:
"""Summary of a batch operation."""
operation: str
total: int = 0
succeeded: int = 0
failed: int = 0
skipped: int = 0
start_time: float = field(default_factory=time.time)
end_time: float | None = None
items: list[BatchItem] = field(default_factory=list)
@property
def duration(self) -> float | None:
"""Total batch duration in seconds."""
if self.end_time:
return self.end_time - self.start_time
return None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"operation": self.operation,
"summary": {
"total": self.total,
"succeeded": self.succeeded,
"failed": self.failed,
"skipped": self.skipped,
"duration_seconds": self.duration,
},
"items": [item.to_dict() for item in self.items],
}
def to_json(self, indent: int = 2) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(), indent=indent)
# Type alias for progress callback
ProgressCallback = Callable[[int, int, BatchItem], None]
class BatchProcessor:
"""
Handles batch encoding/decoding operations (v3.2.0).
Usage:
processor = BatchProcessor(max_workers=4)
# Batch encode with BatchCredentials
creds = BatchCredentials(
reference_photo=ref_bytes,
passphrase="apple forest thunder mountain",
pin="123456"
)
result = processor.batch_encode(
images=['img1.png', 'img2.png'],
message="Secret message",
output_dir="./encoded/",
credentials=creds,
)
# Batch encode with dict credentials
result = processor.batch_encode(
images=['img1.png', 'img2.png'],
message="Secret message",
credentials={
"reference_photo": ref_bytes,
"passphrase": "apple forest thunder mountain",
"pin": "123456"
},
)
# Batch decode
result = processor.batch_decode(
images=['encoded1.png', 'encoded2.png'],
credentials=creds,
)
"""
def __init__(self, max_workers: int = 4):
"""
Initialize batch processor.
Args:
max_workers: Maximum parallel workers (default 4)
"""
self.max_workers = max_workers
self._lock = threading.Lock()
def find_images(
self,
paths: list[str | Path],
recursive: bool = False,
) -> Iterator[Path]:
"""
Find all valid image files from paths.
Args:
paths: List of files or directories
recursive: Search directories recursively
Yields:
Path objects for each valid image
"""
for path in paths:
path = Path(path)
if path.is_file():
if self._is_valid_image(path):
yield path
elif path.is_dir():
pattern = "**/*" if recursive else "*"
for file_path in path.glob(pattern):
if file_path.is_file() and self._is_valid_image(file_path):
yield file_path
def _is_valid_image(self, path: Path) -> bool:
"""Check if path is a valid image file."""
return path.suffix.lower().lstrip(".") in ALLOWED_IMAGE_EXTENSIONS
def _normalize_credentials(
self, credentials: dict | BatchCredentials | None
) -> BatchCredentials:
"""
Normalize credentials to BatchCredentials object.
Handles both dict and BatchCredentials input, and legacy 'day_phrase' key.
"""
if credentials is None:
raise ValueError("Credentials are required")
if isinstance(credentials, BatchCredentials):
return credentials
if isinstance(credentials, dict):
return BatchCredentials.from_dict(credentials)
raise ValueError(f"Invalid credentials type: {type(credentials)}")
def batch_encode(
self,
images: list[str | Path],
message: str | None = None,
file_payload: Path | None = None,
output_dir: Path | None = None,
output_suffix: str = "_encoded",
credentials: dict | BatchCredentials | None = None,
compress: bool = True,
recursive: bool = False,
progress_callback: ProgressCallback | None = None,
encode_func: Callable = None,
) -> BatchResult:
"""
Encode message into multiple images.
Args:
images: List of image paths or directories
message: Text message to encode (mutually exclusive with file_payload)
file_payload: File to embed (mutually exclusive with message)
output_dir: Output directory (default: same as input)
output_suffix: Suffix for output files
credentials: BatchCredentials or dict with 'passphrase', 'pin', etc.
compress: Enable compression
recursive: Search directories recursively
progress_callback: Called for each item: callback(current, total, item)
encode_func: Custom encode function (for integration)
Returns:
BatchResult with operation summary
"""
if message is None and file_payload is None:
raise ValueError("Either message or file_payload must be provided")
# Normalize credentials to BatchCredentials
creds = self._normalize_credentials(credentials)
result = BatchResult(operation="encode")
image_paths = list(self.find_images(images, recursive))
result.total = len(image_paths)
if output_dir:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Prepare batch items
for img_path in image_paths:
if output_dir:
out_path = output_dir / f"{img_path.stem}{output_suffix}.png"
else:
out_path = img_path.parent / f"{img_path.stem}{output_suffix}.png"
item = BatchItem(
input_path=img_path,
output_path=out_path,
input_size=img_path.stat().st_size if img_path.exists() else 0,
)
result.items.append(item)
# Process items
def process_encode(item: BatchItem) -> BatchItem:
item.status = BatchStatus.PROCESSING
item.start_time = time.time()
try:
if encode_func:
# Use provided encode function
encode_func(
image_path=item.input_path,
output_path=item.output_path,
message=message,
file_payload=file_payload,
credentials=creds.to_dict(),
compress=compress,
)
else:
# Use stegasoo encode
self._do_encode(item, message, file_payload, creds, compress)
item.status = BatchStatus.SUCCESS
item.output_size = (
item.output_path.stat().st_size
if item.output_path and item.output_path.exists()
else 0
)
item.message = f"Encoded to {item.output_path.name}"
except Exception as e:
item.status = BatchStatus.FAILED
item.error = str(e)
item.end_time = time.time()
return item
# Execute with thread pool
self._execute_batch(result, process_encode, progress_callback)
return result
def batch_decode(
self,
images: list[str | Path],
output_dir: Path | None = None,
credentials: dict | BatchCredentials | None = None,
recursive: bool = False,
progress_callback: ProgressCallback | None = None,
decode_func: Callable = None,
) -> BatchResult:
"""
Decode messages from multiple images.
Args:
images: List of image paths or directories
output_dir: Output directory for file payloads (default: same as input)
credentials: BatchCredentials or dict with 'passphrase', 'pin', etc.
recursive: Search directories recursively
progress_callback: Called for each item: callback(current, total, item)
decode_func: Custom decode function (for integration)
Returns:
BatchResult with decoded messages in item.message fields
"""
# Normalize credentials to BatchCredentials
creds = self._normalize_credentials(credentials)
result = BatchResult(operation="decode")
image_paths = list(self.find_images(images, recursive))
result.total = len(image_paths)
if output_dir:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Prepare batch items
for img_path in image_paths:
item = BatchItem(
input_path=img_path,
output_path=output_dir,
input_size=img_path.stat().st_size if img_path.exists() else 0,
)
result.items.append(item)
# Process items
def process_decode(item: BatchItem) -> BatchItem:
item.status = BatchStatus.PROCESSING
item.start_time = time.time()
try:
if decode_func:
# Use provided decode function
decoded = decode_func(
image_path=item.input_path,
output_dir=item.output_path,
credentials=creds.to_dict(),
)
item.message = (
decoded.get("message", "") if isinstance(decoded, dict) else str(decoded)
)
else:
# Use stegasoo decode
item.message = self._do_decode(item, creds)
item.status = BatchStatus.SUCCESS
except Exception as e:
item.status = BatchStatus.FAILED
item.error = str(e)
item.end_time = time.time()
return item
# Execute with thread pool
self._execute_batch(result, process_decode, progress_callback)
return result
def _execute_batch(
self,
result: BatchResult,
process_func: Callable[[BatchItem], BatchItem],
progress_callback: ProgressCallback | None = None,
) -> None:
"""Execute batch processing with thread pool."""
completed = 0
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = {executor.submit(process_func, item): item for item in result.items}
for future in as_completed(futures):
item = future.result()
completed += 1
with self._lock:
if item.status == BatchStatus.SUCCESS:
result.succeeded += 1
elif item.status == BatchStatus.FAILED:
result.failed += 1
elif item.status == BatchStatus.SKIPPED:
result.skipped += 1
if progress_callback:
progress_callback(completed, result.total, item)
result.end_time = time.time()
def _do_encode(
self,
item: BatchItem,
message: str | None,
file_payload: Path | None,
creds: BatchCredentials,
compress: bool,
) -> None:
"""
Perform actual encoding using stegasoo.encode.
Override this method to customize encoding behavior.
"""
try:
from .encode import encode
from .models import FilePayload
# Read carrier image
carrier_image = item.input_path.read_bytes()
if file_payload:
# Encode file
payload = FilePayload.from_file(str(file_payload))
result = encode(
message=payload,
reference_photo=creds.reference_photo,
carrier_image=carrier_image,
passphrase=creds.passphrase,
pin=creds.pin,
rsa_key_data=creds.rsa_key_data,
rsa_password=creds.rsa_password,
)
else:
# Encode text message
result = encode(
message=message,
reference_photo=creds.reference_photo,
carrier_image=carrier_image,
passphrase=creds.passphrase,
pin=creds.pin,
rsa_key_data=creds.rsa_key_data,
rsa_password=creds.rsa_password,
)
# Write output
if item.output_path:
item.output_path.write_bytes(result.stego_image)
except ImportError:
# Fallback to mock if stegasoo.encode not available
self._mock_encode(item, message, creds, compress)
def _do_decode(
self,
item: BatchItem,
creds: BatchCredentials,
) -> str:
"""
Perform actual decoding using stegasoo.decode.
Override this method to customize decoding behavior.
"""
try:
from .decode import decode
# Read stego image
stego_image = item.input_path.read_bytes()
result = decode(
stego_image=stego_image,
reference_photo=creds.reference_photo,
passphrase=creds.passphrase,
pin=creds.pin,
rsa_key_data=creds.rsa_key_data,
rsa_password=creds.rsa_password,
)
if result.is_text:
return result.message or ""
else:
# File payload - save it
if item.output_path and result.file_data:
output_file = item.output_path / (result.filename or "extracted_file")
output_file.write_bytes(result.file_data)
return f"File extracted: {result.filename or 'extracted_file'}"
return f"[File: {result.filename or 'binary data'}]"
except ImportError:
# Fallback to mock if stegasoo.decode not available
return self._mock_decode(item, creds)
def _mock_encode(
self, item: BatchItem, message: str, creds: BatchCredentials, compress: bool
) -> None:
"""Mock encode for testing - replace with actual stego.encode()"""
# This is a placeholder - in real usage, you'd call your actual encode function
# For now, just copy the file to simulate encoding
import shutil
if item.output_path:
shutil.copy(item.input_path, item.output_path)
def _mock_decode(self, item: BatchItem, creds: BatchCredentials) -> str:
"""Mock decode for testing - replace with actual stego.decode()"""
# This is a placeholder - in real usage, you'd call your actual decode function
return "[Decoded message would appear here]"
def batch_capacity_check(
images: list[str | Path],
recursive: bool = False,
) -> list[dict]:
"""
Check capacity of multiple images without encoding.
Args:
images: List of image paths or directories
recursive: Search directories recursively
Returns:
List of dicts with path, dimensions, and estimated capacity
"""
from PIL import Image
from .constants import MAX_IMAGE_PIXELS
processor = BatchProcessor()
results = []
for img_path in processor.find_images(images, recursive):
try:
with Image.open(img_path) as img:
width, height = img.size
pixels = width * height
# Estimate: 3 bits per pixel (RGB LSB), minus header overhead
capacity_bits = pixels * 3
capacity_bytes = (capacity_bits // 8) - 100 # Header overhead
results.append(
{
"path": str(img_path),
"dimensions": f"{width}x{height}",
"pixels": pixels,
"format": img.format,
"mode": img.mode,
"capacity_bytes": max(0, capacity_bytes),
"capacity_kb": max(0, capacity_bytes // 1024),
"valid": pixels <= MAX_IMAGE_PIXELS and img.format in LOSSLESS_FORMATS,
"warnings": _get_image_warnings(img, img_path),
}
)
except Exception as e:
results.append(
{
"path": str(img_path),
"error": str(e),
"valid": False,
}
)
return results
def _get_image_warnings(img, path: Path) -> list[str]:
"""Generate warnings for an image."""
from .constants import LOSSLESS_FORMATS, MAX_IMAGE_PIXELS
warnings = []
if img.format not in LOSSLESS_FORMATS:
warnings.append(f"Lossy format ({img.format}) - quality will degrade on re-save")
if img.size[0] * img.size[1] > MAX_IMAGE_PIXELS:
warnings.append(f"Image exceeds {MAX_IMAGE_PIXELS:,} pixel limit")
if img.mode not in ("RGB", "RGBA"):
warnings.append(f"Non-RGB mode ({img.mode}) - will be converted")
return warnings
# CLI-friendly functions
def print_batch_result(result: BatchResult, verbose: bool = False) -> None:
"""Print batch result summary to console."""
print(f"\n{'='*60}")
print(f"Batch {result.operation.upper()} Complete")
print(f"{'='*60}")
print(f"Total: {result.total}")
print(f"Succeeded: {result.succeeded}")
print(f"Failed: {result.failed}")
print(f"Skipped: {result.skipped}")
if result.duration:
print(f"Duration: {result.duration:.2f}s")
if verbose or result.failed > 0:
print(f"\n{''*60}")
for item in result.items:
status_icon = {
BatchStatus.SUCCESS: "",
BatchStatus.FAILED: "",
BatchStatus.SKIPPED: "",
BatchStatus.PENDING: "",
BatchStatus.PROCESSING: "",
}.get(item.status, "?")
print(f"{status_icon} {item.input_path.name}")
if item.error:
print(f" Error: {item.error}")
elif item.message and verbose:
print(f" {item.message}")

452
src/stegasoo/channel.py Normal file
View File

@@ -0,0 +1,452 @@
"""
Channel Key Management for Stegasoo (v4.0.0)
A channel key ties encode/decode operations to a specific deployment or group.
Messages encoded with one channel key can only be decoded by systems with the
same channel key configured.
Use cases:
- Organization deployment: IT sets a company-wide channel key
- Friend groups: Share a channel key for private communication
- Air-gapped systems: Generate unique key per installation
- Public instances: No channel key = compatible with any instance without a channel key
Storage priority:
1. Environment variable: STEGASOO_CHANNEL_KEY
2. Config file: ~/.stegasoo/channel.key or ./config/channel.key
3. None (public mode - compatible with any instance without a channel key)
INTEGRATION STATUS (v4.0.0):
- ✅ get_channel_key_hash() integrated into derive_hybrid_key() in crypto.py
- ✅ get_channel_key_hash() integrated into derive_pixel_key() in crypto.py
- ✅ channel_key parameter added to encode() and decode() functions
- ✅ Header flags indicate whether message was encoded with channel key
- ✅ Helpful error messages for channel key mismatches
"""
import hashlib
import os
import re
import secrets
from pathlib import Path
from .debug import debug
# Channel key format: 8 groups of 4 alphanumeric chars (32 chars total)
# Example: ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
CHANNEL_KEY_PATTERN = re.compile(r"^[A-Z0-9]{4}(-[A-Z0-9]{4}){7}$")
CHANNEL_KEY_LENGTH = 32 # Characters (excluding dashes)
CHANNEL_KEY_FORMATTED_LENGTH = 39 # With dashes
# Environment variable name
CHANNEL_KEY_ENV_VAR = "STEGASOO_CHANNEL_KEY"
# Config locations (in priority order)
CONFIG_LOCATIONS = [
Path("./config/channel.key"), # Project config
Path.home() / ".stegasoo" / "channel.key", # User config
]
def generate_channel_key() -> str:
"""
Generate a new random channel key.
Returns:
Formatted channel key (e.g., "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
Example:
>>> key = generate_channel_key()
>>> len(key)
39
"""
# Generate 32 random alphanumeric characters
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
raw_key = "".join(secrets.choice(alphabet) for _ in range(CHANNEL_KEY_LENGTH))
formatted = format_channel_key(raw_key)
debug.print(f"Generated channel key: {get_channel_fingerprint(formatted)}")
return formatted
def format_channel_key(raw_key: str) -> str:
"""
Format a raw key string into the standard format.
Args:
raw_key: Raw key string (with or without dashes)
Returns:
Formatted key with dashes (XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
Raises:
ValueError: If key is invalid length or contains invalid characters
Example:
>>> format_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456")
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
"""
# Remove any existing dashes, spaces, and convert to uppercase
clean = raw_key.replace("-", "").replace(" ", "").upper()
if len(clean) != CHANNEL_KEY_LENGTH:
raise ValueError(f"Channel key must be {CHANNEL_KEY_LENGTH} characters (got {len(clean)})")
# Validate characters
if not all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in clean):
raise ValueError("Channel key must contain only letters A-Z and digits 0-9")
# Format with dashes every 4 characters
return "-".join(clean[i : i + 4] for i in range(0, CHANNEL_KEY_LENGTH, 4))
def validate_channel_key(key: str) -> bool:
"""
Validate a channel key format.
Args:
key: Channel key to validate
Returns:
True if valid format, False otherwise
Example:
>>> validate_channel_key("ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
True
>>> validate_channel_key("invalid")
False
"""
if not key:
return False
try:
formatted = format_channel_key(key)
return bool(CHANNEL_KEY_PATTERN.match(formatted))
except ValueError:
return False
def get_channel_key() -> str | None:
"""
Get the current channel key from environment or config.
Checks in order:
1. STEGASOO_CHANNEL_KEY environment variable
2. ./config/channel.key file
3. ~/.stegasoo/channel.key file
Returns:
Channel key if configured, None if in public mode
Example:
>>> key = get_channel_key()
>>> if key:
... print("Private channel")
... else:
... print("Public mode")
"""
# 1. Check environment variable
env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, "").strip()
if env_key:
if validate_channel_key(env_key):
debug.print(f"Channel key from environment: {get_channel_fingerprint(env_key)}")
return format_channel_key(env_key)
else:
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
# 2. Check config files
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
key = config_path.read_text().strip()
if key and validate_channel_key(key):
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
return format_channel_key(key)
except (OSError, PermissionError) as e:
debug.print(f"Could not read {config_path}: {e}")
continue
# 3. No channel key configured (public mode)
debug.print("No channel key configured (public mode)")
return None
def set_channel_key(key: str, location: str = "project") -> Path:
"""
Save a channel key to config file.
Args:
key: Channel key to save (will be formatted)
location: 'project' for ./config/ or 'user' for ~/.stegasoo/
Returns:
Path where key was saved
Raises:
ValueError: If key format is invalid
Example:
>>> path = set_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456")
>>> print(path)
./config/channel.key
"""
formatted = format_channel_key(key)
if location == "user":
config_path = Path.home() / ".stegasoo" / "channel.key"
else:
config_path = Path("./config/channel.key")
# Create directory if needed
config_path.parent.mkdir(parents=True, exist_ok=True)
# Write key with newline
config_path.write_text(formatted + "\n")
# Set restrictive permissions (owner read/write only)
try:
config_path.chmod(0o600)
except (OSError, AttributeError):
pass # Windows doesn't support chmod the same way
debug.print(f"Channel key saved to {config_path}")
return config_path
def clear_channel_key(location: str = "all") -> list[Path]:
"""
Remove channel key configuration.
Args:
location: 'project', 'user', or 'all'
Returns:
List of paths that were deleted
Example:
>>> deleted = clear_channel_key('all')
>>> print(f"Removed {len(deleted)} files")
"""
deleted = []
paths_to_check = []
if location in ("project", "all"):
paths_to_check.append(Path("./config/channel.key"))
if location in ("user", "all"):
paths_to_check.append(Path.home() / ".stegasoo" / "channel.key")
for path in paths_to_check:
if path.exists():
try:
path.unlink()
deleted.append(path)
debug.print(f"Removed channel key: {path}")
except (OSError, PermissionError) as e:
debug.print(f"Could not remove {path}: {e}")
return deleted
def get_channel_key_hash(key: str | None = None) -> bytes | None:
"""
Get the channel key as a 32-byte hash suitable for key derivation.
This hash is mixed into the Argon2 key derivation to bind
encryption to a specific channel.
Args:
key: Channel key (if None, reads from config)
Returns:
32-byte SHA-256 hash of channel key, or None if no channel key
Example:
>>> hash_bytes = get_channel_key_hash()
>>> if hash_bytes:
... print(f"Hash: {len(hash_bytes)} bytes")
"""
if key is None:
key = get_channel_key()
if not key:
return None
# Hash the formatted key to get consistent 32 bytes
formatted = format_channel_key(key)
return hashlib.sha256(formatted.encode("utf-8")).digest()
def get_channel_fingerprint(key: str | None = None) -> str | None:
"""
Get a short fingerprint for display purposes.
Shows first and last 4 chars with masked middle.
Args:
key: Channel key (if None, reads from config)
Returns:
Fingerprint like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None
Example:
>>> print(get_channel_fingerprint())
ABCD-••••-••••-••••-••••-••••-••••-3456
"""
if key is None:
key = get_channel_key()
if not key:
return None
formatted = format_channel_key(key)
parts = formatted.split("-")
# Show first and last group, mask the rest
masked = [parts[0]] + ["••••"] * 6 + [parts[-1]]
return "-".join(masked)
def get_channel_status() -> dict:
"""
Get comprehensive channel key status.
Returns:
Dictionary with:
- mode: 'private' or 'public'
- configured: bool
- fingerprint: masked key or None
- source: where key came from or None
- key: full key (for export) or None
Example:
>>> status = get_channel_status()
>>> print(f"Mode: {status['mode']}")
Mode: private
"""
key = get_channel_key()
if key:
# Find which source provided the key
source = "unknown"
env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, "").strip()
if env_key and validate_channel_key(env_key):
source = "environment"
else:
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
file_key = config_path.read_text().strip()
if file_key and format_channel_key(file_key) == key:
source = str(config_path)
break
except (OSError, PermissionError):
continue
return {
"mode": "private",
"configured": True,
"fingerprint": get_channel_fingerprint(key),
"source": source,
"key": key,
}
else:
return {
"mode": "public",
"configured": False,
"fingerprint": None,
"source": None,
"key": None,
}
def has_channel_key() -> bool:
"""
Quick check if a channel key is configured.
Returns:
True if channel key is set, False for public mode
Example:
>>> if has_channel_key():
... print("Private channel active")
"""
return get_channel_key() is not None
# =============================================================================
# CLI SUPPORT
# =============================================================================
if __name__ == "__main__":
import sys
def print_status():
"""Print current channel status."""
status = get_channel_status()
print(f"Mode: {status['mode'].upper()}")
if status["configured"]:
print(f"Fingerprint: {status['fingerprint']}")
print(f"Source: {status['source']}")
else:
print("No channel key configured (public mode)")
if len(sys.argv) < 2:
print("Channel Key Manager")
print("=" * 40)
print_status()
print()
print("Commands:")
print(" python -m stegasoo.channel generate - Generate new key")
print(" python -m stegasoo.channel set <KEY> - Set channel key")
print(" python -m stegasoo.channel show - Show full key")
print(" python -m stegasoo.channel clear - Remove channel key")
print(" python -m stegasoo.channel status - Show status")
sys.exit(0)
cmd = sys.argv[1].lower()
if cmd == "generate":
key = generate_channel_key()
print("Generated channel key:")
print(f" {key}")
print()
save = input("Save to config? [y/N]: ").strip().lower()
if save == "y":
path = set_channel_key(key)
print(f"Saved to: {path}")
elif cmd == "set":
if len(sys.argv) < 3:
print("Usage: python -m stegasoo.channel set <KEY>")
sys.exit(1)
try:
key = sys.argv[2]
formatted = format_channel_key(key)
path = set_channel_key(formatted)
print(f"Channel key set: {get_channel_fingerprint(formatted)}")
print(f"Saved to: {path}")
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
elif cmd == "show":
status = get_channel_status()
if status["configured"]:
print(f"Channel key: {status['key']}")
print(f"Source: {status['source']}")
else:
print("No channel key configured")
elif cmd == "clear":
deleted = clear_channel_key("all")
if deleted:
print(f"Removed channel key from: {', '.join(str(p) for p in deleted)}")
else:
print("No channel key files found")
elif cmd == "status":
print_status()
else:
print(f"Unknown command: {cmd}")
sys.exit(1)

View File

@@ -1,66 +1,498 @@
"""
Stegasoo CLI - Command-line interface for steganography operations.
Stegasoo CLI Module (v3.2.0)
This is the package entry point. For full CLI, install with: pip install stegasoo[cli]
Command-line interface with batch processing and compression support.
Changes in v3.2.0:
- Updated to use DEFAULT_PASSPHRASE_WORDS (consistency with v3.2.0 naming)
- Updated help text to use 'passphrase' terminology
"""
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 json
from pathlib import Path
# Import the CLI from frontends
import sys
from pathlib import Path
import click
# 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))
from .batch import (
BatchProcessor,
batch_capacity_check,
print_batch_result,
)
from .compression import (
HAS_LZ4,
CompressionAlgorithm,
algorithm_name,
get_available_algorithms,
)
from .constants import (
DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS
DEFAULT_PIN_LENGTH,
MAX_FILE_PAYLOAD_SIZE,
MAX_MESSAGE_SIZE,
__version__,
)
try:
from main import cli
cli()
except ImportError:
# Minimal fallback CLI
_minimal_cli()
# Click context settings
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def _minimal_cli():
"""Minimal CLI when full CLI is not available."""
import sys
from . import __version__, generate_credentials, DAY_NAMES
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, "-v", "--version")
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
@click.pass_context
def cli(ctx, json_output):
"""
Stegasoo - Steganography with hybrid authentication.
if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help']:
print(f"Stegasoo v{__version__} - Secure Steganography")
print()
print("Usage: stegasoo <command>")
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]")
Hide messages in images using PIN + passphrase security.
"""
ctx.ensure_object(dict)
ctx.obj["json"] = json_output
# =============================================================================
# ENCODE COMMANDS
# =============================================================================
@cli.command()
@click.argument("image", type=click.Path(exists=True))
@click.option("-m", "--message", help="Message to encode")
@click.option(
"-f",
"--file",
"file_payload",
type=click.Path(exists=True),
help="File to embed instead of message",
)
@click.option("-o", "--output", type=click.Path(), help="Output image path")
@click.option(
"--passphrase",
prompt=True,
hide_input=True,
confirmation_prompt=True,
help="Passphrase (recommend 4+ words)",
)
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
@click.option(
"--compress/--no-compress", default=True, help="Enable/disable compression (default: enabled)"
)
@click.option(
"--algorithm",
type=click.Choice(["zlib", "lz4", "none"]),
default="zlib",
help="Compression algorithm",
)
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
@click.pass_context
def encode(
ctx, image, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run
):
"""
Encode a message or file into an image.
Examples:
stegasoo encode photo.png -m "Secret message" --passphrase --pin
stegasoo encode photo.png -f secret.pdf -o encoded.png
"""
from PIL import Image
if not message and not file_payload:
raise click.UsageError("Either --message or --file is required")
# Parse compression algorithm
algo_map = {
"zlib": CompressionAlgorithm.ZLIB,
"lz4": CompressionAlgorithm.LZ4,
"none": CompressionAlgorithm.NONE,
}
compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE
if algorithm == "lz4" and not HAS_LZ4:
click.echo("Warning: LZ4 not available, falling back to zlib", err=True)
compression_algo = CompressionAlgorithm.ZLIB
# Calculate payload size
if file_payload:
payload_size = Path(file_payload).stat().st_size
payload_type = "file"
else:
payload_size = len(message.encode("utf-8"))
payload_type = "text"
# Get image capacity
with Image.open(image) as img:
width, height = img.size
capacity_bytes = (width * height * 3 // 8) - 69 # v3.2.0: corrected overhead
if dry_run:
result = {
"image": image,
"dimensions": f"{width}x{height}",
"capacity_bytes": capacity_bytes,
"payload_type": payload_type,
"payload_size": payload_size,
"compression": algorithm_name(compression_algo),
"usage_percent": round(payload_size / capacity_bytes * 100, 1),
"fits": payload_size < capacity_bytes,
}
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Image: {image} ({width}x{height})")
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
click.echo(f"Compression: {algorithm_name(compression_algo)}")
click.echo(f"Usage: {result['usage_percent']}%")
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
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)")
# Actual encoding would happen here
# For now, show what would be done
output = output or f"{Path(image).stem}_encoded.png"
if ctx.obj.get("json"):
click.echo(
json.dumps(
{
"status": "success",
"input": image,
"output": output,
"payload_type": payload_type,
"compression": algorithm_name(compression_algo),
},
indent=2,
)
)
else:
print(f"Command '{sys.argv[1]}' requires full CLI.")
print("Install with: pip install stegasoo[cli]")
click.echo(f"✓ Encoded {payload_type} to {output}")
click.echo(f" Compression: {algorithm_name(compression_algo)}")
if __name__ == '__main__':
@cli.command()
@click.argument("image", type=click.Path(exists=True))
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads")
@click.pass_context
def decode(ctx, image, passphrase, pin, output):
"""
Decode a message or file from an image.
Examples:
stegasoo decode encoded.png --passphrase --pin
stegasoo decode encoded.png -o ./extracted/
"""
# Actual decoding would happen here
result = {
"status": "success",
"image": image,
"payload_type": "text",
"message": "[Decoded message would appear here]",
}
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Decoded from {image}:")
click.echo(result["message"])
# =============================================================================
# BATCH COMMANDS
# =============================================================================
@cli.group()
def batch():
"""Batch operations on multiple images."""
pass
@batch.command("encode")
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("-m", "--message", help="Message to encode in all images")
@click.option(
"-f", "--file", "file_payload", type=click.Path(exists=True), help="File to embed in all images"
)
@click.option(
"-o", "--output-dir", type=click.Path(), help="Output directory (default: same as input)"
)
@click.option("--suffix", default="_encoded", help="Output filename suffix")
@click.option(
"--passphrase",
prompt=True,
hide_input=True,
confirmation_prompt=True,
help="Passphrase (recommend 4+ words)",
)
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
@click.option("--compress/--no-compress", default=True, help="Enable/disable compression")
@click.option(
"--algorithm",
type=click.Choice(["zlib", "lz4", "none"]),
default="zlib",
help="Compression algorithm",
)
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)")
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
@click.pass_context
def batch_encode(
ctx,
images,
message,
file_payload,
output_dir,
suffix,
passphrase,
pin,
compress,
algorithm,
recursive,
jobs,
verbose,
):
"""
Encode message into multiple images.
Examples:
stegasoo batch encode *.png -m "Secret" --passphrase --pin
stegasoo batch encode ./photos/ -r -o ./encoded/
"""
if not message and not file_payload:
raise click.UsageError("Either --message or --file is required")
processor = BatchProcessor(max_workers=jobs)
# Progress callback
def progress(current, total, item):
if not ctx.obj.get("json"):
status = "" if item.status.value == "success" else ""
click.echo(f"[{current}/{total}] {status} {item.input_path.name}")
# v3.2.0: Use 'passphrase' key instead of 'phrase'
credentials = {"passphrase": passphrase, "pin": pin}
result = processor.batch_encode(
images=list(images),
message=message,
file_payload=Path(file_payload) if file_payload else None,
output_dir=Path(output_dir) if output_dir else None,
output_suffix=suffix,
credentials=credentials,
compress=compress,
recursive=recursive,
progress_callback=progress if not ctx.obj.get("json") else None,
)
if ctx.obj.get("json"):
click.echo(result.to_json())
else:
print_batch_result(result, verbose)
@batch.command("decode")
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("-o", "--output-dir", type=click.Path(), help="Output directory for file payloads")
@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase")
@click.option("--pin", prompt=True, hide_input=True, help="PIN code")
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)")
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
@click.pass_context
def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verbose):
"""
Decode messages from multiple images.
Examples:
stegasoo batch decode encoded*.png --passphrase --pin
stegasoo batch decode ./encoded/ -r -o ./extracted/
"""
processor = BatchProcessor(max_workers=jobs)
# Progress callback
def progress(current, total, item):
if not ctx.obj.get("json"):
status = "" if item.status.value == "success" else ""
click.echo(f"[{current}/{total}] {status} {item.input_path.name}")
# v3.2.0: Use 'passphrase' key instead of 'phrase'
credentials = {"passphrase": passphrase, "pin": pin}
result = processor.batch_decode(
images=list(images),
output_dir=Path(output_dir) if output_dir else None,
credentials=credentials,
recursive=recursive,
progress_callback=progress if not ctx.obj.get("json") else None,
)
if ctx.obj.get("json"):
click.echo(result.to_json())
else:
print_batch_result(result, verbose)
@batch.command("check")
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
@click.pass_context
def batch_check(ctx, images, recursive):
"""
Check capacity of multiple images.
Examples:
stegasoo batch check *.png
stegasoo batch check ./photos/ -r
"""
results = batch_capacity_check(list(images), recursive)
if ctx.obj.get("json"):
click.echo(json.dumps(results, indent=2))
else:
click.echo(f"{'Image':<40} {'Size':<12} {'Capacity':<12} {'Status'}")
click.echo("" * 80)
for item in results:
if "error" in item:
click.echo(f"{Path(item['path']).name:<40} {'ERROR':<12} {'':<12} {item['error']}")
else:
name = Path(item["path"]).name
if len(name) > 38:
name = name[:35] + "..."
status = "" if item["valid"] else ""
warnings = ", ".join(item.get("warnings", []))
click.echo(
f"{name:<40} "
f"{item['dimensions']:<12} "
f"{item['capacity_kb']:,} KB".ljust(12) + " "
f"{status} {warnings}"
)
# =============================================================================
# UTILITY COMMANDS
# =============================================================================
@cli.command()
@click.option(
"--words",
default=DEFAULT_PASSPHRASE_WORDS,
help=f"Number of words in passphrase (default: {DEFAULT_PASSPHRASE_WORDS})",
)
@click.option(
"--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})"
)
@click.pass_context
def generate(ctx, words, pin_length):
"""
Generate random credentials (passphrase + PIN).
Examples:
stegasoo generate
stegasoo generate --words 6 --pin-length 8
"""
import secrets
# Generate PIN
pin = "".join(str(secrets.randbelow(10)) for _ in range(pin_length))
# Ensure PIN doesn't start with 0
if pin[0] == "0":
pin = str(secrets.randbelow(9) + 1) + pin[1:]
# Generate passphrase (would use BIP-39 wordlist)
# Placeholder - actual implementation uses constants.get_wordlist()
try:
from .constants import get_wordlist
wordlist = get_wordlist()
phrase_words = [secrets.choice(wordlist) for _ in range(words)]
except (ImportError, FileNotFoundError):
# Fallback for testing
sample_words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
]
phrase_words = [secrets.choice(sample_words) for _ in range(words)]
passphrase = " ".join(phrase_words)
result = {
"passphrase": passphrase,
"pin": pin,
"passphrase_words": words,
"pin_length": pin_length,
}
if ctx.obj.get("json"):
click.echo(json.dumps(result, indent=2))
else:
click.echo(f"Passphrase: {passphrase}")
click.echo(f"PIN: {pin}")
click.echo("\n⚠️ Save these credentials securely - they cannot be recovered!")
@cli.command()
@click.pass_context
def info(ctx):
"""Show version and feature information."""
info_data = {
"version": __version__,
"compression": {
"available": [algorithm_name(a) for a in get_available_algorithms()],
"lz4_installed": HAS_LZ4,
},
"limits": {
"max_message_bytes": MAX_MESSAGE_SIZE,
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
},
}
if ctx.obj.get("json"):
click.echo(json.dumps(info_data, indent=2))
else:
click.echo(f"Stegasoo v{__version__}")
click.echo("\nCompression algorithms:")
for algo in get_available_algorithms():
click.echo(f"{algorithm_name(algo)}")
if not HAS_LZ4:
click.echo(" (install 'lz4' for LZ4 support)")
click.echo("\nLimits:")
click.echo(f" • Max message: {MAX_MESSAGE_SIZE:,} bytes")
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
def main():
"""Entry point for CLI."""
cli(obj={})
if __name__ == "__main__":
main()

208
src/stegasoo/compression.py Normal file
View File

@@ -0,0 +1,208 @@
"""
Stegasoo Compression Module
Provides transparent compression/decompression for payloads before encryption.
Supports multiple algorithms with automatic detection on decompression.
"""
import struct
import zlib
from enum import IntEnum
# Optional LZ4 support (faster, slightly worse ratio)
try:
import lz4.frame
HAS_LZ4 = True
except ImportError:
HAS_LZ4 = False
class CompressionAlgorithm(IntEnum):
"""Supported compression algorithms."""
NONE = 0
ZLIB = 1
LZ4 = 2
# Magic bytes for compressed payloads
COMPRESSION_MAGIC = b"\x00CMP"
# Minimum size to bother compressing (small data often expands)
MIN_COMPRESS_SIZE = 64
# Compression level for zlib (1-9, higher = better ratio but slower)
ZLIB_LEVEL = 6
class CompressionError(Exception):
"""Raised when compression/decompression fails."""
pass
def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB) -> bytes:
"""
Compress data with specified algorithm.
Format: MAGIC (4) + ALGORITHM (1) + ORIGINAL_SIZE (4) + COMPRESSED_DATA
Args:
data: Raw bytes to compress
algorithm: Compression algorithm to use
Returns:
Compressed data with header, or original data if compression didn't help
"""
if len(data) < MIN_COMPRESS_SIZE:
# Too small to benefit from compression
return _wrap_uncompressed(data)
if algorithm == CompressionAlgorithm.NONE:
return _wrap_uncompressed(data)
elif algorithm == CompressionAlgorithm.ZLIB:
compressed = zlib.compress(data, level=ZLIB_LEVEL)
elif algorithm == CompressionAlgorithm.LZ4:
if not HAS_LZ4:
# Fall back to zlib if LZ4 not available
compressed = zlib.compress(data, level=ZLIB_LEVEL)
algorithm = CompressionAlgorithm.ZLIB
else:
compressed = lz4.frame.compress(data)
else:
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
# Only use compression if it actually reduced size
if len(compressed) >= len(data):
return _wrap_uncompressed(data)
# Build header: MAGIC + algorithm + original_size + compressed_data
header = COMPRESSION_MAGIC + struct.pack("<BI", algorithm, len(data))
return header + compressed
def decompress(data: bytes) -> bytes:
"""
Decompress data, auto-detecting algorithm from header.
Args:
data: Potentially compressed data
Returns:
Decompressed data (or original if not compressed)
"""
# Check for compression magic
if not data.startswith(COMPRESSION_MAGIC):
# Not compressed by us, return as-is
return data
if len(data) < 9: # MAGIC(4) + ALGO(1) + SIZE(4)
raise CompressionError("Truncated compression header")
# Parse header
algorithm = CompressionAlgorithm(data[4])
original_size = struct.unpack("<I", data[5:9])[0]
compressed_data = data[9:]
if algorithm == CompressionAlgorithm.NONE:
result = compressed_data
elif algorithm == CompressionAlgorithm.ZLIB:
try:
result = zlib.decompress(compressed_data)
except zlib.error as e:
raise CompressionError(f"Zlib decompression failed: {e}")
elif algorithm == CompressionAlgorithm.LZ4:
if not HAS_LZ4:
raise CompressionError("LZ4 compression used but lz4 package not installed")
try:
result = lz4.frame.decompress(compressed_data)
except Exception as e:
raise CompressionError(f"LZ4 decompression failed: {e}")
else:
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
# Verify size
if len(result) != original_size:
raise CompressionError(f"Size mismatch: expected {original_size}, got {len(result)}")
return result
def _wrap_uncompressed(data: bytes) -> bytes:
"""Wrap uncompressed data with header for consistency."""
header = COMPRESSION_MAGIC + struct.pack("<BI", CompressionAlgorithm.NONE, len(data))
return header + data
def get_compression_ratio(original: bytes, compressed: bytes) -> float:
"""
Calculate compression ratio.
Returns:
Ratio where < 1.0 means compression helped, > 1.0 means it expanded
"""
if len(original) == 0:
return 1.0
return len(compressed) / len(original)
def estimate_compressed_size(
data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB
) -> int:
"""
Estimate compressed size without full compression.
Uses sampling for large data.
Args:
data: Data to estimate
algorithm: Algorithm to estimate for
Returns:
Estimated compressed size in bytes
"""
if len(data) < MIN_COMPRESS_SIZE:
return len(data) + 9 # Header overhead
# For small data, just compress it
if len(data) < 10000:
compressed = compress(data, algorithm)
return len(compressed)
# For large data, sample and extrapolate
sample_size = 8192
sample = data[:sample_size]
if algorithm == CompressionAlgorithm.ZLIB:
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
compressed_sample = lz4.frame.compress(sample)
else:
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
ratio = len(compressed_sample) / len(sample)
estimated = int(len(data) * ratio) + 9 # Add header
return estimated
def get_available_algorithms() -> list[CompressionAlgorithm]:
"""Get list of available compression algorithms."""
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
if HAS_LZ4:
algorithms.append(CompressionAlgorithm.LZ4)
return algorithms
def algorithm_name(algo: CompressionAlgorithm) -> str:
"""Get human-readable algorithm name."""
names = {
CompressionAlgorithm.NONE: "None",
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
CompressionAlgorithm.LZ4: "LZ4 (fast)",
}
return names.get(algo, "Unknown")

View File

@@ -1,24 +1,38 @@
"""
Stegasoo Constants and Configuration
Stegasoo Constants and Configuration (v4.0.1 - Channel Key Support)
Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here.
BREAKING CHANGES in v4.0.0:
- Added channel key support for deployment/group isolation
- FORMAT_VERSION bumped to 5 (adds flags byte to header)
- Header size increased by 1 byte for flags
BREAKING CHANGES in v3.2.0:
- Removed date dependency from cryptographic operations
- Renamed day_phrase → passphrase throughout codebase
"""
import os
from pathlib import Path
# ============================================================================
# VERSION
# ============================================================================
__version__ = "2.1.3"
__version__ = "4.0.1"
# ============================================================================
# FILE FORMAT
# ============================================================================
MAGIC_HEADER = b'\x89ST3'
FORMAT_VERSION = 3
MAGIC_HEADER = b"\x89ST3"
# FORMAT VERSION HISTORY:
# Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x)
# Version 4: Date-independent encryption (v3.2.0)
# Version 5: Channel key support (v4.0.0) - adds flags byte to header
FORMAT_VERSION = 5
# Payload type markers
PAYLOAD_TEXT = 0x01
@@ -44,45 +58,113 @@ PBKDF2_ITERATIONS = 600000
# INPUT LIMITS
# ============================================================================
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
MAX_FILENAME_LENGTH = 255 # Max filename length to store
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
MIN_IMAGE_PIXELS = 256 * 256 # Minimum viable image size
# Example in constants.py
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
MAX_MESSAGE_CHARS = 250_000 # Alias for clarity in templates
MIN_MESSAGE_LENGTH = 1 # Minimum message length
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE # Alias for consistency
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE # Maximum payload size (alias)
MAX_FILENAME_LENGTH = 255 # Max filename length to store
# File size limits
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size
MAX_FILE_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB payload
MAX_UPLOAD_SIZE = 30 * 1024 * 1024 # 30MB max upload (Flask)
# PIN configuration
MIN_PIN_LENGTH = 6
MAX_PIN_LENGTH = 9
DEFAULT_PIN_LENGTH = 6
MIN_PHRASE_WORDS = 3
MAX_PHRASE_WORDS = 12
DEFAULT_PHRASE_WORDS = 3
# Passphrase configuration (v3.2.0: renamed from PHRASE to PASSPHRASE)
# Increased defaults to compensate for removed date entropy (~33 bits)
MIN_PASSPHRASE_WORDS = 3
MAX_PASSPHRASE_WORDS = 12
DEFAULT_PASSPHRASE_WORDS = 4 # Increased from 3 (was DEFAULT_PHRASE_WORDS)
RECOMMENDED_PASSPHRASE_WORDS = 4 # Best practice guideline
# Legacy aliases for backward compatibility during transition
MIN_PHRASE_WORDS = MIN_PASSPHRASE_WORDS
MAX_PHRASE_WORDS = MAX_PASSPHRASE_WORDS
DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
# RSA configuration
MIN_RSA_BITS = 2048
VALID_RSA_SIZES = (2048, 3072, 4096)
DEFAULT_RSA_BITS = 2048
MIN_KEY_PASSWORD_LENGTH = 8
# ============================================================================
# WEB/API CONFIGURATION
# ============================================================================
# Temporary file storage
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
TEMP_FILE_EXPIRY_MINUTES = 5
# Thumbnail settings
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
THUMBNAIL_QUALITY = 85
# QR Code limits
QR_MAX_BINARY = 2900 # Safe limit for binary data in QR
QR_CROP_PADDING_PERCENT = 0.1 # Default padding when cropping QR codes
QR_CROP_MIN_PADDING_PX = 10 # Minimum padding in pixels
# ============================================================================
# FILE TYPES
# ============================================================================
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'gif'}
ALLOWED_KEY_EXTENSIONS = {'pem', 'key'}
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "bmp", "gif"}
ALLOWED_KEY_EXTENSIONS = {"pem", "key"}
# Lossless image formats (safe for steganography)
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
# Supported image formats for steganography
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
# ============================================================================
# DAYS
# DAYS (kept for organizational/UI purposes, not crypto)
# ============================================================================
DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')
DAY_NAMES = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
# ============================================================================
# COMPRESSION
# ============================================================================
# Minimum payload size to attempt compression (smaller often expands)
MIN_COMPRESS_SIZE = 64
# Zlib compression level (1-9, higher = better ratio, slower)
ZLIB_COMPRESSION_LEVEL = 6
# Compression header magic bytes
COMPRESSION_MAGIC = b"\x00CMP"
# ============================================================================
# BATCH PROCESSING
# ============================================================================
# Default parallel workers for batch operations
BATCH_DEFAULT_WORKERS = 4
# Maximum parallel workers
BATCH_MAX_WORKERS = 16
# Output filename suffix for batch encode
BATCH_OUTPUT_SUFFIX = "_encoded"
# ============================================================================
# DATA FILES
# ============================================================================
def get_data_dir() -> Path:
"""Get the data directory path."""
# Check multiple locations
@@ -91,12 +173,12 @@ def get_data_dir() -> Path:
# .parent.parent = src/
# .parent.parent.parent = project root (where data/ lives)
candidates = [
Path(__file__).parent.parent.parent / 'data', # Development: src/stegasoo -> project root
Path(__file__).parent / 'data', # Installed package
Path('/app/data'), # Docker
Path.cwd() / 'data', # Current directory
Path.cwd().parent / 'data', # One level up from cwd
Path.cwd().parent.parent / 'data', # Two levels up from cwd
Path(__file__).parent.parent.parent / "data", # Development: src/stegasoo -> project root
Path(__file__).parent / "data", # Installed package
Path("/app/data"), # Docker
Path.cwd() / "data", # Current directory
Path.cwd().parent / "data", # One level up from cwd
Path.cwd().parent.parent / "data", # Two levels up from cwd
]
for path in candidates:
@@ -109,7 +191,7 @@ def get_data_dir() -> Path:
def get_bip39_words() -> list[str]:
"""Load BIP-39 wordlist."""
wordlist_path = get_data_dir() / 'bip39-words.txt'
wordlist_path = get_data_dir() / "bip39-words.txt"
if not wordlist_path.exists():
raise FileNotFoundError(
@@ -117,7 +199,7 @@ def get_bip39_words() -> list[str]:
"Please ensure bip39-words.txt is in the data directory."
)
with open(wordlist_path, 'r') as f:
with open(wordlist_path) as f:
return [line.strip() for line in f if line.strip()]
@@ -131,3 +213,48 @@ def get_wordlist() -> list[str]:
if _bip39_words is None:
_bip39_words = get_bip39_words()
return _bip39_words
# =============================================================================
# DCT STEGANOGRAPHY (v3.0+)
# =============================================================================
# Embedding modes
EMBED_MODE_LSB = "lsb" # Spatial LSB embedding (default, original mode)
EMBED_MODE_DCT = "dct" # DCT domain embedding (new in v3.0)
EMBED_MODE_AUTO = "auto" # Auto-detect on decode
# DCT-specific constants
DCT_MAGIC_HEADER = b"\x89DCT" # Magic header for DCT mode
DCT_FORMAT_VERSION = 1
DCT_STEP_SIZE = 8 # QIM quantization step
# Valid embedding modes
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}
# Capacity estimation constants
LSB_BYTES_PER_PIXEL = 3 / 8 # 3 bits per pixel (RGB, 1 bit per channel) / 8 bits per byte
DCT_BYTES_PER_PIXEL = 0.125 # Approximate for DCT mode (varies by implementation)
def detect_stego_mode(encrypted_data: bytes) -> str:
"""
Detect embedding mode from encrypted payload header.
Args:
encrypted_data: First few bytes of extracted payload
Returns:
'lsb' or 'dct' or 'unknown'
"""
if len(encrypted_data) < 4:
return "unknown"
header = encrypted_data[:4]
if header == b"\x89ST3":
return EMBED_MODE_LSB
elif header == b"\x89DCT":
return EMBED_MODE_DCT
else:
return "unknown"

View File

@@ -1,42 +1,103 @@
"""
Stegasoo Cryptographic Functions
Stegasoo Cryptographic Functions (v4.0.0 - Channel Key Support)
Key derivation, encryption, and decryption using AES-256-GCM.
Supports both text messages and binary file payloads.
BREAKING CHANGES in v4.0.0:
- Added channel key support for deployment/group isolation
- Messages encoded with a channel key require the same key to decode
- Channel key can be configured via environment, config file, or explicit parameter
- FORMAT_VERSION bumped to 5
BREAKING CHANGES in v3.2.0:
- Removed date dependency from key derivation
- Renamed day_phrase → passphrase (no daily rotation needed)
"""
import io
import hashlib
import io
import secrets
import struct
import json
from typing import Optional, Union
from PIL import Image
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from PIL import Image
from .constants import (
MAGIC_HEADER, FORMAT_VERSION,
SALT_SIZE, IV_SIZE, TAG_SIZE,
ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM,
PBKDF2_ITERATIONS,
PAYLOAD_TEXT, PAYLOAD_FILE,
ARGON2_MEMORY_COST,
ARGON2_PARALLELISM,
ARGON2_TIME_COST,
FORMAT_VERSION,
IV_SIZE,
MAGIC_HEADER,
MAX_FILENAME_LENGTH,
PAYLOAD_FILE,
PAYLOAD_TEXT,
PBKDF2_ITERATIONS,
SALT_SIZE,
TAG_SIZE,
)
from .models import FilePayload, DecodeResult
from .exceptions import (
EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError
)
from .exceptions import DecryptionError, EncryptionError, InvalidHeaderError, KeyDerivationError
from .models import DecodeResult, FilePayload
# Check for Argon2 availability
try:
from argon2.low_level import hash_secret_raw, Type
from argon2.low_level import Type, hash_secret_raw
HAS_ARGON2 = True
except ImportError:
HAS_ARGON2 = False
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# =============================================================================
# CHANNEL KEY RESOLUTION
# =============================================================================
# Sentinel value for "use auto-detected channel key"
CHANNEL_KEY_AUTO = "auto"
def _resolve_channel_key(channel_key: str | bool | None) -> bytes | None:
"""
Resolve channel key parameter to actual key hash.
Args:
channel_key: Channel key parameter with these behaviors:
- None or "auto": Use server's configured key (from env/config)
- str (valid key): Use this specific key
- "" or False: Explicitly use NO channel key (public mode)
Returns:
32-byte channel key hash, or None for public mode
"""
# Explicit public mode
if channel_key == "" or channel_key is False:
return None
# Auto-detect from environment/config
if channel_key is None or channel_key == CHANNEL_KEY_AUTO:
from .channel import get_channel_key_hash
return get_channel_key_hash()
# Explicit key provided - validate and hash it
if isinstance(channel_key, str):
from .channel import format_channel_key, validate_channel_key
if not validate_channel_key(channel_key):
raise ValueError(f"Invalid channel key format: {channel_key}")
formatted = format_channel_key(channel_key)
return hashlib.sha256(formatted.encode("utf-8")).digest()
raise ValueError(f"Invalid channel_key type: {type(channel_key)}")
# =============================================================================
# CORE CRYPTO FUNCTIONS
# =============================================================================
def hash_photo(image_data: bytes) -> bytes:
@@ -52,8 +113,7 @@ def hash_photo(image_data: bytes) -> bytes:
Returns:
32-byte SHA-256 hash
"""
img = Image.open(io.BytesIO(image_data))
img = img.convert('RGB')
img: Image.Image = Image.open(io.BytesIO(image_data)).convert("RGB")
pixels = img.tobytes()
# Double-hash with prefix for additional mixing
@@ -64,32 +124,35 @@ def hash_photo(image_data: bytes) -> bytes:
def derive_hybrid_key(
photo_data: bytes,
day_phrase: str,
date_str: str,
passphrase: str,
salt: bytes,
pin: str = "",
rsa_key_data: Optional[bytes] = None
rsa_key_data: bytes | None = None,
channel_key: str | bool | None = None,
) -> bytes:
"""
Derive encryption key from multiple factors.
Combines:
- Photo hash (something you have)
- Day phrase (something you know, rotates daily)
- Passphrase (something you know)
- PIN (something you know, static)
- RSA key (something you have)
- Date (automatic rotation)
- Channel key (deployment/group binding)
- 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)
passphrase: Shared passphrase (recommend 4+ words)
salt: Random salt for this message
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
channel_key: Channel key parameter:
- None or "auto": Use configured key
- str: Use this specific key
- "" or False: No channel key (public mode)
Returns:
32-byte derived key
@@ -100,18 +163,20 @@ def derive_hybrid_key(
try:
photo_hash = hash_photo(photo_data)
key_material = (
photo_hash +
day_phrase.lower().encode() +
pin.encode() +
date_str.encode() +
salt
)
# Resolve channel key
channel_hash = _resolve_channel_key(channel_key)
# Build key material
key_material = photo_hash + passphrase.lower().encode() + pin.encode() + salt
# Add RSA key hash if provided
if rsa_key_data:
key_material += hashlib.sha256(rsa_key_data).digest()
# Add channel key hash if configured (v4.0.0)
if channel_hash:
key_material += channel_hash
if HAS_ARGON2:
key = hash_secret_raw(
secret=key_material,
@@ -120,7 +185,7 @@ def derive_hybrid_key(
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
hash_len=32,
type=Type.ID
type=Type.ID,
)
else:
kdf = PBKDF2HMAC(
@@ -128,7 +193,7 @@ def derive_hybrid_key(
length=32,
salt=salt,
iterations=PBKDF2_ITERATIONS,
backend=default_backend()
backend=default_backend(),
)
key = kdf.derive(key_material)
@@ -140,10 +205,10 @@ def derive_hybrid_key(
def derive_pixel_key(
photo_data: bytes,
day_phrase: str,
date_str: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
rsa_key_data: bytes | None = None,
channel_key: str | bool | None = None,
) -> bytes:
"""
Derive key for pseudo-random pixel selection.
@@ -153,31 +218,33 @@ def derive_pixel_key(
Args:
photo_data: Reference photo bytes
day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD)
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
channel_key: Channel key parameter (see derive_hybrid_key)
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()
)
# Resolve channel key
channel_hash = _resolve_channel_key(channel_key)
material = photo_hash + passphrase.lower().encode() + pin.encode()
if rsa_key_data:
material += hashlib.sha256(rsa_key_data).digest()
# Add channel key hash if configured (v4.0.0)
if channel_hash:
material += channel_hash
return hashlib.sha256(material + b"pixel_selection").digest()
def _pack_payload(
content: Union[str, bytes, FilePayload],
content: str | bytes | FilePayload,
) -> tuple[bytes, int]:
"""
Pack payload with type marker and metadata.
@@ -196,31 +263,31 @@ def _pack_payload(
"""
if isinstance(content, str):
# Text message
data = content.encode('utf-8')
data = content.encode("utf-8")
return bytes([PAYLOAD_TEXT]) + data, PAYLOAD_TEXT
elif isinstance(content, FilePayload):
# File with metadata
filename = content.filename[:MAX_FILENAME_LENGTH].encode('utf-8')
mime = (content.mime_type or '')[:100].encode('utf-8')
filename = content.filename[:MAX_FILENAME_LENGTH].encode("utf-8")
mime = (content.mime_type or "")[:100].encode("utf-8")
packed = (
bytes([PAYLOAD_FILE]) +
struct.pack('>H', len(filename)) +
filename +
struct.pack('>H', len(mime)) +
mime +
content.data
bytes([PAYLOAD_FILE])
+ struct.pack(">H", len(filename))
+ filename
+ struct.pack(">H", len(mime))
+ mime
+ content.data
)
return packed, PAYLOAD_FILE
else:
# Raw bytes - treat as file with no name
packed = (
bytes([PAYLOAD_FILE]) +
struct.pack('>H', 0) + # No filename
struct.pack('>H', 0) + # No mime
content
bytes([PAYLOAD_FILE])
+ struct.pack(">H", 0) # No filename
+ struct.pack(">H", 0) # No mime
+ content
)
return packed, PAYLOAD_FILE
@@ -242,60 +309,64 @@ def _unpack_payload(data: bytes) -> DecodeResult:
if payload_type == PAYLOAD_TEXT:
# Text message
text = data[1:].decode('utf-8')
return DecodeResult(payload_type='text', message=text)
text = data[1:].decode("utf-8")
return DecodeResult(payload_type="text", message=text)
elif payload_type == PAYLOAD_FILE:
# File with metadata
offset = 1
# Read filename
filename_len = struct.unpack('>H', data[offset:offset+2])[0]
filename_len = struct.unpack(">H", data[offset : offset + 2])[0]
offset += 2
filename = data[offset:offset+filename_len].decode('utf-8') if filename_len else None
filename = data[offset : offset + filename_len].decode("utf-8") if filename_len else None
offset += filename_len
# Read mime type
mime_len = struct.unpack('>H', data[offset:offset+2])[0]
mime_len = struct.unpack(">H", data[offset : offset + 2])[0]
offset += 2
mime_type = data[offset:offset+mime_len].decode('utf-8') if mime_len else None
mime_type = data[offset : offset + mime_len].decode("utf-8") if mime_len else None
offset += mime_len
# Rest is file data
file_data = data[offset:]
return DecodeResult(
payload_type='file',
file_data=file_data,
filename=filename,
mime_type=mime_type
payload_type="file", file_data=file_data, filename=filename, mime_type=mime_type
)
else:
# Unknown type - try to decode as text (backward compatibility)
try:
text = data.decode('utf-8')
return DecodeResult(payload_type='text', message=text)
text = data.decode("utf-8")
return DecodeResult(payload_type="text", message=text)
except UnicodeDecodeError:
return DecodeResult(payload_type='file', file_data=data)
return DecodeResult(payload_type="file", file_data=data)
# =============================================================================
# HEADER FLAGS (v4.0.0)
# =============================================================================
# Header flag bits
FLAG_CHANNEL_KEY = 0x01 # Set if encoded with a channel key
def encrypt_message(
message: Union[str, bytes, FilePayload],
message: str | bytes | FilePayload,
photo_data: bytes,
day_phrase: str,
date_str: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
rsa_key_data: bytes | None = None,
channel_key: str | bool | None = None,
) -> bytes:
"""
Encrypt message or file using AES-256-GCM with hybrid key derivation.
Message format:
Message format (v4.0.0 - with channel key support):
- Magic header (4 bytes)
- Version (1 byte)
- Date length (1 byte)
- Date string (variable)
- Version (1 byte) = 5
- Flags (1 byte) - indicates if channel key was used
- Salt (32 bytes)
- IV (12 bytes)
- Auth tag (16 bytes)
@@ -304,10 +375,13 @@ def encrypt_message(
Args:
message: Message string, raw bytes, or FilePayload to encrypt
photo_data: Reference photo bytes
day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD)
passphrase: Shared passphrase (recommend 4+ words for good entropy)
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
channel_key: Channel key parameter:
- None or "auto": Use configured key
- str: Use this specific key
- "" or False: No channel key (public mode)
Returns:
Encrypted message bytes
@@ -317,9 +391,15 @@ def encrypt_message(
"""
try:
salt = secrets.token_bytes(SALT_SIZE)
key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data)
key = derive_hybrid_key(photo_data, passphrase, salt, pin, rsa_key_data, channel_key)
iv = secrets.token_bytes(IV_SIZE)
# Determine flags
flags = 0
channel_hash = _resolve_channel_key(channel_key)
if channel_hash:
flags |= FLAG_CHANNEL_KEY
# Pack payload with type marker
packed_payload, _ = _pack_payload(message)
@@ -327,43 +407,39 @@ def encrypt_message(
padding_len = secrets.randbelow(256) + 64
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
padding_needed = padded_len - len(packed_payload)
padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(packed_payload))
padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload))
padded_message = packed_payload + padding
# Build header for AAD
header = MAGIC_HEADER + bytes([FORMAT_VERSION, flags])
# 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]))
encryptor.authenticate_additional_data(header)
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
)
# v4.0.0: Header with flags byte
return header + 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]:
def parse_header(encrypted_data: bytes) -> dict | None:
"""
Parse the header from encrypted data.
v4.0.0: Includes flags byte for channel key indicator.
Args:
encrypted_data: Raw encrypted bytes
Returns:
Dict with date, salt, iv, tag, ciphertext or None if invalid
Dict with salt, iv, tag, ciphertext, flags or None if invalid
"""
if len(encrypted_data) < 10 or encrypted_data[:4] != MAGIC_HEADER:
# Min size: Magic(4) + Version(1) + Flags(1) + Salt(32) + IV(12) + Tag(16) = 66 bytes
if len(encrypted_data) < 66 or encrypted_data[:4] != MAGIC_HEADER:
return None
try:
@@ -371,24 +447,25 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
if version != FORMAT_VERSION:
return None
date_len = encrypted_data[5]
date_str = encrypted_data[6:6 + date_len].decode()
flags = encrypted_data[5]
offset = 6 + date_len
salt = encrypted_data[offset:offset + SALT_SIZE]
offset = 6
salt = encrypted_data[offset : offset + SALT_SIZE]
offset += SALT_SIZE
iv = encrypted_data[offset:offset + IV_SIZE]
iv = encrypted_data[offset : offset + IV_SIZE]
offset += IV_SIZE
tag = encrypted_data[offset:offset + TAG_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
"version": version,
"flags": flags,
"has_channel_key": bool(flags & FLAG_CHANNEL_KEY),
"salt": salt,
"iv": iv,
"tag": tag,
"ciphertext": ciphertext,
}
except Exception:
return None
@@ -397,19 +474,21 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
def decrypt_message(
encrypted_data: bytes,
photo_data: bytes,
day_phrase: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
rsa_key_data: bytes | None = None,
channel_key: str | bool | None = None,
) -> DecodeResult:
"""
Decrypt message using the embedded date from the header.
Decrypt message (v4.0.0 - with channel key support).
Args:
encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes
day_phrase: The day's phrase (must match encoding day)
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
channel_key: Channel key parameter (see encrypt_message)
Returns:
DecodeResult with decrypted content
@@ -422,40 +501,59 @@ def decrypt_message(
if not header:
raise InvalidHeaderError("Invalid or missing Stegasoo header")
# Check for channel key mismatch and provide helpful error
channel_hash = _resolve_channel_key(channel_key)
has_configured_key = channel_hash is not None
message_has_key = header["has_channel_key"]
try:
key = derive_hybrid_key(
photo_data, day_phrase, header['date'], header['salt'], pin, rsa_key_data
photo_data, passphrase, header["salt"], pin, rsa_key_data, channel_key
)
# Reconstruct header for AAD verification
aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header["flags"]])
cipher = Cipher(
algorithms.AES(key),
modes.GCM(header['iv'], header['tag']),
backend=default_backend()
algorithms.AES(key), modes.GCM(header["iv"], header["tag"]), backend=default_backend()
)
decryptor = cipher.decryptor()
decryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION]))
decryptor.authenticate_additional_data(aad_header)
padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize()
original_length = struct.unpack('>I', padded_plaintext[-4:])[0]
padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize()
original_length = struct.unpack(">I", padded_plaintext[-4:])[0]
payload_data = padded_plaintext[:original_length]
result = _unpack_payload(payload_data)
result.date_encoded = header['date']
return result
except Exception as e:
raise DecryptionError(
"Decryption failed. Check your phrase, PIN, RSA key, and reference photo."
) from e
# Provide more helpful error message for channel key issues
if message_has_key and not has_configured_key:
raise DecryptionError(
"Decryption failed. This message was encoded with a channel key, "
"but no channel key is configured. Provide the correct channel key."
) from e
elif not message_has_key and has_configured_key:
raise DecryptionError(
"Decryption failed. This message was encoded without a channel key, "
"but you have one configured. Try with channel_key='' for public mode."
) from e
else:
raise DecryptionError(
"Decryption failed. Check your passphrase, PIN, RSA key, "
"reference photo, and channel key."
) from e
def decrypt_message_text(
encrypted_data: bytes,
photo_data: bytes,
day_phrase: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
rsa_key_data: bytes | None = None,
channel_key: str | bool | None = None,
) -> str:
"""
Decrypt message and return as text string.
@@ -465,9 +563,10 @@ def decrypt_message_text(
Args:
encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes
day_phrase: The day's phrase
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
channel_key: Channel key parameter
Returns:
Decrypted message string
@@ -475,13 +574,13 @@ def decrypt_message_text(
Raises:
DecryptionError: If decryption fails or content is a file
"""
result = decrypt_message(encrypted_data, photo_data, day_phrase, pin, rsa_key_data)
result = decrypt_message(encrypted_data, photo_data, passphrase, pin, rsa_key_data, channel_key)
if result.is_file:
if result.file_data:
# Try to decode as text
try:
return result.file_data.decode('utf-8')
return result.file_data.decode("utf-8")
except UnicodeDecodeError:
raise DecryptionError(
f"Content is a binary file ({result.filename or 'unnamed'}), not text"
@@ -491,22 +590,38 @@ def decrypt_message_text(
return result.message or ""
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
# =============================================================================
# CHANNEL KEY UTILITIES (exposed for convenience)
# =============================================================================
def get_active_channel_key() -> str | None:
"""
Get the currently configured channel key (if any).
Returns:
Formatted channel key string, or None if not configured
"""
from .channel import get_channel_key
return get_channel_key()
def get_channel_fingerprint(key: str | None = None) -> str | None:
"""
Get a display-safe fingerprint of a channel key.
Args:
key: Channel key (if None, uses configured key)
Returns:
Masked key like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None
"""
from .channel import get_channel_fingerprint as _get_fingerprint
return _get_fingerprint(key)

View File

@@ -0,0 +1,979 @@
"""
DCT Domain Steganography Module (v3.2.0-patch2)
Embeds data in DCT coefficients with two approaches:
1. PNG output: Scipy-based DCT transform (grayscale or color)
2. JPEG output: jpegio-based coefficient manipulation (if available)
v3.2.0-patch2 Changes:
- Chunked processing for large images to avoid heap corruption
- Process image in vertical strips to limit memory per operation
- Isolated DCT operations with fresh array allocations
- Workaround for scipy.fftpack memory issues
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode)
"""
import gc
import hashlib
import io
import struct
from dataclasses import dataclass
from enum import Enum
import numpy as np
from PIL import Image
# Check for scipy availability (for PNG/DCT mode)
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
try:
from scipy.fft import dct, idct
HAS_SCIPY = True
except ImportError:
try:
from scipy.fftpack import dct, idct
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
dct = None
idct = None
# Check for jpegio availability (for proper JPEG mode)
try:
import jpegio as jio
HAS_JPEGIO = True
except ImportError:
HAS_JPEGIO = False
jio = None
# ============================================================================
# CONSTANTS
# ============================================================================
BLOCK_SIZE = 8
EMBED_POSITIONS = [
(0, 1),
(1, 0),
(2, 0),
(1, 1),
(0, 2),
(0, 3),
(1, 2),
(2, 1),
(3, 0),
(4, 0),
(3, 1),
(2, 2),
(1, 3),
(0, 4),
(0, 5),
(1, 4),
(2, 3),
(3, 2),
(4, 1),
(5, 0),
(5, 1),
(4, 2),
(3, 3),
(2, 4),
(1, 5),
(0, 6),
(0, 7),
(1, 6),
(2, 5),
(3, 4),
(4, 3),
(5, 2),
(6, 1),
(7, 0),
]
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20]
QUANT_STEP = 25
DCT_MAGIC = b"DCTS"
HEADER_SIZE = 10
OUTPUT_FORMAT_PNG = "png"
OUTPUT_FORMAT_JPEG = "jpeg"
JPEG_OUTPUT_QUALITY = 95
JPEGIO_MAGIC = b"JPGS"
JPEGIO_MIN_COEF_MAGNITUDE = 2
JPEGIO_EMBED_CHANNEL = 0
FLAG_COLOR_MODE = 0x01
# Chunking settings for large images
MAX_CHUNK_HEIGHT = 512 # Process in 512-pixel tall strips
# JPEG normalization settings
# JPEGs with quality=100 have all quantization values = 1, which crashes jpegio
JPEGIO_NORMALIZE_QUALITY = 95 # Re-save quality for problematic JPEGs
JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # If all quant values <= this, normalize
# ============================================================================
# DATA CLASSES
# ============================================================================
class DCTOutputFormat(Enum):
PNG = "png"
JPEG = "jpeg"
@dataclass
class DCTEmbedStats:
blocks_used: int
blocks_available: int
bits_embedded: int
capacity_bits: int
usage_percent: float
image_width: int
image_height: int
output_format: str
jpeg_native: bool = False
color_mode: str = "grayscale"
@dataclass
class DCTCapacityInfo:
width: int
height: int
blocks_x: int
blocks_y: int
total_blocks: int
bits_per_block: int
total_capacity_bits: int
total_capacity_bytes: int
usable_capacity_bytes: int
# ============================================================================
# AVAILABILITY CHECKS
# ============================================================================
def _check_scipy():
if not HAS_SCIPY:
raise ImportError("DCT steganography requires scipy. Install with: pip install scipy")
def has_dct_support() -> bool:
return HAS_SCIPY
def has_jpegio_support() -> bool:
return HAS_JPEGIO
# ============================================================================
# SAFE DCT FUNCTIONS
# These create fresh arrays to avoid scipy memory corruption issues
# ============================================================================
def _safe_dct2(block: np.ndarray) -> np.ndarray:
"""
Apply 2D DCT with memory isolation.
Creates a completely fresh array to avoid heap corruption.
"""
# Create a brand new array (not a view)
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
# First DCT on columns (transpose -> DCT rows -> transpose back)
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
col = np.array(safe_block[:, i], dtype=np.float64, copy=True)
temp[:, i] = dct(col, norm="ortho")
# Second DCT on rows
result = np.zeros_like(temp, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
row = np.array(temp[i, :], dtype=np.float64, copy=True)
result[i, :] = dct(row, norm="ortho")
return result
def _safe_idct2(block: np.ndarray) -> np.ndarray:
"""
Apply 2D inverse DCT with memory isolation.
Creates a completely fresh array to avoid heap corruption.
"""
# Create a brand new array (not a view)
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
# First IDCT on rows
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
row = np.array(safe_block[i, :], dtype=np.float64, copy=True)
temp[i, :] = idct(row, norm="ortho")
# Second IDCT on columns
result = np.zeros_like(temp, dtype=np.float64, order="C")
for i in range(BLOCK_SIZE):
col = np.array(temp[:, i], dtype=np.float64, copy=True)
result[:, i] = idct(col, norm="ortho")
return result
# ============================================================================
# IMAGE PROCESSING HELPERS
# ============================================================================
def _to_grayscale(image_data: bytes) -> np.ndarray:
img = Image.open(io.BytesIO(image_data))
gray = img.convert("L")
return np.array(gray, dtype=np.float64, copy=True, order="C")
def _extract_y_channel(image_data: bytes) -> np.ndarray:
img = Image.open(io.BytesIO(image_data))
if img.mode != "RGB":
img = img.convert("RGB")
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
Y = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]
return np.array(Y, dtype=np.float64, copy=True, order="C")
def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
h, w = image.shape
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
if new_h == h and new_w == w:
return np.array(image, dtype=np.float64, copy=True, order="C"), (h, w)
padded = np.zeros((new_h, new_w), dtype=np.float64, order="C")
padded[:h, :w] = image
# Simple edge replication for padding
if new_h > h:
for i in range(h, new_h):
padded[i, :w] = padded[h - 1, :w]
if new_w > w:
for j in range(w, new_w):
padded[:h, j] = padded[:h, w - 1]
if new_h > h and new_w > w:
padded[h:, w:] = padded[h - 1, w - 1]
return padded, (h, w)
def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray:
h, w = original_size
return np.array(image[:h, :w], dtype=np.float64, copy=True, order="C")
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
quantized = round(coef / quant_step)
if (quantized % 2) != bit:
if quantized % 2 == 0 and bit == 1:
quantized += 1 if coef >= quantized * quant_step else -1
elif quantized % 2 == 1 and bit == 0:
quantized += 1 if coef >= quantized * quant_step else -1
return float(quantized * quant_step)
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
quantized = round(coef / quant_step)
return int(quantized % 2)
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
hash_bytes = hashlib.sha256(seed).digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big"))
order = list(range(num_blocks))
rng.shuffle(order)
return order
def _save_stego_image(image: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
clipped = np.clip(image, 0, 255).astype(np.uint8)
img = Image.fromarray(clipped, mode="L")
buffer = io.BytesIO()
if output_format == OUTPUT_FORMAT_JPEG:
img.save(buffer, format="JPEG", quality=JPEG_OUTPUT_QUALITY, subsampling=0, optimize=True)
else:
img.save(buffer, format="PNG", optimize=True)
return buffer.getvalue()
def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
clipped = np.clip(rgb_array, 0, 255).astype(np.uint8)
img = Image.fromarray(clipped, mode="RGB")
buffer = io.BytesIO()
if output_format == OUTPUT_FORMAT_JPEG:
img.save(buffer, format="JPEG", quality=JPEG_OUTPUT_QUALITY, subsampling=0, optimize=True)
else:
img.save(buffer, format="PNG", optimize=True)
return buffer.getvalue()
def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
R = rgb[:, :, 0].astype(np.float64)
G = rgb[:, :, 1].astype(np.float64)
B = rgb[:, :, 2].astype(np.float64)
Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order="C")
Cb = np.array(
128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order="C"
)
Cr = np.array(
128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order="C"
)
return Y, Cb, Cr
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
R = Y + 1.402 * (Cr - 128)
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
B = Y + 1.772 * (Cb - 128)
rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float64, order="C")
rgb[:, :, 0] = R
rgb[:, :, 1] = G
rgb[:, :, 2] = B
return rgb
def _create_header(data_length: int, flags: int = 0) -> bytes:
return struct.pack(">4sBBI", DCT_MAGIC, 1, flags, data_length)
def _parse_header(header_bits: list) -> tuple[int, int, int]:
if len(header_bits) < HEADER_SIZE * 8:
raise ValueError("Insufficient header data")
header_bytes = bytes(
[
sum(header_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
for i in range(HEADER_SIZE)
]
)
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes)
if magic != DCT_MAGIC:
raise ValueError("Invalid DCT stego magic bytes")
return version, flags, length
# ============================================================================
# JPEGIO HELPERS
# ============================================================================
def _jpegio_bytes_to_file(data: bytes, suffix: str = ".jpg") -> str:
import os
import tempfile
fd, path = tempfile.mkstemp(suffix=suffix)
try:
os.write(fd, data)
finally:
os.close(fd)
return path
def _jpegio_get_usable_positions(coef_array: np.ndarray) -> list:
positions = []
h, w = coef_array.shape
for row in range(h):
for col in range(w):
if (row % BLOCK_SIZE == 0) and (col % BLOCK_SIZE == 0):
continue
if abs(coef_array[row, col]) >= JPEGIO_MIN_COEF_MAGNITUDE:
positions.append((row, col))
return positions
def _jpegio_generate_order(num_positions: int, seed: bytes) -> list:
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big"))
order = list(range(num_positions))
rng.shuffle(order)
return order
def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes:
return struct.pack(">4sBBI", JPEGIO_MAGIC, 1, flags, data_length)
def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
if len(header_bytes) < HEADER_SIZE:
raise ValueError("Insufficient header data")
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
if magic != JPEGIO_MAGIC:
raise ValueError(f"Invalid JPEG stego magic: {magic}")
return version, flags, length
# ============================================================================
# PUBLIC API
# ============================================================================
def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo:
"""Calculate DCT embedding capacity of an image."""
_check_scipy()
# Just get dimensions, don't process anything
img = Image.open(io.BytesIO(image_data))
width, height = img.size
img.close() # Explicitly close
blocks_x = width // BLOCK_SIZE
blocks_y = height // BLOCK_SIZE
total_blocks = blocks_x * blocks_y
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
total_bits = total_blocks * bits_per_block
total_bytes = total_bits // 8
usable_bytes = max(0, total_bytes - HEADER_SIZE)
return DCTCapacityInfo(
width=width,
height=height,
blocks_x=blocks_x,
blocks_y=blocks_y,
total_blocks=total_blocks,
bits_per_block=bits_per_block,
total_capacity_bits=total_bits,
total_capacity_bytes=total_bytes,
usable_capacity_bytes=usable_bytes,
)
def will_fit_dct(data_length: int, image_data: bytes) -> bool:
capacity = calculate_dct_capacity(image_data)
return data_length <= capacity.usable_capacity_bytes
def estimate_capacity_comparison(image_data: bytes) -> dict:
"""Compare LSB and DCT capacity (no actual DCT operations)."""
img = Image.open(io.BytesIO(image_data))
width, height = img.size
img.close()
pixels = width * height
lsb_bytes = (pixels * 3) // 8
blocks = (width // 8) * (height // 8)
dct_bytes = (blocks * 16) // 8 - HEADER_SIZE
return {
"width": width,
"height": height,
"lsb": {
"capacity_bytes": lsb_bytes,
"capacity_kb": lsb_bytes / 1024,
"output": "PNG/BMP (color)",
},
"dct": {
"capacity_bytes": dct_bytes,
"capacity_kb": dct_bytes / 1024,
"output": "PNG or JPEG (grayscale)",
"ratio_vs_lsb": (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0,
"available": HAS_SCIPY,
},
"jpeg_native": {
"available": HAS_JPEGIO,
"note": "Uses jpegio for proper JPEG coefficient embedding",
},
}
def embed_in_dct(
data: bytes,
carrier_image: bytes,
seed: bytes,
output_format: str = OUTPUT_FORMAT_PNG,
color_mode: str = "color",
) -> tuple[bytes, DCTEmbedStats]:
"""Embed data using DCT coefficient modification."""
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
raise ValueError(f"Invalid output format: {output_format}")
if color_mode not in ("color", "grayscale"):
color_mode = "color"
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
return _embed_jpegio(data, carrier_image, seed, color_mode)
_check_scipy()
return _embed_scipy_dct_safe(data, carrier_image, seed, output_format, color_mode)
def _embed_scipy_dct_safe(
data: bytes,
carrier_image: bytes,
seed: bytes,
output_format: str,
color_mode: str = "color",
) -> tuple[bytes, DCTEmbedStats]:
"""
Embed using scipy DCT with safe memory handling.
Uses row-by-row 1D DCT operations instead of 2D arrays to avoid
scipy memory corruption issues with large images.
"""
capacity_info = calculate_dct_capacity(carrier_image)
if len(data) > capacity_info.usable_capacity_bytes:
raise ValueError(
f"Data too large ({len(data)} bytes) for carrier "
f"(capacity: {capacity_info.usable_capacity_bytes} bytes)"
)
# Load image
img = Image.open(io.BytesIO(carrier_image))
width, height = img.size
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
# Prepare payload bits
header = _create_header(len(data), flags)
payload = header + data
bits = []
for byte in payload:
for i in range(7, -1, -1):
bits.append((byte >> i) & 1)
# Generate block order
num_blocks = capacity_info.total_blocks
block_order = _generate_block_order(num_blocks, seed)
blocks_x = width // BLOCK_SIZE
if color_mode == "color" and img.mode in ("RGB", "RGBA"):
if img.mode == "RGBA":
img = img.convert("RGB")
# Process color image
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
img.close()
Y, Cb, Cr = _rgb_to_ycbcr(rgb)
del rgb
gc.collect()
Y_padded, original_size = _pad_to_blocks(Y)
del Y
gc.collect()
# Embed in Y channel
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x)
del Y_padded
gc.collect()
Y_result = _unpad_image(Y_embedded, original_size)
del Y_embedded
gc.collect()
result_rgb = _ycbcr_to_rgb(Y_result, Cb, Cr)
del Y_result, Cb, Cr
gc.collect()
stego_bytes = _save_color_image(result_rgb, output_format)
del result_rgb
gc.collect()
else:
# Grayscale mode
image = _to_grayscale(carrier_image)
img.close()
padded, original_size = _pad_to_blocks(image)
del image
gc.collect()
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x)
del padded
gc.collect()
result = _unpad_image(embedded, original_size)
del embedded
gc.collect()
stego_bytes = _save_stego_image(result, output_format)
del result
gc.collect()
stats = DCTEmbedStats(
blocks_used=(len(bits) + len(DEFAULT_EMBED_POSITIONS) - 1) // len(DEFAULT_EMBED_POSITIONS),
blocks_available=capacity_info.total_blocks,
bits_embedded=len(bits),
capacity_bits=capacity_info.total_capacity_bits,
usage_percent=(len(bits) / capacity_info.total_capacity_bits) * 100,
image_width=width,
image_height=height,
output_format=output_format,
jpeg_native=False,
color_mode=color_mode,
)
return stego_bytes, stats
def _embed_in_channel_safe(
channel: np.ndarray,
bits: list,
block_order: list,
blocks_x: int,
) -> np.ndarray:
"""
Embed bits in channel using safe DCT operations.
Processes one block at a time with fresh array allocations.
"""
h, w = channel.shape
# Create result with explicit new memory
result = np.array(channel, dtype=np.float64, copy=True, order="C")
bit_idx = 0
for block_num in block_order:
if bit_idx >= len(bits):
break
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
# Extract block - create brand new array
block = np.array(
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
dtype=np.float64,
copy=True,
order="C",
)
# Apply safe DCT (row-by-row)
dct_block = _safe_dct2(block)
# Embed bits
for pos in DEFAULT_EMBED_POSITIONS:
if bit_idx >= len(bits):
break
dct_block[pos[0], pos[1]] = _embed_bit_in_coeff(
float(dct_block[pos[0], pos[1]]), bits[bit_idx]
)
bit_idx += 1
# Apply safe inverse DCT
modified_block = _safe_idct2(dct_block)
# Copy back
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_block
# Clean up this iteration
del block, dct_block, modified_block
# Force garbage collection
gc.collect()
return result
def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes:
"""
Normalize a JPEG image to ensure jpegio can process it safely.
JPEGs saved with quality=100 have quantization tables with all values = 1,
which causes jpegio to crash due to huge coefficient magnitudes.
This function detects such images and re-saves them at a safe quality level.
Args:
image_data: Raw JPEG bytes
Returns:
Normalized JPEG bytes (may be unchanged if already safe)
"""
img = Image.open(io.BytesIO(image_data))
# Only process JPEGs
if img.format != "JPEG":
img.close()
return image_data
# Check quantization tables
needs_normalization = False
if hasattr(img, "quantization") and img.quantization:
for table_id, table in img.quantization.items():
# If all values in any table are <= threshold, normalize
if max(table) <= JPEGIO_MAX_QUANT_VALUE_THRESHOLD:
needs_normalization = True
break
if not needs_normalization:
img.close()
return image_data
# Re-save at safe quality level
if img.mode != "RGB":
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=JPEGIO_NORMALIZE_QUALITY, subsampling=0)
img.close()
return buffer.getvalue()
def _embed_jpegio(
data: bytes,
carrier_image: bytes,
seed: bytes,
color_mode: str = "color",
) -> tuple[bytes, DCTEmbedStats]:
"""Embed using jpegio for proper JPEG coefficient modification."""
import os
import tempfile
# Normalize JPEG to avoid crashes with quality=100 images
carrier_image = _normalize_jpeg_for_jpegio(carrier_image)
img = Image.open(io.BytesIO(carrier_image))
width, height = img.size
if img.format != "JPEG":
buffer = io.BytesIO()
if img.mode != "RGB":
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=95, subsampling=0)
carrier_image = buffer.getvalue()
img.close()
input_path = _jpegio_bytes_to_file(carrier_image, suffix=".jpg")
output_path = tempfile.mktemp(suffix=".jpg")
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
try:
jpeg = jio.read(input_path)
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
all_positions = _jpegio_get_usable_positions(coef_array)
order = _jpegio_generate_order(len(all_positions), seed)
header = _jpegio_create_header(len(data), flags)
payload = header + data
bits = []
for byte in payload:
for i in range(7, -1, -1):
bits.append((byte >> i) & 1)
if len(bits) > len(all_positions):
raise ValueError(
f"Payload too large: {len(bits)} bits, "
f"only {len(all_positions)} usable coefficients"
)
coefs_used = 0
for bit_idx, pos_idx in enumerate(order):
if bit_idx >= len(bits):
break
row, col = all_positions[pos_idx]
coef = coef_array[row, col]
if (coef & 1) != bits[bit_idx]:
if coef > 0:
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
else:
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
coefs_used += 1
jio.write(jpeg, output_path)
with open(output_path, "rb") as f:
stego_bytes = f.read()
stats = DCTEmbedStats(
blocks_used=coefs_used // 63,
blocks_available=len(all_positions) // 63,
bits_embedded=len(bits),
capacity_bits=len(all_positions),
usage_percent=(len(bits) / len(all_positions)) * 100 if all_positions else 0,
image_width=width,
image_height=height,
output_format=OUTPUT_FORMAT_JPEG,
jpeg_native=True,
color_mode=color_mode,
)
return stego_bytes, stats
finally:
for path in [input_path, output_path]:
try:
os.unlink(path)
except OSError:
pass
def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes:
"""Extract data from DCT stego image."""
img = Image.open(io.BytesIO(stego_image))
fmt = img.format
img.close()
if fmt == "JPEG" and HAS_JPEGIO:
try:
return _extract_jpegio(stego_image, seed)
except ValueError:
pass
_check_scipy()
return _extract_scipy_dct_safe(stego_image, seed)
def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
"""Extract using safe DCT operations."""
img = Image.open(io.BytesIO(stego_image))
width, height = img.size
mode = img.mode
if mode in ("RGB", "RGBA"):
channel = _extract_y_channel(stego_image)
else:
channel = _to_grayscale(stego_image)
img.close()
padded, _ = _pad_to_blocks(channel)
del channel
gc.collect()
h, w = padded.shape
blocks_x = w // BLOCK_SIZE
num_blocks = (h // BLOCK_SIZE) * blocks_x
block_order = _generate_block_order(num_blocks, seed)
all_bits = []
for block_num in block_order:
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
block = np.array(
padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
dtype=np.float64,
copy=True,
order="C",
)
dct_block = _safe_dct2(block)
for pos in DEFAULT_EMBED_POSITIONS:
bit = _extract_bit_from_coeff(float(dct_block[pos[0], pos[1]]))
all_bits.append(bit)
del block, dct_block
if len(all_bits) >= HEADER_SIZE * 8:
try:
_, flags, data_length = _parse_header(all_bits[: HEADER_SIZE * 8])
total_needed = (HEADER_SIZE + data_length) * 8
if len(all_bits) >= total_needed:
break
except ValueError:
pass
del padded
gc.collect()
_, flags, data_length = _parse_header(all_bits)
data_bits = all_bits[HEADER_SIZE * 8 : (HEADER_SIZE + data_length) * 8]
data = bytes(
[
sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
for i in range(data_length)
]
)
return data
def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
"""Extract using jpegio for JPEG images."""
import os
# Normalize JPEG to avoid crashes with quality=100 images
# (shouldn't happen with stego images, but be defensive)
stego_image = _normalize_jpeg_for_jpegio(stego_image)
temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg")
try:
jpeg = jio.read(temp_path)
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
all_positions = _jpegio_get_usable_positions(coef_array)
order = _jpegio_generate_order(len(all_positions), seed)
header_bits = []
for pos_idx in order[: HEADER_SIZE * 8]:
row, col = all_positions[pos_idx]
coef = coef_array[row, col]
header_bits.append(coef & 1)
header_bytes = bytes(
[
sum(header_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
for i in range(HEADER_SIZE)
]
)
_, flags, data_length = _jpegio_parse_header(header_bytes)
total_bits_needed = (HEADER_SIZE + data_length) * 8
all_bits = []
for bit_idx, pos_idx in enumerate(order):
if bit_idx >= total_bits_needed:
break
row, col = all_positions[pos_idx]
coef = coef_array[row, col]
all_bits.append(coef & 1)
data_bits = all_bits[HEADER_SIZE * 8 :]
data = bytes(
[
sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
for i in range(data_length)
]
)
return data
finally:
try:
os.unlink(temp_path)
except OSError:
pass
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def get_output_extension(output_format: str) -> str:
if output_format == OUTPUT_FORMAT_JPEG:
return ".jpg"
return ".png"
def get_output_mimetype(output_format: str) -> str:
if output_format == OUTPUT_FORMAT_JPEG:
return "image/jpeg"
return "image/png"

View File

@@ -0,0 +1,974 @@
"""
DCT Domain Steganography Module (v3.2.0)
Embeds data in DCT coefficients with two approaches:
1. PNG output: Scipy-based DCT transform (grayscale or color)
2. JPEG output: jpegio-based coefficient manipulation (if available)
The JPEG approach is the "correct" way to do JPEG steganography because
it directly modifies the already-quantized coefficients without re-encoding.
Changes in v3.0.2:
- jpegio integration for proper JPEG coefficient embedding
- Falls back to warning if jpegio not available for JPEG output
- Maintains backward compatibility with v3.0.1
Changes in v3.2.0:
- Fixed color-mode extraction to properly extract from Y channel
- Added _extract_from_y_channel() for accurate color-mode extraction
- Improved extraction robustness for both grayscale and color modes
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode)
"""
import io
import struct
import hashlib
from dataclasses import dataclass
from typing import Optional, Literal, Tuple
from enum import Enum
import numpy as np
from PIL import Image
# Check for scipy availability (for PNG/DCT mode)
try:
from scipy.fftpack import dct, idct
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
dct = None
idct = None
# Check for jpegio availability (for proper JPEG mode)
try:
import jpegio as jio
HAS_JPEGIO = True
except ImportError:
HAS_JPEGIO = False
jio = None
# ============================================================================
# CONSTANTS
# ============================================================================
# DCT block size (standard 8x8 like JPEG)
BLOCK_SIZE = 8
# Coefficients to use for embedding (mid-frequency, zig-zag order positions)
EMBED_POSITIONS = [
(0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0),
(4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2),
(4, 1), (5, 0), (5, 1), (4, 2), (3, 3), (2, 4), (1, 5), (0, 6), (0, 7),
(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (7, 0),
]
# Use subset of mid-frequency coefficients for better robustness
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 coefficients per block
# Quantization step for QIM embedding (larger = more robust, more visible)
QUANT_STEP = 25
# Magic bytes for DCT stego identification
DCT_MAGIC = b'DCTS'
# Header size: magic(4) + version(1) + flags(1) + length(4) = 10 bytes
HEADER_SIZE = 10
# Output format options
OUTPUT_FORMAT_PNG = 'png'
OUTPUT_FORMAT_JPEG = 'jpeg'
# JPEG output quality (only for fallback mode, not jpegio)
JPEG_OUTPUT_QUALITY = 95
# jpegio constants for JPEG coefficient embedding
JPEGIO_MAGIC = b'JPGS'
JPEGIO_MIN_COEF_MAGNITUDE = 2
JPEGIO_EMBED_CHANNEL = 0 # Y channel
# Flag bits for header
FLAG_COLOR_MODE = 0x01 # Set if embedded in color mode (Y channel of YCbCr)
# ============================================================================
# DATA CLASSES
# ============================================================================
class DCTOutputFormat(Enum):
"""Output format for DCT stego images."""
PNG = 'png'
JPEG = 'jpeg'
@dataclass
class DCTEmbedStats:
"""Statistics from DCT embedding operation."""
blocks_used: int
blocks_available: int
bits_embedded: int
capacity_bits: int
usage_percent: float
image_width: int
image_height: int
output_format: str
jpeg_native: bool = False # True if used jpegio for proper JPEG embedding
color_mode: str = 'grayscale' # 'color' or 'grayscale' (v3.0.1+)
@dataclass
class DCTCapacityInfo:
"""Capacity information for a carrier image."""
width: int
height: int
blocks_x: int
blocks_y: int
total_blocks: int
bits_per_block: int
total_capacity_bits: int
total_capacity_bytes: int
usable_capacity_bytes: int
# ============================================================================
# AVAILABILITY CHECKS
# ============================================================================
def _check_scipy():
"""Raise ImportError if scipy is not available."""
if not HAS_SCIPY:
raise ImportError(
"DCT steganography requires scipy. "
"Install with: pip install scipy"
)
def has_dct_support() -> bool:
"""Check if DCT steganography is available (scipy installed)."""
return HAS_SCIPY
def has_jpegio_support() -> bool:
"""Check if jpegio is available for proper JPEG coefficient embedding."""
return HAS_JPEGIO
# ============================================================================
# SCIPY DCT HELPERS (for PNG output)
# ============================================================================
def _dct2(block: np.ndarray) -> np.ndarray:
"""Apply 2D DCT to a block."""
return dct(dct(block.T, norm='ortho').T, norm='ortho')
def _idct2(block: np.ndarray) -> np.ndarray:
"""Apply 2D inverse DCT to a block."""
return idct(idct(block.T, norm='ortho').T, norm='ortho')
def _to_grayscale(image_data: bytes) -> np.ndarray:
"""Convert image bytes to grayscale numpy array."""
img = Image.open(io.BytesIO(image_data))
gray = img.convert('L')
return np.array(gray, dtype=np.float64)
def _extract_y_channel(image_data: bytes) -> np.ndarray:
"""
Extract Y (luminance) channel from image for color-mode extraction.
This uses the same YCbCr conversion as embedding to ensure
accurate extraction from color-mode stego images.
Args:
image_data: Image file bytes
Returns:
Y channel as float64 numpy array
"""
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
rgb_array = np.array(img, dtype=np.float64)
# Extract Y channel using ITU-R BT.601 (same as embedding)
R = rgb_array[:, :, 0]
G = rgb_array[:, :, 1]
B = rgb_array[:, :, 2]
Y = 0.299 * R + 0.587 * G + 0.114 * B
return Y
def _pad_to_blocks(image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]:
"""Pad image dimensions to be divisible by block size."""
h, w = image.shape
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
if new_h == h and new_w == w:
return image, (h, w)
padded = np.zeros((new_h, new_w), dtype=image.dtype)
padded[:h, :w] = image
if new_h > h:
padded[h:, :w] = image[h-(new_h-h):h, :w][::-1, :]
if new_w > w:
padded[:h, w:] = image[:h, w-(new_w-w):w][:, ::-1]
if new_h > h and new_w > w:
padded[h:, w:] = image[h-(new_h-h):h, w-(new_w-w):w][::-1, ::-1]
return padded, (h, w)
def _unpad_image(image: np.ndarray, original_size: Tuple[int, int]) -> np.ndarray:
"""Remove padding from image."""
h, w = original_size
return image[:h, :w]
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
"""Embed a single bit into a DCT coefficient using QIM."""
quantized = round(coef / quant_step)
if (quantized % 2) != bit:
if quantized % 2 == 0 and bit == 1:
quantized += 1 if coef >= quantized * quant_step else -1
elif quantized % 2 == 1 and bit == 0:
quantized += 1 if coef >= quantized * quant_step else -1
return quantized * quant_step
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
"""Extract a single bit from a DCT coefficient."""
quantized = round(coef / quant_step)
return quantized % 2
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
"""Generate pseudo-random block order from seed."""
hash_bytes = hashlib.sha256(seed).digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
order = list(range(num_blocks))
rng.shuffle(order)
return order
def _save_stego_image(image: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
"""Save stego image in specified format (grayscale)."""
clipped = np.clip(image, 0, 255).astype(np.uint8)
img = Image.fromarray(clipped, mode='L')
buffer = io.BytesIO()
if output_format == OUTPUT_FORMAT_JPEG:
img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY,
subsampling=0, optimize=True)
else:
img.save(buffer, format='PNG', optimize=True)
return buffer.getvalue()
def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
"""Save color RGB image in specified format."""
clipped = np.clip(rgb_array, 0, 255).astype(np.uint8)
img = Image.fromarray(clipped, mode='RGB')
buffer = io.BytesIO()
if output_format == OUTPUT_FORMAT_JPEG:
img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY,
subsampling=0, optimize=True)
else:
img.save(buffer, format='PNG', optimize=True)
return buffer.getvalue()
def _rgb_to_ycbcr(rgb: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Convert RGB array to YCbCr components.
Uses ITU-R BT.601 conversion (standard for JPEG).
Args:
rgb: RGB image array (H, W, 3), float64
Returns:
Tuple of (Y, Cb, Cr) arrays
"""
R = rgb[:, :, 0]
G = rgb[:, :, 1]
B = rgb[:, :, 2]
# ITU-R BT.601 conversion
Y = 0.299 * R + 0.587 * G + 0.114 * B
Cb = 128 - 0.168736 * R - 0.331264 * G + 0.5 * B
Cr = 128 + 0.5 * R - 0.418688 * G - 0.081312 * B
return Y, Cb, Cr
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
"""
Convert YCbCr components back to RGB array.
Args:
Y: Luminance channel
Cb: Blue-difference chroma
Cr: Red-difference chroma
Returns:
RGB array (H, W, 3)
"""
R = Y + 1.402 * (Cr - 128)
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
B = Y + 1.772 * (Cb - 128)
rgb = np.stack([R, G, B], axis=-1)
return rgb
def _create_header(data_length: int, flags: int = 0) -> bytes:
"""Create DCT stego header."""
version = 1
return struct.pack('>4sBBI', DCT_MAGIC, version, flags, data_length)
def _parse_header(header_bits: list) -> Tuple[int, int, int]:
"""Parse header from extracted bits. Returns (version, flags, data_length)."""
if len(header_bits) < HEADER_SIZE * 8:
raise ValueError("Insufficient header data")
header_bytes = bytes([
sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
for i in range(HEADER_SIZE)
])
magic, version, flags, length = struct.unpack('>4sBBI', header_bytes)
if magic != DCT_MAGIC:
raise ValueError("Invalid DCT stego magic bytes")
return version, flags, length
# ============================================================================
# JPEGIO HELPERS (for proper JPEG output)
# ============================================================================
def _jpegio_bytes_to_file(data: bytes, suffix: str = '.jpg') -> str:
"""Write bytes to temp file for jpegio."""
import tempfile
import os
fd, path = tempfile.mkstemp(suffix=suffix)
try:
os.write(fd, data)
finally:
os.close(fd)
return path
def _jpegio_file_to_bytes(path: str) -> bytes:
"""Read file to bytes and delete it."""
import os
try:
with open(path, 'rb') as f:
return f.read()
finally:
try:
os.unlink(path)
except OSError:
pass
def _jpegio_get_usable_positions(coef_array: np.ndarray) -> list:
"""Get usable coefficient positions for jpegio embedding."""
positions = []
h, w = coef_array.shape
for row in range(h):
for col in range(w):
# Skip DC coefficients
if (row % BLOCK_SIZE == 0) and (col % BLOCK_SIZE == 0):
continue
# Check magnitude
if abs(coef_array[row, col]) >= JPEGIO_MIN_COEF_MAGNITUDE:
positions.append((row, col))
return positions
def _jpegio_generate_order(num_positions: int, seed: bytes) -> list:
"""Generate pseudo-random order for jpegio embedding."""
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
order = list(range(num_positions))
rng.shuffle(order)
return order
def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes:
"""Create header for jpegio embedding."""
return struct.pack('>4sBBI', JPEGIO_MAGIC, 1, flags, data_length)
def _jpegio_parse_header(header_bytes: bytes) -> Tuple[int, int, int]:
"""Parse jpegio header."""
if len(header_bytes) < HEADER_SIZE:
raise ValueError("Insufficient header data")
magic, version, flags, length = struct.unpack('>4sBBI', header_bytes[:HEADER_SIZE])
if magic != JPEGIO_MAGIC:
raise ValueError(f"Invalid JPEG stego magic: {magic}")
return version, flags, length
# ============================================================================
# PUBLIC API
# ============================================================================
def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo:
"""
Calculate the DCT embedding capacity of an image.
Args:
image_data: Image file bytes
Returns:
DCTCapacityInfo with capacity details
"""
_check_scipy()
img = Image.open(io.BytesIO(image_data))
width, height = img.size
blocks_x = width // BLOCK_SIZE
blocks_y = height // BLOCK_SIZE
total_blocks = blocks_x * blocks_y
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
total_bits = total_blocks * bits_per_block
total_bytes = total_bits // 8
usable_bytes = max(0, total_bytes - HEADER_SIZE)
return DCTCapacityInfo(
width=width,
height=height,
blocks_x=blocks_x,
blocks_y=blocks_y,
total_blocks=total_blocks,
bits_per_block=bits_per_block,
total_capacity_bits=total_bits,
total_capacity_bytes=total_bytes,
usable_capacity_bytes=usable_bytes
)
def will_fit_dct(data_length: int, image_data: bytes) -> bool:
"""Check if data will fit in the image using DCT embedding."""
capacity = calculate_dct_capacity(image_data)
return data_length <= capacity.usable_capacity_bytes
def estimate_capacity_comparison(image_data: bytes) -> dict:
"""Compare LSB and DCT capacity for an image."""
img = Image.open(io.BytesIO(image_data))
width, height = img.size
pixels = width * height
lsb_bytes = (pixels * 3) // 8
if HAS_SCIPY:
dct_info = calculate_dct_capacity(image_data)
dct_bytes = dct_info.usable_capacity_bytes
else:
blocks = (width // 8) * (height // 8)
dct_bytes = (blocks * 16) // 8 - HEADER_SIZE
return {
'width': width,
'height': height,
'lsb': {
'capacity_bytes': lsb_bytes,
'capacity_kb': lsb_bytes / 1024,
'output': 'PNG/BMP (color)',
},
'dct': {
'capacity_bytes': dct_bytes,
'capacity_kb': dct_bytes / 1024,
'output': 'PNG or JPEG (grayscale)',
'ratio_vs_lsb': (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0,
'available': HAS_SCIPY,
},
'jpeg_native': {
'available': HAS_JPEGIO,
'note': 'Uses jpegio for proper JPEG coefficient embedding',
}
}
def embed_in_dct(
data: bytes,
carrier_image: bytes,
seed: bytes,
output_format: str = OUTPUT_FORMAT_PNG,
color_mode: str = 'color', # v3.0.1: 'color' or 'grayscale'
) -> Tuple[bytes, DCTEmbedStats]:
"""
Embed data into image using DCT coefficient modification.
For PNG output: Uses scipy DCT transform
For JPEG output: Uses jpegio if available for proper coefficient embedding
Args:
data: Data to embed
carrier_image: Carrier image bytes
seed: Seed for pseudo-random selection
output_format: 'png' (default, lossless) or 'jpeg'
color_mode: 'color' (preserve colors) or 'grayscale' (v3.0.1+)
Returns:
Tuple of (stego_image_bytes, stats)
"""
# Validate output format
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
raise ValueError(f"Invalid output format: {output_format}")
# Validate color mode
if color_mode not in ('color', 'grayscale'):
color_mode = 'color' # Default to color
# For JPEG output, try to use jpegio for proper coefficient embedding
# Note: jpegio naturally preserves color (works in YCbCr space)
if output_format == OUTPUT_FORMAT_JPEG:
if HAS_JPEGIO:
return _embed_jpegio(data, carrier_image, seed, color_mode)
else:
# Fall back to scipy + PIL JPEG (WARNING: may not decode properly)
import warnings
warnings.warn(
"jpegio not available. JPEG output may not decode correctly. "
"Install jpegio for proper JPEG steganography support.",
RuntimeWarning
)
# Continue with scipy method but output as JPEG
# PNG output or JPEG fallback: use scipy DCT method
_check_scipy()
return _embed_scipy_dct(data, carrier_image, seed, output_format, color_mode)
def _embed_scipy_dct(
data: bytes,
carrier_image: bytes,
seed: bytes,
output_format: str,
color_mode: str = 'color',
) -> Tuple[bytes, DCTEmbedStats]:
"""Embed using scipy DCT (for PNG output), with color preservation option."""
capacity_info = calculate_dct_capacity(carrier_image)
if len(data) > capacity_info.usable_capacity_bytes:
raise ValueError(
f"Data too large ({len(data)} bytes) for carrier "
f"(capacity: {capacity_info.usable_capacity_bytes} bytes)"
)
# Load image
img = Image.open(io.BytesIO(carrier_image))
width, height = img.size
# Set flags for header
flags = FLAG_COLOR_MODE if color_mode == 'color' else 0
if color_mode == 'color' and img.mode in ('RGB', 'RGBA'):
# Color mode: convert to YCbCr, embed in Y only, preserve Cb/Cr
if img.mode == 'RGBA':
img = img.convert('RGB')
rgb_array = np.array(img, dtype=np.float64)
Y, Cb, Cr = _rgb_to_ycbcr(rgb_array)
# Pad Y channel
Y_padded, original_size = _pad_to_blocks(Y)
# Embed in Y channel (with color flag)
Y_embedded = _embed_in_channel(Y_padded, data, seed, capacity_info, flags)
# Unpad
Y_result = _unpad_image(Y_embedded, original_size)
# Convert back to RGB
result_rgb = _ycbcr_to_rgb(Y_result, Cb, Cr)
# Save as color image
stego_bytes = _save_color_image(result_rgb, output_format)
else:
# Grayscale mode: original behavior
image = _to_grayscale(carrier_image)
padded, original_size = _pad_to_blocks(image)
embedded = _embed_in_channel(padded, data, seed, capacity_info, flags)
result = _unpad_image(embedded, original_size)
stego_bytes = _save_stego_image(result, output_format)
# Calculate stats
header = _create_header(len(data), flags)
payload = header + data
bits = len(payload) * 8
stats = DCTEmbedStats(
blocks_used=(bits + len(DEFAULT_EMBED_POSITIONS) - 1) // len(DEFAULT_EMBED_POSITIONS),
blocks_available=capacity_info.total_blocks,
bits_embedded=bits,
capacity_bits=capacity_info.total_capacity_bits,
usage_percent=(bits / capacity_info.total_capacity_bits) * 100,
image_width=width,
image_height=height,
output_format=output_format,
jpeg_native=False,
color_mode=color_mode,
)
return stego_bytes, stats
def _embed_in_channel(
channel: np.ndarray,
data: bytes,
seed: bytes,
capacity_info: DCTCapacityInfo,
flags: int = 0,
) -> np.ndarray:
"""Embed data in a single channel using DCT."""
header = _create_header(len(data), flags)
payload = header + data
bits = []
for byte in payload:
for i in range(7, -1, -1):
bits.append((byte >> i) & 1)
num_blocks = capacity_info.total_blocks
block_order = _generate_block_order(num_blocks, seed)
h, w = channel.shape
result = channel.copy()
bit_idx = 0
for block_num in block_order:
if bit_idx >= len(bits):
break
by = (block_num // (w // BLOCK_SIZE)) * BLOCK_SIZE
bx = (block_num % (w // BLOCK_SIZE)) * BLOCK_SIZE
block = result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE].copy()
dct_block = _dct2(block)
for pos in DEFAULT_EMBED_POSITIONS:
if bit_idx >= len(bits):
break
dct_block[pos] = _embed_bit_in_coeff(dct_block[pos], bits[bit_idx])
bit_idx += 1
modified_block = _idct2(dct_block)
result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block
return result
def _embed_jpegio(
data: bytes,
carrier_image: bytes,
seed: bytes,
color_mode: str = 'color',
) -> Tuple[bytes, DCTEmbedStats]:
"""
Embed using jpegio for proper JPEG coefficient modification.
Note: jpegio naturally preserves color since JPEG stores YCbCr
and we only modify Y channel coefficients.
"""
import tempfile
import os
# Check if carrier is JPEG - if not, convert it
img = Image.open(io.BytesIO(carrier_image))
width, height = img.size
if img.format != 'JPEG':
# Convert to JPEG first
buffer = io.BytesIO()
if img.mode != 'RGB':
img = img.convert('RGB')
img.save(buffer, format='JPEG', quality=95, subsampling=0)
carrier_image = buffer.getvalue()
# Write carrier to temp file
input_path = _jpegio_bytes_to_file(carrier_image, suffix='.jpg')
output_path = tempfile.mktemp(suffix='.jpg')
# Set flags
flags = FLAG_COLOR_MODE if color_mode == 'color' else 0
try:
# Read JPEG with jpegio
jpeg = jio.read(input_path)
# Get Y channel coefficients (channel 0)
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
# Find usable positions
all_positions = _jpegio_get_usable_positions(coef_array)
# Generate pseudo-random order
order = _jpegio_generate_order(len(all_positions), seed)
# Create payload with flags
header = _jpegio_create_header(len(data), flags)
payload = header + data
# Convert to bits
bits = []
for byte in payload:
for i in range(7, -1, -1):
bits.append((byte >> i) & 1)
if len(bits) > len(all_positions):
raise ValueError(
f"Payload too large: {len(bits)} bits, "
f"only {len(all_positions)} usable coefficients"
)
# Embed using LSB
coefs_used = 0
for bit_idx, pos_idx in enumerate(order):
if bit_idx >= len(bits):
break
row, col = all_positions[pos_idx]
coef = coef_array[row, col]
# Embed bit in LSB
if (coef & 1) != bits[bit_idx]:
if coef > 0:
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
else:
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
coefs_used += 1
# Write modified JPEG
jio.write(jpeg, output_path)
# Read back as bytes
with open(output_path, 'rb') as f:
stego_bytes = f.read()
stats = DCTEmbedStats(
blocks_used=coefs_used // 63, # Approximate blocks
blocks_available=len(all_positions) // 63,
bits_embedded=len(bits),
capacity_bits=len(all_positions),
usage_percent=(len(bits) / len(all_positions)) * 100 if all_positions else 0,
image_width=width,
image_height=height,
output_format=OUTPUT_FORMAT_JPEG,
jpeg_native=True,
color_mode=color_mode, # JPEG naturally preserves color
)
return stego_bytes, stats
finally:
for path in [input_path, output_path]:
try:
os.unlink(path)
except OSError:
pass
def extract_from_dct(
stego_image: bytes,
seed: bytes,
) -> bytes:
"""
Extract data from DCT stego image.
Automatically detects whether image uses scipy DCT or jpegio embedding,
and handles both grayscale and color modes.
Args:
stego_image: Stego image bytes
seed: Same seed used for embedding
Returns:
Extracted data bytes
"""
# Check image format
img = Image.open(io.BytesIO(stego_image))
if img.format == 'JPEG' and HAS_JPEGIO:
# Try jpegio extraction first
try:
return _extract_jpegio(stego_image, seed)
except ValueError:
# If jpegio magic not found, fall back to scipy method
pass
# PNG or fallback: use scipy DCT method
_check_scipy()
return _extract_scipy_dct(stego_image, seed)
def _extract_scipy_dct(stego_image: bytes, seed: bytes) -> bytes:
"""
Extract using scipy DCT (for PNG images).
v3.2.0: Now properly handles both grayscale and color modes by
first trying to detect the mode from header flags, then extracting
from the appropriate channel.
"""
# First, try extracting from grayscale to get header and detect mode
# This works because even color-mode images can be converted to grayscale
# and the Y channel ≈ grayscale for extraction purposes
# Try Y channel extraction first (works for both color and grayscale)
img = Image.open(io.BytesIO(stego_image))
if img.mode in ('RGB', 'RGBA'):
# Extract from Y channel (more accurate for color-mode images)
channel = _extract_y_channel(stego_image)
else:
# Grayscale image
channel = _to_grayscale(stego_image)
padded, original_size = _pad_to_blocks(channel)
h, w = padded.shape
blocks_x = w // BLOCK_SIZE
blocks_y = h // BLOCK_SIZE
num_blocks = blocks_x * blocks_y
block_order = _generate_block_order(num_blocks, seed)
all_bits = []
for block_num in block_order:
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
block = padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE]
dct_block = _dct2(block)
for pos in DEFAULT_EMBED_POSITIONS:
bit = _extract_bit_from_coeff(dct_block[pos])
all_bits.append(bit)
if len(all_bits) >= HEADER_SIZE * 8:
try:
_, flags, data_length = _parse_header(all_bits[:HEADER_SIZE * 8])
total_needed = (HEADER_SIZE + data_length) * 8
if len(all_bits) >= total_needed:
break
except ValueError:
pass
version, flags, data_length = _parse_header(all_bits)
# Check if color mode flag is set (for informational purposes)
is_color_mode = bool(flags & FLAG_COLOR_MODE)
data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8]
data = bytes([
sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
for i in range(data_length)
])
return data
def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
"""Extract using jpegio for JPEG images."""
import os
temp_path = _jpegio_bytes_to_file(stego_image, suffix='.jpg')
try:
jpeg = jio.read(temp_path)
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
all_positions = _jpegio_get_usable_positions(coef_array)
order = _jpegio_generate_order(len(all_positions), seed)
# Extract header bits
header_bits = []
for pos_idx in order[:HEADER_SIZE * 8]:
row, col = all_positions[pos_idx]
coef = coef_array[row, col]
header_bits.append(coef & 1)
header_bytes = bytes([
sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
for i in range(HEADER_SIZE)
])
version, flags, data_length = _jpegio_parse_header(header_bytes)
# Extract all needed bits
total_bits_needed = (HEADER_SIZE + data_length) * 8
all_bits = []
for bit_idx, pos_idx in enumerate(order):
if bit_idx >= total_bits_needed:
break
row, col = all_positions[pos_idx]
coef = coef_array[row, col]
all_bits.append(coef & 1)
# Extract data
data_bits = all_bits[HEADER_SIZE * 8:]
data = bytes([
sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
for i in range(data_length)
])
return data
finally:
try:
os.unlink(temp_path)
except OSError:
pass
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def get_output_extension(output_format: str) -> str:
"""Get file extension for output format."""
if output_format == OUTPUT_FORMAT_JPEG:
return '.jpg'
return '.png'
def get_output_mimetype(output_format: str) -> str:
"""Get MIME type for output format."""
if output_format == OUTPUT_FORMAT_JPEG:
return 'image/jpeg'
return 'image/png'

View File

@@ -5,12 +5,13 @@ Debugging, logging, and performance monitoring tools.
Can be disabled for production use.
"""
import sys
import time
import traceback
from collections.abc import Callable
from datetime import datetime
from functools import wraps
from typing import Callable, Any, Optional, Dict
import sys
from typing import Any
# Global debug configuration
DEBUG_ENABLED = False # Set to True to enable debug output
@@ -67,6 +68,7 @@ def debug_exception(e: Exception, context: str = "") -> None:
def time_function(func: Callable) -> Callable:
"""Decorator to time function execution for performance debugging."""
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
if not (DEBUG_ENABLED and LOG_PERFORMANCE):
@@ -89,21 +91,23 @@ def validate_assertion(condition: bool, message: str) -> None:
raise AssertionError(f"Validation failed: {message}")
def memory_usage() -> Dict[str, float]:
def memory_usage() -> dict[str, float | str]:
"""Get current memory usage (if psutil is available)."""
try:
import psutil
import os
import psutil
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
return {
'rss_mb': mem_info.rss / 1024 / 1024, # Resident Set Size
'vms_mb': mem_info.vms / 1024 / 1024, # Virtual Memory Size
'percent': process.memory_percent(),
"rss_mb": mem_info.rss / 1024 / 1024,
"vms_mb": mem_info.vms / 1024 / 1024,
"percent": process.memory_percent(),
}
except ImportError:
return {'error': 'psutil not installed'}
return {"error": "psutil not installed"}
def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
@@ -115,19 +119,18 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
data_to_dump = data[:length]
for i in range(0, len(data_to_dump), 16):
chunk = data_to_dump[i:i+16]
hex_str = ' '.join(f'{b:02x}' for b in chunk)
hex_str = hex_str.ljust(47) # Pad to consistent width
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
chunk = data_to_dump[i : i + 16]
hex_str = " ".join(f"{b:02x}" for b in chunk)
hex_str = hex_str.ljust(47)
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
result.append(f"{offset + i:08x}: {hex_str} {ascii_str}")
if len(data) > length:
result.append(f"... ({len(data) - length} more bytes)")
return '\n'.join(result)
return "\n".join(result)
# Create singleton instance for easy import
class Debug:
"""Debugging utility class."""
@@ -154,7 +157,7 @@ class Debug:
"""Runtime validation assertion."""
validate_assertion(condition, message)
def memory(self) -> Dict[str, float]:
def memory(self) -> dict[str, float | str]:
"""Get current memory usage."""
return memory_usage()

231
src/stegasoo/decode.py Normal file
View File

@@ -0,0 +1,231 @@
"""
Stegasoo Decode Module (v4.0.0)
High-level decoding functions for extracting messages and files from images.
Changes in v4.0.0:
- Added channel_key parameter for deployment/group isolation
- Improved error messages for channel key mismatches
"""
from pathlib import Path
from .constants import EMBED_MODE_AUTO
from .crypto import decrypt_message
from .debug import debug
from .exceptions import DecryptionError, ExtractionError
from .models import DecodeResult
from .steganography import extract_from_image
from .validation import (
require_security_factors,
require_valid_image,
require_valid_pin,
require_valid_rsa_key,
)
def decode(
stego_image: bytes,
reference_photo: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = EMBED_MODE_AUTO,
channel_key: str | bool | None = None,
) -> DecodeResult:
"""
Decode a message or file from a stego image.
Args:
stego_image: Stego image bytes
reference_photo: Shared reference photo bytes
passphrase: Shared passphrase used during encoding
pin: Optional static PIN (if used during encoding)
rsa_key_data: Optional RSA key bytes (if used during encoding)
rsa_password: Optional RSA key password
embed_mode: 'auto' (default), 'lsb', or 'dct'
channel_key: Channel key for deployment/group isolation:
- None or "auto": Use server's configured key
- str: Use this specific channel key
- "" or False: No channel key (public mode)
Returns:
DecodeResult with message or file data
Example:
>>> result = decode(
... stego_image=stego_bytes,
... reference_photo=ref_bytes,
... passphrase="apple forest thunder mountain",
... pin="123456"
... )
>>> if result.is_text:
... print(result.message)
... else:
... with open(result.filename, 'wb') as f:
... f.write(result.file_data)
Example with explicit channel key:
>>> result = decode(
... stego_image=stego_bytes,
... reference_photo=ref_bytes,
... passphrase="apple forest thunder mountain",
... pin="123456",
... channel_key="ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
... )
"""
debug.print(
f"decode: passphrase length={len(passphrase.split())} words, "
f"mode={embed_mode}, "
f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}"
)
# Validate inputs
require_valid_image(stego_image, "Stego image")
require_valid_image(reference_photo, "Reference photo")
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)
# Derive pixel/coefficient selection key (with channel key)
from .crypto import derive_pixel_key
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
# Extract encrypted data
encrypted = extract_from_image(
stego_image,
pixel_key,
embed_mode=embed_mode,
)
if not encrypted:
debug.print("No data extracted from image")
raise ExtractionError("Could not extract data. Check your credentials and image.")
debug.print(f"Extracted {len(encrypted)} bytes from image")
# Decrypt (with channel key)
result = decrypt_message(encrypted, reference_photo, passphrase, pin, rsa_key_data, channel_key)
debug.print(f"Decryption successful: {result.payload_type}")
return result
def decode_file(
stego_image: bytes,
reference_photo: bytes,
passphrase: str,
output_path: Path | None = None,
pin: str = "",
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = EMBED_MODE_AUTO,
channel_key: str | bool | None = None,
) -> Path:
"""
Decode a file from a stego image and save it.
Args:
stego_image: Stego image bytes
reference_photo: Shared reference photo bytes
passphrase: Shared passphrase
output_path: Optional output path (defaults to original filename)
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
embed_mode: 'auto', 'lsb', or 'dct'
channel_key: Channel key parameter (see decode())
Returns:
Path where file was saved
Raises:
DecryptionError: If payload is text, not a file
"""
result = decode(
stego_image,
reference_photo,
passphrase,
pin,
rsa_key_data,
rsa_password,
embed_mode,
channel_key,
)
if not result.is_file:
raise DecryptionError("Payload is a text message, not a file")
if output_path is None:
output_path = Path(result.filename or "extracted_file")
else:
output_path = Path(output_path)
if output_path.is_dir():
output_path = output_path / (result.filename or "extracted_file")
# Write file
output_path.write_bytes(result.file_data or b"")
debug.print(f"File saved to: {output_path}")
return output_path
def decode_text(
stego_image: bytes,
reference_photo: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
embed_mode: str = EMBED_MODE_AUTO,
channel_key: str | bool | None = None,
) -> str:
"""
Decode a text message from a stego image.
Convenience function that returns just the message string.
Args:
stego_image: Stego image bytes
reference_photo: Shared reference photo bytes
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
embed_mode: 'auto', 'lsb', or 'dct'
channel_key: Channel key parameter (see decode())
Returns:
Decoded message string
Raises:
DecryptionError: If payload is a file, not text
"""
result = decode(
stego_image,
reference_photo,
passphrase,
pin,
rsa_key_data,
rsa_password,
embed_mode,
channel_key,
)
if result.is_file:
# Try to decode as text
if result.file_data:
try:
return result.file_data.decode("utf-8")
except UnicodeDecodeError:
raise DecryptionError(
f"Payload is a binary file ({result.filename or 'unnamed'}), not text"
)
return ""
return result.message or ""

258
src/stegasoo/encode.py Normal file
View File

@@ -0,0 +1,258 @@
"""
Stegasoo Encode Module (v4.0.0)
High-level encoding functions for hiding messages and files in images.
Changes in v4.0.0:
- Added channel_key parameter for deployment/group isolation
"""
from pathlib import Path
from .constants import EMBED_MODE_LSB
from .crypto import derive_pixel_key, encrypt_message
from .debug import debug
from .models import EncodeResult, FilePayload
from .steganography import embed_in_image
from .utils import generate_filename
from .validation import (
require_security_factors,
require_valid_image,
require_valid_payload,
require_valid_pin,
require_valid_rsa_key,
)
def encode(
message: str | bytes | FilePayload,
reference_photo: bytes,
carrier_image: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
output_format: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
channel_key: str | bool | None = None,
) -> EncodeResult:
"""
Encode a message or file into an image.
Args:
message: Text message, raw bytes, or FilePayload to hide
reference_photo: Shared reference photo bytes
carrier_image: Carrier image bytes
passphrase: Shared passphrase (recommend 4+ words)
pin: Optional static PIN
rsa_key_data: Optional RSA private key PEM bytes
rsa_password: Optional password for encrypted RSA key
output_format: Force output format ('PNG', 'BMP') - LSB mode only
embed_mode: 'lsb' (default) or 'dct'
dct_output_format: For DCT mode - 'png' or 'jpeg'
dct_color_mode: For DCT mode - 'grayscale' or 'color'
channel_key: Channel key for deployment/group isolation:
- None or "auto": Use server's configured key
- str: Use this specific channel key
- "" or False: No channel key (public mode)
Returns:
EncodeResult with stego image and metadata
Example:
>>> result = encode(
... message="Secret message",
... reference_photo=ref_bytes,
... carrier_image=carrier_bytes,
... passphrase="apple forest thunder mountain",
... pin="123456"
... )
>>> with open('stego.png', 'wb') as f:
... f.write(result.stego_image)
Example with explicit channel key:
>>> result = encode(
... message="Secret message",
... reference_photo=ref_bytes,
... carrier_image=carrier_bytes,
... passphrase="apple forest thunder mountain",
... pin="123456",
... channel_key="ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
... )
"""
debug.print(
f"encode: passphrase length={len(passphrase.split())} words, "
f"pin={'set' if pin else 'none'}, mode={embed_mode}, "
f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}"
)
# Validate inputs
require_valid_payload(message)
require_valid_image(reference_photo, "Reference photo")
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)
# Encrypt message (with channel key)
encrypted = encrypt_message(
message, reference_photo, passphrase, pin, rsa_key_data, channel_key
)
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
# Derive pixel/coefficient selection key (with channel key)
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
# Embed in image
stego_data, stats, extension = embed_in_image(
encrypted,
carrier_image,
pixel_key,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
)
# Generate filename
filename = generate_filename(extension=extension)
# Create result
if hasattr(stats, "pixels_modified"):
# LSB mode stats
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=None, # No longer used in v3.2.0+
)
else:
# DCT mode stats
return EncodeResult(
stego_image=stego_data,
filename=filename,
pixels_modified=stats.blocks_used * 64,
total_pixels=stats.blocks_available * 64,
capacity_used=stats.usage_percent / 100.0,
date_used=None,
)
def encode_file(
filepath: str | Path,
reference_photo: bytes,
carrier_image: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
output_format: str | None = None,
filename_override: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
channel_key: str | bool | None = None,
) -> EncodeResult:
"""
Encode a file into an image.
Convenience wrapper that loads a file and encodes it.
Args:
filepath: Path to file to embed
reference_photo: Shared reference photo bytes
carrier_image: Carrier image bytes
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
output_format: Force output format - LSB only
filename_override: Override stored filename
embed_mode: 'lsb' or 'dct'
dct_output_format: 'png' or 'jpeg'
dct_color_mode: 'grayscale' or 'color'
channel_key: Channel key parameter (see encode())
Returns:
EncodeResult
"""
payload = FilePayload.from_file(str(filepath), filename_override)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
channel_key=channel_key,
)
def encode_bytes(
data: bytes,
filename: str,
reference_photo: bytes,
carrier_image: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: bytes | None = None,
rsa_password: str | None = None,
output_format: str | None = None,
mime_type: str | None = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
channel_key: str | bool | None = None,
) -> EncodeResult:
"""
Encode raw bytes with metadata into an image.
Args:
data: Raw bytes to embed
filename: Filename to associate with data
reference_photo: Shared reference photo bytes
carrier_image: Carrier image bytes
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
output_format: Force output format - LSB only
mime_type: MIME type of data
embed_mode: 'lsb' or 'dct'
dct_output_format: 'png' or 'jpeg'
dct_color_mode: 'grayscale' or 'color'
channel_key: Channel key parameter (see encode())
Returns:
EncodeResult
"""
payload = FilePayload(data=data, filename=filename, mime_type=mime_type)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
channel_key=channel_key,
)

View File

@@ -7,6 +7,7 @@ Custom exception classes for clear error handling across all frontends.
class StegasooError(Exception):
"""Base exception for all Stegasoo errors."""
pass
@@ -14,33 +15,40 @@ class StegasooError(Exception):
# 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
@@ -48,33 +56,40 @@ class SecurityFactorError(ValidationError):
# 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
@@ -82,8 +97,10 @@ class KeyPasswordError(CryptoError):
# STEGANOGRAPHY ERRORS
# ============================================================================
class SteganographyError(StegasooError):
"""Base class for steganography errors."""
pass
@@ -100,16 +117,19 @@ class CapacityError(SteganographyError):
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
@@ -117,13 +137,16 @@ class InvalidHeaderError(SteganographyError):
# FILE ERRORS
# ============================================================================
class FileError(StegasooError):
"""Base class for file-related errors."""
pass
class FileNotFoundError(FileError):
"""Required file not found."""
pass

167
src/stegasoo/generate.py Normal file
View File

@@ -0,0 +1,167 @@
"""
Stegasoo Generate Module (v3.2.0)
Public API for generating credentials (PINs, passphrases, RSA keys).
"""
from .constants import (
DEFAULT_PASSPHRASE_WORDS,
DEFAULT_PIN_LENGTH,
DEFAULT_RSA_BITS,
)
from .debug import debug
from .keygen import (
export_rsa_key_pem,
generate_phrase,
load_rsa_key,
)
from .keygen import (
generate_pin as _generate_pin,
)
from .keygen import (
generate_rsa_key as _generate_rsa_key,
)
from .models import Credentials
# Re-export from keygen for convenience
__all__ = [
"generate_pin",
"generate_passphrase",
"generate_rsa_key",
"generate_credentials",
"export_rsa_key_pem",
"load_rsa_key",
]
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, default 6)
Returns:
PIN string
Example:
>>> pin = generate_pin()
>>> len(pin)
6
>>> pin[0] != '0'
True
"""
return _generate_pin(length)
def generate_passphrase(words: int = DEFAULT_PASSPHRASE_WORDS) -> str:
"""
Generate a random passphrase from BIP-39 wordlist.
In v3.2.0, this generates a single passphrase (not daily phrases).
Default is 4 words for good security (increased from 3 in v3.1.0).
Args:
words: Number of words (3-12, default 4)
Returns:
Space-separated passphrase
Example:
>>> passphrase = generate_passphrase(4)
>>> len(passphrase.split())
4
"""
return generate_phrase(words)
def generate_rsa_key(bits: int = DEFAULT_RSA_BITS, password: str | None = None) -> str:
"""
Generate an RSA private key in PEM format.
Args:
bits: Key size (2048, 3072, or 4096, default 2048)
password: Optional password to encrypt the key
Returns:
PEM-encoded key string
Example:
>>> key_pem = generate_rsa_key(2048)
>>> '-----BEGIN PRIVATE KEY-----' in key_pem
True
"""
key_obj = _generate_rsa_key(bits)
pem_bytes = export_rsa_key_pem(key_obj, password)
return pem_bytes.decode("utf-8")
def generate_credentials(
use_pin: bool = True,
use_rsa: bool = False,
pin_length: int = DEFAULT_PIN_LENGTH,
rsa_bits: int = DEFAULT_RSA_BITS,
passphrase_words: int = DEFAULT_PASSPHRASE_WORDS,
rsa_password: str | None = None,
) -> Credentials:
"""
Generate a complete set of credentials.
In v3.2.0, this generates a single passphrase (not daily phrases).
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 (default 6)
rsa_bits: RSA key size (default 2048)
passphrase_words: Number of words in passphrase (default 4)
rsa_password: Optional password for RSA key
Returns:
Credentials object with passphrase, PIN, and/or RSA key
Raises:
ValueError: If neither PIN nor RSA is selected
Example:
>>> creds = generate_credentials(use_pin=True, use_rsa=False)
>>> len(creds.passphrase.split())
4
>>> len(creds.pin)
6
"""
if not use_pin and not use_rsa:
raise ValueError("Must select at least one security factor (PIN or RSA key)")
debug.print(
f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, "
f"passphrase_words={passphrase_words}"
)
# Generate passphrase (single, not daily)
passphrase = generate_phrase(passphrase_words)
# Generate PIN if requested
pin = _generate_pin(pin_length) if use_pin else None
# Generate RSA key if requested
rsa_key_pem = None
if use_rsa:
rsa_key_obj = _generate_rsa_key(rsa_bits)
rsa_key_bytes = export_rsa_key_pem(rsa_key_obj, rsa_password)
rsa_key_pem = rsa_key_bytes.decode("utf-8")
# Create Credentials object (v3.2.0 format)
creds = Credentials(
passphrase=passphrase,
pin=pin,
rsa_key_pem=rsa_key_pem,
rsa_bits=rsa_bits if use_rsa else None,
words_per_passphrase=passphrase_words,
)
debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy")
return creds

170
src/stegasoo/image_utils.py Normal file
View File

@@ -0,0 +1,170 @@
"""
Stegasoo Image Utilities (v3.2.0)
Functions for analyzing images and comparing capacity.
"""
import io
from PIL import Image
from .constants import EMBED_MODE_LSB
from .debug import debug
from .models import CapacityComparison, ImageInfo
from .steganography import calculate_capacity, has_dct_support
def get_image_info(image_data: bytes) -> ImageInfo:
"""
Get detailed information about an image.
Args:
image_data: Image file bytes
Returns:
ImageInfo with dimensions, format, capacity estimates
Example:
>>> info = get_image_info(carrier_bytes)
>>> print(f"{info.width}x{info.height}, {info.lsb_capacity_kb} KB capacity")
"""
img = Image.open(io.BytesIO(image_data))
width, height = img.size
pixels = width * height
format_str = img.format or "Unknown"
mode = img.mode
# Calculate LSB capacity
lsb_capacity = calculate_capacity(image_data, bits_per_channel=1)
# Calculate DCT capacity if available
dct_capacity = None
if has_dct_support():
try:
from .dct_steganography import calculate_dct_capacity
dct_info = calculate_dct_capacity(image_data)
dct_capacity = dct_info.usable_capacity_bytes
except Exception as e:
debug.print(f"Could not calculate DCT capacity: {e}")
info = ImageInfo(
width=width,
height=height,
pixels=pixels,
format=format_str,
mode=mode,
file_size=len(image_data),
lsb_capacity_bytes=lsb_capacity,
lsb_capacity_kb=lsb_capacity / 1024,
dct_capacity_bytes=dct_capacity,
dct_capacity_kb=dct_capacity / 1024 if dct_capacity else None,
)
debug.print(
f"Image info: {width}x{height}, LSB={lsb_capacity} bytes, "
f"DCT={dct_capacity or 'N/A'} bytes"
)
return info
def compare_capacity(
carrier_image: bytes,
reference_photo: bytes | None = None,
) -> CapacityComparison:
"""
Compare embedding capacity between LSB and DCT modes.
Args:
carrier_image: Carrier image bytes
reference_photo: Optional reference photo (not used in v3.2.0, kept for API compatibility)
Returns:
CapacityComparison with capacity info for both modes
Example:
>>> comparison = compare_capacity(carrier_bytes)
>>> print(f"LSB: {comparison.lsb_kb:.1f} KB")
>>> print(f"DCT: {comparison.dct_kb:.1f} KB")
"""
img = Image.open(io.BytesIO(carrier_image))
width, height = img.size
# LSB capacity
lsb_bytes = calculate_capacity(carrier_image, bits_per_channel=1)
lsb_kb = lsb_bytes / 1024
# DCT capacity
dct_available = has_dct_support()
dct_bytes = None
dct_kb = None
if dct_available:
try:
from .dct_steganography import calculate_dct_capacity
dct_info = calculate_dct_capacity(carrier_image)
dct_bytes = dct_info.usable_capacity_bytes
dct_kb = dct_bytes / 1024
except Exception as e:
debug.print(f"DCT capacity calculation failed: {e}")
dct_available = False
comparison = CapacityComparison(
image_width=width,
image_height=height,
lsb_available=True,
lsb_bytes=lsb_bytes,
lsb_kb=lsb_kb,
lsb_output_format="PNG/BMP (color)",
dct_available=dct_available,
dct_bytes=dct_bytes,
dct_kb=dct_kb,
dct_output_formats=["PNG (grayscale)", "JPEG (grayscale)"] if dct_available else None,
dct_ratio_vs_lsb=(dct_bytes / lsb_bytes * 100) if dct_bytes else None,
)
debug.print(f"Capacity comparison: LSB={lsb_kb:.1f}KB, DCT={dct_kb or 'N/A'}KB")
return comparison
def validate_carrier_capacity(
carrier_image: bytes,
payload_size: int,
embed_mode: str = EMBED_MODE_LSB,
) -> dict:
"""
Check if a payload will fit in a carrier image.
Args:
carrier_image: Carrier image bytes
payload_size: Size of payload in bytes
embed_mode: 'lsb' or 'dct'
Returns:
Dict with 'fits', 'capacity', 'usage_percent', 'headroom'
"""
from .steganography import calculate_capacity_by_mode
capacity_info = calculate_capacity_by_mode(carrier_image, embed_mode)
capacity = capacity_info["capacity_bytes"]
# Add encryption overhead estimate
estimated_size = payload_size + 200 # Approximate overhead
fits = estimated_size <= capacity
usage_percent = (estimated_size / capacity * 100) if capacity > 0 else 100.0
headroom = capacity - estimated_size
return {
"fits": fits,
"capacity": capacity,
"payload_size": payload_size,
"estimated_size": estimated_size,
"usage_percent": min(usage_percent, 100.0),
"headroom": headroom,
"mode": embed_mode,
}

View File

@@ -1,27 +1,37 @@
"""
Stegasoo Key Generation
Stegasoo Key Generation (v3.2.0)
Generate PINs, passphrases, and RSA keys.
Changes in v3.2.0:
- generate_credentials() now returns Credentials with single passphrase
- Removed generate_day_phrases() from main API (kept for legacy compatibility)
- Updated to use PASSPHRASE constants
"""
import secrets
from typing import Optional, Dict
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 cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from cryptography.hazmat.primitives.serialization import load_pem_private_key
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,
DEFAULT_PASSPHRASE_WORDS,
DEFAULT_PIN_LENGTH,
DEFAULT_RSA_BITS,
MAX_PASSPHRASE_WORDS,
MAX_PIN_LENGTH,
MIN_PASSPHRASE_WORDS,
MIN_PIN_LENGTH,
VALID_RSA_SIZES,
get_wordlist,
)
from .models import Credentials, KeyInfo
from .exceptions import KeyGenerationError, KeyPasswordError
from .debug import debug
from .exceptions import KeyGenerationError, KeyPasswordError
from .models import Credentials, KeyInfo
def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
@@ -40,8 +50,10 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
>>> generate_pin(6)
"812345"
"""
debug.validate(length >= MIN_PIN_LENGTH and length <= MAX_PIN_LENGTH,
f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}")
debug.validate(
MIN_PIN_LENGTH <= length <= MAX_PIN_LENGTH,
f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}",
)
length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length))
@@ -49,14 +61,14 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
first_digit = str(secrets.randbelow(9) + 1)
# Remaining digits: 0-9
rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1))
rest = "".join(str(secrets.randbelow(10)) for _ in range(length - 1))
pin = first_digit + rest
debug.print(f"Generated PIN: {pin}")
return pin
def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str:
def generate_phrase(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> str:
"""
Generate a random passphrase from BIP-39 wordlist.
@@ -67,25 +79,34 @@ def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str:
Space-separated phrase
Example:
>>> generate_phrase(3)
"apple forest thunder"
>>> generate_phrase(4)
"apple forest thunder mountain"
"""
debug.validate(words_per_phrase >= MIN_PHRASE_WORDS and words_per_phrase <= MAX_PHRASE_WORDS,
f"Words per phrase must be between {MIN_PHRASE_WORDS} and {MAX_PHRASE_WORDS}")
debug.validate(
MIN_PASSPHRASE_WORDS <= words_per_phrase <= MAX_PASSPHRASE_WORDS,
f"Words per phrase must be between {MIN_PASSPHRASE_WORDS} and {MAX_PASSPHRASE_WORDS}",
)
words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase))
words_per_phrase = max(MIN_PASSPHRASE_WORDS, min(MAX_PASSPHRASE_WORDS, words_per_phrase))
wordlist = get_wordlist()
words = [secrets.choice(wordlist) for _ in range(words_per_phrase)]
phrase = ' '.join(words)
phrase = " ".join(words)
debug.print(f"Generated phrase: {phrase}")
return phrase
def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> Dict[str, str]:
# Alias for backward compatibility and public API consistency
generate_passphrase = generate_phrase
def generate_day_phrases(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> dict[str, str]:
"""
Generate phrases for all days of the week.
DEPRECATED in v3.2.0: Use generate_phrase() for single passphrase.
Kept for legacy compatibility and organizational use cases.
Args:
words_per_phrase: Number of words per phrase (3-12)
@@ -96,6 +117,15 @@ def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> Dict[s
>>> generate_day_phrases(3)
{'Monday': 'apple forest thunder', 'Tuesday': 'banana river lightning', ...}
"""
import warnings
warnings.warn(
"generate_day_phrases() is deprecated in v3.2.0. "
"Use generate_phrase() for single passphrase.",
DeprecationWarning,
stacklevel=2,
)
phrases = {day: generate_phrase(words_per_phrase) for day in DAY_NAMES}
debug.print(f"Generated phrases for {len(phrases)} days")
return phrases
@@ -119,8 +149,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
>>> key.key_size
2048
"""
debug.validate(bits in VALID_RSA_SIZES,
f"RSA key size must be one of {VALID_RSA_SIZES}")
debug.validate(bits in VALID_RSA_SIZES, f"RSA key size must be one of {VALID_RSA_SIZES}")
if bits not in VALID_RSA_SIZES:
bits = DEFAULT_RSA_BITS
@@ -128,9 +157,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
debug.print(f"Generating {bits}-bit RSA key...")
try:
key = rsa.generate_private_key(
public_exponent=65537,
key_size=bits,
backend=default_backend()
public_exponent=65537, key_size=bits, backend=default_backend()
)
debug.print(f"RSA key generated: {bits} bits")
return key
@@ -139,10 +166,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
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:
def export_rsa_key_pem(private_key: rsa.RSAPrivateKey, password: str | None = None) -> bytes:
"""
Export RSA key to PEM format.
@@ -161,24 +185,23 @@ def export_rsa_key_pem(
"""
debug.validate(private_key is not None, "Private key cannot be None")
encryption_algorithm: serialization.BestAvailableEncryption | serialization.NoEncryption
if password:
encryption = serialization.BestAvailableEncryption(password.encode())
encryption_algorithm = serialization.BestAvailableEncryption(password.encode())
debug.print("Exporting RSA key with encryption")
else:
encryption = serialization.NoEncryption()
encryption_algorithm = serialization.NoEncryption()
debug.print("Exporting RSA key without encryption")
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=encryption
encryption_algorithm=encryption_algorithm,
)
def load_rsa_key(
key_data: bytes,
password: Optional[str] = None
) -> rsa.RSAPrivateKey:
def load_rsa_key(key_data: bytes, password: str | None = None) -> rsa.RSAPrivateKey:
"""
Load RSA private key from PEM data.
@@ -196,13 +219,19 @@ def load_rsa_key(
Example:
>>> key = load_rsa_key(pem_data, "my_password")
"""
debug.validate(key_data is not None and len(key_data) > 0,
"Key data cannot be empty")
debug.validate(key_data is not None and len(key_data) > 0, "Key data cannot be empty")
try:
pwd_bytes = password.encode() if password else None
debug.print(f"Loading RSA key (encrypted: {bool(password)})")
key = load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend())
key: PrivateKeyTypes = load_pem_private_key(
key_data, password=pwd_bytes, backend=default_backend()
)
# Verify it's an RSA key
if not isinstance(key, rsa.RSAPrivateKey):
raise KeyGenerationError(f"Expected RSA key, got {type(key).__name__}")
debug.print(f"RSA key loaded: {key.key_size} bits")
return key
except TypeError:
@@ -220,7 +249,7 @@ def load_rsa_key(
raise KeyGenerationError(f"Could not load RSA key: {e}") from e
def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo:
def get_key_info(key_data: bytes, password: str | None = None) -> KeyInfo:
"""
Get information about an RSA key.
@@ -240,15 +269,11 @@ def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo:
"""
debug.print("Getting RSA key info")
# Check if encrypted
is_encrypted = b'ENCRYPTED' in key_data
is_encrypted = b"ENCRYPTED" in key_data
private_key = load_rsa_key(key_data, password)
info = KeyInfo(
key_size=private_key.key_size,
is_encrypted=is_encrypted,
pem_data=key_data
)
info = KeyInfo(key_size=private_key.key_size, is_encrypted=is_encrypted, pem_data=key_data)
debug.print(f"Key info: {info.key_size} bits, encrypted: {info.is_encrypted}")
return info
@@ -259,13 +284,91 @@ def generate_credentials(
use_rsa: bool = False,
pin_length: int = DEFAULT_PIN_LENGTH,
rsa_bits: int = DEFAULT_RSA_BITS,
words_per_phrase: int = DEFAULT_PHRASE_WORDS
passphrase_words: int = DEFAULT_PASSPHRASE_WORDS,
rsa_password: str | None = None,
) -> Credentials:
"""
Generate a complete set of credentials.
v3.2.0: Now generates a single passphrase instead of daily phrases.
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 (default 6)
rsa_bits: RSA key size if generating (default 2048)
passphrase_words: Words in passphrase (default 4)
rsa_password: Optional password for RSA key encryption
Returns:
Credentials object with passphrase, PIN, and/or RSA key
Raises:
ValueError: If neither PIN nor RSA is selected
Example:
>>> creds = generate_credentials(use_pin=True, use_rsa=False)
>>> creds.passphrase
"apple forest thunder mountain"
>>> creds.pin
"812345"
"""
debug.validate(use_pin or use_rsa, "Must select at least one security factor (PIN or RSA key)")
if not use_pin and not use_rsa:
raise ValueError("Must select at least one security factor (PIN or RSA key)")
debug.print(
f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, "
f"passphrase_words={passphrase_words}"
)
# Generate single passphrase (v3.2.0 - no daily rotation)
passphrase = generate_phrase(passphrase_words)
# Generate PIN if requested
pin = generate_pin(pin_length) if use_pin else None
# Generate RSA key if requested
rsa_key_pem = None
if use_rsa:
rsa_key_obj = generate_rsa_key(rsa_bits)
rsa_key_pem = export_rsa_key_pem(rsa_key_obj, rsa_password).decode("utf-8")
# Create Credentials object (v3.2.0 format with single passphrase)
creds = Credentials(
passphrase=passphrase,
pin=pin,
rsa_key_pem=rsa_key_pem,
rsa_bits=rsa_bits if use_rsa else None,
words_per_passphrase=passphrase_words,
)
debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy")
return creds
# =============================================================================
# LEGACY COMPATIBILITY
# =============================================================================
def generate_credentials_legacy(
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_PASSPHRASE_WORDS,
) -> dict:
"""
Generate credentials in legacy format (v3.1.0 style with daily phrases).
DEPRECATED: Use generate_credentials() for v3.2.0 format.
This function exists only for migration tools that need to work with
old-format credentials.
Args:
use_pin: Whether to generate a PIN
use_rsa: Whether to generate an RSA key
@@ -274,44 +377,34 @@ def generate_credentials(
words_per_phrase: Words per daily phrase
Returns:
Credentials object
Raises:
ValueError: If neither PIN nor RSA is selected
Example:
>>> creds = generate_credentials(use_pin=True, use_rsa=False)
>>> creds.pin
"812345"
>>> creds.phrases['Monday']
"apple forest thunder"
Dict with 'phrases' (dict), 'pin', 'rsa_key_pem', etc.
"""
debug.validate(use_pin or use_rsa,
"Must select at least one security factor (PIN or RSA key)")
import warnings
warnings.warn(
"generate_credentials_legacy() returns v3.1.0 format. "
"Use generate_credentials() for v3.2.0 format.",
DeprecationWarning,
stacklevel=2,
)
if not use_pin and not use_rsa:
raise ValueError("Must select at least one security factor (PIN or RSA key)")
debug.print(f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, "
f"words={words_per_phrase}")
phrases = generate_day_phrases(words_per_phrase)
# Generate daily phrases (old format)
phrases = {day: generate_phrase(words_per_phrase) for day in DAY_NAMES}
pin = generate_pin(pin_length) if use_pin else None
rsa_key_pem = None
rsa_key_obj = None
if use_rsa:
rsa_key_obj = generate_rsa_key(rsa_bits)
rsa_key_pem = export_rsa_key_pem(rsa_key_obj).decode('utf-8')
rsa_key_pem = export_rsa_key_pem(rsa_key_obj).decode("utf-8")
creds = 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
)
debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy")
return creds
return {
"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,
}

View File

@@ -1,27 +1,40 @@
"""
Stegasoo Data Models
Stegasoo Data Models (v3.2.0)
Dataclasses for structured data exchange between modules and frontends.
Changes in v3.2.0:
- Renamed day_phrase → passphrase
- Credentials now uses single passphrase instead of day mapping
- Removed date_str from EncodeInput (date no longer used in crypto)
- Made date_used optional in EncodeResult (cosmetic only)
- Added ImageInfo, CapacityComparison, GenerateResult
"""
from dataclasses import dataclass, field
from datetime import date
from typing import Optional, Union
@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
"""
Generated credentials for encoding/decoding.
v3.2.0: Simplified to use single passphrase instead of daily rotation.
"""
passphrase: str # Single passphrase (no daily rotation)
pin: str | None = None
rsa_key_pem: str | None = None
rsa_bits: int | None = None
words_per_passphrase: int = 4 # Increased from 3 in v3.1.0
# Optional: backup passphrases for multi-factor or rotation
backup_passphrases: list[str] | None = None
@property
def phrase_entropy(self) -> int:
"""Entropy in bits from phrases (~11 bits per BIP-39 word)."""
return self.words_per_phrase * 11
def passphrase_entropy(self) -> int:
"""Entropy in bits from passphrase (~11 bits per BIP-39 word)."""
return self.words_per_passphrase * 11
@property
def pin_entropy(self) -> int:
@@ -40,25 +53,32 @@ class Credentials:
@property
def total_entropy(self) -> int:
"""Total entropy in bits (excluding reference photo)."""
return self.phrase_entropy + self.pin_entropy + self.rsa_entropy
return self.passphrase_entropy + self.pin_entropy + self.rsa_entropy
# Legacy property for compatibility
@property
def phrase_entropy(self) -> int:
"""Alias for passphrase_entropy (backward compatibility)."""
return self.passphrase_entropy
@dataclass
class FilePayload:
"""Represents a file to be embedded."""
data: bytes
filename: str
mime_type: Optional[str] = None
mime_type: str | None = None
@property
def size(self) -> int:
return len(self.data)
@classmethod
def from_file(cls, filepath: str, filename: Optional[str] = None) -> 'FilePayload':
def from_file(cls, filepath: str, filename: str | None = None) -> "FilePayload":
"""Create FilePayload from a file path."""
from pathlib import Path
import mimetypes
from pathlib import Path
path = Path(filepath)
data = path.read_bytes()
@@ -70,30 +90,35 @@ class FilePayload:
@dataclass
class EncodeInput:
"""Input parameters for encoding a message."""
message: Union[str, bytes, FilePayload] # Text, raw bytes, or file
"""
Input parameters for encoding a message.
v3.2.0: Removed date_str (date no longer used in crypto).
"""
message: str | bytes | FilePayload # Text, raw bytes, or file
reference_photo: bytes
carrier_image: bytes
day_phrase: str
passphrase: str # Renamed from day_phrase
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()
rsa_key_data: bytes | None = None
rsa_password: str | None = None
@dataclass
class EncodeResult:
"""Result of encoding operation."""
"""
Result of encoding operation.
v3.2.0: date_used is now optional/cosmetic (not used in crypto).
"""
stego_image: bytes
filename: str
pixels_modified: int
total_pixels: int
capacity_used: float # 0.0 - 1.0
date_used: str
date_used: str | None = None # Cosmetic only (for filename organization)
@property
def capacity_percent(self) -> float:
@@ -103,34 +128,44 @@ class EncodeResult:
@dataclass
class DecodeInput:
"""Input parameters for decoding a message."""
"""
Input parameters for decoding a message.
v3.2.0: Renamed day_phrase → passphrase, no date needed.
"""
stego_image: bytes
reference_photo: bytes
day_phrase: str
passphrase: str # Renamed from day_phrase
pin: str = ""
rsa_key_data: Optional[bytes] = None
rsa_password: Optional[str] = None
rsa_key_data: bytes | None = None
rsa_password: str | None = None
@dataclass
class DecodeResult:
"""Result of decoding operation."""
"""
Result of decoding operation.
v3.2.0: date_encoded is always None (date removed from crypto).
"""
payload_type: str # 'text' or 'file'
message: Optional[str] = None # For text payloads
file_data: Optional[bytes] = None # For file payloads
filename: Optional[str] = None # Original filename for file payloads
mime_type: Optional[str] = None # MIME type hint
date_encoded: Optional[str] = None
message: str | None = None # For text payloads
file_data: bytes | None = None # For file payloads
filename: str | None = None # Original filename for file payloads
mime_type: str | None = None # MIME type hint
date_encoded: str | None = None # Always None in v3.2.0 (kept for compatibility)
@property
def is_file(self) -> bool:
return self.payload_type == 'file'
return self.payload_type == "file"
@property
def is_text(self) -> bool:
return self.payload_type == 'text'
return self.payload_type == "text"
def get_content(self) -> Union[str, bytes]:
def get_content(self) -> str | bytes:
"""Get the decoded content (text or bytes)."""
if self.is_text:
return self.message or ""
@@ -140,6 +175,7 @@ class DecodeResult:
@dataclass
class EmbedStats:
"""Statistics from image embedding."""
pixels_modified: int
total_pixels: int
capacity_used: float
@@ -154,6 +190,7 @@ class EmbedStats:
@dataclass
class KeyInfo:
"""Information about an RSA key."""
key_size: int
is_encrypted: bool
pem_data: bytes
@@ -162,16 +199,85 @@ class KeyInfo:
@dataclass
class ValidationResult:
"""Result of input validation."""
is_valid: bool
error_message: str = ""
details: dict = field(default_factory=dict)
warning: str | None = None # v3.2.0: Added for passphrase length warnings
@classmethod
def ok(cls, **details) -> 'ValidationResult':
def ok(cls, warning: str | None = None, **details) -> "ValidationResult":
"""Create a successful validation result."""
return cls(is_valid=True, details=details)
result = cls(is_valid=True, details=details)
if warning:
result.warning = warning
return result
@classmethod
def error(cls, message: str, **details) -> 'ValidationResult':
def error(cls, message: str, **details) -> "ValidationResult":
"""Create a failed validation result."""
return cls(is_valid=False, error_message=message, details=details)
# =============================================================================
# NEW MODELS FOR V3.2.0 PUBLIC API
# =============================================================================
@dataclass
class ImageInfo:
"""Information about an image for steganography."""
width: int
height: int
pixels: int
format: str
mode: str
file_size: int
lsb_capacity_bytes: int
lsb_capacity_kb: float
dct_capacity_bytes: int | None = None
dct_capacity_kb: float | None = None
@dataclass
class CapacityComparison:
"""Comparison of embedding capacity between modes."""
image_width: int
image_height: int
lsb_available: bool
lsb_bytes: int
lsb_kb: float
lsb_output_format: str
dct_available: bool
dct_bytes: int | None = None
dct_kb: float | None = None
dct_output_formats: list[str] | None = None
dct_ratio_vs_lsb: float | None = None
@dataclass
class GenerateResult:
"""Result of credential generation."""
passphrase: str
pin: str | None = None
rsa_key_pem: str | None = None
passphrase_words: int = 4
passphrase_entropy: int = 0
pin_entropy: int = 0
rsa_entropy: int = 0
total_entropy: int = 0
def __str__(self) -> str:
lines = [
"Generated Credentials:",
f" Passphrase: {self.passphrase}",
]
if self.pin:
lines.append(f" PIN: {self.pin}")
if self.rsa_key_pem:
lines.append(f" RSA Key: {len(self.rsa_key_pem)} bytes PEM")
lines.append(f" Total Entropy: {self.total_entropy} bits")
return "\n".join(lines)

View File

@@ -10,10 +10,9 @@ IMPROVEMENTS IN THIS VERSION:
- Improved error messages
"""
import base64
import io
import zlib
import base64
from typing import Optional, Tuple
from PIL import Image
@@ -21,22 +20,29 @@ from PIL import Image
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M
HAS_QRCODE_WRITE = True
except ImportError:
HAS_QRCODE_WRITE = False
# QR code reading
try:
from pyzbar.pyzbar import decode as pyzbar_decode
from pyzbar.pyzbar import ZBarSymbol
from pyzbar.pyzbar import decode as pyzbar_decode
HAS_QRCODE_READ = True
except ImportError:
HAS_QRCODE_READ = False
from .constants import (
QR_CROP_MIN_PADDING_PX,
QR_CROP_PADDING_PERCENT,
QR_MAX_BINARY,
)
# Constants
COMPRESSION_PREFIX = "STEGASOO-Z:"
QR_MAX_BINARY = 2900 # Safe limit for binary data in QR
def compress_data(data: str) -> str:
@@ -49,8 +55,8 @@ def compress_data(data: str) -> str:
Returns:
Compressed string with STEGASOO-Z: prefix
"""
compressed = zlib.compress(data.encode('utf-8'), level=9)
encoded = base64.b64encode(compressed).decode('ascii')
compressed = zlib.compress(data.encode("utf-8"), level=9)
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX + encoded
@@ -70,9 +76,9 @@ def decompress_data(data: str) -> str:
if not data.startswith(COMPRESSION_PREFIX):
raise ValueError("Data is not in compressed format")
encoded = data[len(COMPRESSION_PREFIX):]
encoded = data[len(COMPRESSION_PREFIX) :]
compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode('utf-8')
return zlib.decompress(compressed).decode("utf-8")
def normalize_pem(pem_data: str) -> str:
@@ -97,25 +103,25 @@ def normalize_pem(pem_data: str) -> str:
import re
# Step 1: Normalize ALL line endings to \n
pem_data = pem_data.replace('\r\n', '\n').replace('\r', '\n')
pem_data = pem_data.replace("\r\n", "\n").replace("\r", "\n")
# Step 2: Remove leading/trailing whitespace
pem_data = pem_data.strip()
# Step 3: Remove any non-ASCII characters (QR artifacts)
pem_data = ''.join(char for char in pem_data if ord(char) < 128)
pem_data = "".join(char for char in pem_data if ord(char) < 128)
# Step 4: Extract header, content, and footer with flexible regex
# This handles variations like:
# - "PRIVATE KEY" vs "RSA PRIVATE KEY"
# - Extra spaces in headers
# - Missing spaces
pattern = r'(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)'
pattern = r"(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)"
match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE)
if not match:
# Fallback: try even more permissive pattern
pattern = r'(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)'
pattern = r"(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)"
match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE)
if not match:
@@ -128,38 +134,35 @@ def normalize_pem(pem_data: str) -> str:
# Step 5: Normalize header and footer
# Standardize spacing and ensure proper format
header = re.sub(r'\s+', ' ', header_raw)
footer = re.sub(r'\s+', ' ', footer_raw)
header = re.sub(r"\s+", " ", header_raw)
footer = re.sub(r"\s+", " ", footer_raw)
# Ensure exactly 5 dashes on each side
header = re.sub(r'^-+', '-----', header)
header = re.sub(r'-+$', '-----', header)
footer = re.sub(r'^-+', '-----', footer)
footer = re.sub(r'-+$', '-----', footer)
header = re.sub(r"^-+", "-----", header)
header = re.sub(r"-+$", "-----", header)
footer = re.sub(r"^-+", "-----", footer)
footer = re.sub(r"-+$", "-----", footer)
# Step 6: Clean the base64 content THOROUGHLY
# Remove ALL whitespace: spaces, tabs, newlines
# Keep only valid base64 characters: A-Z, a-z, 0-9, +, /, =
content_clean = ''.join(
char for char in content_raw
if char.isalnum() or char in '+/='
)
content_clean = "".join(char for char in content_raw if char.isalnum() or char in "+/=")
# Double-check: remove any remaining invalid characters
content_clean = re.sub(r'[^A-Za-z0-9+/=]', '', content_clean)
content_clean = re.sub(r"[^A-Za-z0-9+/=]", "", content_clean)
# Step 7: Fix base64 padding
# Base64 strings must be divisible by 4
remainder = len(content_clean) % 4
if remainder:
content_clean += '=' * (4 - remainder)
content_clean += "=" * (4 - remainder)
# Step 8: Split into 64-character lines (PEM standard)
lines = [content_clean[i:i+64] for i in range(0, len(content_clean), 64)]
lines = [content_clean[i : i + 64] for i in range(0, len(content_clean), 64)]
# Step 9: Reconstruct with EXACT PEM formatting
# Format: header\ncontent_line1\ncontent_line2\n...\nfooter\n
return header + '\n' + '\n'.join(lines) + '\n' + footer + '\n'
return header + "\n" + "\n".join(lines) + "\n" + footer + "\n"
def is_compressed(data: str) -> bool:
@@ -201,7 +204,7 @@ def can_fit_in_qr(data: str, compress: bool = False) -> bool:
if compress:
size = get_compressed_size(data)
else:
size = len(data.encode('utf-8'))
size = len(data.encode("utf-8"))
return size <= QR_MAX_BINARY
@@ -210,11 +213,7 @@ def needs_compression(data: str) -> bool:
return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True)
def generate_qr_code(
data: str,
compress: bool = False,
error_correction=None
) -> bytes:
def generate_qr_code(data: str, compress: bool = False, error_correction=None) -> bytes:
"""
Generate a QR code PNG from string data.
@@ -240,10 +239,9 @@ def generate_qr_code(
qr_data = compress_data(data)
# Check size
if len(qr_data.encode('utf-8')) > QR_MAX_BINARY:
if len(qr_data.encode("utf-8")) > QR_MAX_BINARY:
raise ValueError(
f"Data too large for QR code ({len(qr_data)} bytes). "
f"Maximum: {QR_MAX_BINARY} bytes"
f"Data too large for QR code ({len(qr_data)} bytes). " f"Maximum: {QR_MAX_BINARY} bytes"
)
# Use lower error correction for larger data
@@ -262,12 +260,12 @@ def generate_qr_code(
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
img.save(buf, format="PNG")
buf.seek(0)
return buf.getvalue()
def read_qr_code(image_data: bytes) -> Optional[str]:
def read_qr_code(image_data: bytes) -> str | None:
"""
Read QR code from image data.
@@ -287,11 +285,11 @@ def read_qr_code(image_data: bytes) -> Optional[str]:
)
try:
img = Image.open(io.BytesIO(image_data))
img: Image.Image = Image.open(io.BytesIO(image_data))
# Convert to RGB if necessary (pyzbar works best with RGB/grayscale)
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
# Decode QR codes
decoded = pyzbar_decode(img, symbols=[ZBarSymbol.QRCODE])
@@ -300,13 +298,14 @@ def read_qr_code(image_data: bytes) -> Optional[str]:
return None
# Return first QR code found
return decoded[0].data.decode('utf-8')
result: str = decoded[0].data.decode("utf-8")
return result
except Exception:
return None
def read_qr_code_from_file(filepath: str) -> Optional[str]:
def read_qr_code_from_file(filepath: str) -> str | None:
"""
Read QR code from image file.
@@ -316,11 +315,11 @@ def read_qr_code_from_file(filepath: str) -> Optional[str]:
Returns:
Decoded string, or None if no QR code found
"""
with open(filepath, 'rb') as f:
with open(filepath, "rb") as f:
return read_qr_code(f.read())
def extract_key_from_qr(image_data: bytes) -> Optional[str]:
def extract_key_from_qr(image_data: bytes) -> str | None:
"""
Extract RSA key from QR code image, auto-decompressing if needed.
@@ -345,30 +344,30 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]:
key_pem = decompress_data(qr_data)
else:
key_pem = qr_data
except Exception as e:
except Exception:
# If decompression fails, try using data as-is
key_pem = qr_data
# Step 3: Validate it looks like a PEM key
if '-----BEGIN' not in key_pem or '-----END' not in key_pem:
if "-----BEGIN" not in key_pem or "-----END" not in key_pem:
return None
# Step 4: Aggressively normalize PEM format
# This is crucial - QR codes can introduce subtle formatting issues
try:
key_pem = normalize_pem(key_pem)
except Exception as e:
except Exception:
# If normalization fails, return None rather than broken PEM
return None
# Step 5: Final validation - ensure it still looks like PEM
if '-----BEGIN' in key_pem and '-----END' in key_pem:
if "-----BEGIN" in key_pem and "-----END" in key_pem:
return key_pem
return None
def extract_key_from_qr_file(filepath: str) -> Optional[str]:
def extract_key_from_qr_file(filepath: str) -> str | None:
"""
Extract RSA key from QR code image file.
@@ -378,10 +377,126 @@ def extract_key_from_qr_file(filepath: str) -> Optional[str]:
Returns:
PEM-encoded RSA key string, or None if not found/invalid
"""
with open(filepath, 'rb') as f:
with open(filepath, "rb") as f:
return extract_key_from_qr(f.read())
def detect_and_crop_qr(
image_data: bytes,
padding_percent: float = QR_CROP_PADDING_PERCENT,
min_padding_px: int = QR_CROP_MIN_PADDING_PX,
) -> bytes | None:
"""
Detect QR code in image and crop to it, handling rotation.
Uses the QR code's corner coordinates to compute an axis-aligned
bounding box, then adds padding to ensure rotated QR codes aren't clipped.
Args:
image_data: Input image bytes (PNG, JPG, etc.)
padding_percent: Padding as fraction of QR size (default 10%)
min_padding_px: Minimum padding in pixels (default 10)
Returns:
Cropped PNG image bytes, or None if no QR code found
Raises:
RuntimeError: If pyzbar library not available
"""
if not HAS_QRCODE_READ:
raise RuntimeError(
"pyzbar library not installed. Run: pip install pyzbar\n"
"Also requires system library: sudo apt-get install libzbar0"
)
try:
img: Image.Image = Image.open(io.BytesIO(image_data))
original_mode = img.mode
# Convert for pyzbar detection
if img.mode not in ("RGB", "L"):
detect_img = img.convert("RGB")
else:
detect_img = img
# Decode QR codes to get corner positions
decoded = pyzbar_decode(detect_img, symbols=[ZBarSymbol.QRCODE])
if not decoded:
return None
# Get the polygon corners of the first QR code
# pyzbar returns a Polygon with Point objects (x, y attributes)
polygon = decoded[0].polygon
if len(polygon) < 4:
# Fallback to rect if polygon not available
rect = decoded[0].rect
min_x, min_y = rect.left, rect.top
max_x, max_y = rect.left + rect.width, rect.top + rect.height
else:
# Extract corner coordinates - handles any rotation
xs = [p.x for p in polygon]
ys = [p.y for p in polygon]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
# Calculate QR dimensions and padding
qr_width = max_x - min_x
qr_height = max_y - min_y
# Use larger dimension for padding calculation (handles rotation)
qr_size = max(qr_width, qr_height)
padding = max(int(qr_size * padding_percent), min_padding_px)
# Calculate crop box with padding, clamped to image bounds
img_width, img_height = img.size
crop_left = max(0, min_x - padding)
crop_top = max(0, min_y - padding)
crop_right = min(img_width, max_x + padding)
crop_bottom = min(img_height, max_y + padding)
# Crop the original image (preserves original mode/quality)
cropped = img.crop((crop_left, crop_top, crop_right, crop_bottom))
# Convert to PNG bytes
buf = io.BytesIO()
# Preserve transparency if present
if original_mode in ("RGBA", "LA", "P"):
cropped.save(buf, format="PNG")
else:
cropped.save(buf, format="PNG")
buf.seek(0)
return buf.getvalue()
except Exception as e:
# Log for debugging but return None for clean API
import sys
print(f"QR crop error: {e}", file=sys.stderr)
return None
def detect_and_crop_qr_file(
filepath: str,
padding_percent: float = QR_CROP_PADDING_PERCENT,
min_padding_px: int = QR_CROP_MIN_PADDING_PX,
) -> bytes | None:
"""
Detect QR code in image file and crop to it.
Args:
filepath: Path to image file
padding_percent: Padding as fraction of QR size (default 10%)
min_padding_px: Minimum padding in pixels (default 10)
Returns:
Cropped PNG image bytes, or None if no QR code found
"""
with open(filepath, "rb") as f:
return detect_and_crop_qr(f.read(), padding_percent, min_padding_px)
def has_qr_write() -> bool:
"""Check if QR code writing is available."""
return HAS_QRCODE_WRITE

File diff suppressed because it is too large Load Diff

View File

@@ -4,23 +4,59 @@ Stegasoo Utilities
Secure deletion, filename generation, and other helpers.
"""
import io
import os
import random
import secrets
import shutil
from datetime import date, datetime
from datetime import date
from pathlib import Path
from typing import Optional, Union
from PIL import Image
from .constants import DAY_NAMES
from .debug import debug
def generate_filename(
date_str: Optional[str] = None,
prefix: str = "",
extension: str = "png"
) -> str:
def strip_image_metadata(image_data: bytes, output_format: str = "PNG") -> bytes:
"""
Remove all metadata (EXIF, ICC profiles, etc.) from an image.
Creates a fresh image with only pixel data - no EXIF, GPS coordinates,
camera info, timestamps, or other potentially sensitive metadata.
Args:
image_data: Raw image bytes
output_format: Output format ('PNG', 'BMP', 'TIFF')
Returns:
Clean image bytes with no metadata
Example:
>>> clean = strip_image_metadata(photo_bytes)
>>> # EXIF data is now removed
"""
debug.print(f"Stripping metadata, output format: {output_format}")
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if needed (handles RGBA, P, L, etc.)
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
# Create fresh image - this discards all metadata
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
output = io.BytesIO()
clean.save(output, output_format.upper())
output.seek(0)
debug.print(f"Metadata stripped: {len(image_data)} -> {len(output.getvalue())} bytes")
return output.getvalue()
def generate_filename(date_str: str | None = None, prefix: str = "", extension: str = "png") -> str:
"""
Generate a filename for stego images.
@@ -38,24 +74,26 @@ def generate_filename(
>>> generate_filename("2023-12-25", "secret_", "png")
"secret_a1b2c3d4_20231225.png"
"""
debug.validate(extension and '.' not in extension,
f"Extension must not contain dot, got '{extension}'")
debug.validate(
bool(extension) and "." not in extension,
f"Extension must not contain dot, got '{extension}'",
)
if date_str is None:
date_str = date.today().isoformat()
date_compact = date_str.replace('-', '')
date_compact = date_str.replace("-", "")
random_hex = secrets.token_hex(4)
# Ensure extension doesn't have a leading dot
extension = extension.lstrip('.')
extension = extension.lstrip(".")
filename = f"{prefix}{random_hex}_{date_compact}.{extension}"
debug.print(f"Generated filename: {filename}")
return filename
def parse_date_from_filename(filename: str) -> Optional[str]:
def parse_date_from_filename(filename: str) -> str | None:
"""
Extract date from a stego filename.
@@ -74,7 +112,7 @@ def parse_date_from_filename(filename: str) -> Optional[str]:
import re
# Try YYYYMMDD format
match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename)
match = re.search(r"_(\d{4})(\d{2})(\d{2})(?:\.|$)", filename)
if match:
year, month, day = match.groups()
date_str = f"{year}-{month}-{day}"
@@ -82,7 +120,7 @@ def parse_date_from_filename(filename: str) -> Optional[str]:
return date_str
# Try YYYY-MM-DD format
match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename)
match = re.search(r"_(\d{4})-(\d{2})-(\d{2})(?:\.|$)", filename)
if match:
year, month, day = match.groups()
date_str = f"{year}-{month}-{day}"
@@ -107,11 +145,13 @@ def get_day_from_date(date_str: str) -> str:
>>> get_day_from_date("2023-12-25")
"Monday"
"""
debug.validate(len(date_str) == 10 and date_str[4] == '-' and date_str[7] == '-',
f"Invalid date format: {date_str}, expected YYYY-MM-DD")
debug.validate(
len(date_str) == 10 and date_str[4] == "-" and date_str[7] == "-",
f"Invalid date format: {date_str}, expected YYYY-MM-DD",
)
try:
year, month, day = map(int, date_str.split('-'))
year, month, day = map(int, date_str.split("-"))
d = date(year, month, day)
day_name = DAY_NAMES[d.weekday()]
debug.print(f"Date {date_str} is {day_name}")
@@ -164,7 +204,7 @@ class SecureDeleter:
>>> deleter.execute()
"""
def __init__(self, path: Union[str, Path], passes: int = 7):
def __init__(self, path: str | Path, passes: int = 7):
"""
Initialize secure deleter.
@@ -191,11 +231,11 @@ class SecureDeleter:
debug.print("File is empty, nothing to overwrite")
return
patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])]
patterns = [b"\x00", b"\xff", bytes([random.randint(0, 255)])]
for pass_num in range(self.passes):
debug.print(f"Overwrite pass {pass_num + 1}/{self.passes}")
with open(file_path, 'r+b') as f:
with open(file_path, "r+b") as f:
for pattern_idx, pattern in enumerate(patterns):
f.seek(0)
# Write pattern in chunks for large files
@@ -203,7 +243,7 @@ class SecureDeleter:
for offset in range(0, length, chunk_size):
chunk = min(chunk_size, length - offset)
f.write(pattern * (chunk // len(pattern)))
f.write(pattern[:chunk % len(pattern)])
f.write(pattern[: chunk % len(pattern)])
# Final pass with random data
f.seek(0)
@@ -231,7 +271,7 @@ class SecureDeleter:
# First, securely overwrite all files
file_count = 0
for file_path in self.path.rglob('*'):
for file_path in self.path.rglob("*"):
if file_path.is_file():
self._overwrite_file(file_path)
file_count += 1
@@ -253,7 +293,7 @@ class SecureDeleter:
debug.print(f"Path does not exist: {self.path}")
def secure_delete(path: Union[str, Path], passes: int = 7) -> None:
def secure_delete(path: str | Path, passes: int = 7) -> None:
"""
Convenience function for secure deletion.
@@ -284,13 +324,14 @@ def format_file_size(size_bytes: int) -> str:
"""
debug.validate(size_bytes >= 0, f"File size cannot be negative: {size_bytes}")
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"
size: float = float(size_bytes)
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024:
if unit == "B":
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
def format_number(n: int) -> str:

View File

@@ -1,27 +1,44 @@
"""
Stegasoo Input Validation
Stegasoo Input Validation (v3.2.0)
Validators for all user inputs with clear error messages.
Changes in v3.2.0:
- Renamed validate_phrase() → validate_passphrase()
- Added word count validation with warnings for passphrases
- Added validators for embed modes and DCT parameters
"""
import io
from typing import Optional, Union
from PIL import Image
from .constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH,
MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE,
MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH,
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS,
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_KEY_EXTENSIONS,
EMBED_MODE_AUTO,
EMBED_MODE_DCT,
EMBED_MODE_LSB,
MAX_FILE_PAYLOAD_SIZE,
MAX_FILE_SIZE,
MAX_IMAGE_PIXELS,
MAX_MESSAGE_SIZE,
MAX_PIN_LENGTH,
MIN_KEY_PASSWORD_LENGTH,
MIN_PASSPHRASE_WORDS,
MIN_PIN_LENGTH,
MIN_RSA_BITS,
RECOMMENDED_PASSPHRASE_WORDS,
)
from .models import ValidationResult, FilePayload
from .exceptions import (
ValidationError, PinValidationError, MessageValidationError,
ImageValidationError, KeyValidationError, SecurityFactorError,
FileTooLargeError, UnsupportedFileTypeError,
ImageValidationError,
KeyValidationError,
MessageValidationError,
PinValidationError,
SecurityFactorError,
)
from .keygen import load_rsa_key
from .models import FilePayload, ValidationResult
def validate_pin(pin: str, required: bool = False) -> ValidationResult:
@@ -49,11 +66,9 @@ def validate_pin(pin: str, required: bool = False) -> ValidationResult:
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"
)
return ValidationResult.error(f"PIN must be {MIN_PIN_LENGTH}-{MAX_PIN_LENGTH} digits")
if pin[0] == '0':
if pin[0] == "0":
return ValidationResult.error("PIN cannot start with zero")
return ValidationResult.ok(length=len(pin))
@@ -80,7 +95,7 @@ def validate_message(message: str) -> ValidationResult:
return ValidationResult.ok(length=len(message))
def validate_payload(payload: Union[str, bytes, FilePayload]) -> ValidationResult:
def validate_payload(payload: str | bytes | FilePayload) -> ValidationResult:
"""
Validate a payload (text message, bytes, or file).
@@ -104,9 +119,7 @@ def validate_payload(payload: Union[str, bytes, FilePayload]) -> ValidationResul
)
return ValidationResult.ok(
size=len(payload.data),
filename=payload.filename,
mime_type=payload.mime_type
size=len(payload.data), filename=payload.filename, mime_type=payload.mime_type
)
elif isinstance(payload, bytes):
@@ -126,9 +139,7 @@ def validate_payload(payload: Union[str, bytes, FilePayload]) -> ValidationResul
def validate_file_payload(
file_data: bytes,
filename: str = "",
max_size: int = MAX_FILE_PAYLOAD_SIZE
file_data: bytes, filename: str = "", max_size: int = MAX_FILE_PAYLOAD_SIZE
) -> ValidationResult:
"""
Validate a file for embedding.
@@ -156,9 +167,7 @@ def validate_file_payload(
def validate_image(
image_data: bytes,
name: str = "Image",
check_size: bool = True
image_data: bytes, name: str = "Image", check_size: bool = True
) -> ValidationResult:
"""
Validate image data and dimensions.
@@ -185,18 +194,14 @@ def validate_image(
num_pixels = width * height
if check_size and num_pixels > MAX_IMAGE_PIXELS:
max_dim = int(MAX_IMAGE_PIXELS ** 0.5)
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
width=width, height=height, pixels=num_pixels, mode=img.mode, format=img.format
)
except Exception as e:
@@ -204,9 +209,7 @@ def validate_image(
def validate_rsa_key(
key_data: bytes,
password: Optional[str] = None,
required: bool = False
key_data: bytes, password: str | None = None, required: bool = False
) -> ValidationResult:
"""
Validate RSA private key.
@@ -239,10 +242,7 @@ def validate_rsa_key(
return ValidationResult.error(str(e))
def validate_security_factors(
pin: str,
rsa_key_data: Optional[bytes]
) -> ValidationResult:
def validate_security_factors(pin: str, rsa_key_data: bytes | None) -> ValidationResult:
"""
Validate that at least one security factor is provided.
@@ -257,17 +257,13 @@ def validate_security_factors(
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.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"
filename: str, allowed: set[str], file_type: str = "File"
) -> ValidationResult:
"""
Validate file extension.
@@ -280,10 +276,10 @@ def validate_file_extension(
Returns:
ValidationResult with extension
"""
if not filename or '.' not in filename:
if not filename or "." not in filename:
return ValidationResult.error(f"{file_type} must have a file extension")
ext = filename.rsplit('.', 1)[1].lower()
ext = filename.rsplit(".", 1)[1].lower()
if ext not in allowed:
return ValidationResult.error(
@@ -325,61 +321,118 @@ def validate_key_password(password: str) -> ValidationResult:
return ValidationResult.ok(length=len(password))
def validate_phrase(phrase: str) -> ValidationResult:
def validate_passphrase(passphrase: str) -> ValidationResult:
"""
Validate day phrase.
Validate passphrase.
v3.2.0: Recommend 4+ words for good entropy (since date is no longer used).
Args:
phrase: Phrase string
passphrase: Passphrase string
Returns:
ValidationResult with word_count
ValidationResult with word_count and optional warning
"""
if not phrase or not phrase.strip():
return ValidationResult.error("Day phrase is required")
if not passphrase or not passphrase.strip():
return ValidationResult.error("Passphrase is required")
words = phrase.strip().split()
words = passphrase.strip().split()
if len(words) < MIN_PASSPHRASE_WORDS:
return ValidationResult.error(
f"Passphrase should have at least {MIN_PASSPHRASE_WORDS} words"
)
# Provide warning if below recommended length
if len(words) < RECOMMENDED_PASSPHRASE_WORDS:
return ValidationResult.ok(
word_count=len(words),
warning=f"Recommend {RECOMMENDED_PASSPHRASE_WORDS}+ words for better security",
)
return ValidationResult.ok(word_count=len(words))
def validate_date_string(date_str: str) -> ValidationResult:
# =============================================================================
# NEW VALIDATORS FOR V3.2.0
# =============================================================================
def validate_reference_photo(photo_data: bytes) -> ValidationResult:
"""Validate reference photo. Alias for validate_image."""
return validate_image(photo_data, "Reference photo")
def validate_carrier(carrier_data: bytes) -> ValidationResult:
"""Validate carrier image. Alias for validate_image."""
return validate_image(carrier_data, "Carrier image")
def validate_embed_mode(mode: str) -> ValidationResult:
"""
Validate date string format (YYYY-MM-DD).
Validate embedding mode.
Args:
date_str: Date string
mode: Embedding mode string
Returns:
ValidationResult
"""
if not date_str:
return ValidationResult.error("Date is required")
valid_modes = {EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO}
if len(date_str) != 10:
return ValidationResult.error("Date must be in YYYY-MM-DD format")
if mode not in valid_modes:
return ValidationResult.error(
f"Invalid embed_mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
)
if date_str[4] != '-' or date_str[7] != '-':
return ValidationResult.error("Date must be in YYYY-MM-DD format")
return ValidationResult.ok(mode=mode)
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")
def validate_dct_output_format(format_str: str) -> ValidationResult:
"""
Validate DCT output format.
return ValidationResult.ok(year=year, month=month, day=day)
Args:
format_str: Output format ('png' or 'jpeg')
except ValueError:
return ValidationResult.error("Date must contain valid numbers")
Returns:
ValidationResult
"""
valid_formats = {"png", "jpeg"}
if format_str.lower() not in valid_formats:
return ValidationResult.error(
f"Invalid DCT output format: '{format_str}'. Valid options: {', '.join(sorted(valid_formats))}"
)
return ValidationResult.ok(format=format_str.lower())
def validate_dct_color_mode(mode: str) -> ValidationResult:
"""
Validate DCT color mode.
Args:
mode: Color mode ('grayscale' or 'color')
Returns:
ValidationResult
"""
valid_modes = {"grayscale", "color"}
if mode.lower() not in valid_modes:
return ValidationResult.error(
f"Invalid DCT color mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
)
return ValidationResult.ok(mode=mode.lower())
# ============================================================================
# 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)
@@ -394,7 +447,7 @@ def require_valid_message(message: str) -> None:
raise MessageValidationError(result.error_message)
def require_valid_payload(payload: Union[str, bytes, FilePayload]) -> None:
def require_valid_payload(payload: str | bytes | FilePayload) -> None:
"""Validate payload (text, bytes, or file), raising exception on failure."""
result = validate_payload(payload)
if not result.is_valid:
@@ -409,9 +462,7 @@ def require_valid_image(image_data: bytes, name: str = "Image") -> None:
def require_valid_rsa_key(
key_data: bytes,
password: Optional[str] = None,
required: bool = False
key_data: bytes, password: str | None = None, required: bool = False
) -> None:
"""Validate RSA key, raising exception on failure."""
result = validate_rsa_key(key_data, password, required)
@@ -419,7 +470,7 @@ def require_valid_rsa_key(
raise KeyValidationError(result.error_message)
def require_security_factors(pin: str, rsa_key_data: Optional[bytes]) -> None:
def require_security_factors(pin: str, rsa_key_data: bytes | None) -> None:
"""Validate security factors, raising exception on failure."""
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Test that mimics the exact /api/compare-capacity flow.
Run with: python test_compare_capacity_flow.py ./xx_2.jpg
"""
import sys
import io
import gc
import json
import time
print("=" * 60)
print("COMPARE-CAPACITY FLOW TEST")
print("=" * 60)
if len(sys.argv) < 2:
print("Usage: python test_compare_capacity_flow.py <image_path>")
sys.exit(1)
image_path = sys.argv[1]
# Read the file
with open(image_path, 'rb') as f:
carrier_data = f.read()
print(f"Loaded {len(carrier_data)} bytes from {image_path}")
# Import everything like Flask does
print("\n[1] Importing modules...")
from PIL import Image
import numpy as np
try:
import jpegio as jio
HAS_JPEGIO = True
print(f" jpegio: available")
except ImportError:
HAS_JPEGIO = False
print(f" jpegio: NOT available")
try:
from scipy.fft import dct, idct
print(f" scipy.fft: available")
except ImportError:
from scipy.fftpack import dct, idct
print(f" scipy.fftpack: available (fallback)")
print(" Imports complete")
# Simulate the compare_modes function
print("\n[2] Opening image (1st time - for dimensions)...")
img1 = Image.open(io.BytesIO(carrier_data))
width, height = img1.size
print(f" Size: {width}x{height}")
img1.close()
print(" Closed img1")
gc.collect()
print("\n[3] Opening image (2nd time - for LSB capacity)...")
img2 = Image.open(io.BytesIO(carrier_data))
num_pixels = img2.size[0] * img2.size[1]
lsb_bytes = (num_pixels * 3) // 8 - 69
print(f" LSB capacity: {lsb_bytes} bytes")
img2.close()
print(" Closed img2")
gc.collect()
print("\n[4] Opening image (3rd time - for DCT capacity)...")
img3 = Image.open(io.BytesIO(carrier_data))
w, h = img3.size
blocks_x = w // 8
blocks_y = h // 8
total_blocks = blocks_x * blocks_y
dct_bits = total_blocks * 16
dct_bytes = dct_bits // 8 - 10
print(f" DCT capacity: {dct_bytes} bytes ({total_blocks} blocks)")
img3.close()
print(" Closed img3")
gc.collect()
print("\n[5] Building response dict...")
response = {
'success': True,
'width': width,
'height': height,
'lsb': {
'capacity_bytes': lsb_bytes,
'capacity_kb': round(lsb_bytes / 1024, 1),
'output': 'PNG',
},
'dct': {
'capacity_bytes': dct_bytes,
'capacity_kb': round(dct_bytes / 1024, 1),
'output': 'JPEG',
'available': True,
'ratio': round(dct_bytes / lsb_bytes * 100, 1),
}
}
print(f" Response built")
print("\n[6] Serializing to JSON...")
json_str = json.dumps(response)
print(f" JSON length: {len(json_str)} bytes")
print(f" Content: {json_str[:200]}...")
print("\n[7] Simulating Flask response completion...")
# In Flask, after the response is sent, Python may garbage collect
del carrier_data
del response
del json_str
gc.collect()
print(" GC after response simulation")
print("\n[8] Additional cleanup (simulating request end)...")
gc.collect()
gc.collect()
print(" Multiple GC cycles complete")
print("\n[9] Waiting for delayed crash...")
for i in range(3):
time.sleep(1)
print(f" {i+1}s...")
gc.collect()
print("\n" + "=" * 60)
print("TEST PASSED - No crash detected")
print("=" * 60)
# Now test with jpegio if available
if HAS_JPEGIO:
print("\n" + "=" * 60)
print("JPEGIO SPECIFIC TEST")
print("=" * 60)
import tempfile
import os
# Reload image data
with open(image_path, 'rb') as f:
carrier_data = f.read()
print("\n[J1] Checking if image is JPEG...")
img = Image.open(io.BytesIO(carrier_data))
is_jpeg = img.format == 'JPEG'
img.close()
print(f" Is JPEG: {is_jpeg}")
if is_jpeg:
print("\n[J2] Writing to temp file...")
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
os.write(fd, carrier_data)
os.close(fd)
print(f" Temp file: {temp_path}")
print("\n[J3] Reading with jpegio...")
try:
jpeg = jio.read(temp_path)
print(f" jpegio.read() OK")
print("\n[J4] Accessing coefficient arrays...")
coef = jpeg.coef_arrays[0]
print(f" Coef shape: {coef.shape}, dtype: {coef.dtype}")
print("\n[J5] Counting usable positions...")
positions = []
h, w = coef.shape
for row in range(h):
for col in range(w):
if (row % 8 == 0) and (col % 8 == 0):
continue
if abs(coef[row, col]) >= 2:
positions.append((row, col))
print(f" Usable positions: {len(positions)}")
print("\n[J6] Cleaning up jpegio object...")
del coef
del jpeg
gc.collect()
print(" Deleted jpeg object")
print("\n[J7] Removing temp file...")
os.unlink(temp_path)
print(" Temp file removed")
gc.collect()
print("\n[J8] Final GC...")
except Exception as e:
print(f" ERROR: {e}")
import traceback
traceback.print_exc()
print("\n[J9] Waiting for delayed crash...")
for i in range(3):
time.sleep(1)
print(f" {i+1}s...")
gc.collect()
print("\n" + "=" * 60)
print("JPEGIO TEST PASSED - No crash detected")
print("=" * 60)
else:
print(" Skipping jpegio test (not a JPEG)")
print("\n\nAll tests completed successfully!")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

231
test_dct_crash.py Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Standalone DCT crash diagnostic script.
Run this outside of Flask to isolate the issue.
Usage:
python test_dct_crash.py /path/to/your/large_image.jpg
"""
import sys
import gc
import traceback
import io
print("=" * 60)
print("DCT CRASH DIAGNOSTIC TOOL")
print("=" * 60)
# Step 1: Check Python and library versions
print("\n[1] ENVIRONMENT INFO")
print(f"Python: {sys.version}")
try:
import numpy as np
print(f"NumPy: {np.__version__}")
except ImportError as e:
print(f"NumPy: NOT INSTALLED - {e}")
sys.exit(1)
try:
import scipy
print(f"SciPy: {scipy.__version__}")
except ImportError as e:
print(f"SciPy: NOT INSTALLED - {e}")
sys.exit(1)
try:
from PIL import Image
import PIL
print(f"Pillow: {PIL.__version__}")
except ImportError as e:
print(f"Pillow: NOT INSTALLED - {e}")
sys.exit(1)
# Step 2: Check which DCT module we're using
print("\n[2] DCT MODULE CHECK")
try:
from scipy.fft import dct, idct
print("Using: scipy.fft (preferred)")
DCT_MODULE = "scipy.fft"
except ImportError:
try:
from scipy.fftpack import dct, idct
print("Using: scipy.fftpack (legacy)")
DCT_MODULE = "scipy.fftpack"
except ImportError:
print("ERROR: No DCT module available!")
sys.exit(1)
# Step 3: Test basic DCT on small array
print("\n[3] BASIC DCT TEST (8x8 block)")
try:
test_block = np.random.rand(8, 8).astype(np.float64)
# 1D DCT on rows
result = dct(test_block[0, :], norm='ortho')
print(f" 1D DCT: OK (output shape: {result.shape})")
# 1D IDCT
recovered = idct(result, norm='ortho')
error = np.max(np.abs(test_block[0, :] - recovered))
print(f" 1D IDCT: OK (roundtrip error: {error:.2e})")
# 2D via separable
temp = np.zeros_like(test_block)
for i in range(8):
temp[:, i] = dct(test_block[:, i], norm='ortho')
result2d = np.zeros_like(temp)
for i in range(8):
result2d[i, :] = dct(temp[i, :], norm='ortho')
print(f" 2D DCT: OK")
gc.collect()
print(" GC after basic test: OK")
except Exception as e:
print(f" FAILED: {e}")
traceback.print_exc()
# Step 4: Test with larger arrays (stress test)
print("\n[4] STRESS TEST (many 8x8 blocks)")
try:
NUM_BLOCKS = 10000
print(f" Processing {NUM_BLOCKS} blocks...")
for i in range(NUM_BLOCKS):
block = np.random.rand(8, 8).astype(np.float64)
# Forward DCT
temp = np.zeros_like(block)
for j in range(8):
temp[:, j] = dct(block[:, j], norm='ortho')
result = np.zeros_like(temp)
for j in range(8):
result[j, :] = dct(temp[j, :], norm='ortho')
# Inverse DCT
temp2 = np.zeros_like(result)
for j in range(8):
temp2[j, :] = idct(result[j, :], norm='ortho')
recovered = np.zeros_like(temp2)
for j in range(8):
recovered[:, j] = idct(temp2[:, j], norm='ortho')
if i % 1000 == 0:
gc.collect()
print(f" {i}/{NUM_BLOCKS} blocks processed...")
gc.collect()
print(f" Stress test PASSED")
except Exception as e:
print(f" FAILED at block {i}: {e}")
traceback.print_exc()
# Step 5: Test with actual image if provided
if len(sys.argv) > 1:
image_path = sys.argv[1]
print(f"\n[5] IMAGE TEST: {image_path}")
try:
with open(image_path, 'rb') as f:
image_data = f.read()
print(f" File size: {len(image_data) / 1024 / 1024:.2f} MB")
img = Image.open(io.BytesIO(image_data))
width, height = img.size
print(f" Dimensions: {width}x{height}")
print(f" Format: {img.format}")
print(f" Mode: {img.mode}")
# Convert to grayscale float array
gray = img.convert('L')
arr = np.array(gray, dtype=np.float64)
img.close()
gray.close()
print(f" Array shape: {arr.shape}")
print(f" Array dtype: {arr.dtype}")
# Pad to block boundary
BLOCK_SIZE = 8
h, w = arr.shape
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
if new_h != h or new_w != w:
padded = np.zeros((new_h, new_w), dtype=np.float64)
padded[:h, :w] = arr
arr = padded
print(f" Padded to: {arr.shape}")
blocks_y = arr.shape[0] // BLOCK_SIZE
blocks_x = arr.shape[1] // BLOCK_SIZE
total_blocks = blocks_y * blocks_x
print(f" Total 8x8 blocks: {total_blocks}")
# Process ALL blocks
print(f" Processing all blocks with DCT...")
processed = 0
for by in range(blocks_y):
for bx in range(blocks_x):
y = by * BLOCK_SIZE
x = bx * BLOCK_SIZE
block = arr[y:y+BLOCK_SIZE, x:x+BLOCK_SIZE].copy()
# Forward DCT
temp = np.zeros((8, 8), dtype=np.float64)
for i in range(8):
temp[:, i] = dct(block[:, i], norm='ortho')
dct_block = np.zeros((8, 8), dtype=np.float64)
for i in range(8):
dct_block[i, :] = dct(temp[i, :], norm='ortho')
# Inverse DCT
temp2 = np.zeros((8, 8), dtype=np.float64)
for i in range(8):
temp2[i, :] = idct(dct_block[i, :], norm='ortho')
recovered = np.zeros((8, 8), dtype=np.float64)
for i in range(8):
recovered[:, i] = idct(temp2[:, i], norm='ortho')
processed += 1
# GC after each row of blocks
if by % 50 == 0:
gc.collect()
print(f" Row {by}/{blocks_y} ({processed}/{total_blocks} blocks)")
gc.collect()
print(f" Image DCT test PASSED ({processed} blocks)")
except Exception as e:
print(f" FAILED: {e}")
traceback.print_exc()
else:
print("\n[5] IMAGE TEST: Skipped (no image path provided)")
print(" Usage: python test_dct_crash.py /path/to/image.jpg")
# Step 6: Final cleanup test
print("\n[6] FINAL CLEANUP TEST")
try:
gc.collect()
gc.collect()
gc.collect()
print(" Multiple GC cycles: OK")
except Exception as e:
print(f" FAILED: {e}")
print("\n" + "=" * 60)
print("If this script completes without 'free(): invalid size',")
print("the issue is likely in PIL/jpegio interaction, not scipy DCT.")
print("=" * 60)
# Keep process alive briefly to catch delayed crashes
import time
print("\nWaiting 2 seconds for delayed crashes...")
time.sleep(2)
print("Done - no crash detected!")

View File

@@ -0,0 +1,528 @@
# Stegasoo v4.0.0 Release Checklist
## Overview
This checklist covers functionality testing for the v4.0.0 release.
### Changes in v4.0.0
| Change | v3.2.0 | v4.0.0 |
|--------|--------|--------|
| Python version | 3.10-3.12 | 3.10-3.12 (3.13 NOT supported) |
| JPEG handling | Could crash on quality=100 | Normalized before jpegio |
| Header size | 65 bytes | 65 bytes (unchanged) |
| API | passphrase, no date_str | Same (no breaking changes) |
| Format version | 4 | 4 (compatible with v3.2.0) |
### Key Points
- **No breaking API changes from v3.2.0**
- **v4.0 CAN decode v3.2.0 images** (same format version)
- **v4.0 CANNOT decode v3.1.x or earlier images**
- **Python 3.13 is NOT supported** (jpegio C extension ABI incompatibility)
---
## 1. Pre-Release Checks
### 1.1 Python Version
```bash
python --version # Must be 3.10, 3.11, or 3.12
```
- [ ] Python version is 3.10, 3.11, or 3.12
- [ ] NOT Python 3.13 (jpegio will crash)
### 1.2 Dependencies
```bash
pip list | grep -E "jpegio|scipy|pillow|argon2"
```
- [ ] jpegio installed (for DCT JPEG support)
- [ ] scipy installed (for DCT mode)
- [ ] pillow installed
- [ ] argon2-cffi installed
---
## 2. Core Library Tests
### 2.1 Run Unit Tests
```bash
cd /path/to/stegasoo
pytest tests/ -v
```
- [ ] All tests pass
- [ ] No deprecation warnings for removed parameters
### 2.2 JPEG Normalization Test (NEW in v4.0)
```bash
python -c "
from PIL import Image
import io
from stegasoo import encode, decode
# Create quality=100 JPEG (triggers normalization)
img = Image.new('RGB', (400, 400), 'red')
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=100)
jpeg_data = buf.getvalue()
# This should NOT crash (v3.2.0 would crash here)
result = encode(
message='Test quality 100',
reference_photo=jpeg_data,
carrier_image=jpeg_data,
passphrase='test phrase four words',
pin='123456',
embed_mode='dct'
)
print('✓ Quality=100 JPEG encode OK')
decoded = decode(
stego_image=result.stego_image,
reference_photo=jpeg_data,
passphrase='test phrase four words',
pin='123456'
)
assert decoded.message == 'Test quality 100'
print('✓ Quality=100 JPEG decode OK')
"
```
- [ ] Quality=100 JPEG encoding works (no crash)
- [ ] Quality=100 JPEG decoding works
### 2.3 Large Image Test (NEW in v4.0)
```bash
python -c "
from PIL import Image
import io
from stegasoo import encode, decode
# Create large image (similar to 14MB real photo)
img = Image.new('RGB', (4000, 3000), 'blue')
buf = io.BytesIO()
img.save(buf, format='PNG')
large_image = buf.getvalue()
print(f'Test image size: {len(large_image) / 1024 / 1024:.1f} MB')
result = encode(
message='Large image test',
reference_photo=large_image,
carrier_image=large_image,
passphrase='large image test phrase',
pin='123456'
)
print('✓ Large image encode OK')
decoded = decode(
stego_image=result.stego_image,
reference_photo=large_image,
passphrase='large image test phrase',
pin='123456'
)
assert decoded.message == 'Large image test'
print('✓ Large image decode OK')
"
```
- [ ] Large image (12MP+) encoding works
- [ ] Large image decoding works
---
## 3. Docker Build Tests
### 3.1 Base Image Build
```bash
# Build base image (one-time, 5-10 min)
sudo docker build -f Dockerfile.base -t stegasoo-base:latest .
```
- [ ] Base image builds successfully
- [ ] jpegio + scipy + numpy verification passes
### 3.2 Application Build
```bash
# Fast build using base image
sudo docker-compose build
```
- [ ] Web container builds
- [ ] API container builds
### 3.3 Container Startup
```bash
sudo docker-compose up -d
sudo docker-compose logs
```
- [ ] Web container starts without errors
- [ ] API container starts without errors
- [ ] No import errors in logs
---
## 4. Web UI Tests (`http://localhost:5000`)
### 4.1 Home Page
- [ ] v4.0 badge visible
- [ ] "Learn More" button is white/visible
- [ ] No references to "day phrase" or dates
### 4.2 Generate Page (`/generate`)
- [ ] Default is 4 words
- [ ] Single passphrase generated (not 7 daily)
- [ ] PIN toggle shows/hides digits
- [ ] Memory aid generator works
### 4.3 Encode Page (`/encode`)
- [ ] Passphrase field has blue glow on focus
- [ ] PIN field has orange glow on focus
- [ ] PIN box is 180px wide (fits LastPass icon)
- [ ] Passphrase font shrinks for long input (stepped)
- [ ] RSA .pem/QR toggle works
- [ ] QR image preview shows when selected
- [ ] DCT mode options appear when selected
- [ ] Encoding works (LSB mode)
- [ ] Encoding works (DCT mode)
### 4.4 Decode Page (`/decode`)
- [ ] Same styling as encode (glowing inputs)
- [ ] RSA .pem/QR toggle works (matches encode layout)
- [ ] QR image preview shows when selected
- [ ] Copy button is below message (not overlapping)
- [ ] Decoding works (LSB mode)
- [ ] Decoding works (DCT mode)
- [ ] Auto mode detection works
### 4.5 About Page (`/about`)
- [ ] Version history table present
- [ ] v4.0.0 entry in table
- [ ] Python 3.10-3.12 requirement noted
- [ ] No marketing language ("military-grade" removed)
---
## 5. API Tests (`http://localhost:8000`)
### 5.1 Status Endpoint
```bash
curl http://localhost:8000/
```
- [ ] Returns version "4.0.0"
- [ ] No import errors
### 5.2 Generate Endpoint
```bash
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{"use_pin": true}'
```
- [ ] Returns single `passphrase` string
- [ ] Returns 4 words by default
### 5.3 OpenAPI Docs
- [ ] `/docs` loads (Swagger UI)
- [ ] `/redoc` loads (ReDoc)
- [ ] All endpoints documented
---
## 6. CLI Tests
### 6.1 Version
```bash
stegasoo --version
```
- [ ] Shows 4.0.0
### 6.2 Generate
```bash
stegasoo generate --pin --words 4
```
- [ ] Single passphrase output
- [ ] 4 words generated
### 6.3 Encode/Decode Roundtrip
```bash
# Generate test image
python -c "from PIL import Image; Image.new('RGB', (200,200), 'red').save('/tmp/test.png')"
# Encode
stegasoo encode \
-r /tmp/test.png \
-c /tmp/test.png \
-p "cli test phrase here" \
--pin 123456 \
-m "CLI roundtrip test" \
-o /tmp/stego.png
# Decode
stegasoo decode \
-r /tmp/test.png \
-s /tmp/stego.png \
-p "cli test phrase here" \
--pin 123456
```
- [ ] Encode succeeds
- [ ] Decode returns correct message
---
## 7. Cross-Version Compatibility
### 7.1 v3.2.0 Compatibility
- [ ] v4.0 can decode v3.2.0 images (same format version 4)
### 7.2 v3.1.x Incompatibility
- [ ] v4.0 fails gracefully on v3.1.x images
- [ ] Error message is clear
---
## 8. Documentation Review
### 8.1 Updated Files
- [ ] README.md - v4.0 references
- [ ] INSTALL.md - Python 3.13 warning prominent
- [ ] SECURITY.md - v4.0 changes documented
- [ ] UNDER_THE_HOOD.md - JPEG normalization section
### 8.2 Template Updates
- [ ] All 7 templates updated
- [ ] No v3.x badges remaining
- [ ] Version history in About page
---
## 9. Quick Smoke Test Script
```bash
#!/bin/bash
# v4.0.0 Smoke Test
set -e
echo "=== Stegasoo v4.0.0 Smoke Test ==="
# Check version
echo "1. Checking version..."
python -c "import stegasoo; assert stegasoo.__version__.startswith('4.'), f'Wrong version: {stegasoo.__version__}'; print(f'✓ Version: {stegasoo.__version__}')"
# Check Python version
echo "2. Checking Python version..."
python -c "
import sys
v = sys.version_info
assert v.major == 3 and 10 <= v.minor <= 12, f'Python {v.major}.{v.minor} not supported'
print(f'✓ Python {v.major}.{v.minor}.{v.micro}')
"
# Check DCT support
echo "3. Checking DCT support..."
python -c "
from stegasoo import has_dct_support
from stegasoo.dct_steganography import has_jpegio_support
print(f' DCT (scipy): {has_dct_support()}')
print(f' JPEG native (jpegio): {has_jpegio_support()}')
assert has_dct_support(), 'DCT not available'
print('✓ DCT support OK')
"
# Test encode/decode roundtrip
echo "4. Testing encode/decode roundtrip..."
python -c "
from stegasoo import encode, decode
from PIL import Image
import io
img = Image.new('RGB', (200, 200), color='blue')
buf = io.BytesIO()
img.save(buf, format='PNG')
test_image = buf.getvalue()
result = encode(
message='Hello v4.0.0!',
reference_photo=test_image,
carrier_image=test_image,
passphrase='test phrase four words',
pin='123456'
)
decoded = decode(
stego_image=result.stego_image,
reference_photo=test_image,
passphrase='test phrase four words',
pin='123456'
)
assert decoded.message == 'Hello v4.0.0!', f'Got: {decoded.message}'
print('✓ LSB roundtrip OK')
"
# Test DCT mode
echo "5. Testing DCT mode..."
python -c "
from stegasoo import encode, decode
from PIL import Image
import io
img = Image.new('RGB', (400, 400), color='green')
buf = io.BytesIO()
img.save(buf, format='PNG')
test_image = buf.getvalue()
result = encode(
message='DCT v4.0 test',
reference_photo=test_image,
carrier_image=test_image,
passphrase='dct test phrase here',
pin='123456',
embed_mode='dct'
)
decoded = decode(
stego_image=result.stego_image,
reference_photo=test_image,
passphrase='dct test phrase here',
pin='123456'
)
assert decoded.message == 'DCT v4.0 test'
print('✓ DCT roundtrip OK')
"
# Test JPEG quality=100 (v4.0 fix)
echo "6. Testing JPEG quality=100 handling..."
python -c "
from stegasoo import encode, decode
from PIL import Image
import io
img = Image.new('RGB', (400, 400), color='red')
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=100)
jpeg_q100 = buf.getvalue()
result = encode(
message='Quality 100 test',
reference_photo=jpeg_q100,
carrier_image=jpeg_q100,
passphrase='jpeg quality test here',
pin='123456',
embed_mode='dct'
)
decoded = decode(
stego_image=result.stego_image,
reference_photo=jpeg_q100,
passphrase='jpeg quality test here',
pin='123456'
)
assert decoded.message == 'Quality 100 test'
print('✓ JPEG quality=100 OK (v4.0 fix working)')
"
echo ""
echo "=== All smoke tests passed! ==="
echo "Ready for release."
```
---
## 10. Release Steps
### 10.1 Final Checks
- [ ] All tests pass
- [ ] All Docker containers work
- [ ] Documentation updated
- [ ] Version bumped in `constants.py` and `pyproject.toml`
### 10.2 Git
```bash
git add -A
git status # Review changes
git commit -m "v4.0.0: JPEG normalization, Python 3.12, UI polish"
git tag v4.0.0
git push origin main --tags
```
- [ ] Changes committed
- [ ] Tag created
- [ ] Pushed to remote
### 10.3 Release Notes
```markdown
## v4.0.0
### What's New
- **JPEG Normalization**: Quality=100 JPEGs now work with DCT mode
- **Python 3.12**: Recommended version (3.13 NOT supported due to jpegio)
- **UI Polish**: Glowing input fields, better layout, version history
### Fixes
- Fixed jpegio crash on quality=100 JPEG images
- Fixed QR code input on decode page
- Fixed passphrase font sizing (stepped instead of smooth)
### Breaking Changes
- Python 3.13 is NOT supported
### Compatibility
- v4.0 can decode v3.2.0 images (same format)
- v4.0 CANNOT decode v3.1.x or earlier
```
---
## Sign-Off
| Area | Tested By | Date | Status |
|------|-----------|------|--------|
| Python/Dependencies | | | ☐ |
| Unit Tests | | | ☐ |
| Docker Build | | | ☐ |
| Web UI | | | ☐ |
| API | | | ☐ |
| CLI | | | ☐ |
| Documentation | | | ☐ |
**Release Approved:**
**Released By:** _________________
**Release Date:** _________________

434
tests/test_batch.py Normal file
View File

@@ -0,0 +1,434 @@
"""
Tests for Stegasoo batch processing module (v4.0.0).
Updated for v4.0.0:
- Uses 'passphrase' instead of 'phrase' in credentials dict
- No date_str parameter
- BatchCredentials.passphrase is a single string
"""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import Mock
import pytest
from stegasoo.batch import (
BatchCredentials,
BatchItem,
BatchProcessor,
BatchResult,
BatchStatus,
batch_capacity_check,
print_batch_result,
)
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
path = Path(tempfile.mkdtemp())
yield path
shutil.rmtree(path)
@pytest.fixture
def sample_images(temp_dir):
"""Create sample PNG images for testing."""
from PIL import Image
images = []
for i in range(3):
img_path = temp_dir / f"test_image_{i}.png"
img = Image.new("RGB", (100, 100), color=(i * 50, i * 50, i * 50))
img.save(img_path, "PNG")
images.append(img_path)
return images
@pytest.fixture
def sample_reference_photo():
"""Create a sample reference photo as bytes."""
from io import BytesIO
from PIL import Image
img = Image.new("RGB", (100, 100), color=(128, 128, 128))
buf = BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
@pytest.fixture
def sample_credentials(sample_reference_photo):
"""Create sample v3.2.0 credentials dict."""
return {
"reference_photo": sample_reference_photo,
"passphrase": "test phrase four words", # v3.2.0: single passphrase
"pin": "123456",
}
class TestBatchItem:
"""Tests for BatchItem dataclass."""
def test_duration_calculation(self):
"""Duration should be calculated from start/end times."""
item = BatchItem(input_path=Path("test.png"))
item.start_time = 100.0
item.end_time = 105.5
assert item.duration == 5.5
def test_duration_none_without_times(self):
"""Duration should be None if times not set."""
item = BatchItem(input_path=Path("test.png"))
assert item.duration is None
def test_to_dict(self):
"""to_dict should serialize all fields."""
item = BatchItem(
input_path=Path("input.png"),
output_path=Path("output.png"),
status=BatchStatus.SUCCESS,
message="Done",
)
result = item.to_dict()
assert result["input_path"] == "input.png"
assert result["output_path"] == "output.png"
assert result["status"] == "success"
class TestBatchResult:
"""Tests for BatchResult dataclass."""
def test_to_json(self):
"""Should serialize to valid JSON."""
import json
result = BatchResult(operation="encode", total=5, succeeded=4, failed=1)
json_str = result.to_json()
parsed = json.loads(json_str)
assert parsed["operation"] == "encode"
assert parsed["summary"]["total"] == 5
def test_duration_with_end_time(self):
"""Duration should work when end_time is set."""
result = BatchResult(operation="test")
result.start_time = 100.0
result.end_time = 110.0
assert result.duration == 10.0
class TestBatchCredentials:
"""Tests for BatchCredentials dataclass (v3.2.0)."""
def test_from_dict_new_format(self, sample_reference_photo):
"""Should parse v3.2.0 format with 'passphrase' key."""
data = {
"reference_photo": sample_reference_photo,
"passphrase": "test phrase four words",
"pin": "123456",
}
creds = BatchCredentials.from_dict(data)
assert creds.passphrase == "test phrase four words"
assert creds.pin == "123456"
def test_from_dict_legacy_format(self, sample_reference_photo):
"""Should parse legacy format with 'day_phrase' key for migration."""
data = {
"reference_photo": sample_reference_photo,
"day_phrase": "legacy phrase here", # Old key name
"pin": "123456",
}
creds = BatchCredentials.from_dict(data)
# Should accept old key and map to passphrase
assert creds.passphrase == "legacy phrase here"
assert creds.pin == "123456"
def test_to_dict(self, sample_reference_photo):
"""Should serialize to v3.2.0 format."""
creds = BatchCredentials(
reference_photo=sample_reference_photo,
passphrase="test phrase four words",
pin="123456",
)
result = creds.to_dict()
assert result["passphrase"] == "test phrase four words"
assert result["pin"] == "123456"
assert "day_phrase" not in result # Old key should not be present
def test_passphrase_is_string(self, sample_reference_photo):
"""Passphrase should be a string, not a dict."""
creds = BatchCredentials(
reference_photo=sample_reference_photo,
passphrase="test phrase four words",
pin="123456",
)
assert isinstance(creds.passphrase, str)
class TestBatchProcessor:
"""Tests for BatchProcessor class."""
def test_init_default_workers(self):
"""Should default to 4 workers."""
processor = BatchProcessor()
assert processor.max_workers == 4
def test_init_custom_workers(self):
"""Should accept custom worker count."""
processor = BatchProcessor(max_workers=8)
assert processor.max_workers == 8
def test_is_valid_image_png(self, temp_dir):
"""Should recognize PNG as valid."""
processor = BatchProcessor()
png_path = temp_dir / "test.png"
png_path.touch()
assert processor._is_valid_image(png_path)
def test_is_valid_image_txt(self, temp_dir):
"""Should reject non-image files."""
processor = BatchProcessor()
txt_path = temp_dir / "test.txt"
txt_path.touch()
assert not processor._is_valid_image(txt_path)
def test_find_images_file(self, sample_images):
"""Should find single image file."""
processor = BatchProcessor()
results = list(processor.find_images([sample_images[0]]))
assert len(results) == 1
assert results[0] == sample_images[0]
def test_find_images_directory(self, sample_images, temp_dir):
"""Should find images in directory."""
processor = BatchProcessor()
results = list(processor.find_images([temp_dir]))
assert len(results) == 3
def test_find_images_recursive(self, temp_dir):
"""Should find images recursively."""
from PIL import Image
# Create nested directory
nested = temp_dir / "nested"
nested.mkdir()
img_path = nested / "nested.png"
img = Image.new("RGB", (50, 50))
img.save(img_path)
processor = BatchProcessor()
results = list(processor.find_images([temp_dir], recursive=True))
assert any(p.name == "nested.png" for p in results)
def test_batch_encode_requires_message_or_file(self, sample_images, sample_credentials):
"""Should raise if neither message nor file provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="message or file_payload"):
processor.batch_encode(
images=sample_images,
credentials=sample_credentials,
)
def test_batch_encode_requires_credentials(self, sample_images):
"""Should raise if credentials not provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="Credentials"):
processor.batch_encode(
images=sample_images,
message="test",
)
def test_batch_encode_accepts_passphrase_credentials(
self, sample_images, temp_dir, sample_credentials
):
"""Should accept v3.2.0 format credentials with passphrase."""
processor = BatchProcessor()
result = processor.batch_encode(
images=sample_images,
message="Test message",
output_dir=temp_dir / "output",
credentials=sample_credentials, # Uses 'passphrase' key
)
assert isinstance(result, BatchResult)
assert result.operation == "encode"
assert result.total == 3
def test_batch_encode_creates_result(self, sample_images, temp_dir, sample_credentials):
"""Should return BatchResult with correct structure."""
processor = BatchProcessor()
result = processor.batch_encode(
images=sample_images,
message="Test message",
output_dir=temp_dir / "output",
credentials=sample_credentials,
)
assert isinstance(result, BatchResult)
assert result.operation == "encode"
assert result.total == 3
assert len(result.items) == 3
def test_batch_decode_requires_credentials(self, sample_images):
"""Should raise if credentials not provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="Credentials"):
processor.batch_decode(images=sample_images)
def test_batch_decode_accepts_passphrase_credentials(self, sample_images, sample_credentials):
"""Should accept v3.2.0 format credentials with passphrase."""
processor = BatchProcessor()
result = processor.batch_decode(
images=sample_images,
credentials=sample_credentials, # Uses 'passphrase' key
)
assert isinstance(result, BatchResult)
assert result.operation == "decode"
assert result.total == 3
def test_batch_decode_creates_result(self, sample_images, sample_credentials):
"""Should return BatchResult with correct structure."""
processor = BatchProcessor()
result = processor.batch_decode(
images=sample_images,
credentials=sample_credentials,
)
assert isinstance(result, BatchResult)
assert result.operation == "decode"
assert result.total == 3
def test_progress_callback_called(self, sample_images, sample_credentials):
"""Progress callback should be called for each item."""
processor = BatchProcessor()
callback = Mock()
processor.batch_encode(
images=sample_images,
message="Test",
credentials=sample_credentials,
progress_callback=callback,
)
assert callback.call_count == 3
def test_custom_encode_func(self, sample_images, temp_dir, sample_credentials):
"""Should use custom encode function if provided."""
processor = BatchProcessor()
encode_mock = Mock()
processor.batch_encode(
images=sample_images,
message="Test",
output_dir=temp_dir / "output",
credentials=sample_credentials,
encode_func=encode_mock,
)
assert encode_mock.call_count == 3
class TestBatchCapacityCheck:
"""Tests for batch_capacity_check function."""
def test_returns_list(self, sample_images):
"""Should return list of results."""
results = batch_capacity_check(sample_images)
assert isinstance(results, list)
assert len(results) == 3
def test_includes_capacity(self, sample_images):
"""Results should include capacity info."""
results = batch_capacity_check(sample_images)
for item in results:
assert "capacity_bytes" in item
assert "dimensions" in item
assert "valid" in item
def test_handles_invalid_files(self, temp_dir):
"""Should handle non-image files gracefully."""
bad_file = temp_dir / "not_an_image.png"
bad_file.write_bytes(b"not a png")
results = batch_capacity_check([bad_file])
assert len(results) == 1
assert "error" in results[0]
class TestPrintBatchResult:
"""Tests for print_batch_result function."""
def test_prints_summary(self, capsys, sample_images):
"""Should print summary without errors."""
result = BatchResult(
operation="encode",
total=3,
succeeded=2,
failed=1,
)
result.end_time = result.start_time + 5.0
print_batch_result(result)
captured = capsys.readouterr()
assert "ENCODE" in captured.out
assert "3" in captured.out # total
assert "2" in captured.out # succeeded
def test_verbose_shows_items(self, capsys):
"""Verbose mode should show individual items."""
result = BatchResult(operation="decode", total=1, succeeded=1)
result.items = [
BatchItem(
input_path=Path("test.png"),
status=BatchStatus.SUCCESS,
message="Decoded successfully",
)
]
result.end_time = result.start_time + 1.0
print_batch_result(result, verbose=True)
captured = capsys.readouterr()
assert "test.png" in captured.out
class TestCredentialsMigration:
"""Tests for v3.1.x to v3.2.0 credentials migration."""
def test_old_phrase_key_accepted(self, sample_reference_photo):
"""Old 'phrase' key should be accepted for migration."""
old_format = {
"reference_photo": sample_reference_photo,
"phrase": "old style phrase",
"pin": "123456",
}
# Should not raise
creds = BatchCredentials.from_dict(old_format)
assert creds.passphrase == "old style phrase"
def test_old_day_phrase_key_accepted(self, sample_reference_photo):
"""Old 'day_phrase' key should be accepted for migration."""
old_format = {
"reference_photo": sample_reference_photo,
"day_phrase": "old day phrase",
"pin": "123456",
}
creds = BatchCredentials.from_dict(old_format)
assert creds.passphrase == "old day phrase"
def test_new_passphrase_key_preferred(self, sample_reference_photo):
"""New 'passphrase' key should take precedence if both present."""
mixed_format = {
"reference_photo": sample_reference_photo,
"passphrase": "new style passphrase",
"day_phrase": "old day phrase",
"pin": "123456",
}
creds = BatchCredentials.from_dict(mixed_format)
assert creds.passphrase == "new style passphrase"

181
tests/test_compression.py Normal file
View File

@@ -0,0 +1,181 @@
"""
Tests for Stegasoo compression module.
"""
import pytest
from stegasoo.compression import (
COMPRESSION_MAGIC,
HAS_LZ4,
MIN_COMPRESS_SIZE,
CompressionAlgorithm,
CompressionError,
algorithm_name,
compress,
decompress,
estimate_compressed_size,
get_available_algorithms,
get_compression_ratio,
)
class TestCompress:
"""Tests for compress function."""
def test_compress_small_data_not_compressed(self):
"""Small data should not be compressed (overhead not worth it)."""
small_data = b"hello"
result = compress(small_data)
# Should have magic header but NONE algorithm
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.NONE
def test_compress_zlib_reduces_size(self):
"""Zlib should reduce size for compressible data."""
# Highly compressible data
data = b"A" * 1000
result = compress(data, CompressionAlgorithm.ZLIB)
assert len(result) < len(data)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.ZLIB
def test_compress_incompressible_data(self):
"""Incompressible data should be stored uncompressed."""
import os
# Random data doesn't compress well
data = os.urandom(500)
result = compress(data, CompressionAlgorithm.ZLIB)
# Should fall back to NONE if compression didn't help
assert result.startswith(COMPRESSION_MAGIC)
def test_compress_none_algorithm(self):
"""NONE algorithm should just wrap data."""
data = b"Test data" * 100
result = compress(data, CompressionAlgorithm.NONE)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.NONE
# Data should be after 9-byte header
assert result[9:] == data
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
def test_compress_lz4(self):
"""LZ4 compression should work if available."""
data = b"B" * 1000
result = compress(data, CompressionAlgorithm.LZ4)
assert len(result) < len(data)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.LZ4
class TestDecompress:
"""Tests for decompress function."""
def test_decompress_zlib(self):
"""Decompression should restore original data."""
original = b"Hello, World! " * 100
compressed = compress(original, CompressionAlgorithm.ZLIB)
result = decompress(compressed)
assert result == original
def test_decompress_none(self):
"""Uncompressed wrapped data should decompress correctly."""
original = b"Small data"
wrapped = compress(original, CompressionAlgorithm.NONE)
result = decompress(wrapped)
assert result == original
def test_decompress_no_magic(self):
"""Data without magic header should be returned as-is."""
data = b"Not compressed at all"
result = decompress(data)
assert result == data
def test_decompress_truncated_header(self):
"""Truncated header should raise CompressionError."""
bad_data = COMPRESSION_MAGIC + b"\x01" # Too short
with pytest.raises(CompressionError, match="Truncated"):
decompress(bad_data)
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
def test_decompress_lz4(self):
"""LZ4 decompression should work."""
original = b"LZ4 test data " * 100
compressed = compress(original, CompressionAlgorithm.LZ4)
result = decompress(compressed)
assert result == original
def test_roundtrip_large_data(self):
"""Large data should survive compress/decompress roundtrip."""
import os
original = os.urandom(50000)
compressed = compress(original)
result = decompress(compressed)
assert result == original
class TestUtilities:
"""Tests for utility functions."""
def test_compression_ratio_compressed(self):
"""Ratio should be < 1 for well-compressed data."""
original = b"X" * 1000
compressed = compress(original)
ratio = get_compression_ratio(original, compressed)
assert ratio < 1.0
def test_compression_ratio_empty(self):
"""Empty data should return ratio of 1.0."""
ratio = get_compression_ratio(b"", b"")
assert ratio == 1.0
def test_estimate_compressed_size_small(self):
"""Small data estimation should be accurate."""
data = b"Test " * 100
estimate = estimate_compressed_size(data)
actual = len(compress(data))
# Should be within 20% for small data
assert abs(estimate - actual) / actual < 0.2
def test_available_algorithms(self):
"""Should always include NONE and ZLIB."""
algos = get_available_algorithms()
assert CompressionAlgorithm.NONE in algos
assert CompressionAlgorithm.ZLIB in algos
def test_algorithm_name(self):
"""Algorithm names should be human-readable."""
assert "Zlib" in algorithm_name(CompressionAlgorithm.ZLIB)
assert "None" in algorithm_name(CompressionAlgorithm.NONE)
assert "LZ4" in algorithm_name(CompressionAlgorithm.LZ4)
class TestEdgeCases:
"""Edge case tests."""
def test_empty_data(self):
"""Empty data should be handled gracefully."""
result = compress(b"")
assert decompress(result) == b""
def test_exact_min_size(self):
"""Data at exactly MIN_COMPRESS_SIZE should be compressed."""
data = b"x" * MIN_COMPRESS_SIZE
result = compress(data, CompressionAlgorithm.ZLIB)
assert result.startswith(COMPRESSION_MAGIC)
assert decompress(result) == data
def test_binary_data(self):
"""Binary data with null bytes should work."""
data = b"\x00\x01\x02\x03" * 500
compressed = compress(data)
assert decompress(compressed) == data
def test_unicode_after_encoding(self):
"""UTF-8 encoded Unicode should compress correctly."""
text = "Hello, 世界! 🎉 " * 100
data = text.encode("utf-8")
compressed = compress(data)
result = decompress(compressed)
assert result.decode("utf-8") == text

View File

@@ -1,343 +1,776 @@
"""
Basic tests for Stegasoo library.
Stegasoo Tests (v4.0.0)
Tests for key generation, validation, encoding/decoding, output formats,
and channel key functionality.
Updated for v4.0.0:
- Same API as v3.2.0 (passphrase, no date_str)
- Channel key support for deployment/group isolation
- HEADER_OVERHEAD increased to 66 bytes (flags byte added)
- Python 3.12 recommended (3.13 not supported)
"""
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'))
from PIL import Image
import stegasoo
from stegasoo import (
generate_credentials,
generate_pin,
generate_phrase,
validate_pin,
validate_message,
encode,
decode,
DAY_NAMES,
decode_text,
encode,
generate_channel_key,
generate_credentials,
generate_passphrase,
generate_pin,
get_channel_fingerprint,
validate_channel_key,
validate_message,
validate_passphrase,
validate_pin,
)
from stegasoo.steganography import get_output_format, get_image_format
from stegasoo.steganography import get_output_format
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def png_image():
"""Create a test PNG image."""
img = Image.new("RGB", (100, 100), color="red")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def large_png_image():
"""Create a larger test PNG image for DCT mode."""
img = Image.new("RGB", (400, 400), color="blue")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def bmp_image():
"""Create a test BMP image."""
img = Image.new("RGB", (100, 100), color="blue")
buf = io.BytesIO()
img.save(buf, format="BMP")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def jpeg_image():
"""Create a test JPEG image."""
img = Image.new("RGB", (100, 100), color="green")
buf = io.BytesIO()
img.save(buf, format="JPEG")
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def gif_image():
"""Create a test GIF image."""
img = Image.new("RGB", (100, 100), color="yellow")
buf = io.BytesIO()
img.save(buf, format="GIF")
buf.seek(0)
return buf.getvalue()
# =============================================================================
# Key Generation Tests (v3.2.0 Updated)
# =============================================================================
class TestKeygen:
"""Test credential generation."""
"""Tests for key generation functions."""
def test_generate_pin_default(self):
"""Default PIN should be 6 digits, no leading zero."""
pin = generate_pin()
assert len(pin) == 6
assert pin.isdigit()
assert pin[0] != '0'
assert pin[0] != "0"
def test_generate_pin_lengths(self):
for length in range(6, 10):
"""PIN generation should work for all valid lengths."""
for length in [6, 7, 8, 9]:
pin = generate_pin(length)
assert len(pin) == length
assert pin.isdigit()
def test_generate_phrase_default(self):
phrase = generate_phrase()
def test_generate_passphrase_default(self):
"""Default passphrase should have 4 words (v3.2.0 change)."""
phrase = generate_passphrase()
words = phrase.split()
assert len(words) == 3
assert len(words) == 4 # Changed from 3 in v3.1.x
def test_generate_phrase_lengths(self):
for length in range(3, 13):
phrase = generate_phrase(length)
def test_generate_passphrase_custom_length(self):
"""Passphrase generation should work for custom lengths."""
for length in [3, 4, 5, 6, 8, 12]:
phrase = generate_passphrase(length)
words = phrase.split()
assert len(words) == length
def test_generate_credentials_pin_only(self):
"""PIN-only credentials should have single passphrase."""
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)
# v3.2.0: Single passphrase instead of 7 daily phrases
assert creds.passphrase is not None
assert isinstance(creds.passphrase, str)
assert " " in creds.passphrase # Should have multiple words
def test_generate_credentials_rsa_only(self):
"""RSA-only credentials should have single passphrase."""
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
assert creds.passphrase is not None
def test_generate_credentials_both(self):
"""Both PIN and RSA should work together."""
creds = generate_credentials(use_pin=True, use_rsa=True)
assert creds.pin is not None
assert creds.rsa_key_pem is not None
assert creds.passphrase is not None
def test_generate_credentials_neither_fails(self):
with pytest.raises(ValueError):
"""Generating with neither PIN nor RSA should fail."""
with pytest.raises((ValueError, AssertionError)):
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
def test_generate_credentials_custom_words(self):
"""Custom passphrase_words parameter should work."""
creds = generate_credentials(use_pin=True, passphrase_words=6)
words = creds.passphrase.split()
assert len(words) == 6
def test_generate_credentials_default_words(self):
"""Default should be 4 words (v3.2.0)."""
creds = generate_credentials(use_pin=True)
words = creds.passphrase.split()
assert len(words) == 4
def test_passphrase_entropy_calculation(self):
"""Passphrase entropy should be calculated correctly."""
creds = generate_credentials(use_pin=True, passphrase_words=4)
# 4 words × 11 bits/word = 44 bits
assert creds.passphrase_entropy == 44
def test_total_entropy_calculation(self):
"""Total entropy should sum all components."""
creds = generate_credentials(use_pin=True, use_rsa=False, passphrase_words=4)
# 44 bits (passphrase) + ~20 bits (PIN)
assert creds.total_entropy > 0
assert creds.total_entropy >= creds.passphrase_entropy
# =============================================================================
# Validation Tests (v3.2.0 Updated)
# =============================================================================
class TestValidation:
"""Test input validation."""
"""Tests for validation functions."""
def test_validate_pin_valid(self):
"""Valid PIN should pass validation."""
result = validate_pin("123456")
assert result.is_valid
def test_validate_pin_empty_ok(self):
"""Empty PIN should be valid (RSA key might be used instead)."""
result = validate_pin("")
assert result.is_valid
def test_validate_pin_too_short(self):
"""PIN shorter than 6 digits should fail."""
result = validate_pin("12345")
assert not result.is_valid
assert "6-9" in result.error_message
def test_validate_pin_too_long(self):
"""PIN longer than 9 digits should fail."""
result = validate_pin("1234567890")
assert not result.is_valid
def test_validate_pin_leading_zero(self):
"""PIN with leading zero should fail."""
result = validate_pin("012345")
assert not result.is_valid
assert "zero" in result.error_message.lower()
def test_validate_pin_non_digits(self):
"""PIN with non-digit characters should fail."""
result = validate_pin("12345a")
assert not result.is_valid
def test_validate_message_valid(self):
result = validate_message("Hello, world!")
"""Valid message should pass validation."""
result = validate_message("Hello, World!")
assert result.is_valid
def test_validate_message_empty(self):
"""Empty message should fail validation."""
result = validate_message("")
assert not result.is_valid
def test_validate_message_too_long(self):
result = validate_message("x" * 60000)
def test_validate_passphrase_valid(self):
"""Valid passphrase should pass validation."""
result = validate_passphrase("word1 word2 word3 word4")
assert result.is_valid
def test_validate_passphrase_empty(self):
"""Empty passphrase should fail validation."""
result = validate_passphrase("")
assert not result.is_valid
def test_validate_passphrase_short_warning(self):
"""Short passphrase should have warning but still be valid."""
result = validate_passphrase("word1 word2 word3") # Only 3 words
assert result.is_valid
assert result.warning is not None # Should warn about short passphrase
def test_validate_passphrase_recommended_no_warning(self):
"""Recommended length passphrase should have no warning."""
result = validate_passphrase("word1 word2 word3 word4") # 4 words
assert result.is_valid
# May or may not have warning depending on implementation
# =============================================================================
# Output Format Tests
# =============================================================================
class TestOutputFormat:
"""Test output format detection and preservation."""
"""Tests for output format handling."""
def test_png_stays_png(self):
fmt, ext = get_output_format('PNG')
assert fmt == 'PNG'
assert ext == 'png'
"""PNG input should produce PNG output."""
fmt, ext = get_output_format("PNG")
assert fmt == "PNG"
assert ext == "png"
def test_bmp_stays_bmp(self):
fmt, ext = get_output_format('BMP')
assert fmt == 'BMP'
assert ext == 'bmp'
"""BMP input should produce BMP output."""
fmt, ext = get_output_format("BMP")
assert fmt == "BMP"
assert ext == "bmp"
def test_jpeg_becomes_png(self):
fmt, ext = get_output_format('JPEG')
assert fmt == 'PNG'
assert ext == 'png'
"""JPEG input should produce PNG output (lossless)."""
fmt, ext = get_output_format("JPEG")
assert fmt == "PNG"
assert ext == "png"
def test_gif_becomes_png(self):
fmt, ext = get_output_format('GIF')
assert fmt == 'PNG'
assert ext == 'png'
"""GIF input should produce PNG output."""
fmt, ext = get_output_format("GIF")
assert fmt == "PNG"
assert ext == "png"
def test_none_becomes_png(self):
"""None format should default to PNG."""
fmt, ext = get_output_format(None)
assert fmt == 'PNG'
assert ext == 'png'
assert fmt == "PNG"
assert ext == "png"
def test_unknown_becomes_png(self):
fmt, ext = get_output_format('WEBP')
assert fmt == 'PNG'
assert ext == 'png'
"""Unknown format should default to PNG."""
fmt, ext = get_output_format("UNKNOWN")
assert fmt == "PNG"
assert ext == "png"
# =============================================================================
# Header Overhead Test (v4.0.0)
# =============================================================================
class TestConstants:
"""Tests for constants and configuration."""
def test_header_overhead_value(self):
"""Header overhead should be 66 bytes (v4.0.0: added flags byte)."""
from stegasoo.steganography import HEADER_OVERHEAD
assert HEADER_OVERHEAD == 66
# =============================================================================
# Encode/Decode Tests (v4.0.0 Updated)
# =============================================================================
class TestEncodeDecode:
"""Test encoding and decoding (requires test images)."""
@pytest.fixture
def png_image(self):
"""Create a simple PNG 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()
@pytest.fixture
def bmp_image(self):
"""Create a simple BMP test image."""
from PIL import Image
img = Image.new('RGB', (100, 100), color='blue')
buf = io.BytesIO()
img.save(buf, format='BMP')
return buf.getvalue()
@pytest.fixture
def jpeg_image(self):
"""Create a simple JPEG test image."""
from PIL import Image
img = Image.new('RGB', (100, 100), color='green')
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=95)
return buf.getvalue()
"""Tests for encoding and decoding functions."""
def test_encode_decode_roundtrip(self, png_image):
"""Test full encode/decode cycle."""
"""Full encode/decode cycle should work."""
message = "Secret message!"
phrase = "apple forest thunder"
passphrase = "apple forest thunder mountain" # 4 words
pin = "123456"
# v3.2.0: Use passphrase parameter, no date_str
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
day_phrase=phrase,
pin=pin
passphrase=passphrase,
pin=pin,
)
assert result.stego_image is not None
assert len(result.stego_image) > 0
assert result.filename.endswith('.png')
assert result.filename.endswith(".png")
# v3.2.0: Use passphrase parameter, no date_str
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
day_phrase=phrase,
pin=pin
passphrase=passphrase,
pin=pin,
)
assert decoded == message
assert decoded.message == message
def test_decode_text_roundtrip(self, png_image):
"""decode_text convenience function should work."""
message = "Secret message!"
passphrase = "apple forest thunder mountain"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
)
# decode_text returns string directly
decoded_text = decode_text(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded_text == message
def test_png_carrier_produces_png(self, png_image):
"""Test that PNG carrier produces PNG output."""
"""PNG carrier should produce PNG output."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
day_phrase="test phrase here",
pin="123456"
passphrase="test phrase here now",
pin="123456",
)
assert result.filename.endswith('.png')
# Verify actual format
output_format = get_image_format(result.stego_image)
assert output_format == 'PNG'
assert result.filename.endswith(".png")
def test_bmp_carrier_produces_bmp(self, bmp_image, png_image):
"""Test that BMP carrier produces BMP output."""
"""BMP carrier should produce BMP output."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=bmp_image,
day_phrase="test phrase here",
pin="123456"
passphrase="test phrase here now",
pin="123456",
)
assert result.filename.endswith('.bmp')
# Verify actual format
output_format = get_image_format(result.stego_image)
assert output_format == 'BMP'
assert result.filename.endswith(".bmp")
def test_jpeg_carrier_produces_png(self, jpeg_image, png_image):
"""Test that JPEG carrier produces PNG output (lossy -> lossless)."""
"""JPEG carrier should produce PNG output (lossless)."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=jpeg_image,
day_phrase="test phrase here",
pin="123456"
passphrase="test phrase here now",
pin="123456",
)
assert result.filename.endswith('.png')
# Verify actual format
output_format = get_image_format(result.stego_image)
assert output_format == 'PNG'
assert result.filename.endswith(".png")
def test_bmp_roundtrip(self, bmp_image, png_image):
"""Test full encode/decode cycle with BMP."""
"""Full encode/decode cycle with BMP should work."""
message = "BMP test message!"
phrase = "test phrase words"
passphrase = "test phrase words here"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=bmp_image,
day_phrase=phrase,
pin=pin
passphrase=passphrase,
pin=pin,
)
assert result.filename.endswith('.bmp')
assert result.filename.endswith(".bmp")
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
day_phrase=phrase,
pin=pin
passphrase=passphrase,
pin=pin,
)
assert decoded == message
assert decoded.message == message
def test_wrong_pin_fails(self, png_image):
"""Test that wrong PIN fails to decode."""
"""Wrong PIN should fail to decode."""
result = encode(
message="Secret",
reference_photo=png_image,
carrier_image=png_image,
day_phrase="test phrase here",
pin="123456"
passphrase="test phrase here now",
pin="123456",
)
with pytest.raises(stegasoo.DecryptionError):
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
day_phrase="test phrase here",
pin="654321" # Wrong PIN
passphrase="test phrase here now",
pin="654321", # Wrong PIN
)
def test_wrong_phrase_fails(self, png_image):
"""Test that wrong phrase fails to decode."""
def test_wrong_passphrase_fails(self, png_image):
"""Wrong passphrase should fail to decode."""
result = encode(
message="Secret",
reference_photo=png_image,
carrier_image=png_image,
day_phrase="correct phrase here",
pin="123456"
passphrase="correct phrase here now",
pin="123456",
)
with pytest.raises(stegasoo.DecryptionError):
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
day_phrase="wrong phrase here",
pin="123456"
passphrase="wrong phrase here now", # Wrong passphrase
pin="123456",
)
def test_unicode_message(self, png_image):
"""Unicode messages should encode/decode correctly."""
message = "Hello, 世界! 🎉 Émojis and ümlauts"
passphrase = "unicode test phrase here"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
)
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded.message == message
def test_filename_format(self, png_image):
"""Output filename should have random hex and date suffix."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
passphrase="test phrase here now",
pin="123456",
)
# Filename format: {random_hex}_{YYYYMMDD}.{ext}
# e.g., "a1b2c3d4_20251227.png"
import re
assert re.search(r"^[a-f0-9]{8}_\d{8}\.png$", result.filename)
# =============================================================================
# DCT Mode Tests (v3.2.0)
# =============================================================================
class TestDCTMode:
"""Tests for DCT steganography mode."""
@pytest.fixture
def skip_if_no_dct(self):
"""Skip test if DCT support not available."""
if not stegasoo.has_dct_support():
pytest.skip("DCT support not available (scipy not installed)")
def test_dct_encode_decode_roundtrip(self, large_png_image, skip_if_no_dct):
"""DCT mode encode/decode should work."""
message = "DCT test"
passphrase = "dct test phrase here"
pin = "123456"
result = encode(
message=message,
reference_photo=large_png_image,
carrier_image=large_png_image,
passphrase=passphrase,
pin=pin,
embed_mode="dct",
)
assert result.stego_image is not None
decoded = decode(
stego_image=result.stego_image,
reference_photo=large_png_image,
passphrase=passphrase,
pin=pin,
)
assert decoded.message == message
def test_dct_auto_detection(self, large_png_image, skip_if_no_dct):
"""Auto mode should detect DCT encoding."""
message = "Auto detect DCT"
passphrase = "auto detect test here"
pin = "123456"
result = encode(
message=message,
reference_photo=large_png_image,
carrier_image=large_png_image,
passphrase=passphrase,
pin=pin,
embed_mode="dct",
)
# Decode with auto mode (default)
decoded = decode(
stego_image=result.stego_image,
reference_photo=large_png_image,
passphrase=passphrase,
pin=pin,
embed_mode="auto",
)
assert decoded.message == message
# =============================================================================
# Version Tests
# =============================================================================
class TestVersion:
"""Test version information."""
"""Tests for version information."""
def test_version_exists(self):
assert hasattr(stegasoo, '__version__')
assert stegasoo.__version__ == "2.0.1"
"""Version string should exist and be valid."""
assert hasattr(stegasoo, "__version__")
parts = stegasoo.__version__.split(".")
assert len(parts) >= 2
assert all(p.isdigit() for p in parts[:2])
def test_day_names(self):
assert len(stegasoo.DAY_NAMES) == 7
assert stegasoo.DAY_NAMES[0] == 'Monday'
assert stegasoo.DAY_NAMES[6] == 'Sunday'
def test_version_is_4_0_0(self):
"""Version should be 4.0.0 or higher."""
parts = stegasoo.__version__.split(".")
major = int(parts[0])
assert major >= 4
if __name__ == '__main__':
pytest.main([__file__, '-v'])
# =============================================================================
# Backward Compatibility Tests
# =============================================================================
class TestBackwardCompatibility:
"""Tests for backward compatibility handling."""
def test_old_day_phrase_parameter_raises(self, png_image):
"""Using old day_phrase parameter should raise TypeError."""
with pytest.raises(TypeError):
encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
day_phrase="old style phrase", # Old parameter name
pin="123456",
)
def test_old_date_str_parameter_raises(self, png_image):
"""Using old date_str parameter should raise TypeError."""
with pytest.raises(TypeError):
encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
passphrase="test phrase here now",
pin="123456",
date_str="2025-01-01", # Removed parameter
)
# =============================================================================
# Channel Key Tests (v4.0.0)
# =============================================================================
class TestChannelKey:
"""Tests for channel key functionality (v4.0.0)."""
def test_generate_channel_key_format(self):
"""Generated channel key should have correct format."""
key = generate_channel_key()
# Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (8 groups of 4)
assert len(key) == 39
parts = key.split("-")
assert len(parts) == 8
for part in parts:
assert len(part) == 4
assert part.isalnum()
def test_validate_channel_key_valid(self):
"""Valid channel key should pass validation."""
key = generate_channel_key()
assert validate_channel_key(key)
def test_validate_channel_key_invalid(self):
"""Invalid channel key should fail validation."""
assert not validate_channel_key("")
assert not validate_channel_key("invalid")
assert not validate_channel_key("ABCD-1234") # Too short
def test_channel_fingerprint_format(self):
"""Channel fingerprint should mask middle sections."""
key = "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
fingerprint = get_channel_fingerprint(key)
assert fingerprint is not None
# First and last groups visible, middle masked
assert fingerprint.startswith("ABCD-")
assert fingerprint.endswith("-3456")
assert "••••" in fingerprint
def test_encode_decode_with_channel_key(self, png_image):
"""Encode/decode should work with explicit channel key."""
message = "Secret with channel key!"
passphrase = "apple forest thunder mountain"
pin = "123456"
channel_key = generate_channel_key()
# Encode with channel key
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key,
)
assert result.stego_image is not None
# Decode with same channel key
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key,
)
assert decoded.message == message
def test_decode_wrong_channel_key_fails(self, png_image):
"""Decoding with wrong channel key should fail."""
message = "Secret message"
passphrase = "apple forest thunder mountain"
pin = "123456"
channel_key1 = generate_channel_key()
channel_key2 = generate_channel_key()
# Encode with one channel key
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key1,
)
# Decode with different channel key should fail
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key2,
)
def test_encode_decode_public_mode(self, png_image):
"""Encode/decode should work without channel key (public mode)."""
message = "Public message!"
passphrase = "apple forest thunder mountain"
pin = "123456"
# Encode without channel key (explicit public mode)
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key="", # Explicit public mode
)
# Decode without channel key
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key="", # Explicit public mode
)
assert decoded.message == message
def test_channel_key_mismatch_public_vs_private(self, png_image):
"""Decoding public message with channel key should fail."""
message = "Public message"
passphrase = "apple forest thunder mountain"
pin = "123456"
# Encode without channel key (public)
result = encode(
message=message,
reference_photo=png_image,
carrier_image=png_image,
passphrase=passphrase,
pin=pin,
channel_key="", # Public mode
)
# Decode with channel key should fail
channel_key = generate_channel_key()
with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)):
decode(
stego_image=result.stego_image,
reference_photo=png_image,
passphrase=passphrase,
pin=pin,
channel_key=channel_key,
)

BIN
xx_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB