Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664362bea5 | ||
|
|
4733e3b4dd | ||
|
|
24aec00613 | ||
|
|
0e0aa996bc | ||
|
|
255ae4f30d | ||
|
|
7647ca11d1 | ||
|
|
01e9e5af0a | ||
|
|
39e5daa022 | ||
|
|
54e097c050 | ||
|
|
a3ff8dace1 | ||
|
|
e4cf96bb7c | ||
|
|
597c95070c | ||
|
|
dba5a08476 | ||
|
|
6ceda6c287 | ||
|
|
c2575f973b | ||
|
|
8208ec2955 | ||
|
|
909dc14a92 | ||
|
|
bb91e41d3d | ||
|
|
c54a96894c | ||
|
|
da044017d7 | ||
|
|
d0ec99d5b5 | ||
|
|
aac8037c04 | ||
|
|
7a5092b945 | ||
|
|
e52a709080 | ||
|
|
70fe8fce62 | ||
|
|
d44575deec | ||
|
|
d0d48236ff | ||
|
|
5891285493 | ||
|
|
5501c7e0ba | ||
|
|
038fd6ceac | ||
|
|
8622f1a850 | ||
|
|
710b3a6a98 | ||
|
|
c965a5f8da | ||
|
|
00cda4d929 | ||
|
|
05e2286d02 | ||
|
|
46cbf98a23 | ||
|
|
58673c04fe | ||
|
|
dd07972014 | ||
|
|
1f40eeff9e | ||
|
|
dc09bac489 | ||
|
|
46489dd276 | ||
|
|
9088caa23d | ||
|
|
75b6203525 | ||
|
|
404d7885f4 | ||
|
|
a8db991052 | ||
|
|
ea2948e5d2 | ||
|
|
05278ca55f | ||
|
|
c551078c37 | ||
|
|
b7d86201ca | ||
|
|
07b0bc0b75 | ||
|
|
d8b8e4f5c2 | ||
|
|
143a8bdc65 | ||
|
|
ac92fa36b5 | ||
|
|
c82dcf26f2 | ||
|
|
65a496a9d4 | ||
|
|
25a432fcf3 | ||
|
|
a58dd54ba8 | ||
|
|
05c542d808 | ||
|
|
5e5d6e60de | ||
|
|
d898f6d7b1 | ||
|
|
00dd15b8fb | ||
|
|
419b491737 | ||
|
|
b568026253 | ||
|
|
127d3e54a6 | ||
|
|
de41c0731e | ||
|
|
f3d5699e15 | ||
|
|
298f387c9a | ||
|
|
fcb71303df | ||
|
|
abcff74dd4 | ||
|
|
355a988405 | ||
|
|
fb55878727 | ||
|
|
81d3f37f09 | ||
|
|
3537e8cdf9 | ||
|
|
d71f615d66 | ||
|
|
ed1d230b4e | ||
|
|
13f145c3d5 | ||
|
|
80dc22f150 | ||
|
|
01f0173dd4 | ||
|
|
5df9b9dac8 | ||
|
|
2f1ac3a747 | ||
|
|
8e5f01754f | ||
|
|
823b8824ea | ||
|
|
f4c1aa1912 | ||
|
|
e502f42fb8 | ||
|
|
08e42719ee | ||
|
|
21023099b0 | ||
|
|
8a41796d1b | ||
|
|
7b33501495 | ||
|
|
a8f6ae1dd2 | ||
|
|
b199f03f83 | ||
|
|
b97622956c | ||
|
|
3044c08fe3 | ||
|
|
5042c7d555 | ||
|
|
aa8788168e | ||
|
|
899d043892 | ||
|
|
6fb63edc61 | ||
|
|
e74f12c24d | ||
|
|
272d0e6ef0 | ||
|
|
f38bf4a1c6 | ||
|
|
fee3133f9c | ||
|
|
b058d8bf66 | ||
|
|
916a2e0e7b | ||
|
|
cccb40dc3a | ||
|
|
b60880c8b3 | ||
|
|
c96c595c78 | ||
|
|
e129c38fd8 | ||
|
|
0d7b5a14cb | ||
|
|
45b99d2c5e | ||
|
|
c6f816d61f | ||
|
|
83e9bd6fa1 | ||
|
|
5188492c77 | ||
|
|
8bb70e5667 | ||
|
|
82ac1dcda4 | ||
|
|
464e13567d | ||
|
|
0b19a41b5e | ||
|
|
61c5178752 | ||
|
|
6b1b306f61 | ||
|
|
267547caba | ||
|
|
2ff28034f5 | ||
|
|
4cba75fe06 | ||
|
|
d03b3dea4b | ||
|
|
cf247d207f | ||
|
|
28d77957eb | ||
|
|
89b4809489 | ||
|
|
79ab165b95 | ||
|
|
4194d6923a | ||
|
|
08e19a3bfd | ||
|
|
dea7472018 | ||
|
|
e8863d15d7 | ||
|
|
e4256cd037 | ||
|
|
948a582e5d | ||
|
|
afa88bc73b | ||
|
|
221678d934 | ||
|
|
faf3efac0b | ||
|
|
9c45e0d0f8 | ||
|
|
6b21190f97 | ||
|
|
d94ee7be90 | ||
|
|
6fa4b447db | ||
|
|
1bb3589baf | ||
|
|
cfd1d8fb66 | ||
|
|
c1beaf3611 | ||
|
|
ef7478b30a | ||
|
|
12929bf326 | ||
|
|
a001f227ec | ||
|
|
3898031480 | ||
|
|
657cae0ae6 | ||
|
|
11fc8aab27 | ||
|
|
6d64c69f08 | ||
|
|
6bd38ccf57 | ||
|
|
d8fe25a121 | ||
|
|
3869391336 | ||
|
|
1b914f0409 | ||
|
|
2b7abc52c1 | ||
|
|
66f7d54db5 | ||
|
|
34376b2dfe | ||
|
|
4eefc946c4 | ||
|
|
e4a4a5e074 | ||
|
|
50a7b10c63 | ||
|
|
6de8130c8b | ||
|
|
5394967dce | ||
|
|
5274dd20ec | ||
|
|
1e98a13edf | ||
|
|
a74c0b70ea | ||
|
|
cf55acaf5a | ||
|
|
aa9729b3b1 | ||
|
|
5ed25f706f | ||
|
|
72468e7972 | ||
|
|
37a60d7174 | ||
|
|
a7c2fcc1da | ||
|
|
1b9405389c | ||
|
|
ee44cfd46e | ||
|
|
40ce6d663c | ||
|
|
0aaeb7c6c7 | ||
|
|
c34bc9ef78 | ||
|
|
33dc69ce63 | ||
|
|
c784140cde | ||
|
|
00763de780 | ||
|
|
f35acfed06 | ||
|
|
5217e86ca9 | ||
|
|
b1c343bfe3 | ||
|
|
e2a2d979f8 | ||
|
|
9aef50dbed | ||
|
|
b836692635 | ||
|
|
e8b23b0a87 | ||
|
|
79fb9f21f1 | ||
|
|
9ce4c3e385 | ||
|
|
63e2735d96 | ||
|
|
fdaffbd3bb | ||
|
|
0c7fa647f1 | ||
|
|
a5ee25b297 | ||
|
|
6bd18fd013 | ||
|
|
7c84e25378 | ||
|
|
e43b4defdd | ||
|
|
a7df211242 | ||
|
|
c9741c1da6 | ||
|
|
a318f16a0d | ||
|
|
3bad80361a | ||
|
|
9559a3c39f | ||
|
|
8b69c5d9e9 | ||
|
|
0dc44e2d7b | ||
|
|
5bf477f2ad | ||
|
|
3c759c15d7 | ||
|
|
d937a43c13 | ||
|
|
1c9c51e016 | ||
|
|
749fa00639 | ||
|
|
f12544fd7f |
228
.github/CI_CD_PRIMER.md
vendored
Normal 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
@@ -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
@@ -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
|
||||
62
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
24
.gitignore
vendored
@@ -35,6 +35,12 @@ old_files/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Backup files
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
@@ -48,7 +54,7 @@ htmlcov/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
# Distribution
|
||||
@@ -56,4 +62,18 @@ htmlcov/
|
||||
*.spec
|
||||
|
||||
# Output test files.
|
||||
*.png
|
||||
test_data/*.png
|
||||
|
||||
# Dev scripts (local convenience scripts)
|
||||
scripts/
|
||||
|
||||
# Web UI auth database and SSL certs
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
rpi/inject-wifi.sh
|
||||
|
||||
# RPi image build artifacts
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
pishrink.sh
|
||||
|
||||
40
.pre-commit-config.yaml
Normal 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
@@ -0,0 +1 @@
|
||||
3.12
|
||||
882
API.md
Normal 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
|
||||
152
CHANGELOG.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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.1.0] - 2026-01-04
|
||||
|
||||
### Added
|
||||
- **Admin Recovery System**: Password reset for locked-out admins
|
||||
- Recovery key generated during setup (32-char alphanumeric)
|
||||
- Multiple backup options: text file, QR code, stego image
|
||||
- QR codes obfuscated (XOR'd with magic header hash)
|
||||
- Stego backups hide key in an image using Stegasoo itself
|
||||
- CLI: `stegasoo admin recover --db path/to/db`
|
||||
- **EXIF Editor**: Full metadata editing in Tools page
|
||||
- View all EXIF fields from uploaded image
|
||||
- Inline editing of individual fields
|
||||
- Clear all metadata with one click
|
||||
- Download cleaned image
|
||||
- CLI: `stegasoo tools exif image.jpg [--clear] [--set Field=Value]`
|
||||
- **Multi-User Support**: Admin can create up to 16 additional users
|
||||
- Role-based access control (admin/user)
|
||||
- Admin user management page
|
||||
- Temp password generation for new users
|
||||
- **Saved Channel Keys**: Users can save/manage channel keys in account page
|
||||
|
||||
### Changed
|
||||
- **Architecture**: Consolidated `resolve_channel_key()` to library layer
|
||||
- Single source of truth in `src/stegasoo/channel.py`
|
||||
- CLI, API, WebUI now use thin wrappers
|
||||
- **DCT Pre-Check**: Fail fast with helpful error before expensive encoding
|
||||
- **Toast Notifications**: Auto-dismiss after 20 seconds with fade animation
|
||||
- `RECOVERY_OBFUSCATION_KEY` constant added to `constants.py`
|
||||
|
||||
### Fixed
|
||||
- DCT payload size error now caught early with clear message
|
||||
|
||||
## [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
|
||||
909
CLI.md
Normal file
@@ -0,0 +1,909 @@
|
||||
# Stegasoo CLI Documentation (v4.1.0)
|
||||
|
||||
Complete command-line interface reference for Stegasoo steganography operations.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [What's New in v4.1.0](#whats-new-in-v410)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Commands](#commands)
|
||||
- [generate](#generate-command)
|
||||
- [encode](#encode-command)
|
||||
- [decode](#decode-command)
|
||||
- [verify](#verify-command)
|
||||
- [channel](#channel-command)
|
||||
- [admin](#admin-command)
|
||||
- [tools](#tools-command)
|
||||
- [info](#info-command)
|
||||
- [compare](#compare-command)
|
||||
- [modes](#modes-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.1.0
|
||||
|
||||
Version 4.1.0 adds **admin recovery** and **tools** commands:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Admin recovery | Reset admin password using recovery key |
|
||||
| EXIF tools | View, edit, and strip image metadata |
|
||||
| Peek tool | Quick stego detection check |
|
||||
| Strip tool | Remove hidden data from images |
|
||||
|
||||
**New commands:**
|
||||
- `stegasoo admin recover` - Reset admin password with recovery key
|
||||
- `stegasoo tools exif` - View/edit EXIF metadata
|
||||
- `stegasoo tools peek` - Check for hidden data
|
||||
- `stegasoo tools strip` - Remove stego data from image
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.0
|
||||
|
||||
Version 4.0.0 added **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 |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
### Admin Command
|
||||
|
||||
Manage Web UI admin accounts and recovery.
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `recover` | Reset admin password using recovery key |
|
||||
|
||||
#### admin recover
|
||||
|
||||
Reset the admin password for a Web UI database.
|
||||
|
||||
```bash
|
||||
stegasoo admin recover --db PATH [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Short | Type | Required | Description |
|
||||
|--------|-------|------|----------|-------------|
|
||||
| `--db` | `-d` | path | ✓ | Path to stegasoo.db file |
|
||||
| `--key` | `-k` | string | | Recovery key (prompted if not provided) |
|
||||
| `--password` | `-p` | string | | New password (prompted if not provided) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Interactive mode (prompts for key and password)
|
||||
stegasoo admin recover --db frontends/web/instance/stegasoo.db
|
||||
|
||||
# Non-interactive mode
|
||||
stegasoo admin recover \
|
||||
--db /path/to/stegasoo.db \
|
||||
--key "XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" \
|
||||
--password "NewSecurePassword123"
|
||||
```
|
||||
|
||||
**Recovery process:**
|
||||
1. The recovery key is verified against the database hash
|
||||
2. If valid, the admin password is reset
|
||||
3. User can now log in with the new password
|
||||
|
||||
**Note:** Recovery keys are instance-bound. A key from one database won't work on another.
|
||||
|
||||
---
|
||||
|
||||
### Tools Command
|
||||
|
||||
Image utilities and analysis tools.
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `exif` | View/edit EXIF metadata |
|
||||
| `peek` | Check for hidden data |
|
||||
| `strip` | Remove stego data from image |
|
||||
|
||||
#### tools exif
|
||||
|
||||
View and edit EXIF metadata in images.
|
||||
|
||||
```bash
|
||||
stegasoo tools exif IMAGE [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--clear` | flag | Remove all EXIF metadata |
|
||||
| `--set FIELD=VALUE` | string | Set a specific EXIF field |
|
||||
| `--output` / `-o` | path | Output filename (default: overwrites input) |
|
||||
| `--json` | flag | Output as JSON |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# View all EXIF data
|
||||
stegasoo tools exif photo.jpg
|
||||
|
||||
# View as JSON
|
||||
stegasoo tools exif photo.jpg --json
|
||||
|
||||
# Clear all metadata
|
||||
stegasoo tools exif photo.jpg --clear -o clean.jpg
|
||||
|
||||
# Set specific fields
|
||||
stegasoo tools exif photo.jpg \
|
||||
--set "Artist=John Doe" \
|
||||
--set "Copyright=2026" \
|
||||
-o tagged.jpg
|
||||
|
||||
# Remove GPS data only
|
||||
stegasoo tools exif photo.jpg \
|
||||
--set "GPSLatitude=" \
|
||||
--set "GPSLongitude=" \
|
||||
-o no-gps.jpg
|
||||
```
|
||||
|
||||
#### tools peek
|
||||
|
||||
Check if an image contains hidden Stegasoo data.
|
||||
|
||||
```bash
|
||||
stegasoo tools peek IMAGE [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--json` | flag | Output as JSON |
|
||||
| `--quiet` / `-q` | flag | Exit code only (0=found, 1=not found) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Check for hidden data
|
||||
stegasoo tools peek suspicious.png
|
||||
|
||||
# Script-friendly check
|
||||
if stegasoo tools peek image.png -q; then
|
||||
echo "Contains hidden data"
|
||||
fi
|
||||
```
|
||||
|
||||
#### tools strip
|
||||
|
||||
Remove hidden stego data from an image (destructive).
|
||||
|
||||
```bash
|
||||
stegasoo tools strip IMAGE [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--output` / `-o` | path | Output filename |
|
||||
| `--force` / `-f` | flag | Overwrite without confirmation |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Strip and save to new file
|
||||
stegasoo tools strip stego.png -o clean.png
|
||||
|
||||
# Strip in place (with confirmation)
|
||||
stegasoo tools strip stego.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
@@ -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
@@ -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!
|
||||
77
Dockerfile
@@ -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
@@ -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"
|
||||
825
INSTALL.md
Normal file
@@ -0,0 +1,825 @@
|
||||
# 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/5 (4GB+ RAM recommended for Web UI).
|
||||
|
||||
#### Step 1: Install System Dependencies
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libbz2-dev \
|
||||
libreadline-dev \
|
||||
libsqlite3-dev \
|
||||
libncursesw5-dev \
|
||||
xz-utils \
|
||||
tk-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libffi-dev \
|
||||
liblzma-dev \
|
||||
libzbar0 \
|
||||
libjpeg-dev
|
||||
```
|
||||
|
||||
#### Step 2: Install Python 3.12 via pyenv
|
||||
|
||||
Raspberry Pi OS ships with Python 3.13, which is **not compatible** with jpegio. Install Python 3.12:
|
||||
|
||||
```bash
|
||||
# Install pyenv
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add to ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Python 3.12 (takes ~10 minutes on Pi 5)
|
||||
pyenv install 3.12
|
||||
pyenv global 3.12
|
||||
```
|
||||
|
||||
#### Step 3: Build jpegio for ARM
|
||||
|
||||
The upstream jpegio has x86-specific build flags. Patch and build from source:
|
||||
|
||||
```bash
|
||||
# Clone jpegio
|
||||
git clone https://github.com/dwgoon/jpegio.git
|
||||
cd jpegio
|
||||
|
||||
# Patch for ARM (removes x86-specific -m64 flag)
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM fix/" setup.py
|
||||
|
||||
# Build and install
|
||||
pip install .
|
||||
cd ..
|
||||
```
|
||||
|
||||
#### Step 4: Install Stegasoo
|
||||
|
||||
```bash
|
||||
# Clone Stegasoo
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
|
||||
# Create venv with Python 3.12
|
||||
~/.pyenv/versions/3.12.*/bin/python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install (jpegio already installed, skip it)
|
||||
pip install -e ".[web]" --no-deps
|
||||
pip install argon2-cffi cryptography pillow flask gunicorn scipy numpy pyzbar qrcode
|
||||
```
|
||||
|
||||
#### Step 5: Run the Web UI
|
||||
|
||||
```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
|
||||
# Access at http://<pi-ip>:5000
|
||||
```
|
||||
|
||||
#### Verify Installation
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import stegasoo
|
||||
from stegasoo.dct_steganography import has_jpegio_support
|
||||
print(f'Stegasoo: {stegasoo.__version__}')
|
||||
print(f'Argon2: {stegasoo.has_argon2()}')
|
||||
print(f'DCT: {stegasoo.has_dct_support()}')
|
||||
print(f'jpegio: {has_jpegio_support()}')
|
||||
"
|
||||
# Expected: All True
|
||||
```
|
||||
|
||||
#### Notes
|
||||
|
||||
- **RAM**: Web UI needs ~768MB free for Argon2 + scipy operations
|
||||
- **Performance**: Argon2 operations take 3-5 seconds on Pi 5 (vs ~2s on desktop)
|
||||
- **Python 3.13**: Not supported due to jpegio C extension incompatibility
|
||||
- **First run**: Will prompt you to create an admin account
|
||||
- **HTTPS**: Generates 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
@@ -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.
|
||||
538
PLAN-4.1.0.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# Stegasoo 4.1.0 Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Version 4.1.0 is a feature release focusing on small-group deployment improvements and new utilities.
|
||||
|
||||
## Goals
|
||||
|
||||
1. ~~**Multi-User Support** - Admin can create up to 16 users for shared deployments~~ ✅ DONE
|
||||
2. **Channel Key QR** - Easy visual sharing of channel keys via QR codes
|
||||
3. ~~**CLI Channel Commands** - Manage channel keys from command line~~ ✅ DONE
|
||||
4. **Advanced Tools** - Image/stego utilities (TBD)
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Multi-User Support ✅ COMPLETED
|
||||
|
||||
> Implemented in commit 7b33501. All requirements met.
|
||||
|
||||
### Requirements
|
||||
|
||||
- 16 users + 1 admin maximum (17 total)
|
||||
- First user created at setup is always admin
|
||||
- Admin can add/delete users, reset passwords
|
||||
- Regular users can only change their own password
|
||||
- No self-registration (admin-invite only)
|
||||
|
||||
### Database Changes
|
||||
|
||||
**Update User model in `frontends/web/models.py`:**
|
||||
|
||||
```python
|
||||
class User(db.Model):
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(80), unique=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), default='user') # 'admin' or 'user'
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
**Migration:** Add `role` and `created_at` columns. Existing users get `role='admin'`.
|
||||
|
||||
### New Routes
|
||||
|
||||
| Route | Method | Access | Description |
|
||||
|-------|--------|--------|-------------|
|
||||
| `/admin/users` | GET | admin | List all users |
|
||||
| `/admin/users/new` | GET, POST | admin | Create user form |
|
||||
| `/admin/users/<id>/delete` | POST | admin | Delete user |
|
||||
| `/admin/users/<id>/reset-password` | POST | admin | Generate temp password |
|
||||
|
||||
### New Decorator
|
||||
|
||||
```python
|
||||
# auth.py
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('login'))
|
||||
if current_user.role != 'admin':
|
||||
flash('Admin access required', 'error')
|
||||
return redirect(url_for('index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
```
|
||||
|
||||
### UI Changes
|
||||
|
||||
**Navigation (for admin users):**
|
||||
- Add "Users" link in navbar (visible only to admin)
|
||||
|
||||
**Account page (`/account`):**
|
||||
- Admin sees link to user management
|
||||
- All users see their own password change form
|
||||
|
||||
**New template: `templates/admin/users.html`:**
|
||||
- Table: Username | Role | Created | Actions
|
||||
- Actions: Reset Password, Delete (disabled for self)
|
||||
- "Add User" button (disabled if at 16 user limit)
|
||||
- Show count: "3 of 16 users"
|
||||
|
||||
**New template: `templates/admin/user_new.html`:**
|
||||
- Username field (email-style allowed)
|
||||
- Password field (auto-populated with random 8-char, admin can override)
|
||||
- Submit → confirmation page shows password once with copy button
|
||||
|
||||
### Validation
|
||||
|
||||
- Username: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
||||
- Password: 8+ chars (same as current)
|
||||
- Can't delete yourself
|
||||
- Can't demote the last admin
|
||||
- Deleting user immediately invalidates their sessions
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Channel Key QR
|
||||
|
||||
### Web UI
|
||||
|
||||
**About page additions:**
|
||||
|
||||
If `STEGASOO_CHANNEL_KEY` environment variable is set:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Channel Key │
|
||||
│ │
|
||||
│ ██████████████ Your server uses a │
|
||||
│ ██ ██ private channel key. │
|
||||
│ ██ ██████ ██ Share this QR with │
|
||||
│ ██ ██████ ██ others to join. │
|
||||
│ ██ ██ │
|
||||
│ ██████████████ [Copy Key] [Download]│
|
||||
│ │
|
||||
│ Key: abc123...xyz │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- QR generated server-side using `qrcode` library
|
||||
- "Copy Key" copies text to clipboard
|
||||
- "Download QR" saves as PNG
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# about route addition
|
||||
@app.route('/about')
|
||||
def about():
|
||||
channel_key = os.environ.get('STEGASOO_CHANNEL_KEY', '')
|
||||
channel_qr_b64 = None
|
||||
if channel_key:
|
||||
# Generate QR as base64 PNG
|
||||
qr = qrcode.make(channel_key)
|
||||
buffer = BytesIO()
|
||||
qr.save(buffer, format='PNG')
|
||||
channel_qr_b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
return render_template('about.html',
|
||||
channel_key=channel_key,
|
||||
channel_qr=channel_qr_b64)
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
**New command group: `stegasoo channel`**
|
||||
|
||||
```bash
|
||||
# Generate a new channel key
|
||||
stegasoo channel generate
|
||||
# Output:
|
||||
# Channel Key: stg_abc123...xyz789
|
||||
#
|
||||
# ██████████████████
|
||||
# ██ ██
|
||||
# ██ ██████████ ██
|
||||
# ...
|
||||
#
|
||||
# Set in environment: export STEGASOO_CHANNEL_KEY="stg_abc123..."
|
||||
|
||||
# Show current key (from env or argument)
|
||||
stegasoo channel show
|
||||
# Output:
|
||||
# Channel Key: stg_abc123...xyz789
|
||||
|
||||
# Display QR in terminal (ASCII)
|
||||
stegasoo channel qr
|
||||
# Output: ASCII QR code
|
||||
|
||||
# Save QR as PNG
|
||||
stegasoo channel qr -o channel-key.png
|
||||
# Output: Saved to channel-key.png
|
||||
|
||||
# Explicit format selection
|
||||
stegasoo channel qr --format ascii # Terminal (default)
|
||||
stegasoo channel qr --format png -o - # PNG to stdout
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Use `qrcode[pil]` for PNG output
|
||||
- Use `qrcode` with `print_ascii()` for terminal
|
||||
- Read key from `--key` argument or `STEGASOO_CHANNEL_KEY` env var
|
||||
- `generate` uses existing `generate_channel_key()` from `stegasoo.channel`
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `frontends/web/templates/admin/users.html` | User management page |
|
||||
| `frontends/web/templates/admin/user_new.html` | Add user form |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `frontends/web/models.py` | Add `role`, `created_at` to User |
|
||||
| `frontends/web/auth.py` | Add `@admin_required`, user management routes |
|
||||
| `frontends/web/templates/base.html` | Add Users link for admins |
|
||||
| `frontends/web/templates/account.html` | Add admin link |
|
||||
| `frontends/web/templates/about.html` | Add channel key QR section |
|
||||
| `src/stegasoo/cli.py` | Add `channel` command group |
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Multi-User
|
||||
|
||||
1. Fresh install → first user is admin
|
||||
2. Admin can create users up to limit (16)
|
||||
3. Admin can't create 17th user (shows error)
|
||||
4. Regular user can log in, encode/decode
|
||||
5. Regular user can't access `/admin/users`
|
||||
6. Admin can reset user password
|
||||
7. Admin can delete user
|
||||
8. Admin can't delete self
|
||||
9. Existing 4.0.2 databases upgrade correctly (single user becomes admin)
|
||||
|
||||
### Channel Key QR
|
||||
|
||||
1. About page shows nothing if no channel key
|
||||
2. About page shows QR + key if channel key set
|
||||
3. Copy button works
|
||||
4. Download gives valid PNG
|
||||
5. QR scans correctly to key value
|
||||
|
||||
### CLI
|
||||
|
||||
1. `channel generate` creates valid key + shows QR
|
||||
2. `channel show` displays current key
|
||||
3. `channel qr` outputs ASCII to terminal
|
||||
4. `channel qr -o file.png` saves PNG
|
||||
5. Commands work with `--key` override
|
||||
6. Commands read from env var
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Advanced Tools
|
||||
|
||||
### Included Tools
|
||||
|
||||
| Tool | Web | CLI | Description |
|
||||
|------|-----|-----|-------------|
|
||||
| **Capacity Calculator** | ✓ | ✓ | Upload image → show DCT/LSB capacity |
|
||||
| **Metadata Stripper** | ✓ | ✓ | Remove EXIF/metadata from image |
|
||||
| **Stego Detector** | ✓ | ✓ | Analyze image for signs of hidden data |
|
||||
| **Image Compare** | ✓ | - | Side-by-side before/after diff |
|
||||
| **Header Peek** | ✓ | ✓ | Check for Stegasoo header without decrypting |
|
||||
| **Batch Mode** | - | ✓ | Encode/decode multiple files |
|
||||
|
||||
### Web UI: `/tools` Page
|
||||
|
||||
New page with card-based layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🛠️ Advanced Tools │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 📏 Capacity │ │ 🧹 Metadata │ │
|
||||
│ │ Calculator │ │ Stripper │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Check how much │ │ Remove EXIF │ │
|
||||
│ │ data fits │ │ before encoding │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 🔍 Stego │ │ 🔎 Header │ │
|
||||
│ │ Detector │ │ Peek │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Analyze image │ │ Check for │ │
|
||||
│ │ for hidden data │ │ Stegasoo data │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ ⚖️ Image │ │
|
||||
│ │ Compare │ │
|
||||
│ │ │ │
|
||||
│ │ Before/after │ │
|
||||
│ │ diff view │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each card opens a modal or expands inline for the tool interface.
|
||||
|
||||
### CLI Structure
|
||||
|
||||
```bash
|
||||
# Capacity calculator
|
||||
stegasoo capacity image.jpg
|
||||
stegasoo capacity image.jpg --format json
|
||||
|
||||
# Metadata stripper
|
||||
stegasoo strip image.jpg # Output to image_stripped.jpg
|
||||
stegasoo strip image.jpg -o clean.jpg # Custom output
|
||||
stegasoo strip image.jpg --in-place # Overwrite original
|
||||
|
||||
# Stego detector
|
||||
stegasoo detect image.jpg
|
||||
stegasoo detect image.jpg --verbose # Detailed analysis
|
||||
|
||||
# Header peek
|
||||
stegasoo peek image.jpg
|
||||
# Output: "Stegasoo DCT header detected" or "No Stegasoo header found"
|
||||
|
||||
# Batch mode
|
||||
stegasoo encode --batch manifest.json # JSON with files + credentials
|
||||
stegasoo decode --batch input_dir/ --out output_dir/
|
||||
```
|
||||
|
||||
### Tool Details
|
||||
|
||||
#### Capacity Calculator
|
||||
- Input: Image file
|
||||
- Output: Dimensions, megapixels, DCT capacity, LSB capacity
|
||||
- Web: Upload zone + results panel
|
||||
- CLI: Table or JSON output
|
||||
|
||||
#### Metadata Stripper
|
||||
- Input: Image file
|
||||
- Output: Clean image (EXIF/metadata removed)
|
||||
- Show what was removed (camera model, GPS, etc.)
|
||||
- Preserve image quality
|
||||
|
||||
#### Stego Detector
|
||||
- Input: Image file
|
||||
- Analysis:
|
||||
- Chi-square analysis (LSB detection)
|
||||
- DCT coefficient histogram analysis
|
||||
- Visual inspection hints
|
||||
- Output: Likelihood score + findings
|
||||
- Note: Detection is probabilistic, not definitive
|
||||
|
||||
#### Image Compare
|
||||
- Input: Two images (original + stego)
|
||||
- Output:
|
||||
- Side-by-side view
|
||||
- Difference overlay (amplified)
|
||||
- Pixel-level stats (PSNR, SSIM)
|
||||
- Web only (visual tool)
|
||||
|
||||
#### Header Peek
|
||||
- Input: Image file
|
||||
- Output: Header found (yes/no), mode (DCT/LSB), embedded size estimate
|
||||
- Does NOT decrypt - just checks for valid header structure
|
||||
- Useful for "is this a stego image?" without credentials
|
||||
|
||||
#### Batch Mode
|
||||
- CLI only
|
||||
- Manifest file (JSON) or directory-based
|
||||
- Progress bar for multiple files
|
||||
- Error handling per-file (continue on failure)
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Database Migration
|
||||
|
||||
For existing 4.0.2 installations:
|
||||
|
||||
```python
|
||||
# migrations/add_user_role.py
|
||||
def upgrade():
|
||||
# Add columns with defaults
|
||||
op.add_column('user', sa.Column('role', sa.String(20), default='user'))
|
||||
op.add_column('user', sa.Column('created_at', sa.DateTime))
|
||||
|
||||
# Set existing users as admin (they were the first user)
|
||||
op.execute("UPDATE user SET role = 'admin' WHERE role IS NULL")
|
||||
op.execute("UPDATE user SET created_at = datetime('now') WHERE created_at IS NULL")
|
||||
```
|
||||
|
||||
Or simpler: detect on startup, update schema automatically (current pattern).
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Per-user channel keys
|
||||
- User groups/teams
|
||||
- API authentication tokens
|
||||
- User activity logging
|
||||
- Password complexity rules beyond length
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Component | Complexity |
|
||||
|-----------|------------|
|
||||
| Database schema change | Low |
|
||||
| Admin routes + templates | Medium |
|
||||
| Access control decorator | Low |
|
||||
| About page QR | Low |
|
||||
| CLI channel commands | Medium |
|
||||
| Advanced Tools (TBD) | Medium-High |
|
||||
| Testing | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Temp password flow:** Password field auto-populates with random 8-char password. Admin can override if desired. Show password once on confirmation page.
|
||||
|
||||
2. **Session handling:** Yes - deleting a user immediately invalidates their active sessions (ban hammer).
|
||||
|
||||
3. **Username rules:** Sane requirements, email-style allowed. Validation: 3-80 chars, alphanumeric, underscore, hyphen, @ and . for email-style.
|
||||
|
||||
---
|
||||
|
||||
## Approval
|
||||
|
||||
- [x] Plan reviewed
|
||||
- [x] Questions resolved
|
||||
- [x] Ready to implement
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Multi-User Support (commit 7b33501)
|
||||
- [x] Channel Key QR (Web UI) - added QR generator on About page
|
||||
- [x] CLI Channel Commands
|
||||
- [x] Saved Channel Keys (Web UI) - users can save/manage channel keys
|
||||
- [x] Advanced Tools - Image Security Toolkit
|
||||
- [x] CLI: `stegasoo tools capacity/strip/peek/exif`
|
||||
- [x] API: `/api/tools/capacity`, `/api/tools/peek`, `/api/tools/exif/*`
|
||||
- [x] WebUI: Tools page with tabbed interface
|
||||
- [x] EXIF Editor with inline editing, clear all, save/download
|
||||
|
||||
---
|
||||
|
||||
## Architectural Improvements (4.1.0)
|
||||
|
||||
### Consolidated Channel Key Resolution
|
||||
|
||||
Moved `resolve_channel_key()` from 3 duplicate implementations to single source of truth in `src/stegasoo/channel.py`:
|
||||
|
||||
```python
|
||||
# Library: src/stegasoo/channel.py
|
||||
def resolve_channel_key(value, *, file_path=None, no_channel=False) -> str | None:
|
||||
"""Unified channel key resolution - returns None (auto), "" (public), or key."""
|
||||
|
||||
def get_channel_response_info(channel_key) -> dict:
|
||||
"""Get channel info dict for API/WebUI responses."""
|
||||
```
|
||||
|
||||
Frontends now use thin wrappers that translate exceptions to their context (Click/HTTP).
|
||||
|
||||
### DCT Payload Pre-Check
|
||||
|
||||
Added `will_fit_by_mode()` pre-check to WebUI encode to fail fast with helpful error message instead of cryptic exception deep in DCT processing.
|
||||
|
||||
### EXIF Tools (Library Layer)
|
||||
|
||||
Added to `src/stegasoo/utils.py`:
|
||||
- `read_image_exif(image_data)` - Read EXIF metadata as dict
|
||||
- `write_image_exif(image_data, updates)` - Update EXIF fields (JPEG only)
|
||||
|
||||
Dependencies added: `piexif>=1.1.0`
|
||||
|
||||
---
|
||||
|
||||
## Action Item: Architectural Review ✅ DONE
|
||||
|
||||
Reviewed modules for consistency with Library → CLI → API → WebUI pattern:
|
||||
|
||||
| Module | Library | CLI | API | WebUI | Status |
|
||||
|--------|---------|-----|-----|-------|--------|
|
||||
| encode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
||||
| decode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
||||
| channel | ✓ | ✓ | ✓ | ✓ | Consolidated resolve_channel_key |
|
||||
| tools | ✓ | ✓ | ✓ | ✓ | Complete |
|
||||
| generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` |
|
||||
|
||||
Priority order: Developer/CLI → API integrator → WebUI end-user
|
||||
|
||||
---
|
||||
|
||||
## Admin Recovery System (4.1.0) ✅ DONE
|
||||
|
||||
Password reset capability for locked-out admins with multiple backup options.
|
||||
|
||||
### Library Layer (`src/stegasoo/recovery.py`)
|
||||
|
||||
```python
|
||||
# Key generation and validation
|
||||
generate_recovery_key() -> str # XXXX-XXXX-XXXX-... (32 chars)
|
||||
hash_recovery_key(key) -> str # SHA-256 for storage
|
||||
verify_recovery_key(key, hash) -> bool
|
||||
|
||||
# QR code (obfuscated - scans as gibberish)
|
||||
obfuscate_key(key) -> str # XOR with RECOVERY_OBFUSCATION_KEY
|
||||
deobfuscate_key(data) -> str | None
|
||||
generate_recovery_qr(key) -> bytes # PNG with obfuscated data
|
||||
extract_key_from_qr(image) -> str | None
|
||||
|
||||
# Stego backup (hide key in an image)
|
||||
create_stego_backup(key, carrier_image) -> bytes
|
||||
extract_stego_backup(stego_image, reference) -> str | None
|
||||
```
|
||||
|
||||
### Database (`app_settings` table)
|
||||
|
||||
- `recovery_key_hash` - SHA-256 of recovery key (or null if disabled)
|
||||
|
||||
### Web Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/setup/recovery` | GET, POST | Step 2 of initial setup |
|
||||
| `/recover` | GET, POST | Password reset page |
|
||||
| `/recover/stego` | POST | Extract key from stego backup |
|
||||
| `/account/recovery/regenerate` | GET, POST | Generate new key |
|
||||
| `/account/recovery/disable` | POST | Remove recovery option |
|
||||
| `/account/recovery/stego-backup` | POST | Create stego backup |
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
stegasoo admin recover --db path/to/stegasoo.db # Reset password
|
||||
stegasoo admin generate-key [--qr] # Generate key (reference)
|
||||
```
|
||||
|
||||
### Security Model
|
||||
|
||||
1. Recovery key shown once during setup - only hash stored
|
||||
2. QR codes XOR'd with `RECOVERY_OBFUSCATION_KEY` (fixed in constants.py)
|
||||
3. Stego backups use fixed internal passphrase/PIN - security is obscurity
|
||||
4. Instance-bound: recovery key hash must match in target database
|
||||
5. Options: text file, QR image, stego image, or no recovery (most secure)
|
||||
221
PLAN-4.1.2.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Stegasoo 4.1.2 Plan
|
||||
|
||||
## Release Theme
|
||||
Polish and UX improvements after the 4.1.1 stability release.
|
||||
|
||||
---
|
||||
|
||||
## 1. Real Progress Bar for Encode/Decode
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
|
||||
|
||||
**Solution:** Polling + progress file approach
|
||||
|
||||
### Backend Changes
|
||||
|
||||
1. **dct_steganography.py** - Write progress during block loop:
|
||||
```python
|
||||
if progress_file and block_num % 50 == 0:
|
||||
with open(progress_file, 'w') as f:
|
||||
json.dump({"current": block_num, "total": total_blocks, "phase": "embedding"}, f)
|
||||
```
|
||||
|
||||
2. **app.py** - New endpoints:
|
||||
- `POST /encode` returns `job_id`, starts subprocess
|
||||
- `GET /encode/progress/<job_id>` returns progress JSON
|
||||
- `GET /encode/result/<job_id>` returns final result when done
|
||||
|
||||
3. **Subprocess wrapper** - Pass progress file path to encode/decode functions
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
1. **stegasoo.js** - After form submit:
|
||||
- Show progress bar (Bootstrap progress component)
|
||||
- Poll `/encode/progress/{job_id}` every 500ms
|
||||
- Update bar width and percentage text
|
||||
- Show phase (hashing, embedding, encoding, etc.)
|
||||
|
||||
2. **Templates** - Add progress bar markup to encode.html and decode.html
|
||||
|
||||
### Files to Modify
|
||||
- `src/stegasoo/dct_steganography.py`
|
||||
- `frontends/web/app.py`
|
||||
- `frontends/web/static/js/stegasoo.js`
|
||||
- `frontends/web/templates/encode.html`
|
||||
- `frontends/web/templates/decode.html`
|
||||
|
||||
---
|
||||
|
||||
## 2. Granular Decode Error Messages
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
|
||||
|
||||
**Solution:** Bubble up specific error types from library to UI
|
||||
|
||||
### Library Level (`src/stegasoo/`)
|
||||
|
||||
1. **Custom exception classes:**
|
||||
```python
|
||||
class StegasooError(Exception): pass
|
||||
class InvalidMagicBytesError(StegasooError): pass
|
||||
class DecryptionError(StegasooError): pass
|
||||
class ReedSolomonError(StegasooError): pass
|
||||
class PayloadTooLargeError(StegasooError): pass
|
||||
class InvalidHeaderError(StegasooError): pass
|
||||
class NoDataFoundError(StegasooError): pass
|
||||
```
|
||||
|
||||
2. **Raise specific exceptions** in decode paths:
|
||||
- Magic bytes mismatch → "Not a Stegasoo image or wrong mode (LSB/DCT)"
|
||||
- RS decode failure → "Image corrupted beyond repair"
|
||||
- AES-GCM auth fail → "Wrong credentials (photo/passphrase/PIN)"
|
||||
- Header parse fail → "Invalid or corrupted header"
|
||||
- No stego data → "No hidden data found in image"
|
||||
|
||||
3. **Error codes** for programmatic handling:
|
||||
```python
|
||||
class ErrorCode(Enum):
|
||||
INVALID_MAGIC = "invalid_magic"
|
||||
DECRYPTION_FAILED = "decryption_failed"
|
||||
RS_FAILED = "rs_failed"
|
||||
# etc.
|
||||
```
|
||||
|
||||
### Web UI Level (`frontends/web/`)
|
||||
|
||||
1. **app.py** - Catch specific exceptions, return error type:
|
||||
```python
|
||||
except InvalidMagicBytesError:
|
||||
flash("This doesn't appear to be a Stegasoo image, or mode mismatch", "danger")
|
||||
except DecryptionError:
|
||||
flash("Wrong credentials - check reference photo, passphrase, and PIN", "warning")
|
||||
```
|
||||
|
||||
2. **decode.html** - Error-specific help text:
|
||||
- Wrong credentials → "Double-check your reference photo matches exactly"
|
||||
- Corrupted → "Image may have been re-saved or compressed"
|
||||
- Mode mismatch → "Try switching between Auto/DCT/LSB"
|
||||
|
||||
### Files to Modify
|
||||
- `src/stegasoo/__init__.py` (export exceptions)
|
||||
- `src/stegasoo/exceptions.py` (new file)
|
||||
- `src/stegasoo/dct_steganography.py`
|
||||
- `src/stegasoo/steganography.py` (LSB)
|
||||
- `frontends/web/app.py`
|
||||
- `frontends/web/templates/decode.html`
|
||||
|
||||
---
|
||||
|
||||
## 3. Mobile-Responsive Polish
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
|
||||
|
||||
**Solution:** Targeted CSS/layout fixes for mobile breakpoints
|
||||
|
||||
### Areas to Improve
|
||||
|
||||
1. **Encode/Decode Forms:**
|
||||
- Stack image drop zones vertically on mobile (currently side-by-side)
|
||||
- Larger touch targets for file inputs
|
||||
- Full-width buttons on small screens
|
||||
- Passphrase input readable at smaller sizes
|
||||
|
||||
2. **Navigation:**
|
||||
- Hamburger menu for mobile navbar (if not already)
|
||||
- Sticky header doesn't eat too much screen
|
||||
- Easy thumb reach for main actions
|
||||
|
||||
3. **Results/Output:**
|
||||
- Download buttons full-width on mobile
|
||||
- QR codes sized appropriately
|
||||
- Click-to-copy message box works well with touch
|
||||
|
||||
4. **Drop Zones:**
|
||||
- Larger tap targets
|
||||
- Visual feedback for touch (not just hover)
|
||||
- Camera integration hint on mobile ("Tap to take photo or choose file")
|
||||
|
||||
### Testing Targets
|
||||
- iPhone SE (small)
|
||||
- iPhone 14 (medium)
|
||||
- iPad (tablet)
|
||||
- Android Chrome
|
||||
|
||||
### Files to Modify
|
||||
- `frontends/web/static/css/style.css` (or new mobile.css)
|
||||
- `frontends/web/templates/encode.html`
|
||||
- `frontends/web/templates/decode.html`
|
||||
- `frontends/web/templates/base.html` (navbar)
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Progress bar works on localhost
|
||||
- [ ] Progress bar works on Pi (slower, more visible)
|
||||
- [ ] Cancellation handling (what if user navigates away?)
|
||||
- [ ] Error states display correctly
|
||||
- [ ] Smoke test passes
|
||||
|
||||
---
|
||||
|
||||
## 4. Forced First-Login Setup
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
|
||||
|
||||
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
|
||||
|
||||
### Files to Modify
|
||||
- `frontends/web/app.py` (add before_request check)
|
||||
- `frontends/web/templates/setup.html` (ensure it blocks other nav)
|
||||
|
||||
---
|
||||
|
||||
## 5. Dropzone UX Fixes
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** Dropzone has some interaction bugs:
|
||||
- Dropzone doesn't clear properly if first QR image fails
|
||||
- Can't click on image preview to replace file (have to click surrounding border)
|
||||
|
||||
**Solution:** Fix JS event handling and state management
|
||||
|
||||
### Files to Modify
|
||||
- `frontends/web/static/js/stegasoo.js`
|
||||
- `frontends/web/static/css/style.css` (clickable preview)
|
||||
|
||||
---
|
||||
|
||||
## 6. Smoke Test Benchmarking
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** No way to measure encode/decode performance or track regressions.
|
||||
|
||||
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
||||
|
||||
### Features
|
||||
- Benchmark encode/decode on test images
|
||||
- Output timing stats (min/max/avg)
|
||||
- Optional `--benchmark` flag for smoke-test.sh
|
||||
- Compare NVMe vs SD card, overclocked vs stock
|
||||
|
||||
### Files to Modify
|
||||
- `rpi/smoke-test.sh`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep 4.1.2 focused - 6 small features
|
||||
- Don't break DCT compatibility (4.1.1 RS format is stable)
|
||||
- Test on Pi before release
|
||||
42
PLAN-4.1.3.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Stegasoo 4.1.3 Plan
|
||||
|
||||
## Release Theme
|
||||
Performance and admin features.
|
||||
|
||||
---
|
||||
|
||||
## 1. DCT Performance Optimizations
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** DCT encode/decode can be slow on Pi, especially for large images.
|
||||
|
||||
**Ideas:**
|
||||
- Vectorize block processing with NumPy
|
||||
- Reduce Python loop overhead
|
||||
- Parallel block processing (multiprocessing?)
|
||||
- Profile and identify bottlenecks
|
||||
- Consider Cython for hot paths
|
||||
|
||||
---
|
||||
|
||||
## 2. User Management UI
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** No way for admin to manage users via UI. Currently need direct DB access.
|
||||
|
||||
**Features:**
|
||||
- List all users
|
||||
- Create new user (admin only)
|
||||
- Delete user (admin only)
|
||||
- Reset user password
|
||||
- User activity/last login
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- These are heavier lifts than 4.1.2
|
||||
- Profile before optimizing
|
||||
- Consider security implications of user management
|
||||
297
README.md
@@ -2,272 +2,121 @@
|
||||
|
||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
||||
|
||||

|
||||

|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
|
||||

|
||||
[](LICENSE)
|
||||

|
||||
|
||||
## 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 |
|
||||
|:----:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
|
||||
## 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
@@ -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
@@ -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
|
||||
BIN
data/WebUI.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
data/WebUI_About.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
data/WebUI_Account.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
data/WebUI_Decode.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
data/WebUI_Encode.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
data/WebUI_Generate.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
data/WebUI_Login.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
data/WebUI_Setup.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
@@ -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
|
||||
|
||||
361
docs/TEMPLATES.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Stegasoo Web Templates Specification
|
||||
|
||||
Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Layout](#layout)
|
||||
- [Auth & Setup](#auth--setup)
|
||||
- [Core Features](#core-features)
|
||||
- [Tools & Account](#tools--account)
|
||||
- [Admin](#admin)
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### `base.html`
|
||||
**Purpose:** Master layout template - all pages extend this.
|
||||
|
||||
| Block | Description |
|
||||
|-------|-------------|
|
||||
| `{% block title %}` | Page title |
|
||||
| `{% block content %}` | Main page content |
|
||||
| `{% block scripts %}` | Page-specific JS |
|
||||
|
||||
**Key Elements:**
|
||||
- `nav.navbar` - Bootstrap 5 navbar with logo, links, auth buttons
|
||||
- `div.toast-container` - Flash message toasts (10s auto-dismiss)
|
||||
- `main.container` - Content wrapper
|
||||
- `footer` - Copyright + version
|
||||
|
||||
**Variables:** `is_authenticated`, `username`, `is_admin`
|
||||
|
||||
---
|
||||
|
||||
## Auth & Setup
|
||||
|
||||
### `login.html`
|
||||
**Route:** `/login`
|
||||
|
||||
**Form:** `POST /login`
|
||||
- `username` - text input
|
||||
- `password` - password input
|
||||
- "Forgot password?" link to `/recover`
|
||||
|
||||
**JS:** `static/js/auth.js` - password toggle
|
||||
|
||||
---
|
||||
|
||||
### `setup.html`
|
||||
**Route:** `/setup` (first-run only)
|
||||
|
||||
**Form:** `POST /setup`
|
||||
- `username` - admin username
|
||||
- `password` - password (min 8 chars)
|
||||
- `password_confirm` - confirmation
|
||||
|
||||
**JS:** Password confirmation validation
|
||||
|
||||
---
|
||||
|
||||
### `setup_recovery.html`
|
||||
**Route:** `/setup/recovery`
|
||||
|
||||
**Form:** `POST /setup/recovery`
|
||||
- `recovery_key` - hidden, pre-generated
|
||||
- `action` - "save" or "skip"
|
||||
- Checkbox confirmation required for save
|
||||
|
||||
**Features:**
|
||||
- Recovery key display (readonly input)
|
||||
- Copy to clipboard button
|
||||
- QR code image (if available)
|
||||
- Download options: text file, QR image
|
||||
- Stego backup upload form
|
||||
|
||||
---
|
||||
|
||||
### `recover.html`
|
||||
**Route:** `/recover`
|
||||
|
||||
**Form:** `POST /recover`
|
||||
- `recovery_key` - textarea for key input
|
||||
- `new_password` - new password
|
||||
- `new_password_confirm` - confirmation
|
||||
|
||||
**Accordion:** "Extract from stego backup"
|
||||
- `POST /recover/stego` with `stego_image` + `reference_image`
|
||||
- Pre-fills recovery key on success
|
||||
|
||||
---
|
||||
|
||||
### `regenerate_recovery.html`
|
||||
**Route:** `/account/recovery/regenerate` (admin only)
|
||||
|
||||
**Form:** `POST /account/recovery/regenerate`
|
||||
- `recovery_key` - hidden field
|
||||
- `action` - "save" or "cancel"
|
||||
- Confirmation checkbox
|
||||
|
||||
**Features:**
|
||||
- New key display
|
||||
- QR code (obfuscated)
|
||||
- Download: text, QR, stego backup
|
||||
- Warning if replacing existing key
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### `index.html`
|
||||
**Route:** `/`
|
||||
|
||||
**Structure:**
|
||||
- Hero section with tagline
|
||||
- 3 action cards: Encode, Decode, Generate
|
||||
- "How It Works" explainer section
|
||||
|
||||
---
|
||||
|
||||
### `generate.html`
|
||||
**Route:** `/generate`
|
||||
|
||||
**Form:** `POST /generate`
|
||||
- `words` - passphrase word count (3-12)
|
||||
- `use_pin` - checkbox
|
||||
- `pin_length` - PIN digits (6-9)
|
||||
- `use_rsa` - checkbox
|
||||
- `rsa_bits` - key size (2048/3072/4096)
|
||||
|
||||
**Output panels:**
|
||||
- Passphrase display
|
||||
- PIN display (if enabled)
|
||||
- RSA key + QR (if enabled)
|
||||
- Entropy calculator
|
||||
|
||||
**JS:** `static/js/generate.js`
|
||||
|
||||
---
|
||||
|
||||
### `encode.html`
|
||||
**Route:** `/encode`
|
||||
|
||||
**Form:** `POST /encode` (multipart)
|
||||
- `reference_photo` - file upload (drag-drop zone)
|
||||
- `carrier_image` - file upload (drag-drop zone)
|
||||
- `mode` - radio: DCT (default) / LSB
|
||||
- `dct_format` - PNG / JPEG
|
||||
- `dct_color` - Color / Grayscale
|
||||
- `payload_type` - radio: Text / File
|
||||
- `message` - textarea (if text)
|
||||
- `embed_file` - file input (if file)
|
||||
- `passphrase` - text input
|
||||
- `pin` - text input
|
||||
- `rsa_key` / `rsa_key_qr` - file inputs
|
||||
- `rsa_key_password` - password
|
||||
- `channel_key` - select (saved keys) or manual input
|
||||
|
||||
**Panels:**
|
||||
- Reference preview with "Hash Acquired" status
|
||||
- Carrier preview with capacity info
|
||||
- Character counter for message
|
||||
|
||||
**JS:** `static/js/encode.js`, `static/js/stegasoo.js`
|
||||
|
||||
---
|
||||
|
||||
### `encode_result.html`
|
||||
**Route:** `/encode/result/<file_id>`
|
||||
|
||||
**Elements:**
|
||||
- Success message
|
||||
- Stego image preview
|
||||
- Download button
|
||||
- Share button (Web Share API)
|
||||
- Mode/capacity info
|
||||
- "Encode Another" link
|
||||
|
||||
**Variables:** `file_id`, `filename`, `mode`, `capacity_used`
|
||||
|
||||
---
|
||||
|
||||
### `decode.html`
|
||||
**Route:** `/decode`
|
||||
|
||||
**Form:** `POST /decode` (multipart)
|
||||
- `reference_photo` - file upload
|
||||
- `stego_image` - file upload
|
||||
- `passphrase` - text input
|
||||
- `pin` - text input
|
||||
- `rsa_key` / `rsa_key_qr` - file inputs
|
||||
- `rsa_key_password` - password
|
||||
- `channel_key` - select or manual
|
||||
|
||||
**Output:**
|
||||
- Decoded message display
|
||||
- File download (if file payload)
|
||||
|
||||
**JS:** `static/js/decode.js`, `static/js/stegasoo.js`
|
||||
|
||||
---
|
||||
|
||||
## Tools & Account
|
||||
|
||||
### `tools.html`
|
||||
**Route:** `/tools`
|
||||
|
||||
**Tabbed interface:**
|
||||
|
||||
| Tab | Endpoint | Description |
|
||||
|-----|----------|-------------|
|
||||
| Capacity | `POST /api/tools/capacity` | Image capacity analysis |
|
||||
| Peek | `POST /api/tools/peek` | Check for Stegasoo header |
|
||||
| Strip | `POST /api/tools/strip` | Remove hidden data |
|
||||
| EXIF | `POST /api/tools/exif/*` | Metadata viewer/editor |
|
||||
|
||||
**EXIF Editor features:**
|
||||
- Upload image → view all EXIF fields
|
||||
- Inline editing (click field to edit)
|
||||
- "Clear All" button
|
||||
- "Save" / "Download" buttons
|
||||
|
||||
**JS:** `static/js/tools.js`
|
||||
|
||||
---
|
||||
|
||||
### `account.html`
|
||||
**Route:** `/account`
|
||||
|
||||
**Sections:**
|
||||
|
||||
1. **User Info** - Username, role badge, logout link
|
||||
|
||||
2. **Recovery Key** (admin only)
|
||||
- Status: Configured / Not Set
|
||||
- Generate/Regenerate button
|
||||
- Disable button
|
||||
|
||||
3. **Password Change**
|
||||
- `current_password`
|
||||
- `new_password`
|
||||
- `new_password_confirm`
|
||||
|
||||
4. **Saved Channel Keys**
|
||||
- List of saved keys with edit/delete
|
||||
- "Add Key" form (name + key)
|
||||
- Max 10 keys per user
|
||||
|
||||
**Variables:** `username`, `is_admin`, `has_recovery`, `channel_keys`
|
||||
|
||||
---
|
||||
|
||||
### `about.html`
|
||||
**Route:** `/about`
|
||||
|
||||
**Sections:**
|
||||
- Version info + feature badges
|
||||
- Security model explanation
|
||||
- Channel key QR (if configured)
|
||||
- Dependency status table
|
||||
- Credits + links
|
||||
|
||||
**Variables:** `version`, `has_dct`, `has_qr_write`, `has_qr_read`, `channel_key`, `channel_qr`
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
### `admin/users.html`
|
||||
**Route:** `/admin/users`
|
||||
|
||||
**Table columns:** Username | Role | Created | Actions
|
||||
|
||||
**Actions per user:**
|
||||
- Reset Password button
|
||||
- Delete button (disabled for self)
|
||||
|
||||
**Header:**
|
||||
- User count: "X of 16 users"
|
||||
- "Add User" button (modal trigger)
|
||||
|
||||
**Modal:** Add User form
|
||||
- `username` input
|
||||
- `role` select (admin/user)
|
||||
- Auto-generated temp password display
|
||||
|
||||
---
|
||||
|
||||
### `admin/user_new.html`
|
||||
**Route:** `/admin/users/new`
|
||||
|
||||
**Form:** `POST /admin/users/new`
|
||||
- `username` - text input
|
||||
- `role` - select (user/admin)
|
||||
|
||||
Redirects to `user_created.html` on success.
|
||||
|
||||
---
|
||||
|
||||
### `admin/user_created.html`
|
||||
**Route:** `/admin/users/created`
|
||||
|
||||
**Display:**
|
||||
- Success message
|
||||
- Username
|
||||
- Temporary password (copy button)
|
||||
- "User must change password on first login" notice
|
||||
- Back to users link
|
||||
|
||||
---
|
||||
|
||||
### `admin/password_reset.html`
|
||||
**Route:** `/admin/users/<id>/password-reset`
|
||||
|
||||
**Display:**
|
||||
- Success message
|
||||
- New temporary password
|
||||
- Copy button
|
||||
- Back link
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Drag-Drop Upload Zones
|
||||
```html
|
||||
<div class="upload-zone" id="referenceZone">
|
||||
<input type="file" name="reference_photo" accept="image/*">
|
||||
<div class="preview"></div>
|
||||
<div class="status"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Password Toggle
|
||||
```html
|
||||
<div class="input-group">
|
||||
<input type="password" id="passwordInput">
|
||||
<button onclick="togglePassword('passwordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Toast Flash Messages
|
||||
Rendered in `base.html`, auto-dismiss after 10 seconds:
|
||||
- `success` → green
|
||||
- `warning` → yellow
|
||||
- `error` → red
|
||||
|
||||
---
|
||||
|
||||
## External JS Files
|
||||
|
||||
| File | Used By |
|
||||
|------|---------|
|
||||
| `static/js/stegasoo.js` | encode, decode, about |
|
||||
| `static/js/auth.js` | login, setup, recover, account |
|
||||
| `static/js/generate.js` | generate |
|
||||
| `static/js/encode.js` | encode |
|
||||
| `static/js/decode.js` | decode |
|
||||
| `static/js/tools.js` | tools |
|
||||
48
examples/README.md
Normal 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
@@ -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
@@ -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
@@ -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()
|
||||
952
frontends/API.md
@@ -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
|
||||
634
frontends/CLI.md
@@ -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
|
||||
@@ -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
|
||||
16
frontends/web/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Stegasoo Web UI Configuration
|
||||
# Copy this file to .env and customize
|
||||
|
||||
# Authentication (v4.0.2+)
|
||||
STEGASOO_AUTH_ENABLED=true
|
||||
STEGASOO_HTTPS_ENABLED=false
|
||||
STEGASOO_HOSTNAME=localhost
|
||||
STEGASOO_PORT=5000
|
||||
|
||||
# Channel Key (format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
|
||||
# Generate with: stegasoo generate --channel-key
|
||||
# Leave empty for public mode
|
||||
STEGASOO_CHANNEL_KEY=
|
||||
|
||||
# Flask settings
|
||||
FLASK_ENV=production
|
||||
62
frontends/web/README_subprocess.md
Normal 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.
|
||||
2067
frontends/web/app.py
979
frontends/web/auth.py
Normal file
@@ -0,0 +1,979 @@
|
||||
"""
|
||||
Stegasoo Authentication Module (v4.1.0)
|
||||
|
||||
Multi-user authentication with role-based access control.
|
||||
- Admin user created at first-run setup
|
||||
- Admin can create up to 16 additional users
|
||||
- Uses Argon2id password hashing
|
||||
- Flask sessions for authentication state
|
||||
- SQLite3 for user storage
|
||||
"""
|
||||
|
||||
import functools
|
||||
import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from flask import current_app, flash, 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,
|
||||
)
|
||||
|
||||
# Constants
|
||||
MAX_USERS = 16 # Plus 1 admin = 17 total
|
||||
MAX_CHANNEL_KEYS = 10 # Per user
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_USER = "user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""User data class."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
created_at: str
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == ROLE_ADMIN
|
||||
|
||||
|
||||
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 with migration support."""
|
||||
db = get_db()
|
||||
|
||||
# Check if we need to migrate from old single-user schema
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
||||
)
|
||||
has_old_table = cursor.fetchone() is not None
|
||||
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
)
|
||||
has_new_table = cursor.fetchone() is not None
|
||||
|
||||
if has_old_table and not has_new_table:
|
||||
# Migrate from old schema
|
||||
_migrate_from_single_user(db)
|
||||
elif not has_new_table:
|
||||
# Fresh install - create new schema
|
||||
_create_schema(db)
|
||||
else:
|
||||
# Existing install - check for new tables (migrations)
|
||||
_ensure_channel_keys_table(db)
|
||||
_ensure_app_settings_table(db)
|
||||
|
||||
|
||||
def _create_schema(db: sqlite3.Connection):
|
||||
"""Create the multi-user schema."""
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
channel_key TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, channel_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
|
||||
|
||||
-- App-level settings (v4.1.0)
|
||||
-- Stores recovery key hash and other instance-wide settings
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _migrate_from_single_user(db: sqlite3.Connection):
|
||||
"""Migrate from old single-user admin_user table to multi-user users table."""
|
||||
# Create new table
|
||||
_create_schema(db)
|
||||
|
||||
# Copy admin user from old table
|
||||
old_user = db.execute(
|
||||
"SELECT username, password_hash, created_at FROM admin_user WHERE id = 1"
|
||||
).fetchone()
|
||||
|
||||
if old_user:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, role, created_at)
|
||||
VALUES (?, ?, 'admin', ?)
|
||||
""",
|
||||
(old_user["username"], old_user["password_hash"], old_user["created_at"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Drop old table
|
||||
db.execute("DROP TABLE admin_user")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_channel_keys_table(db: sqlite3.Connection):
|
||||
"""Ensure user_channel_keys table exists (migration for existing installs)."""
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_channel_keys'"
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
channel_key TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, channel_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Settings (v4.1.0)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_app_setting(key: str) -> str | None:
|
||||
"""Get an app-level setting value."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def set_app_setting(key: str, value: str) -> None:
|
||||
"""Set an app-level setting value."""
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO app_settings (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, value, value),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_app_setting(key: str) -> bool:
|
||||
"""Delete an app-level setting. Returns True if deleted."""
|
||||
db = get_db()
|
||||
cursor = db.execute("DELETE FROM app_settings WHERE key = ?", (key,))
|
||||
db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Recovery Key Management (v4.1.0)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Setting key for recovery hash
|
||||
RECOVERY_KEY_SETTING = "recovery_key_hash"
|
||||
|
||||
|
||||
def has_recovery_key() -> bool:
|
||||
"""Check if a recovery key has been configured."""
|
||||
return get_app_setting(RECOVERY_KEY_SETTING) is not None
|
||||
|
||||
|
||||
def get_recovery_key_hash() -> str | None:
|
||||
"""Get the stored recovery key hash."""
|
||||
return get_app_setting(RECOVERY_KEY_SETTING)
|
||||
|
||||
|
||||
def set_recovery_key_hash(key_hash: str) -> None:
|
||||
"""Store a recovery key hash."""
|
||||
set_app_setting(RECOVERY_KEY_SETTING, key_hash)
|
||||
|
||||
|
||||
def clear_recovery_key() -> bool:
|
||||
"""Remove the recovery key. Returns True if removed."""
|
||||
return delete_app_setting(RECOVERY_KEY_SETTING)
|
||||
|
||||
|
||||
def verify_and_reset_admin_password(recovery_key: str, new_password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify recovery key and reset the first admin's password.
|
||||
|
||||
Args:
|
||||
recovery_key: User-provided recovery key
|
||||
new_password: New password to set
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
from stegasoo.recovery import verify_recovery_key
|
||||
|
||||
stored_hash = get_recovery_key_hash()
|
||||
if not stored_hash:
|
||||
return False, "No recovery key configured for this instance"
|
||||
|
||||
if not verify_recovery_key(recovery_key, stored_hash):
|
||||
return False, "Invalid recovery key"
|
||||
|
||||
# Find first admin user
|
||||
db = get_db()
|
||||
admin = db.execute(
|
||||
"SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
if not admin:
|
||||
return False, "No admin user found"
|
||||
|
||||
# Reset password
|
||||
new_hash = ph.hash(new_password)
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, admin["id"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Invalidate all sessions for this user
|
||||
invalidate_user_sessions(admin["id"])
|
||||
|
||||
return True, f"Password reset for '{admin['username']}'"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Queries
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def any_users_exist() -> bool:
|
||||
"""Check if any users have been created (for first-run detection)."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT 1 FROM users LIMIT 1").fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def user_exists() -> bool:
|
||||
"""Alias for any_users_exist() for backwards compatibility."""
|
||||
return any_users_exist()
|
||||
|
||||
|
||||
def get_user_count() -> int:
|
||||
"""Get total number of users."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT COUNT(*) FROM users").fetchone()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def get_non_admin_count() -> int:
|
||||
"""Get number of non-admin users."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT COUNT(*) FROM users WHERE role != 'admin'").fetchone()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def can_create_user() -> bool:
|
||||
"""Check if we can create more users (within limit)."""
|
||||
return get_non_admin_count() < MAX_USERS
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> User | None:
|
||||
"""Get user by ID."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, username, role, created_at FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_username(username: str) -> User | None:
|
||||
"""Get user by username."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, username, role, created_at FROM users WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_all_users() -> list[User]:
|
||||
"""Get all users, admins first, then by creation date."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, username, role, created_at FROM users
|
||||
ORDER BY role = 'admin' DESC, created_at ASC
|
||||
"""
|
||||
).fetchall()
|
||||
return [
|
||||
User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_current_user() -> User | None:
|
||||
"""Get the currently logged-in user from session."""
|
||||
user_id = session.get("user_id")
|
||||
if user_id:
|
||||
return get_user_by_id(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_username() -> str:
|
||||
"""Get current user's username (backwards compatibility)."""
|
||||
user = get_current_user()
|
||||
return user.username if user else "unknown"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def verify_user_password(username: str, password: str) -> User | None:
|
||||
"""
|
||||
Verify password for a user.
|
||||
|
||||
Returns User if valid, None if invalid.
|
||||
Also rehashes password if needed.
|
||||
"""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, username, role, created_at, password_hash FROM users WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
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 users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, row["id"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
except VerifyMismatchError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""Verify password for current user (backwards compatibility)."""
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return False
|
||||
result = verify_user_password(user.username, password)
|
||||
return result is not None
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
"""Check if current session is authenticated."""
|
||||
return session.get("user_id") is not None
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""Check if current user is an admin."""
|
||||
user = get_current_user()
|
||||
return user.is_admin if user else False
|
||||
|
||||
|
||||
def login_user(user: User):
|
||||
"""Set up session for logged-in user."""
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
session["role"] = user.role
|
||||
# Legacy compatibility
|
||||
session["authenticated"] = True
|
||||
|
||||
|
||||
def logout_user():
|
||||
"""Clear session for logout."""
|
||||
session.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_temp_password(length: int = 8) -> str:
|
||||
"""Generate a random temporary password."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def validate_username(username: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate username format.
|
||||
|
||||
Rules: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
||||
"""
|
||||
if not username:
|
||||
return False, "Username is required"
|
||||
|
||||
if len(username) < 3:
|
||||
return False, "Username must be at least 3 characters"
|
||||
|
||||
if len(username) > 80:
|
||||
return False, "Username must be at most 80 characters"
|
||||
|
||||
# Allow: alphanumeric, underscore, hyphen, @, . (for email-style)
|
||||
allowed = set(string.ascii_letters + string.digits + "_-@.")
|
||||
if not all(c in allowed for c in username):
|
||||
return False, "Username can only contain letters, numbers, underscore, hyphen, @ and ."
|
||||
|
||||
# Must start with letter or number
|
||||
if username[0] not in string.ascii_letters + string.digits:
|
||||
return False, "Username must start with a letter or number"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_password(password: str) -> tuple[bool, str]:
|
||||
"""Validate password requirements."""
|
||||
if not password:
|
||||
return False, "Password is required"
|
||||
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str, password: str, role: str = ROLE_USER
|
||||
) -> tuple[bool, str, User | None]:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Returns (success, message, user).
|
||||
"""
|
||||
# Validate username
|
||||
valid, msg = validate_username(username)
|
||||
if not valid:
|
||||
return False, msg, None
|
||||
|
||||
# Validate password
|
||||
valid, msg = validate_password(password)
|
||||
if not valid:
|
||||
return False, msg, None
|
||||
|
||||
# Check if username already exists
|
||||
if get_user_by_username(username):
|
||||
return False, "Username already exists", None
|
||||
|
||||
# Check user limit (only for non-admin users)
|
||||
if role != ROLE_ADMIN and not can_create_user():
|
||||
return False, f"Maximum of {MAX_USERS} users reached", None
|
||||
|
||||
# Create user
|
||||
password_hash = ph.hash(password)
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(username, password_hash, role),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = get_user_by_id(cursor.lastrowid)
|
||||
return True, "User created successfully", user
|
||||
except sqlite3.IntegrityError:
|
||||
return False, "Username already exists", None
|
||||
|
||||
|
||||
def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create the initial admin user (first-run setup)."""
|
||||
if any_users_exist():
|
||||
return False, "Admin user already exists"
|
||||
|
||||
success, msg, _ = create_user(username, password, ROLE_ADMIN)
|
||||
return success, msg
|
||||
|
||||
|
||||
def change_password(
|
||||
user_id: int, current_password: str, new_password: str
|
||||
) -> tuple[bool, str]:
|
||||
"""Change a user's password (requires current password)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "User not found"
|
||||
|
||||
# Verify current password
|
||||
if not verify_user_password(user.username, current_password):
|
||||
return False, "Current password is incorrect"
|
||||
|
||||
# Validate new password
|
||||
valid, msg = validate_password(new_password)
|
||||
if not valid:
|
||||
return False, msg
|
||||
|
||||
# Update password
|
||||
new_hash = ph.hash(new_password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, user_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return True, "Password changed successfully"
|
||||
|
||||
|
||||
def reset_user_password(user_id: int, new_password: str) -> tuple[bool, str]:
|
||||
"""Reset a user's password (admin function, no current password required)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "User not found"
|
||||
|
||||
# Validate new password
|
||||
valid, msg = validate_password(new_password)
|
||||
if not valid:
|
||||
return False, msg
|
||||
|
||||
# Update password
|
||||
new_hash = ph.hash(new_password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, user_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Invalidate user's sessions
|
||||
invalidate_user_sessions(user_id)
|
||||
|
||||
return True, "Password reset successfully"
|
||||
|
||||
|
||||
def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Delete a user.
|
||||
|
||||
Cannot delete yourself or the last admin.
|
||||
"""
|
||||
if user_id == current_user_id:
|
||||
return False, "Cannot delete yourself"
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "User not found"
|
||||
|
||||
# Check if this is the last admin
|
||||
if user.role == ROLE_ADMIN:
|
||||
db = get_db()
|
||||
admin_count = db.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
||||
).fetchone()[0]
|
||||
if admin_count <= 1:
|
||||
return False, "Cannot delete the last admin"
|
||||
|
||||
# Invalidate user's sessions before deletion
|
||||
invalidate_user_sessions(user_id)
|
||||
|
||||
# Delete user
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
db.commit()
|
||||
|
||||
return True, f"User '{user.username}' deleted"
|
||||
|
||||
|
||||
def invalidate_user_sessions(user_id: int):
|
||||
"""
|
||||
Invalidate all sessions for a user.
|
||||
|
||||
This is called when a user is deleted or their password is reset.
|
||||
Since we use server-side sessions, we increment a "session version"
|
||||
that's checked on each request.
|
||||
"""
|
||||
# For Flask's default session (client-side), we can't truly invalidate.
|
||||
# But we can add a check - store a "valid_from" timestamp in the DB
|
||||
# and compare against session creation time.
|
||||
#
|
||||
# For now, we'll use a simpler approach: store invalidated user IDs
|
||||
# in app config (memory) which gets checked by login_required.
|
||||
#
|
||||
# This works for single-process deployments (like RPi).
|
||||
# For multi-process, would need Redis or DB-backed sessions.
|
||||
|
||||
if "invalidated_users" not in current_app.config:
|
||||
current_app.config["invalidated_users"] = set()
|
||||
|
||||
current_app.config["invalidated_users"].add(user_id)
|
||||
|
||||
|
||||
def is_session_valid() -> bool:
|
||||
"""Check if current session is still valid (user not deleted/invalidated)."""
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
# Check if user was invalidated
|
||||
invalidated = current_app.config.get("invalidated_users", set())
|
||||
if user_id in invalidated:
|
||||
return False
|
||||
|
||||
# Check if user still exists
|
||||
if not get_user_by_id(user_id):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channel Keys
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelKey:
|
||||
"""Saved channel key data class."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
channel_key: str
|
||||
created_at: str
|
||||
last_used_at: str | None
|
||||
|
||||
|
||||
def get_user_channel_keys(user_id: int) -> list[ChannelKey]:
|
||||
"""Get all saved channel keys for a user, most recently used first."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, user_id, name, channel_key, created_at, last_used_at
|
||||
FROM user_channel_keys
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_used_at DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
ChannelKey(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
name=row["name"],
|
||||
channel_key=row["channel_key"],
|
||||
created_at=row["created_at"],
|
||||
last_used_at=row["last_used_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_channel_key_by_id(key_id: int, user_id: int) -> ChannelKey | None:
|
||||
"""Get a specific channel key (ensures user owns it)."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, user_id, name, channel_key, created_at, last_used_at
|
||||
FROM user_channel_keys
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(key_id, user_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return ChannelKey(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
name=row["name"],
|
||||
channel_key=row["channel_key"],
|
||||
created_at=row["created_at"],
|
||||
last_used_at=row["last_used_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_channel_key_count(user_id: int) -> int:
|
||||
"""Get count of saved channel keys for a user."""
|
||||
db = get_db()
|
||||
result = db.execute(
|
||||
"SELECT COUNT(*) FROM user_channel_keys WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def can_save_channel_key(user_id: int) -> bool:
|
||||
"""Check if user can save more channel keys (within limit)."""
|
||||
return get_channel_key_count(user_id) < MAX_CHANNEL_KEYS
|
||||
|
||||
|
||||
def save_channel_key(
|
||||
user_id: int, name: str, channel_key: str
|
||||
) -> tuple[bool, str, ChannelKey | None]:
|
||||
"""
|
||||
Save a channel key for a user.
|
||||
|
||||
Returns (success, message, key).
|
||||
"""
|
||||
# Validate name
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return False, "Key name is required", None
|
||||
if len(name) > 50:
|
||||
return False, "Key name must be at most 50 characters", None
|
||||
|
||||
# Validate channel key format (hex string)
|
||||
channel_key = channel_key.strip().lower()
|
||||
if not channel_key:
|
||||
return False, "Channel key is required", None
|
||||
if not all(c in "0123456789abcdef" for c in channel_key):
|
||||
return False, "Invalid channel key format", None
|
||||
|
||||
# Check limit
|
||||
if not can_save_channel_key(user_id):
|
||||
return False, f"Maximum of {MAX_CHANNEL_KEYS} saved keys reached", None
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO user_channel_keys (user_id, name, channel_key)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, name, channel_key),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
key = get_channel_key_by_id(cursor.lastrowid, user_id)
|
||||
return True, "Channel key saved", key
|
||||
except sqlite3.IntegrityError:
|
||||
return False, "This channel key is already saved", None
|
||||
|
||||
|
||||
def update_channel_key_name(
|
||||
key_id: int, user_id: int, new_name: str
|
||||
) -> tuple[bool, str]:
|
||||
"""Update the name of a saved channel key."""
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
return False, "Key name is required"
|
||||
if len(new_name) > 50:
|
||||
return False, "Key name must be at most 50 characters"
|
||||
|
||||
key = get_channel_key_by_id(key_id, user_id)
|
||||
if not key:
|
||||
return False, "Channel key not found"
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE user_channel_keys SET name = ? WHERE id = ? AND user_id = ?",
|
||||
(new_name, key_id, user_id),
|
||||
)
|
||||
db.commit()
|
||||
return True, "Key name updated"
|
||||
|
||||
|
||||
def update_channel_key_last_used(key_id: int, user_id: int):
|
||||
"""Update the last_used_at timestamp for a channel key."""
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE user_channel_keys
|
||||
SET last_used_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(key_id, user_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_channel_key(key_id: int, user_id: int) -> tuple[bool, str]:
|
||||
"""Delete a saved channel key."""
|
||||
key = get_channel_key_by_id(key_id, user_id)
|
||||
if not key:
|
||||
return False, "Channel key not found"
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"DELETE FROM user_channel_keys WHERE id = ? AND user_id = ?",
|
||||
(key_id, user_id),
|
||||
)
|
||||
db.commit()
|
||||
return True, f"Key '{key.name}' deleted"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decorators
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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 any_users_exist():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
# Check authentication
|
||||
if not is_authenticated():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Check if session is still valid (user not deleted)
|
||||
if not is_session_valid():
|
||||
logout_user()
|
||||
flash("Your session has expired. Please log in again.", "warning")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin role 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 any_users_exist():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
# Check authentication
|
||||
if not is_authenticated():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Check if session is still valid
|
||||
if not is_session_valid():
|
||||
logout_user()
|
||||
flash("Your session has expired. Please log in again.", "warning")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Check admin role
|
||||
if not is_admin():
|
||||
flash("Admin access required", "error")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Initialization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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
@@ -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)
|
||||
142
frontends/web/static/js/auth.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Stegasoo Authentication Pages JavaScript
|
||||
* Handles login, setup, account, and admin user management pages
|
||||
*/
|
||||
|
||||
const StegasooAuth = {
|
||||
|
||||
// ========================================================================
|
||||
// PASSWORD VISIBILITY TOGGLE
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Toggle password field visibility
|
||||
* @param {string} inputId - ID of the password input
|
||||
* @param {HTMLElement} btn - The toggle button element
|
||||
*/
|
||||
togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.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');
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// PASSWORD CONFIRMATION VALIDATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize password confirmation validation on a form
|
||||
* @param {string} formId - ID of the form
|
||||
* @param {string} passwordId - ID of the password field
|
||||
* @param {string} confirmId - ID of the confirmation field
|
||||
*/
|
||||
initPasswordConfirmation(formId, passwordId, confirmId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
const password = document.getElementById(passwordId)?.value;
|
||||
const confirm = document.getElementById(confirmId)?.value;
|
||||
|
||||
if (password !== confirm) {
|
||||
e.preventDefault();
|
||||
alert('Passwords do not match');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// COPY TO CLIPBOARD
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Copy field value to clipboard with visual feedback
|
||||
* @param {string} fieldId - ID of the input field to copy
|
||||
*/
|
||||
copyField(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
field.select();
|
||||
navigator.clipboard.writeText(field.value).then(() => {
|
||||
const btn = field.nextElementSibling;
|
||||
if (!btn) return;
|
||||
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-check"></i>';
|
||||
setTimeout(() => btn.innerHTML = originalHTML, 1000);
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// PASSWORD GENERATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Generate a random password
|
||||
* @param {number} length - Password length (default 8)
|
||||
* @returns {string} Generated password
|
||||
*/
|
||||
generatePassword(length = 8) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return password;
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate password and update input field
|
||||
* @param {string} inputId - ID of the password input
|
||||
* @param {number} length - Password length
|
||||
*/
|
||||
regeneratePassword(inputId = 'passwordInput', length = 8) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = this.generatePassword(length);
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// DELETE CONFIRMATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Confirm deletion with a prompt
|
||||
* @param {string} itemName - Name of item being deleted
|
||||
* @param {string} formId - ID of the form to submit if confirmed
|
||||
* @returns {boolean} True if confirmed
|
||||
*/
|
||||
confirmDelete(itemName, formId = null) {
|
||||
const confirmed = confirm(`Are you sure you want to delete "${itemName}"? This cannot be undone.`);
|
||||
if (confirmed && formId) {
|
||||
const form = document.getElementById(formId);
|
||||
form?.submit();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
};
|
||||
|
||||
// Make togglePassword available globally for onclick handlers
|
||||
function togglePassword(inputId, btn) {
|
||||
StegasooAuth.togglePassword(inputId, btn);
|
||||
}
|
||||
|
||||
// Make copyField available globally for onclick handlers
|
||||
function copyField(fieldId) {
|
||||
StegasooAuth.copyField(fieldId);
|
||||
}
|
||||
|
||||
// Make regeneratePassword available globally for onclick handlers
|
||||
function regeneratePassword() {
|
||||
StegasooAuth.regeneratePassword();
|
||||
}
|
||||
285
frontends/web/static/js/generate.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Stegasoo Generate Page JavaScript
|
||||
* Handles credential generation form and display
|
||||
*/
|
||||
|
||||
const StegasooGenerate = {
|
||||
|
||||
// ========================================================================
|
||||
// FORM CONTROLS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the words range slider
|
||||
*/
|
||||
initWordsSlider() {
|
||||
const wordsRange = document.getElementById('wordsRange');
|
||||
const wordsValue = document.getElementById('wordsValue');
|
||||
|
||||
wordsRange?.addEventListener('input', function() {
|
||||
const bits = this.value * 11;
|
||||
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize PIN/RSA option toggles
|
||||
*/
|
||||
initOptionToggles() {
|
||||
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');
|
||||
|
||||
usePinCheck?.addEventListener('change', function() {
|
||||
pinOptions?.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);
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// CREDENTIAL VISIBILITY
|
||||
// ========================================================================
|
||||
|
||||
pinHidden: false,
|
||||
passphraseHidden: false,
|
||||
|
||||
/**
|
||||
* Toggle PIN visibility
|
||||
*/
|
||||
togglePinVisibility() {
|
||||
const pinDigits = document.getElementById('pinDigits');
|
||||
const icon = document.getElementById('pinToggleIcon');
|
||||
const text = document.getElementById('pinToggleText');
|
||||
|
||||
this.pinHidden = !this.pinHidden;
|
||||
pinDigits?.classList.toggle('blurred', this.pinHidden);
|
||||
|
||||
if (icon) icon.className = this.pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
if (text) text.textContent = this.pinHidden ? 'Show' : 'Hide';
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle passphrase visibility
|
||||
*/
|
||||
togglePassphraseVisibility() {
|
||||
const display = document.getElementById('passphraseDisplay');
|
||||
const icon = document.getElementById('passphraseToggleIcon');
|
||||
const text = document.getElementById('passphraseToggleText');
|
||||
|
||||
this.passphraseHidden = !this.passphraseHidden;
|
||||
display?.classList.toggle('blurred', this.passphraseHidden);
|
||||
|
||||
if (icon) icon.className = this.passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
if (text) text.textContent = this.passphraseHidden ? 'Show' : 'Hide';
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// MEMORY AID STORY GENERATION
|
||||
// ========================================================================
|
||||
|
||||
currentStoryTemplate: 0,
|
||||
|
||||
/**
|
||||
* Story templates organized by word count (3-12 words supported)
|
||||
*/
|
||||
storyTemplates: {
|
||||
3: [
|
||||
w => `The ${w[0]} ${w[1]} ${w[2]}.`,
|
||||
w => `${w[0]} loves ${w[1]} and ${w[2]}.`,
|
||||
w => `A ${w[0]} found a ${w[1]} near the ${w[2]}.`,
|
||||
w => `${w[0]}, ${w[1]}, ${w[2]} — never forget.`,
|
||||
w => `The ${w[0]} hid the ${w[1]} under the ${w[2]}.`,
|
||||
],
|
||||
4: [
|
||||
w => `${w[0]} and ${w[1]} discovered a ${w[2]} made of ${w[3]}.`,
|
||||
w => `The ${w[0]} ${w[1]} ate ${w[2]} for ${w[3]}.`,
|
||||
w => `In the ${w[0]}, a ${w[1]} met a ${w[2]} carrying ${w[3]}.`,
|
||||
w => `${w[0]} said "${w[1]}" while holding a ${w[2]} ${w[3]}.`,
|
||||
w => `The secret: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}.`,
|
||||
],
|
||||
5: [
|
||||
w => `${w[0]} traveled to ${w[1]} seeking the ${w[2]} of ${w[3]} and ${w[4]}.`,
|
||||
w => `The ${w[0]} ${w[1]} lived in a ${w[2]} house with ${w[3]} ${w[4]}.`,
|
||||
w => `"${w[0]}!" shouted ${w[1]} as the ${w[2]} ${w[3]} flew toward ${w[4]}.`,
|
||||
w => `Captain ${w[0]} sailed the ${w[1]} ${w[2]} searching for ${w[3]} ${w[4]}.`,
|
||||
w => `In ${w[0]} kingdom, ${w[1]} guards protected the ${w[2]} ${w[3]} ${w[4]}.`,
|
||||
],
|
||||
6: [
|
||||
w => `${w[0]} met ${w[1]} at the ${w[2]}. Together they found ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||
w => `The ${w[0]} ${w[1]} wore a ${w[2]} hat while eating ${w[3]} ${w[4]} ${w[5]}.`,
|
||||
w => `Detective ${w[0]} found ${w[1]} ${w[2]} near the ${w[3]} ${w[4]} ${w[5]}.`,
|
||||
w => `In the ${w[0]} ${w[1]}, a ${w[2]} ${w[3]} sang about ${w[4]} ${w[5]}.`,
|
||||
w => `Chef ${w[0]} combined ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||
],
|
||||
7: [
|
||||
w => `${w[0]} and ${w[1]} walked through the ${w[2]} ${w[3]} to find the ${w[4]} ${w[5]} ${w[6]}.`,
|
||||
w => `The ${w[0]} professor studied ${w[1]} ${w[2]} while drinking ${w[3]} ${w[4]} with ${w[5]} ${w[6]}.`,
|
||||
w => `"${w[0]} ${w[1]}!" yelled ${w[2]} as ${w[3]} ${w[4]} attacked the ${w[5]} ${w[6]}.`,
|
||||
w => `In ${w[0]}, King ${w[1]} decreed that ${w[2]} ${w[3]} must honor ${w[4]} ${w[5]} ${w[6]}.`,
|
||||
],
|
||||
8: [
|
||||
w => `${w[0]} ${w[1]} and ${w[2]} ${w[3]} met at the ${w[4]} ${w[5]} to discuss ${w[6]} ${w[7]}.`,
|
||||
w => `The ${w[0]} ${w[1]} ${w[2]} traveled from ${w[3]} to ${w[4]} carrying ${w[5]} ${w[6]} ${w[7]}.`,
|
||||
w => `${w[0]} discovered that ${w[1]} ${w[2]} plus ${w[3]} ${w[4]} equals ${w[5]} ${w[6]} ${w[7]}.`,
|
||||
],
|
||||
9: [
|
||||
w => `${w[0]} ${w[1]} ${w[2]} watched as ${w[3]} ${w[4]} ${w[5]} danced with ${w[6]} ${w[7]} ${w[8]}.`,
|
||||
w => `In the ${w[0]} ${w[1]} ${w[2]}, three friends — ${w[3]}, ${w[4]}, ${w[5]} — found ${w[6]} ${w[7]} ${w[8]}.`,
|
||||
w => `The recipe: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}.`,
|
||||
],
|
||||
10: [
|
||||
w => `${w[0]} ${w[1]} told ${w[2]} ${w[3]} about the ${w[4]} ${w[5]} ${w[6]} hidden in ${w[7]} ${w[8]} ${w[9]}.`,
|
||||
w => `The ${w[0]} ${w[1]} ${w[2]} ${w[3]} ${w[4]} lived beside ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]}.`,
|
||||
],
|
||||
11: [
|
||||
w => `${w[0]} ${w[1]} ${w[2]} and ${w[3]} ${w[4]} ${w[5]} discovered ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||
w => `In ${w[0]} ${w[1]}, the ${w[2]} ${w[3]} ${w[4]} sang of ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||
],
|
||||
12: [
|
||||
w => `${w[0]} ${w[1]} ${w[2]} met ${w[3]} ${w[4]} ${w[5]} at the ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]} ${w[11]}.`,
|
||||
w => `The twelve treasures: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}, ${w[9]}, ${w[10]}, ${w[11]}.`,
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap word in highlight span
|
||||
*/
|
||||
hl(word) {
|
||||
return `<span class="passphrase-word">${word}</span>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a memory story for given words
|
||||
* @param {string[]} words - Array of passphrase words
|
||||
* @param {number|null} idx - Template index (null for current)
|
||||
* @returns {string} HTML story
|
||||
*/
|
||||
generateStory(words, idx = null) {
|
||||
const count = words.length;
|
||||
if (count === 0) return '';
|
||||
|
||||
// Clamp to supported range (3-12)
|
||||
const templateKey = Math.max(3, Math.min(12, count));
|
||||
const templates = this.storyTemplates[templateKey];
|
||||
|
||||
if (!templates || templates.length === 0) {
|
||||
// Fallback: just list the words
|
||||
return words.map(w => this.hl(w)).join(' — ');
|
||||
}
|
||||
|
||||
const templateIdx = (idx ?? this.currentStoryTemplate) % templates.length;
|
||||
// Apply highlighting to words
|
||||
const highlighted = words.map(w => this.hl(w));
|
||||
return templates[templateIdx](highlighted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle memory aid visibility
|
||||
* @param {string[]} words - Passphrase words array
|
||||
*/
|
||||
toggleMemoryAid(words) {
|
||||
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 = this.generateStory(words);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate story with next template
|
||||
* @param {string[]} words - Passphrase words array
|
||||
*/
|
||||
regenerateStory(words) {
|
||||
const count = words.length;
|
||||
const templateKey = Math.max(3, Math.min(12, count));
|
||||
const templates = this.storyTemplates[templateKey] || [];
|
||||
this.currentStoryTemplate = (this.currentStoryTemplate + 1) % Math.max(1, templates.length);
|
||||
document.getElementById('memoryStory').innerHTML = this.generateStory(words, this.currentStoryTemplate);
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// QR CODE PRINTING
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Print QR code in new window
|
||||
*/
|
||||
printQrCode() {
|
||||
const qrImg = document.getElementById('qrCodeImage');
|
||||
if (!qrImg) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Stegasoo RSA Key QR Code</title>
|
||||
<style>
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
||||
img { max-width: 400px; }
|
||||
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Stegasoo RSA Private Key</h2>
|
||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
||||
<div class="warning">
|
||||
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
|
||||
Store securely and destroy after use.
|
||||
</div>
|
||||
<script>window.onload = function() { window.print(); }<\/script>
|
||||
</body>
|
||||
</html>`);
|
||||
printWindow.document.close();
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// INITIALIZATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize generate form page
|
||||
*/
|
||||
initForm() {
|
||||
this.initWordsSlider();
|
||||
this.initOptionToggles();
|
||||
}
|
||||
};
|
||||
|
||||
// Global function wrappers for onclick handlers
|
||||
function togglePinVisibility() {
|
||||
StegasooGenerate.togglePinVisibility();
|
||||
}
|
||||
|
||||
function togglePassphraseVisibility() {
|
||||
StegasooGenerate.togglePassphraseVisibility();
|
||||
}
|
||||
|
||||
function printQrCode() {
|
||||
StegasooGenerate.printQrCode();
|
||||
}
|
||||
|
||||
// Auto-init form controls
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.querySelector('[data-page="generate"]')) {
|
||||
StegasooGenerate.initForm();
|
||||
}
|
||||
});
|
||||
995
frontends/web/static/js/stegasoo.js
Normal file
@@ -0,0 +1,995 @@
|
||||
/**
|
||||
* 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.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'bi bi-check-circle text-success me-1';
|
||||
labelEl.appendChild(icon);
|
||||
labelEl.appendChild(document.createTextNode(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 serverInfoId = config.serverInfoId || 'channelServerInfo';
|
||||
|
||||
const select = document.getElementById(selectId);
|
||||
const customInput = document.getElementById(customInputId);
|
||||
const keyInput = document.getElementById(keyInputId);
|
||||
const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null;
|
||||
const serverInfo = document.getElementById(serverInfoId);
|
||||
|
||||
// Show/hide custom input and server info based on selection
|
||||
const updateVisibility = () => {
|
||||
const value = select?.value;
|
||||
const isCustom = value === 'custom';
|
||||
const isPublic = value === 'none';
|
||||
const isAuto = value === 'auto';
|
||||
|
||||
// Custom input visibility
|
||||
customInput?.classList.toggle('d-none', !isCustom);
|
||||
if (isCustom && keyInput) {
|
||||
keyInput.focus();
|
||||
// Pulse highlight effect
|
||||
customInput?.classList.add('channel-highlight');
|
||||
setTimeout(() => customInput?.classList.remove('channel-highlight'), 400);
|
||||
}
|
||||
|
||||
// Server info: show for auto, hide for custom, show "no key" for public
|
||||
if (serverInfo) {
|
||||
if (isAuto) {
|
||||
serverInfo.innerHTML = '<i class="bi bi-shield-lock me-1"></i>Server: <code>' + (serverInfo.dataset.fingerprint || '••••-••••-···-••••-••••') + '</code>';
|
||||
serverInfo.className = 'small text-success mt-2';
|
||||
serverInfo.classList.remove('d-none');
|
||||
} else if (isPublic) {
|
||||
serverInfo.innerHTML = '<i class="bi bi-globe me-1"></i>No channel key will be used';
|
||||
serverInfo.className = 'small text-muted mt-2';
|
||||
serverInfo.classList.remove('d-none');
|
||||
} else {
|
||||
serverInfo.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Track saved key usage (fire-and-forget)
|
||||
const selectedOption = select?.selectedOptions?.[0];
|
||||
const keyId = selectedOption?.dataset?.keyId;
|
||||
if (keyId) {
|
||||
fetch(`/api/channel/keys/${keyId}/use`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
|
||||
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;
|
||||
const startTime = Date.now();
|
||||
const updateTimer = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding... ${timeStr}`;
|
||||
};
|
||||
updateTimer();
|
||||
setInterval(updateTimer, 1000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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',
|
||||
serverInfoId: 'channelServerInfoDec'
|
||||
});
|
||||
|
||||
// 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;
|
||||
const startTime = Date.now();
|
||||
const updateTimer = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})... ${timeStr}`;
|
||||
};
|
||||
updateTimer();
|
||||
setInterval(updateTimer, 1000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
BIN
frontends/web/static/logo_home.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
0
frontends/web/stegasoo_users.db
Normal file
272
frontends/web/stego_worker.py
Normal 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()
|
||||
498
frontends/web/subprocess_stego.py
Normal 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
|
||||
@@ -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 & File Embedding</strong> — Hide messages or any file type (PDF, ZIP, documents)
|
||||
<strong>Text & 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 & 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.1</span>
|
||||
<br><small class="text-muted">Group/deployment isolation</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -61,113 +74,357 @@
|
||||
</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>
|
||||
<li><strong>Error Correction:</strong> Reed-Solomon <span class="badge bg-info ms-1">v4.1</span></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.1</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
<span class="badge bg-info me-1">New in v2.1</span>
|
||||
Stegasoo now supports embedding <strong>any file type</strong>, not just text messages.
|
||||
Channel keys provide <strong>deployment/group isolation</strong>. Messages encoded with one channel key
|
||||
cannot be decoded with a different key, even if all other credentials match.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="bi bi-check2-square text-success me-2"></i>Supported</h6>
|
||||
<ul class="small">
|
||||
<li>PDF documents</li>
|
||||
<li>ZIP/RAR archives</li>
|
||||
<li>Office documents (DOCX, XLSX, PPTX)</li>
|
||||
<li>Source code files</li>
|
||||
<li>Any binary file up to {{ max_payload_kb }} KB</li>
|
||||
</ul>
|
||||
<div class="row mt-4">
|
||||
<!-- Auto Mode -->
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card bg-dark h-100">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-gear-fill text-success fs-2 d-block mb-2"></i>
|
||||
<strong>Auto</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
|
||||
<ul class="small mb-0">
|
||||
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
|
||||
<li>Or <code>channel_key</code> in config file</li>
|
||||
<li>All users share the same channel</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="bi bi-info-circle text-info me-2"></i>How It Works</h6>
|
||||
<ul class="small">
|
||||
<li>Original filename is preserved</li>
|
||||
<li>MIME type is stored for proper handling</li>
|
||||
<li>File is encrypted identically to text</li>
|
||||
<li>Decoding auto-detects text vs. file</li>
|
||||
</ul>
|
||||
|
||||
<!-- Public Mode -->
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card bg-dark h-100">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-globe text-info fs-2 d-block mb-2"></i>
|
||||
<strong>Public</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">No channel key. Compatible with other public installations.</p>
|
||||
<ul class="small mb-0">
|
||||
<li>Default if no server key configured</li>
|
||||
<li>Anyone can decode (with credentials)</li>
|
||||
<li>Interoperable between deployments</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Mode -->
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card bg-dark h-100">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-key-fill text-warning fs-2 d-block mb-2"></i>
|
||||
<strong>Custom</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">Your own group key. Share with recipients.</p>
|
||||
<ul class="small mb-0">
|
||||
<li>Format: <code>XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX</code></li>
|
||||
<li>32 chars (128 bits entropy)</li>
|
||||
<li>Private group communication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mt-3">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
<strong>Tip:</strong> For larger files, compress them first (ZIP) to maximize capacity.
|
||||
A 16MP carrier image can hold approximately 6MB of raw data, but we limit payloads
|
||||
to {{ max_payload_kb }} KB for reasonable processing times.
|
||||
{% if channel_configured %}
|
||||
<div class="alert alert-success mt-3 mb-3">
|
||||
<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-3">
|
||||
<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 %}
|
||||
|
||||
<!-- Channel Key QR Generator -->
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small">Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
|
||||
placeholder="Enter or generate a key">
|
||||
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
|
||||
title="Generate random key">
|
||||
<i class="bi bi-shuffle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
|
||||
<i class="bi bi-qr-code me-1"></i>Show QR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3 d-none" id="channelKeyQrContainer">
|
||||
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="channelKeyQrDownload">
|
||||
<i class="bi bi-download me-1"></i>Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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.1.0</strong></td>
|
||||
<td>
|
||||
<strong>Reed-Solomon error correction</strong> for DCT mode (corrects up to 16 byte errors per 223-byte chunk),
|
||||
majority voting on length headers, improved robustness with problematic carrier images
|
||||
</td>
|
||||
</tr>
|
||||
<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 +445,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 +459,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 +485,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 +510,177 @@
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits & Specifications</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits & Specs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-striped">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-file-earmark me-2"></i>Max file payload</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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-upload me-2"></i>Max upload size</td>
|
||||
<td><strong>10 MB</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-clock me-2"></i>Temp file expiry</td>
|
||||
<td><strong>5 minutes</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-key me-2"></i>PIN length</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>
|
||||
</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Key Specs - Always Visible -->
|
||||
<div class="row text-center mb-4">
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-file-earmark text-primary fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Max Payload</div>
|
||||
<strong>{{ max_payload_kb }} KB</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-image text-info fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Max Carrier</div>
|
||||
<strong>24 MP</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-soundwave text-warning fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">DCT Capacity</div>
|
||||
<strong>~75 KB/MP</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-grid-3x3 text-success fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">LSB Capacity</div>
|
||||
<strong>~375 KB/MP</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-shield-check text-danger fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Encryption</div>
|
||||
<strong>AES-256</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Error Correction</div>
|
||||
<strong>Reed-Solomon</strong>
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Correction Detail -->
|
||||
<div class="alert alert-info small mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Reed-Solomon Error Correction:</strong> DCT mode corrects up to 16 byte errors per 223-byte chunk.
|
||||
Handles problematic carrier images with uniform areas that cause unstable DCT coefficients.
|
||||
</div>
|
||||
|
||||
<!-- More Specs - Accordion -->
|
||||
<div class="accordion" id="specsAccordion">
|
||||
<div class="accordion-item bg-dark">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed bg-dark text-light py-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#moreSpecs">
|
||||
<i class="bi bi-list-ul me-2"></i>More Specifications
|
||||
</button>
|
||||
</h2>
|
||||
<div id="moreSpecs" class="accordion-collapse collapse" data-bs-parent="#specsAccordion">
|
||||
<div class="accordion-body p-0">
|
||||
<table class="table table-dark table-striped small mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<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-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 keys</td>
|
||||
<td><strong>2048, 3072, 4096 bit</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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, reedsolo, cryptography, argon2-cffi</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-terminal me-2"></i>CLI & 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 •
|
||||
<i class="bi bi-github me-1"></i>Open Source •
|
||||
Built with Python, Flask, and cryptography
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- QR Code library for channel key sharing -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const input = document.getElementById('channelKeyQrInput');
|
||||
const generateBtn = document.getElementById('channelKeyQrGenerate');
|
||||
const showBtn = document.getElementById('channelKeyQrShow');
|
||||
const container = document.getElementById('channelKeyQrContainer');
|
||||
const canvas = document.getElementById('channelKeyQrCanvas');
|
||||
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
||||
|
||||
// Generate random key
|
||||
generateBtn?.addEventListener('click', function() {
|
||||
if (input && typeof Stegasoo !== 'undefined') {
|
||||
input.value = Stegasoo.generateChannelKey();
|
||||
}
|
||||
});
|
||||
|
||||
// Show QR code
|
||||
showBtn?.addEventListener('click', function() {
|
||||
const key = input?.value?.trim().replace(/-/g, '');
|
||||
if (!key || key.length !== 32) {
|
||||
alert('Please enter a valid 32-character channel key');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format key with dashes for QR
|
||||
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
||||
|
||||
// Generate QR code
|
||||
if (typeof QRCode !== 'undefined' && canvas) {
|
||||
QRCode.toCanvas(canvas, formatted, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: { dark: '#000', light: '#fff' }
|
||||
}, function(error) {
|
||||
if (error) {
|
||||
console.error('QR generation error:', error);
|
||||
return;
|
||||
}
|
||||
container?.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Download QR as PNG
|
||||
downloadBtn?.addEventListener('click', function() {
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'stegasoo-channel-key.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
234
frontends/web/templates/account.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{% 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>
|
||||
{% if is_admin %}
|
||||
<span class="badge bg-warning text-dark ms-2">
|
||||
<i class="bi bi-shield-check me-1"></i>Admin
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-people me-2"></i>Manage Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Key Management (Admin only) -->
|
||||
<div class="card bg-dark mb-4">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
<strong>Recovery Key</strong>
|
||||
{% if has_recovery %}
|
||||
<span class="badge bg-success ms-2">Configured</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">Not Set</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('regenerate_recovery') }}" class="btn btn-outline-warning"
|
||||
onclick="return confirm('Generate a new recovery key? This will invalidate any existing key.')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>
|
||||
{{ 'Regenerate' if has_recovery else 'Generate' }}
|
||||
</a>
|
||||
{% if has_recovery %}
|
||||
<form method="POST" action="{{ url_for('disable_recovery') }}" style="display:inline;">
|
||||
<button type="submit" class="btn btn-outline-danger"
|
||||
onclick="return confirm('Disable recovery? If you forget your password, you will NOT be able to recover your account.')">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-2">
|
||||
{% if has_recovery %}
|
||||
Allows password reset if you're locked out.
|
||||
{% else %}
|
||||
No recovery option - most secure, but no password reset possible.
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Channel Keys Section -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Saved Channel Keys</h5>
|
||||
<span class="badge bg-secondary">{{ channel_keys|length }} / {{ max_channel_keys }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if channel_keys %}
|
||||
<div class="list-group list-group-flush mb-3">
|
||||
{% for key in channel_keys %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<div>
|
||||
<strong>{{ key.name }}</strong>
|
||||
<br>
|
||||
<code class="small text-muted">{{ key.channel_key[:4] }}...{{ key.channel_key[-4:] }}</code>
|
||||
{% if key.last_used_at %}
|
||||
<span class="text-muted small ms-2">Last used: {{ key.last_used_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
onclick="renameKey({{ key.id }}, '{{ key.name }}')"
|
||||
title="Rename">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('account_delete_key', key_id=key.id) }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Delete key "{{ key.name }}"?')">
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-3">No saved channel keys. Save keys for quick access on encode/decode pages.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can_save_key %}
|
||||
<hr>
|
||||
<h6 class="text-muted mb-3">Add New Key</h6>
|
||||
<form method="POST" action="{{ url_for('account_save_key') }}">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-5">
|
||||
<input type="text" name="key_name" class="form-control form-control-sm"
|
||||
placeholder="Key name" required maxlength="50">
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
|
||||
placeholder="Channel key (32 hex chars)" required
|
||||
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>Save Key
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Maximum of {{ max_channel_keys }} keys reached. Delete a key to add more.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="mt-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>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<div class="modal fade" id="renameModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<form method="POST" id="renameForm">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Rename Key</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" name="new_name" class="form-control" id="renameInput"
|
||||
required maxlength="50">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Rename</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||
|
||||
function renameKey(keyId, currentName) {
|
||||
document.getElementById('renameInput').value = currentName;
|
||||
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
||||
new bootstrap.Modal(document.getElementById('renameModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
50
frontends/web/templates/admin/password_reset.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Password Reset - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<i class="bi bi-key fs-4 me-2"></i>
|
||||
<span class="fs-5">Password Reset</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Important:</strong> This password will only be shown once.
|
||||
Make sure to share it with <strong>{{ username }}</strong> securely.
|
||||
</div>
|
||||
|
||||
<p class="text-muted">
|
||||
The user's sessions have been invalidated. They will need to log in
|
||||
again with the new password.
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label text-muted small">New Password for {{ username }}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-lg font-monospace"
|
||||
value="{{ password }}" readonly id="passwordField">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyField('passwordField')" title="Copy password">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
{% endblock %}
|
||||
60
frontends/web/templates/admin/user_created.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Created - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card border-success">
|
||||
<div class="card-header bg-success text-white">
|
||||
<i class="bi bi-check-circle fs-4 me-2"></i>
|
||||
<span class="fs-5">User Created Successfully</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Important:</strong> This password will only be shown once.
|
||||
Make sure to share it with the user securely.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small">Username</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-lg font-monospace"
|
||||
value="{{ username }}" readonly id="usernameField">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyField('usernameField')" title="Copy username">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label text-muted small">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-lg font-monospace"
|
||||
value="{{ password }}" readonly id="passwordField">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyField('passwordField')" title="Copy password">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin_user_new') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-2"></i>Add Another User
|
||||
</a>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
{% endblock %}
|
||||
166
frontends/web/templates/admin/user_new.html
Normal file
@@ -0,0 +1,166 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add User - 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">
|
||||
<i class="bi bi-person-plus fs-4 me-2"></i>
|
||||
<span class="fs-5">Add New User</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="createUserForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person me-1"></i> Username
|
||||
</label>
|
||||
<input type="text" name="username" id="usernameInput" class="form-control"
|
||||
placeholder="e.g., john_doe or john@example.com"
|
||||
pattern="[a-zA-Z0-9][a-zA-Z0-9_\-@.]{2,79}"
|
||||
title="3-80 characters, letters/numbers/underscore/hyphen/@/."
|
||||
required autofocus>
|
||||
<div class="form-text">
|
||||
Letters, numbers, underscore, hyphen, @ and . allowed.
|
||||
</div>
|
||||
</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="text" name="password" id="passwordInput"
|
||||
class="form-control" value="{{ temp_password }}"
|
||||
minlength="8" required>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="regeneratePassword()" title="Generate new password">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Auto-generated password. You can edit or regenerate it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorAlert" class="alert alert-danger d-none"></div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary flex-grow-1" id="createBtn">
|
||||
<i class="bi bi-person-check me-2"></i>Create User
|
||||
</button>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-success">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-check-circle me-2"></i>User Created
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning mb-3 py-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Password shown once. Copy it now.
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label text-muted small mb-1">Username</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace"
|
||||
id="createdUsername" readonly>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyField('createdUsername')" title="Copy">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label text-muted small mb-1">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace"
|
||||
id="createdPassword" readonly>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyField('createdPassword')" title="Copy">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-primary" onclick="addAnother()">
|
||||
<i class="bi bi-person-plus me-1"></i>Add Another
|
||||
</button>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary">
|
||||
Done
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
const form = document.getElementById('createUserForm');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const createBtn = document.getElementById('createBtn');
|
||||
const successModal = new bootstrap.Modal(document.getElementById('successModal'));
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
errorAlert.classList.add('d-none');
|
||||
createBtn.disabled = true;
|
||||
createBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url_for("admin_user_new") }}', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('createdUsername').value = data.username;
|
||||
document.getElementById('createdPassword').value = data.password;
|
||||
successModal.show();
|
||||
} else {
|
||||
errorAlert.textContent = data.error;
|
||||
errorAlert.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
errorAlert.textContent = 'An error occurred. Please try again.';
|
||||
errorAlert.classList.remove('d-none');
|
||||
}
|
||||
|
||||
createBtn.disabled = false;
|
||||
createBtn.innerHTML = '<i class="bi bi-person-check me-2"></i>Create User';
|
||||
});
|
||||
|
||||
function addAnother() {
|
||||
successModal.hide();
|
||||
document.getElementById('usernameInput').value = '';
|
||||
regeneratePassword();
|
||||
document.getElementById('usernameInput').focus();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
95
frontends/web/templates/admin/users.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Users - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10 col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-people fs-4 me-2"></i>
|
||||
<span class="fs-5">User Management</span>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
{{ user_count }} / {{ max_users }} users
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if can_create %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('admin_user_new') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-2"></i>Add User
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Maximum of {{ max_users }} users reached.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person me-2"></i>
|
||||
{{ user.username }}
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge bg-info ms-2">You</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-shield-check me-1"></i>Admin
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
{{ user.created_at[:10] if user.created_at else 'Unknown' }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if user.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('admin_user_reset_password', user_id=user.id) }}"
|
||||
class="d-inline" onsubmit="return confirm('Reset password for {{ user.username }}?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" title="Reset Password">
|
||||
<i class="bi bi-key"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('admin_user_delete', user_id=user.id) }}"
|
||||
class="d-inline" onsubmit="return confirm('Delete user {{ user.username }}? This cannot be undone.')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete User">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Admins can add up to {{ max_users }} regular users.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -14,7 +14,10 @@
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2">
|
||||
<span class="fw-bold">Stegasoo</span>
|
||||
<span style="position: relative; display: inline-block; margin-top: -14px;">
|
||||
<span class="fw-bold title-gold">Stegasoo</span>
|
||||
<span class="badge bg-success" style="position: absolute; font-size: 0.45rem; bottom: -8px; right: 6px;">v4.1</span>
|
||||
</span>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
@@ -24,38 +27,67 @@
|
||||
<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>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/tools"><i class="bi bi-tools me-1"></i> Tools</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>
|
||||
{% if is_admin %}
|
||||
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
|
||||
{% endif %}
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<main class="container py-5">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<!-- Toast notifications container -->
|
||||
<div class="toast-container position-fixed end-0 p-3" style="z-index: 1100; top: 70px;">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% 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>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<div class="toast show align-items-center text-bg-{{ 'danger' if category == 'error' else ('warning' if category == 'warning' else 'success') }} border-0 fade" role="alert" data-bs-autohide="true" data-bs-delay="10000">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -63,12 +95,16 @@
|
||||
<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>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Initialize toasts (auto-hide after delay)
|
||||
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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">
|
||||
@@ -16,15 +120,13 @@
|
||||
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
|
||||
</div>
|
||||
|
||||
<label class="form-label text-muted">Decoded Message:</label>
|
||||
<div class="position-relative">
|
||||
<div class="alert-message p-3 rounded bg-dark border border-secondary" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
|
||||
<button class="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => this.innerHTML = '<i class=\'bi bi-check\'></i>').catch(() => alert('Failed to copy'))">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label>
|
||||
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
|
||||
onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))"
|
||||
onmouseover="this.style.borderColor = '#6c757d'"
|
||||
onmouseout="this.style.borderColor = ''">{{ decoded_message }}</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 +166,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 +207,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 +245,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 +262,179 @@
|
||||
<span class="text-warning small">(provide same factors used during encoding)</span>
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
</label>
|
||||
|
||||
<!-- RSA Input Method Toggle -->
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- .pem File Input -->
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
|
||||
<!-- QR Code Input -->
|
||||
<div id="rsaQrSection" class="d-none">
|
||||
<div class="drop-zone p-3" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<!-- Crop animation container -->
|
||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
|
||||
<!-- Data panel -->
|
||||
<div class="qr-data-panel">
|
||||
<div class="qr-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>RSA Key loaded</span>
|
||||
</div>
|
||||
<div class="qr-data-row">
|
||||
<span class="qr-status-badge">RSA Key</span>
|
||||
<span class="qr-data-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Password (always visible) -->
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-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">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" id="togglePin">
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
If PIN was used during encoding
|
||||
</div>
|
||||
|
||||
<div class="form-text">If PIN was used during encoding</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||
</label>
|
||||
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTabDec" type="button">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="rsaFileTabDec" role="tabpanel">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
<div class="tab-pane fade" id="rsaQrTabDec" role="tabpanel">
|
||||
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
|
||||
<div class="form-text small">PNG, JPG, or other image of QR code</div>
|
||||
</div>
|
||||
|
||||
<select class="form-select" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
{% if saved_channel_keys %}
|
||||
<optgroup label="Saved Keys">
|
||||
{% for key in saved_channel_keys %}
|
||||
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
|
||||
<!-- Server channel indicator (compact) -->
|
||||
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfoDec" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
||||
{% if channel_configured and channel_fingerprint %}
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
If RSA key was used during encoding (file or QR image)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||
<div class="mb-4 d-none" id="channelCustomInputDec">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
|
||||
id="channelKeyInputDec">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSA Key Password (shown when key selected) -->
|
||||
<div class="mb-3 d-none" id="rsaPasswordGroup">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> RSA Key Password
|
||||
</label>
|
||||
<input type="password" name="rsa_password" class="form-control"
|
||||
placeholder="Password for the .pem file (if encrypted)">
|
||||
<div class="form-text">
|
||||
Leave blank if your key file is not password-protected
|
||||
<!-- ================================================================
|
||||
ADVANCED OPTIONS (v3.0) - Extraction Mode
|
||||
================================================================ -->
|
||||
<div class="mb-4">
|
||||
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
|
||||
<i class="bi bi-gear me-1"></i> Advanced Options
|
||||
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
|
||||
</a>
|
||||
|
||||
<div class="collapse" id="advancedOptionsDec">
|
||||
<div class="card card-body mt-2 bg-dark border-secondary">
|
||||
|
||||
<!-- Extraction Mode Selection -->
|
||||
<div class="mb-0">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-cpu me-1"></i> Extraction Mode
|
||||
<span class="badge bg-info ms-1">v3.0</span>
|
||||
</label>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<!-- Auto Mode -->
|
||||
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<i class="bi bi-magic text-success"></i>
|
||||
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
||||
</label>
|
||||
|
||||
<!-- LSB Mode -->
|
||||
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
|
||||
<i class="bi bi-grid-3x3-gap text-primary"></i>
|
||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
|
||||
</label>
|
||||
|
||||
<!-- DCT Mode -->
|
||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<i class="bi bi-soundwave text-warning"></i>
|
||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-text mt-2">
|
||||
<i class="bi bi-lightbulb me-1"></i>
|
||||
<strong>Auto</strong> tries LSB first, then DCT.
|
||||
{% if not has_dct %}
|
||||
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,24 +453,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 +493,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 %}
|
||||
|
||||
@@ -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,182 @@
|
||||
<span class="text-warning small">(provide at least one: PIN or RSA Key)</span>
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
</label>
|
||||
|
||||
<!-- RSA Input Method Toggle -->
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- .pem File Input -->
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
|
||||
</div>
|
||||
|
||||
<!-- QR Code Input -->
|
||||
<div id="rsaQrSection" class="d-none">
|
||||
<div class="drop-zone p-3" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<!-- Crop animation container -->
|
||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
|
||||
<!-- Data panel -->
|
||||
<div class="qr-data-panel">
|
||||
<div class="qr-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>RSA Key loaded</span>
|
||||
</div>
|
||||
<div class="qr-data-row">
|
||||
<span class="qr-status-badge">RSA Key</span>
|
||||
<span class="qr-data-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Password (always visible) -->
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-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 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 class="form-text">Your static 6-9 digit PIN (if configured)</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||
</label>
|
||||
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTab" type="button">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTab" type="button">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="rsaFileTab" role="tabpanel">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
<div class="form-text small">Shared .pem format key file.</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="rsaQrTab" role="tabpanel">
|
||||
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
|
||||
<div class="form-text small">PNG, JPG, or other image of QR code</div>
|
||||
</div>
|
||||
|
||||
<select class="form-select" name="channel_key" id="channelSelect">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
{% if saved_channel_keys %}
|
||||
<optgroup label="Saved Keys">
|
||||
{% for key in saved_channel_keys %}
|
||||
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
|
||||
<!-- Server channel indicator (compact) -->
|
||||
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfo" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
||||
{% if channel_configured and channel_fingerprint %}
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||
<div class="mb-4 d-none" id="channelCustomInput">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
|
||||
id="channelKeyInput">
|
||||
<button class="btn btn-outline-secondary" type="button" id="channelKeyGenerate" title="Generate random key">
|
||||
<i class="bi bi-shuffle"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="invalid-feedback" id="channelKeyError">
|
||||
Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSA Key Password (shown when key selected) -->
|
||||
<div class="mb-3 d-none" id="rsaPasswordGroup">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> RSA Key Password
|
||||
</label>
|
||||
<input type="password" name="rsa_password" class="form-control"
|
||||
placeholder="Password for the .pem file (if encrypted)">
|
||||
<div class="form-text">
|
||||
Leave blank if your key file is not password-protected (not needed for QR codes)
|
||||
<!-- Advanced Options (DCT sub-options only) -->
|
||||
<div class="mb-4 {% if not has_dct %}d-none{% endif %}" id="advancedOptionsContainer">
|
||||
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptions" role="button" aria-expanded="false">
|
||||
<i class="bi bi-gear me-1"></i> DCT Options
|
||||
<i class="bi bi-chevron-down ms-1" id="advancedChevron"></i>
|
||||
</a>
|
||||
|
||||
<div class="collapse" id="advancedOptions">
|
||||
<div class="card card-body mt-2 bg-dark border-secondary py-3">
|
||||
|
||||
<!-- DCT Color Mode - Compact -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small mb-2">
|
||||
<i class="bi bi-palette me-1"></i> Color
|
||||
</label>
|
||||
<div class="d-flex gap-2">
|
||||
<label class="mode-btn equal-width active" id="dctColorCard" for="dctColorColor">
|
||||
<input class="form-check-input" type="radio" name="dct_color_mode" id="dctColorColor" value="color" checked>
|
||||
<i class="bi bi-palette-fill text-success"></i>
|
||||
<span class="ms-2"><strong>Color</strong> <span class="badge bg-success ms-1">Default</span></span>
|
||||
</label>
|
||||
<label class="mode-btn equal-width" id="dctGrayscaleCard" for="dctColorGrayscale">
|
||||
<input class="form-check-input" type="radio" name="dct_color_mode" id="dctColorGrayscale" value="grayscale">
|
||||
<i class="bi bi-circle-half text-secondary"></i>
|
||||
<span class="ms-2"><strong>Grayscale</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DCT Output Format - Compact -->
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-2">
|
||||
<i class="bi bi-file-image me-1"></i> Format
|
||||
</label>
|
||||
<div class="d-flex gap-2">
|
||||
<label class="mode-btn equal-width active" id="dctJpegCard" for="dctFormatJpeg">
|
||||
<input class="form-check-input" type="radio" name="dct_output_format" id="dctFormatJpeg" value="jpeg" checked>
|
||||
<i class="bi bi-file-earmark-richtext text-warning"></i>
|
||||
<span class="ms-2"><strong>JPEG</strong> <span class="badge bg-warning text-dark ms-1">Default</span></span>
|
||||
</label>
|
||||
<label class="mode-btn equal-width" id="dctPngCard" for="dctFormatPng">
|
||||
<input class="form-check-input" type="radio" name="dct_output_format" id="dctFormatPng" value="png">
|
||||
<i class="bi bi-file-earmark-image text-primary"></i>
|
||||
<span class="ms-2"><strong>PNG</strong> <span class="text-muted d-none d-sm-inline">· Lossless</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,8 +535,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 +546,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 +564,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
|
||||
// ============================================================================
|
||||
|
||||
payloadFileInput.addEventListener('change', function() {
|
||||
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');
|
||||
|
||||
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>`;
|
||||
} 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>`;
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// 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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Character counter for text
|
||||
const charCount = document.getElementById('charCount');
|
||||
const charWarning = document.getElementById('charWarning');
|
||||
const charPercent = document.getElementById('charPercent');
|
||||
const maxChars = 250000;
|
||||
|
||||
messageInput.addEventListener('input', function() {
|
||||
const len = this.value.length;
|
||||
charCount.textContent = len.toLocaleString();
|
||||
|
||||
const pct = Math.round((len / maxChars) * 100);
|
||||
charPercent.textContent = pct + '%';
|
||||
|
||||
charWarning.classList.toggle('d-none', len < maxChars * 0.8);
|
||||
charCount.classList.toggle('text-danger', len > maxChars * 0.95);
|
||||
});
|
||||
|
||||
// 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() {
|
||||
if (this.files && this.files[0]) {
|
||||
showPreview(this.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showPreview(file) {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
fileInfo?.classList.remove('d-none');
|
||||
if (fileInfoName) fileInfoName.textContent = file.name;
|
||||
if (fileInfoSize) fileInfoSize.textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||
|
||||
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');
|
||||
const label = document.getElementById('payloadDropLabel');
|
||||
if (label) label.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${file.name}`;
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
fileInfo?.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent Same File Selection
|
||||
// ============================================================================
|
||||
// ENCODE PAGE - Character counter
|
||||
// ============================================================================
|
||||
|
||||
messageInput?.addEventListener('input', function() {
|
||||
const count = this.value.length;
|
||||
const max = 250000;
|
||||
const percent = Math.round((count / max) * 100);
|
||||
|
||||
const charCount = document.getElementById('charCount');
|
||||
const charPercent = document.getElementById('charPercent');
|
||||
const charWarning = document.getElementById('charWarning');
|
||||
|
||||
if (charCount) charCount.textContent = count.toLocaleString();
|
||||
if (charPercent) charPercent.textContent = percent + '%';
|
||||
charWarning?.classList.toggle('d-none', percent < 80);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENCODE PAGE - Carrier capacity
|
||||
// ============================================================================
|
||||
|
||||
const capacityPanel = document.getElementById('capacityPanel');
|
||||
const carrierInput = document.getElementById('carrierInput');
|
||||
|
||||
carrierInput?.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
fetchCapacityComparison(this.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function fetchCapacityComparison(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('carrier', file);
|
||||
|
||||
fetch('/api/compare-capacity', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) return;
|
||||
|
||||
const dims = document.getElementById('carrierDimensions');
|
||||
const lsbBadge = document.getElementById('lsbCapacityBadge');
|
||||
const dctBadge = document.getElementById('dctCapacityBadge');
|
||||
|
||||
if (dims) dims.textContent = `${data.width} × ${data.height} (${(data.width * data.height / 1000000).toFixed(1)} MP)`;
|
||||
if (lsbBadge) lsbBadge.textContent = `LSB: ${data.lsb.capacity_kb} KB`;
|
||||
if (dctBadge) dctBadge.textContent = `DCT: ${data.dct.capacity_kb} KB`;
|
||||
|
||||
capacityPanel?.classList.remove('d-none');
|
||||
})
|
||||
.catch(err => console.error('Capacity fetch failed:', err));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 %}
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -106,4 +197,4 @@ document.getElementById('downloadBtn').addEventListener('click', function() {
|
||||
}, 2000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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,19 +62,59 @@
|
||||
</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 >3072 bits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100 mt-4">
|
||||
<i class="bi bi-shuffle me-2"></i>Generate Credentials
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Channel Key Accordion (Advanced) -->
|
||||
<div class="accordion mt-4" id="advancedAccordion">
|
||||
<div class="accordion-item bg-dark">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed bg-dark text-light" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#channelKeyCollapse">
|
||||
<i class="bi bi-broadcast me-2"></i>Channel Key
|
||||
<span class="badge bg-info ms-2">Advanced</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="channelKeyCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedAccordion">
|
||||
<div class="accordion-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Channel keys create private encoding channels. Only users with the same key can decode each other's images.
|
||||
<a href="{{ url_for('about') }}#channel-keys" class="text-info">Learn more</a>
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<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 to create a key" 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 mt-2">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
After generating, configure this key in your server's environment or use <strong>Custom</strong> channel mode when encoding/decoding.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Generated Credentials Display -->
|
||||
@@ -106,25 +150,57 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted"><i class="bi bi-chat-quote me-2"></i>DAILY PHRASES</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped mb-0">
|
||||
<tbody>
|
||||
{% for day in days %}
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 100px;">{{ day }}</td>
|
||||
<td>
|
||||
<span class="font-monospace phrase-display" id="phrase{{ loop.index }}">{{ phrases[day] }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h6 class="text-muted">
|
||||
<i class="bi bi-chat-quote me-2"></i>PASSPHRASE
|
||||
</h6>
|
||||
|
||||
<div class="passphrase-container">
|
||||
<div class="passphrase-display" id="passphraseDisplay">
|
||||
<code class="passphrase-text">{{ passphrase }}</code>
|
||||
</div>
|
||||
|
||||
<div class="passphrase-buttons mt-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="togglePassphraseVisibility()">
|
||||
<i class="bi bi-eye-slash" id="passphraseToggleIcon"></i>
|
||||
<span id="passphraseToggleText">Hide</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="copyPassphrase()">
|
||||
<i class="bi bi-clipboard" id="passphraseCopyIcon"></i>
|
||||
<span id="passphraseCopyText">Copy</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleMemoryAid()">
|
||||
<i class="bi bi-lightbulb" id="memoryAidIcon"></i>
|
||||
<span id="memoryAidText">Memory Aid</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end mt-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAllPhrases()">
|
||||
<i class="bi bi-eye-slash me-1"></i>Toggle Visibility
|
||||
</button>
|
||||
|
||||
<!-- Memory Aid Story -->
|
||||
<div class="memory-aid-container mt-3 d-none" id="memoryAidContainer">
|
||||
<div class="card bg-dark border-primary">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<i class="bi bi-book me-2"></i>Memory Story
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="memory-story mb-3" id="memoryStory">
|
||||
<!-- Story will be generated by JavaScript -->
|
||||
</p>
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This story is generated from your passphrase to help you remember it.
|
||||
The words appear in order within the narrative.
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-light mt-2" onclick="regenerateStory()">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Generate Different Story
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small class="text-muted">
|
||||
({{ words_per_passphrase }} words = ~{{ passphrase_entropy }} bits entropy)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,8 +305,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 +356,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 +420,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,132 +495,49 @@
|
||||
.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 src="{{ url_for('static', filename='js/generate.js') }}"></script>
|
||||
{% if generated %}
|
||||
<script>
|
||||
// Words range slider
|
||||
const wordsRange = document.getElementById('wordsRange');
|
||||
const wordsValue = document.getElementById('wordsValue');
|
||||
// Page-specific data from Jinja
|
||||
const passphraseWords = '{{ passphrase|default("", true) }}'.split(' ').filter(w => w.length > 0);
|
||||
|
||||
if (wordsRange) {
|
||||
wordsRange.addEventListener('input', function() {
|
||||
const bits = this.value * 11;
|
||||
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle PIN/RSA options
|
||||
const usePinCheck = document.getElementById('usePinCheck');
|
||||
const useRsaCheck = document.getElementById('useRsaCheck');
|
||||
const pinOptions = document.getElementById('pinOptions');
|
||||
const rsaOptions = document.getElementById('rsaOptions');
|
||||
|
||||
if (usePinCheck) {
|
||||
usePinCheck.addEventListener('change', function() {
|
||||
pinOptions.classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
}
|
||||
|
||||
if (useRsaCheck) {
|
||||
useRsaCheck.addEventListener('change', function() {
|
||||
rsaOptions.classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// PIN visibility toggle
|
||||
let pinHidden = false;
|
||||
function togglePinVisibility() {
|
||||
const pinDigits = document.getElementById('pinDigits');
|
||||
const icon = document.getElementById('pinToggleIcon');
|
||||
const text = document.getElementById('pinToggleText');
|
||||
|
||||
pinHidden = !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';
|
||||
}
|
||||
}
|
||||
|
||||
// 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';
|
||||
});
|
||||
function copyPassphrase() {
|
||||
Stegasoo.copyToClipboard(
|
||||
'{{ passphrase|default("", true) }}',
|
||||
document.getElementById('passphraseCopyIcon'),
|
||||
document.getElementById('passphraseCopyText')
|
||||
);
|
||||
}
|
||||
|
||||
// Print QR code
|
||||
function printQrCode() {
|
||||
const qrImg = document.getElementById('qrCodeImage');
|
||||
if (!qrImg) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Stegasoo RSA Key QR Code</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
img { max-width: 400px; }
|
||||
.warning {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
border: 2px solid #ff9800;
|
||||
background: #fff3e0;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Stegasoo RSA Private Key</h2>
|
||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
||||
<div class="warning">
|
||||
<strong>⚠️ SECURITY WARNING</strong><br>
|
||||
This QR code contains your unencrypted RSA private key.<br>
|
||||
Store securely and destroy after use.
|
||||
</div>
|
||||
<script>window.onload = function() { window.print(); }<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
function toggleMemoryAid() {
|
||||
StegasooGenerate.toggleMemoryAid(passphraseWords);
|
||||
}
|
||||
|
||||
function regenerateStory() {
|
||||
StegasooGenerate.regenerateStory(passphraseWords);
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 title-gold">
|
||||
Stegasoo
|
||||
<span class="badge bg-success fs-6 ms-2">v4.1</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,47 +88,85 @@
|
||||
</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 & 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.1</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
55
frontends/web/templates/login.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% 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"
|
||||
placeholder="Enter your username" required autofocus>
|
||||
</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>
|
||||
<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 class="text-center mt-3">
|
||||
<a href="{{ url_for('recover') }}" class="text-muted small">
|
||||
<i class="bi bi-key me-1"></i> Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
{% endblock %}
|
||||
129
frontends/web/templates/recover.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Password Recovery - 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-shield-lock fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">Password Recovery</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted text-center mb-4">
|
||||
Enter your recovery key to reset your admin password.
|
||||
</p>
|
||||
|
||||
<!-- Extract from Stego Backup -->
|
||||
<div class="accordion mb-3" id="stegoAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed py-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#stegoExtract">
|
||||
<i class="bi bi-incognito me-2"></i>
|
||||
<small>Extract from stego backup</small>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stegoExtract" class="accordion-collapse collapse"
|
||||
data-bs-parent="#stegoAccordion">
|
||||
<div class="accordion-body py-2">
|
||||
<form method="POST" action="{{ url_for('recover_from_stego') }}"
|
||||
enctype="multipart/form-data">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Stego Image</label>
|
||||
<input type="file" name="stego_image"
|
||||
class="form-control form-control-sm"
|
||||
accept="image/*" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Original Reference</label>
|
||||
<input type="file" name="reference_image"
|
||||
class="form-control form-control-sm"
|
||||
accept="image/*" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
|
||||
<i class="bi bi-unlock me-1"></i> Extract Key
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('recover') }}" id="recoverForm">
|
||||
<!-- Recovery Key Input -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Recovery Key
|
||||
</label>
|
||||
<textarea name="recovery_key" class="form-control font-monospace"
|
||||
rows="2" required
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
style="font-size: 0.9em;">{{ prefilled_key or '' }}</textarea>
|
||||
<div class="form-text">
|
||||
Paste your full recovery key (with or without dashes)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- New Password -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-lock me-1"></i> New Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_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>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-lock-fill me-1"></i> Confirm Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_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>Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('login') }}" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-4 small">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Note:</strong> This will reset the admin password. If you don't have a valid recovery key,
|
||||
you'll need to delete the database and reconfigure Stegasoo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('recoverForm', 'passwordInput', 'passwordConfirmInput');
|
||||
</script>
|
||||
{% endblock %}
|
||||
183
frontends/web/templates/regenerate_recovery.html
Normal file
@@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Regenerate Recovery Key - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-arrow-repeat fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">{{ 'Regenerate' if has_existing else 'Generate' }} Recovery Key</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if has_existing %}
|
||||
<!-- Warning for existing key -->
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> Your existing recovery key will be invalidated.
|
||||
Make sure to save this new key before continuing.
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Info for first-time setup -->
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>What is a recovery key?</strong><br>
|
||||
If you forget your admin password, this key is the ONLY way to reset it.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recovery Key Display -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Your New Recovery Key
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace text-center"
|
||||
id="recoveryKey" value="{{ recovery_key }}" readonly
|
||||
style="font-size: 1.1em; letter-spacing: 0.5px;">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyToClipboard()" title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard" id="copyIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (if available) -->
|
||||
{% if qr_base64 %}
|
||||
<div class="mb-4 text-center">
|
||||
<label class="form-label d-block">
|
||||
<i class="bi bi-qr-code me-1"></i> QR Code
|
||||
</label>
|
||||
<img src="data:image/png;base64,{{ qr_base64 }}"
|
||||
alt="Recovery Key QR Code" class="img-fluid border rounded"
|
||||
style="max-width: 200px;" id="qrImage">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Download Options -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-download me-1"></i> Download Options
|
||||
</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadTextFile()">
|
||||
<i class="bi bi-file-text me-1"></i> Text File
|
||||
</button>
|
||||
{% if qr_base64 %}
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadQRImage()">
|
||||
<i class="bi bi-image me-1"></i> QR Image
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stego Backup Option -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-incognito me-1"></i> Hide in Image
|
||||
</label>
|
||||
<form method="POST" action="{{ url_for('create_stego_backup') }}"
|
||||
enctype="multipart/form-data" class="d-flex gap-2 align-items-end">
|
||||
<input type="hidden" name="recovery_key" value="{{ recovery_key }}">
|
||||
<div class="flex-grow-1">
|
||||
<input type="file" name="carrier_image" class="form-control form-control-sm"
|
||||
accept="image/jpeg,image/png" required>
|
||||
<div class="form-text">JPG/PNG, 50KB-2MB</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-download me-1"></i> Stego
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Confirmation Form -->
|
||||
<form method="POST" id="recoveryForm">
|
||||
<input type="hidden" name="recovery_key" value="{{ recovery_key }}">
|
||||
|
||||
<!-- Confirm checkbox -->
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved"
|
||||
onchange="updateButtons()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
I have saved my recovery key in a secure location
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-between">
|
||||
<!-- Cancel button -->
|
||||
<button type="submit" name="action" value="cancel"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i> Cancel
|
||||
</button>
|
||||
|
||||
<!-- Save button -->
|
||||
<button type="submit" name="action" value="save"
|
||||
class="btn btn-primary" id="saveBtn" disabled>
|
||||
<i class="bi bi-check-lg me-1"></i> Save New Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Copy recovery key to clipboard
|
||||
function copyToClipboard() {
|
||||
const keyInput = document.getElementById('recoveryKey');
|
||||
navigator.clipboard.writeText(keyInput.value).then(() => {
|
||||
const icon = document.getElementById('copyIcon');
|
||||
icon.className = 'bi bi-clipboard-check';
|
||||
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Download as text file
|
||||
function downloadTextFile() {
|
||||
const key = document.getElementById('recoveryKey').value;
|
||||
const content = `Stegasoo Recovery Key
|
||||
=====================
|
||||
|
||||
${key}
|
||||
|
||||
IMPORTANT:
|
||||
- Keep this file in a secure location
|
||||
- Anyone with this key can reset admin passwords
|
||||
- Do not store with your password
|
||||
|
||||
Generated: ${new Date().toISOString()}
|
||||
`;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'stegasoo-recovery-key.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Download QR as image
|
||||
function downloadQRImage() {
|
||||
const img = document.getElementById('qrImage');
|
||||
if (!img) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = img.src;
|
||||
a.download = 'stegasoo-recovery-qr.png';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// Enable save button when checkbox is checked
|
||||
function updateButtons() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.disabled = !checkbox.checked;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
76
frontends/web/templates/setup.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% 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 src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('setupForm', 'passwordInput', 'passwordConfirmInput');
|
||||
</script>
|
||||
{% endblock %}
|
||||
176
frontends/web/templates/setup_recovery.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Recovery Key Setup - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<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">Recovery Key Setup</h5>
|
||||
<small class="text-muted">Step 2 of 2</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Explanation -->
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>What is a recovery key?</strong><br>
|
||||
If you forget your admin password, this key is the ONLY way to reset it.
|
||||
Save it somewhere safe - it will not be shown again.
|
||||
</div>
|
||||
|
||||
<!-- Recovery Key Display -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Your Recovery Key
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace text-center"
|
||||
id="recoveryKey" value="{{ recovery_key }}" readonly
|
||||
style="font-size: 1.1em; letter-spacing: 0.5px;">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="copyToClipboard()" title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard" id="copyIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (if available) -->
|
||||
{% if qr_base64 %}
|
||||
<div class="mb-4 text-center">
|
||||
<label class="form-label d-block">
|
||||
<i class="bi bi-qr-code me-1"></i> QR Code
|
||||
</label>
|
||||
<img src="data:image/png;base64,{{ qr_base64 }}"
|
||||
alt="Recovery Key QR Code" class="img-fluid border rounded"
|
||||
style="max-width: 200px;" id="qrImage">
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Scan with your phone's camera app</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Download Options -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-download me-1"></i> Download Options
|
||||
</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadTextFile()">
|
||||
<i class="bi bi-file-text me-1"></i> Text File
|
||||
</button>
|
||||
{% if qr_base64 %}
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="downloadQRImage()">
|
||||
<i class="bi bi-image me-1"></i> QR Image
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Confirmation Form -->
|
||||
<form method="POST" id="recoveryForm">
|
||||
<input type="hidden" name="recovery_key" value="{{ recovery_key }}">
|
||||
|
||||
<!-- Confirm checkbox -->
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved"
|
||||
onchange="updateButtons()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
I have saved my recovery key in a secure location
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-between">
|
||||
<!-- Skip button (no recovery) -->
|
||||
<button type="submit" name="action" value="skip"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="return confirm('Are you sure? Without a recovery key, there is NO way to reset your password if you forget it.')">
|
||||
<i class="bi bi-skip-forward me-1"></i> Skip (No Recovery)
|
||||
</button>
|
||||
|
||||
<!-- Save button (with key) -->
|
||||
<button type="submit" name="action" value="save"
|
||||
class="btn btn-primary" id="saveBtn" disabled>
|
||||
<i class="bi bi-check-lg me-1"></i> Continue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notes -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-shield-check me-2"></i>Security Notes
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<ul class="mb-0">
|
||||
<li>The recovery key is <strong>not stored</strong> - only a hash is saved</li>
|
||||
<li>Keep it separate from your password (different location)</li>
|
||||
<li>Anyone with this key can reset admin passwords</li>
|
||||
<li>If you lose it and forget your password, you must recreate the database</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Copy recovery key to clipboard
|
||||
function copyToClipboard() {
|
||||
const keyInput = document.getElementById('recoveryKey');
|
||||
navigator.clipboard.writeText(keyInput.value).then(() => {
|
||||
const icon = document.getElementById('copyIcon');
|
||||
icon.className = 'bi bi-clipboard-check';
|
||||
setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Download as text file
|
||||
function downloadTextFile() {
|
||||
const key = document.getElementById('recoveryKey').value;
|
||||
const content = `Stegasoo Recovery Key
|
||||
=====================
|
||||
|
||||
${key}
|
||||
|
||||
IMPORTANT:
|
||||
- Keep this file in a secure location
|
||||
- Anyone with this key can reset admin passwords
|
||||
- Do not store with your password
|
||||
|
||||
Generated: ${new Date().toISOString()}
|
||||
`;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'stegasoo-recovery-key.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Download QR as image
|
||||
function downloadQRImage() {
|
||||
const img = document.getElementById('qrImage');
|
||||
if (!img) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = img.src;
|
||||
a.download = 'stegasoo-recovery-qr.png';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// Enable save button when checkbox is checked
|
||||
function updateButtons() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.disabled = !checkbox.checked;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
756
frontends/web/templates/tools.html
Normal file
@@ -0,0 +1,756 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tools - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Tool drop zone - compact */
|
||||
.tool-drop-zone {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-drop-zone.drag-over {
|
||||
border-color: #63b3ed;
|
||||
background: rgba(99, 179, 237, 0.1);
|
||||
}
|
||||
|
||||
.tool-drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tool-drop-zone .drop-label {
|
||||
text-align: center;
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.tool-drop-zone .drop-icon {
|
||||
font-size: 2rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-drop-zone.drag-over .drop-icon {
|
||||
color: #63b3ed;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Preview state */
|
||||
.tool-drop-zone.has-file .drop-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-container {
|
||||
display: none;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tool-drop-zone.has-file .preview-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-thumb {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 2px solid rgba(99, 179, 237, 0.5);
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-name {
|
||||
font-weight: 600;
|
||||
color: #63b3ed;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-meta {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-clear {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tool-drop-zone .preview-clear:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Result panels */
|
||||
.result-panel {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* EXIF table styling */
|
||||
.exif-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.exif-table th {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.exif-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.exif-input {
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
border: 1px solid rgba(99, 179, 237, 0.3) !important;
|
||||
color: #63b3ed !important;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.exif-input:focus {
|
||||
border-color: #63b3ed !important;
|
||||
box-shadow: 0 0 10px rgba(99, 179, 237, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Processing state */
|
||||
.processing .tool-drop-zone {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.processing .btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Tool section visibility */
|
||||
.tool-section { display: none; }
|
||||
.tool-section.active { display: block; }
|
||||
|
||||
/* Green→amber gradient (12.5% lighter) */
|
||||
.tool-tabs .btn-outline-primary {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.tool-tabs .btn-outline-primary:nth-of-type(1) {
|
||||
color: #40d770;
|
||||
border-color: #40d770;
|
||||
}
|
||||
.tool-tabs .btn-outline-primary:nth-of-type(2) {
|
||||
color: #96da2c;
|
||||
border-color: #96da2c;
|
||||
}
|
||||
.tool-tabs .btn-outline-primary:nth-of-type(3) {
|
||||
color: #fdda64;
|
||||
border-color: #fdda64;
|
||||
}
|
||||
|
||||
.tool-tabs .btn-outline-primary:nth-of-type(1):hover {
|
||||
background-color: rgba(64, 215, 112, 0.15);
|
||||
}
|
||||
.tool-tabs .btn-outline-primary:nth-of-type(2):hover {
|
||||
background-color: rgba(150, 218, 44, 0.15);
|
||||
}
|
||||
.tool-tabs .btn-outline-primary:nth-of-type(3):hover {
|
||||
background-color: rgba(253, 218, 100, 0.15);
|
||||
}
|
||||
|
||||
.tool-tabs .btn-check:checked + .btn-outline-primary:nth-of-type(1) {
|
||||
background-color: #40d770;
|
||||
border-color: #40d770;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
.tool-tabs .btn-check:checked + .btn-outline-primary:nth-of-type(2) {
|
||||
background-color: #96da2c;
|
||||
border-color: #96da2c;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
.tool-tabs .btn-check:checked + .btn-outline-primary:nth-of-type(3) {
|
||||
background-color: #fdda64;
|
||||
border-color: #fdda64;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-tools me-2"></i>Image Security Toolkit</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Tool Selector Tabs -->
|
||||
<div class="btn-group tool-tabs w-100 mb-4" role="group">
|
||||
<input type="radio" class="btn-check" name="tool_type" id="toolCapacity" value="capacity" checked>
|
||||
<label class="btn btn-outline-primary" for="toolCapacity">
|
||||
<i class="bi bi-rulers me-1"></i> Capacity
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="tool_type" id="toolExif" value="exif">
|
||||
<label class="btn btn-outline-primary" for="toolExif">
|
||||
<i class="bi bi-card-text me-1"></i> EXIF
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="tool_type" id="toolStrip" value="strip">
|
||||
<label class="btn btn-outline-primary" for="toolStrip">
|
||||
<i class="bi bi-eraser me-1"></i> Strip
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CAPACITY CALCULATOR -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="tool-section active" id="capacitySection">
|
||||
<p class="text-muted small mb-3">Check how much data can be hidden in an image</p>
|
||||
|
||||
<div class="tool-drop-zone" id="capacityZone">
|
||||
<input type="file" accept="image/*" id="capacityFile">
|
||||
<div class="drop-label">
|
||||
<i class="bi bi-image drop-icon d-block mb-2"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<div class="preview-container">
|
||||
<img class="preview-thumb" id="capacityThumb">
|
||||
<div class="preview-info">
|
||||
<div class="preview-name" id="capacityName">image.jpg</div>
|
||||
<div class="preview-meta" id="capacityMeta">-- × -- · -- MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preview-clear d-none" id="capacityClear">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="result-panel p-3 mt-3 d-none" id="capacityResult">
|
||||
<div class="row text-center">
|
||||
<div class="col-6 col-md-3 mb-3 mb-md-0">
|
||||
<div class="text-muted small">Dimensions</div>
|
||||
<div class="fw-bold" id="capDimensions">--</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3 mb-md-0">
|
||||
<div class="text-muted small">Megapixels</div>
|
||||
<div class="fw-bold" id="capMegapixels">--</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-muted small">LSB Capacity</div>
|
||||
<div class="fw-bold text-primary" id="capLsb">--</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-muted small">DCT Capacity</div>
|
||||
<div class="fw-bold text-warning" id="capDct">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- EXIF EDITOR -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="tool-section" id="exifSection">
|
||||
<p class="text-muted small mb-3">View, edit, or remove image metadata</p>
|
||||
|
||||
<div class="tool-drop-zone" id="exifZone">
|
||||
<input type="file" accept="image/*" id="exifFile">
|
||||
<div class="drop-label">
|
||||
<i class="bi bi-card-image drop-icon d-block mb-2"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<div class="preview-container">
|
||||
<img class="preview-thumb" id="exifThumb">
|
||||
<div class="preview-info">
|
||||
<div class="preview-name" id="exifName">image.jpg</div>
|
||||
<div class="preview-meta"><span id="exifFieldCount">0</span> metadata fields</div>
|
||||
<div id="exifNotEditable" class="text-warning small d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Non-JPEG: clear only
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preview-clear d-none" id="exifClear">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- EXIF Data Editor -->
|
||||
<div id="exifEditor" class="d-none mt-3">
|
||||
<div class="table-responsive result-panel" style="max-height: 250px; overflow-y: auto;">
|
||||
<table class="table table-sm table-dark table-hover exif-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 35%">Field</th>
|
||||
<th>Value</th>
|
||||
<th style="width: 40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="exifTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="exifEmpty" class="result-panel text-muted text-center py-4 d-none">
|
||||
<i class="bi bi-inbox fs-4 d-block mb-2"></i>No metadata found
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex gap-2 mt-3 pt-3 border-top border-secondary">
|
||||
<button type="button" class="btn btn-outline-danger" id="exifClearAll">
|
||||
<i class="bi bi-trash me-1"></i>Clear All
|
||||
</button>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" id="exifDiscard">
|
||||
Discard
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="exifSave" disabled>
|
||||
<i class="bi bi-download me-1"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- STRIP METADATA -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="tool-section" id="stripSection">
|
||||
<p class="text-muted small mb-3">Remove all EXIF data and get a clean image</p>
|
||||
|
||||
<div class="tool-drop-zone" id="stripZone">
|
||||
<input type="file" accept="image/*" id="stripFile">
|
||||
<div class="drop-label">
|
||||
<i class="bi bi-file-earmark-x drop-icon d-block mb-2"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<div class="preview-container">
|
||||
<img class="preview-thumb" id="stripThumb">
|
||||
<div class="preview-info">
|
||||
<div class="preview-name" id="stripName">image.jpg</div>
|
||||
<div class="preview-meta" id="stripMeta">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary preview-clear d-none" id="stripClearBtn">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Format selector and action -->
|
||||
<div id="stripOptions" class="d-none mt-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<label class="form-label mb-0 small text-muted">Output:</label>
|
||||
<select class="form-select form-select-sm" id="stripFormat" style="width: auto;">
|
||||
<option value="PNG" selected>PNG (lossless)</option>
|
||||
<option value="JPEG">JPEG</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-danger ms-auto" id="stripAction">
|
||||
<i class="bi bi-eraser me-1"></i>Strip & Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ============================================================================
|
||||
// TAB SWITCHING
|
||||
// ============================================================================
|
||||
|
||||
const toolRadios = document.querySelectorAll('input[name="tool_type"]');
|
||||
const toolSections = {
|
||||
capacity: document.getElementById('capacitySection'),
|
||||
exif: document.getElementById('exifSection'),
|
||||
strip: document.getElementById('stripSection')
|
||||
};
|
||||
|
||||
function switchTool() {
|
||||
const selected = document.querySelector('input[name="tool_type"]:checked').value;
|
||||
Object.entries(toolSections).forEach(([key, section]) => {
|
||||
section.classList.toggle('active', key === selected);
|
||||
});
|
||||
}
|
||||
|
||||
toolRadios.forEach(radio => radio.addEventListener('change', switchTool));
|
||||
|
||||
// ============================================================================
|
||||
// SHARED - Drop zone helpers
|
||||
// ============================================================================
|
||||
|
||||
function setupDropZone(zoneId, fileInputId, onFile) {
|
||||
const zone = document.getElementById(zoneId);
|
||||
const input = document.getElementById(fileInputId);
|
||||
if (!zone || !input) return;
|
||||
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files[0]) {
|
||||
input.files = e.dataTransfer.files;
|
||||
input.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('change', function() {
|
||||
if (this.files[0]) onFile(this.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function showPreview(zoneId, file, thumbId, nameId, metaText, clearBtnId) {
|
||||
const zone = document.getElementById(zoneId);
|
||||
const thumb = document.getElementById(thumbId);
|
||||
const name = document.getElementById(nameId);
|
||||
const clearBtn = document.getElementById(clearBtnId);
|
||||
|
||||
zone.classList.add('has-file');
|
||||
name.textContent = file.name;
|
||||
|
||||
if (metaText) {
|
||||
const metaEl = name.nextElementSibling;
|
||||
if (metaEl) metaEl.textContent = metaText;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => thumb.src = e.target.result;
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
clearBtn?.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function clearDropZone(zoneId, fileInputId, clearBtnId, extraCleanup) {
|
||||
const zone = document.getElementById(zoneId);
|
||||
const input = document.getElementById(fileInputId);
|
||||
const clearBtn = document.getElementById(clearBtnId);
|
||||
|
||||
zone?.classList.remove('has-file');
|
||||
if (input) input.value = '';
|
||||
clearBtn?.classList.add('d-none');
|
||||
if (extraCleanup) extraCleanup();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CAPACITY CALCULATOR
|
||||
// ============================================================================
|
||||
|
||||
setupDropZone('capacityZone', 'capacityFile', async (file) => {
|
||||
showPreview('capacityZone', file, 'capacityThumb', 'capacityName', formatBytes(file.size), 'capacityClear');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/capacity', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('capacityMeta').textContent =
|
||||
`${data.width} × ${data.height} · ${formatBytes(file.size)}`;
|
||||
document.getElementById('capDimensions').textContent = `${data.width} × ${data.height}`;
|
||||
document.getElementById('capMegapixels').textContent = data.megapixels + ' MP';
|
||||
document.getElementById('capLsb').textContent = data.lsb.capacity_kb.toFixed(1) + ' KB';
|
||||
document.getElementById('capDct').textContent = data.dct.available
|
||||
? data.dct.capacity_kb.toFixed(1) + ' KB'
|
||||
: 'N/A';
|
||||
document.getElementById('capacityResult').classList.remove('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('capacityClear')?.addEventListener('click', () => {
|
||||
clearDropZone('capacityZone', 'capacityFile', 'capacityClear', () => {
|
||||
document.getElementById('capacityResult').classList.add('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// EXIF EDITOR
|
||||
// ============================================================================
|
||||
|
||||
let exifOriginalData = {};
|
||||
let exifCurrentData = {};
|
||||
let exifEditable = false;
|
||||
let exifCurrentFile = null;
|
||||
|
||||
setupDropZone('exifZone', 'exifFile', async (file) => {
|
||||
exifCurrentFile = file;
|
||||
showPreview('exifZone', file, 'exifThumb', 'exifName', '', 'exifClear');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
exifOriginalData = JSON.parse(JSON.stringify(data.exif));
|
||||
exifCurrentData = JSON.parse(JSON.stringify(data.exif));
|
||||
exifEditable = data.editable;
|
||||
|
||||
document.getElementById('exifFieldCount').textContent = data.field_count;
|
||||
document.getElementById('exifNotEditable').classList.toggle('d-none', data.editable);
|
||||
document.getElementById('exifEditor').classList.remove('d-none');
|
||||
|
||||
renderExifTable();
|
||||
updateSaveButton();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('exifClear')?.addEventListener('click', () => {
|
||||
clearDropZone('exifZone', 'exifFile', 'exifClear', () => {
|
||||
document.getElementById('exifEditor').classList.add('d-none');
|
||||
exifCurrentFile = null;
|
||||
exifOriginalData = {};
|
||||
exifCurrentData = {};
|
||||
});
|
||||
});
|
||||
|
||||
function renderExifTable() {
|
||||
const tbody = document.getElementById('exifTable');
|
||||
const empty = document.getElementById('exifEmpty');
|
||||
const entries = Object.entries(exifCurrentData).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('d-none');
|
||||
tbody.innerHTML = entries.map(([key, value]) => {
|
||||
let displayVal = typeof value === 'object' ? JSON.stringify(value) : value;
|
||||
if (typeof displayVal === 'string' && displayVal.length > 50) {
|
||||
displayVal = displayVal.substring(0, 47) + '...';
|
||||
}
|
||||
|
||||
const editableFields = ['Make', 'Model', 'Software', 'Artist', 'Copyright', 'ImageDescription', 'DateTime', 'DateTimeOriginal', 'DateTimeDigitized', 'UserComment', 'LensMake', 'LensModel'];
|
||||
const canEdit = exifEditable && editableFields.includes(key) && typeof value === 'string';
|
||||
|
||||
return `
|
||||
<tr data-field="${key}">
|
||||
<td class="text-muted small">${key}</td>
|
||||
<td class="font-monospace small">
|
||||
${canEdit
|
||||
? `<input type="text" class="form-control form-control-sm exif-input"
|
||||
value="${String(value).replace(/"/g, '"')}" data-field="${key}">`
|
||||
: `<span title="${String(displayVal)}">${displayVal}</span>`
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
${canEdit
|
||||
? `<button class="btn btn-sm btn-outline-danger border-0 exif-delete" data-field="${key}" title="Remove">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.exif-input').forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
exifCurrentData[this.dataset.field] = this.value;
|
||||
updateSaveButton();
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('.exif-delete').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
delete exifCurrentData[this.dataset.field];
|
||||
renderExifTable();
|
||||
updateSaveButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateSaveButton() {
|
||||
const changed = JSON.stringify(exifCurrentData) !== JSON.stringify(exifOriginalData);
|
||||
document.getElementById('exifSave').disabled = !changed;
|
||||
}
|
||||
|
||||
document.getElementById('exifClearAll')?.addEventListener('click', async function() {
|
||||
if (!exifCurrentFile) return;
|
||||
if (!confirm('Remove all metadata from this image?')) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', exifCurrentFile);
|
||||
formData.append('format', 'PNG');
|
||||
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Clearing...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/exif/clear', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'clean.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
exifCurrentData = {};
|
||||
exifOriginalData = {};
|
||||
renderExifTable();
|
||||
} else {
|
||||
alert('Failed to clear metadata');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to clear metadata: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-trash me-1"></i>Clear All';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('exifDiscard')?.addEventListener('click', function() {
|
||||
exifCurrentData = JSON.parse(JSON.stringify(exifOriginalData));
|
||||
renderExifTable();
|
||||
updateSaveButton();
|
||||
});
|
||||
|
||||
document.getElementById('exifSave')?.addEventListener('click', async function() {
|
||||
if (!exifCurrentFile || !exifEditable) return;
|
||||
|
||||
const updates = {};
|
||||
for (const [key, val] of Object.entries(exifCurrentData)) {
|
||||
if (exifOriginalData[key] !== val) updates[key] = val;
|
||||
}
|
||||
for (const key of Object.keys(exifOriginalData)) {
|
||||
if (!(key in exifCurrentData)) updates[key] = null;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', exifCurrentFile);
|
||||
formData.append('updates', JSON.stringify(updates));
|
||||
|
||||
const btn = this;
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/exif/update', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'updated.jpg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
exifOriginalData = JSON.parse(JSON.stringify(exifCurrentData));
|
||||
updateSaveButton();
|
||||
} else {
|
||||
alert('Failed to save');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to save changes: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
updateSaveButton();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// STRIP METADATA
|
||||
// ============================================================================
|
||||
|
||||
let stripCurrentFile = null;
|
||||
|
||||
setupDropZone('stripZone', 'stripFile', (file) => {
|
||||
stripCurrentFile = file;
|
||||
showPreview('stripZone', file, 'stripThumb', 'stripName', formatBytes(file.size), 'stripClearBtn');
|
||||
document.getElementById('stripMeta').textContent = formatBytes(file.size);
|
||||
document.getElementById('stripOptions').classList.remove('d-none');
|
||||
});
|
||||
|
||||
document.getElementById('stripClearBtn')?.addEventListener('click', () => {
|
||||
clearDropZone('stripZone', 'stripFile', 'stripClearBtn', () => {
|
||||
document.getElementById('stripOptions').classList.add('d-none');
|
||||
stripCurrentFile = null;
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('stripAction')?.addEventListener('click', async function() {
|
||||
if (!stripCurrentFile) return;
|
||||
|
||||
const format = document.getElementById('stripFormat').value;
|
||||
const formData = new FormData();
|
||||
formData.append('image', stripCurrentFile);
|
||||
formData.append('format', format);
|
||||
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/exif/clear', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || `clean.${format.toLowerCase()}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert('Failed to strip metadata');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to strip metadata: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-eraser me-1"></i>Strip & Download';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
instance/.secret_key
Normal file
@@ -0,0 +1 @@
|
||||
6a7378172fc0ec37143720f09a4ca34e83ec2409893aa8cd79ace5b78a64276c
|
||||
BIN
instance/stegasoo.db
Normal file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "2.0.1"
|
||||
version = "4.1.1"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -43,24 +43,47 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# DCT steganography support (v3.0+)
|
||||
dct = [
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
cli = [
|
||||
"click>=8.0.0",
|
||||
"qrcode>=7.30"
|
||||
"qrcode>=7.30",
|
||||
"piexif>=1.1.0",
|
||||
]
|
||||
compression = [
|
||||
"lz4>=4.0.0",
|
||||
]
|
||||
web = [
|
||||
"flask>=3.0.0",
|
||||
"gunicorn>=21.0.0",
|
||||
"qrcode>=7.3.0",
|
||||
"pyzbar",
|
||||
"pyzbar>=0.1.9",
|
||||
"piexif>=1.1.0",
|
||||
# Include DCT support for web UI
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"reedsolo>=1.7.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",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
all = [
|
||||
"stegasoo[cli,web,api]",
|
||||
"stegasoo[cli,web,api,dct,compression]",
|
||||
]
|
||||
dev = [
|
||||
"stegasoo[all]",
|
||||
@@ -99,9 +122,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
128
rpi/BUILD_IMAGE.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Stegasoo Pi Image Build Workflow
|
||||
|
||||
Quick reference for building a distributable SD card image.
|
||||
|
||||
## Step 1: Flash Fresh Raspbian
|
||||
|
||||
Use rpi-imager with these settings:
|
||||
- **OS**: Raspberry Pi OS Lite (64-bit)
|
||||
- **Hostname**: `stegasoo`
|
||||
- **Enable SSH**: Yes (password auth)
|
||||
- **Username**: `admin`
|
||||
- **Password**: `stegasoo`
|
||||
- **WiFi**: Configure for your network (sanitize script removes it later)
|
||||
|
||||
## Step 2: Boot & SSH In
|
||||
|
||||
```bash
|
||||
# Wait for Pi to boot (~60 seconds), then:
|
||||
ssh admin@stegasoo.local
|
||||
# or use IP from router DHCP list
|
||||
```
|
||||
|
||||
## Step 3: Pre-Setup
|
||||
|
||||
```bash
|
||||
# Take ownership of /opt (for pyenv, jpegio builds)
|
||||
sudo chown admin:admin /opt
|
||||
|
||||
# Install git (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
```
|
||||
|
||||
## Step 4: Clone & Run Setup
|
||||
|
||||
```bash
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
This takes ~15-20 minutes and installs:
|
||||
- Python 3.12 via pyenv
|
||||
- jpegio (patched for ARM)
|
||||
- Stegasoo with web UI
|
||||
- Systemd service
|
||||
|
||||
## Step 5: Test It Works
|
||||
|
||||
```bash
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
## Step 6: Sanitize for Distribution
|
||||
|
||||
```bash
|
||||
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# Or soft reset (for testing - keeps WiFi, reboots)
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh --soft
|
||||
```
|
||||
|
||||
This removes:
|
||||
- WiFi credentials (unless `--soft`)
|
||||
- SSH host keys (regenerate on boot)
|
||||
- SSH authorized keys
|
||||
- Bash history
|
||||
- Stegasoo auth database
|
||||
- Logs and temp files
|
||||
|
||||
The script validates all cleanup steps before finishing.
|
||||
|
||||
## Step 7: Copy the Image
|
||||
|
||||
Remove SD card, insert into your Linux machine:
|
||||
|
||||
```bash
|
||||
# Find the SD card device (CAREFUL!)
|
||||
lsblk
|
||||
|
||||
# Copy (replace sdX with actual device, e.g., sda)
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
```
|
||||
|
||||
## Step 8: Shrink & Compress
|
||||
|
||||
```bash
|
||||
# Optional: Shrink image (saves space)
|
||||
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||
chmod +x pishrink.sh
|
||||
sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||
|
||||
# Compress (zstd is faster than xz with similar ratio)
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
```
|
||||
|
||||
## Step 9: Distribute
|
||||
|
||||
Upload `.img.zst` to GitHub Releases.
|
||||
|
||||
Users can flash with:
|
||||
```bash
|
||||
# Linux
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
|
||||
# Or use rpi-imager "Use custom" option
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Command Summary
|
||||
|
||||
```bash
|
||||
# On Pi (after SSH):
|
||||
sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo && ./rpi/setup.sh
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# On your machine:
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
```
|
||||
202
rpi/README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Stegasoo Raspberry Pi
|
||||
|
||||
Scripts and resources for deploying Stegasoo on Raspberry Pi.
|
||||
|
||||
## Quick Install
|
||||
|
||||
On a fresh Raspberry Pi OS Lite (64-bit) installation:
|
||||
|
||||
```bash
|
||||
# Pre-setup (git not included in Lite image)
|
||||
sudo chown $USER:$USER /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
||||
cd /opt/stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
## What the Setup Script Does
|
||||
|
||||
1. **Installs system dependencies** - build tools, libraries
|
||||
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||
3. **Builds jpegio for ARM** - patches x86-specific flags
|
||||
4. **Installs Stegasoo** - with web UI and all dependencies
|
||||
5. **Creates systemd service** - auto-starts on boot
|
||||
6. **Enables the service** - ready to start
|
||||
|
||||
## Requirements
|
||||
|
||||
- Raspberry Pi 4 or 5
|
||||
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
||||
- 4GB+ RAM recommended (2GB minimum)
|
||||
- ~2GB free disk space
|
||||
- Internet connection
|
||||
|
||||
### Performance
|
||||
|
||||
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
||||
|
||||
## Pre-built Image Defaults
|
||||
|
||||
If using a pre-built image from GitHub Releases:
|
||||
|
||||
- **Default login**: `admin` / `stegasoo`
|
||||
- **Hostname**: `stegasoo.local`
|
||||
- **First boot**: A setup wizard runs on first SSH login
|
||||
|
||||
> **Security note**: Change the default password after setup with `passwd`
|
||||
|
||||
## After Installation
|
||||
|
||||
### Start the Service
|
||||
|
||||
```bash
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
sudo systemctl status stegasoo
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
journalctl -u stegasoo -f
|
||||
```
|
||||
|
||||
### Access Web UI
|
||||
|
||||
Open in browser: `http://<pi-ip>:5000`
|
||||
|
||||
On first access, you'll create an admin account.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit the systemd service to change settings:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit stegasoo
|
||||
```
|
||||
|
||||
Add overrides:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||
Environment="STEGASOO_HTTPS_ENABLED=true"
|
||||
Environment="STEGASOO_HOSTNAME=stegasoo.local"
|
||||
```
|
||||
|
||||
Then reload:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
sudo systemctl stop stegasoo
|
||||
sudo systemctl disable stegasoo
|
||||
sudo rm /etc/systemd/system/stegasoo.service
|
||||
rm -rf /opt/stegasoo
|
||||
```
|
||||
|
||||
## Pre-built Images
|
||||
|
||||
Check [GitHub Releases](https://github.com/adlee-was-taken/stegasoo/releases) for pre-built SD card images.
|
||||
|
||||
---
|
||||
|
||||
## Building Your Own Image
|
||||
|
||||
To create a distributable SD card image:
|
||||
|
||||
### 1. Flash Fresh Raspberry Pi OS
|
||||
|
||||
Use rpi-imager to flash Raspberry Pi OS (64-bit) to an SD card.
|
||||
|
||||
In advanced settings, set:
|
||||
- Hostname: `stegasoo`
|
||||
- Enable SSH (password auth for initial setup)
|
||||
- Username/password (temporary, will work for any user)
|
||||
- Skip WiFi for distributable image
|
||||
|
||||
### 2. Boot and Run Setup
|
||||
|
||||
```bash
|
||||
# SSH into the Pi
|
||||
ssh admin@stegasoo.local
|
||||
|
||||
# Pre-setup
|
||||
sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
||||
cd /opt/stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
### 3. Test It Works
|
||||
|
||||
```bash
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000 # Should return HTML
|
||||
```
|
||||
|
||||
### 4. Sanitize for Distribution
|
||||
|
||||
```bash
|
||||
# Full sanitize (removes WiFi, shuts down for imaging)
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# Or soft reset (keeps WiFi for testing, reboots)
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh --soft
|
||||
```
|
||||
|
||||
This removes:
|
||||
- WiFi credentials (unless `--soft`)
|
||||
- SSH host keys (regenerate on boot)
|
||||
- SSH authorized keys
|
||||
- Bash history
|
||||
- Stegasoo auth database (users create their own admin)
|
||||
- Logs and temp files
|
||||
|
||||
The script validates cleanup and reports any issues.
|
||||
|
||||
### 5. Create the Image
|
||||
|
||||
After Pi shuts down, remove SD card and on another Linux machine:
|
||||
|
||||
```bash
|
||||
# Find SD card device (BE CAREFUL - wrong device = data loss!)
|
||||
lsblk
|
||||
|
||||
# Copy (replace sdX with your SD card)
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
|
||||
# Shrink the image (optional but recommended)
|
||||
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||
chmod +x pishrink.sh
|
||||
sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||
|
||||
# Compress (zstd is faster than xz with similar compression)
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
```
|
||||
|
||||
### 6. Distribute
|
||||
|
||||
Upload the `.img.zst` file to GitHub Releases.
|
||||
|
||||
Users flash with:
|
||||
```bash
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
Or use rpi-imager's "Use custom" option.
|
||||
455
rpi/first-boot-wizard.sh
Executable file
@@ -0,0 +1,455 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stegasoo First Boot Wizard
|
||||
# Runs on first SSH login to configure the pre-installed Stegasoo image
|
||||
#
|
||||
# This script is triggered by /etc/profile.d/stegasoo-wizard.sh
|
||||
# After completion, it removes itself to prevent re-running
|
||||
#
|
||||
# Uses gum (Charm.sh) for beautiful TUI - install with:
|
||||
# sudo apt install gum OR go install github.com/charmbracelet/gum@latest
|
||||
#
|
||||
|
||||
# Configuration
|
||||
INSTALL_DIR="/opt/stegasoo"
|
||||
FLAG_FILE="/etc/stegasoo-first-boot"
|
||||
PROFILE_HOOK="/etc/profile.d/stegasoo-wizard.sh"
|
||||
|
||||
# Check if this is first boot
|
||||
if [ ! -f "$FLAG_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for gum, fall back to basic prompts if not available
|
||||
if ! command -v gum &>/dev/null; then
|
||||
echo "Error: gum not found. Install with: sudo apt install gum"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gum styling - terminal green buttons with bold dark text
|
||||
export GUM_CONFIRM_SELECTED_BACKGROUND="46"
|
||||
export GUM_CONFIRM_SELECTED_FOREGROUND="232"
|
||||
export GUM_CONFIRM_SELECTED_BOLD="true"
|
||||
export GUM_CONFIRM_UNSELECTED_BACKGROUND="238"
|
||||
export GUM_CONFIRM_UNSELECTED_FOREGROUND="255"
|
||||
|
||||
clear
|
||||
|
||||
# =============================================================================
|
||||
# Welcome
|
||||
# =============================================================================
|
||||
|
||||
gum style \
|
||||
--border double \
|
||||
--border-foreground 212 \
|
||||
--padding "1 2" \
|
||||
--margin "1" \
|
||||
--align center \
|
||||
" . * . . * . * . * . * ." \
|
||||
" ___ _____ ___ ___ _ ___ ___ ___ " \
|
||||
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
|
||||
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
|
||||
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
|
||||
"" \
|
||||
" * . * . * . * . * . *" \
|
||||
"" \
|
||||
"First Boot Wizard"
|
||||
|
||||
echo ""
|
||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
|
||||
gum style --foreground 245 "You can reconfigure later by editing /etc/systemd/system/stegasoo.service"
|
||||
echo ""
|
||||
|
||||
gum confirm "Ready to begin setup?" || exit 0
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Variables
|
||||
# =============================================================================
|
||||
|
||||
ENABLE_HTTPS="false"
|
||||
USE_PORT_443="false"
|
||||
CHANNEL_KEY=""
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: HTTPS Configuration
|
||||
# =============================================================================
|
||||
|
||||
clear
|
||||
gum style \
|
||||
--foreground 212 --bold \
|
||||
"Step 1 of 4: HTTPS Configuration"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 245 "\
|
||||
HTTPS encrypts all traffic between your browser and this server
|
||||
using a self-signed certificate.
|
||||
|
||||
NOTE: Your browser will show a security warning because the
|
||||
certificate is self-signed. This is normal for home networks."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Enable HTTPS?" --default=true; then
|
||||
ENABLE_HTTPS="true"
|
||||
gum style --foreground 82 "✓ HTTPS will be enabled"
|
||||
else
|
||||
gum style --foreground 214 "→ Using HTTP (unencrypted)"
|
||||
fi
|
||||
sleep 0.5
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: Port Configuration (only if HTTPS)
|
||||
# =============================================================================
|
||||
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
clear
|
||||
gum style \
|
||||
--foreground 212 --bold \
|
||||
"Step 2 of 4: Port Configuration"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 245 "\
|
||||
The standard HTTPS port is 443, which means you can access
|
||||
Stegasoo without specifying a port in the URL.
|
||||
|
||||
Port 443: https://stegasoo.local
|
||||
Port 5000: https://stegasoo.local:5000
|
||||
|
||||
NOTE: Port 443 requires an iptables redirect rule."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Use standard port 443?" --default=true; then
|
||||
USE_PORT_443="true"
|
||||
gum style --foreground 82 "✓ Port 443 will be configured"
|
||||
else
|
||||
gum style --foreground 214 "→ Using port 5000"
|
||||
fi
|
||||
sleep 0.5
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: Channel Key Configuration
|
||||
# =============================================================================
|
||||
|
||||
clear
|
||||
gum style \
|
||||
--foreground 212 --bold \
|
||||
"Step 3 of 4: Channel Key Configuration"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 245 "\
|
||||
A channel key creates a private encoding channel.
|
||||
|
||||
WITHOUT a key: Anyone with Stegasoo can decode your images
|
||||
WITH a key: Only people with YOUR key can decode
|
||||
|
||||
This is useful if you want to share encoded images only with
|
||||
specific people (family, team, etc)."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Generate a private channel key?" --default=false; then
|
||||
echo ""
|
||||
# Generate key to temp file (gum spin doesn't capture stdout well)
|
||||
KEY_FILE=$(mktemp)
|
||||
ERR_FILE=$(mktemp)
|
||||
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
gum spin --spinner dot --title "Generating channel key..." -- \
|
||||
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
|
||||
|
||||
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
|
||||
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
|
||||
rm -f "$KEY_FILE" "$ERR_FILE"
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
|
||||
echo ""
|
||||
gum style --foreground 82 "✓ Channel key generated!"
|
||||
echo ""
|
||||
gum style \
|
||||
--border rounded \
|
||||
--border-foreground 226 \
|
||||
--padding "1 2" \
|
||||
--foreground 226 --bold \
|
||||
"$CHANNEL_KEY"
|
||||
echo ""
|
||||
gum style --foreground 196 --bold \
|
||||
"*** IMPORTANT: Write down or copy this key NOW! ***"
|
||||
gum style --foreground 196 \
|
||||
"You'll need to share it with anyone who should decode" \
|
||||
"your images. This key won't be shown again."
|
||||
echo ""
|
||||
gum confirm "I've saved the key" --default=true --affirmative="Continue" --negative=""
|
||||
else
|
||||
gum style --foreground 196 "Failed to generate key. Using public mode."
|
||||
if [ -n "$KEY_ERROR" ]; then
|
||||
echo ""
|
||||
gum style --foreground 245 "Error details:"
|
||||
echo "$KEY_ERROR"
|
||||
fi
|
||||
CHANNEL_KEY=""
|
||||
echo ""
|
||||
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
|
||||
fi
|
||||
else
|
||||
gum style --foreground 214 "→ Using public mode"
|
||||
sleep 0.5
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 4: Overclock Configuration
|
||||
# =============================================================================
|
||||
|
||||
ENABLE_OVERCLOCK="false"
|
||||
NEEDS_RESTART="false"
|
||||
|
||||
# Detect Pi model
|
||||
PI_MODEL=$(cat /proc/device-tree/model 2>/dev/null | tr -d '\0')
|
||||
|
||||
if [[ "$PI_MODEL" == *"Raspberry Pi 4"* ]] || [[ "$PI_MODEL" == *"Raspberry Pi 5"* ]]; then
|
||||
clear
|
||||
gum style \
|
||||
--foreground 212 --bold \
|
||||
"Step 4 of 4: Performance Tuning"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 245 "\
|
||||
Detected: $PI_MODEL
|
||||
|
||||
Overclocking can improve DCT encode/decode performance.
|
||||
This is ONLY recommended if you have active cooling:
|
||||
• Heatsink + Fan
|
||||
• Active cooler case
|
||||
|
||||
Without cooling, the Pi may throttle or become unstable."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Do you have active cooling (heatsink + fan)?" --default=false; then
|
||||
echo ""
|
||||
gum style --foreground 245 "\
|
||||
Recommended overclock settings:
|
||||
• Pi 4: 2.0 GHz (stock 1.5 GHz) - ~33% faster
|
||||
• Pi 5: 2.8 GHz (stock 2.4 GHz) - ~17% faster"
|
||||
echo ""
|
||||
|
||||
if gum confirm "Enable overclock?" --default=true; then
|
||||
ENABLE_OVERCLOCK="true"
|
||||
NEEDS_RESTART="true"
|
||||
gum style --foreground 82 "✓ Overclock will be enabled (restart required)"
|
||||
else
|
||||
gum style --foreground 214 "→ Running at stock speed"
|
||||
fi
|
||||
else
|
||||
gum style --foreground 214 "→ Skipping overclock (no active cooling)"
|
||||
fi
|
||||
sleep 0.5
|
||||
else
|
||||
# Not a Pi 4/5, skip overclock
|
||||
:
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Apply Configuration
|
||||
# =============================================================================
|
||||
|
||||
clear
|
||||
gum style \
|
||||
--foreground 212 --bold \
|
||||
"Applying Configuration..."
|
||||
echo ""
|
||||
|
||||
# Find the stegasoo user (whoever owns the install dir)
|
||||
STEGASOO_USER=$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo "pi")
|
||||
|
||||
gum spin --spinner dot --title "Updating systemd service..." -- bash -c "
|
||||
sudo tee /etc/systemd/system/stegasoo.service >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$STEGASOO_USER
|
||||
WorkingDirectory=$INSTALL_DIR/frontends/web
|
||||
Environment=\"PATH=$INSTALL_DIR/venv/bin:/usr/bin\"
|
||||
Environment=\"STEGASOO_AUTH_ENABLED=true\"
|
||||
Environment=\"STEGASOO_HTTPS_ENABLED=$ENABLE_HTTPS\"
|
||||
Environment=\"STEGASOO_PORT=5000\"
|
||||
Environment=\"STEGASOO_CHANNEL_KEY=$CHANNEL_KEY\"
|
||||
ExecStart=$INSTALL_DIR/venv/bin/python app.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
"
|
||||
gum style --foreground 82 "✓ Service configured"
|
||||
|
||||
# Setup port 443 if requested
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
gum spin --spinner dot --title "Setting up port 443 redirect..." -- bash -c "
|
||||
if ! command -v iptables &>/dev/null; then
|
||||
sudo apt-get install -y iptables >/dev/null 2>&1
|
||||
fi
|
||||
if ! sudo iptables -t nat -C PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000 2>/dev/null; then
|
||||
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000
|
||||
fi
|
||||
sudo sh -c 'iptables-save > /etc/iptables.rules'
|
||||
sudo tee /etc/systemd/system/iptables-restore.service >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Restore iptables rules
|
||||
Before=network-pre.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/sbin/iptables-restore /etc/iptables.rules
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl enable iptables-restore.service >/dev/null 2>&1
|
||||
"
|
||||
gum style --foreground 82 "✓ Port 443 redirect configured"
|
||||
fi
|
||||
|
||||
gum spin --spinner dot --title "Reloading systemd..." -- sudo systemctl daemon-reload
|
||||
gum style --foreground 82 "✓ Systemd reloaded"
|
||||
|
||||
# Apply overclock if requested
|
||||
if [ "$ENABLE_OVERCLOCK" = "true" ]; then
|
||||
gum spin --spinner dot --title "Configuring overclock..." -- bash -c "
|
||||
CONFIG_FILE='/boot/firmware/config.txt'
|
||||
# Fallback for older Pi OS
|
||||
if [ ! -f \"\$CONFIG_FILE\" ]; then
|
||||
CONFIG_FILE='/boot/config.txt'
|
||||
fi
|
||||
|
||||
# Check if overclock already configured
|
||||
if ! grep -q '^over_voltage=' \"\$CONFIG_FILE\" 2>/dev/null; then
|
||||
# Detect Pi model for appropriate settings
|
||||
PI_MODEL=\$(cat /proc/device-tree/model 2>/dev/null | tr -d '\0')
|
||||
|
||||
echo '' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
echo '# Overclock (configured by Stegasoo wizard)' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
|
||||
if [[ \"\$PI_MODEL\" == *'Raspberry Pi 5'* ]]; then
|
||||
# Pi 5 overclock
|
||||
echo 'over_voltage=4' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
echo 'arm_freq=2800' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
else
|
||||
# Pi 4 overclock
|
||||
echo 'over_voltage=6' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
echo 'arm_freq=2000' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
echo 'gpu_freq=700' | sudo tee -a \"\$CONFIG_FILE\" >/dev/null
|
||||
fi
|
||||
fi
|
||||
"
|
||||
gum style --foreground 82 "✓ Overclock configured"
|
||||
fi
|
||||
|
||||
gum spin --spinner dot --title "Starting Stegasoo..." -- bash -c "sudo systemctl restart stegasoo && sleep 2"
|
||||
|
||||
if systemctl is-active --quiet stegasoo; then
|
||||
gum style --foreground 82 "✓ Stegasoo started successfully"
|
||||
else
|
||||
gum style --foreground 196 "✗ Failed to start (check: journalctl -u stegasoo)"
|
||||
fi
|
||||
|
||||
gum spin --spinner dot --title "Cleaning up wizard..." -- bash -c "
|
||||
sudo rm -f '$FLAG_FILE'
|
||||
sudo rm -f '$PROFILE_HOOK'
|
||||
"
|
||||
gum style --foreground 82 "✓ Wizard complete"
|
||||
|
||||
sleep 1
|
||||
|
||||
# =============================================================================
|
||||
# Final Summary
|
||||
# =============================================================================
|
||||
|
||||
clear
|
||||
|
||||
PI_IP=$(hostname -I | awk '{print $1}')
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
# Build the access URL
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
ACCESS_URL="https://$PI_IP/setup"
|
||||
ACCESS_URL_LOCAL="https://$HOSTNAME.local/setup"
|
||||
else
|
||||
ACCESS_URL="https://$PI_IP:5000/setup"
|
||||
ACCESS_URL_LOCAL="https://$HOSTNAME.local:5000/setup"
|
||||
fi
|
||||
else
|
||||
ACCESS_URL="http://$PI_IP:5000/setup"
|
||||
ACCESS_URL_LOCAL="http://$HOSTNAME.local:5000/setup"
|
||||
fi
|
||||
|
||||
gum style \
|
||||
--border double \
|
||||
--border-foreground 82 \
|
||||
--padding "1 2" \
|
||||
--margin "1" \
|
||||
--align center \
|
||||
" . * . . * . * . * . * ." \
|
||||
" ___ _____ ___ ___ _ ___ ___ ___" \
|
||||
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
|
||||
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
|
||||
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
|
||||
"" \
|
||||
" * . * . * . * . * . *" \
|
||||
"" \
|
||||
"Setup Complete!"
|
||||
|
||||
echo ""
|
||||
gum style --foreground 82 --bold "Create your admin account:"
|
||||
gum style --foreground 226 " $ACCESS_URL"
|
||||
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
|
||||
echo ""
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ]; then
|
||||
gum style --foreground 82 --bold "Channel Key:"
|
||||
gum style --foreground 226 " $CHANNEL_KEY"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
gum style --foreground 82 --bold "First Steps:"
|
||||
gum style --foreground 255 \
|
||||
" 1. Open the URL above in your browser" \
|
||||
" 2. Accept the security warning (self-signed cert)" \
|
||||
" 3. Create your admin account" \
|
||||
" 4. Start encoding secret messages!"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 82 --bold "Useful Commands:"
|
||||
gum style --foreground 245 \
|
||||
" sudo systemctl status stegasoo # Check status" \
|
||||
" sudo systemctl restart stegasoo # Restart" \
|
||||
" journalctl -u stegasoo -f # View logs"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 212 --bold "Enjoy Stegasoo!"
|
||||
echo ""
|
||||
|
||||
# Prompt for restart if overclock was enabled
|
||||
if [ "$NEEDS_RESTART" = "true" ]; then
|
||||
echo ""
|
||||
gum style \
|
||||
--border rounded \
|
||||
--border-foreground 226 \
|
||||
--padding "1 2" \
|
||||
--foreground 226 \
|
||||
"Restart Required" \
|
||||
"" \
|
||||
"Overclock settings require a restart to take effect."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Restart now?" --default=true; then
|
||||
gum style --foreground 82 "Restarting in 3 seconds..."
|
||||
sleep 3
|
||||
sudo reboot
|
||||
else
|
||||
gum style --foreground 214 "Remember to restart later for overclock to take effect:"
|
||||
gum style --foreground 245 " sudo reboot"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
227
rpi/flash-image.sh
Executable file
@@ -0,0 +1,227 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Flash Stegasoo image to SD card
|
||||
# Auto-detects SD card, decompresses and writes with progress
|
||||
#
|
||||
# Usage: ./flash-image.sh <image.img.xz> [device]
|
||||
# ./flash-image.sh <image.img> [device]
|
||||
#
|
||||
# If device is specified, skips auto-detection (useful for large drives)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check for required tools
|
||||
for cmd in dd pv lsblk; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for image argument
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${RED}Usage: $0 <image.img.xz|image.img> [device]${NC}"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 stegasoo-rpi-20260103.img.xz # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-20260103.img.xz /dev/sdb # specify device"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
MANUAL_DEVICE="$2"
|
||||
|
||||
if [ ! -f "$IMAGE" ]; then
|
||||
echo -e "${RED}Error: Image file not found: $IMAGE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect compression
|
||||
COMPRESSED=false
|
||||
COMP_TYPE=""
|
||||
if [[ "$IMAGE" == *.xz ]]; then
|
||||
COMPRESSED=true
|
||||
COMP_TYPE="xz"
|
||||
if ! command -v xzcat &> /dev/null; then
|
||||
echo -e "${RED}Error: xz is required for .xz files but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "$IMAGE" == *.zst ]]; then
|
||||
COMPRESSED=true
|
||||
COMP_TYPE="zst"
|
||||
if ! command -v zstdcat &> /dev/null; then
|
||||
echo -e "${RED}Error: zstd is required for .zst files but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "$IMAGE" == *.gz ]]; then
|
||||
COMPRESSED=true
|
||||
COMP_TYPE="gz"
|
||||
if ! command -v zcat &> /dev/null; then
|
||||
echo -e "${RED}Error: gzip is required for .gz files but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Stegasoo SD Card Flasher ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
echo -e "Image: ${YELLOW}$IMAGE${NC}"
|
||||
echo -e "Size: ${YELLOW}$(du -h "$IMAGE" | awk '{print $1}')${NC}"
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
echo -e "Type: ${YELLOW}Compressed (will decompress on-the-fly)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Use manual device or auto-detect
|
||||
if [ -n "$MANUAL_DEVICE" ]; then
|
||||
# Manual device specified
|
||||
if [ ! -b "$MANUAL_DEVICE" ]; then
|
||||
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="$MANUAL_DEVICE"
|
||||
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
||||
echo ""
|
||||
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
||||
echo ""
|
||||
else
|
||||
# Auto-detect SD card candidates
|
||||
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
||||
echo ""
|
||||
|
||||
declare -a CANDIDATES
|
||||
declare -a CANDIDATE_INFO
|
||||
|
||||
while IFS= read -r line; do
|
||||
DEV=$(echo "$line" | awk '{print $1}')
|
||||
SIZE=$(echo "$line" | awk '{print $2}')
|
||||
TYPE=$(echo "$line" | awk '{print $3}')
|
||||
TRAN=$(echo "$line" | awk '{print $4}')
|
||||
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
||||
|
||||
# Skip if it's the root filesystem
|
||||
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if any partition is mounted as root
|
||||
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
||||
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get size in bytes for reliable comparison
|
||||
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
||||
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
||||
|
||||
# Check if size is in SD card range (8GB - 128GB)
|
||||
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
||||
CANDIDATES+=("/dev/$DEV")
|
||||
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
||||
fi
|
||||
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
||||
echo -e "${RED}No SD card candidates found.${NC}"
|
||||
echo "Looking for USB/removable disks between 8GB and 128GB."
|
||||
echo ""
|
||||
echo "Available disks:"
|
||||
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip: Specify device manually: $0 $IMAGE /dev/sdX${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
||||
echo ""
|
||||
|
||||
for i in "${!CANDIDATES[@]}"; do
|
||||
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
||||
SELECTED="${CANDIDATES[0]}"
|
||||
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
||||
else
|
||||
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
||||
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
||||
echo -e "${RED}Invalid selection.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show current partitions
|
||||
echo ""
|
||||
echo -e "${BOLD}Current partitions on $SELECTED:${NC}"
|
||||
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
||||
echo ""
|
||||
|
||||
# Unmount any mounted partitions
|
||||
MOUNTED=$(mount | grep "^${SELECTED}" | awk '{print $1}' || true)
|
||||
if [ -n "$MOUNTED" ]; then
|
||||
echo -e "${YELLOW}Unmounting partitions...${NC}"
|
||||
for part in $MOUNTED; do
|
||||
umount "$part" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Final confirmation
|
||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ WARNING: ALL DATA ON THIS DEVICE WILL BE DESTROYED! ║${NC}"
|
||||
echo -e "${RED}║ $SELECTED ║${NC}"
|
||||
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
read -p "Type 'yes' to continue: " -r
|
||||
if [[ ! $REPLY == "yes" ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
esac
|
||||
else
|
||||
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Syncing...${NC}"
|
||||
sync
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Flash Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "You can now remove the SD card and boot your Raspberry Pi."
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip:${NC} On first boot, SSH in and the setup wizard will run automatically."
|
||||
echo ""
|
||||
57
rpi/patches/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# RPi Patches
|
||||
|
||||
This directory contains patches for dependencies that need modifications to build on ARM64.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
patches/
|
||||
<package>/
|
||||
arm64.patch # Standard unified diff patch file
|
||||
apply-patch.sh # Script with fallback strategies
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The `apply-patch.sh` script tries multiple strategies in order:
|
||||
|
||||
1. **Patch file** - Apply the `.patch` file using `patch -p1`
|
||||
2. **Sed fallback** - Use sed for simple string replacements
|
||||
3. **Python fallback** - Use regex for flexible pattern matching
|
||||
|
||||
This layered approach handles:
|
||||
- Exact matches (patch file works)
|
||||
- Minor upstream changes (sed catches variations)
|
||||
- Significant changes (Python regex is most flexible)
|
||||
- Already patched files (detected and skipped)
|
||||
|
||||
## Adding a New Patch
|
||||
|
||||
1. Create a directory: `patches/<package>/`
|
||||
2. Create the patch file: `git diff > arm64.patch`
|
||||
3. Create `apply-patch.sh` with appropriate fallback logic
|
||||
4. Update `setup.sh` to call the patch script
|
||||
|
||||
## jpegio Patch
|
||||
|
||||
The jpegio library includes x86-specific `-m64` compiler flags that fail on ARM64.
|
||||
The patch removes these flags by replacing:
|
||||
|
||||
```python
|
||||
cargs.append('-m64')
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
pass # ARM64: removed x86-specific -m64 flag
|
||||
```
|
||||
|
||||
## Updating Patches
|
||||
|
||||
When upstream changes break a patch:
|
||||
|
||||
1. Clone the new version
|
||||
2. Make the necessary modifications
|
||||
3. Generate a new patch: `diff -u original modified > arm64.patch`
|
||||
4. Test on a fresh Pi install
|
||||
105
rpi/patches/jpegio/apply-patch.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Apply ARM64 patch to jpegio
|
||||
# This script tries multiple strategies to remove the x86-specific -m64 flag
|
||||
#
|
||||
# Usage: ./apply-patch.sh /path/to/jpegio
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
JPEGIO_DIR="${1:-.}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PATCH_FILE="$SCRIPT_DIR/arm64.patch"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
cd "$JPEGIO_DIR"
|
||||
|
||||
echo "Applying ARM64 patch to jpegio..."
|
||||
|
||||
# Strategy 1: Try the standard patch file
|
||||
if [ -f "$PATCH_FILE" ]; then
|
||||
echo " Trying patch file..."
|
||||
if patch -p1 --dry-run < "$PATCH_FILE" >/dev/null 2>&1; then
|
||||
patch -p1 < "$PATCH_FILE"
|
||||
echo -e " ${GREEN}✓ Patch applied successfully${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e " ${YELLOW}Patch file didn't apply cleanly, trying fallback...${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Strategy 2: Sed replacement (handles any number of occurrences)
|
||||
if grep -q "cargs.append('-m64')" setup.py 2>/dev/null; then
|
||||
echo " Using sed fallback..."
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64: removed x86-specific -m64 flag/g" setup.py
|
||||
|
||||
# Verify the fix
|
||||
if grep -q "cargs.append('-m64')" setup.py; then
|
||||
echo -e " ${RED}✗ Sed replacement failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓ Sed fallback successful${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Strategy 3: Check if already patched
|
||||
if grep -q "ARM64: removed" setup.py 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓ Already patched${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Strategy 4: Python-based patching (most flexible)
|
||||
echo " Using Python fallback..."
|
||||
python3 << 'PYTHON_PATCH'
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open('setup.py', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
original = content
|
||||
|
||||
# Pattern 1: Direct replacement
|
||||
content = re.sub(
|
||||
r"cargs\.append\(['\"]+-m64['\"]+\)",
|
||||
"pass # ARM64: removed x86-specific -m64 flag",
|
||||
content
|
||||
)
|
||||
|
||||
# Pattern 2: Handle variations with different quotes or spacing
|
||||
content = re.sub(
|
||||
r"cargs\.append\s*\(\s*['\"]+-m64['\"]+\s*\)",
|
||||
"pass # ARM64: removed x86-specific -m64 flag",
|
||||
content
|
||||
)
|
||||
|
||||
if content == original:
|
||||
# Check if already patched or pattern not found
|
||||
if "ARM64: removed" in content:
|
||||
print("Already patched")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Warning: -m64 pattern not found in setup.py")
|
||||
print("This may indicate jpegio's structure has changed significantly")
|
||||
sys.exit(0) # Don't fail - maybe they removed it upstream
|
||||
|
||||
with open('setup.py', 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print("Python patch applied")
|
||||
PYTHON_PATCH
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e " ${GREEN}✓ Python fallback successful${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${RED}✗ All patching strategies failed${NC}"
|
||||
echo "Please check jpegio's setup.py manually"
|
||||
exit 1
|
||||
17
rpi/patches/jpegio/arm64.patch
Normal file
@@ -0,0 +1,17 @@
|
||||
--- a/setup.py
|
||||
+++ b/setup.py
|
||||
@@ -69,12 +69,12 @@
|
||||
largs.append('-mmacosx-version-min=10.9')
|
||||
|
||||
if arch == 'x64':
|
||||
- cargs.append('-m64')
|
||||
+ pass # ARM64: removed x86-specific -m64 flag
|
||||
elif sys.platform == 'linux':
|
||||
cargs.extend(['-w', '-fPIC'])
|
||||
|
||||
if arch == 'x64':
|
||||
- cargs.append('-m64')
|
||||
+ pass # ARM64: removed x86-specific -m64 flag
|
||||
dname_libjpeg = 'libjpeg'
|
||||
|
||||
# end of if-else
|
||||
207
rpi/pull-image.sh
Executable file
@@ -0,0 +1,207 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Pull Stegasoo image from SD card
|
||||
# Auto-detects SD card, copies with progress, shrinks, and compresses
|
||||
#
|
||||
# Usage: ./pull-image.sh [output-name] [device]
|
||||
# Output will be: stegasoo-rpi-YYYYMMDD.img.zst (or custom name)
|
||||
# Use .img extension to skip compression: ./pull-image.sh foo.img
|
||||
#
|
||||
# If device is specified, skips auto-detection (useful for large drives)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check for required tools
|
||||
for cmd in dd pv zstd lsblk; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output filename and optional device
|
||||
if [ -n "$1" ]; then
|
||||
OUTPUT="$1"
|
||||
else
|
||||
OUTPUT="stegasoo-rpi-$(date +%Y%m%d).img.zst"
|
||||
fi
|
||||
MANUAL_DEVICE="$2"
|
||||
|
||||
# Check if output ends in .img (skip compression) or .zst (compress)
|
||||
SKIP_COMPRESS=false
|
||||
if [[ "$OUTPUT" == *.img ]]; then
|
||||
IMG_FILE="$OUTPUT"
|
||||
SKIP_COMPRESS=true
|
||||
elif [[ "$OUTPUT" == *.zst ]]; then
|
||||
IMG_FILE="${OUTPUT%.zst}"
|
||||
else
|
||||
# No recognized extension, add .img.zst
|
||||
IMG_FILE="${OUTPUT}.img"
|
||||
OUTPUT="${OUTPUT}.img.zst"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Stegasoo SD Card Image Puller ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# Use manual device or auto-detect
|
||||
if [ -n "$MANUAL_DEVICE" ]; then
|
||||
# Manual device specified
|
||||
if [ ! -b "$MANUAL_DEVICE" ]; then
|
||||
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="$MANUAL_DEVICE"
|
||||
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
||||
echo ""
|
||||
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
||||
echo ""
|
||||
else
|
||||
# Auto-detect SD card candidates
|
||||
# Looking for: USB/removable, 8-128GB, not mounted as root filesystem
|
||||
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
||||
echo ""
|
||||
|
||||
declare -a CANDIDATES
|
||||
declare -a CANDIDATE_INFO
|
||||
|
||||
while IFS= read -r line; do
|
||||
DEV=$(echo "$line" | awk '{print $1}')
|
||||
SIZE=$(echo "$line" | awk '{print $2}')
|
||||
TYPE=$(echo "$line" | awk '{print $3}')
|
||||
TRAN=$(echo "$line" | awk '{print $4}')
|
||||
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
||||
|
||||
# Skip if it's the root filesystem
|
||||
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if any partition is mounted as root
|
||||
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
||||
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get size in bytes for reliable comparison
|
||||
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
||||
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
||||
|
||||
# Check if size is in SD card range (8GB - 128GB)
|
||||
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
||||
CANDIDATES+=("/dev/$DEV")
|
||||
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
||||
fi
|
||||
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
||||
echo -e "${RED}No SD card candidates found.${NC}"
|
||||
echo "Looking for USB/removable disks between 8GB and 128GB."
|
||||
echo ""
|
||||
echo "Available disks:"
|
||||
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip: Specify device manually: $0 output.img.zst /dev/sdX${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
||||
echo ""
|
||||
|
||||
for i in "${!CANDIDATES[@]}"; do
|
||||
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
||||
SELECTED="${CANDIDATES[0]}"
|
||||
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
||||
else
|
||||
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
||||
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
||||
echo -e "${RED}Invalid selection.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show partitions
|
||||
echo ""
|
||||
echo -e "${BOLD}Partitions on $SELECTED:${NC}"
|
||||
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
||||
echo ""
|
||||
|
||||
# Final confirmation
|
||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ WARNING: This will read the ENTIRE device: ║${NC}"
|
||||
echo -e "${RED}║ $SELECTED ║${NC}"
|
||||
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo ""
|
||||
read -p "Continue? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get device size for pv
|
||||
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[1/3]${NC} Copying image from $SELECTED..."
|
||||
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
|
||||
sync
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[2/3]${NC} Shrinking image..."
|
||||
if command -v pishrink.sh &> /dev/null; then
|
||||
pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "./pishrink.sh" ]; then
|
||||
bash ./pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "../pishrink.sh" ]; then
|
||||
bash ../pishrink.sh "$IMG_FILE"
|
||||
else
|
||||
echo -e "${YELLOW}pishrink.sh not found, skipping shrink step.${NC}"
|
||||
echo "Download from: https://github.com/Drewsif/PiShrink"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$SKIP_COMPRESS" = true ]; then
|
||||
echo -e "${GREEN}[3/3]${NC} Skipping compression (.img output)"
|
||||
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
|
||||
OUTPUT="$IMG_FILE"
|
||||
else
|
||||
echo -e "${GREEN}[3/3]${NC} Compressing with zstd..."
|
||||
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
|
||||
rm -f "$IMG_FILE"
|
||||
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Image Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo -e "Size: ${YELLOW}$FINAL_SIZE${NC}"
|
||||
echo ""
|
||||
594
rpi/sanitize-for-image.sh
Executable file
@@ -0,0 +1,594 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Sanitize Raspberry Pi for SD Card Image Distribution
|
||||
# Run this BEFORE creating an image with dd
|
||||
#
|
||||
# This script removes:
|
||||
# - WiFi credentials (unless --soft)
|
||||
# - SSH host keys (will regenerate on boot)
|
||||
# - SSH authorized keys
|
||||
# - User-specific data
|
||||
# - Bash history
|
||||
# - Logs
|
||||
# - Stegasoo auth database (users will create their own admin)
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./sanitize-for-image.sh # Full sanitize for image distribution
|
||||
# sudo ./sanitize-for-image.sh --soft # Soft reset (keeps WiFi for testing)
|
||||
# sudo ./sanitize-for-image.sh --soft --reboot # Soft reset and auto-reboot
|
||||
# sudo ./sanitize-for-image.sh --reboot # Full sanitize and auto-shutdown
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
GRAY='\033[0;90m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Stegasoo Sanitize Script - Prepare Pi for SD Card Imaging"
|
||||
echo ""
|
||||
echo "Usage: sudo $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " -s, --soft Soft reset (keeps WiFi for testing)"
|
||||
echo " -r, --reboot Auto-reboot/shutdown when done"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " sudo $0 # Full sanitize, prompts for shutdown"
|
||||
echo " sudo $0 --soft # Keep WiFi, reset everything else"
|
||||
echo " sudo $0 --soft --reboot # Soft reset, auto-reboot"
|
||||
echo " sudo $0 --reboot # Full sanitize, auto-shutdown"
|
||||
echo ""
|
||||
echo "Config override:"
|
||||
echo " Set STEGASOO_DIR to specify a custom install location:"
|
||||
echo " export STEGASOO_DIR=\"/home/pi/stegasoo\""
|
||||
echo " sudo -E $0"
|
||||
echo ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
SOFT_RESET=false
|
||||
AUTO_REBOOT=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
-h|--help) show_help ;;
|
||||
--soft|-s) SOFT_RESET=true ;;
|
||||
--reboot|-r) AUTO_REBOOT=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
clear
|
||||
echo ""
|
||||
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
|
||||
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
||||
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
||||
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
||||
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
||||
echo ""
|
||||
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
||||
echo ""
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
echo -e "${CYAN} Soft Reset (Factory)${NC}"
|
||||
else
|
||||
echo -e "${CYAN} Sanitize for Imaging${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
echo " WiFi credentials will be KEPT for continued testing."
|
||||
echo " Everything else will be reset to first-boot state."
|
||||
else
|
||||
echo " This will remove ALL personal data for imaging."
|
||||
echo " The system will shut down when complete."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$AUTO_REBOOT" = false ]; then
|
||||
# Flush input buffer before prompt
|
||||
read -t 0.1 -n 10000 discard </dev/tty 2>/dev/null || true
|
||||
read -p "Continue? This cannot be undone! [y/N] " -n 1 -r </dev/tty
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Track validation results
|
||||
VALIDATION_ERRORS=0
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: WiFi Credentials
|
||||
# =============================================================================
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
echo -e "${GREEN}[1/11]${NC} Keeping WiFi credentials (soft reset)..."
|
||||
echo " WiFi config preserved"
|
||||
else
|
||||
echo -e "${GREEN}[1/11]${NC} Removing WiFi credentials..."
|
||||
|
||||
# Remove from rootfs
|
||||
if [ -f /etc/wpa_supplicant/wpa_supplicant.conf ]; then
|
||||
cat > /etc/wpa_supplicant/wpa_supplicant.conf << 'EOF'
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
update_config=1
|
||||
country=US
|
||||
|
||||
# Add your WiFi network here on first boot:
|
||||
# network={
|
||||
# ssid="YourNetworkName"
|
||||
# psk="YourPassword"
|
||||
# }
|
||||
EOF
|
||||
echo " Cleared /etc/wpa_supplicant/wpa_supplicant.conf"
|
||||
fi
|
||||
|
||||
# Remove from boot partition (headless setup file)
|
||||
BOOT_PART=$(findmnt -n -o SOURCE /boot/firmware 2>/dev/null || findmnt -n -o SOURCE /boot 2>/dev/null || echo "")
|
||||
if [ -n "$BOOT_PART" ]; then
|
||||
BOOT_MOUNT=$(findmnt -n -o TARGET "$BOOT_PART" 2>/dev/null || echo "/boot")
|
||||
rm -f "$BOOT_MOUNT/wpa_supplicant.conf" 2>/dev/null || true
|
||||
echo " Removed boot partition WiFi config"
|
||||
fi
|
||||
|
||||
# Remove NetworkManager connections (RPi OS Bookworm+)
|
||||
if [ -d /etc/NetworkManager/system-connections ]; then
|
||||
# Remove all WiFi connections (files containing type=wifi)
|
||||
for conn in /etc/NetworkManager/system-connections/*; do
|
||||
if [ -f "$conn" ] && grep -q "type=wifi" "$conn" 2>/dev/null; then
|
||||
rm -f "$conn"
|
||||
echo " Removed NetworkManager: $(basename "$conn")"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Remove netplan WiFi configs (Ubuntu-based systems)
|
||||
if [ -d /etc/netplan ]; then
|
||||
for np in /etc/netplan/*.yaml; do
|
||||
if [ -f "$np" ] && grep -q "wifis:" "$np" 2>/dev/null; then
|
||||
rm -f "$np"
|
||||
echo " Removed netplan: $(basename "$np")"
|
||||
fi
|
||||
done
|
||||
# Also remove NM-generated netplan files (contain WiFi SSIDs)
|
||||
rm -f /etc/netplan/90-NM-*.yaml 2>/dev/null && echo " Removed netplan NM configs"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: SSH Authorized Keys
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[2/11]${NC} Removing SSH authorized keys..."
|
||||
for user_home in /home/*; do
|
||||
if [ -d "$user_home/.ssh" ]; then
|
||||
rm -f "$user_home/.ssh/authorized_keys"
|
||||
rm -f "$user_home/.ssh/known_hosts"
|
||||
echo " Cleared $user_home/.ssh/"
|
||||
fi
|
||||
done
|
||||
rm -f /root/.ssh/authorized_keys /root/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: SSH Host Keys
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[3/11]${NC} Removing SSH host keys (will regenerate on first boot)..."
|
||||
rm -f /etc/ssh/ssh_host_*
|
||||
|
||||
# Create a first-boot service to regenerate SSH keys
|
||||
cat > /etc/systemd/system/regenerate-ssh-keys.service <<'SSHEOF'
|
||||
[Unit]
|
||||
Description=Regenerate SSH host keys on first boot
|
||||
Before=ssh.service
|
||||
ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/ssh-keygen -A
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SSHEOF
|
||||
|
||||
systemctl enable regenerate-ssh-keys.service 2>/dev/null || true
|
||||
echo " SSH host keys removed (will regenerate on first boot)"
|
||||
|
||||
# =============================================================================
|
||||
# Step 4: Bash History
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[4/11]${NC} Clearing bash history..."
|
||||
for user_home in /home/*; do
|
||||
rm -f "$user_home/.bash_history"
|
||||
rm -f "$user_home/.python_history"
|
||||
done
|
||||
rm -f /root/.bash_history /root/.python_history 2>/dev/null || true
|
||||
history -c 2>/dev/null || true
|
||||
|
||||
# =============================================================================
|
||||
# Step 5: Stegasoo User Data
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[5/11]${NC} Removing Stegasoo user data..."
|
||||
# Remove auth database (users create their own admin on first run)
|
||||
rm -rf /opt/stegasoo/frontends/web/instance/ 2>/dev/null
|
||||
rm -rf /home/*/stegasoo/frontends/web/instance/
|
||||
# Remove SSL certs (will be regenerated)
|
||||
rm -rf /opt/stegasoo/frontends/web/certs/ 2>/dev/null
|
||||
rm -rf /home/*/stegasoo/frontends/web/certs/
|
||||
# Remove any .env files with channel keys
|
||||
rm -f /opt/stegasoo/frontends/web/.env 2>/dev/null
|
||||
rm -f /home/*/stegasoo/frontends/web/.env
|
||||
# Reset port 443 redirect (user reconfigures in wizard)
|
||||
if systemctl is-enabled --quiet iptables-restore 2>/dev/null; then
|
||||
systemctl disable iptables-restore 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/iptables-restore.service
|
||||
rm -f /etc/iptables.rules
|
||||
iptables -t nat -F PREROUTING 2>/dev/null || true
|
||||
echo " Port 443 redirect cleared"
|
||||
fi
|
||||
echo " Stegasoo instance data cleared"
|
||||
|
||||
# =============================================================================
|
||||
# Step 6: First-Boot Wizard Setup
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[6/11]${NC} Setting up first-boot wizard..."
|
||||
|
||||
# Find stegasoo install directory (prefer /opt/stegasoo)
|
||||
STEGASOO_DIR=""
|
||||
if [ -d /opt/stegasoo ]; then
|
||||
STEGASOO_DIR="/opt/stegasoo"
|
||||
else
|
||||
STEGASOO_DIR=$(ls -d /home/*/stegasoo 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$STEGASOO_DIR" ]; then
|
||||
# Last resort fallback
|
||||
if [ -d /root/stegasoo ]; then
|
||||
STEGASOO_DIR="/root/stegasoo"
|
||||
fi
|
||||
fi
|
||||
|
||||
STEGASOO_USER=$(stat -c '%U' "$STEGASOO_DIR" 2>/dev/null || echo "pi")
|
||||
echo " Stegasoo directory: $STEGASOO_DIR"
|
||||
echo " Stegasoo user: $STEGASOO_USER"
|
||||
|
||||
# Check and repair venv if needed (paths break when moving directories)
|
||||
if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
|
||||
VENV_PYTHON="$STEGASOO_DIR/venv/bin/python"
|
||||
# Check if venv python works and has stegasoo installed
|
||||
if ! "$VENV_PYTHON" -c "import stegasoo" 2>/dev/null; then
|
||||
echo " Venv broken or stegasoo not installed, rebuilding..."
|
||||
rm -rf "$STEGASOO_DIR/venv"
|
||||
|
||||
# Find Python 3.12 (prefer pyenv, fall back to system)
|
||||
USER_HOME=$(eval echo "~$STEGASOO_USER")
|
||||
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
|
||||
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
|
||||
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
|
||||
echo " Using pyenv Python: $PYTHON_BIN"
|
||||
elif command -v python3.12 &>/dev/null; then
|
||||
PYTHON_BIN="python3.12"
|
||||
echo " Using system Python 3.12"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
|
||||
fi
|
||||
|
||||
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||
|
||||
# On ARM64, jpegio needs patching before install
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
||||
echo " Building jpegio for ARM64 (this may take a minute)..."
|
||||
# Install build deps
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
|
||||
JPEGIO_DIR="/tmp/jpegio-build-$$"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
|
||||
# Apply patch to remove -m64 flag
|
||||
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
else
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
fi
|
||||
# Change ownership so user can build
|
||||
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
else
|
||||
echo " Warning: Failed to clone jpegio, DCT mode may not work"
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||
echo " Venv rebuilt and stegasoo installed"
|
||||
else
|
||||
echo " Venv OK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure PATH hook exists for stegasoo CLI and scripts
|
||||
if [ ! -f /etc/profile.d/stegasoo-path.sh ]; then
|
||||
echo " Creating PATH hook..."
|
||||
cat > /etc/profile.d/stegasoo-path.sh <<'PATHEOF'
|
||||
# Stegasoo CLI and scripts
|
||||
if [ -d /opt/stegasoo/venv/bin ]; then
|
||||
export PATH="/opt/stegasoo/venv/bin:$PATH"
|
||||
fi
|
||||
if [ -d /opt/stegasoo/rpi ]; then
|
||||
export PATH="/opt/stegasoo/rpi:$PATH"
|
||||
fi
|
||||
PATHEOF
|
||||
chmod 644 /etc/profile.d/stegasoo-path.sh
|
||||
echo " Installed PATH hook to /etc/profile.d/"
|
||||
else
|
||||
echo " PATH hook OK"
|
||||
fi
|
||||
|
||||
if [ -n "$STEGASOO_DIR" ] && [ -f "$STEGASOO_DIR/rpi/stegasoo-wizard.sh" ]; then
|
||||
# Install the profile.d hook
|
||||
cp "$STEGASOO_DIR/rpi/stegasoo-wizard.sh" /etc/profile.d/stegasoo-wizard.sh
|
||||
chmod 644 /etc/profile.d/stegasoo-wizard.sh
|
||||
echo " Installed wizard hook to /etc/profile.d/"
|
||||
|
||||
# Create the first-boot flag
|
||||
touch /etc/stegasoo-first-boot
|
||||
echo " Created /etc/stegasoo-first-boot flag"
|
||||
|
||||
# Reset systemd service to defaults (wizard will reconfigure)
|
||||
cat > /etc/systemd/system/stegasoo.service <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$STEGASOO_USER
|
||||
WorkingDirectory=$STEGASOO_DIR/frontends/web
|
||||
Environment="PATH=$STEGASOO_DIR/venv/bin:/usr/bin"
|
||||
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||
Environment="STEGASOO_HTTPS_ENABLED=false"
|
||||
Environment="STEGASOO_PORT=5000"
|
||||
Environment="STEGASOO_CHANNEL_KEY="
|
||||
ExecStart=$STEGASOO_DIR/venv/bin/python app.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
echo " Reset systemd service to defaults"
|
||||
else
|
||||
echo -e " ${RED}ERROR: Could not find wizard script${NC}"
|
||||
echo " STEGASOO_DIR: $STEGASOO_DIR"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 7: Logs
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[7/11]${NC} Clearing logs..."
|
||||
journalctl --rotate 2>/dev/null || true
|
||||
journalctl --vacuum-time=1s 2>/dev/null || true
|
||||
rm -rf /var/log/*.log /var/log/*.gz /var/log/*.[0-9] 2>/dev/null || true
|
||||
rm -rf /var/log/apt/* 2>/dev/null || true
|
||||
rm -rf /var/log/journal/* 2>/dev/null || true
|
||||
find /var/log -type f -name "*.log" -delete 2>/dev/null || true
|
||||
echo " Logs cleared"
|
||||
|
||||
# =============================================================================
|
||||
# Step 8: Temporary Files
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[8/11]${NC} Clearing temporary files..."
|
||||
rm -rf /tmp/* 2>/dev/null || true
|
||||
rm -rf /var/tmp/* 2>/dev/null || true
|
||||
echo " Temp files cleared"
|
||||
|
||||
# =============================================================================
|
||||
# Step 9: Package Cache
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[9/11]${NC} Clearing package cache..."
|
||||
apt-get clean 2>/dev/null || true
|
||||
rm -rf /var/cache/apt/archives/* 2>/dev/null || true
|
||||
echo " Package cache cleared"
|
||||
|
||||
# =============================================================================
|
||||
# Step 10: Remove Overclock Settings
|
||||
# =============================================================================
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
echo -e "${GREEN}[10/11]${NC} Keeping overclock settings (soft reset)..."
|
||||
echo " Overclock config preserved"
|
||||
else
|
||||
echo -e "${GREEN}[10/11]${NC} Removing overclock settings..."
|
||||
CONFIG_FILE="/boot/firmware/config.txt"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
CONFIG_FILE="/boot/config.txt"
|
||||
fi
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
# Remove overclock-related lines
|
||||
if grep -q "over_voltage\|arm_freq\|gpu_freq" "$CONFIG_FILE" 2>/dev/null; then
|
||||
# Create temp file without overclock lines
|
||||
grep -v "^over_voltage=\|^arm_freq=\|^gpu_freq=\|^# Overclock" "$CONFIG_FILE" > "${CONFIG_FILE}.tmp"
|
||||
mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
|
||||
echo " Removed overclock settings from $CONFIG_FILE"
|
||||
else
|
||||
echo " No overclock settings found"
|
||||
fi
|
||||
else
|
||||
echo " Config file not found, skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Step 11: Final Sync
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}[11/11]${NC} Final sync..."
|
||||
rm -f /root/.bash_history 2>/dev/null || true
|
||||
sync
|
||||
echo " Filesystem synced"
|
||||
|
||||
# =============================================================================
|
||||
# Validation
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo -e "${CYAN}Validating sanitization...${NC}"
|
||||
|
||||
# Check first-boot flag
|
||||
if [ -f /etc/stegasoo-first-boot ]; then
|
||||
echo -e " ${GREEN}[PASS]${NC} First-boot flag exists"
|
||||
else
|
||||
echo -e " ${RED}[FAIL]${NC} First-boot flag missing"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check profile.d hook
|
||||
if [ -f /etc/profile.d/stegasoo-wizard.sh ]; then
|
||||
echo -e " ${GREEN}[PASS]${NC} Wizard hook installed"
|
||||
else
|
||||
echo -e " ${RED}[FAIL]${NC} Wizard hook missing"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check SSH host keys removed
|
||||
if ls /etc/ssh/ssh_host_* 1>/dev/null 2>&1; then
|
||||
echo -e " ${RED}[FAIL]${NC} SSH host keys still present"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
else
|
||||
echo -e " ${GREEN}[PASS]${NC} SSH host keys removed"
|
||||
fi
|
||||
|
||||
# Check Stegasoo instance data removed
|
||||
DB_FOUND=false
|
||||
if ls /opt/stegasoo/frontends/web/instance/*.db 1>/dev/null 2>&1; then
|
||||
DB_FOUND=true
|
||||
fi
|
||||
if ls /home/*/stegasoo/frontends/web/instance/*.db 1>/dev/null 2>&1; then
|
||||
DB_FOUND=true
|
||||
fi
|
||||
if [ "$DB_FOUND" = true ]; then
|
||||
echo -e " ${RED}[FAIL]${NC} Stegasoo database still present"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
else
|
||||
echo -e " ${GREEN}[PASS]${NC} Stegasoo database removed"
|
||||
fi
|
||||
|
||||
# Check WiFi (only for full sanitize)
|
||||
if [ "$SOFT_RESET" = false ]; then
|
||||
WIFI_FOUND=false
|
||||
|
||||
# Check wpa_supplicant
|
||||
if grep -q "psk=" /etc/wpa_supplicant/wpa_supplicant.conf 2>/dev/null; then
|
||||
WIFI_FOUND=true
|
||||
fi
|
||||
|
||||
# Check NetworkManager
|
||||
for conn in /etc/NetworkManager/system-connections/*; do
|
||||
if [ -f "$conn" ] && grep -q "type=wifi" "$conn" 2>/dev/null; then
|
||||
WIFI_FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Check netplan
|
||||
for np in /etc/netplan/*.yaml; do
|
||||
if [ -f "$np" ] && grep -q "wifis:" "$np" 2>/dev/null; then
|
||||
WIFI_FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Check NM-generated netplan
|
||||
if ls /etc/netplan/90-NM-*.yaml 1>/dev/null 2>&1; then
|
||||
WIFI_FOUND=true
|
||||
fi
|
||||
|
||||
if [ "$WIFI_FOUND" = true ]; then
|
||||
echo -e " ${RED}[FAIL]${NC} WiFi credentials still present"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
else
|
||||
echo -e " ${GREEN}[PASS]${NC} WiFi credentials cleared"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}[SKIP]${NC} WiFi check (soft reset mode)"
|
||||
fi
|
||||
|
||||
# Check authorized_keys removed
|
||||
AUTH_KEYS_FOUND=false
|
||||
for user_home in /home/*; do
|
||||
if [ -f "$user_home/.ssh/authorized_keys" ]; then
|
||||
AUTH_KEYS_FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$AUTH_KEYS_FOUND" = true ]; then
|
||||
echo -e " ${RED}[FAIL]${NC} SSH authorized_keys still present"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
else
|
||||
echo -e " ${GREEN}[PASS]${NC} SSH authorized_keys removed"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo ""
|
||||
if [ $VALIDATION_ERRORS -eq 0 ]; then
|
||||
echo -e "${BOLD}Sanitization Complete!${NC}"
|
||||
echo -e "${GREEN}-------------------------------------------------------${NC}"
|
||||
echo -e " ${GREEN}All validation checks passed.${NC}"
|
||||
else
|
||||
echo -e "${BOLD}Sanitization Complete with Errors${NC}"
|
||||
echo -e "${RED}-------------------------------------------------------${NC}"
|
||||
echo -e " ${RED}$VALIDATION_ERRORS validation check(s) failed${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
echo -e "${CYAN}Soft reset complete.${NC}"
|
||||
echo "You can now reboot to test the first-boot wizard."
|
||||
echo ""
|
||||
if [ "$AUTO_REBOOT" = true ]; then
|
||||
echo "Rebooting..."
|
||||
exec reboot
|
||||
fi
|
||||
# Flush input buffer and pause before prompt
|
||||
read -t 0.1 -n 10000 discard </dev/tty 2>/dev/null || true
|
||||
sleep 0.3
|
||||
read -p "Reboot now? [y/N] " -n 1 -r </dev/tty
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
exec reboot
|
||||
fi
|
||||
else
|
||||
echo "The system is ready for imaging."
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo " 1. Shut down: sudo shutdown -h now"
|
||||
echo " 2. Remove SD card"
|
||||
echo " 3. On another machine, copy with:"
|
||||
echo " sudo dd if=/dev/sdX of=stegasoo-rpi.img bs=4M status=progress"
|
||||
echo " 4. Compress: zstd -19 stegasoo-rpi.img"
|
||||
echo ""
|
||||
if [ "$AUTO_REBOOT" = true ]; then
|
||||
echo "Shutting down..."
|
||||
exec shutdown -h now
|
||||
fi
|
||||
# Flush input buffer and pause before prompt
|
||||
read -t 0.1 -n 10000 discard </dev/tty 2>/dev/null || true
|
||||
sleep 0.3
|
||||
read -p "Shut down now? [y/N] " -n 1 -r </dev/tty
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
exec shutdown -h now
|
||||
fi
|
||||
fi
|
||||
531
rpi/setup.sh
Executable file
@@ -0,0 +1,531 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stegasoo Raspberry Pi Setup Script
|
||||
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
||||
#
|
||||
# Usage:
|
||||
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
||||
# # or
|
||||
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Installs system dependencies
|
||||
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||
# 3. Patches and builds jpegio for ARM
|
||||
# 4. Installs Stegasoo with web UI
|
||||
# 5. Creates systemd service for auto-start
|
||||
# 6. Enables the service
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
GRAY='\033[0;90m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Stegasoo Raspberry Pi Setup Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Config files are loaded in order (later overrides earlier):"
|
||||
echo " 1. /etc/stegasoo.conf"
|
||||
echo " 2. ~/.config/stegasoo/stegasoo.conf"
|
||||
echo " 3. Environment variables"
|
||||
echo ""
|
||||
echo " Available variables:"
|
||||
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
||||
echo " PYTHON_VERSION Python version (default: 3.12)"
|
||||
echo " STEGASOO_REPO Git repo URL"
|
||||
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
|
||||
echo ""
|
||||
echo " Example:"
|
||||
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
||||
echo " ./setup.sh"
|
||||
echo ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse args
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
-h|--help) show_help ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default configuration
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
||||
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
||||
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}"
|
||||
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
|
||||
|
||||
# Load config files (system, then user - user overrides system)
|
||||
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
||||
if [ -f "$config_file" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$config_file"
|
||||
fi
|
||||
done
|
||||
|
||||
clear
|
||||
echo ""
|
||||
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
|
||||
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
||||
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
||||
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
||||
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
||||
echo ""
|
||||
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN} Raspberry Pi Setup${NC}"
|
||||
echo ""
|
||||
echo " This will install Stegasoo with full DCT support"
|
||||
echo " Estimated time: 15-20 minutes on Pi 5"
|
||||
echo ""
|
||||
|
||||
# Check if running on ARM
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
|
||||
echo -e "${RED}Error: This script is for ARM64 systems (Raspberry Pi).${NC}"
|
||||
echo "Detected architecture: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check available memory
|
||||
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||
echo -e "${YELLOW}Warning: Less than 2GB RAM detected ($TOTAL_MEM MB).${NC}"
|
||||
echo "Stegasoo Web UI requires ~768MB for Argon2 operations."
|
||||
echo "Consider using a Pi with more RAM for best results."
|
||||
read -p "Continue anyway? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create /opt/stegasoo with proper permissions
|
||||
echo -e "${GREEN}[1/12]${NC} Setting up install directory..."
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||
echo " Created $INSTALL_DIR"
|
||||
else
|
||||
# Ensure current user owns it
|
||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||
echo " $INSTALL_DIR exists, updated ownership"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[2/12]${NC} Installing system dependencies..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
curl \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libbz2-dev \
|
||||
libreadline-dev \
|
||||
libsqlite3-dev \
|
||||
libncursesw5-dev \
|
||||
xz-utils \
|
||||
tk-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libffi-dev \
|
||||
liblzma-dev \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
python3-dev \
|
||||
btop
|
||||
|
||||
echo -e "${GREEN}[3/12]${NC} Installing gum (TUI toolkit)..."
|
||||
# Add Charm repo for gum
|
||||
if ! command -v gum &>/dev/null; then
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gum
|
||||
else
|
||||
echo " gum already installed"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[4/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
|
||||
|
||||
# Install pyenv if not present
|
||||
if [ ! -d "$HOME/.pyenv" ]; then
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add pyenv to current shell
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
|
||||
# Add to .bashrc if not already there
|
||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
||||
echo '' >> ~/.bashrc
|
||||
echo '# pyenv' >> ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
fi
|
||||
else
|
||||
echo "pyenv already installed, skipping..."
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
fi
|
||||
|
||||
# Install Python 3.12 if not present
|
||||
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
|
||||
echo "Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
|
||||
pyenv install $PYTHON_VERSION
|
||||
fi
|
||||
pyenv global $PYTHON_VERSION
|
||||
|
||||
# Verify Python version
|
||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
|
||||
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[5/12]${NC} Cloning Stegasoo..."
|
||||
|
||||
# Clone Stegasoo first (needed for jpegio patch script)
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo " Stegasoo directory exists, updating..."
|
||||
cd "$INSTALL_DIR"
|
||||
git fetch origin
|
||||
git checkout "$STEGASOO_BRANCH"
|
||||
git pull origin "$STEGASOO_BRANCH"
|
||||
else
|
||||
git clone -b "$STEGASOO_BRANCH" "$STEGASOO_REPO" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
||||
|
||||
# Create venv with pyenv Python (not system Python)
|
||||
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
|
||||
PYENV_PYTHON=$(pyenv which python)
|
||||
echo " Using Python: $PYENV_PYTHON"
|
||||
if [ ! -d "venv" ]; then
|
||||
"$PYENV_PYTHON" -m venv venv
|
||||
fi
|
||||
source venv/bin/activate
|
||||
|
||||
# Verify we're using the right Python
|
||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
echo " venv Python: $VENV_PY"
|
||||
|
||||
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
|
||||
|
||||
# Clone jpegio
|
||||
JPEGIO_DIR="/tmp/jpegio-build"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
||||
|
||||
# Apply ARM64 patch
|
||||
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
else
|
||||
echo " Applying inline ARM64 patch..."
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
fi
|
||||
|
||||
cd "$JPEGIO_DIR"
|
||||
|
||||
# Build jpegio into venv
|
||||
pip install --upgrade pip setuptools wheel cython numpy
|
||||
pip install .
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
|
||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
||||
|
||||
# Install dependencies (jpegio already in venv, won't re-download)
|
||||
pip install -e ".[web]"
|
||||
|
||||
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
|
||||
|
||||
# Create systemd service file
|
||||
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$USER
|
||||
WorkingDirectory=$INSTALL_DIR/frontends/web
|
||||
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||
Environment="STEGASOO_HTTPS_ENABLED=false"
|
||||
Environment="STEGASOO_PORT=5000"
|
||||
ExecStart=$INSTALL_DIR/venv/bin/python app.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}[10/12]${NC} Enabling service..."
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable stegasoo.service
|
||||
|
||||
echo -e "${GREEN}[11/12]${NC} Adding stegasoo to PATH..."
|
||||
|
||||
# Add stegasoo venv and rpi scripts to PATH for all users
|
||||
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
||||
# Stegasoo CLI and scripts
|
||||
if [ -d /opt/stegasoo/venv/bin ]; then
|
||||
export PATH="/opt/stegasoo/venv/bin:$PATH"
|
||||
fi
|
||||
if [ -d /opt/stegasoo/rpi ]; then
|
||||
export PATH="/opt/stegasoo/rpi:$PATH"
|
||||
fi
|
||||
PATHEOF
|
||||
sudo chmod 644 /etc/profile.d/stegasoo-path.sh
|
||||
echo " Added /opt/stegasoo/venv/bin and /opt/stegasoo/rpi to PATH"
|
||||
|
||||
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
|
||||
|
||||
# Create dynamic MOTD script
|
||||
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
||||
# Stegasoo login banner
|
||||
if systemctl is-active --quiet stegasoo 2>/dev/null; then
|
||||
PI_IP=$(hostname -I | awk '{print $1}')
|
||||
# Check if HTTPS and port 443 are configured
|
||||
if systemctl show stegasoo -p Environment 2>/dev/null | grep -q "STEGASOO_HTTPS_ENABLED=true"; then
|
||||
# Check for port 443 redirect (iptables-restore service means 443 is configured)
|
||||
if systemctl is-enabled --quiet iptables-restore 2>/dev/null; then
|
||||
STEGASOO_URL="https://$PI_IP"
|
||||
else
|
||||
STEGASOO_URL="https://$PI_IP:5000"
|
||||
fi
|
||||
else
|
||||
STEGASOO_URL="http://$PI_IP:5000"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[0;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||
echo ""
|
||||
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
||||
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
echo -e " \033[0;31m●\033[0m Stegasoo is not running"
|
||||
echo -e " Start with: sudo systemctl start stegasoo"
|
||||
echo ""
|
||||
fi
|
||||
MOTDEOF
|
||||
sudo chmod 644 /etc/profile.d/stegasoo-motd.sh
|
||||
echo " Created login banner"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Installation Complete!${NC}"
|
||||
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||
echo ""
|
||||
echo -e "Stegasoo installed to: ${YELLOW}$INSTALL_DIR${NC}"
|
||||
echo ""
|
||||
|
||||
# =============================================================================
|
||||
# Interactive Configuration
|
||||
# =============================================================================
|
||||
|
||||
echo -e "${BOLD}Configuration${NC}"
|
||||
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||
echo ""
|
||||
|
||||
# Track configuration choices
|
||||
ENABLE_HTTPS="false"
|
||||
USE_PORT_443="false"
|
||||
CHANNEL_KEY=""
|
||||
|
||||
# --- HTTPS Configuration ---
|
||||
echo -e "${GREEN}HTTPS Configuration${NC}"
|
||||
echo " HTTPS encrypts traffic with a self-signed certificate."
|
||||
echo " (Browser will show a security warning - this is normal for self-signed certs)"
|
||||
echo ""
|
||||
read -p "Enable HTTPS? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
ENABLE_HTTPS="true"
|
||||
echo -e " ${GREEN}✓${NC} HTTPS will be enabled"
|
||||
|
||||
# --- Port 443 Configuration ---
|
||||
echo ""
|
||||
echo -e "${GREEN}Port Configuration${NC}"
|
||||
echo " Standard HTTPS port is 443 (no port needed in URL)."
|
||||
echo " This requires iptables to redirect 443 → 5000."
|
||||
echo ""
|
||||
read -p "Use standard port 443? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
USE_PORT_443="true"
|
||||
echo -e " ${GREEN}✓${NC} Port 443 will be configured"
|
||||
else
|
||||
echo -e " ${YELLOW}→${NC} Using default port 5000"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}→${NC} Using HTTP (unencrypted)"
|
||||
fi
|
||||
|
||||
# --- Channel Key Configuration ---
|
||||
echo ""
|
||||
echo -e "${GREEN}Channel Key Configuration${NC}"
|
||||
echo " A channel key creates a private encoding channel."
|
||||
echo " Only users with the same key can decode each other's images."
|
||||
echo ""
|
||||
read -p "Generate a private channel key? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# Generate channel key using the CLI
|
||||
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "from stegasoo.channel import generate_channel_key; print(generate_channel_key())")
|
||||
echo -e " ${GREEN}✓${NC} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
|
||||
echo ""
|
||||
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone"
|
||||
echo " who should be able to decode your images."
|
||||
else
|
||||
echo -e " ${YELLOW}→${NC} Using public mode (no channel isolation)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Apply Configuration
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Applying configuration...${NC}"
|
||||
|
||||
# Update systemd service with configuration
|
||||
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$USER
|
||||
WorkingDirectory=$INSTALL_DIR/frontends/web
|
||||
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||
Environment="STEGASOO_AUTH_ENABLED=true"
|
||||
Environment="STEGASOO_HTTPS_ENABLED=$ENABLE_HTTPS"
|
||||
Environment="STEGASOO_PORT=5000"
|
||||
Environment="STEGASOO_CHANNEL_KEY=$CHANNEL_KEY"
|
||||
ExecStart=$INSTALL_DIR/venv/bin/python app.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Setup port 443 redirect if requested
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo " Setting up port 443 redirect..."
|
||||
|
||||
# Install iptables if needed
|
||||
if ! command -v iptables &> /dev/null; then
|
||||
sudo apt-get install -y iptables
|
||||
fi
|
||||
|
||||
# Add redirect rule
|
||||
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000
|
||||
sudo sh -c 'iptables-save > /etc/iptables.rules'
|
||||
|
||||
# Create systemd service to restore rules on boot
|
||||
sudo tee /etc/systemd/system/iptables-restore.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Restore iptables rules
|
||||
Before=network-pre.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/sbin/iptables-restore /etc/iptables.rules
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl enable iptables-restore.service
|
||||
echo -e " ${GREEN}✓${NC} Port 443 redirect configured"
|
||||
fi
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# =============================================================================
|
||||
# Final Summary
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Setup Complete!${NC}"
|
||||
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||
echo ""
|
||||
|
||||
PI_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
echo -e "${GREEN}Create your admin account:${NC}"
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo -e " ${YELLOW}https://$PI_IP/setup${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ -n "$CHANNEL_KEY" ]; then
|
||||
echo -e "${GREEN}Channel Key:${NC}"
|
||||
echo -e " ${YELLOW}$CHANNEL_KEY${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Commands:${NC}"
|
||||
echo " Start: sudo systemctl start stegasoo"
|
||||
echo " Stop: sudo systemctl stop stegasoo"
|
||||
echo " Status: sudo systemctl status stegasoo"
|
||||
echo " Logs: journalctl -u stegasoo -f"
|
||||
echo ""
|
||||
|
||||
# Offer to start now
|
||||
read -p "Start Stegasoo now? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
sudo systemctl start stegasoo
|
||||
sleep 2
|
||||
if systemctl is-active --quiet stegasoo; then
|
||||
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo -e " Create admin: ${YELLOW}https://$PI_IP/setup${NC}"
|
||||
else
|
||||
echo -e " Create admin: ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " Create admin: ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
||||
fi
|
||||
fi
|
||||
541
rpi/smoke-test.sh
Executable file
@@ -0,0 +1,541 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stegasoo Pi Image Smoke Test
|
||||
# Automated testing of a fresh Pi image
|
||||
#
|
||||
# Usage: ./smoke-test.sh [ip] [--https] [--443] [--port=PORT]
|
||||
# Default IP: 192.168.0.4
|
||||
# --https Use HTTPS (port 5000)
|
||||
# --443 Use HTTPS on port 443
|
||||
# --port=N Specify custom port
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
PI_IP="192.168.0.4"
|
||||
HTTPS=false
|
||||
PORT=5000
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--https) HTTPS=true ;;
|
||||
--443) HTTPS=true; PORT=443 ;;
|
||||
--port=*) PORT="${arg#*=}" ;;
|
||||
--*) ;; # Ignore other flags
|
||||
*)
|
||||
# If it looks like an IP, use it
|
||||
if [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
PI_IP="$arg"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$HTTPS" = true ]; then
|
||||
if [ "$PORT" = "443" ]; then
|
||||
BASE_URL="https://$PI_IP"
|
||||
else
|
||||
BASE_URL="https://$PI_IP:$PORT"
|
||||
fi
|
||||
CURL_OPTS="-k" # Allow self-signed certs
|
||||
else
|
||||
BASE_URL="http://$PI_IP:$PORT"
|
||||
CURL_OPTS=""
|
||||
fi
|
||||
|
||||
# Test credentials
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASS="stegasoo"
|
||||
REGULAR_USER="smokeuser"
|
||||
REGULAR_PASS="SmokeUser123!"
|
||||
|
||||
# Temp files
|
||||
COOKIE_JAR=$(mktemp)
|
||||
COOKIE_JAR_USER=$(mktemp)
|
||||
TEST_IMAGE=$(mktemp --suffix=.png)
|
||||
ENCODED_IMAGE=$(mktemp --suffix=.png)
|
||||
RESPONSE=$(mktemp)
|
||||
|
||||
ENCODED_IMAGE_USER=$(mktemp --suffix=.png)
|
||||
QR_IMAGE=$(mktemp --suffix=.png)
|
||||
|
||||
cleanup() {
|
||||
rm -f "$COOKIE_JAR" "$COOKIE_JAR_USER" "$TEST_IMAGE" "$ENCODED_IMAGE" "$ENCODED_IMAGE_USER" "$QR_IMAGE" "$RESPONSE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Create a simple test image (red square)
|
||||
create_test_image() {
|
||||
if command -v convert &>/dev/null; then
|
||||
convert -size 100x100 xc:red "$TEST_IMAGE"
|
||||
elif command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
from PIL import Image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img.save('$TEST_IMAGE')
|
||||
"
|
||||
else
|
||||
echo -e "${YELLOW}Warning: No image tool available, skipping encode/decode tests${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Results tracking
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
|
||||
pass() {
|
||||
echo -e " ${GREEN}[PASS]${NC} $1"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo -e " ${RED}[FAIL]${NC} $1"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
}
|
||||
|
||||
skip() {
|
||||
echo -e " ${YELLOW}[SKIP]${NC} $1"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Header
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ Stegasoo Pi Image Smoke Test ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
|
||||
echo ""
|
||||
|
||||
# =============================================================================
|
||||
# Test 1: Web UI Reachable
|
||||
# =============================================================================
|
||||
|
||||
echo -e "${BOLD}[1/9] Web UI Accessibility${NC}"
|
||||
|
||||
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
|
||||
pass "Web UI is reachable"
|
||||
else
|
||||
fail "Web UI not reachable at $BASE_URL"
|
||||
echo -e "${RED}Cannot continue without web access. Is the Pi running?${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if redirected to setup (first run) or login
|
||||
REDIRECT=$(curl $CURL_OPTS -s -o /dev/null -w "%{redirect_url}" "$BASE_URL")
|
||||
if echo "$REDIRECT" | grep -q "setup"; then
|
||||
pass "Redirected to setup (fresh install)"
|
||||
NEEDS_SETUP=true
|
||||
elif echo "$REDIRECT" | grep -q "login"; then
|
||||
pass "Redirected to login (already configured)"
|
||||
NEEDS_SETUP=false
|
||||
else
|
||||
# Check page content
|
||||
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "setup\|Setup\|Create.*Admin"; then
|
||||
pass "Setup page detected"
|
||||
NEEDS_SETUP=true
|
||||
else
|
||||
pass "Login page detected"
|
||||
NEEDS_SETUP=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 2: Create Admin User (if needed)
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[2/9] Admin Setup${NC}"
|
||||
|
||||
if [ "$NEEDS_SETUP" = true ]; then
|
||||
# Get CSRF token from setup page
|
||||
SETUP_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/setup")
|
||||
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
if [ -z "$CSRF_TOKEN" ]; then
|
||||
# Try alternate pattern
|
||||
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'csrf_token.*?value="\K[^"]+' || echo "")
|
||||
fi
|
||||
|
||||
# Create admin user
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/setup" \
|
||||
-d "username=$ADMIN_USER" \
|
||||
-d "password=$ADMIN_PASS" \
|
||||
-d "password_confirm=$ADMIN_PASS" \
|
||||
-d "csrf_token=$CSRF_TOKEN")
|
||||
|
||||
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "login\|Login"; then
|
||||
pass "Admin user created successfully"
|
||||
else
|
||||
pass "Setup completed (assuming success)"
|
||||
fi
|
||||
else
|
||||
fail "Failed to create admin user (HTTP $HTTP_CODE)"
|
||||
fi
|
||||
else
|
||||
skip "Setup already complete"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 3: Admin Login
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[3/9] Admin Authentication${NC}"
|
||||
|
||||
# Get login page and CSRF
|
||||
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
|
||||
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
# Try login as admin
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/login" \
|
||||
-d "username=$ADMIN_USER" \
|
||||
-d "password=$ADMIN_PASS" \
|
||||
-d "csrf_token=$CSRF_TOKEN" \
|
||||
-L)
|
||||
|
||||
# Check if we're logged in by accessing a protected page
|
||||
if curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
|
||||
pass "Admin login successful"
|
||||
ADMIN_LOGGED_IN=true
|
||||
else
|
||||
fail "Admin login failed"
|
||||
ADMIN_LOGGED_IN=false
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 4: Admin Encode/Decode
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[4/9] Admin Encode/Decode${NC}"
|
||||
|
||||
if [ "$ADMIN_LOGGED_IN" = true ]; then
|
||||
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/encode")
|
||||
|
||||
if echo "$ENCODE_PAGE" | grep -qi "encode\|message\|image\|upload"; then
|
||||
pass "Encode page loads"
|
||||
else
|
||||
fail "Encode page not accessible"
|
||||
fi
|
||||
|
||||
# Try actual encoding if we have image tools
|
||||
if create_test_image 2>/dev/null; then
|
||||
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
# For encode: use same image as reference_photo and carrier (for simplicity)
|
||||
# First POST (no redirect follow), get Location header, then GET result page
|
||||
ENCODE_RESULT=$(curl $CURL_OPTS -s -D - -o /dev/null \
|
||||
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/encode" \
|
||||
-F "reference_photo=@$TEST_IMAGE" \
|
||||
-F "carrier=@$TEST_IMAGE" \
|
||||
-F "message=Admin smoke test" \
|
||||
-F "passphrase=smoke test phrase" \
|
||||
-F "pin=123456" \
|
||||
-F "csrf_token=$CSRF_TOKEN")
|
||||
|
||||
# Extract redirect location
|
||||
RESULT_LOCATION=$(echo "$ENCODE_RESULT" | grep -i "^location:" | tr -d '\r' | awk '{print $2}')
|
||||
|
||||
if [ -n "$RESULT_LOCATION" ]; then
|
||||
# GET the result page
|
||||
RESULT_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL$RESULT_LOCATION")
|
||||
|
||||
# Look for download link in result page
|
||||
DOWNLOAD_URL=$(echo "$RESULT_PAGE" | grep -oP 'href="(/encode/download/[^"]+)"' | head -1 | grep -oP '/encode/download/[^"]+')
|
||||
fi
|
||||
|
||||
if [ -n "$DOWNLOAD_URL" ]; then
|
||||
# Download the encoded image
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR" "$BASE_URL$DOWNLOAD_URL")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && file "$ENCODED_IMAGE" | grep -qi "image\|PNG\|JPEG"; then
|
||||
pass "Admin encoding works"
|
||||
|
||||
# Now decode it
|
||||
DECODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/decode")
|
||||
CSRF_TOKEN=$(echo "$DECODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
DECODED=$(curl $CURL_OPTS -s \
|
||||
-b "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/decode" \
|
||||
-F "reference_photo=@$TEST_IMAGE" \
|
||||
-F "stego_image=@$ENCODED_IMAGE" \
|
||||
-F "passphrase=smoke test phrase" \
|
||||
-F "pin=123456" \
|
||||
-F "csrf_token=$CSRF_TOKEN")
|
||||
|
||||
if echo "$DECODED" | grep -q "Admin smoke test"; then
|
||||
pass "Admin decoding works"
|
||||
else
|
||||
fail "Admin decode failed"
|
||||
fi
|
||||
else
|
||||
fail "Failed to download encoded image (HTTP $HTTP_CODE)"
|
||||
fi
|
||||
else
|
||||
# Check for error messages in result page
|
||||
ERROR_MSG=$(echo "$RESULT_PAGE" | grep -oP 'toast-body">[^<]*<[^>]*>[^<]*' | head -1)
|
||||
if [ -n "$ERROR_MSG" ]; then
|
||||
fail "Encoding failed: $ERROR_MSG"
|
||||
else
|
||||
fail "No download link found in encode result"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "Encode/Decode (no image tools)"
|
||||
fi
|
||||
else
|
||||
skip "Admin encode/decode (not logged in)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 5: Create Regular User
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[5/9] Create Regular User${NC}"
|
||||
|
||||
if [ "$ADMIN_LOGGED_IN" = true ]; then
|
||||
# Check if there's a user management page
|
||||
USERS_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/users" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$USERS_PAGE" | grep -qi "user\|create\|add"; then
|
||||
CSRF_TOKEN=$(echo "$USERS_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/users/create" \
|
||||
-d "username=$REGULAR_USER" \
|
||||
-d "password=$REGULAR_PASS" \
|
||||
-d "password_confirm=$REGULAR_PASS" \
|
||||
-d "csrf_token=$CSRF_TOKEN")
|
||||
|
||||
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
pass "Regular user created"
|
||||
USER_CREATED=true
|
||||
else
|
||||
# Try alternate endpoint
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/register" \
|
||||
-d "username=$REGULAR_USER" \
|
||||
-d "password=$REGULAR_PASS" \
|
||||
-d "password_confirm=$REGULAR_PASS" \
|
||||
-d "csrf_token=$CSRF_TOKEN")
|
||||
|
||||
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
pass "Regular user created (via register)"
|
||||
USER_CREATED=true
|
||||
else
|
||||
fail "Failed to create regular user"
|
||||
USER_CREATED=false
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "User creation (no user management page)"
|
||||
USER_CREATED=false
|
||||
fi
|
||||
else
|
||||
skip "User creation (admin not logged in)"
|
||||
USER_CREATED=false
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 6: Regular User Login & Encode/Decode
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[6/9] Regular User Workflow${NC}"
|
||||
|
||||
if [ "$USER_CREATED" = true ]; then
|
||||
# Logout admin first (get fresh session)
|
||||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/logout" >/dev/null
|
||||
|
||||
# Login as regular user
|
||||
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR_USER" "$BASE_URL/login")
|
||||
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR_USER" -c "$COOKIE_JAR_USER" \
|
||||
-X POST "$BASE_URL/login" \
|
||||
-d "username=$REGULAR_USER" \
|
||||
-d "password=$REGULAR_PASS" \
|
||||
-d "csrf_token=$CSRF_TOKEN" \
|
||||
-L)
|
||||
|
||||
if curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
|
||||
pass "Regular user login successful"
|
||||
|
||||
# Try encode/decode as regular user
|
||||
if [ -f "$TEST_IMAGE" ]; then
|
||||
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/encode")
|
||||
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE_USER" -w "%{http_code}" \
|
||||
-b "$COOKIE_JAR_USER" \
|
||||
-X POST "$BASE_URL/encode" \
|
||||
-F "reference_photo=@$TEST_IMAGE" \
|
||||
-F "carrier=@$TEST_IMAGE" \
|
||||
-F "message=User smoke test" \
|
||||
-F "passphrase=user test phrase" \
|
||||
-F "pin=567890" \
|
||||
-F "csrf_token=$CSRF_TOKEN")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && [ -s "$ENCODED_IMAGE_USER" ] && file "$ENCODED_IMAGE_USER" | grep -qi "image\|PNG"; then
|
||||
pass "Regular user encoding works"
|
||||
else
|
||||
fail "Regular user encoding failed"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "Regular user login failed"
|
||||
fi
|
||||
else
|
||||
skip "Regular user workflow (user not created)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 7: Password Recovery QR
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[7/9] Password Recovery QR${NC}"
|
||||
|
||||
# Re-login as admin
|
||||
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
|
||||
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
|
||||
|
||||
curl $CURL_OPTS -s -o /dev/null \
|
||||
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-X POST "$BASE_URL/login" \
|
||||
-d "username=$ADMIN_USER" \
|
||||
-d "password=$ADMIN_PASS" \
|
||||
-d "csrf_token=$CSRF_TOKEN" \
|
||||
-L
|
||||
|
||||
# Check for recovery QR endpoint
|
||||
RECOVERY_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/recovery" 2>/dev/null ||
|
||||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/settings" 2>/dev/null ||
|
||||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/account" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$RECOVERY_PAGE" | grep -qi "recovery\|qr\|backup"; then
|
||||
pass "Recovery page accessible"
|
||||
|
||||
# Try to get QR image
|
||||
QR_URL=$(echo "$RECOVERY_PAGE" | grep -oP 'src="[^"]*qr[^"]*"' | head -1 | sed 's/src="//;s/"$//' || echo "")
|
||||
|
||||
if [ -n "$QR_URL" ]; then
|
||||
if [[ "$QR_URL" != http* ]]; then
|
||||
QR_URL="$BASE_URL$QR_URL"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl $CURL_OPTS -s -o "$QR_IMAGE" -w "%{http_code}" -b "$COOKIE_JAR" "$QR_URL")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && [ -s "$QR_IMAGE" ]; then
|
||||
if file "$QR_IMAGE" | grep -qi "image\|PNG"; then
|
||||
pass "Recovery QR code generated"
|
||||
else
|
||||
fail "QR endpoint returned non-image"
|
||||
fi
|
||||
else
|
||||
fail "Failed to fetch QR code"
|
||||
fi
|
||||
else
|
||||
skip "QR code URL not found in page"
|
||||
fi
|
||||
else
|
||||
skip "Password recovery (no recovery page found)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 8: System Health
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[8/9] System Health${NC}"
|
||||
|
||||
# Check if stegasoo CLI works via SSH (optional)
|
||||
if command -v sshpass &>/dev/null; then
|
||||
CLI_VERSION=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
admin@$PI_IP "stegasoo --version" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$CLI_VERSION" ]; then
|
||||
pass "CLI accessible: $CLI_VERSION"
|
||||
else
|
||||
skip "CLI check (SSH failed or CLI not in PATH)"
|
||||
fi
|
||||
else
|
||||
skip "CLI check (sshpass not installed)"
|
||||
fi
|
||||
|
||||
# Check service status via SSH
|
||||
if command -v sshpass &>/dev/null; then
|
||||
SERVICE_STATUS=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
admin@$PI_IP "systemctl is-active stegasoo" 2>/dev/null || echo "unknown")
|
||||
|
||||
if [ "$SERVICE_STATUS" = "active" ]; then
|
||||
pass "Stegasoo service is active"
|
||||
else
|
||||
fail "Stegasoo service status: $SERVICE_STATUS"
|
||||
fi
|
||||
else
|
||||
skip "Service check (sshpass not installed)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Test 9: Cleanup
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}[9/9] Cleanup${NC}"
|
||||
|
||||
# Just verify we can still access the site
|
||||
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
|
||||
pass "Site still accessible after tests"
|
||||
else
|
||||
fail "Site not accessible after tests"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
TOTAL=$((TESTS_PASSED + TESTS_FAILED))
|
||||
|
||||
if [ $TESTS_FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}${BOLD}All tests passed!${NC} ($TESTS_PASSED/$TOTAL)"
|
||||
else
|
||||
echo -e "${RED}${BOLD}Some tests failed${NC} ($TESTS_PASSED passed, $TESTS_FAILED failed)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "Target: $BASE_URL"
|
||||
echo -e "Admin user: $ADMIN_USER"
|
||||
echo -e "Regular user: $REGULAR_USER"
|
||||
echo ""
|
||||
|
||||
exit $TESTS_FAILED
|
||||
17
rpi/stegasoo-wizard.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo First Boot Wizard Trigger
|
||||
# This file goes in /etc/profile.d/ and runs the wizard on first login
|
||||
|
||||
if [ -f /etc/stegasoo-first-boot ]; then
|
||||
# Find the wizard script (check /opt first, then home dirs)
|
||||
WIZARD=""
|
||||
if [ -f /opt/stegasoo/rpi/first-boot-wizard.sh ]; then
|
||||
WIZARD="/opt/stegasoo/rpi/first-boot-wizard.sh"
|
||||
else
|
||||
WIZARD=$(ls /home/*/stegasoo/rpi/first-boot-wizard.sh 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$WIZARD" ]; then
|
||||
bash "$WIZARD"
|
||||
fi
|
||||
fi
|
||||
30
rpi/stegasoo.conf.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# Stegasoo Raspberry Pi Configuration
|
||||
# Copy this file to /etc/stegasoo.conf or ~/.config/stegasoo/stegasoo.conf
|
||||
#
|
||||
# You can also override these by exporting environment variables:
|
||||
# export STEGASOO_INSTALL_DIR="/custom/path"
|
||||
# ./setup.sh
|
||||
|
||||
# Installation directory (default: /opt/stegasoo)
|
||||
#INSTALL_DIR="/opt/stegasoo"
|
||||
|
||||
# Python version to install via pyenv (default: 3.12)
|
||||
#PYTHON_VERSION="3.12"
|
||||
|
||||
# Git repository URL
|
||||
#STEGASOO_REPO="https://github.com/adlee-was-taken/stegasoo.git"
|
||||
|
||||
# Git branch to checkout (default: 4.1)
|
||||
#STEGASOO_BRANCH="4.1"
|
||||
|
||||
# Web UI port (default: 5000)
|
||||
#STEGASOO_PORT="5000"
|
||||
|
||||
# Enable HTTPS (default: false, configured via wizard)
|
||||
#STEGASOO_HTTPS_ENABLED="false"
|
||||
|
||||
# Enable authentication (default: true)
|
||||
#STEGASOO_AUTH_ENABLED="true"
|
||||
|
||||
# Channel key for private channels (default: none)
|
||||
#STEGASOO_CHANNEL_KEY=""
|
||||
49
src/main.py
@@ -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__":
|
||||
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
|
||||
High-level convenience function that handles validation,
|
||||
encryption, and embedding in one call.
|
||||
|
||||
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
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Exceptions
|
||||
from .exceptions import (
|
||||
CapacityError,
|
||||
CryptoError,
|
||||
DecryptionError,
|
||||
EmbeddingError,
|
||||
EncryptionError,
|
||||
ExtractionError,
|
||||
ImageValidationError,
|
||||
InvalidHeaderError,
|
||||
KeyDerivationError,
|
||||
KeyGenerationError,
|
||||
KeyPasswordError,
|
||||
KeyValidationError,
|
||||
MessageValidationError,
|
||||
PinValidationError,
|
||||
SecurityFactorError,
|
||||
SteganographyError,
|
||||
StegasooError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
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
@@ -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}")
|
||||
570
src/stegasoo/channel.py
Normal file
@@ -0,0 +1,570 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def resolve_channel_key(
|
||||
value: str | None = None,
|
||||
*,
|
||||
file_path: str | Path | None = None,
|
||||
no_channel: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Resolve a channel key from user input (unified for all frontends).
|
||||
|
||||
This consolidates channel key resolution logic used by CLI, API, and WebUI.
|
||||
|
||||
Args:
|
||||
value: Input value:
|
||||
- 'auto' or None: Use server-configured key
|
||||
- 'none' or '': Public mode (no channel key)
|
||||
- explicit key: Validate and use
|
||||
file_path: Path to file containing channel key
|
||||
no_channel: If True, return "" for public mode (overrides value)
|
||||
|
||||
Returns:
|
||||
None: Use server-configured key (auto mode)
|
||||
"": Public mode (no channel key)
|
||||
str: Explicit valid channel key
|
||||
|
||||
Raises:
|
||||
ValueError: If key format is invalid
|
||||
FileNotFoundError: If file_path doesn't exist
|
||||
|
||||
Example:
|
||||
>>> resolve_channel_key("auto") # -> None
|
||||
>>> resolve_channel_key("none") # -> ""
|
||||
>>> resolve_channel_key(no_channel=True) # -> ""
|
||||
>>> resolve_channel_key("ABCD-1234-...") # -> "ABCD-1234-..."
|
||||
>>> resolve_channel_key(file_path="key.txt") # reads from file
|
||||
"""
|
||||
debug.print(f"resolve_channel_key: value={value}, file_path={file_path}, no_channel={no_channel}")
|
||||
|
||||
# no_channel flag takes precedence
|
||||
if no_channel:
|
||||
debug.print("resolve_channel_key: public mode (no_channel=True)")
|
||||
return ""
|
||||
|
||||
# Read from file if provided
|
||||
if file_path:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Channel key file not found: {file_path}")
|
||||
key = path.read_text().strip()
|
||||
if not validate_channel_key(key):
|
||||
raise ValueError(f"Invalid channel key format in file: {file_path}")
|
||||
debug.print(f"resolve_channel_key: from file -> {get_channel_fingerprint(key)}")
|
||||
return format_channel_key(key)
|
||||
|
||||
# Handle value string
|
||||
if value is None or value.lower() == "auto":
|
||||
debug.print("resolve_channel_key: auto mode (server config)")
|
||||
return None
|
||||
|
||||
if value == "" or value.lower() == "none":
|
||||
debug.print("resolve_channel_key: public mode (explicit none)")
|
||||
return ""
|
||||
|
||||
# Explicit key - validate
|
||||
if validate_channel_key(value):
|
||||
formatted = format_channel_key(value)
|
||||
debug.print(f"resolve_channel_key: explicit key -> {get_channel_fingerprint(formatted)}")
|
||||
return formatted
|
||||
|
||||
raise ValueError(
|
||||
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
|
||||
"Generate a new key with: stegasoo channel generate"
|
||||
)
|
||||
|
||||
|
||||
def get_channel_response_info(channel_key: str | None) -> dict:
|
||||
"""
|
||||
Get channel info for API/WebUI responses.
|
||||
|
||||
Args:
|
||||
channel_key: Resolved channel key (None=auto, ""=public, str=explicit)
|
||||
|
||||
Returns:
|
||||
Dict with mode, fingerprint, and display info
|
||||
|
||||
Example:
|
||||
>>> info = get_channel_response_info("ABCD-1234-...")
|
||||
>>> info['mode']
|
||||
'explicit'
|
||||
"""
|
||||
if channel_key is None:
|
||||
# Auto mode - check server config
|
||||
server_key = get_channel_key()
|
||||
if server_key:
|
||||
return {
|
||||
"mode": "private",
|
||||
"fingerprint": get_channel_fingerprint(server_key),
|
||||
"source": "server",
|
||||
}
|
||||
return {
|
||||
"mode": "public",
|
||||
"fingerprint": None,
|
||||
"source": "server",
|
||||
}
|
||||
|
||||
if channel_key == "":
|
||||
return {
|
||||
"mode": "public",
|
||||
"fingerprint": None,
|
||||
"source": "explicit",
|
||||
}
|
||||
|
||||
return {
|
||||
"mode": "private",
|
||||
"fingerprint": get_channel_fingerprint(channel_key),
|
||||
"source": "explicit",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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)
|
||||