Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f54f80214 | ||
|
|
1cd2656e60 | ||
|
|
ce728cec6e | ||
|
|
555735a4fd | ||
|
|
08b70043e4 | ||
|
|
d395e5731e | ||
|
|
110b160e68 | ||
|
|
b09f607d34 | ||
|
|
34ede3815f | ||
|
|
3b5ab41ce9 | ||
|
|
525bcec3c9 | ||
|
|
afc8c93923 | ||
|
|
38bef32750 | ||
|
|
4e3acfca20 | ||
|
|
2ebc42f2cd | ||
|
|
1e07630b49 | ||
|
|
67037ae196 | ||
|
|
5a68840725 | ||
|
|
ebc999b2b3 | ||
|
|
f46ef01f5f | ||
|
|
0d76780deb | ||
|
|
d34919e32f | ||
|
|
a4038589b0 | ||
|
|
db763f1464 | ||
|
|
27c5b08d41 | ||
|
|
28cb9bb9b3 | ||
|
|
889df881ba | ||
|
|
c058d116b8 | ||
|
|
fae86887e2 | ||
|
|
5e45b2c5c1 | ||
|
|
71088989f3 | ||
|
|
530e5debef | ||
|
|
3b062458e3 | ||
|
|
5e65035ca4 | ||
|
|
de9d1de881 | ||
|
|
8d90a888cf | ||
|
|
b0914778e3 | ||
|
|
7e5462ea6e | ||
|
|
e085a8ffe9 | ||
|
|
2d7fbd1e0d | ||
|
|
32842f6b73 | ||
|
|
3fd3204552 | ||
|
|
175362ce4c | ||
|
|
2ed108f3a0 | ||
|
|
167e1a6ff5 | ||
|
|
f2f3e2eefc | ||
|
|
5c685cba67 | ||
|
|
4e819b80cc | ||
|
|
ea86216648 | ||
|
|
8de5659fa6 | ||
|
|
de0bf2410d | ||
|
|
8b948d00a4 | ||
|
|
6d88453b69 | ||
|
|
ea57bdf302 | ||
|
|
55d54717f8 | ||
|
|
c0fe85ac83 | ||
|
|
e9e4d1aab9 | ||
|
|
1acb5a3dcc | ||
|
|
14a73c63ac | ||
|
|
3d53282738 | ||
|
|
e831ae4884 | ||
|
|
4751d05e9f | ||
|
|
d15bcb8df4 | ||
|
|
6ec7de5604 | ||
|
|
1cdb2aca91 | ||
|
|
46de371c42 | ||
|
|
11c0d45548 | ||
|
|
7bb1029c0f | ||
|
|
e3f7f36e5e | ||
|
|
f200737088 | ||
|
|
6def318ba7 | ||
|
|
e203af6a73 | ||
|
|
6ba135098b | ||
|
|
903739c055 | ||
|
|
30fbb5016e | ||
|
|
041148e8fe | ||
|
|
90bedce379 | ||
|
|
021265f3cf | ||
|
|
ff42398509 | ||
|
|
a30ec33b98 | ||
|
|
252efbec7e | ||
|
|
6e906d5981 | ||
|
|
df6125d098 | ||
|
|
3d4a340305 | ||
|
|
0decb39b17 | ||
|
|
4291dfad38 | ||
|
|
ddee3583e8 | ||
|
|
3e2307cbcf | ||
|
|
cc745fbdfa | ||
|
|
3027706d49 | ||
|
|
39fbd617e6 | ||
|
|
de4cb0b3be | ||
|
|
add3951003 | ||
|
|
3858e234da | ||
|
|
03e8e3a840 | ||
|
|
55e78d0503 | ||
|
|
b13a9fcd3f | ||
|
|
96b49c68ec | ||
|
|
be8744179d | ||
|
|
f971b75d7e | ||
|
|
455c6dfd01 | ||
|
|
a00a154a1a | ||
|
|
8b3b331843 | ||
|
|
10c874374f | ||
|
|
0c1e87c7c0 | ||
|
|
d517a4dc8b | ||
|
|
6d59f3edfc | ||
|
|
17d0406be2 | ||
|
|
ef73280015 | ||
|
|
6338d6aab4 | ||
|
|
b9d0fac535 | ||
|
|
5c0a5bbba7 | ||
|
|
ba1a77f00b | ||
|
|
5e587df545 | ||
|
|
23456ac1e4 | ||
|
|
8be512ad7b | ||
|
|
f129500202 | ||
|
|
c37d743b3e | ||
|
|
5bdb625059 | ||
|
|
231ba97fde | ||
|
|
a70e88625f | ||
|
|
b6770c46e5 | ||
|
|
9f4318cc0f | ||
|
|
91dc665a77 | ||
|
|
6066df391b | ||
|
|
be5c95b59d | ||
|
|
09b1abddc7 | ||
|
|
0c9ea0e3f2 | ||
|
|
aebfb20dfc | ||
|
|
b935c474af | ||
|
|
73b34ba8b5 | ||
|
|
89d8fee5da | ||
|
|
0e270dadb3 | ||
|
|
e2002b6026 | ||
|
|
66ed11fb97 | ||
|
|
9cbb4600f8 | ||
|
|
c1c850c593 | ||
|
|
e029f00d66 | ||
|
|
34e417fb55 | ||
|
|
e7954c63e4 | ||
|
|
446789a16f | ||
|
|
2538126573 | ||
|
|
a91d127ed7 | ||
|
|
a0781b1cf7 | ||
|
|
5e32ecb35a | ||
|
|
3e5de98f60 | ||
|
|
c8956b9e43 | ||
|
|
a8f15f87c6 | ||
|
|
8a64db9fcc | ||
|
|
ab450955fe | ||
|
|
afd502dbf3 | ||
|
|
3f02e55ffd | ||
|
|
2ee824b02b | ||
|
|
189620e4fb | ||
|
|
ecad88e859 | ||
|
|
62bd31d0aa | ||
|
|
241cdadd25 | ||
|
|
85309a2044 | ||
|
|
a81a20f8ee | ||
|
|
9c88f53cd0 | ||
|
|
3f8c2a6957 | ||
|
|
22cf27d7f6 | ||
|
|
4d8575ce33 |
@@ -25,7 +25,6 @@ rpi/
|
|||||||
*.img.xz
|
*.img.xz
|
||||||
*.img.zst
|
*.img.zst
|
||||||
*.img.zst.zip
|
*.img.zst.zip
|
||||||
pishrink.sh
|
|
||||||
|
|
||||||
# Docs
|
# Docs
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false # Don't cancel other jobs if one fails
|
fail-fast: false # Don't cancel other jobs if one fails
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
python-version: ["3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# 1. Get the code
|
# 1. Get the code
|
||||||
|
|||||||
16
.gitignore
vendored
@@ -64,9 +64,13 @@ htmlcov/
|
|||||||
# Output test files.
|
# Output test files.
|
||||||
test_data/*.png
|
test_data/*.png
|
||||||
|
|
||||||
# Dev scripts (local convenience scripts - except validate-release.sh)
|
# Dev scripts (local convenience scripts - except these)
|
||||||
scripts/*
|
scripts/*
|
||||||
!scripts/validate-release.sh
|
!scripts/validate-release.sh
|
||||||
|
!scripts/smoke-test.sh
|
||||||
|
!scripts/setup-trusted-certs.sh
|
||||||
|
!scripts/screenshots.sh
|
||||||
|
!scripts/build.sh
|
||||||
|
|
||||||
# Web UI auth database and SSL certs
|
# Web UI auth database and SSL certs
|
||||||
instance/
|
instance/
|
||||||
@@ -80,8 +84,8 @@ tests/
|
|||||||
*.img
|
*.img
|
||||||
*.img.xz
|
*.img.xz
|
||||||
*.img.zst
|
*.img.zst
|
||||||
pishrink.sh
|
|
||||||
*.img.zst.zip
|
*.img.zst.zip
|
||||||
|
rpi/tools/pishrink.sh
|
||||||
|
|
||||||
# Temp file storage
|
# Temp file storage
|
||||||
frontends/web/temp_files/
|
frontends/web/temp_files/
|
||||||
@@ -93,3 +97,11 @@ rpi/*.tar.zst.zip
|
|||||||
rpi/*.img
|
rpi/*.img
|
||||||
rpi/*.img.zst
|
rpi/*.img.zst
|
||||||
rpi/*.img.zst.zip
|
rpi/*.img.zst.zip
|
||||||
|
|
||||||
|
# AUR build artifacts
|
||||||
|
aur-upload/
|
||||||
|
aur/.SRCINFO
|
||||||
|
aur/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# Docker pre-built images and deps (release assets, too large for git)
|
||||||
|
docker/*.tar.zst
|
||||||
|
|||||||
4
API.md
@@ -88,7 +88,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
|||||||
|
|
||||||
**Docker with channel key:**
|
**Docker with channel key:**
|
||||||
```bash
|
```bash
|
||||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose up api
|
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose -f docker/docker-compose.yml up api
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -843,7 +843,7 @@ curl -s -X POST "$BASE_URL/decode/multipart" \
|
|||||||
|
|
||||||
## Docker Configuration
|
## Docker Configuration
|
||||||
|
|
||||||
### docker-compose.yml
|
### docker/docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
x-common-env: &common-env
|
x-common-env: &common-env
|
||||||
|
|||||||
18
CLI.md
@@ -64,6 +64,18 @@ python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if ha
|
|||||||
stegasoo channel show
|
stegasoo channel show
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Man Page
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install man page
|
||||||
|
sudo mkdir -p /usr/local/share/man/man1
|
||||||
|
sudo cp docs/stegasoo.1 /usr/local/share/man/man1/
|
||||||
|
sudo mandb
|
||||||
|
|
||||||
|
# View
|
||||||
|
man stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What's New in v4.1.0
|
## What's New in v4.1.0
|
||||||
@@ -152,7 +164,7 @@ stegasoo generate [OPTIONS]
|
|||||||
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
||||||
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
||||||
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
||||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
|
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
|
||||||
| `--words` | | 3-12 | 4 | Words in passphrase |
|
| `--words` | | 3-12 | 4 | Words in passphrase |
|
||||||
| `--output` | `-o` | path | | Save RSA key to file |
|
| `--output` | `-o` | path | | Save RSA key to file |
|
||||||
| `--password` | `-p` | string | | Password for RSA key file |
|
| `--password` | `-p` | string | | Password for RSA key file |
|
||||||
@@ -168,7 +180,7 @@ stegasoo generate
|
|||||||
stegasoo generate --words 6
|
stegasoo generate --words 6
|
||||||
|
|
||||||
# Generate with RSA key
|
# Generate with RSA key
|
||||||
stegasoo generate --rsa --rsa-bits 4096
|
stegasoo generate --rsa --rsa-bits 3072
|
||||||
|
|
||||||
# Save RSA key to encrypted file
|
# Save RSA key to encrypted file
|
||||||
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
||||||
@@ -798,7 +810,7 @@ stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
|
|||||||
|
|
||||||
### Docker Deployment
|
### Docker Deployment
|
||||||
|
|
||||||
**docker-compose.yml:**
|
**docker/docker-compose.yml:**
|
||||||
```yaml
|
```yaml
|
||||||
x-common-env: &common-env
|
x-common-env: &common-env
|
||||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||||
|
|||||||
39
DOCKER.md
@@ -6,14 +6,14 @@ Stegasoo provides Docker images for both the Web UI and REST API.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and start all services
|
# Build and start all services
|
||||||
docker-compose up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
docker-compose ps
|
docker-compose -f docker/docker-compose.yml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
Access:
|
Access:
|
||||||
- **Web UI**: http://localhost:5000
|
- **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
|
||||||
- **REST API**: http://localhost:8000
|
- **REST API**: http://localhost:8000
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
@@ -36,9 +36,12 @@ STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
|||||||
# Web UI authentication (default: enabled)
|
# Web UI authentication (default: enabled)
|
||||||
STEGASOO_AUTH_ENABLED=true
|
STEGASOO_AUTH_ENABLED=true
|
||||||
|
|
||||||
# HTTPS support (default: disabled)
|
# HTTPS support (default: enabled, generates self-signed cert)
|
||||||
STEGASOO_HTTPS_ENABLED=false
|
STEGASOO_HTTPS_ENABLED=true
|
||||||
STEGASOO_HOSTNAME=localhost
|
STEGASOO_HOSTNAME=localhost
|
||||||
|
|
||||||
|
# To disable HTTPS:
|
||||||
|
# STEGASOO_HTTPS_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Volume Mounts
|
### Volume Mounts
|
||||||
@@ -58,10 +61,10 @@ Uses a pre-built base image with all dependencies:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First time only: build the base image
|
# First time only: build the base image
|
||||||
docker build -f Dockerfile.base -t stegasoo-base:latest .
|
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
||||||
|
|
||||||
# Build services (fast - only copies app code)
|
# Build services (fast - only copies app code)
|
||||||
docker-compose build
|
docker-compose -f docker/docker-compose.yml build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Full Build (No Base Image)
|
### Full Build (No Base Image)
|
||||||
@@ -69,26 +72,26 @@ docker-compose build
|
|||||||
If you don't have the base image, the Dockerfile will build all dependencies (slower):
|
If you don't have the base image, the Dockerfile will build all dependencies (slower):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
docker-compose -f docker/docker-compose.yml build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start services
|
# Start services
|
||||||
docker-compose up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
docker-compose logs -f
|
docker-compose -f docker/docker-compose.yml logs -f
|
||||||
|
|
||||||
# Stop services
|
# Stop services
|
||||||
docker-compose down
|
docker-compose -f docker/docker-compose.yml down
|
||||||
|
|
||||||
# Rebuild after code changes
|
# Rebuild after code changes
|
||||||
docker-compose build && docker-compose up -d
|
docker-compose -f docker/docker-compose.yml build && docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
# Full rebuild (no cache)
|
# Full rebuild (no cache)
|
||||||
docker-compose build --no-cache
|
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||||
```
|
```
|
||||||
|
|
||||||
## Resource Limits
|
## Resource Limits
|
||||||
@@ -109,7 +112,7 @@ Both services include health checks:
|
|||||||
|
|
||||||
Check health status:
|
Check health status:
|
||||||
```bash
|
```bash
|
||||||
docker-compose ps
|
docker-compose -f docker/docker-compose.yml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
@@ -126,7 +129,7 @@ For production, consider:
|
|||||||
```bash
|
```bash
|
||||||
# Don't commit .env files with secrets
|
# Don't commit .env files with secrets
|
||||||
export STEGASOO_CHANNEL_KEY=your-key
|
export STEGASOO_CHANNEL_KEY=your-key
|
||||||
docker-compose up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
|
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
|
||||||
@@ -142,12 +145,12 @@ For production, consider:
|
|||||||
### Container won't start
|
### Container won't start
|
||||||
```bash
|
```bash
|
||||||
# Check logs
|
# Check logs
|
||||||
docker-compose logs web
|
docker-compose -f docker/docker-compose.yml logs web
|
||||||
docker-compose logs api
|
docker-compose -f docker/docker-compose.yml logs api
|
||||||
```
|
```
|
||||||
|
|
||||||
### Out of memory
|
### Out of memory
|
||||||
Increase Docker's memory allocation or reduce worker count in Dockerfile.
|
Increase Docker's memory allocation or reduce worker count in `docker/Dockerfile`.
|
||||||
|
|
||||||
### Permission errors
|
### Permission errors
|
||||||
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.
|
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.
|
||||||
|
|||||||
30
INSTALL.md
@@ -154,10 +154,10 @@ Build and run individual containers.
|
|||||||
#### Build Images
|
#### Build Images
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build all targets
|
# From project root - build all targets
|
||||||
docker build -t stegasoo-web --target web .
|
docker build -t stegasoo-web --target web -f docker/Dockerfile .
|
||||||
docker build -t stegasoo-api --target api .
|
docker build -t stegasoo-api --target api -f docker/Dockerfile .
|
||||||
docker build -t stegasoo-cli --target cli .
|
docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run Web UI
|
#### Run Web UI
|
||||||
@@ -214,17 +214,17 @@ The easiest way to run all services.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start in background
|
# Start in background
|
||||||
docker-compose up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
# Start specific service
|
# Start specific service
|
||||||
docker-compose up -d web
|
docker-compose -f docker/docker-compose.yml up -d web
|
||||||
docker-compose up -d api
|
docker-compose -f docker/docker-compose.yml up -d api
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
docker-compose logs -f
|
docker-compose -f docker/docker-compose.yml logs -f
|
||||||
|
|
||||||
# Stop all
|
# Stop all
|
||||||
docker-compose down
|
docker-compose -f docker/docker-compose.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Authentication Configuration (v4.0.2)
|
#### Authentication Configuration (v4.0.2)
|
||||||
@@ -239,7 +239,7 @@ STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
|
|||||||
STEGASOO_CHANNEL_KEY= # Optional channel key
|
STEGASOO_CHANNEL_KEY= # Optional channel key
|
||||||
|
|
||||||
# Then run
|
# Then run
|
||||||
docker-compose up -d web
|
docker-compose -f docker/docker-compose.yml up -d web
|
||||||
```
|
```
|
||||||
|
|
||||||
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
|
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
|
||||||
@@ -255,16 +255,16 @@ On first access, you'll be prompted to create an admin account. The database and
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build images and start
|
# Build images and start
|
||||||
docker-compose up -d --build
|
docker-compose -f docker/docker-compose.yml up -d --build
|
||||||
|
|
||||||
# Force rebuild (no cache)
|
# Force rebuild (no cache)
|
||||||
docker-compose build --no-cache
|
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||||
docker-compose up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Resource Configuration
|
#### Resource Configuration
|
||||||
|
|
||||||
The `docker-compose.yml` includes resource limits:
|
The `docker/docker-compose.yml` includes resource limits:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -852,7 +852,7 @@ Argon2 needs 256MB per operation. Increase container memory:
|
|||||||
# Docker run
|
# Docker run
|
||||||
docker run --memory=768m ...
|
docker run --memory=768m ...
|
||||||
|
|
||||||
# Docker Compose - edit docker-compose.yml
|
# Docker Compose - edit docker/docker-compose.yml
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
97
PLAN-4.1.6.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Stegasoo v4.1.6 Planning
|
||||||
|
|
||||||
|
## UI Tweaks
|
||||||
|
|
||||||
|
### 1. Revamp Tron Lines Animation (Carrier/Stego Image)
|
||||||
|
**Current state:**
|
||||||
|
- 6-8 snake paths, each with 3-5 segments (~24-40 total lines)
|
||||||
|
- 2px thick lines
|
||||||
|
- 30-60px length per segment
|
||||||
|
- Starting points spread across 80% of image area
|
||||||
|
- Colors: yellow, cyan, purple, blue with glow
|
||||||
|
|
||||||
|
**Target improvements:**
|
||||||
|
- [x] Thinner lines (1px instead of 2px)
|
||||||
|
- [x] More numerous (20-40 paths via 5x4 grid, ~60-200 segments total)
|
||||||
|
- [x] Better distribution across entire image (grid-based seeding)
|
||||||
|
- [x] Shorter segments (12-30px) for denser "circuit board" look
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `frontends/web/static/style.css` (~881-979) - `.embed-trace` styling
|
||||||
|
- `frontends/web/static/js/stegasoo.js` (~333-390) - `generateEmbedTraces()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools Page Expansion
|
||||||
|
|
||||||
|
### Analysis Tools
|
||||||
|
- [x] **JPEG Compression Tester** - Preview image at different quality levels (10-100%), show file size delta. Useful for understanding stego survivability.
|
||||||
|
- [ ] **LSB Plane Viewer** - Visualize least significant bit plane(s) of RGB channels. Classic stego analysis tool.
|
||||||
|
- [ ] **Histogram Viewer** - Color distribution graph per channel. Anomalies can indicate hidden data.
|
||||||
|
- [ ] **Image Diff** - Compare two images side-by-side with pixel difference highlighting. Great for original vs stego comparison.
|
||||||
|
- [ ] **Noise Analysis** - Chi-square or similar statistical analysis for detecting LSB embedding.
|
||||||
|
|
||||||
|
### Transform Tools
|
||||||
|
- [x] **Rotate/Flip** - 90°/180°/270° rotation, horizontal/vertical flip
|
||||||
|
- [ ] **Resize** - Scale with aspect ratio lock, common presets (50%, 25%, etc.)
|
||||||
|
- [ ] **Crop** - Basic rectangular crop with preview
|
||||||
|
- [x] **Format Convert** - PNG ↔ JPEG ↔ WebP with quality slider
|
||||||
|
|
||||||
|
### Existing Tools (already done)
|
||||||
|
- [x] Capacity Calculator
|
||||||
|
- [x] EXIF Viewer
|
||||||
|
- [x] EXIF Strip
|
||||||
|
- [x] Image Peek (header analysis)
|
||||||
|
|
||||||
|
### Tools UI/UX Overhaul
|
||||||
|
|
||||||
|
**Final Layout: Office-style Ribbon + Two-Panel**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 📏 📋 👁️ 📊 ┃ ✂️ 🔄 📐 🔀 Image Tools │ ← Icon toolbar
|
||||||
|
├────────────────────────────────────────┬────────────────────┤
|
||||||
|
│ [Format: PNG ▼] [Quality: 85] │ │
|
||||||
|
├────────────────────────────────────────┤ Capacity │
|
||||||
|
│ │ Calculator │
|
||||||
|
│ ┌────────────────────────────┐ │ ────────────── │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ Drop image here │ │ Dimensions: │
|
||||||
|
│ │ or click │ │ 1920 × 1080 │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └────────────────────────────┘ │ LSB Capacity: │
|
||||||
|
│ │ 245 KB │
|
||||||
|
│ [image.jpg] │ │
|
||||||
|
│ │ ────────────── │
|
||||||
|
│ │ [Clear] [Export] │
|
||||||
|
└────────────────────────────────────────┴────────────────────┘
|
||||||
|
Options + dropzone/preview Results sidebar
|
||||||
|
```
|
||||||
|
|
||||||
|
- Top ribbon: Icon buttons grouped by category (Analyze | Transform)
|
||||||
|
- Left panel: Tool options + dropzone/preview (INPUT)
|
||||||
|
- Right panel: Tool name + results/metadata + actions (OUTPUT)
|
||||||
|
- Flow: Left → Right (input → output)
|
||||||
|
|
||||||
|
**Implementation Tasks:**
|
||||||
|
- [x] Move inline CSS to style.css
|
||||||
|
- [x] Build icon toolbar ribbon
|
||||||
|
- [x] Build two-panel layout structure
|
||||||
|
- [x] Migrate existing tools (Capacity, EXIF, Strip)
|
||||||
|
- [x] Add new tools (Rotate, Compress, Convert)
|
||||||
|
- [ ] Loading spinner on all async operations
|
||||||
|
- [ ] Toast notifications instead of alerts
|
||||||
|
- [ ] Consistent color coding (green=analysis, amber=transform)
|
||||||
|
- [ ] Mobile: stack panels vertically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Improvements
|
||||||
|
|
||||||
|
### (Add items here)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other UI Tweaks
|
||||||
|
|
||||||
|
### (Add items here)
|
||||||
|
|
||||||
12
README.md
@@ -105,15 +105,18 @@ ruff check src/ tests/ frontends/
|
|||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Quick start
|
# Quick start (HTTPS enabled by default)
|
||||||
docker-compose up -d
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
# Access
|
# Access
|
||||||
# Web UI: http://localhost:5000
|
# Web UI: https://localhost:5000 (self-signed cert)
|
||||||
# REST API: http://localhost:8000
|
# REST API: http://localhost:8000
|
||||||
|
|
||||||
|
# Disable HTTPS if needed:
|
||||||
|
STEGASOO_HTTPS_ENABLED=false docker-compose -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
See [DOCKER.md](DOCKER.md) for full documentation.
|
See [DOCKER.md](DOCKER.md) and [docs/DOCKER_QUICKSTART.md](docs/DOCKER_QUICKSTART.md) for full documentation.
|
||||||
|
|
||||||
## Raspberry Pi
|
## Raspberry Pi
|
||||||
|
|
||||||
@@ -143,6 +146,7 @@ See [rpi/README.md](rpi/README.md) for manual installation.
|
|||||||
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
|
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
|
||||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
|
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
|
||||||
|
- `man stegasoo` - Man page (install: `sudo cp docs/stegasoo.1 /usr/local/share/man/man1/ && sudo mandb`)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ Pre-release validation checklist. Complete all items before tagging a release.
|
|||||||
|
|
||||||
## Docker Validation
|
## Docker Validation
|
||||||
|
|
||||||
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .`
|
- [ ] Base image builds: `docker build -f docker/Dockerfile.base -t stegasoo-base:latest .`
|
||||||
- [ ] Web image builds: `docker-compose build web`
|
- [ ] Web image builds: `docker-compose -f docker/docker-compose.yml build web`
|
||||||
- [ ] Container starts: `docker-compose up -d web`
|
- [ ] Container starts: `docker-compose -f docker/docker-compose.yml up -d web`
|
||||||
- [ ] Web UI accessible at http://localhost:5000
|
- [ ] Web UI accessible at http://localhost:5000
|
||||||
- [ ] Encode/decode works in container
|
- [ ] Encode/decode works in container
|
||||||
- [ ] Container stops cleanly: `docker-compose down`
|
- [ ] Container stops cleanly: `docker-compose -f docker/docker-compose.yml down`
|
||||||
|
|
||||||
## Release Process
|
## Release Process
|
||||||
|
|
||||||
|
|||||||
150
RELEASE_NOTES.md
@@ -1,47 +1,131 @@
|
|||||||
## Stegasoo v4.1.5
|
## Stegasoo v4.2.1
|
||||||
|
|
||||||
### Developer Experience
|
### API Security
|
||||||
- **Educational Code Comments**: Core modules now include detailed explanations
|
|
||||||
- DCT: zig-zag coefficient diagrams, QIM embedding math, Reed-Solomon "Voyager" reference
|
|
||||||
- LSB: visual bit manipulation examples, ChaCha20 pixel selection
|
|
||||||
- Crypto: multi-factor KDF flow diagrams, Argon2id memory-hardness reasoning
|
|
||||||
- CLI/Web: architectural patterns for future contributors
|
|
||||||
|
|
||||||
### Raspberry Pi Improvements
|
**API Key Authentication**
|
||||||
- **Streamlined Image Creation**: `pull-image.sh` now handles everything
|
- All protected endpoints require `X-API-Key` header
|
||||||
- Auto-resizes rootfs to exactly 16GB (consistent images from any SD card)
|
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
|
||||||
- Disables Pi OS auto-expand
|
- Auth disabled when no keys configured (easy onboarding)
|
||||||
- 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
|
**TLS Support**
|
||||||
- Dynamic temperature emoji (ice/cool/fire based on CPU temp)
|
- Self-signed certificates auto-generated on first run
|
||||||
- Rocket emoji for service status
|
- Certs valid for localhost, all local IPs, hostname.local
|
||||||
- Cleaner formatting
|
- CLI: `stegasoo api tls generate` to pre-generate
|
||||||
|
|
||||||
### Raspberry Pi Image
|
### CLI Improvements
|
||||||
Download `stegasoo-rpi-4.1.5.img.zst.zip` from Releases.
|
|
||||||
|
|
||||||
|
**New API Management Commands**
|
||||||
```bash
|
```bash
|
||||||
# Flash (auto-detects SD card)
|
stegasoo api keys create NAME # Create new key
|
||||||
sudo ./rpi/flash-image.sh stegasoo-rpi-4.1.5.img.zst.zip
|
stegasoo api keys list # List API keys
|
||||||
|
stegasoo api tls generate # Generate TLS cert
|
||||||
# Or manual
|
stegasoo api serve # Start server with TLS
|
||||||
zstdcat stegasoo-rpi-4.1.5.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**New Image Tools**
|
||||||
|
```bash
|
||||||
|
stegasoo tools compress IMG -q 75 # JPEG compression
|
||||||
|
stegasoo tools rotate IMG -r 90 # Lossless rotation
|
||||||
|
stegasoo tools convert IMG -f png # Format conversion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **DCT rotation**: Portrait photos no longer export rotated 90°
|
||||||
|
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
|
||||||
|
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
|
||||||
|
- **Import paths**: Fixed for installed packages (AUR/pip)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
**AUR (Arch Linux)**
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git # Full (Web + API + CLI)
|
||||||
|
yay -S stegasoo-cli-git # CLI only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi**
|
||||||
|
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
|
||||||
Default login: `admin` / `stegasoo`
|
Default login: `admin` / `stegasoo`
|
||||||
|
|
||||||
First boot runs the setup wizard for WiFi, HTTPS, and channel key configuration.
|
### Requirements
|
||||||
|
|
||||||
### Docker
|
- Python 3.11 - 3.14 (dropped 3.10 support)
|
||||||
```bash
|
|
||||||
docker-compose up -d web # Web UI on :5000
|
### Release Assets
|
||||||
docker-compose up -d api # REST API on :8000
|
|
||||||
```
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
|
||||||
|
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
|
||||||
|
| Source code (zip/tar.gz) | Auto-generated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stegasoo v4.2.0
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
Major performance improvements for Raspberry Pi and resource-constrained deployments.
|
||||||
|
|
||||||
|
#### DCT Vectorization (~14x faster)
|
||||||
|
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
|
||||||
|
- Processes 500 blocks at once instead of one-by-one
|
||||||
|
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
|
||||||
|
|
||||||
|
#### Memory Optimization (50% reduction)
|
||||||
|
- Switched from `float64` to `float32` for all DCT operations
|
||||||
|
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
|
||||||
|
- Critical for Pi 3/4 avoiding swap thrashing
|
||||||
|
|
||||||
|
#### Progress Callbacks for Decode
|
||||||
|
- `progress_file` parameter added to `decode()` and extraction functions
|
||||||
|
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
|
||||||
|
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
|
||||||
|
|
||||||
|
#### Async API Endpoints
|
||||||
|
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
|
||||||
|
- API server can handle concurrent requests without blocking
|
||||||
|
- Essential for multi-user Pi deployments
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
|
||||||
|
#### Zstd Default Compression
|
||||||
|
- `zstandard` is now a core dependency (always installed)
|
||||||
|
- Better compression ratio than zlib for QR code RSA keys
|
||||||
|
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
|
||||||
|
|
||||||
|
### QR Code Generation
|
||||||
|
|
||||||
|
#### CLI Support
|
||||||
|
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
|
||||||
|
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
|
||||||
|
|
||||||
|
#### API Support
|
||||||
|
- `POST /generate-key-qr` - generate QR from RSA key
|
||||||
|
- Supports `png`, `jpg`, and `ascii` output formats
|
||||||
|
- Uses zstd compression by default
|
||||||
|
|
||||||
|
### Other Changes
|
||||||
|
|
||||||
|
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||||
|
- File auto-expire increased to 10 minutes
|
||||||
|
- Progress bar "candy cane" animation during Argon2 key derivation
|
||||||
|
- Optional API service in Pi setup (with security warning)
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| Metric | v4.1.7 | v4.2.0 | Improvement |
|
||||||
|
|--------|--------|--------|-------------|
|
||||||
|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||||
|
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||||
|
| Concurrent API | No | Yes | check |
|
||||||
|
| QR Compression | zlib | zstd | **~15% smaller** |
|
||||||
|
|
||||||
### Full Changelog
|
### Full Changelog
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||||
|
|||||||
284
SECURITY_AUDIT_PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Stegasoo Security Audit Plan
|
||||||
|
|
||||||
|
> **Target Audience**: Developers, security reviewers, and deployment administrators
|
||||||
|
> **Scope**: Web UI, REST API, CLI, and cryptographic core
|
||||||
|
> **Deployment Model**: Air-gapped / private LAN (primary), Internet-facing (secondary)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Stegasoo is a steganography tool designed for **air-gapped deployments** on private networks. While the primary threat model assumes a trusted local network, this audit plan covers security best practices for both isolated and potentially exposed deployments.
|
||||||
|
|
||||||
|
### Known Limitations (By Design)
|
||||||
|
|
||||||
|
- **Self-signed certificates**: HTTPS uses self-signed certs; users must add exceptions or deploy their own CA
|
||||||
|
- **No rate limiting**: Assumes trusted users on private network
|
||||||
|
- **Single-node**: No distributed session store; sessions are per-instance
|
||||||
|
- **Air-gap focus**: External security (firewalls, network isolation) is user's responsibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Authentication & Authorization
|
||||||
|
|
||||||
|
### 1.1 Password Security
|
||||||
|
- [ ] Passwords hashed with Argon2id (preferred) or PBKDF2 fallback
|
||||||
|
- [ ] Minimum password length enforced (8+ characters)
|
||||||
|
- [ ] Password not logged or exposed in error messages
|
||||||
|
- [ ] Password change requires current password verification
|
||||||
|
- [ ] Admin re-authentication required for sensitive operations (channel key export)
|
||||||
|
|
||||||
|
### 1.2 Session Management
|
||||||
|
- [ ] Session tokens are cryptographically random
|
||||||
|
- [ ] Session cookies have `HttpOnly` flag
|
||||||
|
- [ ] Session cookies have `Secure` flag (when HTTPS enabled)
|
||||||
|
- [ ] Session cookies have `SameSite` attribute
|
||||||
|
- [ ] Sessions invalidated on logout
|
||||||
|
- [ ] Sessions invalidated on password change
|
||||||
|
- [ ] Session timeout configured appropriately
|
||||||
|
|
||||||
|
### 1.3 Authorization
|
||||||
|
- [ ] Admin-only routes protected by `@admin_required` decorator
|
||||||
|
- [ ] User-only routes protected by `@login_required` decorator
|
||||||
|
- [ ] Users cannot access other users' saved channel keys
|
||||||
|
- [ ] Users cannot modify other users' accounts
|
||||||
|
- [ ] Role escalation not possible through API manipulation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cryptographic Implementation
|
||||||
|
|
||||||
|
### 2.1 Key Derivation
|
||||||
|
- [ ] KDF uses Argon2id with appropriate parameters (memory, iterations, parallelism)
|
||||||
|
- [ ] PBKDF2 fallback uses sufficient iterations (600,000+)
|
||||||
|
- [ ] Salt is cryptographically random and unique per operation
|
||||||
|
- [ ] PIN/passphrase combined securely before KDF
|
||||||
|
|
||||||
|
### 2.2 Encryption
|
||||||
|
- [ ] AES-256-GCM used for payload encryption
|
||||||
|
- [ ] Nonce/IV is unique per encryption operation
|
||||||
|
- [ ] Authentication tag verified before decryption
|
||||||
|
- [ ] No padding oracle vulnerabilities
|
||||||
|
|
||||||
|
### 2.3 Channel Keys
|
||||||
|
- [ ] Channel keys are 128-bit (32 hex chars)
|
||||||
|
- [ ] Channel key derivation uses HKDF or similar
|
||||||
|
- [ ] Channel isolation prevents cross-channel decryption
|
||||||
|
- [ ] Fingerprint reveals no information about full key
|
||||||
|
|
||||||
|
### 2.4 Random Number Generation
|
||||||
|
- [ ] All random values use `secrets` module or OS CSPRNG
|
||||||
|
- [ ] No use of `random` module for security-sensitive operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Input Validation & Injection Prevention
|
||||||
|
|
||||||
|
### 3.1 Web UI
|
||||||
|
- [ ] All user input sanitized before rendering (XSS prevention)
|
||||||
|
- [ ] Jinja2 auto-escaping enabled
|
||||||
|
- [ ] No `| safe` filter on user-controlled content
|
||||||
|
- [ ] Content-Security-Policy header configured
|
||||||
|
- [ ] X-Content-Type-Options: nosniff
|
||||||
|
|
||||||
|
### 3.2 File Uploads
|
||||||
|
- [ ] File size limits enforced server-side
|
||||||
|
- [ ] File type validation (magic bytes, not just extension)
|
||||||
|
- [ ] Uploaded files not executed
|
||||||
|
- [ ] Filenames sanitized (path traversal prevention)
|
||||||
|
- [ ] Temporary files cleaned up after processing
|
||||||
|
|
||||||
|
### 3.3 API Inputs
|
||||||
|
- [ ] JSON schema validation on API endpoints
|
||||||
|
- [ ] Integer overflow checks on size parameters
|
||||||
|
- [ ] No SQL injection (parameterized queries only)
|
||||||
|
- [ ] No command injection (no shell=True with user input)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Steganography-Specific Security
|
||||||
|
|
||||||
|
### 4.1 Carrier Image Handling
|
||||||
|
- [ ] Malformed images don't crash the server (PIL/jpegio hardening)
|
||||||
|
- [ ] DCT mode subprocess isolation for crash protection
|
||||||
|
- [ ] Memory limits on image processing
|
||||||
|
- [ ] No arbitrary code execution from image metadata
|
||||||
|
|
||||||
|
### 4.2 Payload Security
|
||||||
|
- [ ] Payload size limits enforced
|
||||||
|
- [ ] Encrypted payload indistinguishable from random noise
|
||||||
|
- [ ] No metadata leakage in output images
|
||||||
|
- [ ] Reference photo required (prevents dictionary attacks)
|
||||||
|
|
||||||
|
### 4.3 Capacity Reporting
|
||||||
|
- [ ] Capacity calculation doesn't leak information about encoding method
|
||||||
|
- [ ] Failed decodes don't reveal why (wrong key vs no data vs corrupted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Network & Transport Security
|
||||||
|
|
||||||
|
### 5.1 HTTPS Configuration
|
||||||
|
- [ ] TLS 1.2+ only (no SSLv3, TLS 1.0/1.1)
|
||||||
|
- [ ] Strong cipher suites configured
|
||||||
|
- [ ] Certificate generation uses 2048+ bit RSA or P-256 EC
|
||||||
|
- [ ] Private key file permissions restricted (600)
|
||||||
|
|
||||||
|
### 5.2 Headers
|
||||||
|
- [ ] X-Frame-Options: DENY (clickjacking prevention)
|
||||||
|
- [ ] X-Content-Type-Options: nosniff
|
||||||
|
- [ ] Referrer-Policy: same-origin
|
||||||
|
- [ ] Permissions-Policy configured
|
||||||
|
|
||||||
|
### 5.3 CORS (if applicable)
|
||||||
|
- [ ] CORS not enabled (or restricted to specific origins)
|
||||||
|
- [ ] Credentials not allowed cross-origin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Error Handling & Logging
|
||||||
|
|
||||||
|
### 6.1 Error Messages
|
||||||
|
- [ ] Stack traces not exposed to users in production
|
||||||
|
- [ ] Error messages don't reveal sensitive paths or config
|
||||||
|
- [ ] Failed login doesn't reveal if username exists
|
||||||
|
|
||||||
|
### 6.2 Logging
|
||||||
|
- [ ] Passwords never logged
|
||||||
|
- [ ] Channel keys never logged
|
||||||
|
- [ ] Passphrases never logged
|
||||||
|
- [ ] Log files have appropriate permissions
|
||||||
|
- [ ] Sensitive operations logged for audit trail (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dependency Security
|
||||||
|
|
||||||
|
### 7.1 Python Dependencies
|
||||||
|
- [ ] All dependencies pinned to specific versions
|
||||||
|
- [ ] No known vulnerabilities in dependencies (run `pip-audit` or `safety`)
|
||||||
|
- [ ] Dependencies from trusted sources only (PyPI)
|
||||||
|
|
||||||
|
### 7.2 Frontend Dependencies
|
||||||
|
- [ ] All JS/CSS served locally (air-gap ready)
|
||||||
|
- [ ] No CDN dependencies
|
||||||
|
- [ ] Bootstrap and libraries are official releases
|
||||||
|
- [ ] Subresource integrity considered for any external loads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deployment Security
|
||||||
|
|
||||||
|
### 8.1 File Permissions
|
||||||
|
- [ ] Database file not world-readable (600 or 640)
|
||||||
|
- [ ] SSL certificates/keys not world-readable
|
||||||
|
- [ ] Config files with secrets protected
|
||||||
|
- [ ] Instance directory not in web root
|
||||||
|
|
||||||
|
### 8.2 Docker Deployment
|
||||||
|
- [ ] Container runs as non-root user
|
||||||
|
- [ ] No unnecessary capabilities
|
||||||
|
- [ ] Resource limits configured
|
||||||
|
- [ ] Health checks don't expose sensitive info
|
||||||
|
|
||||||
|
### 8.3 Raspberry Pi Deployment
|
||||||
|
- [ ] Default passwords changed
|
||||||
|
- [ ] SSH key-only authentication (recommended)
|
||||||
|
- [ ] Unnecessary services disabled
|
||||||
|
- [ ] Firewall configured (UFW/iptables)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Air-Gap Specific Considerations
|
||||||
|
|
||||||
|
### 9.1 Network Isolation
|
||||||
|
- [ ] Document expected network topology
|
||||||
|
- [ ] No phone-home or telemetry
|
||||||
|
- [ ] No external API calls
|
||||||
|
- [ ] Works fully offline after deployment
|
||||||
|
|
||||||
|
### 9.2 Key Distribution
|
||||||
|
- [ ] QR code export for channel keys (offline transfer)
|
||||||
|
- [ ] Print sheet for physical key backup
|
||||||
|
- [ ] No cloud sync or external key servers
|
||||||
|
|
||||||
|
### 9.3 Updates
|
||||||
|
- [ ] Document offline update procedure
|
||||||
|
- [ ] Signed releases (future consideration)
|
||||||
|
- [ ] Checksum verification for downloads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Penetration Testing Checklist
|
||||||
|
|
||||||
|
### 10.1 Authentication Attacks
|
||||||
|
- [ ] Brute force login (note: no rate limiting by design)
|
||||||
|
- [ ] Session fixation
|
||||||
|
- [ ] Session hijacking
|
||||||
|
- [ ] Password reset flow abuse
|
||||||
|
|
||||||
|
### 10.2 Injection Attacks
|
||||||
|
- [ ] SQL injection on all inputs
|
||||||
|
- [ ] XSS (stored, reflected, DOM-based)
|
||||||
|
- [ ] Command injection
|
||||||
|
- [ ] Path traversal
|
||||||
|
- [ ] SSTI (Server-Side Template Injection)
|
||||||
|
|
||||||
|
### 10.3 Business Logic
|
||||||
|
- [ ] Access control bypass
|
||||||
|
- [ ] IDOR (Insecure Direct Object Reference)
|
||||||
|
- [ ] Race conditions
|
||||||
|
- [ ] Integer overflow in capacity calculations
|
||||||
|
|
||||||
|
### 10.4 Cryptographic Attacks
|
||||||
|
- [ ] Known-plaintext attacks on stego output
|
||||||
|
- [ ] Timing attacks on password verification
|
||||||
|
- [ ] Padding oracle attacks
|
||||||
|
- [ ] Key reuse vulnerabilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools for Automated Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dependency vulnerability scan
|
||||||
|
pip-audit
|
||||||
|
safety check
|
||||||
|
|
||||||
|
# Static analysis
|
||||||
|
bandit -r stegasoo/ frontends/
|
||||||
|
|
||||||
|
# Web security scan (if exposed)
|
||||||
|
nikto -h https://localhost:5000
|
||||||
|
OWASP ZAP (manual)
|
||||||
|
|
||||||
|
# SSL/TLS configuration
|
||||||
|
testssl.sh https://localhost:5000
|
||||||
|
|
||||||
|
# Python code quality
|
||||||
|
ruff check .
|
||||||
|
mypy stegasoo/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Schedule
|
||||||
|
|
||||||
|
| Phase | Focus Area | Priority |
|
||||||
|
|-------|-----------|----------|
|
||||||
|
| Pre-release | Crypto implementation, auth flow | Critical |
|
||||||
|
| Post-release | Dependency scan, static analysis | High |
|
||||||
|
| Quarterly | Full penetration test | Medium |
|
||||||
|
| Ongoing | CVE monitoring for dependencies | High |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This plan assumes **trusted users on a private network** as the primary deployment model
|
||||||
|
- Internet-facing deployments should add rate limiting, fail2ban, and reverse proxy hardening
|
||||||
|
- For high-security deployments, consider external security audit by professionals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-01-07*
|
||||||
54
TODO-4.2.1.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Stegasoo 4.2.1 Plan
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
- [x] Fix EXIF viewer panel not loading metadata in Web UI
|
||||||
|
- Redesigned with card-based grid layout and categories
|
||||||
|
- Compact styling for better space usage
|
||||||
|
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
|
||||||
|
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
|
||||||
|
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
|
||||||
|
- Added rotation fallback in `extract_from_dct()` with quick header validation
|
||||||
|
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
|
||||||
|
- Web UI rotate tool now uses jpegtran for JPEGs
|
||||||
|
- DCT decode rotation fallback now uses jpegtran for JPEGs
|
||||||
|
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
|
||||||
|
|
||||||
|
## Tools Audit
|
||||||
|
- [x] Web UI tools - full shakedown and fixes
|
||||||
|
- Compress, Rotate, Strip, EXIF viewer all working
|
||||||
|
- Rotate uses jpegtran for lossless JPEG rotation
|
||||||
|
- Compact UI styling
|
||||||
|
- [x] CLI tools - full shakedown and fixes
|
||||||
|
- Fixed encode to output JPEG when carrier is JPEG (was always PNG)
|
||||||
|
- Fixed jpegtran -trim flag destroying DCT stego data
|
||||||
|
- Added compress, rotate, convert tools (matching Web UI)
|
||||||
|
- Rotate uses jpegtran for JPEGs, supports flip-only operations
|
||||||
|
|
||||||
|
## AUR Packages
|
||||||
|
- [x] `stegasoo-cli` - standalone CLI package (no web dependencies)
|
||||||
|
- Created aur-cli/PKGBUILD with [cli,dct,compression] extras only
|
||||||
|
- No flask/gunicorn/fastapi/uvicorn/pyzbar deps
|
||||||
|
- 68MB vs 79MB for full package
|
||||||
|
- [x] `stegasoo-api` - REST API package
|
||||||
|
- Created aur-api/PKGBUILD with [api,cli,compression] extras
|
||||||
|
- Has fastapi/uvicorn, no flask/gunicorn
|
||||||
|
- 74MB package size
|
||||||
|
- Includes systemd service with TLS
|
||||||
|
|
||||||
|
## API Auth Work
|
||||||
|
- [x] API key authentication (simpler than OAuth2 for personal use)
|
||||||
|
- `frontends/api/auth.py` - key generation, hashing, validation
|
||||||
|
- Keys stored in `~/.stegasoo/api_keys.json` (hashed)
|
||||||
|
- `X-API-Key` header for authentication
|
||||||
|
- Auth disabled when no keys configured
|
||||||
|
- [x] TLS with self-signed certificates
|
||||||
|
- Auto-generates certs on first run
|
||||||
|
- CLI: `stegasoo api tls generate`
|
||||||
|
- Certs stored in `~/.stegasoo/certs/`
|
||||||
|
- [x] CLI commands for API management
|
||||||
|
- `stegasoo api keys list/create/delete`
|
||||||
|
- `stegasoo api tls generate/info`
|
||||||
|
- `stegasoo api serve` (starts with TLS by default)
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
- [ ] Postman collection (with environment templates)
|
||||||
@@ -177,7 +177,7 @@ python app.py
|
|||||||
### Docker Configuration
|
### Docker Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker/docker-compose.yml
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
environment:
|
environment:
|
||||||
@@ -360,7 +360,7 @@ gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
|
|||||||
|
|
||||||
**Docker:**
|
**Docker:**
|
||||||
```bash
|
```bash
|
||||||
docker-compose up web
|
docker-compose -f docker/docker-compose.yml up web
|
||||||
```
|
```
|
||||||
|
|
||||||
### First-Time Setup
|
### First-Time Setup
|
||||||
@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
|
|||||||
| Use PIN | on/off | on | Generate a numeric PIN |
|
| Use PIN | on/off | on | Generate a numeric PIN |
|
||||||
| PIN length | 6-9 | 6 | Digits in the PIN |
|
| PIN length | 6-9 | 6 | Digits in the PIN |
|
||||||
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
||||||
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
|
| RSA key size | 2048/3072 | 2048 | Key size in bits |
|
||||||
|
|
||||||
#### Entropy Calculator
|
#### Entropy Calculator
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@ volumes:
|
|||||||
```bash
|
```bash
|
||||||
pip install scipy
|
pip install scipy
|
||||||
# Or rebuild Docker image
|
# Or rebuild Docker image
|
||||||
docker-compose build --no-cache
|
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||||
```
|
```
|
||||||
|
|
||||||
### Browser Compatibility
|
### Browser Compatibility
|
||||||
|
|||||||
23
aur-api/.SRCINFO
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pkgbase = stegasoo-api-git
|
||||||
|
pkgdesc = Stegasoo REST API with TLS and API key authentication
|
||||||
|
pkgver = 4.2.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/adlee-was-taken/stegasoo
|
||||||
|
install = stegasoo-api-git.install
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
makedepends = git
|
||||||
|
makedepends = python
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
depends = python>=3.11
|
||||||
|
depends = zbar
|
||||||
|
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||||
|
provides = stegasoo-api
|
||||||
|
conflicts = stegasoo-api
|
||||||
|
conflicts = stegasoo
|
||||||
|
conflicts = stegasoo-git
|
||||||
|
source = stegasoo-api-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = stegasoo-api-git
|
||||||
109
aur-api/PKGBUILD
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-api-git
|
||||||
|
pkgver=4.2.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
'zbar' # QR code reading for RSA key extraction
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||||
|
)
|
||||||
|
provides=('stegasoo-api')
|
||||||
|
conflicts=('stegasoo-api' 'stegasoo' 'stegasoo-git')
|
||||||
|
install=stegasoo-api-git.install
|
||||||
|
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$pkgname"
|
||||||
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
|
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Detect Python version for site-packages path
|
||||||
|
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo-api with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-api"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo-api/venv"
|
||||||
|
|
||||||
|
# Install the wheel with API + CLI + compression extras
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo-api/venv/bin/pip" install --no-cache-dir "${wheel}[api,cli,compression]"
|
||||||
|
|
||||||
|
# Install API frontend (not included in wheel by default)
|
||||||
|
local site_packages="$pkgdir/opt/stegasoo-api/venv/lib/python${pyver}/site-packages"
|
||||||
|
install -dm755 "$site_packages/frontends/api"
|
||||||
|
cp -r frontends/api/*.py "$site_packages/frontends/api/"
|
||||||
|
cp -r frontends/__init__.py "$site_packages/frontends/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create temp directory for API
|
||||||
|
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||||
|
|
||||||
|
# Create config directories
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-api/config"
|
||||||
|
install -dm700 "$pkgdir/opt/stegasoo-api/certs"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo-api/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo-api/venv|/opt/stegasoo-api/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-api/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlink to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo-api/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||||
|
|
||||||
|
# Install license
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# Install docs
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API (HTTPS)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo-api/venv/lib/python${pyver}/site-packages/frontends/api
|
||||||
|
Environment="PATH=/opt/stegasoo-api/venv/bin"
|
||||||
|
Environment="HOME=/opt/stegasoo-api"
|
||||||
|
# TLS enabled by default - certs auto-generated on first run
|
||||||
|
# Use: stegasoo api tls generate (to pre-generate certs)
|
||||||
|
# Use: stegasoo api keys create <name> (to create API keys)
|
||||||
|
ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
48
aur-api/stegasoo-api-git.install
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
post_install() {
|
||||||
|
# Create stegasoo system user if it doesn't exist
|
||||||
|
if ! getent passwd stegasoo >/dev/null; then
|
||||||
|
useradd -r -s /usr/bin/nologin -d /opt/stegasoo-api stegasoo
|
||||||
|
echo "Created system user 'stegasoo'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set ownership of directories
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo-api/config 2>/dev/null || true
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo-api/certs 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Stegasoo API installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Quick Start:"
|
||||||
|
echo " 1. Create an API key:"
|
||||||
|
echo " stegasoo api keys create mykey"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Start the API server:"
|
||||||
|
echo " sudo systemctl start stegasoo-api"
|
||||||
|
echo ""
|
||||||
|
echo " 3. Access the API:"
|
||||||
|
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
|
||||||
|
echo ""
|
||||||
|
echo "Management commands:"
|
||||||
|
echo " stegasoo api keys list # List API keys"
|
||||||
|
echo " stegasoo api keys create X # Create new key"
|
||||||
|
echo " stegasoo api tls info # Show certificate info"
|
||||||
|
echo " stegasoo api serve --help # Server options"
|
||||||
|
echo ""
|
||||||
|
echo "API docs available at: https://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_remove() {
|
||||||
|
# Stop service if running
|
||||||
|
systemctl stop stegasoo-api 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
post_remove() {
|
||||||
|
echo "Stegasoo API removed."
|
||||||
|
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
|
||||||
|
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
|
||||||
|
}
|
||||||
22
aur-api/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR API package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-api-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Generating .SRCINFO ==="
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
echo "=== Building package ==="
|
||||||
|
makepkg -sf
|
||||||
|
|
||||||
|
echo "=== Package built ==="
|
||||||
|
ls -la *.pkg.tar.zst
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To install: sudo pacman -U stegasoo-api-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
22
aur-cli/.SRCINFO
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pkgbase = stegasoo-cli-git
|
||||||
|
pkgdesc = Secure steganography CLI with hybrid photo + passphrase + PIN authentication
|
||||||
|
pkgver = 4.2.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/adlee-was-taken/stegasoo
|
||||||
|
install = stegasoo-cli-git.install
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
makedepends = git
|
||||||
|
makedepends = python
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
depends = python>=3.11
|
||||||
|
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||||
|
provides = stegasoo-cli
|
||||||
|
conflicts = stegasoo-cli
|
||||||
|
conflicts = stegasoo
|
||||||
|
conflicts = stegasoo-git
|
||||||
|
source = stegasoo-cli-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = stegasoo-cli-git
|
||||||
69
aur-cli/PKGBUILD
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-cli-git
|
||||||
|
pkgver=4.2.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||||
|
)
|
||||||
|
provides=('stegasoo-cli')
|
||||||
|
conflicts=('stegasoo-cli' 'stegasoo' 'stegasoo-git')
|
||||||
|
install=stegasoo-cli-git.install
|
||||||
|
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$pkgname"
|
||||||
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
|
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo-cli with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-cli"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo-cli/venv"
|
||||||
|
|
||||||
|
# Install the wheel with CLI + DCT + compression extras (no web/api)
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo-cli/venv/bin/pip" install --no-cache-dir "${wheel}[cli,dct,compression]"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo-cli/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo-cli/venv|/opt/stegasoo-cli/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-cli/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlink to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo-cli/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||||
|
|
||||||
|
# Install license
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# Install docs
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
}
|
||||||
17
aur-cli/stegasoo-cli-git.install
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
post_install() {
|
||||||
|
echo ""
|
||||||
|
echo "Stegasoo CLI installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " stegasoo --help # Show all commands"
|
||||||
|
echo " stegasoo encode ... # Hide data in an image"
|
||||||
|
echo " stegasoo decode ... # Extract hidden data"
|
||||||
|
echo " stegasoo tools --help # Image tools (compress, rotate, etc.)"
|
||||||
|
echo ""
|
||||||
|
echo "For web UI or REST API, install stegasoo-git instead."
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
22
aur-cli/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR CLI package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-cli-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Generating .SRCINFO ==="
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
echo "=== Building package ==="
|
||||||
|
makepkg -sf
|
||||||
|
|
||||||
|
echo "=== Package built ==="
|
||||||
|
ls -la *.pkg.tar.zst
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To install: sudo pacman -U stegasoo-cli-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
120
aur/PKGBUILD
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-git
|
||||||
|
pkgver=4.2.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
'zbar' # QR code reading for Web UI
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
provides=('stegasoo')
|
||||||
|
conflicts=('stegasoo')
|
||||||
|
install=stegasoo-git.install
|
||||||
|
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$pkgname"
|
||||||
|
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||||
|
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Detect Python version for site-packages path
|
||||||
|
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo/venv"
|
||||||
|
|
||||||
|
# Install the wheel with all extras
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
|
||||||
|
|
||||||
|
# Install frontends (not included in wheel)
|
||||||
|
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
|
||||||
|
cp -r frontends "$site_packages/"
|
||||||
|
|
||||||
|
# Create writable directories for stegasoo user
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
|
||||||
|
install -dm755 "$site_packages/frontends/web/temp_files"
|
||||||
|
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlinks to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||||
|
|
||||||
|
# Install license
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
# Install docs
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# Install systemd service files
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo Web UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
|
||||||
|
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||||
|
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API (HTTPS)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||||
|
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||||
|
Environment="HOME=/opt/stegasoo"
|
||||||
|
# TLS enabled by default - certs auto-generated on first run
|
||||||
|
# Use stegasoo api tls generate to pre-generate certs
|
||||||
|
# Use stegasoo api keys create <name> to create API keys
|
||||||
|
ExecStart=/opt/stegasoo/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
79
aur/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Stegasoo AUR Package
|
||||||
|
|
||||||
|
> **Note:** Uses Python 3.12 via `python312` AUR package (jpegio not yet compatible with 3.13)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From AUR (once published)
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git
|
||||||
|
# or
|
||||||
|
paru -S stegasoo-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual build
|
||||||
|
```bash
|
||||||
|
git clone https://aur.archlinux.org/stegasoo-git.git
|
||||||
|
cd stegasoo-git
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Installed
|
||||||
|
|
||||||
|
- `/opt/stegasoo/venv/` - Self-contained Python 3.12 venv with all dependencies
|
||||||
|
- `/usr/bin/stegasoo` - CLI symlink
|
||||||
|
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service
|
||||||
|
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service
|
||||||
|
|
||||||
|
## Optional Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# QR code reading from webcam/images
|
||||||
|
sudo pacman -S zbar
|
||||||
|
```
|
||||||
|
|
||||||
|
All other dependencies are bundled in the venv.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
```bash
|
||||||
|
stegasoo --help
|
||||||
|
stegasoo generate --rsa --qr-ascii
|
||||||
|
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P passphrase -p 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web UI (systemd)
|
||||||
|
```bash
|
||||||
|
# Create service user (first time)
|
||||||
|
sudo useradd -r -s /usr/bin/nologin stegasoo
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
sudo systemctl enable --now stegasoo-web
|
||||||
|
|
||||||
|
# Access at http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST API (systemd)
|
||||||
|
```bash
|
||||||
|
# Start service
|
||||||
|
sudo systemctl enable --now stegasoo-api
|
||||||
|
|
||||||
|
# Access at http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual run (without systemd)
|
||||||
|
```bash
|
||||||
|
# Web UI
|
||||||
|
/opt/stegasoo/venv/bin/python -m gunicorn -b 0.0.0.0:5000 \
|
||||||
|
--chdir /opt/stegasoo/venv/lib/python3.12/site-packages/frontends/web app:app
|
||||||
|
|
||||||
|
# REST API
|
||||||
|
/opt/stegasoo/venv/bin/uvicorn \
|
||||||
|
--app-dir /opt/stegasoo/venv/lib/python3.12/site-packages/frontends/api \
|
||||||
|
main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainer
|
||||||
|
|
||||||
|
Aaron D. Lee
|
||||||
40
aur/stegasoo-git.install
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
post_install() {
|
||||||
|
# Create stegasoo system user if it doesn't exist
|
||||||
|
if ! getent passwd stegasoo >/dev/null; then
|
||||||
|
useradd -r -s /usr/bin/nologin -d /opt/stegasoo stegasoo
|
||||||
|
echo "Created system user 'stegasoo'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set ownership of instance directory for Flask
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo/venv/var/app-instance 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Stegasoo installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "CLI usage:"
|
||||||
|
echo " stegasoo --help"
|
||||||
|
echo ""
|
||||||
|
echo "To start the web UI:"
|
||||||
|
echo " sudo systemctl start stegasoo-web"
|
||||||
|
echo ""
|
||||||
|
echo "To start the REST API:"
|
||||||
|
echo " sudo systemctl start stegasoo-api"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_remove() {
|
||||||
|
# Stop services if running
|
||||||
|
systemctl stop stegasoo-web 2>/dev/null || true
|
||||||
|
systemctl stop stegasoo-api 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
post_remove() {
|
||||||
|
# Optionally remove the stegasoo user
|
||||||
|
# userdel stegasoo 2>/dev/null || true
|
||||||
|
echo "Stegasoo removed. User 'stegasoo' was not removed."
|
||||||
|
echo "To remove: userdel stegasoo"
|
||||||
|
}
|
||||||
22
aur/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Generating .SRCINFO ==="
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
|
||||||
|
echo "=== Building package ==="
|
||||||
|
makepkg -sf
|
||||||
|
|
||||||
|
echo "=== Package built ==="
|
||||||
|
ls -la *.pkg.tar.zst
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To install: sudo pacman -U stegasoo-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Recover.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Tools.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -35,12 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libzbar0 \
|
libzbar0 \
|
||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
|
curl \
|
||||||
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install ALL dependencies (slow path)
|
# Install ALL dependencies (slow path)
|
||||||
RUN pip install --no-cache-dir \
|
RUN pip install --no-cache-dir \
|
||||||
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
|
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
|
||||||
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
|
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
|
||||||
|
reedsolo>=1.7.0 \
|
||||||
flask>=3.0.0 gunicorn>=21.0.0 \
|
flask>=3.0.0 gunicorn>=21.0.0 \
|
||||||
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
|
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
|
||||||
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
|
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
|
||||||
@@ -57,6 +60,12 @@ FROM base AS web
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies (curl for healthcheck, openssl for cert generation)
|
||||||
|
USER root
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy application files (this is all that rebuilds normally!)
|
# Copy application files (this is all that rebuilds normally!)
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY data/ data/
|
COPY data/ data/
|
||||||
@@ -66,6 +75,10 @@ COPY frontends/web/ frontends/web/
|
|||||||
# temp_files is for multi-worker temp file sharing
|
# temp_files is for multi-worker temp file sharing
|
||||||
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
|
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
|
||||||
|
|
||||||
|
# Copy and set up entrypoint (before switching to non-root user)
|
||||||
|
COPY frontends/web/docker-entrypoint.sh /app/frontends/web/
|
||||||
|
RUN chmod +x /app/frontends/web/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
||||||
USER stego
|
USER stego
|
||||||
@@ -77,12 +90,12 @@ ENV PYTHONPATH=/app/src
|
|||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
|
CMD curl -fsk https://localhost:5000/ || curl -fs http://localhost:5000/ || exit 1
|
||||||
|
|
||||||
# Run with gunicorn
|
# Run with entrypoint (handles HTTPS/HTTP mode)
|
||||||
WORKDIR /app/frontends/web
|
WORKDIR /app/frontends/web
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
ENTRYPOINT ["/app/frontends/web/docker-entrypoint.sh"]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# API stage - REST API
|
# API stage - REST API
|
||||||
@@ -32,7 +32,9 @@ RUN pip install --no-cache-dir \
|
|||||||
jpegio>=0.2.0 \
|
jpegio>=0.2.0 \
|
||||||
argon2-cffi>=23.0.0 \
|
argon2-cffi>=23.0.0 \
|
||||||
pillow>=10.0.0 \
|
pillow>=10.0.0 \
|
||||||
cryptography>=41.0.0
|
cryptography>=41.0.0 \
|
||||||
|
reedsolo>=1.7.0 \
|
||||||
|
zstandard>=0.22.0
|
||||||
|
|
||||||
# Install web/api framework packages (also stable)
|
# Install web/api framework packages (also stable)
|
||||||
RUN pip install --no-cache-dir \
|
RUN pip install --no-cache-dir \
|
||||||
@@ -47,9 +49,9 @@ RUN pip install --no-cache-dir \
|
|||||||
lz4>=4.0.0
|
lz4>=4.0.0
|
||||||
|
|
||||||
# Verify key packages work
|
# Verify key packages work
|
||||||
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
|
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
|
||||||
|
|
||||||
# Label for tracking
|
# Label for tracking
|
||||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||||
LABEL org.opencontainers.image.version="4.0.0"
|
LABEL org.opencontainers.image.version="4.2.1"
|
||||||
@@ -8,7 +8,8 @@ services:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
target: web
|
target: web
|
||||||
container_name: stegasoo-web
|
container_name: stegasoo-web
|
||||||
ports:
|
ports:
|
||||||
@@ -18,7 +19,9 @@ services:
|
|||||||
FLASK_ENV: production
|
FLASK_ENV: production
|
||||||
# Authentication (v4.0.2)
|
# Authentication (v4.0.2)
|
||||||
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
|
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
|
||||||
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
|
# HTTPS enabled by default - generates self-signed cert if none provided
|
||||||
|
# To disable: STEGASOO_HTTPS_ENABLED=false docker-compose up
|
||||||
|
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-true}
|
||||||
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
|
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
|
||||||
volumes:
|
volumes:
|
||||||
# Persist auth database and SSL certs (v4.0.2)
|
# Persist auth database and SSL certs (v4.0.2)
|
||||||
@@ -37,7 +40,8 @@ services:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
target: api
|
target: api
|
||||||
container_name: stegasoo-api
|
container_name: stegasoo-api
|
||||||
ports:
|
ports:
|
||||||
162
docs/DOCKER_QUICKSTART.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Docker Quickstart
|
||||||
|
|
||||||
|
Get Stegasoo running in Docker in under 5 minutes.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root:
|
||||||
|
|
||||||
|
# Build web UI image
|
||||||
|
sudo docker build -t stegasoo-web --target web -f docker/Dockerfile .
|
||||||
|
|
||||||
|
# Or build all targets
|
||||||
|
sudo docker build -t stegasoo-api --target api -f docker/Dockerfile .
|
||||||
|
sudo docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
|
||||||
|
|
||||||
|
# Or use docker-compose
|
||||||
|
sudo docker-compose -f docker/docker-compose.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run (Basic)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTTP only, no auth
|
||||||
|
sudo docker run -d \
|
||||||
|
-p 5000:5000 \
|
||||||
|
-e STEGASOO_AUTH_ENABLED=false \
|
||||||
|
--name stegasoo \
|
||||||
|
stegasoo-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit http://localhost:5000
|
||||||
|
|
||||||
|
## Run (Production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTTPS + Auth + Channel Key
|
||||||
|
sudo docker run -d \
|
||||||
|
-p 5000:5000 \
|
||||||
|
-e STEGASOO_AUTH_ENABLED=true \
|
||||||
|
-e STEGASOO_HTTPS_ENABLED=true \
|
||||||
|
-e STEGASOO_HOSTNAME=stegasoo.local \
|
||||||
|
-e STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456 \
|
||||||
|
-v stegasoo-data:/opt/stegasoo/frontends/web/instance \
|
||||||
|
-v stegasoo-certs:/opt/stegasoo/frontends/web/certs \
|
||||||
|
--name stegasoo \
|
||||||
|
stegasoo-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit https://localhost:5000 (accept self-signed cert warning)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `STEGASOO_AUTH_ENABLED` | `true` | Require login |
|
||||||
|
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS |
|
||||||
|
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for SSL cert |
|
||||||
|
| `STEGASOO_CHANNEL_KEY` | *(none)* | Shared channel key (32 alphanumeric chars with dashes) |
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Create `.env` file in project root:
|
||||||
|
```bash
|
||||||
|
STEGASOO_AUTH_ENABLED=true
|
||||||
|
STEGASOO_HTTPS_ENABLED=true
|
||||||
|
STEGASOO_HOSTNAME=stegasoo.local
|
||||||
|
STEGASOO_CHANNEL_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
sudo docker-compose -f docker/docker-compose.yml up -d web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom SSL Certificates
|
||||||
|
|
||||||
|
### Use Your Own Certs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop container
|
||||||
|
sudo docker stop stegasoo
|
||||||
|
|
||||||
|
# Copy certs to volume
|
||||||
|
sudo docker run --rm -v stegasoo-certs:/certs -v $(pwd):/src alpine \
|
||||||
|
sh -c "cp /src/your-cert.crt /certs/server.crt && cp /src/your-key.key /certs/server.key && chmod 600 /certs/server.key"
|
||||||
|
|
||||||
|
# Start container
|
||||||
|
sudo docker start stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use mkcert (Local Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install mkcert
|
||||||
|
brew install mkcert # macOS
|
||||||
|
# or: sudo apt install mkcert # Debian/Ubuntu
|
||||||
|
|
||||||
|
# Create local CA and certs
|
||||||
|
mkcert -install
|
||||||
|
mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1 stegasoo.local
|
||||||
|
|
||||||
|
# Copy to Docker volume (see above)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Let's Encrypt (Public Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get cert
|
||||||
|
sudo certbot certonly --standalone -d yourdomain.com
|
||||||
|
|
||||||
|
# Copy to Docker volume
|
||||||
|
sudo docker run --rm -v stegasoo-certs:/certs alpine \
|
||||||
|
sh -c "cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /certs/server.crt && \
|
||||||
|
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /certs/server.key && \
|
||||||
|
chmod 600 /certs/server.key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
| Volume | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `stegasoo-data` | User database, settings |
|
||||||
|
| `stegasoo-certs` | SSL certificates |
|
||||||
|
|
||||||
|
## Smoke Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check container logs
|
||||||
|
sudo docker logs stegasoo
|
||||||
|
|
||||||
|
# Test HTTP endpoint
|
||||||
|
curl -k https://localhost:5000/health
|
||||||
|
|
||||||
|
# Expected: {"status":"ok","version":"4.1.7",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Container won't start:**
|
||||||
|
```bash
|
||||||
|
sudo docker logs stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Out of memory:**
|
||||||
|
```bash
|
||||||
|
# Argon2 needs 256MB+ per operation
|
||||||
|
sudo docker run --memory=768m ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Certificate errors:**
|
||||||
|
```bash
|
||||||
|
# Regenerate self-signed cert
|
||||||
|
sudo docker exec stegasoo rm -rf /opt/stegasoo/frontends/web/certs/*
|
||||||
|
sudo docker restart stegasoo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset everything:**
|
||||||
|
```bash
|
||||||
|
sudo docker stop stegasoo && sudo docker rm stegasoo
|
||||||
|
sudo docker volume rm stegasoo-data stegasoo-certs
|
||||||
|
```
|
||||||
@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
|||||||
- `use_pin` - checkbox
|
- `use_pin` - checkbox
|
||||||
- `pin_length` - PIN digits (6-9)
|
- `pin_length` - PIN digits (6-9)
|
||||||
- `use_rsa` - checkbox
|
- `use_rsa` - checkbox
|
||||||
- `rsa_bits` - key size (2048/3072/4096)
|
- `rsa_bits` - key size (2048/3072)
|
||||||
|
|
||||||
**Output panels:**
|
**Output panels:**
|
||||||
- Passphrase display
|
- Passphrase display
|
||||||
|
|||||||
340
docs/stegasoo.1
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
.\" Stegasoo man page
|
||||||
|
.\" Generate with: groff -man -Tascii stegasoo.1
|
||||||
|
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
stegasoo \- steganography with hybrid authentication
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B stegasoo
|
||||||
|
[\fB\-v\fR|\fB\-\-version\fR]
|
||||||
|
[\fB\-\-json\fR]
|
||||||
|
[\fB\-h\fR|\fB\-\-help\fR]
|
||||||
|
.I command
|
||||||
|
[\fIargs\fR]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B stegasoo
|
||||||
|
hides messages and files in images using PIN + passphrase security.
|
||||||
|
It uses LSB (Least Significant Bit) steganography with optional DCT
|
||||||
|
(Discrete Cosine Transform) encoding for JPEG resilience.
|
||||||
|
.PP
|
||||||
|
Messages are encrypted using a hybrid authentication scheme that combines
|
||||||
|
a reference photo (shared secret), passphrase, and PIN code.
|
||||||
|
.SH GLOBAL OPTIONS
|
||||||
|
.TP
|
||||||
|
.BR \-v ", " \-\-version
|
||||||
|
Show version and exit.
|
||||||
|
.TP
|
||||||
|
.B \-\-json
|
||||||
|
Output results as JSON (where supported).
|
||||||
|
.TP
|
||||||
|
.BR \-h ", " \-\-help
|
||||||
|
Show help message and exit.
|
||||||
|
.SH COMMANDS
|
||||||
|
.SS encode
|
||||||
|
Encode a message or file into an image.
|
||||||
|
.PP
|
||||||
|
.B stegasoo encode
|
||||||
|
.I carrier
|
||||||
|
.B \-r
|
||||||
|
.I reference
|
||||||
|
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||||
|
Reference photo (shared secret). Required.
|
||||||
|
.TP
|
||||||
|
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||||
|
Message to encode.
|
||||||
|
.TP
|
||||||
|
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||||
|
File to embed instead of message.
|
||||||
|
.TP
|
||||||
|
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||||
|
Output image path.
|
||||||
|
.TP
|
||||||
|
.B \-\-passphrase " " \fITEXT\fR
|
||||||
|
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-pin " " \fITEXT\fR
|
||||||
|
PIN code. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-dry\-run
|
||||||
|
Show capacity usage without encoding.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo encode photo.png -r ref.jpg -m "Secret" --passphrase --pin
|
||||||
|
stegasoo encode photo.png -r ref.jpg -f doc.pdf -o encoded.png
|
||||||
|
.fi
|
||||||
|
.SS decode
|
||||||
|
Decode a message or file from an image.
|
||||||
|
.PP
|
||||||
|
.B stegasoo decode
|
||||||
|
.I image
|
||||||
|
.B \-r
|
||||||
|
.I reference
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||||
|
Reference photo (shared secret). Required.
|
||||||
|
.TP
|
||||||
|
.B \-\-passphrase " " \fITEXT\fR
|
||||||
|
Passphrase. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.B \-\-pin " " \fITEXT\fR
|
||||||
|
PIN code. Prompts if not provided.
|
||||||
|
.TP
|
||||||
|
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||||
|
Output path for file payloads.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo decode encoded.png -r ref.jpg --passphrase --pin
|
||||||
|
stegasoo decode encoded.png -r ref.jpg -o ./extracted/
|
||||||
|
.fi
|
||||||
|
.SS generate
|
||||||
|
Generate random credentials (passphrase + PIN + optional channel key).
|
||||||
|
.PP
|
||||||
|
.B stegasoo generate
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.TP
|
||||||
|
.B \-\-words " " \fIINTEGER\fR
|
||||||
|
Number of words in passphrase (default: 4).
|
||||||
|
.TP
|
||||||
|
.B \-\-pin\-length " " \fIINTEGER\fR
|
||||||
|
PIN length (default: 6).
|
||||||
|
.TP
|
||||||
|
.B \-\-channel\-key
|
||||||
|
Also generate a 256-bit channel key.
|
||||||
|
.PP
|
||||||
|
.B Examples:
|
||||||
|
.nf
|
||||||
|
stegasoo generate
|
||||||
|
stegasoo generate --words 6 --pin-length 8
|
||||||
|
stegasoo generate --channel-key
|
||||||
|
.fi
|
||||||
|
.SS info
|
||||||
|
Show version, features, and system information.
|
||||||
|
.PP
|
||||||
|
.B stegasoo info
|
||||||
|
[\fB\-\-full\fR]
|
||||||
|
.TP
|
||||||
|
.B \-\-full
|
||||||
|
Show full system information (CPU, temperature, disk on Pi).
|
||||||
|
.SS batch
|
||||||
|
Batch operations on multiple images.
|
||||||
|
.PP
|
||||||
|
.B stegasoo batch
|
||||||
|
.I subcommand
|
||||||
|
[\fIargs\fR]
|
||||||
|
.TP
|
||||||
|
.B batch encode
|
||||||
|
Encode message into multiple images.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo batch encode
|
||||||
|
.I images...
|
||||||
|
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.PP
|
||||||
|
Options: \fB\-m\fR, \fB\-f\fR, \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-suffix\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR,
|
||||||
|
\fB\-r\fR/\fB\-\-recursive\fR, \fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B batch decode
|
||||||
|
Decode messages from multiple images.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo batch decode
|
||||||
|
.I images...
|
||||||
|
[\fIoptions\fR]
|
||||||
|
.PP
|
||||||
|
Options: \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR, \fB\-r\fR/\fB\-\-recursive\fR,
|
||||||
|
\fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B batch check
|
||||||
|
Check capacity of multiple images.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo batch check
|
||||||
|
.I images...
|
||||||
|
[\fB\-r\fR/\fB\-\-recursive\fR]
|
||||||
|
.RE
|
||||||
|
.SS channel
|
||||||
|
Manage channel keys for deployment isolation.
|
||||||
|
.PP
|
||||||
|
Channel keys bind encode/decode operations to a specific group or deployment.
|
||||||
|
Messages encoded with one channel key can only be decoded by systems with
|
||||||
|
the same channel key.
|
||||||
|
.PP
|
||||||
|
.B stegasoo channel
|
||||||
|
.I subcommand
|
||||||
|
[\fIargs\fR]
|
||||||
|
.TP
|
||||||
|
.B channel generate
|
||||||
|
Generate a new random channel key.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
Options: \fB\-\-save\fR (project config), \fB\-\-save\-user\fR (user config).
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B channel show
|
||||||
|
Show the current channel key.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
Options: \fB\-\-key\fR \fITEXT\fR (show specific key instead).
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B channel qr
|
||||||
|
Display channel key as QR code.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
Options: \fB\-\-key\fR \fITEXT\fR, \fB\-\-format\fR [\fIascii\fR|\fIpng\fR], \fB\-o\fR/\fB\-\-output\fR \fIPATH\fR.
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B channel status
|
||||||
|
Show channel key status and configuration.
|
||||||
|
.TP
|
||||||
|
.B channel clear
|
||||||
|
Remove channel key configuration.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
Options: \fB\-\-project\fR, \fB\-\-user\fR.
|
||||||
|
.RE
|
||||||
|
.SS admin
|
||||||
|
Web UI administration commands.
|
||||||
|
.PP
|
||||||
|
.B stegasoo admin
|
||||||
|
.I subcommand
|
||||||
|
[\fIargs\fR]
|
||||||
|
.TP
|
||||||
|
.B admin generate\-key
|
||||||
|
Generate a new recovery key (for reference only).
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
Options: \fB\-\-qr\fR (show QR code in terminal).
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B admin recover
|
||||||
|
Reset admin password using recovery key.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||||
|
.RE
|
||||||
|
.SS tools
|
||||||
|
Image security tools.
|
||||||
|
.PP
|
||||||
|
.B stegasoo tools
|
||||||
|
.I subcommand
|
||||||
|
[\fIargs\fR]
|
||||||
|
.TP
|
||||||
|
.B tools capacity
|
||||||
|
Show steganography capacity for an image.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo tools capacity
|
||||||
|
.I image
|
||||||
|
[\fB\-\-json\fR]
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B tools exif
|
||||||
|
View or edit EXIF metadata.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo tools exif
|
||||||
|
.I image
|
||||||
|
[\fB\-\-clear\fR] [\fB\-\-set\fR \fIFIELD=VALUE\fR] [\fB\-o\fR \fIPATH\fR] [\fB\-\-json\fR]
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B tools peek
|
||||||
|
Check if image contains Stegasoo hidden data.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo tools peek
|
||||||
|
.I image
|
||||||
|
[\fB\-\-json\fR]
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.B tools strip
|
||||||
|
Strip EXIF/metadata from an image.
|
||||||
|
.RS
|
||||||
|
.PP
|
||||||
|
.B stegasoo tools strip
|
||||||
|
.I image
|
||||||
|
[\fB\-o\fR \fIPATH\fR] [\fB\-\-format\fR [\fIpng\fR|\fIbmp\fR]]
|
||||||
|
.RE
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.B STEGASOO_CHANNEL_KEY
|
||||||
|
Channel key for encode/decode operations. Overrides config file settings.
|
||||||
|
.TP
|
||||||
|
.B STEGASOO_HTTPS_ENABLED
|
||||||
|
Enable HTTPS for web UI (Docker/service mode).
|
||||||
|
.TP
|
||||||
|
.B STEGASOO_HOSTNAME
|
||||||
|
Hostname for SSL certificate generation.
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.I ~/.stegasoo/channel.key
|
||||||
|
User's channel key configuration (encrypted).
|
||||||
|
.TP
|
||||||
|
.I .stegasoo.toml
|
||||||
|
Project-level configuration file.
|
||||||
|
.TP
|
||||||
|
.I frontends/web/instance/stegasoo.db
|
||||||
|
Web UI SQLite database (accounts, settings).
|
||||||
|
.SH EXAMPLES
|
||||||
|
.SS Basic encode/decode workflow
|
||||||
|
.nf
|
||||||
|
# Generate credentials
|
||||||
|
stegasoo generate
|
||||||
|
|
||||||
|
# Encode a secret message
|
||||||
|
stegasoo encode vacation.png -r selfie.jpg -m "Meet at noon"
|
||||||
|
|
||||||
|
# Decode the message (on another system with same reference photo)
|
||||||
|
stegasoo decode vacation_steg.png -r selfie.jpg
|
||||||
|
.fi
|
||||||
|
.SS Using channel keys for team isolation
|
||||||
|
.nf
|
||||||
|
# Generate and save a channel key
|
||||||
|
stegasoo channel generate --save-user
|
||||||
|
|
||||||
|
# Share the key with your team
|
||||||
|
stegasoo channel qr -o team-key.png
|
||||||
|
|
||||||
|
# Now all encode/decode operations use this channel
|
||||||
|
stegasoo encode photo.png -r ref.jpg -m "Team secret"
|
||||||
|
.fi
|
||||||
|
.SS Batch processing
|
||||||
|
.nf
|
||||||
|
# Check capacity of all PNGs in a directory
|
||||||
|
stegasoo batch check ./photos/*.png
|
||||||
|
|
||||||
|
# Encode same message into multiple images
|
||||||
|
stegasoo batch encode ./photos/ -r ref.jpg -m "Secret" -o ./encoded/
|
||||||
|
.fi
|
||||||
|
.SH SECURITY
|
||||||
|
Stegasoo uses multiple layers of security:
|
||||||
|
.IP \(bu 2
|
||||||
|
Reference photo provides a visual shared secret
|
||||||
|
.IP \(bu 2
|
||||||
|
Passphrase (recommend 4+ words) for strong encryption
|
||||||
|
.IP \(bu 2
|
||||||
|
PIN code adds additional entropy
|
||||||
|
.IP \(bu 2
|
||||||
|
Channel keys isolate different deployments
|
||||||
|
.IP \(bu 2
|
||||||
|
AES-256 encryption for payload data
|
||||||
|
.PP
|
||||||
|
For maximum security, share the reference photo out-of-band (in person,
|
||||||
|
secure messenger) and use a strong passphrase.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR openssl (1),
|
||||||
|
.BR qrencode (1)
|
||||||
|
.SH BUGS
|
||||||
|
Report bugs at: https://github.com/adlee-was-taken/stegasoo/issues
|
||||||
|
.SH AUTHOR
|
||||||
|
Written by the Stegasoo contributors.
|
||||||
|
.SH COPYRIGHT
|
||||||
|
Copyright \(co 2024-2026. MIT License.
|
||||||
0
frontends/__init__.py
Normal file
0
frontends/api/__init__.py
Normal file
256
frontends/api/auth.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
API Key Authentication for Stegasoo REST API.
|
||||||
|
|
||||||
|
Provides simple API key authentication with hashed key storage.
|
||||||
|
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from .auth import require_api_key, get_api_key_status
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected_endpoint(api_key: str = Depends(require_api_key)):
|
||||||
|
return {"status": "authenticated"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
|
# API key header name
|
||||||
|
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
# Config locations
|
||||||
|
USER_CONFIG_DIR = Path.home() / ".stegasoo"
|
||||||
|
PROJECT_CONFIG_DIR = Path("./config")
|
||||||
|
|
||||||
|
# Key file name
|
||||||
|
API_KEYS_FILE = "api_keys.json"
|
||||||
|
|
||||||
|
# Environment variable for API key (alternative to file)
|
||||||
|
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_key(key: str) -> str:
|
||||||
|
"""Hash an API key for storage."""
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_keys_file(location: str = "user") -> Path:
|
||||||
|
"""Get path to API keys file."""
|
||||||
|
if location == "project":
|
||||||
|
return PROJECT_CONFIG_DIR / API_KEYS_FILE
|
||||||
|
return USER_CONFIG_DIR / API_KEYS_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_keys(location: str = "user") -> dict:
|
||||||
|
"""Load API keys from config file."""
|
||||||
|
keys_file = _get_keys_file(location)
|
||||||
|
if keys_file.exists():
|
||||||
|
try:
|
||||||
|
with open(keys_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {"keys": [], "enabled": True}
|
||||||
|
return {"keys": [], "enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_keys(data: dict, location: str = "user") -> None:
|
||||||
|
"""Save API keys to config file."""
|
||||||
|
keys_file = _get_keys_file(location)
|
||||||
|
keys_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(keys_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Secure permissions (owner read/write only)
|
||||||
|
os.chmod(keys_file, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
"""Generate a new API key."""
|
||||||
|
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
# 32 bytes = 256 bits of entropy
|
||||||
|
random_part = secrets.token_hex(16)
|
||||||
|
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def add_api_key(name: str, location: str = "user") -> str:
|
||||||
|
"""
|
||||||
|
Generate and store a new API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Descriptive name for the key (e.g., "laptop", "automation")
|
||||||
|
location: "user" or "project"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The generated API key (only shown once!)
|
||||||
|
"""
|
||||||
|
key = generate_api_key()
|
||||||
|
key_hash = _hash_key(key)
|
||||||
|
|
||||||
|
data = _load_keys(location)
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
for existing in data["keys"]:
|
||||||
|
if existing["name"] == name:
|
||||||
|
raise ValueError(f"Key with name '{name}' already exists")
|
||||||
|
|
||||||
|
data["keys"].append({
|
||||||
|
"name": name,
|
||||||
|
"hash": key_hash,
|
||||||
|
"created": __import__("datetime").datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
_save_keys(data, location)
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def remove_api_key(name: str, location: str = "user") -> bool:
|
||||||
|
"""
|
||||||
|
Remove an API key by name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if key was found and removed, False otherwise
|
||||||
|
"""
|
||||||
|
data = _load_keys(location)
|
||||||
|
original_count = len(data["keys"])
|
||||||
|
|
||||||
|
data["keys"] = [k for k in data["keys"] if k["name"] != name]
|
||||||
|
|
||||||
|
if len(data["keys"]) < original_count:
|
||||||
|
_save_keys(data, location)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_api_keys(location: str = "user") -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all API keys (names and creation dates, not actual keys).
|
||||||
|
"""
|
||||||
|
data = _load_keys(location)
|
||||||
|
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
|
||||||
|
"""Enable or disable API key authentication."""
|
||||||
|
data = _load_keys(location)
|
||||||
|
data["enabled"] = enabled
|
||||||
|
_save_keys(data, location)
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_enabled() -> bool:
|
||||||
|
"""Check if API key authentication is enabled."""
|
||||||
|
# Check project config first, then user config
|
||||||
|
for location in ["project", "user"]:
|
||||||
|
data = _load_keys(location)
|
||||||
|
if "enabled" in data:
|
||||||
|
return data["enabled"]
|
||||||
|
|
||||||
|
# Default: enabled if any keys exist
|
||||||
|
return bool(get_all_key_hashes())
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_key_hashes() -> set[str]:
|
||||||
|
"""Get all valid API key hashes from all sources."""
|
||||||
|
hashes = set()
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
env_key = os.environ.get(API_KEY_ENV_VAR)
|
||||||
|
if env_key:
|
||||||
|
hashes.add(_hash_key(env_key))
|
||||||
|
|
||||||
|
# Check project and user configs
|
||||||
|
for location in ["project", "user"]:
|
||||||
|
data = _load_keys(location)
|
||||||
|
for key_entry in data.get("keys", []):
|
||||||
|
if "hash" in key_entry:
|
||||||
|
hashes.add(key_entry["hash"])
|
||||||
|
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_key(key: str) -> bool:
|
||||||
|
"""Validate an API key against stored hashes."""
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
key_hash = _hash_key(key)
|
||||||
|
valid_hashes = get_all_key_hashes()
|
||||||
|
|
||||||
|
return key_hash in valid_hashes
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key_status() -> dict:
|
||||||
|
"""Get current API key authentication status."""
|
||||||
|
user_keys = list_api_keys("user")
|
||||||
|
project_keys = list_api_keys("project")
|
||||||
|
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
|
||||||
|
|
||||||
|
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": is_auth_enabled(),
|
||||||
|
"total_keys": total_keys,
|
||||||
|
"user_keys": len(user_keys),
|
||||||
|
"project_keys": len(project_keys),
|
||||||
|
"env_configured": env_configured,
|
||||||
|
"keys": {
|
||||||
|
"user": user_keys,
|
||||||
|
"project": project_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI dependency for API key authentication
|
||||||
|
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that requires a valid API key.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/protected")
|
||||||
|
async def endpoint(key: str = Depends(require_api_key)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
# Check if auth is enabled
|
||||||
|
if not is_auth_enabled():
|
||||||
|
return "auth_disabled"
|
||||||
|
|
||||||
|
# No keys configured = auth disabled
|
||||||
|
if not get_all_key_hashes():
|
||||||
|
return "no_keys_configured"
|
||||||
|
|
||||||
|
# Validate the provided key
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="API key required. Provide X-API-Key header.",
|
||||||
|
headers={"WWW-Authenticate": "ApiKey"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_api_key(api_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Invalid API key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that optionally validates API key.
|
||||||
|
|
||||||
|
Returns the key if valid, None if not provided or invalid.
|
||||||
|
Doesn't raise exceptions - useful for endpoints that work
|
||||||
|
with or without auth.
|
||||||
|
"""
|
||||||
|
if api_key and validate_api_key(api_key):
|
||||||
|
return api_key
|
||||||
|
return None
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Stegasoo REST API (v4.0.0)
|
Stegasoo REST API (v4.2.1)
|
||||||
|
|
||||||
FastAPI-based REST API for steganography operations.
|
FastAPI-based REST API for steganography operations.
|
||||||
Supports both text messages and file embedding.
|
Supports both text messages and file embedding.
|
||||||
|
|
||||||
|
CHANGES in v4.2.1:
|
||||||
|
- API key authentication (X-API-Key header)
|
||||||
|
- TLS support with self-signed certificates
|
||||||
|
- /auth/* endpoints for key management
|
||||||
|
|
||||||
|
CHANGES in v4.2.0:
|
||||||
|
- Async encode/decode operations (run in thread pool)
|
||||||
|
- Server can handle concurrent requests without blocking
|
||||||
|
|
||||||
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 /channel endpoints for key management
|
- New /channel endpoints for key management
|
||||||
@@ -21,15 +30,38 @@ NEW in v3.0: LSB and DCT embedding modes.
|
|||||||
NEW in v3.0.1: DCT color mode and JPEG output format.
|
NEW in v3.0.1: DCT color mode and JPEG output format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import sys
|
import sys
|
||||||
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# API Key Authentication
|
||||||
|
try:
|
||||||
|
from .auth import (
|
||||||
|
require_api_key,
|
||||||
|
get_api_key_status,
|
||||||
|
add_api_key,
|
||||||
|
remove_api_key,
|
||||||
|
list_api_keys,
|
||||||
|
is_auth_enabled,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
# When running directly (not as package)
|
||||||
|
from auth import (
|
||||||
|
require_api_key,
|
||||||
|
get_api_key_status,
|
||||||
|
add_api_key,
|
||||||
|
remove_api_key,
|
||||||
|
list_api_keys,
|
||||||
|
is_auth_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
# Add parent to path for development
|
# Add parent to path for development
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
@@ -68,13 +100,20 @@ from stegasoo.constants import (
|
|||||||
try:
|
try:
|
||||||
from stegasoo.qr_utils import (
|
from stegasoo.qr_utils import (
|
||||||
extract_key_from_qr,
|
extract_key_from_qr,
|
||||||
|
generate_qr_ascii,
|
||||||
|
generate_qr_code,
|
||||||
has_qr_read,
|
has_qr_read,
|
||||||
|
has_qr_write,
|
||||||
)
|
)
|
||||||
|
|
||||||
HAS_QR_READ = has_qr_read()
|
HAS_QR_READ = has_qr_read()
|
||||||
|
HAS_QR_WRITE = has_qr_write()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_QR_READ = False
|
HAS_QR_READ = False
|
||||||
|
HAS_QR_WRITE = False
|
||||||
extract_key_from_qr = None
|
extract_key_from_qr = None
|
||||||
|
generate_qr_code = None
|
||||||
|
generate_qr_ascii = None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -344,6 +383,23 @@ class ChannelSetRequest(BaseModel):
|
|||||||
location: str = Field(default="user", description="'user' or 'project'")
|
location: str = Field(default="user", description="'user' or 'project'")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatusResponse(BaseModel):
|
||||||
|
"""Response for API key authentication status."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Whether API key auth is enabled")
|
||||||
|
total_keys: int = Field(description="Total number of configured API keys")
|
||||||
|
user_keys: int = Field(description="Keys in user config")
|
||||||
|
project_keys: int = Field(description="Keys in project config")
|
||||||
|
env_configured: bool = Field(description="Whether env var key is set")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthKeyInfo(BaseModel):
|
||||||
|
"""Info about a single API key (not the actual key)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
created: str
|
||||||
|
|
||||||
|
|
||||||
class ModesResponse(BaseModel):
|
class ModesResponse(BaseModel):
|
||||||
"""Response showing available embedding modes."""
|
"""Response showing available embedding modes."""
|
||||||
|
|
||||||
@@ -357,6 +413,7 @@ class StatusResponse(BaseModel):
|
|||||||
version: str
|
version: str
|
||||||
has_argon2: bool
|
has_argon2: bool
|
||||||
has_qrcode_read: bool
|
has_qrcode_read: bool
|
||||||
|
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||||
has_dct: bool
|
has_dct: bool
|
||||||
max_payload_kb: int
|
max_payload_kb: int
|
||||||
available_modes: list[str]
|
available_modes: list[str]
|
||||||
@@ -372,6 +429,32 @@ class QrExtractResponse(BaseModel):
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class QrGenerateRequest(BaseModel):
|
||||||
|
"""Request to generate QR code from RSA key."""
|
||||||
|
|
||||||
|
key_pem: str = Field(..., description="RSA private key in PEM format")
|
||||||
|
output_format: str = Field(
|
||||||
|
default="png",
|
||||||
|
description="Output format: 'png', 'jpg', or 'ascii'",
|
||||||
|
)
|
||||||
|
compress: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Compress key data with zstd (recommended for larger keys)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QrGenerateResponse(BaseModel):
|
||||||
|
"""Response containing generated QR code."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
format: str | None = None
|
||||||
|
qr_data: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Base64-encoded image data (for png/jpg) or ASCII string",
|
||||||
|
)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WillFitRequest(BaseModel):
|
class WillFitRequest(BaseModel):
|
||||||
"""Request to check if payload will fit."""
|
"""Request to check if payload will fit."""
|
||||||
|
|
||||||
@@ -436,6 +519,27 @@ def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
|
|||||||
return info["mode"], info.get("fingerprint")
|
return info["mode"], info.get("fingerprint")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HELPER: ASYNC EXECUTION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def run_in_thread(func, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Run a CPU-bound function in a thread pool.
|
||||||
|
|
||||||
|
This allows the FastAPI server to handle other requests while
|
||||||
|
encode/decode operations are running. Essential for Pi deployments
|
||||||
|
where operations can take several seconds.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
result = await run_in_thread(encode, message=msg, carrier_image=carrier, ...)
|
||||||
|
"""
|
||||||
|
if kwargs:
|
||||||
|
func = partial(func, **kwargs)
|
||||||
|
return await asyncio.to_thread(func, *args)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ROUTES - STATUS & INFO
|
# ROUTES - STATUS & INFO
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -469,6 +573,7 @@ async def root():
|
|||||||
version=__version__,
|
version=__version__,
|
||||||
has_argon2=has_argon2(),
|
has_argon2=has_argon2(),
|
||||||
has_qrcode_read=HAS_QR_READ,
|
has_qrcode_read=HAS_QR_READ,
|
||||||
|
has_qrcode_write=HAS_QR_WRITE,
|
||||||
has_dct=has_dct_support(),
|
has_dct=has_dct_support(),
|
||||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||||
available_modes=available_modes,
|
available_modes=available_modes,
|
||||||
@@ -552,6 +657,7 @@ async def api_channel_status(
|
|||||||
|
|
||||||
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
|
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
|
||||||
async def api_channel_generate(
|
async def api_channel_generate(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
save: bool = Query(False, description="Save to user config"),
|
save: bool = Query(False, description="Save to user config"),
|
||||||
save_project: bool = Query(False, description="Save to project config"),
|
save_project: bool = Query(False, description="Save to project config"),
|
||||||
):
|
):
|
||||||
@@ -590,7 +696,7 @@ async def api_channel_generate(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/channel/set")
|
@app.post("/channel/set")
|
||||||
async def api_channel_set(request: ChannelSetRequest):
|
async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Set/save a channel key to config.
|
Set/save a channel key to config.
|
||||||
|
|
||||||
@@ -616,6 +722,7 @@ async def api_channel_set(request: ChannelSetRequest):
|
|||||||
|
|
||||||
@app.delete("/channel")
|
@app.delete("/channel")
|
||||||
async def api_channel_clear(
|
async def api_channel_clear(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
location: str = Query("user", description="'user', 'project', or 'all'")
|
location: str = Query("user", description="'user', 'project', or 'all'")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -642,8 +749,98 @@ async def api_channel_clear(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ROUTES - AUTHENTICATION (v4.2.1)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/status", response_model=AuthStatusResponse)
|
||||||
|
async def api_auth_status():
|
||||||
|
"""
|
||||||
|
Get API key authentication status.
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth status.
|
||||||
|
Returns whether auth is enabled and key counts.
|
||||||
|
"""
|
||||||
|
status = get_api_key_status()
|
||||||
|
return AuthStatusResponse(
|
||||||
|
enabled=status["enabled"],
|
||||||
|
total_keys=status["total_keys"],
|
||||||
|
user_keys=status["user_keys"],
|
||||||
|
project_keys=status["project_keys"],
|
||||||
|
env_configured=status["env_configured"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/keys", response_model=list[AuthKeyInfo])
|
||||||
|
async def api_auth_list_keys(
|
||||||
|
location: str = Query("user", description="'user' or 'project'"),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List configured API keys (names only, not actual keys).
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth management.
|
||||||
|
Requires authentication.
|
||||||
|
"""
|
||||||
|
if location not in ("user", "project"):
|
||||||
|
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||||
|
|
||||||
|
keys = list_api_keys(location)
|
||||||
|
return [AuthKeyInfo(name=k["name"], created=k["created"]) for k in keys]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/keys")
|
||||||
|
async def api_auth_create_key(
|
||||||
|
name: str = Query(..., description="Name for the new API key"),
|
||||||
|
location: str = Query("user", description="'user' or 'project'"),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new API key.
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth management.
|
||||||
|
Returns the key ONCE - it cannot be retrieved again!
|
||||||
|
Requires authentication (or no keys configured yet).
|
||||||
|
"""
|
||||||
|
if location not in ("user", "project"):
|
||||||
|
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = add_api_key(name, location)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"name": name,
|
||||||
|
"key": key,
|
||||||
|
"warning": "Save this key now! It cannot be retrieved again.",
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/auth/keys")
|
||||||
|
async def api_auth_delete_key(
|
||||||
|
name: str = Query(..., description="Name of key to delete"),
|
||||||
|
location: str = Query("user", description="'user' or 'project'"),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete an API key by name.
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth management.
|
||||||
|
Requires authentication.
|
||||||
|
"""
|
||||||
|
if location not in ("user", "project"):
|
||||||
|
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||||
|
|
||||||
|
if remove_api_key(name, location):
|
||||||
|
return {"success": True, "deleted": name}
|
||||||
|
else:
|
||||||
|
raise HTTPException(404, f"Key '{name}' not found in {location} config")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/compare", response_model=CompareModesResponse)
|
@app.post("/compare", response_model=CompareModesResponse)
|
||||||
async def api_compare_modes(request: CompareModesRequest):
|
async def api_compare_modes(request: CompareModesRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Compare LSB and DCT embedding modes for a carrier image.
|
Compare LSB and DCT embedding modes for a carrier image.
|
||||||
|
|
||||||
@@ -701,7 +898,7 @@ async def api_compare_modes(request: CompareModesRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/will-fit", response_model=WillFitResponse)
|
@app.post("/will-fit", response_model=WillFitResponse)
|
||||||
async def api_will_fit(request: WillFitRequest):
|
async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Check if a payload of given size will fit in the carrier image.
|
Check if a payload of given size will fit in the carrier image.
|
||||||
|
|
||||||
@@ -737,6 +934,7 @@ async def api_will_fit(request: WillFitRequest):
|
|||||||
|
|
||||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||||
async def api_extract_key_from_qr(
|
async def api_extract_key_from_qr(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -760,13 +958,58 @@ async def api_extract_key_from_qr(
|
|||||||
return QrExtractResponse(success=False, error=str(e))
|
return QrExtractResponse(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/generate-key-qr", response_model=QrGenerateResponse)
|
||||||
|
async def api_generate_key_qr(request: QrGenerateRequest, _: str = Depends(require_api_key)):
|
||||||
|
"""
|
||||||
|
Generate QR code from an RSA private key.
|
||||||
|
|
||||||
|
Supports PNG, JPG, and ASCII output formats.
|
||||||
|
Uses zstd compression by default for better QR code density.
|
||||||
|
"""
|
||||||
|
if not HAS_QR_WRITE:
|
||||||
|
raise HTTPException(501, "QR code generation not available. Install qrcode library.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fmt = request.output_format.lower()
|
||||||
|
|
||||||
|
if fmt == "ascii":
|
||||||
|
ascii_qr = generate_qr_ascii(
|
||||||
|
request.key_pem,
|
||||||
|
compress=request.compress,
|
||||||
|
invert=False,
|
||||||
|
)
|
||||||
|
return QrGenerateResponse(success=True, format="ascii", qr_data=ascii_qr)
|
||||||
|
|
||||||
|
elif fmt in ("png", "jpg", "jpeg"):
|
||||||
|
import base64
|
||||||
|
|
||||||
|
qr_bytes = generate_qr_code(
|
||||||
|
request.key_pem,
|
||||||
|
compress=request.compress,
|
||||||
|
output_format=fmt,
|
||||||
|
)
|
||||||
|
qr_b64 = base64.b64encode(qr_bytes).decode("ascii")
|
||||||
|
return QrGenerateResponse(success=True, format=fmt, qr_data=qr_b64)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return QrGenerateResponse(
|
||||||
|
success=False,
|
||||||
|
error=f"Unsupported format: {fmt}. Use 'png', 'jpg', or 'ascii'",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return QrGenerateResponse(success=False, error=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return QrGenerateResponse(success=False, error=f"QR generation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ROUTES - GENERATE
|
# ROUTES - GENERATE
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@app.post("/generate", response_model=GenerateResponse)
|
@app.post("/generate", response_model=GenerateResponse)
|
||||||
async def api_generate(request: GenerateRequest):
|
async def api_generate(request: GenerateRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Generate credentials for encoding/decoding.
|
Generate credentials for encoding/decoding.
|
||||||
|
|
||||||
@@ -848,7 +1091,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/encode", response_model=EncodeResponse)
|
@app.post("/encode", response_model=EncodeResponse)
|
||||||
async def api_encode(request: EncodeRequest):
|
async def api_encode(request: EncodeRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Encode a text message into an image.
|
Encode a text message into an image.
|
||||||
|
|
||||||
@@ -874,8 +1117,9 @@ async def api_encode(request: EncodeRequest):
|
|||||||
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key
|
# v4.2.0: Run CPU-bound encode in thread pool
|
||||||
result = encode(
|
result = await run_in_thread(
|
||||||
|
encode,
|
||||||
message=request.message,
|
message=request.message,
|
||||||
reference_photo=ref_photo,
|
reference_photo=ref_photo,
|
||||||
carrier_image=carrier,
|
carrier_image=carrier,
|
||||||
@@ -919,7 +1163,7 @@ async def api_encode(request: EncodeRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/encode/file", response_model=EncodeResponse)
|
@app.post("/encode/file", response_model=EncodeResponse)
|
||||||
async def api_encode_file(request: EncodeFileRequest):
|
async def api_encode_file(request: EncodeFileRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Encode a file into an image (JSON with base64).
|
Encode a file into an image (JSON with base64).
|
||||||
|
|
||||||
@@ -950,8 +1194,9 @@ async def api_encode_file(request: EncodeFileRequest):
|
|||||||
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key
|
# v4.2.0: Run CPU-bound encode in thread pool
|
||||||
result = encode(
|
result = await run_in_thread(
|
||||||
|
encode,
|
||||||
message=payload,
|
message=payload,
|
||||||
reference_photo=ref_photo,
|
reference_photo=ref_photo,
|
||||||
carrier_image=carrier,
|
carrier_image=carrier,
|
||||||
@@ -1000,7 +1245,7 @@ async def api_encode_file(request: EncodeFileRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/decode", response_model=DecodeResponse)
|
@app.post("/decode", response_model=DecodeResponse)
|
||||||
async def api_decode(request: DecodeRequest):
|
async def api_decode(request: DecodeRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Decode a message or file from a stego image.
|
Decode a message or file from a stego image.
|
||||||
|
|
||||||
@@ -1021,8 +1266,9 @@ async def api_decode(request: DecodeRequest):
|
|||||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||||
|
|
||||||
# v4.0.0: Include channel_key
|
# v4.2.0: Run CPU-bound decode in thread pool
|
||||||
result = decode(
|
result = await run_in_thread(
|
||||||
|
decode,
|
||||||
stego_image=stego,
|
stego_image=stego,
|
||||||
reference_photo=ref_photo,
|
reference_photo=ref_photo,
|
||||||
passphrase=request.passphrase,
|
passphrase=request.passphrase,
|
||||||
@@ -1062,6 +1308,7 @@ async def api_decode(request: DecodeRequest):
|
|||||||
|
|
||||||
@app.post("/encode/multipart")
|
@app.post("/encode/multipart")
|
||||||
async def api_encode_multipart(
|
async def api_encode_multipart(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||||
reference_photo: UploadFile = File(...),
|
reference_photo: UploadFile = File(...),
|
||||||
carrier: UploadFile = File(...),
|
carrier: UploadFile = File(...),
|
||||||
@@ -1150,8 +1397,9 @@ async def api_encode_multipart(
|
|||||||
# Get DCT parameters
|
# Get DCT parameters
|
||||||
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
|
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key
|
# v4.2.0: Run CPU-bound encode in thread pool
|
||||||
result = encode(
|
result = await run_in_thread(
|
||||||
|
encode,
|
||||||
message=payload,
|
message=payload,
|
||||||
reference_photo=ref_data,
|
reference_photo=ref_data,
|
||||||
carrier_image=carrier_data,
|
carrier_image=carrier_data,
|
||||||
@@ -1202,6 +1450,7 @@ async def api_encode_multipart(
|
|||||||
|
|
||||||
@app.post("/decode/multipart", response_model=DecodeResponse)
|
@app.post("/decode/multipart", response_model=DecodeResponse)
|
||||||
async def api_decode_multipart(
|
async def api_decode_multipart(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||||
reference_photo: UploadFile = File(...),
|
reference_photo: UploadFile = File(...),
|
||||||
stego_image: UploadFile = File(...),
|
stego_image: UploadFile = File(...),
|
||||||
@@ -1264,8 +1513,9 @@ async def api_decode_multipart(
|
|||||||
# QR code keys are never password-protected
|
# QR code keys are never password-protected
|
||||||
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key
|
# v4.2.0: Run CPU-bound decode in thread pool
|
||||||
result = decode(
|
result = await run_in_thread(
|
||||||
|
decode,
|
||||||
stego_image=stego_data,
|
stego_image=stego_data,
|
||||||
reference_photo=ref_data,
|
reference_photo=ref_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
@@ -1306,6 +1556,7 @@ async def api_decode_multipart(
|
|||||||
|
|
||||||
@app.post("/image/info", response_model=ImageInfoResponse)
|
@app.post("/image/info", response_model=ImageInfoResponse)
|
||||||
async def api_image_info(
|
async def api_image_info(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
|
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
|
||||||
):
|
):
|
||||||
|
|||||||
0
frontends/cli/__init__.py
Normal file
@@ -120,6 +120,7 @@ try:
|
|||||||
from stegasoo.qr_utils import ( # noqa: F401
|
from stegasoo.qr_utils import ( # noqa: F401
|
||||||
can_fit_in_qr,
|
can_fit_in_qr,
|
||||||
extract_key_from_qr_file,
|
extract_key_from_qr_file,
|
||||||
|
generate_qr_ascii,
|
||||||
generate_qr_code,
|
generate_qr_code,
|
||||||
has_qr_read,
|
has_qr_read,
|
||||||
has_qr_write,
|
has_qr_write,
|
||||||
@@ -136,6 +137,9 @@ except ImportError:
|
|||||||
def has_qr_write() -> bool:
|
def has_qr_write() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def generate_qr_ascii(*args, **kwargs):
|
||||||
|
raise RuntimeError("QR code generation not available")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CLI SETUP
|
# CLI SETUP
|
||||||
@@ -236,7 +240,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
|||||||
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
|
"--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--words",
|
"--words",
|
||||||
@@ -247,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
|||||||
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
||||||
@click.option("--password", "-p", help="Password for RSA key file")
|
@click.option("--password", "-p", help="Password for RSA key file")
|
||||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
@click.option(
|
||||||
|
"--qr",
|
||||||
|
type=click.Path(),
|
||||||
|
help="Save RSA key QR code to file (png/jpg, uses zstd compression)",
|
||||||
|
)
|
||||||
|
@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal")
|
||||||
|
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii):
|
||||||
"""
|
"""
|
||||||
Generate credentials for encoding/decoding.
|
Generate credentials for encoding/decoding.
|
||||||
|
|
||||||
@@ -261,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
|||||||
Examples:
|
Examples:
|
||||||
stegasoo generate
|
stegasoo generate
|
||||||
stegasoo generate --words 5
|
stegasoo generate --words 5
|
||||||
stegasoo generate --rsa --rsa-bits 4096
|
stegasoo generate --rsa --rsa-bits 3072
|
||||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||||
|
stegasoo generate --rsa --qr key.png
|
||||||
|
stegasoo generate --rsa --qr-ascii
|
||||||
stegasoo generate --no-pin --rsa
|
stegasoo generate --no-pin --rsa
|
||||||
"""
|
"""
|
||||||
if not pin and not rsa:
|
if not pin and not rsa:
|
||||||
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
||||||
|
|
||||||
|
if (qr or qr_ascii) and not rsa:
|
||||||
|
raise click.UsageError("QR output requires --rsa to generate an RSA key")
|
||||||
|
|
||||||
if output and not password:
|
if output and not password:
|
||||||
raise click.UsageError("--password is required when saving RSA key to file")
|
raise click.UsageError("--password is required when saving RSA key to file")
|
||||||
|
|
||||||
@@ -334,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
|||||||
click.echo(creds.rsa_key_pem)
|
click.echo(creds.rsa_key_pem)
|
||||||
click.echo()
|
click.echo()
|
||||||
|
|
||||||
|
# QR code output (v4.2.0)
|
||||||
|
if qr:
|
||||||
|
if not HAS_QR:
|
||||||
|
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||||
|
else:
|
||||||
|
# Determine format from extension
|
||||||
|
qr_path = Path(qr)
|
||||||
|
ext = qr_path.suffix.lower()
|
||||||
|
fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png"
|
||||||
|
|
||||||
|
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||||
|
qr_path.write_bytes(qr_bytes)
|
||||||
|
click.secho(f"─── RSA KEY QR CODE ───", fg="green")
|
||||||
|
click.secho(f" Saved to: {qr}", fg="bright_white")
|
||||||
|
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
if qr_ascii:
|
||||||
|
if not HAS_QR:
|
||||||
|
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||||
|
else:
|
||||||
|
click.secho("─── RSA KEY QR CODE (ASCII) ───", fg="green")
|
||||||
|
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
ascii_qr = generate_qr_ascii(creds.rsa_key_pem, compress=True, invert=True)
|
||||||
|
click.echo(ascii_qr)
|
||||||
|
|
||||||
click.secho("─── SECURITY ───", fg="green")
|
click.secho("─── SECURITY ───", fg="green")
|
||||||
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||||||
if creds.pin:
|
if creds.pin:
|
||||||
|
|||||||
0
frontends/web/__init__.py
Normal file
@@ -31,7 +31,7 @@ KEY PATTERNS
|
|||||||
============
|
============
|
||||||
|
|
||||||
1. SUBPROCESS ISOLATION
|
1. SUBPROCESS ISOLATION
|
||||||
Stegasoo's DCT mode uses scipy/jpegio which can crash on malformed input.
|
Stegasoo's DCT mode uses scipy/jpeglib which can crash on malformed input.
|
||||||
We run encode/decode in subprocesses so crashes don't take down the server:
|
We run encode/decode in subprocesses so crashes don't take down the server:
|
||||||
|
|
||||||
subprocess_stego = SubprocessStego(timeout=180)
|
subprocess_stego = SubprocessStego(timeout=180)
|
||||||
@@ -213,7 +213,7 @@ except ImportError:
|
|||||||
#
|
#
|
||||||
# This is a critical reliability pattern. Here's the problem:
|
# This is a critical reliability pattern. Here's the problem:
|
||||||
#
|
#
|
||||||
# scipy's DCT and jpegio can crash (segfault) on:
|
# scipy's DCT and jpeglib can crash (segfault) on:
|
||||||
# - Malformed JPEG files
|
# - Malformed JPEG files
|
||||||
# - Very large images that exhaust memory
|
# - Very large images that exhaust memory
|
||||||
# - Certain edge cases in coefficient manipulation
|
# - Certain edge cases in coefficient manipulation
|
||||||
@@ -253,6 +253,7 @@ from stegasoo.qr_utils import (
|
|||||||
detect_and_crop_qr,
|
detect_and_crop_qr,
|
||||||
extract_key_from_qr,
|
extract_key_from_qr,
|
||||||
generate_qr_code,
|
generate_qr_code,
|
||||||
|
is_compressed,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize subprocess wrapper (worker script must be in same directory)
|
# Initialize subprocess wrapper (worker script must be in same directory)
|
||||||
@@ -1116,6 +1117,13 @@ def encode_page():
|
|||||||
# Check if async mode requested
|
# Check if async mode requested
|
||||||
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||||
|
|
||||||
|
def _error_response(msg):
|
||||||
|
"""Return error as JSON (async) or HTML flash (sync)."""
|
||||||
|
if is_async:
|
||||||
|
return jsonify({"error": msg}), 400
|
||||||
|
flash(msg, "error")
|
||||||
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get files
|
# Get files
|
||||||
ref_photo = request.files.get("reference_photo")
|
ref_photo = request.files.get("reference_photo")
|
||||||
@@ -1124,12 +1132,10 @@ def encode_page():
|
|||||||
payload_file = request.files.get("payload_file")
|
payload_file = request.files.get("payload_file")
|
||||||
|
|
||||||
if not ref_photo or not carrier:
|
if not ref_photo or not carrier:
|
||||||
flash("Both reference photo and carrier image are required", "error")
|
return _error_response("Both reference photo and carrier image are required")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
|
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
|
||||||
flash("Invalid file type. Use PNG, JPG, or BMP", "error")
|
return _error_response("Invalid file type. Use PNG, JPG, or BMP")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Get form data - v3.2.0: renamed from day_phrase to passphrase
|
# Get form data - v3.2.0: renamed from day_phrase to passphrase
|
||||||
message = request.form.get("message", "")
|
message = request.form.get("message", "")
|
||||||
@@ -1158,8 +1164,7 @@ def encode_page():
|
|||||||
|
|
||||||
# Check DCT availability
|
# Check DCT availability
|
||||||
if embed_mode == "dct" and not has_dct_support():
|
if embed_mode == "dct" and not has_dct_support():
|
||||||
flash("DCT mode requires scipy. Install with: pip install scipy", "error")
|
return _error_response("DCT mode requires scipy. Install with: pip install scipy")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Determine payload
|
# Determine payload
|
||||||
if payload_type == "file" and payload_file and payload_file.filename:
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
@@ -1168,8 +1173,7 @@ def encode_page():
|
|||||||
|
|
||||||
result = validate_file_payload(file_data, payload_file.filename)
|
result = validate_file_payload(file_data, payload_file.filename)
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||||
payload = FilePayload(
|
payload = FilePayload(
|
||||||
@@ -1179,20 +1183,17 @@ def encode_page():
|
|||||||
# Text message
|
# Text message
|
||||||
result = validate_message(message)
|
result = validate_message(message)
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
payload = message
|
payload = message
|
||||||
|
|
||||||
# v3.2.0: Renamed from day_phrase
|
# v3.2.0: Renamed from day_phrase
|
||||||
if not passphrase:
|
if not passphrase:
|
||||||
flash("Passphrase is required", "error")
|
return _error_response("Passphrase is required")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# v3.2.0: Validate passphrase
|
# v3.2.0: Validate passphrase
|
||||||
result = validate_passphrase(passphrase)
|
result = validate_passphrase(passphrase)
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Show warning if passphrase is short
|
# Show warning if passphrase is short
|
||||||
if result.warning:
|
if result.warning:
|
||||||
@@ -1209,8 +1210,8 @@ def encode_page():
|
|||||||
rsa_key_from_qr = False
|
rsa_key_from_qr = False
|
||||||
|
|
||||||
if rsa_key_pem:
|
if rsa_key_pem:
|
||||||
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
# Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
|
||||||
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
if is_compressed(rsa_key_pem):
|
||||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||||
rsa_key_from_qr = True
|
rsa_key_from_qr = True
|
||||||
@@ -1223,21 +1224,18 @@ def encode_page():
|
|||||||
rsa_key_data = key_pem.encode("utf-8")
|
rsa_key_data = key_pem.encode("utf-8")
|
||||||
rsa_key_from_qr = True
|
rsa_key_from_qr = True
|
||||||
else:
|
else:
|
||||||
flash("Could not extract RSA key from QR code image.", "error")
|
return _error_response("Could not extract RSA key from QR code image.")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Validate security factors
|
# Validate security factors
|
||||||
result = validate_security_factors(pin, rsa_key_data)
|
result = validate_security_factors(pin, rsa_key_data)
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Validate PIN if provided
|
# Validate PIN if provided
|
||||||
if pin:
|
if pin:
|
||||||
result = validate_pin(pin)
|
result = validate_pin(pin)
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Determine key password
|
# Determine key password
|
||||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||||
@@ -1246,14 +1244,12 @@ def encode_page():
|
|||||||
if rsa_key_data:
|
if rsa_key_data:
|
||||||
result = validate_rsa_key(rsa_key_data, key_password)
|
result = validate_rsa_key(rsa_key_data, key_password)
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Validate carrier image
|
# Validate carrier image
|
||||||
result = validate_image(carrier_data, "Carrier image")
|
result = validate_image(carrier_data, "Carrier image")
|
||||||
if not result.is_valid:
|
if not result.is_valid:
|
||||||
flash(result.error_message, "error")
|
return _error_response(result.error_message)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Pre-check payload capacity BEFORE encode (fail fast)
|
# Pre-check payload capacity BEFORE encode (fail fast)
|
||||||
from stegasoo.steganography import will_fit_by_mode
|
from stegasoo.steganography import will_fit_by_mode
|
||||||
@@ -1273,8 +1269,7 @@ def encode_page():
|
|||||||
alt_check = will_fit_by_mode(payload_size, carrier_data, embed_mode="lsb")
|
alt_check = will_fit_by_mode(payload_size, carrier_data, embed_mode="lsb")
|
||||||
if alt_check.get("fits"):
|
if alt_check.get("fits"):
|
||||||
error_msg += " - Try LSB mode instead."
|
error_msg += " - Try LSB mode instead."
|
||||||
flash(error_msg, "error")
|
return _error_response(error_msg)
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
# Build encode params for either sync or async
|
# Build encode params for either sync or async
|
||||||
encode_params = {
|
encode_params = {
|
||||||
@@ -1375,14 +1370,11 @@ def encode_page():
|
|||||||
return redirect(url_for("encode_result", file_id=file_id))
|
return redirect(url_for("encode_result", file_id=file_id))
|
||||||
|
|
||||||
except CapacityError as e:
|
except CapacityError as e:
|
||||||
flash(str(e), "error")
|
return _error_response(str(e))
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
except StegasooError as e:
|
except StegasooError as e:
|
||||||
flash(str(e), "error")
|
return _error_response(str(e))
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Error: {e}", "error")
|
return _error_response(f"Error: {e}")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
|
||||||
|
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
@@ -1657,8 +1649,8 @@ def decode_page():
|
|||||||
rsa_key_from_qr = False
|
rsa_key_from_qr = False
|
||||||
|
|
||||||
if rsa_key_pem:
|
if rsa_key_pem:
|
||||||
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
# Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
|
||||||
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
if is_compressed(rsa_key_pem):
|
||||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||||
rsa_key_from_qr = True
|
rsa_key_from_qr = True
|
||||||
@@ -2105,6 +2097,241 @@ def api_tools_exif_clear():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/rotate", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_rotate():
|
||||||
|
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||||
|
from PIL import Image
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
rotation = int(request.form.get("rotation", 0))
|
||||||
|
flip_h = request.form.get("flip_h", "false").lower() == "true"
|
||||||
|
flip_v = request.form.get("flip_v", "false").lower() == "true"
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = image_file.read()
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
original_format = img.format # JPEG, PNG, etc.
|
||||||
|
img.close()
|
||||||
|
|
||||||
|
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
|
||||||
|
has_jpegtran = shutil.which("jpegtran") is not None
|
||||||
|
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
|
||||||
|
|
||||||
|
if use_jpegtran:
|
||||||
|
# Chain jpegtran operations for lossless transformation
|
||||||
|
current_data = image_data
|
||||||
|
|
||||||
|
# Apply rotation first
|
||||||
|
if rotation in (90, 180, 270):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(current_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
current_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply horizontal flip
|
||||||
|
if flip_h:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(current_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
current_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply vertical flip
|
||||||
|
if flip_v:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(current_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-flip", "vertical", "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
current_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
buffer = io.BytesIO(current_data)
|
||||||
|
mimetype = "image/jpeg"
|
||||||
|
ext = "jpg"
|
||||||
|
else:
|
||||||
|
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Apply rotation (PIL rotates counter-clockwise, so negate)
|
||||||
|
if rotation:
|
||||||
|
img = img.rotate(-rotation, expand=True)
|
||||||
|
|
||||||
|
# Apply flips
|
||||||
|
if flip_h:
|
||||||
|
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
if flip_v:
|
||||||
|
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
# Preserve original format
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
if original_format == "JPEG":
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(buffer, format="JPEG", quality=95)
|
||||||
|
mimetype = "image/jpeg"
|
||||||
|
ext = "jpg"
|
||||||
|
else:
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
mimetype = "image/png"
|
||||||
|
ext = "png"
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
stem = (
|
||||||
|
image_file.filename.rsplit(".", 1)[0]
|
||||||
|
if "." in image_file.filename
|
||||||
|
else image_file.filename
|
||||||
|
)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype=mimetype,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{stem}_transformed.{ext}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/compress", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_compress():
|
||||||
|
"""Compress image to JPEG at specified quality."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
quality = int(request.form.get("quality", 85))
|
||||||
|
quality = max(10, min(100, quality)) # Clamp to valid range
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(image_file.read()))
|
||||||
|
|
||||||
|
# Convert to RGB if necessary (JPEG doesn't support alpha)
|
||||||
|
if img.mode in ("RGBA", "LA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="JPEG", quality=quality)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
stem = (
|
||||||
|
image_file.filename.rsplit(".", 1)[0]
|
||||||
|
if "." in image_file.filename
|
||||||
|
else image_file.filename
|
||||||
|
)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype="image/jpeg",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{stem}_q{quality}.jpg",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools/convert", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_tools_convert():
|
||||||
|
"""Convert image to different format."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
image_file = request.files.get("image")
|
||||||
|
if not image_file:
|
||||||
|
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||||
|
|
||||||
|
output_format = request.form.get("format", "PNG").upper()
|
||||||
|
quality = int(request.form.get("quality", 90))
|
||||||
|
quality = max(10, min(100, quality))
|
||||||
|
|
||||||
|
# Validate format
|
||||||
|
format_map = {
|
||||||
|
"PNG": ("png", "image/png"),
|
||||||
|
"JPEG": ("jpg", "image/jpeg"),
|
||||||
|
"WEBP": ("webp", "image/webp"),
|
||||||
|
}
|
||||||
|
if output_format not in format_map:
|
||||||
|
return jsonify({"success": False, "error": f"Unsupported format: {output_format}"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(image_file.read()))
|
||||||
|
|
||||||
|
# Convert to RGB for JPEG (no alpha)
|
||||||
|
if output_format == "JPEG" and img.mode in ("RGBA", "LA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
save_kwargs = {"format": output_format}
|
||||||
|
if output_format in ("JPEG", "WEBP"):
|
||||||
|
save_kwargs["quality"] = quality
|
||||||
|
img.save(buffer, **save_kwargs)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
ext, mimetype = format_map[output_format]
|
||||||
|
stem = (
|
||||||
|
image_file.filename.rsplit(".", 1)[0]
|
||||||
|
if "." in image_file.filename
|
||||||
|
else image_file.filename
|
||||||
|
)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype=mimetype,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f"{stem}.{ext}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
|
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
|
||||||
|
|
||||||
|
|
||||||
@@ -2554,6 +2781,70 @@ def api_channel_key_use(key_id):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/settings")
|
||||||
|
@admin_required
|
||||||
|
def admin_settings():
|
||||||
|
"""System settings page (admin only)."""
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from stegasoo import __version__
|
||||||
|
from stegasoo.channel import get_channel_status
|
||||||
|
|
||||||
|
channel_status = get_channel_status()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/settings.html",
|
||||||
|
# Channel info (key hidden until password verified)
|
||||||
|
channel_configured=channel_status["configured"],
|
||||||
|
channel_fingerprint=channel_status.get("fingerprint"),
|
||||||
|
channel_source=channel_status.get("source"),
|
||||||
|
# Server config
|
||||||
|
hostname=os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname(),
|
||||||
|
port=os.environ.get("STEGASOO_PORT", "5000"),
|
||||||
|
https_enabled=app.config.get("HTTPS_ENABLED", False),
|
||||||
|
auth_enabled=app.config.get("AUTH_ENABLED", True),
|
||||||
|
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||||
|
max_upload_mb=MAX_FILE_SIZE // (1024 * 1024),
|
||||||
|
dct_available=has_dct_support(),
|
||||||
|
qr_available=HAS_QRCODE_READ,
|
||||||
|
# Environment
|
||||||
|
version=__version__,
|
||||||
|
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||||
|
platform=platform.system(),
|
||||||
|
kdf_type="Argon2id" if has_argon2() else "PBKDF2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/settings/unlock", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def admin_settings_unlock():
|
||||||
|
"""Verify password and return channel key (AJAX)."""
|
||||||
|
from stegasoo.channel import get_channel_status
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
return jsonify({"success": False, "error": "Password required"})
|
||||||
|
|
||||||
|
# Get current user and verify password
|
||||||
|
username = get_username()
|
||||||
|
user = verify_user_password(username, password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return jsonify({"success": False, "error": "Incorrect password"})
|
||||||
|
|
||||||
|
# Password verified - return channel key
|
||||||
|
channel_status = get_channel_status()
|
||||||
|
channel_key = channel_status.get("key") if channel_status["configured"] else ""
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"channel_key": channel_key
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin/users")
|
@app.route("/admin/users")
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_users():
|
def admin_users():
|
||||||
|
|||||||
52
frontends/web/dev_run.sh
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stegasoo Web Frontend - Development Runner
|
||||||
|
# Press 'r' to restart, 'q' to quit (single keypress, no Enter needed)
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo -e "\n\033[33mShutting down...\033[0m"
|
||||||
|
[[ -n "$PID" ]] && kill "$PID" 2>/dev/null
|
||||||
|
stty sane 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGINT SIGTERM EXIT
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
clear
|
||||||
|
echo -e "\033[36m┌──────────────────────────────────────┐\033[0m"
|
||||||
|
echo -e "\033[36m│ Stegasoo Dev Server │\033[0m"
|
||||||
|
echo -e "\033[36m│ \033[0m[r] restart [q] quit\033[36m │\033[0m"
|
||||||
|
echo -e "\033[36m└──────────────────────────────────────┘\033[0m"
|
||||||
|
|
||||||
|
pkill -f "python app.py" 2>/dev/null
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
|
python app.py 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
echo -e "\033[32m✓ Running on http://localhost:5000 (PID: $PID)\033[0m\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server
|
||||||
|
|
||||||
|
# Single keypress mode
|
||||||
|
stty -echo -icanon time 0 min 0
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
key=$(dd bs=1 count=1 2>/dev/null)
|
||||||
|
case "$key" in
|
||||||
|
r|R) start_server ;;
|
||||||
|
q|Q) cleanup ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Check if crashed
|
||||||
|
if [[ -n "$PID" ]] && ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo -e "\033[31m✗ Crashed! Press 'r' to restart\033[0m"
|
||||||
|
PID=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
75
frontends/web/docker-entrypoint.sh
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Docker entrypoint for Stegasoo Web UI
|
||||||
|
# Handles SSL certificate generation and gunicorn startup
|
||||||
|
#
|
||||||
|
# Supports mkcert for browser-trusted certificates (no warning screen)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CERT_DIR="/app/frontends/web/certs"
|
||||||
|
CERT_FILE="$CERT_DIR/cert.pem"
|
||||||
|
KEY_FILE="$CERT_DIR/key.pem"
|
||||||
|
HOSTNAME="${STEGASOO_HOSTNAME:-localhost}"
|
||||||
|
|
||||||
|
# Generate SSL certificates
|
||||||
|
# Priority: 1) Existing certs, 2) mkcert (trusted), 3) openssl (self-signed)
|
||||||
|
generate_certs() {
|
||||||
|
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
|
||||||
|
echo "Using existing SSL certificates."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CERT_DIR"
|
||||||
|
|
||||||
|
# Try mkcert first (creates browser-trusted certs)
|
||||||
|
if command -v mkcert &> /dev/null; then
|
||||||
|
echo "Generating trusted certificate with mkcert for $HOSTNAME..."
|
||||||
|
cd "$CERT_DIR"
|
||||||
|
mkcert -key-file key.pem -cert-file cert.pem "$HOSTNAME" localhost 127.0.0.1 ::1
|
||||||
|
echo "Trusted certificate generated."
|
||||||
|
echo ""
|
||||||
|
echo " To trust on other devices, install the CA cert from:"
|
||||||
|
echo " $(mkcert -CAROOT)/rootCA.pem"
|
||||||
|
echo ""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to self-signed (shows browser warning)
|
||||||
|
echo "Generating self-signed SSL certificate for $HOSTNAME..."
|
||||||
|
echo "(Install mkcert for browser-trusted certs without warnings)"
|
||||||
|
|
||||||
|
openssl req -x509 -newkey rsa:2048 \
|
||||||
|
-keyout "$KEY_FILE" \
|
||||||
|
-out "$CERT_FILE" \
|
||||||
|
-sha256 -days 365 -nodes \
|
||||||
|
-subj "/CN=$HOSTNAME" \
|
||||||
|
-addext "subjectAltName=DNS:$HOSTNAME,DNS:localhost,IP:127.0.0.1" \
|
||||||
|
2>/dev/null
|
||||||
|
|
||||||
|
echo "Self-signed certificate generated."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start gunicorn with appropriate settings
|
||||||
|
if [ "${STEGASOO_HTTPS_ENABLED:-false}" = "true" ]; then
|
||||||
|
echo "HTTPS mode enabled"
|
||||||
|
generate_certs
|
||||||
|
|
||||||
|
exec gunicorn \
|
||||||
|
--bind 0.0.0.0:5000 \
|
||||||
|
--workers 2 \
|
||||||
|
--threads 4 \
|
||||||
|
--timeout 120 \
|
||||||
|
--certfile "$CERT_FILE" \
|
||||||
|
--keyfile "$KEY_FILE" \
|
||||||
|
app:app
|
||||||
|
else
|
||||||
|
echo "HTTP mode (HTTPS disabled)"
|
||||||
|
exec gunicorn \
|
||||||
|
--bind 0.0.0.0:5000 \
|
||||||
|
--workers 2 \
|
||||||
|
--threads 4 \
|
||||||
|
--timeout 120 \
|
||||||
|
app:app
|
||||||
|
fi
|
||||||
6
frontends/web/static/js/qrcode.min.js
vendored
Normal file
@@ -333,56 +333,68 @@ const Stegasoo = {
|
|||||||
generateEmbedTraces(container, width, height) {
|
generateEmbedTraces(container, width, height) {
|
||||||
// Color classes for variety
|
// Color classes for variety
|
||||||
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
|
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
|
||||||
|
|
||||||
// Generate 6-8 snake paths spread across the whole image
|
// Grid-based distribution: divide image into cells for even coverage
|
||||||
const numPaths = 6 + Math.floor(Math.random() * 3);
|
const gridCols = 5;
|
||||||
|
const gridRows = 4;
|
||||||
for (let p = 0; p < numPaths; p++) {
|
const cellWidth = width / gridCols;
|
||||||
// Each path gets a random color
|
const cellHeight = height / gridRows;
|
||||||
const pathColor = colors[Math.floor(Math.random() * colors.length)];
|
|
||||||
|
let pathIndex = 0;
|
||||||
// Distribute starting points across the image
|
|
||||||
let x = (width * 0.1) + (Math.random() * width * 0.8);
|
// Spawn 1-2 paths from each grid cell for even distribution
|
||||||
let y = (height * 0.1) + (Math.random() * height * 0.8);
|
for (let row = 0; row < gridRows; row++) {
|
||||||
let delay = p * 40;
|
for (let col = 0; col < gridCols; col++) {
|
||||||
|
// 1-2 paths per cell
|
||||||
// Each path has 3-5 segments for more coverage
|
const pathsInCell = 1 + Math.floor(Math.random() * 2);
|
||||||
const numSegments = 3 + Math.floor(Math.random() * 3);
|
|
||||||
let horizontal = Math.random() > 0.5;
|
for (let p = 0; p < pathsInCell; p++) {
|
||||||
|
const pathColor = colors[Math.floor(Math.random() * colors.length)];
|
||||||
for (let s = 0; s < numSegments; s++) {
|
|
||||||
const trace = document.createElement('div');
|
// Start within this grid cell (with padding)
|
||||||
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
|
let x = (col * cellWidth) + (cellWidth * 0.15) + (Math.random() * cellWidth * 0.7);
|
||||||
|
let y = (row * cellHeight) + (cellHeight * 0.15) + (Math.random() * cellHeight * 0.7);
|
||||||
const length = 30 + Math.random() * 60;
|
let delay = pathIndex * 15;
|
||||||
trace.style.left = x + 'px';
|
|
||||||
trace.style.top = y + 'px';
|
// Each path has 3-5 short segments
|
||||||
trace.style.animationDelay = delay + 'ms';
|
const numSegments = 3 + Math.floor(Math.random() * 3);
|
||||||
|
let horizontal = Math.random() > 0.5;
|
||||||
if (horizontal) {
|
|
||||||
trace.style.width = length + 'px';
|
for (let s = 0; s < numSegments; s++) {
|
||||||
} else {
|
const trace = document.createElement('div');
|
||||||
trace.style.height = length + 'px';
|
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
|
||||||
|
|
||||||
|
// Shorter segments: 12-30px for denser circuit look
|
||||||
|
const length = 12 + Math.random() * 18;
|
||||||
|
trace.style.left = Math.max(0, Math.min(x, width - length)) + 'px';
|
||||||
|
trace.style.top = Math.max(0, Math.min(y, height - length)) + 'px';
|
||||||
|
trace.style.animationDelay = delay + 'ms';
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
trace.style.width = length + 'px';
|
||||||
|
} else {
|
||||||
|
trace.style.height = length + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(trace);
|
||||||
|
|
||||||
|
// Move position for next segment
|
||||||
|
if (horizontal) {
|
||||||
|
x += length * (Math.random() > 0.5 ? 1 : -1);
|
||||||
|
} else {
|
||||||
|
y += length * (Math.random() > 0.5 ? 1 : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep within bounds
|
||||||
|
x = Math.max(5, Math.min(x, width - 20));
|
||||||
|
y = Math.max(5, Math.min(y, height - 20));
|
||||||
|
|
||||||
|
// Alternate direction (90 degree turn)
|
||||||
|
horizontal = !horizontal;
|
||||||
|
delay += 20;
|
||||||
|
}
|
||||||
|
pathIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(trace);
|
|
||||||
|
|
||||||
// Move position for next segment
|
|
||||||
if (horizontal) {
|
|
||||||
x += length;
|
|
||||||
} else {
|
|
||||||
y += length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap around if out of bounds to keep traces in view
|
|
||||||
if (x > width - 20) x = 10 + Math.random() * 40;
|
|
||||||
if (y > height - 20) y = 10 + Math.random() * 40;
|
|
||||||
if (x < 10) x = width - 60 + Math.random() * 40;
|
|
||||||
if (y < 10) y = height - 60 + Math.random() * 40;
|
|
||||||
|
|
||||||
// Alternate direction (90 degree turn)
|
|
||||||
horizontal = !horizontal;
|
|
||||||
delay += 30;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -997,7 +1009,9 @@ const Stegasoo = {
|
|||||||
const percent = progressData.percent || 0;
|
const percent = progressData.percent || 0;
|
||||||
const phase = progressData.phase || 'processing';
|
const phase = progressData.phase || 'processing';
|
||||||
|
|
||||||
this.updateProgress(percent, this.formatPhase(phase));
|
// Use indeterminate mode for initializing/starting phases
|
||||||
|
const isIndeterminate = (phase === 'initializing' || phase === 'starting');
|
||||||
|
this.updateProgress(percent, this.formatPhase(phase), isIndeterminate);
|
||||||
|
|
||||||
// Continue polling
|
// Continue polling
|
||||||
setTimeout(poll, 500);
|
setTimeout(poll, 500);
|
||||||
@@ -1017,7 +1031,7 @@ const Stegasoo = {
|
|||||||
formatPhase(phase) {
|
formatPhase(phase) {
|
||||||
const phases = {
|
const phases = {
|
||||||
'starting': 'Starting...',
|
'starting': 'Starting...',
|
||||||
'initializing': 'Initializing...',
|
'initializing': 'Deriving keys (may take a moment)...',
|
||||||
'embedding': 'Embedding data...',
|
'embedding': 'Embedding data...',
|
||||||
'saving': 'Saving image...',
|
'saving': 'Saving image...',
|
||||||
'finalizing': 'Finalizing...',
|
'finalizing': 'Finalizing...',
|
||||||
@@ -1058,8 +1072,9 @@ const Stegasoo = {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset progress
|
// Reset progress tracking and start with indeterminate state
|
||||||
this.updateProgress(0, 'Initializing...');
|
this.resetProgressTracking();
|
||||||
|
this.updateProgress(0, 'Initializing...', true);
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
const bsModal = new bootstrap.Modal(modal);
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
@@ -1078,16 +1093,47 @@ const Stegasoo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update progress bar and text
|
* Track max progress to prevent backwards jumps
|
||||||
*/
|
*/
|
||||||
updateProgress(percent, phase) {
|
_maxProgress: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset progress tracking (call when starting new operation)
|
||||||
|
*/
|
||||||
|
resetProgressTracking() {
|
||||||
|
this._maxProgress = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update progress bar and text
|
||||||
|
* Supports indeterminate mode for initializing phase (barber pole at full width)
|
||||||
|
*/
|
||||||
|
updateProgress(percent, phase, indeterminate = false) {
|
||||||
const progressBar = document.getElementById('progressBar');
|
const progressBar = document.getElementById('progressBar');
|
||||||
const progressText = document.getElementById('progressText');
|
const progressText = document.getElementById('progressText');
|
||||||
const phaseText = document.getElementById('progressPhase');
|
const phaseText = document.getElementById('progressPhase');
|
||||||
|
|
||||||
if (progressBar) progressBar.style.width = percent + '%';
|
if (indeterminate) {
|
||||||
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
// Barber pole animation at full width, no percentage
|
||||||
if (phaseText) phaseText.textContent = phase;
|
if (progressBar) {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||||
|
}
|
||||||
|
if (progressText) progressText.textContent = '';
|
||||||
|
if (phaseText) phaseText.textContent = phase;
|
||||||
|
} else {
|
||||||
|
// Determinate progress - never go backwards
|
||||||
|
const safePercent = Math.max(percent, this._maxProgress);
|
||||||
|
this._maxProgress = safePercent;
|
||||||
|
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.width = safePercent + '%';
|
||||||
|
// Keep animation but show actual progress
|
||||||
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||||
|
}
|
||||||
|
if (progressText) progressText.textContent = Math.round(safePercent) + '%';
|
||||||
|
if (phaseText) phaseText.textContent = phase;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -1175,7 +1221,9 @@ const Stegasoo = {
|
|||||||
const percent = progressData.percent || 0;
|
const percent = progressData.percent || 0;
|
||||||
const phase = progressData.phase || 'processing';
|
const phase = progressData.phase || 'processing';
|
||||||
|
|
||||||
this.updateProgress(percent, this.formatDecodePhase(phase));
|
// Use indeterminate mode for initializing/starting/loading phases
|
||||||
|
const isIndeterminate = (phase === 'initializing' || phase === 'starting' || phase === 'loading');
|
||||||
|
this.updateProgress(percent, this.formatDecodePhase(phase), isIndeterminate);
|
||||||
|
|
||||||
// Continue polling
|
// Continue polling
|
||||||
setTimeout(poll, 500);
|
setTimeout(poll, 500);
|
||||||
@@ -1195,8 +1243,11 @@ const Stegasoo = {
|
|||||||
formatDecodePhase(phase) {
|
formatDecodePhase(phase) {
|
||||||
const phases = {
|
const phases = {
|
||||||
'starting': 'Starting...',
|
'starting': 'Starting...',
|
||||||
|
'initializing': 'Deriving keys (may take a moment)...',
|
||||||
|
'loading': 'Deriving keys (may take a moment)...',
|
||||||
'reading': 'Reading image...',
|
'reading': 'Reading image...',
|
||||||
'extracting': 'Extracting data...',
|
'extracting': 'Extracting data...',
|
||||||
|
'decoding': 'Decoding data...',
|
||||||
'decrypting': 'Decrypting...',
|
'decrypting': 'Decrypting...',
|
||||||
'verifying': 'Verifying...',
|
'verifying': 'Verifying...',
|
||||||
'finalizing': 'Finalizing...',
|
'finalizing': 'Finalizing...',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
--overlay-dark: rgba(0, 0, 0, 0.3);
|
--overlay-dark: rgba(0, 0, 0, 0.3);
|
||||||
--overlay-light: rgba(255, 255, 255, 0.05);
|
--overlay-light: rgba(255, 255, 255, 0.05);
|
||||||
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
|
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
|
||||||
--header-gold: #fee862; /* Halfway between light straw and 24k gold */
|
--header-gold: #e5d058; /* Muted gold - less harsh on varied monitors */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------------
|
/* ----------------------------------------------------------------------------
|
||||||
@@ -91,6 +91,56 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact inline mode buttons */
|
||||||
|
.mode-btn.mode-btn-sm {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.mode-btn-sm .form-check-input {
|
||||||
|
left: 8px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.mode-btn-sm i {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled button labels for btn-check groups */
|
||||||
|
.btn-check:disabled + .btn {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
Form Labels - Gold
|
||||||
|
---------------------------------------------------------------------------- */
|
||||||
|
.card .form-label {
|
||||||
|
color: #d9c580;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown selects - ensure chevron is visible in dark mode */
|
||||||
|
.form-select,
|
||||||
|
select.form-select {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23d9c580' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-position: right 0.75rem center !important;
|
||||||
|
background-size: 16px 12px !important;
|
||||||
|
padding-right: 2.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Payload type toggle - gold text when selected */
|
||||||
|
.btn-check:checked + .btn-outline-primary {
|
||||||
|
color: #d9c580 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------------
|
/* ----------------------------------------------------------------------------
|
||||||
Security Factor Boxes - Matches drop-zone dashed border style
|
Security Factor Boxes - Matches drop-zone dashed border style
|
||||||
---------------------------------------------------------------------------- */
|
---------------------------------------------------------------------------- */
|
||||||
@@ -125,6 +175,122 @@ body {
|
|||||||
.navbar {
|
.navbar {
|
||||||
background: var(--overlay-dark) !important;
|
background: var(--overlay-dark) !important;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 1030; /* Above page content for dropdowns */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar > .container {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure navbar dropdown appears above all page content */
|
||||||
|
.navbar .dropdown-menu {
|
||||||
|
z-index: 1031;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left-align collapsed navbar menu on mobile */
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.navbar-collapse .navbar-nav {
|
||||||
|
align-items: flex-start !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
Nav Icons - Floating Label on Hover (label floats below, no layout shift)
|
||||||
|
---------------------------------------------------------------------------- */
|
||||||
|
.nav-icons {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icons .nav-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 0.75rem !important;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand i {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating label - absolutely positioned below */
|
||||||
|
.nav-expand span {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--header-gold);
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||||
|
transition: opacity 0.2s ease,
|
||||||
|
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(74, 40, 96, 0.25) 0%, rgba(85, 112, 212, 0.2) 100%);
|
||||||
|
box-shadow: 0 0 8px rgba(102, 126, 234, 0.15),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand:hover i {
|
||||||
|
color: var(--header-gold);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(254, 232, 98, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand:hover span {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state for current page */
|
||||||
|
.nav-expand.active,
|
||||||
|
.nav-item.active .nav-expand {
|
||||||
|
background: linear-gradient(135deg, rgba(74, 40, 96, 0.6) 0%, rgba(85, 112, 212, 0.5) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand.active i,
|
||||||
|
.nav-item.active .nav-expand i {
|
||||||
|
color: var(--header-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: Always show labels inline in collapsed menu */
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.nav-expand span {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand:hover span {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-expand:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------------
|
/* ----------------------------------------------------------------------------
|
||||||
@@ -893,36 +1059,36 @@ footer {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Color variants - 60% opacity */
|
/* Color variants - 70% opacity with tighter glow for thin lines */
|
||||||
.embed-trace.color-yellow {
|
.embed-trace.color-yellow {
|
||||||
background: rgba(212, 225, 87, 0.6);
|
background: rgba(212, 225, 87, 0.7);
|
||||||
box-shadow: 0 0 6px rgba(212, 225, 87, 0.5), 0 0 12px rgba(212, 225, 87, 0.3);
|
box-shadow: 0 0 3px rgba(212, 225, 87, 0.6), 0 0 6px rgba(212, 225, 87, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-trace.color-cyan {
|
.embed-trace.color-cyan {
|
||||||
background: rgba(0, 255, 170, 0.6);
|
background: rgba(0, 255, 170, 0.7);
|
||||||
box-shadow: 0 0 6px rgba(0, 255, 170, 0.5), 0 0 12px rgba(0, 255, 170, 0.3);
|
box-shadow: 0 0 3px rgba(0, 255, 170, 0.6), 0 0 6px rgba(0, 255, 170, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-trace.color-purple {
|
.embed-trace.color-purple {
|
||||||
background: rgba(167, 139, 250, 0.6);
|
background: rgba(167, 139, 250, 0.7);
|
||||||
box-shadow: 0 0 6px rgba(167, 139, 250, 0.5), 0 0 12px rgba(167, 139, 250, 0.3);
|
box-shadow: 0 0 3px rgba(167, 139, 250, 0.6), 0 0 6px rgba(167, 139, 250, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-trace.color-blue {
|
.embed-trace.color-blue {
|
||||||
background: rgba(102, 126, 234, 0.6);
|
background: rgba(102, 126, 234, 0.7);
|
||||||
box-shadow: 0 0 6px rgba(102, 126, 234, 0.5), 0 0 12px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 0 3px rgba(102, 126, 234, 0.6), 0 0 6px rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vertical segments shrink from top */
|
/* Vertical segments shrink from top */
|
||||||
.embed-trace.v {
|
.embed-trace.v {
|
||||||
width: 2px;
|
width: 1px;
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal segments shrink from left */
|
/* Horizontal segments shrink from left */
|
||||||
.embed-trace.h {
|
.embed-trace.h {
|
||||||
height: 2px;
|
height: 1px;
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,7 +1260,8 @@ footer {
|
|||||||
---------------------------------------------------------------------------- */
|
---------------------------------------------------------------------------- */
|
||||||
#rsaQrSection {
|
#rsaQrSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rsaQrSection .drop-zone {
|
#rsaQrSection .drop-zone {
|
||||||
@@ -1699,3 +1866,518 @@ footer {
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
TOOLS PAGE - Office-style Ribbon + Two-Panel Layout
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Icon Toolbar Ribbon - Purple/Blue Gradient Theme */
|
||||||
|
.tools-ribbon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||||
|
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-ribbon-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-ribbon-divider {
|
||||||
|
width: 2px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(180deg, rgba(102, 126, 234, 0.4) 0%, rgba(139, 92, 246, 0.4) 100%);
|
||||||
|
margin: 0 0.75rem;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Icon Buttons */
|
||||||
|
.tool-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon-btn i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon-btn span {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon-btn:hover {
|
||||||
|
background: rgba(255, 230, 150, 0.1);
|
||||||
|
border-color: rgba(255, 230, 150, 0.3);
|
||||||
|
color: var(--header-gold);
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon-btn.active {
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
|
||||||
|
border-color: rgba(139, 92, 246, 0.5);
|
||||||
|
color: #c4b5fd;
|
||||||
|
box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-Panel Layout */
|
||||||
|
.tools-panels {
|
||||||
|
display: flex;
|
||||||
|
min-height: 400px;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Panel - Input/Dropzone */
|
||||||
|
.tools-panel-input {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Mode Banner - bottom of input panel */
|
||||||
|
.tool-mode-banner {
|
||||||
|
margin-top: auto; /* Push to bottom */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
|
||||||
|
border-top: 1px solid rgba(139, 92, 246, 0.2);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-mode-type {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(139, 92, 246, 0.3);
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-mode-banner.mode-analyze .tool-mode-type {
|
||||||
|
background: rgba(72, 187, 120, 0.3);
|
||||||
|
color: #9ae6b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-mode-banner.mode-transform .tool-mode-type {
|
||||||
|
background: rgba(237, 181, 71, 0.3);
|
||||||
|
color: #fbd38d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-mode-name {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Panel - Results */
|
||||||
|
.tools-panel-results {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Options Row */
|
||||||
|
.tool-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-options:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-options label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-options .form-select,
|
||||||
|
.tool-options .form-control {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Drop Zone */
|
||||||
|
.tool-dropzone {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-label {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-label i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
display: block;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone.drag-over {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
background: rgba(99, 179, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone.drag-over .tool-dropzone-label i {
|
||||||
|
color: #63b3ed;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropzone with preview */
|
||||||
|
.tool-dropzone.has-file .tool-dropzone-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-preview {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone.has-file .tool-dropzone-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 180px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 2px solid rgba(99, 179, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotate preview - smooth transitions for size and transform */
|
||||||
|
#rotateThumb {
|
||||||
|
transition: transform 0.1s ease-out, width 0.1s ease-out, height 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotate image container - fixed height to contain rotated images */
|
||||||
|
.rotate-img-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotate file info - separate row below dropzone */
|
||||||
|
.rotate-file-info {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.rotate-file-info .file-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #63b3ed;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.rotate-file-info .file-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-preview .file-name {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #63b3ed;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-preview .file-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dropzone-clear:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results Panel Content */
|
||||||
|
.tool-results-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-header h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-header small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-empty i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result Items */
|
||||||
|
.tool-result-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-value.text-primary { color: #63b3ed !important; }
|
||||||
|
.tool-result-value.text-success { color: #48bb78 !important; }
|
||||||
|
.tool-result-value.text-warning { color: #edb547 !important; }
|
||||||
|
|
||||||
|
/* Results Actions */
|
||||||
|
.tool-results-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Section Visibility */
|
||||||
|
.tool-section {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-section.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EXIF Grid Layout */
|
||||||
|
.exif-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-card-label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-card-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-card-value.truncated {
|
||||||
|
max-height: 2.4em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category headers */
|
||||||
|
.exif-category {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.35rem 0 0.15rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-category:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact tool headers and actions */
|
||||||
|
.tool-results-header {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-header h6 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-header small {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-actions {
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.tool-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 30;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tools-panels {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-panel-results {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-ribbon {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
5
frontends/web/static/vendor/css/bootstrap-icons.min.css
vendored
Normal file
6
frontends/web/static/vendor/css/bootstrap.min.css
vendored
Normal file
BIN
frontends/web/static/vendor/css/fonts/bootstrap-icons.woff
vendored
Normal file
BIN
frontends/web/static/vendor/css/fonts/bootstrap-icons.woff2
vendored
Normal file
7
frontends/web/static/vendor/js/bootstrap.bundle.min.js
vendored
Normal file
1
frontends/web/static/vendor/js/html5-qrcode.min.js
vendored
Normal file
@@ -3,7 +3,7 @@
|
|||||||
Stegasoo Subprocess Worker (v4.0.0)
|
Stegasoo Subprocess Worker (v4.0.0)
|
||||||
|
|
||||||
This script runs in a subprocess and handles encode/decode operations.
|
This script runs in a subprocess and handles encode/decode operations.
|
||||||
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
|
If it crashes due to jpeglib/scipy issues, the parent Flask process survives.
|
||||||
|
|
||||||
CHANGES in v4.0.0:
|
CHANGES in v4.0.0:
|
||||||
- Added channel_key support for encode/decode operations
|
- Added channel_key support for encode/decode operations
|
||||||
@@ -171,8 +171,7 @@ 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")
|
# Library handles progress internally via progress_file parameter
|
||||||
|
|
||||||
# Call decode with correct parameter names
|
# Call decode with correct parameter names
|
||||||
result = decode(
|
result = decode(
|
||||||
stego_image=stego_data,
|
stego_image=stego_data,
|
||||||
@@ -183,9 +182,9 @@ def decode_operation(params: dict) -> dict:
|
|||||||
rsa_password=params.get("rsa_password"),
|
rsa_password=params.get("rsa_password"),
|
||||||
embed_mode=params.get("embed_mode", "auto"),
|
embed_mode=params.get("embed_mode", "auto"),
|
||||||
channel_key=resolved_channel_key, # v4.0.0
|
channel_key=resolved_channel_key, # v4.0.0
|
||||||
|
progress_file=progress_file, # v4.2.0: pass through for real-time progress
|
||||||
)
|
)
|
||||||
|
# Library writes 100% "complete" - no need for worker to write again
|
||||||
_write_decode_progress(progress_file, 90, "finalizing")
|
|
||||||
|
|
||||||
if result.is_file:
|
if result.is_file:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class SubprocessStego:
|
|||||||
"""
|
"""
|
||||||
Subprocess-isolated steganography operations.
|
Subprocess-isolated steganography operations.
|
||||||
|
|
||||||
All operations run in a separate Python process. If jpegio or scipy
|
All operations run in a separate Python process. If jpeglib or scipy
|
||||||
crashes, only the subprocess dies - Flask keeps running.
|
crashes, only the subprocess dies - Flask keeps running.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -271,8 +271,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
|
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
|
||||||
<ul class="small mb-0">
|
<ul class="small mb-0">
|
||||||
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
|
<li>Server admin configures the shared key</li>
|
||||||
<li>Or <code>channel_key</code> in config file</li>
|
|
||||||
<li>All users share the same channel</li>
|
<li>All users share the same channel</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,58 +316,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if channel_configured %}
|
{% if channel_configured %}
|
||||||
<div class="alert alert-success mt-3 mb-3">
|
<div class="alert alert-success mt-3 mb-0">
|
||||||
<i class="bi bi-shield-lock me-2"></i>
|
<i class="bi bi-shield-lock me-2"></i>
|
||||||
<strong>This server has a channel key configured:</strong>
|
<strong>This server has a channel key configured:</strong>
|
||||||
<code class="ms-2">{{ channel_fingerprint }}</code>
|
<code class="ms-2">{{ channel_fingerprint }}</code>
|
||||||
<span class="text-muted ms-2">({{ channel_source }})</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info mt-3 mb-3">
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
This server is running in <strong>public mode</strong>.
|
This server is running in <strong>public mode</strong>.
|
||||||
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
|
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Channel Key QR Generator (Admin only) -->
|
|
||||||
{% if is_admin %}
|
|
||||||
<div class="card bg-dark border-secondary">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
|
|
||||||
<span class="badge bg-warning text-dark ms-2"><i class="bi bi-shield-check me-1"></i>Admin</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
|
||||||
<div class="row g-2 align-items-end">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label small">Channel Key</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
|
|
||||||
placeholder="Enter or generate a key">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
|
|
||||||
title="Generate random key">
|
|
||||||
<i class="bi bi-shuffle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
|
|
||||||
<i class="bi bi-qr-code me-1"></i>Show QR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center mt-3 d-none" id="channelKeyQrContainer">
|
|
||||||
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
|
||||||
<div class="mt-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="channelKeyQrDownload">
|
|
||||||
<i class="bi bi-download me-1"></i>Download PNG
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -381,11 +340,13 @@
|
|||||||
<!-- Current Version - Prominent -->
|
<!-- Current Version - Prominent -->
|
||||||
<div class="alert alert-success mb-4">
|
<div class="alert alert-success mb-4">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
|
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Progress bars</strong> for encode operations,
|
<strong>Security & API improvements:</strong>
|
||||||
<strong>mobile-responsive polish</strong>,
|
API key authentication,
|
||||||
DCT decode bug fix, release validation script
|
TLS with self-signed certs,
|
||||||
|
CLI tools (compress, rotate, convert),
|
||||||
|
jpegtran lossless JPEG rotation
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,6 +364,10 @@
|
|||||||
<div class="accordion-body p-0">
|
<div class="accordion-body p-0">
|
||||||
<table class="table table-dark table-sm small mb-0">
|
<table class="table table-dark table-sm small mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="80"><strong>4.1.7</strong></td>
|
||||||
|
<td>Progress bars for encode, mobile polish, release validation</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="80"><strong>4.1.1</strong></td>
|
<td width="80"><strong>4.1.1</strong></td>
|
||||||
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||||
@@ -600,7 +565,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-clock me-2"></i>File expiry</td>
|
<td><i class="bi bi-clock me-2"></i>File expiry</td>
|
||||||
<td><strong>5 min</strong></td>
|
<td><strong>10 min</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-key me-2"></i>PIN</td>
|
<td><i class="bi bi-key me-2"></i>PIN</td>
|
||||||
@@ -608,7 +573,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
|
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
|
||||||
<td><strong>2048, 3072, 4096 bit</strong></td>
|
<td><strong>2048, 3072 bit</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
|
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
|
||||||
@@ -635,62 +600,3 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<!-- QR Code library for channel key sharing -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const input = document.getElementById('channelKeyQrInput');
|
|
||||||
const generateBtn = document.getElementById('channelKeyQrGenerate');
|
|
||||||
const showBtn = document.getElementById('channelKeyQrShow');
|
|
||||||
const container = document.getElementById('channelKeyQrContainer');
|
|
||||||
const canvas = document.getElementById('channelKeyQrCanvas');
|
|
||||||
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
|
||||||
|
|
||||||
// Generate random key
|
|
||||||
generateBtn?.addEventListener('click', function() {
|
|
||||||
if (input && typeof Stegasoo !== 'undefined') {
|
|
||||||
input.value = Stegasoo.generateChannelKey();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show QR code
|
|
||||||
showBtn?.addEventListener('click', function() {
|
|
||||||
const key = input?.value?.trim().replace(/-/g, '');
|
|
||||||
if (!key || key.length !== 32) {
|
|
||||||
alert('Please enter a valid 32-character channel key');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format key with dashes for QR
|
|
||||||
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
if (typeof QRCode !== 'undefined' && canvas) {
|
|
||||||
QRCode.toCanvas(canvas, formatted, {
|
|
||||||
width: 200,
|
|
||||||
margin: 2,
|
|
||||||
color: { dark: '#000', light: '#fff' }
|
|
||||||
}, function(error) {
|
|
||||||
if (error) {
|
|
||||||
console.error('QR generation error:', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container?.classList.remove('d-none');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Download QR as PNG
|
|
||||||
downloadBtn?.addEventListener('click', function() {
|
|
||||||
if (canvas) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.download = 'stegasoo-channel-key.png';
|
|
||||||
link.href = canvas.toDataURL('image/png');
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -250,7 +250,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer justify-content-center">
|
<div class="modal-footer justify-content-center">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrDownload">
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrDownload">
|
||||||
<i class="bi bi-download me-1"></i>Download PNG
|
<i class="bi bi-download me-1"></i>Download
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrPrint">
|
||||||
|
<i class="bi bi-printer me-1"></i>Print Sheet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +266,7 @@
|
|||||||
<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>
|
<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="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||||
@@ -305,20 +308,20 @@ function showKeyQr(channelKey, keyName) {
|
|||||||
document.getElementById('qrKeyName').textContent = keyName;
|
document.getElementById('qrKeyName').textContent = keyName;
|
||||||
document.getElementById('qrKeyDisplay').textContent = formatted;
|
document.getElementById('qrKeyDisplay').textContent = formatted;
|
||||||
|
|
||||||
// Generate QR code
|
// Generate QR code using QRious
|
||||||
const canvas = document.getElementById('qrCanvas');
|
const canvas = document.getElementById('qrCanvas');
|
||||||
if (typeof QRCode !== 'undefined' && canvas) {
|
if (typeof QRious !== 'undefined' && canvas) {
|
||||||
QRCode.toCanvas(canvas, formatted, {
|
try {
|
||||||
width: 200,
|
new QRious({
|
||||||
margin: 2,
|
element: canvas,
|
||||||
color: { dark: '#000', light: '#fff' }
|
value: formatted,
|
||||||
}, function(error) {
|
size: 200,
|
||||||
if (error) {
|
level: 'M'
|
||||||
console.error('QR generation error:', error);
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
new bootstrap.Modal(document.getElementById('qrModal')).show();
|
new bootstrap.Modal(document.getElementById('qrModal')).show();
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error('QR generation error:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +336,105 @@ document.getElementById('qrDownload')?.addEventListener('click', function() {
|
|||||||
link.click();
|
link.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Print tiled QR sheet (US Letter)
|
||||||
|
document.getElementById('qrPrint')?.addEventListener('click', function() {
|
||||||
|
const canvas = document.getElementById('qrCanvas');
|
||||||
|
const keyText = document.getElementById('qrKeyDisplay').textContent;
|
||||||
|
const keyName = document.getElementById('qrKeyName').textContent;
|
||||||
|
if (canvas && keyText) {
|
||||||
|
printQrSheet(canvas, keyText, keyName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print QR codes tiled on US Letter paper (8.5" x 11")
|
||||||
|
function printQrSheet(canvas, keyText, title) {
|
||||||
|
const qrDataUrl = canvas.toDataURL('image/png');
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Please allow popups to print');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
|
||||||
|
const cols = 4;
|
||||||
|
const rows = 5;
|
||||||
|
|
||||||
|
// Split key into two lines (4 groups each)
|
||||||
|
const keyParts = keyText.split('-');
|
||||||
|
const keyLine1 = keyParts.slice(0, 4).join('-');
|
||||||
|
const keyLine2 = keyParts.slice(4).join('-');
|
||||||
|
|
||||||
|
let qrGrid = '';
|
||||||
|
for (let i = 0; i < rows * cols; i++) {
|
||||||
|
qrGrid += `
|
||||||
|
<div class="qr-tile">
|
||||||
|
<div class="key-text">${keyLine1}</div>
|
||||||
|
<img src="${qrDataUrl}" alt="QR">
|
||||||
|
<div class="key-text">${keyLine2}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: letter;
|
||||||
|
margin: 0.2in;
|
||||||
|
margin-top: 0.1in;
|
||||||
|
margin-bottom: 0.1in;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
@page { margin: 0.15in; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(${cols}, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 0.09in;
|
||||||
|
}
|
||||||
|
.qr-tile {
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
padding: 0.04in;
|
||||||
|
text-align: center;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.qr-tile img {
|
||||||
|
width: 1.6in;
|
||||||
|
height: 1.6in;
|
||||||
|
}
|
||||||
|
.key-text {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="grid">${qrGrid}</div>
|
||||||
|
<div class="footer">Cut along dashed lines</div>
|
||||||
|
<script>
|
||||||
|
window.onload = function() { window.print(); };
|
||||||
|
<\/script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
506
frontends/web/templates/admin/settings.html
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}System Settings - Stegasoo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<!-- Channel Key Configuration -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-broadcast me-2"></i>Channel Key Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if channel_configured %}
|
||||||
|
<div class="alert alert-success mb-4">
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>
|
||||||
|
<strong>Server channel key active:</strong>
|
||||||
|
<code class="ms-2">{{ channel_fingerprint }}</code>
|
||||||
|
<span class="text-muted ms-2">({{ channel_source }})</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
Server running in <strong>public mode</strong>.
|
||||||
|
Set <code>STEGASOO_CHANNEL_KEY</code> environment variable to enable server-wide channel isolation.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- QR Code Generator -->
|
||||||
|
<div class="card bg-dark border-secondary">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
||||||
|
|
||||||
|
<!-- Locked state - requires password -->
|
||||||
|
<div id="channelKeyLocked">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label small">Channel Key</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control font-monospace"
|
||||||
|
value="********************************" disabled>
|
||||||
|
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button class="btn btn-warning w-100" type="button" id="channelKeyUnlock">
|
||||||
|
<i class="bi bi-unlock me-1"></i>Unlock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted mt-2 d-block">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>Re-enter your password to view or export the channel key.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unlocked state - shows key and QR options -->
|
||||||
|
<div id="channelKeyUnlocked" style="display: none;">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label small">Channel Key</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
|
||||||
|
placeholder="Enter or generate a key">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
|
||||||
|
title="Generate random key">
|
||||||
|
<i class="bi bi-shuffle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
|
||||||
|
<i class="bi bi-qr-code me-1"></i>Show QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-success mt-2 d-block">
|
||||||
|
<i class="bi bi-unlock me-1"></i>Unlocked for this session.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Configuration -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Server Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<table class="table table-dark table-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-hdd-network me-2"></i>Hostname</td>
|
||||||
|
<td><code>{{ hostname }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-ethernet me-2"></i>Port</td>
|
||||||
|
<td><code>{{ port }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-shield-lock me-2"></i>HTTPS</td>
|
||||||
|
<td>
|
||||||
|
{% if https_enabled %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-lock me-1"></i>Enabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark"><i class="bi bi-unlock me-1"></i>Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-person-lock me-2"></i>Authentication</td>
|
||||||
|
<td>
|
||||||
|
{% if auth_enabled %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Enabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x me-1"></i>Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<table class="table table-dark table-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-file-earmark me-2"></i>Max Payload</td>
|
||||||
|
<td><code>{{ max_payload_kb }} KB</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-upload me-2"></i>Max Upload</td>
|
||||||
|
<td><code>{{ max_upload_mb }} MB</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-soundwave me-2"></i>DCT Mode</td>
|
||||||
|
<td>
|
||||||
|
{% if dct_available %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Not Available</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-qr-code me-2"></i>QR Support</td>
|
||||||
|
<td>
|
||||||
|
{% if qr_available %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Not Available</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary small mt-3 mb-0">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
To change server settings, edit environment variables or config file and restart the service.
|
||||||
|
<br>See <code>STEGASOO_HTTPS_ENABLED</code>, <code>STEGASOO_PORT</code>, <code>STEGASOO_CHANNEL_KEY</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Environment Info -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Environment</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-box text-primary fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Version</div>
|
||||||
|
<strong>{{ version }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-terminal text-info fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Python</div>
|
||||||
|
<strong>{{ python_version }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-cpu text-warning fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">Platform</div>
|
||||||
|
<strong>{{ platform }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
|
<i class="bi bi-shield-check text-success fs-3 d-block mb-2"></i>
|
||||||
|
<div class="small text-muted">KDF</div>
|
||||||
|
<strong>{{ kdf_type }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Verification Modal -->
|
||||||
|
<div class="modal fade" id="passwordModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title"><i class="bi bi-shield-lock me-2"></i>Verify Password</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="small text-muted mb-3">Re-enter your password to access sensitive data.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Password</label>
|
||||||
|
<input type="password" class="form-control" id="verifyPassword" autocomplete="current-password">
|
||||||
|
<div class="invalid-feedback" id="passwordError">Incorrect password</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-warning btn-sm" id="verifyPasswordBtn">
|
||||||
|
<i class="bi bi-unlock me-1"></i>Unlock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<div class="modal fade" id="channelKeyQrModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i>Channel Key</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||||
|
<div class="mt-2">
|
||||||
|
<code class="small" id="channelKeyQrDisplay"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-center">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrDownload">
|
||||||
|
<i class="bi bi-download me-1"></i>Download
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrPrint">
|
||||||
|
<i class="bi bi-printer me-1"></i>Print Sheet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const input = document.getElementById('channelKeyQrInput');
|
||||||
|
const generateBtn = document.getElementById('channelKeyQrGenerate');
|
||||||
|
const showBtn = document.getElementById('channelKeyQrShow');
|
||||||
|
const canvas = document.getElementById('channelKeyQrCanvas');
|
||||||
|
const displayEl = document.getElementById('channelKeyQrDisplay');
|
||||||
|
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
||||||
|
const modalEl = document.getElementById('channelKeyQrModal');
|
||||||
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
|
||||||
|
// Password verification elements
|
||||||
|
const lockedDiv = document.getElementById('channelKeyLocked');
|
||||||
|
const unlockedDiv = document.getElementById('channelKeyUnlocked');
|
||||||
|
const unlockBtn = document.getElementById('channelKeyUnlock');
|
||||||
|
const passwordModalEl = document.getElementById('passwordModal');
|
||||||
|
const passwordModal = passwordModalEl ? new bootstrap.Modal(passwordModalEl) : null;
|
||||||
|
const verifyPasswordInput = document.getElementById('verifyPassword');
|
||||||
|
const verifyPasswordBtn = document.getElementById('verifyPasswordBtn');
|
||||||
|
const passwordError = document.getElementById('passwordError');
|
||||||
|
|
||||||
|
// Unlock button shows password modal
|
||||||
|
unlockBtn?.addEventListener('click', function() {
|
||||||
|
verifyPasswordInput.value = '';
|
||||||
|
verifyPasswordInput.classList.remove('is-invalid');
|
||||||
|
passwordModal?.show();
|
||||||
|
setTimeout(() => verifyPasswordInput.focus(), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Enter key in password field
|
||||||
|
verifyPasswordInput?.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
verifyPasswordBtn?.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify password and unlock
|
||||||
|
verifyPasswordBtn?.addEventListener('click', async function() {
|
||||||
|
const password = verifyPasswordInput.value;
|
||||||
|
if (!password) {
|
||||||
|
verifyPasswordInput.classList.add('is-invalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPasswordBtn.disabled = true;
|
||||||
|
verifyPasswordBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Verifying...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/settings/unlock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Unlock successful
|
||||||
|
passwordModal?.hide();
|
||||||
|
lockedDiv.style.display = 'none';
|
||||||
|
unlockedDiv.style.display = 'block';
|
||||||
|
if (data.channel_key && input) {
|
||||||
|
input.value = data.channel_key;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Password incorrect
|
||||||
|
verifyPasswordInput.classList.add('is-invalid');
|
||||||
|
passwordError.textContent = data.error || 'Incorrect password';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
verifyPasswordInput.classList.add('is-invalid');
|
||||||
|
passwordError.textContent = 'Verification failed';
|
||||||
|
} finally {
|
||||||
|
verifyPasswordBtn.disabled = false;
|
||||||
|
verifyPasswordBtn.innerHTML = '<i class="bi bi-unlock me-1"></i>Unlock';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate random key
|
||||||
|
generateBtn?.addEventListener('click', function() {
|
||||||
|
if (!input) return;
|
||||||
|
if (typeof Stegasoo !== 'undefined' && Stegasoo.generateChannelKey) {
|
||||||
|
input.value = Stegasoo.generateChannelKey();
|
||||||
|
} else {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let key = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (i > 0) key += '-';
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.value = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show QR code in modal
|
||||||
|
showBtn?.addEventListener('click', function() {
|
||||||
|
const key = input?.value?.trim().replace(/-/g, '');
|
||||||
|
if (!key || key.length !== 32) {
|
||||||
|
alert('Please enter a valid 32-character channel key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
||||||
|
|
||||||
|
if (typeof QRious === 'undefined') {
|
||||||
|
alert('QR Code library failed to load.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new QRious({
|
||||||
|
element: canvas,
|
||||||
|
value: formatted,
|
||||||
|
size: 200,
|
||||||
|
level: 'M'
|
||||||
|
});
|
||||||
|
if (displayEl) displayEl.textContent = formatted;
|
||||||
|
modal?.show();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to generate QR code: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download QR as PNG
|
||||||
|
downloadBtn?.addEventListener('click', function() {
|
||||||
|
if (canvas) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'stegasoo-channel-key.png';
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print tiled QR sheet (US Letter)
|
||||||
|
document.getElementById('channelKeyQrPrint')?.addEventListener('click', function() {
|
||||||
|
if (canvas && displayEl) {
|
||||||
|
printQrSheet(canvas, displayEl.textContent, 'Channel Key');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print QR codes tiled on US Letter paper (8.5" x 11")
|
||||||
|
function printQrSheet(canvas, keyText, title) {
|
||||||
|
const qrDataUrl = canvas.toDataURL('image/png');
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Please allow popups to print');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
|
||||||
|
const cols = 4;
|
||||||
|
const rows = 5;
|
||||||
|
|
||||||
|
// Split key into two lines (4 groups each)
|
||||||
|
const keyParts = keyText.split('-');
|
||||||
|
const keyLine1 = keyParts.slice(0, 4).join('-');
|
||||||
|
const keyLine2 = keyParts.slice(4).join('-');
|
||||||
|
|
||||||
|
let qrGrid = '';
|
||||||
|
for (let i = 0; i < rows * cols; i++) {
|
||||||
|
qrGrid += `
|
||||||
|
<div class="qr-tile">
|
||||||
|
<div class="key-text">${keyLine1}</div>
|
||||||
|
<img src="${qrDataUrl}" alt="QR">
|
||||||
|
<div class="key-text">${keyLine2}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: letter;
|
||||||
|
margin: 0.2in;
|
||||||
|
margin-top: 0.1in;
|
||||||
|
margin-bottom: 0.1in;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
@page { margin: 0.15in; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(${cols}, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 0.09in;
|
||||||
|
}
|
||||||
|
.qr-tile {
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
padding: 0.04in;
|
||||||
|
text-align: center;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.qr-tile img {
|
||||||
|
width: 1.6in;
|
||||||
|
height: 1.6in;
|
||||||
|
}
|
||||||
|
.key-text {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="grid">${qrGrid}</div>
|
||||||
|
<div class="footer">Cut along dashed lines</div>
|
||||||
|
<script>
|
||||||
|
window.onload = function() { window.print(); };
|
||||||
|
<\/script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,44 +5,46 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Stegasoo{% endblock %}</title>
|
<title>{% block title %}Stegasoo{% endblock %}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="{{ url_for('static', filename='vendor/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="{{ url_for('static', filename='vendor/css/bootstrap-icons.min.css') }}" rel="stylesheet">
|
||||||
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
<div class="container">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
<a class="navbar-brand" href="/" style="padding-left: 6px; margin-right: 8px;">
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
|
||||||
<span style="position: relative; display: inline-block; margin-top: -14px;">
|
|
||||||
<span class="fw-bold title-gold">Stegasoo</span>
|
|
||||||
<span class="badge bg-success" style="position: absolute; font-size: 0.45rem; bottom: -8px; right: 6px;">v4.1</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
{% if channel_configured %}
|
||||||
|
<span class="badge bg-success bg-opacity-25 small" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
|
||||||
|
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary bg-opacity-25 small text-muted" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
|
||||||
|
<i class="bi bi-globe me-1"></i>Public Channel
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto nav-icons">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
|
<a class="nav-link nav-expand" href="/"><i class="bi bi-house"></i><span>Home</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% if not auth_enabled or is_authenticated %}
|
{% if not auth_enabled or is_authenticated %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
|
<a class="nav-link nav-expand" href="/encode"><i class="bi bi-lock"></i><span>Encode</span></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
|
<a class="nav-link nav-expand" href="/decode"><i class="bi bi-unlock"></i><span>Decode</span></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
|
<a class="nav-link nav-expand" href="/generate"><i class="bi bi-key"></i><span>Generate</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
<a class="nav-link nav-expand" href="/tools"><i class="bi bi-tools"></i><span>Tools</span></a>
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/tools"><i class="bi bi-tools me-1"></i> Tools</a>
|
|
||||||
</li>
|
</li>
|
||||||
{% if auth_enabled %}
|
{% if auth_enabled %}
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
|
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/settings"><i class="bi bi-sliders me-2"></i>System Settings</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
||||||
@@ -96,13 +99,15 @@
|
|||||||
<small>
|
<small>
|
||||||
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
|
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
|
||||||
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
|
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
|
||||||
|
<span class="mx-2">|</span>
|
||||||
|
<a href="/about" class="text-muted text-decoration-none"><i class="bi bi-info-circle me-1"></i>About</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="{{ url_for('static', filename='vendor/js/bootstrap.bundle.min.js') }}"></script>
|
||||||
<!-- QR Code scanning library (v4.1.5) -->
|
<!-- QR Code scanning library (local) -->
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
<script src="{{ url_for('static', filename='vendor/js/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));
|
||||||
|
|||||||
@@ -6,20 +6,25 @@
|
|||||||
<style>
|
<style>
|
||||||
/* Accordion styling */
|
/* Accordion styling */
|
||||||
.step-accordion .accordion-button {
|
.step-accordion .accordion-button {
|
||||||
background: rgba(30, 40, 50, 0.6);
|
background: rgba(35, 45, 55, 0.8);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid rgba(255, 230, 153, 0.3);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
.step-accordion .accordion-button:hover {
|
||||||
|
background: rgba(45, 55, 65, 0.9);
|
||||||
|
border-left-color: rgba(255, 230, 153, 0.5);
|
||||||
|
}
|
||||||
.step-accordion .accordion-button:not(.collapsed) {
|
.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%);
|
background: linear-gradient(90deg, rgba(255, 230, 153, 0.12) 0%, rgba(40, 50, 60, 0.85) 40%, rgba(40, 50, 60, 0.85) 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1);
|
box-shadow: inset 0 1px 0 rgba(255, 230, 153, 0.1);
|
||||||
border-left: 3px solid rgba(99, 179, 237, 0.6);
|
border-left: 3px solid #ffe699;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-button::after {
|
.step-accordion .accordion-button::after {
|
||||||
filter: invert(1);
|
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-body {
|
.step-accordion .accordion-body {
|
||||||
background: rgba(30, 40, 50, 0.4);
|
background: rgba(30, 40, 50, 0.4);
|
||||||
@@ -106,46 +111,7 @@
|
|||||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* QR Crop Animation */
|
/* QR Crop Animation - uses .qr-scan-container from style.css */
|
||||||
.qr-crop-container {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
.qr-crop-container img {
|
|
||||||
display: block;
|
|
||||||
max-height: 180px;
|
|
||||||
max-width: 180px;
|
|
||||||
width: auto;
|
|
||||||
margin: 0 auto;
|
|
||||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
.qr-crop-container .qr-original { opacity: 1; }
|
|
||||||
.qr-crop-container .qr-cropped {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%; left: 50%;
|
|
||||||
transform: translate(-50%, -50%) scale(0.3);
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 160px;
|
|
||||||
min-width: 140px;
|
|
||||||
min-height: 140px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.qr-crop-container.scan-complete .qr-original {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(1.1);
|
|
||||||
filter: blur(4px);
|
|
||||||
}
|
|
||||||
.qr-crop-container.scan-complete .qr-cropped {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -192,7 +158,7 @@
|
|||||||
|
|
||||||
<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 10 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">
|
||||||
@@ -274,26 +240,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extraction Mode -->
|
<!-- Extraction Mode -->
|
||||||
<label class="form-label"><i class="bi bi-cpu me-1"></i> Extraction Mode</label>
|
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||||
<div class="d-flex gap-2 mb-2">
|
<div class="btn-group" role="group">
|
||||||
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
|
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||||
<i class="bi bi-magic text-success ms-2"></i>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
|
||||||
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
</label>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||||
<label class="mode-btn flex-fill" id="lsbModeCard" for="modeLsb">
|
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb">
|
</div>
|
||||||
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
|
|
||||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
|
|
||||||
</label>
|
|
||||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct">
|
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
|
||||||
<i class="bi bi-soundwave text-warning ms-2"></i>
|
|
||||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">
|
<div class="form-text" id="modeHint">
|
||||||
<i class="bi bi-lightbulb me-1"></i><strong>Auto</strong> tries LSB first, then DCT.
|
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -394,7 +352,7 @@
|
|||||||
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
|
<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>
|
<span class="text-muted small">Drop QR image</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
<div class="qr-scan-container d-none" id="qrCropContainer">
|
||||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||||
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
||||||
</div>
|
</div>
|
||||||
@@ -461,6 +419,25 @@
|
|||||||
{% 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>
|
||||||
|
// ============================================================================
|
||||||
|
// MODE HINT - Dynamic text based on selected extraction mode
|
||||||
|
// ============================================================================
|
||||||
|
const modeHints = {
|
||||||
|
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||||
|
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
||||||
|
dct: { icon: 'phone', text: 'For social media images' }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
const hint = document.getElementById('modeHint');
|
||||||
|
const data = modeHints[this.value];
|
||||||
|
if (hint && data) {
|
||||||
|
hint.innerHTML = `<i class="bi bi-${data.icon} me-1"></i>${data.text}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACCORDION SUMMARY UPDATES
|
// ACCORDION SUMMARY UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -533,15 +510,10 @@ document.querySelector('input[name="rsa_key"]')?.addEventListener('change', upda
|
|||||||
// MODE SWITCHING
|
// MODE SWITCHING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
// Apply disabled styling to DCT if not available
|
||||||
const modeBtns = { 'auto': document.getElementById('autoModeCard'), 'lsb': document.getElementById('lsbModeCard'), 'dct': document.getElementById('dctModeCard') };
|
if (document.getElementById('modeDct')?.disabled) {
|
||||||
|
document.getElementById('dctModeLabel')?.classList.add('disabled', 'text-muted');
|
||||||
modeRadios.forEach(radio => {
|
}
|
||||||
radio.addEventListener('change', () => {
|
|
||||||
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
|
|
||||||
modeBtns[radio.value]?.classList.add('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LOADING STATE
|
// LOADING STATE
|
||||||
|
|||||||
@@ -6,20 +6,25 @@
|
|||||||
<style>
|
<style>
|
||||||
/* Accordion styling */
|
/* Accordion styling */
|
||||||
.step-accordion .accordion-button {
|
.step-accordion .accordion-button {
|
||||||
background: rgba(30, 40, 50, 0.6);
|
background: rgba(35, 45, 55, 0.8);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid rgba(255, 230, 153, 0.3);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
.step-accordion .accordion-button:hover {
|
||||||
|
background: rgba(45, 55, 65, 0.9);
|
||||||
|
border-left-color: rgba(255, 230, 153, 0.5);
|
||||||
|
}
|
||||||
.step-accordion .accordion-button:not(.collapsed) {
|
.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%);
|
background: linear-gradient(90deg, rgba(255, 230, 153, 0.12) 0%, rgba(40, 50, 60, 0.85) 40%, rgba(40, 50, 60, 0.85) 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1);
|
box-shadow: inset 0 1px 0 rgba(255, 230, 153, 0.1);
|
||||||
border-left: 3px solid rgba(99, 179, 237, 0.6);
|
border-left: 3px solid #ffe699;
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-button::after {
|
.step-accordion .accordion-button::after {
|
||||||
filter: invert(1);
|
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
||||||
}
|
}
|
||||||
.step-accordion .accordion-body {
|
.step-accordion .accordion-body {
|
||||||
background: rgba(30, 40, 50, 0.4);
|
background: rgba(30, 40, 50, 0.4);
|
||||||
@@ -106,46 +111,7 @@
|
|||||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* QR Crop Animation */
|
/* QR Crop Animation - uses .qr-scan-container from style.css */
|
||||||
.qr-crop-container {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
.qr-crop-container img {
|
|
||||||
display: block;
|
|
||||||
max-height: 180px;
|
|
||||||
max-width: 180px;
|
|
||||||
width: auto;
|
|
||||||
margin: 0 auto;
|
|
||||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
.qr-crop-container .qr-original { opacity: 1; }
|
|
||||||
.qr-crop-container .qr-cropped {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%; left: 50%;
|
|
||||||
transform: translate(-50%, -50%) scale(0.3);
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 160px;
|
|
||||||
min-width: 140px;
|
|
||||||
min-height: 140px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.qr-crop-container.scan-complete .qr-original {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(1.1);
|
|
||||||
filter: blur(4px);
|
|
||||||
}
|
|
||||||
.qr-crop-container.scan-complete .qr-cropped {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -202,7 +168,7 @@
|
|||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
<i class="bi bi-file-image me-1"></i> Carrier Image
|
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
|
||||||
</label>
|
</label>
|
||||||
<div class="drop-zone pixel-container" id="carrierDropZone">
|
<div class="drop-zone pixel-container" id="carrierDropZone">
|
||||||
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
||||||
@@ -238,37 +204,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Embedding Mode -->
|
<!-- Embedding Mode (compact inline) -->
|
||||||
<label class="form-label"><i class="bi bi-cpu me-1"></i> Embedding Mode</label>
|
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||||
<div class="d-flex gap-2 mb-2">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %} {% if has_dct %}active{% endif %}" id="dctModeCard" for="modeDct">
|
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||||
<i class="bi bi-soundwave text-warning ms-2"></i>
|
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
|
||||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
|
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||||
</label>
|
|
||||||
<label class="mode-btn flex-fill {% if not has_dct %}active{% endif %}" id="lsbModeCard" for="modeLsb">
|
|
||||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
|
|
||||||
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
|
|
||||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DCT Options (inline, compact) -->
|
|
||||||
<div class="{% if not has_dct %}d-none{% endif %}" id="dctOptionsInline">
|
|
||||||
<div class="row g-2 mt-2" id="dctOptionsRow">
|
|
||||||
<div class="col-6">
|
|
||||||
<select class="form-select form-select-sm" name="dct_color_mode" id="dctColorSelect">
|
|
||||||
<option value="color" selected>Color</option>
|
|
||||||
<option value="grayscale">Grayscale</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<select class="form-select form-select-sm" name="dct_output_format" id="dctFormatSelect">
|
|
||||||
<option value="jpeg" selected>JPEG</option>
|
|
||||||
<option value="png">PNG</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-muted d-none d-sm-inline">|</span>
|
||||||
|
<span class="d-flex gap-2 align-items-center" id="outputOptions">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="dct_color_mode" id="colorMode" value="color" checked>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="colorMode">Color</label>
|
||||||
|
<input type="radio" class="btn-check" name="dct_color_mode" id="grayMode" value="grayscale">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="grayMode" id="grayModeLabel">Gray</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="dct_output_format" id="jpegFormat" value="jpeg" checked>
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="jpegFormat" id="jpegFormatLabel">JPEG</label>
|
||||||
|
<input type="radio" class="btn-check" name="dct_output_format" id="pngFormat" value="png">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="pngFormat">PNG</label>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text" id="modeHint">
|
||||||
|
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -428,7 +389,7 @@
|
|||||||
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
|
<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>
|
<span class="text-muted small">Drop QR image</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
<div class="qr-scan-container d-none" id="qrCropContainer">
|
||||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||||
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
||||||
</div>
|
</div>
|
||||||
@@ -473,7 +434,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<i class="bi bi-eye-slash fs-5 d-block mb-1 text-warning"></i>
|
<i class="bi bi-eye-slash fs-5 d-block mb-1 text-warning"></i>
|
||||||
Undetectable
|
Covertly Embedded
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,6 +444,24 @@
|
|||||||
{% 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>
|
||||||
|
// ============================================================================
|
||||||
|
// MODE HINT - Dynamic text based on selected embedding mode
|
||||||
|
// ============================================================================
|
||||||
|
const modeHints = {
|
||||||
|
dct: { icon: 'phone', text: 'Survives social media compression' },
|
||||||
|
lsb: { icon: 'hdd', text: 'Higher capacity, outputs Color PNG' }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
const hint = document.getElementById('modeHint');
|
||||||
|
const data = modeHints[this.value];
|
||||||
|
if (hint && data) {
|
||||||
|
hint.innerHTML = `<i class="bi bi-${data.icon} me-1"></i>${data.text}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACCORDION SUMMARY UPDATES
|
// ACCORDION SUMMARY UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -665,21 +644,47 @@ carrierInput?.addEventListener('change', function() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
||||||
const modeBtns = { 'dct': document.getElementById('dctModeCard'), 'lsb': document.getElementById('lsbModeCard') };
|
const dctModeLabel = document.getElementById('dctModeLabel');
|
||||||
const dctOptionsRow = document.getElementById('dctOptionsRow');
|
const grayModeInput = document.getElementById('grayMode');
|
||||||
|
const grayModeLabel = document.getElementById('grayModeLabel');
|
||||||
|
const jpegFormatInput = document.getElementById('jpegFormat');
|
||||||
|
const jpegFormatLabel = document.getElementById('jpegFormatLabel');
|
||||||
|
const colorModeInput = document.getElementById('colorMode');
|
||||||
|
const pngFormatInput = document.getElementById('pngFormat');
|
||||||
|
|
||||||
|
// Apply disabled styling to DCT if not available
|
||||||
|
if (document.getElementById('modeDct')?.disabled) {
|
||||||
|
dctModeLabel?.classList.add('disabled', 'text-muted');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutputOptions(mode) {
|
||||||
|
const isLsb = mode === 'lsb';
|
||||||
|
if (isLsb) {
|
||||||
|
// LSB only supports Color + PNG
|
||||||
|
colorModeInput.checked = true;
|
||||||
|
pngFormatInput.checked = true;
|
||||||
|
grayModeInput.disabled = true;
|
||||||
|
jpegFormatInput.disabled = true;
|
||||||
|
grayModeLabel?.classList.add('disabled', 'text-muted');
|
||||||
|
jpegFormatLabel?.classList.add('disabled', 'text-muted');
|
||||||
|
} else {
|
||||||
|
// DCT: reset to defaults (Color + JPEG) and enable all
|
||||||
|
colorModeInput.checked = true;
|
||||||
|
jpegFormatInput.checked = true;
|
||||||
|
grayModeInput.disabled = false;
|
||||||
|
jpegFormatInput.disabled = false;
|
||||||
|
grayModeLabel?.classList.remove('disabled', 'text-muted');
|
||||||
|
jpegFormatLabel?.classList.remove('disabled', 'text-muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modeRadios.forEach(radio => {
|
modeRadios.forEach(radio => {
|
||||||
radio.addEventListener('change', () => {
|
radio.addEventListener('change', () => updateOutputOptions(radio.value));
|
||||||
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
|
|
||||||
modeBtns[radio.value]?.classList.add('active');
|
|
||||||
dctOptionsRow?.classList.toggle('d-none', radio.value !== 'dct');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show DCT options if DCT selected initially
|
// Initialize output options based on initial mode
|
||||||
if (document.getElementById('modeDct')?.checked) {
|
const initialMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'lsb';
|
||||||
dctOptionsRow?.classList.remove('d-none');
|
updateOutputOptions(initialMode);
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DUPLICATE FILE CHECK
|
// DUPLICATE FILE CHECK
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Important:</strong>
|
<strong>Important:</strong>
|
||||||
<ul class="mb-0 mt-2">
|
<ul class="mb-0 mt-2">
|
||||||
<li>This file expires in <strong>5 minutes</strong></li>
|
<li>This file expires in <strong>10 minutes</strong></li>
|
||||||
<li>Do <strong>not</strong> resize or recompress the image</li>
|
<li>Do <strong>not</strong> resize or recompress the image</li>
|
||||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||||
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
||||||
|
|||||||
@@ -65,11 +65,7 @@
|
|||||||
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
|
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
|
||||||
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
|
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
|
||||||
<option value="3072">3072 bits (~128 bits entropy)</option>
|
<option value="3072">3072 bits (~128 bits entropy)</option>
|
||||||
<option value="4096">4096 bits (~128 bits entropy)</option>
|
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text text-warning d-none" id="rsaQrWarning">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys >3072 bits
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,8 +96,8 @@
|
|||||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||||
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
|
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
|
||||||
placeholder="Click Generate to create a key" readonly>
|
placeholder="Click Generate to create a key" readonly>
|
||||||
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
|
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn" title="Generate Channel Key">
|
||||||
<i class="bi bi-shuffle me-1"></i>Generate
|
<i class="bi bi-shuffle"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
|
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
|
||||||
<i class="bi bi-clipboard"></i>
|
<i class="bi bi-clipboard"></i>
|
||||||
@@ -286,12 +282,6 @@
|
|||||||
<i class="bi bi-shield-exclamation me-1"></i>
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
<strong>Security note:</strong> The QR code contains your unencrypted private key.
|
<strong>Security note:</strong> The QR code contains your unencrypted private key.
|
||||||
Only scan in a secure environment. Consider using the password-protected download instead.
|
Only scan in a secure environment. Consider using the password-protected download instead.
|
||||||
{% if rsa_bits >= 4096 %}
|
|
||||||
<br><br>
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>4096-bit keys</strong> produce very dense QR codes. If scanning fails,
|
|
||||||
use the PEM text or download options instead.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,17 +473,17 @@
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
.pin-container, .passphrase-container {
|
.pin-container, .passphrase-container {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-digit-box {
|
.pin-digit-box {
|
||||||
width: 2.25rem;
|
width: 1.9rem;
|
||||||
height: 2.75rem;
|
height: 2.4rem;
|
||||||
font-size: 1.25rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-digits-row {
|
.pin-digits-row {
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.passphrase-text {
|
.passphrase-text {
|
||||||
|
|||||||
@@ -3,170 +3,64 @@
|
|||||||
{% block title %}Stegasoo - Secure Steganography{% endblock %}
|
{% block title %}Stegasoo - Secure Steganography{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.home-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.home-icon i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.9));
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.home-icon span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.home-icon:hover i {
|
||||||
|
color: #e5d058;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
filter: drop-shadow(0 5px 4px rgba(0, 0, 0, 0.8));
|
||||||
|
}
|
||||||
|
.home-icon:hover span {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
color: #e5d058;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height: 70vh;">
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex align-items-end justify-content-center gap-4">
|
<!-- Hero -->
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
|
<div class="d-flex align-items-center mb-4" style="gap: 8px;">
|
||||||
<div style="margin-bottom: 40px;">
|
<div class="position-relative">
|
||||||
<h1 class="display-4 fw-bold mb-2 title-gold">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="80">
|
||||||
Stegasoo
|
<span class="badge bg-success position-absolute" style="bottom: 1px; left: -6px; font-size: 0.6rem;">v4.1</span>
|
||||||
<span class="badge bg-success fs-6 ms-2">v4.1</span>
|
|
||||||
</h1>
|
|
||||||
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Channel Status Banner (v4.0.0) -->
|
|
||||||
{% if channel_configured %}
|
|
||||||
<div class="alert alert-success mb-4">
|
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-shield-lock me-2"></i>
|
<h1 class="display-5 fw-bold title-gold mb-0">Stegasoo</h1>
|
||||||
<strong>Private Channel Mode</strong>
|
<p class="text-muted mb-0 small" style="margin-top: 3px; padding-left: 3px; font-size: 0.85rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);">Hide encrypted data in plain sight.</p>
|
||||||
</div>
|
|
||||||
<div class="key-capsule">
|
|
||||||
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
|
|
||||||
<code class="small ms-2">{{ channel_fingerprint }}</code>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="row g-4 mb-5">
|
<!-- Action Icons -->
|
||||||
<!-- Encode Card -->
|
<div class="d-flex gap-4">
|
||||||
<div class="col-md-4">
|
<a href="/encode" class="home-icon"><i class="bi bi-lock-fill"></i><span>Encode</span></a>
|
||||||
<a href="/encode" class="text-decoration-none card-link">
|
<a href="/decode" class="home-icon"><i class="bi bi-unlock-fill"></i><span>Decode</span></a>
|
||||||
<div class="card h-100 feature-card">
|
<a href="/generate" class="home-icon"><i class="bi bi-key-fill"></i><span>Generate</span></a>
|
||||||
<div class="card-header text-center py-3">
|
|
||||||
<i class="bi bi-lock-fill fs-1 embossed-icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h5 class="card-title">Encode</h5>
|
|
||||||
<p class="card-text text-muted">
|
|
||||||
Hide encrypted messages or files inside images
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Decode Card -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<a href="/decode" class="text-decoration-none card-link">
|
|
||||||
<div class="card h-100 feature-card">
|
|
||||||
<div class="card-header text-center py-3">
|
|
||||||
<i class="bi bi-unlock-fill fs-1 embossed-icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h5 class="card-title">Decode</h5>
|
|
||||||
<p class="card-text text-muted">
|
|
||||||
Extract and decrypt hidden data from stego images
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generate Card -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<a href="/generate" class="text-decoration-none card-link">
|
|
||||||
<div class="card h-100 feature-card">
|
|
||||||
<div class="card-header text-center py-3">
|
|
||||||
<i class="bi bi-key-fill fs-1 embossed-icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h5 class="card-title">Generate</h5>
|
|
||||||
<p class="card-text text-muted">
|
|
||||||
Create passphrases, PINs, and RSA keys
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedding Modes -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col-md-6 mb-3 mb-md-0">
|
|
||||||
<div class="p-3 bg-dark rounded h-100">
|
|
||||||
<i class="bi bi-soundwave text-warning fs-2 d-block mb-2"></i>
|
|
||||||
<strong>DCT Mode</strong>
|
|
||||||
<span class="badge bg-success ms-1">Default</span>
|
|
||||||
<div class="small text-muted mt-2">
|
|
||||||
Survives JPEG recompression<br>
|
|
||||||
Best for social media
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="p-3 bg-dark rounded h-100">
|
|
||||||
<i class="bi bi-grid-3x3-gap text-primary fs-2 d-block mb-2"></i>
|
|
||||||
<strong>LSB Mode</strong>
|
|
||||||
<div class="small text-muted mt-2">
|
|
||||||
Higher capacity (~375 KB/MP)<br>
|
|
||||||
Best for email & file transfer
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
|
|
||||||
<a href="/about" class="btn btn-sm btn-outline-light">Learn More</a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-primary"><i class="bi bi-key me-2"></i>You Provide</h6>
|
|
||||||
<ul class="list-unstyled small">
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-image text-info me-2"></i>
|
|
||||||
<strong>Reference Photo</strong>: shared secret
|
|
||||||
</li>
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-chat-quote text-info me-2"></i>
|
|
||||||
<strong>Passphrase</strong>: 4+ words
|
|
||||||
</li>
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-123 text-info me-2"></i>
|
|
||||||
<strong>PIN</strong>: 6-9 digits (or RSA key)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>Security</h6>
|
|
||||||
<ul class="list-unstyled small">
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-lock text-success me-2"></i>
|
|
||||||
AES-256-GCM encryption
|
|
||||||
</li>
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-memory text-success me-2"></i>
|
|
||||||
Argon2id key derivation (256MB)
|
|
||||||
</li>
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-shuffle text-success me-2"></i>
|
|
||||||
Pseudo-random embedding
|
|
||||||
</li>
|
|
||||||
<li class="mb-1">
|
|
||||||
<i class="bi bi-broadcast text-success me-2"></i>
|
|
||||||
<strong>Channel keys</strong> for group isolation
|
|
||||||
<span class="badge bg-info ms-1">v4.1</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.1.5"
|
version = "4.2.1"
|
||||||
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"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Aaron D. Lee" }
|
{ name = "Aaron D. Lee" }
|
||||||
]
|
]
|
||||||
@@ -29,9 +29,10 @@ classifiers = [
|
|||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"Topic :: Security :: Cryptography",
|
"Topic :: Security :: Cryptography",
|
||||||
"Topic :: Multimedia :: Graphics",
|
"Topic :: Multimedia :: Graphics",
|
||||||
]
|
]
|
||||||
@@ -40,6 +41,7 @@ dependencies = [
|
|||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
"cryptography>=41.0.0",
|
"cryptography>=41.0.0",
|
||||||
"argon2-cffi>=23.0.0",
|
"argon2-cffi>=23.0.0",
|
||||||
|
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -47,7 +49,7 @@ dependencies = [
|
|||||||
dct = [
|
dct = [
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpeglib>=1.0.0",
|
||||||
"reedsolo>=1.7.0",
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
cli = [
|
cli = [
|
||||||
@@ -57,7 +59,7 @@ cli = [
|
|||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
]
|
]
|
||||||
compression = [
|
compression = [
|
||||||
"lz4>=4.0.0",
|
"lz4>=4.0.0", # Optional: faster but slightly worse ratio than zstd
|
||||||
]
|
]
|
||||||
web = [
|
web = [
|
||||||
"flask>=3.0.0",
|
"flask>=3.0.0",
|
||||||
@@ -68,7 +70,7 @@ web = [
|
|||||||
# Include DCT support for web UI
|
# Include DCT support for web UI
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpeglib>=1.0.0",
|
||||||
"reedsolo>=1.7.0",
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
api = [
|
api = [
|
||||||
@@ -80,7 +82,7 @@ api = [
|
|||||||
# Include DCT support for API
|
# Include DCT support for API
|
||||||
"numpy>=2.0.0",
|
"numpy>=2.0.0",
|
||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"jpegio>=0.2.0",
|
"jpeglib>=1.0.0",
|
||||||
"reedsolo>=1.7.0",
|
"reedsolo>=1.7.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
@@ -110,7 +112,7 @@ include = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/stegasoo"]
|
packages = ["src/stegasoo", "frontends"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
@@ -119,7 +121,7 @@ addopts = "-v --cov=stegasoo --cov-report=term-missing"
|
|||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = ["py310", "py311", "py312"]
|
target-version = ["py311", "py312", "py313"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
@@ -136,7 +138,7 @@ ignore = ["E501"]
|
|||||||
"src/stegasoo/__init__.py" = ["E402"]
|
"src/stegasoo/__init__.py" = ["E402"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.10"
|
python_version = "3.11"
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -26,51 +26,50 @@ ssh admin@stegasoo.local
|
|||||||
## Step 3: Pre-Setup
|
## Step 3: Pre-Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Take ownership of /opt (for pyenv, jpegio builds)
|
# Take ownership of /opt
|
||||||
sudo chown admin:admin /opt
|
sudo chown admin:admin /opt
|
||||||
|
|
||||||
# Install git and zstd (not included in Lite image)
|
# Install git (not included in Lite image)
|
||||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 4: Clone Repo
|
## Step 4: Clone Repo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt
|
cd /opt
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 5: Copy Pre-built Tarball (from host)
|
## Step 5: Run Setup
|
||||||
|
|
||||||
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
|
|
||||||
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On your host machine:
|
|
||||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
|
||||||
```
|
|
||||||
|
|
||||||
This tarball contains:
|
|
||||||
- pyenv with Python 3.12 (pre-compiled for ARM64)
|
|
||||||
- venv with all dependencies (jpegio, scipy, etc.)
|
|
||||||
|
|
||||||
Install time: **~2 minutes** (vs 20+ min from source)
|
|
||||||
|
|
||||||
## Step 6: Run Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/stegasoo
|
cd /opt/stegasoo
|
||||||
./rpi/setup.sh # Detects local tarball, skips download
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### From-Source Build (optional)
|
The setup script:
|
||||||
|
- Verifies Python 3.11+ (system Python, no pyenv needed)
|
||||||
|
- Installs dependencies via apt and pip
|
||||||
|
- jpeglib installs cleanly (no ARM patching like jpegio)
|
||||||
|
- Creates and enables systemd service
|
||||||
|
|
||||||
|
Install time: **5-10 minutes** (from source)
|
||||||
|
|
||||||
|
### Pre-built Venv (optional)
|
||||||
|
|
||||||
|
For faster installs, you can provide a pre-built venv tarball:
|
||||||
|
|
||||||
To build without the pre-built tarball:
|
|
||||||
```bash
|
```bash
|
||||||
./rpi/setup.sh --no-prebuilt # Takes 15-20 minutes
|
# On your host machine:
|
||||||
|
scp rpi/stegasoo-rpi-venv-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||||
|
|
||||||
|
# Then on Pi:
|
||||||
|
cd /opt/stegasoo && ./rpi/setup.sh # Detects local tarball, skips pip build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 7: Test It Works
|
Install time with pre-built: **~2 minutes**
|
||||||
|
|
||||||
|
## Step 6: Test It Works
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl start stegasoo
|
sudo systemctl start stegasoo
|
||||||
@@ -78,7 +77,7 @@ curl -k https://localhost:5000
|
|||||||
# Should return HTML
|
# Should return HTML
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 8: Sanitize for Distribution
|
## Step 7: Sanitize for Distribution
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full sanitize (for final image - removes WiFi, shuts down)
|
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||||
@@ -98,7 +97,7 @@ This removes:
|
|||||||
|
|
||||||
The script validates all cleanup steps before finishing.
|
The script validates all cleanup steps before finishing.
|
||||||
|
|
||||||
## Step 9: Pull the Image
|
## Step 8: Pull the Image
|
||||||
|
|
||||||
Remove SD card, insert into your Linux machine:
|
Remove SD card, insert into your Linux machine:
|
||||||
|
|
||||||
@@ -107,12 +106,12 @@ Remove SD card, insert into your Linux machine:
|
|||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The script automatically resizes rootfs to 16GB, disables auto-expand, and compresses.
|
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
||||||
|
|
||||||
## Step 10: Distribute
|
## Step 9: Distribute
|
||||||
|
|
||||||
Upload `.img.zst` to GitHub Releases.
|
Upload `.img.zst` to GitHub Releases.
|
||||||
|
|
||||||
@@ -130,36 +129,31 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Creating the Pre-built Tarball
|
## Creating the Pre-built Venv Tarball
|
||||||
|
|
||||||
After a successful from-source build, create the pre-built tarball for future installs:
|
After a successful from-source build, create the pre-built tarball for future installs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On the Pi after successful setup:
|
# On the Pi after successful setup:
|
||||||
cd ~
|
cd /opt/stegasoo
|
||||||
|
|
||||||
# Strip caches and tests from venv (295MB → 208MB)
|
# Strip caches and tests from venv (saves ~100MB)
|
||||||
find /opt/stegasoo/venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||||
find /opt/stegasoo/venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
||||||
find /opt/stegasoo/venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||||
|
|
||||||
# Create venv tarball
|
# Create venv tarball
|
||||||
cd /opt/stegasoo
|
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||||
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
|
|
||||||
|
|
||||||
# Create combined tarball (pyenv + venv pointer)
|
# Check size (should be ~40-50MB)
|
||||||
cd ~
|
ls -lh /tmp/stegasoo-rpi-venv-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)
|
|
||||||
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-rpi-runtime-env-arm64.tar.zst ./
|
scp admin@stegasoo.local:/tmp/stegasoo-rpi-venv-arm64.tar.zst ./rpi/
|
||||||
# Upload to GitHub releases as stegasoo-rpi-runtime-env-arm64.tar.zst
|
# Upload to GitHub releases as stegasoo-rpi-venv-arm64.tar.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -169,18 +163,15 @@ scp admin@stegasoo.local:/tmp/stegasoo-rpi-runtime-env-arm64.tar.zst ./
|
|||||||
```bash
|
```bash
|
||||||
# On Pi (after SSH):
|
# On Pi (after SSH):
|
||||||
sudo chown admin:admin /opt
|
sudo chown admin:admin /opt
|
||||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
cd /opt && git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
|
|
||||||
# On host (copy tarball):
|
# Run setup:
|
||||||
scp rpi/stegasoo-rpi-runtime-env-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
|
||||||
|
|
||||||
# On Pi (run setup):
|
|
||||||
cd /opt/stegasoo && ./rpi/setup.sh
|
cd /opt/stegasoo && ./rpi/setup.sh
|
||||||
sudo systemctl start stegasoo
|
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 - auto-resizes to 16GB):
|
# On host (pull image - auto-resizes to 16GB):
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Scripts and resources for deploying Stegasoo on Raspberry Pi.
|
|||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
On a fresh Raspberry Pi OS Lite (64-bit) installation:
|
On a fresh Raspberry Pi OS (64-bit) installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pre-setup (git not included in Lite image)
|
# Pre-setup (git not included in Lite image)
|
||||||
@@ -13,16 +13,16 @@ sudo apt-get update && sudo apt-get install -y git
|
|||||||
|
|
||||||
# Clone and run setup
|
# Clone and run setup
|
||||||
cd /opt
|
cd /opt
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
cd stegasoo
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## What the Setup Script Does
|
## What the Setup Script Does
|
||||||
|
|
||||||
1. **Installs system dependencies** - build tools, libraries
|
1. **Verifies Python 3.11+** - uses system Python (no pyenv needed)
|
||||||
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
|
2. **Installs system dependencies** - build tools, libraries
|
||||||
3. **Builds jpegio for ARM** - patches x86-specific flags
|
3. **Installs jpeglib** - DCT steganography (Python 3.11-3.14 compatible)
|
||||||
4. **Installs Stegasoo** - with web UI and all dependencies
|
4. **Installs Stegasoo** - with web UI and all dependencies
|
||||||
5. **Creates systemd service** - auto-starts on boot
|
5. **Creates systemd service** - auto-starts on boot
|
||||||
6. **Enables the service** - ready to start
|
6. **Enables the service** - ready to start
|
||||||
@@ -30,11 +30,18 @@ cd stegasoo
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Raspberry Pi 4 or 5
|
- Raspberry Pi 4 or 5
|
||||||
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
- Raspberry Pi OS (64-bit) - Bookworm (Python 3.11) or Trixie (Python 3.13)
|
||||||
- 4GB+ RAM recommended (2GB minimum)
|
- 4GB+ RAM recommended (2GB minimum)
|
||||||
- 16GB+ SD card (pre-built images are 16GB)
|
- 16GB+ SD card (pre-built images are 16GB)
|
||||||
- Internet connection
|
- Internet connection
|
||||||
|
|
||||||
|
### Python Compatibility
|
||||||
|
|
||||||
|
| Raspberry Pi OS | Python | Supported |
|
||||||
|
|-----------------|--------|-----------|
|
||||||
|
| Bookworm | 3.11 | Yes |
|
||||||
|
| Trixie | 3.13 | Yes |
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
||||||
@@ -159,7 +166,7 @@ sudo apt-get update && sudo apt-get install -y git
|
|||||||
|
|
||||||
# Clone and run setup
|
# Clone and run setup
|
||||||
cd /opt
|
cd /opt
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
cd stegasoo
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
@@ -200,12 +207,12 @@ After Pi shuts down, remove SD card and on another Linux machine:
|
|||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.1.5.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The `pull-image.sh` script automatically:
|
The `pull-image.sh` script automatically:
|
||||||
- Resizes rootfs to exactly 16GB (consistent image size)
|
- Resizes rootfs to exactly 16GB (for smaller download)
|
||||||
- Disables Pi OS auto-expand
|
- Preserves auto-expand (image fills SD card on first boot)
|
||||||
- Compresses with zstd for fast decompression
|
- Compresses with zstd for fast decompression
|
||||||
|
|
||||||
### 6. Distribute
|
### 6. Distribute
|
||||||
|
|||||||
70
rpi/build-runtime-tarball.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Build Stegasoo Pi venv Tarball
|
||||||
|
# Run this ON THE PI after a successful from-source build
|
||||||
|
#
|
||||||
|
# Creates: stegasoo-rpi-venv-arm64.tar.zst (~40-50MB)
|
||||||
|
# Contains: venv with all dependencies (uses system Python 3.11+)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||||
|
OUTPUT_FILE="${1:-$HOME/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||||
|
|
||||||
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Stegasoo Pi venv Tarball Builder ║${NC}"
|
||||||
|
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verify we're on ARM64
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$ARCH" != "aarch64" ]]; then
|
||||||
|
echo -e "${RED}Error: This script must be run on ARM64 (aarch64)${NC}"
|
||||||
|
echo "Current architecture: $ARCH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify venv exists
|
||||||
|
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
||||||
|
echo -e "${RED}Error: venv not found at $INSTALL_DIR/venv${NC}"
|
||||||
|
echo "Run a from-source build first: ./rpi/setup.sh --no-prebuilt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 1: Clean caches from venv
|
||||||
|
echo -e "${GREEN}[1/2]${NC} Cleaning caches from venv..."
|
||||||
|
VENV_SIZE_BEFORE=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||||
|
find "$INSTALL_DIR/venv/" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find "$INSTALL_DIR/venv/" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find "$INSTALL_DIR/venv/" -type d -name 'test' -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find "$INSTALL_DIR/venv/" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||||
|
VENV_SIZE_AFTER=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||||
|
echo " venv: $VENV_SIZE_BEFORE -> $VENV_SIZE_AFTER"
|
||||||
|
|
||||||
|
# Step 2: Create tarball
|
||||||
|
echo -e "${GREEN}[2/2]${NC} Creating tarball..."
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
tar -cf - venv/ | zstd -19 -T0 > "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e " Output: ${YELLOW}$OUTPUT_FILE${NC}"
|
||||||
|
echo -e " Size: ${YELLOW}$FINAL_SIZE${NC}"
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "To pull to your host machine:"
|
||||||
|
echo " scp $(whoami)@$(hostname).local:$OUTPUT_FILE ./"
|
||||||
|
echo ""
|
||||||
|
echo "To use in setup.sh, place at:"
|
||||||
|
echo " rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||||
|
echo ""
|
||||||
|
echo "Or upload to GitHub releases for automatic download."
|
||||||
@@ -53,6 +53,48 @@ echo ""
|
|||||||
|
|
||||||
gum confirm "Ready to begin setup?" || exit 0
|
gum confirm "Ready to begin setup?" || exit 0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Step 0: Expand Filesystem
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
clear
|
||||||
|
gum style \
|
||||||
|
--foreground 212 --bold \
|
||||||
|
"Step 0: Expand Filesystem"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get current and total size
|
||||||
|
ROOT_DEV=$(findmnt -n -o SOURCE /)
|
||||||
|
CURRENT_SIZE=$(df -h / | awk 'NR==2 {print $2}')
|
||||||
|
TOTAL_SIZE=$(lsblk -b -d -o SIZE $(echo "$ROOT_DEV" | sed 's/[0-9]*$//') 2>/dev/null | tail -1 | awk '{printf "%.0fG", $1/1024/1024/1024}')
|
||||||
|
|
||||||
|
gum style --foreground 245 "\
|
||||||
|
The filesystem is currently $CURRENT_SIZE but your SD card may be larger.
|
||||||
|
Expanding will use all available space on the SD card."
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 245 "Current: $CURRENT_SIZE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if gum confirm "Expand filesystem to fill SD card?" --default=true; then
|
||||||
|
# Get the disk device (strip partition number) and partition number
|
||||||
|
DISK_DEV=$(echo "$ROOT_DEV" | sed 's/p\?[0-9]*$//')
|
||||||
|
PART_NUM=$(echo "$ROOT_DEV" | grep -o '[0-9]*$')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 245 "Expanding partition..."
|
||||||
|
sudo growpart "$DISK_DEV" "$PART_NUM" 2>&1 || true
|
||||||
|
|
||||||
|
gum style --foreground 245 "Expanding filesystem..."
|
||||||
|
sudo resize2fs "$ROOT_DEV" 2>&1
|
||||||
|
|
||||||
|
NEW_SIZE=$(df -h / | awk 'NR==2 {print $2}')
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 82 "✓ Expanded to: $NEW_SIZE"
|
||||||
|
else
|
||||||
|
gum style --foreground 214 "→ Skipped (run 'sudo growpart /dev/sdX 2 && sudo resize2fs /dev/sdX2' later)"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Configuration Variables
|
# Configuration Variables
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -137,52 +179,100 @@ This is useful if you want to share encoded images only with
|
|||||||
specific people (family, team, etc)."
|
specific people (family, team, etc)."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if gum confirm "Generate a private channel key?" --default=false; then
|
CHANNEL_CHOICE=$(gum choose \
|
||||||
echo ""
|
"Skip (public mode)" \
|
||||||
# Generate key to temp file (gum spin doesn't capture stdout well)
|
"Generate new key" \
|
||||||
KEY_FILE=$(mktemp)
|
"Enter existing key")
|
||||||
ERR_FILE=$(mktemp)
|
|
||||||
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
|
||||||
gum spin --spinner dot --title "Generating channel key..." -- \
|
|
||||||
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
|
|
||||||
|
|
||||||
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
|
case "$CHANNEL_CHOICE" in
|
||||||
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
|
"Generate new key")
|
||||||
rm -f "$KEY_FILE" "$ERR_FILE"
|
echo ""
|
||||||
|
# Generate key to temp file (gum spin doesn't capture stdout well)
|
||||||
|
KEY_FILE=$(mktemp)
|
||||||
|
ERR_FILE=$(mktemp)
|
||||||
|
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||||
|
gum spin --spinner dot --title "Generating channel key..." -- \
|
||||||
|
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
|
||||||
|
|
||||||
if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
|
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
|
||||||
echo ""
|
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
|
||||||
gum style --foreground 82 "✓ Channel key generated!"
|
rm -f "$KEY_FILE" "$ERR_FILE"
|
||||||
echo ""
|
|
||||||
gum style \
|
if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
|
||||||
--border rounded \
|
|
||||||
--border-foreground 226 \
|
|
||||||
--padding "1 2" \
|
|
||||||
--foreground 226 --bold \
|
|
||||||
"$CHANNEL_KEY"
|
|
||||||
echo ""
|
|
||||||
gum style --foreground 196 --bold \
|
|
||||||
"*** IMPORTANT: Write down or copy this key NOW! ***"
|
|
||||||
gum style --foreground 196 \
|
|
||||||
"You'll need to share it with anyone who should decode" \
|
|
||||||
"your images. This key won't be shown again."
|
|
||||||
echo ""
|
|
||||||
gum confirm "I've saved the key" --default=true --affirmative="Continue" --negative=""
|
|
||||||
else
|
|
||||||
gum style --foreground 196 "Failed to generate key. Using public mode."
|
|
||||||
if [ -n "$KEY_ERROR" ]; then
|
|
||||||
echo ""
|
echo ""
|
||||||
gum style --foreground 245 "Error details:"
|
gum style --foreground 82 "✓ Channel key generated!"
|
||||||
echo "$KEY_ERROR"
|
echo ""
|
||||||
|
gum style \
|
||||||
|
--border rounded \
|
||||||
|
--border-foreground 226 \
|
||||||
|
--padding "1 2" \
|
||||||
|
--foreground 226 --bold \
|
||||||
|
"$CHANNEL_KEY"
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 196 --bold \
|
||||||
|
"*** IMPORTANT: Write down or copy this key NOW! ***"
|
||||||
|
gum style --foreground 196 \
|
||||||
|
"You'll need to share it with anyone who should decode" \
|
||||||
|
"your images. This key won't be shown again."
|
||||||
|
echo ""
|
||||||
|
gum confirm "I've saved the key" --default=true --affirmative="Continue" --negative=""
|
||||||
|
else
|
||||||
|
gum style --foreground 196 "Failed to generate key. Using public mode."
|
||||||
|
if [ -n "$KEY_ERROR" ]; then
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 245 "Error details:"
|
||||||
|
echo "$KEY_ERROR"
|
||||||
|
fi
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
echo ""
|
||||||
|
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
|
||||||
fi
|
fi
|
||||||
CHANNEL_KEY=""
|
;;
|
||||||
|
|
||||||
|
"Enter existing key")
|
||||||
echo ""
|
echo ""
|
||||||
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
|
gum style --foreground 245 "Enter the channel key from your team/deployment."
|
||||||
fi
|
gum style --foreground 245 "Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||||
else
|
echo ""
|
||||||
gum style --foreground 214 "→ Using public mode"
|
|
||||||
sleep 0.5
|
while true; do
|
||||||
fi
|
ENTERED_KEY=$(gum input --placeholder "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" --width 50)
|
||||||
|
|
||||||
|
if [ -z "$ENTERED_KEY" ]; then
|
||||||
|
gum style --foreground 214 "→ Cancelled, using public mode"
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate the key using Python
|
||||||
|
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||||
|
if "$VENV_PYTHON" -c "from stegasoo.channel import validate_channel_key, format_channel_key; k='$ENTERED_KEY'; exit(0 if validate_channel_key(k) else 1)" 2>/dev/null; then
|
||||||
|
# Get formatted key
|
||||||
|
CHANNEL_KEY=$("$VENV_PYTHON" -c "from stegasoo.channel import format_channel_key; print(format_channel_key('$ENTERED_KEY'))" 2>/dev/null)
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 82 "✓ Channel key accepted!"
|
||||||
|
gum style --foreground 245 "Key: $CHANNEL_KEY"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 196 "Invalid key format. Please check and try again."
|
||||||
|
gum style --foreground 245 "Expected: 32 alphanumeric characters (with or without dashes)"
|
||||||
|
echo ""
|
||||||
|
if ! gum confirm "Try again?" --default=true; then
|
||||||
|
gum style --foreground 214 "→ Using public mode"
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
gum style --foreground 214 "→ Using public mode"
|
||||||
|
CHANNEL_KEY=""
|
||||||
|
sleep 0.5
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Step 4: Overclock Configuration
|
# Step 4: Overclock Configuration
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
|
|||||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst # auto-detect SD card"
|
||||||
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst.zip # from GitHub release"
|
||||||
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -249,16 +249,9 @@ if [ -n "$MOUNTED" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ask about wiping
|
# Ask about wiping (defer actual wipe until after final confirmation)
|
||||||
echo
|
echo
|
||||||
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
||||||
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
|
||||||
echo "Wiping partition table..."
|
|
||||||
sudo wipefs -a "$SELECTED"
|
|
||||||
sudo dd if=/dev/zero of="$SELECTED" bs=1M count=10 status=none
|
|
||||||
sync
|
|
||||||
echo " Wiped clean"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Final confirmation
|
# Final confirmation
|
||||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
@@ -272,73 +265,65 @@ if [[ ! $REPLY == "yes" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Now wipe if requested
|
||||||
|
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Wiping partition table..."
|
||||||
|
sudo wipefs -af "$SELECTED" 2>/dev/null || true
|
||||||
|
sync
|
||||||
|
echo " Wiped"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Try rpi-imager first (faster, native support for compressed images)
|
# Flash with dd (status=progress shows actual write progress)
|
||||||
if command -v rpi-imager &> /dev/null; then
|
echo -e "${YELLOW}Flashing (this may take several minutes for SD cards)...${NC}"
|
||||||
echo -e "${YELLOW}Using rpi-imager...${NC}"
|
if [ "$COMPRESSED" = true ]; then
|
||||||
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then
|
case "$COMP_TYPE" in
|
||||||
# rpi-imager succeeded
|
xz) xzcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
|
||||||
:
|
zst) zstdcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
|
||||||
else
|
gz) zcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
|
||||||
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}"
|
esac
|
||||||
# Fall through to dd
|
|
||||||
USE_DD=true
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
USE_DD=true
|
sudo dd if="$IMAGE" of="$SELECTED" bs=1M status=progress
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback to dd
|
|
||||||
if [ "$USE_DD" = true ]; then
|
|
||||||
if [ "$HAS_PV" = true ]; then
|
|
||||||
echo -e "${YELLOW}Using dd with progress...${NC}"
|
|
||||||
if [ "$COMPRESSED" = true ]; then
|
|
||||||
case "$COMP_TYPE" in
|
|
||||||
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
|
||||||
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
|
||||||
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
|
|
||||||
if [ "$COMPRESSED" = true ]; then
|
|
||||||
case "$COMP_TYPE" in
|
|
||||||
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
|
||||||
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
|
||||||
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}Syncing...${NC}"
|
echo -e "${GREEN}Syncing...${NC}"
|
||||||
sync
|
sync
|
||||||
|
|
||||||
|
# Wait for partitions to appear
|
||||||
|
sleep 2
|
||||||
|
partprobe "$SELECTED" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Determine partition names
|
||||||
|
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
|
||||||
|
BOOT_PART="${SELECTED}p1"
|
||||||
|
ROOT_PART="${SELECTED}p2"
|
||||||
|
else
|
||||||
|
BOOT_PART="${SELECTED}1"
|
||||||
|
ROOT_PART="${SELECTED}2"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate and repair filesystems
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Validating filesystems...${NC}"
|
||||||
|
|
||||||
|
echo " Checking boot partition ($BOOT_PART)..."
|
||||||
|
sudo fsck.vfat -a "$BOOT_PART" 2>&1 | grep -v "^$" || true
|
||||||
|
|
||||||
|
echo " Checking root partition ($ROOT_PART)..."
|
||||||
|
sudo e2fsck -f -y "$ROOT_PART" 2>&1 | tail -5 || true
|
||||||
|
|
||||||
|
echo -e "${GREEN} ✓ Filesystems validated${NC}"
|
||||||
|
|
||||||
# Inject WiFi config if config.json was loaded
|
# Inject WiFi config if config.json was loaded
|
||||||
if [ "$HAS_CONFIG" = true ]; then
|
if [ "$HAS_CONFIG" = true ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
|
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
|
||||||
|
|
||||||
# Wait for partitions to appear
|
|
||||||
sleep 2
|
|
||||||
partprobe "$SELECTED" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# Determine boot partition
|
|
||||||
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
|
|
||||||
BOOT_PART="${SELECTED}p1"
|
|
||||||
else
|
|
||||||
BOOT_PART="${SELECTED}1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -b "$BOOT_PART" ]; then
|
if [ -b "$BOOT_PART" ]; then
|
||||||
MOUNT_DIR=$(mktemp -d)
|
MOUNT_DIR=$(mktemp -d)
|
||||||
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then
|
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then
|
||||||
|
|||||||
@@ -2,56 +2,39 @@
|
|||||||
|
|
||||||
This directory contains patches for dependencies that need modifications to build on ARM64.
|
This directory contains patches for dependencies that need modifications to build on ARM64.
|
||||||
|
|
||||||
|
## Current Status (v4.2+)
|
||||||
|
|
||||||
|
As of Stegasoo 4.2, we use **jpeglib** instead of jpegio. The jpeglib build process is handled inline in `setup.sh` and includes:
|
||||||
|
|
||||||
|
- Cloning from GitHub (PyPI tarball missing headers)
|
||||||
|
- Downloading libjpeg headers for each version (6b through 9f)
|
||||||
|
- Patching setup.py to skip turbo/mozjpeg (need cmake-generated headers)
|
||||||
|
|
||||||
|
See `setup.sh` for the full implementation.
|
||||||
|
|
||||||
|
## Legacy: jpegio Patches (v4.1 and earlier)
|
||||||
|
|
||||||
|
The `jpegio/` directory contains patches for the old jpegio dependency, which required removing x86-specific `-m64` compiler flags. These are no longer used but kept for reference.
|
||||||
|
|
||||||
|
## jpeglib Helper Script
|
||||||
|
|
||||||
|
The `jpeglib/install-jpeglib-arm64.sh` script is a standalone version of the jpeglib build process. It's not used by setup.sh (which has the logic inline) but can be useful for manual testing or debugging.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
patches/
|
patches/
|
||||||
<package>/
|
jpegio/ # Legacy (v4.1) - not used in v4.2+
|
||||||
arm64.patch # Standard unified diff patch file
|
arm64.patch
|
||||||
apply-patch.sh # Script with fallback strategies
|
apply-patch.sh
|
||||||
|
jpeglib/ # Reference script for manual builds
|
||||||
|
install-jpeglib-arm64.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
## Adding New Patches
|
||||||
|
|
||||||
The `apply-patch.sh` script tries multiple strategies in order:
|
If a new dependency needs ARM64 patches:
|
||||||
|
|
||||||
1. **Patch file** - Apply the `.patch` file using `patch -p1`
|
|
||||||
2. **Sed fallback** - Use sed for simple string replacements
|
|
||||||
3. **Python fallback** - Use regex for flexible pattern matching
|
|
||||||
|
|
||||||
This layered approach handles:
|
|
||||||
- Exact matches (patch file works)
|
|
||||||
- Minor upstream changes (sed catches variations)
|
|
||||||
- Significant changes (Python regex is most flexible)
|
|
||||||
- Already patched files (detected and skipped)
|
|
||||||
|
|
||||||
## Adding a New Patch
|
|
||||||
|
|
||||||
1. Create a directory: `patches/<package>/`
|
1. Create a directory: `patches/<package>/`
|
||||||
2. Create the patch file: `git diff > arm64.patch`
|
2. Add patch files or helper scripts
|
||||||
3. Create `apply-patch.sh` with appropriate fallback logic
|
3. Update `setup.sh` to apply the patch during installation
|
||||||
4. Update `setup.sh` to call the patch script
|
|
||||||
|
|
||||||
## jpegio Patch
|
|
||||||
|
|
||||||
The jpegio library includes x86-specific `-m64` compiler flags that fail on ARM64.
|
|
||||||
The patch removes these flags by replacing:
|
|
||||||
|
|
||||||
```python
|
|
||||||
cargs.append('-m64')
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
pass # ARM64: removed x86-specific -m64 flag
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating Patches
|
|
||||||
|
|
||||||
When upstream changes break a patch:
|
|
||||||
|
|
||||||
1. Clone the new version
|
|
||||||
2. Make the necessary modifications
|
|
||||||
3. Generate a new patch: `diff -u original modified > arm64.patch`
|
|
||||||
4. Test on a fresh Pi install
|
|
||||||
|
|||||||
57
rpi/patches/jpeglib/install-jpeglib-arm64.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Install jpeglib on ARM64 Linux (Raspberry Pi)
|
||||||
|
# Works around missing headers in the source tarball
|
||||||
|
#
|
||||||
|
# Usage: ./install-jpeglib-arm64.sh
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Installing jpeglib for ARM64..."
|
||||||
|
|
||||||
|
# Create temp directory
|
||||||
|
WORKDIR=$(mktemp -d)
|
||||||
|
cd "$WORKDIR"
|
||||||
|
|
||||||
|
# Download jpeglib source
|
||||||
|
echo " Downloading jpeglib source..."
|
||||||
|
pip download jpeglib==1.0.2 --no-binary :all: --no-deps -d . -q
|
||||||
|
tar -xzf jpeglib-1.0.2.tar.gz
|
||||||
|
cd jpeglib-1.0.2
|
||||||
|
|
||||||
|
# Download official libjpeg sources and copy headers
|
||||||
|
echo " Downloading libjpeg headers..."
|
||||||
|
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||||
|
|
||||||
|
# libjpeg 6b
|
||||||
|
curl -sL "https://www.ijg.org/files/jpegsrc.v6b.tar.gz" | tar -xzf -
|
||||||
|
cp jpeg-6b/*.h "$CJPEGLIB/6b/"
|
||||||
|
|
||||||
|
# libjpeg 7-9f (all use similar headers from 9e)
|
||||||
|
curl -sL "https://www.ijg.org/files/jpegsrc.v9f.tar.gz" | tar -xzf -
|
||||||
|
for v in 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
|
||||||
|
cp jpeg-9f/*.h "$CJPEGLIB/$v/"
|
||||||
|
done
|
||||||
|
|
||||||
|
# libjpeg-turbo versions
|
||||||
|
curl -sL "https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/2.1.0.tar.gz" | tar -xzf -
|
||||||
|
for v in turbo120 turbo130 turbo140 turbo150 turbo200 turbo210; do
|
||||||
|
cp libjpeg-turbo-2.1.0/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# mozjpeg versions
|
||||||
|
curl -sL "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.0.3.tar.gz" | tar -xzf -
|
||||||
|
for v in mozjpeg101 mozjpeg201 mozjpeg300 mozjpeg403; do
|
||||||
|
cp mozjpeg-4.0.3/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build and install
|
||||||
|
echo " Building jpeglib..."
|
||||||
|
pip install . -q
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
cd /
|
||||||
|
rm -rf "$WORKDIR"
|
||||||
|
|
||||||
|
echo " Done! jpeglib installed successfully."
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Resizes rootfs to 16GB for consistent image size, then pulls
|
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||||
#
|
#
|
||||||
# Usage: ./pull-image.sh <device> <output.img.zst>
|
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
|
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.1.img.zst
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ NC='\033[0m'
|
|||||||
|
|
||||||
if [ $# -ne 2 ]; then
|
if [ $# -ne 2 ]; then
|
||||||
echo "Usage: $0 <device> <output.img.zst>"
|
echo "Usage: $0 <device> <output.img.zst>"
|
||||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
|
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -123,25 +123,6 @@ else
|
|||||||
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
||||||
fi
|
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
|
# Pull image
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Stegasoo Pi Test Kickoff Script
|
# Stegasoo Remote Pi Build Script
|
||||||
# Automates: flash -> wait for boot -> setup -> test
|
# Waits for Pi to be reachable, then sets up Stegasoo
|
||||||
#
|
#
|
||||||
# Usage: ./kickoff-pi-test.sh <image.img.zst> </dev/sdX>
|
# Usage: ./remote-build-pi.sh [host] [user] [pass]
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
# Pi connection settings
|
# Pi connection settings (defaults)
|
||||||
PI_HOST="stegasoo.local"
|
PI_HOST="${1:-stegasoo.local}"
|
||||||
PI_USER="admin"
|
PI_USER="${2:-admin}"
|
||||||
PI_PASS="stegasoo"
|
PI_PASS="${3:-stegasoo}"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -26,10 +26,9 @@ NC='\033[0m'
|
|||||||
# Helper functions
|
# Helper functions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
# Wait for Pi to be reachable
|
|
||||||
wait_for_pi() {
|
wait_for_pi() {
|
||||||
local attempt=1
|
local attempt=1
|
||||||
ssh-keygen -R "$PI_HOST" 2>/dev/null
|
ssh-keygen -R "$PI_HOST" 2>/dev/null || true
|
||||||
|
|
||||||
echo "Waiting for $PI_USER@$PI_HOST..."
|
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
|
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
|
||||||
@@ -39,29 +38,25 @@ wait_for_pi() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt"
|
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt"
|
||||||
printf '\a' # Terminal bell
|
printf '\a'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run command on Pi (non-interactive)
|
|
||||||
run_on_pi() {
|
run_on_pi() {
|
||||||
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
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() {
|
run_on_pi_interactive() {
|
||||||
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Copy file to Pi
|
|
||||||
scp_to_pi() {
|
scp_to_pi() {
|
||||||
local src="$1"
|
local src="$1"
|
||||||
local dst="$2"
|
local dst="$2"
|
||||||
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
|
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Interactive SSH session
|
|
||||||
ssh_pi() {
|
ssh_pi() {
|
||||||
ssh-keygen -R "$PI_HOST" 2>/dev/null
|
ssh-keygen -R "$PI_HOST" 2>/dev/null || true
|
||||||
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,92 +64,48 @@ ssh_pi() {
|
|||||||
# Main
|
# 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}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${CYAN}║ Stegasoo Pi Test Kickoff ║${NC}"
|
echo -e "${CYAN}║ Stegasoo Remote Pi Build ║${NC}"
|
||||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "Image: ${YELLOW}$IMAGE${NC}"
|
echo -e "Host: ${YELLOW}$PI_HOST${NC}"
|
||||||
echo -e "Device: ${YELLOW}$DEVICE${NC}"
|
echo -e "User: ${YELLOW}$PI_USER${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 1: Flash the image
|
# Step 1: Wait for Pi to be ready
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo -e "${GREEN}[1/8]${NC} Flashing image..."
|
echo -e "${GREEN}[1/6]${NC} Waiting for Pi..."
|
||||||
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 ""
|
echo ""
|
||||||
|
|
||||||
wait_for_pi
|
wait_for_pi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 4: Pre-setup (install dependencies)
|
# Step 2: Install dependencies
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}[4/8]${NC} Installing dependencies on Pi..."
|
echo -e "${GREEN}[2/6]${NC} Installing dependencies on Pi..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
run_on_pi "sudo chown admin:admin /opt && sudo apt-get update && sudo apt-get install -y git zstd jq"
|
run_on_pi "sudo chown admin:admin /opt && sudo apt-get update && sudo apt-get install -y git zstd jq ca-certificates && sudo update-ca-certificates"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 5: Clone repo
|
# Step 3: Clone repo
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}[5/8]${NC} Cloning Stegasoo repo..."
|
echo -e "${GREEN}[3/6]${NC} Cloning Stegasoo repo..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
run_on_pi "cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo"
|
run_on_pi "cd /opt && rm -rf stegasoo && git clone https://github.com/adlee-was-taken/stegasoo.git stegasoo"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 6: Copy pre-built tarball
|
# Step 4: Copy pre-built tarball
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}[6/8]${NC} Copying pre-built tarball to Pi..."
|
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
TARBALL="$SCRIPT_DIR/stegasoo-rpi-runtime-env-arm64.tar.zst"
|
TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
|
||||||
if [[ -f "$TARBALL" ]]; then
|
if [[ -f "$TARBALL" ]]; then
|
||||||
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
||||||
echo -e " ${GREEN}✓${NC} Tarball copied"
|
echo -e " ${GREEN}✓${NC} Tarball copied"
|
||||||
@@ -164,19 +115,19 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 7: Run setup
|
# Step 5: Run setup
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}[7/8]${NC} Running setup.sh on Pi..."
|
echo -e "${GREEN}[5/6]${NC} Running setup.sh on Pi..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
|
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 8: Test it works
|
# Step 6: Test it works
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}[8/8]${NC} Testing Stegasoo..."
|
echo -e "${GREEN}[6/6]${NC} Testing Stegasoo..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
run_on_pi "sudo systemctl start stegasoo && sleep 2 && curl -sk https://localhost:5000 | head -5"
|
run_on_pi "sudo systemctl start stegasoo && sleep 2 && curl -sk https://localhost:5000 | head -5"
|
||||||
@@ -186,7 +137,7 @@ echo -e "${GREEN}═════════════════════
|
|||||||
echo -e "${GREEN} Build complete! Pi is ready for testing.${NC}"
|
echo -e "${GREEN} Build complete! Pi is ready for testing.${NC}"
|
||||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "Access: ${YELLOW}https://stegasoo.local:5000${NC}"
|
echo -e "Access: ${YELLOW}https://$PI_HOST:5000${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
read -p "Press ENTER to SSH into Pi for manual testing..."
|
read -p "Press ENTER to SSH into Pi for manual testing..."
|
||||||
|
|
||||||
@@ -264,49 +264,25 @@ if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
|
|||||||
echo " Venv broken or stegasoo not installed, rebuilding..."
|
echo " Venv broken or stegasoo not installed, rebuilding..."
|
||||||
rm -rf "$STEGASOO_DIR/venv"
|
rm -rf "$STEGASOO_DIR/venv"
|
||||||
|
|
||||||
# Find Python 3.12 (prefer pyenv, fall back to system)
|
# Find system Python 3.11+ (no pyenv needed)
|
||||||
USER_HOME=$(eval echo "~$STEGASOO_USER")
|
PYTHON_BIN=""
|
||||||
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
|
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||||
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
|
if command -v "$py" &>/dev/null; then
|
||||||
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
|
PYTHON_BIN=$(command -v "$py")
|
||||||
echo " Using pyenv Python: $PYTHON_BIN"
|
break
|
||||||
elif command -v python3.12 &>/dev/null; then
|
|
||||||
PYTHON_BIN="python3.12"
|
|
||||||
echo " Using system Python 3.12"
|
|
||||||
else
|
|
||||||
PYTHON_BIN="python3"
|
|
||||||
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
|
||||||
|
|
||||||
# On ARM64, jpegio needs patching before install
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
|
||||||
echo " Building jpegio for ARM64 (this may take a minute)..."
|
|
||||||
# Install build deps
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
|
|
||||||
JPEGIO_DIR="/tmp/jpegio-build-$$"
|
|
||||||
rm -rf "$JPEGIO_DIR"
|
|
||||||
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
|
|
||||||
# Apply patch to remove -m64 flag
|
|
||||||
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
|
||||||
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
|
||||||
else
|
|
||||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
|
||||||
fi
|
|
||||||
# Change ownership so user can build
|
|
||||||
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
|
|
||||||
rm -rf "$JPEGIO_DIR"
|
|
||||||
else
|
|
||||||
echo " Warning: Failed to clone jpegio, DCT mode may not work"
|
|
||||||
fi
|
fi
|
||||||
fi
|
done
|
||||||
|
|
||||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
if [ -z "$PYTHON_BIN" ]; then
|
||||||
echo " Venv rebuilt and stegasoo installed"
|
echo " Error: Python 3.11+ not found"
|
||||||
|
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo " Using: $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))"
|
||||||
|
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||||
|
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||||
|
echo " Venv rebuilt and stegasoo installed"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " Venv OK"
|
echo " Venv OK"
|
||||||
fi
|
fi
|
||||||
|
|||||||
422
rpi/setup.sh
@@ -4,14 +4,14 @@
|
|||||||
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
|
||||||
# # or
|
# # or
|
||||||
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
|
||||||
#
|
#
|
||||||
# What this script does:
|
# What this script does:
|
||||||
# 1. Installs system dependencies
|
# 1. Installs system dependencies
|
||||||
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
|
# 2. Verifies Python 3.11+ (uses system Python)
|
||||||
# 3. Patches and builds jpegio for ARM
|
# 3. Installs jpeglib for DCT steganography (Python 3.11-3.14 compatible)
|
||||||
# 4. Installs Stegasoo with web UI
|
# 4. Installs Stegasoo with web UI
|
||||||
# 5. Creates systemd service for auto-start
|
# 5. Creates systemd service for auto-start
|
||||||
# 6. Enables the service
|
# 6. Enables the service
|
||||||
@@ -75,9 +75,8 @@ show_help() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Available variables:"
|
echo " Available variables:"
|
||||||
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
||||||
echo " PYTHON_VERSION Python version (default: 3.12)"
|
|
||||||
echo " STEGASOO_REPO Git repo URL"
|
echo " STEGASOO_REPO Git repo URL"
|
||||||
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
|
echo " STEGASOO_BRANCH Git branch (default: 4.2)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Example:"
|
echo " Example:"
|
||||||
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
||||||
@@ -95,10 +94,8 @@ done
|
|||||||
|
|
||||||
# Default configuration
|
# Default configuration
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||||
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
|
||||||
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
||||||
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}"
|
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.2}"
|
||||||
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
|
|
||||||
|
|
||||||
# Load config files (system, then user - user overrides system)
|
# Load config files (system, then user - user overrides system)
|
||||||
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
||||||
@@ -112,7 +109,7 @@ clear
|
|||||||
print_banner "Raspberry Pi Setup"
|
print_banner "Raspberry Pi Setup"
|
||||||
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 5-10 min (from source)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if running on ARM
|
# Check if running on ARM
|
||||||
@@ -123,6 +120,63 @@ if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Python Version Check
|
||||||
|
# =============================================================================
|
||||||
|
echo -e "${GREEN}Checking Python version...${NC}"
|
||||||
|
|
||||||
|
# Find system Python
|
||||||
|
SYSTEM_PYTHON=""
|
||||||
|
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||||
|
if command -v "$py" &>/dev/null; then
|
||||||
|
SYSTEM_PYTHON=$(command -v "$py")
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SYSTEM_PYTHON" ]; then
|
||||||
|
echo -e "${RED}Error: Python 3 not found.${NC}"
|
||||||
|
echo "Please install Python 3.11 or later:"
|
||||||
|
echo " sudo apt-get install python3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get version numbers
|
||||||
|
PY_VERSION=$("$SYSTEM_PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
PY_MAJOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.major)')
|
||||||
|
PY_MINOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.minor)')
|
||||||
|
|
||||||
|
echo " Found: $SYSTEM_PYTHON (Python $PY_VERSION)"
|
||||||
|
|
||||||
|
# Check version range (3.11 <= version <= 3.14)
|
||||||
|
if [ "$PY_MAJOR" -ne 3 ]; then
|
||||||
|
echo -e "${RED}Error: Python 3 required, found Python $PY_MAJOR${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PY_MINOR" -lt 11 ]; then
|
||||||
|
echo -e "${RED}Error: Python 3.11+ required, found Python $PY_VERSION${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Raspberry Pi OS Bookworm ships with Python 3.11."
|
||||||
|
echo "Raspberry Pi OS Trixie ships with Python 3.13."
|
||||||
|
echo ""
|
||||||
|
echo "Please upgrade your Raspberry Pi OS or install Python 3.11+."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PY_MINOR" -gt 14 ]; then
|
||||||
|
echo -e "${YELLOW}Warning: Python $PY_VERSION detected.${NC}"
|
||||||
|
echo "Stegasoo is tested with Python 3.11-3.14."
|
||||||
|
echo "Newer versions may work but are not officially supported."
|
||||||
|
read -p "Continue anyway? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Python $PY_VERSION supported"
|
||||||
|
|
||||||
# Check available memory
|
# Check available memory
|
||||||
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||||
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||||
@@ -136,8 +190,11 @@ if [ "$TOTAL_MEM" -lt 2000 ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create /opt/stegasoo with proper permissions
|
# =============================================================================
|
||||||
echo -e "${GREEN}[1/12]${NC} Setting up install directory..."
|
# Installation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo -e "${GREEN}[1/9]${NC} Setting up install directory..."
|
||||||
if [ ! -d "$INSTALL_DIR" ]; then
|
if [ ! -d "$INSTALL_DIR" ]; then
|
||||||
sudo mkdir -p "$INSTALL_DIR"
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||||
@@ -148,7 +205,7 @@ else
|
|||||||
echo " $INSTALL_DIR exists, updated ownership"
|
echo " $INSTALL_DIR exists, updated ownership"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[2/12]${NC} Installing system dependencies..."
|
echo -e "${GREEN}[2/9]${NC} Installing system dependencies..."
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -170,9 +227,11 @@ sudo apt-get install -y \
|
|||||||
libzbar0 \
|
libzbar0 \
|
||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
|
python3-venv \
|
||||||
|
python3-pip \
|
||||||
btop
|
btop
|
||||||
|
|
||||||
echo -e "${GREEN}[3/12]${NC} Installing gum (TUI toolkit)..."
|
echo -e "${GREEN}[3/9]${NC} Installing gum (TUI toolkit)..."
|
||||||
# Add Charm repo for gum
|
# Add Charm repo for gum
|
||||||
if ! command -v gum &>/dev/null; then
|
if ! command -v gum &>/dev/null; then
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
@@ -184,7 +243,21 @@ else
|
|||||||
echo " gum already installed"
|
echo " gum already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[4/12]${NC} Cloning Stegasoo..."
|
# Install mkcert for browser-trusted certificates (no warning screen!)
|
||||||
|
echo " Installing mkcert for trusted HTTPS certificates..."
|
||||||
|
if ! command -v mkcert &>/dev/null; then
|
||||||
|
sudo apt-get install -y libnss3-tools
|
||||||
|
# Download mkcert for ARM64
|
||||||
|
sudo curl -sL "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-arm64" -o /usr/local/bin/mkcert
|
||||||
|
sudo chmod +x /usr/local/bin/mkcert
|
||||||
|
# Install local CA (makes certs trusted on this Pi)
|
||||||
|
mkcert -install 2>/dev/null || true
|
||||||
|
echo " mkcert installed"
|
||||||
|
else
|
||||||
|
echo " mkcert already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[4/9]${NC} Cloning Stegasoo..."
|
||||||
|
|
||||||
# Clone Stegasoo first (needed to check for pre-built tarball)
|
# Clone Stegasoo first (needed to check for pre-built tarball)
|
||||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||||
@@ -198,17 +271,16 @@ else
|
|||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pre-built environment tarball (skips 20+ min compile time)
|
# Pre-built venv tarball (skips pip compile time)
|
||||||
# Includes both pyenv Python 3.12 AND venv with all dependencies
|
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-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.2.1/stegasoo-rpi-venv-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
|
||||||
if [ -f "$PREBUILT_TARBALL" ]; then
|
if [ -f "$PREBUILT_TARBALL" ]; then
|
||||||
echo -e "${GREEN}Found local pre-built environment - fast install mode${NC}"
|
echo -e "${GREEN}Found local pre-built venv - fast install mode${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}Will download pre-built environment - fast install mode${NC}"
|
echo -e "${GREEN}Will download pre-built venv - fast install mode${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Allow --no-prebuilt flag to force from-source build
|
# Allow --no-prebuilt flag to force from-source build
|
||||||
@@ -217,44 +289,30 @@ if [[ " $* " =~ " --no-prebuilt " ]] || [[ " $* " =~ " --from-source " ]]; then
|
|||||||
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
|
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fast path: use pre-built environment if available
|
echo -e "${GREEN}[5/9]${NC} Setting up Python environment..."
|
||||||
|
|
||||||
if [ "$USE_PREBUILT" = true ]; then
|
if [ "$USE_PREBUILT" = true ]; then
|
||||||
echo -e "${GREEN}[5/8]${NC} Installing pre-built Python environment..."
|
# Fast path: use pre-built venv
|
||||||
|
|
||||||
# Download if local file doesn't exist
|
# Download if local file doesn't exist
|
||||||
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
||||||
echo " Downloading pre-built environment (~50MB)..."
|
echo " Downloading pre-built venv (~50MB)..."
|
||||||
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extract pre-built environment (includes pyenv Python + venv)
|
# Extract pre-built venv
|
||||||
echo " Extracting pre-built environment..."
|
echo " Extracting pre-built venv..."
|
||||||
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME"
|
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$INSTALL_DIR"
|
||||||
|
|
||||||
# Setup pyenv in current shell
|
# Fix venv Python symlinks to point to system Python
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
echo " Updating venv to use system Python..."
|
||||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
rm -f "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/venv/bin/python3"
|
||||||
eval "$(pyenv init -)"
|
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python"
|
||||||
pyenv global $PYTHON_VERSION
|
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python3"
|
||||||
|
|
||||||
# Add to .bashrc if not already there
|
# Update pip shebang if needed
|
||||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
if [ -f "$INSTALL_DIR/venv/bin/pip" ]; then
|
||||||
echo '' >> ~/.bashrc
|
sed -i "1s|^#!.*|#!$INSTALL_DIR/venv/bin/python|" "$INSTALL_DIR/venv/bin/pip" 2>/dev/null || true
|
||||||
echo '# pyenv' >> ~/.bashrc
|
|
||||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
|
||||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
|
||||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify Python
|
|
||||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
|
||||||
echo -e " ${GREEN}✓${NC} Python: $INSTALLED_PY"
|
|
||||||
|
|
||||||
# Extract venv to install dir
|
|
||||||
echo -e "${GREEN}[6/8]${NC} Setting up virtual environment..."
|
|
||||||
if [ -f "$HOME/stegasoo-venv.tar.zst" ]; then
|
|
||||||
zstd -d "$HOME/stegasoo-venv.tar.zst" --stdout | tar -xf - -C "$INSTALL_DIR"
|
|
||||||
rm "$HOME/stegasoo-venv.tar.zst"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Activate and verify
|
# Activate and verify
|
||||||
@@ -263,105 +321,87 @@ if [ "$USE_PREBUILT" = true ]; then
|
|||||||
echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
|
echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
|
||||||
|
|
||||||
# Install stegasoo package in editable mode (quick, no compile)
|
# Install stegasoo package in editable mode (quick, no compile)
|
||||||
echo -e "${GREEN}[7/8]${NC} Installing Stegasoo package..."
|
echo " Installing Stegasoo package..."
|
||||||
pip install -e "." --quiet
|
pip install -e "." --quiet
|
||||||
|
|
||||||
# Adjust step numbers for rest of script
|
|
||||||
STEP_OFFSET=-4
|
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}[5/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
|
# Build from source
|
||||||
|
echo -e " ${YELLOW}Building from source (this takes 5-10 minutes)${NC}"
|
||||||
|
|
||||||
# Install pyenv if not present
|
# Create venv with system Python
|
||||||
if [ ! -d "$HOME/.pyenv" ]; then
|
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
||||||
curl https://pyenv.run | bash
|
"$SYSTEM_PYTHON" -m venv "$INSTALL_DIR/venv"
|
||||||
|
|
||||||
# Add pyenv to current shell
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
|
||||||
eval "$(pyenv init -)"
|
|
||||||
|
|
||||||
# Add to .bashrc if not already there
|
|
||||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
|
||||||
echo '' >> ~/.bashrc
|
|
||||||
echo '# pyenv' >> ~/.bashrc
|
|
||||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
|
||||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
|
||||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " pyenv already installed"
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
|
||||||
eval "$(pyenv init -)"
|
|
||||||
fi
|
fi
|
||||||
|
source "$INSTALL_DIR/venv/bin/activate"
|
||||||
# Install Python 3.12 if not present
|
|
||||||
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
|
|
||||||
echo " Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
|
|
||||||
pyenv install $PYTHON_VERSION
|
|
||||||
else
|
|
||||||
echo " Python $PYTHON_VERSION already installed"
|
|
||||||
fi
|
|
||||||
pyenv global $PYTHON_VERSION
|
|
||||||
|
|
||||||
# Verify Python version
|
|
||||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
|
||||||
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
|
|
||||||
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
|
||||||
echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
|
|
||||||
echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
|
|
||||||
|
|
||||||
# Create venv with pyenv Python (not system Python)
|
|
||||||
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
|
|
||||||
PYENV_PYTHON=$(pyenv which python)
|
|
||||||
echo " Using Python: $PYENV_PYTHON"
|
|
||||||
if [ ! -d "venv" ]; then
|
|
||||||
"$PYENV_PYTHON" -m venv venv
|
|
||||||
fi
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Verify we're using the right Python
|
# Verify we're using the right Python
|
||||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||||
echo " venv Python: $VENV_PY"
|
echo " venv Python: $VENV_PY"
|
||||||
|
|
||||||
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
|
# Upgrade pip and install build tools
|
||||||
|
pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
# Clone jpegio
|
# Install jpeglib (no ARM64 wheel, PyPI tarball missing headers - use GitHub)
|
||||||
JPEGIO_DIR="/tmp/jpegio-build"
|
echo " Installing jpeglib for ARM64..."
|
||||||
rm -rf "$JPEGIO_DIR"
|
JPEGLIB_WORKDIR=$(mktemp -d)
|
||||||
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
cd "$JPEGLIB_WORKDIR"
|
||||||
|
|
||||||
# Apply ARM64 patch
|
# Clone from GitHub (PyPI source tarball is missing .h files)
|
||||||
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
echo " Cloning jpeglib from GitHub..."
|
||||||
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
git clone --depth 1 --branch 1.0.2 https://github.com/martinbenes1996/jpeglib.git
|
||||||
else
|
cd jpeglib
|
||||||
echo " Applying inline ARM64 patch..."
|
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$JPEGIO_DIR"
|
# Fix broken include paths in setup.py (uses jpeglib/ but files are in src/jpeglib/)
|
||||||
|
ln -s src/jpeglib jpeglib
|
||||||
|
|
||||||
# Build jpegio into venv
|
# Download libjpeg headers (not included in repo either)
|
||||||
pip install --upgrade pip setuptools wheel cython numpy
|
# Each version needs EXACT matching headers (APIs differ between versions)
|
||||||
|
echo " Downloading libjpeg headers (all versions)..."
|
||||||
|
|
||||||
|
# Download each version separately (APIs are incompatible between versions)
|
||||||
|
for v in 6b 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
|
||||||
|
echo " libjpeg $v..."
|
||||||
|
curl -sL "https://www.ijg.org/files/jpegsrc.v${v}.tar.gz" | tar -xzf -
|
||||||
|
cp jpeg-${v}/*.h "$CJPEGLIB/$v/" 2>/dev/null || cp jpeg-${v//.}/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Skip turbo/mozjpeg - they need cmake-generated headers
|
||||||
|
# Only remove dict entries (lines 49-59), keep if blocks (they're safe when is_turbo=False)
|
||||||
|
echo " Patching setup.py to skip turbo/mozjpeg (need cmake)..."
|
||||||
|
python3 << 'PYPATCH'
|
||||||
|
with open('setup.py', 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
filtered = []
|
||||||
|
for line in lines:
|
||||||
|
# Only skip dict entries like "'turbo120': ..." or "'mozjpeg101': ..."
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("'turbo") and ':' in stripped:
|
||||||
|
continue
|
||||||
|
if stripped.startswith("'mozjpeg") and ':' in stripped:
|
||||||
|
continue
|
||||||
|
if stripped.startswith("# 'turbo"): # commented turbo line
|
||||||
|
continue
|
||||||
|
filtered.append(line)
|
||||||
|
with open('setup.py', 'w') as f:
|
||||||
|
f.writelines(filtered)
|
||||||
|
PYPATCH
|
||||||
|
|
||||||
|
# Build and install
|
||||||
|
echo " Building jpeglib (this takes a few minutes)..."
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
rm -rf "$JPEGIO_DIR"
|
rm -rf "$JPEGLIB_WORKDIR"
|
||||||
|
|
||||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
# Install remaining dependencies
|
||||||
|
echo " Installing remaining dependencies..."
|
||||||
# Install dependencies (jpegio already in venv, won't re-download)
|
|
||||||
pip install -e ".[web]"
|
pip install -e ".[web]"
|
||||||
|
|
||||||
STEP_OFFSET=0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
|
echo -e " ${GREEN}✓${NC} Stegasoo installed"
|
||||||
|
|
||||||
# Create systemd service file
|
echo -e "${GREEN}[6/9]${NC} Creating systemd services..."
|
||||||
|
|
||||||
|
# Create systemd service file for Web UI
|
||||||
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Stegasoo Web UI
|
Description=Stegasoo Web UI
|
||||||
@@ -383,12 +423,53 @@ RestartSec=5
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo -e "${GREEN}[10/12]${NC} Enabling service..."
|
# Create systemd service file for REST API (optional)
|
||||||
|
sudo tee /etc/systemd/system/stegasoo-api.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR/frontends/api
|
||||||
|
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||||
|
Environment="PYTHONPATH=$INSTALL_DIR/src"
|
||||||
|
ExecStart=$INSTALL_DIR/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}[7/9]${NC} Enabling services..."
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable stegasoo.service
|
sudo systemctl enable stegasoo.service
|
||||||
|
|
||||||
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
|
# Prompt for REST API service (optional, with security warning)
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Would you like to enable the REST API service? (port 8000)${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${RED}⚠ WARNING: The REST API has NO AUTHENTICATION${NC}"
|
||||||
|
echo " Anyone on your network can use it to encode/decode messages."
|
||||||
|
echo " Only enable if you understand the security implications."
|
||||||
|
echo ""
|
||||||
|
echo " The Web UI (port 5000) has authentication and works independently."
|
||||||
|
echo ""
|
||||||
|
read -p "Enable REST API (no auth)? [y/N]: " ENABLE_API
|
||||||
|
if [[ "$ENABLE_API" =~ ^[Yy]$ ]]; then
|
||||||
|
sudo systemctl enable stegasoo-api.service
|
||||||
|
STEGASOO_API_ENABLED=true
|
||||||
|
echo -e " ${YELLOW}⚠${NC} REST API enabled on port 8000 ${RED}(no authentication)${NC}"
|
||||||
|
else
|
||||||
|
STEGASOO_API_ENABLED=false
|
||||||
|
echo -e " ${GREEN}✓${NC} REST API not enabled (recommended)"
|
||||||
|
echo " Can enable later with: sudo systemctl enable --now stegasoo-api"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[8/9]${NC} Setting up user environment..."
|
||||||
|
|
||||||
# Add stegasoo venv and rpi scripts to PATH for all users
|
# Add stegasoo venv and rpi scripts to PATH for all users
|
||||||
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
||||||
@@ -414,7 +495,15 @@ if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
|
# Install man page
|
||||||
|
if [ -f "$INSTALL_DIR/docs/stegasoo.1" ]; then
|
||||||
|
sudo mkdir -p /usr/local/share/man/man1
|
||||||
|
sudo cp "$INSTALL_DIR/docs/stegasoo.1" /usr/local/share/man/man1/
|
||||||
|
sudo mandb -q 2>/dev/null || true
|
||||||
|
echo " Installed man page (man stegasoo)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}[9/9]${NC} Setting up login banner..."
|
||||||
|
|
||||||
# Create dynamic MOTD script
|
# Create dynamic MOTD script
|
||||||
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
||||||
@@ -543,9 +632,15 @@ echo ""
|
|||||||
read -p "Generate a private channel key? [y/N] " -n 1 -r
|
read -p "Generate a private channel key? [y/N] " -n 1 -r
|
||||||
echo
|
echo
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
# Generate channel key using the CLI
|
# Generate channel key and save encrypted to config
|
||||||
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "from stegasoo.channel import generate_channel_key; print(generate_channel_key())")
|
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "
|
||||||
|
from stegasoo.channel import generate_channel_key, set_channel_key
|
||||||
|
key = generate_channel_key()
|
||||||
|
set_channel_key(key, 'user') # Saves encrypted to ~/.stegasoo/channel.key
|
||||||
|
print(key)
|
||||||
|
")
|
||||||
echo -e " ${GREEN}✓${NC} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
|
echo -e " ${GREEN}✓${NC} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
|
||||||
|
echo -e " ${GREEN}✓${NC} Key saved (encrypted) to ~/.stegasoo/channel.key"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone"
|
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone"
|
||||||
echo " who should be able to decode your images."
|
echo " who should be able to decode your images."
|
||||||
@@ -593,19 +688,40 @@ if [ "$ENABLE_HTTPS" = "true" ]; then
|
|||||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||||
PI_HOSTNAME=$(hostname)
|
PI_HOSTNAME=$(hostname)
|
||||||
|
|
||||||
# Generate cert with SANs for IP, hostname, and localhost
|
# Try mkcert first (creates browser-trusted certs - no warning screen!)
|
||||||
openssl req -x509 -newkey rsa:2048 \
|
if command -v mkcert &> /dev/null; then
|
||||||
-keyout "$CERT_DIR/server.key" \
|
echo " Using mkcert for browser-trusted certificates..."
|
||||||
-out "$CERT_DIR/server.crt" \
|
cd "$CERT_DIR"
|
||||||
-days 365 -nodes \
|
mkcert -key-file server.key -cert-file server.crt \
|
||||||
-subj "/O=Stegasoo/CN=$PI_HOSTNAME" \
|
"$PI_HOSTNAME" "$PI_HOSTNAME.local" localhost "$LOCAL_IP" 127.0.0.1 ::1
|
||||||
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
|
|
||||||
2>/dev/null
|
# Copy CA to web-accessible location for easy device setup
|
||||||
|
CA_ROOT=$(mkcert -CAROOT)
|
||||||
|
CA_DIR="$INSTALL_DIR/frontends/web/static/ca"
|
||||||
|
mkdir -p "$CA_DIR"
|
||||||
|
cp "$CA_ROOT/rootCA.pem" "$CA_DIR/"
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Trusted certificates generated with mkcert"
|
||||||
|
echo -e " ${CYAN}Tip:${NC} New devices can get the CA from: http://$PI_HOSTNAME.local/static/ca/rootCA.pem"
|
||||||
|
else
|
||||||
|
# Fallback to self-signed (shows browser warning)
|
||||||
|
echo " Using self-signed certificate (browser will show warning)"
|
||||||
|
echo " Tip: Install mkcert for trusted certs without warnings"
|
||||||
|
|
||||||
|
openssl req -x509 -newkey rsa:2048 \
|
||||||
|
-keyout "$CERT_DIR/server.key" \
|
||||||
|
-out "$CERT_DIR/server.crt" \
|
||||||
|
-days 365 -nodes \
|
||||||
|
-subj "/O=Stegasoo/CN=$PI_HOSTNAME" \
|
||||||
|
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
|
||||||
|
2>/dev/null
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Self-signed certificates generated"
|
||||||
|
fi
|
||||||
|
|
||||||
# Fix permissions
|
# Fix permissions
|
||||||
chmod 600 "$CERT_DIR/server.key"
|
chmod 600 "$CERT_DIR/server.key"
|
||||||
chown -R "$USER:$USER" "$CERT_DIR"
|
chown -R "$USER:$USER" "$CERT_DIR"
|
||||||
echo -e " ${GREEN}✓${NC} SSL certificates generated"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup port 443 redirect if requested
|
# Setup port 443 redirect if requested
|
||||||
@@ -678,6 +794,14 @@ echo " Start: sudo systemctl start stegasoo"
|
|||||||
echo " Stop: sudo systemctl stop stegasoo"
|
echo " Stop: sudo systemctl stop stegasoo"
|
||||||
echo " Status: sudo systemctl status stegasoo"
|
echo " Status: sudo systemctl status stegasoo"
|
||||||
echo " Logs: journalctl -u stegasoo -f"
|
echo " Logs: journalctl -u stegasoo -f"
|
||||||
|
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}REST API Commands:${NC}"
|
||||||
|
echo " Start: sudo systemctl start stegasoo-api"
|
||||||
|
echo " Stop: sudo systemctl stop stegasoo-api"
|
||||||
|
echo " Status: sudo systemctl status stegasoo-api"
|
||||||
|
echo " Logs: journalctl -u stegasoo-api -f"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Offer to start now
|
# Offer to start now
|
||||||
@@ -685,9 +809,12 @@ read -p "Start Stegasoo now? [Y/n] " -n 1 -r
|
|||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
sudo systemctl start stegasoo
|
sudo systemctl start stegasoo
|
||||||
|
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||||
|
sudo systemctl start stegasoo-api
|
||||||
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet stegasoo; then
|
if systemctl is-active --quiet stegasoo; then
|
||||||
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
echo -e "${GREEN}✓ Stegasoo Web UI 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_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
|
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
|
||||||
@@ -697,6 +824,13 @@ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
|||||||
else
|
else
|
||||||
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${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
|
||||||
|
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||||
|
if systemctl is-active --quiet stegasoo-api; then
|
||||||
|
echo -e "${GREEN}✓ Stegasoo REST API is running on port 8000${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ REST API failed to start. Check logs:${NC} journalctl -u stegasoo-api -f"
|
||||||
|
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"
|
||||||
fi
|
fi
|
||||||
|
|||||||
98
scripts/build.sh
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stegasoo Build Script
|
||||||
|
# Usage: ./build.sh [base|fast|full|clean]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
DOCKER_DIR="$PROJECT_DIR/docker"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Detect docker compose command
|
||||||
|
if docker compose version &>/dev/null; then
|
||||||
|
COMPOSE_CMD="docker compose"
|
||||||
|
elif command -v docker-compose &>/dev/null; then
|
||||||
|
COMPOSE_CMD="docker-compose"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: docker compose not found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we need sudo
|
||||||
|
SUDO=""
|
||||||
|
if ! docker ps &>/dev/null; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_FILE="$DOCKER_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
case "${1:-fast}" in
|
||||||
|
base)
|
||||||
|
echo -e "${YELLOW}Building base image (this takes 5-10 minutes)...${NC}"
|
||||||
|
$SUDO docker build -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
|
||||||
|
echo -e "${GREEN}Base image built! Future builds will be fast.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Optional: Push to registry for team use:"
|
||||||
|
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
|
||||||
|
echo " docker push yourregistry/stegasoo-base:latest"
|
||||||
|
;;
|
||||||
|
|
||||||
|
fast)
|
||||||
|
if ! $SUDO docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}Base image not found. Building it first (one-time)...${NC}"
|
||||||
|
$0 base
|
||||||
|
fi
|
||||||
|
echo -e "${CYAN}Fast build using base image...${NC}"
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build
|
||||||
|
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
full)
|
||||||
|
echo -e "${YELLOW}Full build from scratch (slow)...${NC}"
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||||
|
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
clean)
|
||||||
|
echo -e "${YELLOW}Cleaning up...${NC}"
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
|
||||||
|
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}Cleaned!${NC}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
rebuild)
|
||||||
|
echo -e "${YELLOW}Full rebuild from scratch (no cache)...${NC}"
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
|
||||||
|
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||||
|
$SUDO docker build --no-cache -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
|
||||||
|
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||||
|
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo -e "${CYAN}Stegasoo Build Script${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 [command]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " base Build the base image (one-time, 5-10 min)"
|
||||||
|
echo " fast Fast build using base image (default, ~10 sec)"
|
||||||
|
echo " full Rebuild services without cache (uses existing base)"
|
||||||
|
echo " rebuild Complete rebuild with no cache (base + services)"
|
||||||
|
echo " clean Remove all images and volumes"
|
||||||
|
echo ""
|
||||||
|
echo "Typical workflow:"
|
||||||
|
echo " 1. First time: $0 base"
|
||||||
|
echo " 2. Daily dev: $0 fast"
|
||||||
|
echo " 3. Deps change: $0 base"
|
||||||
|
echo " 4. Nuclear: $0 rebuild"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
93
scripts/screenshots.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Capture Web UI screenshots for documentation
|
||||||
|
# Requires: chromium, imagemagick
|
||||||
|
# Usage: ./scripts/screenshots.sh [base_url]
|
||||||
|
#
|
||||||
|
# Modes:
|
||||||
|
# Default (auth disabled): Captures main UI pages
|
||||||
|
# With auth: Also captures login/setup/account pages
|
||||||
|
#
|
||||||
|
# Start server with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:5000}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
OUTPUT_DIR="$PROJECT_DIR/data"
|
||||||
|
WINDOW_SIZE="1280,900"
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════════╗"
|
||||||
|
echo "║ Stegasoo Screenshot Capture ║"
|
||||||
|
echo "╚══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo "Output: $OUTPUT_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
for cmd in chromium magick curl; do
|
||||||
|
if ! command -v "$cmd" &> /dev/null; then
|
||||||
|
echo "Error: $cmd not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check if server is running (-k for self-signed certs)
|
||||||
|
if ! curl -sk "$BASE_URL" > /dev/null 2>&1; then
|
||||||
|
echo "Error: Server not responding at $BASE_URL"
|
||||||
|
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Capture a single screenshot
|
||||||
|
capture() {
|
||||||
|
local name="$1"
|
||||||
|
local route="$2"
|
||||||
|
local url="$BASE_URL$route"
|
||||||
|
|
||||||
|
printf " %-20s <- %s\n" "$name" "$route"
|
||||||
|
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
|
||||||
|
--window-size="$WINDOW_SIZE" --hide-scrollbars \
|
||||||
|
--disable-gpu --no-sandbox --ignore-certificate-errors \
|
||||||
|
"$url" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Capturing main pages..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Core pages (always capture)
|
||||||
|
capture "WebUI" "/"
|
||||||
|
capture "WebUI_Encode" "/encode"
|
||||||
|
capture "WebUI_Decode" "/decode"
|
||||||
|
capture "WebUI_Generate" "/generate"
|
||||||
|
capture "WebUI_Tools" "/tools"
|
||||||
|
capture "WebUI_About" "/about"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Capturing auth pages..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Auth pages (may redirect if auth disabled, that's OK)
|
||||||
|
capture "WebUI_Login" "/login"
|
||||||
|
capture "WebUI_Setup" "/setup"
|
||||||
|
capture "WebUI_Account" "/account"
|
||||||
|
capture "WebUI_Recover" "/recover"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Converting to webp..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for png in "$OUTPUT_DIR"/WebUI*.png; do
|
||||||
|
[ -f "$png" ] || continue
|
||||||
|
name=$(basename "$png" .png)
|
||||||
|
printf " %-20s -> %s.webp\n" "$name.png" "$name"
|
||||||
|
magick "$png" -quality 85 "$OUTPUT_DIR/$name.webp"
|
||||||
|
rm -f "$png"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Screenshots:"
|
||||||
|
echo ""
|
||||||
|
ls -lh "$OUTPUT_DIR"/WebUI*.webp 2>/dev/null | awk '{print " " $NF " (" $5 ")"}'
|
||||||
|
echo ""
|
||||||
149
scripts/setup-trusted-certs.sh
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Setup trusted HTTPS certificates for Stegasoo
|
||||||
|
# Uses mkcert to create browser-trusted certs (no warning screens!)
|
||||||
|
#
|
||||||
|
# Usage: ./setup-trusted-certs.sh [hostname]
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Installs mkcert if needed
|
||||||
|
# 2. Creates a local CA (one-time)
|
||||||
|
# 3. Generates certs for your hostname
|
||||||
|
# 4. Shows how to trust the CA on other devices
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HOSTNAME="${1:-stegasoo.local}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$SCRIPT_DIR/.."
|
||||||
|
CERT_DIR="$PROJECT_ROOT/frontends/web/certs"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${CYAN}║ Stegasoo Trusted Certificate Setup ║${NC}"
|
||||||
|
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check/install mkcert
|
||||||
|
install_mkcert() {
|
||||||
|
if command -v mkcert &> /dev/null; then
|
||||||
|
echo -e "${GREEN}✓${NC} mkcert already installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Installing mkcert...${NC}"
|
||||||
|
|
||||||
|
# Detect OS and install
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
# macOS
|
||||||
|
if command -v brew &> /dev/null; then
|
||||||
|
brew install mkcert
|
||||||
|
else
|
||||||
|
echo -e "${RED}Please install Homebrew first: https://brew.sh${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [[ -f /etc/debian_version ]]; then
|
||||||
|
# Debian/Ubuntu/Raspberry Pi OS
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libnss3-tools
|
||||||
|
|
||||||
|
# Download mkcert binary
|
||||||
|
ARCH=$(dpkg --print-architecture)
|
||||||
|
if [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then
|
||||||
|
MKCERT_URL="https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-linux-arm64"
|
||||||
|
else
|
||||||
|
MKCERT_URL="https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-linux-amd64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo curl -L "$MKCERT_URL" -o /usr/local/bin/mkcert
|
||||||
|
sudo chmod +x /usr/local/bin/mkcert
|
||||||
|
elif [[ -f /etc/arch-release ]]; then
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S mkcert
|
||||||
|
else
|
||||||
|
echo -e "${RED}Unsupported OS. Please install mkcert manually:${NC}"
|
||||||
|
echo " https://github.com/FiloSottile/mkcert#installation"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓${NC} mkcert installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install local CA
|
||||||
|
setup_ca() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Setting up local Certificate Authority...${NC}"
|
||||||
|
|
||||||
|
if mkcert -install 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✓${NC} Local CA installed in system trust store"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}!${NC} Could not auto-install CA (may need manual browser import)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate certificates
|
||||||
|
generate_certs() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Generating trusted certificate for: ${YELLOW}$HOSTNAME${NC}"
|
||||||
|
|
||||||
|
mkdir -p "$CERT_DIR"
|
||||||
|
cd "$CERT_DIR"
|
||||||
|
|
||||||
|
# Generate cert for hostname + common local names
|
||||||
|
mkcert -key-file key.pem -cert-file cert.pem \
|
||||||
|
"$HOSTNAME" \
|
||||||
|
localhost \
|
||||||
|
127.0.0.1 \
|
||||||
|
::1
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓${NC} Certificates generated in: $CERT_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show CA location for other devices
|
||||||
|
show_ca_info() {
|
||||||
|
CA_ROOT=$(mkcert -CAROOT)
|
||||||
|
CA_FILE="$CA_ROOT/rootCA.pem"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN} Setup Complete!${NC}"
|
||||||
|
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Your certificates are ready. Browsers on THIS machine will trust them."
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}To trust on OTHER devices (phones, tablets, other computers):${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 1. Copy the CA certificate to that device:"
|
||||||
|
echo -e " ${CYAN}$CA_FILE${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Import it as a trusted CA:"
|
||||||
|
echo " - iOS: AirDrop/email the file, Settings > Profile Downloaded > Install"
|
||||||
|
echo " - Android: Settings > Security > Install from storage"
|
||||||
|
echo " - Windows: Double-click > Install > Trusted Root CAs"
|
||||||
|
echo " - macOS: Double-click > Keychain Access > Trust Always"
|
||||||
|
echo " - Linux: Copy to /usr/local/share/ca-certificates/ && update-ca-certificates"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Quick copy command:${NC}"
|
||||||
|
echo " scp $CA_FILE user@device:/path/"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Offer to serve CA file via HTTP for easy phone download
|
||||||
|
echo -e "${YELLOW}Or serve the CA for easy phone download:${NC}"
|
||||||
|
echo " python3 -m http.server 8080 -d $CA_ROOT"
|
||||||
|
echo " Then visit: http://$(hostname -I | awk '{print $1}'):8080/rootCA.pem"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
install_mkcert
|
||||||
|
setup_ca
|
||||||
|
generate_certs
|
||||||
|
show_ca_info
|
||||||
333
scripts/smoke-test.sh
Executable file
@@ -0,0 +1,333 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Stegasoo Smoke Test
|
||||||
|
# Tests all core functionality against a running instance (Pi, Docker, or dev)
|
||||||
|
#
|
||||||
|
# Usage: ./smoke-test.sh [host] [port] [user] [pass]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./smoke-test.sh # Pi default (stegasoo.local:443)
|
||||||
|
# ./smoke-test.sh localhost 5000 # Docker default
|
||||||
|
# ./smoke-test.sh 192.168.1.100 5000 # Custom host
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
HOST="${1:-stegasoo.local}"
|
||||||
|
PORT="${2:-443}"
|
||||||
|
USER="${3:-admin}"
|
||||||
|
PASS="${4:-stegasoo}"
|
||||||
|
|
||||||
|
# Build URL (don't include :443 since it's default for https)
|
||||||
|
if [ "$PORT" = "443" ]; then
|
||||||
|
BASE_URL="https://$HOST"
|
||||||
|
else
|
||||||
|
BASE_URL="https://$HOST:$PORT"
|
||||||
|
fi
|
||||||
|
COOKIE_JAR="/tmp/stegasoo_smoke_cookies.txt"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TEST_DATA="$SCRIPT_DIR/../test_data"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
PASSED=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helper functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
log_test() {
|
||||||
|
echo -e "${CYAN}[TEST]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_pass() {
|
||||||
|
echo -e "${GREEN}[PASS]${NC} $1"
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_fail() {
|
||||||
|
echo -e "${RED}[FAIL]${NC} $1"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_get() {
|
||||||
|
curl -sk "$BASE_URL$1" -b "$COOKIE_JAR" -c "$COOKIE_JAR" "${@:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_post() {
|
||||||
|
curl -sk -X POST "$BASE_URL$1" -b "$COOKIE_JAR" -c "$COOKIE_JAR" "${@:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_job() {
|
||||||
|
local endpoint="$1"
|
||||||
|
local job_id="$2"
|
||||||
|
local max_polls="${3:-30}"
|
||||||
|
|
||||||
|
for i in $(seq 1 $max_polls); do
|
||||||
|
sleep 1
|
||||||
|
result=$(curl_get "$endpoint/$job_id")
|
||||||
|
if echo "$result" | grep -q '"status":\s*"complete"'; then
|
||||||
|
echo "$result"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if echo "$result" | grep -q '"status":\s*"error"'; then
|
||||||
|
echo "$result"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo '{"status":"timeout"}'
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test_connectivity() {
|
||||||
|
log_test "Connectivity to $BASE_URL"
|
||||||
|
if curl -sk --connect-timeout 5 "$BASE_URL" -o /dev/null; then
|
||||||
|
log_pass "Server reachable"
|
||||||
|
else
|
||||||
|
log_fail "Cannot reach server"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_setup_or_login() {
|
||||||
|
log_test "Setup/Login"
|
||||||
|
|
||||||
|
# Check if setup needed
|
||||||
|
response=$(curl_get "/" -L -o /dev/null -w "%{url_effective}")
|
||||||
|
|
||||||
|
if echo "$response" | grep -q "/setup"; then
|
||||||
|
log_test "Completing first-time setup..."
|
||||||
|
curl_post "/setup" \
|
||||||
|
-d "username=$USER" \
|
||||||
|
-d "password=$PASS" \
|
||||||
|
-d "password_confirm=$PASS" \
|
||||||
|
-L -o /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Login
|
||||||
|
curl_get "/login" -o /dev/null # Get session
|
||||||
|
curl_post "/login" \
|
||||||
|
-d "username=$USER" \
|
||||||
|
-d "password=$PASS" \
|
||||||
|
-L -o /dev/null
|
||||||
|
|
||||||
|
# Verify logged in
|
||||||
|
code=$(curl_get "/encode" -o /dev/null -w "%{http_code}")
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
log_pass "Authenticated successfully"
|
||||||
|
else
|
||||||
|
log_fail "Authentication failed (got $code)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_pages() {
|
||||||
|
log_test "Page accessibility"
|
||||||
|
|
||||||
|
local pages="encode decode generate tools about"
|
||||||
|
local all_pass=true
|
||||||
|
|
||||||
|
for page in $pages; do
|
||||||
|
code=$(curl_get "/$page" -o /dev/null -w "%{http_code}")
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} /$page"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗${NC} /$page ($code)"
|
||||||
|
all_pass=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if $all_pass; then
|
||||||
|
log_pass "All pages accessible"
|
||||||
|
else
|
||||||
|
log_fail "Some pages inaccessible"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_encode_decode_dct() {
|
||||||
|
log_test "DCT Encode/Decode round trip"
|
||||||
|
|
||||||
|
local message="DCT smoke test $(date +%s)"
|
||||||
|
|
||||||
|
# Encode
|
||||||
|
response=$(curl_post "/encode" \
|
||||||
|
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||||
|
-F "carrier=@$TEST_DATA/carrier.jpg" \
|
||||||
|
-F "message=$message" \
|
||||||
|
-F "passphrase=tower booty sunny windy" \
|
||||||
|
-F "pin=727643678" \
|
||||||
|
-F "embed_mode=dct" \
|
||||||
|
-F "channel_key=auto" \
|
||||||
|
-F "async=true")
|
||||||
|
|
||||||
|
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$job_id" ]; then
|
||||||
|
log_fail "DCT encode - no job ID returned"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for encode
|
||||||
|
result=$(wait_for_job "/encode/status" "$job_id" 15)
|
||||||
|
if ! echo "$result" | grep -q '"status":\s*"complete"'; then
|
||||||
|
log_fail "DCT encode timeout or error"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_id=$(echo "$result" | grep -oP '"file_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||||
|
curl_get "/encode/download/$file_id" -o /tmp/stego_dct_test.jpg
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Encoded $(ls -lh /tmp/stego_dct_test.jpg | awk '{print $5}')"
|
||||||
|
|
||||||
|
# Decode
|
||||||
|
response=$(curl_post "/decode" \
|
||||||
|
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||||
|
-F "stego_image=@/tmp/stego_dct_test.jpg" \
|
||||||
|
-F "passphrase=tower booty sunny windy" \
|
||||||
|
-F "pin=727643678" \
|
||||||
|
-F "embed_mode=auto" \
|
||||||
|
-F "channel_key=auto" \
|
||||||
|
-F "async=true")
|
||||||
|
|
||||||
|
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
# Wait for decode (DCT is slower on Pi)
|
||||||
|
result=$(wait_for_job "/decode/status" "$job_id" 60)
|
||||||
|
|
||||||
|
if echo "$result" | grep -q "$message"; then
|
||||||
|
log_pass "DCT round trip - message verified"
|
||||||
|
else
|
||||||
|
log_fail "DCT decode - message mismatch"
|
||||||
|
echo " Expected: $message"
|
||||||
|
echo " Got: $result"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_encode_decode_lsb() {
|
||||||
|
log_test "LSB Encode/Decode round trip"
|
||||||
|
|
||||||
|
local message="LSB smoke test $(date +%s)"
|
||||||
|
|
||||||
|
# Encode
|
||||||
|
response=$(curl_post "/encode" \
|
||||||
|
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||||
|
-F "carrier=@$TEST_DATA/carrier.jpg" \
|
||||||
|
-F "message=$message" \
|
||||||
|
-F "passphrase=tower booty sunny windy" \
|
||||||
|
-F "pin=727643678" \
|
||||||
|
-F "embed_mode=lsb" \
|
||||||
|
-F "channel_key=auto" \
|
||||||
|
-F "async=true")
|
||||||
|
|
||||||
|
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$job_id" ]; then
|
||||||
|
log_fail "LSB encode - no job ID returned"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
result=$(wait_for_job "/encode/status" "$job_id" 10)
|
||||||
|
if ! echo "$result" | grep -q '"status":\s*"complete"'; then
|
||||||
|
log_fail "LSB encode timeout or error"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_id=$(echo "$result" | grep -oP '"file_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||||
|
curl_get "/encode/download/$file_id" -o /tmp/stego_lsb_test.png
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Encoded $(ls -lh /tmp/stego_lsb_test.png | awk '{print $5}')"
|
||||||
|
|
||||||
|
# Decode
|
||||||
|
response=$(curl_post "/decode" \
|
||||||
|
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||||
|
-F "stego_image=@/tmp/stego_lsb_test.png" \
|
||||||
|
-F "passphrase=tower booty sunny windy" \
|
||||||
|
-F "pin=727643678" \
|
||||||
|
-F "embed_mode=lsb" \
|
||||||
|
-F "channel_key=auto" \
|
||||||
|
-F "async=true")
|
||||||
|
|
||||||
|
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||||
|
result=$(wait_for_job "/decode/status" "$job_id" 15)
|
||||||
|
|
||||||
|
if echo "$result" | grep -q "$message"; then
|
||||||
|
log_pass "LSB round trip - message verified"
|
||||||
|
else
|
||||||
|
log_fail "LSB decode - message mismatch"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_tools() {
|
||||||
|
log_test "Tools endpoints"
|
||||||
|
|
||||||
|
# Capacity check
|
||||||
|
response=$(curl_post "/api/tools/capacity" \
|
||||||
|
-F "image=@$TEST_DATA/carrier.jpg" \
|
||||||
|
-w "%{http_code}" -o /tmp/capacity_result.json)
|
||||||
|
|
||||||
|
if [ "$response" = "200" ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} Capacity check"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗${NC} Capacity check ($response)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# EXIF read
|
||||||
|
response=$(curl_post "/api/tools/exif" \
|
||||||
|
-F "image=@$TEST_DATA/carrier.jpg" \
|
||||||
|
-w "%{http_code}" -o /tmp/exif_result.json)
|
||||||
|
|
||||||
|
if [ "$response" = "200" ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} EXIF read"
|
||||||
|
log_pass "Tools API works"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗${NC} EXIF read ($response)"
|
||||||
|
log_fail "Tools API failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${CYAN}║ Stegasoo Smoke Test ║${NC}"
|
||||||
|
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
|
||||||
|
echo -e "User: ${YELLOW}$USER${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm -f "$COOKIE_JAR" /tmp/stego_*_test.* /tmp/exif_stripped.jpg
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test_connectivity
|
||||||
|
test_setup_or_login
|
||||||
|
test_pages
|
||||||
|
test_encode_decode_lsb
|
||||||
|
test_encode_decode_dct
|
||||||
|
test_tools
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}"
|
||||||
|
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm -f "$COOKIE_JAR"
|
||||||
|
|
||||||
|
if [ $FAILED -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
|||||||
- encode() and decode() now accept channel_key parameter
|
- encode() and decode() now accept channel_key parameter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.1.3"
|
__version__ = "4.2.1"
|
||||||
|
|
||||||
# Core functionality
|
# Core functionality
|
||||||
# Channel key management (v4.0.0)
|
# Channel key management (v4.0.0)
|
||||||
|
|||||||
@@ -47,6 +47,80 @@ CONFIG_LOCATIONS = [
|
|||||||
Path.home() / ".stegasoo" / "channel.key", # User config
|
Path.home() / ".stegasoo" / "channel.key", # User config
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Encrypted config marker
|
||||||
|
ENCRYPTED_PREFIX = "ENC:"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_machine_key() -> bytes:
|
||||||
|
"""
|
||||||
|
Get a machine-specific key for encrypting stored channel keys.
|
||||||
|
|
||||||
|
Uses /etc/machine-id on Linux, falls back to hostname hash.
|
||||||
|
This ties the encrypted key to this specific machine.
|
||||||
|
"""
|
||||||
|
machine_id = None
|
||||||
|
|
||||||
|
# Try Linux machine-id
|
||||||
|
try:
|
||||||
|
machine_id = Path("/etc/machine-id").read_text().strip()
|
||||||
|
except (OSError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to hostname
|
||||||
|
if not machine_id:
|
||||||
|
import socket
|
||||||
|
machine_id = socket.gethostname()
|
||||||
|
|
||||||
|
# Hash to get consistent 32 bytes
|
||||||
|
return hashlib.sha256(machine_id.encode()).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_for_storage(plaintext: str) -> str:
|
||||||
|
"""
|
||||||
|
Encrypt a channel key for storage using machine-specific key.
|
||||||
|
|
||||||
|
Returns ENC: prefixed base64 string.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
key = _get_machine_key()
|
||||||
|
plaintext_bytes = plaintext.encode()
|
||||||
|
|
||||||
|
# XOR with key (cycling if needed)
|
||||||
|
encrypted = bytes(
|
||||||
|
pb ^ key[i % len(key)]
|
||||||
|
for i, pb in enumerate(plaintext_bytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_from_storage(stored: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Decrypt a stored channel key.
|
||||||
|
|
||||||
|
Returns None if decryption fails or format is invalid.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
if not stored.startswith(ENCRYPTED_PREFIX):
|
||||||
|
# Not encrypted, return as-is (legacy plaintext)
|
||||||
|
return stored
|
||||||
|
|
||||||
|
try:
|
||||||
|
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):])
|
||||||
|
key = _get_machine_key()
|
||||||
|
|
||||||
|
# XOR to decrypt
|
||||||
|
decrypted = bytes(
|
||||||
|
eb ^ key[i % len(key)]
|
||||||
|
for i, eb in enumerate(encrypted)
|
||||||
|
)
|
||||||
|
|
||||||
|
return decrypted.decode()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_channel_key() -> str:
|
def generate_channel_key() -> str:
|
||||||
"""
|
"""
|
||||||
@@ -154,11 +228,13 @@ def get_channel_key() -> str | None:
|
|||||||
else:
|
else:
|
||||||
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
|
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
|
||||||
|
|
||||||
# 2. Check config files
|
# 2. Check config files (may be encrypted)
|
||||||
for config_path in CONFIG_LOCATIONS:
|
for config_path in CONFIG_LOCATIONS:
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
try:
|
try:
|
||||||
key = config_path.read_text().strip()
|
stored = config_path.read_text().strip()
|
||||||
|
# Decrypt if encrypted, otherwise use as-is (legacy)
|
||||||
|
key = _decrypt_from_storage(stored)
|
||||||
if key and validate_channel_key(key):
|
if key and validate_channel_key(key):
|
||||||
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
|
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
|
||||||
return format_channel_key(key)
|
return format_channel_key(key)
|
||||||
@@ -200,8 +276,9 @@ def set_channel_key(key: str, location: str = "project") -> Path:
|
|||||||
# Create directory if needed
|
# Create directory if needed
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Write key with newline
|
# Encrypt and write (tied to this machine's identity)
|
||||||
config_path.write_text(formatted + "\n")
|
encrypted = _encrypt_for_storage(formatted)
|
||||||
|
config_path.write_text(encrypted + "\n")
|
||||||
|
|
||||||
# Set restrictive permissions (owner read/write only)
|
# Set restrictive permissions (owner read/write only)
|
||||||
try:
|
try:
|
||||||
@@ -334,11 +411,12 @@ def get_channel_status() -> dict:
|
|||||||
for config_path in CONFIG_LOCATIONS:
|
for config_path in CONFIG_LOCATIONS:
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
try:
|
try:
|
||||||
file_key = config_path.read_text().strip()
|
stored = config_path.read_text().strip()
|
||||||
if file_key and format_channel_key(file_key) == key:
|
file_key = _decrypt_from_storage(stored)
|
||||||
|
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key:
|
||||||
source = str(config_path)
|
source = str(config_path)
|
||||||
break
|
break
|
||||||
except (OSError, PermissionError):
|
except (OSError, PermissionError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -80,12 +80,6 @@ from .batch import (
|
|||||||
batch_capacity_check,
|
batch_capacity_check,
|
||||||
print_batch_result,
|
print_batch_result,
|
||||||
)
|
)
|
||||||
from .compression import (
|
|
||||||
HAS_LZ4,
|
|
||||||
CompressionAlgorithm,
|
|
||||||
algorithm_name,
|
|
||||||
get_available_algorithms,
|
|
||||||
)
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS
|
DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS
|
||||||
DEFAULT_PIN_LENGTH,
|
DEFAULT_PIN_LENGTH,
|
||||||
@@ -183,19 +177,10 @@ def cli(ctx, json_output):
|
|||||||
help="Passphrase (recommend 4+ words)",
|
help="Passphrase (recommend 4+ words)",
|
||||||
)
|
)
|
||||||
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
||||||
@click.option(
|
|
||||||
"--compress/--no-compress", default=True, help="Enable/disable compression (default: enabled)"
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--algorithm",
|
|
||||||
type=click.Choice(["zlib", "lz4", "none"]),
|
|
||||||
default="zlib",
|
|
||||||
help="Compression algorithm",
|
|
||||||
)
|
|
||||||
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def encode(
|
def encode(
|
||||||
ctx, carrier, reference, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run
|
ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
@@ -214,18 +199,6 @@ def encode(
|
|||||||
if not message and not file_payload:
|
if not message and not file_payload:
|
||||||
raise click.UsageError("Either --message or --file is required")
|
raise click.UsageError("Either --message or --file is required")
|
||||||
|
|
||||||
# Parse compression algorithm
|
|
||||||
algo_map = {
|
|
||||||
"zlib": CompressionAlgorithm.ZLIB,
|
|
||||||
"lz4": CompressionAlgorithm.LZ4,
|
|
||||||
"none": CompressionAlgorithm.NONE,
|
|
||||||
}
|
|
||||||
compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE
|
|
||||||
|
|
||||||
if algorithm == "lz4" and not HAS_LZ4:
|
|
||||||
click.echo("Warning: LZ4 not available, falling back to zlib", err=True)
|
|
||||||
compression_algo = CompressionAlgorithm.ZLIB
|
|
||||||
|
|
||||||
# Calculate payload size
|
# Calculate payload size
|
||||||
if file_payload:
|
if file_payload:
|
||||||
payload_size = Path(file_payload).stat().st_size
|
payload_size = Path(file_payload).stat().st_size
|
||||||
@@ -247,7 +220,6 @@ def encode(
|
|||||||
"capacity_bytes": capacity_bytes,
|
"capacity_bytes": capacity_bytes,
|
||||||
"payload_type": payload_type,
|
"payload_type": payload_type,
|
||||||
"payload_size": payload_size,
|
"payload_size": payload_size,
|
||||||
"compression": algorithm_name(compression_algo),
|
|
||||||
"usage_percent": round(payload_size / capacity_bytes * 100, 1),
|
"usage_percent": round(payload_size / capacity_bytes * 100, 1),
|
||||||
"fits": payload_size < capacity_bytes,
|
"fits": payload_size < capacity_bytes,
|
||||||
}
|
}
|
||||||
@@ -259,7 +231,6 @@ def encode(
|
|||||||
click.echo(f"Reference: {reference}")
|
click.echo(f"Reference: {reference}")
|
||||||
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
|
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
|
||||||
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
||||||
click.echo(f"Compression: {algorithm_name(compression_algo)}")
|
|
||||||
click.echo(f"Usage: {result['usage_percent']}%")
|
click.echo(f"Usage: {result['usage_percent']}%")
|
||||||
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
||||||
return
|
return
|
||||||
@@ -270,8 +241,20 @@ def encode(
|
|||||||
with open(carrier, "rb") as f:
|
with open(carrier, "rb") as f:
|
||||||
carrier_data = f.read()
|
carrier_data = f.read()
|
||||||
|
|
||||||
# Determine output path
|
# Determine output path and format
|
||||||
output = output or f"{Path(carrier).stem}_encoded.png"
|
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
|
||||||
|
carrier_ext = Path(carrier).suffix.lower()
|
||||||
|
if not output:
|
||||||
|
if carrier_ext in ('.jpg', '.jpeg'):
|
||||||
|
output = f"{Path(carrier).stem}_encoded.jpg"
|
||||||
|
else:
|
||||||
|
output = f"{Path(carrier).stem}_encoded.png"
|
||||||
|
|
||||||
|
# Detect output format from extension
|
||||||
|
output_ext = Path(output).suffix.lower()
|
||||||
|
use_dct = output_ext in ('.jpg', '.jpeg')
|
||||||
|
|
||||||
|
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if file_payload:
|
if file_payload:
|
||||||
@@ -282,6 +265,8 @@ def encode(
|
|||||||
carrier_image=carrier_data,
|
carrier_image=carrier_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
pin=pin,
|
pin=pin,
|
||||||
|
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||||
|
dct_output_format="jpeg" if use_dct else "png",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Encode message
|
# Encode message
|
||||||
@@ -291,6 +276,8 @@ def encode(
|
|||||||
carrier_image=carrier_data,
|
carrier_image=carrier_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
pin=pin,
|
pin=pin,
|
||||||
|
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||||
|
dct_output_format="jpeg" if use_dct else "png",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write output
|
# Write output
|
||||||
@@ -306,7 +293,6 @@ def encode(
|
|||||||
"reference": reference,
|
"reference": reference,
|
||||||
"output": output,
|
"output": output,
|
||||||
"payload_type": payload_type,
|
"payload_type": payload_type,
|
||||||
"compression": algorithm_name(compression_algo),
|
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
@@ -314,7 +300,6 @@ def encode(
|
|||||||
else:
|
else:
|
||||||
click.echo(f"✓ Encoded {payload_type} to {output}")
|
click.echo(f"✓ Encoded {payload_type} to {output}")
|
||||||
click.echo(f" Reference: {reference}")
|
click.echo(f" Reference: {reference}")
|
||||||
click.echo(f" Compression: {algorithm_name(compression_algo)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if ctx.obj.get("json"):
|
if ctx.obj.get("json"):
|
||||||
@@ -474,13 +459,6 @@ def batch():
|
|||||||
help="Passphrase (recommend 4+ words)",
|
help="Passphrase (recommend 4+ words)",
|
||||||
)
|
)
|
||||||
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
||||||
@click.option("--compress/--no-compress", default=True, help="Enable/disable compression")
|
|
||||||
@click.option(
|
|
||||||
"--algorithm",
|
|
||||||
type=click.Choice(["zlib", "lz4", "none"]),
|
|
||||||
default="zlib",
|
|
||||||
help="Compression algorithm",
|
|
||||||
)
|
|
||||||
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
|
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
|
||||||
@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)")
|
@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)")
|
||||||
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
|
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
|
||||||
@@ -494,8 +472,6 @@ def batch_encode(
|
|||||||
suffix,
|
suffix,
|
||||||
passphrase,
|
passphrase,
|
||||||
pin,
|
pin,
|
||||||
compress,
|
|
||||||
algorithm,
|
|
||||||
recursive,
|
recursive,
|
||||||
jobs,
|
jobs,
|
||||||
verbose,
|
verbose,
|
||||||
@@ -530,7 +506,6 @@ def batch_encode(
|
|||||||
output_dir=Path(output_dir) if output_dir else None,
|
output_dir=Path(output_dir) if output_dir else None,
|
||||||
output_suffix=suffix,
|
output_suffix=suffix,
|
||||||
credentials=credentials,
|
credentials=credentials,
|
||||||
compress=compress,
|
|
||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
progress_callback=progress if not ctx.obj.get("json") else None,
|
progress_callback=progress if not ctx.obj.get("json") else None,
|
||||||
)
|
)
|
||||||
@@ -821,10 +796,6 @@ def info(ctx, full):
|
|||||||
"fingerprint": channel_fingerprint,
|
"fingerprint": channel_fingerprint,
|
||||||
"source": channel_source,
|
"source": channel_source,
|
||||||
} if channel_fingerprint else None,
|
} if channel_fingerprint else None,
|
||||||
"compression": {
|
|
||||||
"available": [algorithm_name(a) for a in get_available_algorithms()],
|
|
||||||
"lz4_installed": HAS_LZ4,
|
|
||||||
},
|
|
||||||
"limits": {
|
"limits": {
|
||||||
"max_message_bytes": MAX_MESSAGE_SIZE,
|
"max_message_bytes": MAX_MESSAGE_SIZE,
|
||||||
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
|
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
|
||||||
@@ -859,7 +830,7 @@ 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(" Channel: \033[33mpublic\033[0m")
|
click.echo(" Channel: public")
|
||||||
|
|
||||||
# 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"
|
||||||
@@ -1342,6 +1313,203 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
|||||||
raise click.UsageError(str(e))
|
raise click.UsageError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("compress")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)")
|
||||||
|
def tools_compress(image, quality, output):
|
||||||
|
"""Compress a JPEG image.
|
||||||
|
|
||||||
|
DCT steganography survives JPEG compression! Use this to reduce file size
|
||||||
|
while preserving hidden data.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools compress photo.jpg -q 60
|
||||||
|
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
if not 1 <= quality <= 100:
|
||||||
|
raise click.UsageError("Quality must be between 1 and 100")
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Convert to RGB if needed (JPEG doesn't support alpha)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="JPEG", quality=quality)
|
||||||
|
compressed_data = buffer.getvalue()
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
output = f"{stem}_q{quality}.jpg"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(compressed_data)
|
||||||
|
|
||||||
|
orig_size = len(image_data)
|
||||||
|
new_size = len(compressed_data)
|
||||||
|
reduction = (1 - new_size / orig_size) * 100
|
||||||
|
|
||||||
|
click.echo(f"Compressed to: {output}")
|
||||||
|
click.echo(f" Original: {orig_size:,} bytes")
|
||||||
|
click.echo(f" Compressed: {new_size:,} bytes ({reduction:.1f}% smaller)")
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("rotate")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise")
|
||||||
|
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
|
||||||
|
@click.option("--flip-v", is_flag=True, help="Flip vertically")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||||
|
def tools_rotate(image, rotation, flip_h, flip_v, output):
|
||||||
|
"""Rotate and/or flip an image.
|
||||||
|
|
||||||
|
For JPEGs, uses lossless jpegtran rotation which preserves DCT steganography.
|
||||||
|
For other formats, uses PIL (re-encodes the image).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools rotate photo.jpg -r 90
|
||||||
|
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
# Must have rotation or flip
|
||||||
|
if not rotation and not flip_h and not flip_v:
|
||||||
|
raise click.UsageError("Must specify at least one of -r/--rotation, --flip-h, or --flip-v")
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
is_jpeg = img.format == "JPEG"
|
||||||
|
img.close()
|
||||||
|
|
||||||
|
rotation_deg = int(rotation) if rotation else 0
|
||||||
|
|
||||||
|
# For JPEGs, use lossless jpegtran
|
||||||
|
if is_jpeg and shutil.which("jpegtran"):
|
||||||
|
from .dct_steganography import _jpegtran_rotate
|
||||||
|
|
||||||
|
result_data = image_data
|
||||||
|
|
||||||
|
# Apply rotation
|
||||||
|
if rotation_deg in (90, 180, 270):
|
||||||
|
result_data = _jpegtran_rotate(result_data, rotation_deg)
|
||||||
|
|
||||||
|
# Apply flips using jpegtran
|
||||||
|
if flip_h or flip_v:
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(result_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["jpegtran", "-flip", flip_type, "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30, check=True
|
||||||
|
)
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
result_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ext = "jpg"
|
||||||
|
click.echo(" (Used lossless jpegtran - DCT stego preserved)")
|
||||||
|
else:
|
||||||
|
# Use PIL for non-JPEGs
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# PIL rotation is counter-clockwise, we want clockwise
|
||||||
|
if rotation_deg:
|
||||||
|
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation_deg]
|
||||||
|
img = img.rotate(pil_rotation, expand=True)
|
||||||
|
|
||||||
|
if flip_h:
|
||||||
|
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
if flip_v:
|
||||||
|
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
result_data = buffer.getvalue()
|
||||||
|
ext = "png"
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
suffix = "rotated" if rotation_deg else "flipped"
|
||||||
|
output = f"{stem}_{suffix}.{ext}"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(result_data)
|
||||||
|
|
||||||
|
click.echo(f"Saved to: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("convert")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format")
|
||||||
|
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||||
|
def tools_convert(image, fmt, quality, output):
|
||||||
|
"""Convert image to a different format.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools convert photo.png -f jpg
|
||||||
|
stegasoo tools convert photo.jpg -f png -o lossless.png
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Handle format-specific conversions
|
||||||
|
save_format = {"jpg": "JPEG", "png": "PNG", "bmp": "BMP", "webp": "WEBP"}[fmt]
|
||||||
|
|
||||||
|
if save_format == "JPEG" and img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
if save_format in ("JPEG", "WEBP"):
|
||||||
|
img.save(buffer, format=save_format, quality=quality)
|
||||||
|
else:
|
||||||
|
img.save(buffer, format=save_format)
|
||||||
|
|
||||||
|
result_data = buffer.getvalue()
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
output = f"{stem}.{fmt}"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(result_data)
|
||||||
|
|
||||||
|
click.echo(f"Converted to: {output}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ADMIN COMMANDS (Web UI administration)
|
# ADMIN COMMANDS (Web UI administration)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1500,6 +1668,301 @@ def admin_generate_key(show_qr):
|
|||||||
click.echo("go to Account > Recovery Key > Regenerate")
|
click.echo("go to Account > Recovery Key > Regenerate")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API COMMANDS (REST API management)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_frontends_path():
|
||||||
|
"""Add frontends directory to sys.path for importing API/web modules."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Try multiple possible locations
|
||||||
|
possible_paths = [
|
||||||
|
# Development: stegasoo/frontends
|
||||||
|
Path(__file__).parent.parent.parent / "frontends",
|
||||||
|
# Installed package: site-packages/frontends
|
||||||
|
Path(__file__).parent.parent / "frontends",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if path.exists() and str(path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(path))
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
@click.pass_context
|
||||||
|
def api(ctx):
|
||||||
|
"""REST API management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api.group("keys")
|
||||||
|
def api_keys():
|
||||||
|
"""Manage API keys for authentication."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api_keys.command("list")
|
||||||
|
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all",
|
||||||
|
help="Config location to list keys from")
|
||||||
|
def api_keys_list(location):
|
||||||
|
"""List configured API keys.
|
||||||
|
|
||||||
|
Shows key names and creation dates (not actual keys).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api keys list
|
||||||
|
stegasoo api keys list --location user
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.auth import list_api_keys, get_api_key_status
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
|
status = get_api_key_status()
|
||||||
|
|
||||||
|
click.echo(f"\nAPI Key Authentication: {'Enabled' if status['enabled'] else 'Disabled'}")
|
||||||
|
click.echo(f"Total keys: {status['total_keys']}")
|
||||||
|
click.echo(f"Environment variable: {'Set' if status['env_configured'] else 'Not set'}")
|
||||||
|
|
||||||
|
locations = ["user", "project"] if location == "all" else [location]
|
||||||
|
|
||||||
|
for loc in locations:
|
||||||
|
keys = list_api_keys(loc)
|
||||||
|
click.echo(f"\n{loc.title()} keys ({len(keys)}):")
|
||||||
|
if keys:
|
||||||
|
for k in keys:
|
||||||
|
click.echo(f" - {k['name']} (created: {k['created'][:10]})")
|
||||||
|
else:
|
||||||
|
click.echo(" (none)")
|
||||||
|
|
||||||
|
|
||||||
|
@api_keys.command("create")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
||||||
|
help="Where to store the key")
|
||||||
|
def api_keys_create(name, location):
|
||||||
|
"""Create a new API key.
|
||||||
|
|
||||||
|
The key is shown ONCE and cannot be retrieved again.
|
||||||
|
Save it immediately!
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api keys create laptop
|
||||||
|
stegasoo api keys create automation --location project
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.auth import add_api_key
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = add_api_key(name, location)
|
||||||
|
click.echo(f"\nAPI Key created: {name}")
|
||||||
|
click.echo("─" * 60)
|
||||||
|
click.echo(f" {key}")
|
||||||
|
click.echo("─" * 60)
|
||||||
|
click.echo("\nSave this key NOW! It cannot be retrieved again.")
|
||||||
|
click.echo(f"Stored in: {location} config")
|
||||||
|
except ValueError as e:
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@api_keys.command("delete")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
||||||
|
help="Config location")
|
||||||
|
def api_keys_delete(name, location):
|
||||||
|
"""Delete an API key by name.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api keys delete laptop
|
||||||
|
stegasoo api keys delete automation --location project
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.auth import remove_api_key
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
|
if remove_api_key(name, location):
|
||||||
|
click.echo(f"Deleted API key: {name}")
|
||||||
|
else:
|
||||||
|
raise click.ClickException(f"Key '{name}' not found in {location} config")
|
||||||
|
|
||||||
|
|
||||||
|
@api.group("tls")
|
||||||
|
def api_tls():
|
||||||
|
"""Manage TLS certificates for HTTPS."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api_tls.command("generate")
|
||||||
|
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
|
||||||
|
@click.option("--days", default=365, help="Certificate validity in days")
|
||||||
|
@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)")
|
||||||
|
def api_tls_generate(hostname, days, output):
|
||||||
|
"""Generate self-signed TLS certificate.
|
||||||
|
|
||||||
|
Creates a certificate valid for:
|
||||||
|
- The specified hostname
|
||||||
|
- localhost / 127.0.0.1
|
||||||
|
- hostname.local (for mDNS)
|
||||||
|
- All detected local network IPs
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api tls generate
|
||||||
|
stegasoo api tls generate --hostname myserver --days 730
|
||||||
|
stegasoo api tls generate -o /etc/stegasoo/certs
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from web.ssl_utils import generate_self_signed_cert, get_cert_paths
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("Web frontend not available (ssl_utils required)")
|
||||||
|
|
||||||
|
if output:
|
||||||
|
base_dir = Path(output)
|
||||||
|
else:
|
||||||
|
base_dir = Path.home() / ".stegasoo"
|
||||||
|
|
||||||
|
click.echo(f"Generating TLS certificate for: {hostname}")
|
||||||
|
click.echo(f"Validity: {days} days")
|
||||||
|
|
||||||
|
cert_path, key_path = generate_self_signed_cert(base_dir, hostname, days)
|
||||||
|
|
||||||
|
click.echo(f"\nCertificate: {cert_path}")
|
||||||
|
click.echo(f"Private Key: {key_path}")
|
||||||
|
click.echo("\nTo use with the API:")
|
||||||
|
click.echo(f" uvicorn main:app --ssl-certfile {cert_path} --ssl-keyfile {key_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@api_tls.command("info")
|
||||||
|
@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)")
|
||||||
|
def api_tls_info(cert):
|
||||||
|
"""Show information about a TLS certificate.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api tls info
|
||||||
|
stegasoo api tls info --cert /path/to/server.crt
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
if not cert:
|
||||||
|
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
|
||||||
|
if not cert.exists():
|
||||||
|
raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate")
|
||||||
|
|
||||||
|
cert_data = Path(cert).read_bytes()
|
||||||
|
certificate = x509.load_pem_x509_certificate(cert_data)
|
||||||
|
|
||||||
|
click.echo(f"\nCertificate: {cert}")
|
||||||
|
click.echo("─" * 50)
|
||||||
|
click.echo(f"Subject: {certificate.subject.rfc4514_string()}")
|
||||||
|
click.echo(f"Issuer: {certificate.issuer.rfc4514_string()}")
|
||||||
|
click.echo(f"Serial: {certificate.serial_number}")
|
||||||
|
click.echo(f"Valid from: {certificate.not_valid_before_utc}")
|
||||||
|
click.echo(f"Valid until: {certificate.not_valid_after_utc}")
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
import datetime
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
if certificate.not_valid_after_utc < now:
|
||||||
|
click.echo("\nStatus: EXPIRED")
|
||||||
|
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
|
||||||
|
days_left = (certificate.not_valid_after_utc - now).days
|
||||||
|
click.echo(f"\nStatus: Expires in {days_left} days (consider renewal)")
|
||||||
|
else:
|
||||||
|
days_left = (certificate.not_valid_after_utc - now).days
|
||||||
|
click.echo(f"\nStatus: Valid ({days_left} days remaining)")
|
||||||
|
|
||||||
|
# Show SANs
|
||||||
|
try:
|
||||||
|
san_ext = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||||
|
click.echo("\nSubject Alternative Names:")
|
||||||
|
for name in san_ext.value:
|
||||||
|
click.echo(f" - {name.value}")
|
||||||
|
except x509.ExtensionNotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api.command("serve")
|
||||||
|
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
||||||
|
@click.option("--port", default=8000, help="Port to bind to")
|
||||||
|
@click.option("--ssl/--no-ssl", default=True, help="Enable/disable TLS")
|
||||||
|
@click.option("--cert", type=click.Path(exists=True), help="TLS certificate file")
|
||||||
|
@click.option("--key", type=click.Path(exists=True), help="TLS private key file")
|
||||||
|
@click.option("--reload", "do_reload", is_flag=True, help="Enable auto-reload for development")
|
||||||
|
def api_serve(host, port, ssl, cert, key, do_reload):
|
||||||
|
"""Start the REST API server.
|
||||||
|
|
||||||
|
By default starts with TLS using certificates from ~/.stegasoo/certs/.
|
||||||
|
If no certificates exist, they are generated automatically.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api serve
|
||||||
|
stegasoo api serve --host 0.0.0.0 --port 8443
|
||||||
|
stegasoo api serve --no-ssl
|
||||||
|
stegasoo api serve --cert /path/to/cert.pem --key /path/to/key.pem
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
# Determine cert paths
|
||||||
|
if ssl:
|
||||||
|
if cert and key:
|
||||||
|
cert_path, key_path = cert, key
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from web.ssl_utils import ensure_certs
|
||||||
|
base_dir = Path.home() / ".stegasoo"
|
||||||
|
cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost")
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("ssl_utils not available")
|
||||||
|
|
||||||
|
click.echo(f"Starting API server with TLS on https://{host}:{port}")
|
||||||
|
click.echo(f"Certificate: {cert_path}")
|
||||||
|
else:
|
||||||
|
cert_path = key_path = None
|
||||||
|
click.echo(f"Starting API server on http://{host}:{port}")
|
||||||
|
click.echo("WARNING: TLS disabled - connections are not encrypted!")
|
||||||
|
|
||||||
|
# Import and run uvicorn
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("uvicorn not installed. Install with: pip install uvicorn")
|
||||||
|
|
||||||
|
uvicorn_kwargs = {
|
||||||
|
"app": "api.main:app",
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"reload": do_reload,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ssl and cert_path and key_path:
|
||||||
|
uvicorn_kwargs["ssl_certfile"] = str(cert_path)
|
||||||
|
uvicorn_kwargs["ssl_keyfile"] = str(key_path)
|
||||||
|
|
||||||
|
uvicorn.run(**uvicorn_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Entry point for CLI."""
|
"""Entry point for CLI."""
|
||||||
cli(obj={})
|
cli(obj={})
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_LZ4 = False
|
HAS_LZ4 = False
|
||||||
|
|
||||||
|
# Optional ZSTD support (best ratio, fast)
|
||||||
|
try:
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
|
HAS_ZSTD = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_ZSTD = False
|
||||||
|
|
||||||
|
|
||||||
class CompressionAlgorithm(IntEnum):
|
class CompressionAlgorithm(IntEnum):
|
||||||
"""Supported compression algorithms."""
|
"""Supported compression algorithms."""
|
||||||
@@ -24,6 +32,7 @@ class CompressionAlgorithm(IntEnum):
|
|||||||
NONE = 0
|
NONE = 0
|
||||||
ZLIB = 1
|
ZLIB = 1
|
||||||
LZ4 = 2
|
LZ4 = 2
|
||||||
|
ZSTD = 3 # v4.2.0: Best ratio, fast compression
|
||||||
|
|
||||||
|
|
||||||
# Magic bytes for compressed payloads
|
# Magic bytes for compressed payloads
|
||||||
@@ -72,6 +81,15 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm
|
|||||||
algorithm = CompressionAlgorithm.ZLIB
|
algorithm = CompressionAlgorithm.ZLIB
|
||||||
else:
|
else:
|
||||||
compressed = lz4.frame.compress(data)
|
compressed = lz4.frame.compress(data)
|
||||||
|
|
||||||
|
elif algorithm == CompressionAlgorithm.ZSTD:
|
||||||
|
if not HAS_ZSTD:
|
||||||
|
# Fall back to zlib if ZSTD not available
|
||||||
|
compressed = zlib.compress(data, level=ZLIB_LEVEL)
|
||||||
|
algorithm = CompressionAlgorithm.ZLIB
|
||||||
|
else:
|
||||||
|
cctx = zstd.ZstdCompressor(level=19) # High compression level
|
||||||
|
compressed = cctx.compress(data)
|
||||||
else:
|
else:
|
||||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||||
|
|
||||||
@@ -123,6 +141,15 @@ def decompress(data: bytes) -> bytes:
|
|||||||
result = lz4.frame.decompress(compressed_data)
|
result = lz4.frame.decompress(compressed_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CompressionError(f"LZ4 decompression failed: {e}")
|
raise CompressionError(f"LZ4 decompression failed: {e}")
|
||||||
|
|
||||||
|
elif algorithm == CompressionAlgorithm.ZSTD:
|
||||||
|
if not HAS_ZSTD:
|
||||||
|
raise CompressionError("ZSTD compression used but zstandard package not installed")
|
||||||
|
try:
|
||||||
|
dctx = zstd.ZstdDecompressor()
|
||||||
|
result = dctx.decompress(compressed_data)
|
||||||
|
except Exception as e:
|
||||||
|
raise CompressionError(f"ZSTD decompression failed: {e}")
|
||||||
else:
|
else:
|
||||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||||
|
|
||||||
@@ -181,6 +208,9 @@ def estimate_compressed_size(
|
|||||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||||
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
|
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
|
||||||
compressed_sample = lz4.frame.compress(sample)
|
compressed_sample = lz4.frame.compress(sample)
|
||||||
|
elif algorithm == CompressionAlgorithm.ZSTD and HAS_ZSTD:
|
||||||
|
cctx = zstd.ZstdCompressor(level=19)
|
||||||
|
compressed_sample = cctx.compress(sample)
|
||||||
else:
|
else:
|
||||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||||
|
|
||||||
@@ -195,14 +225,24 @@ def get_available_algorithms() -> list[CompressionAlgorithm]:
|
|||||||
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
|
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
|
||||||
if HAS_LZ4:
|
if HAS_LZ4:
|
||||||
algorithms.append(CompressionAlgorithm.LZ4)
|
algorithms.append(CompressionAlgorithm.LZ4)
|
||||||
|
if HAS_ZSTD:
|
||||||
|
algorithms.append(CompressionAlgorithm.ZSTD)
|
||||||
return algorithms
|
return algorithms
|
||||||
|
|
||||||
|
|
||||||
|
def get_best_algorithm() -> CompressionAlgorithm:
|
||||||
|
"""Get the best available compression algorithm (prefer ZSTD > ZLIB > LZ4)."""
|
||||||
|
if HAS_ZSTD:
|
||||||
|
return CompressionAlgorithm.ZSTD
|
||||||
|
return CompressionAlgorithm.ZLIB
|
||||||
|
|
||||||
|
|
||||||
def algorithm_name(algo: CompressionAlgorithm) -> str:
|
def algorithm_name(algo: CompressionAlgorithm) -> str:
|
||||||
"""Get human-readable algorithm name."""
|
"""Get human-readable algorithm name."""
|
||||||
names = {
|
names = {
|
||||||
CompressionAlgorithm.NONE: "None",
|
CompressionAlgorithm.NONE: "None",
|
||||||
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
|
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
|
||||||
CompressionAlgorithm.LZ4: "LZ4 (fast)",
|
CompressionAlgorithm.LZ4: "LZ4 (fast)",
|
||||||
|
CompressionAlgorithm.ZSTD: "Zstd (best)",
|
||||||
}
|
}
|
||||||
return names.get(algo, "Unknown")
|
return names.get(algo, "Unknown")
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication)
|
Stegasoo Constants and Configuration (v4.2.0 - Performance & Compression)
|
||||||
|
|
||||||
Central location for all magic numbers, limits, and crypto parameters.
|
Central location for all magic numbers, limits, and crypto parameters.
|
||||||
All version numbers, limits, and configuration values should be defined here.
|
All version numbers, limits, and configuration values should be defined here.
|
||||||
|
|
||||||
|
CHANGES in v4.2.0:
|
||||||
|
- Added zstd compression for QR codes (better ratio than zlib)
|
||||||
|
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||||
|
- Progress bar improvements for encode/decode operations
|
||||||
|
- File auto-expire increased to 10 minutes
|
||||||
|
|
||||||
CHANGES in v4.0.2:
|
CHANGES in v4.0.2:
|
||||||
- Added Web UI authentication with SQLite3 user storage
|
- Added Web UI authentication with SQLite3 user storage
|
||||||
- Added optional HTTPS with auto-generated self-signed certificates
|
- Added optional HTTPS with auto-generated self-signed certificates
|
||||||
@@ -25,7 +31,7 @@ from pathlib import Path
|
|||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "4.1.5"
|
__version__ = "4.2.1"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
@@ -98,7 +104,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
|
|||||||
|
|
||||||
# RSA configuration
|
# RSA configuration
|
||||||
MIN_RSA_BITS = 2048
|
MIN_RSA_BITS = 2048
|
||||||
VALID_RSA_SIZES = (2048, 3072, 4096)
|
VALID_RSA_SIZES = (2048, 3072) # 4096 removed - too large for QR codes
|
||||||
DEFAULT_RSA_BITS = 2048
|
DEFAULT_RSA_BITS = 2048
|
||||||
|
|
||||||
MIN_KEY_PASSWORD_LENGTH = 8
|
MIN_KEY_PASSWORD_LENGTH = 8
|
||||||
@@ -108,8 +114,8 @@ MIN_KEY_PASSWORD_LENGTH = 8
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Temporary file storage
|
# Temporary file storage
|
||||||
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
|
TEMP_FILE_EXPIRY = 600 # 10 minutes in seconds
|
||||||
TEMP_FILE_EXPIRY_MINUTES = 5
|
TEMP_FILE_EXPIRY_MINUTES = 10
|
||||||
|
|
||||||
# Thumbnail settings
|
# Thumbnail settings
|
||||||
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
|
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Why is this cool?
|
|||||||
|
|
||||||
Two approaches depending on what you want:
|
Two approaches depending on what you want:
|
||||||
1. PNG output: We do our own DCT math via scipy (works on any image)
|
1. PNG output: We do our own DCT math via scipy (works on any image)
|
||||||
2. JPEG output: We use jpegio to directly tweak the coefficients (chef's kiss)
|
2. JPEG output: We use jpeglib to directly tweak the coefficients (chef's kiss)
|
||||||
|
|
||||||
v4.1.0 - The "please stop corrupting my data" release:
|
v4.1.0 - The "please stop corrupting my data" release:
|
||||||
- Reed-Solomon error correction (can fix up to 16 byte errors per chunk)
|
- Reed-Solomon error correction (can fix up to 16 byte errors per chunk)
|
||||||
@@ -24,7 +24,7 @@ v3.2.0-patch2 - The "scipy why are you like this" release:
|
|||||||
- Process blocks one at a time with fresh arrays
|
- Process blocks one at a time with fresh arrays
|
||||||
- Yes, it's slower. No, I don't care. Correctness > speed.
|
- Yes, it's slower. No, I don't care. Correctness > speed.
|
||||||
|
|
||||||
Requires: scipy (PNG mode), optionally jpegio (JPEG mode), reedsolo (error correction)
|
Requires: scipy (PNG mode), optionally jpeglib (JPEG mode), reedsolo (error correction)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
@@ -35,32 +35,35 @@ from dataclasses import dataclass
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
# Check for scipy availability (for PNG/DCT mode)
|
# Check for scipy availability (for PNG/DCT mode)
|
||||||
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
||||||
try:
|
try:
|
||||||
from scipy.fft import dct, idct
|
from scipy.fft import dct, idct, dctn, idctn
|
||||||
|
|
||||||
HAS_SCIPY = True
|
HAS_SCIPY = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from scipy.fftpack import dct, idct
|
from scipy.fftpack import dct, idct, dctn, idctn
|
||||||
|
|
||||||
HAS_SCIPY = True
|
HAS_SCIPY = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_SCIPY = False
|
HAS_SCIPY = False
|
||||||
dct = None
|
dct = None
|
||||||
idct = None
|
idct = None
|
||||||
|
dctn = None
|
||||||
|
idctn = None
|
||||||
|
|
||||||
# Check for jpegio availability (for proper JPEG mode)
|
# Check for jpeglib availability (for proper JPEG mode)
|
||||||
|
# jpeglib replaces jpegio for Python 3.13+ compatibility
|
||||||
try:
|
try:
|
||||||
import jpegio as jio
|
import jpeglib
|
||||||
|
|
||||||
HAS_JPEGIO = True
|
HAS_JPEGIO = True # Keep variable name for compatibility
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_JPEGIO = False
|
HAS_JPEGIO = False
|
||||||
jio = None
|
jpeglib = None
|
||||||
|
|
||||||
# Import custom exceptions
|
# Import custom exceptions
|
||||||
from .exceptions import InvalidMagicBytesError
|
from .exceptions import InvalidMagicBytesError
|
||||||
@@ -403,31 +406,72 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_exif_orientation(image_data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Apply EXIF orientation to image and return corrected bytes.
|
||||||
|
|
||||||
|
Portrait photos from cameras often have EXIF orientation metadata that
|
||||||
|
tells viewers to rotate the image for display. However, the raw pixel
|
||||||
|
data is stored in landscape orientation. This function applies that
|
||||||
|
rotation to the pixel data so the output matches what users expect.
|
||||||
|
|
||||||
|
Without this, a portrait photo encoded with DCT would come out rotated
|
||||||
|
90 degrees because we'd embed in the raw (landscape) orientation.
|
||||||
|
"""
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
original_format = img.format or "JPEG"
|
||||||
|
|
||||||
|
# Apply EXIF orientation (rotates/flips pixels to match EXIF tag)
|
||||||
|
# This also removes the EXIF orientation tag since it's now baked in
|
||||||
|
corrected = ImageOps.exif_transpose(img)
|
||||||
|
|
||||||
|
# If no change was needed, return original data unchanged
|
||||||
|
if corrected is img:
|
||||||
|
img.close()
|
||||||
|
return image_data
|
||||||
|
|
||||||
|
# Save corrected image back to bytes
|
||||||
|
output = io.BytesIO()
|
||||||
|
if original_format == "JPEG":
|
||||||
|
if corrected.mode in ("RGBA", "P"):
|
||||||
|
corrected = corrected.convert("RGB")
|
||||||
|
corrected.save(output, format="JPEG", quality=95)
|
||||||
|
else:
|
||||||
|
corrected.save(output, format="PNG")
|
||||||
|
|
||||||
|
img.close()
|
||||||
|
corrected.close()
|
||||||
|
output.seek(0)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _to_grayscale(image_data: bytes) -> np.ndarray:
|
def _to_grayscale(image_data: bytes) -> np.ndarray:
|
||||||
img = Image.open(io.BytesIO(image_data))
|
img = Image.open(io.BytesIO(image_data))
|
||||||
gray = img.convert("L")
|
gray = img.convert("L")
|
||||||
return np.array(gray, dtype=np.float64, copy=True, order="C")
|
return np.array(gray, dtype=np.float32, copy=True, order="C")
|
||||||
|
|
||||||
|
|
||||||
def _extract_y_channel(image_data: bytes) -> np.ndarray:
|
def _extract_y_channel(image_data: bytes) -> np.ndarray:
|
||||||
|
"""Extract Y (luminance) channel - float32 for memory efficiency."""
|
||||||
img = Image.open(io.BytesIO(image_data))
|
img = Image.open(io.BytesIO(image_data))
|
||||||
if img.mode != "RGB":
|
if img.mode != "RGB":
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
|
||||||
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
|
rgb = np.array(img, dtype=np.float32, copy=True, order="C")
|
||||||
Y = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]
|
Y = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]
|
||||||
return np.array(Y, dtype=np.float64, copy=True, order="C")
|
return np.array(Y, dtype=np.float32, copy=True, order="C")
|
||||||
|
|
||||||
|
|
||||||
def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
|
def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
|
||||||
|
"""Pad image to block boundaries - uses float32 for memory efficiency."""
|
||||||
h, w = image.shape
|
h, w = image.shape
|
||||||
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
|
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
|
||||||
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
|
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
|
||||||
|
|
||||||
if new_h == h and new_w == w:
|
if new_h == h and new_w == w:
|
||||||
return np.array(image, dtype=np.float64, copy=True, order="C"), (h, w)
|
return np.array(image, dtype=np.float32, copy=True, order="C"), (h, w)
|
||||||
|
|
||||||
padded = np.zeros((new_h, new_w), dtype=np.float64, order="C")
|
padded = np.zeros((new_h, new_w), dtype=np.float32, order="C")
|
||||||
padded[:h, :w] = image
|
padded[:h, :w] = image
|
||||||
|
|
||||||
# Simple edge replication for padding
|
# Simple edge replication for padding
|
||||||
@@ -444,8 +488,9 @@ def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]:
|
|||||||
|
|
||||||
|
|
||||||
def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray:
|
def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray:
|
||||||
|
"""Remove padding - uses float32 for memory efficiency."""
|
||||||
h, w = original_size
|
h, w = original_size
|
||||||
return np.array(image[:h, :w], dtype=np.float64, copy=True, order="C")
|
return np.array(image[:h, :w], dtype=np.float32, copy=True, order="C")
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -543,20 +588,23 @@ def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|||||||
- Cb/Cr are often subsampled (4:2:0) so Y has more capacity anyway
|
- 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.
|
The coefficients here are from ITU-R BT.601 - the standard for video.
|
||||||
|
|
||||||
|
Uses float32 to reduce memory usage (~50% savings vs float64).
|
||||||
"""
|
"""
|
||||||
R = rgb[:, :, 0].astype(np.float64)
|
# Use float32 - sufficient precision for 8-bit images, halves memory
|
||||||
G = rgb[:, :, 1].astype(np.float64)
|
R = rgb[:, :, 0].astype(np.float32)
|
||||||
B = rgb[:, :, 2].astype(np.float64)
|
G = rgb[:, :, 1].astype(np.float32)
|
||||||
|
B = rgb[:, :, 2].astype(np.float32)
|
||||||
|
|
||||||
# Y = luminance (brightness). Green contributes most because eyes are most sensitive to it.
|
# 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.float32, copy=True, order="C")
|
||||||
# Cb = blue-difference chroma (centered at 128)
|
# 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.float32, copy=True, order="C"
|
||||||
)
|
)
|
||||||
# Cr = red-difference chroma (centered at 128)
|
# 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.float32, copy=True, order="C"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Y, Cb, Cr
|
return Y, Cb, Cr
|
||||||
@@ -569,11 +617,12 @@ def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
|
|||||||
After embedding in the Y channel, we need to reconstruct RGB for display.
|
After embedding in the Y channel, we need to reconstruct RGB for display.
|
||||||
The Cb/Cr channels are unchanged - we only touched luminance.
|
The Cb/Cr channels are unchanged - we only touched luminance.
|
||||||
"""
|
"""
|
||||||
|
# Use float32 for memory efficiency
|
||||||
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)
|
||||||
|
|
||||||
rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float64, order="C")
|
rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float32, order="C")
|
||||||
rgb[:, :, 0] = R
|
rgb[:, :, 0] = R
|
||||||
rgb[:, :, 1] = G
|
rgb[:, :, 1] = G
|
||||||
rgb[:, :, 2] = B
|
rgb[:, :, 2] = B
|
||||||
@@ -733,7 +782,7 @@ def estimate_capacity_comparison(image_data: bytes) -> dict:
|
|||||||
},
|
},
|
||||||
"jpeg_native": {
|
"jpeg_native": {
|
||||||
"available": HAS_JPEGIO,
|
"available": HAS_JPEGIO,
|
||||||
"note": "Uses jpegio for proper JPEG coefficient embedding",
|
"note": "Uses jpeglib for proper JPEG coefficient embedding",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,6 +802,10 @@ def embed_in_dct(
|
|||||||
if color_mode not in ("color", "grayscale"):
|
if color_mode not in ("color", "grayscale"):
|
||||||
color_mode = "color"
|
color_mode = "color"
|
||||||
|
|
||||||
|
# Apply EXIF orientation to carrier image before embedding
|
||||||
|
# This ensures portrait photos are embedded in their correct visual orientation
|
||||||
|
carrier_image = _apply_exif_orientation(carrier_image)
|
||||||
|
|
||||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||||
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||||
|
|
||||||
@@ -818,8 +871,8 @@ def _embed_scipy_dct_safe(
|
|||||||
if img.mode == "RGBA":
|
if img.mode == "RGBA":
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
|
||||||
# Process color image
|
# Process color image (float32 for memory efficiency)
|
||||||
rgb = np.array(img, dtype=np.float64, copy=True, order="C")
|
rgb = np.array(img, dtype=np.float32, copy=True, order="C")
|
||||||
img.close()
|
img.close()
|
||||||
|
|
||||||
Y, Cb, Cr = _rgb_to_ycbcr(rgb)
|
Y, Cb, Cr = _rgb_to_ycbcr(rgb)
|
||||||
@@ -891,61 +944,105 @@ def _embed_in_channel_safe(
|
|||||||
progress_file: str | None = None,
|
progress_file: str | None = None,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Embed bits in channel using safe DCT operations.
|
Embed bits in channel using vectorized DCT operations.
|
||||||
|
|
||||||
Processes one block at a time with fresh array allocations.
|
Processes blocks in batches for ~10x speedup over sequential processing.
|
||||||
"""
|
"""
|
||||||
h, w = channel.shape
|
h, w = channel.shape
|
||||||
|
|
||||||
# Create result with explicit new memory
|
# Create result with explicit new memory (float32 for memory efficiency)
|
||||||
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
result = np.array(channel, dtype=np.float32, copy=True, order="C")
|
||||||
|
|
||||||
|
# Pre-compute embed positions as numpy indices
|
||||||
|
embed_rows = np.array([pos[0] for pos in DEFAULT_EMBED_POSITIONS])
|
||||||
|
embed_cols = np.array([pos[1] for pos in DEFAULT_EMBED_POSITIONS])
|
||||||
|
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
|
||||||
|
|
||||||
|
# Calculate how many blocks we need
|
||||||
|
total_bits = len(bits)
|
||||||
|
blocks_needed = (total_bits + bits_per_block - 1) // bits_per_block
|
||||||
|
blocks_to_process = min(blocks_needed, len(block_order))
|
||||||
|
|
||||||
|
# Initial progress write - signals Argon2/prep is done, embedding starting
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, 5, 100, "embedding")
|
||||||
|
|
||||||
|
# Vectorized embedding: process blocks in batches
|
||||||
|
BATCH_SIZE = 500
|
||||||
bit_idx = 0
|
bit_idx = 0
|
||||||
total_blocks = len(block_order)
|
block_idx = 0
|
||||||
|
|
||||||
for block_idx, block_num in enumerate(block_order):
|
while block_idx < blocks_to_process and bit_idx < total_bits:
|
||||||
if bit_idx >= len(bits):
|
# Determine batch size
|
||||||
break
|
batch_end = min(block_idx + BATCH_SIZE, blocks_to_process)
|
||||||
|
batch_order = block_order[block_idx:batch_end]
|
||||||
|
batch_count = len(batch_order)
|
||||||
|
|
||||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
# Extract blocks into 3D array (float32 for memory efficiency)
|
||||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
blocks = np.zeros((batch_count, BLOCK_SIZE, BLOCK_SIZE), dtype=np.float32)
|
||||||
|
block_positions = []
|
||||||
|
for i, block_num in enumerate(batch_order):
|
||||||
|
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||||
|
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||||
|
blocks[i] = result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE]
|
||||||
|
block_positions.append((by, bx))
|
||||||
|
|
||||||
# Extract block - create brand new array
|
# Vectorized 2D DCT on all blocks at once
|
||||||
block = np.array(
|
dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
|
||||||
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
|
|
||||||
dtype=np.float64,
|
|
||||||
copy=True,
|
|
||||||
order="C",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply safe DCT (row-by-row)
|
# Embed bits in each block (vectorized where possible)
|
||||||
dct_block = _safe_dct2(block)
|
for i in range(batch_count):
|
||||||
|
if bit_idx >= total_bits:
|
||||||
# Embed bits
|
|
||||||
for pos in DEFAULT_EMBED_POSITIONS:
|
|
||||||
if bit_idx >= len(bits):
|
|
||||||
break
|
break
|
||||||
dct_block[pos[0], pos[1]] = _embed_bit_in_coeff(
|
|
||||||
float(dct_block[pos[0], pos[1]]), bits[bit_idx]
|
|
||||||
)
|
|
||||||
bit_idx += 1
|
|
||||||
|
|
||||||
# Apply safe inverse DCT
|
# Get bits for this block
|
||||||
modified_block = _safe_idct2(dct_block)
|
block_bits = bits[bit_idx : bit_idx + bits_per_block]
|
||||||
|
num_bits = len(block_bits)
|
||||||
|
|
||||||
# Copy back
|
if num_bits == bits_per_block:
|
||||||
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_block
|
# Full block - vectorized embedding
|
||||||
|
coeffs = dct_blocks[i, embed_rows, embed_cols]
|
||||||
|
bit_array = np.array(block_bits)
|
||||||
|
# QIM embedding: round to grid, adjust for bit
|
||||||
|
quantized = np.round(coeffs / QUANT_STEP).astype(int)
|
||||||
|
# If quantized % 2 != bit, nudge coefficient
|
||||||
|
needs_adjust = (quantized % 2) != bit_array
|
||||||
|
# Determine direction to nudge
|
||||||
|
dct_blocks[i, embed_rows[needs_adjust], embed_cols[needs_adjust]] = (
|
||||||
|
(quantized[needs_adjust] + (1 - 2 * (quantized[needs_adjust] % 2 == 1))) * QUANT_STEP
|
||||||
|
).astype(np.float64)
|
||||||
|
# For bits that already match, just quantize
|
||||||
|
dct_blocks[i, embed_rows[~needs_adjust], embed_cols[~needs_adjust]] = (
|
||||||
|
quantized[~needs_adjust] * QUANT_STEP
|
||||||
|
).astype(np.float64)
|
||||||
|
else:
|
||||||
|
# Partial block - process remaining bits individually
|
||||||
|
for j, bit in enumerate(block_bits):
|
||||||
|
row, col = embed_rows[j], embed_cols[j]
|
||||||
|
dct_blocks[i, row, col] = _embed_bit_in_coeff(
|
||||||
|
float(dct_blocks[i, row, col]), bit
|
||||||
|
)
|
||||||
|
|
||||||
# Clean up this iteration
|
bit_idx += num_bits
|
||||||
del block, dct_block, modified_block
|
|
||||||
|
# Vectorized inverse DCT
|
||||||
|
modified_blocks = idctn(dct_blocks, axes=(1, 2), norm="ortho")
|
||||||
|
|
||||||
|
# Copy modified blocks back to result
|
||||||
|
for i, (by, bx) in enumerate(block_positions):
|
||||||
|
result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_blocks[i]
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del blocks, dct_blocks, modified_blocks
|
||||||
|
block_idx = batch_end
|
||||||
|
|
||||||
# Report progress periodically
|
# Report progress periodically
|
||||||
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
|
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
|
||||||
_write_progress(progress_file, block_idx, total_blocks, "embedding")
|
_write_progress(progress_file, block_idx, blocks_to_process, "embedding")
|
||||||
|
|
||||||
# Final progress update
|
# Final progress update
|
||||||
if progress_file:
|
if progress_file:
|
||||||
_write_progress(progress_file, total_blocks, total_blocks, "finalizing")
|
_write_progress(progress_file, blocks_to_process, blocks_to_process, "finalizing")
|
||||||
|
|
||||||
# Force garbage collection
|
# Force garbage collection
|
||||||
gc.collect()
|
gc.collect()
|
||||||
@@ -1029,7 +1126,7 @@ def _embed_jpegio(
|
|||||||
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
|
flags = FLAG_COLOR_MODE if color_mode == "color" else 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jpeg = jio.read(input_path)
|
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(input_path))
|
||||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||||
|
|
||||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||||
@@ -1064,6 +1161,10 @@ def _embed_jpegio(
|
|||||||
total_bits = len(bits)
|
total_bits = len(bits)
|
||||||
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
|
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
|
||||||
|
|
||||||
|
# Initial progress write - signals prep is done, embedding starting
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, 5, 100, "embedding")
|
||||||
|
|
||||||
for bit_idx, pos_idx in enumerate(order):
|
for bit_idx, pos_idx in enumerate(order):
|
||||||
if bit_idx >= len(bits):
|
if bit_idx >= len(bits):
|
||||||
break
|
break
|
||||||
@@ -1087,7 +1188,7 @@ def _embed_jpegio(
|
|||||||
if progress_file:
|
if progress_file:
|
||||||
_write_progress(progress_file, total_bits, total_bits, "saving")
|
_write_progress(progress_file, total_bits, total_bits, "saving")
|
||||||
|
|
||||||
jio.write(jpeg, output_path)
|
jpeg.write(output_path)
|
||||||
|
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
stego_bytes = f.read()
|
stego_bytes = f.read()
|
||||||
@@ -1115,24 +1216,261 @@ def _embed_jpegio(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes:
|
def _jpegtran_available() -> bool:
|
||||||
"""Extract data from DCT stego image."""
|
"""Check if jpegtran is available on the system."""
|
||||||
img = Image.open(io.BytesIO(stego_image))
|
import shutil
|
||||||
fmt = img.format
|
return shutil.which("jpegtran") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Losslessly rotate a JPEG using jpegtran.
|
||||||
|
|
||||||
|
This preserves DCT coefficients by rearranging blocks rather than
|
||||||
|
re-encoding. Essential for rotating stego images without destroying
|
||||||
|
the hidden data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_data: JPEG image bytes
|
||||||
|
rotation: Degrees clockwise (90, 180, or 270)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rotated JPEG bytes with DCT coefficients preserved
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
if rotation not in (90, 180, 270):
|
||||||
|
raise ValueError(f"Invalid rotation: {rotation}")
|
||||||
|
|
||||||
|
# Write input to temp file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(image_data)
|
||||||
|
input_path = f.name
|
||||||
|
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# jpegtran -rotate 90|180|270 -copy all
|
||||||
|
# -copy all: preserve all metadata
|
||||||
|
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
|
||||||
|
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"jpegtran failed: {result.stderr.decode()}")
|
||||||
|
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
for path in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_image_bytes(image_data: bytes, rotation: int, lossless: bool = True) -> bytes:
|
||||||
|
"""
|
||||||
|
Rotate image by 90, 180, or 270 degrees and return as bytes.
|
||||||
|
|
||||||
|
For JPEGs with lossless=True (default), uses jpegtran to preserve DCT
|
||||||
|
coefficients. This is essential for rotating stego images.
|
||||||
|
|
||||||
|
For PNGs or when jpegtran is unavailable, uses PIL (which re-encodes
|
||||||
|
but PNGs are lossless anyway).
|
||||||
|
"""
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
original_format = img.format or "PNG"
|
||||||
img.close()
|
img.close()
|
||||||
|
|
||||||
if fmt == "JPEG" and HAS_JPEGIO:
|
# Use jpegtran for lossless JPEG rotation
|
||||||
|
if lossless and original_format == "JPEG" and _jpegtran_available():
|
||||||
|
return _jpegtran_rotate(image_data, rotation)
|
||||||
|
|
||||||
|
# Fallback to PIL for PNGs or when jpegtran unavailable
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# PIL rotation is counter-clockwise, we want clockwise
|
||||||
|
# 90 CW = 270 CCW, 180 = 180, 270 CW = 90 CCW
|
||||||
|
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation]
|
||||||
|
rotated = img.rotate(pil_rotation, expand=True)
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
# Save in original format if possible, fallback to PNG
|
||||||
|
save_format = original_format if original_format in ("JPEG", "PNG") else "PNG"
|
||||||
|
if save_format == "JPEG":
|
||||||
|
rotated.save(output, format="JPEG", quality=95)
|
||||||
|
else:
|
||||||
|
rotated.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
|
||||||
|
"""
|
||||||
|
Quick validation that only extracts enough DCT data to check magic bytes.
|
||||||
|
Returns True if header looks valid, False otherwise.
|
||||||
|
|
||||||
|
This is much faster than full extraction - only processes first ~8 blocks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert to grayscale for quick check
|
||||||
|
gray = _to_grayscale(image_data)
|
||||||
|
height, width = gray.shape
|
||||||
|
padded, _ = _pad_to_blocks(gray)
|
||||||
|
padded_h, padded_w = padded.shape
|
||||||
|
blocks_x = padded_w // BLOCK_SIZE
|
||||||
|
num_blocks = (padded_h // BLOCK_SIZE) * blocks_x
|
||||||
|
|
||||||
|
# Generate block order
|
||||||
|
block_order = _generate_block_order(num_blocks, seed)
|
||||||
|
|
||||||
|
# Only extract first 8 blocks (enough for RS length prefix + header)
|
||||||
|
# 8 blocks * 16 bits/block = 128 bits = 16 bytes (covers RS prefix)
|
||||||
|
blocks_needed = min(8, len(block_order))
|
||||||
|
|
||||||
|
all_bits = []
|
||||||
|
for block_num in block_order[:blocks_needed]:
|
||||||
|
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||||
|
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||||
|
block = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE].astype(np.float32)
|
||||||
|
|
||||||
|
dct_block = dctn(block, norm="ortho")
|
||||||
|
|
||||||
|
for row, col in EMBED_POSITIONS:
|
||||||
|
coef = dct_block[row, col]
|
||||||
|
bit = _extract_bit_from_coeff(coef)
|
||||||
|
all_bits.append(bit)
|
||||||
|
|
||||||
|
# Check RS format first (3 copies of 8-byte length header)
|
||||||
|
if len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||||
|
length_prefix_bits = all_bits[: RS_LENGTH_PREFIX_SIZE * 8]
|
||||||
|
length_prefix_bytes = bytes(
|
||||||
|
[
|
||||||
|
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
for i in range(RS_LENGTH_PREFIX_SIZE)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if 2+ copies match (indicates valid RS format)
|
||||||
|
copies = []
|
||||||
|
for i in range(RS_LENGTH_COPIES):
|
||||||
|
start = i * RS_LENGTH_HEADER_SIZE
|
||||||
|
end = start + RS_LENGTH_HEADER_SIZE
|
||||||
|
copies.append(length_prefix_bytes[start:end])
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
counter = Counter(copies)
|
||||||
|
_, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
|
if count >= 2:
|
||||||
|
return True # Looks like valid RS format
|
||||||
|
|
||||||
|
# Check legacy format (magic bytes in first 10 bytes)
|
||||||
|
if len(all_bits) >= HEADER_SIZE * 8:
|
||||||
|
try:
|
||||||
|
_parse_header(all_bits[: HEADER_SIZE * 8])
|
||||||
|
return True # Magic bytes matched
|
||||||
|
except (ValueError, InvalidMagicBytesError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_from_dct(
|
||||||
|
stego_image: bytes,
|
||||||
|
seed: bytes,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Extract data from DCT stego image.
|
||||||
|
|
||||||
|
If extraction fails with InvalidMagicBytesError, automatically tries
|
||||||
|
90°, 180°, and 270° rotations to handle images that were rotated after
|
||||||
|
encoding (e.g., by external tools or EXIF orientation changes).
|
||||||
|
|
||||||
|
Uses quick header validation to skip obviously invalid rotations.
|
||||||
|
"""
|
||||||
|
rotations_to_try = [0, 90, 180, 270]
|
||||||
|
last_error = None
|
||||||
|
valid_rotations = []
|
||||||
|
|
||||||
|
# Phase 1: Quick validation to find candidate rotations
|
||||||
|
for rotation in rotations_to_try:
|
||||||
|
if rotation == 0:
|
||||||
|
image_to_check = stego_image
|
||||||
|
else:
|
||||||
|
image_to_check = _rotate_image_bytes(stego_image, rotation)
|
||||||
|
|
||||||
|
if _quick_validate_dct_header(image_to_check, seed):
|
||||||
|
valid_rotations.append((rotation, image_to_check))
|
||||||
|
|
||||||
|
# If no rotations pass quick check, try all anyway (fallback)
|
||||||
|
if not valid_rotations:
|
||||||
|
# Must try all rotations - quick validation might have failed due to
|
||||||
|
# scipy vs jpegio differences or other edge cases
|
||||||
|
for rotation in rotations_to_try:
|
||||||
|
if rotation == 0:
|
||||||
|
valid_rotations.append((0, stego_image))
|
||||||
|
else:
|
||||||
|
valid_rotations.append((rotation, _rotate_image_bytes(stego_image, rotation)))
|
||||||
|
|
||||||
|
# Phase 2: Full extraction on valid candidates
|
||||||
|
for rotation, image_to_decode in valid_rotations:
|
||||||
try:
|
try:
|
||||||
return _extract_jpegio(stego_image, seed)
|
img = Image.open(io.BytesIO(image_to_decode))
|
||||||
except ValueError:
|
fmt = img.format
|
||||||
pass
|
img.close()
|
||||||
|
|
||||||
_check_scipy()
|
if fmt == "JPEG" and HAS_JPEGIO:
|
||||||
return _extract_scipy_dct_safe(stego_image, seed)
|
try:
|
||||||
|
result = _extract_jpegio(image_to_decode, seed, progress_file)
|
||||||
|
if rotation != 0:
|
||||||
|
try:
|
||||||
|
from . import debug
|
||||||
|
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let debug logging break extraction
|
||||||
|
return result
|
||||||
|
except (ValueError, InvalidMagicBytesError) as e:
|
||||||
|
last_error = e if isinstance(e, InvalidMagicBytesError) else last_error
|
||||||
|
continue
|
||||||
|
|
||||||
|
_check_scipy()
|
||||||
|
result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file)
|
||||||
|
if rotation != 0:
|
||||||
|
try:
|
||||||
|
from . import debug
|
||||||
|
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let debug logging break extraction
|
||||||
|
return result
|
||||||
|
|
||||||
|
except InvalidMagicBytesError as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All rotations failed
|
||||||
|
raise last_error or InvalidMagicBytesError("Not a Stegasoo image (tried all rotations)")
|
||||||
|
|
||||||
|
|
||||||
def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
def _extract_scipy_dct_safe(
|
||||||
"""Extract using safe DCT operations."""
|
stego_image: bytes,
|
||||||
|
seed: bytes,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Extract using safe DCT operations with vectorized processing."""
|
||||||
|
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
|
||||||
|
|
||||||
img = Image.open(io.BytesIO(stego_image))
|
img = Image.open(io.BytesIO(stego_image))
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
mode = img.mode
|
mode = img.mode
|
||||||
@@ -1156,26 +1494,54 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
block_order = _generate_block_order(num_blocks, seed)
|
block_order = _generate_block_order(num_blocks, seed)
|
||||||
|
|
||||||
|
# Vectorized extraction: process blocks in batches for ~10x speedup
|
||||||
|
# Batch size balances memory usage vs. parallelization benefit
|
||||||
|
BATCH_SIZE = 500
|
||||||
all_bits = []
|
all_bits = []
|
||||||
|
|
||||||
for block_num in block_order:
|
# Pre-compute embed positions as numpy indices for vectorized access
|
||||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
embed_rows = np.array([pos[0] for pos in DEFAULT_EMBED_POSITIONS])
|
||||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
embed_cols = np.array([pos[1] for pos in DEFAULT_EMBED_POSITIONS])
|
||||||
|
|
||||||
block = np.array(
|
# Progress reporting interval - report frequently for responsive UI
|
||||||
padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE],
|
PROGRESS_INTERVAL = 500 # Report every N blocks (matches BATCH_SIZE)
|
||||||
dtype=np.float64,
|
|
||||||
copy=True,
|
|
||||||
order="C",
|
|
||||||
)
|
|
||||||
dct_block = _safe_dct2(block)
|
|
||||||
|
|
||||||
for pos in DEFAULT_EMBED_POSITIONS:
|
block_idx = 0
|
||||||
bit = _extract_bit_from_coeff(float(dct_block[pos[0], pos[1]]))
|
while block_idx < len(block_order):
|
||||||
all_bits.append(bit)
|
# Determine batch size (may be smaller at end)
|
||||||
|
batch_end = min(block_idx + BATCH_SIZE, len(block_order))
|
||||||
|
batch_order = block_order[block_idx:batch_end]
|
||||||
|
batch_count = len(batch_order)
|
||||||
|
|
||||||
del block, dct_block
|
# Extract blocks into 3D array (batch_count, 8, 8) - float32 for memory efficiency
|
||||||
|
blocks = np.zeros((batch_count, BLOCK_SIZE, BLOCK_SIZE), dtype=np.float32)
|
||||||
|
for i, block_num in enumerate(batch_order):
|
||||||
|
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||||
|
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||||
|
blocks[i] = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE]
|
||||||
|
|
||||||
|
# Vectorized 2D DCT on all blocks at once (~10-15x faster than sequential)
|
||||||
|
dct_blocks = dctn(blocks, axes=(1, 2), norm="ortho")
|
||||||
|
|
||||||
|
# Extract bits from embed positions (vectorized)
|
||||||
|
# Shape: (batch_count, num_positions)
|
||||||
|
coeffs = dct_blocks[:, embed_rows, embed_cols]
|
||||||
|
|
||||||
|
# Quantize and extract bits (vectorized)
|
||||||
|
quantized = np.round(coeffs / QUANT_STEP).astype(int)
|
||||||
|
bits = (quantized % 2).flatten().tolist()
|
||||||
|
all_bits.extend(bits)
|
||||||
|
|
||||||
|
del blocks, dct_blocks, coeffs, quantized
|
||||||
|
block_idx = batch_end
|
||||||
|
|
||||||
|
# Report progress (scale to 25-70% range, RS decode gets 70-100%)
|
||||||
|
# Starts at 25% because decode.py writes 25% before calling extraction
|
||||||
|
if progress_file and block_idx % PROGRESS_INTERVAL < BATCH_SIZE:
|
||||||
|
extract_pct = 25 + int(45 * block_idx / num_blocks)
|
||||||
|
_write_progress(progress_file, extract_pct, 100, "extracting")
|
||||||
|
|
||||||
|
# Check if we have enough bits (early exit)
|
||||||
if len(all_bits) >= HEADER_SIZE * 8:
|
if len(all_bits) >= HEADER_SIZE * 8:
|
||||||
try:
|
try:
|
||||||
_, flags, data_length = _parse_header(all_bits[: HEADER_SIZE * 8])
|
_, flags, data_length = _parse_header(all_bits[: HEADER_SIZE * 8])
|
||||||
@@ -1188,6 +1554,9 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
del padded
|
del padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
# Extraction done, RS decode starts at 70%
|
||||||
|
_write_progress(progress_file, 70, 100, "decoding")
|
||||||
|
|
||||||
# Try RS-protected format first (has 24-byte length prefix: 3 copies of 8-byte header)
|
# Try RS-protected format first (has 24-byte length prefix: 3 copies of 8-byte header)
|
||||||
if HAS_REEDSOLO and len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
|
if HAS_REEDSOLO and len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||||
# Extract length prefix (24 bytes: 3 copies of 8-byte header for majority voting)
|
# Extract length prefix (24 bytes: 3 copies of 8-byte header for majority voting)
|
||||||
@@ -1240,10 +1609,16 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 75% - bits converted, starting RS decode (slow part)
|
||||||
|
_write_progress(progress_file, 75, 100, "decoding")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# RS decode to get header + data
|
# RS decode to get header + data
|
||||||
raw_payload = _rs_decode(rs_encoded)
|
raw_payload = _rs_decode(rs_encoded)
|
||||||
|
|
||||||
|
# 95% - RS decode done
|
||||||
|
_write_progress(progress_file, 95, 100, "decoding")
|
||||||
|
|
||||||
# Parse header from decoded payload
|
# Parse header from decoded payload
|
||||||
_, flags, data_length = _parse_header(
|
_, flags, data_length = _parse_header(
|
||||||
[((raw_payload[i // 8] >> (7 - i % 8)) & 1) for i in range(HEADER_SIZE * 8)]
|
[((raw_payload[i // 8] >> (7 - i % 8)) & 1) for i in range(HEADER_SIZE * 8)]
|
||||||
@@ -1251,6 +1626,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Extract data
|
# Extract data
|
||||||
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||||
|
_write_progress(progress_file, 100, 100, "complete")
|
||||||
return data
|
return data
|
||||||
except (ValueError, struct.error):
|
except (ValueError, struct.error):
|
||||||
pass # Fall through to legacy format
|
pass # Fall through to legacy format
|
||||||
@@ -1266,13 +1642,20 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_write_progress(progress_file, 100, 100, "complete")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
def _extract_jpegio(
|
||||||
|
stego_image: bytes,
|
||||||
|
seed: bytes,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
"""Extract using jpegio for JPEG images."""
|
"""Extract using jpegio for JPEG images."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# Progress starts at 25% (decode.py writes 20% for Argon2, 25% before extraction)
|
||||||
|
|
||||||
# Normalize JPEG to avoid crashes with quality=100 images
|
# Normalize JPEG to avoid crashes with quality=100 images
|
||||||
# (shouldn't happen with stego images, but be defensive)
|
# (shouldn't happen with stego images, but be defensive)
|
||||||
stego_image = _normalize_jpeg_for_jpegio(stego_image)
|
stego_image = _normalize_jpeg_for_jpegio(stego_image)
|
||||||
@@ -1280,12 +1663,14 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg")
|
temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jpeg = jio.read(temp_path)
|
jpeg = jpeglib.to_jpegio(jpeglib.read_dct(temp_path))
|
||||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||||
|
|
||||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||||
order = _jpegio_generate_order(len(all_positions), seed)
|
order = _jpegio_generate_order(len(all_positions), seed)
|
||||||
|
|
||||||
|
_write_progress(progress_file, 30, 100, "extracting")
|
||||||
|
|
||||||
# Try RS-protected format first (has 24-byte length prefix: 3 copies for majority voting)
|
# Try RS-protected format first (has 24-byte length prefix: 3 copies for majority voting)
|
||||||
if HAS_REEDSOLO and len(all_positions) >= RS_LENGTH_PREFIX_SIZE * 8:
|
if HAS_REEDSOLO and len(all_positions) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||||
# Extract length prefix (24 bytes: 3 copies of 8-byte header)
|
# Extract length prefix (24 bytes: 3 copies of 8-byte header)
|
||||||
@@ -1349,9 +1734,12 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_write_progress(progress_file, 75, 100, "decoding")
|
||||||
raw_payload = _rs_decode(rs_encoded)
|
raw_payload = _rs_decode(rs_encoded)
|
||||||
|
_write_progress(progress_file, 95, 100, "decoding")
|
||||||
_, flags, data_length = _jpegio_parse_header(raw_payload[:HEADER_SIZE])
|
_, flags, data_length = _jpegio_parse_header(raw_payload[:HEADER_SIZE])
|
||||||
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
data = raw_payload[HEADER_SIZE : HEADER_SIZE + data_length]
|
||||||
|
_write_progress(progress_file, 100, 100, "complete")
|
||||||
return data
|
return data
|
||||||
except (ValueError, struct.error):
|
except (ValueError, struct.error):
|
||||||
pass # Fall through to legacy format
|
pass # Fall through to legacy format
|
||||||
@@ -1389,6 +1777,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_write_progress(progress_file, 100, 100, "complete")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Changes in v4.0.0:
|
|||||||
- Improved error messages for channel key mismatches
|
- Improved error messages for channel key mismatches
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .constants import EMBED_MODE_AUTO
|
from .constants import EMBED_MODE_AUTO
|
||||||
@@ -24,6 +25,22 @@ from .validation import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(progress_file: str | None, current: int, total: int, phase: str) -> None:
|
||||||
|
"""Write progress to file for UI polling."""
|
||||||
|
if progress_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(progress_file, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": (current / total * 100) if total > 0 else 0,
|
||||||
|
"phase": phase,
|
||||||
|
}, f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def decode(
|
def decode(
|
||||||
stego_image: bytes,
|
stego_image: bytes,
|
||||||
reference_photo: bytes,
|
reference_photo: bytes,
|
||||||
@@ -33,6 +50,7 @@ def decode(
|
|||||||
rsa_password: str | None = None,
|
rsa_password: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
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.
|
||||||
@@ -45,6 +63,7 @@ def decode(
|
|||||||
rsa_key_data: Optional RSA key bytes (if used during encoding)
|
rsa_key_data: Optional RSA key bytes (if used during encoding)
|
||||||
rsa_password: Optional RSA key password
|
rsa_password: Optional RSA key password
|
||||||
embed_mode: 'auto' (default), 'lsb', or 'dct'
|
embed_mode: 'auto' (default), 'lsb', or 'dct'
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
channel_key: Channel key for deployment/group isolation:
|
channel_key: Channel key for deployment/group isolation:
|
||||||
- None or "auto": Use server's configured key
|
- None or "auto": Use server's configured key
|
||||||
- str: Use this specific channel key
|
- str: Use this specific channel key
|
||||||
@@ -91,16 +110,23 @@ def decode(
|
|||||||
if rsa_key_data:
|
if rsa_key_data:
|
||||||
require_valid_rsa_key(rsa_key_data, rsa_password)
|
require_valid_rsa_key(rsa_key_data, rsa_password)
|
||||||
|
|
||||||
|
# Progress: starting key derivation (Argon2 - slow on Pi)
|
||||||
|
_write_progress(progress_file, 20, 100, "initializing")
|
||||||
|
|
||||||
# Derive pixel/coefficient selection key (with channel key)
|
# Derive pixel/coefficient selection key (with channel key)
|
||||||
from .crypto import derive_pixel_key
|
from .crypto import derive_pixel_key
|
||||||
|
|
||||||
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key)
|
||||||
|
|
||||||
|
# Progress: key derivation done, starting extraction
|
||||||
|
_write_progress(progress_file, 25, 100, "extracting")
|
||||||
|
|
||||||
# Extract encrypted data
|
# Extract encrypted data
|
||||||
encrypted = extract_from_image(
|
encrypted = extract_from_image(
|
||||||
stego_image,
|
stego_image,
|
||||||
pixel_key,
|
pixel_key,
|
||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
|
progress_file=progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not encrypted:
|
if not encrypted:
|
||||||
@@ -126,6 +152,7 @@ def decode_file(
|
|||||||
rsa_password: str | None = None,
|
rsa_password: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""
|
||||||
Decode a file from a stego image and save it.
|
Decode a file from a stego image and save it.
|
||||||
@@ -140,6 +167,7 @@ def decode_file(
|
|||||||
rsa_password: Optional RSA key password
|
rsa_password: Optional RSA key password
|
||||||
embed_mode: 'auto', 'lsb', or 'dct'
|
embed_mode: 'auto', 'lsb', or 'dct'
|
||||||
channel_key: Channel key parameter (see decode())
|
channel_key: Channel key parameter (see decode())
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path where file was saved
|
Path where file was saved
|
||||||
@@ -156,6 +184,7 @@ def decode_file(
|
|||||||
rsa_password,
|
rsa_password,
|
||||||
embed_mode,
|
embed_mode,
|
||||||
channel_key,
|
channel_key,
|
||||||
|
progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.is_file:
|
if not result.is_file:
|
||||||
@@ -184,6 +213,7 @@ def decode_text(
|
|||||||
rsa_password: str | None = None,
|
rsa_password: str | None = None,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Decode a text message from a stego image.
|
Decode a text message from a stego image.
|
||||||
@@ -199,6 +229,7 @@ def decode_text(
|
|||||||
rsa_password: Optional RSA key password
|
rsa_password: Optional RSA key password
|
||||||
embed_mode: 'auto', 'lsb', or 'dct'
|
embed_mode: 'auto', 'lsb', or 'dct'
|
||||||
channel_key: Channel key parameter (see decode())
|
channel_key: Channel key parameter (see decode())
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded message string
|
Decoded message string
|
||||||
@@ -215,6 +246,7 @@ def decode_text(
|
|||||||
rsa_password,
|
rsa_password,
|
||||||
embed_mode,
|
embed_mode,
|
||||||
channel_key,
|
channel_key,
|
||||||
|
progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.is_file:
|
if result.is_file:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS, password: str | None = None)
|
|||||||
Generate an RSA private key in PEM format.
|
Generate an RSA private key in PEM format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bits: Key size (2048, 3072, or 4096, default 2048)
|
bits: Key size (2048 or 3072, default 2048)
|
||||||
password: Optional password to encrypt the key
|
password: Optional password to encrypt the key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
|
|||||||
Generate an RSA private key.
|
Generate an RSA private key.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bits: Key size (2048, 3072, or 4096)
|
bits: Key size (2048 or 3072)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RSA private key object
|
RSA private key object
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ IMPROVEMENTS IN THIS VERSION:
|
|||||||
- Much more robust PEM normalization
|
- Much more robust PEM normalization
|
||||||
- Better handling of QR code extraction edge cases
|
- Better handling of QR code extraction edge cases
|
||||||
- Improved error messages
|
- Improved error messages
|
||||||
|
- v4.2.0: Added zstd compression (better ratio than zlib)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -16,6 +17,14 @@ import zlib
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
# Optional ZSTD support (better compression ratio)
|
||||||
|
try:
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
|
HAS_ZSTD = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_ZSTD = False
|
||||||
|
|
||||||
# QR code generation
|
# QR code generation
|
||||||
try:
|
try:
|
||||||
import qrcode
|
import qrcode
|
||||||
@@ -42,30 +51,46 @@ from .constants import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
COMPRESSION_PREFIX = "STEGASOO-Z:"
|
COMPRESSION_PREFIX_ZLIB = "STEGASOO-Z:" # Legacy zlib compression
|
||||||
|
COMPRESSION_PREFIX_ZSTD = "STEGASOO-ZS:" # v4.2.0: New zstd compression (better ratio)
|
||||||
|
COMPRESSION_PREFIX = COMPRESSION_PREFIX_ZSTD if HAS_ZSTD else COMPRESSION_PREFIX_ZLIB
|
||||||
|
|
||||||
|
|
||||||
def compress_data(data: str) -> str:
|
def compress_data(data: str) -> str:
|
||||||
"""
|
"""
|
||||||
Compress string data for QR code storage.
|
Compress string data for QR code storage.
|
||||||
|
|
||||||
|
Uses zstd if available (better ratio), falls back to zlib.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: String to compress
|
data: String to compress
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Compressed string with STEGASOO-Z: prefix
|
Compressed string with STEGASOO-ZS: (zstd) or STEGASOO-Z: (zlib) prefix
|
||||||
"""
|
"""
|
||||||
compressed = zlib.compress(data.encode("utf-8"), level=9)
|
data_bytes = data.encode("utf-8")
|
||||||
encoded = base64.b64encode(compressed).decode("ascii")
|
|
||||||
return COMPRESSION_PREFIX + encoded
|
if HAS_ZSTD:
|
||||||
|
# Use zstd (better compression ratio)
|
||||||
|
cctx = zstd.ZstdCompressor(level=19)
|
||||||
|
compressed = cctx.compress(data_bytes)
|
||||||
|
encoded = base64.b64encode(compressed).decode("ascii")
|
||||||
|
return COMPRESSION_PREFIX_ZSTD + encoded
|
||||||
|
else:
|
||||||
|
# Fall back to zlib
|
||||||
|
compressed = zlib.compress(data_bytes, level=9)
|
||||||
|
encoded = base64.b64encode(compressed).decode("ascii")
|
||||||
|
return COMPRESSION_PREFIX_ZLIB + encoded
|
||||||
|
|
||||||
|
|
||||||
def decompress_data(data: str) -> str:
|
def decompress_data(data: str) -> str:
|
||||||
"""
|
"""
|
||||||
Decompress data from QR code.
|
Decompress data from QR code.
|
||||||
|
|
||||||
|
Supports both zstd (STEGASOO-ZS:) and zlib (STEGASOO-Z:) formats.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Compressed string with STEGASOO-Z: prefix
|
data: Compressed string with STEGASOO-ZS: or STEGASOO-Z: prefix
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Original uncompressed string
|
Original uncompressed string
|
||||||
@@ -73,12 +98,26 @@ def decompress_data(data: str) -> str:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If data is not valid compressed format
|
ValueError: If data is not valid compressed format
|
||||||
"""
|
"""
|
||||||
if not data.startswith(COMPRESSION_PREFIX):
|
if data.startswith(COMPRESSION_PREFIX_ZSTD):
|
||||||
raise ValueError("Data is not in compressed format")
|
# v4.2.0: ZSTD compression
|
||||||
|
if not HAS_ZSTD:
|
||||||
|
raise ValueError(
|
||||||
|
"Data compressed with zstd but zstandard package not installed. "
|
||||||
|
"Run: pip install zstandard"
|
||||||
|
)
|
||||||
|
encoded = data[len(COMPRESSION_PREFIX_ZSTD):]
|
||||||
|
compressed = base64.b64decode(encoded)
|
||||||
|
dctx = zstd.ZstdDecompressor()
|
||||||
|
return dctx.decompress(compressed).decode("utf-8")
|
||||||
|
|
||||||
encoded = data[len(COMPRESSION_PREFIX) :]
|
elif data.startswith(COMPRESSION_PREFIX_ZLIB):
|
||||||
compressed = base64.b64decode(encoded)
|
# Legacy zlib compression
|
||||||
return zlib.decompress(compressed).decode("utf-8")
|
encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
|
||||||
|
compressed = base64.b64decode(encoded)
|
||||||
|
return zlib.decompress(compressed).decode("utf-8")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("Data is not in compressed format")
|
||||||
|
|
||||||
|
|
||||||
def normalize_pem(pem_data: str) -> str:
|
def normalize_pem(pem_data: str) -> str:
|
||||||
@@ -166,8 +205,8 @@ def normalize_pem(pem_data: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def is_compressed(data: str) -> bool:
|
def is_compressed(data: str) -> bool:
|
||||||
"""Check if data has compression prefix."""
|
"""Check if data has compression prefix (zstd or zlib)."""
|
||||||
return data.startswith(COMPRESSION_PREFIX)
|
return data.startswith(COMPRESSION_PREFIX_ZSTD) or data.startswith(COMPRESSION_PREFIX_ZLIB)
|
||||||
|
|
||||||
|
|
||||||
def auto_decompress(data: str) -> str:
|
def auto_decompress(data: str) -> str:
|
||||||
@@ -213,17 +252,23 @@ def needs_compression(data: str) -> bool:
|
|||||||
return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True)
|
return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True)
|
||||||
|
|
||||||
|
|
||||||
def generate_qr_code(data: str, compress: bool = False, error_correction=None) -> bytes:
|
def generate_qr_code(
|
||||||
|
data: str,
|
||||||
|
compress: bool = False,
|
||||||
|
error_correction=None,
|
||||||
|
output_format: str = "png",
|
||||||
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generate a QR code PNG from string data.
|
Generate a QR code image from string data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: String data to encode
|
data: String data to encode
|
||||||
compress: Whether to compress data first
|
compress: Whether to compress data first
|
||||||
error_correction: QR error correction level (default: auto)
|
error_correction: QR error correction level (default: auto)
|
||||||
|
output_format: Image format - 'png' or 'jpg'/'jpeg'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PNG image bytes
|
Image bytes in requested format
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If qrcode library not available
|
RuntimeError: If qrcode library not available
|
||||||
@@ -260,11 +305,79 @@ def generate_qr_code(data: str, compress: bool = False, error_correction=None) -
|
|||||||
img = qr.make_image(fill_color="black", back_color="white")
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format="PNG")
|
fmt = output_format.lower()
|
||||||
|
if fmt in ("jpg", "jpeg"):
|
||||||
|
# Convert to RGB for JPEG (no alpha channel)
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(buf, format="JPEG", quality=95)
|
||||||
|
else:
|
||||||
|
img.save(buf, format="PNG")
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_ascii(
|
||||||
|
data: str,
|
||||||
|
compress: bool = False,
|
||||||
|
invert: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate an ASCII representation of a QR code.
|
||||||
|
|
||||||
|
Uses Unicode block characters for compact display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: String data to encode
|
||||||
|
compress: Whether to compress data first
|
||||||
|
invert: Invert colors (white on black for dark terminals)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ASCII string representation of QR code
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If qrcode library not available
|
||||||
|
ValueError: If data too large for QR code
|
||||||
|
"""
|
||||||
|
if not HAS_QRCODE_WRITE:
|
||||||
|
raise RuntimeError("qrcode library not installed. Run: pip install qrcode[pil]")
|
||||||
|
|
||||||
|
qr_data = data
|
||||||
|
|
||||||
|
# Compress if requested
|
||||||
|
if compress:
|
||||||
|
qr_data = compress_data(data)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
if len(qr_data.encode("utf-8")) > QR_MAX_BINARY:
|
||||||
|
raise ValueError(
|
||||||
|
f"Data too large for QR code ({len(qr_data)} bytes). " f"Maximum: {QR_MAX_BINARY} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=None,
|
||||||
|
error_correction=ERROR_CORRECT_L,
|
||||||
|
box_size=1,
|
||||||
|
border=2,
|
||||||
|
)
|
||||||
|
qr.add_data(qr_data)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
# Get the QR matrix
|
||||||
|
# Use print_ascii to a StringIO to capture output
|
||||||
|
import sys
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
sys.stdout = StringIO()
|
||||||
|
try:
|
||||||
|
qr.print_ascii(invert=invert)
|
||||||
|
ascii_qr = sys.stdout.getvalue()
|
||||||
|
finally:
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
|
||||||
|
return ascii_qr
|
||||||
|
|
||||||
|
|
||||||
def read_qr_code(image_data: bytes) -> str | None:
|
def read_qr_code(image_data: bytes) -> str | None:
|
||||||
"""
|
"""
|
||||||
Read QR code from image data.
|
Read QR code from image data.
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ def has_dct_support() -> bool:
|
|||||||
dct_mod = _get_dct_module()
|
dct_mod = _get_dct_module()
|
||||||
return dct_mod.has_dct_support()
|
return dct_mod.has_dct_support()
|
||||||
except (ImportError, ValueError):
|
except (ImportError, ValueError):
|
||||||
# ValueError: numpy binary incompatibility (e.g., jpegio built against numpy 2.x)
|
# ValueError: numpy binary incompatibility (e.g., jpeglib built against numpy 2.x)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -746,6 +746,10 @@ def _embed_lsb(
|
|||||||
modified_pixels = 0
|
modified_pixels = 0
|
||||||
total_pixels_to_process = len(selected_indices)
|
total_pixels_to_process = len(selected_indices)
|
||||||
|
|
||||||
|
# Initial progress write - signals prep is done, embedding starting
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, 5, 100, "embedding")
|
||||||
|
|
||||||
for progress_idx, pixel_idx in enumerate(selected_indices):
|
for progress_idx, pixel_idx in enumerate(selected_indices):
|
||||||
if bit_idx >= len(binary_data):
|
if bit_idx >= len(binary_data):
|
||||||
break
|
break
|
||||||
@@ -839,6 +843,7 @@ def extract_from_image(
|
|||||||
pixel_key: bytes,
|
pixel_key: bytes,
|
||||||
bits_per_channel: int = 1,
|
bits_per_channel: int = 1,
|
||||||
embed_mode: str = EMBED_MODE_AUTO,
|
embed_mode: str = EMBED_MODE_AUTO,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""
|
"""
|
||||||
Extract hidden data from a stego image.
|
Extract hidden data from a stego image.
|
||||||
@@ -848,6 +853,7 @@ def extract_from_image(
|
|||||||
pixel_key: Key for pixel/coefficient selection (must match encoding)
|
pixel_key: Key for pixel/coefficient selection (must match encoding)
|
||||||
bits_per_channel: Bits per channel (LSB mode only)
|
bits_per_channel: Bits per channel (LSB mode only)
|
||||||
embed_mode: 'auto' (try both), 'lsb', or 'dct'
|
embed_mode: 'auto' (try both), 'lsb', or 'dct'
|
||||||
|
progress_file: Optional path to write progress JSON for UI polling
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Extracted data bytes, or None if extraction fails
|
Extracted data bytes, or None if extraction fails
|
||||||
@@ -863,7 +869,7 @@ def extract_from_image(
|
|||||||
|
|
||||||
if has_dct_support():
|
if has_dct_support():
|
||||||
debug.print("Auto-detect: LSB failed, trying DCT")
|
debug.print("Auto-detect: LSB failed, trying DCT")
|
||||||
result = _extract_dct(image_data, pixel_key)
|
result = _extract_dct(image_data, pixel_key, progress_file)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
debug.print("Auto-detect: DCT extraction succeeded")
|
debug.print("Auto-detect: DCT extraction succeeded")
|
||||||
return result
|
return result
|
||||||
@@ -875,18 +881,22 @@ def extract_from_image(
|
|||||||
elif embed_mode == EMBED_MODE_DCT:
|
elif embed_mode == EMBED_MODE_DCT:
|
||||||
if not has_dct_support():
|
if not has_dct_support():
|
||||||
raise ImportError("scipy required for DCT mode")
|
raise ImportError("scipy required for DCT mode")
|
||||||
return _extract_dct(image_data, pixel_key)
|
return _extract_dct(image_data, pixel_key, progress_file)
|
||||||
|
|
||||||
# EXPLICIT LSB MODE
|
# EXPLICIT LSB MODE
|
||||||
else:
|
else:
|
||||||
return _extract_lsb(image_data, pixel_key, bits_per_channel)
|
return _extract_lsb(image_data, pixel_key, bits_per_channel)
|
||||||
|
|
||||||
|
|
||||||
def _extract_dct(image_data: bytes, pixel_key: bytes) -> bytes | None:
|
def _extract_dct(
|
||||||
|
image_data: bytes,
|
||||||
|
pixel_key: bytes,
|
||||||
|
progress_file: str | None = None,
|
||||||
|
) -> bytes | None:
|
||||||
"""Extract using DCT mode."""
|
"""Extract using DCT mode."""
|
||||||
try:
|
try:
|
||||||
dct_mod = _get_dct_module()
|
dct_mod = _get_dct_module()
|
||||||
return dct_mod.extract_from_dct(image_data, pixel_key)
|
return dct_mod.extract_from_dct(image_data, pixel_key, progress_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug.print(f"DCT extraction failed: {e}")
|
debug.print(f"DCT extraction failed: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1087,7 +1097,7 @@ def peek_image(image_data: bytes) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try DCT extraction (requires scipy/jpegio)
|
# Try DCT extraction (requires scipy/jpeglib)
|
||||||
try:
|
try:
|
||||||
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
|
||||||
|
|
||||||
|
|||||||