85 Commits

Author SHA1 Message Date
Aaron D. Lee
cf247d207f v4.0.2: Add Web UI authentication and optional HTTPS
Some checks failed
Release / test (push) Failing after 43s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
- Add single-admin login with SQLite3 user storage
- First-run setup wizard for admin account creation
- Account management page for password changes
- Optional HTTPS with auto-generated self-signed certificates
- Configurable via STEGASOO_AUTH_ENABLED, STEGASOO_HTTPS_ENABLED env vars
- UI improvements: larger QR previews, consistent panel styling
- Update docker-compose.yml with auth config and persistent volumes
- Update all documentation for v4.0.2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:00:47 -05:00
Aaron D. Lee
28d77957eb Bit of project management stuff. 2026-01-02 18:44:00 -05:00
Aaron D. Lee
89b4809489 Streamline README to focus on current features
Reduced from 433 to 123 lines by removing:
- Version history (now in CHANGELOG.md)
- Upgrade guides (now in CHANGELOG.md)
- Breaking changes sections
- Redundant examples
- Verbose project structure

README now focuses on: features, quick start, interfaces, security model.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:33:46 -05:00
Aaron D. Lee
79ab165b95 Add professional project structure and documentation
New files:
- LICENSE (MIT) - Required legal file
- CHANGELOG.md - Version history following Keep a Changelog
- CONTRIBUTING.md - Contributor guidelines
- CODE_OF_CONDUCT.md - Community standards
- .github/ISSUE_TEMPLATE/ - Bug report and feature request forms
- .github/PULL_REQUEST_TEMPLATE.md - PR checklist
- src/stegasoo/py.typed - PEP 561 type hint marker
- examples/ - Usage examples (basic, file embedding, channel keys)

Updated:
- README.md - Added CI status badges

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:23:08 -05:00
Aaron D. Lee
4194d6923a Remove backup files and add pattern to .gitignore
Deleted stale backup files:
- frontends/cli/main.py_old
- src/stegasoo/dct_steganography.py_old

Added gitignore patterns for common backup extensions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:13:04 -05:00
Aaron D. Lee
08e19a3bfd Remove obsolete debug/diagnostic scripts
Deleted one-off debugging scripts that are no longer needed:
- debug_jpegio.py - DCT/jpegio extraction debugger
- test_compare_capacity_flow.py - API flow crash diagnostic
- test_dct_crash.py - DCT crash diagnostic tool

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:10:29 -05:00
Aaron D. Lee
dea7472018 Remove build.sh from repo and update .gitignore
Dev convenience script should not be tracked.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:08:15 -05:00
Aaron D. Lee
e8863d15d7 Remove dev scripts from repo (already in .gitignore)
These are local dev convenience scripts that should not be tracked.
- quick_web.sh - Flask dev server launcher
- rbld_containers.sh - Docker rebuild helper

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:06:45 -05:00
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
Aaron D. Lee
d937a43c13 Fixed more info bugs, need to use more constants, lol. 2025-12-28 22:55:23 -05:00
Aaron D. Lee
1c9c51e016 More info tweaks on about and index. 2025-12-28 22:36:10 -05:00
Aaron D. Lee
749fa00639 Merge branch 'main' of github.com:adlee-was-taken/stegasoo 2025-12-28 22:23:39 -05:00
Aaron D. Lee
f12544fd7f About and front page overhauls, other crap. 2025-12-28 22:23:20 -05:00
94 changed files with 24120 additions and 6076 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.

98
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Run command '...'
2. Upload image '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
validations:
required: true
- type: dropdown
id: interface
attributes:
label: Interface
description: Which interface are you using?
options:
- CLI
- Web UI
- REST API
- Python Library
validations:
required: true
- type: input
id: version
attributes:
label: Stegasoo Version
description: Run `stegasoo --version` or check the web UI footer
placeholder: "4.0.1"
validations:
required: true
- type: input
id: python-version
attributes:
label: Python Version
description: Run `python --version`
placeholder: "3.11.0"
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
placeholder: "Ubuntu 22.04 / Windows 11 / macOS 14"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs
description: Paste any relevant error messages or tracebacks.
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, screenshots, or files here.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Security Vulnerability
url: https://github.com/adlee-was-taken/stegasoo/security/advisories/new
about: Report security vulnerabilities privately
- name: Documentation
url: https://github.com/adlee-was-taken/stegasoo#readme
about: Check the documentation before opening an issue

View File

@@ -0,0 +1,62 @@
name: Feature Request
description: Suggest a new feature or enhancement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please fill out the form below.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Describe it.
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like.
placeholder: I would like to be able to...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or features you've considered.
- type: dropdown
id: interface
attributes:
label: Affected Interface(s)
description: Which interface(s) would this feature affect?
multiple: true
options:
- CLI
- Web UI
- REST API
- Python Library
- Core Library
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to you?
options:
- Nice to have
- Would improve my workflow
- Critical for my use case
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context, mockups, or examples here.

46
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,46 @@
## Description
<!-- Describe your changes in detail -->
## Type of Change
<!-- Mark the relevant option with an 'x' -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] CI/CD or build changes
## Related Issues
<!-- Link any related issues here -->
Fixes #
## Testing
<!-- Describe how you tested your changes -->
- [ ] I have added tests that prove my fix/feature works
- [ ] Existing tests pass locally with my changes
- [ ] I have tested manually (describe below)
### Manual Testing Steps
<!-- If applicable, describe manual testing performed -->
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have updated the documentation accordingly
- [ ] I have updated CHANGELOG.md (if user-facing changes)
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published
## Screenshots
<!-- If applicable, add screenshots to help explain your changes -->

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

18
.gitignore vendored
View File

@@ -35,6 +35,12 @@ old_files/
*.swp
*.swo
# Backup files
*_old
*_old.*
*.bak
*.orig
# Testing
.pytest_cache/
.coverage
@@ -56,4 +62,14 @@ htmlcov/
*.spec
# Output test files.
*.png
test_data/*.png
# Dev scripts (local convenience scripts)
build.sh
rbld_containers.sh
quick_web.sh
project_stats.sh
# Web UI auth database and SSL certs
frontends/web/instance/
frontends/web/certs/

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.2)
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.2",
"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.2
<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

120
CHANGELOG.md Normal file
View File

@@ -0,0 +1,120 @@
# Changelog
All notable changes to Stegasoo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org).
## [4.0.2] - 2026-01-02
### Added
- **Web UI Authentication**: Single-admin login with SQLite3 user storage
- First-run setup wizard for admin account creation
- Account management page for password changes
- `@login_required` decorator protects encode/decode/generate routes
- Argon2id password hashing (lighter 64MB for fast login)
- **Optional HTTPS**: Auto-generated self-signed certificates for home network deployment
- Configurable via `STEGASOO_HTTPS_ENABLED` environment variable
- Certificates stored in `frontends/web/certs/`
- New environment variables: `STEGASOO_AUTH_ENABLED`, `STEGASOO_HTTPS_ENABLED`, `STEGASOO_HOSTNAME`
### Changed
- PIN entry column widened in encode/decode forms (col-md-4 → col-md-6)
- Channel options column narrowed (col-md-8 → col-md-6)
- QR preview panels enlarged for better text readability
- Consistent font sizing across all preview panel banners (0.7rem filename, 0.6rem data, 0.65rem badges)
### Fixed
- QR preview text too small to read in encode/decode templates
- Inconsistent label sizes between reference/carrier/stego panels
## [4.0.1] - 2025-01-02
### Fixed
- Fixed numpy binary incompatibility on Python 3.10 (jpegio/scipy)
- Fixed BatchCredentials test failures with missing `reference_photo` parameter
- Graceful handling when DCT dependencies have version mismatches
### Changed
- Applied `ruff` linter fixes across entire codebase (~400 issues)
- Applied `black` formatter to all Python files
- Modernized type hints: `Optional[X]``X | None`
- Updated ruff config to use `[tool.ruff.lint]` section
- Moved documentation files to repository root
### Removed
- Removed obsolete debug/diagnostic scripts
- Cleaned up backup files and dev scripts
## [4.0.0] - 2024-12-29
### Added
- Refreshed Web UI with modern, snazzy interface
- Improved user experience across all pages
### Changed
- Major version bump for breaking API changes
- Simplified passphrase handling (single passphrase instead of day-based)
- Removed date_str parameter from encoding
### Fixed
- Various bug fixes for Web UI
- CLI updates and improvements
## [3.2.0] - 2024-12-28
### Added
- Big revamp of the encoding system
- Home and about page improvements
- UNDER_THE_HOOD.md documentation
### Changed
- Renamed `phrase``passphrase` in API
- Updated Web UI styling
## [3.0.2] - 2024-12-27
### Added
- Full experimental DCT steganography support
- jpegio integration for better JPEG manipulation
- DCT/LSB mode selector in Web UI
## [3.0.0] - 2024-12-25
### Added
- DCT (Discrete Cosine Transform) steganography mode
- Support for JPEG carriers without quality loss
- Channel key feature for private messaging
### Changed
- Complete rewrite of steganography engine
- New hybrid authentication system
## [2.0.0] - 2024-12-20
### Added
- Web UI frontend
- REST API (FastAPI)
- Batch processing support
- RSA key authentication option
### Changed
- Migrated to hybrid photo + passphrase + PIN authentication
## [1.0.0] - 2024-12-15
### Added
- Initial release
- LSB steganography
- AES-256-GCM encryption
- CLI interface
- Basic PIN authentication
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0
[3.2.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.2...v3.2.0
[3.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.0...v3.0.2
[3.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v2.0.0...v3.0.0
[2.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v1.0.0...v2.0.0
[1.0.0]: https://github.com/adlee-was-taken/stegasoo/releases/tag/v1.0.0

759
CLI.md Normal file
View File

@@ -0,0 +1,759 @@
# Stegasoo CLI Documentation (v4.0.2)
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

54
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,54 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior:
* The use of sexualized language or imagery, and sexual attention or advances
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information without explicit permission
* Other conduct which could reasonably be considered inappropriate
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards
of acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainers. All complaints will be reviewed and
investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.0.

165
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,165 @@
# Contributing to Stegasoo
Thank you for your interest in contributing to Stegasoo! This document provides guidelines and information for contributors.
## Getting Started
### Prerequisites
- Python 3.10 or higher
- Git
- Docker (optional, for container testing)
### Development Setup
1. **Clone the repository**
```bash
git clone https://github.com/adlee-was-taken/stegasoo.git
cd stegasoo
```
2. **Create a virtual environment**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install development dependencies**
```bash
pip install -e ".[dev]"
```
4. **Install pre-commit hooks**
```bash
pre-commit install
```
## Development Workflow
### Code Style
We use the following tools to maintain code quality:
- **Black** - Code formatting (line length: 100)
- **Ruff** - Linting
- **MyPy** - Type checking
Run all checks before committing:
```bash
black src/ tests/ frontends/
ruff check src/ tests/ frontends/
mypy src/
```
### Running Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=stegasoo --cov-report=term-missing
# Run specific test file
pytest tests/test_stegasoo.py
```
### Type Hints
All new code should include type hints:
```python
def encode_message(
message: str,
carrier_image: bytes,
passphrase: str,
pin: str = "",
) -> EncodeResult:
...
```
## Making Changes
### Branch Naming
- `feature/description` - New features
- `fix/description` - Bug fixes
- `docs/description` - Documentation updates
- `refactor/description` - Code refactoring
### Commit Messages
Write clear, concise commit messages:
```
Add channel key validation for private messaging
- Implement validate_channel_key() function
- Add tests for valid/invalid key formats
- Update CLI to support --channel-key flag
```
### Pull Request Process
1. **Create a feature branch** from `main`
2. **Make your changes** with appropriate tests
3. **Ensure all checks pass** (tests, linting, formatting)
4. **Submit a PR** with a clear description
5. **Address review feedback** promptly
### PR Checklist
- [ ] Tests added/updated for changes
- [ ] Documentation updated if needed
- [ ] CHANGELOG.md updated for user-facing changes
- [ ] All CI checks passing
- [ ] No merge conflicts with `main`
## Project Structure
```
stegasoo/
├── src/stegasoo/ # Core library
│ ├── crypto.py # Encryption/decryption
│ ├── steganography.py # LSB embedding
│ ├── dct_steganography.py # DCT embedding
│ └── ...
├── frontends/
│ ├── cli/ # Command-line interface
│ ├── web/ # Flask web UI
│ └── api/ # FastAPI REST API
├── tests/ # Test suite
└── examples/ # Usage examples
```
## Reporting Issues
### Bug Reports
Please include:
- Python version and OS
- Stegasoo version (`stegasoo --version`)
- Minimal reproduction steps
- Expected vs actual behavior
- Error messages/tracebacks
### Feature Requests
Please include:
- Use case description
- Proposed solution (if any)
- Alternatives considered
## Security
If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines. **Do not open a public issue for security vulnerabilities.**
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
## Questions?
Feel free to open a discussion or issue if you have questions about contributing.
Thank you for helping make Stegasoo better!

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"

746
INSTALL.md Normal file
View File

@@ -0,0 +1,746 @@
# 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
```
#### Authentication Configuration (v4.0.2)
The Web UI supports optional authentication. Configure via environment variables:
```bash
# .env file (create in project root)
STEGASOO_AUTH_ENABLED=true # Enable login (default: true)
STEGASOO_HTTPS_ENABLED=false # Enable HTTPS (default: false)
STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
STEGASOO_CHANNEL_KEY= # Optional channel key
# Then run
docker-compose up -d web
```
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
#### 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
# Create venv with Python 3.12 (if available, or 3.11)
python3 -m venv venv
source venv/bin/activate
# Install (may take a while to compile)
pip install stegasoo[cli]
# For web/api, ensure enough RAM
pip install stegasoo[web] # Needs ~768MB free
```
**Running the Web UI on Pi:**
```bash
cd frontends/web
# Optional: Enable authentication
export STEGASOO_AUTH_ENABLED=true
# Optional: Enable HTTPS for local network security
export STEGASOO_HTTPS_ENABLED=true
export STEGASOO_HOSTNAME=raspberrypi.local
# Start server
python app.py
```
**Notes:**
- Argon2 operations will be slower on Pi due to memory-hardness
- First run will prompt you to create an admin account
- HTTPS generates a self-signed certificate (browsers will warn)
---
## 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! 🦕

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2025 Aaron D. Lee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

297
README.md
View File

@@ -2,272 +2,121 @@
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
![Python](https://img.shields.io/badge/Python-3.10+-blue)
![License](https://img.shields.io/badge/License-MIT-green)
[![Tests](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
[![Lint](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml/badge.svg)](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red)
## 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
- **AES-256-GCM** authenticated encryption
- **Argon2id** memory-hard key derivation (256MB RAM requirement)
- **Pseudo-random pixel selection** defeats steganalysis
- **Multi-factor authentication**: Reference photo + passphrase + PIN/RSA key
- **Multiple interfaces**: CLI, Web UI, REST API
- **File embedding**: Hide any file type (PDF, ZIP, documents)
- **DCT steganography**: JPEG-resilient embedding for social media
- **Channel keys**: Private group communication channels
## Installation
## Embedding Modes
### From PyPI (coming soon)
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|------|------------------|----------------|----------|
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
| **LSB** | ~750 KB | No | Email, direct file transfer |
```bash
# Core library only
pip install stegasoo
## Web UI
# With CLI
pip install stegasoo[cli]
# With Web UI
pip install stegasoo[web]
# With REST API
pip install stegasoo[api]
# Everything
pip install stegasoo[all]
```
### From Source
```bash
git clone https://github.com/example/stegasoo.git
cd stegasoo
# Install with all extras
pip install -e ".[all]"
```
### Docker
```bash
# Web UI only
docker-compose up web
# REST API only
docker-compose up api
# Both
docker-compose up
```
| Home | Encode | Decode | Generate |
|:----:|:------:|:------:|:--------:|
| ![Home](data/WebUI.webp) | ![Encode](data/WebUI_Encode.webp) | ![Decode](data/WebUI_Decode.webp) | ![Generate](data/WebUI_Generate.webp) |
## Quick Start
### Python Library
```python
import stegasoo
```bash
# Install (Python 3.10-3.12)
pip install -e ".[all]"
# 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}")
stegasoo generate --pin --words 4
# 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
# With RSA key
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "secretpassword"
# Encode
stegasoo encode \
--ref photo.jpg \
--carrier meme.png \
--phrase "apple forest thunder" \
--ref my_photo.jpg \
--carrier meme.jpg \
--passphrase "apple forest thunder mountain" \
--pin 123456 \
--message "Secret message"
# Decode
stegasoo decode \
--ref photo.jpg \
--stego stego.png \
--phrase "apple forest thunder" \
--ref my_photo.jpg \
--stego stego_image.png \
--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
## Interfaces
```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"
```
| Interface | Start Command | Documentation |
|-----------|---------------|---------------|
| **CLI** | `stegasoo --help` | [CLI.md](CLI.md) |
| **Web UI** | `cd frontends/web && python app.py` | [WEB_UI.md](WEB_UI.md) |
| **REST API** | `cd frontends/api && uvicorn main:app` | [API.md](API.md) |
## Security Model
| 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 |
| **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 |
| GPU cracking | Argon2id requires 256MB RAM per attempt |
| Side-channel | Constant-time operations in crypto |
## Project Structure
```
stegasoo/
├── src/stegasoo/ # Core library
├── __init__.py # Public API
│ ├── constants.py # Configuration
│ ├── crypto.py # Encryption/decryption
├── steganography.py # Image embedding
│ ├── keygen.py # Credential generation
│ ├── validation.py # Input validation
├── models.py # Data classes
│ ├── exceptions.py # Custom exceptions
│ └── utils.py # Utilities
├── frontends/
│ ├── web/ # Flask web UI
│ ├── cli/ # Command-line interface
│ └── api/ # FastAPI REST API
├── data/
│ └── bip39-words.txt # BIP-39 wordlist
├── pyproject.toml # Package configuration
├── Dockerfile # Multi-stage Docker build
└── docker-compose.yml # Container orchestration
Reference Photo ──┐
(~80-256 bits) │
├──► Argon2id KDF ──► AES-256-GCM Key
Passphrase ───────┤ (256MB RAM)
(~43-132 bits) │
PIN ──────────────┤
(~20-30 bits) │
RSA Key ──────────┘
(optional)
```
## Configuration
| Configuration | Entropy | Use Case |
|--------------|---------|----------|
| 4-word passphrase + 6-digit PIN | ~153 bits | Standard security |
| 4-word passphrase + PIN + RSA | ~280+ bits | Maximum security |
### Environment Variables
## Requirements
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `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 |
| Requirement | Version |
|-------------|---------|
| Python | 3.10-3.12 |
| RAM | 512 MB+ |
## Development
```bash
# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Format code
black src/ frontends/
ruff check src/ frontends/
# Type checking
mypy src/
black src/ tests/ frontends/
ruff check src/ tests/ frontends/
```
## Documentation
- [INSTALL.md](INSTALL.md) - Installation guide
- [CLI.md](CLI.md) - Command-line reference
- [API.md](API.md) - REST API documentation
- [WEB_UI.md](WEB_UI.md) - Web interface guide
- [SECURITY.md](SECURITY.md) - Security model details
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
- [CHANGELOG.md](CHANGELOG.md) - Version history
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
## License
MIT License - Use responsibly.
MIT License - see [LICENSE](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.
*This tool is for educational and legitimate privacy purposes. Users are responsible for complying with applicable laws.*

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

1100
WEB_UI.md Normal file

File diff suppressed because it is too large Load Diff

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

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,23 @@ services:
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
<<: *common-env
FLASK_ENV: production
# Authentication (v4.0.2)
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
volumes:
# Persist auth database and SSL certs (v4.0.2)
- stegasoo-web-data:/app/frontends/web/instance
- stegasoo-web-certs:/app/frontends/web/certs
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 +44,19 @@ services:
container_name: stegasoo-api
ports:
- "8000:8000"
environment:
<<: *common-env
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
memory: 768M
reservations:
memory: 256M
memory: 384M
# ============================================================================
# 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
# Named volumes for persistent data
volumes:
stegasoo-web-data:
driver: local
stegasoo-web-certs:
driver: local

48
examples/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Stegasoo Examples
This directory contains example scripts demonstrating how to use Stegasoo.
## Prerequisites
Install Stegasoo first:
```bash
pip install stegasoo
# Or for development:
pip install -e ".[all]"
```
## Examples
### basic_usage.py
Basic encode/decode workflow with a text message.
```bash
python basic_usage.py
```
### embed_file.py
Embed and extract files (documents, images, etc.) inside carrier images.
```bash
python embed_file.py
```
### channel_keys.py
Use channel keys to create private communication channels for groups.
```bash
python channel_keys.py
```
## Test Images
You'll need to provide your own images:
- `my_secret_photo.png` - Your reference photo (keep this secret!)
- `carrier.png` - The image that will carry your hidden message
For testing, you can use any PNG or BMP image. JPEG carriers are supported with DCT mode.

59
examples/basic_usage.py Normal file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Basic Stegasoo Usage Example
This example demonstrates how to encode and decode a secret message
using the Stegasoo library.
"""
from pathlib import Path
import stegasoo
def main():
# Load your images
# The reference photo is your "key" - keep it secret!
reference_photo = Path("my_secret_photo.png").read_bytes()
carrier_image = Path("carrier.png").read_bytes()
# Your secret message
message = "This is my secret message!"
# Your credentials
passphrase = "correct horse battery staple" # Use 4+ words
pin = "123456" # 6-9 digits
# === ENCODE ===
print("Encoding message...")
result = stegasoo.encode(
message=message,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
)
# Save the stego image
output_path = Path(f"secret_{result.suggested_filename}")
output_path.write_bytes(result.stego_image)
print(f"Saved to: {output_path}")
print(f"Capacity used: {result.capacity_used_percent:.1f}%")
# === DECODE ===
print("\nDecoding message...")
stego_image = output_path.read_bytes()
decoded = stegasoo.decode(
stego_image=stego_image,
reference_photo=reference_photo,
passphrase=passphrase,
pin=pin,
)
print(f"Decoded message: {decoded.message}")
print(f"Message type: {decoded.payload_type}")
if __name__ == "__main__":
main()

72
examples/channel_keys.py Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Channel Keys Example
Channel keys allow you to create private communication channels.
Only people with the same channel key can decode messages.
"""
from pathlib import Path
import stegasoo
from stegasoo.channel import generate_channel_key, get_channel_fingerprint
def main():
# Generate a channel key for your group
channel_key = generate_channel_key()
fingerprint = get_channel_fingerprint(channel_key)
print("=== Channel Key Generated ===")
print(f"Key: {channel_key}")
print(f"Fingerprint: {fingerprint}")
print("\nShare this key securely with your group members!")
print("-" * 40)
# Load images
reference_photo = Path("my_secret_photo.png").read_bytes()
carrier_image = Path("carrier.png").read_bytes()
# Encode with channel key
print("\nEncoding message with channel key...")
result = stegasoo.encode(
message="Secret group message!",
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase="correct horse battery staple",
pin="123456",
channel_key=channel_key, # Add the channel key
)
stego_data = result.stego_image
print(f"Encoded successfully!")
# Decode with correct channel key
print("\nDecoding with correct channel key...")
decoded = stegasoo.decode(
stego_image=stego_data,
reference_photo=reference_photo,
passphrase="correct horse battery staple",
pin="123456",
channel_key=channel_key, # Same channel key
)
print(f"Message: {decoded.message}")
# Try to decode with wrong channel key
print("\nTrying to decode with wrong channel key...")
wrong_key = generate_channel_key()
try:
stegasoo.decode(
stego_image=stego_data,
reference_photo=reference_photo,
passphrase="correct horse battery staple",
pin="123456",
channel_key=wrong_key, # Different channel key
)
print("ERROR: Should have failed!")
except (stegasoo.DecryptionError, stegasoo.ExtractionError):
print("Correctly rejected - wrong channel key!")
if __name__ == "__main__":
main()

78
examples/embed_file.py Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
File Embedding Example
This example demonstrates how to embed a file (like a document or image)
inside a carrier image using Stegasoo.
"""
from pathlib import Path
import stegasoo
from stegasoo.models import FilePayload
def main():
# Load images
reference_photo = Path("my_secret_photo.png").read_bytes()
carrier_image = Path("carrier.png").read_bytes()
# Load the file to embed
secret_file = Path("secret_document.pdf")
file_data = secret_file.read_bytes()
# Create a FilePayload
payload = FilePayload(
filename=secret_file.name,
data=file_data,
mime_type="application/pdf",
)
# Credentials
passphrase = "correct horse battery staple"
pin = "123456"
# Check capacity first
capacity = stegasoo.calculate_capacity(carrier_image)
print(f"Carrier capacity: {capacity['capacity_bytes']:,} bytes")
print(f"File size: {len(file_data):,} bytes")
if len(file_data) > capacity["capacity_bytes"]:
print("Error: File too large for this carrier!")
return
# Encode the file
print("\nEmbedding file...")
result = stegasoo.encode(
file_payload=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
)
output_path = Path(f"contains_file_{result.suggested_filename}")
output_path.write_bytes(result.stego_image)
print(f"Saved to: {output_path}")
# Decode and extract the file
print("\nExtracting file...")
decoded = stegasoo.decode(
stego_image=output_path.read_bytes(),
reference_photo=reference_photo,
passphrase=passphrase,
pin=pin,
)
if decoded.payload_type == "file":
extracted_path = Path(f"extracted_{decoded.filename}")
extracted_path.write_bytes(decoded.file_data)
print(f"Extracted: {extracted_path}")
print(f"Original filename: {decoded.filename}")
print(f"MIME type: {decoded.mime_type}")
else:
print(f"Unexpected payload type: {decoded.payload_type}")
if __name__ == "__main__":
main()

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

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

162
frontends/web/auth.py Normal file
View File

@@ -0,0 +1,162 @@
"""
Stegasoo Authentication Module
Single-admin authentication with Argon2 password hashing.
Uses Flask sessions for authentication state and SQLite3 for storage.
"""
import functools
import sqlite3
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from flask import current_app, g, redirect, session, url_for
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
ph = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64MB
parallelism=4,
hash_len=32,
salt_len=16,
)
def get_db_path() -> Path:
"""Get database path in Flask instance folder."""
instance_path = Path(current_app.instance_path)
instance_path.mkdir(parents=True, exist_ok=True)
return instance_path / "stegasoo.db"
def get_db() -> sqlite3.Connection:
"""Get database connection, cached on Flask g object."""
if "db" not in g:
g.db = sqlite3.connect(get_db_path())
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
"""Close database connection at end of request."""
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
"""Initialize database schema."""
db = get_db()
db.executescript("""
CREATE TABLE IF NOT EXISTS admin_user (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL DEFAULT 'admin',
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
""")
db.commit()
def user_exists() -> bool:
"""Check if admin user has been created."""
db = get_db()
result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone()
return result is not None
def create_user(username: str, password: str):
"""Create admin user (first-run setup)."""
if user_exists():
raise ValueError("Admin user already exists")
password_hash = ph.hash(password)
db = get_db()
db.execute(
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)",
(username, password_hash),
)
db.commit()
def get_username() -> str:
"""Get the admin username."""
db = get_db()
row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone()
return row["username"] if row else "admin"
def verify_password(password: str) -> bool:
"""Verify password against stored hash."""
db = get_db()
row = db.execute("SELECT password_hash FROM admin_user WHERE id = 1").fetchone()
if not row:
return False
try:
ph.verify(row["password_hash"], password)
# Rehash if parameters changed
if ph.check_needs_rehash(row["password_hash"]):
new_hash = ph.hash(password)
db.execute(
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
(new_hash,),
)
db.commit()
return True
except VerifyMismatchError:
return False
def change_password(current_password: str, new_password: str) -> tuple[bool, str]:
"""Change admin password. Returns (success, message)."""
if not verify_password(current_password):
return False, "Current password is incorrect"
if len(new_password) < 8:
return False, "New password must be at least 8 characters"
new_hash = ph.hash(new_password)
db = get_db()
db.execute(
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
(new_hash,),
)
db.commit()
return True, "Password changed successfully"
def is_authenticated() -> bool:
"""Check if current session is authenticated."""
return session.get("authenticated", False)
def login_required(f):
"""Decorator to require login for a route."""
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# Check if auth is enabled
if not current_app.config.get("AUTH_ENABLED", True):
return f(*args, **kwargs)
# Check for first-run setup
if not user_exists():
return redirect(url_for("setup"))
# Check authentication
if not is_authenticated():
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated_function
def init_app(app):
"""Initialize auth module with Flask app."""
app.teardown_appcontext(close_db)
with app.app_context():
init_db()

111
frontends/web/ssl_utils.py Normal file
View File

@@ -0,0 +1,111 @@
"""
SSL Certificate Utilities
Auto-generates self-signed certificates for HTTPS.
Uses cryptography library (already a dependency).
"""
import datetime
import ipaddress
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
"""Get paths for cert and key files."""
cert_dir = base_dir / "certs"
cert_dir.mkdir(parents=True, exist_ok=True)
return cert_dir / "server.crt", cert_dir / "server.key"
def certs_exist(base_dir: Path) -> bool:
"""Check if both cert files exist."""
cert_path, key_path = get_cert_paths(base_dir)
return cert_path.exists() and key_path.exists()
def generate_self_signed_cert(
base_dir: Path,
hostname: str = "localhost",
days_valid: int = 365,
) -> tuple[Path, Path]:
"""
Generate self-signed SSL certificate.
Args:
base_dir: Base directory for certs folder
hostname: Server hostname for certificate
days_valid: Certificate validity in days
Returns:
Tuple of (cert_path, key_path)
"""
cert_path, key_path = get_cert_paths(base_dir)
# Generate RSA key
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
])
# Subject Alternative Names
san_list = [
x509.DNSName(hostname),
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]
# Add the hostname as IP if it looks like one
try:
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
except ipaddress.AddressValueError:
pass
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(
x509.SubjectAlternativeName(san_list),
critical=False,
)
.sign(key, hashes.SHA256())
)
# Write key file (chmod 600)
key_path.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
key_path.chmod(0o600)
# Write cert file
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
return cert_path, key_path
def ensure_certs(base_dir: Path, hostname: str = "localhost") -> tuple[Path, Path]:
"""Ensure certificates exist, generating if needed."""
if certs_exist(base_dir):
return get_cert_paths(base_dir)
print(f"Generating self-signed SSL certificate for {hostname}...")
return generate_self_signed_cert(base_dir, hostname)

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,892 @@ 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;
font-size: 0.65rem;
}
.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.65rem;
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;
font-size: 0.65rem;
}
.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.65rem;
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: 280px;
max-width: 400px;
height: auto;
min-height: 280px;
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: 220px;
min-width: 220px;
padding: 12px;
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: 240px;
max-width: 240px;
min-width: 180px;
min-height: 180px;
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: 8px 10px 6px 10px;
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.7rem;
color: #fff;
text-align: center;
margin-bottom: 3px;
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.6rem;
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: 3px;
padding: 2px 6px;
font-size: 0.65rem;
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.65rem;
}
/* ----------------------------------------------------------------------------
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,29 +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> — Hide messages or any file type (PDF, ZIP, documents)
<strong>Text &amp; File Embedding</strong>
<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> — Combines photo + phrase + PIN/RSA key
<strong>Multi-Factor Security</strong>
<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> — Military-grade authenticated encryption
<strong>AES-256-GCM Encryption</strong>
<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> — 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>
@@ -41,19 +44,29 @@
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Random Pixel Embedding</strong> — Defeats statistical steganalysis
<strong>Random Pixel Embedding</strong>
<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> — Maintains PNG/BMP lossless formats
<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>Large Capacity</strong> — Up to {{ max_payload_kb }} KB payload, 16MP images
<strong>Zero Server Storage</strong>
<br><small class="text-muted">Nothing saved, files auto-expire</small>
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Zero Server Storage</strong> — Nothing saved, files auto-expire
<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>
@@ -61,113 +74,313 @@
</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>
<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 class="col-md-6">
<h6><i class="bi bi-info-circle text-info me-2"></i>How It Works</h6>
<ul class="small">
<li>Original filename is preserved</li>
<li>MIME type is stored for proper handling</li>
<li>File is encrypted identically to text</li>
<li>Decoding auto-detects text vs. file</li>
</ul>
</div>
</div>
<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.
<!-- 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>
{% 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>
<!-- Version History -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
</div>
<div class="card-body">
<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>
</div>
</div>
@@ -188,11 +401,11 @@
<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>
@@ -202,20 +415,23 @@
<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>
@@ -225,23 +441,21 @@
<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>
@@ -252,100 +466,64 @@
<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>250,000 characters</strong> (~250 KB)</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> (~4000×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><strong>10 MB</strong></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-clock me-2"></i>Temp file expiry</td>
<td><strong>5 minutes</strong></td>
<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-key me-2"></i>PIN length</td>
<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>File expiry</td>
<td><strong>5 min</strong></td>
</tr>
<tr>
<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-code me-2"></i>Python Version</td>
<td><strong>3.10-3.12</strong></td>
</tr>
<tr>
<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="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-terminal me-2"></i>CLI &amp; API</h5>
</div>
<div class="card-body">
<p>Stegasoo is also available as a command-line tool and REST API:</p>
<h6 class="mt-3">Command Line</h6>
<pre class="bg-dark p-3 rounded"><code># Generate credentials
stegasoo generate --pin --rsa
# Encode a text message
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret"
# Encode a file
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -e document.pdf
# Decode (auto-detects text vs file)
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456</code></pre>
<h6 class="mt-4">REST API</h6>
<pre class="bg-dark p-3 rounded"><code># Encode with multipart upload
curl -X POST http://localhost:8000/encode/multipart \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
-F "message=secret" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
--output stego.png
# Encode a file
curl -X POST http://localhost:8000/encode/multipart \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
-F "payload_file=@document.pdf" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
--output stego.png</code></pre>
<p class="small text-muted mt-3 mb-0">
API documentation available at <code>/docs</code> (Swagger) or <code>/redoc</code> when running the API server.
</p>
</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, Flask, and cryptography
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Account - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Account Settings</h5>
</div>
<div class="card-body">
<p class="text-muted mb-4">
Logged in as <strong>{{ username }}</strong>
</p>
<h6 class="text-muted mb-3">Change Password</h6>
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key me-1"></i> Current Password
</label>
<div class="input-group">
<input type="password" name="current_password" class="form-control"
id="currentPasswordInput" required>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('currentPasswordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> New Password
</label>
<div class="input-group">
<input type="password" name="new_password" class="form-control"
id="newPasswordInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('newPasswordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Minimum 8 characters</div>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> Confirm New Password
</label>
<div class="input-group">
<input type="password" name="new_password_confirm" class="form-control"
id="newPasswordConfirmInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('newPasswordConfirmInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg me-2"></i>Update Password
</button>
</form>
<hr class="my-4">
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
<i class="bi bi-box-arrow-left me-2"></i>Logout
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.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');
}
}
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
const newPass = document.getElementById('newPasswordInput').value;
const confirm = document.getElementById('newPasswordConfirmInput').value;
if (newPass !== confirm) {
e.preventDefault();
alert('New passwords do not match');
}
});
</script>
{% endblock %}

View File

@@ -24,20 +24,38 @@
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
</li>
{% if not auth_enabled or is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
</li>
{% if auth_enabled %}
{% if is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i> {{ username }}
</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="/login"><i class="bi bi-box-arrow-in-right me-1"></i> Login</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
@@ -47,8 +65,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 +81,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.0 — 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'))">
<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">
<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">
If PIN was used during encoding
</div>
</div>
<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>
<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">
<!-- 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
</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">
</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
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTabDec" role="tabpanel">
</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>
<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>
<!-- 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 class="form-text">
If RSA key was used during encoding (file or QR image)
</div>
</div>
</div>
<!-- RSA Key Password (shown when key selected) -->
<div class="mb-3 d-none" id="rsaPasswordGroup">
<!-- 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-6 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">
<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>
</div>
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-key me-1"></i> RSA Key Password
<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>
<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
<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>
{% 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>
<!-- ================================================================
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>
<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>
<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">
<!-- 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
</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">
</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
</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>
</label>
</div>
<!-- .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 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>
</div>
</div>
</div>
<!-- RSA Key Password (shown when key selected) -->
<div class="mb-3 d-none" id="rsaPasswordGroup">
<!-- 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-6 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">
<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-6 mb-3">
<div class="security-box h-100">
<label class="form-label">
<i class="bi bi-key me-1"></i> RSA Key Password
<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>
<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)
<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>
<!-- 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,8 +528,8 @@
<div class="alert alert-secondary mt-4 small">
<i class="bi bi-info-circle me-1"></i>
<strong>Limits:</strong>
Carrier image max ~4 megapixels (2000×2000).
Files max 10MB upload.
Carrier image max ~24 megapixels (6000x4000).
Files max 30MB upload.
Payload max {{ max_payload_kb }} KB.
</div>
</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
// ============================================================================
// 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');
messageInput?.addEventListener('input', function() {
const count = this.value.length;
const max = 250000;
const percent = Math.round((count / max) * 100);
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 = '';
}
});
}
const charCount = document.getElementById('charCount');
const charPercent = document.getElementById('charPercent');
const charWarning = document.getElementById('charWarning');
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;
if (charCount) charCount.textContent = count.toLocaleString();
if (charPercent) charPercent.textContent = percent + '%';
charWarning?.classList.toggle('d-none', percent < 80);
});
// Character counter for text
const charCount = document.getElementById('charCount');
const charWarning = document.getElementById('charWarning');
const charPercent = document.getElementById('charPercent');
const maxChars = 250000;
// ============================================================================
// ENCODE PAGE - Carrier capacity
// ============================================================================
messageInput.addEventListener('input', function() {
const len = this.value.length;
charCount.textContent = len.toLocaleString();
const capacityPanel = document.getElementById('capacityPanel');
const carrierInput = document.getElementById('carrierInput');
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);
});
// 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';
['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;
input.dispatchEvent(new Event('change'));
if (!isPayloadZone) {
showPreview(e.dataTransfer.files[0]);
}
}
});
if (!isPayloadZone) {
input.addEventListener('change', function() {
carrierInput?.addEventListener('change', function() {
if (this.files && this.files[0]) {
showPreview(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));
}
// ============================================================================
// ENCODE PAGE - Mode switching (LSB/DCT)
// ============================================================================
// Initialize tooltips for mode info icons
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
// Mode button active state toggle
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
const modeBtns = { 'dct': document.getElementById('dctModeCard'), 'lsb': document.getElementById('lsbModeCard') };
modeRadios.forEach(radio => {
radio.addEventListener('change', () => {
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
modeBtns[radio.value]?.classList.add('active');
});
}
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');
}
// Show/hide DCT options
const modeDct = document.getElementById('modeDct');
const advancedOptionsContainer = document.getElementById('advancedOptionsContainer');
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
radio.addEventListener('change', () => {
advancedOptionsContainer?.classList.toggle('d-none', !modeDct?.checked);
});
});
// Prevent Same File Selection
// 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');
});
});
// 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,14 +62,43 @@
</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
@@ -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="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
<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>
<!-- 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() {
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,34 +703,17 @@ function printQrCode() {
if (!qrImg) return;
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
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;
}
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;
}
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
</head>
<body>
<h2>Stegasoo RSA Private Key</h2>
<img src="${qrImg.src}" alt="RSA Key QR Code">
<div class="warning">
@@ -480,10 +722,10 @@ function printQrCode() {
Store securely and destroy after use.
</div>
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>
`);
</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 your secret message inside an innocent-looking image using your daily phrase + PIN.
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 hidden messages from Stegasoo-encoded images using your credentials.
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 your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security.
Create passphrases, PINs, and RSA keys
</p>
</div>
</div>
@@ -62,43 +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 words, different each day of the week
<strong>Passphrase</strong>: 4+ words
</li>
<li class="mb-2">
<li class="mb-1">
<i class="bi bi-123 text-info me-2"></i>
<strong>Static PIN</strong> — 6 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>
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>

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Login - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card">
<div class="card-header text-center">
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
<h5 class="mb-0">Login</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
value="{{ username }}" readonly>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key me-1"></i> Password
</label>
<div class="input-group">
<input type="password" name="password" class="form-control"
id="passwordInput" required autofocus>
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.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');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Setup - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card">
<div class="card-header text-center">
<i class="bi bi-gear-fill fs-1 d-block mb-2"></i>
<h5 class="mb-0">Initial Setup</h5>
</div>
<div class="card-body">
<p class="text-muted text-center mb-4">
Welcome to Stegasoo! Create your admin account to get started.
</p>
<form method="POST" action="{{ url_for('setup') }}" id="setupForm">
<div class="mb-3">
<label class="form-label">
<i class="bi bi-person me-1"></i> Username
</label>
<input type="text" name="username" class="form-control"
value="admin" required minlength="3">
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-key me-1"></i> Password
</label>
<div class="input-group">
<input type="password" name="password" class="form-control"
id="passwordInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Minimum 8 characters</div>
</div>
<div class="mb-4">
<label class="form-label">
<i class="bi bi-key-fill me-1"></i> Confirm Password
</label>
<div class="input-group">
<input type="password" name="password_confirm" class="form-control"
id="passwordConfirmInput" required minlength="8">
<button class="btn btn-outline-secondary" type="button"
onclick="togglePassword('passwordConfirmInput', this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg me-2"></i>Create Admin Account
</button>
</form>
</div>
</div>
<div class="alert alert-info mt-4 small">
<i class="bi bi-info-circle me-2"></i>
This is a single-user setup. The admin account has full access to all features.
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const icon = btn.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');
}
}
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
const pass = document.getElementById('passwordInput').value;
const confirm = document.getElementById('passwordConfirmInput').value;
if (pass !== confirm) {
e.preventDefault();
alert('Passwords do not match');
}
});
</script>
{% endblock %}

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

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,43 @@
"""
Stegasoo Constants and Configuration
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication)
Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here.
CHANGES in v4.0.2:
- Added Web UI authentication with SQLite3 user storage
- Added optional HTTPS with auto-generated self-signed certificates
- UI improvements for QR preview panels and PIN/channel columns
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.2"
# ============================================================================
# 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
@@ -45,44 +64,112 @@ PBKDF2_ITERATIONS = 600000
# ============================================================================
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
MIN_IMAGE_PIXELS = 256 * 256 # Minimum viable image size
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
# Example in constants.py
# 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 +178,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 +196,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 +204,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 +218,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:
# Provide more helpful error message for channel key issues
if message_has_key and not has_configured_key:
raise DecryptionError(
"Decryption failed. Check your phrase, PIN, RSA key, and reference photo."
"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

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

0
src/stegasoo/py.typed Normal file
View File

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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

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