Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -69,6 +69,7 @@ scripts/*
|
|||||||
!scripts/validate-release.sh
|
!scripts/validate-release.sh
|
||||||
|
|
||||||
# Web UI auth database and SSL certs
|
# Web UI auth database and SSL certs
|
||||||
|
instance/
|
||||||
frontends/web/instance/
|
frontends/web/instance/
|
||||||
frontends/web/certs/
|
frontends/web/certs/
|
||||||
|
|
||||||
@@ -87,9 +88,8 @@ frontends/web/temp_files/
|
|||||||
rpi/config.json
|
rpi/config.json
|
||||||
|
|
||||||
# Pre-built Pi tarballs and images (release assets, too large for git)
|
# Pre-built Pi tarballs and images (release assets, too large for git)
|
||||||
rpi/stegasoo-pi-arm64.tar.zst
|
rpi/*.tar.zst
|
||||||
rpi/stegasoo-pi-arm64.tar.zst.zip
|
rpi/*.tar.zst.zip
|
||||||
rpi/stegasoo-venv-pi-arm64.tar.zst
|
|
||||||
rpi/*.img
|
rpi/*.img
|
||||||
rpi/*.img.zst
|
rpi/*.img.zst
|
||||||
rpi/*.img.zst.zip
|
rpi/*.img.zst.zip
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -5,6 +5,27 @@ 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/),
|
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).
|
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
|
## [4.1.3] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -180,6 +201,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
|||||||
- CLI interface
|
- CLI interface
|
||||||
- Basic PIN authentication
|
- 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.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.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.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
|
||||||
|
|||||||
538
PLAN-4.1.0.md
538
PLAN-4.1.0.md
@@ -1,538 +0,0 @@
|
|||||||
# Stegasoo 4.1.0 Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Version 4.1.0 is a feature release focusing on small-group deployment improvements and new utilities.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. ~~**Multi-User Support** - Admin can create up to 16 users for shared deployments~~ ✅ DONE
|
|
||||||
2. **Channel Key QR** - Easy visual sharing of channel keys via QR codes
|
|
||||||
3. ~~**CLI Channel Commands** - Manage channel keys from command line~~ ✅ DONE
|
|
||||||
4. **Advanced Tools** - Image/stego utilities (TBD)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 1: Multi-User Support ✅ COMPLETED
|
|
||||||
|
|
||||||
> Implemented in commit 7b33501. All requirements met.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- 16 users + 1 admin maximum (17 total)
|
|
||||||
- First user created at setup is always admin
|
|
||||||
- Admin can add/delete users, reset passwords
|
|
||||||
- Regular users can only change their own password
|
|
||||||
- No self-registration (admin-invite only)
|
|
||||||
|
|
||||||
### Database Changes
|
|
||||||
|
|
||||||
**Update User model in `frontends/web/models.py`:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
class User(db.Model):
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
username = Column(String(80), unique=True, nullable=False)
|
|
||||||
password_hash = Column(String(255), nullable=False)
|
|
||||||
role = Column(String(20), default='user') # 'admin' or 'user'
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Migration:** Add `role` and `created_at` columns. Existing users get `role='admin'`.
|
|
||||||
|
|
||||||
### New Routes
|
|
||||||
|
|
||||||
| Route | Method | Access | Description |
|
|
||||||
|-------|--------|--------|-------------|
|
|
||||||
| `/admin/users` | GET | admin | List all users |
|
|
||||||
| `/admin/users/new` | GET, POST | admin | Create user form |
|
|
||||||
| `/admin/users/<id>/delete` | POST | admin | Delete user |
|
|
||||||
| `/admin/users/<id>/reset-password` | POST | admin | Generate temp password |
|
|
||||||
|
|
||||||
### New Decorator
|
|
||||||
|
|
||||||
```python
|
|
||||||
# auth.py
|
|
||||||
def admin_required(f):
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
if not current_user.is_authenticated:
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
if current_user.role != 'admin':
|
|
||||||
flash('Admin access required', 'error')
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI Changes
|
|
||||||
|
|
||||||
**Navigation (for admin users):**
|
|
||||||
- Add "Users" link in navbar (visible only to admin)
|
|
||||||
|
|
||||||
**Account page (`/account`):**
|
|
||||||
- Admin sees link to user management
|
|
||||||
- All users see their own password change form
|
|
||||||
|
|
||||||
**New template: `templates/admin/users.html`:**
|
|
||||||
- Table: Username | Role | Created | Actions
|
|
||||||
- Actions: Reset Password, Delete (disabled for self)
|
|
||||||
- "Add User" button (disabled if at 16 user limit)
|
|
||||||
- Show count: "3 of 16 users"
|
|
||||||
|
|
||||||
**New template: `templates/admin/user_new.html`:**
|
|
||||||
- Username field (email-style allowed)
|
|
||||||
- Password field (auto-populated with random 8-char, admin can override)
|
|
||||||
- Submit → confirmation page shows password once with copy button
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
|
|
||||||
- Username: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
|
||||||
- Password: 8+ chars (same as current)
|
|
||||||
- Can't delete yourself
|
|
||||||
- Can't demote the last admin
|
|
||||||
- Deleting user immediately invalidates their sessions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 2: Channel Key QR
|
|
||||||
|
|
||||||
### Web UI
|
|
||||||
|
|
||||||
**About page additions:**
|
|
||||||
|
|
||||||
If `STEGASOO_CHANNEL_KEY` environment variable is set:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Channel Key │
|
|
||||||
│ │
|
|
||||||
│ ██████████████ Your server uses a │
|
|
||||||
│ ██ ██ private channel key. │
|
|
||||||
│ ██ ██████ ██ Share this QR with │
|
|
||||||
│ ██ ██████ ██ others to join. │
|
|
||||||
│ ██ ██ │
|
|
||||||
│ ██████████████ [Copy Key] [Download]│
|
|
||||||
│ │
|
|
||||||
│ Key: abc123...xyz │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- QR generated server-side using `qrcode` library
|
|
||||||
- "Copy Key" copies text to clipboard
|
|
||||||
- "Download QR" saves as PNG
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# about route addition
|
|
||||||
@app.route('/about')
|
|
||||||
def about():
|
|
||||||
channel_key = os.environ.get('STEGASOO_CHANNEL_KEY', '')
|
|
||||||
channel_qr_b64 = None
|
|
||||||
if channel_key:
|
|
||||||
# Generate QR as base64 PNG
|
|
||||||
qr = qrcode.make(channel_key)
|
|
||||||
buffer = BytesIO()
|
|
||||||
qr.save(buffer, format='PNG')
|
|
||||||
channel_qr_b64 = base64.b64encode(buffer.getvalue()).decode()
|
|
||||||
return render_template('about.html',
|
|
||||||
channel_key=channel_key,
|
|
||||||
channel_qr=channel_qr_b64)
|
|
||||||
```
|
|
||||||
|
|
||||||
### CLI Commands
|
|
||||||
|
|
||||||
**New command group: `stegasoo channel`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate a new channel key
|
|
||||||
stegasoo channel generate
|
|
||||||
# Output:
|
|
||||||
# Channel Key: stg_abc123...xyz789
|
|
||||||
#
|
|
||||||
# ██████████████████
|
|
||||||
# ██ ██
|
|
||||||
# ██ ██████████ ██
|
|
||||||
# ...
|
|
||||||
#
|
|
||||||
# Set in environment: export STEGASOO_CHANNEL_KEY="stg_abc123..."
|
|
||||||
|
|
||||||
# Show current key (from env or argument)
|
|
||||||
stegasoo channel show
|
|
||||||
# Output:
|
|
||||||
# Channel Key: stg_abc123...xyz789
|
|
||||||
|
|
||||||
# Display QR in terminal (ASCII)
|
|
||||||
stegasoo channel qr
|
|
||||||
# Output: ASCII QR code
|
|
||||||
|
|
||||||
# Save QR as PNG
|
|
||||||
stegasoo channel qr -o channel-key.png
|
|
||||||
# Output: Saved to channel-key.png
|
|
||||||
|
|
||||||
# Explicit format selection
|
|
||||||
stegasoo channel qr --format ascii # Terminal (default)
|
|
||||||
stegasoo channel qr --format png -o - # PNG to stdout
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation notes:**
|
|
||||||
|
|
||||||
- Use `qrcode[pil]` for PNG output
|
|
||||||
- Use `qrcode` with `print_ascii()` for terminal
|
|
||||||
- Read key from `--key` argument or `STEGASOO_CHANNEL_KEY` env var
|
|
||||||
- `generate` uses existing `generate_channel_key()` from `stegasoo.channel`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Changes Summary
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `frontends/web/templates/admin/users.html` | User management page |
|
|
||||||
| `frontends/web/templates/admin/user_new.html` | Add user form |
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `frontends/web/models.py` | Add `role`, `created_at` to User |
|
|
||||||
| `frontends/web/auth.py` | Add `@admin_required`, user management routes |
|
|
||||||
| `frontends/web/templates/base.html` | Add Users link for admins |
|
|
||||||
| `frontends/web/templates/account.html` | Add admin link |
|
|
||||||
| `frontends/web/templates/about.html` | Add channel key QR section |
|
|
||||||
| `src/stegasoo/cli.py` | Add `channel` command group |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
### Multi-User
|
|
||||||
|
|
||||||
1. Fresh install → first user is admin
|
|
||||||
2. Admin can create users up to limit (16)
|
|
||||||
3. Admin can't create 17th user (shows error)
|
|
||||||
4. Regular user can log in, encode/decode
|
|
||||||
5. Regular user can't access `/admin/users`
|
|
||||||
6. Admin can reset user password
|
|
||||||
7. Admin can delete user
|
|
||||||
8. Admin can't delete self
|
|
||||||
9. Existing 4.0.2 databases upgrade correctly (single user becomes admin)
|
|
||||||
|
|
||||||
### Channel Key QR
|
|
||||||
|
|
||||||
1. About page shows nothing if no channel key
|
|
||||||
2. About page shows QR + key if channel key set
|
|
||||||
3. Copy button works
|
|
||||||
4. Download gives valid PNG
|
|
||||||
5. QR scans correctly to key value
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
1. `channel generate` creates valid key + shows QR
|
|
||||||
2. `channel show` displays current key
|
|
||||||
3. `channel qr` outputs ASCII to terminal
|
|
||||||
4. `channel qr -o file.png` saves PNG
|
|
||||||
5. Commands work with `--key` override
|
|
||||||
6. Commands read from env var
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 3: Advanced Tools
|
|
||||||
|
|
||||||
### Included Tools
|
|
||||||
|
|
||||||
| Tool | Web | CLI | Description |
|
|
||||||
|------|-----|-----|-------------|
|
|
||||||
| **Capacity Calculator** | ✓ | ✓ | Upload image → show DCT/LSB capacity |
|
|
||||||
| **Metadata Stripper** | ✓ | ✓ | Remove EXIF/metadata from image |
|
|
||||||
| **Stego Detector** | ✓ | ✓ | Analyze image for signs of hidden data |
|
|
||||||
| **Image Compare** | ✓ | - | Side-by-side before/after diff |
|
|
||||||
| **Header Peek** | ✓ | ✓ | Check for Stegasoo header without decrypting |
|
|
||||||
| **Batch Mode** | - | ✓ | Encode/decode multiple files |
|
|
||||||
|
|
||||||
### Web UI: `/tools` Page
|
|
||||||
|
|
||||||
New page with card-based layout:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 🛠️ Advanced Tools │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ 📏 Capacity │ │ 🧹 Metadata │ │
|
|
||||||
│ │ Calculator │ │ Stripper │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ Check how much │ │ Remove EXIF │ │
|
|
||||||
│ │ data fits │ │ before encoding │ │
|
|
||||||
│ └─────────────────┘ └─────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ 🔍 Stego │ │ 🔎 Header │ │
|
|
||||||
│ │ Detector │ │ Peek │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ Analyze image │ │ Check for │ │
|
|
||||||
│ │ for hidden data │ │ Stegasoo data │ │
|
|
||||||
│ └─────────────────┘ └─────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ │
|
|
||||||
│ │ ⚖️ Image │ │
|
|
||||||
│ │ Compare │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Before/after │ │
|
|
||||||
│ │ diff view │ │
|
|
||||||
│ └─────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Each card opens a modal or expands inline for the tool interface.
|
|
||||||
|
|
||||||
### CLI Structure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Capacity calculator
|
|
||||||
stegasoo capacity image.jpg
|
|
||||||
stegasoo capacity image.jpg --format json
|
|
||||||
|
|
||||||
# Metadata stripper
|
|
||||||
stegasoo strip image.jpg # Output to image_stripped.jpg
|
|
||||||
stegasoo strip image.jpg -o clean.jpg # Custom output
|
|
||||||
stegasoo strip image.jpg --in-place # Overwrite original
|
|
||||||
|
|
||||||
# Stego detector
|
|
||||||
stegasoo detect image.jpg
|
|
||||||
stegasoo detect image.jpg --verbose # Detailed analysis
|
|
||||||
|
|
||||||
# Header peek
|
|
||||||
stegasoo peek image.jpg
|
|
||||||
# Output: "Stegasoo DCT header detected" or "No Stegasoo header found"
|
|
||||||
|
|
||||||
# Batch mode
|
|
||||||
stegasoo encode --batch manifest.json # JSON with files + credentials
|
|
||||||
stegasoo decode --batch input_dir/ --out output_dir/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Details
|
|
||||||
|
|
||||||
#### Capacity Calculator
|
|
||||||
- Input: Image file
|
|
||||||
- Output: Dimensions, megapixels, DCT capacity, LSB capacity
|
|
||||||
- Web: Upload zone + results panel
|
|
||||||
- CLI: Table or JSON output
|
|
||||||
|
|
||||||
#### Metadata Stripper
|
|
||||||
- Input: Image file
|
|
||||||
- Output: Clean image (EXIF/metadata removed)
|
|
||||||
- Show what was removed (camera model, GPS, etc.)
|
|
||||||
- Preserve image quality
|
|
||||||
|
|
||||||
#### Stego Detector
|
|
||||||
- Input: Image file
|
|
||||||
- Analysis:
|
|
||||||
- Chi-square analysis (LSB detection)
|
|
||||||
- DCT coefficient histogram analysis
|
|
||||||
- Visual inspection hints
|
|
||||||
- Output: Likelihood score + findings
|
|
||||||
- Note: Detection is probabilistic, not definitive
|
|
||||||
|
|
||||||
#### Image Compare
|
|
||||||
- Input: Two images (original + stego)
|
|
||||||
- Output:
|
|
||||||
- Side-by-side view
|
|
||||||
- Difference overlay (amplified)
|
|
||||||
- Pixel-level stats (PSNR, SSIM)
|
|
||||||
- Web only (visual tool)
|
|
||||||
|
|
||||||
#### Header Peek
|
|
||||||
- Input: Image file
|
|
||||||
- Output: Header found (yes/no), mode (DCT/LSB), embedded size estimate
|
|
||||||
- Does NOT decrypt - just checks for valid header structure
|
|
||||||
- Useful for "is this a stego image?" without credentials
|
|
||||||
|
|
||||||
#### Batch Mode
|
|
||||||
- CLI only
|
|
||||||
- Manifest file (JSON) or directory-based
|
|
||||||
- Progress bar for multiple files
|
|
||||||
- Error handling per-file (continue on failure)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### Database Migration
|
|
||||||
|
|
||||||
For existing 4.0.2 installations:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# migrations/add_user_role.py
|
|
||||||
def upgrade():
|
|
||||||
# Add columns with defaults
|
|
||||||
op.add_column('user', sa.Column('role', sa.String(20), default='user'))
|
|
||||||
op.add_column('user', sa.Column('created_at', sa.DateTime))
|
|
||||||
|
|
||||||
# Set existing users as admin (they were the first user)
|
|
||||||
op.execute("UPDATE user SET role = 'admin' WHERE role IS NULL")
|
|
||||||
op.execute("UPDATE user SET created_at = datetime('now') WHERE created_at IS NULL")
|
|
||||||
```
|
|
||||||
|
|
||||||
Or simpler: detect on startup, update schema automatically (current pattern).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Per-user channel keys
|
|
||||||
- User groups/teams
|
|
||||||
- API authentication tokens
|
|
||||||
- User activity logging
|
|
||||||
- Password complexity rules beyond length
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estimated Effort
|
|
||||||
|
|
||||||
| Component | Complexity |
|
|
||||||
|-----------|------------|
|
|
||||||
| Database schema change | Low |
|
|
||||||
| Admin routes + templates | Medium |
|
|
||||||
| Access control decorator | Low |
|
|
||||||
| About page QR | Low |
|
|
||||||
| CLI channel commands | Medium |
|
|
||||||
| Advanced Tools (TBD) | Medium-High |
|
|
||||||
| Testing | Medium |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
1. **Temp password flow:** Password field auto-populates with random 8-char password. Admin can override if desired. Show password once on confirmation page.
|
|
||||||
|
|
||||||
2. **Session handling:** Yes - deleting a user immediately invalidates their active sessions (ban hammer).
|
|
||||||
|
|
||||||
3. **Username rules:** Sane requirements, email-style allowed. Validation: 3-80 chars, alphanumeric, underscore, hyphen, @ and . for email-style.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Approval
|
|
||||||
|
|
||||||
- [x] Plan reviewed
|
|
||||||
- [x] Questions resolved
|
|
||||||
- [x] Ready to implement
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Multi-User Support (commit 7b33501)
|
|
||||||
- [x] Channel Key QR (Web UI) - added QR generator on About page
|
|
||||||
- [x] CLI Channel Commands
|
|
||||||
- [x] Saved Channel Keys (Web UI) - users can save/manage channel keys
|
|
||||||
- [x] Advanced Tools - Image Security Toolkit
|
|
||||||
- [x] CLI: `stegasoo tools capacity/strip/peek/exif`
|
|
||||||
- [x] API: `/api/tools/capacity`, `/api/tools/peek`, `/api/tools/exif/*`
|
|
||||||
- [x] WebUI: Tools page with tabbed interface
|
|
||||||
- [x] EXIF Editor with inline editing, clear all, save/download
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architectural Improvements (4.1.0)
|
|
||||||
|
|
||||||
### Consolidated Channel Key Resolution
|
|
||||||
|
|
||||||
Moved `resolve_channel_key()` from 3 duplicate implementations to single source of truth in `src/stegasoo/channel.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Library: src/stegasoo/channel.py
|
|
||||||
def resolve_channel_key(value, *, file_path=None, no_channel=False) -> str | None:
|
|
||||||
"""Unified channel key resolution - returns None (auto), "" (public), or key."""
|
|
||||||
|
|
||||||
def get_channel_response_info(channel_key) -> dict:
|
|
||||||
"""Get channel info dict for API/WebUI responses."""
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontends now use thin wrappers that translate exceptions to their context (Click/HTTP).
|
|
||||||
|
|
||||||
### DCT Payload Pre-Check
|
|
||||||
|
|
||||||
Added `will_fit_by_mode()` pre-check to WebUI encode to fail fast with helpful error message instead of cryptic exception deep in DCT processing.
|
|
||||||
|
|
||||||
### EXIF Tools (Library Layer)
|
|
||||||
|
|
||||||
Added to `src/stegasoo/utils.py`:
|
|
||||||
- `read_image_exif(image_data)` - Read EXIF metadata as dict
|
|
||||||
- `write_image_exif(image_data, updates)` - Update EXIF fields (JPEG only)
|
|
||||||
|
|
||||||
Dependencies added: `piexif>=1.1.0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Action Item: Architectural Review ✅ DONE
|
|
||||||
|
|
||||||
Reviewed modules for consistency with Library → CLI → API → WebUI pattern:
|
|
||||||
|
|
||||||
| Module | Library | CLI | API | WebUI | Status |
|
|
||||||
|--------|---------|-----|-----|-------|--------|
|
|
||||||
| encode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
|
||||||
| decode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
|
||||||
| channel | ✓ | ✓ | ✓ | ✓ | Consolidated resolve_channel_key |
|
|
||||||
| tools | ✓ | ✓ | ✓ | ✓ | Complete |
|
|
||||||
| generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` |
|
|
||||||
|
|
||||||
Priority order: Developer/CLI → API integrator → WebUI end-user
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin Recovery System (4.1.0) ✅ DONE
|
|
||||||
|
|
||||||
Password reset capability for locked-out admins with multiple backup options.
|
|
||||||
|
|
||||||
### Library Layer (`src/stegasoo/recovery.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Key generation and validation
|
|
||||||
generate_recovery_key() -> str # XXXX-XXXX-XXXX-... (32 chars)
|
|
||||||
hash_recovery_key(key) -> str # SHA-256 for storage
|
|
||||||
verify_recovery_key(key, hash) -> bool
|
|
||||||
|
|
||||||
# QR code (obfuscated - scans as gibberish)
|
|
||||||
obfuscate_key(key) -> str # XOR with RECOVERY_OBFUSCATION_KEY
|
|
||||||
deobfuscate_key(data) -> str | None
|
|
||||||
generate_recovery_qr(key) -> bytes # PNG with obfuscated data
|
|
||||||
extract_key_from_qr(image) -> str | None
|
|
||||||
|
|
||||||
# Stego backup (hide key in an image)
|
|
||||||
create_stego_backup(key, carrier_image) -> bytes
|
|
||||||
extract_stego_backup(stego_image, reference) -> str | None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database (`app_settings` table)
|
|
||||||
|
|
||||||
- `recovery_key_hash` - SHA-256 of recovery key (or null if disabled)
|
|
||||||
|
|
||||||
### Web Routes
|
|
||||||
|
|
||||||
| Route | Method | Description |
|
|
||||||
|-------|--------|-------------|
|
|
||||||
| `/setup/recovery` | GET, POST | Step 2 of initial setup |
|
|
||||||
| `/recover` | GET, POST | Password reset page |
|
|
||||||
| `/recover/stego` | POST | Extract key from stego backup |
|
|
||||||
| `/account/recovery/regenerate` | GET, POST | Generate new key |
|
|
||||||
| `/account/recovery/disable` | POST | Remove recovery option |
|
|
||||||
| `/account/recovery/stego-backup` | POST | Create stego backup |
|
|
||||||
|
|
||||||
### CLI Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stegasoo admin recover --db path/to/stegasoo.db # Reset password
|
|
||||||
stegasoo admin generate-key [--qr] # Generate key (reference)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Model
|
|
||||||
|
|
||||||
1. Recovery key shown once during setup - only hash stored
|
|
||||||
2. QR codes XOR'd with `RECOVERY_OBFUSCATION_KEY` (fixed in constants.py)
|
|
||||||
3. Stego backups use fixed internal passphrase/PIN - security is obscurity
|
|
||||||
4. Instance-bound: recovery key hash must match in target database
|
|
||||||
5. Options: text file, QR image, stego image, or no recovery (most secure)
|
|
||||||
250
PLAN-4.1.2.md
250
PLAN-4.1.2.md
@@ -1,250 +0,0 @@
|
|||||||
# Stegasoo 4.1.2 Plan
|
|
||||||
|
|
||||||
## Release Theme
|
|
||||||
Polish and UX improvements after the 4.1.1 stability release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Real Progress Bar for Encode/Decode
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
|
|
||||||
|
|
||||||
**Solution:** Polling + progress file approach
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
1. **dct_steganography.py** - Write progress during block loop:
|
|
||||||
```python
|
|
||||||
if progress_file and block_num % 50 == 0:
|
|
||||||
with open(progress_file, 'w') as f:
|
|
||||||
json.dump({"current": block_num, "total": total_blocks, "phase": "embedding"}, f)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **app.py** - New endpoints:
|
|
||||||
- `POST /encode` returns `job_id`, starts subprocess
|
|
||||||
- `GET /encode/progress/<job_id>` returns progress JSON
|
|
||||||
- `GET /encode/result/<job_id>` returns final result when done
|
|
||||||
|
|
||||||
3. **Subprocess wrapper** - Pass progress file path to encode/decode functions
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
1. **stegasoo.js** - After form submit:
|
|
||||||
- Show progress bar (Bootstrap progress component)
|
|
||||||
- Poll `/encode/progress/{job_id}` every 500ms
|
|
||||||
- Update bar width and percentage text
|
|
||||||
- Show phase (hashing, embedding, encoding, etc.)
|
|
||||||
|
|
||||||
2. **Templates** - Add progress bar markup to encode.html and decode.html
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
- `src/stegasoo/dct_steganography.py`
|
|
||||||
- `frontends/web/app.py`
|
|
||||||
- `frontends/web/static/js/stegasoo.js`
|
|
||||||
- `frontends/web/templates/encode.html`
|
|
||||||
- `frontends/web/templates/decode.html`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Granular Decode Error Messages
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
|
|
||||||
|
|
||||||
**Solution:** Bubble up specific error types from library to UI
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- Added new exceptions: InvalidMagicBytesError, ReedSolomonError, NoDataFoundError, ModeMismatchError
|
|
||||||
- DCT decode now raises InvalidMagicBytesError for wrong magic bytes
|
|
||||||
- DCT decode now raises ReedSolomonError (renamed from reedsolo's) for corruption
|
|
||||||
- app.py catches specific exceptions with user-friendly messages:
|
|
||||||
- Invalid magic → "Try a different mode (LSB/DCT)"
|
|
||||||
- RS error → "Image too corrupted, may have been re-saved"
|
|
||||||
- Invalid header → "Image may have been modified"
|
|
||||||
- Decryption error → "Wrong credentials"
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `src/stegasoo/exceptions.py` (new exceptions)
|
|
||||||
- `src/stegasoo/__init__.py` (exports)
|
|
||||||
- `src/stegasoo/dct_steganography.py` (raise specific exceptions)
|
|
||||||
- `frontends/web/app.py` (catch and display)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Mobile-Responsive Polish
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
|
|
||||||
|
|
||||||
**Solution:** Targeted CSS/layout fixes for mobile breakpoints
|
|
||||||
|
|
||||||
### Areas to Improve
|
|
||||||
|
|
||||||
1. **Encode/Decode Forms:**
|
|
||||||
- Stack image drop zones vertically on mobile (currently side-by-side)
|
|
||||||
- Larger touch targets for file inputs
|
|
||||||
- Full-width buttons on small screens
|
|
||||||
- Passphrase input readable at smaller sizes
|
|
||||||
|
|
||||||
2. **Navigation:**
|
|
||||||
- Hamburger menu for mobile navbar (if not already)
|
|
||||||
- Sticky header doesn't eat too much screen
|
|
||||||
- Easy thumb reach for main actions
|
|
||||||
|
|
||||||
3. **Results/Output:**
|
|
||||||
- Download buttons full-width on mobile
|
|
||||||
- QR codes sized appropriately
|
|
||||||
- Click-to-copy message box works well with touch
|
|
||||||
|
|
||||||
4. **Drop Zones:**
|
|
||||||
- Larger tap targets
|
|
||||||
- Visual feedback for touch (not just hover)
|
|
||||||
- Camera integration hint on mobile ("Tap to take photo or choose file")
|
|
||||||
|
|
||||||
### Testing Targets
|
|
||||||
- iPhone SE (small)
|
|
||||||
- iPhone 14 (medium)
|
|
||||||
- iPad (tablet)
|
|
||||||
- Android Chrome
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
- `frontends/web/static/css/style.css` (or new mobile.css)
|
|
||||||
- `frontends/web/templates/encode.html`
|
|
||||||
- `frontends/web/templates/decode.html`
|
|
||||||
- `frontends/web/templates/base.html` (navbar)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Progress bar works on localhost
|
|
||||||
- [ ] Progress bar works on Pi (slower, more visible)
|
|
||||||
- [ ] Cancellation handling (what if user navigates away?)
|
|
||||||
- [ ] Error states display correctly
|
|
||||||
- [ ] Smoke test passes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Forced First-Login Setup
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
|
|
||||||
|
|
||||||
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- Added `@app.before_request` hook that redirects to /setup if no users exist
|
|
||||||
- Skips redirect for static files and setup-related routes
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `frontends/web/app.py` (added require_setup before_request hook)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Dropzone UX Fixes
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** Dropzone has some interaction bugs:
|
|
||||||
- Dropzone doesn't clear properly if first QR image fails
|
|
||||||
- Can't click on image preview to replace file (have to click surrounding border)
|
|
||||||
|
|
||||||
**Solution:** Fix JS event handling and state management
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- Added click handler on preview images to trigger file input
|
|
||||||
- Made entire drop zone clickable (not just label)
|
|
||||||
- QR zone now resets after 2 seconds on error, allowing retry
|
|
||||||
- Clear file input on QR error so same file can be re-selected
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `frontends/web/static/js/stegasoo.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Smoke Test Benchmarking
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** No way to measure encode/decode performance or track regressions.
|
|
||||||
|
|
||||||
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- Added `--benchmark` flag to run encode/decode benchmarks after tests
|
|
||||||
- Added `--runs=N` flag to customize number of benchmark runs (default: 5)
|
|
||||||
- Uses hyperfine if available for precise timing with warmup
|
|
||||||
- Falls back to manual timing with bc if hyperfine not installed
|
|
||||||
- Outputs min/max/avg stats for both encode and decode operations
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `tests/smoke-test.sh`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Docker Cleanup
|
|
||||||
|
|
||||||
**Status:** Done (4.1.1)
|
|
||||||
|
|
||||||
**Problem:** Docker build context is larger than needed (includes test images, rpi scripts, etc.)
|
|
||||||
|
|
||||||
**Solution:** Added `.dockerignore` and fixed volume permissions in Dockerfile
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `.dockerignore` (created)
|
|
||||||
- `Dockerfile` (instance dir permissions)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Release Validation Script
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** Manual release checklist is error-prone. Need automated validation.
|
|
||||||
|
|
||||||
**Solution:** Script that runs through testable checklist items
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Run pytest
|
|
||||||
- Build and test Docker image
|
|
||||||
- SSH to Pi and run smoke test (optional, if PI_IP provided)
|
|
||||||
- Report pass/fail summary
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
- `scripts/validate-release.sh`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Smoke Test Docker Support
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
|
|
||||||
**Problem:** Smoke test expects systemd service, doesn't auto-create admin for Docker.
|
|
||||||
|
|
||||||
**Solution:** Make smoke test Docker-aware
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Skip systemd checks if not on Pi/Linux with systemd
|
|
||||||
- Auto-detect fresh Docker (no users) and create admin via /setup
|
|
||||||
- Add `--docker` flag to skip Pi-specific checks
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- Added `--docker` flag that sets localhost and skips SSH/systemd checks
|
|
||||||
- Docker health check verifies container responds with HTTP 200/302
|
|
||||||
- Header shows "Docker Smoke Test" in Docker mode
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `rpi/smoke-test.sh`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Keep 4.1.2 focused - 9 features (9 done)
|
|
||||||
- Don't break DCT compatibility (4.1.1 RS format is stable)
|
|
||||||
- Test on Pi before release
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Stegasoo 4.1.3 Plan
|
|
||||||
|
|
||||||
## Release Theme
|
|
||||||
Performance and admin features.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. DCT Performance Optimizations
|
|
||||||
|
|
||||||
**Status:** Planned
|
|
||||||
|
|
||||||
**Problem:** DCT encode/decode can be slow on Pi, especially for large images.
|
|
||||||
|
|
||||||
**Ideas:**
|
|
||||||
- Vectorize block processing with NumPy
|
|
||||||
- Reduce Python loop overhead
|
|
||||||
- Parallel block processing (multiprocessing?)
|
|
||||||
- Profile and identify bottlenecks
|
|
||||||
- Consider Cython for hot paths
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. User Management UI
|
|
||||||
|
|
||||||
**Status:** Planned
|
|
||||||
|
|
||||||
**Problem:** No way for admin to manage users via UI. Currently need direct DB access.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- List all users
|
|
||||||
- Create new user (admin only)
|
|
||||||
- Delete user (admin only)
|
|
||||||
- Reset user password
|
|
||||||
- User activity/last login
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- These are heavier lifts than 4.1.2
|
|
||||||
- Profile before optimizing
|
|
||||||
- Consider security implications of user management
|
|
||||||
165
PLAN-4.1.4.md
165
PLAN-4.1.4.md
@@ -1,165 +0,0 @@
|
|||||||
# Stegasoo 4.1.4 Plan
|
|
||||||
|
|
||||||
## Build / Deploy
|
|
||||||
- [x] Pre-built Python 3.12 venv tarball for Pi (skip 20+ min compile) - see details below
|
|
||||||
- [x] Fixed partition sizing in flash script (16GB rootfs for faster imaging)
|
|
||||||
- [x] Rename `flash-pi.sh` → `flash-stock-img.sh` for clarity
|
|
||||||
- [x] pip-audit integration in release validation
|
|
||||||
|
|
||||||
### Pi venv Tarball Approach
|
|
||||||
1. Flash fresh Pi image, let it fully build (20+ min compile)
|
|
||||||
2. Once running and working, SSH in and create optimized tarball:
|
|
||||||
```bash
|
|
||||||
cd /opt/stegasoo
|
|
||||||
# Strip caches and tests (295MB → 208MB)
|
|
||||||
find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
|
||||||
find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
|
||||||
find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
|
||||||
# Compress with zstd (208MB → 39MB)
|
|
||||||
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-venv-pi-arm64.tar.zst
|
|
||||||
```
|
|
||||||
3. Pull tarball to host: `scp admin@pi:/tmp/stegasoo-venv-pi-arm64.tar.zst rpi/`
|
|
||||||
4. setup.sh auto-detects and extracts tarball if present in rpi/
|
|
||||||
5. Re-flash and test fresh build with pre-built venv (should be <2 min vs 20+)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- [x] QR channel key sharing (see detailed plan below)
|
|
||||||
- [ ] Role-based permissions: admin / mod / user
|
|
||||||
- [x] `stegasoo info` fastfetch-style command (version, service status, channel, CPU, temp, etc.)
|
|
||||||
- [ ] Better capacity estimates / pre-flight check before encode fails
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## QR Channel Key Sharing - Implementation Plan
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
- ✅ **CLI**: `stegasoo channel qr` generates ASCII/PNG QR for server channel key
|
|
||||||
- ✅ **Web UI (about.html)**: Client-side QR generator exists - input key, generate/show QR, download PNG
|
|
||||||
- ✅ **Account page**: Shows saved channel keys with fingerprint, rename, delete
|
|
||||||
- ❌ No role restrictions on QR sharing
|
|
||||||
- ❌ No QR button for saved keys on account page
|
|
||||||
- ❌ No QR scanning to import keys
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
**UI Placement** (avoiding encode/decode page crowding):
|
|
||||||
- Keep QR generator in **about.html** (already exists, logical place for tools)
|
|
||||||
- Add QR button to **account.html** saved keys (small icon, doesn't crowd)
|
|
||||||
- Both should be admin-only
|
|
||||||
|
|
||||||
**Role Restriction** (per user request):
|
|
||||||
- QR sharing = admin only (hide generator + saved key QR buttons from non-admins)
|
|
||||||
- Prerequisite: Need role-based permissions feature first
|
|
||||||
- Interim option: Just hide from non-admin users using existing `is_admin` flag
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
#### Phase 1: Admin-only restriction (quick win)
|
|
||||||
1. **about.html**: Wrap QR generator section in `{% if is_admin %}` block
|
|
||||||
2. **Account route**: Pass `is_admin` to template (if not already)
|
|
||||||
3. **account.html**: Add small QR icon button to saved keys row (admin only)
|
|
||||||
- Opens modal with QR canvas (reuse qrcode.js pattern from about.html)
|
|
||||||
- Download PNG button in modal
|
|
||||||
|
|
||||||
#### Phase 2: QR Import (optional enhancement)
|
|
||||||
1. Add "Import via QR" button to account.html key-add section
|
|
||||||
2. Use device camera or file upload to scan QR
|
|
||||||
3. Decode and populate channel_key input field
|
|
||||||
4. Requires `pyzbar` on server OR client-side JS library like `jsQR`
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
```
|
|
||||||
frontends/web/app.py
|
|
||||||
- about() route: Add missing vars: is_admin, channel_configured,
|
|
||||||
channel_fingerprint, channel_source (BUG: currently not passed!)
|
|
||||||
- account() route: ✅ Already passes is_admin
|
|
||||||
|
|
||||||
frontends/web/templates/about.html
|
|
||||||
- Wrap channel key QR section in {% if is_admin %}
|
|
||||||
|
|
||||||
frontends/web/templates/account.html
|
|
||||||
- Add QR button to saved keys (admin only)
|
|
||||||
- Add QR modal (copy pattern from about.html)
|
|
||||||
- Include qrcode.min.js CDN script
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bug Found During Research
|
|
||||||
The about.html template uses `channel_configured`, `channel_fingerprint`,
|
|
||||||
`channel_source` but the route doesn't pass them - always shows "public mode".
|
|
||||||
Fix this while implementing QR admin restriction.
|
|
||||||
|
|
||||||
### Exact Code Changes
|
|
||||||
|
|
||||||
**app.py - Fix about() route (around line 1564):**
|
|
||||||
```python
|
|
||||||
@app.route("/about")
|
|
||||||
def about():
|
|
||||||
from stegasoo.channel import get_channel_status
|
|
||||||
channel_status = get_channel_status()
|
|
||||||
|
|
||||||
# Check if user is admin (for QR sharing)
|
|
||||||
current_user = get_current_user()
|
|
||||||
is_admin = current_user.is_admin if current_user else False
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"about.html",
|
|
||||||
has_argon2=has_argon2(),
|
|
||||||
has_qrcode_read=HAS_QRCODE_READ,
|
|
||||||
# Channel info (bugfix)
|
|
||||||
channel_configured=channel_status["configured"],
|
|
||||||
channel_fingerprint=channel_status.get("fingerprint"),
|
|
||||||
channel_source=channel_status.get("source"),
|
|
||||||
# Admin check for QR sharing
|
|
||||||
is_admin=is_admin,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template Changes Preview
|
|
||||||
|
|
||||||
**account.html - Add to saved key row:**
|
|
||||||
```html
|
|
||||||
{% if is_admin %}
|
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
|
||||||
onclick="showKeyQr('{{ key.channel_key }}')" title="Show QR">
|
|
||||||
<i class="bi bi-qr-code"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
```
|
|
||||||
|
|
||||||
**about.html - Wrap existing section:**
|
|
||||||
```html
|
|
||||||
{% if is_admin %}
|
|
||||||
<!-- Channel Key QR Generator -->
|
|
||||||
<div class="card bg-dark border-secondary">
|
|
||||||
...existing QR generator...
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Checklist (Phase 1 Implemented)
|
|
||||||
- [ ] Non-admin users cannot see QR generator in about.html
|
|
||||||
- [ ] Non-admin users cannot see QR buttons on account page
|
|
||||||
- [ ] Admin users can generate QR for any saved key
|
|
||||||
- [ ] QR downloads work correctly
|
|
||||||
- [ ] QR scans correctly with phone camera
|
|
||||||
|
|
||||||
### Implementation Status
|
|
||||||
**Phase 1: COMPLETE** - Admin-only QR sharing implemented:
|
|
||||||
- `app.py`: Fixed about() route to pass channel status + is_admin
|
|
||||||
- `about.html`: QR generator wrapped in `{% if is_admin %}` with Admin badge
|
|
||||||
- `account.html`: QR button added to saved keys (admin only), modal + JS for generation/download
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
|
||||||
- [ ] Optional encryption for temp file storage (paranoid mode, config toggle)
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
- [x] Update UNDER_THE_HOOD.md (v4.1 changes, channel keys)
|
|
||||||
- [ ] General docs refresh
|
|
||||||
|
|
||||||
## Ideas (maybe later)
|
|
||||||
- [ ] Stego detection tool
|
|
||||||
- [ ] Browser extension
|
|
||||||
- [ ] Pi snapshot/backup feature
|
|
||||||
106
PLAN-4.1.5.md
106
PLAN-4.1.5.md
@@ -1,106 +0,0 @@
|
|||||||
# Stegasoo 4.1.5 Plan
|
|
||||||
|
|
||||||
## Decode Progress Bar (Real Progress)
|
|
||||||
|
|
||||||
Mirror the encode async pattern for decode operations.
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
**1. Add async mode to `/decode` route (`app.py`)**
|
|
||||||
- Check for `async=true` form param
|
|
||||||
- Generate job_id, store job, submit to executor
|
|
||||||
- Return `{"job_id": ..., "status": "pending"}` immediately
|
|
||||||
|
|
||||||
**2. Add decode status/progress endpoints (`app.py`)**
|
|
||||||
```python
|
|
||||||
@app.route("/decode/status/<job_id>")
|
|
||||||
def decode_status(job_id):
|
|
||||||
# Return {"status": "pending|running|complete|error", "result": {...}}
|
|
||||||
|
|
||||||
@app.route("/decode/progress/<job_id>")
|
|
||||||
def decode_progress(job_id):
|
|
||||||
# Read from /tmp/stegasoo_progress_{job_id}.json
|
|
||||||
# Return {"percent": 0-100, "phase": "..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Add `_run_decode_job()` background worker (`app.py`)**
|
|
||||||
- Similar to `_run_encode_job()`
|
|
||||||
- Pass `progress_file` param to decode function
|
|
||||||
- Store result/error in job dict
|
|
||||||
|
|
||||||
**4. Update decode functions to write progress (`lsb_steganography.py`, `dct_steganography.py`)**
|
|
||||||
|
|
||||||
Phases for decode:
|
|
||||||
- `"starting"` (0%)
|
|
||||||
- `"reading"` (10%) - reading stego image
|
|
||||||
- `"extracting"` (30%) - extracting hidden data
|
|
||||||
- `"decrypting"` (60%) - Argon2 + AES decryption
|
|
||||||
- `"verifying"` (80%) - HMAC verification
|
|
||||||
- `"finalizing"` (95%) - preparing output
|
|
||||||
- `"complete"` (100%)
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
**5. Update decode form submission (`decode.html`)**
|
|
||||||
- Add async form handler like encode
|
|
||||||
- Call `Stegasoo.submitDecodeAsync(form, btn)`
|
|
||||||
|
|
||||||
**6. Add decode async methods (`stegasoo.js`)**
|
|
||||||
```javascript
|
|
||||||
submitDecodeAsync(form, btn) // POST with async=true, show modal
|
|
||||||
pollDecodeProgress(jobId) // Poll /decode/status, /decode/progress
|
|
||||||
```
|
|
||||||
|
|
||||||
Reuse existing:
|
|
||||||
- `showProgressModal('Decoding')`
|
|
||||||
- `updateProgress(percent, phase)`
|
|
||||||
|
|
||||||
**7. Handle decode result redirect**
|
|
||||||
- On complete: redirect to `/decode/result/{file_id}` or display inline
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
```
|
|
||||||
frontends/web/app.py
|
|
||||||
- Add async handling to /decode route (~line 1300+)
|
|
||||||
- Add /decode/status/<job_id> endpoint
|
|
||||||
- Add /decode/progress/<job_id> endpoint
|
|
||||||
- Add _run_decode_job() function
|
|
||||||
|
|
||||||
frontends/web/static/js/stegasoo.js
|
|
||||||
- Add submitDecodeAsync()
|
|
||||||
- Add pollDecodeProgress()
|
|
||||||
|
|
||||||
frontends/web/templates/decode.html
|
|
||||||
- Update form submit to use async mode
|
|
||||||
|
|
||||||
src/stegasoo/lsb_steganography.py
|
|
||||||
- Add progress_file param to decode()
|
|
||||||
- Write progress at each phase
|
|
||||||
|
|
||||||
src/stegasoo/dct_steganography.py
|
|
||||||
- Add progress_file param to decode()
|
|
||||||
- Write progress at each phase
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Decode shows progress modal on submit
|
|
||||||
- [ ] Progress bar animates through phases
|
|
||||||
- [ ] Successful decode redirects to result
|
|
||||||
- [ ] Failed decode shows error in modal
|
|
||||||
- [ ] Works for both LSB and DCT modes
|
|
||||||
- [ ] Works for message and file payloads
|
|
||||||
- [ ] Progress file cleaned up after completion
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Other 4.1.5 Ideas (if time)
|
|
||||||
|
|
||||||
- [ ] Role-based permissions: admin / mod / user
|
|
||||||
- [ ] Better capacity estimates / pre-flight check
|
|
||||||
- [ ] Stego detection tool
|
|
||||||
|
|
||||||
## Bugs / Nice to Have
|
|
||||||
|
|
||||||
- [ ] **flash-stock-img.sh 16GB resize not working** - partition still full SD size after flash, makes dd pull slow. Investigate resize2fs/parted logic and test fix.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Stegasoo 4.1.1 Release Notes
|
|
||||||
|
|
||||||
**Release Date:** January 5, 2026
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- **Reed-Solomon Error Correction** - DCT steganography now includes RS error correction, making encoded images more resilient to minor corruption and compression artifacts
|
|
||||||
- **Completely Rewritten Pi Setup** - Fresh install tested and validated, works reliably from scratch
|
|
||||||
- **SSH Login Banner** - See your Stegasoo URL immediately on SSH login
|
|
||||||
|
|
||||||
## New Features
|
|
||||||
|
|
||||||
### Reed-Solomon Error Correction
|
|
||||||
DCT-encoded images now include Reed-Solomon error correction codes, allowing recovery from minor image corruption. This significantly improves reliability when images are shared through platforms that may slightly modify them.
|
|
||||||
|
|
||||||
### SSH Login Banner (MOTD)
|
|
||||||
When you SSH into your Stegasoo Pi, you'll now see:
|
|
||||||
```
|
|
||||||
___ _____ ___ ___ _ ___ ___ ___
|
|
||||||
/ __||_ _|| __| / __| /_\ / __| / _ \ / _ \
|
|
||||||
\__ \ | | | _| | (_ | / _ \ \__ \ | (_) || (_) |
|
|
||||||
|___/ |_| |___| \___//_/ \_\|___/ \___/ \___/
|
|
||||||
|
|
||||||
● Stegasoo is running
|
|
||||||
https://192.168.0.4
|
|
||||||
```
|
|
||||||
|
|
||||||
### Elapsed Time Counter
|
|
||||||
Encode/decode buttons now show elapsed time during operations.
|
|
||||||
|
|
||||||
### Click-to-Copy Decoded Message
|
|
||||||
Click the decoded message box to copy to clipboard (no button needed).
|
|
||||||
|
|
||||||
### Overclock Wizard Option
|
|
||||||
First-boot wizard now offers optional CPU overclocking for Pi 4/5 with active cooling.
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
### Setup Script (setup.sh)
|
|
||||||
- Fixed pyenv Python path resolution (handles 3.12 → 3.12.12 mapping)
|
|
||||||
- Changed default install location to `/opt/stegasoo`
|
|
||||||
- Fixed jpegio build order (clone stegasoo first, then build jpegio into venv)
|
|
||||||
- Added python3-dev to dependencies
|
|
||||||
- Added btop for system monitoring
|
|
||||||
- Shows `/setup` URL at completion for admin account creation
|
|
||||||
|
|
||||||
### Sanitize Script
|
|
||||||
- Now clears port 443 iptables redirect (clean slate for wizard)
|
|
||||||
- Removes overclock settings before imaging
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Updated all docs to reference `/opt/stegasoo` path
|
|
||||||
- Added pre-setup steps (chown /opt, install git)
|
|
||||||
- Added Pi 4 performance baseline (~60s for 10MB JPEG)
|
|
||||||
|
|
||||||
### About Page
|
|
||||||
- Redesigned "Limits & Specs" section with key stats cards and accordion
|
|
||||||
|
|
||||||
## Bug Fixes
|
|
||||||
|
|
||||||
- Fixed DCT steganography for non-8-aligned images
|
|
||||||
- Fixed MOTD port detection (was using iptables which requires root)
|
|
||||||
- Fixed smoke test `--443` flag parsing
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
On a Raspberry Pi 4 at 2GHz with USB 3.0 NVMe:
|
|
||||||
- ~50 seconds to encode a 10MB JPEG
|
|
||||||
- ~60 seconds to decode a 10MB JPEG
|
|
||||||
- Full encryption: passphrase + PIN + reference photo
|
|
||||||
|
|
||||||
## Upgrade Notes
|
|
||||||
|
|
||||||
If upgrading from 4.1.0:
|
|
||||||
```bash
|
|
||||||
cd /opt/stegasoo # or ~/stegasoo
|
|
||||||
git pull origin 4.1
|
|
||||||
```
|
|
||||||
|
|
||||||
For fresh installs, see the [Pi README](rpi/README.md).
|
|
||||||
|
|
||||||
## Pre-built Images
|
|
||||||
|
|
||||||
- `stegasoo-rpi-4.1.1_20260105-2.img.zst` - Raspberry Pi 4/5 image
|
|
||||||
|
|
||||||
Flash with:
|
|
||||||
```bash
|
|
||||||
zstdcat stegasoo-rpi-4.1.1_20260105-2.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Full changelog: [v4.1.0...v4.1.1](https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.1)
|
|
||||||
@@ -1,14 +1,41 @@
|
|||||||
## Stegasoo v4.1.3
|
## Stegasoo v4.1.5
|
||||||
|
|
||||||
### Fixes
|
### Developer Experience
|
||||||
- **SSL Certificate Generation**: First-boot wizard now properly generates self-signed certs when HTTPS is enabled
|
- **Educational Code Comments**: Core modules now include detailed explanations
|
||||||
- **Download Bug Fixed**: No more "File expired or not found" errors - fixed multi-worker temp file sharing
|
- DCT: zig-zag coefficient diagrams, QIM embedding math, Reed-Solomon "Voyager" reference
|
||||||
- **Docker Build**: Reduced build context from 2.3GB to ~900KB
|
- 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
|
||||||
|
|
||||||
### Improvements
|
### Raspberry Pi Improvements
|
||||||
- Docker memory limits increased to 2GB (prevents OOM on large DCT operations)
|
- **Streamlined Image Creation**: `pull-image.sh` now handles everything
|
||||||
- Decode button now shows loading spinner during processing
|
- Auto-resizes rootfs to exactly 16GB (consistent images from any SD card)
|
||||||
- Headless Pi flash script with Trixie/NetworkManager support
|
- 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
|
### Docker
|
||||||
```bash
|
```bash
|
||||||
@@ -16,19 +43,5 @@ docker-compose up -d web # Web UI on :5000
|
|||||||
docker-compose up -d api # REST API on :8000
|
docker-compose up -d api # REST API on :8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Raspberry Pi Image
|
|
||||||
Download `stegasoo-rpi-4.1.3.img.zst`, flash to SD card, and boot. The first-boot wizard will guide you through WiFi, HTTPS, and channel key setup.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Flash with included script
|
|
||||||
./rpi/flash-image.sh stegasoo-rpi-4.1.3.img.zst /dev/sdX
|
|
||||||
|
|
||||||
# First time: save your WiFi credentials
|
|
||||||
./rpi/inject-wifi.sh --setup
|
|
||||||
|
|
||||||
# Then inject WiFi after flashing
|
|
||||||
sudo ./rpi/inject-wifi.sh /dev/sdX
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full Changelog
|
### Full Changelog
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for complete details.
|
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||||
|
|||||||
42
WISHLIST-4.2.md
Normal file
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
|
||||||
@@ -2,23 +2,76 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo Web Frontend (v4.0.0)
|
Stegasoo Web Frontend (v4.0.0)
|
||||||
|
|
||||||
Flask-based web UI for steganography operations.
|
A production Flask application demonstrating proper web architecture patterns.
|
||||||
Supports both text messages and file embedding.
|
This isn't just a quick demo - it's built to run on a Raspberry Pi 24/7.
|
||||||
|
|
||||||
|
ARCHITECTURE OVERVIEW
|
||||||
|
=====================
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FLASK APPLICATION │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Routes (/encode, /decode, /api/*) │
|
||||||
|
│ │ │
|
||||||
|
│ ├── auth.py # Session management, user accounts │
|
||||||
|
│ ├── temp_storage.py # File-based temp storage with expiry │
|
||||||
|
│ ├── subprocess_stego.py # Isolated encode/decode workers │
|
||||||
|
│ └── ssl_utils.py # Self-signed cert generation │
|
||||||
|
│ │
|
||||||
|
│ Templates (Jinja2) │
|
||||||
|
│ └── base.html → encode.html, decode.html, etc. │
|
||||||
|
│ │
|
||||||
|
│ Static assets (CSS, JS) │
|
||||||
|
│ └── Vanilla JS, no framework (keeps it simple) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
KEY PATTERNS
|
||||||
|
============
|
||||||
|
|
||||||
|
1. SUBPROCESS ISOLATION
|
||||||
|
Stegasoo's DCT mode uses scipy/jpegio which can crash on malformed input.
|
||||||
|
We run encode/decode in subprocesses so crashes don't take down the server:
|
||||||
|
|
||||||
|
subprocess_stego = SubprocessStego(timeout=180)
|
||||||
|
result = subprocess_stego.encode(carrier, ref, message, ...)
|
||||||
|
|
||||||
|
If the subprocess crashes, we catch it and return an error gracefully.
|
||||||
|
|
||||||
|
2. ASYNC JOBS WITH PROGRESS
|
||||||
|
Encoding large images can take 30+ seconds. We use ThreadPoolExecutor
|
||||||
|
to run jobs in background threads with progress reporting:
|
||||||
|
|
||||||
|
job_id = generate_job_id()
|
||||||
|
_executor.submit(_run_encode_job, job_id, params)
|
||||||
|
# Client polls /api/encode/progress/<job_id> for updates
|
||||||
|
|
||||||
|
3. CONTEXT PROCESSORS
|
||||||
|
@app.context_processor injects variables into ALL templates:
|
||||||
|
|
||||||
|
return {"version": __version__, "has_dct": has_dct_support()}
|
||||||
|
|
||||||
|
Now every template can use {{ version }} without passing it explicitly.
|
||||||
|
|
||||||
|
4. BEFORE_REQUEST HOOKS
|
||||||
|
@app.before_request runs before every request. We use it for:
|
||||||
|
- First-run setup redirect (no users → /setup)
|
||||||
|
- Session validation
|
||||||
|
- Cleanup of old temp files
|
||||||
|
|
||||||
|
5. SECURE SECRET KEY
|
||||||
|
Flask sessions need a secret key. We persist it to a file so sessions
|
||||||
|
survive server restarts (otherwise everyone gets logged out).
|
||||||
|
|
||||||
CHANGES in v4.0.0:
|
CHANGES in v4.0.0:
|
||||||
- Added channel key support for deployment/group isolation
|
- Added channel key support for deployment/group isolation
|
||||||
- New /api/channel/status endpoint
|
- New /api/channel/status endpoint
|
||||||
- Channel key selector on encode/decode pages
|
- Channel key selector on encode/decode pages
|
||||||
- Messages encoded with channel key require same key to decode
|
|
||||||
|
|
||||||
CHANGES in v3.2.0:
|
CHANGES in v3.2.0:
|
||||||
- Removed date dependency from all operations
|
- Removed date dependency from all operations
|
||||||
- Renamed day_phrase → passphrase
|
|
||||||
- No date selection or tracking needed
|
|
||||||
- Simplified user experience for asynchronous communications
|
- Simplified user experience for asynchronous communications
|
||||||
|
|
||||||
NEW in v3.0: LSB and DCT embedding modes with advanced options.
|
|
||||||
NEW in v3.0.1: DCT output format selection (PNG or JPEG) and color mode (grayscale or color).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -31,6 +84,7 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import temp_storage
|
||||||
from auth import (
|
from auth import (
|
||||||
MAX_CHANNEL_KEYS,
|
MAX_CHANNEL_KEYS,
|
||||||
MAX_USERS,
|
MAX_USERS,
|
||||||
@@ -83,7 +137,6 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from ssl_utils import ensure_certs
|
from ssl_utils import ensure_certs
|
||||||
import temp_storage
|
|
||||||
|
|
||||||
os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0"
|
os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0"
|
||||||
os.environ["OMP_NUM_THREADS"] = "1"
|
os.environ["OMP_NUM_THREADS"] = "1"
|
||||||
@@ -157,8 +210,35 @@ except ImportError:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SUBPROCESS ISOLATION FOR STEGASOO OPERATIONS
|
# SUBPROCESS ISOLATION FOR STEGASOO OPERATIONS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
#
|
||||||
# from taking down the Flask server.
|
# This is a critical reliability pattern. Here's the problem:
|
||||||
|
#
|
||||||
|
# scipy's DCT and jpegio can crash (segfault) on:
|
||||||
|
# - Malformed JPEG files
|
||||||
|
# - Very large images that exhaust memory
|
||||||
|
# - Certain edge cases in coefficient manipulation
|
||||||
|
#
|
||||||
|
# If these crash in the main Flask process, your whole server dies.
|
||||||
|
# Users get a connection reset, and the service goes down.
|
||||||
|
#
|
||||||
|
# The solution: Run stegasoo operations in separate Python processes.
|
||||||
|
#
|
||||||
|
# Main Flask process Worker subprocess
|
||||||
|
# ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
# │ │ spawn │ │
|
||||||
|
# │ /api/encode │──────────────>│ encode() │
|
||||||
|
# │ │ │ │
|
||||||
|
# │ wait for │<──────────────│ return result │
|
||||||
|
# │ result │ or crash │ (or crash) │
|
||||||
|
# │ │ │ │
|
||||||
|
# │ handle error │ │ (process dies) │
|
||||||
|
# └─────────────────┘ └─────────────────┘
|
||||||
|
#
|
||||||
|
# If the subprocess crashes, we catch the error and return a friendly message.
|
||||||
|
# The main server keeps running. Users can try again with different input.
|
||||||
|
#
|
||||||
|
# The subprocess_stego module handles all the pickling/unpickling of data.
|
||||||
|
|
||||||
from subprocess_stego import (
|
from subprocess_stego import (
|
||||||
SubprocessStego,
|
SubprocessStego,
|
||||||
cleanup_progress_file,
|
cleanup_progress_file,
|
||||||
@@ -169,6 +249,7 @@ from subprocess_stego import (
|
|||||||
|
|
||||||
from stegasoo.qr_utils import (
|
from stegasoo.qr_utils import (
|
||||||
can_fit_in_qr,
|
can_fit_in_qr,
|
||||||
|
decompress_data,
|
||||||
detect_and_crop_qr,
|
detect_and_crop_qr,
|
||||||
extract_key_from_qr,
|
extract_key_from_qr,
|
||||||
generate_qr_code,
|
generate_qr_code,
|
||||||
@@ -181,38 +262,89 @@ subprocess_stego = SubprocessStego(timeout=180) # 3 minute timeout for large im
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FLASK APP CONFIGURATION
|
# FLASK APP CONFIGURATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Flask configuration demonstrates several production patterns:
|
||||||
|
#
|
||||||
|
# 1. SECRET KEY PERSISTENCE
|
||||||
|
# Flask uses secret_key to sign session cookies. If it changes, all users
|
||||||
|
# get logged out. We save it to a file so it survives restarts.
|
||||||
|
#
|
||||||
|
# 2. CONTENT LENGTH LIMITS
|
||||||
|
# MAX_CONTENT_LENGTH prevents DoS via huge uploads. Flask will reject
|
||||||
|
# requests that exceed this before loading them into memory.
|
||||||
|
#
|
||||||
|
# 3. ENVIRONMENT-BASED CONFIG
|
||||||
|
# Settings come from environment variables, allowing:
|
||||||
|
# - Different settings per deployment (dev/staging/prod)
|
||||||
|
# - Docker/systemd to inject config without code changes
|
||||||
|
# - 12-factor app compliance
|
||||||
|
#
|
||||||
|
# 4. INSTANCE FOLDER
|
||||||
|
# Flask's instance_path is for per-deployment data (databases, keys).
|
||||||
|
# It's .gitignored by default - perfect for secrets.
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Persist secret key so sessions survive restarts
|
# Persist secret key so sessions survive restarts
|
||||||
|
# Without this, every restart = everyone gets logged out
|
||||||
_instance_path = Path(app.instance_path)
|
_instance_path = Path(app.instance_path)
|
||||||
_instance_path.mkdir(parents=True, exist_ok=True)
|
_instance_path.mkdir(parents=True, exist_ok=True)
|
||||||
_secret_key_file = _instance_path / ".secret_key"
|
_secret_key_file = _instance_path / ".secret_key"
|
||||||
if _secret_key_file.exists():
|
if _secret_key_file.exists():
|
||||||
app.secret_key = _secret_key_file.read_text().strip()
|
app.secret_key = _secret_key_file.read_text().strip()
|
||||||
else:
|
else:
|
||||||
app.secret_key = secrets.token_hex(32)
|
# First run: generate a new key and save it
|
||||||
|
app.secret_key = secrets.token_hex(32) # 256 bits of randomness
|
||||||
_secret_key_file.write_text(app.secret_key)
|
_secret_key_file.write_text(app.secret_key)
|
||||||
_secret_key_file.chmod(0o600)
|
_secret_key_file.chmod(0o600) # Only owner can read
|
||||||
|
|
||||||
|
# Reject uploads larger than this (prevents memory exhaustion)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
||||||
|
|
||||||
# Auth configuration from environment
|
# Auth configuration from environment
|
||||||
|
# STEGASOO_AUTH_ENABLED=false disables login (for local/dev use)
|
||||||
app.config["AUTH_ENABLED"] = os.environ.get("STEGASOO_AUTH_ENABLED", "true").lower() == "true"
|
app.config["AUTH_ENABLED"] = os.environ.get("STEGASOO_AUTH_ENABLED", "true").lower() == "true"
|
||||||
app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").lower() == "true"
|
app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").lower() == "true"
|
||||||
|
|
||||||
# Initialize auth module
|
# Initialize auth module (sets up session handling, user DB)
|
||||||
init_auth(app)
|
init_auth(app)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ASYNC JOB MANAGEMENT (v4.1.2)
|
# ASYNC JOB MANAGEMENT (v4.1.2)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Encode operations can run in background threads with progress reporting
|
#
|
||||||
|
# Problem: DCT encoding a large image can take 30-60 seconds.
|
||||||
|
# Solution: Run it in a background thread, let the client poll for progress.
|
||||||
|
#
|
||||||
|
# The flow:
|
||||||
|
#
|
||||||
|
# Client Server
|
||||||
|
# ────── ──────
|
||||||
|
# POST /api/encode/async ──────> Start background job
|
||||||
|
# <────── Return job_id
|
||||||
|
#
|
||||||
|
# GET /api/encode/progress/123 ─> Check job status
|
||||||
|
# <────── {"progress": 45, "phase": "embedding"}
|
||||||
|
#
|
||||||
|
# GET /api/encode/progress/123 ─> Check again
|
||||||
|
# <────── {"status": "complete", "file_id": "abc"}
|
||||||
|
#
|
||||||
|
# GET /api/download/abc ────────> Download result
|
||||||
|
# <────── Encoded image
|
||||||
|
#
|
||||||
|
# Why ThreadPoolExecutor instead of Celery/Redis?
|
||||||
|
# - This runs on a Raspberry Pi with 1GB RAM
|
||||||
|
# - We don't need distributed workers
|
||||||
|
# - Keep it simple - threads are fine for 2 concurrent jobs
|
||||||
|
#
|
||||||
|
# The thread pool is limited to 2 workers because:
|
||||||
|
# - Each encode loads the full image into memory
|
||||||
|
# - Too many concurrent jobs = OOM on the Pi
|
||||||
|
|
||||||
# Thread pool for background encode/decode operations
|
|
||||||
_executor = ThreadPoolExecutor(max_workers=2)
|
_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
|
||||||
# Job storage: job_id -> {status, result, error, file_id, ...}
|
# Job storage: job_id -> {status, result, error, file_id, created, ...}
|
||||||
|
# We use a dict with a lock because threads access it concurrently
|
||||||
_jobs = {}
|
_jobs = {}
|
||||||
_jobs_lock = threading.Lock()
|
_jobs_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -267,6 +399,27 @@ THUMBNAIL_FILES: dict[str, bytes] = {} # Not used - see temp_storage.py
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# TEMPLATE CONTEXT PROCESSOR
|
# TEMPLATE CONTEXT PROCESSOR
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Context processors inject variables into EVERY template automatically.
|
||||||
|
# Instead of passing the same data to every render_template() call:
|
||||||
|
#
|
||||||
|
# # Bad: repetitive and error-prone
|
||||||
|
# return render_template("page.html", version=__version__, has_dct=...)
|
||||||
|
#
|
||||||
|
# We define it once here and it's available everywhere:
|
||||||
|
#
|
||||||
|
# # In any template:
|
||||||
|
# <p>Version: {{ version }}</p>
|
||||||
|
# {% if has_dct %}DCT mode available{% endif %}
|
||||||
|
#
|
||||||
|
# This is great for:
|
||||||
|
# - Version numbers (show in footer)
|
||||||
|
# - Feature flags (has_dct, auth_enabled)
|
||||||
|
# - User info (username, is_admin)
|
||||||
|
# - Global config (max sizes, limits)
|
||||||
|
#
|
||||||
|
# The function runs on EVERY request, so keep it fast.
|
||||||
|
# Don't do expensive database queries here.
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -1049,12 +1202,19 @@ def encode_page():
|
|||||||
ref_data = ref_photo.read()
|
ref_data = ref_photo.read()
|
||||||
carrier_data = carrier.read()
|
carrier_data = carrier.read()
|
||||||
|
|
||||||
# Handle RSA key - can come from .pem file or QR code image
|
# Handle RSA key - can come from .pem file, QR code image, or webcam-scanned PEM (v4.1.5)
|
||||||
rsa_key_data = None
|
rsa_key_data = None
|
||||||
|
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||||
rsa_key_from_qr = False
|
rsa_key_from_qr = False
|
||||||
|
|
||||||
if rsa_key_file and rsa_key_file.filename:
|
if rsa_key_pem:
|
||||||
|
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
||||||
|
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
||||||
|
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||||
|
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||||
|
rsa_key_from_qr = True
|
||||||
|
elif rsa_key_file and rsa_key_file.filename:
|
||||||
rsa_key_data = rsa_key_file.read()
|
rsa_key_data = rsa_key_file.read()
|
||||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||||
qr_image_data = rsa_key_qr.read()
|
qr_image_data = rsa_key_qr.read()
|
||||||
@@ -1371,6 +1531,82 @@ def encode_cleanup(file_id):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _run_decode_job(job_id: str, decode_params: dict) -> None:
|
||||||
|
"""Background thread function for async decode."""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||||
|
|
||||||
|
# Run decode with progress file
|
||||||
|
decode_result = subprocess_stego.decode(
|
||||||
|
stego_data=decode_params["stego_data"],
|
||||||
|
reference_data=decode_params["ref_data"],
|
||||||
|
passphrase=decode_params["passphrase"],
|
||||||
|
pin=decode_params.get("pin"),
|
||||||
|
rsa_key_data=decode_params.get("rsa_key_data"),
|
||||||
|
rsa_password=decode_params.get("rsa_password"),
|
||||||
|
embed_mode=decode_params.get("embed_mode", "auto"),
|
||||||
|
channel_key=decode_params.get("channel_key"),
|
||||||
|
progress_file=progress_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not decode_result.success:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": decode_result.error or "Decoding failed",
|
||||||
|
"error_type": decode_result.error_type,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store result based on type
|
||||||
|
if decode_result.is_file:
|
||||||
|
file_id = secrets.token_urlsafe(16)
|
||||||
|
filename = decode_result.filename or "decoded_file"
|
||||||
|
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||||
|
"filename": filename,
|
||||||
|
"mime_type": decode_result.mime_type,
|
||||||
|
})
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"file_id": file_id,
|
||||||
|
"is_file": True,
|
||||||
|
"filename": filename,
|
||||||
|
"file_size": len(decode_result.file_data),
|
||||||
|
"mime_type": decode_result.mime_type,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"is_file": False,
|
||||||
|
"message": decode_result.message,
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
cleanup_progress_file(job_id)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/decode", methods=["GET", "POST"])
|
@app.route("/decode", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def decode_page():
|
def decode_page():
|
||||||
@@ -1414,12 +1650,19 @@ def decode_page():
|
|||||||
ref_data = ref_photo.read()
|
ref_data = ref_photo.read()
|
||||||
stego_data = stego_image.read()
|
stego_data = stego_image.read()
|
||||||
|
|
||||||
# Handle RSA key - can come from .pem file or QR code image
|
# Handle RSA key - can come from .pem file, QR code image, or webcam-scanned PEM (v4.1.5)
|
||||||
rsa_key_data = None
|
rsa_key_data = None
|
||||||
|
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||||
rsa_key_from_qr = False
|
rsa_key_from_qr = False
|
||||||
|
|
||||||
if rsa_key_file and rsa_key_file.filename:
|
if rsa_key_pem:
|
||||||
|
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
||||||
|
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
||||||
|
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||||
|
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||||
|
rsa_key_from_qr = True
|
||||||
|
elif rsa_key_file and rsa_key_file.filename:
|
||||||
rsa_key_data = rsa_key_file.read()
|
rsa_key_data = rsa_key_file.read()
|
||||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||||
qr_image_data = rsa_key_qr.read()
|
qr_image_data = rsa_key_qr.read()
|
||||||
@@ -1454,6 +1697,29 @@ def decode_page():
|
|||||||
flash(result.error_message, "error")
|
flash(result.error_message, "error")
|
||||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
# Check for async mode (v4.1.5)
|
||||||
|
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||||
|
|
||||||
|
# Build decode params
|
||||||
|
decode_params = {
|
||||||
|
"stego_data": stego_data,
|
||||||
|
"ref_data": ref_data,
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin if pin else None,
|
||||||
|
"rsa_key_data": rsa_key_data,
|
||||||
|
"rsa_password": key_password,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"channel_key": channel_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ASYNC MODE: Start background job and return JSON
|
||||||
|
if is_async:
|
||||||
|
job_id = generate_job_id()
|
||||||
|
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||||
|
_executor.submit(_run_decode_job, job_id, decode_params)
|
||||||
|
return jsonify({"job_id": job_id, "status": "pending"})
|
||||||
|
|
||||||
|
# SYNC MODE: Run inline (original behavior)
|
||||||
# v4.0.0: Include channel_key parameter
|
# v4.0.0: Include channel_key parameter
|
||||||
# Use subprocess-isolated decode to prevent crashes
|
# Use subprocess-isolated decode to prevent crashes
|
||||||
decode_result = subprocess_stego.decode(
|
decode_result = subprocess_stego.decode(
|
||||||
@@ -1559,6 +1825,92 @@ def decode_download(file_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DECODE PROGRESS ENDPOINTS (v4.1.5)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/decode/status/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def decode_status(job_id):
|
||||||
|
"""Get the status of an async decode job."""
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
|
||||||
|
response = {"status": job.get("status", "unknown")}
|
||||||
|
|
||||||
|
if job["status"] == "complete":
|
||||||
|
response["is_file"] = job.get("is_file", False)
|
||||||
|
if job.get("is_file"):
|
||||||
|
response["file_id"] = job.get("file_id")
|
||||||
|
response["filename"] = job.get("filename")
|
||||||
|
response["file_size"] = job.get("file_size")
|
||||||
|
response["mime_type"] = job.get("mime_type")
|
||||||
|
else:
|
||||||
|
response["message"] = job.get("message")
|
||||||
|
elif job["status"] == "error":
|
||||||
|
response["error"] = job.get("error", "Unknown error")
|
||||||
|
response["error_type"] = job.get("error_type")
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/decode/progress/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def decode_progress(job_id):
|
||||||
|
"""Get the progress of an async decode job."""
|
||||||
|
progress = read_progress(job_id)
|
||||||
|
if progress:
|
||||||
|
return jsonify(progress)
|
||||||
|
|
||||||
|
# No progress file yet - check job status
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
|
||||||
|
if job["status"] == "complete":
|
||||||
|
return jsonify({"percent": 100, "phase": "complete"})
|
||||||
|
elif job["status"] == "error":
|
||||||
|
return jsonify({"percent": 0, "phase": "error", "error": job.get("error")})
|
||||||
|
elif job["status"] == "pending":
|
||||||
|
return jsonify({"percent": 0, "phase": "starting"})
|
||||||
|
|
||||||
|
# Running but no progress file yet
|
||||||
|
return jsonify({"percent": 5, "phase": "reading"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/decode/result/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def decode_result(job_id):
|
||||||
|
"""Get the result page for an async decode job."""
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
flash("Job not found or expired.", "error")
|
||||||
|
return redirect(url_for("decode_page"))
|
||||||
|
|
||||||
|
if job["status"] != "complete":
|
||||||
|
flash("Decode not complete.", "error")
|
||||||
|
return redirect(url_for("decode_page"))
|
||||||
|
|
||||||
|
if job.get("is_file"):
|
||||||
|
return render_template(
|
||||||
|
"decode.html",
|
||||||
|
decoded_file=True,
|
||||||
|
file_id=job.get("file_id"),
|
||||||
|
filename=job.get("filename"),
|
||||||
|
file_size=format_size(job.get("file_size", 0)),
|
||||||
|
mime_type=job.get("mime_type"),
|
||||||
|
has_qrcode_read=HAS_QRCODE_READ,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
"decode.html",
|
||||||
|
decoded_message=job.get("message"),
|
||||||
|
has_qrcode_read=HAS_QRCODE_READ,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/about")
|
@app.route("/about")
|
||||||
def about():
|
def about():
|
||||||
from stegasoo.channel import get_channel_status
|
from stegasoo.channel import get_channel_status
|
||||||
@@ -2332,7 +2684,8 @@ if __name__ == "__main__":
|
|||||||
# HTTPS configuration
|
# HTTPS configuration
|
||||||
ssl_context = None
|
ssl_context = None
|
||||||
if app.config.get("HTTPS_ENABLED", False):
|
if app.config.get("HTTPS_ENABLED", False):
|
||||||
hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost")
|
import socket
|
||||||
|
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
|
||||||
try:
|
try:
|
||||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||||
if cert_path.exists() and key_path.exists():
|
if cert_path.exists() and key_path.exists():
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Uses cryptography library (already a dependency).
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import socket
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
@@ -15,6 +16,33 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
|||||||
from cryptography.x509.oid import NameOID
|
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]:
|
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
|
||||||
"""Get paths for cert and key files."""
|
"""Get paths for cert and key files."""
|
||||||
cert_dir = base_dir / "certs"
|
cert_dir = base_dir / "certs"
|
||||||
@@ -64,12 +92,26 @@ def generate_self_signed_cert(
|
|||||||
x509.DNSName("localhost"),
|
x509.DNSName("localhost"),
|
||||||
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
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
|
# Add the hostname as IP if it looks like one
|
||||||
try:
|
try:
|
||||||
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
||||||
except ipaddress.AddressValueError:
|
except ipaddress.AddressValueError:
|
||||||
pass
|
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)
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
cert = (
|
cert = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
|
|||||||
@@ -231,20 +231,14 @@ const StegasooGenerate = {
|
|||||||
printWindow.document.write(`<!DOCTYPE html>
|
printWindow.document.write(`<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Stegasoo RSA Key QR Code</title>
|
<title>QR Code</title>
|
||||||
<style>
|
<style>
|
||||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||||
img { max-width: 400px; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>Stegasoo RSA Private Key</h2>
|
<img src="${qrImg.src}" alt="QR Code">
|
||||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
|
||||||
<div class="warning">
|
|
||||||
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
|
|
||||||
Store securely and destroy after use.
|
|
||||||
</div>
|
|
||||||
<script>window.onload = function() { window.print(); }<\/script>
|
<script>window.onload = function() { window.print(); }<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`);
|
||||||
|
|||||||
@@ -1090,6 +1090,400 @@ const Stegasoo = {
|
|||||||
if (phaseText) phaseText.textContent = phase;
|
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
|
// INITIALIZATION HELPERS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -1111,6 +1505,39 @@ const Stegasoo = {
|
|||||||
generateBtnId: 'channelKeyGenerate'
|
generateBtnId: 'channelKeyGenerate'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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)
|
// Form submission with async progress tracking (v4.1.2)
|
||||||
const form = document.getElementById('encodeForm');
|
const form = document.getElementById('encodeForm');
|
||||||
const btn = document.getElementById('encodeBtn');
|
const btn = document.getElementById('encodeBtn');
|
||||||
@@ -1136,7 +1563,7 @@ const Stegasoo = {
|
|||||||
this.initRsaMethodToggle();
|
this.initRsaMethodToggle();
|
||||||
this.initDropZones();
|
this.initDropZones();
|
||||||
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
|
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
|
||||||
this.initQrCropAnimation('rsaKeyQrInput');
|
this.initQrCropAnimation('rsaQrInput');
|
||||||
this.initCollapseChevrons();
|
this.initCollapseChevrons();
|
||||||
this.initPassphraseFontResize();
|
this.initPassphraseFontResize();
|
||||||
|
|
||||||
@@ -1148,28 +1575,56 @@ const Stegasoo = {
|
|||||||
serverInfoId: 'channelServerInfoDec'
|
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 form = document.getElementById('decodeForm');
|
||||||
const btn = document.getElementById('decodeBtn');
|
const btn = document.getElementById('decodeBtn');
|
||||||
form?.addEventListener('submit', (e) => {
|
form?.addEventListener('submit', (e) => {
|
||||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const startTime = Date.now();
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||||
const updateTimer = () => {
|
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
const mins = Math.floor(elapsed / 60);
|
|
||||||
const secs = elapsed % 60;
|
|
||||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
|
||||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})... ${timeStr}`;
|
|
||||||
};
|
|
||||||
updateTimer();
|
|
||||||
setInterval(updateTimer, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use async submission with progress tracking
|
||||||
|
this.submitDecodeAsync(form, btn);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -136,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:
|
def decode_operation(params: dict) -> dict:
|
||||||
"""Handle decode operation."""
|
"""Handle decode operation."""
|
||||||
from stegasoo import decode
|
from stegasoo import decode
|
||||||
|
|
||||||
|
progress_file = params.get("progress_file")
|
||||||
|
|
||||||
|
# Progress: starting
|
||||||
|
_write_decode_progress(progress_file, 5, "reading")
|
||||||
|
|
||||||
# Decode base64 inputs
|
# Decode base64 inputs
|
||||||
stego_data = base64.b64decode(params["stego_b64"])
|
stego_data = base64.b64decode(params["stego_b64"])
|
||||||
reference_data = base64.b64decode(params["reference_b64"])
|
reference_data = base64.b64decode(params["reference_b64"])
|
||||||
|
|
||||||
|
_write_decode_progress(progress_file, 15, "reading")
|
||||||
|
|
||||||
# Optional RSA key
|
# Optional RSA key
|
||||||
rsa_key_data = None
|
rsa_key_data = None
|
||||||
if params.get("rsa_key_b64"):
|
if params.get("rsa_key_b64"):
|
||||||
@@ -152,6 +171,8 @@ def decode_operation(params: dict) -> dict:
|
|||||||
# Resolve channel key (v4.0.0)
|
# Resolve channel key (v4.0.0)
|
||||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||||
|
|
||||||
|
_write_decode_progress(progress_file, 25, "extracting")
|
||||||
|
|
||||||
# Call decode with correct parameter names
|
# Call decode with correct parameter names
|
||||||
result = decode(
|
result = decode(
|
||||||
stego_image=stego_data,
|
stego_image=stego_data,
|
||||||
@@ -164,6 +185,8 @@ def decode_operation(params: dict) -> dict:
|
|||||||
channel_key=resolved_channel_key, # v4.0.0
|
channel_key=resolved_channel_key, # v4.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_write_decode_progress(progress_file, 90, "finalizing")
|
||||||
|
|
||||||
if result.is_file:
|
if result.is_file:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ class SubprocessStego:
|
|||||||
# Channel key (v4.0.0)
|
# Channel key (v4.0.0)
|
||||||
channel_key: str | None = "auto",
|
channel_key: str | None = "auto",
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
# Progress tracking (v4.1.5)
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> DecodeResult:
|
) -> DecodeResult:
|
||||||
"""
|
"""
|
||||||
Decode a message or file from a stego image.
|
Decode a message or file from a stego image.
|
||||||
@@ -328,6 +330,7 @@ class SubprocessStego:
|
|||||||
embed_mode: 'auto', 'lsb', or 'dct'
|
embed_mode: 'auto', 'lsb', or 'dct'
|
||||||
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
|
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
|
||||||
timeout: Operation timeout in seconds
|
timeout: Operation timeout in seconds
|
||||||
|
progress_file: Path to write progress updates (v4.1.5)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DecodeResult with message or file_data on success
|
DecodeResult with message or file_data on success
|
||||||
@@ -340,6 +343,7 @@ class SubprocessStego:
|
|||||||
"pin": pin,
|
"pin": pin,
|
||||||
"embed_mode": embed_mode,
|
"embed_mode": embed_mode,
|
||||||
"channel_key": channel_key, # v4.0.0
|
"channel_key": channel_key, # v4.0.0
|
||||||
|
"progress_file": progress_file, # v4.1.5
|
||||||
}
|
}
|
||||||
|
|
||||||
if rsa_key_data:
|
if rsa_key_data:
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ It does NOT touch instance/ (auth database) or any other directories.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|||||||
@@ -177,9 +177,16 @@
|
|||||||
placeholder="Key name" required maxlength="50">
|
placeholder="Key name" required maxlength="50">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-7">
|
<div class="col-7">
|
||||||
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
|
<div class="input-group input-group-sm">
|
||||||
placeholder="Channel key (32 hex chars)" required
|
<input type="text" name="channel_key" id="channelKeyInput"
|
||||||
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
|
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>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||||
@@ -254,12 +261,34 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
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) {
|
function renameKey(keyId, currentName) {
|
||||||
document.getElementById('renameInput').value = currentName;
|
document.getElementById('renameInput').value = currentName;
|
||||||
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
||||||
|
|||||||
@@ -101,6 +101,8 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<!-- QR Code scanning library (v4.1.5) -->
|
||||||
|
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Initialize toasts (auto-hide after delay)
|
// Initialize toasts (auto-hide after delay)
|
||||||
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));
|
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));
|
||||||
|
|||||||
@@ -4,6 +4,74 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<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 */
|
/* Glowing passphrase input */
|
||||||
.passphrase-input {
|
.passphrase-input {
|
||||||
background: rgba(30, 40, 50, 0.8) !important;
|
background: rgba(30, 40, 50, 0.8) !important;
|
||||||
@@ -13,20 +81,17 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
padding: 12px 16px;
|
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 {
|
.passphrase-input:focus {
|
||||||
border-color: rgba(99, 179, 237, 0.8) !important;
|
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;
|
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4) !important;
|
||||||
background: rgba(30, 40, 50, 0.95) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.passphrase-input::placeholder {
|
.passphrase-input::placeholder {
|
||||||
color: rgba(99, 179, 237, 0.4);
|
color: rgba(99, 179, 237, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glowing PIN input */
|
/* PIN input */
|
||||||
.pin-input-container .form-control {
|
.pin-input-container .form-control {
|
||||||
background: rgba(30, 40, 50, 0.8) !important;
|
background: rgba(30, 40, 50, 0.8) !important;
|
||||||
border: 2px solid rgba(246, 173, 85, 0.3) !important;
|
border: 2px solid rgba(246, 173, 85, 0.3) !important;
|
||||||
@@ -35,18 +100,10 @@
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-input-container .form-control:focus {
|
.pin-input-container .form-control:focus {
|
||||||
border-color: rgba(246, 173, 85, 0.8) !important;
|
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;
|
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
||||||
background: rgba(30, 40, 50, 0.95) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin-input-container .form-control::placeholder {
|
|
||||||
color: rgba(246, 173, 85, 0.4);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* QR Crop Animation */
|
/* QR Crop Animation */
|
||||||
@@ -55,8 +112,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-crop-container img {
|
.qr-crop-container img {
|
||||||
display: block;
|
display: block;
|
||||||
max-height: 180px;
|
max-height: 180px;
|
||||||
@@ -65,15 +126,10 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
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 {
|
.qr-crop-container .qr-cropped {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%; left: 50%;
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%) scale(0.3);
|
transform: translate(-50%, -50%) scale(0.3);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
@@ -81,30 +137,15 @@
|
|||||||
min-height: 140px;
|
min-height: 140px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-crop-container.scan-complete .qr-original {
|
.qr-crop-container.scan-complete .qr-original {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
filter: blur(4px);
|
filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-crop-container.scan-complete .qr-cropped {
|
.qr-crop-container.scan-complete .qr-cropped {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, -50%) scale(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>
|
</style>
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -113,7 +154,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
|
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body {% if not decoded_message and not decoded_file %}p-0{% endif %}">
|
||||||
{% if decoded_message %}
|
{% if decoded_message %}
|
||||||
<!-- Text Message Result -->
|
<!-- Text Message Result -->
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
@@ -161,46 +202,48 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<!-- Decode Form -->
|
<!-- Decode Form -->
|
||||||
<form method="POST" enctype="multipart/form-data" id="decodeForm">
|
<form method="POST" enctype="multipart/form-data" id="decodeForm">
|
||||||
|
|
||||||
|
<div class="accordion step-accordion" id="decodeAccordion">
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
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>
|
||||||
|
</h2>
|
||||||
|
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-image me-1"></i> Reference Photo
|
<i class="bi bi-image me-1"></i> Reference Photo
|
||||||
</label>
|
</label>
|
||||||
<div class="drop-zone scan-container" id="refDropZone">
|
<div class="drop-zone scan-container" id="refDropZone">
|
||||||
<input type="file" name="reference_photo" accept="image/*" required>
|
<input type="file" name="reference_photo" accept="image/*" required id="refPhotoInput">
|
||||||
<div class="drop-zone-label">
|
<div class="drop-zone-label">
|
||||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
<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>
|
<span class="text-muted">Drop image or click</span>
|
||||||
</div>
|
</div>
|
||||||
<img class="drop-zone-preview d-none" id="refPreview">
|
<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>
|
||||||
<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-corners">
|
||||||
<div class="scan-corner tl"></div>
|
<div class="scan-corner tl"></div><div class="scan-corner tr"></div>
|
||||||
<div class="scan-corner tr"></div>
|
<div class="scan-corner bl"></div><div class="scan-corner br"></div>
|
||||||
<div class="scan-corner bl"></div>
|
|
||||||
<div class="scan-corner br"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Data panel (shown after scan) -->
|
|
||||||
<div class="scan-data-panel">
|
<div class="scan-data-panel">
|
||||||
<div class="scan-data-filename">
|
<div class="scan-data-filename"><i class="bi bi-check-circle-fill"></i><span id="refFileName">image.jpg</span></div>
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
<div class="scan-data-row"><span class="scan-status-badge">Hash Acquired</span><span class="scan-data-value" id="refFileSize">--</span></div>
|
||||||
<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>
|
</div>
|
||||||
<div class="form-text">
|
<div class="form-text">Same reference photo used for encoding</div>
|
||||||
The same reference photo used for encoding
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
@@ -208,124 +251,85 @@
|
|||||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||||
</label>
|
</label>
|
||||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||||
<input type="file" name="stego_image" accept="image/*" required>
|
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||||
<div class="drop-zone-label">
|
<div class="drop-zone-label">
|
||||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
<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>
|
<span class="text-muted">Drop image or click</span>
|
||||||
</div>
|
</div>
|
||||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||||
<!-- Pixel blocks overlay - populated by JS -->
|
|
||||||
<div class="pixel-blocks"></div>
|
<div class="pixel-blocks"></div>
|
||||||
<!-- Pixel scan line -->
|
|
||||||
<div class="pixel-scan-line"></div>
|
<div class="pixel-scan-line"></div>
|
||||||
<!-- Corner brackets -->
|
|
||||||
<div class="pixel-corners">
|
<div class="pixel-corners">
|
||||||
<div class="pixel-corner tl"></div>
|
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||||
<div class="pixel-corner tr"></div>
|
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||||
<div class="pixel-corner bl"></div>
|
|
||||||
<div class="pixel-corner br"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Data panel -->
|
|
||||||
<div class="pixel-data-panel">
|
<div class="pixel-data-panel">
|
||||||
<div class="pixel-data-filename">
|
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
|
||||||
<span id="stegoFileName">image.png</span>
|
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pixel-data-row">
|
|
||||||
<span class="pixel-status-badge">Stego Loaded</span>
|
|
||||||
<span class="pixel-data-value" id="stegoFileSize">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pixel-dimensions" id="stegoDims">-- × -- px</div>
|
<div class="form-text">Image containing the hidden message</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Extraction Mode -->
|
||||||
|
<label class="form-label"><i class="bi bi-cpu me-1"></i> Extraction Mode</label>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
||||||
|
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||||
|
<i class="bi bi-magic text-success ms-2"></i>
|
||||||
|
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="mode-btn flex-fill" 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 ms-2"></i>
|
||||||
|
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="mode-btn flex-fill {% 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 ms-2"></i>
|
||||||
|
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
The image containing the hidden message/file
|
<i class="bi bi-lightbulb me-1"></i><strong>Auto</strong> tries LSB first, then DCT.
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- ================================================================
|
||||||
<label class="form-label">
|
STEP 2: SECURITY
|
||||||
<i class="bi bi-chat-quote me-1"></i> Passphrase
|
================================================================ -->
|
||||||
</label>
|
<div class="accordion-item">
|
||||||
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
|
<h2 class="accordion-header">
|
||||||
placeholder="e.g., correct horse battery staple" required>
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||||
<div class="form-text">
|
<span class="step-title">
|
||||||
The passphrase used during encoding (typically 4 words)
|
<span class="step-number" id="stepSecurityNumber">2</span>
|
||||||
</div>
|
<i class="bi bi-shield-lock me-1"></i> Security
|
||||||
</div>
|
</span>
|
||||||
|
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</h2>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- PIN + Channel Row -->
|
<hr class="my-3 opacity-25">
|
||||||
|
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<!-- PIN -->
|
||||||
|
<div class="col-md-6 mb-2">
|
||||||
<div class="security-box h-100">
|
<div class="security-box h-100">
|
||||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||||
<div class="input-group pin-input-container">
|
<div class="input-group pin-input-container">
|
||||||
@@ -334,103 +338,75 @@
|
|||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">If PIN was used during encoding</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<!-- Channel -->
|
||||||
|
<div class="col-md-6 mb-2">
|
||||||
<div class="security-box h-100">
|
<div class="security-box h-100">
|
||||||
<label class="form-label">
|
<label class="form-label"><i class="bi bi-broadcast me-1"></i> Channel</label>
|
||||||
<i class="bi bi-broadcast me-1"></i> Channel
|
<select class="form-select form-select-sm" name="channel_key" id="channelSelectDec">
|
||||||
<span class="badge bg-info ms-1">v4.1</span>
|
<option value="auto" selected>Auto{% if channel_configured %} (Server){% endif %}</option>
|
||||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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="none">Public</option>
|
||||||
{% if saved_channel_keys %}
|
{% if saved_channel_keys %}
|
||||||
<optgroup label="Saved Keys">
|
<optgroup label="Saved Keys">
|
||||||
{% for key in saved_channel_keys %}
|
{% for key in saved_channel_keys %}
|
||||||
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
<option value="{{ key.channel_key }}">{{ key.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<option value="custom">Custom...</option>
|
<option value="custom">Custom...</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Server channel indicator (compact) -->
|
|
||||||
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfoDec" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
|
||||||
{% if channel_configured and channel_fingerprint %}
|
|
||||||
<i class="bi bi-shield-lock me-1"></i>
|
|
||||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
<!-- Custom Channel Key -->
|
||||||
<div class="mb-4 d-none" id="channelCustomInputDec">
|
<div class="mb-3 d-none" id="channelCustomInputDec">
|
||||||
<div class="security-box">
|
<div class="security-box">
|
||||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" name="channel_key_custom" class="form-control font-monospace"
|
<input type="text" name="channel_key_custom" class="form-control form-control-sm font-monospace"
|
||||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" id="channelKeyInputDec">
|
||||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
|
<button class="btn btn-outline-secondary btn-sm" type="button" id="channelKeyScanDec" title="Scan QR"><i class="bi bi-camera"></i></button>
|
||||||
id="channelKeyInputDec">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- RSA Key -->
|
||||||
ADVANCED OPTIONS (v3.0) - Extraction Mode
|
<div class="mb-3">
|
||||||
================================================================ -->
|
<div class="security-box">
|
||||||
<div class="mb-4">
|
<label class="form-label"><i class="bi bi-file-earmark-lock me-1"></i> RSA Key <span class="text-muted">(if used)</span></label>
|
||||||
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
|
<div class="btn-group w-100 mb-2" role="group">
|
||||||
<i class="bi bi-gear me-1"></i> Advanced Options
|
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||||
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
|
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile"><i class="bi bi-file-earmark me-1"></i>.pem</label>
|
||||||
</a>
|
<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 class="collapse" id="advancedOptionsDec">
|
</div>
|
||||||
<div class="card card-body mt-2 bg-dark border-secondary">
|
<div id="rsaFileSection">
|
||||||
|
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
|
||||||
<!-- Extraction Mode Selection -->
|
</div>
|
||||||
<div class="mb-0">
|
<div id="rsaQrSection" class="d-none d-flex flex-column">
|
||||||
<label class="form-label">
|
<input type="hidden" name="rsa_key_pem" id="rsaKeyPem">
|
||||||
<i class="bi bi-cpu me-1"></i> Extraction Mode
|
<div class="drop-zone p-2 w-100" id="qrDropZone">
|
||||||
<span class="badge bg-info ms-1">v3.0</span>
|
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
|
||||||
</label>
|
<div class="drop-zone-label text-center">
|
||||||
|
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
|
||||||
<div class="d-flex gap-2">
|
<span class="text-muted small">Drop QR image</span>
|
||||||
<!-- Auto Mode -->
|
</div>
|
||||||
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
|
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||||
<i class="bi bi-magic text-success"></i>
|
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
||||||
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2" id="rsaQrWebcam">
|
||||||
<!-- LSB Mode -->
|
<i class="bi bi-camera me-1"></i>Scan with Camera
|
||||||
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
|
</button>
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
|
</div>
|
||||||
<i class="bi bi-grid-3x3-gap text-primary"></i>
|
<div class="input-group input-group-sm mt-2">
|
||||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
|
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||||
</label>
|
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput"><i class="bi bi-eye"></i></button>
|
||||||
|
|
||||||
<!-- DCT Mode -->
|
|
||||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
|
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
|
|
||||||
<i class="bi bi-soundwave text-warning"></i>
|
|
||||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-text mt-2">
|
|
||||||
<i class="bi bi-lightbulb me-1"></i>
|
|
||||||
<strong>Auto</strong> tries LSB first, then DCT.
|
|
||||||
{% if not has_dct %}
|
|
||||||
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -438,51 +414,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="p-3">
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||||
<i class="bi bi-unlock me-2"></i>Decode
|
<i class="bi bi-unlock me-2"></i>Decode
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not decoded_message and not decoded_file %}
|
{% if not decoded_message and not decoded_file %}
|
||||||
|
<!-- Troubleshooting Card -->
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
|
<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">
|
<ul class="list-unstyled text-muted small mb-0">
|
||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
<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>
|
||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
<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)
|
Enter the <strong>exact passphrase</strong> used during encoding
|
||||||
</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
|
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
<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>
|
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
|
||||||
<strong>Format compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format)
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-broadcast text-info me-1"></i>
|
|
||||||
<strong>Channel key:</strong> Use the same channel (Auto/Public/Custom) that was used during encoding
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
|
||||||
If using an RSA key, verify the <strong>password is correct</strong> (if key is encrypted)
|
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-0">
|
<li class="mb-0">
|
||||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,31 +461,92 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
// Extraction mode button active state toggle
|
// ============================================================================
|
||||||
const extractModeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
// ACCORDION SUMMARY UPDATES
|
||||||
const extractModeBtns = {
|
// ============================================================================
|
||||||
'auto': document.getElementById('autoModeCard'),
|
|
||||||
'lsb': document.getElementById('lsbModeCardDec'),
|
|
||||||
'dct': document.getElementById('dctModeCardDec')
|
|
||||||
};
|
|
||||||
|
|
||||||
extractModeRadios.forEach(radio => {
|
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', () => {
|
radio.addEventListener('change', () => {
|
||||||
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
|
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
|
||||||
extractModeBtns[radio.value]?.classList.add('active');
|
modeBtns[radio.value]?.classList.add('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advanced options chevron
|
// ============================================================================
|
||||||
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
|
// LOADING STATE
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Loading state for decode button
|
|
||||||
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
|
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
6a7378172fc0ec37143720f09a4ca34e83ec2409893aa8cd79ace5b78a64276c
|
|
||||||
Binary file not shown.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.1.2"
|
version = "4.1.5"
|
||||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your host machine:
|
# On your host machine:
|
||||||
scp rpi/stegasoo-pi-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||||
```
|
```
|
||||||
|
|
||||||
This tarball contains:
|
This tarball contains:
|
||||||
@@ -98,7 +98,7 @@ This removes:
|
|||||||
|
|
||||||
The script validates all cleanup steps before finishing.
|
The script validates all cleanup steps before finishing.
|
||||||
|
|
||||||
## Step 9: Copy the Image
|
## Step 9: Pull the Image
|
||||||
|
|
||||||
Remove SD card, insert into your Linux machine:
|
Remove SD card, insert into your Linux machine:
|
||||||
|
|
||||||
@@ -106,23 +106,13 @@ Remove SD card, insert into your Linux machine:
|
|||||||
# Find the SD card device (CAREFUL!)
|
# Find the SD card device (CAREFUL!)
|
||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Copy (replace sdX with actual device, e.g., sda)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 10: Shrink & Compress
|
The script automatically resizes rootfs to 16GB, disables auto-expand, and compresses.
|
||||||
|
|
||||||
```bash
|
## Step 10: Distribute
|
||||||
# Optional: Shrink image (saves space)
|
|
||||||
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
|
||||||
chmod +x pishrink.sh
|
|
||||||
sudo ./pishrink.sh stegasoo-rpi-*.img
|
|
||||||
|
|
||||||
# Compress (zstd is faster than xz with similar ratio)
|
|
||||||
zstd -19 -T0 stegasoo-rpi-*.img
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 11: Distribute
|
|
||||||
|
|
||||||
Upload `.img.zst` to GitHub Releases.
|
Upload `.img.zst` to GitHub Releases.
|
||||||
|
|
||||||
@@ -159,17 +149,17 @@ tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
|
|||||||
|
|
||||||
# Create combined tarball (pyenv + venv pointer)
|
# Create combined tarball (pyenv + venv pointer)
|
||||||
cd ~
|
cd ~
|
||||||
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-pi-arm64.tar.zst
|
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
|
||||||
|
|
||||||
# Check size (should be ~50-60MB)
|
# Check size (should be ~50-60MB)
|
||||||
ls -lh /tmp/stegasoo-pi-arm64.tar.zst
|
ls -lh /tmp/stegasoo-rpi-runtime-env-arm64.tar.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
Pull to host and upload to GitHub releases:
|
Pull to host and upload to GitHub releases:
|
||||||
```bash
|
```bash
|
||||||
# On host:
|
# On host:
|
||||||
scp admin@stegasoo.local:/tmp/stegasoo-pi-arm64.tar.zst ./
|
scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
||||||
# Upload to GitHub releases as stegasoo-pi-arm64.tar.zst
|
# Upload to GitHub releases as stegasoo-rpi-runtime-env-arm64.tar.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -183,7 +173,7 @@ 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
|
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
|
|
||||||
# On host (copy tarball):
|
# On host (copy tarball):
|
||||||
scp rpi/stegasoo-pi-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||||
|
|
||||||
# On Pi (run setup):
|
# On Pi (run setup):
|
||||||
cd /opt/stegasoo && ./rpi/setup.sh
|
cd /opt/stegasoo && ./rpi/setup.sh
|
||||||
@@ -191,7 +181,6 @@ sudo systemctl start stegasoo
|
|||||||
curl -k https://localhost:5000
|
curl -k https://localhost:5000
|
||||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||||
|
|
||||||
# On host (pull image):
|
# On host (pull image - auto-resizes to 16GB):
|
||||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||||
zstd -19 -T0 stegasoo-rpi-*.img
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ cd stegasoo
|
|||||||
- Raspberry Pi 4 or 5
|
- Raspberry Pi 4 or 5
|
||||||
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
||||||
- 4GB+ RAM recommended (2GB minimum)
|
- 4GB+ RAM recommended (2GB minimum)
|
||||||
- ~2GB free disk space
|
- 16GB+ SD card (pre-built images are 16GB)
|
||||||
- Internet connection
|
- Internet connection
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
@@ -49,6 +49,25 @@ If using a pre-built image from GitHub Releases:
|
|||||||
|
|
||||||
> **Security note**: Change the default password after setup with `passwd`
|
> **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
|
## After Installation
|
||||||
|
|
||||||
### Start the Service
|
### Start the Service
|
||||||
@@ -180,18 +199,15 @@ After Pi shuts down, remove SD card and on another Linux machine:
|
|||||||
# Find SD card device (BE CAREFUL - wrong device = data loss!)
|
# Find SD card device (BE CAREFUL - wrong device = data loss!)
|
||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Copy (replace sdX with your SD card)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
||||||
|
|
||||||
# Shrink the image (optional but recommended)
|
|
||||||
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
|
||||||
chmod +x pishrink.sh
|
|
||||||
sudo ./pishrink.sh stegasoo-rpi-*.img
|
|
||||||
|
|
||||||
# Compress (zstd is faster than xz with similar compression)
|
|
||||||
zstd -19 -T0 stegasoo-rpi-*.img
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### 6. Distribute
|
||||||
|
|
||||||
Upload the `.img.zst` file to GitHub Releases.
|
Upload the `.img.zst` file to GitHub Releases.
|
||||||
|
|||||||
63
rpi/banner.sh
Normal file
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
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
INSTALL_DIR="/opt/stegasoo"
|
INSTALL_DIR="/opt/stegasoo"
|
||||||
FLAG_FILE="/etc/stegasoo-first-boot"
|
FLAG_FILE="/etc/stegasoo-first-boot"
|
||||||
PROFILE_HOOK="/etc/profile.d/stegasoo-wizard.sh"
|
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
|
# Check if this is first boot
|
||||||
if [ ! -f "$FLAG_FILE" ]; then
|
if [ ! -f "$FLAG_FILE" ]; then
|
||||||
@@ -39,21 +43,12 @@ clear
|
|||||||
# Welcome
|
# Welcome
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
print_banner "First Boot Wizard"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
gum style --foreground 245 "This wizard will help you configure your Stegasoo server"
|
||||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
|
||||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
|
||||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
|
||||||
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
|
||||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
|
||||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo -e "\033[1;37m First Boot Wizard\033[0m"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
|
gum style --foreground 245 "You can reconfigure later by editing:"
|
||||||
gum style --foreground 245 "You can reconfigure later by editing /etc/systemd/system/stegasoo.service"
|
gum style --foreground 214 " /etc/systemd/system/stegasoo.service"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
gum confirm "Ready to begin setup?" || exit 0
|
gum confirm "Ready to begin setup?" || exit 0
|
||||||
@@ -407,22 +402,11 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo ""
|
print_complete_banner "Setup Complete!"
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
|
||||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
|
||||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
|
||||||
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
|
||||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
|
||||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo -e "\033[1;32m Setup Complete!\033[0m"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
gum style --foreground 82 --bold "Create your admin account:"
|
gum style --foreground 82 --bold "Create your admin account:"
|
||||||
gum style --foreground 226 " $ACCESS_URL"
|
gum style --foreground 226 " $ACCESS_URL_LOCAL"
|
||||||
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
|
gum style --foreground 245 " $ACCESS_URL (fallback IP)"
|
||||||
|
|
||||||
if [ -n "$CHANNEL_KEY" ]; then
|
if [ -n "$CHANNEL_KEY" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -120,28 +120,64 @@ read -p "Resize rootfs to 16GB for faster imaging? [Y/n] " resize_confirm
|
|||||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||||
echo "Resizing rootfs partition to 16GB..."
|
echo "Resizing rootfs partition to 16GB..."
|
||||||
|
|
||||||
# Get boot partition end
|
# 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')
|
BOOT_END=$(sudo parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||||
|
|
||||||
# Calculate 16GB in sectors (512 byte sectors)
|
# Calculate 16GB in sectors (512 byte sectors)
|
||||||
# 16GB = 16 * 1024 * 1024 * 1024 / 512 = 33554432 sectors
|
|
||||||
ROOT_SIZE_SECTORS=33554432
|
ROOT_SIZE_SECTORS=33554432
|
||||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||||
|
|
||||||
# Delete and recreate partition 2 with fixed size
|
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" rm 2
|
||||||
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||||
|
|
||||||
# Refresh partition table
|
# Refresh partition table
|
||||||
sudo partprobe "$DEVICE"
|
sudo partprobe "$DEVICE"
|
||||||
sleep 1
|
sleep 2
|
||||||
|
|
||||||
# Check and resize filesystem
|
# 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..."
|
echo "Checking filesystem..."
|
||||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
|
|
||||||
echo "Resizing filesystem to fit partition..."
|
# 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"
|
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
|
# Disable Pi OS auto-expand on first boot
|
||||||
echo "Disabling auto-expand..."
|
echo "Disabling auto-expand..."
|
||||||
@@ -155,12 +191,10 @@ if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
|||||||
# Disable the systemd resize service
|
# Disable the systemd resize service
|
||||||
sudo rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
sudo rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||||
|
|
||||||
# Remove init= parameter from cmdline.txt on boot partition (handled later)
|
|
||||||
|
|
||||||
sudo umount "$TEMP_ROOT"
|
sudo umount "$TEMP_ROOT"
|
||||||
rmdir "$TEMP_ROOT"
|
rmdir "$TEMP_ROOT"
|
||||||
|
|
||||||
echo " Rootfs resized to 16GB (auto-expand disabled)"
|
echo " Rootfs set to 16GB (auto-expand disabled)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MOUNT_DIR=$(mktemp -d)
|
MOUNT_DIR=$(mktemp -d)
|
||||||
@@ -282,4 +316,17 @@ echo " User: $PI_USER"
|
|||||||
echo " SSH: enabled"
|
echo " SSH: enabled"
|
||||||
echo " WiFi: $WIFI_SSID"
|
echo " WiFi: $WIFI_SSID"
|
||||||
echo
|
echo
|
||||||
echo "Insert into Pi and boot. Find it with: ping $PI_HOSTNAME.local"
|
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
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)
|
||||||
193
rpi/kickoff-pi-test.sh
Executable file
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
|
||||||
@@ -1,31 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Pull Raspberry Pi image from SD card (after setup)
|
||||||
|
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||||
#
|
#
|
||||||
# Pull Stegasoo image from SD card
|
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||||
# Auto-detects SD card, copies with progress, shrinks, and compresses
|
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
|
||||||
#
|
|
||||||
# Usage: ./pull-image.sh [output-name] [device]
|
|
||||||
# Output will be: stegasoo-rpi-YYYYMMDD.img.zst (or custom name)
|
|
||||||
# Use .img extension to skip compression: ./pull-image.sh foo.img
|
|
||||||
#
|
|
||||||
# If device is specified, skips auto-detection (useful for large drives)
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
|
||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Check for required tools
|
if [ $# -ne 2 ]; then
|
||||||
for cmd in dd pv zstd lsblk; do
|
echo "Usage: $0 <device> <output.img.zst>"
|
||||||
if ! command -v $cmd &> /dev/null; then
|
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
|
||||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
DEVICE="$1"
|
||||||
|
OUTPUT="$2"
|
||||||
|
|
||||||
# Check for root
|
# Check for root
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
@@ -33,204 +28,185 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Output filename and optional device
|
if [ ! -b "$DEVICE" ]; then
|
||||||
if [ -n "$1" ]; then
|
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
||||||
OUTPUT="$1"
|
exit 1
|
||||||
else
|
|
||||||
OUTPUT="stegasoo-rpi-$(date +%Y%m%d).img.zst"
|
|
||||||
fi
|
|
||||||
MANUAL_DEVICE="$2"
|
|
||||||
|
|
||||||
# Check if output ends in .img (skip compression) or .zst (compress)
|
|
||||||
SKIP_COMPRESS=false
|
|
||||||
if [[ "$OUTPUT" == *.img ]]; then
|
|
||||||
IMG_FILE="$OUTPUT"
|
|
||||||
SKIP_COMPRESS=true
|
|
||||||
elif [[ "$OUTPUT" == *.zst ]]; then
|
|
||||||
IMG_FILE="${OUTPUT%.zst}"
|
|
||||||
else
|
|
||||||
# No recognized extension, add .img.zst
|
|
||||||
IMG_FILE="${OUTPUT}.img"
|
|
||||||
OUTPUT="${OUTPUT}.img.zst"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}"
|
echo -e "${BOLD}Device info:${NC}"
|
||||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
lsblk "$DEVICE"
|
||||||
echo "║ Stegasoo SD Card Image Puller ║"
|
|
||||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
|
||||||
echo -e "${NC}"
|
|
||||||
|
|
||||||
# Use manual device or auto-detect
|
|
||||||
if [ -n "$MANUAL_DEVICE" ]; then
|
|
||||||
# Manual device specified
|
|
||||||
if [ ! -b "$MANUAL_DEVICE" ]; then
|
|
||||||
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SELECTED="$MANUAL_DEVICE"
|
|
||||||
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
|
||||||
echo ""
|
|
||||||
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
# Auto-detect SD card candidates
|
|
||||||
# Looking for: USB/removable, 8-128GB, not mounted as root filesystem
|
|
||||||
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
declare -a CANDIDATES
|
|
||||||
declare -a CANDIDATE_INFO
|
|
||||||
|
|
||||||
while IFS= read -r line; do
|
|
||||||
DEV=$(echo "$line" | awk '{print $1}')
|
|
||||||
SIZE=$(echo "$line" | awk '{print $2}')
|
|
||||||
TYPE=$(echo "$line" | awk '{print $3}')
|
|
||||||
TRAN=$(echo "$line" | awk '{print $4}')
|
|
||||||
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
|
||||||
|
|
||||||
# Skip if it's the root filesystem
|
|
||||||
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Skip if any partition is mounted as root
|
|
||||||
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
|
||||||
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get size in bytes for reliable comparison
|
|
||||||
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
|
||||||
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
|
||||||
|
|
||||||
# Check if size is in SD card range (8GB - 128GB)
|
|
||||||
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
|
||||||
CANDIDATES+=("/dev/$DEV")
|
|
||||||
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
|
||||||
fi
|
|
||||||
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
|
||||||
|
|
||||||
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
|
||||||
echo -e "${RED}No SD card candidates found.${NC}"
|
|
||||||
echo "Looking for USB/removable disks between 8GB and 128GB."
|
|
||||||
echo ""
|
|
||||||
echo "Available disks:"
|
|
||||||
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Tip: Specify device manually: $0 output.img.zst /dev/sdX${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
for i in "${!CANDIDATES[@]}"; do
|
|
||||||
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
|
||||||
SELECTED="${CANDIDATES[0]}"
|
|
||||||
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
|
||||||
else
|
|
||||||
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
|
||||||
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
|
||||||
echo -e "${RED}Invalid selection.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show partitions
|
|
||||||
echo ""
|
|
||||||
echo -e "${BOLD}Partitions on $SELECTED:${NC}"
|
|
||||||
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Final confirmation
|
|
||||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${RED}║ WARNING: This will read the ENTIRE device: ║${NC}"
|
|
||||||
echo -e "${RED}║ $SELECTED ║${NC}"
|
|
||||||
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
|
||||||
echo ""
|
|
||||||
read -p "Continue? [y/N] " -n 1 -r
|
|
||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
|
# 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 -e "${RED}Error: Could not find partitions${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unmount any mounted partitions
|
||||||
|
echo -e "${YELLOW}Unmounting partitions...${NC}"
|
||||||
|
umount "$BOOT_PART" 2>/dev/null || true
|
||||||
|
umount "$ROOT_PART" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Resize rootfs to 16GB
|
||||||
|
# ============================================================================
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Checking partition size...${NC}"
|
||||||
|
|
||||||
|
# Get current partition size in bytes
|
||||||
|
CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART")
|
||||||
|
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
||||||
|
CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc)
|
||||||
|
|
||||||
|
echo " Current rootfs size: ${CURRENT_GB}GB"
|
||||||
|
|
||||||
|
if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then
|
||||||
|
echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}"
|
||||||
|
|
||||||
|
# Get boot partition end in sectors
|
||||||
|
BOOT_END=$(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))
|
||||||
|
|
||||||
|
# SHRINKING: filesystem first, then partition
|
||||||
|
echo " Checking filesystem..."
|
||||||
|
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||||
|
echo " Shrinking filesystem to 15500M..."
|
||||||
|
resize2fs "$ROOT_PART" 15500M
|
||||||
|
|
||||||
|
# Delete and recreate partition 2 with 16GB size
|
||||||
|
echo " Shrinking partition to 16GB..."
|
||||||
|
parted -s "$DEVICE" rm 2
|
||||||
|
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||||
|
|
||||||
|
# Refresh partition table
|
||||||
|
partprobe "$DEVICE"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Expand filesystem to fill the partition exactly
|
||||||
|
echo " Expanding filesystem to fill partition..."
|
||||||
|
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
|
resize2fs "$ROOT_PART"
|
||||||
|
|
||||||
|
echo -e "${GREEN} Rootfs resized to 16GB${NC}"
|
||||||
|
elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
||||||
|
echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}"
|
||||||
|
|
||||||
|
# Get boot partition end in sectors
|
||||||
|
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||||
|
ROOT_SIZE_SECTORS=33554432
|
||||||
|
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||||
|
|
||||||
|
# EXPANDING: partition first, then filesystem
|
||||||
|
parted -s "$DEVICE" rm 2
|
||||||
|
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||||
|
|
||||||
|
partprobe "$DEVICE"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||||
|
resize2fs "$ROOT_PART"
|
||||||
|
|
||||||
|
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Disable auto-expand on first boot
|
||||||
|
# ============================================================================
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Disabling auto-expand...${NC}"
|
||||||
|
TEMP_ROOT=$(mktemp -d)
|
||||||
|
mount "$ROOT_PART" "$TEMP_ROOT"
|
||||||
|
|
||||||
|
# Remove resize2fs_once service if it exists
|
||||||
|
rm -f "$TEMP_ROOT/etc/init.d/resize2fs_once"
|
||||||
|
rm -f "$TEMP_ROOT/etc/rc3.d/S01resize2fs_once"
|
||||||
|
|
||||||
|
# Disable the systemd resize service
|
||||||
|
rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||||
|
|
||||||
|
umount "$TEMP_ROOT"
|
||||||
|
rmdir "$TEMP_ROOT"
|
||||||
|
echo -e "${GREEN} Auto-expand disabled${NC}"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Pull image
|
||||||
|
# ============================================================================
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Partition table:${NC}"
|
||||||
|
parted -s "$DEVICE" unit s print
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Get the end of the last partition (partition 2 = rootfs)
|
||||||
|
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
|
||||||
|
|
||||||
|
if [ -z "$END_SECTOR" ]; then
|
||||||
|
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add a small buffer (1MB = 2048 sectors) for safety
|
||||||
|
TOTAL_SECTORS=$((END_SECTOR + 2048))
|
||||||
|
TOTAL_BYTES=$((TOTAL_SECTORS * 512))
|
||||||
|
TOTAL_GB=$(echo "scale=2; $TOTAL_BYTES / 1073741824" | bc)
|
||||||
|
|
||||||
|
echo -e "Image size: ${YELLOW}~${TOTAL_GB}GB${NC} (${TOTAL_SECTORS} sectors)"
|
||||||
|
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Proceed with image pull? [Y/n] " confirm
|
||||||
|
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||||
echo "Aborted."
|
echo "Aborted."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get device size for pv
|
echo
|
||||||
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
|
echo -e "${GREEN}Pulling image...${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
echo ""
|
# Use pv if available for progress, otherwise fallback to dd status
|
||||||
echo -e "${GREEN}[1/4]${NC} Copying image from $SELECTED..."
|
if command -v pv &> /dev/null; then
|
||||||
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
|
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \
|
||||||
sync
|
pv -s $TOTAL_BYTES | \
|
||||||
|
zstd -T0 -3 > "$OUTPUT"
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}[2/4]${NC} Re-enabling auto-expand for distribution..."
|
|
||||||
# Mount the image and restore auto-expand service (may have been disabled during build)
|
|
||||||
LOOP_DEV=$(losetup -f --show -P "$IMG_FILE")
|
|
||||||
if [ -n "$LOOP_DEV" ]; then
|
|
||||||
TEMP_MOUNT=$(mktemp -d)
|
|
||||||
if mount "${LOOP_DEV}p2" "$TEMP_MOUNT" 2>/dev/null; then
|
|
||||||
# Re-enable the resize service if the service file exists
|
|
||||||
SERVICE_FILE="$TEMP_MOUNT/lib/systemd/system/rpi-resizerootfs.service"
|
|
||||||
SERVICE_LINK="$TEMP_MOUNT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
|
||||||
if [ -f "$SERVICE_FILE" ] && [ ! -L "$SERVICE_LINK" ]; then
|
|
||||||
mkdir -p "$(dirname "$SERVICE_LINK")"
|
|
||||||
ln -sf /lib/systemd/system/rpi-resizerootfs.service "$SERVICE_LINK"
|
|
||||||
echo -e " ${GREEN}✓${NC} Auto-expand service re-enabled"
|
|
||||||
elif [ -L "$SERVICE_LINK" ]; then
|
|
||||||
echo -e " ${GREEN}✓${NC} Auto-expand already enabled"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Could not find resize service file"
|
|
||||||
fi
|
|
||||||
umount "$TEMP_MOUNT"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Could not mount rootfs, skipping auto-expand fix"
|
|
||||||
fi
|
|
||||||
rmdir "$TEMP_MOUNT" 2>/dev/null || true
|
|
||||||
losetup -d "$LOOP_DEV"
|
|
||||||
else
|
else
|
||||||
echo -e " ${YELLOW}⚠${NC} Could not create loop device, skipping auto-expand fix"
|
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \
|
||||||
|
zstd -T0 -3 > "$OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo
|
||||||
echo -e "${GREEN}[3/4]${NC} Shrinking image..."
|
echo -e "${GREEN}Done!${NC} Image saved to: $OUTPUT"
|
||||||
if command -v pishrink.sh &> /dev/null; then
|
ls -lh "$OUTPUT"
|
||||||
pishrink.sh "$IMG_FILE"
|
|
||||||
elif [ -f "./pishrink.sh" ]; then
|
|
||||||
bash ./pishrink.sh "$IMG_FILE"
|
|
||||||
elif [ -f "../pishrink.sh" ]; then
|
|
||||||
bash ../pishrink.sh "$IMG_FILE"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}pishrink.sh not found, skipping shrink step.${NC}"
|
|
||||||
echo "Download from: https://github.com/Drewsif/PiShrink"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
# ============================================================================
|
||||||
if [ "$SKIP_COMPRESS" = true ]; then
|
# Optional: Zip-wrap for GitHub releases
|
||||||
echo -e "${GREEN}[4/4]${NC} Skipping compression (.img output)"
|
# ============================================================================
|
||||||
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
|
echo
|
||||||
OUTPUT="$IMG_FILE"
|
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
|
||||||
|
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
ZIP_OUTPUT="${OUTPUT}.zip"
|
||||||
|
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
|
||||||
|
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
|
||||||
|
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
|
||||||
|
ls -lh "$ZIP_OUTPUT"
|
||||||
|
echo
|
||||||
|
echo "Users can flash with:"
|
||||||
|
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}[4/4]${NC} Compressing with zstd..."
|
echo
|
||||||
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
|
echo "To verify:"
|
||||||
rm -f "$IMG_FILE"
|
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
||||||
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${GREEN}║ Image Complete! ║${NC}"
|
|
||||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
|
||||||
echo -e "Size: ${YELLOW}$FINAL_SIZE${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ GRAY='\033[0;90m'
|
|||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Source banner functions
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/banner.sh"
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
show_help() {
|
show_help() {
|
||||||
echo "Stegasoo Sanitize Script - Prepare Pi for SD Card Imaging"
|
echo "Stegasoo Sanitize Script - Prepare Pi for SD Card Imaging"
|
||||||
@@ -70,21 +74,11 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
clear
|
clear
|
||||||
echo ""
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
|
||||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
|
||||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
|
|
||||||
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
|
|
||||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
|
|
||||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
if [ "$SOFT_RESET" = true ]; then
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
echo -e "\033[1;37m Soft Reset (Factory)\033[0m"
|
print_banner "Soft Reset (Factory)"
|
||||||
else
|
else
|
||||||
echo -e "\033[1;37m Sanitize for Imaging\033[0m"
|
print_banner "Sanitize for Imaging"
|
||||||
fi
|
fi
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ "$SOFT_RESET" = true ]; then
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
|||||||
79
rpi/setup.sh
79
rpi/setup.sh
@@ -29,6 +29,33 @@ GRAY='\033[0;90m'
|
|||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Source banner.sh if available (for local runs), otherwise define inline
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
||||||
|
if [ -f "$SCRIPT_DIR/banner.sh" ]; then
|
||||||
|
source "$SCRIPT_DIR/banner.sh"
|
||||||
|
else
|
||||||
|
# Inline banner functions for curl-pipe execution
|
||||||
|
print_gradient_line() {
|
||||||
|
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||||
|
}
|
||||||
|
print_banner() {
|
||||||
|
local subtitle="$1"
|
||||||
|
echo ""
|
||||||
|
print_gradient_line
|
||||||
|
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||||
|
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
|
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||||
|
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||||
|
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||||
|
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||||
|
print_gradient_line
|
||||||
|
if [ -n "$subtitle" ]; then
|
||||||
|
echo -e "\033[1;37m ${subtitle}\033[0m"
|
||||||
|
print_gradient_line
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
show_help() {
|
show_help() {
|
||||||
echo "Stegasoo Raspberry Pi Setup Script"
|
echo "Stegasoo Raspberry Pi Setup Script"
|
||||||
@@ -82,17 +109,7 @@ for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf";
|
|||||||
done
|
done
|
||||||
|
|
||||||
clear
|
clear
|
||||||
echo ""
|
print_banner "Raspberry Pi Setup"
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
|
||||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
|
||||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
|
|
||||||
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
|
|
||||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
|
|
||||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo -e "\033[1;37m Raspberry Pi Setup\033[0m"
|
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " This will install Stegasoo with full DCT support"
|
echo " This will install Stegasoo with full DCT support"
|
||||||
echo " Estimated time: ~2 minutes (pre-built) or 15-20 min (from source)"
|
echo " Estimated time: ~2 minutes (pre-built) or 15-20 min (from source)"
|
||||||
@@ -183,8 +200,8 @@ fi
|
|||||||
|
|
||||||
# Pre-built environment tarball (skips 20+ min compile time)
|
# Pre-built environment tarball (skips 20+ min compile time)
|
||||||
# Includes both pyenv Python 3.12 AND venv with all dependencies
|
# Includes both pyenv Python 3.12 AND venv with all dependencies
|
||||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-pi-arm64.tar.zst"
|
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
||||||
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.1.3/stegasoo-pi-arm64.tar.zst}"
|
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.1.5/stegasoo-rpi-runtime-env-arm64.tar.zst}"
|
||||||
USE_PREBUILT=true
|
USE_PREBUILT=true
|
||||||
|
|
||||||
# Use local tarball if present, otherwise will download
|
# Use local tarball if present, otherwise will download
|
||||||
@@ -424,19 +441,35 @@ if systemctl is-active --quiet stegasoo 2>/dev/null; then
|
|||||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||||
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
|
||||||
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
|
||||||
# Show CPU stats if overclocked (read configured freq, not current idle freq)
|
# Show CPU stats if overclocked (read configured freq, not current idle freq)
|
||||||
CONFIG_FILE=""
|
CONFIG_FILE=""
|
||||||
if [ -f /boot/firmware/config.txt ]; then CONFIG_FILE="/boot/firmware/config.txt"
|
if [ -f /boot/firmware/config.txt ]; then CONFIG_FILE="/boot/firmware/config.txt"
|
||||||
elif [ -f /boot/config.txt ]; then CONFIG_FILE="/boot/config.txt"; fi
|
elif [ -f /boot/config.txt ]; then CONFIG_FILE="/boot/config.txt"; fi
|
||||||
|
CPU_MHZ=""
|
||||||
|
CPU_TEMP=""
|
||||||
if [ -n "$CONFIG_FILE" ] && grep -qE "^arm_freq=" "$CONFIG_FILE" 2>/dev/null; then
|
if [ -n "$CONFIG_FILE" ] && grep -qE "^arm_freq=" "$CONFIG_FILE" 2>/dev/null; then
|
||||||
CPU_MHZ=$(grep "^arm_freq=" "$CONFIG_FILE" | cut -d= -f2)
|
CPU_MHZ=$(grep "^arm_freq=" "$CONFIG_FILE" | cut -d= -f2)
|
||||||
CPU_TEMP=$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2)
|
CPU_TEMP=$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2)
|
||||||
|
fi
|
||||||
|
# Compact two-column layout
|
||||||
|
echo -e " 🚀 Stegasoo running 🌐 \033[0;33m$STEGASOO_URL\033[0m"
|
||||||
if [ -n "$CPU_MHZ" ] && [ -n "$CPU_TEMP" ]; then
|
if [ -n "$CPU_MHZ" ] && [ -n "$CPU_TEMP" ]; then
|
||||||
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz \033[0;35m🌡\033[0m ${CPU_TEMP}"
|
# Temp emoji: ice<50, cool 50-70, fire>70
|
||||||
|
TEMP_NUM=$(echo "$CPU_TEMP" | grep -oE "[0-9]+" | head -1)
|
||||||
|
if [ -n "$TEMP_NUM" ]; then
|
||||||
|
if [ "$TEMP_NUM" -ge 70 ]; then
|
||||||
|
TEMP_EMOJI="🔥"
|
||||||
|
elif [ "$TEMP_NUM" -ge 50 ]; then
|
||||||
|
TEMP_EMOJI="😎"
|
||||||
|
else
|
||||||
|
TEMP_EMOJI="🧊"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
TEMP_EMOJI="🌡"
|
||||||
fi
|
fi
|
||||||
|
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz ${TEMP_EMOJI} ${CPU_TEMP}"
|
||||||
|
fi
|
||||||
|
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
@@ -448,6 +481,10 @@ MOTDEOF
|
|||||||
sudo chmod 644 /etc/profile.d/stegasoo-motd.sh
|
sudo chmod 644 /etc/profile.d/stegasoo-motd.sh
|
||||||
echo " Created login banner"
|
echo " Created login banner"
|
||||||
|
|
||||||
|
# Shorten the default Debian MOTD boilerplate
|
||||||
|
echo "Debian GNU/Linux · License: /usr/share/doc/*/copyright" | sudo tee /etc/motd > /dev/null
|
||||||
|
echo " Shortened system MOTD"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}Installation Complete!${NC}"
|
echo -e "${BOLD}Installation Complete!${NC}"
|
||||||
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||||
@@ -613,15 +650,19 @@ echo -e "${BLUE}-------------------------------------------------------${NC}"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
PI_IP=$(hostname -I | awk '{print $1}')
|
PI_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
PI_HOST=$(hostname)
|
||||||
|
|
||||||
echo -e "${GREEN}Create your admin account:${NC}"
|
echo -e "${GREEN}Create your admin account:${NC}"
|
||||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
if [ "$USE_PORT_443" = "true" ]; then
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
|
echo -e " ${YELLOW}https://$PI_HOST.local/setup${NC}"
|
||||||
echo -e " ${YELLOW}https://$PI_IP/setup${NC}"
|
echo -e " ${YELLOW}https://$PI_IP/setup${NC}"
|
||||||
else
|
else
|
||||||
|
echo -e " ${YELLOW}https://$PI_HOST.local:5000/setup${NC}"
|
||||||
echo -e " ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
echo -e " ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
echo -e " ${YELLOW}http://$PI_HOST.local:5000/setup${NC}"
|
||||||
echo -e " ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
echo -e " ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -649,12 +690,12 @@ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
|||||||
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
||||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||||
if [ "$USE_PORT_443" = "true" ]; then
|
if [ "$USE_PORT_443" = "true" ]; then
|
||||||
echo -e " Create admin: ${YELLOW}https://$PI_IP/setup${NC}"
|
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
|
||||||
else
|
else
|
||||||
echo -e " Create admin: ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local:5000/setup${NC} or ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e " Create admin: ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
||||||
|
|||||||
@@ -1,7 +1,69 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo CLI Module (v3.2.0)
|
Stegasoo CLI Module (v3.2.0)
|
||||||
|
|
||||||
Command-line interface with batch processing and compression support.
|
A proper CLI architecture using Click. This module demonstrates several
|
||||||
|
important patterns for building production-quality command-line tools:
|
||||||
|
|
||||||
|
PATTERN: COMMAND GROUPS
|
||||||
|
=======================
|
||||||
|
Click's @group decorator creates a hierarchy of commands:
|
||||||
|
|
||||||
|
stegasoo <- Main entry point
|
||||||
|
├── encode <- Simple commands at root level
|
||||||
|
├── decode
|
||||||
|
├── generate
|
||||||
|
├── info
|
||||||
|
├── batch/ <- Group for related commands
|
||||||
|
│ ├── encode
|
||||||
|
│ ├── decode
|
||||||
|
│ └── check
|
||||||
|
├── channel/ <- Another group
|
||||||
|
│ ├── generate
|
||||||
|
│ ├── show
|
||||||
|
│ ├── status
|
||||||
|
│ ├── qr
|
||||||
|
│ └── clear
|
||||||
|
├── tools/ <- Utility group
|
||||||
|
│ ├── capacity
|
||||||
|
│ ├── strip
|
||||||
|
│ ├── peek
|
||||||
|
│ └── exif
|
||||||
|
└── admin/ <- Administration group
|
||||||
|
├── recover
|
||||||
|
└── generate-key
|
||||||
|
|
||||||
|
PATTERN: JSON OUTPUT MODE
|
||||||
|
=========================
|
||||||
|
Every command supports --json for machine-readable output. The pattern:
|
||||||
|
|
||||||
|
@click.pass_context
|
||||||
|
def my_command(ctx, ...):
|
||||||
|
if ctx.obj.get("json"):
|
||||||
|
click.echo(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
# Human-readable output with colors/formatting
|
||||||
|
click.echo(f"✓ Success: {result}")
|
||||||
|
|
||||||
|
This makes the CLI scriptable - you can pipe to jq, use in shell scripts, etc.
|
||||||
|
|
||||||
|
PATTERN: SENSITIVE INPUT
|
||||||
|
========================
|
||||||
|
Passwords/secrets use Click's secure prompts:
|
||||||
|
|
||||||
|
@click.option("--passphrase", prompt=True, hide_input=True,
|
||||||
|
confirmation_prompt=True, help="Passphrase")
|
||||||
|
|
||||||
|
- prompt=True: Asks if not provided
|
||||||
|
- hide_input=True: No echo (like sudo)
|
||||||
|
- confirmation_prompt=True: "Repeat for confirmation"
|
||||||
|
|
||||||
|
PATTERN: DRY-RUN MODE
|
||||||
|
=====================
|
||||||
|
For destructive or slow operations, --dry-run shows what WOULD happen:
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
click.echo(f"Would encode to {output}")
|
||||||
|
return
|
||||||
|
|
||||||
Changes in v3.2.0:
|
Changes in v3.2.0:
|
||||||
- Updated to use DEFAULT_PASSPHRASE_WORDS (consistency with v3.2.0 naming)
|
- Updated to use DEFAULT_PASSPHRASE_WORDS (consistency with v3.2.0 naming)
|
||||||
@@ -32,10 +94,23 @@ from .constants import (
|
|||||||
__version__,
|
__version__,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Click context settings
|
# Click context settings - these apply to all commands
|
||||||
|
# help_option_names lets users use either -h or --help
|
||||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ROOT GROUP - The main entry point
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# @click.group() creates a command group. The function becomes both:
|
||||||
|
# 1. A callable that sets up shared state (ctx.obj)
|
||||||
|
# 2. A container for subcommands via @cli.command() decorators
|
||||||
|
#
|
||||||
|
# The context object (ctx.obj) is passed down to all subcommands.
|
||||||
|
# We use it to share the --json flag across the entire CLI.
|
||||||
|
|
||||||
|
|
||||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||||
@click.version_option(__version__, "-v", "--version")
|
@click.version_option(__version__, "-v", "--version")
|
||||||
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
|
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
|
||||||
@@ -46,6 +121,8 @@ def cli(ctx, json_output):
|
|||||||
|
|
||||||
Hide messages in images using PIN + passphrase security.
|
Hide messages in images using PIN + passphrase security.
|
||||||
"""
|
"""
|
||||||
|
# ensure_object(dict) creates ctx.obj if it doesn't exist
|
||||||
|
# This prevents "NoneType has no attribute" errors
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["json"] = json_output
|
ctx.obj["json"] = json_output
|
||||||
|
|
||||||
@@ -53,6 +130,31 @@ def cli(ctx, json_output):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ENCODE COMMANDS
|
# ENCODE COMMANDS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# The encode command demonstrates several Click patterns:
|
||||||
|
#
|
||||||
|
# 1. ARGUMENT vs OPTION
|
||||||
|
# - Arguments are positional: `stegasoo encode photo.png`
|
||||||
|
# - Options have flags: `stegasoo encode -m "message" --pin 1234`
|
||||||
|
# Rule of thumb: required inputs → arguments, optional/secret → options
|
||||||
|
#
|
||||||
|
# 2. MUTUAL EXCLUSIVITY
|
||||||
|
# We need either --message OR --file, not both. Click doesn't have built-in
|
||||||
|
# mutual exclusivity, so we check manually:
|
||||||
|
#
|
||||||
|
# if not message and not file_payload:
|
||||||
|
# raise click.UsageError("Either --message or --file is required")
|
||||||
|
#
|
||||||
|
# 3. TYPE VALIDATION
|
||||||
|
# Click validates types automatically:
|
||||||
|
# - type=click.Path(exists=True) → file must exist
|
||||||
|
# - type=click.Choice(["a", "b"]) → must be one of these values
|
||||||
|
# - type=int → must be an integer
|
||||||
|
#
|
||||||
|
# 4. DEFAULT VALUES
|
||||||
|
# Options can have smart defaults:
|
||||||
|
# - default="zlib" → use this if not specified
|
||||||
|
# - default=True with is_flag=True → boolean flag defaults to on
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@@ -320,6 +422,32 @@ def decode(ctx, image, reference, passphrase, pin, output):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# BATCH COMMANDS
|
# BATCH COMMANDS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Batch processing demonstrates:
|
||||||
|
#
|
||||||
|
# 1. SUBGROUPS
|
||||||
|
# @cli.group() creates a nested command group:
|
||||||
|
# stegasoo batch encode *.png
|
||||||
|
# stegasoo batch decode *.png
|
||||||
|
# stegasoo batch check *.png
|
||||||
|
#
|
||||||
|
# 2. VARIADIC ARGUMENTS
|
||||||
|
# nargs=-1 accepts multiple arguments:
|
||||||
|
# @click.argument("images", nargs=-1, required=True)
|
||||||
|
# This lets users do: `stegasoo batch encode img1.png img2.png img3.png`
|
||||||
|
# Or with shell expansion: `stegasoo batch encode *.png`
|
||||||
|
#
|
||||||
|
# 3. PROGRESS CALLBACKS
|
||||||
|
# We pass a callback to the BatchProcessor for real-time updates:
|
||||||
|
#
|
||||||
|
# def progress(current, total, item):
|
||||||
|
# click.echo(f"[{current}/{total}] {item.input_path.name}")
|
||||||
|
#
|
||||||
|
# processor.batch_encode(..., progress_callback=progress)
|
||||||
|
#
|
||||||
|
# 4. PARALLEL PROCESSING
|
||||||
|
# --jobs/-j controls worker count. Default is 4 for good balance between
|
||||||
|
# speed and memory usage. Each worker loads images into memory.
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
@@ -595,10 +723,10 @@ def info(ctx, full):
|
|||||||
|
|
||||||
# Check for DCT support
|
# Check for DCT support
|
||||||
try:
|
try:
|
||||||
from .dct_steganography import HAS_SCIPY, HAS_JPEGIO
|
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
||||||
HAS_DCT = HAS_SCIPY and HAS_JPEGIO
|
has_dct = HAS_SCIPY and HAS_JPEGIO
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_DCT = False
|
has_dct = False
|
||||||
|
|
||||||
# Check service status
|
# Check service status
|
||||||
service_status = "unknown"
|
service_status = "unknown"
|
||||||
@@ -637,7 +765,7 @@ def info(ctx, full):
|
|||||||
channel_fingerprint = None
|
channel_fingerprint = None
|
||||||
channel_source = None
|
channel_source = None
|
||||||
try:
|
try:
|
||||||
from .channel import get_channel_key, get_channel_fingerprint, get_channel_status
|
from .channel import get_channel_fingerprint, get_channel_key, get_channel_status
|
||||||
key = get_channel_key()
|
key = get_channel_key()
|
||||||
if key:
|
if key:
|
||||||
channel_fingerprint = get_channel_fingerprint(key)
|
channel_fingerprint = get_channel_fingerprint(key)
|
||||||
@@ -688,7 +816,7 @@ def info(ctx, full):
|
|||||||
"version": __version__,
|
"version": __version__,
|
||||||
"service": service_status,
|
"service": service_status,
|
||||||
"url": service_url,
|
"url": service_url,
|
||||||
"dct_support": HAS_DCT,
|
"dct_support": has_dct,
|
||||||
"channel": {
|
"channel": {
|
||||||
"fingerprint": channel_fingerprint,
|
"fingerprint": channel_fingerprint,
|
||||||
"source": channel_source,
|
"source": channel_source,
|
||||||
@@ -718,11 +846,11 @@ def info(ctx, full):
|
|||||||
|
|
||||||
# Service status
|
# Service status
|
||||||
if service_status == "active":
|
if service_status == "active":
|
||||||
click.echo(f" Service: \033[32m● running\033[0m")
|
click.echo(" Service: \033[32m● running\033[0m")
|
||||||
if service_url:
|
if service_url:
|
||||||
click.echo(f" URL: {service_url}")
|
click.echo(f" URL: {service_url}")
|
||||||
elif service_status == "inactive":
|
elif service_status == "inactive":
|
||||||
click.echo(f" Service: \033[31m○ stopped\033[0m")
|
click.echo(" Service: \033[31m○ stopped\033[0m")
|
||||||
else:
|
else:
|
||||||
click.echo(f" Service: \033[33m? {service_status}\033[0m")
|
click.echo(f" Service: \033[33m? {service_status}\033[0m")
|
||||||
|
|
||||||
@@ -731,10 +859,10 @@ def info(ctx, full):
|
|||||||
masked = f"{channel_fingerprint[:4]}••••••••{channel_fingerprint[-4:]}"
|
masked = f"{channel_fingerprint[:4]}••••••••{channel_fingerprint[-4:]}"
|
||||||
click.echo(f" Channel: {masked}")
|
click.echo(f" Channel: {masked}")
|
||||||
else:
|
else:
|
||||||
click.echo(f" Channel: \033[33mpublic\033[0m")
|
click.echo(" Channel: \033[33mpublic\033[0m")
|
||||||
|
|
||||||
# DCT
|
# DCT
|
||||||
dct_status = "\033[32m✓ enabled\033[0m" if HAS_DCT else "\033[31m✗ disabled\033[0m"
|
dct_status = "\033[32m✓ enabled\033[0m" if has_dct else "\033[31m✗ disabled\033[0m"
|
||||||
click.echo(f" DCT: {dct_status}")
|
click.echo(f" DCT: {dct_status}")
|
||||||
|
|
||||||
# System info (if --full)
|
# System info (if --full)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
|||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "4.1.3"
|
__version__ = "4.1.5"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo Cryptographic Functions (v4.0.0 - Channel Key Support)
|
Stegasoo Cryptographic Functions (v4.0.0 - Channel Key Support)
|
||||||
|
|
||||||
Key derivation, encryption, and decryption using AES-256-GCM.
|
This is the crypto layer - where we turn plaintext into indecipherable noise.
|
||||||
Supports both text messages and binary file payloads.
|
|
||||||
|
|
||||||
BREAKING CHANGES in v4.0.0:
|
The security model is multi-factor:
|
||||||
- Added channel key support for deployment/group isolation
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
- Messages encoded with a channel key require the same key to decode
|
│ SOMETHING YOU HAVE SOMETHING YOU KNOW │
|
||||||
- Channel key can be configured via environment, config file, or explicit parameter
|
│ ├─ Reference photo ├─ Passphrase (4+ BIP-39 words) │
|
||||||
- FORMAT_VERSION bumped to 5
|
│ └─ RSA private key (opt) └─ PIN (6-9 digits) │
|
||||||
|
│ │
|
||||||
|
│ DEPLOYMENT BINDING │
|
||||||
|
│ └─ Channel key (ties messages to a specific server/group) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
BREAKING CHANGES in v3.2.0:
|
All factors get mixed together through Argon2id (memory-hard KDF) to derive
|
||||||
- Removed date dependency from key derivation
|
the actual encryption key. Miss any factor = wrong key = garbage output.
|
||||||
- Renamed day_phrase → passphrase (no daily rotation needed)
|
|
||||||
|
Encryption: AES-256-GCM (authenticated encryption - tamper = detection)
|
||||||
|
KDF: Argon2id (256MB RAM, 4 iterations) or PBKDF2 fallback (600K iterations)
|
||||||
|
|
||||||
|
v4.0.0: Added channel key for server/group isolation
|
||||||
|
v3.2.0: Removed date dependency (was cute but annoying in practice)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -98,25 +106,38 @@ def _resolve_channel_key(channel_key: str | bool | None) -> bytes | None:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CORE CRYPTO FUNCTIONS
|
# CORE CRYPTO FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# The "reference photo as a key" concept is one of Stegasoo's unique features.
|
||||||
|
# Most steganography tools just use a password. We add the photo as a
|
||||||
|
# "something you have" factor - like a hardware token, but it's a cat picture.
|
||||||
|
|
||||||
|
|
||||||
def hash_photo(image_data: bytes) -> bytes:
|
def hash_photo(image_data: bytes) -> bytes:
|
||||||
"""
|
"""
|
||||||
Compute deterministic hash of photo pixel content.
|
Compute deterministic hash of photo pixel content.
|
||||||
|
|
||||||
This normalizes the image to RGB and hashes the raw pixel data,
|
This is the magic sauce that turns your cat photo into a cryptographic key.
|
||||||
making it resistant to metadata changes.
|
|
||||||
|
Why pixels and not the file hash?
|
||||||
|
- File metadata changes (EXIF stripped, resaved) = different file hash
|
||||||
|
- But pixel content stays the same
|
||||||
|
- We hash the RGB values directly, so format conversions don't matter
|
||||||
|
|
||||||
|
The double-hash with prefix is belt-and-suspenders mixing. Probably
|
||||||
|
overkill, but hey, it's crypto - paranoia is a feature.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_data: Raw image file bytes
|
image_data: Raw image file bytes (any format PIL can read)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
32-byte SHA-256 hash
|
32-byte SHA-256 hash of pixel content
|
||||||
"""
|
"""
|
||||||
|
# Convert to RGB to normalize (RGBA, grayscale, etc. all become RGB)
|
||||||
img: Image.Image = Image.open(io.BytesIO(image_data)).convert("RGB")
|
img: Image.Image = Image.open(io.BytesIO(image_data)).convert("RGB")
|
||||||
pixels = img.tobytes()
|
pixels = img.tobytes()
|
||||||
|
|
||||||
# Double-hash with prefix for additional mixing
|
# Double-hash: SHA256(SHA256(pixels) + first 1KB of pixels)
|
||||||
|
# The prefix adds image-specific data to prevent length-extension shenanigans
|
||||||
h = hashlib.sha256(pixels).digest()
|
h = hashlib.sha256(pixels).digest()
|
||||||
h = hashlib.sha256(h + pixels[:1024]).digest()
|
h = hashlib.sha256(h + pixels[:1024]).digest()
|
||||||
return h
|
return h
|
||||||
@@ -133,20 +154,38 @@ def derive_hybrid_key(
|
|||||||
"""
|
"""
|
||||||
Derive encryption key from multiple factors.
|
Derive encryption key from multiple factors.
|
||||||
|
|
||||||
Combines:
|
This is the heart of Stegasoo's security model. We take all the things
|
||||||
- Photo hash (something you have)
|
you need to prove you're authorized (photo, passphrase, PIN, etc.) and
|
||||||
- Passphrase (something you know)
|
blend them together into one 32-byte key.
|
||||||
- PIN (something you know, static)
|
|
||||||
- RSA key (something you have)
|
|
||||||
- Channel key (deployment/group binding)
|
|
||||||
- Salt (random per message)
|
|
||||||
|
|
||||||
Uses Argon2id if available, falls back to PBKDF2.
|
The flow:
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Photo hash │ + │ passphrase │ + │ PIN + RSA │ + salt
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│ │ │
|
||||||
|
└────────────────┴────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Argon2id │ <- Memory-hard KDF
|
||||||
|
│ 256MB / 4 iter │ <- Makes brute force expensive
|
||||||
|
└─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
32-byte AES key
|
||||||
|
|
||||||
|
Why Argon2id?
|
||||||
|
- Memory-hard: attackers can't just throw GPUs at it
|
||||||
|
- 256MB RAM per attempt = expensive at scale
|
||||||
|
- Winner of the Password Hashing Competition (2015)
|
||||||
|
- "id" variant resists both side-channel and GPU attacks
|
||||||
|
|
||||||
|
Fallback: PBKDF2-SHA512 with 600K iterations (for systems without argon2)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
photo_data: Reference photo bytes
|
photo_data: Reference photo bytes
|
||||||
passphrase: Shared passphrase (recommend 4+ words)
|
passphrase: Shared passphrase (recommend 4+ words from BIP-39)
|
||||||
salt: Random salt for this message
|
salt: Random salt for this message (32 bytes)
|
||||||
pin: Optional static PIN
|
pin: Optional static PIN
|
||||||
rsa_key_data: Optional RSA key bytes
|
rsa_key_data: Optional RSA key bytes
|
||||||
channel_key: Channel key parameter:
|
channel_key: Channel key parameter:
|
||||||
@@ -155,7 +194,7 @@ def derive_hybrid_key(
|
|||||||
- "" or False: No channel key (public mode)
|
- "" or False: No channel key (public mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
32-byte derived key
|
32-byte derived key (ready for AES-256)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
KeyDerivationError: If key derivation fails
|
KeyDerivationError: If key derivation fails
|
||||||
@@ -163,31 +202,36 @@ def derive_hybrid_key(
|
|||||||
try:
|
try:
|
||||||
photo_hash = hash_photo(photo_data)
|
photo_hash = hash_photo(photo_data)
|
||||||
|
|
||||||
# Resolve channel key
|
# Resolve channel key (server-specific binding)
|
||||||
channel_hash = _resolve_channel_key(channel_key)
|
channel_hash = _resolve_channel_key(channel_key)
|
||||||
|
|
||||||
# Build key material
|
# Build key material by concatenating all factors
|
||||||
|
# Passphrase is lowercased to be forgiving of case differences
|
||||||
key_material = photo_hash + passphrase.lower().encode() + pin.encode() + salt
|
key_material = photo_hash + passphrase.lower().encode() + pin.encode() + salt
|
||||||
|
|
||||||
# Add RSA key hash if provided
|
# Add RSA key hash if provided (another "something you have")
|
||||||
if rsa_key_data:
|
if rsa_key_data:
|
||||||
key_material += hashlib.sha256(rsa_key_data).digest()
|
key_material += hashlib.sha256(rsa_key_data).digest()
|
||||||
|
|
||||||
# Add channel key hash if configured (v4.0.0)
|
# Add channel key hash if configured (v4.0.0 - deployment binding)
|
||||||
if channel_hash:
|
if channel_hash:
|
||||||
key_material += channel_hash
|
key_material += channel_hash
|
||||||
|
|
||||||
|
# Run it all through the KDF
|
||||||
if HAS_ARGON2:
|
if HAS_ARGON2:
|
||||||
|
# Argon2id: the good stuff
|
||||||
key = hash_secret_raw(
|
key = hash_secret_raw(
|
||||||
secret=key_material,
|
secret=key_material,
|
||||||
salt=salt[:32],
|
salt=salt[:32],
|
||||||
time_cost=ARGON2_TIME_COST,
|
time_cost=ARGON2_TIME_COST, # 4 iterations
|
||||||
memory_cost=ARGON2_MEMORY_COST,
|
memory_cost=ARGON2_MEMORY_COST, # 256 MB RAM
|
||||||
parallelism=ARGON2_PARALLELISM,
|
parallelism=ARGON2_PARALLELISM, # 4 threads
|
||||||
hash_len=32,
|
hash_len=32,
|
||||||
type=Type.ID,
|
type=Type.ID, # Hybrid mode: resists side-channel AND GPU attacks
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# PBKDF2 fallback for systems without argon2-cffi
|
||||||
|
# 600K iterations is slow but not memory-hard
|
||||||
kdf = PBKDF2HMAC(
|
kdf = PBKDF2HMAC(
|
||||||
algorithm=hashes.SHA512(),
|
algorithm=hashes.SHA512(),
|
||||||
length=32,
|
length=32,
|
||||||
@@ -347,9 +391,12 @@ def _unpack_payload(data: bytes) -> DecodeResult:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# HEADER FLAGS (v4.0.0)
|
# HEADER FLAGS (v4.0.0)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# The flags byte tells us about the message without decrypting it.
|
||||||
|
# Currently just one flag, but the byte gives us room for 8.
|
||||||
|
|
||||||
# Header flag bits
|
FLAG_CHANNEL_KEY = 0x01 # Bit 0: Message was encoded with a channel key
|
||||||
FLAG_CHANNEL_KEY = 0x01 # Set if encoded with a channel key
|
# Future flags could include: compression, file attachment, etc.
|
||||||
|
|
||||||
|
|
||||||
def encrypt_message(
|
def encrypt_message(
|
||||||
@@ -361,33 +408,40 @@ def encrypt_message(
|
|||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Encrypt message or file using AES-256-GCM with hybrid key derivation.
|
Encrypt message or file using AES-256-GCM.
|
||||||
|
|
||||||
Message format (v4.0.0 - with channel key support):
|
This is where plaintext becomes ciphertext. We use AES-256-GCM which is:
|
||||||
- Magic header (4 bytes)
|
- AES: The standard, used by everyone from banks to governments
|
||||||
- Version (1 byte) = 5
|
- 256-bit key: Enough entropy to survive until the heat death of the universe
|
||||||
- Flags (1 byte) - indicates if channel key was used
|
- GCM mode: Authenticated encryption - if anyone tampers, decryption fails
|
||||||
- Salt (32 bytes)
|
|
||||||
- IV (12 bytes)
|
The output format (v4.0.0):
|
||||||
- Auth tag (16 bytes)
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
- Ciphertext (variable, padded)
|
│ \x89ST3 │ 05 │ flags │ salt (32B) │ iv (12B) │ tag (16B) │ ··· │
|
||||||
|
│ magic │ver │ │ │ │ │cipher│
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Why the random padding at the end?
|
||||||
|
- Message length can reveal information (traffic analysis)
|
||||||
|
- We add 64-319 random bytes and round to 256-byte boundary
|
||||||
|
- All messages look roughly the same size
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: Message string, raw bytes, or FilePayload to encrypt
|
message: Message string, raw bytes, or FilePayload to encrypt
|
||||||
photo_data: Reference photo bytes
|
photo_data: Reference photo bytes (your "key photo")
|
||||||
passphrase: Shared passphrase (recommend 4+ words for good entropy)
|
passphrase: Shared passphrase (recommend 4+ words from BIP-39)
|
||||||
pin: Optional static PIN
|
pin: Optional static PIN for additional security
|
||||||
rsa_key_data: Optional RSA key bytes
|
rsa_key_data: Optional RSA key bytes (another "something you have")
|
||||||
channel_key: Channel key parameter:
|
channel_key: Channel key parameter:
|
||||||
- None or "auto": Use configured key
|
- None or "auto": Use server's configured key
|
||||||
- str: Use this specific key
|
- str: Use this specific key
|
||||||
- "" or False: No channel key (public mode)
|
- "" or False: No channel key (public mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Encrypted message bytes
|
Encrypted message bytes ready for embedding
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
EncryptionError: If encryption fails
|
EncryptionError: If encryption fails (shouldn't happen with valid inputs)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
salt = secrets.token_bytes(SALT_SIZE)
|
salt = secrets.token_bytes(SALT_SIZE)
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
DCT Domain Steganography Module (v4.1.0)
|
DCT Domain Steganography Module (v4.1.0)
|
||||||
|
|
||||||
Embeds data in DCT coefficients with two approaches:
|
The fancy pants mode. Instead of hiding bits in pixel values (LSB mode),
|
||||||
1. PNG output: Scipy-based DCT transform (grayscale or color)
|
we hide them in the *frequency domain* - specifically in the Discrete Cosine
|
||||||
2. JPEG output: jpegio-based coefficient manipulation (if available)
|
Transform coefficients that JPEG compression uses internally.
|
||||||
|
|
||||||
v4.1.0 Changes:
|
Why is this cool?
|
||||||
- Reed-Solomon error correction protects against bit errors in problematic blocks
|
- Survives some image processing that would destroy LSB data
|
||||||
- Majority voting on length headers (3 copies) for additional robustness
|
- Works with JPEG without the usual "save destroys everything" problem
|
||||||
- RS can correct up to 16 byte errors per 223-byte chunk
|
- Uses the same math that JPEG itself uses - we're hiding in plain sight
|
||||||
|
|
||||||
v3.2.0-patch2 Changes:
|
Two approaches depending on what you want:
|
||||||
- Chunked processing for large images to avoid heap corruption
|
1. PNG output: We do our own DCT math via scipy (works on any image)
|
||||||
- Process image in vertical strips to limit memory per operation
|
2. JPEG output: We use jpegio to directly tweak the coefficients (chef's kiss)
|
||||||
- Isolated DCT operations with fresh array allocations
|
|
||||||
- Workaround for scipy.fftpack memory issues
|
|
||||||
|
|
||||||
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode), reedsolo (for error correction)
|
v4.1.0 - The "please stop corrupting my data" release:
|
||||||
|
- Reed-Solomon error correction (can fix up to 16 byte errors per chunk)
|
||||||
|
- Majority voting on headers (store 3 copies, take the winner)
|
||||||
|
- Because some image regions are just... problematic
|
||||||
|
|
||||||
|
v3.2.0-patch2 - The "scipy why are you like this" release:
|
||||||
|
- Chunked processing because scipy's FFT was corrupting memory on big images
|
||||||
|
- Process blocks one at a time with fresh arrays
|
||||||
|
- Yes, it's slower. No, I don't care. Correctness > speed.
|
||||||
|
|
||||||
|
Requires: scipy (PNG mode), optionally jpegio (JPEG mode), reedsolo (error correction)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
@@ -87,11 +95,31 @@ def _write_progress(progress_file: str | None, current: int, total: int, phase:
|
|||||||
# CONSTANTS
|
# CONSTANTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# JPEG uses 8x8 blocks for DCT - this is baked into the standard
|
||||||
BLOCK_SIZE = 8
|
BLOCK_SIZE = 8
|
||||||
|
|
||||||
|
# The zig-zag order of DCT coefficients. JPEG stores them this way because
|
||||||
|
# the human eye is more sensitive to low frequencies (top-left corner)
|
||||||
|
# than high frequencies (bottom-right). After quantization, most high-freq
|
||||||
|
# coefficients become zero, so zig-zag gives great compression.
|
||||||
|
#
|
||||||
|
# Visual of an 8x8 DCT block with zig-zag numbering:
|
||||||
|
#
|
||||||
|
# DC 1 5 6 14 15 27 28 <- Low frequency (smooth gradients)
|
||||||
|
# 2 4 7 13 16 26 29 42
|
||||||
|
# 3 8 12 17 25 30 41 43
|
||||||
|
# 9 11 18 24 31 40 44 53
|
||||||
|
# 10 19 23 32 39 45 52 54
|
||||||
|
# 20 22 33 38 46 51 55 60
|
||||||
|
# 21 34 37 47 50 56 59 61
|
||||||
|
# 35 36 48 49 57 58 62 63 <- High frequency (fine detail/noise)
|
||||||
|
#
|
||||||
|
# Position (0,0) is the DC coefficient - the average brightness of the block.
|
||||||
|
# We NEVER touch DC because changing it causes visible brightness shifts.
|
||||||
EMBED_POSITIONS = [
|
EMBED_POSITIONS = [
|
||||||
(0, 1),
|
(0, 1), # 1st AC coefficient
|
||||||
(1, 0),
|
(1, 0), # 2nd AC coefficient
|
||||||
(2, 0),
|
(2, 0), # ... and so on in zig-zag order
|
||||||
(1, 1),
|
(1, 1),
|
||||||
(0, 2),
|
(0, 2),
|
||||||
(0, 3),
|
(0, 3),
|
||||||
@@ -124,32 +152,59 @@ EMBED_POSITIONS = [
|
|||||||
(6, 1),
|
(6, 1),
|
||||||
(7, 0),
|
(7, 0),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# We use positions 4-20 (mid-frequency range). Here's the reasoning:
|
||||||
|
# - Positions 0-3: Too low frequency, changes are visible as color shifts
|
||||||
|
# - Positions 4-20: Sweet spot - carries enough energy to survive, not visible
|
||||||
|
# - Positions 21+: High frequency, often quantized to zero, unreliable
|
||||||
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20]
|
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20]
|
||||||
|
|
||||||
|
# Quantization step for QIM (Quantization Index Modulation).
|
||||||
|
# This is how we actually embed bits: we round the coefficient to a grid
|
||||||
|
# and then nudge it based on whether we want a 0 or 1.
|
||||||
|
# Bigger step = more robust to noise, but more visible. 25 is a good balance.
|
||||||
QUANT_STEP = 25
|
QUANT_STEP = 25
|
||||||
DCT_MAGIC = b"DCTS"
|
|
||||||
HEADER_SIZE = 10
|
# Magic bytes so we can identify our own images
|
||||||
|
DCT_MAGIC = b"DCTS" # scipy DCT mode marker
|
||||||
|
JPEGIO_MAGIC = b"JPGS" # jpegio native JPEG mode marker
|
||||||
|
HEADER_SIZE = 10 # Magic (4) + version (1) + flags (1) + length (4)
|
||||||
|
|
||||||
OUTPUT_FORMAT_PNG = "png"
|
OUTPUT_FORMAT_PNG = "png"
|
||||||
OUTPUT_FORMAT_JPEG = "jpeg"
|
OUTPUT_FORMAT_JPEG = "jpeg"
|
||||||
JPEG_OUTPUT_QUALITY = 95
|
JPEG_OUTPUT_QUALITY = 95 # High quality but not 100 (100 causes issues, see below)
|
||||||
JPEGIO_MAGIC = b"JPGS"
|
|
||||||
|
# For jpegio mode: we only embed in coefficients with magnitude >= 2
|
||||||
|
# Coefficients of 0 or 1 are usually quantized noise - unreliable
|
||||||
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
||||||
|
|
||||||
|
# We embed in the Y (luminance) channel only - it has the most capacity
|
||||||
|
# Cb/Cr are often subsampled 4:2:0 anyway
|
||||||
JPEGIO_EMBED_CHANNEL = 0
|
JPEGIO_EMBED_CHANNEL = 0
|
||||||
FLAG_COLOR_MODE = 0x01
|
|
||||||
FLAG_RS_PROTECTED = 0x02 # Reed-Solomon error correction enabled
|
|
||||||
|
|
||||||
# Reed-Solomon settings - 32 symbols can correct up to 16 byte errors per 223-byte chunk
|
# Header flags
|
||||||
|
FLAG_COLOR_MODE = 0x01 # Set if we preserved color (YCbCr mode)
|
||||||
|
FLAG_RS_PROTECTED = 0x02 # Set if Reed-Solomon protected (v4.1.0+)
|
||||||
|
|
||||||
|
# Reed-Solomon settings - the "please don't lose my data" system
|
||||||
|
# 32 parity symbols per chunk means we can correct up to 16 byte errors
|
||||||
|
# Math: RS(255, 223) where 255-223=32 parity bytes, corrects floor(32/2)=16
|
||||||
RS_NSYM = 32
|
RS_NSYM = 32
|
||||||
RS_LENGTH_HEADER_SIZE = 8 # 8 bytes: 4 for raw_payload_length + 4 for rs_payload_length
|
|
||||||
RS_LENGTH_COPIES = 3 # Store length header 3 times for majority voting
|
|
||||||
RS_LENGTH_PREFIX_SIZE = RS_LENGTH_HEADER_SIZE * RS_LENGTH_COPIES # Total: 24 bytes
|
|
||||||
|
|
||||||
# Chunking settings for large images
|
# We store the payload length 3 times and take majority vote
|
||||||
MAX_CHUNK_HEIGHT = 512 # Process in 512-pixel tall strips
|
# Because if the length is wrong, everything is wrong
|
||||||
|
RS_LENGTH_HEADER_SIZE = 8 # 4 bytes raw length + 4 bytes RS-encoded length
|
||||||
|
RS_LENGTH_COPIES = 3 # Store 3 copies, need 2 to agree
|
||||||
|
RS_LENGTH_PREFIX_SIZE = RS_LENGTH_HEADER_SIZE * RS_LENGTH_COPIES # 24 bytes total
|
||||||
|
|
||||||
# JPEG normalization settings
|
# Chunking for large images - scipy's FFT gets memory-corrupty on huge arrays
|
||||||
# JPEGs with quality=100 have all quantization values = 1, which crashes jpegio
|
MAX_CHUNK_HEIGHT = 512 # Process in strips to keep memory sane
|
||||||
JPEGIO_NORMALIZE_QUALITY = 95 # Re-save quality for problematic JPEGs
|
|
||||||
JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # If all quant values <= this, normalize
|
# Fun bug: JPEGs saved with quality=100 have quantization tables full of 1s
|
||||||
|
# This makes the DCT coefficients HUGE and jpegio crashes spectacularly
|
||||||
|
# Solution: detect and re-save at quality 95 first
|
||||||
|
JPEGIO_NORMALIZE_QUALITY = 95
|
||||||
|
JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # All 1s in quant table = bad news
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -209,13 +264,26 @@ def has_jpegio_support() -> bool:
|
|||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# REED-SOLOMON ERROR CORRECTION
|
# REED-SOLOMON ERROR CORRECTION
|
||||||
# Protects against bit errors in problematic image blocks
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Why do we need this? DCT embedding isn't perfect. Some image regions are
|
||||||
|
# problematic - flat areas, high compression, edge cases. Bits can flip.
|
||||||
|
#
|
||||||
|
# Reed-Solomon is the same error correction used in CDs, DVDs, QR codes, and
|
||||||
|
# deep space communications. If it's good enough for Voyager, it's good enough
|
||||||
|
# for hiding cat pictures in other cat pictures.
|
||||||
|
#
|
||||||
|
# How it works (simplified):
|
||||||
|
# 1. Take your data bytes
|
||||||
|
# 2. Add extra "parity" bytes calculated from the data
|
||||||
|
# 3. If some bytes get corrupted, the math lets you reconstruct them
|
||||||
|
# 4. RS(255, 223) means: 255 byte blocks, 223 data + 32 parity
|
||||||
|
# 5. Can correct up to 16 corrupted bytes per block (floor(32/2))
|
||||||
|
#
|
||||||
|
# The tradeoff: ~14% overhead (32/223). Worth it for reliability.
|
||||||
|
|
||||||
# Check for reedsolo availability
|
|
||||||
try:
|
try:
|
||||||
from reedsolo import ReedSolomonError, RSCodec
|
from reedsolo import ReedSolomonError, RSCodec
|
||||||
|
|
||||||
HAS_REEDSOLO = True
|
HAS_REEDSOLO = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_REEDSOLO = False
|
HAS_REEDSOLO = False
|
||||||
@@ -224,48 +292,78 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
def _rs_encode(data: bytes) -> bytes:
|
def _rs_encode(data: bytes) -> bytes:
|
||||||
"""Add Reed-Solomon error correction symbols to data."""
|
"""
|
||||||
|
Wrap data in Reed-Solomon error correction.
|
||||||
|
|
||||||
|
Takes your precious payload and adds parity bytes so we can
|
||||||
|
recover from the inevitable bit-rot of DCT embedding.
|
||||||
|
"""
|
||||||
if not HAS_REEDSOLO:
|
if not HAS_REEDSOLO:
|
||||||
return data # No protection if reedsolo not available
|
return data # YOLO mode - no protection, good luck
|
||||||
rs = RSCodec(RS_NSYM)
|
rs = RSCodec(RS_NSYM)
|
||||||
return bytes(rs.encode(data))
|
return bytes(rs.encode(data))
|
||||||
|
|
||||||
|
|
||||||
def _rs_decode(data: bytes) -> bytes:
|
def _rs_decode(data: bytes) -> bytes:
|
||||||
"""Decode Reed-Solomon protected data, correcting errors if possible."""
|
"""
|
||||||
|
Decode Reed-Solomon protected data, fixing errors along the way.
|
||||||
|
|
||||||
|
This is where the magic happens. If bits got flipped during
|
||||||
|
extraction, RS will quietly fix them. If too many flipped...
|
||||||
|
well, we tried.
|
||||||
|
"""
|
||||||
if not HAS_REEDSOLO:
|
if not HAS_REEDSOLO:
|
||||||
return data # No decoding if reedsolo not available
|
return data
|
||||||
rs = RSCodec(RS_NSYM)
|
rs = RSCodec(RS_NSYM)
|
||||||
try:
|
try:
|
||||||
decoded, _, errata_pos = rs.decode(data)
|
decoded, _, errata_pos = rs.decode(data)
|
||||||
if errata_pos:
|
if errata_pos:
|
||||||
pass # Errors were corrected
|
# Errors were found and corrected - RS earned its keep today
|
||||||
|
pass
|
||||||
return bytes(decoded)
|
return bytes(decoded)
|
||||||
except ReedSolomonError as e:
|
except ReedSolomonError as e:
|
||||||
|
# Too many errors - the image got mangled beyond repair
|
||||||
raise StegasooRSError(f"Image corrupted beyond repair: {e}") from e
|
raise StegasooRSError(f"Image corrupted beyond repair: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SAFE DCT FUNCTIONS
|
# SAFE DCT FUNCTIONS
|
||||||
# These create fresh arrays to avoid scipy memory corruption issues
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Story time: scipy's fftpack (the old DCT implementation) has memory issues
|
||||||
|
# when you process large images. We'd get random garbage in our output, or
|
||||||
|
# worse, segfaults. Turns out it was reusing internal buffers in unsafe ways.
|
||||||
|
#
|
||||||
|
# The fix? Be paranoid. Every single array operation creates a fresh copy.
|
||||||
|
# Is it slower? Yes. Does it work? Also yes. I'll take correct over fast.
|
||||||
|
#
|
||||||
|
# The newer scipy.fft module is better, but we still play it safe because
|
||||||
|
# not everyone has the latest scipy and I don't want debugging nightmares.
|
||||||
|
|
||||||
|
|
||||||
def _safe_dct2(block: np.ndarray) -> np.ndarray:
|
def _safe_dct2(block: np.ndarray) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Apply 2D DCT with memory isolation.
|
Apply 2D DCT (Discrete Cosine Transform) to an 8x8 block.
|
||||||
Creates a completely fresh array to avoid heap corruption.
|
|
||||||
|
The DCT converts spatial data (pixel values) into frequency data
|
||||||
|
(how much of each frequency component is present). It's the heart
|
||||||
|
of JPEG compression.
|
||||||
|
|
||||||
|
We do it row-by-row and column-by-column with fresh arrays each time
|
||||||
|
because scipy's built-in dct2 can corrupt memory on large batches.
|
||||||
|
Paranoid? Yes. Necessary? Also yes.
|
||||||
"""
|
"""
|
||||||
# Create a brand new array (not a view)
|
# Create a brand new array (not a view) - paranoia level: maximum
|
||||||
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
|
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
|
||||||
|
|
||||||
# First DCT on columns (transpose -> DCT rows -> transpose back)
|
# 2D DCT = 1D DCT on rows, then 1D DCT on columns (separable transform)
|
||||||
|
# First pass: DCT each column
|
||||||
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
|
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
|
||||||
for i in range(BLOCK_SIZE):
|
for i in range(BLOCK_SIZE):
|
||||||
col = np.array(safe_block[:, i], dtype=np.float64, copy=True)
|
col = np.array(safe_block[:, i], dtype=np.float64, copy=True)
|
||||||
temp[:, i] = dct(col, norm="ortho")
|
temp[:, i] = dct(col, norm="ortho") # ortho normalization for symmetry
|
||||||
|
|
||||||
# Second DCT on rows
|
# Second pass: DCT each row of the result
|
||||||
result = np.zeros_like(temp, dtype=np.float64, order="C")
|
result = np.zeros_like(temp, dtype=np.float64, order="C")
|
||||||
for i in range(BLOCK_SIZE):
|
for i in range(BLOCK_SIZE):
|
||||||
row = np.array(temp[i, :], dtype=np.float64, copy=True)
|
row = np.array(temp[i, :], dtype=np.float64, copy=True)
|
||||||
@@ -276,19 +374,22 @@ def _safe_dct2(block: np.ndarray) -> np.ndarray:
|
|||||||
|
|
||||||
def _safe_idct2(block: np.ndarray) -> np.ndarray:
|
def _safe_idct2(block: np.ndarray) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Apply 2D inverse DCT with memory isolation.
|
Apply 2D inverse DCT - convert frequency data back to pixels.
|
||||||
Creates a completely fresh array to avoid heap corruption.
|
|
||||||
|
After we've embedded our secret bits in the DCT coefficients,
|
||||||
|
we need to convert back to pixel values. This is the reverse
|
||||||
|
of _safe_dct2.
|
||||||
|
|
||||||
|
Same paranoid memory handling because same paranoid developer.
|
||||||
"""
|
"""
|
||||||
# Create a brand new array (not a view)
|
|
||||||
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
|
safe_block = np.array(block, dtype=np.float64, copy=True, order="C")
|
||||||
|
|
||||||
# First IDCT on rows
|
# Inverse is the same idea: IDCT rows, then IDCT columns
|
||||||
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
|
temp = np.zeros_like(safe_block, dtype=np.float64, order="C")
|
||||||
for i in range(BLOCK_SIZE):
|
for i in range(BLOCK_SIZE):
|
||||||
row = np.array(safe_block[i, :], dtype=np.float64, copy=True)
|
row = np.array(safe_block[i, :], dtype=np.float64, copy=True)
|
||||||
temp[i, :] = idct(row, norm="ortho")
|
temp[i, :] = idct(row, norm="ortho")
|
||||||
|
|
||||||
# Second IDCT on columns
|
|
||||||
result = np.zeros_like(temp, dtype=np.float64, order="C")
|
result = np.zeros_like(temp, dtype=np.float64, order="C")
|
||||||
for i in range(BLOCK_SIZE):
|
for i in range(BLOCK_SIZE):
|
||||||
col = np.array(temp[:, i], dtype=np.float64, copy=True)
|
col = np.array(temp[:, i], dtype=np.float64, copy=True)
|
||||||
@@ -348,8 +449,25 @@ def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarra
|
|||||||
|
|
||||||
|
|
||||||
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
|
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
|
||||||
|
"""
|
||||||
|
Embed a single bit into a DCT coefficient using QIM.
|
||||||
|
|
||||||
|
QIM (Quantization Index Modulation) is smarter than simple LSB flipping.
|
||||||
|
Instead of just changing the last bit, we round to a quantization grid
|
||||||
|
and use odd/even to encode 0/1.
|
||||||
|
|
||||||
|
Why is this better?
|
||||||
|
- More robust to noise (small changes don't flip the bit)
|
||||||
|
- Works naturally with JPEG's own quantization
|
||||||
|
- The change is spread across the coefficient's magnitude
|
||||||
|
|
||||||
|
Visual example (quant_step=25):
|
||||||
|
- Coef = 73, want bit=0 -> round to 75 (75/25=3, 3%2=1) -> nudge to 50 (50/25=2, 2%2=0)
|
||||||
|
- Coef = 73, want bit=1 -> round to 75 (75/25=3, 3%2=1) -> already odd, keep at 75
|
||||||
|
"""
|
||||||
quantized = round(coef / quant_step)
|
quantized = round(coef / quant_step)
|
||||||
if (quantized % 2) != bit:
|
if (quantized % 2) != bit:
|
||||||
|
# Need to flip even<->odd. Nudge in the direction that's closest.
|
||||||
if quantized % 2 == 0 and bit == 1:
|
if quantized % 2 == 0 and bit == 1:
|
||||||
quantized += 1 if coef >= quantized * quant_step else -1
|
quantized += 1 if coef >= quantized * quant_step else -1
|
||||||
elif quantized % 2 == 1 and bit == 0:
|
elif quantized % 2 == 1 and bit == 0:
|
||||||
@@ -358,13 +476,35 @@ def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) ->
|
|||||||
|
|
||||||
|
|
||||||
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
|
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
|
||||||
|
"""
|
||||||
|
Extract a bit from a DCT coefficient.
|
||||||
|
|
||||||
|
The inverse of _embed_bit_in_coeff. We round to the quantization grid
|
||||||
|
and check if it's odd (1) or even (0).
|
||||||
|
|
||||||
|
This is why QIM is robust: small noise in the coefficient usually
|
||||||
|
doesn't change which grid point we round to.
|
||||||
|
"""
|
||||||
quantized = round(coef / quant_step)
|
quantized = round(coef / quant_step)
|
||||||
return int(quantized % 2)
|
return int(quantized % 2)
|
||||||
|
|
||||||
|
|
||||||
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
|
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
|
||||||
|
"""
|
||||||
|
Generate a pseudo-random order for processing blocks.
|
||||||
|
|
||||||
|
This is crucial for security - if we just went left-to-right, top-to-bottom,
|
||||||
|
anyone could find the message by checking blocks in order. Instead, we
|
||||||
|
use a keyed shuffle so only someone with the same seed can find the data.
|
||||||
|
|
||||||
|
The seed comes from the crypto layer (derived from passphrase + photo + pin),
|
||||||
|
so the block order is effectively part of the encryption.
|
||||||
|
"""
|
||||||
|
# Use SHA-256 to expand the seed into randomness
|
||||||
hash_bytes = hashlib.sha256(seed).digest()
|
hash_bytes = hashlib.sha256(seed).digest()
|
||||||
|
# Seed numpy's RNG (we use RandomState for reproducibility across versions)
|
||||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big"))
|
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big"))
|
||||||
|
# Fisher-Yates shuffle
|
||||||
order = list(range(num_blocks))
|
order = list(range(num_blocks))
|
||||||
rng.shuffle(order)
|
rng.shuffle(order)
|
||||||
return order
|
return order
|
||||||
@@ -393,14 +533,28 @@ def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_
|
|||||||
|
|
||||||
|
|
||||||
def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Convert RGB to YCbCr color space.
|
||||||
|
|
||||||
|
YCbCr separates brightness (Y) from color (Cb=blue-ish, Cr=red-ish).
|
||||||
|
This is what JPEG uses internally, and it's great for us because:
|
||||||
|
- Human eyes are WAY more sensitive to brightness than color
|
||||||
|
- We can hide data in Y without it being as visible
|
||||||
|
- Cb/Cr are often subsampled (4:2:0) so Y has more capacity anyway
|
||||||
|
|
||||||
|
The coefficients here are from ITU-R BT.601 - the standard for video.
|
||||||
|
"""
|
||||||
R = rgb[:, :, 0].astype(np.float64)
|
R = rgb[:, :, 0].astype(np.float64)
|
||||||
G = rgb[:, :, 1].astype(np.float64)
|
G = rgb[:, :, 1].astype(np.float64)
|
||||||
B = rgb[:, :, 2].astype(np.float64)
|
B = rgb[:, :, 2].astype(np.float64)
|
||||||
|
|
||||||
|
# Y = luminance (brightness). Green contributes most because eyes are most sensitive to it.
|
||||||
Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order="C")
|
Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order="C")
|
||||||
|
# Cb = blue-difference chroma (centered at 128)
|
||||||
Cb = np.array(
|
Cb = np.array(
|
||||||
128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order="C"
|
128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order="C"
|
||||||
)
|
)
|
||||||
|
# Cr = red-difference chroma (centered at 128)
|
||||||
Cr = np.array(
|
Cr = np.array(
|
||||||
128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order="C"
|
128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order="C"
|
||||||
)
|
)
|
||||||
@@ -409,6 +563,12 @@ def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|||||||
|
|
||||||
|
|
||||||
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
|
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Convert YCbCr back to RGB.
|
||||||
|
|
||||||
|
After embedding in the Y channel, we need to reconstruct RGB for display.
|
||||||
|
The Cb/Cr channels are unchanged - we only touched luminance.
|
||||||
|
"""
|
||||||
R = Y + 1.402 * (Cr - 128)
|
R = Y + 1.402 * (Cr - 128)
|
||||||
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
|
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
|
||||||
B = Y + 1.772 * (Cb - 128)
|
B = Y + 1.772 * (Cb - 128)
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo Steganography Functions (v3.2.0)
|
Stegasoo Steganography Functions (v3.2.0)
|
||||||
|
|
||||||
LSB and DCT embedding modes with pseudo-random pixel/coefficient selection.
|
This is the core embedding/extraction module. Two modes available:
|
||||||
|
|
||||||
Changes in v3.0:
|
LSB (Least Significant Bit) Mode:
|
||||||
- DCT domain embedding mode (requires scipy)
|
- Classic steganography technique - hide bits in the least significant bit of pixel values
|
||||||
- embed_mode parameter for encode/decode
|
- Works on any image, outputs lossless PNG/BMP
|
||||||
- Auto-detection of embedding mode
|
- Higher capacity than DCT, but destroyed by JPEG compression
|
||||||
- Comparison utilities
|
- Great for: high-capacity needs, lossless workflows
|
||||||
|
|
||||||
Changes in v3.0.1:
|
DCT Mode (see dct_steganography.py):
|
||||||
- dct_output_format parameter for DCT mode ('png' or 'jpeg')
|
- Hides data in frequency-domain coefficients
|
||||||
- dct_color_mode parameter for DCT mode ('grayscale' or 'color')
|
- Survives some image processing, works with JPEG
|
||||||
|
- Lower capacity but more robust
|
||||||
|
- Great for: JPEG images, robustness needs
|
||||||
|
|
||||||
Changes in v3.2.0:
|
Both modes use pseudo-random pixel/coefficient selection based on a key.
|
||||||
- Fixed HEADER_OVERHEAD constant (65 bytes, not 104 - date field removed)
|
Without the key, you don't know where to look - security through obscurity
|
||||||
- Updated ENCRYPTION_OVERHEAD calculation
|
PLUS actual encryption of the payload.
|
||||||
|
|
||||||
|
v3.0: Added DCT mode with scipy
|
||||||
|
v3.0.1: DCT output format options (PNG/JPEG, grayscale/color)
|
||||||
|
v3.2.0: Fixed overhead calculations after removing date field
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -83,24 +89,31 @@ EXT_TO_FORMAT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# OVERHEAD CONSTANTS (v4.0.0 - Updated for channel key support)
|
# OVERHEAD CONSTANTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# v4.0.0 Header format (with flags byte for channel key indicator):
|
|
||||||
# Magic: 4 bytes (\x89ST3)
|
|
||||||
# Version: 1 byte (5 for v4.0.0)
|
|
||||||
# Flags: 1 byte (bit 0 = has channel key)
|
|
||||||
# Salt: 32 bytes
|
|
||||||
# IV: 12 bytes
|
|
||||||
# Tag: 16 bytes
|
|
||||||
# -----------------
|
|
||||||
# Total: 66 bytes
|
|
||||||
#
|
#
|
||||||
# v3.2.0 had 65 bytes (no flags byte)
|
# Every stego image has some overhead before the actual payload:
|
||||||
# v3.1.0 had date field (10 bytes + 1 byte length) = 76 bytes header
|
#
|
||||||
|
# The encrypted message format (v4.0.0):
|
||||||
|
# ┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
# │ \x89ST3 │ v5 │ flags │ salt (32) │ iv (12) │ tag (16) │ ... │
|
||||||
|
# │ magic │ ver│ │ │ │ │ data│
|
||||||
|
# └─────────────────────────────────────────────────────────────────┘
|
||||||
|
# 4 bytes 1 1 32 12 16 var
|
||||||
|
#
|
||||||
|
# Plus LSB embedding adds a 4-byte length prefix so we know where to stop.
|
||||||
|
#
|
||||||
|
# History of overhead sizes (in case you're debugging old images):
|
||||||
|
# - v3.1.0: 76 bytes (had date field - 10+1 bytes)
|
||||||
|
# - v3.2.0: 65 bytes (removed date, simpler)
|
||||||
|
# - v4.0.0: 66 bytes (added flags byte for channel key)
|
||||||
|
|
||||||
HEADER_OVERHEAD = 66 # v4.0.0: Magic + version + flags + salt + iv + tag
|
HEADER_OVERHEAD = 66 # What the crypto layer adds to any message
|
||||||
LENGTH_PREFIX = 4 # 4 bytes for payload length in LSB embedding
|
LENGTH_PREFIX = 4 # We prepend the payload length for LSB extraction
|
||||||
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # 70 bytes total
|
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # Total: 70 bytes
|
||||||
|
|
||||||
|
# That 70 bytes is your minimum image capacity requirement.
|
||||||
|
# A tiny 100x100 image gives you ~3750 bytes capacity, minus 70 = ~3680 usable.
|
||||||
|
|
||||||
# DCT output format options (v3.0.1)
|
# DCT output format options (v3.0.1)
|
||||||
DCT_OUTPUT_PNG = "png"
|
DCT_OUTPUT_PNG = "png"
|
||||||
@@ -456,6 +469,20 @@ def compare_modes(image_data: bytes) -> dict:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PIXEL INDEX GENERATION
|
# PIXEL INDEX GENERATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# The key insight: we don't hide data in sequential pixels (that's easy to find).
|
||||||
|
# Instead, we scatter the data across pseudo-random pixel locations.
|
||||||
|
#
|
||||||
|
# The pixel selection key (derived from passphrase + photo + pin) determines
|
||||||
|
# WHICH pixels get modified. Without the key, an attacker would have to:
|
||||||
|
# 1. Know we're using LSB steganography
|
||||||
|
# 2. Try every possible subset of pixels
|
||||||
|
# 3. Decrypt the result (which they also can't do without the key)
|
||||||
|
#
|
||||||
|
# We use ChaCha20 as a CSPRNG (Cryptographically Secure PRNG). It's:
|
||||||
|
# - Fast (faster than AES-CTR on most CPUs)
|
||||||
|
# - Deterministic (same key = same sequence, needed for extraction)
|
||||||
|
# - Secure (can't predict the sequence without the key)
|
||||||
|
|
||||||
|
|
||||||
@debug.time
|
@debug.time
|
||||||
@@ -463,8 +490,13 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
|
|||||||
"""
|
"""
|
||||||
Generate pseudo-random pixel indices for embedding.
|
Generate pseudo-random pixel indices for embedding.
|
||||||
|
|
||||||
Uses ChaCha20 as a CSPRNG seeded by the key to deterministically
|
This is the "where do we hide the bits?" function. We use ChaCha20
|
||||||
select which pixels will hold hidden data.
|
to generate a deterministic sequence of pixel indices that only
|
||||||
|
someone with the same key can reproduce.
|
||||||
|
|
||||||
|
Two strategies based on how much of the image we're using:
|
||||||
|
- >= 50% capacity: Full Fisher-Yates shuffle (sample without replacement)
|
||||||
|
- < 50% capacity: Direct random sampling (faster, same result)
|
||||||
"""
|
"""
|
||||||
debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}")
|
debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}")
|
||||||
debug.validate(num_pixels > 0, f"Number of pixels must be positive, got {num_pixels}")
|
debug.validate(num_pixels > 0, f"Number of pixels must be positive, got {num_pixels}")
|
||||||
@@ -475,6 +507,8 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
|
|||||||
|
|
||||||
debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels")
|
debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels")
|
||||||
|
|
||||||
|
# Strategy 1: Full shuffle when we need a lot of pixels
|
||||||
|
# Fisher-Yates shuffle is O(n) and gives us perfect random sampling
|
||||||
if num_needed >= num_pixels // 2:
|
if num_needed >= num_pixels // 2:
|
||||||
debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)")
|
debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)")
|
||||||
nonce = b"\x00" * 16
|
nonce = b"\x00" * 16
|
||||||
@@ -482,8 +516,10 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
|
|||||||
encryptor = cipher.encryptor()
|
encryptor = cipher.encryptor()
|
||||||
|
|
||||||
indices = list(range(num_pixels))
|
indices = list(range(num_pixels))
|
||||||
|
# Get enough random bytes to do the shuffle
|
||||||
random_bytes = encryptor.update(b"\x00" * (num_pixels * 4))
|
random_bytes = encryptor.update(b"\x00" * (num_pixels * 4))
|
||||||
|
|
||||||
|
# Fisher-Yates shuffle - swap each element with a random earlier element
|
||||||
for i in range(num_pixels - 1, 0, -1):
|
for i in range(num_pixels - 1, 0, -1):
|
||||||
j_bytes = random_bytes[(num_pixels - 1 - i) * 4 : (num_pixels - i) * 4]
|
j_bytes = random_bytes[(num_pixels - 1 - i) * 4 : (num_pixels - i) * 4]
|
||||||
j = int.from_bytes(j_bytes, "big") % (i + 1)
|
j = int.from_bytes(j_bytes, "big") % (i + 1)
|
||||||
@@ -493,14 +529,17 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
|
|||||||
debug.print(f"Generated {len(selected)} indices via shuffle")
|
debug.print(f"Generated {len(selected)} indices via shuffle")
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
# Strategy 2: Direct sampling when we need fewer pixels
|
||||||
|
# Generate random indices until we have enough unique ones
|
||||||
debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)")
|
debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)")
|
||||||
selected = []
|
selected = []
|
||||||
used = set()
|
used = set() # Track which pixels we've already picked
|
||||||
|
|
||||||
nonce = b"\x00" * 16
|
nonce = b"\x00" * 16
|
||||||
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
|
||||||
encryptor = cipher.encryptor()
|
encryptor = cipher.encryptor()
|
||||||
|
|
||||||
|
# Pre-generate 2x the bytes we think we'll need (for collision handling)
|
||||||
bytes_needed = (num_needed * 2) * 4
|
bytes_needed = (num_needed * 2) * 4
|
||||||
random_bytes = encryptor.update(b"\x00" * bytes_needed)
|
random_bytes = encryptor.update(b"\x00" * bytes_needed)
|
||||||
|
|
||||||
@@ -514,8 +553,9 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
|
|||||||
used.add(idx)
|
used.add(idx)
|
||||||
selected.append(idx)
|
selected.append(idx)
|
||||||
else:
|
else:
|
||||||
collisions += 1
|
collisions += 1 # Birthday paradox in action
|
||||||
|
|
||||||
|
# Edge case: ran out of pre-generated bytes (very high collision rate)
|
||||||
if len(selected) < num_needed:
|
if len(selected) < num_needed:
|
||||||
debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
|
debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
|
||||||
extra_needed = num_needed - len(selected)
|
extra_needed = num_needed - len(selected)
|
||||||
@@ -539,6 +579,23 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# EMBEDDING FUNCTIONS
|
# EMBEDDING FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# The actual bit-hiding magic happens here. LSB embedding is conceptually simple:
|
||||||
|
#
|
||||||
|
# Original pixel RGB: (142, 87, 201)
|
||||||
|
# In binary: (10001110, 01010111, 11001001)
|
||||||
|
# ^ ^ ^
|
||||||
|
# These are the LSBs (least significant bits)
|
||||||
|
#
|
||||||
|
# To hide the bits [1, 0, 1]:
|
||||||
|
# Modified pixel RGB: (10001111, 01010110, 11001001) = (143, 86, 201)
|
||||||
|
# ^ ^ ^
|
||||||
|
# Changed! Changed! Already 1, no change needed
|
||||||
|
#
|
||||||
|
# The human eye can't see the difference between 142 and 143.
|
||||||
|
# But we've hidden 3 bits of secret data in one pixel.
|
||||||
|
#
|
||||||
|
# With a 1000x1000 image: 1 million pixels * 3 channels = 3 million bits = 375 KB!
|
||||||
|
|
||||||
|
|
||||||
@debug.time
|
@debug.time
|
||||||
|
|||||||
Reference in New Issue
Block a user