Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf247d207f | ||
|
|
28d77957eb | ||
|
|
89b4809489 | ||
|
|
79ab165b95 | ||
|
|
4194d6923a | ||
|
|
08e19a3bfd | ||
|
|
dea7472018 | ||
|
|
e8863d15d7 |
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Run command '...'
|
||||
2. Upload image '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: interface
|
||||
attributes:
|
||||
label: Interface
|
||||
description: Which interface are you using?
|
||||
options:
|
||||
- CLI
|
||||
- Web UI
|
||||
- REST API
|
||||
- Python Library
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Stegasoo Version
|
||||
description: Run `stegasoo --version` or check the web UI footer
|
||||
placeholder: "4.0.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: python-version
|
||||
attributes:
|
||||
label: Python Version
|
||||
description: Run `python --version`
|
||||
placeholder: "3.11.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
placeholder: "Ubuntu 22.04 / Windows 11 / macOS 14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Logs
|
||||
description: Paste any relevant error messages or tracebacks.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or files here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security Vulnerability
|
||||
url: https://github.com/adlee-was-taken/stegasoo/security/advisories/new
|
||||
about: Report security vulnerabilities privately
|
||||
- name: Documentation
|
||||
url: https://github.com/adlee-was-taken/stegasoo#readme
|
||||
about: Check the documentation before opening an issue
|
||||
62
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
62
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please fill out the form below.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Describe it.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like.
|
||||
placeholder: I would like to be able to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered.
|
||||
|
||||
- type: dropdown
|
||||
id: interface
|
||||
attributes:
|
||||
label: Affected Interface(s)
|
||||
description: Which interface(s) would this feature affect?
|
||||
multiple: true
|
||||
options:
|
||||
- CLI
|
||||
- Web UI
|
||||
- REST API
|
||||
- Python Library
|
||||
- Core Library
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this feature to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow
|
||||
- Critical for my use case
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or examples here.
|
||||
46
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
46
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
## Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Mark the relevant option with an 'x' -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] CI/CD or build changes
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link any related issues here -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
- [ ] I have added tests that prove my fix/feature works
|
||||
- [ ] Existing tests pass locally with my changes
|
||||
- [ ] I have tested manually (describe below)
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
<!-- If applicable, describe manual testing performed -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have updated the documentation accordingly
|
||||
- [ ] I have updated CHANGELOG.md (if user-facing changes)
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your changes -->
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -35,6 +35,12 @@ old_files/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Backup files
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
@@ -58,6 +64,12 @@ htmlcov/
|
||||
# Output test files.
|
||||
test_data/*.png
|
||||
|
||||
#Project root scripts.
|
||||
# Dev scripts (local convenience scripts)
|
||||
build.sh
|
||||
rbld_containers.sh
|
||||
quick_web.sh
|
||||
project_stats.sh
|
||||
|
||||
# Web UI auth database and SSL certs
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
|
||||
6
API.md
6
API.md
@@ -1,4 +1,4 @@
|
||||
# Stegasoo REST API Documentation (v4.0.1)
|
||||
# Stegasoo REST API Documentation (v4.0.2)
|
||||
|
||||
Complete REST API reference for Stegasoo steganography operations.
|
||||
|
||||
@@ -113,7 +113,7 @@ Check API status and configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"has_argon2": true,
|
||||
"has_qrcode_read": true,
|
||||
"has_dct": true,
|
||||
@@ -462,7 +462,7 @@ X-Stegasoo-Capacity-Percent: 12.4
|
||||
X-Stegasoo-Embed-Mode: lsb
|
||||
X-Stegasoo-Channel-Mode: private
|
||||
X-Stegasoo-Channel-Fingerprint: ABCD-••••-...-3456
|
||||
X-Stegasoo-Version: 4.0.1
|
||||
X-Stegasoo-Version: 4.0.2
|
||||
|
||||
<binary image data>
|
||||
```
|
||||
|
||||
120
CHANGELOG.md
Normal file
120
CHANGELOG.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Stegasoo will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.0.2] - 2026-01-02
|
||||
|
||||
### Added
|
||||
- **Web UI Authentication**: Single-admin login with SQLite3 user storage
|
||||
- First-run setup wizard for admin account creation
|
||||
- Account management page for password changes
|
||||
- `@login_required` decorator protects encode/decode/generate routes
|
||||
- Argon2id password hashing (lighter 64MB for fast login)
|
||||
- **Optional HTTPS**: Auto-generated self-signed certificates for home network deployment
|
||||
- Configurable via `STEGASOO_HTTPS_ENABLED` environment variable
|
||||
- Certificates stored in `frontends/web/certs/`
|
||||
- New environment variables: `STEGASOO_AUTH_ENABLED`, `STEGASOO_HTTPS_ENABLED`, `STEGASOO_HOSTNAME`
|
||||
|
||||
### Changed
|
||||
- PIN entry column widened in encode/decode forms (col-md-4 → col-md-6)
|
||||
- Channel options column narrowed (col-md-8 → col-md-6)
|
||||
- QR preview panels enlarged for better text readability
|
||||
- Consistent font sizing across all preview panel banners (0.7rem filename, 0.6rem data, 0.65rem badges)
|
||||
|
||||
### Fixed
|
||||
- QR preview text too small to read in encode/decode templates
|
||||
- Inconsistent label sizes between reference/carrier/stego panels
|
||||
|
||||
## [4.0.1] - 2025-01-02
|
||||
|
||||
### Fixed
|
||||
- Fixed numpy binary incompatibility on Python 3.10 (jpegio/scipy)
|
||||
- Fixed BatchCredentials test failures with missing `reference_photo` parameter
|
||||
- Graceful handling when DCT dependencies have version mismatches
|
||||
|
||||
### Changed
|
||||
- Applied `ruff` linter fixes across entire codebase (~400 issues)
|
||||
- Applied `black` formatter to all Python files
|
||||
- Modernized type hints: `Optional[X]` → `X | None`
|
||||
- Updated ruff config to use `[tool.ruff.lint]` section
|
||||
- Moved documentation files to repository root
|
||||
|
||||
### Removed
|
||||
- Removed obsolete debug/diagnostic scripts
|
||||
- Cleaned up backup files and dev scripts
|
||||
|
||||
## [4.0.0] - 2024-12-29
|
||||
|
||||
### Added
|
||||
- Refreshed Web UI with modern, snazzy interface
|
||||
- Improved user experience across all pages
|
||||
|
||||
### Changed
|
||||
- Major version bump for breaking API changes
|
||||
- Simplified passphrase handling (single passphrase instead of day-based)
|
||||
- Removed date_str parameter from encoding
|
||||
|
||||
### Fixed
|
||||
- Various bug fixes for Web UI
|
||||
- CLI updates and improvements
|
||||
|
||||
## [3.2.0] - 2024-12-28
|
||||
|
||||
### Added
|
||||
- Big revamp of the encoding system
|
||||
- Home and about page improvements
|
||||
- UNDER_THE_HOOD.md documentation
|
||||
|
||||
### Changed
|
||||
- Renamed `phrase` → `passphrase` in API
|
||||
- Updated Web UI styling
|
||||
|
||||
## [3.0.2] - 2024-12-27
|
||||
|
||||
### Added
|
||||
- Full experimental DCT steganography support
|
||||
- jpegio integration for better JPEG manipulation
|
||||
- DCT/LSB mode selector in Web UI
|
||||
|
||||
## [3.0.0] - 2024-12-25
|
||||
|
||||
### Added
|
||||
- DCT (Discrete Cosine Transform) steganography mode
|
||||
- Support for JPEG carriers without quality loss
|
||||
- Channel key feature for private messaging
|
||||
|
||||
### Changed
|
||||
- Complete rewrite of steganography engine
|
||||
- New hybrid authentication system
|
||||
|
||||
## [2.0.0] - 2024-12-20
|
||||
|
||||
### Added
|
||||
- Web UI frontend
|
||||
- REST API (FastAPI)
|
||||
- Batch processing support
|
||||
- RSA key authentication option
|
||||
|
||||
### Changed
|
||||
- Migrated to hybrid photo + passphrase + PIN authentication
|
||||
|
||||
## [1.0.0] - 2024-12-15
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- LSB steganography
|
||||
- AES-256-GCM encryption
|
||||
- CLI interface
|
||||
- Basic PIN authentication
|
||||
|
||||
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
|
||||
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
|
||||
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0
|
||||
[3.2.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.2...v3.2.0
|
||||
[3.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.0...v3.0.2
|
||||
[3.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v2.0.0...v3.0.0
|
||||
[2.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v1.0.0...v2.0.0
|
||||
[1.0.0]: https://github.com/adlee-was-taken/stegasoo/releases/tag/v1.0.0
|
||||
2
CLI.md
2
CLI.md
@@ -1,4 +1,4 @@
|
||||
# Stegasoo CLI Documentation (v4.0.1)
|
||||
# Stegasoo CLI Documentation (v4.0.2)
|
||||
|
||||
Complete command-line interface reference for Stegasoo steganography operations.
|
||||
|
||||
|
||||
54
CODE_OF_CONDUCT.md
Normal file
54
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards
|
||||
of acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainers. All complaints will be reviewed and
|
||||
investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
|
||||
version 2.0.
|
||||
165
CONTRIBUTING.md
Normal file
165
CONTRIBUTING.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Contributing to Stegasoo
|
||||
|
||||
Thank you for your interest in contributing to Stegasoo! This document provides guidelines and information for contributors.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Git
|
||||
- Docker (optional, for container testing)
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
```
|
||||
|
||||
2. **Create a virtual environment**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install development dependencies**
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
4. **Install pre-commit hooks**
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Style
|
||||
|
||||
We use the following tools to maintain code quality:
|
||||
|
||||
- **Black** - Code formatting (line length: 100)
|
||||
- **Ruff** - Linting
|
||||
- **MyPy** - Type checking
|
||||
|
||||
Run all checks before committing:
|
||||
```bash
|
||||
black src/ tests/ frontends/
|
||||
ruff check src/ tests/ frontends/
|
||||
mypy src/
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=stegasoo --cov-report=term-missing
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_stegasoo.py
|
||||
```
|
||||
|
||||
### Type Hints
|
||||
|
||||
All new code should include type hints:
|
||||
|
||||
```python
|
||||
def encode_message(
|
||||
message: str,
|
||||
carrier_image: bytes,
|
||||
passphrase: str,
|
||||
pin: str = "",
|
||||
) -> EncodeResult:
|
||||
...
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branch Naming
|
||||
|
||||
- `feature/description` - New features
|
||||
- `fix/description` - Bug fixes
|
||||
- `docs/description` - Documentation updates
|
||||
- `refactor/description` - Code refactoring
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Write clear, concise commit messages:
|
||||
|
||||
```
|
||||
Add channel key validation for private messaging
|
||||
|
||||
- Implement validate_channel_key() function
|
||||
- Add tests for valid/invalid key formats
|
||||
- Update CLI to support --channel-key flag
|
||||
```
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. **Create a feature branch** from `main`
|
||||
2. **Make your changes** with appropriate tests
|
||||
3. **Ensure all checks pass** (tests, linting, formatting)
|
||||
4. **Submit a PR** with a clear description
|
||||
5. **Address review feedback** promptly
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Tests added/updated for changes
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] CHANGELOG.md updated for user-facing changes
|
||||
- [ ] All CI checks passing
|
||||
- [ ] No merge conflicts with `main`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stegasoo/
|
||||
├── src/stegasoo/ # Core library
|
||||
│ ├── crypto.py # Encryption/decryption
|
||||
│ ├── steganography.py # LSB embedding
|
||||
│ ├── dct_steganography.py # DCT embedding
|
||||
│ └── ...
|
||||
├── frontends/
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ ├── web/ # Flask web UI
|
||||
│ └── api/ # FastAPI REST API
|
||||
├── tests/ # Test suite
|
||||
└── examples/ # Usage examples
|
||||
```
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Please include:
|
||||
- Python version and OS
|
||||
- Stegasoo version (`stegasoo --version`)
|
||||
- Minimal reproduction steps
|
||||
- Expected vs actual behavior
|
||||
- Error messages/tracebacks
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Please include:
|
||||
- Use case description
|
||||
- Proposed solution (if any)
|
||||
- Alternatives considered
|
||||
|
||||
## Security
|
||||
|
||||
If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines. **Do not open a public issue for security vulnerabilities.**
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open a discussion or issue if you have questions about contributing.
|
||||
|
||||
Thank you for helping make Stegasoo better!
|
||||
41
INSTALL.md
41
INSTALL.md
@@ -227,6 +227,23 @@ docker-compose logs -f
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
#### Authentication Configuration (v4.0.2)
|
||||
|
||||
The Web UI supports optional authentication. Configure via environment variables:
|
||||
|
||||
```bash
|
||||
# .env file (create in project root)
|
||||
STEGASOO_AUTH_ENABLED=true # Enable login (default: true)
|
||||
STEGASOO_HTTPS_ENABLED=false # Enable HTTPS (default: false)
|
||||
STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
|
||||
STEGASOO_CHANNEL_KEY= # Optional channel key
|
||||
|
||||
# Then run
|
||||
docker-compose up -d web
|
||||
```
|
||||
|
||||
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
|
||||
|
||||
#### Services
|
||||
|
||||
| Service | URL | Description |
|
||||
@@ -424,6 +441,10 @@ Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
|
||||
# System dependencies
|
||||
sudo apt-get install python3-dev libzbar0 libjpeg-dev
|
||||
|
||||
# Create venv with Python 3.12 (if available, or 3.11)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install (may take a while to compile)
|
||||
pip install stegasoo[cli]
|
||||
|
||||
@@ -431,7 +452,25 @@ pip install stegasoo[cli]
|
||||
pip install stegasoo[web] # Needs ~768MB free
|
||||
```
|
||||
|
||||
**Note:** Argon2 operations will be slower on Pi due to memory-hardness.
|
||||
**Running the Web UI on Pi:**
|
||||
```bash
|
||||
cd frontends/web
|
||||
|
||||
# Optional: Enable authentication
|
||||
export STEGASOO_AUTH_ENABLED=true
|
||||
|
||||
# Optional: Enable HTTPS for local network security
|
||||
export STEGASOO_HTTPS_ENABLED=true
|
||||
export STEGASOO_HOSTNAME=raspberrypi.local
|
||||
|
||||
# Start server
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Argon2 operations will be slower on Pi due to memory-hardness
|
||||
- First run will prompt you to create an admin account
|
||||
- HTTPS generates a self-signed certificate (browsers will warn)
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Aaron D. Lee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
430
README.md
430
README.md
@@ -2,429 +2,121 @@
|
||||
|
||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
||||
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/test.yml)
|
||||
[](https://github.com/adlee-was-taken/stegasoo/actions/workflows/lint.yml)
|
||||

|
||||

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

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **AES-256-GCM** authenticated encryption
|
||||
- 🧠 **Argon2id** memory-hard key derivation (256MB RAM requirement)
|
||||
- 🎲 **Pseudo-random pixel selection** defeats steganalysis
|
||||
- 🔑 **Multi-factor authentication**: PIN, RSA key, or both
|
||||
- 🖼️ **Reference photo** as "something you have"
|
||||
- 🌐 **Multiple interfaces**: CLI, Web UI, REST API
|
||||
- 📁 **File embedding** - Hide any file type (PDF, ZIP, documents)
|
||||
- 📱 **QR code support** - Encode/decode RSA keys via QR codes
|
||||
- 🆕 **DCT steganography** - JPEG-resilient embedding for social media
|
||||
- 🆕 **Large image support** - Process images up to 14MB+
|
||||
- **AES-256-GCM** authenticated encryption
|
||||
- **Argon2id** memory-hard key derivation (256MB RAM requirement)
|
||||
- **Pseudo-random pixel selection** defeats steganalysis
|
||||
- **Multi-factor authentication**: Reference photo + passphrase + PIN/RSA key
|
||||
- **Multiple interfaces**: CLI, Web UI, REST API
|
||||
- **File embedding**: Hide any file type (PDF, ZIP, documents)
|
||||
- **DCT steganography**: JPEG-resilient embedding for social media
|
||||
- **Channel keys**: Private group communication channels
|
||||
|
||||
## What's New in v4.0.0
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Simplified Auth** | Removed date dependency - encode/decode anytime without tracking dates |
|
||||
| **Passphrase** | Renamed from "day phrase" to "passphrase" (no more daily rotation) |
|
||||
| **Python 3.12** | Requires Python 3.10-3.12 (jpegio incompatible with 3.13) |
|
||||
| **Large Image Fix** | JPEG normalization prevents crashes with quality=100 images |
|
||||
| **Subprocess Isolation** | WebUI runs encode/decode in subprocesses for stability |
|
||||
| **4-Word Default** | Default passphrase increased from 3 to 4 words |
|
||||
|
||||
### Breaking Changes from v3.x
|
||||
|
||||
- `day_phrase` parameter renamed to `passphrase` in all APIs
|
||||
- `date_str` parameter removed from encode/decode functions
|
||||
- Python 3.13 not supported (jpegio C extension incompatibility)
|
||||
|
||||
### Embedding Mode Comparison
|
||||
## Embedding Modes
|
||||
|
||||
| Mode | Capacity (1080p) | JPEG Resilient | Best For |
|
||||
|------|------------------|----------------|----------|
|
||||
| **DCT** (default) | ~150 KB | ✅ Yes | Social media, messaging apps |
|
||||
| **LSB** | ~750 KB | ❌ No | Email, file transfer |
|
||||
| **DCT** (default) | ~150 KB | Yes | Social media, messaging apps |
|
||||
| **LSB** | ~750 KB | No | Email, direct file transfer |
|
||||
|
||||
## WebUI Preview
|
||||
## Web UI
|
||||
|
||||
| Front Page | Encode | Decode | Generate |
|
||||
|:----------:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
| Home | Encode | Decode | Generate |
|
||||
|:----:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install with all features (requires Python 3.10-3.12)
|
||||
# Install (Python 3.10-3.12)
|
||||
pip install -e ".[all]"
|
||||
|
||||
# Generate credentials (memorize these!)
|
||||
# Generate credentials
|
||||
stegasoo generate --pin --words 4
|
||||
|
||||
# Encode a message (DCT mode - default, best for social media)
|
||||
# Encode a message
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--ref my_photo.jpg \
|
||||
--carrier meme.jpg \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message"
|
||||
|
||||
# Encode with LSB mode (higher capacity, for email/file transfer)
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--carrier meme.png \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message" \
|
||||
--mode lsb
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
# Decode
|
||||
stegasoo decode \
|
||||
--ref photo.jpg \
|
||||
--stego stego.png \
|
||||
--ref my_photo.jpg \
|
||||
--stego stego_image.png \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456
|
||||
```
|
||||
|
||||
For detailed installation instructions, see **[INSTALL.md](INSTALL.md)**.
|
||||
## Interfaces
|
||||
|
||||
---
|
||||
| Interface | Start Command | Documentation |
|
||||
|-----------|---------------|---------------|
|
||||
| **CLI** | `stegasoo --help` | [CLI.md](CLI.md) |
|
||||
| **Web UI** | `cd frontends/web && python app.py` | [WEB_UI.md](WEB_UI.md) |
|
||||
| **REST API** | `cd frontends/api && uvicorn main:app` | [API.md](API.md) |
|
||||
|
||||
## Security Model
|
||||
|
||||
Stegasoo uses multiple authentication factors combined with strong cryptography:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AUTHENTICATION LAYERS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Reference Photo ──┐ │
|
||||
│ (~80-256 bits) │ │
|
||||
│ ├──► Argon2id KDF ──► AES-256-GCM Key │
|
||||
│ Passphrase ───────┤ (256MB RAM) │
|
||||
│ (~43-132 bits) │ │
|
||||
│ │ │
|
||||
│ Static PIN ───────┤ │
|
||||
│ (~20-30 bits) │ │
|
||||
│ │ │
|
||||
│ RSA Key ──────────┘ │
|
||||
│ (~128 bits) (optional, adds another factor) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
Reference Photo ──┐
|
||||
(~80-256 bits) │
|
||||
├──► Argon2id KDF ──► AES-256-GCM Key
|
||||
Passphrase ───────┤ (256MB RAM)
|
||||
(~43-132 bits) │
|
||||
│
|
||||
PIN ──────────────┤
|
||||
(~20-30 bits) │
|
||||
│
|
||||
RSA Key ──────────┘
|
||||
(optional)
|
||||
```
|
||||
|
||||
### Entropy Summary
|
||||
|
||||
| Component | Entropy | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Reference Photo | ~80-256 bits | Something you have |
|
||||
| Passphrase (3-12 words) | ~33-132 bits | Something you know |
|
||||
| PIN (6-9 digits) | ~20-30 bits | Something you know |
|
||||
| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) |
|
||||
| **Combined** | **133-400+ bits** | **Beyond brute force** |
|
||||
|
||||
### Attack Resistance
|
||||
|
||||
| Attack | Protection |
|
||||
|--------|------------|
|
||||
| Brute force | 2^133+ combinations minimum |
|
||||
| Rainbow tables | Random 16-byte salt per message |
|
||||
| Steganalysis | Pseudo-random pixel/coefficient selection |
|
||||
| GPU cracking | Argon2id requires 256MB RAM per attempt |
|
||||
| Side-channel | Constant-time operations in cryptography library |
|
||||
| JPEG recompression | DCT mode embeds in frequency domain |
|
||||
|
||||
### Security Configurations
|
||||
|
||||
| Configuration | Entropy | Use Case |
|
||||
|--------------|---------|----------|
|
||||
| 3-word passphrase + 6-digit PIN | ~133 bits | Casual private messaging |
|
||||
| 4-word passphrase + 9-digit PIN | ~176 bits | Standard security (recommended) |
|
||||
| 4-word passphrase + RSA 2048 | ~241 bits | File-based authentication |
|
||||
| 6-word passphrase + PIN + RSA 4096 | ~304 bits | Maximum security |
|
||||
|
||||
---
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Command-Line Interface (CLI)
|
||||
|
||||
Full-featured CLI with piping support:
|
||||
|
||||
```bash
|
||||
# Generate with RSA key
|
||||
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "password"
|
||||
|
||||
# Encode (DCT mode is now default)
|
||||
stegasoo encode -r ref.jpg -c carrier.jpg -p "passphrase words here" --pin 123456 -m "Message"
|
||||
|
||||
# Encode with LSB mode for higher capacity
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 \
|
||||
-m "Message" --mode lsb
|
||||
|
||||
# Encode a file
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 -f secret.txt
|
||||
|
||||
# Decode to stdout (quiet mode)
|
||||
stegasoo decode -r ref.jpg -s stego.png -p "passphrase words here" --pin 123456 -q
|
||||
|
||||
# Compare LSB vs DCT capacity for an image
|
||||
stegasoo compare carrier.png
|
||||
|
||||
# Check available modes
|
||||
stegasoo modes
|
||||
```
|
||||
|
||||
📖 Full documentation: **[CLI.md](CLI.md)**
|
||||
|
||||
### Web UI
|
||||
|
||||
Browser-based interface with drag-and-drop uploads:
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
cd frontends/web
|
||||
python app.py
|
||||
# Visit http://localhost:5000
|
||||
```
|
||||
|
||||
Features:
|
||||
- Drag-and-drop image uploads with scan animations
|
||||
- Real-time entropy calculator
|
||||
- Native mobile sharing (Web Share API)
|
||||
- DCT mode default with compact mode selector
|
||||
- Subprocess isolation for stability
|
||||
- Large image support (14MB+ tested)
|
||||
- Streamlined form flow (v3.3.0)
|
||||
|
||||
📖 Full documentation: **[WEB_UI.md](WEB_UI.md)**
|
||||
|
||||
### REST API
|
||||
|
||||
FastAPI-powered REST API with OpenAPI documentation:
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
cd frontends/api
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
# Docs at http://localhost:8000/docs
|
||||
```
|
||||
|
||||
Example API calls:
|
||||
|
||||
```bash
|
||||
# Generate credentials
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"use_pin": true, "passphrase_words": 4}'
|
||||
|
||||
# Encode (DCT mode is default)
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.jpg" \
|
||||
--output stego.jpg
|
||||
|
||||
# Encode with LSB mode
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "embed_mode=lsb" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.png" \
|
||||
--output stego.png
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
curl -X POST http://localhost:8000/decode/multipart \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "stego_image=@stego.jpg"
|
||||
```
|
||||
|
||||
📖 Full documentation: **[API.md](API.md)**
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stegasoo/
|
||||
├── src/stegasoo/ # Core library
|
||||
│ ├── __init__.py # Public API
|
||||
│ ├── constants.py # Configuration
|
||||
│ ├── crypto.py # Encryption/decryption
|
||||
│ ├── steganography.py # LSB image embedding
|
||||
│ ├── dct_steganography.py # DCT embedding
|
||||
│ ├── keygen.py # Credential generation
|
||||
│ ├── validation.py # Input validation
|
||||
│ ├── models.py # Data classes
|
||||
│ ├── exceptions.py # Custom exceptions
|
||||
│ ├── qr_utils.py # QR code utilities
|
||||
│ └── utils.py # Utilities
|
||||
│
|
||||
├── frontends/
|
||||
│ ├── web/ # Flask web UI
|
||||
│ │ ├── app.py
|
||||
│ │ ├── subprocess_stego.py # Subprocess isolation
|
||||
│ │ └── stego_worker.py # Worker script
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── api/ # FastAPI REST API
|
||||
│
|
||||
├── data/
|
||||
│ └── bip39-words.txt # BIP-39 wordlist
|
||||
│
|
||||
├── pyproject.toml # Package configuration
|
||||
├── requirements.txt # Dependencies
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Container orchestration
|
||||
│
|
||||
├── README.md # This file
|
||||
├── INSTALL.md # Installation guide
|
||||
├── CLI.md # CLI documentation
|
||||
├── API.md # API documentation
|
||||
├── WEB_UI.md # Web UI documentation
|
||||
├── SECURITY.md # Security documentation
|
||||
└── UNDER_THE_HOOD.md # Technical deep-dive
|
||||
```
|
||||
|
||||
---
|
||||
| 4-word passphrase + 6-digit PIN | ~153 bits | Standard security |
|
||||
| 4-word passphrase + PIN + RSA | ~280+ bits | Maximum security |
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python | 3.10-3.12 | **3.13 not supported** (jpegio incompatibility) |
|
||||
| RAM | 512 MB+ | 256MB for Argon2 operations |
|
||||
| Disk | ~100 MB | |
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `cryptography` | AES-256-GCM encryption |
|
||||
| `Pillow` | Image processing |
|
||||
| `argon2-cffi` | Memory-hard key derivation |
|
||||
| `scipy` | DCT transforms |
|
||||
| `jpegio` | JPEG coefficient manipulation |
|
||||
| `numpy` | Array operations |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Max image size | Tested up to 14MB |
|
||||
| Max message size | 50 KB |
|
||||
| Max file upload | 5 MB |
|
||||
| PIN length | 6-9 digits |
|
||||
| Passphrase length | 3-12 words |
|
||||
| RSA key sizes | 2048, 3072, 4096 bits |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONPATH` | - | Include `src/` for development |
|
||||
|
||||
---
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| Python | 3.10-3.12 |
|
||||
| RAM | 512 MB+ |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Format code
|
||||
black src/ frontends/
|
||||
ruff check src/ frontends/
|
||||
|
||||
# Type checking
|
||||
mypy src/
|
||||
|
||||
# Check DCT support
|
||||
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
|
||||
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(f'jpegio: {has_jpegio_support()}')"
|
||||
black src/ tests/ frontends/
|
||||
ruff check src/ tests/ frontends/
|
||||
```
|
||||
|
||||
---
|
||||
## Documentation
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Changes |
|
||||
|---------|---------|
|
||||
| **4.0.1** | Lint cleanup, test fixes, Web UI improvements (channel key dropdown, LED indicators) |
|
||||
| **4.0.0** | Channel key support for deployment isolation, removed date dependency, renamed day_phrase→passphrase, Python 3.12 requirement, JPEG normalization fix, subprocess isolation, large image support |
|
||||
| **3.2.x** | DCT color mode, JPEG output fixes |
|
||||
| **3.0.x** | Added DCT steganography mode |
|
||||
| **2.2.x** | QR code support, file embedding |
|
||||
| **2.0.x** | Web UI, REST API, RSA keys |
|
||||
| **1.0.x** | Initial release, CLI only |
|
||||
|
||||
---
|
||||
|
||||
## Upgrading from v3.x
|
||||
|
||||
### Code Changes Required
|
||||
|
||||
```python
|
||||
# Old (v3.x)
|
||||
result = encode(
|
||||
message="secret",
|
||||
day_phrase="apple forest thunder",
|
||||
date_str="2024-01-15",
|
||||
...
|
||||
)
|
||||
|
||||
# New (v4.0)
|
||||
result = encode(
|
||||
message="secret",
|
||||
passphrase="apple forest thunder mountain",
|
||||
# No date_str needed!
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### CLI Changes
|
||||
|
||||
```bash
|
||||
# Old (v3.x)
|
||||
stegasoo encode --phrase "words" --date 2024-01-15 ...
|
||||
|
||||
# New (v4.0)
|
||||
stegasoo encode --passphrase "words here more" ...
|
||||
# or short form
|
||||
stegasoo encode -p "words here more" ...
|
||||
```
|
||||
|
||||
---
|
||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||
- [CLI.md](CLI.md) - Command-line reference
|
||||
- [API.md](API.md) - REST API documentation
|
||||
- [WEB_UI.md](WEB_UI.md) - Web interface guide
|
||||
- [SECURITY.md](SECURITY.md) - Security model details
|
||||
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Use responsibly.
|
||||
MIT License - see [LICENSE](LICENSE). Use responsibly.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is for educational and legitimate privacy purposes only. Users are responsible for complying with applicable laws in their jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions
|
||||
- **[CLI.md](CLI.md)** - Command-line interface reference
|
||||
- **[API.md](API.md)** - REST API documentation
|
||||
- **[WEB_UI.md](WEB_UI.md)** - Web interface guide
|
||||
- **[SECURITY.md](SECURITY.md)** - Security model and threat analysis
|
||||
- **[UNDER_THE_HOOD.md](UNDER_THE_HOOD.md)** - Technical implementation details
|
||||
*This tool is for educational and legitimate privacy purposes. Users are responsible for complying with applicable laws.*
|
||||
|
||||
138
WEB_UI.md
138
WEB_UI.md
@@ -1,11 +1,12 @@
|
||||
# Stegasoo Web UI Documentation (v4.0.1)
|
||||
# Stegasoo Web UI Documentation (v4.0.2)
|
||||
|
||||
Complete guide for the Stegasoo web-based steganography interface.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What's New in v4.0.1](#whats-new-in-v401)
|
||||
- [What's New in v4.0.2](#whats-new-in-v402)
|
||||
- [Authentication & HTTPS](#authentication--https)
|
||||
- [Installation & Setup](#installation--setup)
|
||||
- [Pages & Features](#pages--features)
|
||||
- [Home Page](#home-page)
|
||||
@@ -53,24 +54,118 @@ Built with Flask, Bootstrap 5, and a modern dark theme.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.1
|
||||
## What's New in v4.0.2
|
||||
|
||||
Version 4.0.1 adds channel key support and UI improvements:
|
||||
Version 4.0.2 adds authentication and HTTPS support for secure home network deployment:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Channel keys | 256-bit keys for deployment/group isolation |
|
||||
| Channel dropdown | Select channel mode (Auto/Public/Custom) |
|
||||
| LED indicators | Visual status indicators for form fields |
|
||||
| Key capsule styling | Improved RSA key display |
|
||||
| Streamlined layout | PIN + Channel key in same row |
|
||||
| **Authentication** | Single-admin login with SQLite3 user storage |
|
||||
| **First-run setup** | Wizard to create admin account on first access |
|
||||
| **Account management** | Change password page |
|
||||
| **Optional HTTPS** | Auto-generated self-signed certificates |
|
||||
| **UI improvements** | Larger QR previews, consistent panel styling |
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Channel key isolation - Different teams/deployments can't read each other's messages
|
||||
- ✅ Dropdown selection for channel mode instead of radio buttons
|
||||
- ✅ Visual LED indicators show field status
|
||||
- ✅ Cleaner form layout with improved spacing
|
||||
- ✅ Backward compatible - public mode works without channel key
|
||||
- ✅ Secure your Web UI with username/password
|
||||
- ✅ No manual database setup - automatic on first run
|
||||
- ✅ HTTPS with auto-generated certs for home networks
|
||||
- ✅ Configurable via environment variables
|
||||
- ✅ Improved readability of QR preview panels
|
||||
|
||||
---
|
||||
|
||||
## Authentication & HTTPS
|
||||
|
||||
### Overview
|
||||
|
||||
v4.0.2 adds optional authentication and HTTPS for secure home network deployment.
|
||||
|
||||
### First-Run Setup
|
||||
|
||||
On first access, you'll be prompted to create an admin account:
|
||||
|
||||
1. Navigate to `http://localhost:5000`
|
||||
2. You'll be redirected to `/setup`
|
||||
3. Enter a username (e.g., "admin")
|
||||
4. Enter a password (minimum 8 characters)
|
||||
5. Confirm the password
|
||||
6. Click "Create Admin Account"
|
||||
|
||||
The admin account is stored in `frontends/web/instance/stegasoo.db` (SQLite).
|
||||
|
||||
### Login
|
||||
|
||||
After setup, protected pages require login:
|
||||
|
||||
- **Protected routes:** `/encode`, `/decode`, `/generate`, `/account`, `/api/*`
|
||||
- **Public routes:** `/`, `/about`, `/login`, `/setup`
|
||||
|
||||
### Account Management
|
||||
|
||||
Access `/account` to:
|
||||
- View current username
|
||||
- Change your password
|
||||
- Logout
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate generation |
|
||||
|
||||
### Enabling HTTPS
|
||||
|
||||
```bash
|
||||
# Enable HTTPS
|
||||
export STEGASOO_HTTPS_ENABLED=true
|
||||
export STEGASOO_HOSTNAME=stegasoo.local # Optional: your hostname
|
||||
|
||||
cd frontends/web
|
||||
python app.py
|
||||
```
|
||||
|
||||
On first run with HTTPS enabled:
|
||||
- Generates RSA 2048-bit private key
|
||||
- Creates self-signed X.509 certificate (365 days validity)
|
||||
- Stores in `frontends/web/certs/`
|
||||
- Server starts on https://localhost:5000
|
||||
|
||||
**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use.
|
||||
|
||||
### Disabling Authentication
|
||||
|
||||
For development or trusted networks:
|
||||
|
||||
```bash
|
||||
export STEGASOO_AUTH_ENABLED=false
|
||||
cd frontends/web
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
STEGASOO_AUTH_ENABLED: "true"
|
||||
STEGASOO_HTTPS_ENABLED: "true"
|
||||
STEGASOO_HOSTNAME: "stegasoo.local"
|
||||
volumes:
|
||||
- ./instance:/app/frontends/web/instance # Persist user database
|
||||
- ./certs:/app/frontends/web/certs # Persist SSL certs
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Passwords are hashed with Argon2id (time_cost=3, memory_cost=64MB)
|
||||
- Single admin user only (no registration)
|
||||
- Session-based authentication using Flask sessions
|
||||
- Database stored in `instance/stegasoo.db` (add to `.gitignore`)
|
||||
|
||||
---
|
||||
|
||||
@@ -752,6 +847,10 @@ Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivat
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONPATH` | - | Include `src/` for development |
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication (v4.0.2) |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs (v4.0.2) |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate CN (v4.0.2) |
|
||||
| `STEGASOO_CHANNEL_KEY` | - | Channel key for deployment isolation |
|
||||
|
||||
### Application Limits
|
||||
|
||||
@@ -808,12 +907,23 @@ services:
|
||||
target: web
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
STEGASOO_AUTH_ENABLED: "true"
|
||||
STEGASOO_HTTPS_ENABLED: "false"
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
volumes:
|
||||
- stegasoo-web-data:/app/frontends/web/instance
|
||||
- stegasoo-web-certs:/app/frontends/web/certs
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
reservations:
|
||||
memory: 384M
|
||||
|
||||
volumes:
|
||||
stegasoo-web-data:
|
||||
stegasoo-web-certs:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
61
build.sh
61
build.sh
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Build Script
|
||||
# Usage: ./build.sh [base|fast|full|clean]
|
||||
|
||||
set -e
|
||||
|
||||
case "${1:-fast}" in
|
||||
base)
|
||||
# Build base image with all dependencies (run once, or when deps change)
|
||||
echo "🔨 Building base image (this takes 5-10 minutes)..."
|
||||
docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
echo "✅ Base image built! Future builds will be fast."
|
||||
echo ""
|
||||
echo "Optional: Push to registry for team use:"
|
||||
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
|
||||
echo " docker push yourregistry/stegasoo-base:latest"
|
||||
;;
|
||||
|
||||
fast)
|
||||
# Fast build using pre-built base image
|
||||
if ! docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
|
||||
echo "⚠️ Base image not found. Building it first (one-time)..."
|
||||
$0 base
|
||||
fi
|
||||
echo "🚀 Fast build using base image..."
|
||||
docker-compose build
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
full)
|
||||
# Full rebuild from scratch (slow, but no base image needed)
|
||||
echo "🐢 Full build from scratch (slow)..."
|
||||
docker-compose build --no-cache
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
clean)
|
||||
# Clean up everything
|
||||
echo "🧹 Cleaning up..."
|
||||
docker-compose down --rmi local -v 2>/dev/null || true
|
||||
docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
echo "✅ Cleaned!"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Stegasoo Build Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Full rebuild from scratch (slow, no base needed)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo ""
|
||||
echo "Typical workflow:"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast (or just 'docker-compose build')"
|
||||
echo " 3. Deps change: $0 base (rebuild base image)"
|
||||
;;
|
||||
esac
|
||||
215
debug_jpegio.py
215
debug_jpegio.py
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script for DCT/jpegio extraction issues.
|
||||
Run from the stegasoo directory.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / 'src'))
|
||||
|
||||
import hashlib
|
||||
import numpy as np
|
||||
|
||||
# Check for jpegio
|
||||
try:
|
||||
import jpegio as jio
|
||||
print("✓ jpegio available")
|
||||
except ImportError:
|
||||
print("✗ jpegio NOT available")
|
||||
sys.exit(1)
|
||||
|
||||
def get_usable_positions(coef_array, min_magnitude=2):
|
||||
"""Get positions of usable coefficients."""
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
# Skip DC coefficients (top-left of each 8x8 block)
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef_array[row, col]) >= min_magnitude:
|
||||
positions.append((row, col))
|
||||
return positions
|
||||
|
||||
def generate_order(num_positions, seed):
|
||||
"""Generate pseudo-random order for coefficient selection."""
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_positions))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
def extract_bits(coef_array, positions, order, num_bits):
|
||||
"""Extract bits from coefficients."""
|
||||
bits = []
|
||||
for i, pos_idx in enumerate(order):
|
||||
if i >= num_bits:
|
||||
break
|
||||
row, col = positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
bits.append(coef & 1)
|
||||
return bits
|
||||
|
||||
def bits_to_bytes(bits):
|
||||
"""Convert list of bits to bytes."""
|
||||
result = []
|
||||
for i in range(0, len(bits), 8):
|
||||
byte_bits = bits[i:i+8]
|
||||
if len(byte_bits) == 8:
|
||||
byte_val = sum(byte_bits[j] << (7-j) for j in range(8))
|
||||
result.append(byte_val)
|
||||
return bytes(result)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python debug_jpegio.py <stego_image.jpg> <reference_photo>")
|
||||
print("\nOptional: add passphrase, pin, key path")
|
||||
print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem")
|
||||
sys.exit(1)
|
||||
|
||||
stego_path = sys.argv[1]
|
||||
ref_path = sys.argv[2]
|
||||
passphrase = sys.argv[3] if len(sys.argv) > 3 else "test"
|
||||
pin = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
key_path = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("JPEGIO DCT EXTRACTION DEBUG")
|
||||
print(f"{'='*60}")
|
||||
print(f"Stego image: {stego_path}")
|
||||
print(f"Reference: {ref_path}")
|
||||
print(f"Passphrase: '{passphrase}'")
|
||||
print(f"PIN: '{pin}'")
|
||||
print(f"Key: {key_path}")
|
||||
|
||||
# Load stego image with jpegio
|
||||
print(f"\n[1] Loading stego image with jpegio...")
|
||||
try:
|
||||
jpeg = jio.read(stego_path)
|
||||
print(f" ✓ jpegio.read() succeeded")
|
||||
print(f" Number of components: {len(jpeg.coef_arrays)}")
|
||||
for i, arr in enumerate(jpeg.coef_arrays):
|
||||
print(f" Component {i}: shape={arr.shape}, dtype={arr.dtype}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get coefficient array (channel 0)
|
||||
coef_array = jpeg.coef_arrays[0]
|
||||
print(f"\n[2] Coefficient array analysis...")
|
||||
print(f" Shape: {coef_array.shape}")
|
||||
print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}")
|
||||
print(f" Min value: {coef_array.min()}")
|
||||
print(f" Max value: {coef_array.max()}")
|
||||
|
||||
# Get usable positions
|
||||
print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...")
|
||||
positions = get_usable_positions(coef_array)
|
||||
print(f" Usable positions: {len(positions)}")
|
||||
print(f" Capacity: ~{len(positions) // 8} bytes")
|
||||
|
||||
# Generate seed (this needs to match the encode seed!)
|
||||
print(f"\n[4] Generating seed...")
|
||||
|
||||
# Load reference photo
|
||||
ref_data = Path(ref_path).read_bytes()
|
||||
ref_hash = hashlib.sha256(ref_data).digest()
|
||||
print(f" Reference hash: {ref_hash[:8].hex()}...")
|
||||
|
||||
# Load RSA key if provided
|
||||
rsa_component = b""
|
||||
if key_path:
|
||||
try:
|
||||
from stegasoo import load_rsa_key
|
||||
key_data = Path(key_path).read_bytes()
|
||||
# Try without password first
|
||||
try:
|
||||
rsa_key = load_rsa_key(key_data, password=None)
|
||||
except:
|
||||
rsa_key = load_rsa_key(key_data, password="testpass")
|
||||
|
||||
# Get public key bytes for seed
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
pub_bytes = rsa_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
rsa_component = hashlib.sha256(pub_bytes).digest()
|
||||
print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...")
|
||||
except Exception as e:
|
||||
print(f" ✗ Could not load RSA key: {e}")
|
||||
|
||||
# Build seed like stegasoo does
|
||||
# This is the critical part - must match encoding!
|
||||
seed_parts = [
|
||||
ref_hash,
|
||||
passphrase.encode('utf-8'),
|
||||
pin.encode('utf-8') if pin else b"",
|
||||
rsa_component,
|
||||
]
|
||||
seed = hashlib.sha256(b"".join(seed_parts)).digest()
|
||||
print(f" Combined seed: {seed[:8].hex()}...")
|
||||
|
||||
# Generate order
|
||||
print(f"\n[5] Generating coefficient order...")
|
||||
order = generate_order(len(positions), seed)
|
||||
print(f" First 10 indices: {order[:10]}")
|
||||
|
||||
# Try to extract header
|
||||
print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...")
|
||||
HEADER_SIZE = 10
|
||||
header_bits = extract_bits(coef_array, positions, order, HEADER_SIZE * 8)
|
||||
header_bytes = bits_to_bytes(header_bits)
|
||||
print(f" Raw header bytes: {header_bytes.hex()}")
|
||||
print(f" As ASCII (if printable): {repr(header_bytes)}")
|
||||
|
||||
# Check for JPGS magic
|
||||
JPEGIO_MAGIC = b'JPGS'
|
||||
if header_bytes[:4] == JPEGIO_MAGIC:
|
||||
print(f" ✓ Found JPEGIO magic bytes!")
|
||||
version = header_bytes[4]
|
||||
flags = header_bytes[5]
|
||||
data_length = struct.unpack('>I', header_bytes[6:10])[0]
|
||||
print(f" Version: {version}")
|
||||
print(f" Flags: {flags}")
|
||||
print(f" Data length: {data_length} bytes")
|
||||
|
||||
if data_length > 0 and data_length < len(positions) // 8:
|
||||
print(f"\n[7] Extracting payload ({data_length} bytes)...")
|
||||
total_bits = (HEADER_SIZE + data_length) * 8
|
||||
all_bits = extract_bits(coef_array, positions, order, total_bits)
|
||||
data_bits = all_bits[HEADER_SIZE * 8:]
|
||||
payload = bits_to_bytes(data_bits)
|
||||
print(f" Payload (first 64 bytes): {payload[:64].hex()}")
|
||||
print(f" This should be encrypted data starting with salt/IV")
|
||||
else:
|
||||
print(f" ✗ Invalid data length: {data_length}")
|
||||
else:
|
||||
print(f" ✗ No JPEGIO magic found")
|
||||
print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')")
|
||||
print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')")
|
||||
|
||||
# Try alternate interpretations
|
||||
print(f"\n[7] Trying alternate header interpretations...")
|
||||
|
||||
# Maybe it's scipy DCT format?
|
||||
DCT_MAGIC = b'DCTS'
|
||||
if header_bytes[:4] == DCT_MAGIC:
|
||||
print(f" Found SCIPY DCT magic - wrong extraction method!")
|
||||
else:
|
||||
# Show bit distribution
|
||||
print(f" First 32 extracted bits: {header_bits[:32]}")
|
||||
|
||||
# Check if bits look random or patterned
|
||||
ones = sum(header_bits[:80])
|
||||
print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("DEBUG COMPLETE")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -18,6 +18,14 @@ services:
|
||||
environment:
|
||||
<<: *common-env
|
||||
FLASK_ENV: production
|
||||
# Authentication (v4.0.2)
|
||||
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
|
||||
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
|
||||
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
|
||||
volumes:
|
||||
# Persist auth database and SSL certs (v4.0.2)
|
||||
- stegasoo-web-data:/app/frontends/web/instance
|
||||
- stegasoo-web-certs:/app/frontends/web/certs
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
@@ -45,3 +53,10 @@ services:
|
||||
memory: 768M
|
||||
reservations:
|
||||
memory: 384M
|
||||
|
||||
# Named volumes for persistent data
|
||||
volumes:
|
||||
stegasoo-web-data:
|
||||
driver: local
|
||||
stegasoo-web-certs:
|
||||
driver: local
|
||||
|
||||
48
examples/README.md
Normal file
48
examples/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Stegasoo Examples
|
||||
|
||||
This directory contains example scripts demonstrating how to use Stegasoo.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Stegasoo first:
|
||||
|
||||
```bash
|
||||
pip install stegasoo
|
||||
# Or for development:
|
||||
pip install -e ".[all]"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### basic_usage.py
|
||||
|
||||
Basic encode/decode workflow with a text message.
|
||||
|
||||
```bash
|
||||
python basic_usage.py
|
||||
```
|
||||
|
||||
### embed_file.py
|
||||
|
||||
Embed and extract files (documents, images, etc.) inside carrier images.
|
||||
|
||||
```bash
|
||||
python embed_file.py
|
||||
```
|
||||
|
||||
### channel_keys.py
|
||||
|
||||
Use channel keys to create private communication channels for groups.
|
||||
|
||||
```bash
|
||||
python channel_keys.py
|
||||
```
|
||||
|
||||
## Test Images
|
||||
|
||||
You'll need to provide your own images:
|
||||
|
||||
- `my_secret_photo.png` - Your reference photo (keep this secret!)
|
||||
- `carrier.png` - The image that will carry your hidden message
|
||||
|
||||
For testing, you can use any PNG or BMP image. JPEG carriers are supported with DCT mode.
|
||||
59
examples/basic_usage.py
Normal file
59
examples/basic_usage.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Stegasoo Usage Example
|
||||
|
||||
This example demonstrates how to encode and decode a secret message
|
||||
using the Stegasoo library.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import stegasoo
|
||||
|
||||
|
||||
def main():
|
||||
# Load your images
|
||||
# The reference photo is your "key" - keep it secret!
|
||||
reference_photo = Path("my_secret_photo.png").read_bytes()
|
||||
carrier_image = Path("carrier.png").read_bytes()
|
||||
|
||||
# Your secret message
|
||||
message = "This is my secret message!"
|
||||
|
||||
# Your credentials
|
||||
passphrase = "correct horse battery staple" # Use 4+ words
|
||||
pin = "123456" # 6-9 digits
|
||||
|
||||
# === ENCODE ===
|
||||
print("Encoding message...")
|
||||
result = stegasoo.encode(
|
||||
message=message,
|
||||
reference_photo=reference_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
# Save the stego image
|
||||
output_path = Path(f"secret_{result.suggested_filename}")
|
||||
output_path.write_bytes(result.stego_image)
|
||||
print(f"Saved to: {output_path}")
|
||||
print(f"Capacity used: {result.capacity_used_percent:.1f}%")
|
||||
|
||||
# === DECODE ===
|
||||
print("\nDecoding message...")
|
||||
stego_image = output_path.read_bytes()
|
||||
|
||||
decoded = stegasoo.decode(
|
||||
stego_image=stego_image,
|
||||
reference_photo=reference_photo,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
print(f"Decoded message: {decoded.message}")
|
||||
print(f"Message type: {decoded.payload_type}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
72
examples/channel_keys.py
Normal file
72
examples/channel_keys.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Channel Keys Example
|
||||
|
||||
Channel keys allow you to create private communication channels.
|
||||
Only people with the same channel key can decode messages.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import stegasoo
|
||||
from stegasoo.channel import generate_channel_key, get_channel_fingerprint
|
||||
|
||||
|
||||
def main():
|
||||
# Generate a channel key for your group
|
||||
channel_key = generate_channel_key()
|
||||
fingerprint = get_channel_fingerprint(channel_key)
|
||||
|
||||
print("=== Channel Key Generated ===")
|
||||
print(f"Key: {channel_key}")
|
||||
print(f"Fingerprint: {fingerprint}")
|
||||
print("\nShare this key securely with your group members!")
|
||||
print("-" * 40)
|
||||
|
||||
# Load images
|
||||
reference_photo = Path("my_secret_photo.png").read_bytes()
|
||||
carrier_image = Path("carrier.png").read_bytes()
|
||||
|
||||
# Encode with channel key
|
||||
print("\nEncoding message with channel key...")
|
||||
result = stegasoo.encode(
|
||||
message="Secret group message!",
|
||||
reference_photo=reference_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
channel_key=channel_key, # Add the channel key
|
||||
)
|
||||
|
||||
stego_data = result.stego_image
|
||||
print(f"Encoded successfully!")
|
||||
|
||||
# Decode with correct channel key
|
||||
print("\nDecoding with correct channel key...")
|
||||
decoded = stegasoo.decode(
|
||||
stego_image=stego_data,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
channel_key=channel_key, # Same channel key
|
||||
)
|
||||
print(f"Message: {decoded.message}")
|
||||
|
||||
# Try to decode with wrong channel key
|
||||
print("\nTrying to decode with wrong channel key...")
|
||||
wrong_key = generate_channel_key()
|
||||
try:
|
||||
stegasoo.decode(
|
||||
stego_image=stego_data,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
channel_key=wrong_key, # Different channel key
|
||||
)
|
||||
print("ERROR: Should have failed!")
|
||||
except (stegasoo.DecryptionError, stegasoo.ExtractionError):
|
||||
print("Correctly rejected - wrong channel key!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
examples/embed_file.py
Normal file
78
examples/embed_file.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
File Embedding Example
|
||||
|
||||
This example demonstrates how to embed a file (like a document or image)
|
||||
inside a carrier image using Stegasoo.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import stegasoo
|
||||
from stegasoo.models import FilePayload
|
||||
|
||||
|
||||
def main():
|
||||
# Load images
|
||||
reference_photo = Path("my_secret_photo.png").read_bytes()
|
||||
carrier_image = Path("carrier.png").read_bytes()
|
||||
|
||||
# Load the file to embed
|
||||
secret_file = Path("secret_document.pdf")
|
||||
file_data = secret_file.read_bytes()
|
||||
|
||||
# Create a FilePayload
|
||||
payload = FilePayload(
|
||||
filename=secret_file.name,
|
||||
data=file_data,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
# Credentials
|
||||
passphrase = "correct horse battery staple"
|
||||
pin = "123456"
|
||||
|
||||
# Check capacity first
|
||||
capacity = stegasoo.calculate_capacity(carrier_image)
|
||||
print(f"Carrier capacity: {capacity['capacity_bytes']:,} bytes")
|
||||
print(f"File size: {len(file_data):,} bytes")
|
||||
|
||||
if len(file_data) > capacity["capacity_bytes"]:
|
||||
print("Error: File too large for this carrier!")
|
||||
return
|
||||
|
||||
# Encode the file
|
||||
print("\nEmbedding file...")
|
||||
result = stegasoo.encode(
|
||||
file_payload=payload,
|
||||
reference_photo=reference_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
output_path = Path(f"contains_file_{result.suggested_filename}")
|
||||
output_path.write_bytes(result.stego_image)
|
||||
print(f"Saved to: {output_path}")
|
||||
|
||||
# Decode and extract the file
|
||||
print("\nExtracting file...")
|
||||
decoded = stegasoo.decode(
|
||||
stego_image=output_path.read_bytes(),
|
||||
reference_photo=reference_photo,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
if decoded.payload_type == "file":
|
||||
extracted_path = Path(f"extracted_{decoded.filename}")
|
||||
extracted_path.write_bytes(decoded.file_data)
|
||||
print(f"Extracted: {extracted_path}")
|
||||
print(f"Original filename: {decoded.filename}")
|
||||
print(f"MIME type: {decoded.mime_type}")
|
||||
else:
|
||||
print(f"Unexpected payload type: {decoded.payload_type}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,31 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for
|
||||
from auth import (
|
||||
change_password,
|
||||
create_user,
|
||||
get_username,
|
||||
is_authenticated,
|
||||
login_required,
|
||||
user_exists,
|
||||
verify_password,
|
||||
)
|
||||
from auth import (
|
||||
init_app as init_auth,
|
||||
)
|
||||
from flask import (
|
||||
Flask,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from PIL import Image
|
||||
from ssl_utils import ensure_certs
|
||||
|
||||
os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0"
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
@@ -124,6 +147,13 @@ app = Flask(__name__)
|
||||
app.secret_key = secrets.token_hex(32)
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
||||
|
||||
# Auth configuration from environment
|
||||
app.config["AUTH_ENABLED"] = os.environ.get("STEGASOO_AUTH_ENABLED", "true").lower() == "true"
|
||||
app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").lower() == "true"
|
||||
|
||||
# Initialize auth module
|
||||
init_auth(app)
|
||||
|
||||
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
|
||||
TEMP_FILES: dict[str, dict] = {}
|
||||
THUMBNAIL_FILES: dict[str, bytes] = {}
|
||||
@@ -159,6 +189,10 @@ def inject_globals():
|
||||
"channel_configured": channel_status["configured"],
|
||||
"channel_fingerprint": channel_status.get("fingerprint"),
|
||||
"channel_source": channel_status.get("source"),
|
||||
# NEW in v4.0.2 - Auth state
|
||||
"auth_enabled": app.config.get("AUTH_ENABLED", True),
|
||||
"is_authenticated": is_authenticated(),
|
||||
"username": get_username() if is_authenticated() else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +330,7 @@ def index():
|
||||
|
||||
|
||||
@app.route("/api/channel/status")
|
||||
@login_required
|
||||
def api_channel_status():
|
||||
"""
|
||||
Get current channel key status (v4.0.0).
|
||||
@@ -330,6 +365,7 @@ def api_channel_status():
|
||||
|
||||
|
||||
@app.route("/api/channel/validate", methods=["POST"])
|
||||
@login_required
|
||||
def api_channel_validate():
|
||||
"""
|
||||
Validate a channel key format (v4.0.0).
|
||||
@@ -366,6 +402,7 @@ def api_channel_validate():
|
||||
|
||||
|
||||
@app.route("/generate", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def generate():
|
||||
if request.method == "POST":
|
||||
# v3.2.0: Changed from words_per_phrase to words_per_passphrase, default increased to 4
|
||||
@@ -450,6 +487,7 @@ def generate():
|
||||
|
||||
|
||||
@app.route("/generate/qr/<token>")
|
||||
@login_required
|
||||
def generate_qr(token):
|
||||
"""Generate QR code for RSA key."""
|
||||
if not HAS_QRCODE:
|
||||
@@ -473,6 +511,7 @@ def generate_qr(token):
|
||||
|
||||
|
||||
@app.route("/generate/qr-download/<token>")
|
||||
@login_required
|
||||
def generate_qr_download(token):
|
||||
"""Download QR code as PNG file."""
|
||||
if not HAS_QRCODE:
|
||||
@@ -501,6 +540,7 @@ def generate_qr_download(token):
|
||||
|
||||
|
||||
@app.route("/qr/crop", methods=["POST"])
|
||||
@login_required
|
||||
def qr_crop():
|
||||
"""
|
||||
Detect and crop QR code from an image.
|
||||
@@ -538,6 +578,7 @@ def qr_crop():
|
||||
|
||||
|
||||
@app.route("/generate/download-key", methods=["POST"])
|
||||
@login_required
|
||||
def download_key():
|
||||
"""Download RSA key as password-protected PEM file."""
|
||||
key_pem = request.form.get("key_pem", "")
|
||||
@@ -570,6 +611,7 @@ def download_key():
|
||||
|
||||
|
||||
@app.route("/extract-key-from-qr", methods=["POST"])
|
||||
@login_required
|
||||
def extract_key_from_qr_route():
|
||||
"""
|
||||
Extract RSA key from uploaded QR code image.
|
||||
@@ -609,6 +651,7 @@ def extract_key_from_qr_route():
|
||||
|
||||
|
||||
@app.route("/api/compare-capacity", methods=["POST"])
|
||||
@login_required
|
||||
def api_compare_capacity():
|
||||
"""
|
||||
Compare LSB and DCT capacity for an uploaded carrier image.
|
||||
@@ -652,6 +695,7 @@ def api_compare_capacity():
|
||||
|
||||
|
||||
@app.route("/api/check-fit", methods=["POST"])
|
||||
@login_required
|
||||
def api_check_fit():
|
||||
"""
|
||||
Check if a payload will fit in the carrier with selected mode.
|
||||
@@ -705,6 +749,7 @@ def api_check_fit():
|
||||
|
||||
|
||||
@app.route("/encode", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def encode_page():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
@@ -926,6 +971,7 @@ def encode_page():
|
||||
|
||||
|
||||
@app.route("/encode/result/<file_id>")
|
||||
@login_required
|
||||
def encode_result(file_id):
|
||||
if file_id not in TEMP_FILES:
|
||||
flash("File expired or not found. Please encode again.", "error")
|
||||
@@ -956,6 +1002,7 @@ def encode_result(file_id):
|
||||
|
||||
|
||||
@app.route("/encode/thumbnail/<thumb_id>")
|
||||
@login_required
|
||||
def encode_thumbnail(thumb_id):
|
||||
"""Serve thumbnail image."""
|
||||
if thumb_id not in THUMBNAIL_FILES:
|
||||
@@ -967,6 +1014,7 @@ def encode_thumbnail(thumb_id):
|
||||
|
||||
|
||||
@app.route("/encode/download/<file_id>")
|
||||
@login_required
|
||||
def encode_download(file_id):
|
||||
if file_id not in TEMP_FILES:
|
||||
flash("File expired or not found.", "error")
|
||||
@@ -984,6 +1032,7 @@ def encode_download(file_id):
|
||||
|
||||
|
||||
@app.route("/encode/file/<file_id>")
|
||||
@login_required
|
||||
def encode_file_route(file_id):
|
||||
"""Serve file for Web Share API."""
|
||||
if file_id not in TEMP_FILES:
|
||||
@@ -1001,6 +1050,7 @@ def encode_file_route(file_id):
|
||||
|
||||
|
||||
@app.route("/encode/cleanup/<file_id>", methods=["POST"])
|
||||
@login_required
|
||||
def encode_cleanup(file_id):
|
||||
"""Manually cleanup a file after sharing."""
|
||||
TEMP_FILES.pop(file_id, None)
|
||||
@@ -1018,6 +1068,7 @@ def encode_cleanup(file_id):
|
||||
|
||||
|
||||
@app.route("/decode", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def decode_page():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
@@ -1170,6 +1221,7 @@ def decode_page():
|
||||
|
||||
|
||||
@app.route("/decode/download/<file_id>")
|
||||
@login_required
|
||||
def decode_download(file_id):
|
||||
"""Download decoded file."""
|
||||
if file_id not in TEMP_FILES:
|
||||
@@ -1245,9 +1297,117 @@ def test_capacity_nopil():
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION ROUTES (v4.0.2)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page."""
|
||||
if not app.config.get("AUTH_ENABLED", True):
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if not user_exists():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
if is_authenticated():
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if verify_password(password):
|
||||
session["authenticated"] = True
|
||||
session.permanent = True
|
||||
flash("Login successful", "success")
|
||||
return redirect(url_for("index"))
|
||||
else:
|
||||
flash("Invalid password", "error")
|
||||
|
||||
return render_template("login.html", username=get_username())
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
"""Logout and clear session."""
|
||||
session.clear()
|
||||
flash("Logged out successfully", "success")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/setup", methods=["GET", "POST"])
|
||||
def setup():
|
||||
"""First-run setup page."""
|
||||
if not app.config.get("AUTH_ENABLED", True):
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if user_exists():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "admin")
|
||||
password = request.form.get("password", "")
|
||||
password_confirm = request.form.get("password_confirm", "")
|
||||
|
||||
if len(password) < 8:
|
||||
flash("Password must be at least 8 characters", "error")
|
||||
elif password != password_confirm:
|
||||
flash("Passwords do not match", "error")
|
||||
else:
|
||||
try:
|
||||
create_user(username, password)
|
||||
session["authenticated"] = True
|
||||
session.permanent = True
|
||||
flash("Admin account created successfully!", "success")
|
||||
return redirect(url_for("index"))
|
||||
except Exception as e:
|
||||
flash(f"Error creating account: {e}", "error")
|
||||
|
||||
return render_template("setup.html")
|
||||
|
||||
|
||||
@app.route("/account", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def account():
|
||||
"""Account management page."""
|
||||
if request.method == "POST":
|
||||
current = request.form.get("current_password", "")
|
||||
new = request.form.get("new_password", "")
|
||||
new_confirm = request.form.get("new_password_confirm", "")
|
||||
|
||||
if new != new_confirm:
|
||||
flash("New passwords do not match", "error")
|
||||
else:
|
||||
success, message = change_password(current, new)
|
||||
flash(message, "success" if success else "error")
|
||||
|
||||
return render_template("account.html", username=get_username())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
# HTTPS configuration
|
||||
ssl_context = None
|
||||
if app.config.get("HTTPS_ENABLED", False):
|
||||
hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost")
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
ssl_context = (str(cert_path), str(key_path))
|
||||
print(f"HTTPS enabled with self-signed certificate for {hostname}")
|
||||
|
||||
# Auth status
|
||||
if app.config.get("AUTH_ENABLED", True):
|
||||
print("Authentication enabled")
|
||||
else:
|
||||
print("Authentication disabled")
|
||||
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=5000,
|
||||
debug=False,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
162
frontends/web/auth.py
Normal file
162
frontends/web/auth.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Stegasoo Authentication Module
|
||||
|
||||
Single-admin authentication with Argon2 password hashing.
|
||||
Uses Flask sessions for authentication state and SQLite3 for storage.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from flask import current_app, g, redirect, session, url_for
|
||||
|
||||
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
|
||||
ph = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536, # 64MB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
"""Get database path in Flask instance folder."""
|
||||
instance_path = Path(current_app.instance_path)
|
||||
instance_path.mkdir(parents=True, exist_ok=True)
|
||||
return instance_path / "stegasoo.db"
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Get database connection, cached on Flask g object."""
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(get_db_path())
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(e=None):
|
||||
"""Close database connection at end of request."""
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database schema."""
|
||||
db = get_db()
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS admin_user (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def user_exists() -> bool:
|
||||
"""Check if admin user has been created."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def create_user(username: str, password: str):
|
||||
"""Create admin user (first-run setup)."""
|
||||
if user_exists():
|
||||
raise ValueError("Admin user already exists")
|
||||
|
||||
password_hash = ph.hash(password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_username() -> str:
|
||||
"""Get the admin username."""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone()
|
||||
return row["username"] if row else "admin"
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""Verify password against stored hash."""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT password_hash FROM admin_user WHERE id = 1").fetchone()
|
||||
if not row:
|
||||
return False
|
||||
try:
|
||||
ph.verify(row["password_hash"], password)
|
||||
# Rehash if parameters changed
|
||||
if ph.check_needs_rehash(row["password_hash"]):
|
||||
new_hash = ph.hash(password)
|
||||
db.execute(
|
||||
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
|
||||
(new_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return True
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def change_password(current_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change admin password. Returns (success, message)."""
|
||||
if not verify_password(current_password):
|
||||
return False, "Current password is incorrect"
|
||||
|
||||
if len(new_password) < 8:
|
||||
return False, "New password must be at least 8 characters"
|
||||
|
||||
new_hash = ph.hash(new_password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
|
||||
(new_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return True, "Password changed successfully"
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
"""Check if current session is authenticated."""
|
||||
return session.get("authenticated", False)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for a route."""
|
||||
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if auth is enabled
|
||||
if not current_app.config.get("AUTH_ENABLED", True):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Check for first-run setup
|
||||
if not user_exists():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
# Check authentication
|
||||
if not is_authenticated():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Initialize auth module with Flask app."""
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
111
frontends/web/ssl_utils.py
Normal file
111
frontends/web/ssl_utils.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
SSL Certificate Utilities
|
||||
|
||||
Auto-generates self-signed certificates for HTTPS.
|
||||
Uses cryptography library (already a dependency).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
|
||||
"""Get paths for cert and key files."""
|
||||
cert_dir = base_dir / "certs"
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cert_dir / "server.crt", cert_dir / "server.key"
|
||||
|
||||
|
||||
def certs_exist(base_dir: Path) -> bool:
|
||||
"""Check if both cert files exist."""
|
||||
cert_path, key_path = get_cert_paths(base_dir)
|
||||
return cert_path.exists() and key_path.exists()
|
||||
|
||||
|
||||
def generate_self_signed_cert(
|
||||
base_dir: Path,
|
||||
hostname: str = "localhost",
|
||||
days_valid: int = 365,
|
||||
) -> tuple[Path, Path]:
|
||||
"""
|
||||
Generate self-signed SSL certificate.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for certs folder
|
||||
hostname: Server hostname for certificate
|
||||
days_valid: Certificate validity in days
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_path, key_path)
|
||||
"""
|
||||
cert_path, key_path = get_cert_paths(base_dir)
|
||||
|
||||
# Generate RSA key
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
|
||||
# Subject Alternative Names
|
||||
san_list = [
|
||||
x509.DNSName(hostname),
|
||||
x509.DNSName("localhost"),
|
||||
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
||||
]
|
||||
# Add the hostname as IP if it looks like one
|
||||
try:
|
||||
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
||||
except ipaddress.AddressValueError:
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=days_valid))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(san_list),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
|
||||
# Write key file (chmod 600)
|
||||
key_path.write_bytes(
|
||||
key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# Write cert file
|
||||
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def ensure_certs(base_dir: Path, hostname: str = "localhost") -> tuple[Path, Path]:
|
||||
"""Ensure certificates exist, generating if needed."""
|
||||
if certs_exist(base_dir):
|
||||
return get_cert_paths(base_dir)
|
||||
|
||||
print(f"Generating self-signed SSL certificate for {hostname}...")
|
||||
return generate_self_signed_cert(base_dir, hostname)
|
||||
@@ -724,6 +724,7 @@ footer {
|
||||
.scan-data-value {
|
||||
color: rgba(0, 255, 170, 1);
|
||||
font-weight: 600;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.scan-hash-preview {
|
||||
@@ -744,7 +745,7 @@ footer {
|
||||
border: 1px solid rgba(0, 255, 170, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(0, 255, 170, 1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -1001,6 +1002,7 @@ footer {
|
||||
.pixel-data-value {
|
||||
color: #d4e157;
|
||||
font-weight: 600;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.pixel-status-badge {
|
||||
@@ -1010,7 +1012,7 @@ footer {
|
||||
border: 1px solid rgba(212, 225, 87, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.55rem;
|
||||
font-size: 0.65rem;
|
||||
color: #d4e157;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -1047,10 +1049,10 @@ footer {
|
||||
/* Expand drop zone when showing scanned QR result */
|
||||
#rsaQrSection .drop-zone:has(.qr-scan-container:not(.d-none)) {
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
min-height: 200px;
|
||||
min-height: 280px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
@@ -1070,9 +1072,9 @@ footer {
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
min-height: 160px;
|
||||
min-width: 160px;
|
||||
padding: 10px;
|
||||
min-height: 220px;
|
||||
min-width: 220px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -1090,10 +1092,10 @@ footer {
|
||||
|
||||
/* Cropped image - hidden until loaded, scales UP to fill container */
|
||||
.qr-scan-container .qr-cropped {
|
||||
max-height: 180px;
|
||||
max-width: 180px;
|
||||
min-width: 140px;
|
||||
min-height: 140px;
|
||||
max-height: 240px;
|
||||
max-width: 240px;
|
||||
min-width: 180px;
|
||||
min-height: 180px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
@@ -1259,7 +1261,7 @@ footer {
|
||||
rgba(10, 15, 30, 0.95) 0%,
|
||||
rgba(10, 15, 30, 0.6) 80%,
|
||||
transparent 100%);
|
||||
padding: 4px 6px 3px 6px;
|
||||
padding: 8px 10px 6px 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
border-radius: 0 0 6px 6px;
|
||||
@@ -1282,10 +1284,10 @@ footer {
|
||||
/* QR Data Panel text styles */
|
||||
.qr-data-filename {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1301,7 +1303,7 @@ footer {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1310,9 +1312,9 @@ footer {
|
||||
align-items: center;
|
||||
background: rgba(0, 255, 170, 0.15);
|
||||
border: 1px solid rgba(0, 255, 170, 0.4);
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.45rem;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(0, 255, 170, 1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -1321,7 +1323,7 @@ footer {
|
||||
.qr-data-value {
|
||||
color: rgba(0, 255, 170, 1);
|
||||
font-weight: 600;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
|
||||
102
frontends/web/templates/account.html
Normal file
102
frontends/web/templates/account.html
Normal file
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Account - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Account Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-4">
|
||||
Logged in as <strong>{{ username }}</strong>
|
||||
</p>
|
||||
|
||||
<h6 class="text-muted mb-3">Change Password</h6>
|
||||
|
||||
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> Current Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="current_password" class="form-control"
|
||||
id="currentPasswordInput" required>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('currentPasswordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> New Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_password" class="form-control"
|
||||
id="newPasswordInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('newPasswordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Minimum 8 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Confirm New Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_password_confirm" class="form-control"
|
||||
id="newPasswordConfirmInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('newPasswordConfirmInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-lg me-2"></i>Update Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
|
||||
<i class="bi bi-box-arrow-left me-2"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
|
||||
const newPass = document.getElementById('newPasswordInput').value;
|
||||
const confirm = document.getElementById('newPasswordConfirmInput').value;
|
||||
if (newPass !== confirm) {
|
||||
e.preventDefault();
|
||||
alert('New passwords do not match');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -24,20 +24,38 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
|
||||
</li>
|
||||
{% if not auth_enabled or is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
||||
</li>
|
||||
{% if auth_enabled %}
|
||||
{% if is_authenticated %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-1"></i> {{ username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/login"><i class="bi bi-box-arrow-in-right me-1"></i> Login</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,11 +327,11 @@
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
@@ -340,7 +340,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
|
||||
@@ -394,11 +394,11 @@
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
@@ -407,7 +407,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
|
||||
61
frontends/web/templates/login.html
Normal file
61
frontends/web/templates/login.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">Login</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('login') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person me-1"></i> Username
|
||||
</label>
|
||||
<input type="text" name="username" class="form-control"
|
||||
value="{{ username }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password" class="form-control"
|
||||
id="passwordInput" required autofocus>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('passwordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
94
frontends/web/templates/setup.html
Normal file
94
frontends/web/templates/setup.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Setup - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-gear-fill fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">Initial Setup</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted text-center mb-4">
|
||||
Welcome to Stegasoo! Create your admin account to get started.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('setup') }}" id="setupForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person me-1"></i> Username
|
||||
</label>
|
||||
<input type="text" name="username" class="form-control"
|
||||
value="admin" required minlength="3">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password" class="form-control"
|
||||
id="passwordInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('passwordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Minimum 8 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Confirm Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password_confirm" class="form-control"
|
||||
id="passwordConfirmInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('passwordConfirmInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-lg me-2"></i>Create Admin Account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4 small">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This is a single-user setup. The admin account has full access to all features.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
|
||||
const pass = document.getElementById('passwordInput').value;
|
||||
const confirm = document.getElementById('passwordConfirmInput').value;
|
||||
if (pass !== confirm) {
|
||||
e.preventDefault();
|
||||
alert('Passwords do not match');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd ./frontends/web/
|
||||
python app.py
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
sudo docker-compose down
|
||||
sudo docker-compose build
|
||||
sudo docker-compose up -d
|
||||
@@ -1,9 +1,14 @@
|
||||
"""
|
||||
Stegasoo Constants and Configuration (v4.0.1 - Channel Key Support)
|
||||
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication)
|
||||
|
||||
Central location for all magic numbers, limits, and crypto parameters.
|
||||
All version numbers, limits, and configuration values should be defined here.
|
||||
|
||||
CHANGES in v4.0.2:
|
||||
- Added Web UI authentication with SQLite3 user storage
|
||||
- Added optional HTTPS with auto-generated self-signed certificates
|
||||
- UI improvements for QR preview panels and PIN/channel columns
|
||||
|
||||
BREAKING CHANGES in v4.0.0:
|
||||
- Added channel key support for deployment/group isolation
|
||||
- FORMAT_VERSION bumped to 5 (adds flags byte to header)
|
||||
@@ -20,7 +25,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "4.0.1"
|
||||
__version__ = "4.0.2"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
|
||||
@@ -1,974 +0,0 @@
|
||||
"""
|
||||
DCT Domain Steganography Module (v3.2.0)
|
||||
|
||||
Embeds data in DCT coefficients with two approaches:
|
||||
1. PNG output: Scipy-based DCT transform (grayscale or color)
|
||||
2. JPEG output: jpegio-based coefficient manipulation (if available)
|
||||
|
||||
The JPEG approach is the "correct" way to do JPEG steganography because
|
||||
it directly modifies the already-quantized coefficients without re-encoding.
|
||||
|
||||
Changes in v3.0.2:
|
||||
- jpegio integration for proper JPEG coefficient embedding
|
||||
- Falls back to warning if jpegio not available for JPEG output
|
||||
- Maintains backward compatibility with v3.0.1
|
||||
|
||||
Changes in v3.2.0:
|
||||
- Fixed color-mode extraction to properly extract from Y channel
|
||||
- Added _extract_from_y_channel() for accurate color-mode extraction
|
||||
- Improved extraction robustness for both grayscale and color modes
|
||||
|
||||
Requires: scipy (for PNG mode), optionally jpegio (for JPEG mode)
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Literal, Tuple
|
||||
from enum import Enum
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Check for scipy availability (for PNG/DCT mode)
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
HAS_SCIPY = False
|
||||
dct = None
|
||||
idct = None
|
||||
|
||||
# Check for jpegio availability (for proper JPEG mode)
|
||||
try:
|
||||
import jpegio as jio
|
||||
HAS_JPEGIO = True
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
jio = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTS
|
||||
# ============================================================================
|
||||
|
||||
# DCT block size (standard 8x8 like JPEG)
|
||||
BLOCK_SIZE = 8
|
||||
|
||||
# Coefficients to use for embedding (mid-frequency, zig-zag order positions)
|
||||
EMBED_POSITIONS = [
|
||||
(0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0),
|
||||
(4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2),
|
||||
(4, 1), (5, 0), (5, 1), (4, 2), (3, 3), (2, 4), (1, 5), (0, 6), (0, 7),
|
||||
(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (7, 0),
|
||||
]
|
||||
|
||||
# Use subset of mid-frequency coefficients for better robustness
|
||||
DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] # 16 coefficients per block
|
||||
|
||||
# Quantization step for QIM embedding (larger = more robust, more visible)
|
||||
QUANT_STEP = 25
|
||||
|
||||
# Magic bytes for DCT stego identification
|
||||
DCT_MAGIC = b'DCTS'
|
||||
|
||||
# Header size: magic(4) + version(1) + flags(1) + length(4) = 10 bytes
|
||||
HEADER_SIZE = 10
|
||||
|
||||
# Output format options
|
||||
OUTPUT_FORMAT_PNG = 'png'
|
||||
OUTPUT_FORMAT_JPEG = 'jpeg'
|
||||
|
||||
# JPEG output quality (only for fallback mode, not jpegio)
|
||||
JPEG_OUTPUT_QUALITY = 95
|
||||
|
||||
# jpegio constants for JPEG coefficient embedding
|
||||
JPEGIO_MAGIC = b'JPGS'
|
||||
JPEGIO_MIN_COEF_MAGNITUDE = 2
|
||||
JPEGIO_EMBED_CHANNEL = 0 # Y channel
|
||||
|
||||
# Flag bits for header
|
||||
FLAG_COLOR_MODE = 0x01 # Set if embedded in color mode (Y channel of YCbCr)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATA CLASSES
|
||||
# ============================================================================
|
||||
|
||||
class DCTOutputFormat(Enum):
|
||||
"""Output format for DCT stego images."""
|
||||
PNG = 'png'
|
||||
JPEG = 'jpeg'
|
||||
|
||||
|
||||
@dataclass
|
||||
class DCTEmbedStats:
|
||||
"""Statistics from DCT embedding operation."""
|
||||
blocks_used: int
|
||||
blocks_available: int
|
||||
bits_embedded: int
|
||||
capacity_bits: int
|
||||
usage_percent: float
|
||||
image_width: int
|
||||
image_height: int
|
||||
output_format: str
|
||||
jpeg_native: bool = False # True if used jpegio for proper JPEG embedding
|
||||
color_mode: str = 'grayscale' # 'color' or 'grayscale' (v3.0.1+)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DCTCapacityInfo:
|
||||
"""Capacity information for a carrier image."""
|
||||
width: int
|
||||
height: int
|
||||
blocks_x: int
|
||||
blocks_y: int
|
||||
total_blocks: int
|
||||
bits_per_block: int
|
||||
total_capacity_bits: int
|
||||
total_capacity_bytes: int
|
||||
usable_capacity_bytes: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AVAILABILITY CHECKS
|
||||
# ============================================================================
|
||||
|
||||
def _check_scipy():
|
||||
"""Raise ImportError if scipy is not available."""
|
||||
if not HAS_SCIPY:
|
||||
raise ImportError(
|
||||
"DCT steganography requires scipy. "
|
||||
"Install with: pip install scipy"
|
||||
)
|
||||
|
||||
|
||||
def has_dct_support() -> bool:
|
||||
"""Check if DCT steganography is available (scipy installed)."""
|
||||
return HAS_SCIPY
|
||||
|
||||
|
||||
def has_jpegio_support() -> bool:
|
||||
"""Check if jpegio is available for proper JPEG coefficient embedding."""
|
||||
return HAS_JPEGIO
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCIPY DCT HELPERS (for PNG output)
|
||||
# ============================================================================
|
||||
|
||||
def _dct2(block: np.ndarray) -> np.ndarray:
|
||||
"""Apply 2D DCT to a block."""
|
||||
return dct(dct(block.T, norm='ortho').T, norm='ortho')
|
||||
|
||||
|
||||
def _idct2(block: np.ndarray) -> np.ndarray:
|
||||
"""Apply 2D inverse DCT to a block."""
|
||||
return idct(idct(block.T, norm='ortho').T, norm='ortho')
|
||||
|
||||
|
||||
def _to_grayscale(image_data: bytes) -> np.ndarray:
|
||||
"""Convert image bytes to grayscale numpy array."""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
gray = img.convert('L')
|
||||
return np.array(gray, dtype=np.float64)
|
||||
|
||||
|
||||
def _extract_y_channel(image_data: bytes) -> np.ndarray:
|
||||
"""
|
||||
Extract Y (luminance) channel from image for color-mode extraction.
|
||||
|
||||
This uses the same YCbCr conversion as embedding to ensure
|
||||
accurate extraction from color-mode stego images.
|
||||
|
||||
Args:
|
||||
image_data: Image file bytes
|
||||
|
||||
Returns:
|
||||
Y channel as float64 numpy array
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if needed
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
rgb_array = np.array(img, dtype=np.float64)
|
||||
|
||||
# Extract Y channel using ITU-R BT.601 (same as embedding)
|
||||
R = rgb_array[:, :, 0]
|
||||
G = rgb_array[:, :, 1]
|
||||
B = rgb_array[:, :, 2]
|
||||
|
||||
Y = 0.299 * R + 0.587 * G + 0.114 * B
|
||||
|
||||
return Y
|
||||
|
||||
|
||||
def _pad_to_blocks(image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]:
|
||||
"""Pad image dimensions to be divisible by block size."""
|
||||
h, w = image.shape
|
||||
new_h = ((h + 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:
|
||||
return image, (h, w)
|
||||
|
||||
padded = np.zeros((new_h, new_w), dtype=image.dtype)
|
||||
padded[:h, :w] = image
|
||||
|
||||
if new_h > h:
|
||||
padded[h:, :w] = image[h-(new_h-h):h, :w][::-1, :]
|
||||
if new_w > w:
|
||||
padded[:h, w:] = image[:h, w-(new_w-w):w][:, ::-1]
|
||||
if new_h > h and new_w > w:
|
||||
padded[h:, w:] = image[h-(new_h-h):h, w-(new_w-w):w][::-1, ::-1]
|
||||
|
||||
return padded, (h, w)
|
||||
|
||||
|
||||
def _unpad_image(image: np.ndarray, original_size: Tuple[int, int]) -> np.ndarray:
|
||||
"""Remove padding from image."""
|
||||
h, w = original_size
|
||||
return image[:h, :w]
|
||||
|
||||
|
||||
def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float:
|
||||
"""Embed a single bit into a DCT coefficient using QIM."""
|
||||
quantized = round(coef / quant_step)
|
||||
if (quantized % 2) != bit:
|
||||
if quantized % 2 == 0 and bit == 1:
|
||||
quantized += 1 if coef >= quantized * quant_step else -1
|
||||
elif quantized % 2 == 1 and bit == 0:
|
||||
quantized += 1 if coef >= quantized * quant_step else -1
|
||||
return quantized * quant_step
|
||||
|
||||
|
||||
def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int:
|
||||
"""Extract a single bit from a DCT coefficient."""
|
||||
quantized = round(coef / quant_step)
|
||||
return quantized % 2
|
||||
|
||||
|
||||
def _generate_block_order(num_blocks: int, seed: bytes) -> list:
|
||||
"""Generate pseudo-random block order from seed."""
|
||||
hash_bytes = hashlib.sha256(seed).digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_blocks))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
|
||||
def _save_stego_image(image: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
|
||||
"""Save stego image in specified format (grayscale)."""
|
||||
clipped = np.clip(image, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(clipped, mode='L')
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY,
|
||||
subsampling=0, optimize=True)
|
||||
else:
|
||||
img.save(buffer, format='PNG', optimize=True)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes:
|
||||
"""Save color RGB image in specified format."""
|
||||
clipped = np.clip(rgb_array, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(clipped, mode='RGB')
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY,
|
||||
subsampling=0, optimize=True)
|
||||
else:
|
||||
img.save(buffer, format='PNG', optimize=True)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rgb_to_ycbcr(rgb: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Convert RGB array to YCbCr components.
|
||||
|
||||
Uses ITU-R BT.601 conversion (standard for JPEG).
|
||||
|
||||
Args:
|
||||
rgb: RGB image array (H, W, 3), float64
|
||||
|
||||
Returns:
|
||||
Tuple of (Y, Cb, Cr) arrays
|
||||
"""
|
||||
R = rgb[:, :, 0]
|
||||
G = rgb[:, :, 1]
|
||||
B = rgb[:, :, 2]
|
||||
|
||||
# ITU-R BT.601 conversion
|
||||
Y = 0.299 * R + 0.587 * G + 0.114 * B
|
||||
Cb = 128 - 0.168736 * R - 0.331264 * G + 0.5 * B
|
||||
Cr = 128 + 0.5 * R - 0.418688 * G - 0.081312 * B
|
||||
|
||||
return Y, Cb, Cr
|
||||
|
||||
|
||||
def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Convert YCbCr components back to RGB array.
|
||||
|
||||
Args:
|
||||
Y: Luminance channel
|
||||
Cb: Blue-difference chroma
|
||||
Cr: Red-difference chroma
|
||||
|
||||
Returns:
|
||||
RGB array (H, W, 3)
|
||||
"""
|
||||
R = Y + 1.402 * (Cr - 128)
|
||||
G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
|
||||
B = Y + 1.772 * (Cb - 128)
|
||||
|
||||
rgb = np.stack([R, G, B], axis=-1)
|
||||
return rgb
|
||||
|
||||
|
||||
def _create_header(data_length: int, flags: int = 0) -> bytes:
|
||||
"""Create DCT stego header."""
|
||||
version = 1
|
||||
return struct.pack('>4sBBI', DCT_MAGIC, version, flags, data_length)
|
||||
|
||||
|
||||
def _parse_header(header_bits: list) -> Tuple[int, int, int]:
|
||||
"""Parse header from extracted bits. Returns (version, flags, data_length)."""
|
||||
if len(header_bits) < HEADER_SIZE * 8:
|
||||
raise ValueError("Insufficient header data")
|
||||
|
||||
header_bytes = bytes([
|
||||
sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
|
||||
for i in range(HEADER_SIZE)
|
||||
])
|
||||
|
||||
magic, version, flags, length = struct.unpack('>4sBBI', header_bytes)
|
||||
|
||||
if magic != DCT_MAGIC:
|
||||
raise ValueError("Invalid DCT stego magic bytes")
|
||||
|
||||
return version, flags, length
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JPEGIO HELPERS (for proper JPEG output)
|
||||
# ============================================================================
|
||||
|
||||
def _jpegio_bytes_to_file(data: bytes, suffix: str = '.jpg') -> str:
|
||||
"""Write bytes to temp file for jpegio."""
|
||||
import tempfile
|
||||
import os
|
||||
fd, path = tempfile.mkstemp(suffix=suffix)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
def _jpegio_file_to_bytes(path: str) -> bytes:
|
||||
"""Read file to bytes and delete it."""
|
||||
import os
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _jpegio_get_usable_positions(coef_array: np.ndarray) -> list:
|
||||
"""Get usable coefficient positions for jpegio embedding."""
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
# Skip DC coefficients
|
||||
if (row % BLOCK_SIZE == 0) and (col % BLOCK_SIZE == 0):
|
||||
continue
|
||||
# Check magnitude
|
||||
if abs(coef_array[row, col]) >= JPEGIO_MIN_COEF_MAGNITUDE:
|
||||
positions.append((row, col))
|
||||
|
||||
return positions
|
||||
|
||||
|
||||
def _jpegio_generate_order(num_positions: int, seed: bytes) -> list:
|
||||
"""Generate pseudo-random order for jpegio embedding."""
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_positions))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
|
||||
def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes:
|
||||
"""Create header for jpegio embedding."""
|
||||
return struct.pack('>4sBBI', JPEGIO_MAGIC, 1, flags, data_length)
|
||||
|
||||
|
||||
def _jpegio_parse_header(header_bytes: bytes) -> Tuple[int, int, int]:
|
||||
"""Parse jpegio header."""
|
||||
if len(header_bytes) < HEADER_SIZE:
|
||||
raise ValueError("Insufficient header data")
|
||||
|
||||
magic, version, flags, length = struct.unpack('>4sBBI', header_bytes[:HEADER_SIZE])
|
||||
|
||||
if magic != JPEGIO_MAGIC:
|
||||
raise ValueError(f"Invalid JPEG stego magic: {magic}")
|
||||
|
||||
return version, flags, length
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC API
|
||||
# ============================================================================
|
||||
|
||||
def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo:
|
||||
"""
|
||||
Calculate the DCT embedding capacity of an image.
|
||||
|
||||
Args:
|
||||
image_data: Image file bytes
|
||||
|
||||
Returns:
|
||||
DCTCapacityInfo with capacity details
|
||||
"""
|
||||
_check_scipy()
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
|
||||
blocks_x = width // BLOCK_SIZE
|
||||
blocks_y = height // BLOCK_SIZE
|
||||
total_blocks = blocks_x * blocks_y
|
||||
|
||||
bits_per_block = len(DEFAULT_EMBED_POSITIONS)
|
||||
total_bits = total_blocks * bits_per_block
|
||||
total_bytes = total_bits // 8
|
||||
usable_bytes = max(0, total_bytes - HEADER_SIZE)
|
||||
|
||||
return DCTCapacityInfo(
|
||||
width=width,
|
||||
height=height,
|
||||
blocks_x=blocks_x,
|
||||
blocks_y=blocks_y,
|
||||
total_blocks=total_blocks,
|
||||
bits_per_block=bits_per_block,
|
||||
total_capacity_bits=total_bits,
|
||||
total_capacity_bytes=total_bytes,
|
||||
usable_capacity_bytes=usable_bytes
|
||||
)
|
||||
|
||||
|
||||
def will_fit_dct(data_length: int, image_data: bytes) -> bool:
|
||||
"""Check if data will fit in the image using DCT embedding."""
|
||||
capacity = calculate_dct_capacity(image_data)
|
||||
return data_length <= capacity.usable_capacity_bytes
|
||||
|
||||
|
||||
def estimate_capacity_comparison(image_data: bytes) -> dict:
|
||||
"""Compare LSB and DCT capacity for an image."""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
pixels = width * height
|
||||
|
||||
lsb_bytes = (pixels * 3) // 8
|
||||
|
||||
if HAS_SCIPY:
|
||||
dct_info = calculate_dct_capacity(image_data)
|
||||
dct_bytes = dct_info.usable_capacity_bytes
|
||||
else:
|
||||
blocks = (width // 8) * (height // 8)
|
||||
dct_bytes = (blocks * 16) // 8 - HEADER_SIZE
|
||||
|
||||
return {
|
||||
'width': width,
|
||||
'height': height,
|
||||
'lsb': {
|
||||
'capacity_bytes': lsb_bytes,
|
||||
'capacity_kb': lsb_bytes / 1024,
|
||||
'output': 'PNG/BMP (color)',
|
||||
},
|
||||
'dct': {
|
||||
'capacity_bytes': dct_bytes,
|
||||
'capacity_kb': dct_bytes / 1024,
|
||||
'output': 'PNG or JPEG (grayscale)',
|
||||
'ratio_vs_lsb': (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0,
|
||||
'available': HAS_SCIPY,
|
||||
},
|
||||
'jpeg_native': {
|
||||
'available': HAS_JPEGIO,
|
||||
'note': 'Uses jpegio for proper JPEG coefficient embedding',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def embed_in_dct(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
output_format: str = OUTPUT_FORMAT_PNG,
|
||||
color_mode: str = 'color', # v3.0.1: 'color' or 'grayscale'
|
||||
) -> Tuple[bytes, DCTEmbedStats]:
|
||||
"""
|
||||
Embed data into image using DCT coefficient modification.
|
||||
|
||||
For PNG output: Uses scipy DCT transform
|
||||
For JPEG output: Uses jpegio if available for proper coefficient embedding
|
||||
|
||||
Args:
|
||||
data: Data to embed
|
||||
carrier_image: Carrier image bytes
|
||||
seed: Seed for pseudo-random selection
|
||||
output_format: 'png' (default, lossless) or 'jpeg'
|
||||
color_mode: 'color' (preserve colors) or 'grayscale' (v3.0.1+)
|
||||
|
||||
Returns:
|
||||
Tuple of (stego_image_bytes, stats)
|
||||
"""
|
||||
# Validate output format
|
||||
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
||||
raise ValueError(f"Invalid output format: {output_format}")
|
||||
|
||||
# Validate color mode
|
||||
if color_mode not in ('color', 'grayscale'):
|
||||
color_mode = 'color' # Default to color
|
||||
|
||||
# For JPEG output, try to use jpegio for proper coefficient embedding
|
||||
# Note: jpegio naturally preserves color (works in YCbCr space)
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
if HAS_JPEGIO:
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode)
|
||||
else:
|
||||
# Fall back to scipy + PIL JPEG (WARNING: may not decode properly)
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"jpegio not available. JPEG output may not decode correctly. "
|
||||
"Install jpegio for proper JPEG steganography support.",
|
||||
RuntimeWarning
|
||||
)
|
||||
# Continue with scipy method but output as JPEG
|
||||
|
||||
# PNG output or JPEG fallback: use scipy DCT method
|
||||
_check_scipy()
|
||||
return _embed_scipy_dct(data, carrier_image, seed, output_format, color_mode)
|
||||
|
||||
|
||||
def _embed_scipy_dct(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
output_format: str,
|
||||
color_mode: str = 'color',
|
||||
) -> Tuple[bytes, DCTEmbedStats]:
|
||||
"""Embed using scipy DCT (for PNG output), with color preservation option."""
|
||||
capacity_info = calculate_dct_capacity(carrier_image)
|
||||
|
||||
if len(data) > capacity_info.usable_capacity_bytes:
|
||||
raise ValueError(
|
||||
f"Data too large ({len(data)} bytes) for carrier "
|
||||
f"(capacity: {capacity_info.usable_capacity_bytes} bytes)"
|
||||
)
|
||||
|
||||
# Load image
|
||||
img = Image.open(io.BytesIO(carrier_image))
|
||||
width, height = img.size
|
||||
|
||||
# Set flags for header
|
||||
flags = FLAG_COLOR_MODE if color_mode == 'color' else 0
|
||||
|
||||
if color_mode == 'color' and img.mode in ('RGB', 'RGBA'):
|
||||
# Color mode: convert to YCbCr, embed in Y only, preserve Cb/Cr
|
||||
if img.mode == 'RGBA':
|
||||
img = img.convert('RGB')
|
||||
|
||||
rgb_array = np.array(img, dtype=np.float64)
|
||||
Y, Cb, Cr = _rgb_to_ycbcr(rgb_array)
|
||||
|
||||
# Pad Y channel
|
||||
Y_padded, original_size = _pad_to_blocks(Y)
|
||||
|
||||
# Embed in Y channel (with color flag)
|
||||
Y_embedded = _embed_in_channel(Y_padded, data, seed, capacity_info, flags)
|
||||
|
||||
# Unpad
|
||||
Y_result = _unpad_image(Y_embedded, original_size)
|
||||
|
||||
# Convert back to RGB
|
||||
result_rgb = _ycbcr_to_rgb(Y_result, Cb, Cr)
|
||||
|
||||
# Save as color image
|
||||
stego_bytes = _save_color_image(result_rgb, output_format)
|
||||
else:
|
||||
# Grayscale mode: original behavior
|
||||
image = _to_grayscale(carrier_image)
|
||||
padded, original_size = _pad_to_blocks(image)
|
||||
|
||||
embedded = _embed_in_channel(padded, data, seed, capacity_info, flags)
|
||||
|
||||
result = _unpad_image(embedded, original_size)
|
||||
stego_bytes = _save_stego_image(result, output_format)
|
||||
|
||||
# Calculate stats
|
||||
header = _create_header(len(data), flags)
|
||||
payload = header + data
|
||||
bits = len(payload) * 8
|
||||
|
||||
stats = DCTEmbedStats(
|
||||
blocks_used=(bits + len(DEFAULT_EMBED_POSITIONS) - 1) // len(DEFAULT_EMBED_POSITIONS),
|
||||
blocks_available=capacity_info.total_blocks,
|
||||
bits_embedded=bits,
|
||||
capacity_bits=capacity_info.total_capacity_bits,
|
||||
usage_percent=(bits / capacity_info.total_capacity_bits) * 100,
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
output_format=output_format,
|
||||
jpeg_native=False,
|
||||
color_mode=color_mode,
|
||||
)
|
||||
|
||||
return stego_bytes, stats
|
||||
|
||||
|
||||
def _embed_in_channel(
|
||||
channel: np.ndarray,
|
||||
data: bytes,
|
||||
seed: bytes,
|
||||
capacity_info: DCTCapacityInfo,
|
||||
flags: int = 0,
|
||||
) -> np.ndarray:
|
||||
"""Embed data in a single channel using DCT."""
|
||||
header = _create_header(len(data), flags)
|
||||
payload = header + data
|
||||
|
||||
bits = []
|
||||
for byte in payload:
|
||||
for i in range(7, -1, -1):
|
||||
bits.append((byte >> i) & 1)
|
||||
|
||||
num_blocks = capacity_info.total_blocks
|
||||
block_order = _generate_block_order(num_blocks, seed)
|
||||
|
||||
h, w = channel.shape
|
||||
result = channel.copy()
|
||||
|
||||
bit_idx = 0
|
||||
for block_num in block_order:
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
|
||||
by = (block_num // (w // BLOCK_SIZE)) * BLOCK_SIZE
|
||||
bx = (block_num % (w // BLOCK_SIZE)) * BLOCK_SIZE
|
||||
|
||||
block = result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE].copy()
|
||||
dct_block = _dct2(block)
|
||||
|
||||
for pos in DEFAULT_EMBED_POSITIONS:
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
dct_block[pos] = _embed_bit_in_coeff(dct_block[pos], bits[bit_idx])
|
||||
bit_idx += 1
|
||||
|
||||
modified_block = _idct2(dct_block)
|
||||
result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _embed_jpegio(
|
||||
data: bytes,
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
color_mode: str = 'color',
|
||||
) -> Tuple[bytes, DCTEmbedStats]:
|
||||
"""
|
||||
Embed using jpegio for proper JPEG coefficient modification.
|
||||
|
||||
Note: jpegio naturally preserves color since JPEG stores YCbCr
|
||||
and we only modify Y channel coefficients.
|
||||
"""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Check if carrier is JPEG - if not, convert it
|
||||
img = Image.open(io.BytesIO(carrier_image))
|
||||
width, height = img.size
|
||||
|
||||
if img.format != 'JPEG':
|
||||
# Convert to JPEG first
|
||||
buffer = io.BytesIO()
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
img.save(buffer, format='JPEG', quality=95, subsampling=0)
|
||||
carrier_image = buffer.getvalue()
|
||||
|
||||
# Write carrier to temp file
|
||||
input_path = _jpegio_bytes_to_file(carrier_image, suffix='.jpg')
|
||||
output_path = tempfile.mktemp(suffix='.jpg')
|
||||
|
||||
# Set flags
|
||||
flags = FLAG_COLOR_MODE if color_mode == 'color' else 0
|
||||
|
||||
try:
|
||||
# Read JPEG with jpegio
|
||||
jpeg = jio.read(input_path)
|
||||
|
||||
# Get Y channel coefficients (channel 0)
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
|
||||
# Find usable positions
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
|
||||
# Generate pseudo-random order
|
||||
order = _jpegio_generate_order(len(all_positions), seed)
|
||||
|
||||
# Create payload with flags
|
||||
header = _jpegio_create_header(len(data), flags)
|
||||
payload = header + data
|
||||
|
||||
# Convert to bits
|
||||
bits = []
|
||||
for byte in payload:
|
||||
for i in range(7, -1, -1):
|
||||
bits.append((byte >> i) & 1)
|
||||
|
||||
if len(bits) > len(all_positions):
|
||||
raise ValueError(
|
||||
f"Payload too large: {len(bits)} bits, "
|
||||
f"only {len(all_positions)} usable coefficients"
|
||||
)
|
||||
|
||||
# Embed using LSB
|
||||
coefs_used = 0
|
||||
for bit_idx, pos_idx in enumerate(order):
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
|
||||
row, col = all_positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
|
||||
# Embed bit in LSB
|
||||
if (coef & 1) != bits[bit_idx]:
|
||||
if coef > 0:
|
||||
coef_array[row, col] = coef - 1 if (coef & 1) else coef + 1
|
||||
else:
|
||||
coef_array[row, col] = coef + 1 if (coef & 1) else coef - 1
|
||||
|
||||
coefs_used += 1
|
||||
|
||||
# Write modified JPEG
|
||||
jio.write(jpeg, output_path)
|
||||
|
||||
# Read back as bytes
|
||||
with open(output_path, 'rb') as f:
|
||||
stego_bytes = f.read()
|
||||
|
||||
stats = DCTEmbedStats(
|
||||
blocks_used=coefs_used // 63, # Approximate blocks
|
||||
blocks_available=len(all_positions) // 63,
|
||||
bits_embedded=len(bits),
|
||||
capacity_bits=len(all_positions),
|
||||
usage_percent=(len(bits) / len(all_positions)) * 100 if all_positions else 0,
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
output_format=OUTPUT_FORMAT_JPEG,
|
||||
jpeg_native=True,
|
||||
color_mode=color_mode, # JPEG naturally preserves color
|
||||
)
|
||||
|
||||
return stego_bytes, stats
|
||||
|
||||
finally:
|
||||
for path in [input_path, output_path]:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_from_dct(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
) -> bytes:
|
||||
"""
|
||||
Extract data from DCT stego image.
|
||||
|
||||
Automatically detects whether image uses scipy DCT or jpegio embedding,
|
||||
and handles both grayscale and color modes.
|
||||
|
||||
Args:
|
||||
stego_image: Stego image bytes
|
||||
seed: Same seed used for embedding
|
||||
|
||||
Returns:
|
||||
Extracted data bytes
|
||||
"""
|
||||
# Check image format
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
|
||||
if img.format == 'JPEG' and HAS_JPEGIO:
|
||||
# Try jpegio extraction first
|
||||
try:
|
||||
return _extract_jpegio(stego_image, seed)
|
||||
except ValueError:
|
||||
# If jpegio magic not found, fall back to scipy method
|
||||
pass
|
||||
|
||||
# PNG or fallback: use scipy DCT method
|
||||
_check_scipy()
|
||||
return _extract_scipy_dct(stego_image, seed)
|
||||
|
||||
|
||||
def _extract_scipy_dct(stego_image: bytes, seed: bytes) -> bytes:
|
||||
"""
|
||||
Extract using scipy DCT (for PNG images).
|
||||
|
||||
v3.2.0: Now properly handles both grayscale and color modes by
|
||||
first trying to detect the mode from header flags, then extracting
|
||||
from the appropriate channel.
|
||||
"""
|
||||
# First, try extracting from grayscale to get header and detect mode
|
||||
# This works because even color-mode images can be converted to grayscale
|
||||
# and the Y channel ≈ grayscale for extraction purposes
|
||||
|
||||
# Try Y channel extraction first (works for both color and grayscale)
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
|
||||
if img.mode in ('RGB', 'RGBA'):
|
||||
# Extract from Y channel (more accurate for color-mode images)
|
||||
channel = _extract_y_channel(stego_image)
|
||||
else:
|
||||
# Grayscale image
|
||||
channel = _to_grayscale(stego_image)
|
||||
|
||||
padded, original_size = _pad_to_blocks(channel)
|
||||
|
||||
h, w = padded.shape
|
||||
blocks_x = w // BLOCK_SIZE
|
||||
blocks_y = h // BLOCK_SIZE
|
||||
num_blocks = blocks_x * blocks_y
|
||||
|
||||
block_order = _generate_block_order(num_blocks, seed)
|
||||
|
||||
all_bits = []
|
||||
|
||||
for block_num in block_order:
|
||||
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||
|
||||
block = padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE]
|
||||
dct_block = _dct2(block)
|
||||
|
||||
for pos in DEFAULT_EMBED_POSITIONS:
|
||||
bit = _extract_bit_from_coeff(dct_block[pos])
|
||||
all_bits.append(bit)
|
||||
|
||||
if len(all_bits) >= HEADER_SIZE * 8:
|
||||
try:
|
||||
_, flags, data_length = _parse_header(all_bits[:HEADER_SIZE * 8])
|
||||
total_needed = (HEADER_SIZE + data_length) * 8
|
||||
if len(all_bits) >= total_needed:
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
version, flags, data_length = _parse_header(all_bits)
|
||||
|
||||
# Check if color mode flag is set (for informational purposes)
|
||||
is_color_mode = bool(flags & FLAG_COLOR_MODE)
|
||||
|
||||
data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8]
|
||||
|
||||
data = bytes([
|
||||
sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
|
||||
for i in range(data_length)
|
||||
])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
"""Extract using jpegio for JPEG images."""
|
||||
import os
|
||||
|
||||
temp_path = _jpegio_bytes_to_file(stego_image, suffix='.jpg')
|
||||
|
||||
try:
|
||||
jpeg = jio.read(temp_path)
|
||||
coef_array = jpeg.coef_arrays[JPEGIO_EMBED_CHANNEL]
|
||||
|
||||
all_positions = _jpegio_get_usable_positions(coef_array)
|
||||
order = _jpegio_generate_order(len(all_positions), seed)
|
||||
|
||||
# Extract header bits
|
||||
header_bits = []
|
||||
for pos_idx in order[:HEADER_SIZE * 8]:
|
||||
row, col = all_positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
header_bits.append(coef & 1)
|
||||
|
||||
header_bytes = bytes([
|
||||
sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
|
||||
for i in range(HEADER_SIZE)
|
||||
])
|
||||
|
||||
version, flags, data_length = _jpegio_parse_header(header_bytes)
|
||||
|
||||
# Extract all needed bits
|
||||
total_bits_needed = (HEADER_SIZE + data_length) * 8
|
||||
|
||||
all_bits = []
|
||||
for bit_idx, pos_idx in enumerate(order):
|
||||
if bit_idx >= total_bits_needed:
|
||||
break
|
||||
row, col = all_positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
all_bits.append(coef & 1)
|
||||
|
||||
# Extract data
|
||||
data_bits = all_bits[HEADER_SIZE * 8:]
|
||||
|
||||
data = bytes([
|
||||
sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8))
|
||||
for i in range(data_length)
|
||||
])
|
||||
|
||||
return data
|
||||
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def get_output_extension(output_format: str) -> str:
|
||||
"""Get file extension for output format."""
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
return '.jpg'
|
||||
return '.png'
|
||||
|
||||
|
||||
def get_output_mimetype(output_format: str) -> str:
|
||||
"""Get MIME type for output format."""
|
||||
if output_format == OUTPUT_FORMAT_JPEG:
|
||||
return 'image/jpeg'
|
||||
return 'image/png'
|
||||
0
src/stegasoo/py.typed
Normal file
0
src/stegasoo/py.typed
Normal file
@@ -1,205 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test that mimics the exact /api/compare-capacity flow.
|
||||
Run with: python test_compare_capacity_flow.py ./xx_2.jpg
|
||||
"""
|
||||
|
||||
import sys
|
||||
import io
|
||||
import gc
|
||||
import json
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print("COMPARE-CAPACITY FLOW TEST")
|
||||
print("=" * 60)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python test_compare_capacity_flow.py <image_path>")
|
||||
sys.exit(1)
|
||||
|
||||
image_path = sys.argv[1]
|
||||
|
||||
# Read the file
|
||||
with open(image_path, 'rb') as f:
|
||||
carrier_data = f.read()
|
||||
print(f"Loaded {len(carrier_data)} bytes from {image_path}")
|
||||
|
||||
# Import everything like Flask does
|
||||
print("\n[1] Importing modules...")
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import jpegio as jio
|
||||
HAS_JPEGIO = True
|
||||
print(f" jpegio: available")
|
||||
except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
print(f" jpegio: NOT available")
|
||||
|
||||
try:
|
||||
from scipy.fft import dct, idct
|
||||
print(f" scipy.fft: available")
|
||||
except ImportError:
|
||||
from scipy.fftpack import dct, idct
|
||||
print(f" scipy.fftpack: available (fallback)")
|
||||
|
||||
print(" Imports complete")
|
||||
|
||||
# Simulate the compare_modes function
|
||||
print("\n[2] Opening image (1st time - for dimensions)...")
|
||||
img1 = Image.open(io.BytesIO(carrier_data))
|
||||
width, height = img1.size
|
||||
print(f" Size: {width}x{height}")
|
||||
img1.close()
|
||||
print(" Closed img1")
|
||||
gc.collect()
|
||||
|
||||
print("\n[3] Opening image (2nd time - for LSB capacity)...")
|
||||
img2 = Image.open(io.BytesIO(carrier_data))
|
||||
num_pixels = img2.size[0] * img2.size[1]
|
||||
lsb_bytes = (num_pixels * 3) // 8 - 69
|
||||
print(f" LSB capacity: {lsb_bytes} bytes")
|
||||
img2.close()
|
||||
print(" Closed img2")
|
||||
gc.collect()
|
||||
|
||||
print("\n[4] Opening image (3rd time - for DCT capacity)...")
|
||||
img3 = Image.open(io.BytesIO(carrier_data))
|
||||
w, h = img3.size
|
||||
blocks_x = w // 8
|
||||
blocks_y = h // 8
|
||||
total_blocks = blocks_x * blocks_y
|
||||
dct_bits = total_blocks * 16
|
||||
dct_bytes = dct_bits // 8 - 10
|
||||
print(f" DCT capacity: {dct_bytes} bytes ({total_blocks} blocks)")
|
||||
img3.close()
|
||||
print(" Closed img3")
|
||||
gc.collect()
|
||||
|
||||
print("\n[5] Building response dict...")
|
||||
response = {
|
||||
'success': True,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'lsb': {
|
||||
'capacity_bytes': lsb_bytes,
|
||||
'capacity_kb': round(lsb_bytes / 1024, 1),
|
||||
'output': 'PNG',
|
||||
},
|
||||
'dct': {
|
||||
'capacity_bytes': dct_bytes,
|
||||
'capacity_kb': round(dct_bytes / 1024, 1),
|
||||
'output': 'JPEG',
|
||||
'available': True,
|
||||
'ratio': round(dct_bytes / lsb_bytes * 100, 1),
|
||||
}
|
||||
}
|
||||
print(f" Response built")
|
||||
|
||||
print("\n[6] Serializing to JSON...")
|
||||
json_str = json.dumps(response)
|
||||
print(f" JSON length: {len(json_str)} bytes")
|
||||
print(f" Content: {json_str[:200]}...")
|
||||
|
||||
print("\n[7] Simulating Flask response completion...")
|
||||
# In Flask, after the response is sent, Python may garbage collect
|
||||
del carrier_data
|
||||
del response
|
||||
del json_str
|
||||
gc.collect()
|
||||
print(" GC after response simulation")
|
||||
|
||||
print("\n[8] Additional cleanup (simulating request end)...")
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
print(" Multiple GC cycles complete")
|
||||
|
||||
print("\n[9] Waiting for delayed crash...")
|
||||
for i in range(3):
|
||||
time.sleep(1)
|
||||
print(f" {i+1}s...")
|
||||
gc.collect()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED - No crash detected")
|
||||
print("=" * 60)
|
||||
|
||||
# Now test with jpegio if available
|
||||
if HAS_JPEGIO:
|
||||
print("\n" + "=" * 60)
|
||||
print("JPEGIO SPECIFIC TEST")
|
||||
print("=" * 60)
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Reload image data
|
||||
with open(image_path, 'rb') as f:
|
||||
carrier_data = f.read()
|
||||
|
||||
print("\n[J1] Checking if image is JPEG...")
|
||||
img = Image.open(io.BytesIO(carrier_data))
|
||||
is_jpeg = img.format == 'JPEG'
|
||||
img.close()
|
||||
print(f" Is JPEG: {is_jpeg}")
|
||||
|
||||
if is_jpeg:
|
||||
print("\n[J2] Writing to temp file...")
|
||||
fd, temp_path = tempfile.mkstemp(suffix='.jpg')
|
||||
os.write(fd, carrier_data)
|
||||
os.close(fd)
|
||||
print(f" Temp file: {temp_path}")
|
||||
|
||||
print("\n[J3] Reading with jpegio...")
|
||||
try:
|
||||
jpeg = jio.read(temp_path)
|
||||
print(f" jpegio.read() OK")
|
||||
|
||||
print("\n[J4] Accessing coefficient arrays...")
|
||||
coef = jpeg.coef_arrays[0]
|
||||
print(f" Coef shape: {coef.shape}, dtype: {coef.dtype}")
|
||||
|
||||
print("\n[J5] Counting usable positions...")
|
||||
positions = []
|
||||
h, w = coef.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef[row, col]) >= 2:
|
||||
positions.append((row, col))
|
||||
print(f" Usable positions: {len(positions)}")
|
||||
|
||||
print("\n[J6] Cleaning up jpegio object...")
|
||||
del coef
|
||||
del jpeg
|
||||
gc.collect()
|
||||
print(" Deleted jpeg object")
|
||||
|
||||
print("\n[J7] Removing temp file...")
|
||||
os.unlink(temp_path)
|
||||
print(" Temp file removed")
|
||||
|
||||
gc.collect()
|
||||
print("\n[J8] Final GC...")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n[J9] Waiting for delayed crash...")
|
||||
for i in range(3):
|
||||
time.sleep(1)
|
||||
print(f" {i+1}s...")
|
||||
gc.collect()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("JPEGIO TEST PASSED - No crash detected")
|
||||
print("=" * 60)
|
||||
else:
|
||||
print(" Skipping jpegio test (not a JPEG)")
|
||||
|
||||
print("\n\nAll tests completed successfully!")
|
||||
@@ -1,231 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone DCT crash diagnostic script.
|
||||
Run this outside of Flask to isolate the issue.
|
||||
|
||||
Usage:
|
||||
python test_dct_crash.py /path/to/your/large_image.jpg
|
||||
"""
|
||||
|
||||
import sys
|
||||
import gc
|
||||
import traceback
|
||||
import io
|
||||
|
||||
print("=" * 60)
|
||||
print("DCT CRASH DIAGNOSTIC TOOL")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Check Python and library versions
|
||||
print("\n[1] ENVIRONMENT INFO")
|
||||
print(f"Python: {sys.version}")
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
print(f"NumPy: {np.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"NumPy: NOT INSTALLED - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import scipy
|
||||
print(f"SciPy: {scipy.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"SciPy: NOT INSTALLED - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import PIL
|
||||
print(f"Pillow: {PIL.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"Pillow: NOT INSTALLED - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Check which DCT module we're using
|
||||
print("\n[2] DCT MODULE CHECK")
|
||||
try:
|
||||
from scipy.fft import dct, idct
|
||||
print("Using: scipy.fft (preferred)")
|
||||
DCT_MODULE = "scipy.fft"
|
||||
except ImportError:
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
print("Using: scipy.fftpack (legacy)")
|
||||
DCT_MODULE = "scipy.fftpack"
|
||||
except ImportError:
|
||||
print("ERROR: No DCT module available!")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Test basic DCT on small array
|
||||
print("\n[3] BASIC DCT TEST (8x8 block)")
|
||||
try:
|
||||
test_block = np.random.rand(8, 8).astype(np.float64)
|
||||
|
||||
# 1D DCT on rows
|
||||
result = dct(test_block[0, :], norm='ortho')
|
||||
print(f" 1D DCT: OK (output shape: {result.shape})")
|
||||
|
||||
# 1D IDCT
|
||||
recovered = idct(result, norm='ortho')
|
||||
error = np.max(np.abs(test_block[0, :] - recovered))
|
||||
print(f" 1D IDCT: OK (roundtrip error: {error:.2e})")
|
||||
|
||||
# 2D via separable
|
||||
temp = np.zeros_like(test_block)
|
||||
for i in range(8):
|
||||
temp[:, i] = dct(test_block[:, i], norm='ortho')
|
||||
result2d = np.zeros_like(temp)
|
||||
for i in range(8):
|
||||
result2d[i, :] = dct(temp[i, :], norm='ortho')
|
||||
print(f" 2D DCT: OK")
|
||||
|
||||
gc.collect()
|
||||
print(" GC after basic test: OK")
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Step 4: Test with larger arrays (stress test)
|
||||
print("\n[4] STRESS TEST (many 8x8 blocks)")
|
||||
try:
|
||||
NUM_BLOCKS = 10000
|
||||
print(f" Processing {NUM_BLOCKS} blocks...")
|
||||
|
||||
for i in range(NUM_BLOCKS):
|
||||
block = np.random.rand(8, 8).astype(np.float64)
|
||||
|
||||
# Forward DCT
|
||||
temp = np.zeros_like(block)
|
||||
for j in range(8):
|
||||
temp[:, j] = dct(block[:, j], norm='ortho')
|
||||
result = np.zeros_like(temp)
|
||||
for j in range(8):
|
||||
result[j, :] = dct(temp[j, :], norm='ortho')
|
||||
|
||||
# Inverse DCT
|
||||
temp2 = np.zeros_like(result)
|
||||
for j in range(8):
|
||||
temp2[j, :] = idct(result[j, :], norm='ortho')
|
||||
recovered = np.zeros_like(temp2)
|
||||
for j in range(8):
|
||||
recovered[:, j] = idct(temp2[:, j], norm='ortho')
|
||||
|
||||
if i % 1000 == 0:
|
||||
gc.collect()
|
||||
print(f" {i}/{NUM_BLOCKS} blocks processed...")
|
||||
|
||||
gc.collect()
|
||||
print(f" Stress test PASSED")
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAILED at block {i}: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Step 5: Test with actual image if provided
|
||||
if len(sys.argv) > 1:
|
||||
image_path = sys.argv[1]
|
||||
print(f"\n[5] IMAGE TEST: {image_path}")
|
||||
|
||||
try:
|
||||
with open(image_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
print(f" File size: {len(image_data) / 1024 / 1024:.2f} MB")
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
width, height = img.size
|
||||
print(f" Dimensions: {width}x{height}")
|
||||
print(f" Format: {img.format}")
|
||||
print(f" Mode: {img.mode}")
|
||||
|
||||
# Convert to grayscale float array
|
||||
gray = img.convert('L')
|
||||
arr = np.array(gray, dtype=np.float64)
|
||||
img.close()
|
||||
gray.close()
|
||||
print(f" Array shape: {arr.shape}")
|
||||
print(f" Array dtype: {arr.dtype}")
|
||||
|
||||
# Pad to block boundary
|
||||
BLOCK_SIZE = 8
|
||||
h, w = arr.shape
|
||||
new_h = ((h + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
|
||||
new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE
|
||||
|
||||
if new_h != h or new_w != w:
|
||||
padded = np.zeros((new_h, new_w), dtype=np.float64)
|
||||
padded[:h, :w] = arr
|
||||
arr = padded
|
||||
print(f" Padded to: {arr.shape}")
|
||||
|
||||
blocks_y = arr.shape[0] // BLOCK_SIZE
|
||||
blocks_x = arr.shape[1] // BLOCK_SIZE
|
||||
total_blocks = blocks_y * blocks_x
|
||||
print(f" Total 8x8 blocks: {total_blocks}")
|
||||
|
||||
# Process ALL blocks
|
||||
print(f" Processing all blocks with DCT...")
|
||||
|
||||
processed = 0
|
||||
for by in range(blocks_y):
|
||||
for bx in range(blocks_x):
|
||||
y = by * BLOCK_SIZE
|
||||
x = bx * BLOCK_SIZE
|
||||
|
||||
block = arr[y:y+BLOCK_SIZE, x:x+BLOCK_SIZE].copy()
|
||||
|
||||
# Forward DCT
|
||||
temp = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
temp[:, i] = dct(block[:, i], norm='ortho')
|
||||
dct_block = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
dct_block[i, :] = dct(temp[i, :], norm='ortho')
|
||||
|
||||
# Inverse DCT
|
||||
temp2 = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
temp2[i, :] = idct(dct_block[i, :], norm='ortho')
|
||||
recovered = np.zeros((8, 8), dtype=np.float64)
|
||||
for i in range(8):
|
||||
recovered[:, i] = idct(temp2[:, i], norm='ortho')
|
||||
|
||||
processed += 1
|
||||
|
||||
# GC after each row of blocks
|
||||
if by % 50 == 0:
|
||||
gc.collect()
|
||||
print(f" Row {by}/{blocks_y} ({processed}/{total_blocks} blocks)")
|
||||
|
||||
gc.collect()
|
||||
print(f" Image DCT test PASSED ({processed} blocks)")
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
else:
|
||||
print("\n[5] IMAGE TEST: Skipped (no image path provided)")
|
||||
print(" Usage: python test_dct_crash.py /path/to/image.jpg")
|
||||
|
||||
# Step 6: Final cleanup test
|
||||
print("\n[6] FINAL CLEANUP TEST")
|
||||
try:
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
print(" Multiple GC cycles: OK")
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("If this script completes without 'free(): invalid size',")
|
||||
print("the issue is likely in PIL/jpegio interaction, not scipy DCT.")
|
||||
print("=" * 60)
|
||||
|
||||
# Keep process alive briefly to catch delayed crashes
|
||||
import time
|
||||
print("\nWaiting 2 seconds for delayed crashes...")
|
||||
time.sleep(2)
|
||||
print("Done - no crash detected!")
|
||||
Reference in New Issue
Block a user