25 Commits

Author SHA1 Message Date
Aaron D. Lee
28b539bcd9 Remove instance/ from tracking, fix ruff lint errors
Some checks failed
Release / test (push) Failing after 30s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
Security:
- Remove instance/.secret_key and instance/stegasoo.db from git
- Add instance/ to .gitignore (was only ignoring frontends/web/instance/)

Lint fixes:
- Remove unused imports in temp_storage.py (os, shutil)
- Sort imports and fix f-string placeholders in cli.py

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 14:28:07 -05:00
Aaron D. Lee
6b82069dc8 Rename Pi tarball to stegasoo-rpi-runtime-env-arm64.tar.zst
More descriptive name for the pre-built pyenv + venv bundle.
Updated all scripts and docs to use new filename.
Also bumped PREBUILT_URL to v4.1.5.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:21:43 -05:00
Aaron D. Lee
52e1a3dfbf Release v4.1.5: Clean up docs, update release notes
- Remove old PLAN-4.1.x.md and RELEASE-4.1.1.md files
- Update RELEASE_NOTES.md for v4.1.5
- Highlights: dev docs, pull-image.sh auto-resize, 16GB images

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:17:25 -05:00
Aaron D. Lee
4a27d0c182 Add host-requirements.txt for Pi scripts
Lists all host machine dependencies needed to run:
- pull-image.sh (parted, e2fsprogs, zstd, zip, bc, pv)
- flash-image.sh (unzip, zstd, pv, jq)
- kickoff-pi-test.sh (sshpass, avahi-utils)

Includes quick install command for Debian/Ubuntu.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:08:54 -05:00
Aaron D. Lee
36931518ce Docs: Update Pi image workflow, 16GB+ requirement
- rpi/README.md: 16GB+ SD card requirement, use pull-image.sh
- rpi/BUILD_IMAGE.md: Simplified steps using pull-image.sh
- pull-image.sh: Optional .zst.zip wrapper for GitHub releases

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 01:01:24 -05:00
Aaron D. Lee
f79c63428b pull-image: Auto-resize rootfs to 16GB before pull
- Unmounts and resizes partition to exactly 16GB
- Handles both shrinking (large cards) and expanding (small cards)
- Disables Pi OS auto-expand service
- Consistent image size regardless of source SD card
- 16GB = minimum disk requirement = image size

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:58:55 -05:00
Aaron D. Lee
cc29de4200 Add update instructions to Pi README
Documents easy 3-command update process for existing installations.
Most updates just need git pull + systemctl restart since we use
editable pip installs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:16:30 -05:00
Aaron D. Lee
c14f3f75cb Bump version to 4.1.5
Developer documentation release:
- Educational comments throughout core modules
- Pi test automation script
- MOTD improvements with dynamic emojis
- v4.2 wishlist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:08:59 -05:00
Aaron D. Lee
aa99a258f4 Document CLI and Web UI architecture for future devs
CLI module now explains:
- Click command group hierarchy (tree diagram)
- JSON output pattern for scriptability
- Secure input handling (hide_input, confirmation_prompt)
- Dry-run mode pattern
- Batch processing with variadic args and progress callbacks

Web UI now explains:
- Flask architecture overview with ASCII diagram
- Subprocess isolation pattern (why we run stegasoo in subprocesses)
- Async job management with polling flow diagram
- Context processors for template globals
- Secret key persistence for session survival
- Environment-based configuration (12-factor style)

If you're reading this code trying to learn Flask/Click patterns,
these comments should actually teach you something useful.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:58:59 -05:00
Aaron D. Lee
93420704e8 Add personality to the codebase (comments that don't suck)
The code now explains itself like a friend teaching you crypto:
- DCT module: Why mid-frequency? What's QIM? Why is scipy being weird?
- Steganography: How LSB actually works with visual examples
- Crypto: The multi-factor security model with ASCII art diagrams

Also adds kickoff-pi-test.sh - one command to flash, wait, setup, test.
No more manual steps between flashing and seeing if it works.

Comments should teach, not just describe. If you're reading the code
trying to understand how DCT steganography works, these comments
should actually help. Novel concept, I know.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:53:26 -05:00
Aaron D. Lee
6e4eb5464e Fix MOTD: Remove escaped vars, shorten Debian boilerplate
- Fix TEMP_NUM/TEMP_EMOJI variables (no escaping in quoted heredoc)
- Shorten /etc/motd to one-liner with license path reference

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:14:19 -05:00
Aaron D. Lee
d04670e352 MOTD: Use globe emoji for URL
🚀 Stegasoo running     🌐 https://...

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:55:47 -05:00
Aaron D. Lee
fda1cdad51 MOTD: Dynamic temp emoji based on temperature
- 🧊 ice cube: < 50°C (cool)
- 😎 cool face: 50-70°C (warm)
- 🔥 fire: > 70°C (hot)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:54:52 -05:00
Aaron D. Lee
b48ccc5d16 MOTD: Adjust thermometer spacing
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:51:27 -05:00
Aaron D. Lee
15ed63cafa MOTD: Use link emoji for URL
🚀 Stegasoo running    🔗 https://...

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:48:17 -05:00
Aaron D. Lee
869d7ee8e3 MOTD: Replace bullet with rocket emoji
🚀 Stegasoo running    🚀 https://...

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:47:55 -05:00
Aaron D. Lee
3ee8c1d22a Fix MOTD temperature line alignment
Adjust spacing between MHz and thermometer emoji for proper
column alignment in terminal.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:46:22 -05:00
Aaron D. Lee
b96564358a Add v4.2 wishlist with GPU decode idea
Blue sky document for capturing future feature ideas.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:41 -05:00
Aaron D. Lee
01afb3da66 Refactor: Extract banner template to shared banner.sh
- Create rpi/banner.sh with print_banner, print_gradient_line,
  print_logo, print_starfield, print_complete_banner functions
- Update setup.sh to source banner.sh (with inline fallback for curl)
- Update first-boot-wizard.sh to use banner functions
- Update sanitize-for-image.sh to use banner functions
- Fix MOTD thermometer spacing alignment

Single source of truth for ASCII banner styling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:12 -05:00
Aaron D. Lee
a98df5f9a0 SSL cert: Use actual hostname instead of 'localhost' default
When STEGASOO_HOSTNAME env var is not set, use socket.gethostname()
to get the actual machine hostname for certificate generation.

This ensures the cert includes proper hostname.local SAN.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:16:23 -05:00
Aaron D. Lee
70da348bce SSL certs: Include .local hostname and local IPs in SANs
The auto-generated SSL certificate now includes:
- hostname.local for mDNS browser access
- All detected local network IPs

This fixes browser access via stegasoo.local when HTTPS is enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:15:24 -05:00
Aaron D. Lee
90ba8543a7 Remove trailing period from wizard intro 2026-01-06 21:56:37 -05:00
Aaron D. Lee
da3aea992c Polish first-boot-wizard intro text formatting
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:55:17 -05:00
Aaron D. Lee
ae47ff4932 Show mDNS hostname alongside IP in RPi scripts
- flash-stock-img.sh: Show stegasoo.local URL and SSH command
- setup.sh: Display both .local and IP URLs
- first-boot-wizard.sh: Prioritize .local URL, IP as fallback
- Clean up service file path display

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:52:51 -05:00
Aaron D. Lee
eb16eb1db2 v4.1.5: Accordion UI, webcam QR scanning, Pi image fix
Encode/Decode UI:
- New accordion layout with 3 steps (encode) / 2 steps (decode)
- Gold step numbers with checkmarks on completion
- Dynamic right-aligned summaries as fields are filled
- Subtle gradient highlight on active accordion step

Webcam QR Scanning:
- Camera button for RSA key QR codes on encode/decode pages
- Camera button for channel key scanning
- 3-2-1 countdown capture for dense QR codes
- Proper scanner stop/restart on retry
- Backend decompression for STEGASOO-Z: compressed keys

RSA Key Print:
- Removed identifying text from QR print output
- Now prints plain QR code for discretion

Pi Image Script:
- Fixed 16GB resize to detect expand vs shrink
- Fresh images now properly EXPAND to 16GB
- Already-expanded images properly SHRINK to 16GB

UI Polish:
- Removed PIN helper text for compactness
- Fixed QR drop zone centering
- Fixed decode page element IDs for JS

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:31:11 -05:00
39 changed files with 3137 additions and 2653 deletions

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {
e.preventDefault();
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) { if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
e.preventDefault();
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);
}); });
}, },

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

@@ -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,13 +154,13 @@
<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">
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6> <h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
</div> </div>
<label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label> <label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label>
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;" <div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))" onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))"
@@ -129,13 +170,13 @@
<a href="/decode" class="btn btn-outline-light w-100"> <a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another <i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a> </a>
{% elif decoded_file %} {% elif decoded_file %}
<!-- File Result --> <!-- File Result -->
<div class="alert alert-success"> <div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6> <h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6>
</div> </div>
<div class="text-center mb-4"> <div class="text-center mb-4">
<i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i> <i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i>
<h5 class="mt-3">{{ filename }}</h5> <h5 class="mt-3">{{ filename }}</h5>
@@ -144,345 +185,270 @@
<small class="text-muted">Type: {{ mime_type }}</small> <small class="text-muted">Type: {{ mime_type }}</small>
{% endif %} {% endif %}
</div> </div>
<a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3"> <a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3">
<i class="bi bi-download me-2"></i>Download File <i class="bi bi-download me-2"></i>Download File
</a> </a>
<div class="alert alert-warning small"> <div class="alert alert-warning small">
<i class="bi bi-clock me-1"></i> <i class="bi bi-clock me-1"></i>
<strong>File expires in 5 minutes.</strong> Download now. <strong>File expires in 5 minutes.</strong> Download now.
</div> </div>
<a href="/decode" class="btn btn-outline-light w-100"> <a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another <i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a> </a>
{% 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="row">
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-image me-1"></i> Reference Photo
</label>
<div class="drop-zone scan-container" id="refDropZone">
<input type="file" name="reference_photo" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none" id="refPreview">
<!-- Scan overlay elements -->
<div class="scan-overlay">
<div class="scan-grid"></div>
<div class="scan-line"></div>
</div>
<!-- Corner brackets (shown after scan) -->
<div class="scan-corners">
<div class="scan-corner tl"></div>
<div class="scan-corner tr"></div>
<div class="scan-corner bl"></div>
<div class="scan-corner br"></div>
</div>
<!-- Data panel (shown after scan) -->
<div class="scan-data-panel">
<div class="scan-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="refFileName">image.jpg</span>
</div>
<div class="scan-data-row">
<span class="scan-status-badge">Hash Acquired</span>
<span class="scan-data-value" id="refFileSize">--</span>
</div>
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
</div>
</div>
<div class="form-text">
The same reference photo used for encoding
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
</label>
<div class="drop-zone pixel-container" id="stegoDropZone">
<input type="file" name="stego_image" accept="image/*" required>
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click to browse</span>
</div>
<img class="drop-zone-preview d-none" id="stegoPreview">
<!-- Pixel blocks overlay - populated by JS -->
<div class="pixel-blocks"></div>
<!-- Pixel scan line -->
<div class="pixel-scan-line"></div>
<!-- Corner brackets -->
<div class="pixel-corners">
<div class="pixel-corner tl"></div>
<div class="pixel-corner tr"></div>
<div class="pixel-corner bl"></div>
<div class="pixel-corner br"></div>
</div>
<!-- Data panel -->
<div class="pixel-data-panel">
<div class="pixel-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span id="stegoFileName">image.png</span>
</div>
<div class="pixel-data-row">
<span class="pixel-status-badge">Stego Loaded</span>
<span class="pixel-data-value" id="stegoFileSize">--</span>
</div>
<div class="pixel-dimensions" id="stegoDims">-- × -- px</div>
</div>
</div>
<div class="form-text">
The image containing the hidden message/file
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">
<i class="bi bi-chat-quote me-1"></i> Passphrase
</label>
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
placeholder="e.g., correct horse battery staple" required>
<div class="form-text">
The passphrase used during encoding (typically 4 words)
</div>
</div>
<hr class="my-4">
<h6 class="text-muted mb-3">
SECURITY FACTORS
<span class="text-warning small">(provide same factors used during encoding)</span>
</h6>
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle --> <div class="accordion step-accordion" id="decodeAccordion">
<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"> STEP 1: IMAGES & MODE
<i class="bi bi-qr-code me-1"></i>QR Code ================================================================ -->
</label> <div class="accordion-item">
</div> <h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
<!-- .pem File Input --> <span class="step-title">
<div id="rsaFileSection"> <span class="step-number" id="stepImagesNumber">1</span>
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file"> <i class="bi bi-images me-1"></i> Images & Mode
</div> </span>
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row">
<div class="col-md-6 mb-3">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button> </button>
</div> </h2>
<div class="form-text">If PIN was used during encoding</div> <div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
</div> <div class="accordion-body">
</div>
<div class="col-md-6 mb-3"> <div class="row">
<div class="security-box h-100"> <div class="col-md-6 mb-3">
<label class="form-label"> <label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel <i class="bi bi-image me-1"></i> Reference Photo
<span class="badge bg-info ms-1">v4.1</span> </label>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a> <div class="drop-zone scan-container" id="refDropZone">
</label> <input type="file" name="reference_photo" accept="image/*" required id="refPhotoInput">
<div class="drop-zone-label">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop image or click</span>
</div>
<img class="drop-zone-preview d-none" id="refPreview">
<div class="scan-overlay"><div class="scan-grid"></div><div class="scan-line"></div></div>
<div class="scan-corners">
<div class="scan-corner tl"></div><div class="scan-corner tr"></div>
<div class="scan-corner bl"></div><div class="scan-corner br"></div>
</div>
<div class="scan-data-panel">
<div class="scan-data-filename"><i class="bi bi-check-circle-fill"></i><span id="refFileName">image.jpg</span></div>
<div class="scan-data-row"><span class="scan-status-badge">Hash Acquired</span><span class="scan-data-value" id="refFileSize">--</span></div>
</div>
</div>
<div class="form-text">Same reference photo used for encoding</div>
</div>
<select class="form-select" name="channel_key" id="channelSelectDec"> <div class="col-md-6 mb-3">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option> <label class="form-label">
<option value="none">Public</option> <i class="bi bi-file-earmark-image me-1"></i> Stego Image
{% if saved_channel_keys %} </label>
<optgroup label="Saved Keys"> <div class="drop-zone pixel-container" id="stegoDropZone">
{% for key in saved_channel_keys %} <input type="file" name="stego_image" accept="image/*" required id="stegoInput">
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option> <div class="drop-zone-label">
{% endfor %} <i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
</optgroup> <span class="text-muted">Drop image or click</span>
{% endif %} </div>
<option value="custom">Custom...</option> <img class="drop-zone-preview d-none" id="stegoPreview">
</select> <div class="pixel-blocks"></div>
<div class="pixel-scan-line"></div>
<div class="pixel-corners">
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
</div>
<div class="pixel-data-panel">
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
</div>
</div>
<div class="form-text">Image containing the hidden message</div>
</div>
</div>
<!-- Server channel indicator (compact) --> <!-- Extraction Mode -->
<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 '' }}"> <label class="form-label"><i class="bi bi-cpu me-1"></i> Extraction Mode</label>
{% if channel_configured and channel_fingerprint %} <div class="d-flex gap-2 mb-2">
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInputDec">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInputDec">
</div>
</div>
</div>
<!-- ================================================================
ADVANCED OPTIONS (v3.0) - Extraction Mode
================================================================ -->
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Advanced Options
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
</a>
<div class="collapse" id="advancedOptionsDec">
<div class="card card-body mt-2 bg-dark border-secondary">
<!-- Extraction Mode Selection -->
<div class="mb-0">
<label class="form-label">
<i class="bi bi-cpu me-1"></i> Extraction Mode
<span class="badge bg-info ms-1">v3.0</span>
</label>
<div class="d-flex gap-2">
<!-- Auto Mode -->
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto"> <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> <input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
<i class="bi bi-magic text-success"></i> <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> <span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
</label> </label>
<label class="mode-btn flex-fill" id="lsbModeCard" for="modeLsb">
<!-- LSB Mode --> <input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb">
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec"> <i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb"> <span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
<i class="bi bi-grid-3x3-gap text-primary"></i>
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
</label> </label>
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct">
<!-- DCT Mode --> <input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec"> <i class="bi bi-soundwave text-warning ms-2"></i>
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}> <span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
<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> </label>
</div> </div>
<div class="form-text">
<div class="form-text mt-2"> <i class="bi bi-lightbulb me-1"></i><strong>Auto</strong> tries LSB first, then DCT.
<i class="bi bi-lightbulb me-1"></i>
<strong>Auto</strong> tries LSB first, then DCT.
{% if not has_dct %}
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- ================================================================
STEP 2: SECURITY
================================================================ -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
<span class="step-title">
<span class="step-number" id="stepSecurityNumber">2</span>
<i class="bi bi-shield-lock me-1"></i> Security
</span>
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
</button>
</h2>
<div id="stepSecurity" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
<div class="accordion-body">
<!-- Passphrase -->
<div class="mb-3">
<label class="form-label"><i class="bi bi-chat-quote me-1"></i> Passphrase</label>
<input type="text" name="passphrase" class="form-control passphrase-input"
placeholder="e.g., apple forest thunder mountain" required id="passphraseInput">
<div class="form-text">The passphrase used during encoding</div>
</div>
<hr class="my-3 opacity-25">
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
<div class="row">
<!-- PIN -->
<div class="col-md-6 mb-2">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- Channel -->
<div class="col-md-6 mb-2">
<div class="security-box h-100">
<label class="form-label"><i class="bi bi-broadcast me-1"></i> Channel</label>
<select class="form-select form-select-sm" name="channel_key" id="channelSelectDec">
<option value="auto" selected>Auto{% if channel_configured %} (Server){% endif %}</option>
<option value="none">Public</option>
{% if saved_channel_keys %}
<optgroup label="Saved Keys">
{% for key in saved_channel_keys %}
<option value="{{ key.channel_key }}">{{ key.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="custom">Custom...</option>
</select>
</div>
</div>
</div>
<!-- Custom Channel Key -->
<div class="mb-3 d-none" id="channelCustomInputDec">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control form-control-sm font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" id="channelKeyInputDec">
<button class="btn btn-outline-secondary btn-sm" type="button" id="channelKeyScanDec" title="Scan QR"><i class="bi bi-camera"></i></button>
</div>
</div>
</div>
<!-- RSA Key -->
<div class="mb-3">
<div class="security-box">
<label class="form-label"><i class="bi bi-file-earmark-lock me-1"></i> RSA Key <span class="text-muted">(if used)</span></label>
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile"><i class="bi bi-file-earmark me-1"></i>.pem</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr"><i class="bi bi-qr-code me-1"></i>QR</label>
</div>
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
</div>
<div id="rsaQrSection" class="d-none d-flex flex-column">
<input type="hidden" name="rsa_key_pem" id="rsaKeyPem">
<div class="drop-zone p-2 w-100" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image</span>
</div>
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped">
</div>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2" id="rsaQrWebcam">
<i class="bi bi-camera me-1"></i>Scan with Camera
</button>
</div>
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput"><i class="bi bi-eye"></i></button>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn"> <!-- Submit Button -->
<i class="bi bi-unlock me-2"></i>Decode <div class="p-3">
</button> <button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
<i class="bi bi-unlock me-2"></i>Decode
</button>
</div>
</form> </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

View File

@@ -1 +0,0 @@
6a7378172fc0ec37143720f09a4ca34e83ec2409893aa8cd79ace5b78a64276c

Binary file not shown.

View File

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

View File

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

View File

@@ -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
View 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
}

View File

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

View File

@@ -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
sudo parted -s "$DEVICE" rm 2 # EXPANDING: partition first, then filesystem
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s echo "Current partition is smaller than 16GB - expanding..."
# Refresh partition table # Delete and recreate partition 2 with 16GB size
sudo partprobe "$DEVICE" echo "Expanding partition to 16GB..."
sleep 1 sudo parted -s "$DEVICE" rm 2
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
# Check and resize filesystem # Refresh partition table
echo "Checking filesystem..." sudo partprobe "$DEVICE"
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true sleep 2
echo "Resizing filesystem to fit partition..." # Expand filesystem to fill the new partition
sudo resize2fs "$ROOT_PART" echo "Expanding filesystem to fill partition..."
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
sudo resize2fs "$ROOT_PART"
else
# SHRINKING: filesystem first, then partition
echo "Current partition is larger than 16GB - shrinking..."
# Check and shrink filesystem first
echo "Checking filesystem..."
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
# Shrink filesystem to 15.5GB (leave room for partition overhead)
echo "Shrinking filesystem to 15500M..."
sudo resize2fs "$ROOT_PART" 15500M
# Delete and recreate partition 2 with 16GB size
echo "Shrinking partition to 16GB..."
sudo parted -s "$DEVICE" rm 2
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
# Refresh partition table
sudo partprobe "$DEVICE"
sleep 2
# Expand filesystem to fill the partition exactly
echo "Expanding filesystem to fill partition..."
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
sudo resize2fs "$ROOT_PART"
fi
# Verify and show result
echo "Verifying partition size..."
sudo parted -s "$DEVICE" unit GB print | grep "^ 2"
# Disable Pi OS auto-expand on first boot # 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
View 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
View 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

View File

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

View File

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

View File

@@ -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)
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}"
fi
fi fi
# Compact two-column layout
echo -e " 🚀 Stegasoo running 🌐 \033[0;33m$STEGASOO_URL\033[0m"
if [ -n "$CPU_MHZ" ] && [ -n "$CPU_TEMP" ]; then
# 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
else
TEMP_EMOJI="🌡"
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"

View File

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

View File

@@ -25,7 +25,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "4.1.3" __version__ = "4.1.5"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT

View File

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

View File

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

View File

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