Compare commits
225 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c88f53cd0 | ||
|
|
3f8c2a6957 | ||
|
|
22cf27d7f6 | ||
|
|
4d8575ce33 | ||
|
|
28b539bcd9 | ||
|
|
6b82069dc8 | ||
|
|
52e1a3dfbf | ||
|
|
4a27d0c182 | ||
|
|
36931518ce | ||
|
|
f79c63428b | ||
|
|
cc29de4200 | ||
|
|
c14f3f75cb | ||
|
|
aa99a258f4 | ||
|
|
93420704e8 | ||
|
|
6e4eb5464e | ||
|
|
d04670e352 | ||
|
|
fda1cdad51 | ||
|
|
b48ccc5d16 | ||
|
|
15ed63cafa | ||
|
|
869d7ee8e3 | ||
|
|
3ee8c1d22a | ||
|
|
b96564358a | ||
|
|
01afb3da66 | ||
|
|
a98df5f9a0 | ||
|
|
70da348bce | ||
|
|
90ba8543a7 | ||
|
|
da3aea992c | ||
|
|
ae47ff4932 | ||
|
|
eb16eb1db2 | ||
|
|
c65d9e6682 | ||
|
|
eeb44eae94 | ||
|
|
26d4b82c91 | ||
|
|
7efeaf02e8 | ||
|
|
925fb05cbd | ||
|
|
29a02265a1 | ||
|
|
d58f3c6fb6 | ||
|
|
cc46993d80 | ||
|
|
893a044eaa | ||
|
|
9f03b69408 | ||
|
|
cce2007c6e | ||
|
|
52f43d3a86 | ||
|
|
85a7092d55 | ||
|
|
4b37a81087 | ||
|
|
31941dc3f5 | ||
|
|
9a7e4ddce7 | ||
|
|
0424dd34d5 | ||
|
|
2127b916f3 | ||
|
|
f8e65890e5 | ||
|
|
5861ab0e1e | ||
|
|
5309a08aaf | ||
|
|
d8fb95b68e | ||
|
|
c0b6865790 | ||
|
|
6e7ae0d6f9 | ||
|
|
6a5b12f98e | ||
|
|
d8eb7b0160 | ||
|
|
962c04084b | ||
|
|
597a9c6411 | ||
|
|
67b25a43a6 | ||
|
|
65a663fe3b | ||
|
|
fc6e4eb805 | ||
|
|
50f07a0ce9 | ||
|
|
7accd26821 | ||
|
|
075e10792c | ||
|
|
9a790de5c3 | ||
|
|
3c91c92a4d | ||
|
|
9d1bc7f829 | ||
|
|
d8118d688b | ||
|
|
b6acee1acb | ||
|
|
b9baf35dfa | ||
|
|
561f03ffde | ||
|
|
038347a505 | ||
|
|
e026d1a4db | ||
|
|
3f93e7a752 | ||
|
|
cdc7ffd3bf | ||
|
|
6c3bc995f1 | ||
|
|
2d3ed8a79a | ||
|
|
040c44fec6 | ||
|
|
832d8be025 | ||
|
|
7088623d2c | ||
|
|
44a3ca8a0f | ||
|
|
7a35ac3df7 | ||
|
|
f69475b406 | ||
|
|
559dcd3dcf | ||
|
|
b1ddfaa75b | ||
|
|
4843ec8c22 | ||
|
|
ac08011236 | ||
|
|
12c4b091fb | ||
|
|
c2c2c924e1 | ||
|
|
df7ad06a08 | ||
|
|
166b936ee5 | ||
|
|
7138455f8d | ||
|
|
9ab3260298 | ||
|
|
763f7bf603 | ||
|
|
1059e17f4e | ||
|
|
7cb42e189a | ||
|
|
8c283bc4e5 | ||
|
|
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 |
53
.dockerignore
Normal file
@@ -0,0 +1,53 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.egg-info
|
||||
.eggs
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Instance data (user creates fresh)
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
instance/
|
||||
|
||||
# Test data
|
||||
test_data/
|
||||
tests/
|
||||
|
||||
# Pi-specific
|
||||
rpi/
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
*.img.zst.zip
|
||||
pishrink.sh
|
||||
|
||||
# Docs
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
*.tmp
|
||||
.DS_Store
|
||||
|
||||
# Dev scripts and old files
|
||||
scripts/
|
||||
old_files/
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Temp files
|
||||
frontends/web/temp_files/
|
||||
*.db
|
||||
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 -->
|
||||
40
.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
|
||||
@@ -58,6 +64,32 @@ htmlcov/
|
||||
# Output test files.
|
||||
test_data/*.png
|
||||
|
||||
#Project root scripts.
|
||||
rbld_containers.sh
|
||||
quick_web.sh
|
||||
# Dev scripts (local convenience scripts - except validate-release.sh)
|
||||
scripts/*
|
||||
!scripts/validate-release.sh
|
||||
|
||||
# Web UI auth database and SSL certs
|
||||
instance/
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
|
||||
# Tests (private)
|
||||
tests/
|
||||
|
||||
# RPi image build artifacts
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
pishrink.sh
|
||||
*.img.zst.zip
|
||||
|
||||
# Temp file storage
|
||||
frontends/web/temp_files/
|
||||
rpi/config.json
|
||||
|
||||
# Pre-built Pi tarballs and images (release assets, too large for git)
|
||||
rpi/*.tar.zst
|
||||
rpi/*.tar.zst.zip
|
||||
rpi/*.img
|
||||
rpi/*.img.zst
|
||||
rpi/*.img.zst.zip
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.12.0
|
||||
3.12
|
||||
|
||||
6
API.md
@@ -1,4 +1,4 @@
|
||||
# Stegasoo REST API Documentation (v4.0.1)
|
||||
# Stegasoo REST API Documentation (v4.0.2)
|
||||
|
||||
Complete REST API reference for Stegasoo steganography operations.
|
||||
|
||||
@@ -113,7 +113,7 @@ Check API status and configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"has_argon2": true,
|
||||
"has_qrcode_read": true,
|
||||
"has_dct": true,
|
||||
@@ -462,7 +462,7 @@ X-Stegasoo-Capacity-Percent: 12.4
|
||||
X-Stegasoo-Embed-Mode: lsb
|
||||
X-Stegasoo-Channel-Mode: private
|
||||
X-Stegasoo-Channel-Fingerprint: ABCD-••••-...-3456
|
||||
X-Stegasoo-Version: 4.0.1
|
||||
X-Stegasoo-Version: 4.0.2
|
||||
|
||||
<binary image data>
|
||||
```
|
||||
|
||||
214
CHANGELOG.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 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.5] - 2026-01-07
|
||||
|
||||
### Added
|
||||
- **Developer Documentation**: Educational comments throughout core modules
|
||||
- DCT module: zig-zag diagrams, QIM explanation, Reed-Solomon deep dive
|
||||
- LSB module: visual bit embedding examples, ChaCha20 pixel selection
|
||||
- Crypto module: multi-factor KDF flow diagrams, Argon2id reasoning
|
||||
- CLI module: Click patterns (groups, JSON output, secure input)
|
||||
- Web UI module: Flask architecture, subprocess isolation, async jobs
|
||||
- **Pi Test Automation**: `rpi/kickoff-pi-test.sh` script
|
||||
- One command to flash, wait for boot, setup, and smoke test
|
||||
- Self-contained (no dotfile dependencies)
|
||||
- **v4.2 Wishlist**: `WISHLIST-4.2.md` for blue-sky ideas (GPU acceleration)
|
||||
|
||||
### Changed
|
||||
- **Pi MOTD Improvements**:
|
||||
- Dynamic temperature emoji (ice/cool/fire based on temp)
|
||||
- Rocket emoji for service status, globe emoji for URL
|
||||
- Shortened Debian boilerplate message
|
||||
- Fixed escaped variable syntax in heredoc
|
||||
|
||||
## [4.1.3] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **Docker Deployment**: Production-ready containerization
|
||||
- `docker-compose.yml` for Web UI (port 5000) and REST API (port 8000)
|
||||
- Multi-stage builds with base image for faster rebuilds
|
||||
- Health checks, resource limits (768MB), and volume persistence
|
||||
- Comprehensive `DOCKER.md` documentation
|
||||
- **Raspberry Pi First-Boot Wizard**: Interactive TUI setup experience
|
||||
- `gum` TUI toolkit for styled prompts and spinners
|
||||
- WiFi configuration, HTTPS setup, channel key generation
|
||||
- Overclock presets (Pi 5: 2.8/3.0 GHz with cooling recommendations)
|
||||
- Port 443 redirect option for clean HTTPS URLs
|
||||
- Styled banners with purple→blue gradient and gold logo
|
||||
- **Pi Image Distribution**: Scripts for SD card imaging
|
||||
- `sanitize-for-image.sh` removes credentials, SSH keys, user data
|
||||
- Soft reset mode for testing without clearing WiFi
|
||||
- Auto-validates sanitization before imaging
|
||||
- **Unit Tests**: Comprehensive pytest test suite
|
||||
- Tests for encode/decode, LSB/DCT modes, channel keys
|
||||
- Validation, generation, compression, edge cases
|
||||
- 29 tests covering core library functionality
|
||||
- **Release Validation**: `scripts/validate-release.sh` for pre-release checks
|
||||
- **Custom SSL Documentation**: Guide for replacing certs, Let's Encrypt setup
|
||||
|
||||
### Changed
|
||||
- Pi MOTD shows CPU speed and temperature when overclocked
|
||||
- Mobile UI polish and responsive improvements
|
||||
- Standardized ASCII banners across all Pi scripts
|
||||
- Setup script uses pyenv for Python 3.12 (Pi OS ships 3.13)
|
||||
|
||||
### Fixed
|
||||
- **SSL certificate generation**: Wizard and setup now generate certs when HTTPS enabled
|
||||
- DCT decode reliability improvements
|
||||
- Fixed `gum --inline` flag compatibility (not supported in all versions)
|
||||
- Wizard banner alignment and spacing issues
|
||||
- Better error handling in app.py for SSL failures
|
||||
|
||||
## [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.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
||||
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
||||
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||
[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
|
||||
180
CLI.md
@@ -1,11 +1,11 @@
|
||||
# Stegasoo CLI Documentation (v4.0.1)
|
||||
# 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.0.0](#whats-new-in-v400)
|
||||
- [What's New in v4.1.0](#whats-new-in-v410)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Commands](#commands)
|
||||
- [generate](#generate-command)
|
||||
@@ -13,10 +13,11 @@ Complete command-line interface reference for Stegasoo steganography operations.
|
||||
- [decode](#decode-command)
|
||||
- [verify](#verify-command)
|
||||
- [channel](#channel-command)
|
||||
- [admin](#admin-command)
|
||||
- [tools](#tools-command)
|
||||
- [info](#info-command)
|
||||
- [compare](#compare-command)
|
||||
- [modes](#modes-command)
|
||||
- [strip-metadata](#strip-metadata-command)
|
||||
- [Channel Keys](#channel-keys)
|
||||
- [Embedding Modes](#embedding-modes)
|
||||
- [Security Factors](#security-factors)
|
||||
@@ -65,9 +66,28 @@ 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 adds **channel key** support for deployment/group isolation:
|
||||
Version 4.0.0 added **channel key** support for deployment/group isolation:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
@@ -76,14 +96,6 @@ Version 4.0.0 adds **channel key** support for deployment/group isolation:
|
||||
| CLI management | New `stegasoo channel` command group |
|
||||
| Flexible override | Use server config, explicit key, or public mode |
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Isolate messages between teams, deployments, or groups
|
||||
- ✅ Same credentials can't decode messages from different channels
|
||||
- ✅ Backward compatible (public mode = no channel key)
|
||||
- ✅ Easy key distribution via environment variables or config files
|
||||
|
||||
**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -495,12 +507,150 @@ Now also displays channel key status.
|
||||
|
||||
---
|
||||
|
||||
### Strip-Metadata Command
|
||||
### Admin Command
|
||||
|
||||
Remove all metadata from an image.
|
||||
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 strip-metadata IMAGE [OPTIONS]
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
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 - 3.12
|
||||
- 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!
|
||||
153
DOCKER.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Docker Deployment
|
||||
|
||||
Stegasoo provides Docker images for both the Web UI and REST API.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build and start all services
|
||||
docker-compose up -d
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
Access:
|
||||
- **Web UI**: http://localhost:5000
|
||||
- **REST API**: http://localhost:8000
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| `web` | 5000 | Flask Web UI with authentication |
|
||||
| `api` | 8000 | FastAPI REST API |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file or set these variables:
|
||||
|
||||
```bash
|
||||
# Channel key for private group communication (optional)
|
||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||||
|
||||
# Web UI authentication (default: enabled)
|
||||
STEGASOO_AUTH_ENABLED=true
|
||||
|
||||
# HTTPS support (default: disabled)
|
||||
STEGASOO_HTTPS_ENABLED=false
|
||||
STEGASOO_HOSTNAME=localhost
|
||||
```
|
||||
|
||||
### Volume Mounts
|
||||
|
||||
Persistent data is stored in Docker volumes:
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `stegasoo-web-data` | User database, session data |
|
||||
| `stegasoo-web-certs` | SSL certificates (if HTTPS enabled) |
|
||||
|
||||
## Building
|
||||
|
||||
### Standard Build (Recommended)
|
||||
|
||||
Uses a pre-built base image with all dependencies:
|
||||
|
||||
```bash
|
||||
# First time only: build the base image
|
||||
docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
|
||||
# Build services (fast - only copies app code)
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
### Full Build (No Base Image)
|
||||
|
||||
If you don't have the base image, the Dockerfile will build all dependencies (slower):
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose build && docker-compose up -d
|
||||
|
||||
# Full rebuild (no cache)
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
## Resource Limits
|
||||
|
||||
Each container is configured with:
|
||||
- **Memory limit**: 768 MB
|
||||
- **Memory reservation**: 384 MB
|
||||
|
||||
This accounts for Argon2id's 256 MB RAM requirement during key derivation.
|
||||
|
||||
## Health Checks
|
||||
|
||||
Both services include health checks:
|
||||
- Interval: 30 seconds
|
||||
- Timeout: 10 seconds
|
||||
- Start period: 5 seconds
|
||||
- Retries: 3
|
||||
|
||||
Check health status:
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production, consider:
|
||||
|
||||
1. **Enable HTTPS**:
|
||||
```bash
|
||||
STEGASOO_HTTPS_ENABLED=true
|
||||
STEGASOO_HOSTNAME=your-domain.com
|
||||
```
|
||||
|
||||
2. **Use secrets for channel key**:
|
||||
```bash
|
||||
# Don't commit .env files with secrets
|
||||
export STEGASOO_CHANNEL_KEY=your-key
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
|
||||
|
||||
4. **Backup volumes**:
|
||||
```bash
|
||||
docker run --rm -v stegasoo-web-data:/data -v $(pwd):/backup \
|
||||
alpine tar czf /backup/stegasoo-backup.tar.gz /data
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs web
|
||||
docker-compose logs api
|
||||
```
|
||||
|
||||
### Out of memory
|
||||
Increase Docker's memory allocation or reduce worker count in Dockerfile.
|
||||
|
||||
### Permission errors
|
||||
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.
|
||||
@@ -62,8 +62,9 @@ COPY src/ src/
|
||||
COPY data/ data/
|
||||
COPY frontends/web/ frontends/web/
|
||||
|
||||
# Create upload directory
|
||||
RUN mkdir -p /tmp/stego_uploads
|
||||
# Create upload directory and instance directories (for volumes)
|
||||
# temp_files is for multi-worker temp file sharing
|
||||
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
||||
|
||||
217
INSTALL.md
@@ -227,6 +227,23 @@ docker-compose logs -f
|
||||
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 |
|
||||
@@ -418,20 +435,200 @@ pip install stegasoo[all]
|
||||
|
||||
### Raspberry Pi
|
||||
|
||||
Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
|
||||
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
|
||||
|
||||
#### Step 1: Install System Dependencies
|
||||
|
||||
```bash
|
||||
# System dependencies
|
||||
sudo apt-get install python3-dev libzbar0 libjpeg-dev
|
||||
|
||||
# Install (may take a while to compile)
|
||||
pip install stegasoo[cli]
|
||||
|
||||
# For web/api, ensure enough RAM
|
||||
pip install stegasoo[web] # Needs ~768MB free
|
||||
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
|
||||
```
|
||||
|
||||
**Note:** Argon2 operations will be slower on Pi due to memory-hardness.
|
||||
#### 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)
|
||||
|
||||
---
|
||||
|
||||
## Custom SSL Certificates
|
||||
|
||||
By default, Stegasoo generates a self-signed certificate for HTTPS. To use your own certificate (e.g., from Let's Encrypt or your organization's CA):
|
||||
|
||||
### Replace Self-Signed Certificates
|
||||
|
||||
```bash
|
||||
# Stop the service
|
||||
sudo systemctl stop stegasoo
|
||||
|
||||
# Backup existing certs (optional)
|
||||
mv /opt/stegasoo/frontends/web/certs /opt/stegasoo/frontends/web/certs.bak
|
||||
|
||||
# Create new certs directory
|
||||
mkdir -p /opt/stegasoo/frontends/web/certs
|
||||
|
||||
# Copy your certificates (adjust paths as needed)
|
||||
cp /path/to/your/certificate.crt /opt/stegasoo/frontends/web/certs/server.crt
|
||||
cp /path/to/your/private.key /opt/stegasoo/frontends/web/certs/server.key
|
||||
|
||||
# Set permissions (key must be readable by service user)
|
||||
chmod 600 /opt/stegasoo/frontends/web/certs/server.key
|
||||
chown -R $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Generate New Self-Signed Certificate
|
||||
|
||||
If your certificate expires or you need to regenerate:
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop stegasoo
|
||||
|
||||
# Generate new cert with SANs
|
||||
CERT_DIR="/opt/stegasoo/frontends/web/certs"
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$CERT_DIR/server.key" \
|
||||
-out "$CERT_DIR/server.crt" \
|
||||
-days 365 -nodes \
|
||||
-subj "/O=Stegasoo/CN=$HOSTNAME" \
|
||||
-addext "subjectAltName=DNS:$HOSTNAME,DNS:$HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1"
|
||||
|
||||
chmod 600 "$CERT_DIR/server.key"
|
||||
|
||||
# Start service
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Let's Encrypt with Certbot
|
||||
|
||||
For publicly accessible servers:
|
||||
|
||||
```bash
|
||||
# Install certbot
|
||||
sudo apt install certbot
|
||||
|
||||
# Get certificate (standalone mode)
|
||||
sudo certbot certonly --standalone -d yourdomain.com
|
||||
|
||||
# Copy to Stegasoo
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /opt/stegasoo/frontends/web/certs/server.crt
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /opt/stegasoo/frontends/web/certs/server.key
|
||||
sudo chown $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs/*
|
||||
sudo chmod 600 /opt/stegasoo/frontends/web/certs/server.key
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
**Note:** Set up a cron job or systemd timer to copy renewed certificates and restart Stegasoo.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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.
|
||||
97
PLAN-4.1.6.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Stegasoo v4.1.6 Planning
|
||||
|
||||
## UI Tweaks
|
||||
|
||||
### 1. Revamp Tron Lines Animation (Carrier/Stego Image)
|
||||
**Current state:**
|
||||
- 6-8 snake paths, each with 3-5 segments (~24-40 total lines)
|
||||
- 2px thick lines
|
||||
- 30-60px length per segment
|
||||
- Starting points spread across 80% of image area
|
||||
- Colors: yellow, cyan, purple, blue with glow
|
||||
|
||||
**Target improvements:**
|
||||
- [x] Thinner lines (1px instead of 2px)
|
||||
- [x] More numerous (20-40 paths via 5x4 grid, ~60-200 segments total)
|
||||
- [x] Better distribution across entire image (grid-based seeding)
|
||||
- [x] Shorter segments (12-30px) for denser "circuit board" look
|
||||
|
||||
**Files:**
|
||||
- `frontends/web/static/style.css` (~881-979) - `.embed-trace` styling
|
||||
- `frontends/web/static/js/stegasoo.js` (~333-390) - `generateEmbedTraces()`
|
||||
|
||||
---
|
||||
|
||||
## Tools Page Expansion
|
||||
|
||||
### Analysis Tools
|
||||
- [x] **JPEG Compression Tester** - Preview image at different quality levels (10-100%), show file size delta. Useful for understanding stego survivability.
|
||||
- [ ] **LSB Plane Viewer** - Visualize least significant bit plane(s) of RGB channels. Classic stego analysis tool.
|
||||
- [ ] **Histogram Viewer** - Color distribution graph per channel. Anomalies can indicate hidden data.
|
||||
- [ ] **Image Diff** - Compare two images side-by-side with pixel difference highlighting. Great for original vs stego comparison.
|
||||
- [ ] **Noise Analysis** - Chi-square or similar statistical analysis for detecting LSB embedding.
|
||||
|
||||
### Transform Tools
|
||||
- [x] **Rotate/Flip** - 90°/180°/270° rotation, horizontal/vertical flip
|
||||
- [ ] **Resize** - Scale with aspect ratio lock, common presets (50%, 25%, etc.)
|
||||
- [ ] **Crop** - Basic rectangular crop with preview
|
||||
- [x] **Format Convert** - PNG ↔ JPEG ↔ WebP with quality slider
|
||||
|
||||
### Existing Tools (already done)
|
||||
- [x] Capacity Calculator
|
||||
- [x] EXIF Viewer
|
||||
- [x] EXIF Strip
|
||||
- [x] Image Peek (header analysis)
|
||||
|
||||
### Tools UI/UX Overhaul
|
||||
|
||||
**Final Layout: Office-style Ribbon + Two-Panel**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📏 📋 👁️ 📊 ┃ ✂️ 🔄 📐 🔀 Image Tools │ ← Icon toolbar
|
||||
├────────────────────────────────────────┬────────────────────┤
|
||||
│ [Format: PNG ▼] [Quality: 85] │ │
|
||||
├────────────────────────────────────────┤ Capacity │
|
||||
│ │ Calculator │
|
||||
│ ┌────────────────────────────┐ │ ────────────── │
|
||||
│ │ │ │ │
|
||||
│ │ Drop image here │ │ Dimensions: │
|
||||
│ │ or click │ │ 1920 × 1080 │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────────────┘ │ LSB Capacity: │
|
||||
│ │ 245 KB │
|
||||
│ [image.jpg] │ │
|
||||
│ │ ────────────── │
|
||||
│ │ [Clear] [Export] │
|
||||
└────────────────────────────────────────┴────────────────────┘
|
||||
Options + dropzone/preview Results sidebar
|
||||
```
|
||||
|
||||
- Top ribbon: Icon buttons grouped by category (Analyze | Transform)
|
||||
- Left panel: Tool options + dropzone/preview (INPUT)
|
||||
- Right panel: Tool name + results/metadata + actions (OUTPUT)
|
||||
- Flow: Left → Right (input → output)
|
||||
|
||||
**Implementation Tasks:**
|
||||
- [x] Move inline CSS to style.css
|
||||
- [x] Build icon toolbar ribbon
|
||||
- [x] Build two-panel layout structure
|
||||
- [x] Migrate existing tools (Capacity, EXIF, Strip)
|
||||
- [x] Add new tools (Rotate, Compress, Convert)
|
||||
- [ ] Loading spinner on all async operations
|
||||
- [ ] Toast notifications instead of alerts
|
||||
- [ ] Consistent color coding (green=analysis, amber=transform)
|
||||
- [ ] Mobile: stack panels vertically
|
||||
|
||||
---
|
||||
|
||||
## CLI Improvements
|
||||
|
||||
### (Add items here)
|
||||
|
||||
---
|
||||
|
||||
## Other UI Tweaks
|
||||
|
||||
### (Add items here)
|
||||
|
||||
453
README.md
@@ -2,429 +2,152 @@
|
||||
|
||||
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
|
||||
- 🔑 **Multi-factor authentication**: PIN, RSA key, or both
|
||||
- 🖼️ **Reference photo** as "something you have"
|
||||
- 🌐 **Multiple interfaces**: CLI, Web UI, REST API
|
||||
- 📁 **File embedding** - Hide any file type (PDF, ZIP, documents)
|
||||
- 📱 **QR code support** - Encode/decode RSA keys via QR codes
|
||||
- 🆕 **DCT steganography** - JPEG-resilient embedding for social media
|
||||
- 🆕 **Large image support** - Process images up to 14MB+
|
||||
- **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
|
||||
|
||||
## What's New in v4.0.0
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Simplified Auth** | Removed date dependency - encode/decode anytime without tracking dates |
|
||||
| **Passphrase** | Renamed from "day phrase" to "passphrase" (no more daily rotation) |
|
||||
| **Python 3.12** | Requires Python 3.10-3.12 (jpegio incompatible with 3.13) |
|
||||
| **Large Image Fix** | JPEG normalization prevents crashes with quality=100 images |
|
||||
| **Subprocess Isolation** | WebUI runs encode/decode in subprocesses for stability |
|
||||
| **4-Word Default** | Default passphrase increased from 3 to 4 words |
|
||||
|
||||
### Breaking Changes from v3.x
|
||||
|
||||
- `day_phrase` parameter renamed to `passphrase` in all APIs
|
||||
- `date_str` parameter removed from encode/decode functions
|
||||
- Python 3.13 not supported (jpegio C extension incompatibility)
|
||||
|
||||
### Embedding Mode Comparison
|
||||
## Embedding Modes
|
||||
|
||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||
|------|------------------|----------------|----------|
|
||||
| **DCT** (default) | ~150 KB | ✅ Yes | Social media, messaging apps |
|
||||
| **LSB** | ~750 KB | ❌ No | Email, file transfer |
|
||||
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
||||
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
||||
|
||||
## WebUI Preview
|
||||
## Web UI
|
||||
|
||||
| Front Page | Encode | Decode | Generate |
|
||||
|:----------:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
| Home | Encode | Decode | Generate |
|
||||
|:----:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install with all features (requires Python 3.10-3.12)
|
||||
# Install (Python 3.10-3.12)
|
||||
pip install -e ".[all]"
|
||||
|
||||
# Generate credentials (memorize these!)
|
||||
# Generate credentials
|
||||
stegasoo generate --pin --words 4
|
||||
|
||||
# Encode a message (DCT mode - default, best for social media)
|
||||
# Encode a message
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--ref my_photo.jpg \
|
||||
--carrier meme.jpg \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message"
|
||||
|
||||
# Encode with LSB mode (higher capacity, for email/file transfer)
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--carrier meme.png \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message" \
|
||||
--mode lsb
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
# Decode
|
||||
stegasoo decode \
|
||||
--ref photo.jpg \
|
||||
--stego stego.png \
|
||||
--ref my_photo.jpg \
|
||||
--stego stego_image.png \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456
|
||||
```
|
||||
|
||||
For detailed installation instructions, see **[INSTALL.md](INSTALL.md)**.
|
||||
## Interfaces
|
||||
|
||||
---
|
||||
| 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
|
||||
|
||||
Stegasoo uses multiple authentication factors combined with strong cryptography:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AUTHENTICATION LAYERS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Reference Photo ──┐ │
|
||||
│ (~80-256 bits) │ │
|
||||
│ ├──► Argon2id KDF ──► AES-256-GCM Key │
|
||||
│ Passphrase ───────┤ (256MB RAM) │
|
||||
│ (~43-132 bits) │ │
|
||||
│ │ │
|
||||
│ Static PIN ───────┤ │
|
||||
│ (~20-30 bits) │ │
|
||||
│ │ │
|
||||
│ RSA Key ──────────┘ │
|
||||
│ (~128 bits) (optional, adds another factor) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
Reference Photo ──┐
|
||||
(~80-256 bits) │
|
||||
├──► Argon2id KDF ──► AES-256-GCM Key
|
||||
Passphrase ───────┤ (256MB RAM)
|
||||
(~43-132 bits) │
|
||||
│
|
||||
PIN ──────────────┤
|
||||
(~20-30 bits) │
|
||||
│
|
||||
RSA Key ──────────┘
|
||||
(optional)
|
||||
```
|
||||
|
||||
### Entropy Summary
|
||||
|
||||
| Component | Entropy | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Reference Photo | ~80-256 bits | Something you have |
|
||||
| Passphrase (3-12 words) | ~33-132 bits | Something you know |
|
||||
| PIN (6-9 digits) | ~20-30 bits | Something you know |
|
||||
| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) |
|
||||
| **Combined** | **133-400+ bits** | **Beyond brute force** |
|
||||
|
||||
### Attack Resistance
|
||||
|
||||
| Attack | Protection |
|
||||
|--------|------------|
|
||||
| Brute force | 2^133+ combinations minimum |
|
||||
| Rainbow tables | Random 16-byte salt per message |
|
||||
| Steganalysis | Pseudo-random pixel/coefficient selection |
|
||||
| GPU cracking | Argon2id requires 256MB RAM per attempt |
|
||||
| Side-channel | Constant-time operations in cryptography library |
|
||||
| JPEG recompression | DCT mode embeds in frequency domain |
|
||||
|
||||
### Security Configurations
|
||||
|
||||
| Configuration | Entropy | Use Case |
|
||||
|--------------|---------|----------|
|
||||
| 3-word passphrase + 6-digit PIN | ~133 bits | Casual private messaging |
|
||||
| 4-word passphrase + 9-digit PIN | ~176 bits | Standard security (recommended) |
|
||||
| 4-word passphrase + RSA 2048 | ~241 bits | File-based authentication |
|
||||
| 6-word passphrase + PIN + RSA 4096 | ~304 bits | Maximum security |
|
||||
|
||||
---
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Command-Line Interface (CLI)
|
||||
|
||||
Full-featured CLI with piping support:
|
||||
|
||||
```bash
|
||||
# Generate with RSA key
|
||||
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "password"
|
||||
|
||||
# Encode (DCT mode is now default)
|
||||
stegasoo encode -r ref.jpg -c carrier.jpg -p "passphrase words here" --pin 123456 -m "Message"
|
||||
|
||||
# Encode with LSB mode for higher capacity
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 \
|
||||
-m "Message" --mode lsb
|
||||
|
||||
# Encode a file
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 -f secret.txt
|
||||
|
||||
# Decode to stdout (quiet mode)
|
||||
stegasoo decode -r ref.jpg -s stego.png -p "passphrase words here" --pin 123456 -q
|
||||
|
||||
# Compare LSB vs DCT capacity for an image
|
||||
stegasoo compare carrier.png
|
||||
|
||||
# Check available modes
|
||||
stegasoo modes
|
||||
```
|
||||
|
||||
📖 Full documentation: **[CLI.md](CLI.md)**
|
||||
|
||||
### Web UI
|
||||
|
||||
Browser-based interface with drag-and-drop uploads:
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
cd frontends/web
|
||||
python app.py
|
||||
# Visit http://localhost:5000
|
||||
```
|
||||
|
||||
Features:
|
||||
- Drag-and-drop image uploads with scan animations
|
||||
- Real-time entropy calculator
|
||||
- Native mobile sharing (Web Share API)
|
||||
- DCT mode default with compact mode selector
|
||||
- Subprocess isolation for stability
|
||||
- Large image support (14MB+ tested)
|
||||
- Streamlined form flow (v3.3.0)
|
||||
|
||||
📖 Full documentation: **[WEB_UI.md](WEB_UI.md)**
|
||||
|
||||
### REST API
|
||||
|
||||
FastAPI-powered REST API with OpenAPI documentation:
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
cd frontends/api
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
# Docs at http://localhost:8000/docs
|
||||
```
|
||||
|
||||
Example API calls:
|
||||
|
||||
```bash
|
||||
# Generate credentials
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"use_pin": true, "passphrase_words": 4}'
|
||||
|
||||
# Encode (DCT mode is default)
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.jpg" \
|
||||
--output stego.jpg
|
||||
|
||||
# Encode with LSB mode
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "embed_mode=lsb" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.png" \
|
||||
--output stego.png
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
curl -X POST http://localhost:8000/decode/multipart \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "stego_image=@stego.jpg"
|
||||
```
|
||||
|
||||
📖 Full documentation: **[API.md](API.md)**
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stegasoo/
|
||||
├── src/stegasoo/ # Core library
|
||||
│ ├── __init__.py # Public API
|
||||
│ ├── constants.py # Configuration
|
||||
│ ├── crypto.py # Encryption/decryption
|
||||
│ ├── steganography.py # LSB image embedding
|
||||
│ ├── dct_steganography.py # DCT embedding
|
||||
│ ├── keygen.py # Credential generation
|
||||
│ ├── validation.py # Input validation
|
||||
│ ├── models.py # Data classes
|
||||
│ ├── exceptions.py # Custom exceptions
|
||||
│ ├── qr_utils.py # QR code utilities
|
||||
│ └── utils.py # Utilities
|
||||
│
|
||||
├── frontends/
|
||||
│ ├── web/ # Flask web UI
|
||||
│ │ ├── app.py
|
||||
│ │ ├── subprocess_stego.py # Subprocess isolation
|
||||
│ │ └── stego_worker.py # Worker script
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── api/ # FastAPI REST API
|
||||
│
|
||||
├── data/
|
||||
│ └── bip39-words.txt # BIP-39 wordlist
|
||||
│
|
||||
├── pyproject.toml # Package configuration
|
||||
├── requirements.txt # Dependencies
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Container orchestration
|
||||
│
|
||||
├── README.md # This file
|
||||
├── INSTALL.md # Installation guide
|
||||
├── CLI.md # CLI documentation
|
||||
├── API.md # API documentation
|
||||
├── WEB_UI.md # Web UI documentation
|
||||
├── SECURITY.md # Security documentation
|
||||
└── UNDER_THE_HOOD.md # Technical deep-dive
|
||||
```
|
||||
|
||||
---
|
||||
| 4-word passphrase + 6-digit PIN | ~153 bits | Standard security |
|
||||
| 4-word passphrase + PIN + RSA | ~280+ bits | Maximum security |
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python | 3.10-3.12 | **3.13 not supported** (jpegio incompatibility) |
|
||||
| RAM | 512 MB+ | 256MB for Argon2 operations |
|
||||
| Disk | ~100 MB | |
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `cryptography` | AES-256-GCM encryption |
|
||||
| `Pillow` | Image processing |
|
||||
| `argon2-cffi` | Memory-hard key derivation |
|
||||
| `scipy` | DCT transforms |
|
||||
| `jpegio` | JPEG coefficient manipulation |
|
||||
| `numpy` | Array operations |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Max image size | Tested up to 14MB |
|
||||
| Max message size | 50 KB |
|
||||
| Max file upload | 5 MB |
|
||||
| PIN length | 6-9 digits |
|
||||
| Passphrase length | 3-12 words |
|
||||
| RSA key sizes | 2048, 3072, 4096 bits |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONPATH` | - | Include `src/` for development |
|
||||
|
||||
---
|
||||
| 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/
|
||||
|
||||
# Check DCT support
|
||||
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
|
||||
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(f'jpegio: {has_jpegio_support()}')"
|
||||
black src/ tests/ frontends/
|
||||
ruff check src/ tests/ frontends/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Changes |
|
||||
|---------|---------|
|
||||
| **4.0.1** | Lint cleanup, test fixes, Web UI improvements (channel key dropdown, LED indicators) |
|
||||
| **4.0.0** | Channel key support for deployment isolation, removed date dependency, renamed day_phrase→passphrase, Python 3.12 requirement, JPEG normalization fix, subprocess isolation, large image support |
|
||||
| **3.2.x** | DCT color mode, JPEG output fixes |
|
||||
| **3.0.x** | Added DCT steganography mode |
|
||||
| **2.2.x** | QR code support, file embedding |
|
||||
| **2.0.x** | Web UI, REST API, RSA keys |
|
||||
| **1.0.x** | Initial release, CLI only |
|
||||
|
||||
---
|
||||
|
||||
## Upgrading from v3.x
|
||||
|
||||
### Code Changes Required
|
||||
|
||||
```python
|
||||
# Old (v3.x)
|
||||
result = encode(
|
||||
message="secret",
|
||||
day_phrase="apple forest thunder",
|
||||
date_str="2024-01-15",
|
||||
...
|
||||
)
|
||||
|
||||
# New (v4.0)
|
||||
result = encode(
|
||||
message="secret",
|
||||
passphrase="apple forest thunder mountain",
|
||||
# No date_str needed!
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### CLI Changes
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Old (v3.x)
|
||||
stegasoo encode --phrase "words" --date 2024-01-15 ...
|
||||
# Quick start
|
||||
docker-compose up -d
|
||||
|
||||
# New (v4.0)
|
||||
stegasoo encode --passphrase "words here more" ...
|
||||
# or short form
|
||||
stegasoo encode -p "words here more" ...
|
||||
# Access
|
||||
# Web UI: http://localhost:5000
|
||||
# REST API: http://localhost:8000
|
||||
```
|
||||
|
||||
---
|
||||
See [DOCKER.md](DOCKER.md) for full documentation.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
Pre-built SD card images available for Pi 4/5:
|
||||
|
||||
```bash
|
||||
# Flash image (download from GitHub Releases)
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
|
||||
# First boot runs interactive setup wizard:
|
||||
# - WiFi configuration
|
||||
# - HTTPS with port 443
|
||||
# - Channel key generation
|
||||
# - Optional overclocking
|
||||
```
|
||||
|
||||
See [rpi/README.md](rpi/README.md) for manual installation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||
- [DOCKER.md](DOCKER.md) - Docker deployment
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions
|
||||
- **[CLI.md](CLI.md)** - Command-line interface reference
|
||||
- **[API.md](API.md)** - REST API documentation
|
||||
- **[WEB_UI.md](WEB_UI.md)** - Web interface guide
|
||||
- **[SECURITY.md](SECURITY.md)** - Security model and threat analysis
|
||||
- **[UNDER_THE_HOOD.md](UNDER_THE_HOOD.md)** - Technical implementation details
|
||||
*This tool is for educational and legitimate privacy purposes. Users are responsible for complying with applicable laws.*
|
||||
|
||||
44
RELEASE_CHECKLIST.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Stegasoo Release Checklist
|
||||
|
||||
Pre-release validation checklist. Complete all items before tagging a release.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] All tests pass: `./venv/bin/pytest tests/ -v`
|
||||
- [ ] No lint errors: `./venv/bin/ruff check src/`
|
||||
- [ ] Version bumped in `pyproject.toml`
|
||||
- [ ] CHANGELOG.md updated
|
||||
|
||||
## Pi Image Validation
|
||||
|
||||
- [ ] Fresh Pi OS install with setup.sh works
|
||||
- [ ] First-boot wizard completes successfully
|
||||
- [ ] MOTD shows correct URL on SSH login
|
||||
- [ ] Smoke test passes: `./rpi/smoke-test.sh --443 <PI_IP>`
|
||||
- [ ] Encode/decode works on large image (10MB+)
|
||||
- [ ] Sanitize script runs cleanly
|
||||
- [ ] Image created and compressed
|
||||
|
||||
## Docker Validation
|
||||
|
||||
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .`
|
||||
- [ ] Web image builds: `docker-compose build web`
|
||||
- [ ] Container starts: `docker-compose up -d web`
|
||||
- [ ] Web UI accessible at http://localhost:5000
|
||||
- [ ] Encode/decode works in container
|
||||
- [ ] Container stops cleanly: `docker-compose down`
|
||||
|
||||
## Release Process
|
||||
|
||||
- [ ] Merge feature branch to main
|
||||
- [ ] Create annotated tag: `git tag -a vX.Y.Z -m "message"`
|
||||
- [ ] Push tag: `git push origin vX.Y.Z`
|
||||
- [ ] Create GitHub Release with release notes
|
||||
- [ ] Upload Pi image (.img.zst.zip)
|
||||
- [ ] Verify download links work
|
||||
|
||||
## Post-Release
|
||||
|
||||
- [ ] Delete old/obsolete releases if needed
|
||||
- [ ] Update any external documentation
|
||||
- [ ] Announce release (if applicable)
|
||||
47
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,47 @@
|
||||
## Stegasoo v4.1.5
|
||||
|
||||
### Developer Experience
|
||||
- **Educational Code Comments**: Core modules now include detailed explanations
|
||||
- DCT: zig-zag coefficient diagrams, QIM embedding math, Reed-Solomon "Voyager" reference
|
||||
- LSB: visual bit manipulation examples, ChaCha20 pixel selection
|
||||
- Crypto: multi-factor KDF flow diagrams, Argon2id memory-hardness reasoning
|
||||
- CLI/Web: architectural patterns for future contributors
|
||||
|
||||
### Raspberry Pi Improvements
|
||||
- **Streamlined Image Creation**: `pull-image.sh` now handles everything
|
||||
- Auto-resizes rootfs to exactly 16GB (consistent images from any SD card)
|
||||
- Disables Pi OS auto-expand
|
||||
- Compresses with zstd
|
||||
- Optional .zst.zip wrapper for GitHub releases
|
||||
- **16GB Minimum**: Pre-built images are now 16GB (was variable)
|
||||
- **Host Requirements**: `rpi/host-requirements.txt` documents all dependencies
|
||||
- **Test Automation**: `kickoff-pi-test.sh` for one-command flash+test cycles
|
||||
|
||||
### MOTD Polish
|
||||
- Dynamic temperature emoji (ice/cool/fire based on CPU temp)
|
||||
- Rocket emoji for service status
|
||||
- Cleaner formatting
|
||||
|
||||
### Raspberry Pi Image
|
||||
Download `stegasoo-rpi-4.1.5.img.zst.zip` from Releases.
|
||||
|
||||
```bash
|
||||
# Flash (auto-detects SD card)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.5.img.zst.zip
|
||||
|
||||
# Or manual
|
||||
zstdcat stegasoo-rpi-4.1.5.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
Default login: `admin` / `stegasoo`
|
||||
|
||||
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration.
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up -d web # Web UI on :5000
|
||||
docker-compose up -d api # REST API on :8000
|
||||
```
|
||||
|
||||
### Full Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||
10
SECURITY.md
@@ -4,16 +4,16 @@
|
||||
|
||||
| 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 | |
|
||||
| 4.1.x | Current Version | What you SHOULD be using. |
|
||||
| 4.x.x | ⚠️ Security fixes only | Upgrade (EOL soon) |
|
||||
| <= 3.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)
|
||||
Instead, please email: **adlee-was-taken@proton.me**
|
||||
|
||||
Include:
|
||||
- Description of the vulnerability
|
||||
|
||||
284
SECURITY_AUDIT_PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Stegasoo Security Audit Plan
|
||||
|
||||
> **Target Audience**: Developers, security reviewers, and deployment administrators
|
||||
> **Scope**: Web UI, REST API, CLI, and cryptographic core
|
||||
> **Deployment Model**: Air-gapped / private LAN (primary), Internet-facing (secondary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Stegasoo is a steganography tool designed for **air-gapped deployments** on private networks. While the primary threat model assumes a trusted local network, this audit plan covers security best practices for both isolated and potentially exposed deployments.
|
||||
|
||||
### Known Limitations (By Design)
|
||||
|
||||
- **Self-signed certificates**: HTTPS uses self-signed certs; users must add exceptions or deploy their own CA
|
||||
- **No rate limiting**: Assumes trusted users on private network
|
||||
- **Single-node**: No distributed session store; sessions are per-instance
|
||||
- **Air-gap focus**: External security (firewalls, network isolation) is user's responsibility
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication & Authorization
|
||||
|
||||
### 1.1 Password Security
|
||||
- [ ] Passwords hashed with Argon2id (preferred) or PBKDF2 fallback
|
||||
- [ ] Minimum password length enforced (8+ characters)
|
||||
- [ ] Password not logged or exposed in error messages
|
||||
- [ ] Password change requires current password verification
|
||||
- [ ] Admin re-authentication required for sensitive operations (channel key export)
|
||||
|
||||
### 1.2 Session Management
|
||||
- [ ] Session tokens are cryptographically random
|
||||
- [ ] Session cookies have `HttpOnly` flag
|
||||
- [ ] Session cookies have `Secure` flag (when HTTPS enabled)
|
||||
- [ ] Session cookies have `SameSite` attribute
|
||||
- [ ] Sessions invalidated on logout
|
||||
- [ ] Sessions invalidated on password change
|
||||
- [ ] Session timeout configured appropriately
|
||||
|
||||
### 1.3 Authorization
|
||||
- [ ] Admin-only routes protected by `@admin_required` decorator
|
||||
- [ ] User-only routes protected by `@login_required` decorator
|
||||
- [ ] Users cannot access other users' saved channel keys
|
||||
- [ ] Users cannot modify other users' accounts
|
||||
- [ ] Role escalation not possible through API manipulation
|
||||
|
||||
---
|
||||
|
||||
## 2. Cryptographic Implementation
|
||||
|
||||
### 2.1 Key Derivation
|
||||
- [ ] KDF uses Argon2id with appropriate parameters (memory, iterations, parallelism)
|
||||
- [ ] PBKDF2 fallback uses sufficient iterations (600,000+)
|
||||
- [ ] Salt is cryptographically random and unique per operation
|
||||
- [ ] PIN/passphrase combined securely before KDF
|
||||
|
||||
### 2.2 Encryption
|
||||
- [ ] AES-256-GCM used for payload encryption
|
||||
- [ ] Nonce/IV is unique per encryption operation
|
||||
- [ ] Authentication tag verified before decryption
|
||||
- [ ] No padding oracle vulnerabilities
|
||||
|
||||
### 2.3 Channel Keys
|
||||
- [ ] Channel keys are 128-bit (32 hex chars)
|
||||
- [ ] Channel key derivation uses HKDF or similar
|
||||
- [ ] Channel isolation prevents cross-channel decryption
|
||||
- [ ] Fingerprint reveals no information about full key
|
||||
|
||||
### 2.4 Random Number Generation
|
||||
- [ ] All random values use `secrets` module or OS CSPRNG
|
||||
- [ ] No use of `random` module for security-sensitive operations
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Validation & Injection Prevention
|
||||
|
||||
### 3.1 Web UI
|
||||
- [ ] All user input sanitized before rendering (XSS prevention)
|
||||
- [ ] Jinja2 auto-escaping enabled
|
||||
- [ ] No `| safe` filter on user-controlled content
|
||||
- [ ] Content-Security-Policy header configured
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
|
||||
### 3.2 File Uploads
|
||||
- [ ] File size limits enforced server-side
|
||||
- [ ] File type validation (magic bytes, not just extension)
|
||||
- [ ] Uploaded files not executed
|
||||
- [ ] Filenames sanitized (path traversal prevention)
|
||||
- [ ] Temporary files cleaned up after processing
|
||||
|
||||
### 3.3 API Inputs
|
||||
- [ ] JSON schema validation on API endpoints
|
||||
- [ ] Integer overflow checks on size parameters
|
||||
- [ ] No SQL injection (parameterized queries only)
|
||||
- [ ] No command injection (no shell=True with user input)
|
||||
|
||||
---
|
||||
|
||||
## 4. Steganography-Specific Security
|
||||
|
||||
### 4.1 Carrier Image Handling
|
||||
- [ ] Malformed images don't crash the server (PIL/jpegio hardening)
|
||||
- [ ] DCT mode subprocess isolation for crash protection
|
||||
- [ ] Memory limits on image processing
|
||||
- [ ] No arbitrary code execution from image metadata
|
||||
|
||||
### 4.2 Payload Security
|
||||
- [ ] Payload size limits enforced
|
||||
- [ ] Encrypted payload indistinguishable from random noise
|
||||
- [ ] No metadata leakage in output images
|
||||
- [ ] Reference photo required (prevents dictionary attacks)
|
||||
|
||||
### 4.3 Capacity Reporting
|
||||
- [ ] Capacity calculation doesn't leak information about encoding method
|
||||
- [ ] Failed decodes don't reveal why (wrong key vs no data vs corrupted)
|
||||
|
||||
---
|
||||
|
||||
## 5. Network & Transport Security
|
||||
|
||||
### 5.1 HTTPS Configuration
|
||||
- [ ] TLS 1.2+ only (no SSLv3, TLS 1.0/1.1)
|
||||
- [ ] Strong cipher suites configured
|
||||
- [ ] Certificate generation uses 2048+ bit RSA or P-256 EC
|
||||
- [ ] Private key file permissions restricted (600)
|
||||
|
||||
### 5.2 Headers
|
||||
- [ ] X-Frame-Options: DENY (clickjacking prevention)
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
- [ ] Referrer-Policy: same-origin
|
||||
- [ ] Permissions-Policy configured
|
||||
|
||||
### 5.3 CORS (if applicable)
|
||||
- [ ] CORS not enabled (or restricted to specific origins)
|
||||
- [ ] Credentials not allowed cross-origin
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handling & Logging
|
||||
|
||||
### 6.1 Error Messages
|
||||
- [ ] Stack traces not exposed to users in production
|
||||
- [ ] Error messages don't reveal sensitive paths or config
|
||||
- [ ] Failed login doesn't reveal if username exists
|
||||
|
||||
### 6.2 Logging
|
||||
- [ ] Passwords never logged
|
||||
- [ ] Channel keys never logged
|
||||
- [ ] Passphrases never logged
|
||||
- [ ] Log files have appropriate permissions
|
||||
- [ ] Sensitive operations logged for audit trail (optional)
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependency Security
|
||||
|
||||
### 7.1 Python Dependencies
|
||||
- [ ] All dependencies pinned to specific versions
|
||||
- [ ] No known vulnerabilities in dependencies (run `pip-audit` or `safety`)
|
||||
- [ ] Dependencies from trusted sources only (PyPI)
|
||||
|
||||
### 7.2 Frontend Dependencies
|
||||
- [ ] All JS/CSS served locally (air-gap ready)
|
||||
- [ ] No CDN dependencies
|
||||
- [ ] Bootstrap and libraries are official releases
|
||||
- [ ] Subresource integrity considered for any external loads
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Security
|
||||
|
||||
### 8.1 File Permissions
|
||||
- [ ] Database file not world-readable (600 or 640)
|
||||
- [ ] SSL certificates/keys not world-readable
|
||||
- [ ] Config files with secrets protected
|
||||
- [ ] Instance directory not in web root
|
||||
|
||||
### 8.2 Docker Deployment
|
||||
- [ ] Container runs as non-root user
|
||||
- [ ] No unnecessary capabilities
|
||||
- [ ] Resource limits configured
|
||||
- [ ] Health checks don't expose sensitive info
|
||||
|
||||
### 8.3 Raspberry Pi Deployment
|
||||
- [ ] Default passwords changed
|
||||
- [ ] SSH key-only authentication (recommended)
|
||||
- [ ] Unnecessary services disabled
|
||||
- [ ] Firewall configured (UFW/iptables)
|
||||
|
||||
---
|
||||
|
||||
## 9. Air-Gap Specific Considerations
|
||||
|
||||
### 9.1 Network Isolation
|
||||
- [ ] Document expected network topology
|
||||
- [ ] No phone-home or telemetry
|
||||
- [ ] No external API calls
|
||||
- [ ] Works fully offline after deployment
|
||||
|
||||
### 9.2 Key Distribution
|
||||
- [ ] QR code export for channel keys (offline transfer)
|
||||
- [ ] Print sheet for physical key backup
|
||||
- [ ] No cloud sync or external key servers
|
||||
|
||||
### 9.3 Updates
|
||||
- [ ] Document offline update procedure
|
||||
- [ ] Signed releases (future consideration)
|
||||
- [ ] Checksum verification for downloads
|
||||
|
||||
---
|
||||
|
||||
## 10. Penetration Testing Checklist
|
||||
|
||||
### 10.1 Authentication Attacks
|
||||
- [ ] Brute force login (note: no rate limiting by design)
|
||||
- [ ] Session fixation
|
||||
- [ ] Session hijacking
|
||||
- [ ] Password reset flow abuse
|
||||
|
||||
### 10.2 Injection Attacks
|
||||
- [ ] SQL injection on all inputs
|
||||
- [ ] XSS (stored, reflected, DOM-based)
|
||||
- [ ] Command injection
|
||||
- [ ] Path traversal
|
||||
- [ ] SSTI (Server-Side Template Injection)
|
||||
|
||||
### 10.3 Business Logic
|
||||
- [ ] Access control bypass
|
||||
- [ ] IDOR (Insecure Direct Object Reference)
|
||||
- [ ] Race conditions
|
||||
- [ ] Integer overflow in capacity calculations
|
||||
|
||||
### 10.4 Cryptographic Attacks
|
||||
- [ ] Known-plaintext attacks on stego output
|
||||
- [ ] Timing attacks on password verification
|
||||
- [ ] Padding oracle attacks
|
||||
- [ ] Key reuse vulnerabilities
|
||||
|
||||
---
|
||||
|
||||
## Tools for Automated Testing
|
||||
|
||||
```bash
|
||||
# Dependency vulnerability scan
|
||||
pip-audit
|
||||
safety check
|
||||
|
||||
# Static analysis
|
||||
bandit -r stegasoo/ frontends/
|
||||
|
||||
# Web security scan (if exposed)
|
||||
nikto -h https://localhost:5000
|
||||
OWASP ZAP (manual)
|
||||
|
||||
# SSL/TLS configuration
|
||||
testssl.sh https://localhost:5000
|
||||
|
||||
# Python code quality
|
||||
ruff check .
|
||||
mypy stegasoo/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Schedule
|
||||
|
||||
| Phase | Focus Area | Priority |
|
||||
|-------|-----------|----------|
|
||||
| Pre-release | Crypto implementation, auth flow | Critical |
|
||||
| Post-release | Dependency scan, static analysis | High |
|
||||
| Quarterly | Full penetration test | Medium |
|
||||
| Ongoing | CVE monitoring for dependencies | High |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This plan assumes **trusted users on a private network** as the primary deployment model
|
||||
- Internet-facing deployments should add rate limiting, fail2ban, and reverse proxy hardening
|
||||
- For high-security deployments, consider external security audit by professionals
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-07*
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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)
|
||||
**Version 4.1** - Updated for channel keys and deployment isolation
|
||||
|
||||
---
|
||||
|
||||
@@ -22,20 +22,20 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STEGASOO ARCHITECTURE (v4.0) │
|
||||
│ STEGASOO ARCHITECTURE (v4.1) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ INPUTS PROCESSING OUTPUT │
|
||||
│ ─────── ────────── ────── │
|
||||
│ │
|
||||
│ Reference Photo ─┐ │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┘ │ │
|
||||
│ ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┤ │ │
|
||||
│ Channel Key ─────┘ (v4.1) ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
│ Encryption │ │
|
||||
│ ▼ │
|
||||
│ Carrier Image ───────────────────────────────────────► Embedding ──► Stego│
|
||||
│ Carrier Image ───────────────────────────────────────► Embedding ─► Stego │
|
||||
│ (LSB/DCT) Image │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -50,11 +50,24 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
| Header size | 75 bytes | 65 bytes (no date field) |
|
||||
| Python support | 3.10+ | 3.10-3.12 only |
|
||||
|
||||
### v4.1 Changes
|
||||
|
||||
| Change | v4.0 | v4.1 |
|
||||
|--------|------|------|
|
||||
| Channel keys | None | 32-byte deployment isolation |
|
||||
| Key derivation | passphrase + ref + pin | passphrase + ref + pin + channel |
|
||||
| Web auth | Session-based | Session + admin/user roles |
|
||||
| Raspberry Pi | Manual setup | First-boot wizard with gum |
|
||||
| Docker | Basic | Production-ready compose |
|
||||
|
||||
**Channel Keys** provide deployment isolation - messages encoded on one Stegasoo instance cannot be decoded by another instance with a different channel key, even with the same passphrase/PIN/reference photo.
|
||||
|
||||
### Module Responsibilities
|
||||
|
||||
| Module | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| **Crypto** | `crypto.py` | Key derivation (Argon2id), AES-256-GCM encryption/decryption |
|
||||
| **Channel** | `channel.py` | Channel key management, deployment isolation (v4.1) |
|
||||
| **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 |
|
||||
@@ -626,7 +639,7 @@ Factor 1: Reference Photo ─┐
|
||||
• 80-256 bits entropy │
|
||||
• "Something you have" │
|
||||
├──► Combined entropy: 133-400+ bits
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
• 43-132 bits entropy │
|
||||
• "Something you know" │
|
||||
• 4 words default (v4.0) │
|
||||
@@ -688,7 +701,7 @@ AUTHENTICATED ENCRYPTION (AES-256-GCM)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
User Inputs Processing Output
|
||||
@@ -714,14 +727,14 @@ Carrier Image ──────────────────────
|
||||
│ │
|
||||
┌───────────┴─────┴────────────┐
|
||||
│ │
|
||||
LSB Mode DCT Mode
|
||||
LSB Mode DCT Mode
|
||||
│ │
|
||||
▼ ▼
|
||||
embed_lsb() embed_in_dct()
|
||||
(pixel LSBs) (DCT coefficients)
|
||||
embed_lsb() embed_in_dct()
|
||||
(pixel LSBs) (DCT coefficients)
|
||||
│ │
|
||||
▼ ▼
|
||||
PNG Output PNG or JPEG
|
||||
PNG Output PNG or JPEG
|
||||
│ │
|
||||
└──────────┬───────────────────┘
|
||||
│
|
||||
@@ -793,8 +806,8 @@ Stego Image ──────────► detect_mode() ──────
|
||||
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)
|
||||
- **Private channel?** → LSB (maximum capacity)
|
||||
|
||||
### v4.0 Simplifications
|
||||
|
||||
|
||||
377
WEB_UI.md
@@ -1,17 +1,22 @@
|
||||
# Stegasoo Web UI Documentation (v4.0.1)
|
||||
# Stegasoo Web UI Documentation (v4.1.0)
|
||||
|
||||
Complete guide for the Stegasoo web-based steganography interface.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What's New in v4.0.1](#whats-new-in-v401)
|
||||
- [What's New in v4.1.0](#whats-new-in-v410)
|
||||
- [Authentication & HTTPS](#authentication--https)
|
||||
- [Admin Recovery](#admin-recovery)
|
||||
- [Multi-User Support](#multi-user-support)
|
||||
- [Installation & Setup](#installation--setup)
|
||||
- [Pages & Features](#pages--features)
|
||||
- [Home Page](#home-page)
|
||||
- [Generate Credentials](#generate-credentials)
|
||||
- [Encode Message](#encode-message)
|
||||
- [Decode Message](#decode-message)
|
||||
- [Tools Page](#tools-page)
|
||||
- [Account Page](#account-page)
|
||||
- [About Page](#about-page)
|
||||
- [Embedding Modes](#embedding-modes)
|
||||
- [DCT Mode (Default)](#dct-mode-default)
|
||||
@@ -53,24 +58,270 @@ Built with Flask, Bootstrap 5, and a modern dark theme.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.1
|
||||
## What's New in v4.1.0
|
||||
|
||||
Version 4.0.1 adds channel key support and UI improvements:
|
||||
Version 4.1.0 adds admin recovery, multi-user support, and new tools:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Channel keys | 256-bit keys for deployment/group isolation |
|
||||
| Channel dropdown | Select channel mode (Auto/Public/Custom) |
|
||||
| LED indicators | Visual status indicators for form fields |
|
||||
| Key capsule styling | Improved RSA key display |
|
||||
| Streamlined layout | PIN + Channel key in same row |
|
||||
| **Admin Recovery** | Password reset using secure recovery key |
|
||||
| **Multi-User Support** | Up to 16 users with role-based access |
|
||||
| **EXIF Editor** | View, edit, and strip image metadata |
|
||||
| **Saved Channel Keys** | Users can save/manage channel keys in account |
|
||||
| **Toast Improvements** | Auto-dismiss after 20 seconds with fade |
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Channel key isolation - Different teams/deployments can't read each other's messages
|
||||
- ✅ Dropdown selection for channel mode instead of radio buttons
|
||||
- ✅ Visual LED indicators show field status
|
||||
- ✅ Cleaner form layout with improved spacing
|
||||
- ✅ Backward compatible - public mode works without channel key
|
||||
- ✅ Never get locked out - recovery key backup options
|
||||
- ✅ Share access with team members (admin/user roles)
|
||||
- ✅ Full EXIF metadata control in Tools page
|
||||
- ✅ Persistent channel key storage per user
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.2
|
||||
|
||||
Version 4.0.2 added authentication and HTTPS support:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Authentication** | Single-admin login with SQLite3 user storage |
|
||||
| **First-run setup** | Wizard to create admin account on first access |
|
||||
| **Account management** | Change password page |
|
||||
| **Optional HTTPS** | Auto-generated self-signed certificates |
|
||||
|
||||
---
|
||||
|
||||
## Authentication & HTTPS
|
||||
|
||||
### Overview
|
||||
|
||||
v4.0.2 adds optional authentication and HTTPS for secure home network deployment.
|
||||
|
||||
### First-Run Setup
|
||||
|
||||
On first access, you'll be prompted to create an admin account:
|
||||
|
||||
1. Navigate to `http://localhost:5000`
|
||||
2. You'll be redirected to `/setup`
|
||||
3. Enter a username (e.g., "admin")
|
||||
4. Enter a password (minimum 8 characters)
|
||||
5. Confirm the password
|
||||
6. Click "Create Admin Account"
|
||||
|
||||
The admin account is stored in `frontends/web/instance/stegasoo.db` (SQLite).
|
||||
|
||||
### Login
|
||||
|
||||
After setup, protected pages require login:
|
||||
|
||||
- **Protected routes:** `/encode`, `/decode`, `/generate`, `/account`, `/api/*`
|
||||
- **Public routes:** `/`, `/about`, `/login`, `/setup`
|
||||
|
||||
### Account Management
|
||||
|
||||
Access `/account` to:
|
||||
- View current username
|
||||
- Change your password
|
||||
- Logout
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate generation |
|
||||
|
||||
### Enabling HTTPS
|
||||
|
||||
```bash
|
||||
# Enable HTTPS
|
||||
export STEGASOO_HTTPS_ENABLED=true
|
||||
export STEGASOO_HOSTNAME=stegasoo.local # Optional: your hostname
|
||||
|
||||
cd frontends/web
|
||||
python app.py
|
||||
```
|
||||
|
||||
On first run with HTTPS enabled:
|
||||
- Generates RSA 2048-bit private key
|
||||
- Creates self-signed X.509 certificate (365 days validity)
|
||||
- Stores in `frontends/web/certs/`
|
||||
- Server starts on https://localhost:5000
|
||||
|
||||
**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use.
|
||||
|
||||
**Tip:** To avoid browser warnings, use [mkcert](https://github.com/FiloSottile/mkcert) to generate locally-trusted certificates:
|
||||
|
||||
```bash
|
||||
# Install mkcert and create local CA (one-time)
|
||||
mkcert -install
|
||||
|
||||
# Generate trusted certs for your Pi
|
||||
mkcert -key-file key.pem -cert-file cert.pem stegasoo.local localhost 127.0.0.1 YOUR_PI_IP
|
||||
|
||||
# Copy to certs directory
|
||||
mv key.pem cert.pem frontends/web/certs/
|
||||
```
|
||||
|
||||
### Disabling Authentication
|
||||
|
||||
For development or trusted networks:
|
||||
|
||||
```bash
|
||||
export STEGASOO_AUTH_ENABLED=false
|
||||
cd frontends/web
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
STEGASOO_AUTH_ENABLED: "true"
|
||||
STEGASOO_HTTPS_ENABLED: "true"
|
||||
STEGASOO_HOSTNAME: "stegasoo.local"
|
||||
volumes:
|
||||
- ./instance:/app/frontends/web/instance # Persist user database
|
||||
- ./certs:/app/frontends/web/certs # Persist SSL certs
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Passwords are hashed with Argon2id (time_cost=3, memory_cost=64MB)
|
||||
- Single admin user only (no registration)
|
||||
- Session-based authentication using Flask sessions
|
||||
- Database stored in `instance/stegasoo.db` (add to `.gitignore`)
|
||||
|
||||
---
|
||||
|
||||
## Admin Recovery
|
||||
|
||||
### Overview
|
||||
|
||||
If you forget your admin password, the recovery key is the ONLY way to reset it. Generate and save your recovery key immediately after setup.
|
||||
|
||||
### Recovery Key Format
|
||||
|
||||
```
|
||||
XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||||
└──────────────────────────────────────┘
|
||||
32 alphanumeric characters (8 groups of 4)
|
||||
```
|
||||
|
||||
### Backup Options
|
||||
|
||||
The recovery key can be saved in multiple ways:
|
||||
|
||||
| Method | Description | Security Level |
|
||||
|--------|-------------|----------------|
|
||||
| **Text file** | Plain text download | Low - store securely |
|
||||
| **QR code** | Obfuscated PNG image | Medium - XOR'd with magic hash |
|
||||
| **Stego image** | Hidden in carrier image | High - requires original image |
|
||||
|
||||
### Generating a Recovery Key
|
||||
|
||||
**During first-run setup:**
|
||||
1. Complete the admin account wizard
|
||||
2. You'll be prompted to save your recovery key
|
||||
3. Choose backup method(s)
|
||||
4. Confirm you've saved the key
|
||||
|
||||
**From Account page (admin only):**
|
||||
1. Navigate to `/account`
|
||||
2. Click "Generate Recovery Key" (or "Regenerate" if one exists)
|
||||
3. Save using your preferred method
|
||||
4. Check the confirmation box
|
||||
5. Click "Save New Key"
|
||||
|
||||
### QR Code Obfuscation
|
||||
|
||||
QR codes are not plain text - they're XOR'd with a fixed obfuscation key derived from Stegasoo's magic headers. This prevents casual scanning from revealing the key.
|
||||
|
||||
### Stego Backup
|
||||
|
||||
Hide your recovery key inside an image using Stegasoo itself:
|
||||
|
||||
1. Upload a carrier image (JPG/PNG, 50KB-2MB)
|
||||
2. Click the "Stego" button
|
||||
3. Download the stego image
|
||||
4. **Important:** Keep the original carrier image - you'll need it for extraction
|
||||
|
||||
### Recovering Your Password
|
||||
|
||||
**URL:** `/recover`
|
||||
|
||||
1. Navigate to the login page
|
||||
2. Click "Forgot password?"
|
||||
3. **Option A:** Enter recovery key directly
|
||||
4. **Option B:** Extract from stego backup:
|
||||
- Expand "Extract from stego backup"
|
||||
- Upload your stego backup image
|
||||
- Upload the original carrier/reference image
|
||||
- Click "Extract Key"
|
||||
5. Enter and confirm your new password
|
||||
6. Click "Reset Password"
|
||||
|
||||
### CLI Recovery
|
||||
|
||||
For locked-out scenarios where you can't access the web UI:
|
||||
|
||||
```bash
|
||||
stegasoo admin recover --db frontends/web/instance/stegasoo.db
|
||||
```
|
||||
|
||||
You'll be prompted for your recovery key and new password.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Recovery keys are instance-bound (tied to the specific database)
|
||||
- Regenerating a key invalidates the previous one
|
||||
- Store backups in a secure, separate location
|
||||
- Without a recovery key, the only option is to delete the database and reconfigure
|
||||
|
||||
---
|
||||
|
||||
## Multi-User Support
|
||||
|
||||
### Overview
|
||||
|
||||
Admins can create up to 16 additional users with role-based access control.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| **Admin** | Full access: encode, decode, generate, tools, user management, recovery |
|
||||
| **User** | Standard access: encode, decode, generate, account settings |
|
||||
|
||||
### User Management
|
||||
|
||||
**URL:** `/admin/users` (admin only)
|
||||
|
||||
#### Creating Users
|
||||
|
||||
1. Click "Add User"
|
||||
2. Enter username
|
||||
3. Select role (admin/user)
|
||||
4. A temporary password is generated
|
||||
5. Share the temporary password securely with the new user
|
||||
6. User must change password on first login
|
||||
|
||||
#### Managing Users
|
||||
|
||||
- View all users and their roles
|
||||
- Reset user passwords (generates new temp password)
|
||||
- Change user roles
|
||||
- Delete users (except yourself)
|
||||
|
||||
### User Limits
|
||||
|
||||
- Maximum 16 users total (including admin)
|
||||
- At least one admin must exist
|
||||
- Users can't delete or demote the last admin
|
||||
|
||||
---
|
||||
|
||||
@@ -441,6 +692,83 @@ If decryption fails:
|
||||
|
||||
---
|
||||
|
||||
### Tools Page
|
||||
|
||||
**URL:** `/tools`
|
||||
|
||||
The Tools page provides utilities for image analysis and manipulation.
|
||||
|
||||
#### EXIF Editor
|
||||
|
||||
View and edit image metadata (EXIF data).
|
||||
|
||||
**Features:**
|
||||
- View all EXIF fields from uploaded image
|
||||
- Inline editing of individual fields
|
||||
- Clear all metadata with one click
|
||||
- Download cleaned image
|
||||
|
||||
**Usage:**
|
||||
1. Upload an image (JPG recommended - richest EXIF data)
|
||||
2. View all metadata fields in a table
|
||||
3. Click any field to edit its value
|
||||
4. Click "Save" to apply changes
|
||||
5. Use "Clear All" to strip all metadata
|
||||
6. Download the modified image
|
||||
|
||||
**Common EXIF fields:**
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Make/Model | Camera manufacturer and model |
|
||||
| DateTime | When the photo was taken |
|
||||
| GPSLatitude/GPSLongitude | Location coordinates |
|
||||
| Software | Editing software used |
|
||||
| Artist | Photographer name |
|
||||
|
||||
**Privacy tip:** Always strip EXIF data before sharing images publicly to remove location and device information.
|
||||
|
||||
#### Peek (Stego Detection)
|
||||
|
||||
Quickly check if an image contains hidden data.
|
||||
|
||||
#### Strip Metadata
|
||||
|
||||
Remove all metadata from an image in one click.
|
||||
|
||||
---
|
||||
|
||||
### Account Page
|
||||
|
||||
**URL:** `/account`
|
||||
|
||||
Manage your account settings and preferences.
|
||||
|
||||
#### Password Change
|
||||
|
||||
1. Enter current password
|
||||
2. Enter new password (minimum 8 characters)
|
||||
3. Confirm new password
|
||||
4. Click "Change Password"
|
||||
|
||||
#### Saved Channel Keys (v4.1.0)
|
||||
|
||||
Users can save frequently-used channel keys for quick access:
|
||||
|
||||
1. Click "Add Channel Key"
|
||||
2. Enter a name/label for the key
|
||||
3. Paste the channel key
|
||||
4. Click "Save"
|
||||
|
||||
Saved keys appear in a dropdown during encode/decode operations.
|
||||
|
||||
#### Recovery Key Management (Admin only)
|
||||
|
||||
- View recovery key status (configured/not configured)
|
||||
- Generate or regenerate recovery key
|
||||
- Download backup options (text, QR, stego)
|
||||
|
||||
---
|
||||
|
||||
### About Page
|
||||
|
||||
**URL:** `/about`
|
||||
@@ -448,10 +776,10 @@ If decryption fails:
|
||||
Information about the Stegasoo project, security model, and credits.
|
||||
|
||||
Includes:
|
||||
- Version information (v3.3.0)
|
||||
- Recent UI improvements
|
||||
- Version information (v4.1.0)
|
||||
- Feature highlights
|
||||
- Security model overview
|
||||
- Dependency status (Argon2, QR code support)
|
||||
- Dependency status (Argon2, scipy/DCT, QR code support)
|
||||
|
||||
---
|
||||
|
||||
@@ -752,6 +1080,10 @@ Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivat
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONPATH` | - | Include `src/` for development |
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication (v4.0.2) |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs (v4.0.2) |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate CN (v4.0.2) |
|
||||
| `STEGASOO_CHANNEL_KEY` | - | Channel key for deployment isolation |
|
||||
|
||||
### Application Limits
|
||||
|
||||
@@ -808,12 +1140,23 @@ services:
|
||||
target: web
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
STEGASOO_AUTH_ENABLED: "true"
|
||||
STEGASOO_HTTPS_ENABLED: "false"
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
volumes:
|
||||
- stegasoo-web-data:/app/frontends/web/instance
|
||||
- stegasoo-web-certs:/app/frontends/web/certs
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
reservations:
|
||||
memory: 384M
|
||||
|
||||
volumes:
|
||||
stegasoo-web-data:
|
||||
stegasoo-web-certs:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
42
WISHLIST-4.2.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Stegasoo v4.2 Wishlist
|
||||
|
||||
Blue sky ideas for future development. No timeline - just capturing thoughts.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### GPU-Accelerated DCT Encoding/Decoding
|
||||
- **Idea**: Leverage GPU for JPEG DCT coefficient manipulation
|
||||
- **Potential Approaches**:
|
||||
- OpenCL/CUDA for parallel DCT operations
|
||||
- Raspberry Pi VideoCore IV/VI GPU compute
|
||||
- WebGPU for browser-based acceleration
|
||||
- **Challenges**:
|
||||
- jpegio library is CPU-bound (C extension)
|
||||
- Would need custom DCT implementation
|
||||
- Memory transfer overhead may negate gains for small images
|
||||
- **Research**:
|
||||
- libjpeg-turbo uses SIMD but not GPU
|
||||
- nvJPEG (NVIDIA) does GPU-accelerated JPEG
|
||||
- Could potentially use GPU for the embedding math, not JPEG decode
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
(Add ideas here)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
(Add ideas here)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a living document - add ideas anytime
|
||||
- Not all ideas will be implemented
|
||||
- Feasibility research needed before committing to roadmap
|
||||
61
build.sh
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Build Script
|
||||
# Usage: ./build.sh [base|fast|full|clean]
|
||||
|
||||
set -e
|
||||
|
||||
case "${1:-fast}" in
|
||||
base)
|
||||
# Build base image with all dependencies (run once, or when deps change)
|
||||
echo "🔨 Building base image (this takes 5-10 minutes)..."
|
||||
docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
echo "✅ Base image built! Future builds will be fast."
|
||||
echo ""
|
||||
echo "Optional: Push to registry for team use:"
|
||||
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
|
||||
echo " docker push yourregistry/stegasoo-base:latest"
|
||||
;;
|
||||
|
||||
fast)
|
||||
# Fast build using pre-built base image
|
||||
if ! docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
|
||||
echo "⚠️ Base image not found. Building it first (one-time)..."
|
||||
$0 base
|
||||
fi
|
||||
echo "🚀 Fast build using base image..."
|
||||
docker-compose build
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
full)
|
||||
# Full rebuild from scratch (slow, but no base image needed)
|
||||
echo "🐢 Full build from scratch (slow)..."
|
||||
docker-compose build --no-cache
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
clean)
|
||||
# Clean up everything
|
||||
echo "🧹 Cleaning up..."
|
||||
docker-compose down --rmi local -v 2>/dev/null || true
|
||||
docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
echo "✅ Cleaned!"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Stegasoo Build Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Full rebuild from scratch (slow, no base needed)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo ""
|
||||
echo "Typical workflow:"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast (or just 'docker-compose build')"
|
||||
echo " 3. Deps change: $0 base (rebuild base image)"
|
||||
;;
|
||||
esac
|
||||
170
check_scipy.py
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnostic script to check for scipy/numpy issues.
|
||||
Run this BEFORE starting the web app.
|
||||
|
||||
Usage:
|
||||
python check_scipy.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
print(f"Python version: {sys.version}")
|
||||
print()
|
||||
|
||||
# Check numpy
|
||||
try:
|
||||
import numpy as np
|
||||
print(f"NumPy version: {np.__version__}")
|
||||
print(f"NumPy config:")
|
||||
np.show_config()
|
||||
except ImportError as e:
|
||||
print(f"NumPy not installed: {e}")
|
||||
except Exception as e:
|
||||
print(f"NumPy error: {e}")
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Check scipy
|
||||
try:
|
||||
import scipy
|
||||
print(f"SciPy version: {scipy.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"SciPy not installed: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Check PIL
|
||||
try:
|
||||
from PIL import Image
|
||||
print(f"Pillow version: {Image.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"Pillow not installed: {e}")
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test scipy DCT directly
|
||||
print("Testing scipy DCT...")
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
import numpy as np
|
||||
|
||||
# Create test array
|
||||
test = np.random.rand(8, 8).astype(np.float64)
|
||||
print(f"Input array shape: {test.shape}, dtype: {test.dtype}")
|
||||
|
||||
# Test 1D DCT
|
||||
row = test[0, :]
|
||||
result = dct(row, norm='ortho')
|
||||
print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}")
|
||||
|
||||
# Test 2D DCT (the potentially problematic operation)
|
||||
result2d = dct(dct(test.T, norm='ortho').T, norm='ortho')
|
||||
print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}")
|
||||
|
||||
# Test inverse
|
||||
recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho')
|
||||
error = np.max(np.abs(test - recovered))
|
||||
print(f"Round-trip error: {error}")
|
||||
|
||||
if error < 1e-10:
|
||||
print("✓ scipy DCT working correctly")
|
||||
else:
|
||||
print("⚠ scipy DCT has precision issues")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ scipy DCT failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test with larger array (more like real image processing)
|
||||
print("Testing with larger arrays (512x512)...")
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
import numpy as np
|
||||
import gc
|
||||
|
||||
# Simulate processing many 8x8 blocks
|
||||
large_array = np.random.rand(512, 512).astype(np.float64)
|
||||
print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes")
|
||||
|
||||
count = 0
|
||||
for y in range(0, 512, 8):
|
||||
for x in range(0, 512, 8):
|
||||
block = large_array[y:y+8, x:x+8].copy()
|
||||
dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho')
|
||||
recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
|
||||
large_array[y:y+8, x:x+8] = recovered
|
||||
count += 1
|
||||
|
||||
print(f"Processed {count} blocks successfully")
|
||||
|
||||
del large_array
|
||||
gc.collect()
|
||||
|
||||
print("✓ Large array processing completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Large array processing failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test PIL with large image
|
||||
print("Testing PIL with large image...")
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a large test image
|
||||
img = Image.new('RGB', (4000, 3000), color=(128, 128, 128))
|
||||
|
||||
# Save to bytes
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
print(f"Test image size: {len(img_bytes)} bytes")
|
||||
|
||||
# Re-open and process
|
||||
buffer2 = io.BytesIO(img_bytes)
|
||||
img2 = Image.open(buffer2)
|
||||
print(f"Re-opened image: {img2.size}, mode: {img2.mode}")
|
||||
|
||||
# Convert to numpy array
|
||||
import numpy as np
|
||||
arr = np.array(img2)
|
||||
print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}")
|
||||
|
||||
# Clean up
|
||||
img.close()
|
||||
img2.close()
|
||||
buffer.close()
|
||||
buffer2.close()
|
||||
del arr
|
||||
gc.collect()
|
||||
|
||||
print("✓ PIL large image test completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ PIL test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("Diagnostics complete")
|
||||
print()
|
||||
print("If no errors above but web app still crashes, try:")
|
||||
print("1. pip install --upgrade scipy numpy pillow")
|
||||
print("2. pip install scipy==1.11.4 numpy==1.26.4 # Known stable versions")
|
||||
print("3. Check if using conda vs pip (mixing can cause issues)")
|
||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 60 KiB 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 |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB 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 |
215
debug_jpegio.py
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script for DCT/jpegio extraction issues.
|
||||
Run from the stegasoo directory.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / 'src'))
|
||||
|
||||
import hashlib
|
||||
import numpy as np
|
||||
|
||||
# Check for jpegio
|
||||
try:
|
||||
import jpegio as jio
|
||||
print("✓ jpegio available")
|
||||
except ImportError:
|
||||
print("✗ jpegio NOT available")
|
||||
sys.exit(1)
|
||||
|
||||
def get_usable_positions(coef_array, min_magnitude=2):
|
||||
"""Get positions of usable coefficients."""
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
# Skip DC coefficients (top-left of each 8x8 block)
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef_array[row, col]) >= min_magnitude:
|
||||
positions.append((row, col))
|
||||
return positions
|
||||
|
||||
def generate_order(num_positions, seed):
|
||||
"""Generate pseudo-random order for coefficient selection."""
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_positions))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
def extract_bits(coef_array, positions, order, num_bits):
|
||||
"""Extract bits from coefficients."""
|
||||
bits = []
|
||||
for i, pos_idx in enumerate(order):
|
||||
if i >= num_bits:
|
||||
break
|
||||
row, col = positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
bits.append(coef & 1)
|
||||
return bits
|
||||
|
||||
def bits_to_bytes(bits):
|
||||
"""Convert list of bits to bytes."""
|
||||
result = []
|
||||
for i in range(0, len(bits), 8):
|
||||
byte_bits = bits[i:i+8]
|
||||
if len(byte_bits) == 8:
|
||||
byte_val = sum(byte_bits[j] << (7-j) for j in range(8))
|
||||
result.append(byte_val)
|
||||
return bytes(result)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python debug_jpegio.py <stego_image.jpg> <reference_photo>")
|
||||
print("\nOptional: add passphrase, pin, key path")
|
||||
print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem")
|
||||
sys.exit(1)
|
||||
|
||||
stego_path = sys.argv[1]
|
||||
ref_path = sys.argv[2]
|
||||
passphrase = sys.argv[3] if len(sys.argv) > 3 else "test"
|
||||
pin = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
key_path = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("JPEGIO DCT EXTRACTION DEBUG")
|
||||
print(f"{'='*60}")
|
||||
print(f"Stego image: {stego_path}")
|
||||
print(f"Reference: {ref_path}")
|
||||
print(f"Passphrase: '{passphrase}'")
|
||||
print(f"PIN: '{pin}'")
|
||||
print(f"Key: {key_path}")
|
||||
|
||||
# Load stego image with jpegio
|
||||
print(f"\n[1] Loading stego image with jpegio...")
|
||||
try:
|
||||
jpeg = jio.read(stego_path)
|
||||
print(f" ✓ jpegio.read() succeeded")
|
||||
print(f" Number of components: {len(jpeg.coef_arrays)}")
|
||||
for i, arr in enumerate(jpeg.coef_arrays):
|
||||
print(f" Component {i}: shape={arr.shape}, dtype={arr.dtype}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get coefficient array (channel 0)
|
||||
coef_array = jpeg.coef_arrays[0]
|
||||
print(f"\n[2] Coefficient array analysis...")
|
||||
print(f" Shape: {coef_array.shape}")
|
||||
print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}")
|
||||
print(f" Min value: {coef_array.min()}")
|
||||
print(f" Max value: {coef_array.max()}")
|
||||
|
||||
# Get usable positions
|
||||
print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...")
|
||||
positions = get_usable_positions(coef_array)
|
||||
print(f" Usable positions: {len(positions)}")
|
||||
print(f" Capacity: ~{len(positions) // 8} bytes")
|
||||
|
||||
# Generate seed (this needs to match the encode seed!)
|
||||
print(f"\n[4] Generating seed...")
|
||||
|
||||
# Load reference photo
|
||||
ref_data = Path(ref_path).read_bytes()
|
||||
ref_hash = hashlib.sha256(ref_data).digest()
|
||||
print(f" Reference hash: {ref_hash[:8].hex()}...")
|
||||
|
||||
# Load RSA key if provided
|
||||
rsa_component = b""
|
||||
if key_path:
|
||||
try:
|
||||
from stegasoo import load_rsa_key
|
||||
key_data = Path(key_path).read_bytes()
|
||||
# Try without password first
|
||||
try:
|
||||
rsa_key = load_rsa_key(key_data, password=None)
|
||||
except:
|
||||
rsa_key = load_rsa_key(key_data, password="testpass")
|
||||
|
||||
# Get public key bytes for seed
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
pub_bytes = rsa_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
rsa_component = hashlib.sha256(pub_bytes).digest()
|
||||
print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...")
|
||||
except Exception as e:
|
||||
print(f" ✗ Could not load RSA key: {e}")
|
||||
|
||||
# Build seed like stegasoo does
|
||||
# This is the critical part - must match encoding!
|
||||
seed_parts = [
|
||||
ref_hash,
|
||||
passphrase.encode('utf-8'),
|
||||
pin.encode('utf-8') if pin else b"",
|
||||
rsa_component,
|
||||
]
|
||||
seed = hashlib.sha256(b"".join(seed_parts)).digest()
|
||||
print(f" Combined seed: {seed[:8].hex()}...")
|
||||
|
||||
# Generate order
|
||||
print(f"\n[5] Generating coefficient order...")
|
||||
order = generate_order(len(positions), seed)
|
||||
print(f" First 10 indices: {order[:10]}")
|
||||
|
||||
# Try to extract header
|
||||
print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...")
|
||||
HEADER_SIZE = 10
|
||||
header_bits = extract_bits(coef_array, positions, order, HEADER_SIZE * 8)
|
||||
header_bytes = bits_to_bytes(header_bits)
|
||||
print(f" Raw header bytes: {header_bytes.hex()}")
|
||||
print(f" As ASCII (if printable): {repr(header_bytes)}")
|
||||
|
||||
# Check for JPGS magic
|
||||
JPEGIO_MAGIC = b'JPGS'
|
||||
if header_bytes[:4] == JPEGIO_MAGIC:
|
||||
print(f" ✓ Found JPEGIO magic bytes!")
|
||||
version = header_bytes[4]
|
||||
flags = header_bytes[5]
|
||||
data_length = struct.unpack('>I', header_bytes[6:10])[0]
|
||||
print(f" Version: {version}")
|
||||
print(f" Flags: {flags}")
|
||||
print(f" Data length: {data_length} bytes")
|
||||
|
||||
if data_length > 0 and data_length < len(positions) // 8:
|
||||
print(f"\n[7] Extracting payload ({data_length} bytes)...")
|
||||
total_bits = (HEADER_SIZE + data_length) * 8
|
||||
all_bits = extract_bits(coef_array, positions, order, total_bits)
|
||||
data_bits = all_bits[HEADER_SIZE * 8:]
|
||||
payload = bits_to_bytes(data_bits)
|
||||
print(f" Payload (first 64 bytes): {payload[:64].hex()}")
|
||||
print(f" This should be encrypted data starting with salt/IV")
|
||||
else:
|
||||
print(f" ✗ Invalid data length: {data_length}")
|
||||
else:
|
||||
print(f" ✗ No JPEGIO magic found")
|
||||
print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')")
|
||||
print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')")
|
||||
|
||||
# Try alternate interpretations
|
||||
print(f"\n[7] Trying alternate header interpretations...")
|
||||
|
||||
# Maybe it's scipy DCT format?
|
||||
DCT_MAGIC = b'DCTS'
|
||||
if header_bytes[:4] == DCT_MAGIC:
|
||||
print(f" Found SCIPY DCT magic - wrong extraction method!")
|
||||
else:
|
||||
# Show bit distribution
|
||||
print(f" First 32 extracted bits: {header_bits[:32]}")
|
||||
|
||||
# Check if bits look random or patterned
|
||||
ones = sum(header_bits[:80])
|
||||
print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("DEBUG COMPLETE")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
# Shared environment variables
|
||||
x-common-env: &common-env
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
@@ -18,13 +16,21 @@ services:
|
||||
environment:
|
||||
<<: *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: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# ============================================================================
|
||||
# REST API (FastAPI)
|
||||
@@ -42,6 +48,13 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# 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()
|
||||
@@ -1,500 +0,0 @@
|
||||
# API Update Summary for v3.2.0
|
||||
|
||||
## Overview
|
||||
|
||||
The FastAPI REST API has been updated to align with Stegasoo v3.2.0's breaking changes:
|
||||
1. **Removed date dependency** - No `date_str` field in requests
|
||||
2. **Renamed day_phrase → passphrase** - Updated all request/response models
|
||||
3. **Updated generation** - Now generates single passphrase instead of daily phrases
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### Request Model Changes
|
||||
|
||||
#### 1. EncodeRequest & EncodeFileRequest
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class EncodeRequest(BaseModel):
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_image_base64: str
|
||||
day_phrase: str # ← Changed to passphrase
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
date_str: Optional[str] = None # ← REMOVED
|
||||
embed_mode: EmbedModeType = "lsb"
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class EncodeRequest(BaseModel):
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_image_base64: str
|
||||
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode: EmbedModeType = "lsb"
|
||||
dct_output_format: DctOutputFormatType = "png"
|
||||
dct_color_mode: DctColorModeType = "grayscale"
|
||||
```
|
||||
|
||||
#### 2. DecodeRequest
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class DecodeRequest(BaseModel):
|
||||
stego_image_base64: str
|
||||
reference_photo_base64: str
|
||||
day_phrase: str # ← Changed to passphrase
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
embed_mode: ExtractModeType = "auto"
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class DecodeRequest(BaseModel):
|
||||
stego_image_base64: str
|
||||
reference_photo_base64: str
|
||||
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
embed_mode: ExtractModeType = "auto"
|
||||
```
|
||||
|
||||
#### 3. GenerateRequest
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class GenerateRequest(BaseModel):
|
||||
use_pin: bool = True
|
||||
use_rsa: bool = False
|
||||
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
|
||||
rsa_bits: int = Field(default=2048)
|
||||
words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS)
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class GenerateRequest(BaseModel):
|
||||
use_pin: bool = True
|
||||
use_rsa: bool = False
|
||||
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
|
||||
rsa_bits: int = Field(default=2048)
|
||||
words_per_passphrase: int = Field(
|
||||
default=DEFAULT_PASSPHRASE_WORDS, # = 4, was 3
|
||||
ge=MIN_PASSPHRASE_WORDS,
|
||||
le=MAX_PASSPHRASE_WORDS,
|
||||
description="Words per passphrase (v3.2.0: default increased to 4)"
|
||||
)
|
||||
```
|
||||
|
||||
### Response Model Changes
|
||||
|
||||
#### 1. GenerateResponse
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class GenerateResponse(BaseModel):
|
||||
phrases: dict[str, str] # Monday -> phrase, Tuesday -> phrase, etc.
|
||||
pin: Optional[str] = None
|
||||
rsa_key_pem: Optional[str] = None
|
||||
entropy: dict[str, int]
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class GenerateResponse(BaseModel):
|
||||
passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
|
||||
pin: Optional[str] = None
|
||||
rsa_key_pem: Optional[str] = None
|
||||
entropy: dict[str, int]
|
||||
# Legacy field for compatibility
|
||||
phrases: Optional[dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="Deprecated: Use 'passphrase' instead"
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. EncodeResponse
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class EncodeResponse(BaseModel):
|
||||
stego_image_base64: str
|
||||
filename: str
|
||||
capacity_used_percent: float
|
||||
date_used: str
|
||||
day_of_week: str
|
||||
embed_mode: str
|
||||
output_format: str = "png"
|
||||
color_mode: str = "color"
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class EncodeResponse(BaseModel):
|
||||
stego_image_base64: str
|
||||
filename: str
|
||||
capacity_used_percent: float
|
||||
embed_mode: str
|
||||
output_format: str = "png"
|
||||
color_mode: str = "color"
|
||||
# Legacy fields (no longer used in crypto)
|
||||
date_used: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Deprecated: Date no longer used in v3.2.0"
|
||||
)
|
||||
day_of_week: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Deprecated: Date no longer used in v3.2.0"
|
||||
)
|
||||
```
|
||||
|
||||
### Endpoint Changes
|
||||
|
||||
#### 1. POST /encode
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```json
|
||||
{
|
||||
"message": "Secret message",
|
||||
"reference_photo_base64": "...",
|
||||
"carrier_image_base64": "...",
|
||||
"day_phrase": "apple forest thunder",
|
||||
"date_str": "2025-01-15",
|
||||
"pin": "123456",
|
||||
"embed_mode": "lsb"
|
||||
}
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```json
|
||||
{
|
||||
"message": "Secret message",
|
||||
"reference_photo_base64": "...",
|
||||
"carrier_image_base64": "...",
|
||||
"passphrase": "apple forest thunder mountain",
|
||||
"pin": "123456",
|
||||
"embed_mode": "lsb"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. POST /decode
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```json
|
||||
{
|
||||
"stego_image_base64": "...",
|
||||
"reference_photo_base64": "...",
|
||||
"day_phrase": "apple forest thunder",
|
||||
"pin": "123456",
|
||||
"embed_mode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```json
|
||||
{
|
||||
"stego_image_base64": "...",
|
||||
"reference_photo_base64": "...",
|
||||
"passphrase": "apple forest thunder mountain",
|
||||
"pin": "123456",
|
||||
"embed_mode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. POST /generate
|
||||
|
||||
**Response Before (v3.1.0):**
|
||||
```json
|
||||
{
|
||||
"phrases": {
|
||||
"Monday": "apple forest thunder",
|
||||
"Tuesday": "banana river lightning",
|
||||
...
|
||||
},
|
||||
"pin": "123456",
|
||||
"rsa_key_pem": null,
|
||||
"entropy": {
|
||||
"phrase": 33,
|
||||
"pin": 20,
|
||||
"rsa": 0,
|
||||
"total": 53
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response After (v3.2.0):**
|
||||
```json
|
||||
{
|
||||
"passphrase": "apple forest thunder mountain",
|
||||
"pin": "123456",
|
||||
"rsa_key_pem": null,
|
||||
"entropy": {
|
||||
"passphrase": 44,
|
||||
"pin": 20,
|
||||
"rsa": 0,
|
||||
"total": 64
|
||||
},
|
||||
"phrases": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. POST /encode/multipart
|
||||
|
||||
**Form Fields Before (v3.1.0):**
|
||||
- `day_phrase` (required)
|
||||
- `date_str` (optional)
|
||||
- `reference_photo` (file)
|
||||
- `carrier` (file)
|
||||
- ...
|
||||
|
||||
**Form Fields After (v3.2.0):**
|
||||
- `passphrase` (required) ← renamed from day_phrase
|
||||
- `reference_photo` (file)
|
||||
- `carrier` (file)
|
||||
- ... (date_str removed)
|
||||
|
||||
**Response Headers Before (v3.1.0):**
|
||||
```
|
||||
X-Stegasoo-Date: 2025-01-15
|
||||
X-Stegasoo-Day: Wednesday
|
||||
X-Stegasoo-Capacity-Percent: 25.5
|
||||
X-Stegasoo-Embed-Mode: lsb
|
||||
```
|
||||
|
||||
**Response Headers After (v3.2.0):**
|
||||
```
|
||||
X-Stegasoo-Capacity-Percent: 25.5
|
||||
X-Stegasoo-Embed-Mode: lsb
|
||||
X-Stegasoo-Output-Format: png
|
||||
X-Stegasoo-Color-Mode: color
|
||||
X-Stegasoo-Version: 3.2.0
|
||||
```
|
||||
|
||||
### New Status Endpoint Information
|
||||
|
||||
#### GET /
|
||||
|
||||
**Added to response:**
|
||||
```json
|
||||
{
|
||||
"version": "3.2.0",
|
||||
...
|
||||
"breaking_changes": {
|
||||
"date_removed": "No date_str parameter needed - encode/decode anytime",
|
||||
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
|
||||
"format_version": 4,
|
||||
"backward_compatible": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Guide for API Clients
|
||||
|
||||
### 1. Update Request Bodies
|
||||
|
||||
**Find and replace in client code:**
|
||||
```javascript
|
||||
// Before
|
||||
{
|
||||
day_phrase: "apple forest thunder",
|
||||
date_str: "2025-01-15"
|
||||
}
|
||||
|
||||
// After
|
||||
{
|
||||
passphrase: "apple forest thunder mountain"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Response Handling
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
const response = await fetch('/encode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: "secret",
|
||||
day_phrase: "words",
|
||||
date_str: "2025-01-15",
|
||||
...
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data.date_used); // "2025-01-15"
|
||||
console.log(data.day_of_week); // "Wednesday"
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const response = await fetch('/encode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: "secret",
|
||||
passphrase: "longer words here now",
|
||||
// date_str removed
|
||||
...
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// date_used and day_of_week are null in v3.2.0
|
||||
```
|
||||
|
||||
### 3. Update Generate Endpoint Usage
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
const creds = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ use_pin: true })
|
||||
}).then(r => r.json());
|
||||
|
||||
// Use Monday's phrase
|
||||
const mondayPhrase = creds.phrases['Monday'];
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const creds = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ use_pin: true })
|
||||
}).then(r => r.json());
|
||||
|
||||
// Use single passphrase
|
||||
const passphrase = creds.passphrase;
|
||||
```
|
||||
|
||||
### 4. Update Multipart Requests
|
||||
|
||||
**Before (JavaScript fetch):**
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('day_phrase', 'apple forest thunder');
|
||||
formData.append('date_str', '2025-01-15');
|
||||
formData.append('reference_photo', refPhotoFile);
|
||||
formData.append('carrier', carrierFile);
|
||||
formData.append('message', 'secret');
|
||||
formData.append('pin', '123456');
|
||||
|
||||
const response = await fetch('/encode/multipart', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
**After (JavaScript fetch):**
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('passphrase', 'apple forest thunder mountain');
|
||||
// date_str removed
|
||||
formData.append('reference_photo', refPhotoFile);
|
||||
formData.append('carrier', carrierFile);
|
||||
formData.append('message', 'secret');
|
||||
formData.append('pin', '123456');
|
||||
|
||||
const response = await fetch('/encode/multipart', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Endpoints to Test
|
||||
|
||||
- [ ] GET / - Returns v3.2.0 with breaking_changes info
|
||||
- [ ] GET /modes - Returns mode information
|
||||
- [ ] POST /generate - Returns single passphrase
|
||||
- [ ] POST /encode - Works without date_str
|
||||
- [ ] POST /encode/file - Works without date_str
|
||||
- [ ] POST /decode - Works without date_str
|
||||
- [ ] POST /encode/multipart - Accepts passphrase instead of day_phrase
|
||||
- [ ] POST /decode/multipart - Accepts passphrase instead of day_phrase
|
||||
- [ ] POST /compare - Still works
|
||||
- [ ] POST /will-fit - Still works
|
||||
- [ ] POST /image/info - Still works
|
||||
- [ ] POST /extract-key-from-qr - Still works
|
||||
|
||||
### Validation Tests
|
||||
|
||||
- [ ] Reject requests with `day_phrase` field (should get validation error)
|
||||
- [ ] Reject requests with `date_str` field (should be ignored or error)
|
||||
- [ ] Accept requests with `passphrase` field
|
||||
- [ ] Generate response includes `passphrase` field
|
||||
- [ ] Generate response has `phrases` as null
|
||||
- [ ] Encode response has `date_used` and `day_of_week` as null
|
||||
- [ ] Multipart encode works with new field names
|
||||
- [ ] Response headers updated correctly
|
||||
|
||||
## OpenAPI/Swagger Documentation
|
||||
|
||||
The FastAPI auto-generated documentation (/docs and /redoc) will automatically reflect the changes:
|
||||
|
||||
1. **Models updated** - Request/response schemas show new field names
|
||||
2. **Descriptions updated** - Field descriptions mention v3.2.0 changes
|
||||
3. **Examples updated** - Interactive API explorer uses new field names
|
||||
|
||||
Users can browse to `/docs` to see the updated API specification.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
**Breaking Change:** API v3.2.0 is NOT backward compatible with v3.1.0
|
||||
|
||||
Clients using the old API will encounter:
|
||||
1. **Validation errors** - Missing required `passphrase` field
|
||||
2. **Unexpected responses** - `phrases` field will be null
|
||||
3. **Changed behavior** - Date fields no longer populated
|
||||
|
||||
### Migration Timeline Recommendation
|
||||
|
||||
1. **Deploy v3.2.0 API** to staging
|
||||
2. **Update client applications** to use new field names
|
||||
3. **Test thoroughly** with staging API
|
||||
4. **Deploy v3.2.0 API** to production
|
||||
5. **Notify users** of breaking changes
|
||||
|
||||
Alternatively, run v3.1.0 and v3.2.0 APIs side-by-side on different paths:
|
||||
- `/api/v3.1/` - Old API
|
||||
- `/api/v3.2/` - New API
|
||||
|
||||
## Constants Updates
|
||||
|
||||
Used in validation:
|
||||
```python
|
||||
from stegasoo.constants import (
|
||||
MIN_PASSPHRASE_WORDS, # = 3
|
||||
MAX_PASSPHRASE_WORDS, # = 12
|
||||
DEFAULT_PASSPHRASE_WORDS, # = 4 (increased from 3)
|
||||
)
|
||||
```
|
||||
|
||||
## Error Messages
|
||||
|
||||
All error messages updated:
|
||||
- "day_phrase is required" → "passphrase is required"
|
||||
- References to "phrase" now mean "passphrase"
|
||||
|
||||
## Implementation Status
|
||||
|
||||
✅ All request models updated
|
||||
✅ All response models updated
|
||||
✅ All endpoints updated
|
||||
✅ Multipart endpoints updated
|
||||
✅ Status endpoint shows breaking changes
|
||||
✅ Constants imported correctly
|
||||
✅ Error handling updated
|
||||
✅ No references to day_phrase in user-facing text
|
||||
✅ No date_str parameters accepted
|
||||
|
||||
Ready for deployment!
|
||||
@@ -49,7 +49,6 @@ from stegasoo import (
|
||||
generate_credentials,
|
||||
get_channel_status,
|
||||
has_argon2,
|
||||
has_channel_key,
|
||||
has_dct_support,
|
||||
set_channel_key,
|
||||
validate_channel_key,
|
||||
@@ -406,11 +405,7 @@ def _resolve_channel_key(channel_key: str | None) -> str | None:
|
||||
"""
|
||||
Resolve channel key from API parameter.
|
||||
|
||||
Args:
|
||||
channel_key: API parameter value
|
||||
- None: Use server-configured key (auto mode)
|
||||
- "": Public mode (no channel key)
|
||||
- "XXXX-...": Explicit key
|
||||
Wrapper around library's resolve_channel_key with HTTP exception handling.
|
||||
|
||||
Returns:
|
||||
Resolved channel key to pass to encode/decode
|
||||
@@ -418,44 +413,27 @@ def _resolve_channel_key(channel_key: str | None) -> str | None:
|
||||
Raises:
|
||||
HTTPException: If key format is invalid
|
||||
"""
|
||||
if channel_key is None:
|
||||
# Auto mode - use server config
|
||||
return None
|
||||
from stegasoo.channel import resolve_channel_key
|
||||
|
||||
if channel_key == "":
|
||||
# Public mode
|
||||
return ""
|
||||
|
||||
# Explicit key - validate format
|
||||
if not validate_channel_key(channel_key):
|
||||
raise HTTPException(
|
||||
400, "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
)
|
||||
|
||||
return channel_key
|
||||
try:
|
||||
return resolve_channel_key(channel_key)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
|
||||
"""
|
||||
Get channel mode and fingerprint for response.
|
||||
|
||||
Uses library's get_channel_response_info for consistent formatting.
|
||||
|
||||
Returns:
|
||||
(mode, fingerprint) tuple
|
||||
"""
|
||||
if channel_key == "":
|
||||
return "public", None
|
||||
from stegasoo.channel import get_channel_response_info
|
||||
|
||||
if channel_key is not None:
|
||||
# Explicit key
|
||||
fingerprint = f"{channel_key[:4]}-••••-••••-••••-••••-••••-••••-{channel_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
|
||||
info = get_channel_response_info(channel_key)
|
||||
return info["mode"], info.get("fingerprint")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -24,11 +24,31 @@ Usage:
|
||||
stegasoo channel [SUBCOMMAND]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Rich progress bar (optional)
|
||||
try:
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
)
|
||||
|
||||
HAS_RICH = True
|
||||
except ImportError:
|
||||
HAS_RICH = False
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
@@ -168,37 +188,25 @@ def resolve_channel_key_option(
|
||||
"""
|
||||
Resolve channel key from CLI options.
|
||||
|
||||
Wrapper around library's resolve_channel_key with Click exception handling.
|
||||
|
||||
Returns:
|
||||
None: Use server-configured key (auto mode)
|
||||
"": Public mode (no channel key)
|
||||
str: Explicit channel key
|
||||
"""
|
||||
if no_channel:
|
||||
return "" # Public mode
|
||||
from stegasoo.channel import resolve_channel_key
|
||||
|
||||
if channel_file:
|
||||
# Load from file
|
||||
path = Path(channel_file)
|
||||
if not path.exists():
|
||||
raise click.ClickException(f"Channel key file not found: {channel_file}")
|
||||
key = path.read_text().strip()
|
||||
if not validate_channel_key(key):
|
||||
raise click.ClickException(f"Invalid channel key format in file: {channel_file}")
|
||||
return key
|
||||
|
||||
if channel:
|
||||
if channel.lower() == "auto":
|
||||
return None # Use server config
|
||||
# Explicit key provided
|
||||
if not validate_channel_key(channel):
|
||||
raise click.ClickException(
|
||||
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
|
||||
"Generate a new key with: stegasoo channel generate"
|
||||
)
|
||||
return channel
|
||||
|
||||
# Default: use server-configured key (auto mode)
|
||||
return None
|
||||
try:
|
||||
return resolve_channel_key(
|
||||
value=channel,
|
||||
file_path=channel_file,
|
||||
no_channel=no_channel,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise click.ClickException(str(e))
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
@@ -610,6 +618,73 @@ def channel_clear(project, clear_all, force):
|
||||
click.echo(" Mode is now: PUBLIC")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROGRESS BAR UTILITIES (v4.1.2)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _generate_progress_job_id() -> str:
|
||||
"""Generate a unique job ID for progress tracking."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def _get_progress_file_path(job_id: str) -> str:
|
||||
"""Get the progress file path for a job ID."""
|
||||
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||
|
||||
|
||||
def _read_progress(job_id: str) -> dict | None:
|
||||
"""Read progress from file for a job ID."""
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
try:
|
||||
with open(progress_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_progress_file(job_id: str) -> None:
|
||||
"""Remove progress file for a completed job."""
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
try:
|
||||
Path(progress_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_encode_with_progress(encode_func, encode_kwargs: dict, progress_file: str) -> tuple:
|
||||
"""
|
||||
Run encode in a thread and return result.
|
||||
|
||||
Returns:
|
||||
(success, result_or_error)
|
||||
"""
|
||||
result_holder = {"result": None, "error": None}
|
||||
|
||||
def run():
|
||||
try:
|
||||
result_holder["result"] = encode_func(**encode_kwargs, progress_file=progress_file)
|
||||
except Exception as e:
|
||||
result_holder["error"] = e
|
||||
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
return thread, result_holder
|
||||
|
||||
|
||||
def _format_phase(phase: str) -> str:
|
||||
"""Format phase name for display."""
|
||||
phases = {
|
||||
"starting": "Starting",
|
||||
"initializing": "Initializing",
|
||||
"embedding": "Embedding",
|
||||
"saving": "Saving",
|
||||
"finalizing": "Finalizing",
|
||||
"complete": "Complete",
|
||||
}
|
||||
return phases.get(phase, phase.capitalize())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE COMMAND
|
||||
# ============================================================================
|
||||
@@ -654,6 +729,7 @@ def channel_clear(project, clear_all, force):
|
||||
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
||||
)
|
||||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
||||
@click.option("--progress", is_flag=True, help="Show progress bar (requires rich)")
|
||||
def encode_cmd(
|
||||
ref,
|
||||
carrier,
|
||||
@@ -673,6 +749,7 @@ def encode_cmd(
|
||||
dct_output_format,
|
||||
dct_color_mode,
|
||||
quiet,
|
||||
progress,
|
||||
):
|
||||
"""
|
||||
Encode a secret message or file into an image.
|
||||
@@ -820,19 +897,63 @@ def encode_cmd(
|
||||
click.echo(channel_status)
|
||||
|
||||
# v4.0.0: Include channel_key parameter
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_key_password,
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format,
|
||||
dct_color_mode=dct_color_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
# v4.1.2: Progress bar support
|
||||
encode_kwargs = {
|
||||
"message": payload,
|
||||
"reference_photo": ref_photo,
|
||||
"carrier_image": carrier_image,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin or "",
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"rsa_password": effective_key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"dct_output_format": dct_output_format,
|
||||
"dct_color_mode": dct_color_mode,
|
||||
"channel_key": resolved_channel_key,
|
||||
}
|
||||
|
||||
if progress and HAS_RICH:
|
||||
# Run with progress bar
|
||||
job_id = _generate_progress_job_id()
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
|
||||
thread, result_holder = _run_encode_with_progress(encode, encode_kwargs, progress_file)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeElapsedColumn(),
|
||||
transient=True,
|
||||
) as progress_bar:
|
||||
task = progress_bar.add_task("Encoding...", total=100)
|
||||
|
||||
while thread.is_alive():
|
||||
prog = _read_progress(job_id)
|
||||
if prog:
|
||||
percent = prog.get("percent", 0)
|
||||
phase = _format_phase(prog.get("phase", "processing"))
|
||||
progress_bar.update(task, completed=percent, description=f"{phase}...")
|
||||
time.sleep(0.1)
|
||||
|
||||
# Final update
|
||||
progress_bar.update(task, completed=100, description="Complete!")
|
||||
|
||||
_cleanup_progress_file(job_id)
|
||||
|
||||
if result_holder["error"]:
|
||||
raise result_holder["error"]
|
||||
result = result_holder["result"]
|
||||
|
||||
elif progress and not HAS_RICH:
|
||||
click.secho(
|
||||
"Warning: --progress requires 'rich' package. Install with: pip install rich",
|
||||
fg="yellow",
|
||||
)
|
||||
result = encode(**encode_kwargs)
|
||||
else:
|
||||
result = encode(**encode_kwargs)
|
||||
|
||||
# Determine output path
|
||||
if output:
|
||||
|
||||
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
|
||||
@@ -1,426 +0,0 @@
|
||||
# Web Frontend Update Summary for v3.2.0
|
||||
|
||||
## Overview
|
||||
|
||||
The Flask web frontend has been updated to align with Stegasoo v3.2.0's breaking changes:
|
||||
1. **Removed date dependency** - No date selection or tracking in UI
|
||||
2. **Renamed day_phrase → passphrase** - Updated all forms and templates
|
||||
3. **Increased default words** - From 3 to 4 for better security
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Form Parameter Changes
|
||||
|
||||
#### Generate Page
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
words_per_phrase = int(request.form.get('words_per_phrase', 3))
|
||||
# Generated daily phrases for all days of the week
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
words_per_passphrase = int(request.form.get('words_per_passphrase', 4))
|
||||
# Generates single passphrase
|
||||
```
|
||||
|
||||
**Template variables changed:**
|
||||
- `phrases` → `passphrase` (single string instead of dict)
|
||||
- `words_per_phrase` → `words_per_passphrase`
|
||||
- `phrase_entropy` → `passphrase_entropy`
|
||||
- Removed `days` variable (no longer needed)
|
||||
|
||||
#### Encode Page
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
day_phrase = request.form.get('day_phrase', '')
|
||||
client_date = request.form.get('client_date', '').strip()
|
||||
day_of_week = get_today_day() # Used in template
|
||||
|
||||
encode_result = encode(
|
||||
...,
|
||||
day_phrase=day_phrase,
|
||||
date_str=date_str,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
passphrase = request.form.get('passphrase', '')
|
||||
# No client_date or day_of_week needed
|
||||
|
||||
encode_result = encode(
|
||||
...,
|
||||
passphrase=passphrase, # Renamed
|
||||
# date_str removed
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
#### Decode Page
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
day_phrase = request.form.get('day_phrase', '')
|
||||
stego_date = request.form.get('stego_date', '').strip()
|
||||
|
||||
decode_result = decode(
|
||||
...,
|
||||
day_phrase=day_phrase,
|
||||
date_str=stego_date if stego_date else None,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
passphrase = request.form.get('passphrase', '')
|
||||
# No stego_date needed
|
||||
|
||||
decode_result = decode(
|
||||
...,
|
||||
passphrase=passphrase, # Renamed
|
||||
# date_str removed
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Template Context Updates
|
||||
|
||||
**inject_globals() changes:**
|
||||
|
||||
**Added:**
|
||||
```python
|
||||
'min_passphrase_words': MIN_PASSPHRASE_WORDS,
|
||||
'recommended_passphrase_words': RECOMMENDED_PASSPHRASE_WORDS,
|
||||
'default_passphrase_words': DEFAULT_PASSPHRASE_WORDS,
|
||||
```
|
||||
|
||||
**Used for:**
|
||||
- Showing passphrase length requirements
|
||||
- Default values in generate form
|
||||
- Validation messages
|
||||
|
||||
### 3. Validation Updates
|
||||
|
||||
**Added passphrase validation:**
|
||||
```python
|
||||
from stegasoo import validate_passphrase
|
||||
|
||||
# In encode_page()
|
||||
result = validate_passphrase(passphrase)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, 'error')
|
||||
return ...
|
||||
|
||||
# Show warning if passphrase is short
|
||||
if result.warning:
|
||||
flash(result.warning, 'warning')
|
||||
```
|
||||
|
||||
### 4. Error Message Updates
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
flash('Day phrase is required', 'error')
|
||||
flash('Decryption failed. Check your phrase, PIN...', 'error')
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
flash('Passphrase is required', 'error')
|
||||
flash('Decryption failed. Check your passphrase, PIN...', 'error')
|
||||
```
|
||||
|
||||
## Template Changes Needed
|
||||
|
||||
These Flask routes will need corresponding template updates:
|
||||
|
||||
### generate.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<label for="words_per_phrase">Words per phrase</label>
|
||||
<input type="number" name="words_per_phrase" value="3">
|
||||
|
||||
{% if generated %}
|
||||
<h3>Daily Phrases</h3>
|
||||
{% for day in days %}
|
||||
<tr>
|
||||
<td>{{ day }}</td>
|
||||
<td>{{ phrases[day] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- After -->
|
||||
<label for="words_per_passphrase">Words per passphrase</label>
|
||||
<input type="number" name="words_per_passphrase" value="{{ default_passphrase_words }}">
|
||||
|
||||
{% if generated %}
|
||||
<h3>Passphrase</h3>
|
||||
<div class="passphrase-display">
|
||||
<code>{{ passphrase }}</code>
|
||||
<p class="help-text">Use this passphrase to encode and decode messages (no date needed!)</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Entropy display:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<li>Phrase entropy: {{ phrase_entropy }} bits</li>
|
||||
|
||||
<!-- After -->
|
||||
<li>Passphrase entropy: {{ passphrase_entropy }} bits ({{ words_per_passphrase }} words)</li>
|
||||
```
|
||||
|
||||
### encode.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<label for="day_phrase">Day Phrase</label>
|
||||
<input type="text" name="day_phrase" required>
|
||||
|
||||
<label for="client_date">Encoding Date (Optional)</label>
|
||||
<input type="date" name="client_date">
|
||||
<p class="help-text">Defaults to today: {{ day_of_week }}</p>
|
||||
|
||||
<!-- After -->
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<input type="text" name="passphrase" required
|
||||
placeholder="Enter at least {{ recommended_passphrase_words }} words">
|
||||
<p class="help-text">
|
||||
v3.2.0: No date needed! Use your passphrase anytime.
|
||||
</p>
|
||||
```
|
||||
|
||||
### decode.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<label for="day_phrase">Day Phrase</label>
|
||||
<input type="text" name="day_phrase" required>
|
||||
|
||||
<label for="stego_date">Encoding Date</label>
|
||||
<input type="date" name="stego_date" id="stego_date">
|
||||
<p class="help-text">Will be auto-detected from filename if possible</p>
|
||||
|
||||
<script>
|
||||
// Auto-detect date from filename
|
||||
stegoInput.addEventListener('change', function() {
|
||||
const filename = this.files[0]?.name || '';
|
||||
const dateMatch = filename.match(/_(\d{4})(\d{2})(\d{2})/);
|
||||
if (dateMatch) {
|
||||
document.getElementById('stego_date').value =
|
||||
`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- After -->
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<input type="text" name="passphrase" required
|
||||
placeholder="Enter your passphrase">
|
||||
<p class="help-text">
|
||||
v3.2.0: No date needed to decode!
|
||||
</p>
|
||||
|
||||
<!-- Remove date detection script -->
|
||||
```
|
||||
|
||||
### index.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<p>Generate daily passphrases and security credentials</p>
|
||||
<p>Hide messages using day-specific phrases</p>
|
||||
|
||||
<!-- After -->
|
||||
<p>Generate passphrases and security credentials</p>
|
||||
<p>v3.2.0: Simplified - no more daily rotation!</p>
|
||||
```
|
||||
|
||||
### about.html
|
||||
|
||||
**Add v3.2.0 section:**
|
||||
```html
|
||||
<h2>Version 3.2.0 Changes</h2>
|
||||
<ul>
|
||||
<li><strong>No date dependency</strong> - Encode and decode anytime without tracking dates</li>
|
||||
<li><strong>Single passphrase</strong> - No more daily rotation, just remember one strong passphrase</li>
|
||||
<li><strong>Better security</strong> - Default passphrase length increased to 4 words</li>
|
||||
<li><strong>Asynchronous ready</strong> - Perfect for dead drops and delayed delivery</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## JavaScript Changes Needed
|
||||
|
||||
### Remove date-related code:
|
||||
|
||||
```javascript
|
||||
// REMOVE THIS (date detection from filename)
|
||||
function detectDateFromFilename(filename) {
|
||||
const match = filename.match(/_(\d{4})(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// REMOVE THIS (day-of-week display)
|
||||
function updateDayOfWeek() {
|
||||
const dateInput = document.getElementById('client_date');
|
||||
const dayDisplay = document.getElementById('day_display');
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Update validation:
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
const dayPhrase = document.getElementById('day_phrase').value;
|
||||
if (!dayPhrase || dayPhrase.trim().length === 0) {
|
||||
alert('Day phrase is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
// After
|
||||
const passphrase = document.getElementById('passphrase').value;
|
||||
if (!passphrase || passphrase.trim().length === 0) {
|
||||
alert('Passphrase is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add word count validation
|
||||
const words = passphrase.trim().split(/\s+/);
|
||||
if (words.length < {{ min_passphrase_words }}) {
|
||||
alert(`Passphrase should have at least {{ recommended_passphrase_words }} words`);
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Updates
|
||||
|
||||
Add styling for passphrase warnings:
|
||||
|
||||
```css
|
||||
.passphrase-display {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.passphrase-display code {
|
||||
font-size: 1.2em;
|
||||
color: #2c3e50;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.help-text.v3-2-0 {
|
||||
color: #3498db;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.flash.warning {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Notes for Users
|
||||
|
||||
Add to templates:
|
||||
|
||||
```html
|
||||
<div class="alert alert-info">
|
||||
<h4>⚠️ v3.2.0 Breaking Changes</h4>
|
||||
<p>If you have messages encoded with v3.1.0:</p>
|
||||
<ul>
|
||||
<li>They cannot be decoded with v3.2.0</li>
|
||||
<li>You need the original v3.1.0 installation to decode them</li>
|
||||
<li>After decoding, you can re-encode with v3.2.0</li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Form Field Summary
|
||||
|
||||
### Changed Field Names
|
||||
|
||||
| Old Name (v3.1.0) | New Name (v3.2.0) | Type |
|
||||
|-------------------|-------------------|------|
|
||||
| `day_phrase` | `passphrase` | text input |
|
||||
| `words_per_phrase` | `words_per_passphrase` | number input |
|
||||
| `client_date` | (removed) | date input |
|
||||
| `stego_date` | (removed) | date input |
|
||||
|
||||
### New Validation Attributes
|
||||
|
||||
```html
|
||||
<input type="text" name="passphrase"
|
||||
required
|
||||
minlength="{{ min_passphrase_words * 4 }}"
|
||||
placeholder="Enter at least {{ recommended_passphrase_words }} words"
|
||||
pattern="^\s*\S+(\s+\S+){3,}.*$"
|
||||
title="Please enter at least 4 words">
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Generate page creates single passphrase
|
||||
- [ ] Generate page shows correct entropy (4 words = 44 bits)
|
||||
- [ ] Generate page doesn't show day names
|
||||
- [ ] Encode page accepts passphrase (not day_phrase)
|
||||
- [ ] Encode page doesn't have date selection
|
||||
- [ ] Encode page shows v3.2.0 help text
|
||||
- [ ] Decode page accepts passphrase
|
||||
- [ ] Decode page doesn't have date input
|
||||
- [ ] Decode page doesn't auto-detect date from filename
|
||||
- [ ] Error messages say "passphrase" not "day phrase"
|
||||
- [ ] Validation shows warnings for short passphrases
|
||||
- [ ] QR code functionality still works
|
||||
- [ ] DCT mode options still work
|
||||
- [ ] All flash messages updated
|
||||
|
||||
## Implementation Status
|
||||
|
||||
✅ Flask routes updated
|
||||
✅ Form parameter names changed
|
||||
✅ Function calls updated
|
||||
✅ Validation added for passphrases
|
||||
✅ Error messages updated
|
||||
✅ Template context updated
|
||||
⏳ Templates need updating (generate.html, encode.html, decode.html, index.html, about.html)
|
||||
⏳ JavaScript needs updating
|
||||
⏳ CSS styling for v3.2.0 features
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**To test the Flask app:**
|
||||
```bash
|
||||
cd frontends/web
|
||||
python app.py
|
||||
# Visit http://localhost:5000
|
||||
```
|
||||
|
||||
**Key user-facing changes:**
|
||||
1. Generate: Shows one passphrase, not 7 daily phrases
|
||||
2. Encode: No date selection, just passphrase
|
||||
3. Decode: No date needed, just passphrase
|
||||
|
||||
**Benefits to highlight:**
|
||||
- ✅ Simpler UI (fewer fields)
|
||||
- ✅ No date tracking needed
|
||||
- ✅ Encode today, decode anytime
|
||||
- ✅ Perfect for asynchronous communications
|
||||
1835
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()
|
||||
52
frontends/web/dev_run.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Web Frontend - Development Runner
|
||||
# Press 'r' to restart, 'q' to quit (single keypress, no Enter needed)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PID=""
|
||||
|
||||
cleanup() {
|
||||
echo -e "\n\033[33mShutting down...\033[0m"
|
||||
[[ -n "$PID" ]] && kill "$PID" 2>/dev/null
|
||||
stty sane 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
start_server() {
|
||||
clear
|
||||
echo -e "\033[36m┌──────────────────────────────────────┐\033[0m"
|
||||
echo -e "\033[36m│ Stegasoo Dev Server │\033[0m"
|
||||
echo -e "\033[36m│ \033[0m[r] restart [q] quit\033[36m │\033[0m"
|
||||
echo -e "\033[36m└──────────────────────────────────────┘\033[0m"
|
||||
|
||||
pkill -f "python app.py" 2>/dev/null
|
||||
sleep 0.3
|
||||
|
||||
python app.py 2>&1 &
|
||||
PID=$!
|
||||
echo -e "\033[32m✓ Running on http://localhost:5000 (PID: $PID)\033[0m\n"
|
||||
}
|
||||
|
||||
start_server
|
||||
|
||||
# Single keypress mode
|
||||
stty -echo -icanon time 0 min 0
|
||||
|
||||
while true; do
|
||||
key=$(dd bs=1 count=1 2>/dev/null)
|
||||
case "$key" in
|
||||
r|R) start_server ;;
|
||||
q|Q) cleanup ;;
|
||||
esac
|
||||
|
||||
# Check if crashed
|
||||
if [[ -n "$PID" ]] && ! kill -0 "$PID" 2>/dev/null; then
|
||||
echo -e "\033[31m✗ Crashed! Press 'r' to restart\033[0m"
|
||||
PID=""
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
153
frontends/web/ssl_utils.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
SSL Certificate Utilities
|
||||
|
||||
Auto-generates self-signed certificates for HTTPS.
|
||||
Uses cryptography library (already a dependency).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
import socket
|
||||
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_local_ips() -> list[str]:
|
||||
"""Get local IP addresses for this machine."""
|
||||
ips = []
|
||||
try:
|
||||
# Get hostname and resolve to IP
|
||||
hostname = socket.gethostname()
|
||||
for addr_info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
ip = addr_info[4][0]
|
||||
if ip not in ips and not ip.startswith("127."):
|
||||
ips.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try connecting to external to get primary interface IP
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
if ip not in ips:
|
||||
ips.append(ip)
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ips
|
||||
|
||||
|
||||
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 hostname.local for mDNS access
|
||||
if not hostname.endswith(".local"):
|
||||
san_list.append(x509.DNSName(f"{hostname}.local"))
|
||||
|
||||
# Add the hostname as IP if it looks like one
|
||||
try:
|
||||
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
||||
except ipaddress.AddressValueError:
|
||||
pass
|
||||
|
||||
# Add local network IPs
|
||||
for local_ip in _get_local_ips():
|
||||
try:
|
||||
ip_addr = ipaddress.IPv4Address(local_ip)
|
||||
if x509.IPAddress(ip_addr) not in san_list:
|
||||
san_list.append(x509.IPAddress(ip_addr))
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
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();
|
||||
}
|
||||
279
frontends/web/static/js/generate.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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>QR Code</title>
|
||||
<style>
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
img { max-width: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${qrImg.src}" alt="QR Code">
|
||||
<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();
|
||||
}
|
||||
});
|
||||
6
frontends/web/static/js/qrcode.min.js
vendored
Normal file
@@ -99,6 +99,23 @@ const Stegasoo = {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Make preview clickable to replace file
|
||||
if (preview) {
|
||||
preview.style.cursor = 'pointer';
|
||||
preview.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Make entire zone clickable (in case label/preview don't cover it)
|
||||
zone.addEventListener('click', (e) => {
|
||||
// Only trigger if not clicking directly on the input
|
||||
if (e.target !== input) {
|
||||
input.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -119,7 +136,11 @@ const Stegasoo = {
|
||||
if (isScanContainer || isPixelContainer) {
|
||||
labelEl.classList.add('d-none');
|
||||
} else {
|
||||
labelEl.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,56 +333,68 @@ const Stegasoo = {
|
||||
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';
|
||||
|
||||
// Grid-based distribution: divide image into cells for even coverage
|
||||
const gridCols = 5;
|
||||
const gridRows = 4;
|
||||
const cellWidth = width / gridCols;
|
||||
const cellHeight = height / gridRows;
|
||||
|
||||
let pathIndex = 0;
|
||||
|
||||
// Spawn 1-2 paths from each grid cell for even distribution
|
||||
for (let row = 0; row < gridRows; row++) {
|
||||
for (let col = 0; col < gridCols; col++) {
|
||||
// 1-2 paths per cell
|
||||
const pathsInCell = 1 + Math.floor(Math.random() * 2);
|
||||
|
||||
for (let p = 0; p < pathsInCell; p++) {
|
||||
const pathColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
// Start within this grid cell (with padding)
|
||||
let x = (col * cellWidth) + (cellWidth * 0.15) + (Math.random() * cellWidth * 0.7);
|
||||
let y = (row * cellHeight) + (cellHeight * 0.15) + (Math.random() * cellHeight * 0.7);
|
||||
let delay = pathIndex * 15;
|
||||
|
||||
// Each path has 3-5 short segments
|
||||
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;
|
||||
|
||||
// Shorter segments: 12-30px for denser circuit look
|
||||
const length = 12 + Math.random() * 18;
|
||||
trace.style.left = Math.max(0, Math.min(x, width - length)) + 'px';
|
||||
trace.style.top = Math.max(0, Math.min(y, height - length)) + '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 * (Math.random() > 0.5 ? 1 : -1);
|
||||
} else {
|
||||
y += length * (Math.random() > 0.5 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Keep within bounds
|
||||
x = Math.max(5, Math.min(x, width - 20));
|
||||
y = Math.max(5, Math.min(y, height - 20));
|
||||
|
||||
// Alternate direction (90 degree turn)
|
||||
horizontal = !horizontal;
|
||||
delay += 20;
|
||||
}
|
||||
pathIndex++;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -571,7 +604,7 @@ const Stegasoo = {
|
||||
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) {
|
||||
@@ -580,6 +613,17 @@ const Stegasoo = {
|
||||
<span>No QR code detected</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset after delay so user can try again
|
||||
setTimeout(() => {
|
||||
container.classList.remove('error');
|
||||
container.classList.add('d-none');
|
||||
label?.classList.remove('d-none');
|
||||
// Clear the file input so same file can be re-selected
|
||||
input.value = '';
|
||||
// Remove loader
|
||||
if (loader) loader.remove();
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -761,18 +805,43 @@ const Stegasoo = {
|
||||
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 based on selection
|
||||
// Show/hide custom input and server info based on selection
|
||||
const updateVisibility = () => {
|
||||
const isCustom = select?.value === 'custom';
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -815,6 +884,14 @@ const Stegasoo = {
|
||||
// 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;
|
||||
},
|
||||
|
||||
@@ -851,10 +928,578 @@ const Stegasoo = {
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// ASYNC ENCODE WITH PROGRESS (v4.1.2)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Submit encode form asynchronously with progress tracking
|
||||
* @param {HTMLFormElement} form - The encode form
|
||||
* @param {HTMLElement} btn - The submit button
|
||||
*/
|
||||
async submitEncodeAsync(form, btn) {
|
||||
const formData = new FormData(form);
|
||||
formData.append('async', 'true');
|
||||
|
||||
// Show progress modal
|
||||
this.showProgressModal('Encoding');
|
||||
|
||||
try {
|
||||
// Start encode job
|
||||
const response = await fetch('/encode', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start encode');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const jobId = result.job_id;
|
||||
|
||||
// Poll for progress
|
||||
await this.pollEncodeProgress(jobId);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert('Encode failed: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-lock-fill me-2"></i>Encode';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll encode progress until complete
|
||||
* @param {string} jobId - The job ID
|
||||
*/
|
||||
async pollEncodeProgress(jobId) {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const phaseText = document.getElementById('progressPhase');
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
// Check status first
|
||||
const statusResponse = await fetch(`/encode/status/${jobId}`);
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
if (statusData.status === 'complete') {
|
||||
// Done - redirect to result
|
||||
this.updateProgress(100, 'Complete!');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/encode/result/${statusData.file_id}`;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusData.status === 'error') {
|
||||
throw new Error(statusData.error || 'Encode failed');
|
||||
}
|
||||
|
||||
// Get progress
|
||||
const progressResponse = await fetch(`/encode/progress/${jobId}`);
|
||||
const progressData = await progressResponse.json();
|
||||
|
||||
const percent = progressData.percent || 0;
|
||||
const phase = progressData.phase || 'processing';
|
||||
|
||||
this.updateProgress(percent, this.formatPhase(phase));
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, 500);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert('Encode failed: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format phase name for display
|
||||
*/
|
||||
formatPhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'initializing': 'Initializing...',
|
||||
'embedding': 'Embedding data...',
|
||||
'saving': 'Saving image...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
};
|
||||
return phases[phase] || phase;
|
||||
},
|
||||
|
||||
/**
|
||||
* Show progress modal
|
||||
*/
|
||||
showProgressModal(operation = 'Processing') {
|
||||
// Create modal if doesn't exist
|
||||
let modal = document.getElementById('progressModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'progressModal';
|
||||
modal.className = 'modal fade';
|
||||
modal.setAttribute('data-bs-backdrop', 'static');
|
||||
modal.setAttribute('data-bs-keyboard', 'false');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<div class="modal-body p-4">
|
||||
<h5 class="mb-3" id="progressTitle">${operation}...</h5>
|
||||
<div class="progress mb-2" style="height: 24px;">
|
||||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
|
||||
role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="progressPhase">Initializing...</span>
|
||||
<span id="progressText">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
this.updateProgress(0, 'Initializing...');
|
||||
|
||||
// Show modal
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide progress modal
|
||||
*/
|
||||
hideProgressModal() {
|
||||
const modal = document.getElementById('progressModal');
|
||||
if (modal) {
|
||||
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||
bsModal?.hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update progress bar and text
|
||||
*/
|
||||
updateProgress(percent, phase) {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const phaseText = document.getElementById('progressPhase');
|
||||
|
||||
if (progressBar) progressBar.style.width = percent + '%';
|
||||
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// ASYNC DECODE WITH PROGRESS (v4.1.5)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Submit decode form asynchronously with progress tracking
|
||||
* @param {HTMLFormElement} form - The decode form
|
||||
* @param {HTMLElement} btn - The submit button
|
||||
*/
|
||||
async submitDecodeAsync(form, btn) {
|
||||
const formData = new FormData(form);
|
||||
formData.append('async', 'true');
|
||||
|
||||
// Show progress modal
|
||||
this.showProgressModal('Decoding');
|
||||
|
||||
try {
|
||||
// Start decode job
|
||||
const response = await fetch('/decode', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start decode');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const jobId = result.job_id;
|
||||
|
||||
// Poll for progress
|
||||
await this.pollDecodeProgress(jobId);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert('Decode failed: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-unlock-fill me-2"></i>Decode';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll decode progress until complete
|
||||
* @param {string} jobId - The job ID
|
||||
*/
|
||||
async pollDecodeProgress(jobId) {
|
||||
const poll = async () => {
|
||||
try {
|
||||
// Check status first
|
||||
const statusResponse = await fetch(`/decode/status/${jobId}`);
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
if (statusData.status === 'complete') {
|
||||
// Done - redirect to result page
|
||||
this.updateProgress(100, 'Complete!');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/decode/result/${jobId}`;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusData.status === 'error') {
|
||||
// Handle specific error types
|
||||
const errorType = statusData.error_type;
|
||||
let errorMsg = statusData.error || 'Decode failed';
|
||||
|
||||
if (errorType === 'DecryptionError' || errorMsg.toLowerCase().includes('decrypt')) {
|
||||
errorMsg = 'Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.';
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Get progress
|
||||
const progressResponse = await fetch(`/decode/progress/${jobId}`);
|
||||
const progressData = await progressResponse.json();
|
||||
|
||||
const percent = progressData.percent || 0;
|
||||
const phase = progressData.phase || 'processing';
|
||||
|
||||
this.updateProgress(percent, this.formatDecodePhase(phase));
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, 500);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format decode phase name for display
|
||||
*/
|
||||
formatDecodePhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'reading': 'Reading image...',
|
||||
'extracting': 'Extracting data...',
|
||||
'decrypting': 'Decrypting...',
|
||||
'verifying': 'Verifying...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
};
|
||||
return phases[phase] || phase;
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// WEBCAM QR SCANNING (v4.1.5)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Active scanner instance
|
||||
*/
|
||||
_qrScanner: null,
|
||||
_qrScannerModal: null,
|
||||
_qrScannerCallback: null,
|
||||
|
||||
/**
|
||||
* Show webcam QR scanner modal
|
||||
* @param {Function} onSuccess - Callback with decoded QR text
|
||||
* @param {string} title - Modal title
|
||||
*/
|
||||
showQrScanner(onSuccess, title = 'Scan QR Code') {
|
||||
this._qrScannerCallback = onSuccess;
|
||||
|
||||
// Create modal if doesn't exist
|
||||
let modal = document.getElementById('qrScannerModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'qrScannerModal';
|
||||
modal.className = 'modal fade';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-camera-video me-2"></i>
|
||||
<span id="qrScannerTitle">${title}</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div id="qrScannerReader" style="width: 100%;"></div>
|
||||
<div id="qrScannerStatus" class="text-center py-3 text-muted">
|
||||
<i class="bi bi-qr-code-scan me-2"></i>
|
||||
Point camera at QR code
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-primary" id="qrCaptureBtn">
|
||||
<i class="bi bi-camera me-1"></i>Capture
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Clean up scanner when modal hides
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
this.stopQrScanner();
|
||||
});
|
||||
|
||||
// Manual capture button
|
||||
modal.querySelector('#qrCaptureBtn')?.addEventListener('click', () => {
|
||||
this.captureQrFrame();
|
||||
});
|
||||
}
|
||||
|
||||
// Update title
|
||||
const titleEl = modal.querySelector('#qrScannerTitle');
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
|
||||
// Reset status
|
||||
const statusEl = modal.querySelector('#qrScannerStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="bi bi-qr-code-scan me-2"></i>Point camera at QR code';
|
||||
statusEl.className = 'text-center py-3 text-muted';
|
||||
}
|
||||
|
||||
// Show modal
|
||||
this._qrScannerModal = new bootstrap.Modal(modal);
|
||||
this._qrScannerModal.show();
|
||||
|
||||
// Start scanner after modal is shown
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
this.startQrScanner();
|
||||
}, { once: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the QR scanner
|
||||
*/
|
||||
startQrScanner() {
|
||||
const readerEl = document.getElementById('qrScannerReader');
|
||||
if (!readerEl) return;
|
||||
|
||||
// Check if Html5Qrcode is available
|
||||
if (typeof Html5Qrcode === 'undefined') {
|
||||
console.error('Html5Qrcode library not loaded');
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>QR scanner not available';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._qrScanner = new Html5Qrcode('qrScannerReader');
|
||||
|
||||
const config = {
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
aspectRatio: 1.0,
|
||||
};
|
||||
|
||||
this._qrScanner.start(
|
||||
{ facingMode: 'environment' }, // Prefer back camera
|
||||
config,
|
||||
(decodedText, decodedResult) => {
|
||||
// QR code detected
|
||||
this.onQrCodeDetected(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
// Scan error (ignore, keep scanning)
|
||||
}
|
||||
).catch((err) => {
|
||||
console.error('Failed to start scanner:', err);
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
if (statusEl) {
|
||||
if (err.toString().includes('Permission')) {
|
||||
statusEl.innerHTML = '<i class="bi bi-camera-video-off text-danger me-2"></i>Camera permission denied';
|
||||
} else {
|
||||
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>Could not access camera';
|
||||
}
|
||||
statusEl.className = 'text-center py-3';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Capture a frame with countdown and try to decode
|
||||
*/
|
||||
captureQrFrame() {
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
const captureBtn = document.getElementById('qrCaptureBtn');
|
||||
if (!statusEl || !this._qrScanner) return;
|
||||
|
||||
// Disable button during countdown
|
||||
if (captureBtn) captureBtn.disabled = true;
|
||||
|
||||
let count = 3;
|
||||
const countdown = () => {
|
||||
if (count > 0) {
|
||||
statusEl.innerHTML = `<i class="bi bi-camera me-2"></i><span style="font-size: 1.5rem; font-weight: bold;">${count}</span>`;
|
||||
statusEl.className = 'text-center py-3 text-warning';
|
||||
count--;
|
||||
setTimeout(countdown, 1000);
|
||||
} else {
|
||||
// Capture!
|
||||
statusEl.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Analyzing...';
|
||||
statusEl.className = 'text-center py-3 text-info';
|
||||
|
||||
// Get video element and capture frame
|
||||
const video = document.querySelector('#qrScannerReader video');
|
||||
if (video) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
// Stop the scanner before file scan (prevents conflicts)
|
||||
const scanner = this._qrScanner;
|
||||
scanner.stop().then(() => {
|
||||
canvas.toBlob((blob) => {
|
||||
const file = new File([blob], 'capture.png', { type: 'image/png' });
|
||||
scanner.scanFile(file, true)
|
||||
.then((decodedText) => {
|
||||
this.onQrCodeDetected(decodedText);
|
||||
})
|
||||
.catch((err) => {
|
||||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>No QR code found. Try again.';
|
||||
statusEl.className = 'text-center py-3 text-danger';
|
||||
if (captureBtn) captureBtn.disabled = false;
|
||||
// Restart the scanner
|
||||
this.startQrScanner();
|
||||
});
|
||||
}, 'image/png');
|
||||
}).catch(() => {
|
||||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Scanner error';
|
||||
statusEl.className = 'text-center py-3 text-danger';
|
||||
if (captureBtn) captureBtn.disabled = false;
|
||||
});
|
||||
} else {
|
||||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Camera not ready';
|
||||
statusEl.className = 'text-center py-3 text-danger';
|
||||
if (captureBtn) captureBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
countdown();
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop the QR scanner
|
||||
*/
|
||||
stopQrScanner() {
|
||||
if (this._qrScanner) {
|
||||
this._qrScanner.stop().then(() => {
|
||||
this._qrScanner.clear();
|
||||
this._qrScanner = null;
|
||||
}).catch((err) => {
|
||||
console.log('Scanner stop error:', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle detected QR code
|
||||
* @param {string} text - Decoded QR text
|
||||
*/
|
||||
onQrCodeDetected(text) {
|
||||
// Update status
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="bi bi-check-circle text-success me-2"></i>QR code detected!';
|
||||
statusEl.className = 'text-center py-3 text-success';
|
||||
}
|
||||
|
||||
// Close modal after brief delay
|
||||
setTimeout(() => {
|
||||
this._qrScannerModal?.hide();
|
||||
|
||||
// Call callback
|
||||
if (this._qrScannerCallback) {
|
||||
this._qrScannerCallback(text);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add camera scan button to an input field
|
||||
* @param {string} inputId - ID of the input field
|
||||
* @param {string} title - Modal title
|
||||
* @param {Function} validator - Optional validation function for scanned text
|
||||
*/
|
||||
addCameraScanButton(inputId, title = 'Scan QR Code', validator = null) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
|
||||
// Create button
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-secondary';
|
||||
btn.innerHTML = '<i class="bi bi-camera"></i>';
|
||||
btn.title = 'Scan QR code with camera';
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
// Validate if validator provided
|
||||
if (validator && !validator(text)) {
|
||||
alert('Invalid QR code format');
|
||||
return;
|
||||
}
|
||||
// Set input value
|
||||
input.value = text;
|
||||
// Trigger input event for formatting
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, title);
|
||||
});
|
||||
|
||||
// Wrap input in input-group if not already
|
||||
const parent = input.parentElement;
|
||||
if (!parent.classList.contains('input-group')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'input-group';
|
||||
parent.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(btn);
|
||||
} else {
|
||||
parent.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// INITIALIZATION HELPERS
|
||||
// ========================================================================
|
||||
|
||||
|
||||
initEncodePage() {
|
||||
this.initPasswordToggles();
|
||||
this.initRsaMethodToggle();
|
||||
@@ -872,18 +1517,56 @@ const Stegasoo = {
|
||||
generateBtnId: 'channelKeyGenerate'
|
||||
});
|
||||
|
||||
// Form submission with channel key validation
|
||||
// Webcam QR scanning for channel key (v4.1.5)
|
||||
document.getElementById('channelKeyScan')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInput');
|
||||
if (input) {
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Webcam QR scanning for RSA key (v4.1.5)
|
||||
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
|
||||
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
|
||||
const isCompressed = text.startsWith('STEGASOO-Z:');
|
||||
if (isRawPem || isCompressed) {
|
||||
// Valid RSA key data scanned
|
||||
document.getElementById('rsaKeyPem').value = text;
|
||||
// Show success in drop zone
|
||||
const dropZone = document.getElementById('qrDropZone');
|
||||
const label = dropZone?.querySelector('.drop-zone-label');
|
||||
if (label) {
|
||||
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
|
||||
}
|
||||
} else {
|
||||
alert('QR code does not contain a valid RSA key');
|
||||
}
|
||||
}, 'Scan RSA Key QR');
|
||||
});
|
||||
|
||||
// Form submission with async progress tracking (v4.1.2)
|
||||
const form = document.getElementById('encodeForm');
|
||||
const btn = document.getElementById('encodeBtn');
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Encoding...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||
}
|
||||
|
||||
// Use async submission with progress tracking
|
||||
this.submitEncodeAsync(form, btn);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -892,7 +1575,7 @@ const Stegasoo = {
|
||||
this.initRsaMethodToggle();
|
||||
this.initDropZones();
|
||||
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
|
||||
this.initQrCropAnimation('rsaKeyQrInput');
|
||||
this.initQrCropAnimation('rsaQrInput');
|
||||
this.initCollapseChevrons();
|
||||
this.initPassphraseFontResize();
|
||||
|
||||
@@ -900,22 +1583,60 @@ const Stegasoo = {
|
||||
this.initChannelKey({
|
||||
selectId: 'channelSelectDec',
|
||||
customInputId: 'channelCustomInputDec',
|
||||
keyInputId: 'channelKeyInputDec'
|
||||
keyInputId: 'channelKeyInputDec',
|
||||
serverInfoId: 'channelServerInfoDec'
|
||||
});
|
||||
|
||||
// Form submission with channel key validation and mode display
|
||||
// Webcam QR scanning for channel key (v4.1.5)
|
||||
document.getElementById('channelKeyScanDec')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInputDec');
|
||||
if (input) {
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Webcam QR scanning for RSA key (v4.1.5)
|
||||
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
|
||||
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
|
||||
const isCompressed = text.startsWith('STEGASOO-Z:');
|
||||
if (isRawPem || isCompressed) {
|
||||
// Valid RSA key data scanned
|
||||
document.getElementById('rsaKeyPem').value = text;
|
||||
// Show success in drop zone
|
||||
const dropZone = document.getElementById('qrDropZone');
|
||||
const label = dropZone?.querySelector('.drop-zone-label');
|
||||
if (label) {
|
||||
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
|
||||
}
|
||||
} else {
|
||||
alert('QR code does not contain a valid RSA key');
|
||||
}
|
||||
}, 'Scan RSA Key QR');
|
||||
});
|
||||
|
||||
// Form submission with async progress tracking (v4.1.5)
|
||||
const form = document.getElementById('decodeForm');
|
||||
const btn = document.getElementById('decodeBtn');
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})...`;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||
}
|
||||
|
||||
// Use async submission with progress tracking
|
||||
this.submitDecodeAsync(form, btn);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
5
frontends/web/static/vendor/css/bootstrap-icons.min.css
vendored
Normal file
6
frontends/web/static/vendor/css/bootstrap.min.css
vendored
Normal file
BIN
frontends/web/static/vendor/css/fonts/bootstrap-icons.woff
vendored
Normal file
BIN
frontends/web/static/vendor/css/fonts/bootstrap-icons.woff2
vendored
Normal file
7
frontends/web/static/vendor/js/bootstrap.bundle.min.js
vendored
Normal file
1
frontends/web/static/vendor/js/html5-qrcode.min.js
vendored
Normal file
@@ -111,6 +111,7 @@ def encode_operation(params: dict) -> dict:
|
||||
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
|
||||
progress_file=params.get("progress_file"), # v4.1.2
|
||||
)
|
||||
|
||||
# Build stats dict if available
|
||||
@@ -135,14 +136,33 @@ def encode_operation(params: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _write_decode_progress(progress_file: str | None, percent: int, phase: str) -> None:
|
||||
"""Write decode progress to file."""
|
||||
if not progress_file:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({"percent": percent, "phase": phase}, f)
|
||||
except Exception:
|
||||
pass # Best effort
|
||||
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
from stegasoo import decode
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
|
||||
# Progress: starting
|
||||
_write_decode_progress(progress_file, 5, "reading")
|
||||
|
||||
# Decode base64 inputs
|
||||
stego_data = base64.b64decode(params["stego_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
_write_decode_progress(progress_file, 15, "reading")
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
@@ -151,6 +171,8 @@ def decode_operation(params: dict) -> dict:
|
||||
# Resolve channel key (v4.0.0)
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
_write_decode_progress(progress_file, 25, "extracting")
|
||||
|
||||
# Call decode with correct parameter names
|
||||
result = decode(
|
||||
stego_image=stego_data,
|
||||
@@ -163,6 +185,8 @@ def decode_operation(params: dict) -> dict:
|
||||
channel_key=resolved_channel_key, # v4.0.0
|
||||
)
|
||||
|
||||
_write_decode_progress(progress_file, 90, "finalizing")
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -47,6 +47,8 @@ import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -233,6 +235,8 @@ class SubprocessStego:
|
||||
# Channel key (v4.0.0)
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
# Progress file (v4.1.2)
|
||||
progress_file: str | None = None,
|
||||
) -> EncodeResult:
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
@@ -268,6 +272,7 @@ class SubprocessStego:
|
||||
"dct_output_format": dct_output_format,
|
||||
"dct_color_mode": dct_color_mode,
|
||||
"channel_key": channel_key, # v4.0.0
|
||||
"progress_file": progress_file, # v4.1.2
|
||||
}
|
||||
|
||||
if file_data:
|
||||
@@ -309,6 +314,8 @@ class SubprocessStego:
|
||||
# Channel key (v4.0.0)
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
# Progress tracking (v4.1.5)
|
||||
progress_file: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from a stego image.
|
||||
@@ -323,6 +330,7 @@ class SubprocessStego:
|
||||
embed_mode: 'auto', 'lsb', or 'dct'
|
||||
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
|
||||
timeout: Operation timeout in seconds
|
||||
progress_file: Path to write progress updates (v4.1.5)
|
||||
|
||||
Returns:
|
||||
DecodeResult with message or file_data on success
|
||||
@@ -335,6 +343,7 @@ class SubprocessStego:
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key, # v4.0.0
|
||||
"progress_file": progress_file, # v4.1.5
|
||||
}
|
||||
|
||||
if rsa_key_data:
|
||||
@@ -496,3 +505,42 @@ def get_subprocess_stego() -> SubprocessStego:
|
||||
if _default_stego is None:
|
||||
_default_stego = SubprocessStego()
|
||||
return _default_stego
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Progress File Utilities (v4.1.2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_job_id() -> str:
|
||||
"""Generate a unique job ID for tracking encode/decode operations."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def get_progress_file_path(job_id: str) -> str:
|
||||
"""Get the progress file path for a job ID."""
|
||||
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||
|
||||
|
||||
def read_progress(job_id: str) -> dict | None:
|
||||
"""
|
||||
Read progress from file for a job ID.
|
||||
|
||||
Returns:
|
||||
Progress dict with current, total, percent, phase, or None if not found
|
||||
"""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
try:
|
||||
with open(progress_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_progress_file(job_id: str) -> None:
|
||||
"""Remove progress file for a completed job."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
try:
|
||||
Path(progress_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
212
frontends/web/temp_storage.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
File-based Temporary Storage
|
||||
|
||||
Stores temp files on disk instead of in-memory dict.
|
||||
This allows multiple Gunicorn workers to share temp files
|
||||
and survives service restarts within the expiry window.
|
||||
|
||||
Files are stored in a temp directory with:
|
||||
- {file_id}.data - The actual file data
|
||||
- {file_id}.json - Metadata (filename, timestamp, mime_type, etc.)
|
||||
|
||||
IMPORTANT: This module ONLY manages files in the temp_files/ directory.
|
||||
It does NOT touch instance/ (auth database) or any other directories.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
# Default temp directory (can be overridden)
|
||||
DEFAULT_TEMP_DIR = Path(__file__).parent / "temp_files"
|
||||
|
||||
# Lock for thread-safe operations
|
||||
_lock = Lock()
|
||||
|
||||
# Module-level temp directory (set on init)
|
||||
_temp_dir: Path = DEFAULT_TEMP_DIR
|
||||
|
||||
|
||||
def init(temp_dir: Path | str | None = None):
|
||||
"""Initialize temp storage with optional custom directory."""
|
||||
global _temp_dir
|
||||
_temp_dir = Path(temp_dir) if temp_dir else DEFAULT_TEMP_DIR
|
||||
_temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _data_path(file_id: str) -> Path:
|
||||
"""Get path for file data."""
|
||||
return _temp_dir / f"{file_id}.data"
|
||||
|
||||
|
||||
def _meta_path(file_id: str) -> Path:
|
||||
"""Get path for file metadata."""
|
||||
return _temp_dir / f"{file_id}.json"
|
||||
|
||||
|
||||
def _thumb_path(thumb_id: str) -> Path:
|
||||
"""Get path for thumbnail data."""
|
||||
return _temp_dir / f"{thumb_id}.thumb"
|
||||
|
||||
|
||||
def save_temp_file(file_id: str, data: bytes, metadata: dict) -> None:
|
||||
"""
|
||||
Save a temp file with its metadata.
|
||||
|
||||
Args:
|
||||
file_id: Unique identifier for the file
|
||||
data: File contents as bytes
|
||||
metadata: Dict with filename, mime_type, timestamp, etc.
|
||||
"""
|
||||
init() # Ensure directory exists
|
||||
|
||||
with _lock:
|
||||
# Add timestamp if not present
|
||||
if "timestamp" not in metadata:
|
||||
metadata["timestamp"] = time.time()
|
||||
|
||||
# Write data file
|
||||
_data_path(file_id).write_bytes(data)
|
||||
|
||||
# Write metadata
|
||||
_meta_path(file_id).write_text(json.dumps(metadata))
|
||||
|
||||
|
||||
def get_temp_file(file_id: str) -> dict | None:
|
||||
"""
|
||||
Get a temp file and its metadata.
|
||||
|
||||
Returns:
|
||||
Dict with 'data' (bytes) and all metadata fields, or None if not found.
|
||||
"""
|
||||
init()
|
||||
|
||||
data_file = _data_path(file_id)
|
||||
meta_file = _meta_path(file_id)
|
||||
|
||||
if not data_file.exists() or not meta_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = data_file.read_bytes()
|
||||
metadata = json.loads(meta_file.read_text())
|
||||
return {"data": data, **metadata}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def has_temp_file(file_id: str) -> bool:
|
||||
"""Check if a temp file exists."""
|
||||
init()
|
||||
return _data_path(file_id).exists() and _meta_path(file_id).exists()
|
||||
|
||||
|
||||
def delete_temp_file(file_id: str) -> None:
|
||||
"""Delete a temp file and its metadata."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_data_path(file_id).unlink(missing_ok=True)
|
||||
_meta_path(file_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def save_thumbnail(thumb_id: str, data: bytes) -> None:
|
||||
"""Save a thumbnail."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_thumb_path(thumb_id).write_bytes(data)
|
||||
|
||||
|
||||
def get_thumbnail(thumb_id: str) -> bytes | None:
|
||||
"""Get thumbnail data."""
|
||||
init()
|
||||
|
||||
thumb_file = _thumb_path(thumb_id)
|
||||
if not thumb_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return thumb_file.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def delete_thumbnail(thumb_id: str) -> None:
|
||||
"""Delete a thumbnail."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_thumb_path(thumb_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def cleanup_expired(max_age_seconds: float) -> int:
|
||||
"""
|
||||
Delete expired temp files.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds before expiry
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
"""
|
||||
init()
|
||||
|
||||
now = time.time()
|
||||
deleted = 0
|
||||
|
||||
with _lock:
|
||||
# Find all metadata files
|
||||
for meta_file in _temp_dir.glob("*.json"):
|
||||
try:
|
||||
metadata = json.loads(meta_file.read_text())
|
||||
timestamp = metadata.get("timestamp", 0)
|
||||
|
||||
if now - timestamp > max_age_seconds:
|
||||
file_id = meta_file.stem
|
||||
_data_path(file_id).unlink(missing_ok=True)
|
||||
meta_file.unlink(missing_ok=True)
|
||||
# Also delete thumbnail if exists
|
||||
_thumb_path(f"{file_id}_thumb").unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# Remove corrupted files
|
||||
meta_file.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def cleanup_all() -> int:
|
||||
"""
|
||||
Delete all temp files. Call on service start/stop.
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
"""
|
||||
init()
|
||||
|
||||
deleted = 0
|
||||
|
||||
with _lock:
|
||||
for f in _temp_dir.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""Get temp storage statistics."""
|
||||
init()
|
||||
|
||||
files = list(_temp_dir.glob("*.data"))
|
||||
total_size = sum(f.stat().st_size for f in files if f.exists())
|
||||
|
||||
return {
|
||||
"file_count": len(files),
|
||||
"total_size_bytes": total_size,
|
||||
"temp_dir": str(_temp_dir),
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Channel Keys</strong>
|
||||
<span class="badge bg-info ms-1">v4.0</span>
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
<br><small class="text-muted">Group/deployment isolation</small>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -100,6 +100,7 @@
|
||||
<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</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<div class="small">
|
||||
@@ -250,7 +251,7 @@
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-broadcast me-2"></i>Channel Keys
|
||||
<span class="badge bg-info ms-2">v4.0</span>
|
||||
<span class="badge bg-info ms-2">v4.1</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -325,7 +326,7 @@
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This server is running in <strong>public mode</strong>.
|
||||
This server is running in <strong>public mode</strong>.
|
||||
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -338,49 +339,64 @@
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-sm small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>4.0.0</strong></td>
|
||||
<td>
|
||||
<strong>Channel keys</strong> for group/deployment isolation,
|
||||
DCT default, simplified auth, passphrase replaces day_phrase,
|
||||
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.2.0</td>
|
||||
<td>Single passphrase, more default words</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.0.0</td>
|
||||
<td>DCT mode, JPEG output, color preservation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.2.0</td>
|
||||
<td>QR code RSA key import/export</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.1.0</td>
|
||||
<td>File embedding, compression</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.0.0</td>
|
||||
<td>Web UI, REST API, RSA keys</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1.0.0</td>
|
||||
<td>Initial release, CLI only, LSB mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Current Version - Prominent -->
|
||||
<div class="alert alert-success mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
|
||||
<div>
|
||||
<strong>Progress bars</strong> for encode operations,
|
||||
<strong>mobile-responsive polish</strong>,
|
||||
DCT decode bug fix, release validation script
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Previous Versions - Accordion -->
|
||||
<div class="accordion" id="versionAccordion">
|
||||
<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="#olderVersions">
|
||||
<i class="bi bi-archive me-2"></i>Previous Versions
|
||||
</button>
|
||||
</h2>
|
||||
<div id="olderVersions" class="accordion-collapse collapse" data-bs-parent="#versionAccordion">
|
||||
<div class="accordion-body p-0">
|
||||
<table class="table table-dark table-sm small mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="80"><strong>4.1.1</strong></td>
|
||||
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>4.1.0</strong></td>
|
||||
<td>Reed-Solomon error correction for DCT, majority voting headers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>4.0.0</strong></td>
|
||||
<td>Channel keys, DCT default, subprocess isolation</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.x</td>
|
||||
<td>Web UI, REST API, RSA keys, QR codes, file embedding</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1.0.0</td>
|
||||
<td>Initial release, CLI only, LSB mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,61 +485,114 @@
|
||||
<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 small">
|
||||
<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-file-earmark me-2"></i>Max file</td>
|
||||
<td><strong>{{ max_payload_kb }} KB</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-image me-2"></i>Max carrier</td>
|
||||
<td><strong>24 MP</strong> (~6000x4000)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-soundwave me-2"></i>DCT capacity</td>
|
||||
<td><strong>~75 KB/MP</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-grid-3x3 me-2"></i>LSB capacity</td>
|
||||
<td><strong>~375 KB/MP</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-upload me-2"></i>Max upload</td>
|
||||
<td><strong>30 MB</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-clock me-2"></i>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, cryptography, argon2-cffi</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">DCT ECC</div>
|
||||
<strong>RS Code</strong>
|
||||
</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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
440
frontends/web/templates/account.html
Normal file
@@ -0,0 +1,440 @@
|
||||
{% 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">
|
||||
{% if is_admin %}
|
||||
<button type="button" class="btn btn-outline-info"
|
||||
onclick="showKeyQr('{{ key.channel_key }}', '{{ key.name }}')"
|
||||
title="Show QR Code">
|
||||
<i class="bi bi-qr-code"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<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">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="channel_key" id="channelKeyInput"
|
||||
class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-..." required
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="scanChannelKeyBtn"
|
||||
title="Scan QR code with camera">
|
||||
<i class="bi bi-camera"></i>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{% if is_admin %}
|
||||
<!-- QR Code Modal (Admin only) -->
|
||||
<div class="modal fade" id="qrModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i><span id="qrKeyName">Channel Key</span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<canvas id="qrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||
<div class="mt-2">
|
||||
<code class="small" id="qrKeyDisplay"></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrDownload">
|
||||
<i class="bi bi-download me-1"></i>Download
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrPrint">
|
||||
<i class="bi bi-printer me-1"></i>Print Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
{% if is_admin %}
|
||||
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||
|
||||
// Webcam QR scanning for channel key input (v4.1.5)
|
||||
document.getElementById('scanChannelKeyBtn')?.addEventListener('click', function() {
|
||||
Stegasoo.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInput');
|
||||
if (input) {
|
||||
// Clean and format the key
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
if (clean.length === 32) {
|
||||
input.value = clean.match(/.{4}/g).join('-');
|
||||
} else {
|
||||
input.value = text.toUpperCase();
|
||||
}
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Format channel key input as user types
|
||||
document.getElementById('channelKeyInput')?.addEventListener('input', function() {
|
||||
Stegasoo.formatChannelKeyInput(this);
|
||||
});
|
||||
|
||||
function renameKey(keyId, currentName) {
|
||||
document.getElementById('renameInput').value = currentName;
|
||||
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
||||
new bootstrap.Modal(document.getElementById('renameModal')).show();
|
||||
}
|
||||
|
||||
{% if is_admin %}
|
||||
function showKeyQr(channelKey, keyName) {
|
||||
// Format key with dashes if not already
|
||||
const clean = channelKey.replace(/-/g, '').toUpperCase();
|
||||
const formatted = clean.match(/.{4}/g)?.join('-') || clean;
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('qrKeyName').textContent = keyName;
|
||||
document.getElementById('qrKeyDisplay').textContent = formatted;
|
||||
|
||||
// Generate QR code using QRious
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
if (typeof QRious !== 'undefined' && canvas) {
|
||||
try {
|
||||
new QRious({
|
||||
element: canvas,
|
||||
value: formatted,
|
||||
size: 200,
|
||||
level: 'M'
|
||||
});
|
||||
new bootstrap.Modal(document.getElementById('qrModal')).show();
|
||||
} catch (error) {
|
||||
console.error('QR generation error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download QR as PNG
|
||||
document.getElementById('qrDownload')?.addEventListener('click', function() {
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
const keyName = document.getElementById('qrKeyName').textContent;
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'stegasoo-channel-key-' + keyName.toLowerCase().replace(/\s+/g, '-') + '.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Print tiled QR sheet (US Letter)
|
||||
document.getElementById('qrPrint')?.addEventListener('click', function() {
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
const keyText = document.getElementById('qrKeyDisplay').textContent;
|
||||
const keyName = document.getElementById('qrKeyName').textContent;
|
||||
if (canvas && keyText) {
|
||||
printQrSheet(canvas, keyText, keyName);
|
||||
}
|
||||
});
|
||||
|
||||
// Print QR codes tiled on US Letter paper (8.5" x 11")
|
||||
function printQrSheet(canvas, keyText, title) {
|
||||
const qrDataUrl = canvas.toDataURL('image/png');
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
alert('Please allow popups to print');
|
||||
return;
|
||||
}
|
||||
|
||||
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
|
||||
const cols = 4;
|
||||
const rows = 5;
|
||||
|
||||
// Split key into two lines (4 groups each)
|
||||
const keyParts = keyText.split('-');
|
||||
const keyLine1 = keyParts.slice(0, 4).join('-');
|
||||
const keyLine2 = keyParts.slice(4).join('-');
|
||||
|
||||
let qrGrid = '';
|
||||
for (let i = 0; i < rows * cols; i++) {
|
||||
qrGrid += `
|
||||
<div class="qr-tile">
|
||||
<div class="key-text">${keyLine1}</div>
|
||||
<img src="${qrDataUrl}" alt="QR">
|
||||
<div class="key-text">${keyLine2}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style>
|
||||
@page {
|
||||
size: letter;
|
||||
margin: 0.2in;
|
||||
margin-top: 0.1in;
|
||||
margin-bottom: 0.1in;
|
||||
}
|
||||
@media print {
|
||||
@page { margin: 0.15in; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: white;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${cols}, 1fr);
|
||||
gap: 0;
|
||||
margin-top: 0.09in;
|
||||
}
|
||||
.qr-tile {
|
||||
border: 1px dashed #ccc;
|
||||
padding: 0.04in;
|
||||
text-align: center;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.qr-tile img {
|
||||
width: 1.6in;
|
||||
height: 1.6in;
|
||||
}
|
||||
.key-text {
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">${qrGrid}</div>
|
||||
<div class="footer">Cut along dashed lines</div>
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
}
|
||||
{% endif %}
|
||||
</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 %}
|
||||
506
frontends/web/templates/admin/settings.html
Normal file
@@ -0,0 +1,506 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}System Settings - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- Channel Key Configuration -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-broadcast me-2"></i>Channel Key Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if channel_configured %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
<strong>Server channel key active:</strong>
|
||||
<code class="ms-2">{{ channel_fingerprint }}</code>
|
||||
<span class="text-muted ms-2">({{ channel_source }})</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Server running in <strong>public mode</strong>.
|
||||
Set <code>STEGASOO_CHANNEL_KEY</code> environment variable to enable server-wide channel isolation.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- QR Code 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>
|
||||
|
||||
<!-- Locked state - requires password -->
|
||||
<div id="channelKeyLocked">
|
||||
<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="password" class="form-control font-monospace"
|
||||
value="********************************" disabled>
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-warning w-100" type="button" id="channelKeyUnlock">
|
||||
<i class="bi bi-unlock me-1"></i>Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">
|
||||
<i class="bi bi-shield-lock me-1"></i>Re-enter your password to view or export the channel key.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Unlocked state - shows key and QR options -->
|
||||
<div id="channelKeyUnlocked" style="display: none;">
|
||||
<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>
|
||||
<small class="text-success mt-2 d-block">
|
||||
<i class="bi bi-unlock me-1"></i>Unlocked for this session.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Server Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-dark table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><i class="bi bi-hdd-network me-2"></i>Hostname</td>
|
||||
<td><code>{{ hostname }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-ethernet me-2"></i>Port</td>
|
||||
<td><code>{{ port }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-shield-lock me-2"></i>HTTPS</td>
|
||||
<td>
|
||||
{% if https_enabled %}
|
||||
<span class="badge bg-success"><i class="bi bi-lock me-1"></i>Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-unlock me-1"></i>Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-person-lock me-2"></i>Authentication</td>
|
||||
<td>
|
||||
{% if auth_enabled %}
|
||||
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x me-1"></i>Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-dark table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><i class="bi bi-file-earmark me-2"></i>Max Payload</td>
|
||||
<td><code>{{ max_payload_kb }} KB</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-upload me-2"></i>Max Upload</td>
|
||||
<td><code>{{ max_upload_mb }} MB</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-soundwave me-2"></i>DCT Mode</td>
|
||||
<td>
|
||||
{% if dct_available %}
|
||||
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Not Available</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-qr-code me-2"></i>QR Support</td>
|
||||
<td>
|
||||
{% if qr_available %}
|
||||
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Not Available</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary small mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
To change server settings, edit environment variables or config file and restart the service.
|
||||
<br>See <code>STEGASOO_HTTPS_ENABLED</code>, <code>STEGASOO_PORT</code>, <code>STEGASOO_CHANNEL_KEY</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Environment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-box text-primary fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Version</div>
|
||||
<strong>{{ version }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-terminal text-info fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Python</div>
|
||||
<strong>{{ python_version }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-cpu text-warning fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Platform</div>
|
||||
<strong>{{ platform }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-shield-check text-success fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">KDF</div>
|
||||
<strong>{{ kdf_type }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Verification Modal -->
|
||||
<div class="modal fade" id="passwordModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-shield-lock me-2"></i>Verify Password</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted mb-3">Re-enter your password to access sensitive data.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Password</label>
|
||||
<input type="password" class="form-control" id="verifyPassword" autocomplete="current-password">
|
||||
<div class="invalid-feedback" id="passwordError">Incorrect password</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" id="verifyPasswordBtn">
|
||||
<i class="bi bi-unlock me-1"></i>Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Modal -->
|
||||
<div class="modal fade" id="channelKeyQrModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i>Channel Key</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||
<div class="mt-2">
|
||||
<code class="small" id="channelKeyQrDisplay"></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrDownload">
|
||||
<i class="bi bi-download me-1"></i>Download
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrPrint">
|
||||
<i class="bi bi-printer me-1"></i>Print Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/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 canvas = document.getElementById('channelKeyQrCanvas');
|
||||
const displayEl = document.getElementById('channelKeyQrDisplay');
|
||||
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
||||
const modalEl = document.getElementById('channelKeyQrModal');
|
||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||
|
||||
// Password verification elements
|
||||
const lockedDiv = document.getElementById('channelKeyLocked');
|
||||
const unlockedDiv = document.getElementById('channelKeyUnlocked');
|
||||
const unlockBtn = document.getElementById('channelKeyUnlock');
|
||||
const passwordModalEl = document.getElementById('passwordModal');
|
||||
const passwordModal = passwordModalEl ? new bootstrap.Modal(passwordModalEl) : null;
|
||||
const verifyPasswordInput = document.getElementById('verifyPassword');
|
||||
const verifyPasswordBtn = document.getElementById('verifyPasswordBtn');
|
||||
const passwordError = document.getElementById('passwordError');
|
||||
|
||||
// Unlock button shows password modal
|
||||
unlockBtn?.addEventListener('click', function() {
|
||||
verifyPasswordInput.value = '';
|
||||
verifyPasswordInput.classList.remove('is-invalid');
|
||||
passwordModal?.show();
|
||||
setTimeout(() => verifyPasswordInput.focus(), 300);
|
||||
});
|
||||
|
||||
// Handle Enter key in password field
|
||||
verifyPasswordInput?.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
verifyPasswordBtn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Verify password and unlock
|
||||
verifyPasswordBtn?.addEventListener('click', async function() {
|
||||
const password = verifyPasswordInput.value;
|
||||
if (!password) {
|
||||
verifyPasswordInput.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
verifyPasswordBtn.disabled = true;
|
||||
verifyPasswordBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Verifying...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/settings/unlock', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Unlock successful
|
||||
passwordModal?.hide();
|
||||
lockedDiv.style.display = 'none';
|
||||
unlockedDiv.style.display = 'block';
|
||||
if (data.channel_key && input) {
|
||||
input.value = data.channel_key;
|
||||
}
|
||||
} else {
|
||||
// Password incorrect
|
||||
verifyPasswordInput.classList.add('is-invalid');
|
||||
passwordError.textContent = data.error || 'Incorrect password';
|
||||
}
|
||||
} catch (error) {
|
||||
verifyPasswordInput.classList.add('is-invalid');
|
||||
passwordError.textContent = 'Verification failed';
|
||||
} finally {
|
||||
verifyPasswordBtn.disabled = false;
|
||||
verifyPasswordBtn.innerHTML = '<i class="bi bi-unlock me-1"></i>Unlock';
|
||||
}
|
||||
});
|
||||
|
||||
// Generate random key
|
||||
generateBtn?.addEventListener('click', function() {
|
||||
if (!input) return;
|
||||
if (typeof Stegasoo !== 'undefined' && Stegasoo.generateChannelKey) {
|
||||
input.value = Stegasoo.generateChannelKey();
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
}
|
||||
input.value = key;
|
||||
}
|
||||
});
|
||||
|
||||
// Show QR code in modal
|
||||
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;
|
||||
}
|
||||
|
||||
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
||||
|
||||
if (typeof QRious === 'undefined') {
|
||||
alert('QR Code library failed to load.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new QRious({
|
||||
element: canvas,
|
||||
value: formatted,
|
||||
size: 200,
|
||||
level: 'M'
|
||||
});
|
||||
if (displayEl) displayEl.textContent = formatted;
|
||||
modal?.show();
|
||||
} catch (error) {
|
||||
alert('Failed to generate QR code: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
// Print tiled QR sheet (US Letter)
|
||||
document.getElementById('channelKeyQrPrint')?.addEventListener('click', function() {
|
||||
if (canvas && displayEl) {
|
||||
printQrSheet(canvas, displayEl.textContent, 'Channel Key');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Print QR codes tiled on US Letter paper (8.5" x 11")
|
||||
function printQrSheet(canvas, keyText, title) {
|
||||
const qrDataUrl = canvas.toDataURL('image/png');
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
alert('Please allow popups to print');
|
||||
return;
|
||||
}
|
||||
|
||||
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
|
||||
const cols = 4;
|
||||
const rows = 5;
|
||||
|
||||
// Split key into two lines (4 groups each)
|
||||
const keyParts = keyText.split('-');
|
||||
const keyLine1 = keyParts.slice(0, 4).join('-');
|
||||
const keyLine2 = keyParts.slice(4).join('-');
|
||||
|
||||
let qrGrid = '';
|
||||
for (let i = 0; i < rows * cols; i++) {
|
||||
qrGrid += `
|
||||
<div class="qr-tile">
|
||||
<div class="key-text">${keyLine1}</div>
|
||||
<img src="${qrDataUrl}" alt="QR">
|
||||
<div class="key-text">${keyLine2}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style>
|
||||
@page {
|
||||
size: letter;
|
||||
margin: 0.2in;
|
||||
margin-top: 0.1in;
|
||||
margin-bottom: 0.1in;
|
||||
}
|
||||
@media print {
|
||||
@page { margin: 0.15in; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: white;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${cols}, 1fr);
|
||||
gap: 0;
|
||||
margin-top: 0.09in;
|
||||
}
|
||||
.qr-tile {
|
||||
border: 1px dashed #ccc;
|
||||
padding: 0.04in;
|
||||
text-align: center;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.qr-tile img {
|
||||
width: 1.6in;
|
||||
height: 1.6in;
|
||||
}
|
||||
.key-text {
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">${qrGrid}</div>
|
||||
<div class="footer">Cut along dashed lines</div>
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
}
|
||||
</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 %}
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Stegasoo{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='vendor/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='vendor/css/bootstrap-icons.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,48 +14,78 @@
|
||||
<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>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<ul class="navbar-nav ms-auto nav-icons">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
|
||||
<a class="nav-link nav-expand" href="/"><i class="bi bi-house"></i><span>Home</span></a>
|
||||
</li>
|
||||
{% if not auth_enabled or is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link nav-expand" href="/encode"><i class="bi bi-lock"></i><span>Encode</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
|
||||
<a class="nav-link nav-expand" href="/decode"><i class="bi bi-unlock"></i><span>Decode</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
|
||||
<a class="nav-link nav-expand" href="/generate"><i class="bi bi-key"></i><span>Generate</span></a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
||||
<a class="nav-link nav-expand" href="/tools"><i class="bi bi-tools"></i><span>Tools</span></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>
|
||||
<li><a class="dropdown-item" href="/admin/settings"><i class="bi bi-sliders me-2"></i>System Settings</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 ('warning' if category == 'warning' else 'success') }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-{{ 'exclamation-triangle' if category == 'error' else ('exclamation-circle' if category == 'warning' else 'check-circle') }} me-2"></i>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<div 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>
|
||||
|
||||
@@ -64,11 +94,19 @@
|
||||
<small>
|
||||
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
|
||||
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
|
||||
<span class="mx-2">|</span>
|
||||
<a href="/about" class="text-muted text-decoration-none"><i class="bi bi-info-circle me-1"></i>About</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- QR Code scanning library (local) -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/html5-qrcode.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>
|
||||
|
||||
@@ -4,6 +4,74 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Accordion styling */
|
||||
.step-accordion .accordion-button {
|
||||
background: rgba(30, 40, 50, 0.6);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.step-accordion .accordion-button:not(.collapsed) {
|
||||
background: linear-gradient(90deg, rgba(99, 179, 237, 0.15) 0%, rgba(40, 50, 60, 0.8) 40%, rgba(40, 50, 60, 0.8) 100%);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1);
|
||||
border-left: 3px solid rgba(99, 179, 237, 0.6);
|
||||
}
|
||||
.step-accordion .accordion-button::after {
|
||||
filter: invert(1);
|
||||
}
|
||||
.step-accordion .accordion-body {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
padding: 1rem;
|
||||
}
|
||||
.step-accordion .accordion-item {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
}
|
||||
.step-accordion .accordion-item:first-child .accordion-button {
|
||||
border-radius: 0;
|
||||
}
|
||||
.step-accordion .accordion-item:last-child .accordion-button.collapsed {
|
||||
border-radius: 0;
|
||||
}
|
||||
.step-summary {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-left: auto;
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
}
|
||||
.step-summary.has-content {
|
||||
color: rgba(99, 179, 237, 0.8);
|
||||
}
|
||||
.step-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.step-number {
|
||||
background: rgba(246, 173, 85, 0.2);
|
||||
color: #f6ad55;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
border: 1px solid rgba(246, 173, 85, 0.3);
|
||||
}
|
||||
.step-number.complete {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
border-color: rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
/* Glowing passphrase input */
|
||||
.passphrase-input {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
@@ -13,20 +81,17 @@
|
||||
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;
|
||||
transition: border-color 0.3s ease, box-shadow 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;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4) !important;
|
||||
}
|
||||
|
||||
.passphrase-input::placeholder {
|
||||
color: rgba(99, 179, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Glowing PIN input */
|
||||
/* 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;
|
||||
@@ -35,18 +100,10 @@
|
||||
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;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
||||
}
|
||||
|
||||
/* QR Crop Animation */
|
||||
@@ -55,8 +112,12 @@
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.qr-crop-container img {
|
||||
display: block;
|
||||
max-height: 180px;
|
||||
@@ -65,15 +126,10 @@
|
||||
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-original { opacity: 1; }
|
||||
.qr-crop-container .qr-cropped {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.3);
|
||||
opacity: 0;
|
||||
max-height: 160px;
|
||||
@@ -81,30 +137,15 @@
|
||||
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">
|
||||
@@ -113,31 +154,29 @@
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body {% if not decoded_message and not decoded_file %}p-0{% endif %}">
|
||||
{% if decoded_message %}
|
||||
<!-- Text Message Result -->
|
||||
<div class="alert alert-success">
|
||||
<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="alert-message p-3 rounded bg-dark border border-secondary mb-2" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-sm btn-outline-light" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => { this.innerHTML = '<i class=\'bi bi-check\'></i> Copied!'; setTimeout(() => this.innerHTML = '<i class=\'bi bi-clipboard\'></i> Copy', 2000); }).catch(() => alert('Failed to copy'))">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
|
||||
{% elif decoded_file %}
|
||||
<!-- File Result -->
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i>
|
||||
<h5 class="mt-3">{{ filename }}</h5>
|
||||
@@ -146,338 +185,266 @@
|
||||
<small class="text-muted">Type: {{ mime_type }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3">
|
||||
<i class="bi bi-download me-2"></i>Download File
|
||||
</a>
|
||||
|
||||
|
||||
<div class="alert alert-warning small">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<strong>File expires in 5 minutes.</strong> Download now.
|
||||
</div>
|
||||
|
||||
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
|
||||
{% else %}
|
||||
<!-- Decode Form -->
|
||||
<form method="POST" enctype="multipart/form-data" id="decodeForm">
|
||||
<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 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 same reference photo used for encoding
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<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" 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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-chat-quote me-1"></i> Passphrase
|
||||
</label>
|
||||
<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 passphrase used during encoding (typically 4 words)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h6 class="text-muted mb-3">
|
||||
SECURITY FACTORS
|
||||
<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>
|
||||
<div class="accordion step-accordion" id="decodeAccordion">
|
||||
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- .pem File Input -->
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
|
||||
<!-- QR Code Input -->
|
||||
<div id="rsaQrSection" class="d-none">
|
||||
<div class="drop-zone p-3" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<!-- Crop animation container -->
|
||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
|
||||
<!-- Data panel -->
|
||||
<div class="qr-data-panel">
|
||||
<div class="qr-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>RSA Key loaded</span>
|
||||
</div>
|
||||
<div class="qr-data-row">
|
||||
<span class="qr-status-badge">RSA Key</span>
|
||||
<span class="qr-data-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Password (always visible) -->
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepImagesNumber">1</span>
|
||||
<i class="bi bi-images me-1"></i> Images & Mode
|
||||
</span>
|
||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">If PIN was used during encoding</div>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
<span class="badge bg-info ms-1">v4.0</span>
|
||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||
</label>
|
||||
<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 scan-container" id="refDropZone">
|
||||
<input type="file" name="reference_photo" accept="image/*" required id="refPhotoInput">
|
||||
<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</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="refPreview">
|
||||
<div class="scan-overlay"><div class="scan-grid"></div><div class="scan-line"></div></div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-text">Same reference photo used for encoding</div>
|
||||
</div>
|
||||
|
||||
<select class="form-select" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||
<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</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<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>
|
||||
<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">-- x -- px</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Image containing the hidden message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server channel indicator (compact) -->
|
||||
{% if channel_configured %}
|
||||
<div class="small text-success mt-2">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||
<div class="mb-4 d-none" id="channelCustomInputDec">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
|
||||
id="channelKeyInputDec">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
ADVANCED OPTIONS (v3.0) - Extraction Mode
|
||||
================================================================ -->
|
||||
<div class="mb-4">
|
||||
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
|
||||
<i class="bi bi-gear me-1"></i> Advanced Options
|
||||
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
|
||||
</a>
|
||||
|
||||
<div class="collapse" id="advancedOptionsDec">
|
||||
<div class="card card-body mt-2 bg-dark border-secondary">
|
||||
|
||||
<!-- Extraction Mode Selection -->
|
||||
<div class="mb-0">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-cpu me-1"></i> Extraction Mode
|
||||
<span class="badge bg-info ms-1">v3.0</span>
|
||||
</label>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<!-- Auto Mode -->
|
||||
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
||||
<!-- Extraction Mode (compact inline) -->
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||
<label class="mode-btn mode-btn-sm 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>
|
||||
<i class="bi bi-magic text-success"></i> Auto
|
||||
</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 class="mode-btn mode-btn-sm" id="lsbModeCard" for="modeLsb">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb">
|
||||
<i class="bi bi-grid-3x3-gap text-primary"></i> LSB
|
||||
</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 class="mode-btn mode-btn-sm {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<i class="bi bi-soundwave text-warning"></i> DCT
|
||||
</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 class="form-text" id="modeHint">
|
||||
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: SECURITY
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepSecurityNumber">2</span>
|
||||
<i class="bi bi-shield-lock me-1"></i> Security
|
||||
</span>
|
||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepSecurity" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<!-- Passphrase -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-chat-quote me-1"></i> Passphrase</label>
|
||||
<input type="text" name="passphrase" class="form-control passphrase-input"
|
||||
placeholder="e.g., apple forest thunder mountain" required id="passphraseInput">
|
||||
<div class="form-text">The passphrase used during encoding</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3 opacity-25">
|
||||
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- PIN -->
|
||||
<div class="col-md-6 mb-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Channel -->
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-broadcast me-1"></i> Channel</label>
|
||||
<select class="form-select form-select-sm" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server){% 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 }}">{{ key.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key -->
|
||||
<div class="mb-3 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 form-control-sm font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" id="channelKeyInputDec">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" id="channelKeyScanDec" title="Scan QR"><i class="bi bi-camera"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSA Key -->
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-file-earmark-lock me-1"></i> RSA Key <span class="text-muted">(if used)</span></label>
|
||||
<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</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</label>
|
||||
</div>
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
|
||||
</div>
|
||||
<div id="rsaQrSection" class="d-none d-flex flex-column">
|
||||
<input type="hidden" name="rsa_key_pem" id="rsaKeyPem">
|
||||
<div class="drop-zone p-2 w-100" 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-5 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image</span>
|
||||
</div>
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2" id="rsaQrWebcam">
|
||||
<i class="bi bi-camera me-1"></i>Scan with Camera
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||
<i class="bi bi-unlock me-2"></i>Decode
|
||||
</button>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="p-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||
<i class="bi bi-unlock me-2"></i>Decode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if not decoded_message and not decoded_file %}
|
||||
<!-- Troubleshooting Card -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<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-check-circle-fill text-success me-1"></i>
|
||||
Use the <strong>exact same reference photo</strong> file (byte-for-byte identical)
|
||||
Use the <strong>exact same reference photo</strong> (byte-for-byte identical)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<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-check-circle-fill text-success me-1"></i>
|
||||
Provide the <strong>same security factors</strong> (PIN and/or RSA key) used during encoding
|
||||
Enter the <strong>exact passphrase</strong> used during encoding
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<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)
|
||||
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<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
|
||||
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -490,28 +457,111 @@
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
// 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')
|
||||
// ============================================================================
|
||||
// MODE HINT - Dynamic text based on selected extraction mode
|
||||
// ============================================================================
|
||||
const modeHints = {
|
||||
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
||||
dct: { icon: 'phone', text: 'For social media images' }
|
||||
};
|
||||
|
||||
extractModeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
|
||||
extractModeBtns[radio.value]?.classList.add('active');
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const hint = document.getElementById('modeHint');
|
||||
const data = modeHints[this.value];
|
||||
if (hint && data) {
|
||||
hint.innerHTML = `<i class="bi bi-${data.icon} me-1"></i>${data.text}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Advanced options chevron
|
||||
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
|
||||
advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
|
||||
});
|
||||
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
|
||||
// ============================================================================
|
||||
// ACCORDION SUMMARY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
function updateImagesSummary() {
|
||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||
const stego = document.getElementById('stegoInput')?.files[0];
|
||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
||||
const summary = document.getElementById('stepImagesSummary');
|
||||
const stepNum = document.getElementById('stepImagesNumber');
|
||||
|
||||
if (ref && stego) {
|
||||
const refName = ref.name.length > 12 ? ref.name.slice(0, 10) + '..' : ref.name;
|
||||
const stegoName = stego.name.length > 12 ? stego.name.slice(0, 10) + '..' : stego.name;
|
||||
summary.textContent = `${refName} + ${stegoName}, ${mode}`;
|
||||
summary.classList.add('has-content');
|
||||
stepNum.classList.add('complete');
|
||||
stepNum.innerHTML = '<i class="bi bi-check"></i>';
|
||||
} else if (ref || stego) {
|
||||
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & stego';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function updateSecuritySummary() {
|
||||
const passphrase = document.getElementById('passphraseInput')?.value || '';
|
||||
const pin = document.getElementById('pinInput')?.value || '';
|
||||
const rsaFile = document.querySelector('input[name="rsa_key"]')?.files[0];
|
||||
const rsaPem = document.getElementById('rsaKeyPem')?.value || '';
|
||||
const summary = document.getElementById('stepSecuritySummary');
|
||||
const stepNum = document.getElementById('stepSecurityNumber');
|
||||
|
||||
const parts = [];
|
||||
if (passphrase.trim()) parts.push('passphrase');
|
||||
if (pin) parts.push('PIN');
|
||||
if (rsaFile || rsaPem) parts.push('RSA');
|
||||
|
||||
if (parts.length > 0) {
|
||||
summary.textContent = parts.join(' + ');
|
||||
summary.classList.add('has-content');
|
||||
if (passphrase.trim()) {
|
||||
stepNum.classList.add('complete');
|
||||
stepNum.innerHTML = '<i class="bi bi-check"></i>';
|
||||
}
|
||||
} else {
|
||||
summary.textContent = 'Passphrase & keys';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
|
||||
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
||||
|
||||
// ============================================================================
|
||||
// MODE SWITCHING
|
||||
// ============================================================================
|
||||
|
||||
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
||||
const modeBtns = { 'auto': document.getElementById('autoModeCard'), 'lsb': document.getElementById('lsbModeCard'), 'dct': document.getElementById('dctModeCard') };
|
||||
|
||||
modeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
|
||||
modeBtns[radio.value]?.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LOADING STATE
|
||||
// ============================================================================
|
||||
|
||||
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -74,36 +74,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Channel Key Generation (v4.0.0) -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel Key
|
||||
<span class="badge bg-info ms-1">v4.0</span>
|
||||
<a href="{{ url_for('about') }}#channel-keys" class="text-muted ms-2" title="Learn about channel keys">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</label>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
|
||||
placeholder="Click Generate" readonly>
|
||||
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
|
||||
<i class="bi bi-shuffle me-1"></i>Generate
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">For private groups: generate, then use <strong>Custom</strong> mode when encoding/decoding.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
|
||||
<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 -->
|
||||
@@ -498,61 +509,12 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
// ============================================================================
|
||||
// GENERATE PAGE - Form Controls
|
||||
// ============================================================================
|
||||
|
||||
// Words range slider
|
||||
const wordsRange = document.getElementById('wordsRange');
|
||||
const wordsValue = document.getElementById('wordsValue');
|
||||
|
||||
wordsRange?.addEventListener('input', function() {
|
||||
const bits = this.value * 11;
|
||||
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
||||
});
|
||||
|
||||
// Toggle PIN/RSA options visibility
|
||||
const usePinCheck = document.getElementById('usePinCheck');
|
||||
const useRsaCheck = document.getElementById('useRsaCheck');
|
||||
const pinOptions = document.getElementById('pinOptions');
|
||||
const rsaOptions = document.getElementById('rsaOptions');
|
||||
const rsaQrWarning = document.getElementById('rsaQrWarning');
|
||||
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
<script src="{{ url_for('static', filename='js/generate.js') }}"></script>
|
||||
{% if generated %}
|
||||
// ============================================================================
|
||||
// GENERATE PAGE - Credential Display
|
||||
// ============================================================================
|
||||
<script>
|
||||
// Page-specific data from Jinja
|
||||
const passphraseWords = '{{ passphrase|default("", true) }}'.split(' ').filter(w => w.length > 0);
|
||||
|
||||
// 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;
|
||||
pinDigits?.classList.toggle('blurred', pinHidden);
|
||||
|
||||
if (icon) icon.className = pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
if (text) text.textContent = pinHidden ? 'Show' : 'Hide';
|
||||
}
|
||||
|
||||
// Copy PIN
|
||||
function copyPin() {
|
||||
Stegasoo.copyToClipboard(
|
||||
'{{ pin|default("", true) }}',
|
||||
@@ -561,21 +523,6 @@ function copyPin() {
|
||||
);
|
||||
}
|
||||
|
||||
// Passphrase visibility toggle
|
||||
let passphraseHidden = false;
|
||||
function togglePassphraseVisibility() {
|
||||
const display = document.getElementById('passphraseDisplay');
|
||||
const icon = document.getElementById('passphraseToggleIcon');
|
||||
const text = document.getElementById('passphraseToggleText');
|
||||
|
||||
passphraseHidden = !passphraseHidden;
|
||||
display?.classList.toggle('blurred', passphraseHidden);
|
||||
|
||||
if (icon) icon.className = passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
if (text) text.textContent = passphraseHidden ? 'Show' : 'Hide';
|
||||
}
|
||||
|
||||
// Copy passphrase
|
||||
function copyPassphrase() {
|
||||
Stegasoo.copyToClipboard(
|
||||
'{{ passphrase|default("", true) }}',
|
||||
@@ -584,148 +531,13 @@ function copyPassphrase() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Aid Story Generation - Templates by word count
|
||||
// ============================================================================
|
||||
|
||||
const passphrase = '{{ passphrase|default("", true) }}';
|
||||
const passphraseWords = passphrase.split(' ').filter(w => w.length > 0);
|
||||
let currentStoryTemplate = 0;
|
||||
|
||||
// Templates organized by word count (3-12 words supported)
|
||||
const storyTemplatesByLength = {
|
||||
3: [
|
||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}.`,
|
||||
w => `${hl(w[0])} loves ${hl(w[1])} and ${hl(w[2])}.`,
|
||||
w => `A ${hl(w[0])} found a ${hl(w[1])} near the ${hl(w[2])}.`,
|
||||
w => `${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])} — never forget.`,
|
||||
w => `The ${hl(w[0])} hid the ${hl(w[1])} under the ${hl(w[2])}.`,
|
||||
],
|
||||
4: [
|
||||
w => `${hl(w[0])} and ${hl(w[1])} discovered a ${hl(w[2])} made of ${hl(w[3])}.`,
|
||||
w => `The ${hl(w[0])} ${hl(w[1])} ate ${hl(w[2])} for ${hl(w[3])}.`,
|
||||
w => `In the ${hl(w[0])}, a ${hl(w[1])} met a ${hl(w[2])} carrying ${hl(w[3])}.`,
|
||||
w => `${hl(w[0])} said "${hl(w[1])}" while holding a ${hl(w[2])} ${hl(w[3])}.`,
|
||||
w => `The secret: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}.`,
|
||||
],
|
||||
5: [
|
||||
w => `${hl(w[0])} traveled to ${hl(w[1])} seeking the ${hl(w[2])} of ${hl(w[3])} and ${hl(w[4])}.`,
|
||||
w => `The ${hl(w[0])} ${hl(w[1])} lived in a ${hl(w[2])} house with ${hl(w[3])} ${hl(w[4])}.`,
|
||||
w => `"${hl(w[0])}!" shouted ${hl(w[1])} as the ${hl(w[2])} ${hl(w[3])} flew toward ${hl(w[4])}.`,
|
||||
w => `Captain ${hl(w[0])} sailed the ${hl(w[1])} ${hl(w[2])} searching for ${hl(w[3])} ${hl(w[4])}.`,
|
||||
w => `In ${hl(w[0])} kingdom, ${hl(w[1])} guards protected the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])}.`,
|
||||
],
|
||||
6: [
|
||||
w => `${hl(w[0])} met ${hl(w[1])} at the ${hl(w[2])}. Together they found ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
|
||||
w => `The ${hl(w[0])} ${hl(w[1])} wore a ${hl(w[2])} hat while eating ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
|
||||
w => `Detective ${hl(w[0])} found ${hl(w[1])} ${hl(w[2])} near the ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
|
||||
w => `In the ${hl(w[0])} ${hl(w[1])}, a ${hl(w[2])} ${hl(w[3])} sang about ${hl(w[4])} ${hl(w[5])}.`,
|
||||
w => `Chef ${hl(w[0])} combined ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
|
||||
],
|
||||
7: [
|
||||
w => `${hl(w[0])} and ${hl(w[1])} walked through the ${hl(w[2])} ${hl(w[3])} to find the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
|
||||
w => `The ${hl(w[0])} professor studied ${hl(w[1])} ${hl(w[2])} while drinking ${hl(w[3])} ${hl(w[4])} with ${hl(w[5])} ${hl(w[6])}.`,
|
||||
w => `"${hl(w[0])} ${hl(w[1])}!" yelled ${hl(w[2])} as ${hl(w[3])} ${hl(w[4])} attacked the ${hl(w[5])} ${hl(w[6])}.`,
|
||||
w => `In ${hl(w[0])}, King ${hl(w[1])} decreed that ${hl(w[2])} ${hl(w[3])} must honor ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
|
||||
],
|
||||
8: [
|
||||
w => `${hl(w[0])} ${hl(w[1])} and ${hl(w[2])} ${hl(w[3])} met at the ${hl(w[4])} ${hl(w[5])} to discuss ${hl(w[6])} ${hl(w[7])}.`,
|
||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} traveled from ${hl(w[3])} to ${hl(w[4])} carrying ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
|
||||
w => `${hl(w[0])} discovered that ${hl(w[1])} ${hl(w[2])} plus ${hl(w[3])} ${hl(w[4])} equals ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
|
||||
],
|
||||
9: [
|
||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} watched as ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} danced with ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
|
||||
w => `In the ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}, three friends — ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])} — found ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
|
||||
w => `The recipe: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}.`,
|
||||
],
|
||||
10: [
|
||||
w => `${hl(w[0])} ${hl(w[1])} told ${hl(w[2])} ${hl(w[3])} about the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])} hidden in ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
|
||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} lived beside ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
|
||||
],
|
||||
11: [
|
||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} and ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} discovered ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
|
||||
w => `In ${hl(w[0])} ${hl(w[1])}, the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} sang of ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
|
||||
],
|
||||
12: [
|
||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} met ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} at the ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])} ${hl(w[11])}.`,
|
||||
w => `The twelve treasures: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}, ${hl(w[9])}, ${hl(w[10])}, ${hl(w[11])}.`,
|
||||
],
|
||||
};
|
||||
|
||||
function hl(word) {
|
||||
return `<span class="passphrase-word">${word}</span>`;
|
||||
}
|
||||
|
||||
function generateStory(idx = null) {
|
||||
const count = passphraseWords.length;
|
||||
if (count === 0) return '';
|
||||
|
||||
// Clamp to supported range (3-12)
|
||||
const templateKey = Math.max(3, Math.min(12, count));
|
||||
const templates = storyTemplatesByLength[templateKey];
|
||||
|
||||
if (!templates || templates.length === 0) {
|
||||
// Fallback: just list the words
|
||||
return passphraseWords.map(w => hl(w)).join(' — ');
|
||||
}
|
||||
|
||||
const templateIdx = (idx ?? currentStoryTemplate) % templates.length;
|
||||
return templates[templateIdx](passphraseWords);
|
||||
}
|
||||
|
||||
function toggleMemoryAid() {
|
||||
const container = document.getElementById('memoryAidContainer');
|
||||
const icon = document.getElementById('memoryAidIcon');
|
||||
const text = document.getElementById('memoryAidText');
|
||||
|
||||
const isHidden = container?.classList.contains('d-none');
|
||||
container?.classList.toggle('d-none', !isHidden);
|
||||
|
||||
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
|
||||
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
|
||||
|
||||
if (isHidden) {
|
||||
document.getElementById('memoryStory').innerHTML = generateStory();
|
||||
}
|
||||
StegasooGenerate.toggleMemoryAid(passphraseWords);
|
||||
}
|
||||
|
||||
function regenerateStory() {
|
||||
const count = passphraseWords.length;
|
||||
const templateKey = Math.max(3, Math.min(12, count));
|
||||
const templates = storyTemplatesByLength[templateKey] || [];
|
||||
currentStoryTemplate = (currentStoryTemplate + 1) % Math.max(1, templates.length);
|
||||
document.getElementById('memoryStory').innerHTML = generateStory(currentStoryTemplate);
|
||||
StegasooGenerate.regenerateStory(passphraseWords);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<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">
|
||||
<h1 class="display-4 fw-bold mb-2 title-gold">
|
||||
Stegasoo
|
||||
<span class="badge bg-success fs-6 ms-2">v4.0</span>
|
||||
<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>
|
||||
@@ -162,7 +162,7 @@
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-broadcast text-success me-2"></i>
|
||||
<strong>Channel keys</strong> for group isolation
|
||||
<span class="badge bg-info ms-1">v4.0</span>
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
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 %}
|
||||
1121
frontends/web/templates/tools.html
Normal file
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal Flask app to isolate the crash.
|
||||
Run with: python minimal_flask_crash.py
|
||||
|
||||
Then test with:
|
||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1
|
||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test2
|
||||
curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test3
|
||||
"""
|
||||
|
||||
import io
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Minimal imports first
|
||||
from flask import Flask, request, jsonify
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
# Check for jpegio
|
||||
try:
|
||||
import jpegio as jio
|
||||
HAS_JPEGIO = True
|
||||
print("jpegio: available")
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
print("jpegio: NOT available")
|
||||
|
||||
|
||||
@app.route('/test1', methods=['POST'])
|
||||
def test1_pil_only():
|
||||
"""Test 1: PIL only, no jpegio, no scipy"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test1] Read {len(data)} bytes")
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
width, height = img.size
|
||||
fmt = img.format
|
||||
img.close()
|
||||
print(f"[test1] Image: {width}x{height} {fmt}")
|
||||
|
||||
gc.collect()
|
||||
print("[test1] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'pil_only',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'format': fmt,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test2', methods=['POST'])
|
||||
def test2_multiple_opens():
|
||||
"""Test 2: Open image multiple times like compare_modes does"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test2] Read {len(data)} bytes")
|
||||
|
||||
# First open
|
||||
img1 = Image.open(io.BytesIO(data))
|
||||
width, height = img1.size
|
||||
img1.close()
|
||||
print(f"[test2] Open 1: {width}x{height}")
|
||||
|
||||
# Second open
|
||||
img2 = Image.open(io.BytesIO(data))
|
||||
pixels = img2.size[0] * img2.size[1]
|
||||
img2.close()
|
||||
print(f"[test2] Open 2: {pixels} pixels")
|
||||
|
||||
# Third open
|
||||
img3 = Image.open(io.BytesIO(data))
|
||||
blocks = (img3.size[0] // 8) * (img3.size[1] // 8)
|
||||
img3.close()
|
||||
print(f"[test2] Open 3: {blocks} blocks")
|
||||
|
||||
gc.collect()
|
||||
print("[test2] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'multiple_opens',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'pixels': pixels,
|
||||
'blocks': blocks,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test3', methods=['POST'])
|
||||
def test3_with_jpegio():
|
||||
"""Test 3: Include jpegio operations"""
|
||||
if not HAS_JPEGIO:
|
||||
return jsonify({'error': 'jpegio not available'}), 501
|
||||
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test3] Read {len(data)} bytes")
|
||||
|
||||
# Check if JPEG
|
||||
img = Image.open(io.BytesIO(data))
|
||||
is_jpeg = img.format == 'JPEG'
|
||||
width, height = img.size
|
||||
img.close()
|
||||
print(f"[test3] Image: {width}x{height}, JPEG: {is_jpeg}")
|
||||
|
||||
if not is_jpeg:
|
||||
return jsonify({'error': 'Not a JPEG'}), 400
|
||||
|
||||
# Write to temp file
|
||||
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
os.write(fd, data)
|
||||
os.close(fd)
|
||||
print(f"[test3] Temp file: {temp_path}")
|
||||
|
||||
try:
|
||||
# Read with jpegio
|
||||
jpeg = jio.read(temp_path)
|
||||
print(f"[test3] jpegio.read() OK")
|
||||
|
||||
coef = jpeg.coef_arrays[0]
|
||||
coef_shape = coef.shape
|
||||
print(f"[test3] Coef shape: {coef_shape}")
|
||||
|
||||
# Count positions like the real code does
|
||||
positions = 0
|
||||
h, w = coef.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef[row, col]) >= 2:
|
||||
positions += 1
|
||||
print(f"[test3] Usable positions: {positions}")
|
||||
|
||||
# Cleanup
|
||||
del coef
|
||||
del jpeg
|
||||
print(f"[test3] Deleted jpegio objects")
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
print(f"[test3] Removed temp file")
|
||||
|
||||
gc.collect()
|
||||
print("[test3] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'with_jpegio',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'coef_shape': list(coef_shape),
|
||||
'positions': positions,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test4', methods=['POST'])
|
||||
def test4_numpy_array_from_pil():
|
||||
"""Test 4: Create numpy array from PIL image (like DCT does)"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
data = carrier.read()
|
||||
print(f"[test4] Read {len(data)} bytes")
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
width, height = img.size
|
||||
print(f"[test4] Image: {width}x{height}")
|
||||
|
||||
# Convert to grayscale and numpy array
|
||||
gray = img.convert('L')
|
||||
arr = np.array(gray, dtype=np.float64, copy=True)
|
||||
print(f"[test4] Array: {arr.shape} {arr.dtype}")
|
||||
|
||||
# Close PIL images
|
||||
gray.close()
|
||||
img.close()
|
||||
print(f"[test4] PIL closed")
|
||||
|
||||
# Do some numpy operations
|
||||
mean_val = float(np.mean(arr))
|
||||
std_val = float(np.std(arr))
|
||||
print(f"[test4] Stats: mean={mean_val:.2f}, std={std_val:.2f}")
|
||||
|
||||
# Clear array
|
||||
del arr
|
||||
gc.collect()
|
||||
print("[test4] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'numpy_from_pil',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'mean': mean_val,
|
||||
'std': std_val,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/test5', methods=['POST'])
|
||||
def test5_file_read_keep_reference():
|
||||
"""Test 5: Keep reference to file data in request scope"""
|
||||
carrier = request.files.get('carrier')
|
||||
if not carrier:
|
||||
return jsonify({'error': 'No carrier'}), 400
|
||||
|
||||
# Don't read into local variable - read directly each time
|
||||
# This mimics potential issues with Flask's file handling
|
||||
|
||||
print(f"[test5] File object: {carrier}")
|
||||
|
||||
# Read once
|
||||
carrier.seek(0)
|
||||
data1 = carrier.read()
|
||||
print(f"[test5] First read: {len(data1)} bytes")
|
||||
|
||||
img = Image.open(io.BytesIO(data1))
|
||||
width, height = img.size
|
||||
img.close()
|
||||
|
||||
# Try to read again (should be empty or need seek)
|
||||
data2 = carrier.read()
|
||||
print(f"[test5] Second read (no seek): {len(data2)} bytes")
|
||||
|
||||
carrier.seek(0)
|
||||
data3 = carrier.read()
|
||||
print(f"[test5] Third read (after seek): {len(data3)} bytes")
|
||||
|
||||
gc.collect()
|
||||
print("[test5] Returning response...")
|
||||
|
||||
return jsonify({
|
||||
'test': 'file_handling',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'read1': len(data1),
|
||||
'read2': len(data2),
|
||||
'read3': len(data3),
|
||||
})
|
||||
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
"""Log after each request"""
|
||||
print(f"[after_request] Response status: {response.status}")
|
||||
return response
|
||||
|
||||
|
||||
@app.teardown_request
|
||||
def teardown_request(exception):
|
||||
"""Log during teardown"""
|
||||
if exception:
|
||||
print(f"[teardown] Exception: {exception}")
|
||||
else:
|
||||
print("[teardown] Clean teardown")
|
||||
gc.collect()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n" + "=" * 60)
|
||||
print("MINIMAL FLASK CRASH TEST")
|
||||
print("=" * 60)
|
||||
print("\nTest endpoints:")
|
||||
print(" /test1 - PIL only")
|
||||
print(" /test2 - Multiple PIL opens")
|
||||
print(" /test3 - With jpegio")
|
||||
print(" /test4 - NumPy array from PIL")
|
||||
print(" /test5 - File handling test")
|
||||
print("\nUsage:")
|
||||
print(' curl -X POST -F "carrier=@xx_2.jpg" http://localhost:5001/test1')
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
app.run(host='0.0.0.0', port=5001, debug=False, threaded=False)
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.0.1"
|
||||
version = "4.1.5"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -48,10 +48,13 @@ 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",
|
||||
"rich>=13.0.0",
|
||||
]
|
||||
compression = [
|
||||
"lz4>=4.0.0",
|
||||
@@ -61,10 +64,12 @@ web = [
|
||||
"gunicorn>=21.0.0",
|
||||
"qrcode>=7.3.0",
|
||||
"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",
|
||||
@@ -76,6 +81,7 @@ api = [
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
all = [
|
||||
"stegasoo[cli,web,api,dct,compression]",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd ./frontends/web/
|
||||
python app.py
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
sudo docker-compose down
|
||||
sudo docker-compose build
|
||||
sudo docker-compose up -d
|
||||
186
rpi/BUILD_IMAGE.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Stegasoo Pi Image Build Workflow
|
||||
|
||||
> **Note:** This guide is for developers building custom Pi images.
|
||||
> **End users:** Just download the pre-built `.img.zst` from [Releases](https://github.com/adlee-was-taken/stegasoo/releases), flash it, and boot. No build process needed.
|
||||
|
||||
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 and zstd (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
||||
```
|
||||
|
||||
## Step 4: Clone Repo
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
```
|
||||
|
||||
## Step 5: Copy Pre-built Tarball (from host)
|
||||
|
||||
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
|
||||
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
|
||||
|
||||
```bash
|
||||
# On your host machine:
|
||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||
```
|
||||
|
||||
This tarball contains:
|
||||
- pyenv with Python 3.12 (pre-compiled for ARM64)
|
||||
- venv with all dependencies (jpegio, scipy, etc.)
|
||||
|
||||
Install time: **~2 minutes** (vs 20+ min from source)
|
||||
|
||||
## Step 6: Run Setup
|
||||
|
||||
```bash
|
||||
cd /opt/stegasoo
|
||||
./rpi/setup.sh # Detects local tarball, skips download
|
||||
```
|
||||
|
||||
### From-Source Build (optional)
|
||||
|
||||
To build without the pre-built tarball:
|
||||
```bash
|
||||
./rpi/setup.sh --no-prebuilt # Takes 15-20 minutes
|
||||
```
|
||||
|
||||
## Step 7: Test It Works
|
||||
|
||||
```bash
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
## Step 8: 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 9: Pull the Image
|
||||
|
||||
Remove SD card, insert into your Linux machine:
|
||||
|
||||
```bash
|
||||
# Find the SD card device (CAREFUL!)
|
||||
lsblk
|
||||
|
||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||
```
|
||||
|
||||
The script automatically resizes rootfs to 16GB, disables auto-expand, and compresses.
|
||||
|
||||
## Step 10: Distribute
|
||||
|
||||
Upload `.img.zst` to GitHub Releases.
|
||||
|
||||
Users can flash with:
|
||||
```bash
|
||||
# Option 1: rpi-imager CLI (supports .zst.zip directly)
|
||||
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
|
||||
|
||||
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
|
||||
|
||||
# Option 3: Manual dd
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating the Pre-built Tarball
|
||||
|
||||
After a successful from-source build, create the pre-built tarball for future installs:
|
||||
|
||||
```bash
|
||||
# On the Pi after successful setup:
|
||||
cd ~
|
||||
|
||||
# Strip caches and tests from venv (295MB → 208MB)
|
||||
find /opt/stegasoo/venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||
find /opt/stegasoo/venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
||||
find /opt/stegasoo/venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
# Create venv tarball
|
||||
cd /opt/stegasoo
|
||||
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
|
||||
|
||||
# Create combined tarball (pyenv + venv pointer)
|
||||
cd ~
|
||||
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
|
||||
|
||||
# Check size (should be ~50-60MB)
|
||||
ls -lh /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
|
||||
```
|
||||
|
||||
Pull to host and upload to GitHub releases:
|
||||
```bash
|
||||
# On host:
|
||||
scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
||||
# Upload to GitHub releases as stegasoo-rpi-runtime-env-arm64.tar.zst
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Command Summary
|
||||
|
||||
```bash
|
||||
# On Pi (after SSH):
|
||||
sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
||||
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
|
||||
# On host (copy tarball):
|
||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||
|
||||
# On Pi (run setup):
|
||||
cd /opt/stegasoo && ./rpi/setup.sh
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# On host (pull image - auto-resizes to 16GB):
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||
```
|
||||
225
rpi/README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 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
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd 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)
|
||||
- 16GB+ SD card (pre-built images are 16GB)
|
||||
- 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`
|
||||
|
||||
## Updating an Existing Installation
|
||||
|
||||
To update to the latest version:
|
||||
|
||||
```bash
|
||||
cd /opt/stegasoo
|
||||
git pull origin main
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
That's it - the editable install means Python uses the source directly.
|
||||
|
||||
**If dependencies changed** (check release notes), also run:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
pip install -e ".[web]"
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
## 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
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd 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
|
||||
|
||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||
```
|
||||
|
||||
The `pull-image.sh` script automatically:
|
||||
- Resizes rootfs to exactly 16GB (consistent image size)
|
||||
- Disables Pi OS auto-expand
|
||||
- Compresses with zstd for fast decompression
|
||||
|
||||
### 6. Distribute
|
||||
|
||||
Upload the `.img.zst` file to GitHub Releases.
|
||||
|
||||
Users flash with:
|
||||
```bash
|
||||
# Option 1: rpi-imager CLI (supports .zst.zip directly)
|
||||
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
|
||||
|
||||
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
|
||||
|
||||
# Option 3: Manual dd
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
63
rpi/banner.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Banner/Header Template
|
||||
# Source this file to use the banner functions
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/banner.sh"
|
||||
# print_banner "Raspberry Pi Setup"
|
||||
# print_gradient_line
|
||||
|
||||
# Colors
|
||||
STEGASOO_GOLD='\033[38;5;220m'
|
||||
STEGASOO_GRAY='\033[0;90m'
|
||||
STEGASOO_WHITE='\033[1;37m'
|
||||
STEGASOO_GREEN='\033[0;32m'
|
||||
STEGASOO_NC='\033[0m'
|
||||
|
||||
# Gradient line (purple -> blue)
|
||||
print_gradient_line() {
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
}
|
||||
|
||||
# Starfield decoration line
|
||||
print_starfield() {
|
||||
echo -e "${STEGASOO_GRAY} · . · . * · . * · . * · . * · . * · . ·${STEGASOO_NC}"
|
||||
}
|
||||
|
||||
# ASCII logo (gold)
|
||||
print_logo() {
|
||||
echo -e "${STEGASOO_GOLD} ___ _____ ___ ___ _ ___ ___ ___${STEGASOO_NC}"
|
||||
echo -e "${STEGASOO_GOLD} / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\${STEGASOO_NC}"
|
||||
echo -e "${STEGASOO_GOLD} \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |${STEGASOO_NC}"
|
||||
echo -e "${STEGASOO_GOLD} |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/${STEGASOO_NC}"
|
||||
}
|
||||
|
||||
# Full banner with optional subtitle
|
||||
# Usage: print_banner "Subtitle Text"
|
||||
print_banner() {
|
||||
local subtitle="$1"
|
||||
echo ""
|
||||
print_gradient_line
|
||||
print_starfield
|
||||
print_logo
|
||||
print_starfield
|
||||
print_gradient_line
|
||||
if [ -n "$subtitle" ]; then
|
||||
echo -e "${STEGASOO_WHITE} ${subtitle}${STEGASOO_NC}"
|
||||
print_gradient_line
|
||||
fi
|
||||
}
|
||||
|
||||
# Completion banner (green title)
|
||||
# Usage: print_complete_banner "Setup Complete!"
|
||||
print_complete_banner() {
|
||||
local title="$1"
|
||||
echo ""
|
||||
print_gradient_line
|
||||
print_starfield
|
||||
print_logo
|
||||
print_starfield
|
||||
print_gradient_line
|
||||
echo -e "\033[1;32m ${title}\033[0m"
|
||||
print_gradient_line
|
||||
}
|
||||
12
rpi/config.json.example
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hostname": "stegasoo",
|
||||
"username": "admin",
|
||||
"password": "stegasoo",
|
||||
"wifiSSID": "YourNetworkName",
|
||||
"wifiPassword": "YourWiFiPassword",
|
||||
"wifiCountry": "US",
|
||||
"locale": "en_US.UTF-8",
|
||||
"keyboardLayout": "us",
|
||||
"timezone": "America/New_York",
|
||||
"enableSSH": true
|
||||
}
|
||||
438
rpi/first-boot-wizard.sh
Executable file
@@ -0,0 +1,438 @@
|
||||
#!/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"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source banner functions
|
||||
source "$SCRIPT_DIR/banner.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
|
||||
# =============================================================================
|
||||
|
||||
print_banner "First Boot Wizard"
|
||||
echo ""
|
||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server"
|
||||
echo ""
|
||||
gum style --foreground 245 "You can reconfigure later by editing:"
|
||||
gum style --foreground 214 " /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"
|
||||
|
||||
# Generate SSL certificates if HTTPS enabled
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
gum spin --spinner dot --title "Generating SSL certificates..." -- bash -c "
|
||||
CERT_DIR='$INSTALL_DIR/frontends/web/certs'
|
||||
mkdir -p \"\$CERT_DIR\"
|
||||
|
||||
# Get local IP for SAN
|
||||
LOCAL_IP=\$(hostname -I | awk '{print \$1}')
|
||||
HOSTNAME=\$(hostname)
|
||||
|
||||
# Generate cert with SANs for IP, hostname, and localhost
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout \"\$CERT_DIR/server.key\" \
|
||||
-out \"\$CERT_DIR/server.crt\" \
|
||||
-days 365 -nodes \
|
||||
-subj \"/O=Stegasoo/CN=\$HOSTNAME\" \
|
||||
-addext \"subjectAltName=DNS:\$HOSTNAME,DNS:\$HOSTNAME.local,DNS:localhost,IP:\$LOCAL_IP,IP:127.0.0.1\" \
|
||||
2>/dev/null
|
||||
|
||||
# Fix permissions
|
||||
chmod 600 \"\$CERT_DIR/server.key\"
|
||||
chown -R $STEGASOO_USER:\$(id -gn $STEGASOO_USER) \"\$CERT_DIR\"
|
||||
"
|
||||
gum style --foreground 82 "✓ SSL certificates generated"
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
echo ""
|
||||
print_complete_banner "Setup Complete!"
|
||||
echo ""
|
||||
gum style --foreground 82 --bold "Create your admin account:"
|
||||
gum style --foreground 226 " $ACCESS_URL_LOCAL"
|
||||
gum style --foreground 245 " $ACCESS_URL (fallback IP)"
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ]; then
|
||||
echo ""
|
||||
echo -e "\033[1;32mChannel Key:\033[0m \033[0;33m$CHANNEL_KEY\033[0m"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
gum style --foreground 82 --bold "First Steps:"
|
||||
gum style --foreground 255 " 1. Open URL → 2. Accept cert → 3. Create admin → 4. Encode!"
|
||||
|
||||
echo ""
|
||||
gum style --foreground 245 "Commands: systemctl {status|restart} stegasoo, journalctl -u stegasoo -f"
|
||||
|
||||
# Prompt for restart if overclock was enabled
|
||||
if [ "$NEEDS_RESTART" = "true" ]; then
|
||||
echo ""
|
||||
gum style --foreground 226 --bold "⚠ Restart required for overclock settings"
|
||||
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 "Run 'sudo reboot' later to apply overclock."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
gum style --foreground 212 --bold "Enjoy Stegasoo!"
|
||||
echo ""
|
||||
442
rpi/flash-image.sh
Executable file
@@ -0,0 +1,442 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Flash Stegasoo image to SD card
|
||||
# Uses rpi-imager if available, falls back to dd
|
||||
#
|
||||
# Usage: ./flash-image.sh <image> [device]
|
||||
#
|
||||
# Supports: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip (GitHub release format)
|
||||
# If device is specified, skips auto-detection (useful for NVMe/large drives)
|
||||
#
|
||||
# Optional: Place config.json in same directory for headless WiFi setup
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Load config if present (optional - for headless WiFi setup)
|
||||
HAS_CONFIG=false
|
||||
if [ -f "$CONFIG_FILE" ] && command -v jq &> /dev/null; then
|
||||
WIFI_SSID=$(jq -r '.wifiSSID // empty' "$CONFIG_FILE")
|
||||
WIFI_PASS=$(jq -r '.wifiPassword // empty' "$CONFIG_FILE")
|
||||
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
|
||||
PI_HOSTNAME=$(jq -r '.hostname // empty' "$CONFIG_FILE")
|
||||
if [ -n "$WIFI_SSID" ] && [ -n "$WIFI_PASS" ]; then
|
||||
HAS_CONFIG=true
|
||||
echo -e "${GREEN}Found config.json - will configure WiFi after flash${NC}"
|
||||
echo -e " WiFi: ${YELLOW}$WIFI_SSID${NC}"
|
||||
if [ -n "$PI_HOSTNAME" ]; then
|
||||
echo -e " Hostname: ${YELLOW}$PI_HOSTNAME${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
elif [ -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${YELLOW}Note: config.json found but jq not installed (apt install jq)${NC}"
|
||||
echo -e "${YELLOW} WiFi will need to be configured manually after boot${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check for required tools
|
||||
for cmd in dd 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 optional tools
|
||||
HAS_RPI_IMAGER=false
|
||||
HAS_PV=false
|
||||
if command -v rpi-imager &> /dev/null; then
|
||||
HAS_RPI_IMAGER=true
|
||||
fi
|
||||
if command -v pv &> /dev/null; then
|
||||
HAS_PV=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_RPI_IMAGER" = false ] && [ "$HAS_PV" = false ]; then
|
||||
echo -e "${YELLOW}Warning: Neither rpi-imager nor pv found. Progress will not be shown.${NC}"
|
||||
fi
|
||||
|
||||
# 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> [device]${NC}"
|
||||
echo ""
|
||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst /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
|
||||
|
||||
# Handle .zst.zip wrapper (GitHub releases workaround)
|
||||
if [[ "$IMAGE" == *.zst.zip ]]; then
|
||||
echo -e "${YELLOW}Extracting .zst from zip wrapper...${NC}"
|
||||
if ! command -v unzip &> /dev/null; then
|
||||
echo -e "${RED}Error: unzip is required for .zst.zip files but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
unzip -q "$IMAGE" -d "$TEMP_DIR"
|
||||
IMAGE=$(find "$TEMP_DIR" -name "*.zst" | head -1)
|
||||
if [ -z "$IMAGE" ]; then
|
||||
echo -e "${RED}Error: No .zst file found in zip archive${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Found: $(basename "$IMAGE")${NC}"
|
||||
echo ""
|
||||
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
|
||||
|
||||
# Ask about wiping
|
||||
echo
|
||||
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
||||
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Wiping partition table..."
|
||||
sudo wipefs -a "$SELECTED"
|
||||
sudo dd if=/dev/zero of="$SELECTED" bs=1M count=10 status=none
|
||||
sync
|
||||
echo " Wiped clean"
|
||||
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 ""
|
||||
|
||||
# Try rpi-imager first (faster, native support for compressed images)
|
||||
if command -v rpi-imager &> /dev/null; then
|
||||
echo -e "${YELLOW}Using rpi-imager...${NC}"
|
||||
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then
|
||||
# rpi-imager succeeded
|
||||
:
|
||||
else
|
||||
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}"
|
||||
# Fall through to dd
|
||||
USE_DD=true
|
||||
fi
|
||||
else
|
||||
USE_DD=true
|
||||
fi
|
||||
|
||||
# Fallback to dd
|
||||
if [ "$USE_DD" = true ]; then
|
||||
if [ "$HAS_PV" = true ]; then
|
||||
echo -e "${YELLOW}Using dd with progress...${NC}"
|
||||
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
|
||||
else
|
||||
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
||||
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
||||
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
||||
esac
|
||||
else
|
||||
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Syncing...${NC}"
|
||||
sync
|
||||
|
||||
# Inject WiFi config if config.json was loaded
|
||||
if [ "$HAS_CONFIG" = true ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
|
||||
|
||||
# Wait for partitions to appear
|
||||
sleep 2
|
||||
partprobe "$SELECTED" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Determine boot partition
|
||||
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
|
||||
BOOT_PART="${SELECTED}p1"
|
||||
else
|
||||
BOOT_PART="${SELECTED}1"
|
||||
fi
|
||||
|
||||
if [ -b "$BOOT_PART" ]; then
|
||||
MOUNT_DIR=$(mktemp -d)
|
||||
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then
|
||||
# Create firstrun.sh for WiFi setup
|
||||
cat > "$MOUNT_DIR/firstrun.sh" << 'EOFSCRIPT'
|
||||
#!/bin/bash
|
||||
set +e
|
||||
|
||||
# Set hostname if provided
|
||||
if [ -n "PLACEHOLDER_HOSTNAME" ] && [ "PLACEHOLDER_HOSTNAME" != "" ]; then
|
||||
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
|
||||
else
|
||||
echo PLACEHOLDER_HOSTNAME >/etc/hostname
|
||||
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
|
||||
fi
|
||||
fi
|
||||
|
||||
# Configure WiFi
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
|
||||
else
|
||||
# NetworkManager method (Trixie)
|
||||
cat >/etc/NetworkManager/system-connections/preconfigured.nmconnection <<'NMEOF'
|
||||
[connection]
|
||||
id=preconfigured
|
||||
type=wifi
|
||||
autoconnect=true
|
||||
|
||||
[wifi]
|
||||
mode=infrastructure
|
||||
ssid=PLACEHOLDER_SSID
|
||||
|
||||
[wifi-security]
|
||||
auth-alg=open
|
||||
key-mgmt=wpa-psk
|
||||
psk=PLACEHOLDER_WIFIPASS
|
||||
|
||||
[ipv4]
|
||||
method=auto
|
||||
|
||||
[ipv6]
|
||||
method=auto
|
||||
NMEOF
|
||||
chmod 600 /etc/NetworkManager/system-connections/preconfigured.nmconnection
|
||||
rfkill unblock wifi
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f /boot/firstrun.sh
|
||||
rm -f /boot/firmware/firstrun.sh
|
||||
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
|
||||
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
|
||||
exit 0
|
||||
EOFSCRIPT
|
||||
|
||||
# Replace placeholders
|
||||
sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
|
||||
if [ -n "$PI_HOSTNAME" ]; then
|
||||
sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
|
||||
else
|
||||
sed -i "s/PLACEHOLDER_HOSTNAME//g" "$MOUNT_DIR/firstrun.sh"
|
||||
fi
|
||||
chmod +x "$MOUNT_DIR/firstrun.sh"
|
||||
|
||||
# Update cmdline.txt to run firstrun.sh
|
||||
CMDLINE="$MOUNT_DIR/cmdline.txt"
|
||||
if [ -f "$CMDLINE" ]; then
|
||||
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g')
|
||||
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" > "$CMDLINE"
|
||||
fi
|
||||
|
||||
umount "$MOUNT_DIR"
|
||||
echo -e " ${GREEN}✓${NC} WiFi configured for: $WIFI_SSID"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not mount boot partition"
|
||||
fi
|
||||
rmdir "$MOUNT_DIR" 2>/dev/null || true
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Boot partition not found"
|
||||
fi
|
||||
fi
|
||||
|
||||
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 ""
|
||||
if [ "$HAS_CONFIG" = true ]; then
|
||||
echo -e "${GREEN}WiFi pre-configured${NC} - Pi will connect to $WIFI_SSID on boot"
|
||||
echo -e "SSH: ${YELLOW}ssh admin@${PI_HOSTNAME:-stegasoo}.local${NC} (password: stegasoo)"
|
||||
else
|
||||
echo -e "${YELLOW}Tip:${NC} On first boot, the setup wizard will help configure WiFi."
|
||||
echo -e "${YELLOW}Tip:${NC} Or place config.json in rpi/ for headless setup next time."
|
||||
fi
|
||||
echo ""
|
||||
332
rpi/flash-stock-img.sh
Executable file
@@ -0,0 +1,332 @@
|
||||
#!/bin/bash
|
||||
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
|
||||
# Usage: ./flash-stock-img.sh <image.img.xz> <device>
|
||||
# Reads settings from config.json in same directory
|
||||
#
|
||||
# Uses the same firstrun.sh approach as rpi-imager for compatibility
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||
|
||||
# ============================================================================
|
||||
# Load config
|
||||
# ============================================================================
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "Error: config.json not found at $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PI_USER=$(jq -r '.username' "$CONFIG_FILE")
|
||||
PI_PASS=$(jq -r '.password' "$CONFIG_FILE")
|
||||
WIFI_SSID=$(jq -r '.wifiSSID' "$CONFIG_FILE")
|
||||
WIFI_PASS=$(jq -r '.wifiPassword' "$CONFIG_FILE")
|
||||
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
|
||||
PI_HOSTNAME=$(jq -r '.hostname' "$CONFIG_FILE")
|
||||
PI_TIMEZONE=$(jq -r '.timezone // "America/New_York"' "$CONFIG_FILE")
|
||||
PI_KEYMAP=$(jq -r '.keyboardLayout // "us"' "$CONFIG_FILE")
|
||||
|
||||
echo "Loaded config from $CONFIG_FILE"
|
||||
echo " Hostname: $PI_HOSTNAME"
|
||||
echo " User: $PI_USER"
|
||||
echo " WiFi: $WIFI_SSID"
|
||||
echo " Timezone: $PI_TIMEZONE"
|
||||
echo
|
||||
|
||||
# ============================================================================
|
||||
# Validate args
|
||||
# ============================================================================
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <image.img.xz> <device>"
|
||||
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
DEVICE="$2"
|
||||
|
||||
if [ ! -f "$IMAGE" ]; then
|
||||
echo "Error: Image file not found: $IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -b "$DEVICE" ]; then
|
||||
echo "Error: Device not found: $DEVICE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safety check
|
||||
echo "WARNING: This will ERASE all data on $DEVICE"
|
||||
echo "Device info:"
|
||||
lsblk "$DEVICE"
|
||||
echo
|
||||
read -p "Type 'yes' to continue: " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask about wiping
|
||||
echo
|
||||
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
||||
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Wiping partition table..."
|
||||
sudo wipefs -a "$DEVICE"
|
||||
sudo dd if=/dev/zero of="$DEVICE" bs=1M count=10 status=none
|
||||
sync
|
||||
echo " Wiped clean"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Flash image
|
||||
# ============================================================================
|
||||
echo "Flashing $IMAGE to $DEVICE..."
|
||||
if [[ "$IMAGE" == *.xz ]]; then
|
||||
xzcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
|
||||
elif [[ "$IMAGE" == *.zst ]]; then
|
||||
zstdcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
|
||||
else
|
||||
sudo dd if="$IMAGE" of="$DEVICE" bs=4M status=progress conv=fsync
|
||||
fi
|
||||
|
||||
echo "Syncing..."
|
||||
sync
|
||||
|
||||
# Wait for partitions
|
||||
sleep 2
|
||||
sudo partprobe "$DEVICE" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# ============================================================================
|
||||
# Find partitions
|
||||
# ============================================================================
|
||||
if [ -b "${DEVICE}1" ]; then
|
||||
BOOT_PART="${DEVICE}1"
|
||||
ROOT_PART="${DEVICE}2"
|
||||
elif [ -b "${DEVICE}p1" ]; then
|
||||
BOOT_PART="${DEVICE}p1"
|
||||
ROOT_PART="${DEVICE}p2"
|
||||
else
|
||||
echo "Error: Could not find boot partition"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Resize rootfs to 16GB (faster imaging)
|
||||
# ============================================================================
|
||||
echo
|
||||
read -p "Resize rootfs to 16GB for faster imaging? [Y/n] " resize_confirm
|
||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
echo "Resizing rootfs partition to 16GB..."
|
||||
|
||||
# Get current partition size in bytes
|
||||
CURRENT_SIZE=$(sudo blockdev --getsize64 "$ROOT_PART")
|
||||
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
||||
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(sudo parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
if [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
||||
# EXPANDING: partition first, then filesystem
|
||||
echo "Current partition is smaller than 16GB - expanding..."
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo "Expanding partition to 16GB..."
|
||||
sudo parted -s "$DEVICE" rm 2
|
||||
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
sudo partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
# Expand filesystem to fill the new partition
|
||||
echo "Expanding filesystem to fill partition..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
else
|
||||
# SHRINKING: filesystem first, then partition
|
||||
echo "Current partition is larger than 16GB - shrinking..."
|
||||
|
||||
# Check and shrink filesystem first
|
||||
echo "Checking filesystem..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||
echo "Shrinking filesystem to 15500M..."
|
||||
sudo resize2fs "$ROOT_PART" 15500M
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo "Shrinking partition to 16GB..."
|
||||
sudo parted -s "$DEVICE" rm 2
|
||||
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
sudo partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
# Expand filesystem to fill the partition exactly
|
||||
echo "Expanding filesystem to fill partition..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
fi
|
||||
|
||||
# Verify and show result
|
||||
echo "Verifying partition size..."
|
||||
sudo parted -s "$DEVICE" unit GB print | grep "^ 2"
|
||||
|
||||
# Disable Pi OS auto-expand on first boot
|
||||
echo "Disabling auto-expand..."
|
||||
TEMP_ROOT=$(mktemp -d)
|
||||
sudo mount "$ROOT_PART" "$TEMP_ROOT"
|
||||
|
||||
# Remove resize2fs_once service if it exists
|
||||
sudo rm -f "$TEMP_ROOT/etc/init.d/resize2fs_once"
|
||||
sudo rm -f "$TEMP_ROOT/etc/rc3.d/S01resize2fs_once"
|
||||
|
||||
# Disable the systemd resize service
|
||||
sudo rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||
|
||||
sudo umount "$TEMP_ROOT"
|
||||
rmdir "$TEMP_ROOT"
|
||||
|
||||
echo " Rootfs set to 16GB (auto-expand disabled)"
|
||||
fi
|
||||
|
||||
MOUNT_DIR=$(mktemp -d)
|
||||
|
||||
# ============================================================================
|
||||
# Configure boot partition with firstrun.sh (rpi-imager method)
|
||||
# ============================================================================
|
||||
echo "Mounting boot partition..."
|
||||
sudo mount "$BOOT_PART" "$MOUNT_DIR"
|
||||
|
||||
# Enable SSH
|
||||
echo "Enabling SSH..."
|
||||
sudo touch "$MOUNT_DIR/ssh"
|
||||
|
||||
# Generate password hash
|
||||
PASS_HASH=$(echo "$PI_PASS" | openssl passwd -6 -stdin)
|
||||
|
||||
# Create firstrun.sh - this is exactly what rpi-imager generates
|
||||
echo "Creating firstrun.sh..."
|
||||
sudo tee "$MOUNT_DIR/firstrun.sh" > /dev/null << 'EOFSCRIPT'
|
||||
#!/bin/bash
|
||||
set +e
|
||||
|
||||
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
|
||||
else
|
||||
echo PLACEHOLDER_HOSTNAME >/etc/hostname
|
||||
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
|
||||
fi
|
||||
|
||||
FIRSTUSER=$(getent passwd 1000 | cut -d: -f1)
|
||||
FIRSTUSERHOME=$(getent passwd 1000 | cut -d: -f6)
|
||||
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh
|
||||
else
|
||||
systemctl enable ssh
|
||||
fi
|
||||
|
||||
if [ -f /usr/lib/userconf-pi/userconf ]; then
|
||||
/usr/lib/userconf-pi/userconf 'PLACEHOLDER_USER' 'PLACEHOLDER_HASH'
|
||||
else
|
||||
echo "$FIRSTUSER:"'PLACEHOLDER_HASH' | chpasswd -e
|
||||
if [ "$FIRSTUSER" != "PLACEHOLDER_USER" ]; then
|
||||
usermod -l "PLACEHOLDER_USER" "$FIRSTUSER"
|
||||
usermod -m -d "/home/PLACEHOLDER_USER" "PLACEHOLDER_USER"
|
||||
groupmod -n "PLACEHOLDER_USER" "$FIRSTUSER"
|
||||
if grep -q "^autologin-user=" /etc/lightdm/lightdm.conf 2>/dev/null; then
|
||||
sed -i "s/^autologin-user=.*/autologin-user=PLACEHOLDER_USER/" /etc/lightdm/lightdm.conf
|
||||
fi
|
||||
if [ -f /etc/systemd/system/getty@tty1.service.d/autologin.conf ]; then
|
||||
sed -i "s/$FIRSTUSER/PLACEHOLDER_USER/" /etc/systemd/system/getty@tty1.service.d/autologin.conf
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'PLACEHOLDER_KEYMAP'
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'PLACEHOLDER_TIMEZONE'
|
||||
fi
|
||||
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
|
||||
else
|
||||
cat >/etc/wpa_supplicant/wpa_supplicant.conf <<'WPAEOF'
|
||||
country=PLACEHOLDER_COUNTRY
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
ap_scan=1
|
||||
update_config=1
|
||||
network={
|
||||
ssid="PLACEHOLDER_SSID"
|
||||
psk="PLACEHOLDER_WIFIPASS"
|
||||
}
|
||||
WPAEOF
|
||||
chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf
|
||||
rfkill unblock wifi
|
||||
for filename in /var/lib/systemd/rfkill/*:wlan ; do
|
||||
echo 0 > "$filename"
|
||||
done
|
||||
fi
|
||||
|
||||
rm -f /boot/firstrun.sh
|
||||
rm -f /boot/firmware/firstrun.sh
|
||||
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
|
||||
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
|
||||
exit 0
|
||||
EOFSCRIPT
|
||||
|
||||
# Replace placeholders with actual values
|
||||
sudo sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_USER/$PI_USER/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s|PLACEHOLDER_HASH|$PASS_HASH|g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_KEYMAP/$PI_KEYMAP/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s|PLACEHOLDER_TIMEZONE|$PI_TIMEZONE|g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
|
||||
|
||||
sudo chmod +x "$MOUNT_DIR/firstrun.sh"
|
||||
|
||||
# Update cmdline.txt to run firstrun.sh on boot
|
||||
echo "Updating cmdline.txt..."
|
||||
CMDLINE="$MOUNT_DIR/cmdline.txt"
|
||||
if [ -f "$CMDLINE" ]; then
|
||||
# Read current cmdline, strip existing systemd.run and init= (auto-expand)
|
||||
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g' | sed 's| init=[^ ]*||g')
|
||||
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" | sudo tee "$CMDLINE" > /dev/null
|
||||
echo " cmdline.txt updated"
|
||||
fi
|
||||
|
||||
sudo umount "$MOUNT_DIR"
|
||||
rmdir "$MOUNT_DIR"
|
||||
|
||||
echo
|
||||
echo "Done! SD card is ready."
|
||||
echo " Hostname: $PI_HOSTNAME"
|
||||
echo " User: $PI_USER"
|
||||
echo " SSH: enabled"
|
||||
echo " WiFi: $WIFI_SSID"
|
||||
echo
|
||||
echo "Insert into Pi and boot. Access via:"
|
||||
echo " mDNS: http://$PI_HOSTNAME.local"
|
||||
echo " Find IP: ping $PI_HOSTNAME.local"
|
||||
echo
|
||||
echo "Once booted, SSH with: ssh $PI_USER@$PI_HOSTNAME.local"
|
||||
|
||||
# If we resized, remind about pull-image.sh
|
||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
echo
|
||||
echo "=== After setup, use pull-image.sh to create distributable image ==="
|
||||
echo " ./pull-image.sh $DEVICE stegasoo-rpi-VERSION.img.zst"
|
||||
echo
|
||||
echo "This will only pull the 16GB partition, not the entire SD card."
|
||||
fi
|
||||
29
rpi/host-requirements.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# Host Machine Dependencies for Stegasoo Pi Scripts
|
||||
# =================================================
|
||||
#
|
||||
# Quick install (Debian/Ubuntu):
|
||||
# sudo apt install parted e2fsprogs zstd zip bc pv jq unzip sshpass
|
||||
#
|
||||
# Or install with this file:
|
||||
# sudo apt install $(grep -v '^#' rpi/host-requirements.txt | grep -v '^$' | xargs)
|
||||
|
||||
# pull-image.sh - Create distributable images
|
||||
parted # Partition table reading/writing
|
||||
e2fsprogs # e2fsck, resize2fs for ext4
|
||||
zstd # Compression (zstd -T0 -3)
|
||||
zip # Optional .zst.zip wrapper for GitHub
|
||||
bc # Floating point math for size display
|
||||
pv # Progress bar (optional, falls back to dd status)
|
||||
|
||||
# flash-image.sh - Flash images to SD cards
|
||||
unzip # Extract .zst.zip wrappers
|
||||
zstd # Decompress .zst images
|
||||
pv # Progress bar (optional)
|
||||
jq # Parse config.json for headless WiFi (optional)
|
||||
|
||||
# kickoff-pi-test.sh - Automated flash+test
|
||||
sshpass # Non-interactive SSH with password
|
||||
avahi-utils # avahi-resolve for .local hostname lookup
|
||||
|
||||
# Optional tools
|
||||
rpi-imager # Faster flashing (flash-image.sh falls back to dd)
|
||||
200
rpi/inject-wifi.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Inject WiFi credentials into SD card for Raspberry Pi
|
||||
# Supports both Bookworm (NetworkManager) and older (wpa_supplicant)
|
||||
#
|
||||
# First-time setup:
|
||||
# ./inject-wifi.sh --setup
|
||||
#
|
||||
# Then after flashing:
|
||||
# sudo ./inject-wifi.sh # auto-detect partitions
|
||||
# sudo ./inject-wifi.sh /dev/sdb # specify device (finds partitions)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
CONFIG_DIR="$HOME/.config/stegasoo"
|
||||
CONFIG_FILE="$CONFIG_DIR/wifi.conf"
|
||||
|
||||
# Setup mode - save credentials
|
||||
if [ "$1" == "--setup" ]; then
|
||||
echo -e "${BLUE}Stegasoo WiFi Config Setup${NC}"
|
||||
echo ""
|
||||
|
||||
read -p "WiFi SSID: " WIFI_SSID
|
||||
read -s -p "WiFi Password: " WIFI_PASS
|
||||
echo ""
|
||||
read -p "Country code [US]: " WIFI_COUNTRY
|
||||
WIFI_COUNTRY=${WIFI_COUNTRY:-US}
|
||||
|
||||
# Generate hashed PSK for wpa_supplicant (legacy)
|
||||
if command -v wpa_passphrase &> /dev/null; then
|
||||
HASHED_PSK=$(wpa_passphrase "$WIFI_SSID" "$WIFI_PASS" | grep -E "^\s+psk=" | tr -d '\t' | cut -d= -f2)
|
||||
else
|
||||
HASHED_PSK=""
|
||||
echo -e "${YELLOW}Note: wpa_passphrase not found, legacy mode disabled${NC}"
|
||||
fi
|
||||
|
||||
# Save config (includes plaintext for NetworkManager)
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chmod 700 "$CONFIG_DIR"
|
||||
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
# Stegasoo WiFi config
|
||||
WIFI_SSID="$WIFI_SSID"
|
||||
WIFI_PASS="$WIFI_PASS"
|
||||
WIFI_PSK_HASH="$HASHED_PSK"
|
||||
WIFI_COUNTRY="$WIFI_COUNTRY"
|
||||
EOF
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Config saved to $CONFIG_FILE${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normal mode - inject credentials
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
echo "Usage: sudo $0 [/dev/sdX]"
|
||||
echo ""
|
||||
echo "First-time setup (no sudo): $0 --setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load config
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
USER_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
|
||||
CONFIG_FILE="$USER_HOME/.config/stegasoo/wifi.conf"
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${RED}Config not found: $CONFIG_FILE${NC}"
|
||||
echo ""
|
||||
echo "Run setup first (without sudo):"
|
||||
echo " ./inject-wifi.sh --setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
if [ -z "$WIFI_SSID" ] || [ -z "$WIFI_PASS" ]; then
|
||||
echo -e "${RED}Invalid config. Run --setup again.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find partitions
|
||||
MANUAL_DEV="$1"
|
||||
|
||||
if [ -n "$MANUAL_DEV" ]; then
|
||||
# Strip partition number if given (e.g., /dev/sdb1 -> /dev/sdb)
|
||||
BASE_DEV=$(echo "$MANUAL_DEV" | sed 's/[0-9]*$//')
|
||||
BOOT_DEV="${BASE_DEV}1"
|
||||
ROOT_DEV="${BASE_DEV}2"
|
||||
else
|
||||
# Auto-detect by label
|
||||
BOOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "vfat.*(bootfs|boot)" | head -1 | awk '{print $1}')
|
||||
ROOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "ext4.*rootfs" | head -1 | awk '{print $1}')
|
||||
|
||||
if [ -z "$BOOT_PART" ] || [ -z "$ROOT_PART" ]; then
|
||||
echo -e "${RED}Could not find boot/root partitions. Is the SD card inserted?${NC}"
|
||||
echo ""
|
||||
lsblk -o NAME,SIZE,FSTYPE,LABEL
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip: Specify device manually: sudo $0 /dev/sdX${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BOOT_DEV="/dev/$BOOT_PART"
|
||||
ROOT_DEV="/dev/$ROOT_PART"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found partitions:${NC}"
|
||||
echo -e " Boot: ${YELLOW}$BOOT_DEV${NC}"
|
||||
echo -e " Root: ${YELLOW}$ROOT_DEV${NC}"
|
||||
|
||||
# Mount points
|
||||
BOOT_MNT="/tmp/stegasoo-boot-$$"
|
||||
ROOT_MNT="/tmp/stegasoo-root-$$"
|
||||
|
||||
cleanup() {
|
||||
umount "$BOOT_MNT" 2>/dev/null || true
|
||||
umount "$ROOT_MNT" 2>/dev/null || true
|
||||
rmdir "$BOOT_MNT" "$ROOT_MNT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BOOT_MNT" "$ROOT_MNT"
|
||||
|
||||
# Mount partitions
|
||||
mount "$BOOT_DEV" "$BOOT_MNT"
|
||||
mount "$ROOT_DEV" "$ROOT_MNT"
|
||||
|
||||
echo ""
|
||||
|
||||
# 1. Write NetworkManager config (Bookworm+)
|
||||
NM_DIR="$ROOT_MNT/etc/NetworkManager/system-connections"
|
||||
if [ -d "$ROOT_MNT/etc/NetworkManager" ]; then
|
||||
mkdir -p "$NM_DIR"
|
||||
|
||||
# NetworkManager connection file
|
||||
NM_FILE="$NM_DIR/stegasoo-wifi.nmconnection"
|
||||
cat > "$NM_FILE" << EOF
|
||||
[connection]
|
||||
id=$WIFI_SSID
|
||||
type=wifi
|
||||
autoconnect=true
|
||||
|
||||
[wifi]
|
||||
mode=infrastructure
|
||||
ssid=$WIFI_SSID
|
||||
|
||||
[wifi-security]
|
||||
auth-alg=open
|
||||
key-mgmt=wpa-psk
|
||||
psk=$WIFI_PASS
|
||||
|
||||
[ipv4]
|
||||
method=auto
|
||||
|
||||
[ipv6]
|
||||
method=auto
|
||||
EOF
|
||||
chmod 600 "$NM_FILE"
|
||||
echo -e "${GREEN}Created NetworkManager config (Bookworm)${NC}"
|
||||
fi
|
||||
|
||||
# 2. Write wpa_supplicant.conf (legacy, boot partition)
|
||||
if [ -n "$WIFI_PSK_HASH" ]; then
|
||||
cat > "$BOOT_MNT/wpa_supplicant.conf" << EOF
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
update_config=1
|
||||
country=$WIFI_COUNTRY
|
||||
|
||||
network={
|
||||
ssid="$WIFI_SSID"
|
||||
psk=$WIFI_PSK_HASH
|
||||
}
|
||||
EOF
|
||||
echo -e "${GREEN}Created wpa_supplicant.conf (legacy)${NC}"
|
||||
fi
|
||||
|
||||
# 3. Set WiFi country in boot config
|
||||
if [ -f "$BOOT_MNT/config.txt" ]; then
|
||||
if ! grep -q "^dtparam=cfg80211" "$BOOT_MNT/config.txt"; then
|
||||
echo "" >> "$BOOT_MNT/config.txt"
|
||||
echo "# WiFi country" >> "$BOOT_MNT/config.txt"
|
||||
echo "dtparam=cfg80211" >> "$BOOT_MNT/config.txt"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " SSID: ${YELLOW}$WIFI_SSID${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Done! WiFi credentials injected for Bookworm + legacy.${NC}"
|
||||
193
rpi/kickoff-pi-test.sh
Executable file
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stegasoo Pi Test Kickoff Script
|
||||
# Automates: flash -> wait for boot -> setup -> test
|
||||
#
|
||||
# Usage: ./kickoff-pi-test.sh <image.img.zst> </dev/sdX>
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Pi connection settings
|
||||
PI_HOST="stegasoo.local"
|
||||
PI_USER="admin"
|
||||
PI_PASS="stegasoo"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Wait for Pi to be reachable
|
||||
wait_for_pi() {
|
||||
local attempt=1
|
||||
ssh-keygen -R "$PI_HOST" 2>/dev/null
|
||||
|
||||
echo "Waiting for $PI_USER@$PI_HOST..."
|
||||
while ! sshpass -p "$PI_PASS" ssh -o ConnectTimeout=2 -o StrictHostKeyChecking=no -o BatchMode=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "exit" 2>/dev/null; do
|
||||
printf "\rAttempt %d..." "$attempt"
|
||||
((attempt++))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt"
|
||||
printf '\a' # Terminal bell
|
||||
}
|
||||
|
||||
# Run command on Pi (non-interactive)
|
||||
run_on_pi() {
|
||||
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||
}
|
||||
|
||||
# Run command on Pi (interactive/PTY)
|
||||
run_on_pi_interactive() {
|
||||
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||
}
|
||||
|
||||
# Copy file to Pi
|
||||
scp_to_pi() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
|
||||
}
|
||||
|
||||
# Interactive SSH session
|
||||
ssh_pi() {
|
||||
ssh-keygen -R "$PI_HOST" 2>/dev/null
|
||||
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <image.img.zst> </dev/sdX>"
|
||||
echo ""
|
||||
echo "Example: $0 stegasoo-v4.1.img.zst /dev/sda"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
DEVICE="$2"
|
||||
|
||||
if [[ ! -f "$IMAGE" ]]; then
|
||||
echo -e "${RED}Error: Image file not found: $IMAGE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -b "$DEVICE" ]]; then
|
||||
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ Stegasoo Pi Test Kickoff ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Image: ${YELLOW}$IMAGE${NC}"
|
||||
echo -e "Device: ${YELLOW}$DEVICE${NC}"
|
||||
echo ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 1: Flash the image
|
||||
# -----------------------------------------------------------------------------
|
||||
echo -e "${GREEN}[1/8]${NC} Flashing image..."
|
||||
echo ""
|
||||
|
||||
# Auto-answer: "yes" for confirm, "y" for wipe, "y" for resize
|
||||
printf 'yes\ny\ny\n' | "$SCRIPT_DIR/flash-stock-img.sh" "$IMAGE" "$DEVICE"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[2/8]${NC} Flash complete! Waiting for SD card insertion..."
|
||||
echo ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 2: Wait for user to insert SD card
|
||||
# -----------------------------------------------------------------------------
|
||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${YELLOW} Insert SD card into Pi and power on${NC}"
|
||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
read -p "Press ENTER when Pi is booting..."
|
||||
|
||||
echo ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3: Wait for Pi to be ready
|
||||
# -----------------------------------------------------------------------------
|
||||
echo -e "${GREEN}[3/8]${NC} Waiting for Pi to boot..."
|
||||
echo ""
|
||||
|
||||
wait_for_pi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4: Pre-setup (install dependencies)
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[4/8]${NC} Installing dependencies on Pi..."
|
||||
echo ""
|
||||
|
||||
run_on_pi "sudo chown admin:admin /opt && sudo apt-get update && sudo apt-get install -y git zstd jq"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 5: Clone repo
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[5/8]${NC} Cloning Stegasoo repo..."
|
||||
echo ""
|
||||
|
||||
run_on_pi "cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 6: Copy pre-built tarball
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[6/8]${NC} Copying pre-built tarball to Pi..."
|
||||
echo ""
|
||||
|
||||
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
||||
if [[ -f "$TARBALL" ]]; then
|
||||
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
||||
echo -e " ${GREEN}✓${NC} Tarball copied"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Tarball not found at $TARBALL"
|
||||
echo -e " ${YELLOW}⚠${NC} Setup will build from source (takes longer)"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 7: Run setup
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[7/8]${NC} Running setup.sh on Pi..."
|
||||
echo ""
|
||||
|
||||
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 8: Test it works
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[8/8]${NC} Testing Stegasoo..."
|
||||
echo ""
|
||||
|
||||
run_on_pi "sudo systemctl start stegasoo && sleep 2 && curl -sk https://localhost:5000 | head -5"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Build complete! Pi is ready for testing.${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "Access: ${YELLOW}https://stegasoo.local:5000${NC}"
|
||||
echo ""
|
||||
read -p "Press ENTER to SSH into Pi for manual testing..."
|
||||
|
||||
ssh_pi
|
||||
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
|
||||