Compare commits
399 Commits
4_0_1
...
c970261e53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c970261e53 | ||
|
|
4607ff27dd | ||
|
|
70b941d55a | ||
|
|
14fce4d3ed | ||
|
|
05382c4081 | ||
|
|
ef5a9ce9cb | ||
|
|
0248bec813 | ||
|
|
7aeb26e003 | ||
|
|
1630d044aa | ||
|
|
c2a0a731d7 | ||
|
|
89de839fd8 | ||
|
|
49566292ba | ||
|
|
9f0e0afeb6 | ||
|
|
398a359778 | ||
|
|
86aa5cbddf | ||
|
|
2f54f80214 | ||
|
|
1cd2656e60 | ||
|
|
ce728cec6e | ||
|
|
555735a4fd | ||
|
|
08b70043e4 | ||
|
|
d395e5731e | ||
|
|
110b160e68 | ||
|
|
b09f607d34 | ||
|
|
34ede3815f | ||
|
|
3b5ab41ce9 | ||
|
|
525bcec3c9 | ||
|
|
afc8c93923 | ||
|
|
38bef32750 | ||
|
|
4e3acfca20 | ||
|
|
2ebc42f2cd | ||
|
|
1e07630b49 | ||
|
|
67037ae196 | ||
|
|
5a68840725 | ||
|
|
ebc999b2b3 | ||
|
|
f46ef01f5f | ||
|
|
0d76780deb | ||
|
|
d34919e32f | ||
|
|
a4038589b0 | ||
|
|
db763f1464 | ||
|
|
27c5b08d41 | ||
|
|
28cb9bb9b3 | ||
|
|
889df881ba | ||
|
|
c058d116b8 | ||
|
|
fae86887e2 | ||
|
|
5e45b2c5c1 | ||
|
|
71088989f3 | ||
|
|
530e5debef | ||
|
|
3b062458e3 | ||
|
|
5e65035ca4 | ||
|
|
de9d1de881 | ||
|
|
8d90a888cf | ||
|
|
b0914778e3 | ||
|
|
7e5462ea6e | ||
|
|
e085a8ffe9 | ||
|
|
2d7fbd1e0d | ||
|
|
32842f6b73 | ||
|
|
3fd3204552 | ||
|
|
175362ce4c | ||
|
|
2ed108f3a0 | ||
|
|
167e1a6ff5 | ||
|
|
f2f3e2eefc | ||
|
|
5c685cba67 | ||
|
|
4e819b80cc | ||
|
|
ea86216648 | ||
|
|
8de5659fa6 | ||
|
|
de0bf2410d | ||
|
|
8b948d00a4 | ||
|
|
6d88453b69 | ||
|
|
ea57bdf302 | ||
|
|
55d54717f8 | ||
|
|
c0fe85ac83 | ||
|
|
e9e4d1aab9 | ||
|
|
1acb5a3dcc | ||
|
|
14a73c63ac | ||
|
|
3d53282738 | ||
|
|
e831ae4884 | ||
|
|
4751d05e9f | ||
|
|
d15bcb8df4 | ||
|
|
6ec7de5604 | ||
|
|
1cdb2aca91 | ||
|
|
46de371c42 | ||
|
|
11c0d45548 | ||
|
|
7bb1029c0f | ||
|
|
e3f7f36e5e | ||
|
|
f200737088 | ||
|
|
6def318ba7 | ||
|
|
e203af6a73 | ||
|
|
6ba135098b | ||
|
|
903739c055 | ||
|
|
30fbb5016e | ||
|
|
041148e8fe | ||
|
|
90bedce379 | ||
|
|
021265f3cf | ||
|
|
ff42398509 | ||
|
|
a30ec33b98 | ||
|
|
252efbec7e | ||
|
|
6e906d5981 | ||
|
|
df6125d098 | ||
|
|
3d4a340305 | ||
|
|
0decb39b17 | ||
|
|
4291dfad38 | ||
|
|
ddee3583e8 | ||
|
|
3e2307cbcf | ||
|
|
cc745fbdfa | ||
|
|
3027706d49 | ||
|
|
39fbd617e6 | ||
|
|
de4cb0b3be | ||
|
|
add3951003 | ||
|
|
3858e234da | ||
|
|
03e8e3a840 | ||
|
|
55e78d0503 | ||
|
|
b13a9fcd3f | ||
|
|
96b49c68ec | ||
|
|
be8744179d | ||
|
|
f971b75d7e | ||
|
|
455c6dfd01 | ||
|
|
a00a154a1a | ||
|
|
8b3b331843 | ||
|
|
10c874374f | ||
|
|
0c1e87c7c0 | ||
|
|
d517a4dc8b | ||
|
|
6d59f3edfc | ||
|
|
17d0406be2 | ||
|
|
ef73280015 | ||
|
|
6338d6aab4 | ||
|
|
b9d0fac535 | ||
|
|
5c0a5bbba7 | ||
|
|
ba1a77f00b | ||
|
|
5e587df545 | ||
|
|
23456ac1e4 | ||
|
|
8be512ad7b | ||
|
|
f129500202 | ||
|
|
c37d743b3e | ||
|
|
5bdb625059 | ||
|
|
231ba97fde | ||
|
|
a70e88625f | ||
|
|
b6770c46e5 | ||
|
|
9f4318cc0f | ||
|
|
91dc665a77 | ||
|
|
6066df391b | ||
|
|
be5c95b59d | ||
|
|
09b1abddc7 | ||
|
|
0c9ea0e3f2 | ||
|
|
aebfb20dfc | ||
|
|
b935c474af | ||
|
|
73b34ba8b5 | ||
|
|
89d8fee5da | ||
|
|
0e270dadb3 | ||
|
|
e2002b6026 | ||
|
|
66ed11fb97 | ||
|
|
9cbb4600f8 | ||
|
|
c1c850c593 | ||
|
|
e029f00d66 | ||
|
|
34e417fb55 | ||
|
|
e7954c63e4 | ||
|
|
446789a16f | ||
|
|
2538126573 | ||
|
|
a91d127ed7 | ||
|
|
a0781b1cf7 | ||
|
|
5e32ecb35a | ||
|
|
3e5de98f60 | ||
|
|
c8956b9e43 | ||
|
|
a8f15f87c6 | ||
|
|
8a64db9fcc | ||
|
|
ab450955fe | ||
|
|
afd502dbf3 | ||
|
|
3f02e55ffd | ||
|
|
2ee824b02b | ||
|
|
189620e4fb | ||
|
|
ecad88e859 | ||
|
|
62bd31d0aa | ||
|
|
241cdadd25 | ||
|
|
85309a2044 | ||
|
|
a81a20f8ee | ||
|
|
9c88f53cd0 | ||
|
|
3f8c2a6957 | ||
|
|
22cf27d7f6 | ||
|
|
4d8575ce33 | ||
|
|
28b539bcd9 | ||
|
|
6b82069dc8 | ||
|
|
52e1a3dfbf | ||
|
|
4a27d0c182 | ||
|
|
36931518ce | ||
|
|
f79c63428b | ||
|
|
cc29de4200 | ||
|
|
c14f3f75cb | ||
|
|
aa99a258f4 | ||
|
|
93420704e8 | ||
|
|
6e4eb5464e | ||
|
|
d04670e352 | ||
|
|
fda1cdad51 | ||
|
|
b48ccc5d16 | ||
|
|
15ed63cafa | ||
|
|
869d7ee8e3 | ||
|
|
3ee8c1d22a | ||
|
|
b96564358a | ||
|
|
01afb3da66 | ||
|
|
a98df5f9a0 | ||
|
|
70da348bce | ||
|
|
90ba8543a7 | ||
|
|
da3aea992c | ||
|
|
ae47ff4932 | ||
|
|
eb16eb1db2 | ||
|
|
c65d9e6682 | ||
|
|
eeb44eae94 | ||
|
|
26d4b82c91 | ||
|
|
7efeaf02e8 | ||
|
|
925fb05cbd | ||
|
|
29a02265a1 | ||
|
|
d58f3c6fb6 | ||
|
|
cc46993d80 | ||
|
|
893a044eaa | ||
|
|
9f03b69408 | ||
|
|
cce2007c6e | ||
|
|
52f43d3a86 | ||
|
|
85a7092d55 | ||
|
|
4b37a81087 | ||
|
|
31941dc3f5 | ||
|
|
9a7e4ddce7 | ||
|
|
0424dd34d5 | ||
|
|
2127b916f3 | ||
|
|
f8e65890e5 | ||
|
|
5861ab0e1e | ||
|
|
5309a08aaf | ||
|
|
d8fb95b68e | ||
|
|
c0b6865790 | ||
|
|
6e7ae0d6f9 | ||
|
|
6a5b12f98e | ||
|
|
d8eb7b0160 | ||
|
|
962c04084b | ||
|
|
597a9c6411 | ||
|
|
67b25a43a6 | ||
|
|
65a663fe3b | ||
|
|
fc6e4eb805 | ||
|
|
50f07a0ce9 | ||
|
|
7accd26821 | ||
|
|
075e10792c | ||
|
|
9a790de5c3 | ||
|
|
3c91c92a4d | ||
|
|
9d1bc7f829 | ||
|
|
d8118d688b | ||
|
|
b6acee1acb | ||
|
|
b9baf35dfa | ||
|
|
561f03ffde | ||
|
|
038347a505 | ||
|
|
e026d1a4db | ||
|
|
3f93e7a752 | ||
|
|
cdc7ffd3bf | ||
|
|
6c3bc995f1 | ||
|
|
2d3ed8a79a | ||
|
|
040c44fec6 | ||
|
|
832d8be025 | ||
|
|
7088623d2c | ||
|
|
44a3ca8a0f | ||
|
|
7a35ac3df7 | ||
|
|
f69475b406 | ||
|
|
559dcd3dcf | ||
|
|
b1ddfaa75b | ||
|
|
4843ec8c22 | ||
|
|
ac08011236 | ||
|
|
12c4b091fb | ||
|
|
c2c2c924e1 | ||
|
|
df7ad06a08 | ||
|
|
166b936ee5 | ||
|
|
7138455f8d | ||
|
|
9ab3260298 | ||
|
|
763f7bf603 | ||
|
|
1059e17f4e | ||
|
|
7cb42e189a | ||
|
|
8c283bc4e5 | ||
|
|
664362bea5 | ||
|
|
4733e3b4dd | ||
|
|
24aec00613 | ||
|
|
0e0aa996bc | ||
|
|
255ae4f30d | ||
|
|
7647ca11d1 | ||
|
|
01e9e5af0a | ||
|
|
39e5daa022 | ||
|
|
54e097c050 | ||
|
|
a3ff8dace1 | ||
|
|
e4cf96bb7c | ||
|
|
597c95070c | ||
|
|
dba5a08476 | ||
|
|
6ceda6c287 | ||
|
|
c2575f973b | ||
|
|
8208ec2955 | ||
|
|
909dc14a92 | ||
|
|
bb91e41d3d | ||
|
|
c54a96894c | ||
|
|
da044017d7 | ||
|
|
d0ec99d5b5 | ||
|
|
aac8037c04 | ||
|
|
7a5092b945 | ||
|
|
e52a709080 | ||
|
|
70fe8fce62 | ||
|
|
d44575deec | ||
|
|
d0d48236ff | ||
|
|
5891285493 | ||
|
|
5501c7e0ba | ||
|
|
038fd6ceac | ||
|
|
8622f1a850 | ||
|
|
710b3a6a98 | ||
|
|
c965a5f8da | ||
|
|
00cda4d929 | ||
|
|
05e2286d02 | ||
|
|
46cbf98a23 | ||
|
|
58673c04fe | ||
|
|
dd07972014 | ||
|
|
1f40eeff9e | ||
|
|
dc09bac489 | ||
|
|
46489dd276 | ||
|
|
9088caa23d | ||
|
|
75b6203525 | ||
|
|
404d7885f4 | ||
|
|
a8db991052 | ||
|
|
ea2948e5d2 | ||
|
|
05278ca55f | ||
|
|
c551078c37 | ||
|
|
b7d86201ca | ||
|
|
07b0bc0b75 | ||
|
|
d8b8e4f5c2 | ||
|
|
143a8bdc65 | ||
|
|
ac92fa36b5 | ||
|
|
c82dcf26f2 | ||
|
|
65a496a9d4 | ||
|
|
25a432fcf3 | ||
|
|
a58dd54ba8 | ||
|
|
05c542d808 | ||
|
|
5e5d6e60de | ||
|
|
d898f6d7b1 | ||
|
|
00dd15b8fb | ||
|
|
419b491737 | ||
|
|
b568026253 | ||
|
|
127d3e54a6 | ||
|
|
de41c0731e | ||
|
|
f3d5699e15 | ||
|
|
298f387c9a | ||
|
|
fcb71303df | ||
|
|
abcff74dd4 | ||
|
|
355a988405 | ||
|
|
fb55878727 | ||
|
|
81d3f37f09 | ||
|
|
3537e8cdf9 | ||
|
|
d71f615d66 | ||
|
|
ed1d230b4e | ||
|
|
13f145c3d5 | ||
|
|
80dc22f150 | ||
|
|
01f0173dd4 | ||
|
|
5df9b9dac8 | ||
|
|
2f1ac3a747 | ||
|
|
8e5f01754f | ||
|
|
823b8824ea | ||
|
|
f4c1aa1912 | ||
|
|
e502f42fb8 | ||
|
|
08e42719ee | ||
|
|
21023099b0 | ||
|
|
8a41796d1b | ||
|
|
7b33501495 | ||
|
|
a8f6ae1dd2 | ||
|
|
b199f03f83 | ||
|
|
b97622956c | ||
|
|
3044c08fe3 | ||
|
|
5042c7d555 | ||
|
|
aa8788168e | ||
|
|
899d043892 | ||
|
|
6fb63edc61 | ||
|
|
e74f12c24d | ||
|
|
272d0e6ef0 | ||
|
|
f38bf4a1c6 | ||
|
|
fee3133f9c | ||
|
|
b058d8bf66 | ||
|
|
916a2e0e7b | ||
|
|
cccb40dc3a | ||
|
|
b60880c8b3 | ||
|
|
c96c595c78 | ||
|
|
e129c38fd8 | ||
|
|
0d7b5a14cb | ||
|
|
45b99d2c5e | ||
|
|
c6f816d61f | ||
|
|
83e9bd6fa1 | ||
|
|
5188492c77 | ||
|
|
8bb70e5667 | ||
|
|
82ac1dcda4 | ||
|
|
464e13567d | ||
|
|
0b19a41b5e | ||
|
|
61c5178752 | ||
|
|
6b1b306f61 | ||
|
|
267547caba | ||
|
|
2ff28034f5 | ||
|
|
4cba75fe06 | ||
|
|
d03b3dea4b | ||
|
|
cf247d207f | ||
|
|
28d77957eb | ||
|
|
89b4809489 | ||
|
|
79ab165b95 | ||
|
|
4194d6923a | ||
|
|
08e19a3bfd | ||
|
|
dea7472018 | ||
|
|
e8863d15d7 |
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"church@church": true
|
||||
}
|
||||
}
|
||||
52
.dockerignore
Normal file
@@ -0,0 +1,52 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.egg-info
|
||||
.eggs
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Instance data (user creates fresh)
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
instance/
|
||||
|
||||
# Test data
|
||||
test_data/
|
||||
tests/
|
||||
|
||||
# Pi-specific
|
||||
rpi/
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
*.img.zst.zip
|
||||
|
||||
# Docs
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
*.tmp
|
||||
.DS_Store
|
||||
|
||||
# Dev scripts and old files
|
||||
scripts/
|
||||
old_files/
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Temp files
|
||||
frontends/web/temp_files/
|
||||
*.db
|
||||
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Run command '...'
|
||||
2. Upload image '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: interface
|
||||
attributes:
|
||||
label: Interface
|
||||
description: Which interface are you using?
|
||||
options:
|
||||
- CLI
|
||||
- Web UI
|
||||
- REST API
|
||||
- Python Library
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Stegasoo Version
|
||||
description: Run `stegasoo --version` or check the web UI footer
|
||||
placeholder: "4.0.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: python-version
|
||||
attributes:
|
||||
label: Python Version
|
||||
description: Run `python --version`
|
||||
placeholder: "3.11.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
placeholder: "Ubuntu 22.04 / Windows 11 / macOS 14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Logs
|
||||
description: Paste any relevant error messages or tracebacks.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or files here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security Vulnerability
|
||||
url: https://github.com/adlee-was-taken/stegasoo/security/advisories/new
|
||||
about: Report security vulnerabilities privately
|
||||
- name: Documentation
|
||||
url: https://github.com/adlee-was-taken/stegasoo#readme
|
||||
about: Check the documentation before opening an issue
|
||||
62
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please fill out the form below.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Describe it.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like.
|
||||
placeholder: I would like to be able to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered.
|
||||
|
||||
- type: dropdown
|
||||
id: interface
|
||||
attributes:
|
||||
label: Affected Interface(s)
|
||||
description: Which interface(s) would this feature affect?
|
||||
multiple: true
|
||||
options:
|
||||
- CLI
|
||||
- Web UI
|
||||
- REST API
|
||||
- Python Library
|
||||
- Core Library
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this feature to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow
|
||||
- Critical for my use case
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or examples here.
|
||||
46
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
## Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Mark the relevant option with an 'x' -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] CI/CD or build changes
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link any related issues here -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
- [ ] I have added tests that prove my fix/feature works
|
||||
- [ ] Existing tests pass locally with my changes
|
||||
- [ ] I have tested manually (describe below)
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
<!-- If applicable, describe manual testing performed -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have updated the documentation accordingly
|
||||
- [ ] I have updated CHANGELOG.md (if user-facing changes)
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your changes -->
|
||||
3
.github/workflows/release.yml
vendored
@@ -37,7 +37,8 @@ jobs:
|
||||
publish:
|
||||
needs: test # Only run if tests pass
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment: pypi
|
||||
|
||||
# Required for PyPI trusted publishing (recommended)
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
2
.github/workflows/test.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other jobs if one fails
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
# 1. Get the code
|
||||
|
||||
55
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# Embedded repos (AUR packaging)
|
||||
aur-cli-upload/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -35,6 +38,12 @@ old_files/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Backup files
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
@@ -48,7 +57,7 @@ htmlcov/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
# Distribution
|
||||
@@ -58,6 +67,44 @@ htmlcov/
|
||||
# Output test files.
|
||||
test_data/*.png
|
||||
|
||||
#Project root scripts.
|
||||
rbld_containers.sh
|
||||
quick_web.sh
|
||||
# Dev scripts (local convenience scripts - except these)
|
||||
scripts/*
|
||||
!scripts/validate-release.sh
|
||||
!scripts/smoke-test.sh
|
||||
!scripts/setup-trusted-certs.sh
|
||||
!scripts/screenshots.sh
|
||||
!scripts/build.sh
|
||||
|
||||
# Web UI auth database and SSL certs
|
||||
instance/
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
|
||||
# Tests (private)
|
||||
tests/
|
||||
|
||||
# RPi image build artifacts
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
*.img.zst.zip
|
||||
rpi/tools/pishrink.sh
|
||||
|
||||
# Temp file storage
|
||||
frontends/web/temp_files/
|
||||
rpi/config.json
|
||||
|
||||
# Pre-built Pi tarballs and images (release assets, too large for git)
|
||||
rpi/*.tar.zst
|
||||
rpi/*.tar.zst.zip
|
||||
rpi/*.img
|
||||
rpi/*.img.zst
|
||||
rpi/*.img.zst.zip
|
||||
|
||||
# AUR build artifacts
|
||||
aur-upload/
|
||||
aur/.SRCINFO
|
||||
aur/*.pkg.tar.zst
|
||||
|
||||
# Docker pre-built images and deps (release assets, too large for git)
|
||||
docker/*.tar.zst
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.12.0
|
||||
3.12
|
||||
|
||||
10
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.
|
||||
|
||||
@@ -88,7 +88,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
**Docker with channel key:**
|
||||
```bash
|
||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose up api
|
||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose -f docker/docker-compose.yml up api
|
||||
```
|
||||
|
||||
---
|
||||
@@ -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>
|
||||
```
|
||||
@@ -843,7 +843,7 @@ curl -s -X POST "$BASE_URL/decode/multipart" \
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
### docker-compose.yml
|
||||
### docker/docker-compose.yml
|
||||
|
||||
```yaml
|
||||
x-common-env: &common-env
|
||||
|
||||
234
CHANGELOG.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# 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.3.0] - 2026-02-27
|
||||
|
||||
### Added
|
||||
- **Audio Steganography** — Hide messages in audio files (WAV, FLAC, MP3, OGG, AAC, M4A)
|
||||
- LSB mode: Direct least-significant-bit embedding in audio samples
|
||||
- Spread Spectrum mode: Noise-resistant encoding using pseudo-random spreading
|
||||
- Automatic format transcoding to WAV for embedding
|
||||
- Full CLI support: `stegasoo audio-encode`, `audio-decode`, `audio-info`
|
||||
- REST API endpoints: `/audio/encode`, `/audio/decode`, `/audio/info`
|
||||
- Web UI: Unified encode/decode pages with carrier type selector (Image/Audio)
|
||||
- New `AudioCapacityInfo`, `AudioEmbedStats`, `AudioInfo` model classes
|
||||
- Audio-specific exceptions: `AudioError`, `AudioValidationError`, `AudioCapacityError`, `AudioExtractionError`, `AudioTranscodeError`, `UnsupportedAudioFormatError`
|
||||
- Subprocess isolation for audio operations (crash protection)
|
||||
- `debug.py` module for structured logging across all steganography operations
|
||||
|
||||
### Changed
|
||||
- Encode/Decode web pages now have a "Carrier Type" step to switch between Image and Audio
|
||||
- Version bumped to 4.3.0
|
||||
|
||||
## [4.1.5] - 2026-01-07
|
||||
|
||||
### Added
|
||||
- **Developer Documentation**: Educational comments throughout core modules
|
||||
- DCT module: zig-zag diagrams, QIM explanation, Reed-Solomon deep dive
|
||||
- LSB module: visual bit embedding examples, ChaCha20 pixel selection
|
||||
- Crypto module: multi-factor KDF flow diagrams, Argon2id reasoning
|
||||
- CLI module: Click patterns (groups, JSON output, secure input)
|
||||
- Web UI module: Flask architecture, subprocess isolation, async jobs
|
||||
- **Pi Test Automation**: `rpi/kickoff-pi-test.sh` script
|
||||
- One command to flash, wait for boot, setup, and smoke test
|
||||
- Self-contained (no dotfile dependencies)
|
||||
- **v4.2 Wishlist**: `WISHLIST-4.2.md` for blue-sky ideas (GPU acceleration)
|
||||
|
||||
### Changed
|
||||
- **Pi MOTD Improvements**:
|
||||
- Dynamic temperature emoji (ice/cool/fire based on temp)
|
||||
- Rocket emoji for service status, globe emoji for URL
|
||||
- Shortened Debian boilerplate message
|
||||
- Fixed escaped variable syntax in heredoc
|
||||
|
||||
## [4.1.3] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **Docker Deployment**: Production-ready containerization
|
||||
- `docker-compose.yml` for Web UI (port 5000) and REST API (port 8000)
|
||||
- Multi-stage builds with base image for faster rebuilds
|
||||
- Health checks, resource limits (768MB), and volume persistence
|
||||
- Comprehensive `DOCKER.md` documentation
|
||||
- **Raspberry Pi First-Boot Wizard**: Interactive TUI setup experience
|
||||
- `gum` TUI toolkit for styled prompts and spinners
|
||||
- WiFi configuration, HTTPS setup, channel key generation
|
||||
- Overclock presets (Pi 5: 2.8/3.0 GHz with cooling recommendations)
|
||||
- Port 443 redirect option for clean HTTPS URLs
|
||||
- Styled banners with purple→blue gradient and gold logo
|
||||
- **Pi Image Distribution**: Scripts for SD card imaging
|
||||
- `sanitize-for-image.sh` removes credentials, SSH keys, user data
|
||||
- Soft reset mode for testing without clearing WiFi
|
||||
- Auto-validates sanitization before imaging
|
||||
- **Unit Tests**: Comprehensive pytest test suite
|
||||
- Tests for encode/decode, LSB/DCT modes, channel keys
|
||||
- Validation, generation, compression, edge cases
|
||||
- 29 tests covering core library functionality
|
||||
- **Release Validation**: `scripts/validate-release.sh` for pre-release checks
|
||||
- **Custom SSL Documentation**: Guide for replacing certs, Let's Encrypt setup
|
||||
|
||||
### Changed
|
||||
- Pi MOTD shows CPU speed and temperature when overclocked
|
||||
- Mobile UI polish and responsive improvements
|
||||
- Standardized ASCII banners across all Pi scripts
|
||||
- Setup script uses pyenv for Python 3.12 (Pi OS ships 3.13)
|
||||
|
||||
### Fixed
|
||||
- **SSL certificate generation**: Wizard and setup now generate certs when HTTPS enabled
|
||||
- DCT decode reliability improvements
|
||||
- Fixed `gum --inline` flag compatibility (not supported in all versions)
|
||||
- Wizard banner alignment and spacing issues
|
||||
- Better error handling in app.py for SSL failures
|
||||
|
||||
## [4.1.0] - 2026-01-04
|
||||
|
||||
### Added
|
||||
- **Admin Recovery System**: Password reset for locked-out admins
|
||||
- Recovery key generated during setup (32-char alphanumeric)
|
||||
- Multiple backup options: text file, QR code, stego image
|
||||
- QR codes obfuscated (XOR'd with magic header hash)
|
||||
- Stego backups hide key in an image using Stegasoo itself
|
||||
- CLI: `stegasoo admin recover --db path/to/db`
|
||||
- **EXIF Editor**: Full metadata editing in Tools page
|
||||
- View all EXIF fields from uploaded image
|
||||
- Inline editing of individual fields
|
||||
- Clear all metadata with one click
|
||||
- Download cleaned image
|
||||
- CLI: `stegasoo tools exif image.jpg [--clear] [--set Field=Value]`
|
||||
- **Multi-User Support**: Admin can create up to 16 additional users
|
||||
- Role-based access control (admin/user)
|
||||
- Admin user management page
|
||||
- Temp password generation for new users
|
||||
- **Saved Channel Keys**: Users can save/manage channel keys in account page
|
||||
|
||||
### Changed
|
||||
- **Architecture**: Consolidated `resolve_channel_key()` to library layer
|
||||
- Single source of truth in `src/stegasoo/channel.py`
|
||||
- CLI, API, WebUI now use thin wrappers
|
||||
- **DCT Pre-Check**: Fail fast with helpful error before expensive encoding
|
||||
- **Toast Notifications**: Auto-dismiss after 20 seconds with fade animation
|
||||
- `RECOVERY_OBFUSCATION_KEY` constant added to `constants.py`
|
||||
|
||||
### Fixed
|
||||
- DCT payload size error now caught early with clear message
|
||||
|
||||
## [4.0.2] - 2026-01-02
|
||||
|
||||
### Added
|
||||
- **Web UI Authentication**: Single-admin login with SQLite3 user storage
|
||||
- First-run setup wizard for admin account creation
|
||||
- Account management page for password changes
|
||||
- `@login_required` decorator protects encode/decode/generate routes
|
||||
- Argon2id password hashing (lighter 64MB for fast login)
|
||||
- **Optional HTTPS**: Auto-generated self-signed certificates for home network deployment
|
||||
- Configurable via `STEGASOO_HTTPS_ENABLED` environment variable
|
||||
- Certificates stored in `frontends/web/certs/`
|
||||
- New environment variables: `STEGASOO_AUTH_ENABLED`, `STEGASOO_HTTPS_ENABLED`, `STEGASOO_HOSTNAME`
|
||||
|
||||
### Changed
|
||||
- PIN entry column widened in encode/decode forms (col-md-4 → col-md-6)
|
||||
- Channel options column narrowed (col-md-8 → col-md-6)
|
||||
- QR preview panels enlarged for better text readability
|
||||
- Consistent font sizing across all preview panel banners (0.7rem filename, 0.6rem data, 0.65rem badges)
|
||||
|
||||
### Fixed
|
||||
- QR preview text too small to read in encode/decode templates
|
||||
- Inconsistent label sizes between reference/carrier/stego panels
|
||||
|
||||
## [4.0.1] - 2025-01-02
|
||||
|
||||
### Fixed
|
||||
- Fixed numpy binary incompatibility on Python 3.10 (jpegio/scipy)
|
||||
- Fixed BatchCredentials test failures with missing `reference_photo` parameter
|
||||
- Graceful handling when DCT dependencies have version mismatches
|
||||
|
||||
### Changed
|
||||
- Applied `ruff` linter fixes across entire codebase (~400 issues)
|
||||
- Applied `black` formatter to all Python files
|
||||
- Modernized type hints: `Optional[X]` → `X | None`
|
||||
- Updated ruff config to use `[tool.ruff.lint]` section
|
||||
- Moved documentation files to repository root
|
||||
|
||||
### Removed
|
||||
- Removed obsolete debug/diagnostic scripts
|
||||
- Cleaned up backup files and dev scripts
|
||||
|
||||
## [4.0.0] - 2024-12-29
|
||||
|
||||
### Added
|
||||
- Refreshed Web UI with modern, snazzy interface
|
||||
- Improved user experience across all pages
|
||||
|
||||
### Changed
|
||||
- Major version bump for breaking API changes
|
||||
- Simplified passphrase handling (single passphrase instead of day-based)
|
||||
- Removed date_str parameter from encoding
|
||||
|
||||
### Fixed
|
||||
- Various bug fixes for Web UI
|
||||
- CLI updates and improvements
|
||||
|
||||
## [3.2.0] - 2024-12-28
|
||||
|
||||
### Added
|
||||
- Big revamp of the encoding system
|
||||
- Home and about page improvements
|
||||
- UNDER_THE_HOOD.md documentation
|
||||
|
||||
### Changed
|
||||
- Renamed `phrase` → `passphrase` in API
|
||||
- Updated Web UI styling
|
||||
|
||||
## [3.0.2] - 2024-12-27
|
||||
|
||||
### Added
|
||||
- Full experimental DCT steganography support
|
||||
- jpegio integration for better JPEG manipulation
|
||||
- DCT/LSB mode selector in Web UI
|
||||
|
||||
## [3.0.0] - 2024-12-25
|
||||
|
||||
### Added
|
||||
- DCT (Discrete Cosine Transform) steganography mode
|
||||
- Support for JPEG carriers without quality loss
|
||||
- Channel key feature for private messaging
|
||||
|
||||
### Changed
|
||||
- Complete rewrite of steganography engine
|
||||
- New hybrid authentication system
|
||||
|
||||
## [2.0.0] - 2024-12-20
|
||||
|
||||
### Added
|
||||
- Web UI frontend
|
||||
- REST API (FastAPI)
|
||||
- Batch processing support
|
||||
- RSA key authentication option
|
||||
|
||||
### Changed
|
||||
- Migrated to hybrid photo + passphrase + PIN authentication
|
||||
|
||||
## [1.0.0] - 2024-12-15
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- LSB steganography
|
||||
- AES-256-GCM encryption
|
||||
- CLI interface
|
||||
- Basic PIN authentication
|
||||
|
||||
[4.3.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.2.1...v4.3.0
|
||||
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
||||
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
||||
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
|
||||
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
|
||||
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0
|
||||
[3.2.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.2...v3.2.0
|
||||
[3.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v3.0.0...v3.0.2
|
||||
[3.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v2.0.0...v3.0.0
|
||||
[2.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v1.0.0...v2.0.0
|
||||
[1.0.0]: https://github.com/adlee-was-taken/stegasoo/releases/tag/v1.0.0
|
||||
114
CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Stegasoo — Claude Code Project Guide
|
||||
|
||||
Stegasoo is a secure steganography toolkit with hybrid photo + passphrase + PIN authentication.
|
||||
Version 4.3.0 · Python >=3.11 · MIT License
|
||||
|
||||
## Quick commands
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]" # Install for development (includes all extras)
|
||||
pytest # Run tests (coverage reported automatically)
|
||||
black src/ tests/ frontends/ # Format code
|
||||
ruff check src/ tests/ frontends/ --fix # Lint (auto-fix)
|
||||
mypy src/ # Type check
|
||||
pre-commit run --all-files # Run all pre-commit hooks
|
||||
PYTHONPATH=src python -m stegasoo.cli # Run CLI directly without install
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/stegasoo/ Core library
|
||||
crypto.py Argon2id / PBKDF2 key derivation + AES-256-GCM encryption
|
||||
steganography.py LSB spatial embedding
|
||||
dct_steganography.py DCT domain embedding (JPEG-safe, needs [dct] extras)
|
||||
validation.py Input validation for all security factors
|
||||
constants.py All magic numbers, crypto params, limits
|
||||
models.py Dataclasses (EncodeResult, DecodeResult, etc.)
|
||||
encode.py / decode.py High-level encode/decode orchestration
|
||||
channel.py Channel key management (v4.0+)
|
||||
audio_steganography.py LSB audio embedding/extraction (v4.3.0)
|
||||
spread_steganography.py Spread spectrum audio embedding (v4.3.0)
|
||||
audio_utils.py Audio format detection, validation, transcoding (v4.3.0)
|
||||
debug.py Structured logging for operations (v4.3.0)
|
||||
compression.py Zstandard / zlib / lz4 payload compression
|
||||
cli.py Click CLI entry point
|
||||
generate.py Credential generation (passphrase, PIN, RSA keys)
|
||||
exceptions.py Exception hierarchy (all inherit StegasooError)
|
||||
__init__.py Public API surface (__all__)
|
||||
|
||||
frontends/web/ Flask web UI (entry: app.py)
|
||||
frontends/api/ FastAPI REST API (entry: main.py)
|
||||
frontends/cli/ CLI extras
|
||||
|
||||
tests/ Pytest suite
|
||||
test_stegasoo.py Single test file covering core library
|
||||
```
|
||||
|
||||
### Entry points
|
||||
|
||||
| Interface | Entry point | Install extra |
|
||||
|-----------|-------------|---------------|
|
||||
| CLI | `stegasoo.cli:main` (`stegasoo` command) | `[cli]` |
|
||||
| Web UI | `frontends/web/app.py` | `[web]` |
|
||||
| REST API | `frontends/api/main.py` | `[api]` |
|
||||
|
||||
## Code conventions
|
||||
|
||||
- **Formatter**: Black, 100-char line length
|
||||
- **Linter**: Ruff — rules E, F, I, N, W, UP (E501 ignored). N803/N806 suppressed in `dct_steganography.py` for colorspace variable names
|
||||
- **Type hints**: Required on all new code. `mypy` with `ignore_missing_imports = true`
|
||||
- **Pre-commit hooks**: ruff, ruff-format, trailing-whitespace, end-of-file-fixer, check-yaml, check-toml, check-added-large-files (1MB), check-merge-conflict, debug-statements, bandit (excludes tests/)
|
||||
- **Branch naming**: `feature/`, `fix/`, `docs/`, `refactor/`
|
||||
- **Commits**: Imperative mood, clear subject line. Include what + why
|
||||
|
||||
## Security-critical modules
|
||||
|
||||
These files implement the cryptographic and steganographic core. Changes require extra care, thorough test coverage, and careful review:
|
||||
|
||||
- **`crypto.py`** — Argon2id KDF (256 MB / 4 iterations / 4 parallelism) + PBKDF2 fallback (600K iterations) → AES-256-GCM authenticated encryption
|
||||
- **`steganography.py`** — LSB spatial embedding/extraction
|
||||
- **`dct_steganography.py`** — DCT domain embedding with Reed-Solomon error correction
|
||||
- **`validation.py`** — Input validation for all security factors (PIN, passphrase, image, RSA key, channel key)
|
||||
- **`constants.py`** — Crypto parameters (salt sizes, iteration counts, Argon2 memory cost, format versions). Do not change these casually — they affect backward compatibility and security margins
|
||||
|
||||
## Public API
|
||||
|
||||
`src/stegasoo/__init__.py` defines the full public API surface via `__all__`. Any new public function must be:
|
||||
1. Imported in `__init__.py`
|
||||
2. Added to the `__all__` list
|
||||
|
||||
## Testing
|
||||
|
||||
- Single test file: `tests/test_stegasoo.py`
|
||||
- Requires `pip install -e ".[dev]"` (includes DCT dependencies)
|
||||
- Coverage is reported automatically via pytest config (`--cov=stegasoo --cov-report=term-missing`)
|
||||
- Run: `pytest` (no extra flags needed)
|
||||
|
||||
## Worktree workflow
|
||||
|
||||
When working on features or fixes that touch multiple files, prefer using a git worktree for isolation:
|
||||
|
||||
```bash
|
||||
# Claude Code can create worktrees automatically via /worktree or EnterWorktree
|
||||
# Manual creation:
|
||||
git worktree add .claude/worktrees/<name> -b <branch-name>
|
||||
```
|
||||
|
||||
### Guidelines for worktree usage
|
||||
|
||||
- **Use worktrees for**: multi-file refactors, experimental changes, anything that might need to be discarded
|
||||
- **Worktree location**: `.claude/worktrees/` (gitignored by Claude Code)
|
||||
- **Branch from**: always branch from `main` unless working on a version branch (e.g., `4.2`)
|
||||
- **Naming**: use the same conventions as branches — `feature/description`, `fix/description`, etc.
|
||||
- **Cleanup**: worktrees in `.claude/worktrees/` are ephemeral. Remove with `git worktree remove <path>` when done
|
||||
- **Testing in worktrees**: run `pip install -e ".[dev]"` inside the worktree before running tests, since the editable install points to the worktree's source
|
||||
- **Merging back**: create a PR from the worktree branch, or merge locally into `main`
|
||||
|
||||
## Useful context
|
||||
|
||||
- BIP-39 wordlist lives at `src/stegasoo/data/bip39-words.txt` (used for passphrase generation)
|
||||
- Docker support: `src/stegasoo/Dockerfile` + `docs/DOCKER_QUICKSTART.md`
|
||||
- Raspberry Pi builds: `rpi/` directory
|
||||
- AUR packages: `aur/`, `aur-cli/`, `aur-api/`
|
||||
- Version is defined in both `pyproject.toml` and `src/stegasoo/__init__.py` — keep them in sync
|
||||
198
CLI.md
@@ -1,11 +1,11 @@
|
||||
# Stegasoo CLI Documentation (v4.0.1)
|
||||
# Stegasoo CLI Documentation (v4.1.0)
|
||||
|
||||
Complete command-line interface reference for Stegasoo steganography operations.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [What's New in v4.0.0](#whats-new-in-v400)
|
||||
- [What's New in v4.1.0](#whats-new-in-v410)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Commands](#commands)
|
||||
- [generate](#generate-command)
|
||||
@@ -13,10 +13,11 @@ Complete command-line interface reference for Stegasoo steganography operations.
|
||||
- [decode](#decode-command)
|
||||
- [verify](#verify-command)
|
||||
- [channel](#channel-command)
|
||||
- [admin](#admin-command)
|
||||
- [tools](#tools-command)
|
||||
- [info](#info-command)
|
||||
- [compare](#compare-command)
|
||||
- [modes](#modes-command)
|
||||
- [strip-metadata](#strip-metadata-command)
|
||||
- [Channel Keys](#channel-keys)
|
||||
- [Embedding Modes](#embedding-modes)
|
||||
- [Security Factors](#security-factors)
|
||||
@@ -63,11 +64,42 @@ python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if ha
|
||||
stegasoo channel show
|
||||
```
|
||||
|
||||
### Man Page
|
||||
|
||||
```bash
|
||||
# Install man page
|
||||
sudo mkdir -p /usr/local/share/man/man1
|
||||
sudo cp docs/stegasoo.1 /usr/local/share/man/man1/
|
||||
sudo mandb
|
||||
|
||||
# View
|
||||
man stegasoo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.1.0
|
||||
|
||||
Version 4.1.0 adds **admin recovery** and **tools** commands:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Admin recovery | Reset admin password using recovery key |
|
||||
| EXIF tools | View, edit, and strip image metadata |
|
||||
| Peek tool | Quick stego detection check |
|
||||
| Strip tool | Remove hidden data from images |
|
||||
|
||||
**New commands:**
|
||||
- `stegasoo admin recover` - Reset admin password with recovery key
|
||||
- `stegasoo tools exif` - View/edit EXIF metadata
|
||||
- `stegasoo tools peek` - Check for hidden data
|
||||
- `stegasoo tools strip` - Remove stego data from image
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.0
|
||||
|
||||
Version 4.0.0 adds **channel key** support for deployment/group isolation:
|
||||
Version 4.0.0 added **channel key** support for deployment/group isolation:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
@@ -76,14 +108,6 @@ Version 4.0.0 adds **channel key** support for deployment/group isolation:
|
||||
| CLI management | New `stegasoo channel` command group |
|
||||
| Flexible override | Use server config, explicit key, or public mode |
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Isolate messages between teams, deployments, or groups
|
||||
- ✅ Same credentials can't decode messages from different channels
|
||||
- ✅ Backward compatible (public mode = no channel key)
|
||||
- ✅ Easy key distribution via environment variables or config files
|
||||
|
||||
**Breaking change:** v4.0.0 messages (with channel key) cannot be decoded by v3.x installations.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -140,7 +164,7 @@ stegasoo generate [OPTIONS]
|
||||
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
||||
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
||||
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
|
||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
|
||||
| `--words` | | 3-12 | 4 | Words in passphrase |
|
||||
| `--output` | `-o` | path | | Save RSA key to file |
|
||||
| `--password` | `-p` | string | | Password for RSA key file |
|
||||
@@ -156,7 +180,7 @@ stegasoo generate
|
||||
stegasoo generate --words 6
|
||||
|
||||
# Generate with RSA key
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa --rsa-bits 3072
|
||||
|
||||
# Save RSA key to encrypted file
|
||||
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
||||
@@ -495,12 +519,150 @@ Now also displays channel key status.
|
||||
|
||||
---
|
||||
|
||||
### Strip-Metadata Command
|
||||
### Admin Command
|
||||
|
||||
Remove all metadata from an image.
|
||||
Manage Web UI admin accounts and recovery.
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `recover` | Reset admin password using recovery key |
|
||||
|
||||
#### admin recover
|
||||
|
||||
Reset the admin password for a Web UI database.
|
||||
|
||||
```bash
|
||||
stegasoo strip-metadata IMAGE [OPTIONS]
|
||||
stegasoo admin recover --db PATH [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Short | Type | Required | Description |
|
||||
|--------|-------|------|----------|-------------|
|
||||
| `--db` | `-d` | path | ✓ | Path to stegasoo.db file |
|
||||
| `--key` | `-k` | string | | Recovery key (prompted if not provided) |
|
||||
| `--password` | `-p` | string | | New password (prompted if not provided) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Interactive mode (prompts for key and password)
|
||||
stegasoo admin recover --db frontends/web/instance/stegasoo.db
|
||||
|
||||
# Non-interactive mode
|
||||
stegasoo admin recover \
|
||||
--db /path/to/stegasoo.db \
|
||||
--key "XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" \
|
||||
--password "NewSecurePassword123"
|
||||
```
|
||||
|
||||
**Recovery process:**
|
||||
1. The recovery key is verified against the database hash
|
||||
2. If valid, the admin password is reset
|
||||
3. User can now log in with the new password
|
||||
|
||||
**Note:** Recovery keys are instance-bound. A key from one database won't work on another.
|
||||
|
||||
---
|
||||
|
||||
### Tools Command
|
||||
|
||||
Image utilities and analysis tools.
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `exif` | View/edit EXIF metadata |
|
||||
| `peek` | Check for hidden data |
|
||||
| `strip` | Remove stego data from image |
|
||||
|
||||
#### tools exif
|
||||
|
||||
View and edit EXIF metadata in images.
|
||||
|
||||
```bash
|
||||
stegasoo tools exif IMAGE [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--clear` | flag | Remove all EXIF metadata |
|
||||
| `--set FIELD=VALUE` | string | Set a specific EXIF field |
|
||||
| `--output` / `-o` | path | Output filename (default: overwrites input) |
|
||||
| `--json` | flag | Output as JSON |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# View all EXIF data
|
||||
stegasoo tools exif photo.jpg
|
||||
|
||||
# View as JSON
|
||||
stegasoo tools exif photo.jpg --json
|
||||
|
||||
# Clear all metadata
|
||||
stegasoo tools exif photo.jpg --clear -o clean.jpg
|
||||
|
||||
# Set specific fields
|
||||
stegasoo tools exif photo.jpg \
|
||||
--set "Artist=John Doe" \
|
||||
--set "Copyright=2026" \
|
||||
-o tagged.jpg
|
||||
|
||||
# Remove GPS data only
|
||||
stegasoo tools exif photo.jpg \
|
||||
--set "GPSLatitude=" \
|
||||
--set "GPSLongitude=" \
|
||||
-o no-gps.jpg
|
||||
```
|
||||
|
||||
#### tools peek
|
||||
|
||||
Check if an image contains hidden Stegasoo data.
|
||||
|
||||
```bash
|
||||
stegasoo tools peek IMAGE [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--json` | flag | Output as JSON |
|
||||
| `--quiet` / `-q` | flag | Exit code only (0=found, 1=not found) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Check for hidden data
|
||||
stegasoo tools peek suspicious.png
|
||||
|
||||
# Script-friendly check
|
||||
if stegasoo tools peek image.png -q; then
|
||||
echo "Contains hidden data"
|
||||
fi
|
||||
```
|
||||
|
||||
#### tools strip
|
||||
|
||||
Remove hidden stego data from an image (destructive).
|
||||
|
||||
```bash
|
||||
stegasoo tools strip IMAGE [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `--output` / `-o` | path | Output filename |
|
||||
| `--force` / `-f` | flag | Overwrite without confirmation |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Strip and save to new file
|
||||
stegasoo tools strip stego.png -o clean.png
|
||||
|
||||
# Strip in place (with confirmation)
|
||||
stegasoo tools strip stego.png
|
||||
```
|
||||
|
||||
---
|
||||
@@ -648,7 +810,7 @@ stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**docker-compose.yml:**
|
||||
**docker/docker-compose.yml:**
|
||||
```yaml
|
||||
x-common-env: &common-env
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
|
||||
54
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards
|
||||
of acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainers. All complaints will be reviewed and
|
||||
investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
|
||||
version 2.0.
|
||||
165
CONTRIBUTING.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Contributing to Stegasoo
|
||||
|
||||
Thank you for your interest in contributing to Stegasoo! This document provides guidelines and information for contributors.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 - 3.12
|
||||
- Git
|
||||
- Docker (optional, for container testing)
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
```
|
||||
|
||||
2. **Create a virtual environment**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install development dependencies**
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
4. **Install pre-commit hooks**
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Style
|
||||
|
||||
We use the following tools to maintain code quality:
|
||||
|
||||
- **Black** - Code formatting (line length: 100)
|
||||
- **Ruff** - Linting
|
||||
- **MyPy** - Type checking
|
||||
|
||||
Run all checks before committing:
|
||||
```bash
|
||||
black src/ tests/ frontends/
|
||||
ruff check src/ tests/ frontends/
|
||||
mypy src/
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=stegasoo --cov-report=term-missing
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_stegasoo.py
|
||||
```
|
||||
|
||||
### Type Hints
|
||||
|
||||
All new code should include type hints:
|
||||
|
||||
```python
|
||||
def encode_message(
|
||||
message: str,
|
||||
carrier_image: bytes,
|
||||
passphrase: str,
|
||||
pin: str = "",
|
||||
) -> EncodeResult:
|
||||
...
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branch Naming
|
||||
|
||||
- `feature/description` - New features
|
||||
- `fix/description` - Bug fixes
|
||||
- `docs/description` - Documentation updates
|
||||
- `refactor/description` - Code refactoring
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Write clear, concise commit messages:
|
||||
|
||||
```
|
||||
Add channel key validation for private messaging
|
||||
|
||||
- Implement validate_channel_key() function
|
||||
- Add tests for valid/invalid key formats
|
||||
- Update CLI to support --channel-key flag
|
||||
```
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. **Create a feature branch** from `main`
|
||||
2. **Make your changes** with appropriate tests
|
||||
3. **Ensure all checks pass** (tests, linting, formatting)
|
||||
4. **Submit a PR** with a clear description
|
||||
5. **Address review feedback** promptly
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Tests added/updated for changes
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] CHANGELOG.md updated for user-facing changes
|
||||
- [ ] All CI checks passing
|
||||
- [ ] No merge conflicts with `main`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stegasoo/
|
||||
├── src/stegasoo/ # Core library
|
||||
│ ├── crypto.py # Encryption/decryption
|
||||
│ ├── steganography.py # LSB embedding
|
||||
│ ├── dct_steganography.py # DCT embedding
|
||||
│ └── ...
|
||||
├── frontends/
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ ├── web/ # Flask web UI
|
||||
│ └── api/ # FastAPI REST API
|
||||
├── tests/ # Test suite
|
||||
└── examples/ # Usage examples
|
||||
```
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Please include:
|
||||
- Python version and OS
|
||||
- Stegasoo version (`stegasoo --version`)
|
||||
- Minimal reproduction steps
|
||||
- Expected vs actual behavior
|
||||
- Error messages/tracebacks
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Please include:
|
||||
- Use case description
|
||||
- Proposed solution (if any)
|
||||
- Alternatives considered
|
||||
|
||||
## Security
|
||||
|
||||
If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines. **Do not open a public issue for security vulnerabilities.**
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open a discussion or issue if you have questions about contributing.
|
||||
|
||||
Thank you for helping make Stegasoo better!
|
||||
156
DOCKER.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Docker Deployment
|
||||
|
||||
Stegasoo provides Docker images for both the Web UI and REST API.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build and start all services
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Check status
|
||||
docker-compose -f docker/docker-compose.yml ps
|
||||
```
|
||||
|
||||
Access:
|
||||
- **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
|
||||
- **REST API**: http://localhost:8000
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| `web` | 5000 | Flask Web UI with authentication |
|
||||
| `api` | 8000 | FastAPI REST API |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file or set these variables:
|
||||
|
||||
```bash
|
||||
# Channel key for private group communication (optional)
|
||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||||
|
||||
# Web UI authentication (default: enabled)
|
||||
STEGASOO_AUTH_ENABLED=true
|
||||
|
||||
# HTTPS support (default: enabled, generates self-signed cert)
|
||||
STEGASOO_HTTPS_ENABLED=true
|
||||
STEGASOO_HOSTNAME=localhost
|
||||
|
||||
# To disable HTTPS:
|
||||
# STEGASOO_HTTPS_ENABLED=false
|
||||
```
|
||||
|
||||
### Volume Mounts
|
||||
|
||||
Persistent data is stored in Docker volumes:
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `stegasoo-web-data` | User database, session data |
|
||||
| `stegasoo-web-certs` | SSL certificates (if HTTPS enabled) |
|
||||
|
||||
## Building
|
||||
|
||||
### Standard Build (Recommended)
|
||||
|
||||
Uses a pre-built base image with all dependencies:
|
||||
|
||||
```bash
|
||||
# First time only: build the base image
|
||||
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
||||
|
||||
# Build services (fast - only copies app code)
|
||||
docker-compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
### Full Build (No Base Image)
|
||||
|
||||
If you don't have the base image, the Dockerfile will build all dependencies (slower):
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose -f docker/docker-compose.yml down
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose -f docker/docker-compose.yml build && docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Full rebuild (no cache)
|
||||
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||
```
|
||||
|
||||
## Resource Limits
|
||||
|
||||
Each container is configured with:
|
||||
- **Memory limit**: 768 MB
|
||||
- **Memory reservation**: 384 MB
|
||||
|
||||
This accounts for Argon2id's 256 MB RAM requirement during key derivation.
|
||||
|
||||
## Health Checks
|
||||
|
||||
Both services include health checks:
|
||||
- Interval: 30 seconds
|
||||
- Timeout: 10 seconds
|
||||
- Start period: 5 seconds
|
||||
- Retries: 3
|
||||
|
||||
Check health status:
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml ps
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production, consider:
|
||||
|
||||
1. **Enable HTTPS**:
|
||||
```bash
|
||||
STEGASOO_HTTPS_ENABLED=true
|
||||
STEGASOO_HOSTNAME=your-domain.com
|
||||
```
|
||||
|
||||
2. **Use secrets for channel key**:
|
||||
```bash
|
||||
# Don't commit .env files with secrets
|
||||
export STEGASOO_CHANNEL_KEY=your-key
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
|
||||
|
||||
4. **Backup volumes**:
|
||||
```bash
|
||||
docker run --rm -v stegasoo-web-data:/data -v $(pwd):/backup \
|
||||
alpine tar czf /backup/stegasoo-backup.tar.gz /data
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose -f docker/docker-compose.yml logs web
|
||||
docker-compose -f docker/docker-compose.yml logs api
|
||||
```
|
||||
|
||||
### Out of memory
|
||||
Increase Docker's memory allocation or reduce worker count in `docker/Dockerfile`.
|
||||
|
||||
### Permission errors
|
||||
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.
|
||||
309
INSTALL.md
@@ -20,22 +20,23 @@ Complete installation instructions for all platforms and deployment methods.
|
||||
|
||||
## Requirements
|
||||
|
||||
### ⚠️ Python Version Requirements
|
||||
### Python Version Requirements
|
||||
|
||||
| Python Version | Status | Notes |
|
||||
|----------------|--------|-------|
|
||||
| 3.10 | ✅ Supported | |
|
||||
| 3.11 | ✅ Supported | Recommended |
|
||||
| 3.10 | ❌ Not Supported | Dropped in v4.2.1 |
|
||||
| 3.11 | ✅ Supported | Minimum version |
|
||||
| 3.12 | ✅ Supported | Recommended |
|
||||
| 3.13 | ❌ **Not Supported** | jpegio C extension incompatible |
|
||||
| 3.13 | ✅ Supported | |
|
||||
| 3.14 | ✅ Supported | Tested on Arch |
|
||||
|
||||
**Important:** Python 3.13 (released October 2024) is **not compatible** with jpegio due to C extension ABI changes. Use Python 3.12 or earlier.
|
||||
**Note:** v4.2.1 switched from `jpegio` to `jpeglib` for DCT steganography, enabling Python 3.11-3.14 support.
|
||||
|
||||
### Minimum Requirements
|
||||
|
||||
| Requirement | Value |
|
||||
|-------------|-------|
|
||||
| Python | 3.10-3.12 |
|
||||
| Python | 3.11-3.14 |
|
||||
| RAM | 512 MB minimum (256MB for Argon2) |
|
||||
| Disk | ~100 MB |
|
||||
|
||||
@@ -154,10 +155,10 @@ Build and run individual containers.
|
||||
#### Build Images
|
||||
|
||||
```bash
|
||||
# Build all targets
|
||||
docker build -t stegasoo-web --target web .
|
||||
docker build -t stegasoo-api --target api .
|
||||
docker build -t stegasoo-cli --target cli .
|
||||
# From project root - build all targets
|
||||
docker build -t stegasoo-web --target web -f docker/Dockerfile .
|
||||
docker build -t stegasoo-api --target api -f docker/Dockerfile .
|
||||
docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
#### Run Web UI
|
||||
@@ -214,19 +215,36 @@ The easiest way to run all services.
|
||||
|
||||
```bash
|
||||
# Start in background
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Start specific service
|
||||
docker-compose up -d web
|
||||
docker-compose up -d api
|
||||
docker-compose -f docker/docker-compose.yml up -d web
|
||||
docker-compose -f docker/docker-compose.yml up -d api
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
docker-compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Stop all
|
||||
docker-compose down
|
||||
docker-compose -f docker/docker-compose.yml 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 -f docker/docker-compose.yml up -d web
|
||||
```
|
||||
|
||||
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
|
||||
|
||||
#### Services
|
||||
|
||||
| Service | URL | Description |
|
||||
@@ -238,16 +256,16 @@ docker-compose down
|
||||
|
||||
```bash
|
||||
# Build images and start
|
||||
docker-compose up -d --build
|
||||
docker-compose -f docker/docker-compose.yml up -d --build
|
||||
|
||||
# Force rebuild (no cache)
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### Resource Configuration
|
||||
|
||||
The `docker-compose.yml` includes resource limits:
|
||||
The `docker/docker-compose.yml` includes resource limits:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -406,32 +424,257 @@ pip install jpegio
|
||||
|
||||
### Windows
|
||||
|
||||
1. Install Python 3.12 from [python.org](https://python.org) (NOT 3.13!)
|
||||
2. Install Visual Studio Build Tools
|
||||
Windows users have three options, listed from easiest to most complex:
|
||||
|
||||
#### Option 1: Docker Desktop (Recommended)
|
||||
|
||||
The easiest way to run Stegasoo on Windows. No Python installation needed.
|
||||
|
||||
1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||
2. Enable WSL2 backend when prompted
|
||||
3. Clone and run:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
docker-compose -f docker/docker-compose.yml up -d web
|
||||
```
|
||||
|
||||
Access at http://localhost:5000
|
||||
|
||||
#### Option 2: WSL2 (Windows Subsystem for Linux)
|
||||
|
||||
Run the Linux version natively on Windows.
|
||||
|
||||
```powershell
|
||||
# Install WSL2 with Ubuntu
|
||||
wsl --install -d Ubuntu
|
||||
|
||||
# Open Ubuntu terminal, then follow Linux instructions:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3.12 python3.12-venv libzbar0 libjpeg-dev
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e ".[all]"
|
||||
stegasoo --version
|
||||
```
|
||||
|
||||
#### Option 3: Native Windows (Advanced)
|
||||
|
||||
Native Windows installation requires Visual Studio Build Tools for compiling C extensions.
|
||||
|
||||
1. Install Python 3.11 or 3.12 from [python.org](https://python.org)
|
||||
2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with "Desktop development with C++"
|
||||
3. Install from pip:
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate
|
||||
pip install stegasoo[all]
|
||||
pip install stegasoo[cli] # CLI only (easiest)
|
||||
# or
|
||||
pip install stegasoo[all] # Full install (may require additional setup)
|
||||
```
|
||||
|
||||
**Note:** Native Windows installation may have issues with `jpegio` (DCT mode). Docker or WSL2 is recommended for full functionality.
|
||||
|
||||
### Raspberry Pi
|
||||
|
||||
Stegasoo works on Raspberry Pi 4 (2GB+ RAM recommended):
|
||||
Stegasoo works on Raspberry Pi 4/5 (4GB+ RAM recommended for Web UI).
|
||||
|
||||
#### Step 1: Install System Dependencies
|
||||
|
||||
```bash
|
||||
# System dependencies
|
||||
sudo apt-get install python3-dev libzbar0 libjpeg-dev
|
||||
|
||||
# Install (may take a while to compile)
|
||||
pip install stegasoo[cli]
|
||||
|
||||
# For web/api, ensure enough RAM
|
||||
pip install stegasoo[web] # Needs ~768MB free
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libbz2-dev \
|
||||
libreadline-dev \
|
||||
libsqlite3-dev \
|
||||
libncursesw5-dev \
|
||||
xz-utils \
|
||||
tk-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libffi-dev \
|
||||
liblzma-dev \
|
||||
libzbar0 \
|
||||
libjpeg-dev
|
||||
```
|
||||
|
||||
**Note:** Argon2 operations will be slower on Pi due to memory-hardness.
|
||||
#### Step 2: Install Python 3.12 via pyenv
|
||||
|
||||
Raspberry Pi OS ships with Python 3.13, which is **not compatible** with jpegio. Install Python 3.12:
|
||||
|
||||
```bash
|
||||
# Install pyenv
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add to ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Python 3.12 (takes ~10 minutes on Pi 5)
|
||||
pyenv install 3.12
|
||||
pyenv global 3.12
|
||||
```
|
||||
|
||||
#### Step 3: Build jpegio for ARM
|
||||
|
||||
The upstream jpegio has x86-specific build flags. Patch and build from source:
|
||||
|
||||
```bash
|
||||
# Clone jpegio
|
||||
git clone https://github.com/dwgoon/jpegio.git
|
||||
cd jpegio
|
||||
|
||||
# Patch for ARM (removes x86-specific -m64 flag)
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM fix/" setup.py
|
||||
|
||||
# Build and install
|
||||
pip install .
|
||||
cd ..
|
||||
```
|
||||
|
||||
#### Step 4: Install Stegasoo
|
||||
|
||||
```bash
|
||||
# Clone Stegasoo
|
||||
git clone https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
|
||||
# Create venv with Python 3.12
|
||||
~/.pyenv/versions/3.12.*/bin/python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install (jpegio already installed, skip it)
|
||||
pip install -e ".[web]" --no-deps
|
||||
pip install argon2-cffi cryptography pillow flask gunicorn scipy numpy pyzbar qrcode
|
||||
```
|
||||
|
||||
#### Step 5: Run the Web UI
|
||||
|
||||
```bash
|
||||
cd frontends/web
|
||||
|
||||
# Optional: Enable authentication
|
||||
export STEGASOO_AUTH_ENABLED=true
|
||||
|
||||
# Optional: Enable HTTPS for local network security
|
||||
export STEGASOO_HTTPS_ENABLED=true
|
||||
export STEGASOO_HOSTNAME=raspberrypi.local
|
||||
|
||||
# Start server
|
||||
python app.py
|
||||
# Access at http://<pi-ip>:5000
|
||||
```
|
||||
|
||||
#### Verify Installation
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import stegasoo
|
||||
from stegasoo.dct_steganography import has_jpegio_support
|
||||
print(f'Stegasoo: {stegasoo.__version__}')
|
||||
print(f'Argon2: {stegasoo.has_argon2()}')
|
||||
print(f'DCT: {stegasoo.has_dct_support()}')
|
||||
print(f'jpegio: {has_jpegio_support()}')
|
||||
"
|
||||
# Expected: All True
|
||||
```
|
||||
|
||||
#### Notes
|
||||
|
||||
- **RAM**: Web UI needs ~768MB free for Argon2 + scipy operations
|
||||
- **Performance**: Argon2 operations take 3-5 seconds on Pi 5 (vs ~2s on desktop)
|
||||
- **Python 3.13**: Not supported due to jpegio C extension incompatibility
|
||||
- **First run**: Will prompt you to create an admin account
|
||||
- **HTTPS**: Generates self-signed certificate (browsers will warn)
|
||||
|
||||
---
|
||||
|
||||
## Custom SSL Certificates
|
||||
|
||||
By default, Stegasoo generates a self-signed certificate for HTTPS. To use your own certificate (e.g., from Let's Encrypt or your organization's CA):
|
||||
|
||||
### Replace Self-Signed Certificates
|
||||
|
||||
```bash
|
||||
# Stop the service
|
||||
sudo systemctl stop stegasoo
|
||||
|
||||
# Backup existing certs (optional)
|
||||
mv /opt/stegasoo/frontends/web/certs /opt/stegasoo/frontends/web/certs.bak
|
||||
|
||||
# Create new certs directory
|
||||
mkdir -p /opt/stegasoo/frontends/web/certs
|
||||
|
||||
# Copy your certificates (adjust paths as needed)
|
||||
cp /path/to/your/certificate.crt /opt/stegasoo/frontends/web/certs/server.crt
|
||||
cp /path/to/your/private.key /opt/stegasoo/frontends/web/certs/server.key
|
||||
|
||||
# Set permissions (key must be readable by service user)
|
||||
chmod 600 /opt/stegasoo/frontends/web/certs/server.key
|
||||
chown -R $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Generate New Self-Signed Certificate
|
||||
|
||||
If your certificate expires or you need to regenerate:
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop stegasoo
|
||||
|
||||
# Generate new cert with SANs
|
||||
CERT_DIR="/opt/stegasoo/frontends/web/certs"
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$CERT_DIR/server.key" \
|
||||
-out "$CERT_DIR/server.crt" \
|
||||
-days 365 -nodes \
|
||||
-subj "/O=Stegasoo/CN=$HOSTNAME" \
|
||||
-addext "subjectAltName=DNS:$HOSTNAME,DNS:$HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1"
|
||||
|
||||
chmod 600 "$CERT_DIR/server.key"
|
||||
|
||||
# Start service
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Let's Encrypt with Certbot
|
||||
|
||||
For publicly accessible servers:
|
||||
|
||||
```bash
|
||||
# Install certbot
|
||||
sudo apt install certbot
|
||||
|
||||
# Get certificate (standalone mode)
|
||||
sudo certbot certonly --standalone -d yourdomain.com
|
||||
|
||||
# Copy to Stegasoo
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /opt/stegasoo/frontends/web/certs/server.crt
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /opt/stegasoo/frontends/web/certs/server.key
|
||||
sudo chown $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs/*
|
||||
sudo chmod 600 /opt/stegasoo/frontends/web/certs/server.key
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
**Note:** Set up a cron job or systemd timer to copy renewed certificates and restart Stegasoo.
|
||||
|
||||
---
|
||||
|
||||
@@ -655,7 +898,7 @@ Argon2 needs 256MB per operation. Increase container memory:
|
||||
# Docker run
|
||||
docker run --memory=768m ...
|
||||
|
||||
# Docker Compose - edit docker-compose.yml
|
||||
# Docker Compose - edit docker/docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
294
IdeasScout_PLANS_20260324.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Stegasoo Ideas Scout — Implementation Plans (2026-03-24)
|
||||
|
||||
Baseline: v4.3.0, Python >=3.11, FORMAT_VERSION 5, no existing users (no backward compat constraints).
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Quick Wins
|
||||
|
||||
### 1. Platform-Calibrated DCT Presets
|
||||
|
||||
**Description**: `--platform telegram|discord|signal|whatsapp` flag for DCT encode. Bakes in each platform's known recompression parameters. Pre-verifies payload survives before outputting.
|
||||
|
||||
**Implementation approach**:
|
||||
- New file `src/stegasoo/platform_presets.py` — `PlatformPreset` dataclass + `PRESETS` dict mapping platform → tuned `quant_step`, `jpeg_quality`, `embed_positions`, `max_dimension`, `recompress_quality`
|
||||
- `dct_steganography.py`: `_embed_scipy_dct_safe()` / `_embed_jpegio()` accept optional preset overrides for `QUANT_STEP`, `DEFAULT_EMBED_POSITIONS`, output quality
|
||||
- New `pre_verify_survival()` function: encode → re-save at platform quality → extract → pass/fail
|
||||
- Thread `platform` param through `encode.py` → `steganography.py` → DCT functions
|
||||
- `cli.py`: add `--platform` as `click.Choice` + `--verify/--no-verify` (pre-verification doubles encode time)
|
||||
- LSB + `--platform` should error early — LSB data is destroyed by any JPEG recompression
|
||||
|
||||
**Known platform params** (from research):
|
||||
| Platform | Quality | Max Dimension | Notes |
|
||||
|----------|---------|---------------|-------|
|
||||
| Telegram | ~82 | 2560×2560 | ~81KB embeddable |
|
||||
| Discord | ~85 | Varies (Nitro) | |
|
||||
| Signal | ~80 | Aggressive | |
|
||||
| WhatsApp | ~70 | 1600×1600 | Most lossy |
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- >95% payload survival rate per platform at 1KB message size in automated tests
|
||||
- Pre-verification correctly predicts real platform behavior (manual validation per platform at least once)
|
||||
|
||||
**Complexity**: **M** — new file + parameter threading through 4-5 functions
|
||||
|
||||
**Risks**: Platform params change without notice. Add version/date stamps to presets and a `stegasoo tools verify-platform` test command.
|
||||
|
||||
---
|
||||
|
||||
### 2. Steganalysis Self-Check (`stegasoo check`)
|
||||
|
||||
**Description**: New CLI command running chi-square and RS (Regular-Singular) statistical analysis on stego images. Outputs detectability risk level (low/medium/high).
|
||||
|
||||
**Implementation approach**:
|
||||
- New file `src/stegasoo/steganalysis.py`:
|
||||
- `chi_square_analysis(image_data) -> float` — chi-square statistic on LSB distribution per channel
|
||||
- `rs_analysis(image_data) -> float` — Regular-Singular groups analysis (requires numpy)
|
||||
- `assess_risk(chi_p, rs_estimate) -> str` — maps to "low"/"medium"/"high"
|
||||
- `check_image(image_data) -> dict` — orchestrator
|
||||
- `cli.py`: new `@cli.command("check")` with `IMAGE` arg, `--json`, `--mode lsb|dct|auto`
|
||||
- `constants.py`: threshold constants for chi-square p-value and RS boundaries
|
||||
- `__init__.py`: export `check_image` in `__all__`
|
||||
- Start LSB-only; DCT steganalysis (calibration attack) deferred
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- Clean images → consistently "low risk"
|
||||
- Naive sequential LSB → "high risk"
|
||||
- Stegasoo LSB at <50% capacity → "low" or "medium"
|
||||
|
||||
**Complexity**: **M** — ~150 lines numpy per test, straightforward CLI integration
|
||||
|
||||
---
|
||||
|
||||
### 3. Python 3.13 DCT Cleanup
|
||||
|
||||
**Description**: The `jpegio` → `jpeglib` migration is already done in code. Remaining work: rename stale `jpegio` references and verify on 3.13.
|
||||
|
||||
**Implementation approach**:
|
||||
- `dct_steganography.py`: rename `HAS_JPEGIO` → `HAS_JPEGLIB`, `_jpegio_*` functions → `_jpeglib_*`, update constant names (`JPEGIO_MAGIC` → `JPEGLIB_MAGIC`, etc.)
|
||||
- Verify `jpeglib.to_jpegio()` compatibility shim — if jpeglib plans to deprecate it, migrate to native API
|
||||
- Run full test suite on Python 3.13
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- All DCT tests pass on Python 3.13
|
||||
- No deprecation warnings from jpeglib
|
||||
|
||||
**Complexity**: **S** — renaming and verification only
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Strategic
|
||||
|
||||
### 4. Content-Adaptive Embedding (S-UNIWARD/WOW-inspired)
|
||||
|
||||
**Description**: Replace uniform-random pixel selection with texture-weighted cost functions. Embed preferentially in busy/textured regions where changes are least detectable. 3-5x harder to detect statistically.
|
||||
|
||||
**Implementation approach**:
|
||||
- New file `src/stegasoo/adaptive_cost.py`:
|
||||
- `compute_cost_map(image_data) -> np.ndarray` — per-pixel distortion cost via directional high-pass filters (Daubechets wavelet bank / KB filter)
|
||||
- `select_pixels_by_cost(cost_map, pixel_key, num_needed) -> list[int]` — weighted sampling, still ChaCha20-seeded for determinism
|
||||
- `steganography.py`:
|
||||
- `generate_pixel_indices()`: add `cost_map` param, use weighted sampling when provided
|
||||
- `_embed_lsb()`: compute cost map when adaptive mode enabled
|
||||
- `_extract_lsb()`: must compute identical cost map to find same pixels
|
||||
- `dct_steganography.py`: adapt `DEFAULT_EMBED_POSITIONS` per-block based on block texture energy
|
||||
- Thread `adaptive: bool` through `encode.py`/`decode.py`
|
||||
- `constants.py`: add `EMBED_MODE_ADAPTIVE_LSB`, filter kernels, cost thresholds
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- Chi-square test (Feature 2) shows measurable improvement vs uniform-random
|
||||
- **Critical**: cost map computation is deterministic across platforms (quantize to fixed-point integers)
|
||||
- Round-trip decode succeeds on Linux x86, Linux ARM, macOS
|
||||
|
||||
**Complexity**: **L** — novel algorithm, cross-platform determinism requirement, touches core embedding
|
||||
|
||||
**Risks**: Floating-point differences in wavelet computation could break extraction. Mitigate with integer quantization. Increases encode/decode time ~2-3x.
|
||||
|
||||
---
|
||||
|
||||
### 5. Per-Message Forward Secrecy via HKDF
|
||||
|
||||
**Description**: Derive ephemeral per-message encryption keys using HKDF expansion from the Argon2id root key + random nonce. Compromising one message doesn't reveal others.
|
||||
|
||||
**Implementation approach**:
|
||||
- `crypto.py`:
|
||||
- Add `from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand`
|
||||
- `derive_message_key(root_key, nonce) -> bytes` — HKDF-Expand with SHA-256
|
||||
- `encrypt_message()`: generate 16-byte random nonce, derive per-message key, embed nonce in header
|
||||
- `decrypt_message()`: extract nonce, derive same key
|
||||
- Also derive pixel selection key via HKDF with different `info` param
|
||||
- `constants.py`:
|
||||
- Bump `FORMAT_VERSION` to 6
|
||||
- `HKDF_INFO_ENCRYPTION = b"stegasoo-v6-encrypt"`, `HKDF_INFO_PIXEL = b"stegasoo-v6-pixel"`
|
||||
- `MESSAGE_NONCE_SIZE = 16`
|
||||
- Header grows from 66 → 82 bytes: add `message_nonce(16)` field
|
||||
- Update `HEADER_OVERHEAD` / `ENCRYPTION_OVERHEAD` in `steganography.py`
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- Two messages with identical credentials produce different ciphertexts and different pixel locations
|
||||
- `cryptography` library HKDF works with existing Argon2id output
|
||||
|
||||
**Complexity**: **M** — well-defined crypto change, touches security-critical header format
|
||||
|
||||
---
|
||||
|
||||
### 6. PWA Mobile Interface
|
||||
|
||||
**Description**: Convert Flask Web UI to Progressive Web App. Mobile-optimized, installable, offline-capable static pages.
|
||||
|
||||
**Implementation approach**:
|
||||
- New files in `frontends/web/static/`: `manifest.json`, `sw.js`, icon set (192×192, 512×512)
|
||||
- Base template: add manifest link, theme-color meta, viewport meta, service worker registration
|
||||
- `app.py`: serve manifest with correct MIME, add cache headers for static assets
|
||||
- Responsive CSS for encode/decode accordion forms
|
||||
- Camera capture: `<input type="file" accept="image/*" capture="environment">` for reference photo
|
||||
- Service worker caches static assets only — NOT encode/decode API endpoints
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- Lighthouse PWA score >= 90
|
||||
- Installable on Android Chrome and iOS Safari
|
||||
- Offline: static pages load, encode/decode shows graceful "offline" message
|
||||
|
||||
**Complexity**: **M** — frontend only, no core library changes
|
||||
|
||||
**Risks**: Camera capture requires HTTPS (already supported via `ssl_utils.py`).
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Moonshot
|
||||
|
||||
### 7. Plausible Deniability / Dual-Payload Mode
|
||||
|
||||
**Description**: Two independent encrypted payloads in one carrier, each with different credentials. Reveal decoy under coercion; real payload stays hidden.
|
||||
|
||||
**Implementation approach**:
|
||||
- New file `src/stegasoo/dual_payload.py`:
|
||||
- `encode_dual(message_a, message_b, carrier, creds_a, creds_b)`
|
||||
- Partition available pixels into two disjoint pools using different seeds
|
||||
- **Critical**: ALL images (single or dual) must fill unused pixel pool with random data so single-payload and dual-payload images are indistinguishable
|
||||
- `steganography.py`: `generate_pixel_indices()` gets `exclude_indices` param
|
||||
- `decode.py`: each credential set finds a different valid payload; wrong credentials produce garbage
|
||||
- CLI + Web UI: dual-payload encode workflow
|
||||
|
||||
**Go/No-Go metrics**:
|
||||
- Single-payload and dual-payload images are statistically indistinguishable (chi-square can't differentiate)
|
||||
- Each payload decodes independently
|
||||
- Wrong credentials for one payload don't reveal other payload's existence
|
||||
|
||||
**Complexity**: **XL** — novel design, halves capacity per payload, challenging UX, needs rigorous security analysis
|
||||
|
||||
**Dependencies**: Feature 2 (validation), Feature 4 (detectability reduction)
|
||||
|
||||
---
|
||||
|
||||
## Architectural Improvements
|
||||
|
||||
### 8. EmbeddingBackend Protocol
|
||||
|
||||
**Description**: Typed plugin interface for all embedding algorithms. Replace if/elif dispatch in `steganography.py` with a registry.
|
||||
|
||||
**Implementation approach**:
|
||||
- New package `src/stegasoo/backends/`:
|
||||
- `protocol.py` — `EmbeddingBackend(Protocol)` with `embed()`, `extract()`, `calculate_capacity()`, `is_available()`
|
||||
- `lsb.py`, `dct.py` — wrap existing functions
|
||||
- `registry.py` — `BackendRegistry` mapping mode strings to backends
|
||||
- `steganography.py`: `embed_in_image()` / `extract_from_image()` dispatch via registry
|
||||
- `__init__.py`: export protocol and `register_backend()`
|
||||
|
||||
**Complexity**: **M** — implement before Features 4 and 7 (they become new backends)
|
||||
|
||||
---
|
||||
|
||||
### 9. HKDF Key Separation
|
||||
|
||||
Subsumed by Feature 5. The HKDF expansion provides:
|
||||
- Encryption key: `HKDF-Expand(root_key, info="stegasoo-encrypt", nonce)`
|
||||
- Pixel selection key: `HKDF-Expand(root_key, info="stegasoo-pixel", nonce)`
|
||||
- Future: MAC key, padding key, etc.
|
||||
|
||||
---
|
||||
|
||||
### 10. `[core]` Extra with Minimal Deps
|
||||
|
||||
**Description**: Move Pillow to `[image]` extra, base deps = `cryptography` + `argon2-cffi` + `zstandard` only.
|
||||
|
||||
**Complexity**: **S** — but Pillow is used in `crypto.py` for photo hashing (core to security model). Only worth it with a concrete headless use case. **Low priority.**
|
||||
|
||||
---
|
||||
|
||||
## Ecosystem Features
|
||||
|
||||
### 11. Aletheia Integration
|
||||
|
||||
Optional `--engine aletheia` backend for Feature 2's `stegasoo check`. BSD-licensed, provides SPA/RS/WS attacks + ML classifiers. **Complexity: S** (after Feature 2). **Depends on**: Feature 2.
|
||||
|
||||
### 12. C2PA/AI Provenance Watermarking
|
||||
|
||||
Embed C2PA metadata alongside stego payloads. **Complexity: L** — C2PA is a complex standard. Potentially conflicts with stego goals (adds detectable metadata). Research-heavy.
|
||||
|
||||
### 13. Signal/Matrix Bot
|
||||
|
||||
Bot that decodes stego images in a channel using configured channel key. **Complexity: M** — integration work, uses existing `decode()` API.
|
||||
|
||||
### 14. Homebrew Tap + Nix Flake
|
||||
|
||||
Package distribution for macOS/NixOS. **Complexity: S** — packaging only, no code changes.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| # | Feature | Tier | Size | Dependencies | Primary Files |
|
||||
|---|---------|------|------|-------------|---------------|
|
||||
| 1 | Platform DCT Presets | T1 | M | — | new `platform_presets.py`, `dct_steganography.py`, `encode.py`, `cli.py` |
|
||||
| 2 | Steganalysis Self-Check | T1 | M | — | new `steganalysis.py`, `cli.py`, `constants.py` |
|
||||
| 3 | Python 3.13 DCT Cleanup | T1 | S | — | `dct_steganography.py` |
|
||||
| 4 | Content-Adaptive Embedding | T2 | L | numpy, #2 | new `adaptive_cost.py`, `steganography.py`, `constants.py` |
|
||||
| 5 | HKDF Forward Secrecy | T2 | M | — | `crypto.py`, `constants.py`, `steganography.py` |
|
||||
| 6 | PWA Mobile Interface | T2 | M | — | `frontends/web/` templates + static |
|
||||
| 7 | Dual-Payload Mode | T3 | XL | #2, #4 | new `dual_payload.py`, `steganography.py`, `cli.py` |
|
||||
| 8 | EmbeddingBackend Protocol | Arch | M | — | new `backends/` package, `steganography.py` |
|
||||
| 9 | HKDF Key Separation | Arch | — | Included in #5 | `crypto.py` |
|
||||
| 10 | `[core]` Extra | Arch | S | — | `pyproject.toml` |
|
||||
| 11 | Aletheia Integration | Eco | S | #2 | `steganalysis.py` |
|
||||
| 12 | C2PA Watermarking | Eco | L | — | new module |
|
||||
| 13 | Signal/Matrix Bot | Eco | M | — | new `bots/` package |
|
||||
| 14 | Homebrew + Nix | Eco | S | — | packaging files only |
|
||||
|
||||
---
|
||||
|
||||
## Suggested Roadmap
|
||||
|
||||
### Phase 1 — Foundations (v4.4.0)
|
||||
|
||||
1. **#3** Python 3.13 DCT Cleanup (S) — unblocks CI on 3.13
|
||||
2. **#8** EmbeddingBackend Protocol (M) — architectural cleanup before new embedding work
|
||||
3. **#2** Steganalysis Self-Check (M) — validation tooling for everything that follows
|
||||
|
||||
### Phase 2 — Security & Robustness (v4.5.0)
|
||||
|
||||
4. **#5** HKDF Forward Secrecy (M) — FORMAT_VERSION bump to 6, improved crypto
|
||||
5. **#1** Platform-Calibrated DCT Presets (M) — high user value for social media
|
||||
6. **#14** Homebrew + Nix (S) — distribution expansion
|
||||
|
||||
### Phase 3 — Advanced Steganography (v5.0.0)
|
||||
|
||||
7. **#4** Content-Adaptive Embedding (L) — major security improvement
|
||||
8. **#6** PWA Mobile Interface (M) — parallel frontend work stream
|
||||
|
||||
### Phase 4 — Moonshot (v5.x+)
|
||||
|
||||
9. **#7** Dual-Payload Mode (XL) — after #2 and #4 are solid
|
||||
10. **#12** C2PA Watermarking (L) — research-heavy
|
||||
11. **#13** Signal/Matrix Bot (M) — community-driven
|
||||
|
||||
---
|
||||
|
||||
## Additional Ideas (Backlog)
|
||||
|
||||
- **Animated GIF steganography** — LSB in GIF frames, natural multi-media extension
|
||||
- **PDF steganography** — whitespace/font metric/embedded image payloads
|
||||
- **Batch encode** — `stegasoo batch-encode --dir /photos/` with auto carrier selection (BATCH_* constants suggest this was planned)
|
||||
- **Stego identification** — `stegasoo identify image.png` probes for known stego signatures
|
||||
- **Per-device credential sync via QR** — channel key as stego image of reference photo
|
||||
- **`stegasoo verify`** — decode + confirm message matches expected hash without revealing contents
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Aaron D. Lee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
97
PLAN-4.1.6.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Stegasoo v4.1.6 Planning
|
||||
|
||||
## UI Tweaks
|
||||
|
||||
### 1. Revamp Tron Lines Animation (Carrier/Stego Image)
|
||||
**Current state:**
|
||||
- 6-8 snake paths, each with 3-5 segments (~24-40 total lines)
|
||||
- 2px thick lines
|
||||
- 30-60px length per segment
|
||||
- Starting points spread across 80% of image area
|
||||
- Colors: yellow, cyan, purple, blue with glow
|
||||
|
||||
**Target improvements:**
|
||||
- [x] Thinner lines (1px instead of 2px)
|
||||
- [x] More numerous (20-40 paths via 5x4 grid, ~60-200 segments total)
|
||||
- [x] Better distribution across entire image (grid-based seeding)
|
||||
- [x] Shorter segments (12-30px) for denser "circuit board" look
|
||||
|
||||
**Files:**
|
||||
- `frontends/web/static/style.css` (~881-979) - `.embed-trace` styling
|
||||
- `frontends/web/static/js/stegasoo.js` (~333-390) - `generateEmbedTraces()`
|
||||
|
||||
---
|
||||
|
||||
## Tools Page Expansion
|
||||
|
||||
### Analysis Tools
|
||||
- [x] **JPEG Compression Tester** - Preview image at different quality levels (10-100%), show file size delta. Useful for understanding stego survivability.
|
||||
- [ ] **LSB Plane Viewer** - Visualize least significant bit plane(s) of RGB channels. Classic stego analysis tool.
|
||||
- [ ] **Histogram Viewer** - Color distribution graph per channel. Anomalies can indicate hidden data.
|
||||
- [ ] **Image Diff** - Compare two images side-by-side with pixel difference highlighting. Great for original vs stego comparison.
|
||||
- [ ] **Noise Analysis** - Chi-square or similar statistical analysis for detecting LSB embedding.
|
||||
|
||||
### Transform Tools
|
||||
- [x] **Rotate/Flip** - 90°/180°/270° rotation, horizontal/vertical flip
|
||||
- [ ] **Resize** - Scale with aspect ratio lock, common presets (50%, 25%, etc.)
|
||||
- [ ] **Crop** - Basic rectangular crop with preview
|
||||
- [x] **Format Convert** - PNG ↔ JPEG ↔ WebP with quality slider
|
||||
|
||||
### Existing Tools (already done)
|
||||
- [x] Capacity Calculator
|
||||
- [x] EXIF Viewer
|
||||
- [x] EXIF Strip
|
||||
- [x] Image Peek (header analysis)
|
||||
|
||||
### Tools UI/UX Overhaul
|
||||
|
||||
**Final Layout: Office-style Ribbon + Two-Panel**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📏 📋 👁️ 📊 ┃ ✂️ 🔄 📐 🔀 Image Tools │ ← Icon toolbar
|
||||
├────────────────────────────────────────┬────────────────────┤
|
||||
│ [Format: PNG ▼] [Quality: 85] │ │
|
||||
├────────────────────────────────────────┤ Capacity │
|
||||
│ │ Calculator │
|
||||
│ ┌────────────────────────────┐ │ ────────────── │
|
||||
│ │ │ │ │
|
||||
│ │ Drop image here │ │ Dimensions: │
|
||||
│ │ or click │ │ 1920 × 1080 │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────────────┘ │ LSB Capacity: │
|
||||
│ │ 245 KB │
|
||||
│ [image.jpg] │ │
|
||||
│ │ ────────────── │
|
||||
│ │ [Clear] [Export] │
|
||||
└────────────────────────────────────────┴────────────────────┘
|
||||
Options + dropzone/preview Results sidebar
|
||||
```
|
||||
|
||||
- Top ribbon: Icon buttons grouped by category (Analyze | Transform)
|
||||
- Left panel: Tool options + dropzone/preview (INPUT)
|
||||
- Right panel: Tool name + results/metadata + actions (OUTPUT)
|
||||
- Flow: Left → Right (input → output)
|
||||
|
||||
**Implementation Tasks:**
|
||||
- [x] Move inline CSS to style.css
|
||||
- [x] Build icon toolbar ribbon
|
||||
- [x] Build two-panel layout structure
|
||||
- [x] Migrate existing tools (Capacity, EXIF, Strip)
|
||||
- [x] Add new tools (Rotate, Compress, Convert)
|
||||
- [ ] Loading spinner on all async operations
|
||||
- [ ] Toast notifications instead of alerts
|
||||
- [ ] Consistent color coding (green=analysis, amber=transform)
|
||||
- [ ] Mobile: stack panels vertically
|
||||
|
||||
---
|
||||
|
||||
## CLI Improvements
|
||||
|
||||
### (Add items here)
|
||||
|
||||
---
|
||||
|
||||
## Other UI Tweaks
|
||||
|
||||
### (Add items here)
|
||||
|
||||
469
README.md
@@ -1,430 +1,167 @@
|
||||
# Stegasoo
|
||||
|
||||
A secure steganography system for hiding encrypted messages in images using hybrid authentication.
|
||||
A secure steganography system for hiding encrypted messages in images and audio 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
|
||||
- **Audio steganography**: Hide messages in WAV, FLAC, MP3, OGG, AAC, M4A files (LSB and Spread Spectrum modes)
|
||||
- **Channel keys**: Private group communication channels
|
||||
|
||||
## What's New in v4.0.0
|
||||
## Embedding Modes
|
||||
|
||||
| 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
|
||||
### Image 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
|
||||
### Audio Modes
|
||||
|
||||
| Front Page | Encode | Decode | Generate |
|
||||
|:----------:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
| Mode | Capacity (5 min WAV) | Noise Resistant | Best For |
|
||||
|------|---------------------|-----------------|----------|
|
||||
| **LSB** | ~1.3 MB | No | Direct file transfer |
|
||||
| **Spread Spectrum** | ~160 KB | Yes | Shared files, light processing |
|
||||
|
||||
## Web UI
|
||||
|
||||
| Home | Encode | Decode | Generate |
|
||||
|:----:|:------:|:------:|:--------:|
|
||||
|  |  |  |  |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install with all features (requires Python 3.10-3.12)
|
||||
# Install (Python 3.10-3.12)
|
||||
pip install -e ".[all]"
|
||||
|
||||
# Generate credentials (memorize these!)
|
||||
# Generate credentials
|
||||
stegasoo generate --pin --words 4
|
||||
|
||||
# Encode a message (DCT mode - default, best for social media)
|
||||
# Encode a message
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--ref my_photo.jpg \
|
||||
--carrier meme.jpg \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message"
|
||||
|
||||
# Encode with LSB mode (higher capacity, for email/file transfer)
|
||||
stegasoo encode \
|
||||
--ref photo.jpg \
|
||||
--carrier meme.png \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456 \
|
||||
--message "Secret message" \
|
||||
--mode lsb
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
# Decode
|
||||
stegasoo decode \
|
||||
--ref photo.jpg \
|
||||
--stego stego.png \
|
||||
--ref my_photo.jpg \
|
||||
--stego stego_image.png \
|
||||
--passphrase "apple forest thunder mountain" \
|
||||
--pin 123456
|
||||
```
|
||||
|
||||
For detailed installation instructions, see **[INSTALL.md](INSTALL.md)**.
|
||||
## Interfaces
|
||||
|
||||
---
|
||||
| Interface | Start Command | Documentation |
|
||||
|-----------|---------------|---------------|
|
||||
| **CLI** | `stegasoo --help` | [CLI.md](CLI.md) |
|
||||
| **Web UI** | `cd frontends/web && python app.py` | [WEB_UI.md](WEB_UI.md) |
|
||||
| **REST API** | `cd frontends/api && uvicorn main:app` | [API.md](API.md) |
|
||||
|
||||
## Security Model
|
||||
|
||||
Stegasoo uses multiple authentication factors combined with strong cryptography:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AUTHENTICATION LAYERS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Reference Photo ──┐ │
|
||||
│ (~80-256 bits) │ │
|
||||
│ ├──► Argon2id KDF ──► AES-256-GCM Key │
|
||||
│ Passphrase ───────┤ (256MB RAM) │
|
||||
│ (~43-132 bits) │ │
|
||||
│ │ │
|
||||
│ Static PIN ───────┤ │
|
||||
│ (~20-30 bits) │ │
|
||||
│ │ │
|
||||
│ RSA Key ──────────┘ │
|
||||
│ (~128 bits) (optional, adds another factor) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
Reference Photo ──┐
|
||||
(~80-256 bits) │
|
||||
├──► Argon2id KDF ──► AES-256-GCM Key
|
||||
Passphrase ───────┤ (256MB RAM)
|
||||
(~43-132 bits) │
|
||||
│
|
||||
PIN ──────────────┤
|
||||
(~20-30 bits) │
|
||||
│
|
||||
RSA Key ──────────┘
|
||||
(optional)
|
||||
```
|
||||
|
||||
### Entropy Summary
|
||||
|
||||
| Component | Entropy | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Reference Photo | ~80-256 bits | Something you have |
|
||||
| Passphrase (3-12 words) | ~33-132 bits | Something you know |
|
||||
| PIN (6-9 digits) | ~20-30 bits | Something you know |
|
||||
| RSA Key (2048-4096 bit) | ~112-128 bits | Something you have (optional) |
|
||||
| **Combined** | **133-400+ bits** | **Beyond brute force** |
|
||||
|
||||
### Attack Resistance
|
||||
|
||||
| Attack | Protection |
|
||||
|--------|------------|
|
||||
| Brute force | 2^133+ combinations minimum |
|
||||
| Rainbow tables | Random 16-byte salt per message |
|
||||
| Steganalysis | Pseudo-random pixel/coefficient selection |
|
||||
| GPU cracking | Argon2id requires 256MB RAM per attempt |
|
||||
| Side-channel | Constant-time operations in cryptography library |
|
||||
| JPEG recompression | DCT mode embeds in frequency domain |
|
||||
|
||||
### Security Configurations
|
||||
|
||||
| Configuration | Entropy | Use Case |
|
||||
|--------------|---------|----------|
|
||||
| 3-word passphrase + 6-digit PIN | ~133 bits | Casual private messaging |
|
||||
| 4-word passphrase + 9-digit PIN | ~176 bits | Standard security (recommended) |
|
||||
| 4-word passphrase + RSA 2048 | ~241 bits | File-based authentication |
|
||||
| 6-word passphrase + PIN + RSA 4096 | ~304 bits | Maximum security |
|
||||
|
||||
---
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Command-Line Interface (CLI)
|
||||
|
||||
Full-featured CLI with piping support:
|
||||
|
||||
```bash
|
||||
# Generate with RSA key
|
||||
stegasoo generate --rsa --rsa-bits 4096 -o mykey.pem -p "password"
|
||||
|
||||
# Encode (DCT mode is now default)
|
||||
stegasoo encode -r ref.jpg -c carrier.jpg -p "passphrase words here" --pin 123456 -m "Message"
|
||||
|
||||
# Encode with LSB mode for higher capacity
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 \
|
||||
-m "Message" --mode lsb
|
||||
|
||||
# Encode a file
|
||||
stegasoo encode -r ref.jpg -c carrier.png -p "passphrase words here" --pin 123456 -f secret.txt
|
||||
|
||||
# Decode to stdout (quiet mode)
|
||||
stegasoo decode -r ref.jpg -s stego.png -p "passphrase words here" --pin 123456 -q
|
||||
|
||||
# Compare LSB vs DCT capacity for an image
|
||||
stegasoo compare carrier.png
|
||||
|
||||
# Check available modes
|
||||
stegasoo modes
|
||||
```
|
||||
|
||||
📖 Full documentation: **[CLI.md](CLI.md)**
|
||||
|
||||
### Web UI
|
||||
|
||||
Browser-based interface with drag-and-drop uploads:
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
cd frontends/web
|
||||
python app.py
|
||||
# Visit http://localhost:5000
|
||||
```
|
||||
|
||||
Features:
|
||||
- Drag-and-drop image uploads with scan animations
|
||||
- Real-time entropy calculator
|
||||
- Native mobile sharing (Web Share API)
|
||||
- DCT mode default with compact mode selector
|
||||
- Subprocess isolation for stability
|
||||
- Large image support (14MB+ tested)
|
||||
- Streamlined form flow (v3.3.0)
|
||||
|
||||
📖 Full documentation: **[WEB_UI.md](WEB_UI.md)**
|
||||
|
||||
### REST API
|
||||
|
||||
FastAPI-powered REST API with OpenAPI documentation:
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
cd frontends/api
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
# Docs at http://localhost:8000/docs
|
||||
```
|
||||
|
||||
Example API calls:
|
||||
|
||||
```bash
|
||||
# Generate credentials
|
||||
curl -X POST http://localhost:8000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"use_pin": true, "passphrase_words": 4}'
|
||||
|
||||
# Encode (DCT mode is default)
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.jpg" \
|
||||
--output stego.jpg
|
||||
|
||||
# Encode with LSB mode
|
||||
curl -X POST http://localhost:8000/encode/multipart \
|
||||
-F "message=Secret" \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "embed_mode=lsb" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "carrier=@meme.png" \
|
||||
--output stego.png
|
||||
|
||||
# Decode (auto-detects mode)
|
||||
curl -X POST http://localhost:8000/decode/multipart \
|
||||
-F "passphrase=apple forest thunder mountain" \
|
||||
-F "pin=123456" \
|
||||
-F "reference_photo=@photo.jpg" \
|
||||
-F "stego_image=@stego.jpg"
|
||||
```
|
||||
|
||||
📖 Full documentation: **[API.md](API.md)**
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stegasoo/
|
||||
├── src/stegasoo/ # Core library
|
||||
│ ├── __init__.py # Public API
|
||||
│ ├── constants.py # Configuration
|
||||
│ ├── crypto.py # Encryption/decryption
|
||||
│ ├── steganography.py # LSB image embedding
|
||||
│ ├── dct_steganography.py # DCT embedding
|
||||
│ ├── keygen.py # Credential generation
|
||||
│ ├── validation.py # Input validation
|
||||
│ ├── models.py # Data classes
|
||||
│ ├── exceptions.py # Custom exceptions
|
||||
│ ├── qr_utils.py # QR code utilities
|
||||
│ └── utils.py # Utilities
|
||||
│
|
||||
├── frontends/
|
||||
│ ├── web/ # Flask web UI
|
||||
│ │ ├── app.py
|
||||
│ │ ├── subprocess_stego.py # Subprocess isolation
|
||||
│ │ └── stego_worker.py # Worker script
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── api/ # FastAPI REST API
|
||||
│
|
||||
├── data/
|
||||
│ └── bip39-words.txt # BIP-39 wordlist
|
||||
│
|
||||
├── pyproject.toml # Package configuration
|
||||
├── requirements.txt # Dependencies
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Container orchestration
|
||||
│
|
||||
├── README.md # This file
|
||||
├── INSTALL.md # Installation guide
|
||||
├── CLI.md # CLI documentation
|
||||
├── API.md # API documentation
|
||||
├── WEB_UI.md # Web UI documentation
|
||||
├── SECURITY.md # Security documentation
|
||||
└── UNDER_THE_HOOD.md # Technical deep-dive
|
||||
```
|
||||
|
||||
---
|
||||
| 4-word passphrase + 6-digit PIN | ~153 bits | Standard security |
|
||||
| 4-word passphrase + PIN + RSA | ~280+ bits | Maximum security |
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python | 3.10-3.12 | **3.13 not supported** (jpegio incompatibility) |
|
||||
| RAM | 512 MB+ | 256MB for Argon2 operations |
|
||||
| Disk | ~100 MB | |
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `cryptography` | AES-256-GCM encryption |
|
||||
| `Pillow` | Image processing |
|
||||
| `argon2-cffi` | Memory-hard key derivation |
|
||||
| `scipy` | DCT transforms |
|
||||
| `jpegio` | JPEG coefficient manipulation |
|
||||
| `numpy` | Array operations |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Max image size | Tested up to 14MB |
|
||||
| Max message size | 50 KB |
|
||||
| Max file upload | 5 MB |
|
||||
| PIN length | 6-9 digits |
|
||||
| Passphrase length | 3-12 words |
|
||||
| RSA key sizes | 2048, 3072, 4096 bits |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONPATH` | - | Include `src/` for development |
|
||||
|
||||
---
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| Python | 3.10-3.12 |
|
||||
| RAM | 512 MB+ |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Format code
|
||||
black src/ frontends/
|
||||
ruff check src/ frontends/
|
||||
|
||||
# Type checking
|
||||
mypy src/
|
||||
|
||||
# Check DCT support
|
||||
python -c "from stegasoo import has_dct_support; print(f'DCT: {has_dct_support()}')"
|
||||
python -c "from stegasoo.dct_steganography import has_jpegio_support; print(f'jpegio: {has_jpegio_support()}')"
|
||||
black src/ tests/ frontends/
|
||||
ruff check src/ tests/ frontends/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Changes |
|
||||
|---------|---------|
|
||||
| **4.0.1** | Lint cleanup, test fixes, Web UI improvements (channel key dropdown, LED indicators) |
|
||||
| **4.0.0** | Channel key support for deployment isolation, removed date dependency, renamed day_phrase→passphrase, Python 3.12 requirement, JPEG normalization fix, subprocess isolation, large image support |
|
||||
| **3.2.x** | DCT color mode, JPEG output fixes |
|
||||
| **3.0.x** | Added DCT steganography mode |
|
||||
| **2.2.x** | QR code support, file embedding |
|
||||
| **2.0.x** | Web UI, REST API, RSA keys |
|
||||
| **1.0.x** | Initial release, CLI only |
|
||||
|
||||
---
|
||||
|
||||
## Upgrading from v3.x
|
||||
|
||||
### Code Changes Required
|
||||
|
||||
```python
|
||||
# Old (v3.x)
|
||||
result = encode(
|
||||
message="secret",
|
||||
day_phrase="apple forest thunder",
|
||||
date_str="2024-01-15",
|
||||
...
|
||||
)
|
||||
|
||||
# New (v4.0)
|
||||
result = encode(
|
||||
message="secret",
|
||||
passphrase="apple forest thunder mountain",
|
||||
# No date_str needed!
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### CLI Changes
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Old (v3.x)
|
||||
stegasoo encode --phrase "words" --date 2024-01-15 ...
|
||||
# Quick start (HTTPS enabled by default)
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# New (v4.0)
|
||||
stegasoo encode --passphrase "words here more" ...
|
||||
# or short form
|
||||
stegasoo encode -p "words here more" ...
|
||||
# Access
|
||||
# Web UI: https://localhost:5000 (self-signed cert)
|
||||
# REST API: http://localhost:8000
|
||||
|
||||
# Disable HTTPS if needed:
|
||||
STEGASOO_HTTPS_ENABLED=false docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
See [DOCKER.md](DOCKER.md) and [docs/DOCKER_QUICKSTART.md](docs/DOCKER_QUICKSTART.md) for full documentation.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
Pre-built SD card images available for Pi 4/5:
|
||||
|
||||
```bash
|
||||
# Flash image (download from GitHub Releases)
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
|
||||
# First boot runs interactive setup wizard:
|
||||
# - WiFi configuration
|
||||
# - HTTPS with port 443
|
||||
# - Channel key generation
|
||||
# - Optional overclocking
|
||||
```
|
||||
|
||||
See [rpi/README.md](rpi/README.md) for manual installation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||
- [DOCKER.md](DOCKER.md) - Docker deployment
|
||||
- [CLI.md](CLI.md) - Command-line reference
|
||||
- [API.md](API.md) - REST API documentation
|
||||
- [WEB_UI.md](WEB_UI.md) - Web interface guide
|
||||
- [SECURITY.md](SECURITY.md) - Security model details
|
||||
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
|
||||
- `man stegasoo` - Man page (install: `sudo cp docs/stegasoo.1 /usr/local/share/man/man1/ && sudo mandb`)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Use responsibly.
|
||||
MIT License - see [LICENSE](LICENSE). Use responsibly.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is for educational and legitimate privacy purposes only. Users are responsible for complying with applicable laws in their jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions
|
||||
- **[CLI.md](CLI.md)** - Command-line interface reference
|
||||
- **[API.md](API.md)** - REST API documentation
|
||||
- **[WEB_UI.md](WEB_UI.md)** - Web interface guide
|
||||
- **[SECURITY.md](SECURITY.md)** - Security model and threat analysis
|
||||
- **[UNDER_THE_HOOD.md](UNDER_THE_HOOD.md)** - Technical implementation details
|
||||
*This tool is for educational and legitimate privacy purposes. Users are responsible for complying with applicable laws.*
|
||||
|
||||
44
RELEASE_CHECKLIST.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Stegasoo Release Checklist
|
||||
|
||||
Pre-release validation checklist. Complete all items before tagging a release.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] All tests pass: `./venv/bin/pytest tests/ -v`
|
||||
- [ ] No lint errors: `./venv/bin/ruff check src/`
|
||||
- [ ] Version bumped in `pyproject.toml`
|
||||
- [ ] CHANGELOG.md updated
|
||||
|
||||
## Pi Image Validation
|
||||
|
||||
- [ ] Fresh Pi OS install with setup.sh works
|
||||
- [ ] First-boot wizard completes successfully
|
||||
- [ ] MOTD shows correct URL on SSH login
|
||||
- [ ] Smoke test passes: `./rpi/smoke-test.sh --443 <PI_IP>`
|
||||
- [ ] Encode/decode works on large image (10MB+)
|
||||
- [ ] Sanitize script runs cleanly
|
||||
- [ ] Image created and compressed
|
||||
|
||||
## Docker Validation
|
||||
|
||||
- [ ] Base image builds: `docker build -f docker/Dockerfile.base -t stegasoo-base:latest .`
|
||||
- [ ] Web image builds: `docker-compose -f docker/docker-compose.yml build web`
|
||||
- [ ] Container starts: `docker-compose -f docker/docker-compose.yml up -d web`
|
||||
- [ ] Web UI accessible at http://localhost:5000
|
||||
- [ ] Encode/decode works in container
|
||||
- [ ] Container stops cleanly: `docker-compose -f docker/docker-compose.yml down`
|
||||
|
||||
## Release Process
|
||||
|
||||
- [ ] Merge feature branch to main
|
||||
- [ ] Create annotated tag: `git tag -a vX.Y.Z -m "message"`
|
||||
- [ ] Push tag: `git push origin vX.Y.Z`
|
||||
- [ ] Create GitHub Release with release notes
|
||||
- [ ] Upload Pi image (.img.zst.zip)
|
||||
- [ ] Verify download links work
|
||||
|
||||
## Post-Release
|
||||
|
||||
- [ ] Delete old/obsolete releases if needed
|
||||
- [ ] Update any external documentation
|
||||
- [ ] Announce release (if applicable)
|
||||
173
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# v4.3.0 — Audio Steganography
|
||||
|
||||
**Release Date:** 2026-02-27
|
||||
|
||||
## Highlights
|
||||
|
||||
Stegasoo can now hide messages in audio files! This release adds full audio steganography support with two embedding modes:
|
||||
|
||||
- **LSB (Least Significant Bit)**: Embeds data directly in audio sample LSBs. High capacity, best for direct file transfers.
|
||||
- **Spread Spectrum**: Spreads data across audio frequencies using pseudo-random sequences. Lower capacity but more resistant to noise and light processing.
|
||||
|
||||
## What's New
|
||||
|
||||
### Audio Steganography
|
||||
- Support for WAV, FLAC, MP3, OGG, AAC, and M4A input formats
|
||||
- Automatic transcoding to WAV (16-bit PCM) for embedding
|
||||
- Same security model: reference photo + passphrase + PIN/RSA + channel key
|
||||
- Full CLI, REST API, and Web UI support
|
||||
|
||||
### Unified Web UI
|
||||
- Encode and Decode pages now feature a "Carrier Type" selector
|
||||
- Switch between Image and Audio modes without leaving the page
|
||||
- Audio capacity display shows LSB and Spread Spectrum capacities
|
||||
- Audio preview player on encode result page
|
||||
|
||||
### New Modules
|
||||
- `audio_steganography.py` — LSB audio embedding/extraction
|
||||
- `spread_steganography.py` — Spread spectrum embedding/extraction
|
||||
- `audio_utils.py` — Audio format detection, validation, transcoding
|
||||
- `debug.py` — Structured logging for all operations
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
Audio steganography requires `numpy` and `soundfile` packages. Install with:
|
||||
```bash
|
||||
pip install stegasoo[audio]
|
||||
```
|
||||
|
||||
For full audio format support (MP3, AAC, etc.), install FFmpeg on your system.
|
||||
|
||||
---
|
||||
|
||||
## Stegasoo v4.2.1
|
||||
|
||||
### API Security
|
||||
|
||||
**API Key Authentication**
|
||||
- All protected endpoints require `X-API-Key` header
|
||||
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
|
||||
- Auth disabled when no keys configured (easy onboarding)
|
||||
|
||||
**TLS Support**
|
||||
- Self-signed certificates auto-generated on first run
|
||||
- Certs valid for localhost, all local IPs, hostname.local
|
||||
- CLI: `stegasoo api tls generate` to pre-generate
|
||||
|
||||
### CLI Improvements
|
||||
|
||||
**New API Management Commands**
|
||||
```bash
|
||||
stegasoo api keys create NAME # Create new key
|
||||
stegasoo api keys list # List API keys
|
||||
stegasoo api tls generate # Generate TLS cert
|
||||
stegasoo api serve # Start server with TLS
|
||||
```
|
||||
|
||||
**New Image Tools**
|
||||
```bash
|
||||
stegasoo tools compress IMG -q 75 # JPEG compression
|
||||
stegasoo tools rotate IMG -r 90 # Lossless rotation
|
||||
stegasoo tools convert IMG -f png # Format conversion
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DCT rotation**: Portrait photos no longer export rotated 90°
|
||||
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
|
||||
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
|
||||
- **Import paths**: Fixed for installed packages (AUR/pip)
|
||||
|
||||
### Installation
|
||||
|
||||
**AUR (Arch Linux)**
|
||||
```bash
|
||||
yay -S stegasoo-git # Full (Web + API + CLI)
|
||||
yay -S stegasoo-cli-git # CLI only
|
||||
```
|
||||
|
||||
**Docker**
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
**Raspberry Pi**
|
||||
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
|
||||
Default login: `admin` / `stegasoo`
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.11 - 3.14 (dropped 3.10 support)
|
||||
|
||||
### Release Assets
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
|
||||
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
|
||||
| Source code (zip/tar.gz) | Auto-generated |
|
||||
|
||||
---
|
||||
|
||||
## Stegasoo v4.2.0
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
Major performance improvements for Raspberry Pi and resource-constrained deployments.
|
||||
|
||||
#### DCT Vectorization (~14x faster)
|
||||
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
|
||||
- Processes 500 blocks at once instead of one-by-one
|
||||
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
|
||||
|
||||
#### Memory Optimization (50% reduction)
|
||||
- Switched from `float64` to `float32` for all DCT operations
|
||||
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
|
||||
- Critical for Pi 3/4 avoiding swap thrashing
|
||||
|
||||
#### Progress Callbacks for Decode
|
||||
- `progress_file` parameter added to `decode()` and extraction functions
|
||||
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
|
||||
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
|
||||
|
||||
#### Async API Endpoints
|
||||
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
|
||||
- API server can handle concurrent requests without blocking
|
||||
- Essential for multi-user Pi deployments
|
||||
|
||||
### Compression
|
||||
|
||||
#### Zstd Default Compression
|
||||
- `zstandard` is now a core dependency (always installed)
|
||||
- Better compression ratio than zlib for QR code RSA keys
|
||||
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
|
||||
|
||||
### QR Code Generation
|
||||
|
||||
#### CLI Support
|
||||
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
|
||||
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
|
||||
|
||||
#### API Support
|
||||
- `POST /generate-key-qr` - generate QR from RSA key
|
||||
- Supports `png`, `jpg`, and `ascii` output formats
|
||||
- Uses zstd compression by default
|
||||
|
||||
### Other Changes
|
||||
|
||||
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||
- File auto-expire increased to 10 minutes
|
||||
- Progress bar "candy cane" animation during Argon2 key derivation
|
||||
- Optional API service in Pi setup (with security warning)
|
||||
|
||||
### Summary
|
||||
|
||||
| Metric | v4.1.7 | v4.2.0 | Improvement |
|
||||
|--------|--------|--------|-------------|
|
||||
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||
| Concurrent API | No | Yes | check |
|
||||
| QR Compression | zlib | zstd | **~15% smaller** |
|
||||
|
||||
### Full Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||
10
SECURITY.md
@@ -4,16 +4,16 @@
|
||||
|
||||
| Version | Supported | Notes |
|
||||
| ------- | ------------------ | ----- |
|
||||
| 4.x.x | ✅ Active | Current release |
|
||||
| 3.x.x | ⚠️ Security fixes only | Upgrade recommended |
|
||||
| 2.x.x | ❌ End of life | |
|
||||
| 1.x.x | ❌ End of life | |
|
||||
| 4.1.x | Current Version | What you SHOULD be using. |
|
||||
| 4.x.x | ⚠️ Security fixes only | Upgrade (EOL soon) |
|
||||
| <= 3.x.x | ❌ End of life | |
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please email: **security@example.com** (replace with your email)
|
||||
Instead, please email: **adlee-was-taken@proton.me**
|
||||
|
||||
Include:
|
||||
- Description of the vulnerability
|
||||
|
||||
284
SECURITY_AUDIT_PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Stegasoo Security Audit Plan
|
||||
|
||||
> **Target Audience**: Developers, security reviewers, and deployment administrators
|
||||
> **Scope**: Web UI, REST API, CLI, and cryptographic core
|
||||
> **Deployment Model**: Air-gapped / private LAN (primary), Internet-facing (secondary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Stegasoo is a steganography tool designed for **air-gapped deployments** on private networks. While the primary threat model assumes a trusted local network, this audit plan covers security best practices for both isolated and potentially exposed deployments.
|
||||
|
||||
### Known Limitations (By Design)
|
||||
|
||||
- **Self-signed certificates**: HTTPS uses self-signed certs; users must add exceptions or deploy their own CA
|
||||
- **No rate limiting**: Assumes trusted users on private network
|
||||
- **Single-node**: No distributed session store; sessions are per-instance
|
||||
- **Air-gap focus**: External security (firewalls, network isolation) is user's responsibility
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication & Authorization
|
||||
|
||||
### 1.1 Password Security
|
||||
- [ ] Passwords hashed with Argon2id (preferred) or PBKDF2 fallback
|
||||
- [ ] Minimum password length enforced (8+ characters)
|
||||
- [ ] Password not logged or exposed in error messages
|
||||
- [ ] Password change requires current password verification
|
||||
- [ ] Admin re-authentication required for sensitive operations (channel key export)
|
||||
|
||||
### 1.2 Session Management
|
||||
- [ ] Session tokens are cryptographically random
|
||||
- [ ] Session cookies have `HttpOnly` flag
|
||||
- [ ] Session cookies have `Secure` flag (when HTTPS enabled)
|
||||
- [ ] Session cookies have `SameSite` attribute
|
||||
- [ ] Sessions invalidated on logout
|
||||
- [ ] Sessions invalidated on password change
|
||||
- [ ] Session timeout configured appropriately
|
||||
|
||||
### 1.3 Authorization
|
||||
- [ ] Admin-only routes protected by `@admin_required` decorator
|
||||
- [ ] User-only routes protected by `@login_required` decorator
|
||||
- [ ] Users cannot access other users' saved channel keys
|
||||
- [ ] Users cannot modify other users' accounts
|
||||
- [ ] Role escalation not possible through API manipulation
|
||||
|
||||
---
|
||||
|
||||
## 2. Cryptographic Implementation
|
||||
|
||||
### 2.1 Key Derivation
|
||||
- [ ] KDF uses Argon2id with appropriate parameters (memory, iterations, parallelism)
|
||||
- [ ] PBKDF2 fallback uses sufficient iterations (600,000+)
|
||||
- [ ] Salt is cryptographically random and unique per operation
|
||||
- [ ] PIN/passphrase combined securely before KDF
|
||||
|
||||
### 2.2 Encryption
|
||||
- [ ] AES-256-GCM used for payload encryption
|
||||
- [ ] Nonce/IV is unique per encryption operation
|
||||
- [ ] Authentication tag verified before decryption
|
||||
- [ ] No padding oracle vulnerabilities
|
||||
|
||||
### 2.3 Channel Keys
|
||||
- [ ] Channel keys are 128-bit (32 hex chars)
|
||||
- [ ] Channel key derivation uses HKDF or similar
|
||||
- [ ] Channel isolation prevents cross-channel decryption
|
||||
- [ ] Fingerprint reveals no information about full key
|
||||
|
||||
### 2.4 Random Number Generation
|
||||
- [ ] All random values use `secrets` module or OS CSPRNG
|
||||
- [ ] No use of `random` module for security-sensitive operations
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Validation & Injection Prevention
|
||||
|
||||
### 3.1 Web UI
|
||||
- [ ] All user input sanitized before rendering (XSS prevention)
|
||||
- [ ] Jinja2 auto-escaping enabled
|
||||
- [ ] No `| safe` filter on user-controlled content
|
||||
- [ ] Content-Security-Policy header configured
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
|
||||
### 3.2 File Uploads
|
||||
- [ ] File size limits enforced server-side
|
||||
- [ ] File type validation (magic bytes, not just extension)
|
||||
- [ ] Uploaded files not executed
|
||||
- [ ] Filenames sanitized (path traversal prevention)
|
||||
- [ ] Temporary files cleaned up after processing
|
||||
|
||||
### 3.3 API Inputs
|
||||
- [ ] JSON schema validation on API endpoints
|
||||
- [ ] Integer overflow checks on size parameters
|
||||
- [ ] No SQL injection (parameterized queries only)
|
||||
- [ ] No command injection (no shell=True with user input)
|
||||
|
||||
---
|
||||
|
||||
## 4. Steganography-Specific Security
|
||||
|
||||
### 4.1 Carrier Image Handling
|
||||
- [ ] Malformed images don't crash the server (PIL/jpegio hardening)
|
||||
- [ ] DCT mode subprocess isolation for crash protection
|
||||
- [ ] Memory limits on image processing
|
||||
- [ ] No arbitrary code execution from image metadata
|
||||
|
||||
### 4.2 Payload Security
|
||||
- [ ] Payload size limits enforced
|
||||
- [ ] Encrypted payload indistinguishable from random noise
|
||||
- [ ] No metadata leakage in output images
|
||||
- [ ] Reference photo required (prevents dictionary attacks)
|
||||
|
||||
### 4.3 Capacity Reporting
|
||||
- [ ] Capacity calculation doesn't leak information about encoding method
|
||||
- [ ] Failed decodes don't reveal why (wrong key vs no data vs corrupted)
|
||||
|
||||
---
|
||||
|
||||
## 5. Network & Transport Security
|
||||
|
||||
### 5.1 HTTPS Configuration
|
||||
- [ ] TLS 1.2+ only (no SSLv3, TLS 1.0/1.1)
|
||||
- [ ] Strong cipher suites configured
|
||||
- [ ] Certificate generation uses 2048+ bit RSA or P-256 EC
|
||||
- [ ] Private key file permissions restricted (600)
|
||||
|
||||
### 5.2 Headers
|
||||
- [ ] X-Frame-Options: DENY (clickjacking prevention)
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
- [ ] Referrer-Policy: same-origin
|
||||
- [ ] Permissions-Policy configured
|
||||
|
||||
### 5.3 CORS (if applicable)
|
||||
- [ ] CORS not enabled (or restricted to specific origins)
|
||||
- [ ] Credentials not allowed cross-origin
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handling & Logging
|
||||
|
||||
### 6.1 Error Messages
|
||||
- [ ] Stack traces not exposed to users in production
|
||||
- [ ] Error messages don't reveal sensitive paths or config
|
||||
- [ ] Failed login doesn't reveal if username exists
|
||||
|
||||
### 6.2 Logging
|
||||
- [ ] Passwords never logged
|
||||
- [ ] Channel keys never logged
|
||||
- [ ] Passphrases never logged
|
||||
- [ ] Log files have appropriate permissions
|
||||
- [ ] Sensitive operations logged for audit trail (optional)
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependency Security
|
||||
|
||||
### 7.1 Python Dependencies
|
||||
- [ ] All dependencies pinned to specific versions
|
||||
- [ ] No known vulnerabilities in dependencies (run `pip-audit` or `safety`)
|
||||
- [ ] Dependencies from trusted sources only (PyPI)
|
||||
|
||||
### 7.2 Frontend Dependencies
|
||||
- [ ] All JS/CSS served locally (air-gap ready)
|
||||
- [ ] No CDN dependencies
|
||||
- [ ] Bootstrap and libraries are official releases
|
||||
- [ ] Subresource integrity considered for any external loads
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Security
|
||||
|
||||
### 8.1 File Permissions
|
||||
- [ ] Database file not world-readable (600 or 640)
|
||||
- [ ] SSL certificates/keys not world-readable
|
||||
- [ ] Config files with secrets protected
|
||||
- [ ] Instance directory not in web root
|
||||
|
||||
### 8.2 Docker Deployment
|
||||
- [ ] Container runs as non-root user
|
||||
- [ ] No unnecessary capabilities
|
||||
- [ ] Resource limits configured
|
||||
- [ ] Health checks don't expose sensitive info
|
||||
|
||||
### 8.3 Raspberry Pi Deployment
|
||||
- [ ] Default passwords changed
|
||||
- [ ] SSH key-only authentication (recommended)
|
||||
- [ ] Unnecessary services disabled
|
||||
- [ ] Firewall configured (UFW/iptables)
|
||||
|
||||
---
|
||||
|
||||
## 9. Air-Gap Specific Considerations
|
||||
|
||||
### 9.1 Network Isolation
|
||||
- [ ] Document expected network topology
|
||||
- [ ] No phone-home or telemetry
|
||||
- [ ] No external API calls
|
||||
- [ ] Works fully offline after deployment
|
||||
|
||||
### 9.2 Key Distribution
|
||||
- [ ] QR code export for channel keys (offline transfer)
|
||||
- [ ] Print sheet for physical key backup
|
||||
- [ ] No cloud sync or external key servers
|
||||
|
||||
### 9.3 Updates
|
||||
- [ ] Document offline update procedure
|
||||
- [ ] Signed releases (future consideration)
|
||||
- [ ] Checksum verification for downloads
|
||||
|
||||
---
|
||||
|
||||
## 10. Penetration Testing Checklist
|
||||
|
||||
### 10.1 Authentication Attacks
|
||||
- [ ] Brute force login (note: no rate limiting by design)
|
||||
- [ ] Session fixation
|
||||
- [ ] Session hijacking
|
||||
- [ ] Password reset flow abuse
|
||||
|
||||
### 10.2 Injection Attacks
|
||||
- [ ] SQL injection on all inputs
|
||||
- [ ] XSS (stored, reflected, DOM-based)
|
||||
- [ ] Command injection
|
||||
- [ ] Path traversal
|
||||
- [ ] SSTI (Server-Side Template Injection)
|
||||
|
||||
### 10.3 Business Logic
|
||||
- [ ] Access control bypass
|
||||
- [ ] IDOR (Insecure Direct Object Reference)
|
||||
- [ ] Race conditions
|
||||
- [ ] Integer overflow in capacity calculations
|
||||
|
||||
### 10.4 Cryptographic Attacks
|
||||
- [ ] Known-plaintext attacks on stego output
|
||||
- [ ] Timing attacks on password verification
|
||||
- [ ] Padding oracle attacks
|
||||
- [ ] Key reuse vulnerabilities
|
||||
|
||||
---
|
||||
|
||||
## Tools for Automated Testing
|
||||
|
||||
```bash
|
||||
# Dependency vulnerability scan
|
||||
pip-audit
|
||||
safety check
|
||||
|
||||
# Static analysis
|
||||
bandit -r stegasoo/ frontends/
|
||||
|
||||
# Web security scan (if exposed)
|
||||
nikto -h https://localhost:5000
|
||||
OWASP ZAP (manual)
|
||||
|
||||
# SSL/TLS configuration
|
||||
testssl.sh https://localhost:5000
|
||||
|
||||
# Python code quality
|
||||
ruff check .
|
||||
mypy stegasoo/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Schedule
|
||||
|
||||
| Phase | Focus Area | Priority |
|
||||
|-------|-----------|----------|
|
||||
| Pre-release | Crypto implementation, auth flow | Critical |
|
||||
| Post-release | Dependency scan, static analysis | High |
|
||||
| Quarterly | Full penetration test | Medium |
|
||||
| Ongoing | CVE monitoring for dependencies | High |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This plan assumes **trusted users on a private network** as the primary deployment model
|
||||
- Internet-facing deployments should add rate limiting, fail2ban, and reverse proxy hardening
|
||||
- For high-security deployments, consider external security audit by professionals
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-07*
|
||||
54
TODO-4.2.1.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Stegasoo 4.2.1 Plan
|
||||
|
||||
## Bugs
|
||||
- [x] Fix EXIF viewer panel not loading metadata in Web UI
|
||||
- Redesigned with card-based grid layout and categories
|
||||
- Compact styling for better space usage
|
||||
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
|
||||
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
|
||||
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
|
||||
- Added rotation fallback in `extract_from_dct()` with quick header validation
|
||||
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
|
||||
- Web UI rotate tool now uses jpegtran for JPEGs
|
||||
- DCT decode rotation fallback now uses jpegtran for JPEGs
|
||||
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
|
||||
|
||||
## Tools Audit
|
||||
- [x] Web UI tools - full shakedown and fixes
|
||||
- Compress, Rotate, Strip, EXIF viewer all working
|
||||
- Rotate uses jpegtran for lossless JPEG rotation
|
||||
- Compact UI styling
|
||||
- [x] CLI tools - full shakedown and fixes
|
||||
- Fixed encode to output JPEG when carrier is JPEG (was always PNG)
|
||||
- Fixed jpegtran -trim flag destroying DCT stego data
|
||||
- Added compress, rotate, convert tools (matching Web UI)
|
||||
- Rotate uses jpegtran for JPEGs, supports flip-only operations
|
||||
|
||||
## AUR Packages
|
||||
- [x] `stegasoo-cli` - standalone CLI package (no web dependencies)
|
||||
- Created aur-cli/PKGBUILD with [cli,dct,compression] extras only
|
||||
- No flask/gunicorn/fastapi/uvicorn/pyzbar deps
|
||||
- 68MB vs 79MB for full package
|
||||
- [x] `stegasoo-api` - REST API package
|
||||
- Created aur-api/PKGBUILD with [api,cli,compression] extras
|
||||
- Has fastapi/uvicorn, no flask/gunicorn
|
||||
- 74MB package size
|
||||
- Includes systemd service with TLS
|
||||
|
||||
## API Auth Work
|
||||
- [x] API key authentication (simpler than OAuth2 for personal use)
|
||||
- `frontends/api/auth.py` - key generation, hashing, validation
|
||||
- Keys stored in `~/.stegasoo/api_keys.json` (hashed)
|
||||
- `X-API-Key` header for authentication
|
||||
- Auth disabled when no keys configured
|
||||
- [x] TLS with self-signed certificates
|
||||
- Auto-generates certs on first run
|
||||
- CLI: `stegasoo api tls generate`
|
||||
- Certs stored in `~/.stegasoo/certs/`
|
||||
- [x] CLI commands for API management
|
||||
- `stegasoo api keys list/create/delete`
|
||||
- `stegasoo api tls generate/info`
|
||||
- `stegasoo api serve` (starts with TLS by default)
|
||||
|
||||
## API Documentation
|
||||
- [ ] Postman collection (with environment templates)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work under the hood.
|
||||
|
||||
**Version 4.0** - Updated for simplified authentication (no date dependency)
|
||||
**Version 4.1** - Updated for channel keys and deployment isolation
|
||||
|
||||
---
|
||||
|
||||
@@ -22,20 +22,20 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STEGASOO ARCHITECTURE (v4.0) │
|
||||
│ STEGASOO ARCHITECTURE (v4.1) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ INPUTS PROCESSING OUTPUT │
|
||||
│ ─────── ────────── ────── │
|
||||
│ │
|
||||
│ Reference Photo ─┐ │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┘ │ │
|
||||
│ ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┤ │ │
|
||||
│ Channel Key ─────┘ (v4.1) ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
│ Encryption │ │
|
||||
│ ▼ │
|
||||
│ Carrier Image ───────────────────────────────────────► Embedding ──► Stego│
|
||||
│ Carrier Image ───────────────────────────────────────► Embedding ─► Stego │
|
||||
│ (LSB/DCT) Image │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -50,11 +50,24 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
| Header size | 75 bytes | 65 bytes (no date field) |
|
||||
| Python support | 3.10+ | 3.10-3.12 only |
|
||||
|
||||
### v4.1 Changes
|
||||
|
||||
| Change | v4.0 | v4.1 |
|
||||
|--------|------|------|
|
||||
| Channel keys | None | 32-byte deployment isolation |
|
||||
| Key derivation | passphrase + ref + pin | passphrase + ref + pin + channel |
|
||||
| Web auth | Session-based | Session + admin/user roles |
|
||||
| Raspberry Pi | Manual setup | First-boot wizard with gum |
|
||||
| Docker | Basic | Production-ready compose |
|
||||
|
||||
**Channel Keys** provide deployment isolation - messages encoded on one Stegasoo instance cannot be decoded by another instance with a different channel key, even with the same passphrase/PIN/reference photo.
|
||||
|
||||
### Module Responsibilities
|
||||
|
||||
| Module | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| **Crypto** | `crypto.py` | Key derivation (Argon2id), AES-256-GCM encryption/decryption |
|
||||
| **Channel** | `channel.py` | Channel key management, deployment isolation (v4.1) |
|
||||
| **Steganography** | `steganography.py` | LSB pixel manipulation, capacity calculation |
|
||||
| **DCT Steganography** | `dct_steganography.py` | Frequency-domain embedding, jpegio integration |
|
||||
| **Compression** | `compression.py` | Optional LZ4 compression of payload |
|
||||
@@ -626,7 +639,7 @@ Factor 1: Reference Photo ─┐
|
||||
• 80-256 bits entropy │
|
||||
• "Something you have" │
|
||||
├──► Combined entropy: 133-400+ bits
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
• 43-132 bits entropy │
|
||||
• "Something you know" │
|
||||
• 4 words default (v4.0) │
|
||||
@@ -688,7 +701,7 @@ AUTHENTICATED ENCRYPTION (AES-256-GCM)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
User Inputs Processing Output
|
||||
@@ -714,14 +727,14 @@ Carrier Image ──────────────────────
|
||||
│ │
|
||||
┌───────────┴─────┴────────────┐
|
||||
│ │
|
||||
LSB Mode DCT Mode
|
||||
LSB Mode DCT Mode
|
||||
│ │
|
||||
▼ ▼
|
||||
embed_lsb() embed_in_dct()
|
||||
(pixel LSBs) (DCT coefficients)
|
||||
embed_lsb() embed_in_dct()
|
||||
(pixel LSBs) (DCT coefficients)
|
||||
│ │
|
||||
▼ ▼
|
||||
PNG Output PNG or JPEG
|
||||
PNG Output PNG or JPEG
|
||||
│ │
|
||||
└──────────┬───────────────────┘
|
||||
│
|
||||
@@ -793,8 +806,8 @@ Stego Image ──────────► detect_mode() ──────
|
||||
Both modes share the same cryptographic foundation (Argon2id + AES-256-GCM) and multi-factor authentication, ensuring security regardless of embedding method.
|
||||
|
||||
The choice comes down to your use case:
|
||||
- **Private channel?** → LSB (maximum capacity)
|
||||
- **Public platform?** → DCT (maximum compatibility)
|
||||
- **Private channel?** → LSB (maximum capacity)
|
||||
|
||||
### v4.0 Simplifications
|
||||
|
||||
|
||||
383
WEB_UI.md
@@ -1,17 +1,22 @@
|
||||
# Stegasoo Web UI Documentation (v4.0.1)
|
||||
# Stegasoo Web UI Documentation (v4.1.0)
|
||||
|
||||
Complete guide for the Stegasoo web-based steganography interface.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What's New in v4.0.1](#whats-new-in-v401)
|
||||
- [What's New in v4.1.0](#whats-new-in-v410)
|
||||
- [Authentication & HTTPS](#authentication--https)
|
||||
- [Admin Recovery](#admin-recovery)
|
||||
- [Multi-User Support](#multi-user-support)
|
||||
- [Installation & Setup](#installation--setup)
|
||||
- [Pages & Features](#pages--features)
|
||||
- [Home Page](#home-page)
|
||||
- [Generate Credentials](#generate-credentials)
|
||||
- [Encode Message](#encode-message)
|
||||
- [Decode Message](#decode-message)
|
||||
- [Tools Page](#tools-page)
|
||||
- [Account Page](#account-page)
|
||||
- [About Page](#about-page)
|
||||
- [Embedding Modes](#embedding-modes)
|
||||
- [DCT Mode (Default)](#dct-mode-default)
|
||||
@@ -53,24 +58,270 @@ Built with Flask, Bootstrap 5, and a modern dark theme.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.1
|
||||
## What's New in v4.1.0
|
||||
|
||||
Version 4.0.1 adds channel key support and UI improvements:
|
||||
Version 4.1.0 adds admin recovery, multi-user support, and new tools:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Channel keys | 256-bit keys for deployment/group isolation |
|
||||
| Channel dropdown | Select channel mode (Auto/Public/Custom) |
|
||||
| LED indicators | Visual status indicators for form fields |
|
||||
| Key capsule styling | Improved RSA key display |
|
||||
| Streamlined layout | PIN + Channel key in same row |
|
||||
| **Admin Recovery** | Password reset using secure recovery key |
|
||||
| **Multi-User Support** | Up to 16 users with role-based access |
|
||||
| **EXIF Editor** | View, edit, and strip image metadata |
|
||||
| **Saved Channel Keys** | Users can save/manage channel keys in account |
|
||||
| **Toast Improvements** | Auto-dismiss after 20 seconds with fade |
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Channel key isolation - Different teams/deployments can't read each other's messages
|
||||
- ✅ Dropdown selection for channel mode instead of radio buttons
|
||||
- ✅ Visual LED indicators show field status
|
||||
- ✅ Cleaner form layout with improved spacing
|
||||
- ✅ Backward compatible - public mode works without channel key
|
||||
- ✅ Never get locked out - recovery key backup options
|
||||
- ✅ Share access with team members (admin/user roles)
|
||||
- ✅ Full EXIF metadata control in Tools page
|
||||
- ✅ Persistent channel key storage per user
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.0.2
|
||||
|
||||
Version 4.0.2 added authentication and HTTPS support:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Authentication** | Single-admin login with SQLite3 user storage |
|
||||
| **First-run setup** | Wizard to create admin account on first access |
|
||||
| **Account management** | Change password page |
|
||||
| **Optional HTTPS** | Auto-generated self-signed certificates |
|
||||
|
||||
---
|
||||
|
||||
## Authentication & HTTPS
|
||||
|
||||
### Overview
|
||||
|
||||
v4.0.2 adds optional authentication and HTTPS for secure home network deployment.
|
||||
|
||||
### First-Run Setup
|
||||
|
||||
On first access, you'll be prompted to create an admin account:
|
||||
|
||||
1. Navigate to `http://localhost:5000`
|
||||
2. You'll be redirected to `/setup`
|
||||
3. Enter a username (e.g., "admin")
|
||||
4. Enter a password (minimum 8 characters)
|
||||
5. Confirm the password
|
||||
6. Click "Create Admin Account"
|
||||
|
||||
The admin account is stored in `frontends/web/instance/stegasoo.db` (SQLite).
|
||||
|
||||
### Login
|
||||
|
||||
After setup, protected pages require login:
|
||||
|
||||
- **Protected routes:** `/encode`, `/decode`, `/generate`, `/account`, `/api/*`
|
||||
- **Public routes:** `/`, `/about`, `/login`, `/setup`
|
||||
|
||||
### Account Management
|
||||
|
||||
Access `/account` to:
|
||||
- View current username
|
||||
- Change your password
|
||||
- Logout
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate generation |
|
||||
|
||||
### Enabling HTTPS
|
||||
|
||||
```bash
|
||||
# Enable HTTPS
|
||||
export STEGASOO_HTTPS_ENABLED=true
|
||||
export STEGASOO_HOSTNAME=stegasoo.local # Optional: your hostname
|
||||
|
||||
cd frontends/web
|
||||
python app.py
|
||||
```
|
||||
|
||||
On first run with HTTPS enabled:
|
||||
- Generates RSA 2048-bit private key
|
||||
- Creates self-signed X.509 certificate (365 days validity)
|
||||
- Stores in `frontends/web/certs/`
|
||||
- Server starts on https://localhost:5000
|
||||
|
||||
**Note:** Browsers will show a security warning for self-signed certificates. This is expected for home network use.
|
||||
|
||||
**Tip:** To avoid browser warnings, use [mkcert](https://github.com/FiloSottile/mkcert) to generate locally-trusted certificates:
|
||||
|
||||
```bash
|
||||
# Install mkcert and create local CA (one-time)
|
||||
mkcert -install
|
||||
|
||||
# Generate trusted certs for your Pi
|
||||
mkcert -key-file key.pem -cert-file cert.pem stegasoo.local localhost 127.0.0.1 YOUR_PI_IP
|
||||
|
||||
# Copy to certs directory
|
||||
mv key.pem cert.pem frontends/web/certs/
|
||||
```
|
||||
|
||||
### Disabling Authentication
|
||||
|
||||
For development or trusted networks:
|
||||
|
||||
```bash
|
||||
export STEGASOO_AUTH_ENABLED=false
|
||||
cd frontends/web
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
```yaml
|
||||
# docker/docker-compose.yml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
STEGASOO_AUTH_ENABLED: "true"
|
||||
STEGASOO_HTTPS_ENABLED: "true"
|
||||
STEGASOO_HOSTNAME: "stegasoo.local"
|
||||
volumes:
|
||||
- ./instance:/app/frontends/web/instance # Persist user database
|
||||
- ./certs:/app/frontends/web/certs # Persist SSL certs
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Passwords are hashed with Argon2id (time_cost=3, memory_cost=64MB)
|
||||
- Single admin user only (no registration)
|
||||
- Session-based authentication using Flask sessions
|
||||
- Database stored in `instance/stegasoo.db` (add to `.gitignore`)
|
||||
|
||||
---
|
||||
|
||||
## Admin Recovery
|
||||
|
||||
### Overview
|
||||
|
||||
If you forget your admin password, the recovery key is the ONLY way to reset it. Generate and save your recovery key immediately after setup.
|
||||
|
||||
### Recovery Key Format
|
||||
|
||||
```
|
||||
XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||||
└──────────────────────────────────────┘
|
||||
32 alphanumeric characters (8 groups of 4)
|
||||
```
|
||||
|
||||
### Backup Options
|
||||
|
||||
The recovery key can be saved in multiple ways:
|
||||
|
||||
| Method | Description | Security Level |
|
||||
|--------|-------------|----------------|
|
||||
| **Text file** | Plain text download | Low - store securely |
|
||||
| **QR code** | Obfuscated PNG image | Medium - XOR'd with magic hash |
|
||||
| **Stego image** | Hidden in carrier image | High - requires original image |
|
||||
|
||||
### Generating a Recovery Key
|
||||
|
||||
**During first-run setup:**
|
||||
1. Complete the admin account wizard
|
||||
2. You'll be prompted to save your recovery key
|
||||
3. Choose backup method(s)
|
||||
4. Confirm you've saved the key
|
||||
|
||||
**From Account page (admin only):**
|
||||
1. Navigate to `/account`
|
||||
2. Click "Generate Recovery Key" (or "Regenerate" if one exists)
|
||||
3. Save using your preferred method
|
||||
4. Check the confirmation box
|
||||
5. Click "Save New Key"
|
||||
|
||||
### QR Code Obfuscation
|
||||
|
||||
QR codes are not plain text - they're XOR'd with a fixed obfuscation key derived from Stegasoo's magic headers. This prevents casual scanning from revealing the key.
|
||||
|
||||
### Stego Backup
|
||||
|
||||
Hide your recovery key inside an image using Stegasoo itself:
|
||||
|
||||
1. Upload a carrier image (JPG/PNG, 50KB-2MB)
|
||||
2. Click the "Stego" button
|
||||
3. Download the stego image
|
||||
4. **Important:** Keep the original carrier image - you'll need it for extraction
|
||||
|
||||
### Recovering Your Password
|
||||
|
||||
**URL:** `/recover`
|
||||
|
||||
1. Navigate to the login page
|
||||
2. Click "Forgot password?"
|
||||
3. **Option A:** Enter recovery key directly
|
||||
4. **Option B:** Extract from stego backup:
|
||||
- Expand "Extract from stego backup"
|
||||
- Upload your stego backup image
|
||||
- Upload the original carrier/reference image
|
||||
- Click "Extract Key"
|
||||
5. Enter and confirm your new password
|
||||
6. Click "Reset Password"
|
||||
|
||||
### CLI Recovery
|
||||
|
||||
For locked-out scenarios where you can't access the web UI:
|
||||
|
||||
```bash
|
||||
stegasoo admin recover --db frontends/web/instance/stegasoo.db
|
||||
```
|
||||
|
||||
You'll be prompted for your recovery key and new password.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Recovery keys are instance-bound (tied to the specific database)
|
||||
- Regenerating a key invalidates the previous one
|
||||
- Store backups in a secure, separate location
|
||||
- Without a recovery key, the only option is to delete the database and reconfigure
|
||||
|
||||
---
|
||||
|
||||
## Multi-User Support
|
||||
|
||||
### Overview
|
||||
|
||||
Admins can create up to 16 additional users with role-based access control.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| **Admin** | Full access: encode, decode, generate, tools, user management, recovery |
|
||||
| **User** | Standard access: encode, decode, generate, account settings |
|
||||
|
||||
### User Management
|
||||
|
||||
**URL:** `/admin/users` (admin only)
|
||||
|
||||
#### Creating Users
|
||||
|
||||
1. Click "Add User"
|
||||
2. Enter username
|
||||
3. Select role (admin/user)
|
||||
4. A temporary password is generated
|
||||
5. Share the temporary password securely with the new user
|
||||
6. User must change password on first login
|
||||
|
||||
#### Managing Users
|
||||
|
||||
- View all users and their roles
|
||||
- Reset user passwords (generates new temp password)
|
||||
- Change user roles
|
||||
- Delete users (except yourself)
|
||||
|
||||
### User Limits
|
||||
|
||||
- Maximum 16 users total (including admin)
|
||||
- At least one admin must exist
|
||||
- Users can't delete or demote the last admin
|
||||
|
||||
---
|
||||
|
||||
@@ -109,7 +360,7 @@ gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker-compose up web
|
||||
docker-compose -f docker/docker-compose.yml up web
|
||||
```
|
||||
|
||||
### First-Time Setup
|
||||
@@ -160,7 +411,7 @@ Create a new set of credentials for steganography operations.
|
||||
| Use PIN | on/off | on | Generate a numeric PIN |
|
||||
| PIN length | 6-9 | 6 | Digits in the PIN |
|
||||
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
||||
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
|
||||
| RSA key size | 2048/3072 | 2048 | Key size in bits |
|
||||
|
||||
#### Entropy Calculator
|
||||
|
||||
@@ -441,6 +692,83 @@ If decryption fails:
|
||||
|
||||
---
|
||||
|
||||
### Tools Page
|
||||
|
||||
**URL:** `/tools`
|
||||
|
||||
The Tools page provides utilities for image analysis and manipulation.
|
||||
|
||||
#### EXIF Editor
|
||||
|
||||
View and edit image metadata (EXIF data).
|
||||
|
||||
**Features:**
|
||||
- View all EXIF fields from uploaded image
|
||||
- Inline editing of individual fields
|
||||
- Clear all metadata with one click
|
||||
- Download cleaned image
|
||||
|
||||
**Usage:**
|
||||
1. Upload an image (JPG recommended - richest EXIF data)
|
||||
2. View all metadata fields in a table
|
||||
3. Click any field to edit its value
|
||||
4. Click "Save" to apply changes
|
||||
5. Use "Clear All" to strip all metadata
|
||||
6. Download the modified image
|
||||
|
||||
**Common EXIF fields:**
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Make/Model | Camera manufacturer and model |
|
||||
| DateTime | When the photo was taken |
|
||||
| GPSLatitude/GPSLongitude | Location coordinates |
|
||||
| Software | Editing software used |
|
||||
| Artist | Photographer name |
|
||||
|
||||
**Privacy tip:** Always strip EXIF data before sharing images publicly to remove location and device information.
|
||||
|
||||
#### Peek (Stego Detection)
|
||||
|
||||
Quickly check if an image contains hidden data.
|
||||
|
||||
#### Strip Metadata
|
||||
|
||||
Remove all metadata from an image in one click.
|
||||
|
||||
---
|
||||
|
||||
### Account Page
|
||||
|
||||
**URL:** `/account`
|
||||
|
||||
Manage your account settings and preferences.
|
||||
|
||||
#### Password Change
|
||||
|
||||
1. Enter current password
|
||||
2. Enter new password (minimum 8 characters)
|
||||
3. Confirm new password
|
||||
4. Click "Change Password"
|
||||
|
||||
#### Saved Channel Keys (v4.1.0)
|
||||
|
||||
Users can save frequently-used channel keys for quick access:
|
||||
|
||||
1. Click "Add Channel Key"
|
||||
2. Enter a name/label for the key
|
||||
3. Paste the channel key
|
||||
4. Click "Save"
|
||||
|
||||
Saved keys appear in a dropdown during encode/decode operations.
|
||||
|
||||
#### Recovery Key Management (Admin only)
|
||||
|
||||
- View recovery key status (configured/not configured)
|
||||
- Generate or regenerate recovery key
|
||||
- Download backup options (text, QR, stego)
|
||||
|
||||
---
|
||||
|
||||
### About Page
|
||||
|
||||
**URL:** `/about`
|
||||
@@ -448,10 +776,10 @@ If decryption fails:
|
||||
Information about the Stegasoo project, security model, and credits.
|
||||
|
||||
Includes:
|
||||
- Version information (v3.3.0)
|
||||
- Recent UI improvements
|
||||
- Version information (v4.1.0)
|
||||
- Feature highlights
|
||||
- Security model overview
|
||||
- Dependency status (Argon2, QR code support)
|
||||
- Dependency status (Argon2, scipy/DCT, QR code support)
|
||||
|
||||
---
|
||||
|
||||
@@ -752,6 +1080,10 @@ Both modes use the same strong encryption (AES-256-GCM with Argon2id key derivat
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONPATH` | - | Include `src/` for development |
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Enable/disable authentication (v4.0.2) |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS with self-signed certs (v4.0.2) |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for certificate CN (v4.0.2) |
|
||||
| `STEGASOO_CHANNEL_KEY` | - | Channel key for deployment isolation |
|
||||
|
||||
### Application Limits
|
||||
|
||||
@@ -808,12 +1140,23 @@ services:
|
||||
target: web
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
STEGASOO_AUTH_ENABLED: "true"
|
||||
STEGASOO_HTTPS_ENABLED: "false"
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
volumes:
|
||||
- stegasoo-web-data:/app/frontends/web/instance
|
||||
- stegasoo-web-certs:/app/frontends/web/certs
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
reservations:
|
||||
memory: 384M
|
||||
|
||||
volumes:
|
||||
stegasoo-web-data:
|
||||
stegasoo-web-certs:
|
||||
```
|
||||
|
||||
---
|
||||
@@ -902,7 +1245,7 @@ services:
|
||||
```bash
|
||||
pip install scipy
|
||||
# Or rebuild Docker image
|
||||
docker-compose build --no-cache
|
||||
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||
```
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
42
WISHLIST-4.2.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Stegasoo v4.2 Wishlist
|
||||
|
||||
Blue sky ideas for future development. No timeline - just capturing thoughts.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### GPU-Accelerated DCT Encoding/Decoding
|
||||
- **Idea**: Leverage GPU for JPEG DCT coefficient manipulation
|
||||
- **Potential Approaches**:
|
||||
- OpenCL/CUDA for parallel DCT operations
|
||||
- Raspberry Pi VideoCore IV/VI GPU compute
|
||||
- WebGPU for browser-based acceleration
|
||||
- **Challenges**:
|
||||
- jpegio library is CPU-bound (C extension)
|
||||
- Would need custom DCT implementation
|
||||
- Memory transfer overhead may negate gains for small images
|
||||
- **Research**:
|
||||
- libjpeg-turbo uses SIMD but not GPU
|
||||
- nvJPEG (NVIDIA) does GPU-accelerated JPEG
|
||||
- Could potentially use GPU for the embedding math, not JPEG decode
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
(Add ideas here)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
(Add ideas here)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a living document - add ideas anytime
|
||||
- Not all ideas will be implemented
|
||||
- Feasibility research needed before committing to roadmap
|
||||
30
agentstuff/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "sentiment-agent"
|
||||
version = "0.1.0"
|
||||
description = "AI agent for gathering data and performing sentiment analysis"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"claude-agent-sdk",
|
||||
"anyio",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"ruff",
|
||||
"mypy",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
sentiment-agent = "sentiment_agent.main:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
ignore_missing_imports = true
|
||||
3
agentstuff/sentiment_agent/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Sentiment analysis agent powered by Claude Agent SDK."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
115
agentstuff/sentiment_agent/agent.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Core sentiment analysis agent using Claude Agent SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
ClaudeSDKClient,
|
||||
ResultMessage,
|
||||
TextBlock,
|
||||
)
|
||||
|
||||
from sentiment_agent.config import SafetyConfig
|
||||
from sentiment_agent.tools import create_social_tools_server
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a sentiment analysis agent. Your job is to gather data from multiple \
|
||||
platforms and produce a structured, evidence-based sentiment report.
|
||||
|
||||
## Rules — you MUST follow these
|
||||
|
||||
1. **Budget awareness.** You have a limited API call budget. Call \
|
||||
`get_api_budget_status` before starting and after every few tool calls. \
|
||||
Stop gathering data when you have <5 calls remaining and begin your analysis.
|
||||
|
||||
2. **Credibility first.** Every tool result includes credibility scores and \
|
||||
bot/disinfo flags. You MUST:
|
||||
- NEVER quote or cite posts marked `likely_inauthentic` (score < 0.3).
|
||||
- Flag posts marked `suspicious` (score 0.3–0.5) with a warning when citing them.
|
||||
- Give more weight to `likely_authentic` posts (score ≥ 0.7).
|
||||
- If coordination warnings appear (copy-paste campaigns, burst posting), \
|
||||
call them out prominently in your report.
|
||||
|
||||
3. **Platform diversity.** Gather from at least 2 different platforms before \
|
||||
analyzing. Do not over-index on a single source.
|
||||
|
||||
4. **No fabrication.** Only report on data you actually retrieved. If a tool \
|
||||
call fails or returns no results, say so — do not invent data.
|
||||
|
||||
5. **Structured output.** Your final report MUST include these sections:
|
||||
- **Data Quality Summary**: platforms queried, posts analyzed vs excluded, \
|
||||
coordination warnings
|
||||
- **Overall Sentiment**: score (-1.0 to +1.0) and label \
|
||||
(very negative / negative / mixed / neutral / positive / very positive)
|
||||
- **Platform Breakdown**: sentiment per platform with sample size
|
||||
- **Key Themes**: top 3-5 themes with sentiment direction
|
||||
- **Credibility Concerns**: any bot networks, disinfo patterns, or \
|
||||
coordinated campaigns detected
|
||||
- **Notable Quotes**: 3-5 representative quotes (authentic sources only, \
|
||||
with credibility score noted)
|
||||
- **Confidence Assessment**: how confident you are in the analysis given \
|
||||
data quality and volume
|
||||
|
||||
6. **Scope discipline.** Stay focused on the requested topic. Do not expand \
|
||||
scope, follow tangents, or analyze adjacent topics unless explicitly asked.
|
||||
|
||||
7. **No side effects.** Do not write files, run commands, or take any action \
|
||||
beyond reading data and producing your report.
|
||||
"""
|
||||
|
||||
|
||||
async def run_sentiment_analysis(
|
||||
topic: str,
|
||||
sources: list[str] | None = None,
|
||||
config: SafetyConfig | None = None,
|
||||
) -> str:
|
||||
"""Run the sentiment analysis agent on a given topic.
|
||||
|
||||
Args:
|
||||
topic: The topic or subject to analyze sentiment for.
|
||||
sources: Optional list of URLs or data sources to analyze.
|
||||
config: Safety configuration. Defaults to SafetyConfig.from_env().
|
||||
|
||||
Returns:
|
||||
The agent's sentiment analysis report.
|
||||
"""
|
||||
config = config or SafetyConfig.from_env()
|
||||
|
||||
source_instructions = ""
|
||||
if sources:
|
||||
source_list = "\n".join(f"- {s}" for s in sources)
|
||||
source_instructions = f"\n\nAlso analyze these specific sources:\n{source_list}"
|
||||
|
||||
prompt = (
|
||||
f"Perform a sentiment analysis on the following topic: {topic}\n\n"
|
||||
"Start by calling `get_api_budget_status` to check your budget, then "
|
||||
"gather data from multiple platforms (Reddit, Hacker News, Bluesky if "
|
||||
"configured, and web search). Pay close attention to credibility scores "
|
||||
"and coordination warnings in the results."
|
||||
f"{source_instructions}"
|
||||
)
|
||||
|
||||
social_server = create_social_tools_server(config)
|
||||
|
||||
options = ClaudeAgentOptions(
|
||||
# Only allow read-only tools — no Write/Bash to prevent side effects
|
||||
allowed_tools=["WebSearch", "WebFetch", "Read"],
|
||||
max_turns=config.max_turns,
|
||||
max_budget_usd=config.max_budget_usd,
|
||||
mcp_servers={"social": social_server},
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query(prompt)
|
||||
async for message in client.receive_response():
|
||||
if isinstance(message, AssistantMessage):
|
||||
for block in message.content:
|
||||
if isinstance(block, TextBlock):
|
||||
print(block.text, end="", flush=True)
|
||||
if isinstance(message, ResultMessage):
|
||||
result_text = message.result
|
||||
|
||||
return result_text
|
||||
1
agentstuff/sentiment_agent/clients/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API clients for social media and forum data sources."""
|
||||
166
agentstuff/sentiment_agent/clients/bluesky.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Bluesky client using the AT Protocol API.
|
||||
|
||||
Search requires authentication. Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD
|
||||
env vars. Create an app password at: https://bsky.app/settings/app-passwords
|
||||
|
||||
Thread fetching works without auth via the public API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
|
||||
BSKY_PUBLIC_API = "https://public.api.bsky.app"
|
||||
BSKY_AUTH_API = "https://bsky.social"
|
||||
|
||||
|
||||
async def _get_session() -> dict | None:
|
||||
"""Authenticate with Bluesky and return session tokens, or None if no creds."""
|
||||
handle = os.environ.get("BLUESKY_HANDLE")
|
||||
app_password = os.environ.get("BLUESKY_APP_PASSWORD")
|
||||
if not handle or not app_password:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"{BSKY_AUTH_API}/xrpc/com.atproto.server.createSession",
|
||||
json={"identifier": handle, "password": app_password},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _format_post(post_view: dict) -> dict:
|
||||
"""Extract relevant fields from an AT Protocol post view."""
|
||||
post = post_view.get("post", post_view)
|
||||
record = post.get("record", {})
|
||||
author = post.get("author", {})
|
||||
return {
|
||||
"text": record.get("text", ""),
|
||||
"author_handle": author.get("handle", ""),
|
||||
"author_display_name": author.get("displayName", ""),
|
||||
"created_at": record.get("createdAt", ""),
|
||||
"like_count": post.get("likeCount", 0),
|
||||
"repost_count": post.get("repostCount", 0),
|
||||
"reply_count": post.get("replyCount", 0),
|
||||
"uri": post.get("uri", ""),
|
||||
"cid": post.get("cid", ""),
|
||||
"url": _uri_to_url(post.get("uri", ""), author.get("handle", "")),
|
||||
}
|
||||
|
||||
|
||||
def _uri_to_url(uri: str, handle: str) -> str:
|
||||
"""Convert an at:// URI to a bsky.app URL."""
|
||||
# at://did:plc:xxx/app.bsky.feed.post/rkey -> https://bsky.app/profile/handle/post/rkey
|
||||
if not uri.startswith("at://"):
|
||||
return ""
|
||||
parts = uri.split("/")
|
||||
if len(parts) >= 5:
|
||||
rkey = parts[-1]
|
||||
return f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||
return ""
|
||||
|
||||
|
||||
async def search_posts(query: str, limit: int = 25, sort: str = "top") -> list[dict]:
|
||||
"""Search Bluesky for posts matching a query.
|
||||
|
||||
Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
query: Search terms.
|
||||
limit: Max results (capped at 100).
|
||||
sort: "top" (most liked) or "latest" (chronological).
|
||||
|
||||
Returns:
|
||||
List of post dicts with: text, author_handle, author_display_name,
|
||||
created_at, like_count, repost_count, reply_count, uri, url.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If Bluesky credentials are not configured.
|
||||
"""
|
||||
session = await _get_session()
|
||||
if not session:
|
||||
raise RuntimeError(
|
||||
"Bluesky search requires authentication. "
|
||||
"Set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD environment variables. "
|
||||
"Create an app password at: https://bsky.app/settings/app-passwords"
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{BSKY_AUTH_API}/xrpc/app.bsky.feed.searchPosts",
|
||||
params={
|
||||
"q": query,
|
||||
"limit": min(limit, 100),
|
||||
"sort": sort,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {session['accessJwt']}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return [_format_post(p) for p in data.get("posts", [])]
|
||||
|
||||
|
||||
async def get_thread(uri: str, depth: int = 6) -> dict:
|
||||
"""Fetch a Bluesky thread by AT URI or bsky.app URL.
|
||||
|
||||
Args:
|
||||
uri: Either an at:// URI or a https://bsky.app/profile/.../post/... URL.
|
||||
depth: How many levels of replies to fetch (max 1000).
|
||||
|
||||
Returns:
|
||||
Dict with "post" (the root post) and "replies" (list of reply post dicts).
|
||||
"""
|
||||
# Convert bsky.app URL to AT URI if needed
|
||||
if uri.startswith("https://bsky.app/"):
|
||||
uri = await _resolve_url_to_uri(uri)
|
||||
|
||||
headers = {}
|
||||
session = await _get_session()
|
||||
if session:
|
||||
headers["Authorization"] = f"Bearer {session['accessJwt']}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{BSKY_PUBLIC_API}/xrpc/app.bsky.feed.getPostThread",
|
||||
params={"uri": uri, "depth": min(depth, 1000)},
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
thread = data.get("thread", {})
|
||||
root_post = _format_post(thread) if "post" in thread else {}
|
||||
|
||||
replies = []
|
||||
for reply in thread.get("replies", []):
|
||||
if "post" in reply:
|
||||
replies.append(_format_post(reply))
|
||||
# Include nested replies one level deep
|
||||
for nested in reply.get("replies", []):
|
||||
if "post" in nested:
|
||||
replies.append(_format_post(nested))
|
||||
|
||||
return {"post": root_post, "replies": replies}
|
||||
|
||||
|
||||
async def _resolve_url_to_uri(url: str) -> str:
|
||||
"""Convert a bsky.app URL to an AT URI by resolving the handle."""
|
||||
# https://bsky.app/profile/handle.bsky.social/post/rkey
|
||||
parts = url.rstrip("/").split("/")
|
||||
if len(parts) < 6:
|
||||
raise ValueError(f"Invalid Bluesky URL: {url}")
|
||||
|
||||
handle = parts[4] # profile/{handle}
|
||||
rkey = parts[6] # post/{rkey}
|
||||
|
||||
# Resolve handle to DID
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle",
|
||||
params={"handle": handle},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
did = resp.json()["did"]
|
||||
|
||||
return f"at://{did}/app.bsky.feed.post/{rkey}"
|
||||
78
agentstuff/sentiment_agent/clients/hackernews.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Hacker News client using the Algolia HN Search API.
|
||||
|
||||
No authentication required. Docs: https://hn.algolia.com/api
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
HN_API_BASE = "https://hn.algolia.com/api/v1"
|
||||
|
||||
|
||||
async def search_stories(query: str, limit: int = 25) -> list[dict]:
|
||||
"""Search HN for stories matching a query.
|
||||
|
||||
Returns a list of story dicts with: title, url, author, points,
|
||||
num_comments, created_at, objectID, story_text.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{HN_API_BASE}/search",
|
||||
params={
|
||||
"query": query,
|
||||
"tags": "story",
|
||||
"hitsPerPage": min(limit, 50),
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for hit in data.get("hits", []):
|
||||
results.append(
|
||||
{
|
||||
"title": hit.get("title", ""),
|
||||
"url": hit.get("url", ""),
|
||||
"author": hit.get("author", ""),
|
||||
"points": hit.get("points", 0),
|
||||
"num_comments": hit.get("num_comments", 0),
|
||||
"created_at": hit.get("created_at", ""),
|
||||
"object_id": hit.get("objectID", ""),
|
||||
"story_text": hit.get("story_text") or "",
|
||||
"hn_url": f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
async def search_comments(query: str, limit: int = 25) -> list[dict]:
|
||||
"""Search HN for comments matching a query.
|
||||
|
||||
Returns a list of comment dicts with: comment_text, author, points,
|
||||
created_at, story_title, story_url.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{HN_API_BASE}/search",
|
||||
params={
|
||||
"query": query,
|
||||
"tags": "comment",
|
||||
"hitsPerPage": min(limit, 50),
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for hit in data.get("hits", []):
|
||||
results.append(
|
||||
{
|
||||
"comment_text": hit.get("comment_text", ""),
|
||||
"author": hit.get("author", ""),
|
||||
"points": hit.get("points", 0),
|
||||
"created_at": hit.get("created_at", ""),
|
||||
"story_title": hit.get("story_title", ""),
|
||||
"story_url": hit.get("story_url", ""),
|
||||
"hn_url": f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}",
|
||||
}
|
||||
)
|
||||
return results
|
||||
117
agentstuff/sentiment_agent/clients/reddit.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Reddit client using the public JSON API.
|
||||
|
||||
No authentication required for read-only search. Reddit requires a descriptive
|
||||
User-Agent header — requests with generic UAs get 429'd.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
REDDIT_BASE = "https://www.reddit.com"
|
||||
USER_AGENT = "sentiment-agent/0.1.0 (research; sentiment analysis tool)"
|
||||
|
||||
|
||||
async def search_posts(
|
||||
query: str,
|
||||
subreddit: str = "all",
|
||||
sort: str = "relevance",
|
||||
time_filter: str = "month",
|
||||
limit: int = 25,
|
||||
) -> list[dict]:
|
||||
"""Search Reddit for posts matching a query.
|
||||
|
||||
Args:
|
||||
query: Search terms.
|
||||
subreddit: Subreddit to search within, or "all" for site-wide.
|
||||
sort: One of "relevance", "hot", "top", "new", "comments".
|
||||
time_filter: One of "hour", "day", "week", "month", "year", "all".
|
||||
limit: Max results (capped at 100 by Reddit).
|
||||
|
||||
Returns:
|
||||
List of post dicts with: title, selftext, author, score,
|
||||
num_comments, subreddit, url, permalink, created_utc.
|
||||
"""
|
||||
url = f"{REDDIT_BASE}/r/{subreddit}/search.json"
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
params={
|
||||
"q": query,
|
||||
"sort": sort,
|
||||
"t": time_filter,
|
||||
"limit": min(limit, 100),
|
||||
"restrict_sr": "on" if subreddit != "all" else "off",
|
||||
},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for child in data.get("data", {}).get("children", []):
|
||||
post = child.get("data", {})
|
||||
results.append(
|
||||
{
|
||||
"title": post.get("title", ""),
|
||||
"selftext": (post.get("selftext") or "")[:2000],
|
||||
"author": post.get("author", "[deleted]"),
|
||||
"score": post.get("score", 0),
|
||||
"upvote_ratio": post.get("upvote_ratio", 0),
|
||||
"num_comments": post.get("num_comments", 0),
|
||||
"subreddit": post.get("subreddit", ""),
|
||||
"url": post.get("url", ""),
|
||||
"permalink": f"https://reddit.com{post.get('permalink', '')}",
|
||||
"created_utc": post.get("created_utc", 0),
|
||||
"is_self": post.get("is_self", False),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
async def get_post_comments(
|
||||
permalink: str,
|
||||
sort: str = "top",
|
||||
limit: int = 25,
|
||||
) -> list[dict]:
|
||||
"""Fetch top-level comments for a Reddit post.
|
||||
|
||||
Args:
|
||||
permalink: The post's permalink path (e.g., "/r/python/comments/abc123/title/").
|
||||
sort: Comment sort order: "top", "best", "new", "controversial".
|
||||
limit: Max comments to return.
|
||||
|
||||
Returns:
|
||||
List of comment dicts with: body, author, score, created_utc.
|
||||
"""
|
||||
# Strip domain if full URL was passed
|
||||
if permalink.startswith("https://"):
|
||||
permalink = permalink.replace("https://reddit.com", "")
|
||||
permalink = permalink.replace("https://www.reddit.com", "")
|
||||
|
||||
url = f"{REDDIT_BASE}{permalink}.json"
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
params={"sort": sort, "limit": limit},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Reddit returns [post_listing, comments_listing]
|
||||
if not isinstance(data, list) or len(data) < 2:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for child in data[1].get("data", {}).get("children", []):
|
||||
if child.get("kind") != "t1":
|
||||
continue
|
||||
comment = child.get("data", {})
|
||||
results.append(
|
||||
{
|
||||
"body": (comment.get("body") or "")[:2000],
|
||||
"author": comment.get("author", "[deleted]"),
|
||||
"score": comment.get("score", 0),
|
||||
"created_utc": comment.get("created_utc", 0),
|
||||
}
|
||||
)
|
||||
return results
|
||||
70
agentstuff/sentiment_agent/config.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Configuration and safety limits for the sentiment agent.
|
||||
|
||||
All guardrails are centralized here so they can be tuned from one place
|
||||
or overridden via CLI flags / env vars.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitConfig:
|
||||
"""Per-platform rate limiting."""
|
||||
|
||||
requests_per_minute: int = 10
|
||||
burst_size: int = 3 # max concurrent requests
|
||||
cooldown_after_429: float = 30.0 # seconds to wait after a 429
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafetyConfig:
|
||||
"""Top-level safety rails for the agent."""
|
||||
|
||||
# --- Agent-level limits ---
|
||||
max_turns: int = 20
|
||||
max_budget_usd: float = 0.50 # hard cap on Claude API spend per run
|
||||
max_total_api_calls: int = 50 # across ALL platforms combined
|
||||
max_results_per_call: int = 50 # cap the `limit` param sent to any API
|
||||
|
||||
# --- Per-platform rate limits ---
|
||||
bluesky_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
|
||||
requests_per_minute=10, burst_size=2,
|
||||
))
|
||||
reddit_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
|
||||
requests_per_minute=10, burst_size=2,
|
||||
))
|
||||
hackernews_rate: RateLimitConfig = field(default_factory=lambda: RateLimitConfig(
|
||||
requests_per_minute=15, burst_size=3, # HN Algolia is more generous
|
||||
))
|
||||
|
||||
# --- Content size limits ---
|
||||
max_post_text_chars: int = 2000 # truncate individual posts beyond this
|
||||
max_total_content_bytes: int = 500_000 # ~500KB total data gathered before agent stops
|
||||
|
||||
# --- Timeout ---
|
||||
api_timeout_seconds: float = 15.0
|
||||
|
||||
# --- Credibility thresholds ---
|
||||
min_credibility_score: float = 0.3 # posts below this are flagged/excluded
|
||||
flag_bot_threshold: float = 0.5 # posts between min and this are flagged but included
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> SafetyConfig:
|
||||
"""Build config with env var overrides.
|
||||
|
||||
Env vars: SENTIMENT_MAX_TURNS, SENTIMENT_MAX_BUDGET_USD,
|
||||
SENTIMENT_MAX_API_CALLS, SENTIMENT_MIN_CREDIBILITY.
|
||||
"""
|
||||
kwargs: dict = {}
|
||||
if v := os.environ.get("SENTIMENT_MAX_TURNS"):
|
||||
kwargs["max_turns"] = int(v)
|
||||
if v := os.environ.get("SENTIMENT_MAX_BUDGET_USD"):
|
||||
kwargs["max_budget_usd"] = float(v)
|
||||
if v := os.environ.get("SENTIMENT_MAX_API_CALLS"):
|
||||
kwargs["max_total_api_calls"] = int(v)
|
||||
if v := os.environ.get("SENTIMENT_MIN_CREDIBILITY"):
|
||||
kwargs["min_credibility_score"] = float(v)
|
||||
return cls(**kwargs)
|
||||
398
agentstuff/sentiment_agent/credibility.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Credibility scoring and bot/disinfo detection.
|
||||
|
||||
Assigns a 0.0–1.0 credibility score to each post based on heuristic signals.
|
||||
Posts below the configured threshold are excluded or flagged so they don't
|
||||
pollute the sentiment analysis.
|
||||
|
||||
Signals are platform-aware — each platform has different indicators of
|
||||
inauthentic behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredibilityResult:
|
||||
"""Credibility assessment for a single post."""
|
||||
|
||||
score: float # 0.0 (likely bot/disinfo) to 1.0 (likely authentic)
|
||||
flags: list[str] = field(default_factory=list) # human-readable reasons
|
||||
is_excluded: bool = False # below min_credibility_score
|
||||
is_flagged: bool = False # between min and flag threshold
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
if self.score >= 0.7:
|
||||
return "likely_authentic"
|
||||
if self.score >= 0.5:
|
||||
return "uncertain"
|
||||
if self.score >= 0.3:
|
||||
return "suspicious"
|
||||
return "likely_inauthentic"
|
||||
|
||||
|
||||
# --- Shared heuristics ---
|
||||
|
||||
# Common bot patterns in text
|
||||
_BOT_TEXT_PATTERNS = [
|
||||
# Crypto/scam spam
|
||||
re.compile(r"(?i)(dm me|check my bio|link in bio|click here|free giveaway)"),
|
||||
re.compile(r"(?i)(join my|subscribe to|follow me for|🔥.*🔥.*🔥)"),
|
||||
# Astroturfing phrases
|
||||
re.compile(r"(?i)(i (just )?(discovered|found|tried) this (amazing|incredible|awesome))"),
|
||||
re.compile(r"(?i)(game.?changer|life.?changing|you won'?t believe)"),
|
||||
# Excessive hashtags (5+)
|
||||
re.compile(r"(#\w+\s*){5,}"),
|
||||
# Walls of emojis (10+ consecutive)
|
||||
re.compile(r"[\U0001F300-\U0001FAFF]{10,}"),
|
||||
# Repetitive characters (spammy emphasis)
|
||||
re.compile(r"(.)\1{9,}"),
|
||||
]
|
||||
|
||||
# Coordinated campaign indicators: identical or near-identical text
|
||||
# This is checked at the batch level, not per-post
|
||||
|
||||
|
||||
def _check_text_patterns(text: str) -> list[str]:
|
||||
"""Check text against common bot/spam patterns."""
|
||||
flags = []
|
||||
for pattern in _BOT_TEXT_PATTERNS:
|
||||
if pattern.search(text):
|
||||
flags.append(f"bot_text_pattern: {pattern.pattern[:60]}")
|
||||
if len(text) < 15:
|
||||
flags.append("very_short_text")
|
||||
return flags
|
||||
|
||||
|
||||
def _engagement_ratio_score(
|
||||
likes: int, reposts: int, replies: int
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Score based on engagement ratios.
|
||||
|
||||
Authentic posts tend to have a mix of likes, replies, and reposts.
|
||||
Bot-amplified posts often have inflated likes with very few replies,
|
||||
or massive repost counts with no discussion.
|
||||
"""
|
||||
flags = []
|
||||
total = likes + reposts + replies
|
||||
|
||||
if total == 0:
|
||||
return 0.5, ["no_engagement"]
|
||||
|
||||
# High repost-to-reply ratio suggests amplification without discussion
|
||||
if reposts > 0 and replies == 0 and reposts > 10:
|
||||
flags.append(f"high_repost_no_replies: {reposts} reposts, 0 replies")
|
||||
return 0.3, flags
|
||||
|
||||
# Extremely high like count with zero replies is suspicious
|
||||
if likes > 100 and replies == 0:
|
||||
flags.append(f"high_likes_no_replies: {likes} likes, 0 replies")
|
||||
return 0.4, flags
|
||||
|
||||
# Normal engagement
|
||||
return min(1.0, 0.5 + (replies / max(total, 1)) * 0.5), flags
|
||||
|
||||
|
||||
# --- Platform-specific scoring ---
|
||||
|
||||
|
||||
def score_bluesky_post(post: dict) -> CredibilityResult:
|
||||
"""Score a Bluesky post for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
text = post.get("text", "")
|
||||
handle = post.get("author_handle", "")
|
||||
display_name = post.get("author_display_name", "")
|
||||
likes = post.get("like_count", 0)
|
||||
reposts = post.get("repost_count", 0)
|
||||
replies = post.get("reply_count", 0)
|
||||
|
||||
# Text pattern checks
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.15 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# Handle heuristics
|
||||
# Randomly generated handles (long hex/number strings)
|
||||
if re.match(r"^[a-f0-9]{8,}\.", handle):
|
||||
flags.append(f"random_handle: {handle}")
|
||||
score -= 0.3
|
||||
|
||||
# No display name set
|
||||
if not display_name or display_name == handle:
|
||||
flags.append("no_display_name")
|
||||
score -= 0.1
|
||||
|
||||
# Engagement ratio
|
||||
eng_score, eng_flags = _engagement_ratio_score(likes, reposts, replies)
|
||||
flags.extend(eng_flags)
|
||||
score = score * 0.6 + eng_score * 0.4
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_reddit_post(post: dict) -> CredibilityResult:
|
||||
"""Score a Reddit post for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
text = post.get("selftext", "") or post.get("title", "")
|
||||
author = post.get("author", "")
|
||||
upvote_ratio = post.get("upvote_ratio", 0.5)
|
||||
post_score = post.get("score", 0)
|
||||
num_comments = post.get("num_comments", 0)
|
||||
|
||||
# Text patterns
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.15 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# Deleted author
|
||||
if author in ("[deleted]", "[removed]"):
|
||||
flags.append("deleted_author")
|
||||
score -= 0.2
|
||||
|
||||
# Suspicious username patterns (random alphanumeric + numbers)
|
||||
if re.match(r"^[A-Za-z]+[-_]?\d{4,}$", author):
|
||||
flags.append(f"auto_generated_username: {author}")
|
||||
score -= 0.15
|
||||
|
||||
# Very controversial ratio (lots of up AND down votes)
|
||||
if upvote_ratio < 0.4 and post_score > 0:
|
||||
flags.append(f"highly_controversial: {upvote_ratio:.0%} upvote ratio")
|
||||
score -= 0.1
|
||||
|
||||
# High score but zero comments = potential vote manipulation
|
||||
if post_score > 100 and num_comments == 0:
|
||||
flags.append(f"high_score_no_comments: {post_score} score, 0 comments")
|
||||
score -= 0.2
|
||||
|
||||
# Low-effort cross-post spam: very short title, external link, no selftext
|
||||
if (
|
||||
len(post.get("title", "")) < 20
|
||||
and not post.get("is_self", True)
|
||||
and not post.get("selftext")
|
||||
):
|
||||
flags.append("possible_link_spam")
|
||||
score -= 0.1
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_reddit_comment(comment: dict) -> CredibilityResult:
|
||||
"""Score a Reddit comment for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
body = comment.get("body", "")
|
||||
author = comment.get("author", "")
|
||||
comment_score = comment.get("score", 0)
|
||||
|
||||
text_flags = _check_text_patterns(body)
|
||||
if text_flags:
|
||||
score -= 0.15 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
if author in ("[deleted]", "[removed]"):
|
||||
flags.append("deleted_author")
|
||||
score -= 0.2
|
||||
|
||||
if re.match(r"^[A-Za-z]+[-_]?\d{4,}$", author):
|
||||
flags.append(f"auto_generated_username: {author}")
|
||||
score -= 0.15
|
||||
|
||||
# Heavily downvoted
|
||||
if comment_score < -5:
|
||||
flags.append(f"heavily_downvoted: {comment_score}")
|
||||
score -= 0.15
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_hackernews_post(post: dict) -> CredibilityResult:
|
||||
"""Score a HN story for credibility.
|
||||
|
||||
HN is generally higher-signal than social media, but we still check
|
||||
for low-effort submissions and spammy patterns.
|
||||
"""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
title = post.get("title", "")
|
||||
text = post.get("story_text", "") or title
|
||||
points = post.get("points", 0)
|
||||
num_comments = post.get("num_comments", 0)
|
||||
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.1 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# Zero points = the community didn't find it valuable
|
||||
if points == 0:
|
||||
flags.append("zero_points")
|
||||
score -= 0.1
|
||||
|
||||
# HN is generally more credible, start with a bonus
|
||||
score = min(1.0, score + 0.1)
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
def score_hackernews_comment(comment: dict) -> CredibilityResult:
|
||||
"""Score a HN comment for credibility."""
|
||||
score = 1.0
|
||||
flags: list[str] = []
|
||||
|
||||
text = comment.get("comment_text", "")
|
||||
|
||||
text_flags = _check_text_patterns(text)
|
||||
if text_flags:
|
||||
score -= 0.1 * len(text_flags)
|
||||
flags.extend(text_flags)
|
||||
|
||||
# HN comments are generally higher quality
|
||||
score = min(1.0, score + 0.1)
|
||||
|
||||
return CredibilityResult(score=max(0.0, min(1.0, score)), flags=flags)
|
||||
|
||||
|
||||
# --- Batch-level coordination detection ---
|
||||
|
||||
|
||||
def detect_coordination(posts: list[dict], text_key: str = "text") -> list[str]:
|
||||
"""Detect coordinated inauthentic behavior across a batch of posts.
|
||||
|
||||
Looks for:
|
||||
- Duplicate or near-duplicate text (copy-paste campaigns)
|
||||
- Burst posting (many posts in a very short window)
|
||||
- Same talking points with minor variations
|
||||
|
||||
Returns a list of warning strings.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
texts = [p.get(text_key, "") for p in posts if p.get(text_key)]
|
||||
|
||||
if not texts:
|
||||
return warnings
|
||||
|
||||
# Exact duplicates
|
||||
seen: dict[str, int] = {}
|
||||
for t in texts:
|
||||
normalized = t.strip().lower()
|
||||
seen[normalized] = seen.get(normalized, 0) + 1
|
||||
|
||||
duplicates = {text: count for text, count in seen.items() if count > 1}
|
||||
if duplicates:
|
||||
total_dupes = sum(duplicates.values())
|
||||
warnings.append(
|
||||
f"COORDINATION WARNING: {len(duplicates)} duplicate texts found "
|
||||
f"({total_dupes} total copies). Possible copy-paste campaign."
|
||||
)
|
||||
|
||||
# Near-duplicates: check if many posts share a long common substring
|
||||
# (simplified: check if >30% of posts start with the same 50+ chars)
|
||||
if len(texts) >= 5:
|
||||
prefixes: dict[str, int] = {}
|
||||
for t in texts:
|
||||
prefix = t.strip().lower()[:80]
|
||||
if len(prefix) >= 50:
|
||||
prefixes[prefix] = prefixes.get(prefix, 0) + 1
|
||||
|
||||
for prefix, count in prefixes.items():
|
||||
if count >= len(texts) * 0.3:
|
||||
warnings.append(
|
||||
f"COORDINATION WARNING: {count}/{len(texts)} posts share "
|
||||
f"a common prefix ({prefix[:50]}...). Possible template campaign."
|
||||
)
|
||||
|
||||
# Burst detection: if timestamps are available
|
||||
timestamps = []
|
||||
for p in posts:
|
||||
created = p.get("created_at") or p.get("created_utc")
|
||||
if isinstance(created, str):
|
||||
try:
|
||||
timestamps.append(datetime.fromisoformat(created.replace("Z", "+00:00")))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif isinstance(created, (int, float)):
|
||||
timestamps.append(datetime.fromtimestamp(created, tz=timezone.utc))
|
||||
|
||||
if len(timestamps) >= 5:
|
||||
timestamps.sort()
|
||||
# Check if >50% of posts landed within a 5-minute window
|
||||
window_seconds = 300
|
||||
for i in range(len(timestamps) - 2):
|
||||
window_end = timestamps[i] + __import__("datetime").timedelta(seconds=window_seconds)
|
||||
in_window = sum(1 for t in timestamps if timestamps[i] <= t <= window_end)
|
||||
if in_window >= len(timestamps) * 0.5:
|
||||
warnings.append(
|
||||
f"COORDINATION WARNING: {in_window}/{len(timestamps)} posts "
|
||||
f"appeared within a 5-minute window. Possible coordinated posting."
|
||||
)
|
||||
break
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def filter_and_annotate(
|
||||
posts: list[dict],
|
||||
scorer,
|
||||
min_score: float = 0.3,
|
||||
flag_threshold: float = 0.5,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Score all posts, filter out low-credibility ones, and annotate the rest.
|
||||
|
||||
Args:
|
||||
posts: List of post dicts from any platform.
|
||||
scorer: A scoring function (e.g., score_reddit_post).
|
||||
min_score: Posts below this are excluded.
|
||||
flag_threshold: Posts between min_score and this are flagged.
|
||||
|
||||
Returns:
|
||||
Tuple of (filtered_posts, stats_dict).
|
||||
Each post in filtered_posts gets a "_credibility" key added.
|
||||
"""
|
||||
filtered = []
|
||||
stats = {
|
||||
"total": len(posts),
|
||||
"excluded": 0,
|
||||
"flagged": 0,
|
||||
"authentic": 0,
|
||||
"excluded_reasons": [],
|
||||
}
|
||||
|
||||
for post in posts:
|
||||
result = scorer(post)
|
||||
result.is_excluded = result.score < min_score
|
||||
result.is_flagged = min_score <= result.score < flag_threshold
|
||||
|
||||
if result.is_excluded:
|
||||
stats["excluded"] += 1
|
||||
stats["excluded_reasons"].append(
|
||||
{"score": round(result.score, 2), "flags": result.flags}
|
||||
)
|
||||
continue
|
||||
|
||||
post["_credibility"] = {
|
||||
"score": round(result.score, 2),
|
||||
"label": result.label,
|
||||
"flags": result.flags,
|
||||
"is_flagged": result.is_flagged,
|
||||
}
|
||||
|
||||
if result.is_flagged:
|
||||
stats["flagged"] += 1
|
||||
else:
|
||||
stats["authentic"] += 1
|
||||
|
||||
filtered.append(post)
|
||||
|
||||
return filtered, stats
|
||||
66
agentstuff/sentiment_agent/main.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""CLI entry point for the sentiment analysis agent."""
|
||||
|
||||
import argparse
|
||||
import anyio
|
||||
|
||||
from sentiment_agent.agent import run_sentiment_analysis
|
||||
from sentiment_agent.config import SafetyConfig
|
||||
|
||||
|
||||
async def async_main(args: argparse.Namespace) -> None:
|
||||
config = SafetyConfig(
|
||||
max_turns=args.max_turns,
|
||||
max_budget_usd=args.max_budget,
|
||||
max_total_api_calls=args.max_api_calls,
|
||||
min_credibility_score=args.min_credibility,
|
||||
flag_bot_threshold=args.flag_threshold,
|
||||
)
|
||||
|
||||
result = await run_sentiment_analysis(
|
||||
topic=args.topic,
|
||||
sources=args.sources,
|
||||
config=config,
|
||||
)
|
||||
print("\n" + "=" * 60)
|
||||
print("SENTIMENT ANALYSIS REPORT")
|
||||
print("=" * 60)
|
||||
print(result)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run sentiment analysis on a topic with bot/disinfo detection",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument("topic", help="The topic to analyze sentiment for")
|
||||
parser.add_argument(
|
||||
"--sources", nargs="*", help="Specific URLs or sources to also analyze"
|
||||
)
|
||||
|
||||
safety = parser.add_argument_group("safety limits")
|
||||
safety.add_argument(
|
||||
"--max-turns", type=int, default=20, help="Max agent turns"
|
||||
)
|
||||
safety.add_argument(
|
||||
"--max-budget", type=float, default=0.50, help="Max Claude API spend (USD)"
|
||||
)
|
||||
safety.add_argument(
|
||||
"--max-api-calls", type=int, default=50, help="Max total API calls across all platforms"
|
||||
)
|
||||
|
||||
credibility = parser.add_argument_group("credibility filtering")
|
||||
credibility.add_argument(
|
||||
"--min-credibility", type=float, default=0.3,
|
||||
help="Posts below this score are excluded (0.0-1.0)",
|
||||
)
|
||||
credibility.add_argument(
|
||||
"--flag-threshold", type=float, default=0.5,
|
||||
help="Posts between min and this are flagged but included (0.0-1.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
anyio.run(async_main, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
agentstuff/sentiment_agent/ratelimit.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Rate limiter and API call budget tracker.
|
||||
|
||||
Enforces per-platform rate limits and a global call budget so the agent
|
||||
can't hammer APIs or run up unbounded costs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sentiment_agent.config import RateLimitConfig
|
||||
|
||||
|
||||
class BudgetExhaustedError(Exception):
|
||||
"""Raised when the global API call budget is spent."""
|
||||
|
||||
|
||||
class RateLimitExceededError(Exception):
|
||||
"""Raised when a platform's rate limit is hit and cooldown hasn't elapsed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PlatformState:
|
||||
"""Tracks call timestamps and active request count for one platform."""
|
||||
|
||||
config: RateLimitConfig
|
||||
call_timestamps: list[float] = field(default_factory=list)
|
||||
active_requests: int = 0
|
||||
last_429_at: float = 0.0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Manages rate limiting across all platforms + a global call budget.
|
||||
|
||||
Usage:
|
||||
limiter = RateLimiter(max_total_calls=50)
|
||||
limiter.register_platform("reddit", RateLimitConfig(...))
|
||||
|
||||
async with limiter.acquire("reddit"):
|
||||
await do_reddit_call()
|
||||
"""
|
||||
|
||||
def __init__(self, max_total_calls: int = 50):
|
||||
self._max_total = max_total_calls
|
||||
self._total_calls = 0
|
||||
self._platforms: dict[str, _PlatformState] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def total_calls(self) -> int:
|
||||
return self._total_calls
|
||||
|
||||
@property
|
||||
def remaining_calls(self) -> int:
|
||||
return max(0, self._max_total - self._total_calls)
|
||||
|
||||
def register_platform(self, name: str, config: RateLimitConfig) -> None:
|
||||
self._platforms[name] = _PlatformState(config=config)
|
||||
|
||||
def acquire(self, platform: str) -> _AcquireContext:
|
||||
"""Context manager that enforces rate limits before allowing a call."""
|
||||
return _AcquireContext(self, platform)
|
||||
|
||||
async def _acquire(self, platform: str) -> None:
|
||||
async with self._lock:
|
||||
if self._total_calls >= self._max_total:
|
||||
raise BudgetExhaustedError(
|
||||
f"Global API call budget exhausted ({self._max_total} calls). "
|
||||
"Increase max_total_api_calls in SafetyConfig to allow more."
|
||||
)
|
||||
|
||||
state = self._platforms.get(platform)
|
||||
if not state:
|
||||
raise ValueError(f"Platform '{platform}' not registered with rate limiter")
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
# Check 429 cooldown
|
||||
if state.last_429_at:
|
||||
elapsed = now - state.last_429_at
|
||||
if elapsed < state.config.cooldown_after_429:
|
||||
remaining = state.config.cooldown_after_429 - elapsed
|
||||
raise RateLimitExceededError(
|
||||
f"Platform '{platform}' is in cooldown after 429. "
|
||||
f"Try again in {remaining:.0f}s."
|
||||
)
|
||||
state.last_429_at = 0.0
|
||||
|
||||
# Check burst limit
|
||||
if state.active_requests >= state.config.burst_size:
|
||||
raise RateLimitExceededError(
|
||||
f"Platform '{platform}' burst limit reached "
|
||||
f"({state.config.burst_size} concurrent). Wait for a request to finish."
|
||||
)
|
||||
|
||||
# Check RPM: discard timestamps older than 60s, then check count
|
||||
cutoff = now - 60.0
|
||||
state.call_timestamps = [t for t in state.call_timestamps if t > cutoff]
|
||||
|
||||
if len(state.call_timestamps) >= state.config.requests_per_minute:
|
||||
oldest = state.call_timestamps[0]
|
||||
wait_time = 60.0 - (now - oldest)
|
||||
raise RateLimitExceededError(
|
||||
f"Platform '{platform}' rate limit: {state.config.requests_per_minute}/min. "
|
||||
f"Try again in {wait_time:.0f}s."
|
||||
)
|
||||
|
||||
# All clear — record the call
|
||||
state.call_timestamps.append(now)
|
||||
state.active_requests += 1
|
||||
self._total_calls += 1
|
||||
|
||||
async def _release(self, platform: str) -> None:
|
||||
async with self._lock:
|
||||
state = self._platforms.get(platform)
|
||||
if state:
|
||||
state.active_requests = max(0, state.active_requests - 1)
|
||||
|
||||
def record_429(self, platform: str) -> None:
|
||||
"""Call this when an API returns 429 to trigger cooldown."""
|
||||
state = self._platforms.get(platform)
|
||||
if state:
|
||||
state.last_429_at = time.monotonic()
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return current usage stats for logging/reporting."""
|
||||
stats: dict = {
|
||||
"total_calls": self._total_calls,
|
||||
"remaining_calls": self.remaining_calls,
|
||||
"platforms": {},
|
||||
}
|
||||
for name, state in self._platforms.items():
|
||||
now = time.monotonic()
|
||||
cutoff = now - 60.0
|
||||
recent = [t for t in state.call_timestamps if t > cutoff]
|
||||
stats["platforms"][name] = {
|
||||
"calls_last_60s": len(recent),
|
||||
"active_requests": state.active_requests,
|
||||
"rpm_limit": state.config.requests_per_minute,
|
||||
"in_cooldown": bool(
|
||||
state.last_429_at
|
||||
and (now - state.last_429_at) < state.config.cooldown_after_429
|
||||
),
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
class _AcquireContext:
|
||||
"""Async context manager for rate-limited API calls."""
|
||||
|
||||
def __init__(self, limiter: RateLimiter, platform: str):
|
||||
self._limiter = limiter
|
||||
self._platform = platform
|
||||
|
||||
async def __aenter__(self) -> None:
|
||||
await self._limiter._acquire(self._platform)
|
||||
|
||||
async def __aexit__(self, *exc_info) -> None:
|
||||
# Check if the call got a 429
|
||||
if exc_info[0] is not None:
|
||||
import httpx
|
||||
|
||||
exc = exc_info[1]
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
|
||||
self._limiter.record_429(self._platform)
|
||||
|
||||
await self._limiter._release(self._platform)
|
||||
352
agentstuff/sentiment_agent/tools.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Custom MCP tools for social media and forum data gathering.
|
||||
|
||||
Each tool wraps an API client, enforces rate limits, runs credibility
|
||||
scoring, and returns MCP-formatted results with bot/disinfo annotations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from claude_agent_sdk import tool, create_sdk_mcp_server
|
||||
|
||||
from sentiment_agent.clients import bluesky, reddit, hackernews
|
||||
from sentiment_agent.config import SafetyConfig
|
||||
from sentiment_agent.credibility import (
|
||||
detect_coordination,
|
||||
filter_and_annotate,
|
||||
score_bluesky_post,
|
||||
score_hackernews_comment,
|
||||
score_hackernews_post,
|
||||
score_reddit_comment,
|
||||
score_reddit_post,
|
||||
)
|
||||
from sentiment_agent.ratelimit import BudgetExhaustedError, RateLimiter
|
||||
|
||||
# Module-level state — initialized by create_social_tools_server()
|
||||
_limiter: RateLimiter | None = None
|
||||
_config: SafetyConfig | None = None
|
||||
|
||||
|
||||
def _get_limiter() -> RateLimiter:
|
||||
if _limiter is None:
|
||||
raise RuntimeError("Tools not initialized — call create_social_tools_server() first")
|
||||
return _limiter
|
||||
|
||||
|
||||
def _get_config() -> SafetyConfig:
|
||||
if _config is None:
|
||||
return SafetyConfig()
|
||||
return _config
|
||||
|
||||
|
||||
def _text_result(text: str) -> dict:
|
||||
return {"content": [{"type": "text", "text": text}]}
|
||||
|
||||
|
||||
def _error_result(error: str) -> dict:
|
||||
return {"content": [{"type": "text", "text": f"Error: {error}"}], "isError": True}
|
||||
|
||||
|
||||
def _clamp_limit(requested: int) -> int:
|
||||
"""Enforce max results per call."""
|
||||
return min(requested, _get_config().max_results_per_call)
|
||||
|
||||
|
||||
def _format_with_stats(
|
||||
posts: list[dict],
|
||||
stats: dict,
|
||||
coordination_warnings: list[str],
|
||||
platform: str,
|
||||
) -> str:
|
||||
"""Format results with credibility stats prepended."""
|
||||
header_parts = [
|
||||
f"Platform: {platform}",
|
||||
f"Results: {stats['authentic']} authentic, {stats['flagged']} flagged, "
|
||||
f"{stats['excluded']} excluded (of {stats['total']} total)",
|
||||
]
|
||||
if coordination_warnings:
|
||||
header_parts.append("--- COORDINATION ALERTS ---")
|
||||
header_parts.extend(coordination_warnings)
|
||||
header_parts.append("---")
|
||||
|
||||
limiter = _get_limiter()
|
||||
header_parts.append(f"API budget remaining: {limiter.remaining_calls} calls")
|
||||
|
||||
header = "\n".join(header_parts)
|
||||
body = json.dumps(posts, indent=2, default=str)
|
||||
return f"{header}\n\n{body}"
|
||||
|
||||
|
||||
# --- Bluesky tools ---
|
||||
|
||||
|
||||
@tool(
|
||||
"search_bluesky",
|
||||
"Search Bluesky for posts about a topic. Returns posts with text, author, "
|
||||
"engagement metrics, credibility scores, and bot/disinfo flags. "
|
||||
"Requires BLUESKY_HANDLE and BLUESKY_APP_PASSWORD env vars.",
|
||||
{"query": str, "limit": int, "sort": str},
|
||||
)
|
||||
async def search_bluesky(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("bluesky"):
|
||||
posts = await bluesky.search_posts(
|
||||
query=args["query"],
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
sort=args.get("sort", "top"),
|
||||
)
|
||||
|
||||
if not posts:
|
||||
return _text_result(f"No Bluesky posts found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(posts, text_key="text")
|
||||
filtered, stats = filter_and_annotate(
|
||||
posts, score_bluesky_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Bluesky"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Bluesky search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@tool(
|
||||
"get_bluesky_thread",
|
||||
"Fetch a Bluesky thread/post and its replies with credibility scoring. "
|
||||
"Accepts an at:// URI or https://bsky.app/... URL.",
|
||||
{"uri": str, "depth": int},
|
||||
)
|
||||
async def get_bluesky_thread(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("bluesky"):
|
||||
thread = await bluesky.get_thread(
|
||||
uri=args["uri"],
|
||||
depth=args.get("depth", 6),
|
||||
)
|
||||
|
||||
# Score replies
|
||||
if thread.get("replies"):
|
||||
coordination = detect_coordination(thread["replies"], text_key="text")
|
||||
filtered_replies, stats = filter_and_annotate(
|
||||
thread["replies"], score_bluesky_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
thread["replies"] = filtered_replies
|
||||
thread["_reply_credibility_stats"] = stats
|
||||
thread["_coordination_warnings"] = coordination
|
||||
|
||||
# Score root post
|
||||
if thread.get("post"):
|
||||
result = score_bluesky_post(thread["post"])
|
||||
thread["post"]["_credibility"] = {
|
||||
"score": round(result.score, 2),
|
||||
"label": result.label,
|
||||
"flags": result.flags,
|
||||
}
|
||||
|
||||
return _text_result(json.dumps(thread, indent=2, default=str))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Bluesky thread fetch failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
# --- Reddit tools ---
|
||||
|
||||
|
||||
@tool(
|
||||
"search_reddit",
|
||||
"Search Reddit for posts about a topic. Returns posts with credibility scores "
|
||||
"and bot/disinfo flags. Posts below the credibility threshold are auto-excluded. "
|
||||
"Use subreddit='all' for site-wide or specify a subreddit name.",
|
||||
{"query": str, "subreddit": str, "sort": str, "time_filter": str, "limit": int},
|
||||
)
|
||||
async def search_reddit_tool(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("reddit"):
|
||||
posts = await reddit.search_posts(
|
||||
query=args["query"],
|
||||
subreddit=args.get("subreddit", "all"),
|
||||
sort=args.get("sort", "relevance"),
|
||||
time_filter=args.get("time_filter", "month"),
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not posts:
|
||||
return _text_result(f"No Reddit posts found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(posts, text_key="title")
|
||||
filtered, stats = filter_and_annotate(
|
||||
posts, score_reddit_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Reddit"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Reddit search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@tool(
|
||||
"get_reddit_comments",
|
||||
"Fetch comments for a Reddit post with credibility scoring. "
|
||||
"Pass the permalink path or full URL.",
|
||||
{"permalink": str, "sort": str, "limit": int},
|
||||
)
|
||||
async def get_reddit_comments(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("reddit"):
|
||||
comments = await reddit.get_post_comments(
|
||||
permalink=args["permalink"],
|
||||
sort=args.get("sort", "top"),
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not comments:
|
||||
return _text_result("No comments found for this post.")
|
||||
|
||||
coordination = detect_coordination(comments, text_key="body")
|
||||
filtered, stats = filter_and_annotate(
|
||||
comments, score_reddit_comment,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Reddit Comments"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"Reddit comments fetch failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
# --- Hacker News tools ---
|
||||
|
||||
|
||||
@tool(
|
||||
"search_hackernews",
|
||||
"Search Hacker News for stories with credibility scoring. "
|
||||
"No authentication required.",
|
||||
{"query": str, "limit": int},
|
||||
)
|
||||
async def search_hackernews_tool(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("hackernews"):
|
||||
stories = await hackernews.search_stories(
|
||||
query=args["query"],
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not stories:
|
||||
return _text_result(f"No HN stories found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(stories, text_key="title")
|
||||
filtered, stats = filter_and_annotate(
|
||||
stories, score_hackernews_post,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(_format_with_stats(filtered, stats, coordination, "Hacker News"))
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"HN search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
@tool(
|
||||
"search_hackernews_comments",
|
||||
"Search Hacker News comments for opinions and discussions with credibility scoring.",
|
||||
{"query": str, "limit": int},
|
||||
)
|
||||
async def search_hackernews_comments(args: dict) -> dict:
|
||||
try:
|
||||
limiter = _get_limiter()
|
||||
config = _get_config()
|
||||
|
||||
async with limiter.acquire("hackernews"):
|
||||
comments = await hackernews.search_comments(
|
||||
query=args["query"],
|
||||
limit=_clamp_limit(args.get("limit", 25)),
|
||||
)
|
||||
|
||||
if not comments:
|
||||
return _text_result(f"No HN comments found for: {args['query']}")
|
||||
|
||||
coordination = detect_coordination(comments, text_key="comment_text")
|
||||
filtered, stats = filter_and_annotate(
|
||||
comments, score_hackernews_comment,
|
||||
min_score=config.min_credibility_score,
|
||||
flag_threshold=config.flag_bot_threshold,
|
||||
)
|
||||
return _text_result(
|
||||
_format_with_stats(filtered, stats, coordination, "HN Comments")
|
||||
)
|
||||
except BudgetExhaustedError as e:
|
||||
return _error_result(str(e))
|
||||
except Exception as e:
|
||||
return _error_result(f"HN comment search failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
# --- Budget status tool ---
|
||||
|
||||
|
||||
@tool(
|
||||
"get_api_budget_status",
|
||||
"Check remaining API call budget, rate limit status, and per-platform stats. "
|
||||
"Use this before making more API calls to avoid hitting limits.",
|
||||
{},
|
||||
)
|
||||
async def get_api_budget_status(args: dict) -> dict:
|
||||
limiter = _get_limiter()
|
||||
stats = limiter.get_stats()
|
||||
return _text_result(json.dumps(stats, indent=2, default=str))
|
||||
|
||||
|
||||
# --- Server factory ---
|
||||
|
||||
|
||||
def create_social_tools_server(config: SafetyConfig | None = None):
|
||||
"""Create an MCP server with all social media/forum tools.
|
||||
|
||||
Initializes rate limiting and credibility thresholds from config.
|
||||
"""
|
||||
global _limiter, _config
|
||||
|
||||
_config = config or SafetyConfig.from_env()
|
||||
|
||||
_limiter = RateLimiter(max_total_calls=_config.max_total_api_calls)
|
||||
_limiter.register_platform("bluesky", _config.bluesky_rate)
|
||||
_limiter.register_platform("reddit", _config.reddit_rate)
|
||||
_limiter.register_platform("hackernews", _config.hackernews_rate)
|
||||
|
||||
return create_sdk_mcp_server(
|
||||
"social-tools",
|
||||
tools=[
|
||||
search_bluesky,
|
||||
get_bluesky_thread,
|
||||
search_reddit_tool,
|
||||
get_reddit_comments,
|
||||
search_hackernews_tool,
|
||||
search_hackernews_comments,
|
||||
get_api_budget_status,
|
||||
],
|
||||
)
|
||||
23
aur-api/.SRCINFO
Normal file
@@ -0,0 +1,23 @@
|
||||
pkgbase = stegasoo-api-git
|
||||
pkgdesc = Stegasoo REST API with TLS and API key authentication
|
||||
pkgver = 4.2.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/adlee-was-taken/stegasoo
|
||||
install = stegasoo-api-git.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = git
|
||||
makedepends = python
|
||||
makedepends = python-build
|
||||
makedepends = python-hatchling
|
||||
depends = python>=3.11
|
||||
depends = zbar
|
||||
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||
provides = stegasoo-api
|
||||
conflicts = stegasoo-api
|
||||
conflicts = stegasoo
|
||||
conflicts = stegasoo-git
|
||||
source = stegasoo-api-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = stegasoo-api-git
|
||||
109
aur-api/PKGBUILD
Normal file
@@ -0,0 +1,109 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-api-git
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
'zbar' # QR code reading for RSA key extraction
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
optdepends=(
|
||||
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||
)
|
||||
provides=('stegasoo-api')
|
||||
conflicts=('stegasoo-api' 'stegasoo' 'stegasoo-git')
|
||||
install=stegasoo-api-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Detect Python version for site-packages path
|
||||
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
# Install to /opt/stegasoo-api with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo-api"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo-api/venv"
|
||||
|
||||
# Install the wheel with API + CLI + compression extras
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo-api/venv/bin/pip" install --no-cache-dir "${wheel}[api,cli,compression]"
|
||||
|
||||
# Install API frontend (not included in wheel by default)
|
||||
local site_packages="$pkgdir/opt/stegasoo-api/venv/lib/python${pyver}/site-packages"
|
||||
install -dm755 "$site_packages/frontends/api"
|
||||
cp -r frontends/api/*.py "$site_packages/frontends/api/"
|
||||
cp -r frontends/__init__.py "$site_packages/frontends/" 2>/dev/null || true
|
||||
|
||||
# Create temp directory for API
|
||||
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||
|
||||
# Create config directories
|
||||
install -dm755 "$pkgdir/opt/stegasoo-api/config"
|
||||
install -dm700 "$pkgdir/opt/stegasoo-api/certs"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo-api/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo-api/venv|/opt/stegasoo-api/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-api/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlink to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo-api/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Install systemd service
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API (HTTPS)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo-api/venv/lib/python${pyver}/site-packages/frontends/api
|
||||
Environment="PATH=/opt/stegasoo-api/venv/bin"
|
||||
Environment="HOME=/opt/stegasoo-api"
|
||||
# TLS enabled by default - certs auto-generated on first run
|
||||
# Use: stegasoo api tls generate (to pre-generate certs)
|
||||
# Use: stegasoo api keys create <name> (to create API keys)
|
||||
ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
102
aur-api/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Stegasoo API AUR Package
|
||||
|
||||
REST API server package for programmatic steganography operations. Includes HTTPS support and API key authentication.
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-api-git
|
||||
# or
|
||||
paru -S stegasoo-api-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-api-git.git
|
||||
cd stegasoo-api-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo-api/venv/` - Self-contained Python venv with API dependencies
|
||||
- `/opt/stegasoo-api/config/` - API key storage
|
||||
- `/opt/stegasoo-api/certs/` - TLS certificates
|
||||
- `/usr/bin/stegasoo` - CLI executable
|
||||
- `/usr/lib/systemd/system/stegasoo-api.service` - Systemd service
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Create an API key
|
||||
sudo -u stegasoo stegasoo api keys create mykey
|
||||
|
||||
# 2. Start the service
|
||||
sudo systemctl enable --now stegasoo-api
|
||||
|
||||
# 3. Test the API
|
||||
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
|
||||
```
|
||||
|
||||
## Service Details
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Port | 8000 |
|
||||
| Protocol | HTTPS (self-signed cert auto-generated) |
|
||||
| API Docs | https://localhost:8000/docs |
|
||||
| OpenAPI | https://localhost:8000/openapi.json |
|
||||
|
||||
## API Key Management
|
||||
|
||||
```bash
|
||||
# List all keys
|
||||
stegasoo api keys list
|
||||
|
||||
# Create a new key
|
||||
sudo -u stegasoo stegasoo api keys create <name>
|
||||
|
||||
# Revoke a key
|
||||
sudo -u stegasoo stegasoo api keys revoke <name>
|
||||
```
|
||||
|
||||
## TLS Configuration
|
||||
|
||||
```bash
|
||||
# View current certificate info
|
||||
stegasoo api tls info
|
||||
|
||||
# Generate new self-signed certificate
|
||||
sudo -u stegasoo stegasoo api tls generate
|
||||
|
||||
# Use custom certificates (edit service)
|
||||
sudo systemctl edit stegasoo-api
|
||||
# Add:
|
||||
# [Service]
|
||||
# ExecStart=
|
||||
# ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve \
|
||||
# --host 0.0.0.0 --port 8000 \
|
||||
# --cert /path/to/cert.pem --key /path/to/key.pem
|
||||
```
|
||||
|
||||
## Manual Run (without systemd)
|
||||
|
||||
```bash
|
||||
# Development mode (auto-reload)
|
||||
/opt/stegasoo-api/venv/bin/stegasoo api serve --reload
|
||||
|
||||
# Production mode
|
||||
/opt/stegasoo-api/venv/bin/stegasoo api serve --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## For Web UI
|
||||
|
||||
Install the full package instead:
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
```
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
63
aur-api/stegasoo-api-git.install
Normal file
@@ -0,0 +1,63 @@
|
||||
post_install() {
|
||||
# Create stegasoo system user if it doesn't exist
|
||||
if ! getent passwd stegasoo >/dev/null; then
|
||||
useradd -r -s /usr/bin/nologin -d /opt/stegasoo-api stegasoo
|
||||
echo "Created system user 'stegasoo'"
|
||||
fi
|
||||
|
||||
# Set ownership of directories
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo-api/config 2>/dev/null || true
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo-api/certs 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " Stegasoo API installed successfully!"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Quick Start"
|
||||
echo "-----------------------------------------------"
|
||||
echo " 1. Create an API key:"
|
||||
echo " sudo -u stegasoo stegasoo api keys create mykey"
|
||||
echo ""
|
||||
echo " 2. Start the API server:"
|
||||
echo " sudo systemctl start stegasoo-api"
|
||||
echo " sudo systemctl enable stegasoo-api # auto-start"
|
||||
echo ""
|
||||
echo " 3. Access the API:"
|
||||
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Service Details"
|
||||
echo "-----------------------------------------------"
|
||||
echo " Port: 8000 (HTTPS by default)"
|
||||
echo " Docs: https://localhost:8000/docs"
|
||||
echo " Status: sudo systemctl status stegasoo-api"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Management Commands"
|
||||
echo "-----------------------------------------------"
|
||||
echo " stegasoo api keys list # List API keys"
|
||||
echo " stegasoo api keys create X # Create new key"
|
||||
echo " stegasoo api tls generate # Generate TLS certs"
|
||||
echo " stegasoo api tls info # Show certificate info"
|
||||
echo " stegasoo api serve --help # Server options"
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
|
||||
pre_remove() {
|
||||
# Stop service if running
|
||||
systemctl stop stegasoo-api 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
echo "Stegasoo API removed."
|
||||
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
|
||||
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
|
||||
}
|
||||
22
aur-api/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Test build the AUR API package locally
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cleaning previous builds ==="
|
||||
rm -rf stegasoo-api-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||
|
||||
echo "=== Generating .SRCINFO ==="
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
echo "=== Building package ==="
|
||||
makepkg -sf
|
||||
|
||||
echo "=== Package built ==="
|
||||
ls -la *.pkg.tar.zst
|
||||
|
||||
echo ""
|
||||
echo "To install: sudo pacman -U stegasoo-api-git-*.pkg.tar.zst"
|
||||
echo "To test: makepkg -si"
|
||||
22
aur-cli/.SRCINFO
Normal file
@@ -0,0 +1,22 @@
|
||||
pkgbase = stegasoo-cli-git
|
||||
pkgdesc = Secure steganography CLI with hybrid photo + passphrase + PIN authentication
|
||||
pkgver = 4.2.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/adlee-was-taken/stegasoo
|
||||
install = stegasoo-cli-git.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = git
|
||||
makedepends = python
|
||||
makedepends = python-build
|
||||
makedepends = python-hatchling
|
||||
depends = python>=3.11
|
||||
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||
provides = stegasoo-cli
|
||||
conflicts = stegasoo-cli
|
||||
conflicts = stegasoo
|
||||
conflicts = stegasoo-git
|
||||
source = stegasoo-cli-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = stegasoo-cli-git
|
||||
69
aur-cli/PKGBUILD
Normal file
@@ -0,0 +1,69 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-cli-git
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography CLI with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
optdepends=(
|
||||
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||
)
|
||||
provides=('stegasoo-cli')
|
||||
conflicts=('stegasoo-cli' 'stegasoo' 'stegasoo-git')
|
||||
install=stegasoo-cli-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Install to /opt/stegasoo-cli with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo-cli"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo-cli/venv"
|
||||
|
||||
# Install the wheel with CLI + DCT + compression extras (no web/api)
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo-cli/venv/bin/pip" install --no-cache-dir "${wheel}[cli,dct,compression]"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo-cli/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo-cli/venv|/opt/stegasoo-cli/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-cli/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlink to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo-cli/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
62
aur-cli/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Stegasoo CLI AUR Package
|
||||
|
||||
Lightweight CLI-only package for steganography operations. No web UI or API server.
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-cli-git
|
||||
# or
|
||||
paru -S stegasoo-cli-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-cli-git.git
|
||||
cd stegasoo-cli-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo-cli/venv/` - Self-contained Python venv with CLI dependencies only
|
||||
- `/usr/bin/stegasoo` - CLI executable
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Show all commands
|
||||
stegasoo --help
|
||||
|
||||
# Generate credentials (passphrase + PIN)
|
||||
stegasoo generate
|
||||
stegasoo generate --words 5 --pin-length 8
|
||||
|
||||
# Generate with RSA keys and QR codes
|
||||
stegasoo generate --rsa --qr-ascii
|
||||
|
||||
# Encode a message
|
||||
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret message" \
|
||||
-P "word1 word2 word3 word4" -p 123456
|
||||
|
||||
# Decode a message
|
||||
stegasoo decode -i encoded.png -r reference.jpg \
|
||||
-P "word1 word2 word3 word4" -p 123456
|
||||
|
||||
# Image tools
|
||||
stegasoo tools --help
|
||||
stegasoo tools compress image.png
|
||||
stegasoo tools rotate image.jpg 90
|
||||
```
|
||||
|
||||
## For Web UI or REST API
|
||||
|
||||
Install the full package instead:
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
```
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
20
aur-cli/stegasoo-cli-git.install
Normal file
@@ -0,0 +1,20 @@
|
||||
post_install() {
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " Stegasoo CLI installed successfully!"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " stegasoo --help # Show all commands"
|
||||
echo " stegasoo generate # Generate passphrase + PIN"
|
||||
echo " stegasoo encode ... # Hide data in an image"
|
||||
echo " stegasoo decode ... # Extract hidden data"
|
||||
echo " stegasoo tools --help # Image tools (compress, etc.)"
|
||||
echo ""
|
||||
echo "For web UI or REST API, install stegasoo-git instead."
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
22
aur-cli/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Test build the AUR CLI package locally
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cleaning previous builds ==="
|
||||
rm -rf stegasoo-cli-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||
|
||||
echo "=== Generating .SRCINFO ==="
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
echo "=== Building package ==="
|
||||
makepkg -sf
|
||||
|
||||
echo "=== Package built ==="
|
||||
ls -la *.pkg.tar.zst
|
||||
|
||||
echo ""
|
||||
echo "To install: sudo pacman -U stegasoo-cli-git-*.pkg.tar.zst"
|
||||
echo "To test: makepkg -si"
|
||||
120
aur/PKGBUILD
Normal file
@@ -0,0 +1,120 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-git
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
'zbar' # QR code reading for Web UI
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
provides=('stegasoo')
|
||||
conflicts=('stegasoo')
|
||||
install=stegasoo-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.3.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Detect Python version for site-packages path
|
||||
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
# Install to /opt/stegasoo with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo/venv"
|
||||
|
||||
# Install the wheel with all extras
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
|
||||
|
||||
# Install frontends (not included in wheel)
|
||||
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
|
||||
cp -r frontends "$site_packages/"
|
||||
|
||||
# Create writable directories for stegasoo user
|
||||
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
|
||||
install -dm755 "$site_packages/frontends/web/temp_files"
|
||||
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlinks to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Install systemd service files
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API (HTTPS)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
Environment="HOME=/opt/stegasoo"
|
||||
# TLS enabled by default - certs auto-generated on first run
|
||||
# Use stegasoo api tls generate to pre-generate certs
|
||||
# Use stegasoo api keys create <name> to create API keys
|
||||
ExecStart=/opt/stegasoo/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
90
aur/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Stegasoo AUR Package
|
||||
|
||||
Full package with CLI, Web UI, and REST API. Supports Python 3.11-3.14.
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
# or
|
||||
paru -S stegasoo-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-git.git
|
||||
cd stegasoo-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo/venv/` - Self-contained Python venv with all dependencies
|
||||
- `/usr/bin/stegasoo` - CLI symlink
|
||||
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service (port 5000)
|
||||
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service (port 8000, HTTPS)
|
||||
|
||||
## Optional Dependencies
|
||||
|
||||
```bash
|
||||
# QR code reading from webcam/images (recommended)
|
||||
sudo pacman -S zbar
|
||||
```
|
||||
|
||||
All other dependencies are bundled in the venv.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
stegasoo --help
|
||||
stegasoo generate # Generate passphrase + PIN
|
||||
stegasoo generate --rsa --qr-ascii # With RSA keys and QR codes
|
||||
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P "word1 word2 word3 word4" -p 123456
|
||||
stegasoo decode -i encoded.png -r reference.jpg -P "word1 word2 word3 word4" -p 123456
|
||||
```
|
||||
|
||||
### Web UI
|
||||
```bash
|
||||
# Start service (user created automatically on install)
|
||||
sudo systemctl enable --now stegasoo-web
|
||||
|
||||
# Access at http://localhost:5000
|
||||
```
|
||||
|
||||
### REST API
|
||||
```bash
|
||||
# Create an API key first
|
||||
sudo -u stegasoo stegasoo api keys create mykey
|
||||
|
||||
# Start service (HTTPS with auto-generated self-signed cert)
|
||||
sudo systemctl enable --now stegasoo-api
|
||||
|
||||
# Access docs at https://localhost:8000/docs
|
||||
curl -k -H "X-API-Key: YOUR_KEY" https://localhost:8000/
|
||||
```
|
||||
|
||||
### HTTPS Configuration
|
||||
|
||||
The API uses HTTPS by default with auto-generated self-signed certificates.
|
||||
|
||||
```bash
|
||||
# View certificate info
|
||||
stegasoo api tls info
|
||||
|
||||
# Generate new self-signed cert
|
||||
sudo -u stegasoo stegasoo api tls generate
|
||||
|
||||
# Use custom certs (edit service file)
|
||||
sudo systemctl edit stegasoo-api
|
||||
```
|
||||
|
||||
## Alternative Packages
|
||||
|
||||
- `stegasoo-cli-git` - CLI only, minimal dependencies
|
||||
- `stegasoo-api-git` - CLI + REST API, no web UI
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
75
aur/stegasoo-git.install
Normal file
@@ -0,0 +1,75 @@
|
||||
post_install() {
|
||||
# Create stegasoo system user if it doesn't exist
|
||||
if ! getent passwd stegasoo >/dev/null; then
|
||||
useradd -r -s /usr/bin/nologin -d /opt/stegasoo stegasoo
|
||||
echo "Created system user 'stegasoo'"
|
||||
fi
|
||||
|
||||
# Set ownership of instance directory for Flask
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo/venv/var/app-instance 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo " Stegasoo installed successfully!"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "CLI usage:"
|
||||
echo " stegasoo --help"
|
||||
echo " stegasoo generate # Generate credentials"
|
||||
echo " stegasoo encode # Encode a message"
|
||||
echo " stegasoo decode # Decode a message"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " Web UI Service"
|
||||
echo "-----------------------------------------------"
|
||||
echo " Port: 5000 (HTTP)"
|
||||
echo " Start: sudo systemctl start stegasoo-web"
|
||||
echo " Enable: sudo systemctl enable stegasoo-web"
|
||||
echo " Status: sudo systemctl status stegasoo-web"
|
||||
echo " Access: http://localhost:5000"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " REST API Service"
|
||||
echo "-----------------------------------------------"
|
||||
echo " Port: 8000 (HTTPS by default)"
|
||||
echo " Start: sudo systemctl start stegasoo-api"
|
||||
echo " Enable: sudo systemctl enable stegasoo-api"
|
||||
echo " Status: sudo systemctl status stegasoo-api"
|
||||
echo " Access: https://localhost:8000"
|
||||
echo ""
|
||||
echo "-----------------------------------------------"
|
||||
echo " HTTPS Configuration"
|
||||
echo "-----------------------------------------------"
|
||||
echo " The API generates self-signed certs on first run."
|
||||
echo " To pre-generate or use custom certificates:"
|
||||
echo ""
|
||||
echo " # Generate self-signed certs"
|
||||
echo " sudo -u stegasoo stegasoo api tls generate"
|
||||
echo ""
|
||||
echo " # Use custom certs (edit the service file)"
|
||||
echo " sudo systemctl edit stegasoo-api"
|
||||
echo " # Add: ExecStart= with --cert and --key flags"
|
||||
echo ""
|
||||
echo " # Create API keys for authentication"
|
||||
echo " sudo -u stegasoo stegasoo api keys create <name>"
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
|
||||
pre_remove() {
|
||||
# Stop services if running
|
||||
systemctl stop stegasoo-web 2>/dev/null || true
|
||||
systemctl stop stegasoo-api 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
# Optionally remove the stegasoo user
|
||||
# userdel stegasoo 2>/dev/null || true
|
||||
echo "Stegasoo removed. User 'stegasoo' was not removed."
|
||||
echo "To remove: userdel stegasoo"
|
||||
}
|
||||
22
aur/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Test build the AUR package locally
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cleaning previous builds ==="
|
||||
rm -rf stegasoo-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||
|
||||
echo "=== Generating .SRCINFO ==="
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
echo "=== Building package ==="
|
||||
makepkg -sf
|
||||
|
||||
echo "=== Package built ==="
|
||||
ls -la *.pkg.tar.zst
|
||||
|
||||
echo ""
|
||||
echo "To install: sudo pacman -U stegasoo-git-*.pkg.tar.zst"
|
||||
echo "To test: makepkg -si"
|
||||
61
build.sh
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Build Script
|
||||
# Usage: ./build.sh [base|fast|full|clean]
|
||||
|
||||
set -e
|
||||
|
||||
case "${1:-fast}" in
|
||||
base)
|
||||
# Build base image with all dependencies (run once, or when deps change)
|
||||
echo "🔨 Building base image (this takes 5-10 minutes)..."
|
||||
docker build -f Dockerfile.base -t stegasoo-base:latest .
|
||||
echo "✅ Base image built! Future builds will be fast."
|
||||
echo ""
|
||||
echo "Optional: Push to registry for team use:"
|
||||
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
|
||||
echo " docker push yourregistry/stegasoo-base:latest"
|
||||
;;
|
||||
|
||||
fast)
|
||||
# Fast build using pre-built base image
|
||||
if ! docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
|
||||
echo "⚠️ Base image not found. Building it first (one-time)..."
|
||||
$0 base
|
||||
fi
|
||||
echo "🚀 Fast build using base image..."
|
||||
docker-compose build
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
full)
|
||||
# Full rebuild from scratch (slow, but no base image needed)
|
||||
echo "🐢 Full build from scratch (slow)..."
|
||||
docker-compose build --no-cache
|
||||
echo "✅ Done! Start with: docker-compose up -d"
|
||||
;;
|
||||
|
||||
clean)
|
||||
# Clean up everything
|
||||
echo "🧹 Cleaning up..."
|
||||
docker-compose down --rmi local -v 2>/dev/null || true
|
||||
docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
echo "✅ Cleaned!"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Stegasoo Build Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Full rebuild from scratch (slow, no base needed)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo ""
|
||||
echo "Typical workflow:"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast (or just 'docker-compose build')"
|
||||
echo " 3. Deps change: $0 base (rebuild base image)"
|
||||
;;
|
||||
esac
|
||||
170
check_scipy.py
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnostic script to check for scipy/numpy issues.
|
||||
Run this BEFORE starting the web app.
|
||||
|
||||
Usage:
|
||||
python check_scipy.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
print(f"Python version: {sys.version}")
|
||||
print()
|
||||
|
||||
# Check numpy
|
||||
try:
|
||||
import numpy as np
|
||||
print(f"NumPy version: {np.__version__}")
|
||||
print(f"NumPy config:")
|
||||
np.show_config()
|
||||
except ImportError as e:
|
||||
print(f"NumPy not installed: {e}")
|
||||
except Exception as e:
|
||||
print(f"NumPy error: {e}")
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Check scipy
|
||||
try:
|
||||
import scipy
|
||||
print(f"SciPy version: {scipy.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"SciPy not installed: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Check PIL
|
||||
try:
|
||||
from PIL import Image
|
||||
print(f"Pillow version: {Image.__version__}")
|
||||
except ImportError as e:
|
||||
print(f"Pillow not installed: {e}")
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test scipy DCT directly
|
||||
print("Testing scipy DCT...")
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
import numpy as np
|
||||
|
||||
# Create test array
|
||||
test = np.random.rand(8, 8).astype(np.float64)
|
||||
print(f"Input array shape: {test.shape}, dtype: {test.dtype}")
|
||||
|
||||
# Test 1D DCT
|
||||
row = test[0, :]
|
||||
result = dct(row, norm='ortho')
|
||||
print(f"1D DCT result shape: {result.shape}, dtype: {result.dtype}")
|
||||
|
||||
# Test 2D DCT (the potentially problematic operation)
|
||||
result2d = dct(dct(test.T, norm='ortho').T, norm='ortho')
|
||||
print(f"2D DCT result shape: {result2d.shape}, dtype: {result2d.dtype}")
|
||||
|
||||
# Test inverse
|
||||
recovered = idct(idct(result2d.T, norm='ortho').T, norm='ortho')
|
||||
error = np.max(np.abs(test - recovered))
|
||||
print(f"Round-trip error: {error}")
|
||||
|
||||
if error < 1e-10:
|
||||
print("✓ scipy DCT working correctly")
|
||||
else:
|
||||
print("⚠ scipy DCT has precision issues")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ scipy DCT failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test with larger array (more like real image processing)
|
||||
print("Testing with larger arrays (512x512)...")
|
||||
try:
|
||||
from scipy.fftpack import dct, idct
|
||||
import numpy as np
|
||||
import gc
|
||||
|
||||
# Simulate processing many 8x8 blocks
|
||||
large_array = np.random.rand(512, 512).astype(np.float64)
|
||||
print(f"Large array shape: {large_array.shape}, size: {large_array.nbytes} bytes")
|
||||
|
||||
count = 0
|
||||
for y in range(0, 512, 8):
|
||||
for x in range(0, 512, 8):
|
||||
block = large_array[y:y+8, x:x+8].copy()
|
||||
dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho')
|
||||
recovered = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
|
||||
large_array[y:y+8, x:x+8] = recovered
|
||||
count += 1
|
||||
|
||||
print(f"Processed {count} blocks successfully")
|
||||
|
||||
del large_array
|
||||
gc.collect()
|
||||
|
||||
print("✓ Large array processing completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Large array processing failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("-" * 50)
|
||||
print()
|
||||
|
||||
# Test PIL with large image
|
||||
print("Testing PIL with large image...")
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a large test image
|
||||
img = Image.new('RGB', (4000, 3000), color=(128, 128, 128))
|
||||
|
||||
# Save to bytes
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_bytes = buffer.getvalue()
|
||||
print(f"Test image size: {len(img_bytes)} bytes")
|
||||
|
||||
# Re-open and process
|
||||
buffer2 = io.BytesIO(img_bytes)
|
||||
img2 = Image.open(buffer2)
|
||||
print(f"Re-opened image: {img2.size}, mode: {img2.mode}")
|
||||
|
||||
# Convert to numpy array
|
||||
import numpy as np
|
||||
arr = np.array(img2)
|
||||
print(f"NumPy array: {arr.shape}, dtype: {arr.dtype}")
|
||||
|
||||
# Clean up
|
||||
img.close()
|
||||
img2.close()
|
||||
buffer.close()
|
||||
buffer2.close()
|
||||
del arr
|
||||
gc.collect()
|
||||
|
||||
print("✓ PIL large image test completed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ PIL test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("Diagnostics complete")
|
||||
print()
|
||||
print("If no errors above but web app still crashes, try:")
|
||||
print("1. pip install --upgrade scipy numpy pillow")
|
||||
print("2. pip install scipy==1.11.4 numpy==1.26.4 # Known stable versions")
|
||||
print("3. Check if using conda vs pip (mixing can cause issues)")
|
||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 17 KiB |
BIN
data/WebUI_About.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
data/WebUI_Account.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Login.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Recover.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
data/WebUI_Setup.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Tools.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
215
debug_jpegio.py
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script for DCT/jpegio extraction issues.
|
||||
Run from the stegasoo directory.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / 'src'))
|
||||
|
||||
import hashlib
|
||||
import numpy as np
|
||||
|
||||
# Check for jpegio
|
||||
try:
|
||||
import jpegio as jio
|
||||
print("✓ jpegio available")
|
||||
except ImportError:
|
||||
print("✗ jpegio NOT available")
|
||||
sys.exit(1)
|
||||
|
||||
def get_usable_positions(coef_array, min_magnitude=2):
|
||||
"""Get positions of usable coefficients."""
|
||||
positions = []
|
||||
h, w = coef_array.shape
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
# Skip DC coefficients (top-left of each 8x8 block)
|
||||
if (row % 8 == 0) and (col % 8 == 0):
|
||||
continue
|
||||
if abs(coef_array[row, col]) >= min_magnitude:
|
||||
positions.append((row, col))
|
||||
return positions
|
||||
|
||||
def generate_order(num_positions, seed):
|
||||
"""Generate pseudo-random order for coefficient selection."""
|
||||
hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest()
|
||||
rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big'))
|
||||
order = list(range(num_positions))
|
||||
rng.shuffle(order)
|
||||
return order
|
||||
|
||||
def extract_bits(coef_array, positions, order, num_bits):
|
||||
"""Extract bits from coefficients."""
|
||||
bits = []
|
||||
for i, pos_idx in enumerate(order):
|
||||
if i >= num_bits:
|
||||
break
|
||||
row, col = positions[pos_idx]
|
||||
coef = coef_array[row, col]
|
||||
bits.append(coef & 1)
|
||||
return bits
|
||||
|
||||
def bits_to_bytes(bits):
|
||||
"""Convert list of bits to bytes."""
|
||||
result = []
|
||||
for i in range(0, len(bits), 8):
|
||||
byte_bits = bits[i:i+8]
|
||||
if len(byte_bits) == 8:
|
||||
byte_val = sum(byte_bits[j] << (7-j) for j in range(8))
|
||||
result.append(byte_val)
|
||||
return bytes(result)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python debug_jpegio.py <stego_image.jpg> <reference_photo>")
|
||||
print("\nOptional: add passphrase, pin, key path")
|
||||
print(" python debug_jpegio.py stego.jpg ref.jpg 'passphrase' '123456' key.pem")
|
||||
sys.exit(1)
|
||||
|
||||
stego_path = sys.argv[1]
|
||||
ref_path = sys.argv[2]
|
||||
passphrase = sys.argv[3] if len(sys.argv) > 3 else "test"
|
||||
pin = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
key_path = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("JPEGIO DCT EXTRACTION DEBUG")
|
||||
print(f"{'='*60}")
|
||||
print(f"Stego image: {stego_path}")
|
||||
print(f"Reference: {ref_path}")
|
||||
print(f"Passphrase: '{passphrase}'")
|
||||
print(f"PIN: '{pin}'")
|
||||
print(f"Key: {key_path}")
|
||||
|
||||
# Load stego image with jpegio
|
||||
print(f"\n[1] Loading stego image with jpegio...")
|
||||
try:
|
||||
jpeg = jio.read(stego_path)
|
||||
print(f" ✓ jpegio.read() succeeded")
|
||||
print(f" Number of components: {len(jpeg.coef_arrays)}")
|
||||
for i, arr in enumerate(jpeg.coef_arrays):
|
||||
print(f" Component {i}: shape={arr.shape}, dtype={arr.dtype}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get coefficient array (channel 0)
|
||||
coef_array = jpeg.coef_arrays[0]
|
||||
print(f"\n[2] Coefficient array analysis...")
|
||||
print(f" Shape: {coef_array.shape}")
|
||||
print(f" Non-zero coefficients: {np.count_nonzero(coef_array)}")
|
||||
print(f" Min value: {coef_array.min()}")
|
||||
print(f" Max value: {coef_array.max()}")
|
||||
|
||||
# Get usable positions
|
||||
print(f"\n[3] Finding usable positions (|coef| >= 2, non-DC)...")
|
||||
positions = get_usable_positions(coef_array)
|
||||
print(f" Usable positions: {len(positions)}")
|
||||
print(f" Capacity: ~{len(positions) // 8} bytes")
|
||||
|
||||
# Generate seed (this needs to match the encode seed!)
|
||||
print(f"\n[4] Generating seed...")
|
||||
|
||||
# Load reference photo
|
||||
ref_data = Path(ref_path).read_bytes()
|
||||
ref_hash = hashlib.sha256(ref_data).digest()
|
||||
print(f" Reference hash: {ref_hash[:8].hex()}...")
|
||||
|
||||
# Load RSA key if provided
|
||||
rsa_component = b""
|
||||
if key_path:
|
||||
try:
|
||||
from stegasoo import load_rsa_key
|
||||
key_data = Path(key_path).read_bytes()
|
||||
# Try without password first
|
||||
try:
|
||||
rsa_key = load_rsa_key(key_data, password=None)
|
||||
except:
|
||||
rsa_key = load_rsa_key(key_data, password="testpass")
|
||||
|
||||
# Get public key bytes for seed
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
pub_bytes = rsa_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
rsa_component = hashlib.sha256(pub_bytes).digest()
|
||||
print(f" RSA key loaded, hash: {rsa_component[:8].hex()}...")
|
||||
except Exception as e:
|
||||
print(f" ✗ Could not load RSA key: {e}")
|
||||
|
||||
# Build seed like stegasoo does
|
||||
# This is the critical part - must match encoding!
|
||||
seed_parts = [
|
||||
ref_hash,
|
||||
passphrase.encode('utf-8'),
|
||||
pin.encode('utf-8') if pin else b"",
|
||||
rsa_component,
|
||||
]
|
||||
seed = hashlib.sha256(b"".join(seed_parts)).digest()
|
||||
print(f" Combined seed: {seed[:8].hex()}...")
|
||||
|
||||
# Generate order
|
||||
print(f"\n[5] Generating coefficient order...")
|
||||
order = generate_order(len(positions), seed)
|
||||
print(f" First 10 indices: {order[:10]}")
|
||||
|
||||
# Try to extract header
|
||||
print(f"\n[6] Extracting header (first 80 bits = 10 bytes)...")
|
||||
HEADER_SIZE = 10
|
||||
header_bits = extract_bits(coef_array, positions, order, HEADER_SIZE * 8)
|
||||
header_bytes = bits_to_bytes(header_bits)
|
||||
print(f" Raw header bytes: {header_bytes.hex()}")
|
||||
print(f" As ASCII (if printable): {repr(header_bytes)}")
|
||||
|
||||
# Check for JPGS magic
|
||||
JPEGIO_MAGIC = b'JPGS'
|
||||
if header_bytes[:4] == JPEGIO_MAGIC:
|
||||
print(f" ✓ Found JPEGIO magic bytes!")
|
||||
version = header_bytes[4]
|
||||
flags = header_bytes[5]
|
||||
data_length = struct.unpack('>I', header_bytes[6:10])[0]
|
||||
print(f" Version: {version}")
|
||||
print(f" Flags: {flags}")
|
||||
print(f" Data length: {data_length} bytes")
|
||||
|
||||
if data_length > 0 and data_length < len(positions) // 8:
|
||||
print(f"\n[7] Extracting payload ({data_length} bytes)...")
|
||||
total_bits = (HEADER_SIZE + data_length) * 8
|
||||
all_bits = extract_bits(coef_array, positions, order, total_bits)
|
||||
data_bits = all_bits[HEADER_SIZE * 8:]
|
||||
payload = bits_to_bytes(data_bits)
|
||||
print(f" Payload (first 64 bytes): {payload[:64].hex()}")
|
||||
print(f" This should be encrypted data starting with salt/IV")
|
||||
else:
|
||||
print(f" ✗ Invalid data length: {data_length}")
|
||||
else:
|
||||
print(f" ✗ No JPEGIO magic found")
|
||||
print(f" Expected: {JPEGIO_MAGIC.hex()} ('JPGS')")
|
||||
print(f" Got: {header_bytes[:4].hex()} ('{header_bytes[:4]}')")
|
||||
|
||||
# Try alternate interpretations
|
||||
print(f"\n[7] Trying alternate header interpretations...")
|
||||
|
||||
# Maybe it's scipy DCT format?
|
||||
DCT_MAGIC = b'DCTS'
|
||||
if header_bytes[:4] == DCT_MAGIC:
|
||||
print(f" Found SCIPY DCT magic - wrong extraction method!")
|
||||
else:
|
||||
# Show bit distribution
|
||||
print(f" First 32 extracted bits: {header_bits[:32]}")
|
||||
|
||||
# Check if bits look random or patterned
|
||||
ones = sum(header_bits[:80])
|
||||
print(f" Bit distribution: {ones}/80 ones ({100*ones/80:.1f}%)")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("DEBUG COMPLETE")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -35,12 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install ALL dependencies (slow path)
|
||||
RUN pip install --no-cache-dir \
|
||||
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
|
||||
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
|
||||
reedsolo>=1.7.0 \
|
||||
flask>=3.0.0 gunicorn>=21.0.0 \
|
||||
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
|
||||
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
|
||||
@@ -57,13 +60,24 @@ FROM base AS web
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies (curl for healthcheck, openssl for cert generation)
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy application files (this is all that rebuilds normally!)
|
||||
COPY src/ src/
|
||||
COPY data/ data/
|
||||
COPY frontends/web/ frontends/web/
|
||||
|
||||
# Create upload directory
|
||||
RUN mkdir -p /tmp/stego_uploads
|
||||
# Create upload directory and instance directories (for volumes)
|
||||
# temp_files is for multi-worker temp file sharing
|
||||
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
|
||||
|
||||
# Copy and set up entrypoint (before switching to non-root user)
|
||||
COPY frontends/web/docker-entrypoint.sh /app/frontends/web/
|
||||
RUN chmod +x /app/frontends/web/docker-entrypoint.sh
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
||||
@@ -76,12 +90,12 @@ ENV PYTHONPATH=/app/src
|
||||
EXPOSE 5000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD curl -fsk https://localhost:5000/ || curl -fs http://localhost:5000/ || exit 1
|
||||
|
||||
# Run with gunicorn
|
||||
# Run with entrypoint (handles HTTPS/HTTP mode)
|
||||
WORKDIR /app/frontends/web
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
ENTRYPOINT ["/app/frontends/web/docker-entrypoint.sh"]
|
||||
|
||||
# ============================================================================
|
||||
# API stage - REST API
|
||||
@@ -32,7 +32,9 @@ RUN pip install --no-cache-dir \
|
||||
jpegio>=0.2.0 \
|
||||
argon2-cffi>=23.0.0 \
|
||||
pillow>=10.0.0 \
|
||||
cryptography>=41.0.0
|
||||
cryptography>=41.0.0 \
|
||||
reedsolo>=1.7.0 \
|
||||
zstandard>=0.22.0
|
||||
|
||||
# Install web/api framework packages (also stable)
|
||||
RUN pip install --no-cache-dir \
|
||||
@@ -47,9 +49,9 @@ RUN pip install --no-cache-dir \
|
||||
lz4>=4.0.0
|
||||
|
||||
# Verify key packages work
|
||||
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
|
||||
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
|
||||
|
||||
# Label for tracking
|
||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||
LABEL org.opencontainers.image.version="4.0.0"
|
||||
LABEL org.opencontainers.image.version="4.2.1"
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
# Shared environment variables
|
||||
x-common-env: &common-env
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
@@ -10,7 +8,8 @@ services:
|
||||
# ============================================================================
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
target: web
|
||||
container_name: stegasoo-web
|
||||
ports:
|
||||
@@ -18,20 +17,31 @@ services:
|
||||
environment:
|
||||
<<: *common-env
|
||||
FLASK_ENV: production
|
||||
# Authentication (v4.0.2)
|
||||
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
|
||||
# HTTPS enabled by default - generates self-signed cert if none provided
|
||||
# To disable: STEGASOO_HTTPS_ENABLED=false docker-compose up
|
||||
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-true}
|
||||
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
|
||||
volumes:
|
||||
# Persist auth database and SSL certs (v4.0.2)
|
||||
- stegasoo-web-data:/app/frontends/web/instance
|
||||
- stegasoo-web-certs:/app/frontends/web/certs
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# ============================================================================
|
||||
# REST API (FastAPI)
|
||||
# ============================================================================
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
target: api
|
||||
container_name: stegasoo-api
|
||||
ports:
|
||||
@@ -42,6 +52,13 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# Named volumes for persistent data
|
||||
volumes:
|
||||
stegasoo-web-data:
|
||||
driver: local
|
||||
stegasoo-web-certs:
|
||||
driver: local
|
||||
224
docs/CLAUDE_WORKTREES.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Using Claude Code with Git Worktrees — A Stegasoo Guide
|
||||
|
||||
## What is a worktree?
|
||||
|
||||
A git worktree is a second (or third, or fourth...) copy of your repo that shares the same `.git` history but lives in its own folder with its own branch. Think of it like opening the same project in a parallel universe — you can hack on a feature in one worktree while keeping `main` pristine in another.
|
||||
|
||||
Claude Code has built-in worktree support, so you don't need to memorize any git commands.
|
||||
|
||||
## Why bother?
|
||||
|
||||
- **Safety net**: Your `main` branch stays untouched. If Claude's changes go sideways, just delete the worktree — zero damage.
|
||||
- **Easy A/B comparison**: Keep the original code open in one editor tab, Claude's changes in another.
|
||||
- **Parallel work**: You can keep working in `main` while Claude tinkers in a worktree.
|
||||
- **Clean PRs**: The worktree branch becomes your PR branch with no stray changes mixed in.
|
||||
|
||||
## The 30-second version
|
||||
|
||||
1. Ask Claude to work in a worktree
|
||||
2. Claude creates an isolated copy and works there
|
||||
3. When done, you either merge or throw it away
|
||||
|
||||
That's it. Everything below is details.
|
||||
|
||||
---
|
||||
|
||||
## How to start a worktree session
|
||||
|
||||
### Option A: Ask Claude directly
|
||||
|
||||
Just tell Claude you want to work in a worktree:
|
||||
|
||||
```
|
||||
> Let's work in a worktree for this
|
||||
> Start a worktree called "dct-refactor"
|
||||
> Can you make these changes in an isolated worktree?
|
||||
```
|
||||
|
||||
Claude will use `EnterWorktree` behind the scenes and switch into it automatically.
|
||||
|
||||
### Option B: Use the slash command
|
||||
|
||||
```
|
||||
> /worktree
|
||||
```
|
||||
|
||||
This drops you into a fresh worktree immediately.
|
||||
|
||||
### Option C: Tell Claude to launch an agent in a worktree
|
||||
|
||||
If you want Claude to do something in the background without touching your working directory:
|
||||
|
||||
```
|
||||
> Run the tests in a worktree so we don't mess up my local state
|
||||
```
|
||||
|
||||
Claude can spin up a sub-agent with `isolation: "worktree"` — it gets its own copy and reports back.
|
||||
|
||||
---
|
||||
|
||||
## Where do worktrees live?
|
||||
|
||||
Claude puts them in:
|
||||
|
||||
```
|
||||
.claude/worktrees/<name>/
|
||||
```
|
||||
|
||||
This directory is inside your repo but ignored by git, so it won't pollute your commits.
|
||||
|
||||
## What happens inside a worktree?
|
||||
|
||||
The worktree is a full checkout of your repo on a new branch. Claude's working directory switches to it, so all file reads, edits, and commands happen there — not in your main checkout.
|
||||
|
||||
**Important for Stegasoo**: The first thing you (or Claude) should do in a fresh worktree is:
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
This points your editable install at the worktree's source code instead of your main checkout. Without this, `pytest` will test the wrong copy of the code.
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Example 1: Feature work
|
||||
|
||||
```
|
||||
You: I want to add lz4 as a default compression option. Let's use a worktree.
|
||||
Claude: *creates worktree, switches to it*
|
||||
Claude: *installs dev deps, makes changes, runs tests*
|
||||
Claude: All tests pass. Ready to merge or open a PR.
|
||||
You: Looks good, make a PR.
|
||||
Claude: *pushes branch, creates PR*
|
||||
```
|
||||
|
||||
### Example 2: Risky refactor
|
||||
|
||||
```
|
||||
You: Refactor the crypto module to split KDF logic into its own file.
|
||||
Do it in a worktree so I can review before touching main.
|
||||
Claude: *creates worktree "refactor/split-kdf"*
|
||||
Claude: *does the refactor, runs tests*
|
||||
You: Hmm, I don't love the approach. Throw it away.
|
||||
Claude: *removes worktree — main is untouched*
|
||||
```
|
||||
|
||||
### Example 3: Investigate a bug without side effects
|
||||
|
||||
```
|
||||
You: Something's wrong with DCT encoding on large images.
|
||||
Can you investigate in a worktree? I've got uncommitted work here.
|
||||
Claude: *creates worktree, adds debug logging, runs tests*
|
||||
Claude: Found it — the block size calculation overflows at >16MP.
|
||||
Here's the fix. Want me to apply it to main?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to use a worktree vs. just editing in place
|
||||
|
||||
| Situation | Worktree? | Why |
|
||||
|-----------|-----------|-----|
|
||||
| Quick one-file fix | No | Overkill — just edit directly |
|
||||
| Multi-file refactor | Yes | Easy to discard if it goes wrong |
|
||||
| Touching security-critical code (`crypto.py`, `steganography.py`, etc.) | Yes | Extra safety for sensitive changes |
|
||||
| Experimental / "let's try this" work | Yes | Zero-cost throwaway |
|
||||
| You have uncommitted changes you don't want to stash | Yes | Worktree won't touch your working tree |
|
||||
| Adding a single test | No | Low risk, just do it |
|
||||
|
||||
---
|
||||
|
||||
## Cleaning up
|
||||
|
||||
### If you merged or created a PR
|
||||
|
||||
The worktree served its purpose. Clean up:
|
||||
|
||||
```bash
|
||||
git worktree remove .claude/worktrees/<name>
|
||||
```
|
||||
|
||||
Or ask Claude:
|
||||
|
||||
```
|
||||
> Clean up the worktree
|
||||
```
|
||||
|
||||
### If you want to throw everything away
|
||||
|
||||
Same command — removing the worktree deletes the directory and its branch reference. Your `main` branch is completely unaffected.
|
||||
|
||||
### If Claude's session ends
|
||||
|
||||
When a Claude Code session ends while in a worktree, you'll be prompted to keep or remove it. If you keep it, you can resume later:
|
||||
|
||||
```bash
|
||||
cd .claude/worktrees/<name>
|
||||
# pick up where you left off
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Branch naming in worktrees
|
||||
|
||||
Follow the same conventions as the rest of the project:
|
||||
|
||||
| Type | Branch name | Example |
|
||||
|------|-------------|---------|
|
||||
| Feature | `feature/description` | `feature/batch-progress-bars` |
|
||||
| Bug fix | `fix/description` | `fix/dct-overflow-large-images` |
|
||||
| Docs | `docs/description` | `docs/api-examples` |
|
||||
| Refactor | `refactor/description` | `refactor/split-crypto-module` |
|
||||
|
||||
When Claude creates a worktree automatically, it generates a random branch name. You can rename it before pushing:
|
||||
|
||||
```bash
|
||||
git branch -m <old-name> feature/my-better-name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "I ran pytest but it's testing the old code"
|
||||
|
||||
You forgot to install in the worktree:
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### "I can't find my worktree"
|
||||
|
||||
```bash
|
||||
git worktree list
|
||||
```
|
||||
|
||||
This shows all worktrees and their paths.
|
||||
|
||||
### "I accidentally deleted the worktree folder without removing it from git"
|
||||
|
||||
```bash
|
||||
git worktree prune
|
||||
```
|
||||
|
||||
This cleans up stale worktree references.
|
||||
|
||||
### "I want to switch back to my main checkout"
|
||||
|
||||
If you're in a Claude Code session that entered a worktree, the session stays in the worktree until it ends. Start a new session to go back to your main checkout, or:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/stegasoo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. Say "use a worktree" when asking Claude to make changes
|
||||
2. Claude works in an isolated copy — your `main` is safe
|
||||
3. Merge the good stuff, trash the bad stuff
|
||||
4. Never think about it again until next time
|
||||
162
docs/DOCKER_QUICKSTART.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Docker Quickstart
|
||||
|
||||
Get Stegasoo running in Docker in under 5 minutes.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# From project root:
|
||||
|
||||
# Build web UI image
|
||||
sudo docker build -t stegasoo-web --target web -f docker/Dockerfile .
|
||||
|
||||
# Or build all targets
|
||||
sudo docker build -t stegasoo-api --target api -f docker/Dockerfile .
|
||||
sudo docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
|
||||
|
||||
# Or use docker-compose
|
||||
sudo docker-compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
## Run (Basic)
|
||||
|
||||
```bash
|
||||
# HTTP only, no auth
|
||||
sudo docker run -d \
|
||||
-p 5000:5000 \
|
||||
-e STEGASOO_AUTH_ENABLED=false \
|
||||
--name stegasoo \
|
||||
stegasoo-web
|
||||
```
|
||||
|
||||
Visit http://localhost:5000
|
||||
|
||||
## Run (Production)
|
||||
|
||||
```bash
|
||||
# HTTPS + Auth + Channel Key
|
||||
sudo docker run -d \
|
||||
-p 5000:5000 \
|
||||
-e STEGASOO_AUTH_ENABLED=true \
|
||||
-e STEGASOO_HTTPS_ENABLED=true \
|
||||
-e STEGASOO_HOSTNAME=stegasoo.local \
|
||||
-e STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456 \
|
||||
-v stegasoo-data:/opt/stegasoo/frontends/web/instance \
|
||||
-v stegasoo-certs:/opt/stegasoo/frontends/web/certs \
|
||||
--name stegasoo \
|
||||
stegasoo-web
|
||||
```
|
||||
|
||||
Visit https://localhost:5000 (accept self-signed cert warning)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Require login |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for SSL cert |
|
||||
| `STEGASOO_CHANNEL_KEY` | *(none)* | Shared channel key (32 alphanumeric chars with dashes) |
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Create `.env` file in project root:
|
||||
```bash
|
||||
STEGASOO_AUTH_ENABLED=true
|
||||
STEGASOO_HTTPS_ENABLED=true
|
||||
STEGASOO_HOSTNAME=stegasoo.local
|
||||
STEGASOO_CHANNEL_KEY=
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sudo docker-compose -f docker/docker-compose.yml up -d web
|
||||
```
|
||||
|
||||
## Custom SSL Certificates
|
||||
|
||||
### Use Your Own Certs
|
||||
|
||||
```bash
|
||||
# Stop container
|
||||
sudo docker stop stegasoo
|
||||
|
||||
# Copy certs to volume
|
||||
sudo docker run --rm -v stegasoo-certs:/certs -v $(pwd):/src alpine \
|
||||
sh -c "cp /src/your-cert.crt /certs/server.crt && cp /src/your-key.key /certs/server.key && chmod 600 /certs/server.key"
|
||||
|
||||
# Start container
|
||||
sudo docker start stegasoo
|
||||
```
|
||||
|
||||
### Use mkcert (Local Development)
|
||||
|
||||
```bash
|
||||
# Install mkcert
|
||||
brew install mkcert # macOS
|
||||
# or: sudo apt install mkcert # Debian/Ubuntu
|
||||
|
||||
# Create local CA and certs
|
||||
mkcert -install
|
||||
mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1 stegasoo.local
|
||||
|
||||
# Copy to Docker volume (see above)
|
||||
```
|
||||
|
||||
### Use Let's Encrypt (Public Server)
|
||||
|
||||
```bash
|
||||
# Get cert
|
||||
sudo certbot certonly --standalone -d yourdomain.com
|
||||
|
||||
# Copy to Docker volume
|
||||
sudo docker run --rm -v stegasoo-certs:/certs alpine \
|
||||
sh -c "cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /certs/server.crt && \
|
||||
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /certs/server.key && \
|
||||
chmod 600 /certs/server.key"
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `stegasoo-data` | User database, settings |
|
||||
| `stegasoo-certs` | SSL certificates |
|
||||
|
||||
## Smoke Test
|
||||
|
||||
```bash
|
||||
# Check container logs
|
||||
sudo docker logs stegasoo
|
||||
|
||||
# Test HTTP endpoint
|
||||
curl -k https://localhost:5000/health
|
||||
|
||||
# Expected: {"status":"ok","version":"4.1.7",...}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
sudo docker logs stegasoo
|
||||
```
|
||||
|
||||
**Out of memory:**
|
||||
```bash
|
||||
# Argon2 needs 256MB+ per operation
|
||||
sudo docker run --memory=768m ...
|
||||
```
|
||||
|
||||
**Certificate errors:**
|
||||
```bash
|
||||
# Regenerate self-signed cert
|
||||
sudo docker exec stegasoo rm -rf /opt/stegasoo/frontends/web/certs/*
|
||||
sudo docker restart stegasoo
|
||||
```
|
||||
|
||||
**Reset everything:**
|
||||
```bash
|
||||
sudo docker stop stegasoo && sudo docker rm stegasoo
|
||||
sudo docker volume rm stegasoo-data stegasoo-certs
|
||||
```
|
||||
361
docs/TEMPLATES.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Stegasoo Web Templates Specification
|
||||
|
||||
Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Layout](#layout)
|
||||
- [Auth & Setup](#auth--setup)
|
||||
- [Core Features](#core-features)
|
||||
- [Tools & Account](#tools--account)
|
||||
- [Admin](#admin)
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### `base.html`
|
||||
**Purpose:** Master layout template - all pages extend this.
|
||||
|
||||
| Block | Description |
|
||||
|-------|-------------|
|
||||
| `{% block title %}` | Page title |
|
||||
| `{% block content %}` | Main page content |
|
||||
| `{% block scripts %}` | Page-specific JS |
|
||||
|
||||
**Key Elements:**
|
||||
- `nav.navbar` - Bootstrap 5 navbar with logo, links, auth buttons
|
||||
- `div.toast-container` - Flash message toasts (10s auto-dismiss)
|
||||
- `main.container` - Content wrapper
|
||||
- `footer` - Copyright + version
|
||||
|
||||
**Variables:** `is_authenticated`, `username`, `is_admin`
|
||||
|
||||
---
|
||||
|
||||
## Auth & Setup
|
||||
|
||||
### `login.html`
|
||||
**Route:** `/login`
|
||||
|
||||
**Form:** `POST /login`
|
||||
- `username` - text input
|
||||
- `password` - password input
|
||||
- "Forgot password?" link to `/recover`
|
||||
|
||||
**JS:** `static/js/auth.js` - password toggle
|
||||
|
||||
---
|
||||
|
||||
### `setup.html`
|
||||
**Route:** `/setup` (first-run only)
|
||||
|
||||
**Form:** `POST /setup`
|
||||
- `username` - admin username
|
||||
- `password` - password (min 8 chars)
|
||||
- `password_confirm` - confirmation
|
||||
|
||||
**JS:** Password confirmation validation
|
||||
|
||||
---
|
||||
|
||||
### `setup_recovery.html`
|
||||
**Route:** `/setup/recovery`
|
||||
|
||||
**Form:** `POST /setup/recovery`
|
||||
- `recovery_key` - hidden, pre-generated
|
||||
- `action` - "save" or "skip"
|
||||
- Checkbox confirmation required for save
|
||||
|
||||
**Features:**
|
||||
- Recovery key display (readonly input)
|
||||
- Copy to clipboard button
|
||||
- QR code image (if available)
|
||||
- Download options: text file, QR image
|
||||
- Stego backup upload form
|
||||
|
||||
---
|
||||
|
||||
### `recover.html`
|
||||
**Route:** `/recover`
|
||||
|
||||
**Form:** `POST /recover`
|
||||
- `recovery_key` - textarea for key input
|
||||
- `new_password` - new password
|
||||
- `new_password_confirm` - confirmation
|
||||
|
||||
**Accordion:** "Extract from stego backup"
|
||||
- `POST /recover/stego` with `stego_image` + `reference_image`
|
||||
- Pre-fills recovery key on success
|
||||
|
||||
---
|
||||
|
||||
### `regenerate_recovery.html`
|
||||
**Route:** `/account/recovery/regenerate` (admin only)
|
||||
|
||||
**Form:** `POST /account/recovery/regenerate`
|
||||
- `recovery_key` - hidden field
|
||||
- `action` - "save" or "cancel"
|
||||
- Confirmation checkbox
|
||||
|
||||
**Features:**
|
||||
- New key display
|
||||
- QR code (obfuscated)
|
||||
- Download: text, QR, stego backup
|
||||
- Warning if replacing existing key
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### `index.html`
|
||||
**Route:** `/`
|
||||
|
||||
**Structure:**
|
||||
- Hero section with tagline
|
||||
- 3 action cards: Encode, Decode, Generate
|
||||
- "How It Works" explainer section
|
||||
|
||||
---
|
||||
|
||||
### `generate.html`
|
||||
**Route:** `/generate`
|
||||
|
||||
**Form:** `POST /generate`
|
||||
- `words` - passphrase word count (3-12)
|
||||
- `use_pin` - checkbox
|
||||
- `pin_length` - PIN digits (6-9)
|
||||
- `use_rsa` - checkbox
|
||||
- `rsa_bits` - key size (2048/3072)
|
||||
|
||||
**Output panels:**
|
||||
- Passphrase display
|
||||
- PIN display (if enabled)
|
||||
- RSA key + QR (if enabled)
|
||||
- Entropy calculator
|
||||
|
||||
**JS:** `static/js/generate.js`
|
||||
|
||||
---
|
||||
|
||||
### `encode.html`
|
||||
**Route:** `/encode`
|
||||
|
||||
**Form:** `POST /encode` (multipart)
|
||||
- `reference_photo` - file upload (drag-drop zone)
|
||||
- `carrier_image` - file upload (drag-drop zone)
|
||||
- `mode` - radio: DCT (default) / LSB
|
||||
- `dct_format` - PNG / JPEG
|
||||
- `dct_color` - Color / Grayscale
|
||||
- `payload_type` - radio: Text / File
|
||||
- `message` - textarea (if text)
|
||||
- `embed_file` - file input (if file)
|
||||
- `passphrase` - text input
|
||||
- `pin` - text input
|
||||
- `rsa_key` / `rsa_key_qr` - file inputs
|
||||
- `rsa_key_password` - password
|
||||
- `channel_key` - select (saved keys) or manual input
|
||||
|
||||
**Panels:**
|
||||
- Reference preview with "Hash Acquired" status
|
||||
- Carrier preview with capacity info
|
||||
- Character counter for message
|
||||
|
||||
**JS:** `static/js/encode.js`, `static/js/stegasoo.js`
|
||||
|
||||
---
|
||||
|
||||
### `encode_result.html`
|
||||
**Route:** `/encode/result/<file_id>`
|
||||
|
||||
**Elements:**
|
||||
- Success message
|
||||
- Stego image preview
|
||||
- Download button
|
||||
- Share button (Web Share API)
|
||||
- Mode/capacity info
|
||||
- "Encode Another" link
|
||||
|
||||
**Variables:** `file_id`, `filename`, `mode`, `capacity_used`
|
||||
|
||||
---
|
||||
|
||||
### `decode.html`
|
||||
**Route:** `/decode`
|
||||
|
||||
**Form:** `POST /decode` (multipart)
|
||||
- `reference_photo` - file upload
|
||||
- `stego_image` - file upload
|
||||
- `passphrase` - text input
|
||||
- `pin` - text input
|
||||
- `rsa_key` / `rsa_key_qr` - file inputs
|
||||
- `rsa_key_password` - password
|
||||
- `channel_key` - select or manual
|
||||
|
||||
**Output:**
|
||||
- Decoded message display
|
||||
- File download (if file payload)
|
||||
|
||||
**JS:** `static/js/decode.js`, `static/js/stegasoo.js`
|
||||
|
||||
---
|
||||
|
||||
## Tools & Account
|
||||
|
||||
### `tools.html`
|
||||
**Route:** `/tools`
|
||||
|
||||
**Tabbed interface:**
|
||||
|
||||
| Tab | Endpoint | Description |
|
||||
|-----|----------|-------------|
|
||||
| Capacity | `POST /api/tools/capacity` | Image capacity analysis |
|
||||
| Peek | `POST /api/tools/peek` | Check for Stegasoo header |
|
||||
| Strip | `POST /api/tools/strip` | Remove hidden data |
|
||||
| EXIF | `POST /api/tools/exif/*` | Metadata viewer/editor |
|
||||
|
||||
**EXIF Editor features:**
|
||||
- Upload image → view all EXIF fields
|
||||
- Inline editing (click field to edit)
|
||||
- "Clear All" button
|
||||
- "Save" / "Download" buttons
|
||||
|
||||
**JS:** `static/js/tools.js`
|
||||
|
||||
---
|
||||
|
||||
### `account.html`
|
||||
**Route:** `/account`
|
||||
|
||||
**Sections:**
|
||||
|
||||
1. **User Info** - Username, role badge, logout link
|
||||
|
||||
2. **Recovery Key** (admin only)
|
||||
- Status: Configured / Not Set
|
||||
- Generate/Regenerate button
|
||||
- Disable button
|
||||
|
||||
3. **Password Change**
|
||||
- `current_password`
|
||||
- `new_password`
|
||||
- `new_password_confirm`
|
||||
|
||||
4. **Saved Channel Keys**
|
||||
- List of saved keys with edit/delete
|
||||
- "Add Key" form (name + key)
|
||||
- Max 10 keys per user
|
||||
|
||||
**Variables:** `username`, `is_admin`, `has_recovery`, `channel_keys`
|
||||
|
||||
---
|
||||
|
||||
### `about.html`
|
||||
**Route:** `/about`
|
||||
|
||||
**Sections:**
|
||||
- Version info + feature badges
|
||||
- Security model explanation
|
||||
- Channel key QR (if configured)
|
||||
- Dependency status table
|
||||
- Credits + links
|
||||
|
||||
**Variables:** `version`, `has_dct`, `has_qr_write`, `has_qr_read`, `channel_key`, `channel_qr`
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
### `admin/users.html`
|
||||
**Route:** `/admin/users`
|
||||
|
||||
**Table columns:** Username | Role | Created | Actions
|
||||
|
||||
**Actions per user:**
|
||||
- Reset Password button
|
||||
- Delete button (disabled for self)
|
||||
|
||||
**Header:**
|
||||
- User count: "X of 16 users"
|
||||
- "Add User" button (modal trigger)
|
||||
|
||||
**Modal:** Add User form
|
||||
- `username` input
|
||||
- `role` select (admin/user)
|
||||
- Auto-generated temp password display
|
||||
|
||||
---
|
||||
|
||||
### `admin/user_new.html`
|
||||
**Route:** `/admin/users/new`
|
||||
|
||||
**Form:** `POST /admin/users/new`
|
||||
- `username` - text input
|
||||
- `role` - select (user/admin)
|
||||
|
||||
Redirects to `user_created.html` on success.
|
||||
|
||||
---
|
||||
|
||||
### `admin/user_created.html`
|
||||
**Route:** `/admin/users/created`
|
||||
|
||||
**Display:**
|
||||
- Success message
|
||||
- Username
|
||||
- Temporary password (copy button)
|
||||
- "User must change password on first login" notice
|
||||
- Back to users link
|
||||
|
||||
---
|
||||
|
||||
### `admin/password_reset.html`
|
||||
**Route:** `/admin/users/<id>/password-reset`
|
||||
|
||||
**Display:**
|
||||
- Success message
|
||||
- New temporary password
|
||||
- Copy button
|
||||
- Back link
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Drag-Drop Upload Zones
|
||||
```html
|
||||
<div class="upload-zone" id="referenceZone">
|
||||
<input type="file" name="reference_photo" accept="image/*">
|
||||
<div class="preview"></div>
|
||||
<div class="status"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Password Toggle
|
||||
```html
|
||||
<div class="input-group">
|
||||
<input type="password" id="passwordInput">
|
||||
<button onclick="togglePassword('passwordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Toast Flash Messages
|
||||
Rendered in `base.html`, auto-dismiss after 10 seconds:
|
||||
- `success` → green
|
||||
- `warning` → yellow
|
||||
- `error` → red
|
||||
|
||||
---
|
||||
|
||||
## External JS Files
|
||||
|
||||
| File | Used By |
|
||||
|------|---------|
|
||||
| `static/js/stegasoo.js` | encode, decode, about |
|
||||
| `static/js/auth.js` | login, setup, recover, account |
|
||||
| `static/js/generate.js` | generate |
|
||||
| `static/js/encode.js` | encode |
|
||||
| `static/js/decode.js` | decode |
|
||||
| `static/js/tools.js` | tools |
|
||||
418
docs/stegasoo.1
Normal file
@@ -0,0 +1,418 @@
|
||||
.\" Stegasoo man page
|
||||
.\" Generate with: groff -man -Tascii stegasoo.1
|
||||
.TH STEGASOO 1 "February 2026" "Stegasoo 4.3.0" "User Commands"
|
||||
.SH NAME
|
||||
stegasoo \- steganography with hybrid authentication
|
||||
.SH SYNOPSIS
|
||||
.B stegasoo
|
||||
[\fB\-v\fR|\fB\-\-version\fR]
|
||||
[\fB\-\-json\fR]
|
||||
[\fB\-h\fR|\fB\-\-help\fR]
|
||||
.I command
|
||||
[\fIargs\fR]
|
||||
.SH DESCRIPTION
|
||||
.B stegasoo
|
||||
hides messages and files in images and audio using PIN + passphrase security.
|
||||
It uses LSB (Least Significant Bit) steganography with optional DCT
|
||||
(Discrete Cosine Transform) encoding for JPEG resilience, and supports
|
||||
audio steganography with LSB and Spread Spectrum modes.
|
||||
.PP
|
||||
Messages are encrypted using a hybrid authentication scheme that combines
|
||||
a reference photo (shared secret), passphrase, and PIN code.
|
||||
.SH GLOBAL OPTIONS
|
||||
.TP
|
||||
.BR \-v ", " \-\-version
|
||||
Show version and exit.
|
||||
.TP
|
||||
.B \-\-json
|
||||
Output results as JSON (where supported).
|
||||
.TP
|
||||
.BR \-h ", " \-\-help
|
||||
Show help message and exit.
|
||||
.SH COMMANDS
|
||||
.SS encode
|
||||
Encode a message or file into an image.
|
||||
.PP
|
||||
.B stegasoo encode
|
||||
.I carrier
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||
Message to encode.
|
||||
.TP
|
||||
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||
File to embed instead of message.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output image path.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-dry\-run
|
||||
Show capacity usage without encoding.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo encode photo.png -r ref.jpg -m "Secret" --passphrase --pin
|
||||
stegasoo encode photo.png -r ref.jpg -f doc.pdf -o encoded.png
|
||||
.fi
|
||||
.SS decode
|
||||
Decode a message or file from an image.
|
||||
.PP
|
||||
.B stegasoo decode
|
||||
.I image
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output path for file payloads.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo decode encoded.png -r ref.jpg --passphrase --pin
|
||||
stegasoo decode encoded.png -r ref.jpg -o ./extracted/
|
||||
.fi
|
||||
.SS generate
|
||||
Generate random credentials (passphrase + PIN + optional channel key).
|
||||
.PP
|
||||
.B stegasoo generate
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.B \-\-words " " \fIINTEGER\fR
|
||||
Number of words in passphrase (default: 4).
|
||||
.TP
|
||||
.B \-\-pin\-length " " \fIINTEGER\fR
|
||||
PIN length (default: 6).
|
||||
.TP
|
||||
.B \-\-channel\-key
|
||||
Also generate a 256-bit channel key.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo generate
|
||||
stegasoo generate --words 6 --pin-length 8
|
||||
stegasoo generate --channel-key
|
||||
.fi
|
||||
.SS info
|
||||
Show version, features, and system information.
|
||||
.PP
|
||||
.B stegasoo info
|
||||
[\fB\-\-full\fR]
|
||||
.TP
|
||||
.B \-\-full
|
||||
Show full system information (CPU, temperature, disk on Pi).
|
||||
.SS batch
|
||||
Batch operations on multiple images.
|
||||
.PP
|
||||
.B stegasoo batch
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B batch encode
|
||||
Encode message into multiple images.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo batch encode
|
||||
.I images...
|
||||
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||
[\fIoptions\fR]
|
||||
.PP
|
||||
Options: \fB\-m\fR, \fB\-f\fR, \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-suffix\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR,
|
||||
\fB\-r\fR/\fB\-\-recursive\fR, \fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
|
||||
.RE
|
||||
.TP
|
||||
.B batch decode
|
||||
Decode messages from multiple images.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo batch decode
|
||||
.I images...
|
||||
[\fIoptions\fR]
|
||||
.PP
|
||||
Options: \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR, \fB\-r\fR/\fB\-\-recursive\fR,
|
||||
\fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
|
||||
.RE
|
||||
.TP
|
||||
.B batch check
|
||||
Check capacity of multiple images.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo batch check
|
||||
.I images...
|
||||
[\fB\-r\fR/\fB\-\-recursive\fR]
|
||||
.RE
|
||||
.SS channel
|
||||
Manage channel keys for deployment isolation.
|
||||
.PP
|
||||
Channel keys bind encode/decode operations to a specific group or deployment.
|
||||
Messages encoded with one channel key can only be decoded by systems with
|
||||
the same channel key.
|
||||
.PP
|
||||
.B stegasoo channel
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B channel generate
|
||||
Generate a new random channel key.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-save\fR (project config), \fB\-\-save\-user\fR (user config).
|
||||
.RE
|
||||
.TP
|
||||
.B channel show
|
||||
Show the current channel key.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-key\fR \fITEXT\fR (show specific key instead).
|
||||
.RE
|
||||
.TP
|
||||
.B channel qr
|
||||
Display channel key as QR code.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-key\fR \fITEXT\fR, \fB\-\-format\fR [\fIascii\fR|\fIpng\fR], \fB\-o\fR/\fB\-\-output\fR \fIPATH\fR.
|
||||
.RE
|
||||
.TP
|
||||
.B channel status
|
||||
Show channel key status and configuration.
|
||||
.TP
|
||||
.B channel clear
|
||||
Remove channel key configuration.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-project\fR, \fB\-\-user\fR.
|
||||
.RE
|
||||
.SS admin
|
||||
Web UI administration commands.
|
||||
.PP
|
||||
.B stegasoo admin
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B admin generate\-key
|
||||
Generate a new recovery key (for reference only).
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-qr\fR (show QR code in terminal).
|
||||
.RE
|
||||
.TP
|
||||
.B admin recover
|
||||
Reset admin password using recovery key.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||
.RE
|
||||
.SS audio\-encode
|
||||
Encode a message or file into an audio file.
|
||||
.PP
|
||||
.B stegasoo audio\-encode
|
||||
.I audio
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||
Message to encode.
|
||||
.TP
|
||||
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||
File to embed instead of message.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output audio path.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-mode " " [\fIlsb\fR|\fIspread\fR]
|
||||
Embedding mode: lsb (default) or spread (spread spectrum).
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo audio-encode song.wav -r ref.jpg -m "Secret" --passphrase --pin
|
||||
stegasoo audio-encode podcast.mp3 -r ref.jpg -f doc.pdf --mode spread
|
||||
.fi
|
||||
.SS audio\-decode
|
||||
Decode a message or file from a stego audio file.
|
||||
.PP
|
||||
.B stegasoo audio\-decode
|
||||
.I audio
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output path for file payloads.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo audio-decode stego.wav -r ref.jpg --passphrase --pin
|
||||
stegasoo audio-decode stego.wav -r ref.jpg -o ./extracted/
|
||||
.fi
|
||||
.SS audio\-info
|
||||
Display audio file information and steganographic capacity.
|
||||
.PP
|
||||
.B stegasoo audio\-info
|
||||
.I audio
|
||||
[\fB\-\-json\fR]
|
||||
.PP
|
||||
Shows format, sample rate, channels, bit depth, duration, and embedding
|
||||
capacity for both LSB and Spread Spectrum modes.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo audio-info song.wav
|
||||
stegasoo audio-info podcast.mp3 --json
|
||||
.fi
|
||||
.SS tools
|
||||
Image security tools.
|
||||
.PP
|
||||
.B stegasoo tools
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B tools capacity
|
||||
Show steganography capacity for an image.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools capacity
|
||||
.I image
|
||||
[\fB\-\-json\fR]
|
||||
.RE
|
||||
.TP
|
||||
.B tools exif
|
||||
View or edit EXIF metadata.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools exif
|
||||
.I image
|
||||
[\fB\-\-clear\fR] [\fB\-\-set\fR \fIFIELD=VALUE\fR] [\fB\-o\fR \fIPATH\fR] [\fB\-\-json\fR]
|
||||
.RE
|
||||
.TP
|
||||
.B tools peek
|
||||
Check if image contains Stegasoo hidden data.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools peek
|
||||
.I image
|
||||
[\fB\-\-json\fR]
|
||||
.RE
|
||||
.TP
|
||||
.B tools strip
|
||||
Strip EXIF/metadata from an image.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools strip
|
||||
.I image
|
||||
[\fB\-o\fR \fIPATH\fR] [\fB\-\-format\fR [\fIpng\fR|\fIbmp\fR]]
|
||||
.RE
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B STEGASOO_CHANNEL_KEY
|
||||
Channel key for encode/decode operations. Overrides config file settings.
|
||||
.TP
|
||||
.B STEGASOO_HTTPS_ENABLED
|
||||
Enable HTTPS for web UI (Docker/service mode).
|
||||
.TP
|
||||
.B STEGASOO_HOSTNAME
|
||||
Hostname for SSL certificate generation.
|
||||
.SH FILES
|
||||
.TP
|
||||
.I ~/.stegasoo/channel.key
|
||||
User's channel key configuration (encrypted).
|
||||
.TP
|
||||
.I .stegasoo.toml
|
||||
Project-level configuration file.
|
||||
.TP
|
||||
.I frontends/web/instance/stegasoo.db
|
||||
Web UI SQLite database (accounts, settings).
|
||||
.SH EXAMPLES
|
||||
.SS Basic encode/decode workflow
|
||||
.nf
|
||||
# Generate credentials
|
||||
stegasoo generate
|
||||
|
||||
# Encode a secret message
|
||||
stegasoo encode vacation.png -r selfie.jpg -m "Meet at noon"
|
||||
|
||||
# Decode the message (on another system with same reference photo)
|
||||
stegasoo decode vacation_steg.png -r selfie.jpg
|
||||
.fi
|
||||
.SS Using channel keys for team isolation
|
||||
.nf
|
||||
# Generate and save a channel key
|
||||
stegasoo channel generate --save-user
|
||||
|
||||
# Share the key with your team
|
||||
stegasoo channel qr -o team-key.png
|
||||
|
||||
# Now all encode/decode operations use this channel
|
||||
stegasoo encode photo.png -r ref.jpg -m "Team secret"
|
||||
.fi
|
||||
.SS Batch processing
|
||||
.nf
|
||||
# Check capacity of all PNGs in a directory
|
||||
stegasoo batch check ./photos/*.png
|
||||
|
||||
# Encode same message into multiple images
|
||||
stegasoo batch encode ./photos/ -r ref.jpg -m "Secret" -o ./encoded/
|
||||
.fi
|
||||
.SH SECURITY
|
||||
Stegasoo uses multiple layers of security:
|
||||
.IP \(bu 2
|
||||
Reference photo provides a visual shared secret
|
||||
.IP \(bu 2
|
||||
Passphrase (recommend 4+ words) for strong encryption
|
||||
.IP \(bu 2
|
||||
PIN code adds additional entropy
|
||||
.IP \(bu 2
|
||||
Channel keys isolate different deployments
|
||||
.IP \(bu 2
|
||||
AES-256 encryption for payload data
|
||||
.PP
|
||||
For maximum security, share the reference photo out-of-band (in person,
|
||||
secure messenger) and use a strong passphrase.
|
||||
.SH SEE ALSO
|
||||
.BR openssl (1),
|
||||
.BR qrencode (1)
|
||||
.SH BUGS
|
||||
Report bugs at: https://github.com/adlee-was-taken/stegasoo/issues
|
||||
.SH AUTHOR
|
||||
Written by the Stegasoo contributors.
|
||||
.SH COPYRIGHT
|
||||
Copyright \(co 2024-2026. MIT License.
|
||||
48
examples/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Stegasoo Examples
|
||||
|
||||
This directory contains example scripts demonstrating how to use Stegasoo.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Stegasoo first:
|
||||
|
||||
```bash
|
||||
pip install stegasoo
|
||||
# Or for development:
|
||||
pip install -e ".[all]"
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### basic_usage.py
|
||||
|
||||
Basic encode/decode workflow with a text message.
|
||||
|
||||
```bash
|
||||
python basic_usage.py
|
||||
```
|
||||
|
||||
### embed_file.py
|
||||
|
||||
Embed and extract files (documents, images, etc.) inside carrier images.
|
||||
|
||||
```bash
|
||||
python embed_file.py
|
||||
```
|
||||
|
||||
### channel_keys.py
|
||||
|
||||
Use channel keys to create private communication channels for groups.
|
||||
|
||||
```bash
|
||||
python channel_keys.py
|
||||
```
|
||||
|
||||
## Test Images
|
||||
|
||||
You'll need to provide your own images:
|
||||
|
||||
- `my_secret_photo.png` - Your reference photo (keep this secret!)
|
||||
- `carrier.png` - The image that will carry your hidden message
|
||||
|
||||
For testing, you can use any PNG or BMP image. JPEG carriers are supported with DCT mode.
|
||||
59
examples/basic_usage.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Stegasoo Usage Example
|
||||
|
||||
This example demonstrates how to encode and decode a secret message
|
||||
using the Stegasoo library.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import stegasoo
|
||||
|
||||
|
||||
def main():
|
||||
# Load your images
|
||||
# The reference photo is your "key" - keep it secret!
|
||||
reference_photo = Path("my_secret_photo.png").read_bytes()
|
||||
carrier_image = Path("carrier.png").read_bytes()
|
||||
|
||||
# Your secret message
|
||||
message = "This is my secret message!"
|
||||
|
||||
# Your credentials
|
||||
passphrase = "correct horse battery staple" # Use 4+ words
|
||||
pin = "123456" # 6-9 digits
|
||||
|
||||
# === ENCODE ===
|
||||
print("Encoding message...")
|
||||
result = stegasoo.encode(
|
||||
message=message,
|
||||
reference_photo=reference_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
# Save the stego image
|
||||
output_path = Path(f"secret_{result.suggested_filename}")
|
||||
output_path.write_bytes(result.stego_image)
|
||||
print(f"Saved to: {output_path}")
|
||||
print(f"Capacity used: {result.capacity_used_percent:.1f}%")
|
||||
|
||||
# === DECODE ===
|
||||
print("\nDecoding message...")
|
||||
stego_image = output_path.read_bytes()
|
||||
|
||||
decoded = stegasoo.decode(
|
||||
stego_image=stego_image,
|
||||
reference_photo=reference_photo,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
print(f"Decoded message: {decoded.message}")
|
||||
print(f"Message type: {decoded.payload_type}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
72
examples/channel_keys.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Channel Keys Example
|
||||
|
||||
Channel keys allow you to create private communication channels.
|
||||
Only people with the same channel key can decode messages.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import stegasoo
|
||||
from stegasoo.channel import generate_channel_key, get_channel_fingerprint
|
||||
|
||||
|
||||
def main():
|
||||
# Generate a channel key for your group
|
||||
channel_key = generate_channel_key()
|
||||
fingerprint = get_channel_fingerprint(channel_key)
|
||||
|
||||
print("=== Channel Key Generated ===")
|
||||
print(f"Key: {channel_key}")
|
||||
print(f"Fingerprint: {fingerprint}")
|
||||
print("\nShare this key securely with your group members!")
|
||||
print("-" * 40)
|
||||
|
||||
# Load images
|
||||
reference_photo = Path("my_secret_photo.png").read_bytes()
|
||||
carrier_image = Path("carrier.png").read_bytes()
|
||||
|
||||
# Encode with channel key
|
||||
print("\nEncoding message with channel key...")
|
||||
result = stegasoo.encode(
|
||||
message="Secret group message!",
|
||||
reference_photo=reference_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
channel_key=channel_key, # Add the channel key
|
||||
)
|
||||
|
||||
stego_data = result.stego_image
|
||||
print(f"Encoded successfully!")
|
||||
|
||||
# Decode with correct channel key
|
||||
print("\nDecoding with correct channel key...")
|
||||
decoded = stegasoo.decode(
|
||||
stego_image=stego_data,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
channel_key=channel_key, # Same channel key
|
||||
)
|
||||
print(f"Message: {decoded.message}")
|
||||
|
||||
# Try to decode with wrong channel key
|
||||
print("\nTrying to decode with wrong channel key...")
|
||||
wrong_key = generate_channel_key()
|
||||
try:
|
||||
stegasoo.decode(
|
||||
stego_image=stego_data,
|
||||
reference_photo=reference_photo,
|
||||
passphrase="correct horse battery staple",
|
||||
pin="123456",
|
||||
channel_key=wrong_key, # Different channel key
|
||||
)
|
||||
print("ERROR: Should have failed!")
|
||||
except (stegasoo.DecryptionError, stegasoo.ExtractionError):
|
||||
print("Correctly rejected - wrong channel key!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
examples/embed_file.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
File Embedding Example
|
||||
|
||||
This example demonstrates how to embed a file (like a document or image)
|
||||
inside a carrier image using Stegasoo.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import stegasoo
|
||||
from stegasoo.models import FilePayload
|
||||
|
||||
|
||||
def main():
|
||||
# Load images
|
||||
reference_photo = Path("my_secret_photo.png").read_bytes()
|
||||
carrier_image = Path("carrier.png").read_bytes()
|
||||
|
||||
# Load the file to embed
|
||||
secret_file = Path("secret_document.pdf")
|
||||
file_data = secret_file.read_bytes()
|
||||
|
||||
# Create a FilePayload
|
||||
payload = FilePayload(
|
||||
filename=secret_file.name,
|
||||
data=file_data,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
# Credentials
|
||||
passphrase = "correct horse battery staple"
|
||||
pin = "123456"
|
||||
|
||||
# Check capacity first
|
||||
capacity = stegasoo.calculate_capacity(carrier_image)
|
||||
print(f"Carrier capacity: {capacity['capacity_bytes']:,} bytes")
|
||||
print(f"File size: {len(file_data):,} bytes")
|
||||
|
||||
if len(file_data) > capacity["capacity_bytes"]:
|
||||
print("Error: File too large for this carrier!")
|
||||
return
|
||||
|
||||
# Encode the file
|
||||
print("\nEmbedding file...")
|
||||
result = stegasoo.encode(
|
||||
file_payload=payload,
|
||||
reference_photo=reference_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
output_path = Path(f"contains_file_{result.suggested_filename}")
|
||||
output_path.write_bytes(result.stego_image)
|
||||
print(f"Saved to: {output_path}")
|
||||
|
||||
# Decode and extract the file
|
||||
print("\nExtracting file...")
|
||||
decoded = stegasoo.decode(
|
||||
stego_image=output_path.read_bytes(),
|
||||
reference_photo=reference_photo,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
if decoded.payload_type == "file":
|
||||
extracted_path = Path(f"extracted_{decoded.filename}")
|
||||
extracted_path.write_bytes(decoded.file_data)
|
||||
print(f"Extracted: {extracted_path}")
|
||||
print(f"Original filename: {decoded.filename}")
|
||||
print(f"MIME type: {decoded.mime_type}")
|
||||
else:
|
||||
print(f"Unexpected payload type: {decoded.payload_type}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,500 +0,0 @@
|
||||
# API Update Summary for v3.2.0
|
||||
|
||||
## Overview
|
||||
|
||||
The FastAPI REST API has been updated to align with Stegasoo v3.2.0's breaking changes:
|
||||
1. **Removed date dependency** - No `date_str` field in requests
|
||||
2. **Renamed day_phrase → passphrase** - Updated all request/response models
|
||||
3. **Updated generation** - Now generates single passphrase instead of daily phrases
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### Request Model Changes
|
||||
|
||||
#### 1. EncodeRequest & EncodeFileRequest
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class EncodeRequest(BaseModel):
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_image_base64: str
|
||||
day_phrase: str # ← Changed to passphrase
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
date_str: Optional[str] = None # ← REMOVED
|
||||
embed_mode: EmbedModeType = "lsb"
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class EncodeRequest(BaseModel):
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_image_base64: str
|
||||
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode: EmbedModeType = "lsb"
|
||||
dct_output_format: DctOutputFormatType = "png"
|
||||
dct_color_mode: DctColorModeType = "grayscale"
|
||||
```
|
||||
|
||||
#### 2. DecodeRequest
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class DecodeRequest(BaseModel):
|
||||
stego_image_base64: str
|
||||
reference_photo_base64: str
|
||||
day_phrase: str # ← Changed to passphrase
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
embed_mode: ExtractModeType = "auto"
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class DecodeRequest(BaseModel):
|
||||
stego_image_base64: str
|
||||
reference_photo_base64: str
|
||||
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
|
||||
pin: str = ""
|
||||
rsa_key_base64: Optional[str] = None
|
||||
rsa_password: Optional[str] = None
|
||||
embed_mode: ExtractModeType = "auto"
|
||||
```
|
||||
|
||||
#### 3. GenerateRequest
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class GenerateRequest(BaseModel):
|
||||
use_pin: bool = True
|
||||
use_rsa: bool = False
|
||||
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
|
||||
rsa_bits: int = Field(default=2048)
|
||||
words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS)
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class GenerateRequest(BaseModel):
|
||||
use_pin: bool = True
|
||||
use_rsa: bool = False
|
||||
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
|
||||
rsa_bits: int = Field(default=2048)
|
||||
words_per_passphrase: int = Field(
|
||||
default=DEFAULT_PASSPHRASE_WORDS, # = 4, was 3
|
||||
ge=MIN_PASSPHRASE_WORDS,
|
||||
le=MAX_PASSPHRASE_WORDS,
|
||||
description="Words per passphrase (v3.2.0: default increased to 4)"
|
||||
)
|
||||
```
|
||||
|
||||
### Response Model Changes
|
||||
|
||||
#### 1. GenerateResponse
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class GenerateResponse(BaseModel):
|
||||
phrases: dict[str, str] # Monday -> phrase, Tuesday -> phrase, etc.
|
||||
pin: Optional[str] = None
|
||||
rsa_key_pem: Optional[str] = None
|
||||
entropy: dict[str, int]
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class GenerateResponse(BaseModel):
|
||||
passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
|
||||
pin: Optional[str] = None
|
||||
rsa_key_pem: Optional[str] = None
|
||||
entropy: dict[str, int]
|
||||
# Legacy field for compatibility
|
||||
phrases: Optional[dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="Deprecated: Use 'passphrase' instead"
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. EncodeResponse
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
class EncodeResponse(BaseModel):
|
||||
stego_image_base64: str
|
||||
filename: str
|
||||
capacity_used_percent: float
|
||||
date_used: str
|
||||
day_of_week: str
|
||||
embed_mode: str
|
||||
output_format: str = "png"
|
||||
color_mode: str = "color"
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
class EncodeResponse(BaseModel):
|
||||
stego_image_base64: str
|
||||
filename: str
|
||||
capacity_used_percent: float
|
||||
embed_mode: str
|
||||
output_format: str = "png"
|
||||
color_mode: str = "color"
|
||||
# Legacy fields (no longer used in crypto)
|
||||
date_used: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Deprecated: Date no longer used in v3.2.0"
|
||||
)
|
||||
day_of_week: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Deprecated: Date no longer used in v3.2.0"
|
||||
)
|
||||
```
|
||||
|
||||
### Endpoint Changes
|
||||
|
||||
#### 1. POST /encode
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```json
|
||||
{
|
||||
"message": "Secret message",
|
||||
"reference_photo_base64": "...",
|
||||
"carrier_image_base64": "...",
|
||||
"day_phrase": "apple forest thunder",
|
||||
"date_str": "2025-01-15",
|
||||
"pin": "123456",
|
||||
"embed_mode": "lsb"
|
||||
}
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```json
|
||||
{
|
||||
"message": "Secret message",
|
||||
"reference_photo_base64": "...",
|
||||
"carrier_image_base64": "...",
|
||||
"passphrase": "apple forest thunder mountain",
|
||||
"pin": "123456",
|
||||
"embed_mode": "lsb"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. POST /decode
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```json
|
||||
{
|
||||
"stego_image_base64": "...",
|
||||
"reference_photo_base64": "...",
|
||||
"day_phrase": "apple forest thunder",
|
||||
"pin": "123456",
|
||||
"embed_mode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```json
|
||||
{
|
||||
"stego_image_base64": "...",
|
||||
"reference_photo_base64": "...",
|
||||
"passphrase": "apple forest thunder mountain",
|
||||
"pin": "123456",
|
||||
"embed_mode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. POST /generate
|
||||
|
||||
**Response Before (v3.1.0):**
|
||||
```json
|
||||
{
|
||||
"phrases": {
|
||||
"Monday": "apple forest thunder",
|
||||
"Tuesday": "banana river lightning",
|
||||
...
|
||||
},
|
||||
"pin": "123456",
|
||||
"rsa_key_pem": null,
|
||||
"entropy": {
|
||||
"phrase": 33,
|
||||
"pin": 20,
|
||||
"rsa": 0,
|
||||
"total": 53
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response After (v3.2.0):**
|
||||
```json
|
||||
{
|
||||
"passphrase": "apple forest thunder mountain",
|
||||
"pin": "123456",
|
||||
"rsa_key_pem": null,
|
||||
"entropy": {
|
||||
"passphrase": 44,
|
||||
"pin": 20,
|
||||
"rsa": 0,
|
||||
"total": 64
|
||||
},
|
||||
"phrases": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. POST /encode/multipart
|
||||
|
||||
**Form Fields Before (v3.1.0):**
|
||||
- `day_phrase` (required)
|
||||
- `date_str` (optional)
|
||||
- `reference_photo` (file)
|
||||
- `carrier` (file)
|
||||
- ...
|
||||
|
||||
**Form Fields After (v3.2.0):**
|
||||
- `passphrase` (required) ← renamed from day_phrase
|
||||
- `reference_photo` (file)
|
||||
- `carrier` (file)
|
||||
- ... (date_str removed)
|
||||
|
||||
**Response Headers Before (v3.1.0):**
|
||||
```
|
||||
X-Stegasoo-Date: 2025-01-15
|
||||
X-Stegasoo-Day: Wednesday
|
||||
X-Stegasoo-Capacity-Percent: 25.5
|
||||
X-Stegasoo-Embed-Mode: lsb
|
||||
```
|
||||
|
||||
**Response Headers After (v3.2.0):**
|
||||
```
|
||||
X-Stegasoo-Capacity-Percent: 25.5
|
||||
X-Stegasoo-Embed-Mode: lsb
|
||||
X-Stegasoo-Output-Format: png
|
||||
X-Stegasoo-Color-Mode: color
|
||||
X-Stegasoo-Version: 3.2.0
|
||||
```
|
||||
|
||||
### New Status Endpoint Information
|
||||
|
||||
#### GET /
|
||||
|
||||
**Added to response:**
|
||||
```json
|
||||
{
|
||||
"version": "3.2.0",
|
||||
...
|
||||
"breaking_changes": {
|
||||
"date_removed": "No date_str parameter needed - encode/decode anytime",
|
||||
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
|
||||
"format_version": 4,
|
||||
"backward_compatible": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Guide for API Clients
|
||||
|
||||
### 1. Update Request Bodies
|
||||
|
||||
**Find and replace in client code:**
|
||||
```javascript
|
||||
// Before
|
||||
{
|
||||
day_phrase: "apple forest thunder",
|
||||
date_str: "2025-01-15"
|
||||
}
|
||||
|
||||
// After
|
||||
{
|
||||
passphrase: "apple forest thunder mountain"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Response Handling
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
const response = await fetch('/encode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: "secret",
|
||||
day_phrase: "words",
|
||||
date_str: "2025-01-15",
|
||||
...
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data.date_used); // "2025-01-15"
|
||||
console.log(data.day_of_week); // "Wednesday"
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const response = await fetch('/encode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: "secret",
|
||||
passphrase: "longer words here now",
|
||||
// date_str removed
|
||||
...
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// date_used and day_of_week are null in v3.2.0
|
||||
```
|
||||
|
||||
### 3. Update Generate Endpoint Usage
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
const creds = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ use_pin: true })
|
||||
}).then(r => r.json());
|
||||
|
||||
// Use Monday's phrase
|
||||
const mondayPhrase = creds.phrases['Monday'];
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const creds = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ use_pin: true })
|
||||
}).then(r => r.json());
|
||||
|
||||
// Use single passphrase
|
||||
const passphrase = creds.passphrase;
|
||||
```
|
||||
|
||||
### 4. Update Multipart Requests
|
||||
|
||||
**Before (JavaScript fetch):**
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('day_phrase', 'apple forest thunder');
|
||||
formData.append('date_str', '2025-01-15');
|
||||
formData.append('reference_photo', refPhotoFile);
|
||||
formData.append('carrier', carrierFile);
|
||||
formData.append('message', 'secret');
|
||||
formData.append('pin', '123456');
|
||||
|
||||
const response = await fetch('/encode/multipart', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
**After (JavaScript fetch):**
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('passphrase', 'apple forest thunder mountain');
|
||||
// date_str removed
|
||||
formData.append('reference_photo', refPhotoFile);
|
||||
formData.append('carrier', carrierFile);
|
||||
formData.append('message', 'secret');
|
||||
formData.append('pin', '123456');
|
||||
|
||||
const response = await fetch('/encode/multipart', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Endpoints to Test
|
||||
|
||||
- [ ] GET / - Returns v3.2.0 with breaking_changes info
|
||||
- [ ] GET /modes - Returns mode information
|
||||
- [ ] POST /generate - Returns single passphrase
|
||||
- [ ] POST /encode - Works without date_str
|
||||
- [ ] POST /encode/file - Works without date_str
|
||||
- [ ] POST /decode - Works without date_str
|
||||
- [ ] POST /encode/multipart - Accepts passphrase instead of day_phrase
|
||||
- [ ] POST /decode/multipart - Accepts passphrase instead of day_phrase
|
||||
- [ ] POST /compare - Still works
|
||||
- [ ] POST /will-fit - Still works
|
||||
- [ ] POST /image/info - Still works
|
||||
- [ ] POST /extract-key-from-qr - Still works
|
||||
|
||||
### Validation Tests
|
||||
|
||||
- [ ] Reject requests with `day_phrase` field (should get validation error)
|
||||
- [ ] Reject requests with `date_str` field (should be ignored or error)
|
||||
- [ ] Accept requests with `passphrase` field
|
||||
- [ ] Generate response includes `passphrase` field
|
||||
- [ ] Generate response has `phrases` as null
|
||||
- [ ] Encode response has `date_used` and `day_of_week` as null
|
||||
- [ ] Multipart encode works with new field names
|
||||
- [ ] Response headers updated correctly
|
||||
|
||||
## OpenAPI/Swagger Documentation
|
||||
|
||||
The FastAPI auto-generated documentation (/docs and /redoc) will automatically reflect the changes:
|
||||
|
||||
1. **Models updated** - Request/response schemas show new field names
|
||||
2. **Descriptions updated** - Field descriptions mention v3.2.0 changes
|
||||
3. **Examples updated** - Interactive API explorer uses new field names
|
||||
|
||||
Users can browse to `/docs` to see the updated API specification.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
**Breaking Change:** API v3.2.0 is NOT backward compatible with v3.1.0
|
||||
|
||||
Clients using the old API will encounter:
|
||||
1. **Validation errors** - Missing required `passphrase` field
|
||||
2. **Unexpected responses** - `phrases` field will be null
|
||||
3. **Changed behavior** - Date fields no longer populated
|
||||
|
||||
### Migration Timeline Recommendation
|
||||
|
||||
1. **Deploy v3.2.0 API** to staging
|
||||
2. **Update client applications** to use new field names
|
||||
3. **Test thoroughly** with staging API
|
||||
4. **Deploy v3.2.0 API** to production
|
||||
5. **Notify users** of breaking changes
|
||||
|
||||
Alternatively, run v3.1.0 and v3.2.0 APIs side-by-side on different paths:
|
||||
- `/api/v3.1/` - Old API
|
||||
- `/api/v3.2/` - New API
|
||||
|
||||
## Constants Updates
|
||||
|
||||
Used in validation:
|
||||
```python
|
||||
from stegasoo.constants import (
|
||||
MIN_PASSPHRASE_WORDS, # = 3
|
||||
MAX_PASSPHRASE_WORDS, # = 12
|
||||
DEFAULT_PASSPHRASE_WORDS, # = 4 (increased from 3)
|
||||
)
|
||||
```
|
||||
|
||||
## Error Messages
|
||||
|
||||
All error messages updated:
|
||||
- "day_phrase is required" → "passphrase is required"
|
||||
- References to "phrase" now mean "passphrase"
|
||||
|
||||
## Implementation Status
|
||||
|
||||
✅ All request models updated
|
||||
✅ All response models updated
|
||||
✅ All endpoints updated
|
||||
✅ Multipart endpoints updated
|
||||
✅ Status endpoint shows breaking changes
|
||||
✅ Constants imported correctly
|
||||
✅ Error handling updated
|
||||
✅ No references to day_phrase in user-facing text
|
||||
✅ No date_str parameters accepted
|
||||
|
||||
Ready for deployment!
|
||||
0
frontends/api/__init__.py
Normal file
257
frontends/api/auth.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
API Key Authentication for Stegasoo REST API.
|
||||
|
||||
Provides simple API key authentication with hashed key storage.
|
||||
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
|
||||
|
||||
Usage:
|
||||
from .auth import require_api_key, get_api_key_status
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected_endpoint(api_key: str = Depends(require_api_key)):
|
||||
return {"status": "authenticated"}
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
# API key header name
|
||||
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
# Config locations
|
||||
USER_CONFIG_DIR = Path.home() / ".stegasoo"
|
||||
PROJECT_CONFIG_DIR = Path("./config")
|
||||
|
||||
# Key file name
|
||||
API_KEYS_FILE = "api_keys.json"
|
||||
|
||||
# Environment variable for API key (alternative to file)
|
||||
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
|
||||
|
||||
|
||||
def _hash_key(key: str) -> str:
|
||||
"""Hash an API key for storage."""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_keys_file(location: str = "user") -> Path:
|
||||
"""Get path to API keys file."""
|
||||
if location == "project":
|
||||
return PROJECT_CONFIG_DIR / API_KEYS_FILE
|
||||
return USER_CONFIG_DIR / API_KEYS_FILE
|
||||
|
||||
|
||||
def _load_keys(location: str = "user") -> dict:
|
||||
"""Load API keys from config file."""
|
||||
keys_file = _get_keys_file(location)
|
||||
if keys_file.exists():
|
||||
try:
|
||||
with open(keys_file) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"keys": [], "enabled": True}
|
||||
return {"keys": [], "enabled": True}
|
||||
|
||||
|
||||
def _save_keys(data: dict, location: str = "user") -> None:
|
||||
"""Save API keys to config file."""
|
||||
keys_file = _get_keys_file(location)
|
||||
keys_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(keys_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Secure permissions (owner read/write only)
|
||||
os.chmod(keys_file, 0o600)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a new API key."""
|
||||
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
# 32 bytes = 256 bits of entropy
|
||||
random_part = secrets.token_hex(16)
|
||||
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
|
||||
|
||||
|
||||
def add_api_key(name: str, location: str = "user") -> str:
|
||||
"""
|
||||
Generate and store a new API key.
|
||||
|
||||
Args:
|
||||
name: Descriptive name for the key (e.g., "laptop", "automation")
|
||||
location: "user" or "project"
|
||||
|
||||
Returns:
|
||||
The generated API key (only shown once!)
|
||||
"""
|
||||
key = generate_api_key()
|
||||
key_hash = _hash_key(key)
|
||||
|
||||
data = _load_keys(location)
|
||||
|
||||
# Check for duplicate name
|
||||
for existing in data["keys"]:
|
||||
if existing["name"] == name:
|
||||
raise ValueError(f"Key with name '{name}' already exists")
|
||||
|
||||
data["keys"].append(
|
||||
{
|
||||
"name": name,
|
||||
"hash": key_hash,
|
||||
"created": __import__("datetime").datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
_save_keys(data, location)
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def remove_api_key(name: str, location: str = "user") -> bool:
|
||||
"""
|
||||
Remove an API key by name.
|
||||
|
||||
Returns:
|
||||
True if key was found and removed, False otherwise
|
||||
"""
|
||||
data = _load_keys(location)
|
||||
original_count = len(data["keys"])
|
||||
|
||||
data["keys"] = [k for k in data["keys"] if k["name"] != name]
|
||||
|
||||
if len(data["keys"]) < original_count:
|
||||
_save_keys(data, location)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_api_keys(location: str = "user") -> list[dict]:
|
||||
"""
|
||||
List all API keys (names and creation dates, not actual keys).
|
||||
"""
|
||||
data = _load_keys(location)
|
||||
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
|
||||
|
||||
|
||||
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
|
||||
"""Enable or disable API key authentication."""
|
||||
data = _load_keys(location)
|
||||
data["enabled"] = enabled
|
||||
_save_keys(data, location)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Check if API key authentication is enabled."""
|
||||
# Check project config first, then user config
|
||||
for location in ["project", "user"]:
|
||||
data = _load_keys(location)
|
||||
if "enabled" in data:
|
||||
return data["enabled"]
|
||||
|
||||
# Default: enabled if any keys exist
|
||||
return bool(get_all_key_hashes())
|
||||
|
||||
|
||||
def get_all_key_hashes() -> set[str]:
|
||||
"""Get all valid API key hashes from all sources."""
|
||||
hashes = set()
|
||||
|
||||
# Check environment variable first
|
||||
env_key = os.environ.get(API_KEY_ENV_VAR)
|
||||
if env_key:
|
||||
hashes.add(_hash_key(env_key))
|
||||
|
||||
# Check project and user configs
|
||||
for location in ["project", "user"]:
|
||||
data = _load_keys(location)
|
||||
for key_entry in data.get("keys", []):
|
||||
if "hash" in key_entry:
|
||||
hashes.add(key_entry["hash"])
|
||||
|
||||
return hashes
|
||||
|
||||
|
||||
def validate_api_key(key: str) -> bool:
|
||||
"""Validate an API key against stored hashes."""
|
||||
if not key:
|
||||
return False
|
||||
|
||||
key_hash = _hash_key(key)
|
||||
valid_hashes = get_all_key_hashes()
|
||||
|
||||
return key_hash in valid_hashes
|
||||
|
||||
|
||||
def get_api_key_status() -> dict:
|
||||
"""Get current API key authentication status."""
|
||||
user_keys = list_api_keys("user")
|
||||
project_keys = list_api_keys("project")
|
||||
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
|
||||
|
||||
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
|
||||
|
||||
return {
|
||||
"enabled": is_auth_enabled(),
|
||||
"total_keys": total_keys,
|
||||
"user_keys": len(user_keys),
|
||||
"project_keys": len(project_keys),
|
||||
"env_configured": env_configured,
|
||||
"keys": {
|
||||
"user": user_keys,
|
||||
"project": project_keys,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# FastAPI dependency for API key authentication
|
||||
async def require_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str:
|
||||
"""
|
||||
FastAPI dependency that requires a valid API key.
|
||||
|
||||
Usage:
|
||||
@app.get("/protected")
|
||||
async def endpoint(key: str = Depends(require_api_key)):
|
||||
...
|
||||
"""
|
||||
# Check if auth is enabled
|
||||
if not is_auth_enabled():
|
||||
return "auth_disabled"
|
||||
|
||||
# No keys configured = auth disabled
|
||||
if not get_all_key_hashes():
|
||||
return "no_keys_configured"
|
||||
|
||||
# Validate the provided key
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="API key required. Provide X-API-Key header.",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
if not validate_api_key(api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key.",
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
async def optional_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
||||
"""
|
||||
FastAPI dependency that optionally validates API key.
|
||||
|
||||
Returns the key if valid, None if not provided or invalid.
|
||||
Doesn't raise exceptions - useful for endpoints that work
|
||||
with or without auth.
|
||||
"""
|
||||
if api_key and validate_api_key(api_key):
|
||||
return api_key
|
||||
return None
|
||||
0
frontends/cli/__init__.py
Normal file
@@ -24,11 +24,31 @@ Usage:
|
||||
stegasoo channel [SUBCOMMAND]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Rich progress bar (optional)
|
||||
try:
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
)
|
||||
|
||||
HAS_RICH = True
|
||||
except ImportError:
|
||||
HAS_RICH = False
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
@@ -100,6 +120,7 @@ try:
|
||||
from stegasoo.qr_utils import ( # noqa: F401
|
||||
can_fit_in_qr,
|
||||
extract_key_from_qr_file,
|
||||
generate_qr_ascii,
|
||||
generate_qr_code,
|
||||
has_qr_read,
|
||||
has_qr_write,
|
||||
@@ -116,6 +137,9 @@ except ImportError:
|
||||
def has_qr_write() -> bool:
|
||||
return False
|
||||
|
||||
def generate_qr_ascii(*args, **kwargs):
|
||||
raise RuntimeError("QR code generation not available")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI SETUP
|
||||
@@ -168,37 +192,25 @@ def resolve_channel_key_option(
|
||||
"""
|
||||
Resolve channel key from CLI options.
|
||||
|
||||
Wrapper around library's resolve_channel_key with Click exception handling.
|
||||
|
||||
Returns:
|
||||
None: Use server-configured key (auto mode)
|
||||
"": Public mode (no channel key)
|
||||
str: Explicit channel key
|
||||
"""
|
||||
if no_channel:
|
||||
return "" # Public mode
|
||||
from stegasoo.channel import resolve_channel_key
|
||||
|
||||
if channel_file:
|
||||
# Load from file
|
||||
path = Path(channel_file)
|
||||
if not path.exists():
|
||||
raise click.ClickException(f"Channel key file not found: {channel_file}")
|
||||
key = path.read_text().strip()
|
||||
if not validate_channel_key(key):
|
||||
raise click.ClickException(f"Invalid channel key format in file: {channel_file}")
|
||||
return key
|
||||
|
||||
if channel:
|
||||
if channel.lower() == "auto":
|
||||
return None # Use server config
|
||||
# Explicit key provided
|
||||
if not validate_channel_key(channel):
|
||||
raise click.ClickException(
|
||||
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
|
||||
"Generate a new key with: stegasoo channel generate"
|
||||
)
|
||||
return channel
|
||||
|
||||
# Default: use server-configured key (auto mode)
|
||||
return None
|
||||
try:
|
||||
return resolve_channel_key(
|
||||
value=channel,
|
||||
file_path=channel_file,
|
||||
no_channel=no_channel,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise click.ClickException(str(e))
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
@@ -228,7 +240,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
||||
)
|
||||
@click.option(
|
||||
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
|
||||
"--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
|
||||
)
|
||||
@click.option(
|
||||
"--words",
|
||||
@@ -239,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
||||
@click.option("--password", "-p", help="Password for RSA key file")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
@click.option(
|
||||
"--qr",
|
||||
type=click.Path(),
|
||||
help="Save RSA key QR code to file (png/jpg, uses zstd compression)",
|
||||
)
|
||||
@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal")
|
||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii):
|
||||
"""
|
||||
Generate credentials for encoding/decoding.
|
||||
|
||||
@@ -253,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
Examples:
|
||||
stegasoo generate
|
||||
stegasoo generate --words 5
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa --rsa-bits 3072
|
||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||
stegasoo generate --rsa --qr key.png
|
||||
stegasoo generate --rsa --qr-ascii
|
||||
stegasoo generate --no-pin --rsa
|
||||
"""
|
||||
if not pin and not rsa:
|
||||
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
||||
|
||||
if (qr or qr_ascii) and not rsa:
|
||||
raise click.UsageError("QR output requires --rsa to generate an RSA key")
|
||||
|
||||
if output and not password:
|
||||
raise click.UsageError("--password is required when saving RSA key to file")
|
||||
|
||||
@@ -326,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
click.echo(creds.rsa_key_pem)
|
||||
click.echo()
|
||||
|
||||
# QR code output (v4.2.0)
|
||||
if qr:
|
||||
if not HAS_QR:
|
||||
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||
else:
|
||||
# Determine format from extension
|
||||
qr_path = Path(qr)
|
||||
ext = qr_path.suffix.lower()
|
||||
fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png"
|
||||
|
||||
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||
qr_path.write_bytes(qr_bytes)
|
||||
click.secho("─── RSA KEY QR CODE ───", fg="green")
|
||||
click.secho(f" Saved to: {qr}", fg="bright_white")
|
||||
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||
click.echo()
|
||||
|
||||
if qr_ascii:
|
||||
if not HAS_QR:
|
||||
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||
else:
|
||||
click.secho("─── RSA KEY QR CODE (ASCII) ───", fg="green")
|
||||
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||
click.echo()
|
||||
ascii_qr = generate_qr_ascii(creds.rsa_key_pem, compress=True, invert=True)
|
||||
click.echo(ascii_qr)
|
||||
|
||||
click.secho("─── SECURITY ───", fg="green")
|
||||
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||||
if creds.pin:
|
||||
@@ -610,6 +660,73 @@ def channel_clear(project, clear_all, force):
|
||||
click.echo(" Mode is now: PUBLIC")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROGRESS BAR UTILITIES (v4.1.2)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _generate_progress_job_id() -> str:
|
||||
"""Generate a unique job ID for progress tracking."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def _get_progress_file_path(job_id: str) -> str:
|
||||
"""Get the progress file path for a job ID."""
|
||||
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||
|
||||
|
||||
def _read_progress(job_id: str) -> dict | None:
|
||||
"""Read progress from file for a job ID."""
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
try:
|
||||
with open(progress_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_progress_file(job_id: str) -> None:
|
||||
"""Remove progress file for a completed job."""
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
try:
|
||||
Path(progress_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_encode_with_progress(encode_func, encode_kwargs: dict, progress_file: str) -> tuple:
|
||||
"""
|
||||
Run encode in a thread and return result.
|
||||
|
||||
Returns:
|
||||
(success, result_or_error)
|
||||
"""
|
||||
result_holder = {"result": None, "error": None}
|
||||
|
||||
def run():
|
||||
try:
|
||||
result_holder["result"] = encode_func(**encode_kwargs, progress_file=progress_file)
|
||||
except Exception as e:
|
||||
result_holder["error"] = e
|
||||
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
return thread, result_holder
|
||||
|
||||
|
||||
def _format_phase(phase: str) -> str:
|
||||
"""Format phase name for display."""
|
||||
phases = {
|
||||
"starting": "Starting",
|
||||
"initializing": "Initializing",
|
||||
"embedding": "Embedding",
|
||||
"saving": "Saving",
|
||||
"finalizing": "Finalizing",
|
||||
"complete": "Complete",
|
||||
}
|
||||
return phases.get(phase, phase.capitalize())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE COMMAND
|
||||
# ============================================================================
|
||||
@@ -654,6 +771,7 @@ def channel_clear(project, clear_all, force):
|
||||
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
||||
)
|
||||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
||||
@click.option("--progress", is_flag=True, help="Show progress bar (requires rich)")
|
||||
def encode_cmd(
|
||||
ref,
|
||||
carrier,
|
||||
@@ -673,6 +791,7 @@ def encode_cmd(
|
||||
dct_output_format,
|
||||
dct_color_mode,
|
||||
quiet,
|
||||
progress,
|
||||
):
|
||||
"""
|
||||
Encode a secret message or file into an image.
|
||||
@@ -820,19 +939,63 @@ def encode_cmd(
|
||||
click.echo(channel_status)
|
||||
|
||||
# v4.0.0: Include channel_key parameter
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_key_password,
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format,
|
||||
dct_color_mode=dct_color_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
# v4.1.2: Progress bar support
|
||||
encode_kwargs = {
|
||||
"message": payload,
|
||||
"reference_photo": ref_photo,
|
||||
"carrier_image": carrier_image,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin or "",
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"rsa_password": effective_key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"dct_output_format": dct_output_format,
|
||||
"dct_color_mode": dct_color_mode,
|
||||
"channel_key": resolved_channel_key,
|
||||
}
|
||||
|
||||
if progress and HAS_RICH:
|
||||
# Run with progress bar
|
||||
job_id = _generate_progress_job_id()
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
|
||||
thread, result_holder = _run_encode_with_progress(encode, encode_kwargs, progress_file)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeElapsedColumn(),
|
||||
transient=True,
|
||||
) as progress_bar:
|
||||
task = progress_bar.add_task("Encoding...", total=100)
|
||||
|
||||
while thread.is_alive():
|
||||
prog = _read_progress(job_id)
|
||||
if prog:
|
||||
percent = prog.get("percent", 0)
|
||||
phase = _format_phase(prog.get("phase", "processing"))
|
||||
progress_bar.update(task, completed=percent, description=f"{phase}...")
|
||||
time.sleep(0.1)
|
||||
|
||||
# Final update
|
||||
progress_bar.update(task, completed=100, description="Complete!")
|
||||
|
||||
_cleanup_progress_file(job_id)
|
||||
|
||||
if result_holder["error"]:
|
||||
raise result_holder["error"]
|
||||
result = result_holder["result"]
|
||||
|
||||
elif progress and not HAS_RICH:
|
||||
click.secho(
|
||||
"Warning: --progress requires 'rich' package. Install with: pip install rich",
|
||||
fg="yellow",
|
||||
)
|
||||
result = encode(**encode_kwargs)
|
||||
else:
|
||||
result = encode(**encode_kwargs)
|
||||
|
||||
# Determine output path
|
||||
if output:
|
||||
|
||||
16
frontends/web/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Stegasoo Web UI Configuration
|
||||
# Copy this file to .env and customize
|
||||
|
||||
# Authentication (v4.0.2+)
|
||||
STEGASOO_AUTH_ENABLED=true
|
||||
STEGASOO_HTTPS_ENABLED=false
|
||||
STEGASOO_HOSTNAME=localhost
|
||||
STEGASOO_PORT=5000
|
||||
|
||||
# Channel Key (format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
|
||||
# Generate with: stegasoo generate --channel-key
|
||||
# Leave empty for public mode
|
||||
STEGASOO_CHANNEL_KEY=
|
||||
|
||||
# Flask settings
|
||||
FLASK_ENV=production
|
||||
@@ -1,426 +0,0 @@
|
||||
# Web Frontend Update Summary for v3.2.0
|
||||
|
||||
## Overview
|
||||
|
||||
The Flask web frontend has been updated to align with Stegasoo v3.2.0's breaking changes:
|
||||
1. **Removed date dependency** - No date selection or tracking in UI
|
||||
2. **Renamed day_phrase → passphrase** - Updated all forms and templates
|
||||
3. **Increased default words** - From 3 to 4 for better security
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Form Parameter Changes
|
||||
|
||||
#### Generate Page
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
words_per_phrase = int(request.form.get('words_per_phrase', 3))
|
||||
# Generated daily phrases for all days of the week
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
words_per_passphrase = int(request.form.get('words_per_passphrase', 4))
|
||||
# Generates single passphrase
|
||||
```
|
||||
|
||||
**Template variables changed:**
|
||||
- `phrases` → `passphrase` (single string instead of dict)
|
||||
- `words_per_phrase` → `words_per_passphrase`
|
||||
- `phrase_entropy` → `passphrase_entropy`
|
||||
- Removed `days` variable (no longer needed)
|
||||
|
||||
#### Encode Page
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
day_phrase = request.form.get('day_phrase', '')
|
||||
client_date = request.form.get('client_date', '').strip()
|
||||
day_of_week = get_today_day() # Used in template
|
||||
|
||||
encode_result = encode(
|
||||
...,
|
||||
day_phrase=day_phrase,
|
||||
date_str=date_str,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
passphrase = request.form.get('passphrase', '')
|
||||
# No client_date or day_of_week needed
|
||||
|
||||
encode_result = encode(
|
||||
...,
|
||||
passphrase=passphrase, # Renamed
|
||||
# date_str removed
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
#### Decode Page
|
||||
|
||||
**Before (v3.1.0):**
|
||||
```python
|
||||
day_phrase = request.form.get('day_phrase', '')
|
||||
stego_date = request.form.get('stego_date', '').strip()
|
||||
|
||||
decode_result = decode(
|
||||
...,
|
||||
day_phrase=day_phrase,
|
||||
date_str=stego_date if stego_date else None,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**After (v3.2.0):**
|
||||
```python
|
||||
passphrase = request.form.get('passphrase', '')
|
||||
# No stego_date needed
|
||||
|
||||
decode_result = decode(
|
||||
...,
|
||||
passphrase=passphrase, # Renamed
|
||||
# date_str removed
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Template Context Updates
|
||||
|
||||
**inject_globals() changes:**
|
||||
|
||||
**Added:**
|
||||
```python
|
||||
'min_passphrase_words': MIN_PASSPHRASE_WORDS,
|
||||
'recommended_passphrase_words': RECOMMENDED_PASSPHRASE_WORDS,
|
||||
'default_passphrase_words': DEFAULT_PASSPHRASE_WORDS,
|
||||
```
|
||||
|
||||
**Used for:**
|
||||
- Showing passphrase length requirements
|
||||
- Default values in generate form
|
||||
- Validation messages
|
||||
|
||||
### 3. Validation Updates
|
||||
|
||||
**Added passphrase validation:**
|
||||
```python
|
||||
from stegasoo import validate_passphrase
|
||||
|
||||
# In encode_page()
|
||||
result = validate_passphrase(passphrase)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, 'error')
|
||||
return ...
|
||||
|
||||
# Show warning if passphrase is short
|
||||
if result.warning:
|
||||
flash(result.warning, 'warning')
|
||||
```
|
||||
|
||||
### 4. Error Message Updates
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
flash('Day phrase is required', 'error')
|
||||
flash('Decryption failed. Check your phrase, PIN...', 'error')
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
flash('Passphrase is required', 'error')
|
||||
flash('Decryption failed. Check your passphrase, PIN...', 'error')
|
||||
```
|
||||
|
||||
## Template Changes Needed
|
||||
|
||||
These Flask routes will need corresponding template updates:
|
||||
|
||||
### generate.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<label for="words_per_phrase">Words per phrase</label>
|
||||
<input type="number" name="words_per_phrase" value="3">
|
||||
|
||||
{% if generated %}
|
||||
<h3>Daily Phrases</h3>
|
||||
{% for day in days %}
|
||||
<tr>
|
||||
<td>{{ day }}</td>
|
||||
<td>{{ phrases[day] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- After -->
|
||||
<label for="words_per_passphrase">Words per passphrase</label>
|
||||
<input type="number" name="words_per_passphrase" value="{{ default_passphrase_words }}">
|
||||
|
||||
{% if generated %}
|
||||
<h3>Passphrase</h3>
|
||||
<div class="passphrase-display">
|
||||
<code>{{ passphrase }}</code>
|
||||
<p class="help-text">Use this passphrase to encode and decode messages (no date needed!)</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Entropy display:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<li>Phrase entropy: {{ phrase_entropy }} bits</li>
|
||||
|
||||
<!-- After -->
|
||||
<li>Passphrase entropy: {{ passphrase_entropy }} bits ({{ words_per_passphrase }} words)</li>
|
||||
```
|
||||
|
||||
### encode.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<label for="day_phrase">Day Phrase</label>
|
||||
<input type="text" name="day_phrase" required>
|
||||
|
||||
<label for="client_date">Encoding Date (Optional)</label>
|
||||
<input type="date" name="client_date">
|
||||
<p class="help-text">Defaults to today: {{ day_of_week }}</p>
|
||||
|
||||
<!-- After -->
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<input type="text" name="passphrase" required
|
||||
placeholder="Enter at least {{ recommended_passphrase_words }} words">
|
||||
<p class="help-text">
|
||||
v3.2.0: No date needed! Use your passphrase anytime.
|
||||
</p>
|
||||
```
|
||||
|
||||
### decode.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<label for="day_phrase">Day Phrase</label>
|
||||
<input type="text" name="day_phrase" required>
|
||||
|
||||
<label for="stego_date">Encoding Date</label>
|
||||
<input type="date" name="stego_date" id="stego_date">
|
||||
<p class="help-text">Will be auto-detected from filename if possible</p>
|
||||
|
||||
<script>
|
||||
// Auto-detect date from filename
|
||||
stegoInput.addEventListener('change', function() {
|
||||
const filename = this.files[0]?.name || '';
|
||||
const dateMatch = filename.match(/_(\d{4})(\d{2})(\d{2})/);
|
||||
if (dateMatch) {
|
||||
document.getElementById('stego_date').value =
|
||||
`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- After -->
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<input type="text" name="passphrase" required
|
||||
placeholder="Enter your passphrase">
|
||||
<p class="help-text">
|
||||
v3.2.0: No date needed to decode!
|
||||
</p>
|
||||
|
||||
<!-- Remove date detection script -->
|
||||
```
|
||||
|
||||
### index.html
|
||||
|
||||
**Changes needed:**
|
||||
```html
|
||||
<!-- Before -->
|
||||
<p>Generate daily passphrases and security credentials</p>
|
||||
<p>Hide messages using day-specific phrases</p>
|
||||
|
||||
<!-- After -->
|
||||
<p>Generate passphrases and security credentials</p>
|
||||
<p>v3.2.0: Simplified - no more daily rotation!</p>
|
||||
```
|
||||
|
||||
### about.html
|
||||
|
||||
**Add v3.2.0 section:**
|
||||
```html
|
||||
<h2>Version 3.2.0 Changes</h2>
|
||||
<ul>
|
||||
<li><strong>No date dependency</strong> - Encode and decode anytime without tracking dates</li>
|
||||
<li><strong>Single passphrase</strong> - No more daily rotation, just remember one strong passphrase</li>
|
||||
<li><strong>Better security</strong> - Default passphrase length increased to 4 words</li>
|
||||
<li><strong>Asynchronous ready</strong> - Perfect for dead drops and delayed delivery</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## JavaScript Changes Needed
|
||||
|
||||
### Remove date-related code:
|
||||
|
||||
```javascript
|
||||
// REMOVE THIS (date detection from filename)
|
||||
function detectDateFromFilename(filename) {
|
||||
const match = filename.match(/_(\d{4})(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// REMOVE THIS (day-of-week display)
|
||||
function updateDayOfWeek() {
|
||||
const dateInput = document.getElementById('client_date');
|
||||
const dayDisplay = document.getElementById('day_display');
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Update validation:
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
const dayPhrase = document.getElementById('day_phrase').value;
|
||||
if (!dayPhrase || dayPhrase.trim().length === 0) {
|
||||
alert('Day phrase is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
// After
|
||||
const passphrase = document.getElementById('passphrase').value;
|
||||
if (!passphrase || passphrase.trim().length === 0) {
|
||||
alert('Passphrase is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add word count validation
|
||||
const words = passphrase.trim().split(/\s+/);
|
||||
if (words.length < {{ min_passphrase_words }}) {
|
||||
alert(`Passphrase should have at least {{ recommended_passphrase_words }} words`);
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Updates
|
||||
|
||||
Add styling for passphrase warnings:
|
||||
|
||||
```css
|
||||
.passphrase-display {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.passphrase-display code {
|
||||
font-size: 1.2em;
|
||||
color: #2c3e50;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.help-text.v3-2-0 {
|
||||
color: #3498db;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.flash.warning {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Notes for Users
|
||||
|
||||
Add to templates:
|
||||
|
||||
```html
|
||||
<div class="alert alert-info">
|
||||
<h4>⚠️ v3.2.0 Breaking Changes</h4>
|
||||
<p>If you have messages encoded with v3.1.0:</p>
|
||||
<ul>
|
||||
<li>They cannot be decoded with v3.2.0</li>
|
||||
<li>You need the original v3.1.0 installation to decode them</li>
|
||||
<li>After decoding, you can re-encode with v3.2.0</li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Form Field Summary
|
||||
|
||||
### Changed Field Names
|
||||
|
||||
| Old Name (v3.1.0) | New Name (v3.2.0) | Type |
|
||||
|-------------------|-------------------|------|
|
||||
| `day_phrase` | `passphrase` | text input |
|
||||
| `words_per_phrase` | `words_per_passphrase` | number input |
|
||||
| `client_date` | (removed) | date input |
|
||||
| `stego_date` | (removed) | date input |
|
||||
|
||||
### New Validation Attributes
|
||||
|
||||
```html
|
||||
<input type="text" name="passphrase"
|
||||
required
|
||||
minlength="{{ min_passphrase_words * 4 }}"
|
||||
placeholder="Enter at least {{ recommended_passphrase_words }} words"
|
||||
pattern="^\s*\S+(\s+\S+){3,}.*$"
|
||||
title="Please enter at least 4 words">
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Generate page creates single passphrase
|
||||
- [ ] Generate page shows correct entropy (4 words = 44 bits)
|
||||
- [ ] Generate page doesn't show day names
|
||||
- [ ] Encode page accepts passphrase (not day_phrase)
|
||||
- [ ] Encode page doesn't have date selection
|
||||
- [ ] Encode page shows v3.2.0 help text
|
||||
- [ ] Decode page accepts passphrase
|
||||
- [ ] Decode page doesn't have date input
|
||||
- [ ] Decode page doesn't auto-detect date from filename
|
||||
- [ ] Error messages say "passphrase" not "day phrase"
|
||||
- [ ] Validation shows warnings for short passphrases
|
||||
- [ ] QR code functionality still works
|
||||
- [ ] DCT mode options still work
|
||||
- [ ] All flash messages updated
|
||||
|
||||
## Implementation Status
|
||||
|
||||
✅ Flask routes updated
|
||||
✅ Form parameter names changed
|
||||
✅ Function calls updated
|
||||
✅ Validation added for passphrases
|
||||
✅ Error messages updated
|
||||
✅ Template context updated
|
||||
⏳ Templates need updating (generate.html, encode.html, decode.html, index.html, about.html)
|
||||
⏳ JavaScript needs updating
|
||||
⏳ CSS styling for v3.2.0 features
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**To test the Flask app:**
|
||||
```bash
|
||||
cd frontends/web
|
||||
python app.py
|
||||
# Visit http://localhost:5000
|
||||
```
|
||||
|
||||
**Key user-facing changes:**
|
||||
1. Generate: Shows one passphrase, not 7 daily phrases
|
||||
2. Encode: No date selection, just passphrase
|
||||
3. Decode: No date needed, just passphrase
|
||||
|
||||
**Benefits to highlight:**
|
||||
- ✅ Simpler UI (fewer fields)
|
||||
- ✅ No date tracking needed
|
||||
- ✅ Encode today, decode anytime
|
||||
- ✅ Perfect for asynchronous communications
|
||||
0
frontends/web/__init__.py
Normal file
2633
frontends/web/app.py
963
frontends/web/auth.py
Normal file
@@ -0,0 +1,963 @@
|
||||
"""
|
||||
Stegasoo Authentication Module (v4.1.0)
|
||||
|
||||
Multi-user authentication with role-based access control.
|
||||
- Admin user created at first-run setup
|
||||
- Admin can create up to 16 additional users
|
||||
- Uses Argon2id password hashing
|
||||
- Flask sessions for authentication state
|
||||
- SQLite3 for user storage
|
||||
"""
|
||||
|
||||
import functools
|
||||
import secrets
|
||||
import sqlite3
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from flask import current_app, flash, g, redirect, session, url_for
|
||||
|
||||
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
|
||||
ph = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536, # 64MB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
# Constants
|
||||
MAX_USERS = 16 # Plus 1 admin = 17 total
|
||||
MAX_CHANNEL_KEYS = 10 # Per user
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_USER = "user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""User data class."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
created_at: str
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == ROLE_ADMIN
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
"""Get database path in Flask instance folder."""
|
||||
instance_path = Path(current_app.instance_path)
|
||||
instance_path.mkdir(parents=True, exist_ok=True)
|
||||
return instance_path / "stegasoo.db"
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Get database connection, cached on Flask g object."""
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(get_db_path())
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(e=None):
|
||||
"""Close database connection at end of request."""
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database schema with migration support."""
|
||||
db = get_db()
|
||||
|
||||
# Check if we need to migrate from old single-user schema
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
|
||||
has_old_table = cursor.fetchone() is not None
|
||||
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||
has_new_table = cursor.fetchone() is not None
|
||||
|
||||
if has_old_table and not has_new_table:
|
||||
# Migrate from old schema
|
||||
_migrate_from_single_user(db)
|
||||
elif not has_new_table:
|
||||
# Fresh install - create new schema
|
||||
_create_schema(db)
|
||||
else:
|
||||
# Existing install - check for new tables (migrations)
|
||||
_ensure_channel_keys_table(db)
|
||||
_ensure_app_settings_table(db)
|
||||
|
||||
|
||||
def _create_schema(db: sqlite3.Connection):
|
||||
"""Create the multi-user schema."""
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
channel_key TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, channel_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
|
||||
|
||||
-- App-level settings (v4.1.0)
|
||||
-- Stores recovery key hash and other instance-wide settings
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _migrate_from_single_user(db: sqlite3.Connection):
|
||||
"""Migrate from old single-user admin_user table to multi-user users table."""
|
||||
# Create new table
|
||||
_create_schema(db)
|
||||
|
||||
# Copy admin user from old table
|
||||
old_user = db.execute(
|
||||
"SELECT username, password_hash, created_at FROM admin_user WHERE id = 1"
|
||||
).fetchone()
|
||||
|
||||
if old_user:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, role, created_at)
|
||||
VALUES (?, ?, 'admin', ?)
|
||||
""",
|
||||
(old_user["username"], old_user["password_hash"], old_user["created_at"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Drop old table
|
||||
db.execute("DROP TABLE admin_user")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_channel_keys_table(db: sqlite3.Connection):
|
||||
"""Ensure user_channel_keys table exists (migration for existing installs)."""
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_channel_keys'"
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
channel_key TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, channel_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Settings (v4.1.0)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_app_setting(key: str) -> str | None:
|
||||
"""Get an app-level setting value."""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def set_app_setting(key: str, value: str) -> None:
|
||||
"""Set an app-level setting value."""
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO app_settings (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, value, value),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_app_setting(key: str) -> bool:
|
||||
"""Delete an app-level setting. Returns True if deleted."""
|
||||
db = get_db()
|
||||
cursor = db.execute("DELETE FROM app_settings WHERE key = ?", (key,))
|
||||
db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Recovery Key Management (v4.1.0)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Setting key for recovery hash
|
||||
RECOVERY_KEY_SETTING = "recovery_key_hash"
|
||||
|
||||
|
||||
def has_recovery_key() -> bool:
|
||||
"""Check if a recovery key has been configured."""
|
||||
return get_app_setting(RECOVERY_KEY_SETTING) is not None
|
||||
|
||||
|
||||
def get_recovery_key_hash() -> str | None:
|
||||
"""Get the stored recovery key hash."""
|
||||
return get_app_setting(RECOVERY_KEY_SETTING)
|
||||
|
||||
|
||||
def set_recovery_key_hash(key_hash: str) -> None:
|
||||
"""Store a recovery key hash."""
|
||||
set_app_setting(RECOVERY_KEY_SETTING, key_hash)
|
||||
|
||||
|
||||
def clear_recovery_key() -> bool:
|
||||
"""Remove the recovery key. Returns True if removed."""
|
||||
return delete_app_setting(RECOVERY_KEY_SETTING)
|
||||
|
||||
|
||||
def verify_and_reset_admin_password(recovery_key: str, new_password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify recovery key and reset the first admin's password.
|
||||
|
||||
Args:
|
||||
recovery_key: User-provided recovery key
|
||||
new_password: New password to set
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
from stegasoo.recovery import verify_recovery_key
|
||||
|
||||
stored_hash = get_recovery_key_hash()
|
||||
if not stored_hash:
|
||||
return False, "No recovery key configured for this instance"
|
||||
|
||||
if not verify_recovery_key(recovery_key, stored_hash):
|
||||
return False, "Invalid recovery key"
|
||||
|
||||
# Find first admin user
|
||||
db = get_db()
|
||||
admin = db.execute(
|
||||
"SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
if not admin:
|
||||
return False, "No admin user found"
|
||||
|
||||
# Reset password
|
||||
new_hash = ph.hash(new_password)
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, admin["id"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Invalidate all sessions for this user
|
||||
invalidate_user_sessions(admin["id"])
|
||||
|
||||
return True, f"Password reset for '{admin['username']}'"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Queries
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def any_users_exist() -> bool:
|
||||
"""Check if any users have been created (for first-run detection)."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT 1 FROM users LIMIT 1").fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def user_exists() -> bool:
|
||||
"""Alias for any_users_exist() for backwards compatibility."""
|
||||
return any_users_exist()
|
||||
|
||||
|
||||
def get_user_count() -> int:
|
||||
"""Get total number of users."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT COUNT(*) FROM users").fetchone()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def get_non_admin_count() -> int:
|
||||
"""Get number of non-admin users."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT COUNT(*) FROM users WHERE role != 'admin'").fetchone()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def can_create_user() -> bool:
|
||||
"""Check if we can create more users (within limit)."""
|
||||
return get_non_admin_count() < MAX_USERS
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> User | None:
|
||||
"""Get user by ID."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, username, role, created_at FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_username(username: str) -> User | None:
|
||||
"""Get user by username."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, username, role, created_at FROM users WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_all_users() -> list[User]:
|
||||
"""Get all users, admins first, then by creation date."""
|
||||
db = get_db()
|
||||
rows = db.execute("""
|
||||
SELECT id, username, role, created_at FROM users
|
||||
ORDER BY role = 'admin' DESC, created_at ASC
|
||||
""").fetchall()
|
||||
return [
|
||||
User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_current_user() -> User | None:
|
||||
"""Get the currently logged-in user from session."""
|
||||
user_id = session.get("user_id")
|
||||
if user_id:
|
||||
return get_user_by_id(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_username() -> str:
|
||||
"""Get current user's username (backwards compatibility)."""
|
||||
user = get_current_user()
|
||||
return user.username if user else "unknown"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def verify_user_password(username: str, password: str) -> User | None:
|
||||
"""
|
||||
Verify password for a user.
|
||||
|
||||
Returns User if valid, None if invalid.
|
||||
Also rehashes password if needed.
|
||||
"""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, username, role, created_at, password_hash FROM users WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
try:
|
||||
ph.verify(row["password_hash"], password)
|
||||
|
||||
# Rehash if parameters changed
|
||||
if ph.check_needs_rehash(row["password_hash"]):
|
||||
new_hash = ph.hash(password)
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, row["id"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return User(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
except VerifyMismatchError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""Verify password for current user (backwards compatibility)."""
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return False
|
||||
result = verify_user_password(user.username, password)
|
||||
return result is not None
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
"""Check if current session is authenticated."""
|
||||
return session.get("user_id") is not None
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""Check if current user is an admin."""
|
||||
user = get_current_user()
|
||||
return user.is_admin if user else False
|
||||
|
||||
|
||||
def login_user(user: User):
|
||||
"""Set up session for logged-in user."""
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
session["role"] = user.role
|
||||
# Legacy compatibility
|
||||
session["authenticated"] = True
|
||||
|
||||
|
||||
def logout_user():
|
||||
"""Clear session for logout."""
|
||||
session.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_temp_password(length: int = 8) -> str:
|
||||
"""Generate a random temporary password."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def validate_username(username: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate username format.
|
||||
|
||||
Rules: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
||||
"""
|
||||
if not username:
|
||||
return False, "Username is required"
|
||||
|
||||
if len(username) < 3:
|
||||
return False, "Username must be at least 3 characters"
|
||||
|
||||
if len(username) > 80:
|
||||
return False, "Username must be at most 80 characters"
|
||||
|
||||
# Allow: alphanumeric, underscore, hyphen, @, . (for email-style)
|
||||
allowed = set(string.ascii_letters + string.digits + "_-@.")
|
||||
if not all(c in allowed for c in username):
|
||||
return False, "Username can only contain letters, numbers, underscore, hyphen, @ and ."
|
||||
|
||||
# Must start with letter or number
|
||||
if username[0] not in string.ascii_letters + string.digits:
|
||||
return False, "Username must start with a letter or number"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_password(password: str) -> tuple[bool, str]:
|
||||
"""Validate password requirements."""
|
||||
if not password:
|
||||
return False, "Password is required"
|
||||
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str, password: str, role: str = ROLE_USER
|
||||
) -> tuple[bool, str, User | None]:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Returns (success, message, user).
|
||||
"""
|
||||
# Validate username
|
||||
valid, msg = validate_username(username)
|
||||
if not valid:
|
||||
return False, msg, None
|
||||
|
||||
# Validate password
|
||||
valid, msg = validate_password(password)
|
||||
if not valid:
|
||||
return False, msg, None
|
||||
|
||||
# Check if username already exists
|
||||
if get_user_by_username(username):
|
||||
return False, "Username already exists", None
|
||||
|
||||
# Check user limit (only for non-admin users)
|
||||
if role != ROLE_ADMIN and not can_create_user():
|
||||
return False, f"Maximum of {MAX_USERS} users reached", None
|
||||
|
||||
# Create user
|
||||
password_hash = ph.hash(password)
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(username, password_hash, role),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = get_user_by_id(cursor.lastrowid)
|
||||
return True, "User created successfully", user
|
||||
except sqlite3.IntegrityError:
|
||||
return False, "Username already exists", None
|
||||
|
||||
|
||||
def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create the initial admin user (first-run setup)."""
|
||||
if any_users_exist():
|
||||
return False, "Admin user already exists"
|
||||
|
||||
success, msg, _ = create_user(username, password, ROLE_ADMIN)
|
||||
return success, msg
|
||||
|
||||
|
||||
def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change a user's password (requires current password)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "User not found"
|
||||
|
||||
# Verify current password
|
||||
if not verify_user_password(user.username, current_password):
|
||||
return False, "Current password is incorrect"
|
||||
|
||||
# Validate new password
|
||||
valid, msg = validate_password(new_password)
|
||||
if not valid:
|
||||
return False, msg
|
||||
|
||||
# Update password
|
||||
new_hash = ph.hash(new_password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, user_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return True, "Password changed successfully"
|
||||
|
||||
|
||||
def reset_user_password(user_id: int, new_password: str) -> tuple[bool, str]:
|
||||
"""Reset a user's password (admin function, no current password required)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "User not found"
|
||||
|
||||
# Validate new password
|
||||
valid, msg = validate_password(new_password)
|
||||
if not valid:
|
||||
return False, msg
|
||||
|
||||
# Update password
|
||||
new_hash = ph.hash(new_password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(new_hash, user_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Invalidate user's sessions
|
||||
invalidate_user_sessions(user_id)
|
||||
|
||||
return True, "Password reset successfully"
|
||||
|
||||
|
||||
def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Delete a user.
|
||||
|
||||
Cannot delete yourself or the last admin.
|
||||
"""
|
||||
if user_id == current_user_id:
|
||||
return False, "Cannot delete yourself"
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False, "User not found"
|
||||
|
||||
# Check if this is the last admin
|
||||
if user.role == ROLE_ADMIN:
|
||||
db = get_db()
|
||||
admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
|
||||
if admin_count <= 1:
|
||||
return False, "Cannot delete the last admin"
|
||||
|
||||
# Invalidate user's sessions before deletion
|
||||
invalidate_user_sessions(user_id)
|
||||
|
||||
# Delete user
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
db.commit()
|
||||
|
||||
return True, f"User '{user.username}' deleted"
|
||||
|
||||
|
||||
def invalidate_user_sessions(user_id: int):
|
||||
"""
|
||||
Invalidate all sessions for a user.
|
||||
|
||||
This is called when a user is deleted or their password is reset.
|
||||
Since we use server-side sessions, we increment a "session version"
|
||||
that's checked on each request.
|
||||
"""
|
||||
# For Flask's default session (client-side), we can't truly invalidate.
|
||||
# But we can add a check - store a "valid_from" timestamp in the DB
|
||||
# and compare against session creation time.
|
||||
#
|
||||
# For now, we'll use a simpler approach: store invalidated user IDs
|
||||
# in app config (memory) which gets checked by login_required.
|
||||
#
|
||||
# This works for single-process deployments (like RPi).
|
||||
# For multi-process, would need Redis or DB-backed sessions.
|
||||
|
||||
if "invalidated_users" not in current_app.config:
|
||||
current_app.config["invalidated_users"] = set()
|
||||
|
||||
current_app.config["invalidated_users"].add(user_id)
|
||||
|
||||
|
||||
def is_session_valid() -> bool:
|
||||
"""Check if current session is still valid (user not deleted/invalidated)."""
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
# Check if user was invalidated
|
||||
invalidated = current_app.config.get("invalidated_users", set())
|
||||
if user_id in invalidated:
|
||||
return False
|
||||
|
||||
# Check if user still exists
|
||||
if not get_user_by_id(user_id):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channel Keys
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelKey:
|
||||
"""Saved channel key data class."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
channel_key: str
|
||||
created_at: str
|
||||
last_used_at: str | None
|
||||
|
||||
|
||||
def get_user_channel_keys(user_id: int) -> list[ChannelKey]:
|
||||
"""Get all saved channel keys for a user, most recently used first."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, user_id, name, channel_key, created_at, last_used_at
|
||||
FROM user_channel_keys
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_used_at DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
ChannelKey(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
name=row["name"],
|
||||
channel_key=row["channel_key"],
|
||||
created_at=row["created_at"],
|
||||
last_used_at=row["last_used_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_channel_key_by_id(key_id: int, user_id: int) -> ChannelKey | None:
|
||||
"""Get a specific channel key (ensures user owns it)."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, user_id, name, channel_key, created_at, last_used_at
|
||||
FROM user_channel_keys
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(key_id, user_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return ChannelKey(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
name=row["name"],
|
||||
channel_key=row["channel_key"],
|
||||
created_at=row["created_at"],
|
||||
last_used_at=row["last_used_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_channel_key_count(user_id: int) -> int:
|
||||
"""Get count of saved channel keys for a user."""
|
||||
db = get_db()
|
||||
result = db.execute(
|
||||
"SELECT COUNT(*) FROM user_channel_keys WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def can_save_channel_key(user_id: int) -> bool:
|
||||
"""Check if user can save more channel keys (within limit)."""
|
||||
return get_channel_key_count(user_id) < MAX_CHANNEL_KEYS
|
||||
|
||||
|
||||
def save_channel_key(
|
||||
user_id: int, name: str, channel_key: str
|
||||
) -> tuple[bool, str, ChannelKey | None]:
|
||||
"""
|
||||
Save a channel key for a user.
|
||||
|
||||
Returns (success, message, key).
|
||||
"""
|
||||
# Validate name
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return False, "Key name is required", None
|
||||
if len(name) > 50:
|
||||
return False, "Key name must be at most 50 characters", None
|
||||
|
||||
# Validate channel key format (hex string)
|
||||
channel_key = channel_key.strip().lower()
|
||||
if not channel_key:
|
||||
return False, "Channel key is required", None
|
||||
if not all(c in "0123456789abcdef" for c in channel_key):
|
||||
return False, "Invalid channel key format", None
|
||||
|
||||
# Check limit
|
||||
if not can_save_channel_key(user_id):
|
||||
return False, f"Maximum of {MAX_CHANNEL_KEYS} saved keys reached", None
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO user_channel_keys (user_id, name, channel_key)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, name, channel_key),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
key = get_channel_key_by_id(cursor.lastrowid, user_id)
|
||||
return True, "Channel key saved", key
|
||||
except sqlite3.IntegrityError:
|
||||
return False, "This channel key is already saved", None
|
||||
|
||||
|
||||
def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
|
||||
"""Update the name of a saved channel key."""
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
return False, "Key name is required"
|
||||
if len(new_name) > 50:
|
||||
return False, "Key name must be at most 50 characters"
|
||||
|
||||
key = get_channel_key_by_id(key_id, user_id)
|
||||
if not key:
|
||||
return False, "Channel key not found"
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE user_channel_keys SET name = ? WHERE id = ? AND user_id = ?",
|
||||
(new_name, key_id, user_id),
|
||||
)
|
||||
db.commit()
|
||||
return True, "Key name updated"
|
||||
|
||||
|
||||
def update_channel_key_last_used(key_id: int, user_id: int):
|
||||
"""Update the last_used_at timestamp for a channel key."""
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE user_channel_keys
|
||||
SET last_used_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(key_id, user_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_channel_key(key_id: int, user_id: int) -> tuple[bool, str]:
|
||||
"""Delete a saved channel key."""
|
||||
key = get_channel_key_by_id(key_id, user_id)
|
||||
if not key:
|
||||
return False, "Channel key not found"
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"DELETE FROM user_channel_keys WHERE id = ? AND user_id = ?",
|
||||
(key_id, user_id),
|
||||
)
|
||||
db.commit()
|
||||
return True, f"Key '{key.name}' deleted"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decorators
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for a route."""
|
||||
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if auth is enabled
|
||||
if not current_app.config.get("AUTH_ENABLED", True):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Check for first-run setup
|
||||
if not any_users_exist():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
# Check authentication
|
||||
if not is_authenticated():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Check if session is still valid (user not deleted)
|
||||
if not is_session_valid():
|
||||
logout_user()
|
||||
flash("Your session has expired. Please log in again.", "warning")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin role for a route."""
|
||||
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if auth is enabled
|
||||
if not current_app.config.get("AUTH_ENABLED", True):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Check for first-run setup
|
||||
if not any_users_exist():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
# Check authentication
|
||||
if not is_authenticated():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Check if session is still valid
|
||||
if not is_session_valid():
|
||||
logout_user()
|
||||
flash("Your session has expired. Please log in again.", "warning")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Check admin role
|
||||
if not is_admin():
|
||||
flash("Admin access required", "error")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Initialization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Initialize auth module with Flask app."""
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
52
frontends/web/dev_run.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Web Frontend - Development Runner
|
||||
# Press 'r' to restart, 'q' to quit (single keypress, no Enter needed)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PID=""
|
||||
|
||||
cleanup() {
|
||||
echo -e "\n\033[33mShutting down...\033[0m"
|
||||
[[ -n "$PID" ]] && kill "$PID" 2>/dev/null
|
||||
stty sane 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
start_server() {
|
||||
clear
|
||||
echo -e "\033[36m┌──────────────────────────────────────┐\033[0m"
|
||||
echo -e "\033[36m│ Stegasoo Dev Server │\033[0m"
|
||||
echo -e "\033[36m│ \033[0m[r] restart [q] quit\033[36m │\033[0m"
|
||||
echo -e "\033[36m└──────────────────────────────────────┘\033[0m"
|
||||
|
||||
pkill -f "python app.py" 2>/dev/null
|
||||
sleep 0.3
|
||||
|
||||
python app.py 2>&1 &
|
||||
PID=$!
|
||||
echo -e "\033[32m✓ Running on http://localhost:5000 (PID: $PID)\033[0m\n"
|
||||
}
|
||||
|
||||
start_server
|
||||
|
||||
# Single keypress mode
|
||||
stty -echo -icanon time 0 min 0
|
||||
|
||||
while true; do
|
||||
key=$(dd bs=1 count=1 2>/dev/null)
|
||||
case "$key" in
|
||||
r|R) start_server ;;
|
||||
q|Q) cleanup ;;
|
||||
esac
|
||||
|
||||
# Check if crashed
|
||||
if [[ -n "$PID" ]] && ! kill -0 "$PID" 2>/dev/null; then
|
||||
echo -e "\033[31m✗ Crashed! Press 'r' to restart\033[0m"
|
||||
PID=""
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
75
frontends/web/docker-entrypoint.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Docker entrypoint for Stegasoo Web UI
|
||||
# Handles SSL certificate generation and gunicorn startup
|
||||
#
|
||||
# Supports mkcert for browser-trusted certificates (no warning screen)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CERT_DIR="/app/frontends/web/certs"
|
||||
CERT_FILE="$CERT_DIR/cert.pem"
|
||||
KEY_FILE="$CERT_DIR/key.pem"
|
||||
HOSTNAME="${STEGASOO_HOSTNAME:-localhost}"
|
||||
|
||||
# Generate SSL certificates
|
||||
# Priority: 1) Existing certs, 2) mkcert (trusted), 3) openssl (self-signed)
|
||||
generate_certs() {
|
||||
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
|
||||
echo "Using existing SSL certificates."
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Try mkcert first (creates browser-trusted certs)
|
||||
if command -v mkcert &> /dev/null; then
|
||||
echo "Generating trusted certificate with mkcert for $HOSTNAME..."
|
||||
cd "$CERT_DIR"
|
||||
mkcert -key-file key.pem -cert-file cert.pem "$HOSTNAME" localhost 127.0.0.1 ::1
|
||||
echo "Trusted certificate generated."
|
||||
echo ""
|
||||
echo " To trust on other devices, install the CA cert from:"
|
||||
echo " $(mkcert -CAROOT)/rootCA.pem"
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to self-signed (shows browser warning)
|
||||
echo "Generating self-signed SSL certificate for $HOSTNAME..."
|
||||
echo "(Install mkcert for browser-trusted certs without warnings)"
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$KEY_FILE" \
|
||||
-out "$CERT_FILE" \
|
||||
-sha256 -days 365 -nodes \
|
||||
-subj "/CN=$HOSTNAME" \
|
||||
-addext "subjectAltName=DNS:$HOSTNAME,DNS:localhost,IP:127.0.0.1" \
|
||||
2>/dev/null
|
||||
|
||||
echo "Self-signed certificate generated."
|
||||
}
|
||||
|
||||
# Start gunicorn with appropriate settings
|
||||
if [ "${STEGASOO_HTTPS_ENABLED:-false}" = "true" ]; then
|
||||
echo "HTTPS mode enabled"
|
||||
generate_certs
|
||||
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:5000 \
|
||||
--workers 2 \
|
||||
--threads 4 \
|
||||
--timeout 120 \
|
||||
--certfile "$CERT_FILE" \
|
||||
--keyfile "$KEY_FILE" \
|
||||
app:app
|
||||
else
|
||||
echo "HTTP mode (HTTPS disabled)"
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:5000 \
|
||||
--workers 2 \
|
||||
--threads 4 \
|
||||
--timeout 120 \
|
||||
app:app
|
||||
fi
|
||||
155
frontends/web/ssl_utils.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
SSL Certificate Utilities
|
||||
|
||||
Auto-generates self-signed certificates for HTTPS.
|
||||
Uses cryptography library (already a dependency).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
def _get_local_ips() -> list[str]:
|
||||
"""Get local IP addresses for this machine."""
|
||||
ips = []
|
||||
try:
|
||||
# Get hostname and resolve to IP
|
||||
hostname = socket.gethostname()
|
||||
for addr_info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
ip = addr_info[4][0]
|
||||
if ip not in ips and not ip.startswith("127."):
|
||||
ips.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try connecting to external to get primary interface IP
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
if ip not in ips:
|
||||
ips.append(ip)
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ips
|
||||
|
||||
|
||||
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
|
||||
"""Get paths for cert and key files."""
|
||||
cert_dir = base_dir / "certs"
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cert_dir / "server.crt", cert_dir / "server.key"
|
||||
|
||||
|
||||
def certs_exist(base_dir: Path) -> bool:
|
||||
"""Check if both cert files exist."""
|
||||
cert_path, key_path = get_cert_paths(base_dir)
|
||||
return cert_path.exists() and key_path.exists()
|
||||
|
||||
|
||||
def generate_self_signed_cert(
|
||||
base_dir: Path,
|
||||
hostname: str = "localhost",
|
||||
days_valid: int = 365,
|
||||
) -> tuple[Path, Path]:
|
||||
"""
|
||||
Generate self-signed SSL certificate.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for certs folder
|
||||
hostname: Server hostname for certificate
|
||||
days_valid: Certificate validity in days
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_path, key_path)
|
||||
"""
|
||||
cert_path, key_path = get_cert_paths(base_dir)
|
||||
|
||||
# Generate RSA key
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
]
|
||||
)
|
||||
|
||||
# Subject Alternative Names
|
||||
san_list = [
|
||||
x509.DNSName(hostname),
|
||||
x509.DNSName("localhost"),
|
||||
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
||||
]
|
||||
|
||||
# Add hostname.local for mDNS access
|
||||
if not hostname.endswith(".local"):
|
||||
san_list.append(x509.DNSName(f"{hostname}.local"))
|
||||
|
||||
# Add the hostname as IP if it looks like one
|
||||
try:
|
||||
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
||||
except ipaddress.AddressValueError:
|
||||
pass
|
||||
|
||||
# Add local network IPs
|
||||
for local_ip in _get_local_ips():
|
||||
try:
|
||||
ip_addr = ipaddress.IPv4Address(local_ip)
|
||||
if x509.IPAddress(ip_addr) not in san_list:
|
||||
san_list.append(x509.IPAddress(ip_addr))
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=days_valid))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(san_list),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
|
||||
# Write key file (chmod 600)
|
||||
key_path.write_bytes(
|
||||
key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# Write cert file
|
||||
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def ensure_certs(base_dir: Path, hostname: str = "localhost") -> tuple[Path, Path]:
|
||||
"""Ensure certificates exist, generating if needed."""
|
||||
if certs_exist(base_dir):
|
||||
return get_cert_paths(base_dir)
|
||||
|
||||
print(f"Generating self-signed SSL certificate for {hostname}...")
|
||||
return generate_self_signed_cert(base_dir, hostname)
|
||||
142
frontends/web/static/js/auth.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Stegasoo Authentication Pages JavaScript
|
||||
* Handles login, setup, account, and admin user management pages
|
||||
*/
|
||||
|
||||
const StegasooAuth = {
|
||||
|
||||
// ========================================================================
|
||||
// PASSWORD VISIBILITY TOGGLE
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Toggle password field visibility
|
||||
* @param {string} inputId - ID of the password input
|
||||
* @param {HTMLElement} btn - The toggle button element
|
||||
*/
|
||||
togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (!input) return;
|
||||
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon?.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon?.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// PASSWORD CONFIRMATION VALIDATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize password confirmation validation on a form
|
||||
* @param {string} formId - ID of the form
|
||||
* @param {string} passwordId - ID of the password field
|
||||
* @param {string} confirmId - ID of the confirmation field
|
||||
*/
|
||||
initPasswordConfirmation(formId, passwordId, confirmId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
const password = document.getElementById(passwordId)?.value;
|
||||
const confirm = document.getElementById(confirmId)?.value;
|
||||
|
||||
if (password !== confirm) {
|
||||
e.preventDefault();
|
||||
alert('Passwords do not match');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// COPY TO CLIPBOARD
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Copy field value to clipboard with visual feedback
|
||||
* @param {string} fieldId - ID of the input field to copy
|
||||
*/
|
||||
copyField(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
field.select();
|
||||
navigator.clipboard.writeText(field.value).then(() => {
|
||||
const btn = field.nextElementSibling;
|
||||
if (!btn) return;
|
||||
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-check"></i>';
|
||||
setTimeout(() => btn.innerHTML = originalHTML, 1000);
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// PASSWORD GENERATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Generate a random password
|
||||
* @param {number} length - Password length (default 8)
|
||||
* @returns {string} Generated password
|
||||
*/
|
||||
generatePassword(length = 8) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return password;
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate password and update input field
|
||||
* @param {string} inputId - ID of the password input
|
||||
* @param {number} length - Password length
|
||||
*/
|
||||
regeneratePassword(inputId = 'passwordInput', length = 8) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = this.generatePassword(length);
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// DELETE CONFIRMATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Confirm deletion with a prompt
|
||||
* @param {string} itemName - Name of item being deleted
|
||||
* @param {string} formId - ID of the form to submit if confirmed
|
||||
* @returns {boolean} True if confirmed
|
||||
*/
|
||||
confirmDelete(itemName, formId = null) {
|
||||
const confirmed = confirm(`Are you sure you want to delete "${itemName}"? This cannot be undone.`);
|
||||
if (confirmed && formId) {
|
||||
const form = document.getElementById(formId);
|
||||
form?.submit();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
};
|
||||
|
||||
// Make togglePassword available globally for onclick handlers
|
||||
function togglePassword(inputId, btn) {
|
||||
StegasooAuth.togglePassword(inputId, btn);
|
||||
}
|
||||
|
||||
// Make copyField available globally for onclick handlers
|
||||
function copyField(fieldId) {
|
||||
StegasooAuth.copyField(fieldId);
|
||||
}
|
||||
|
||||
// Make regeneratePassword available globally for onclick handlers
|
||||
function regeneratePassword() {
|
||||
StegasooAuth.regeneratePassword();
|
||||
}
|
||||
279
frontends/web/static/js/generate.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Stegasoo Generate Page JavaScript
|
||||
* Handles credential generation form and display
|
||||
*/
|
||||
|
||||
const StegasooGenerate = {
|
||||
|
||||
// ========================================================================
|
||||
// FORM CONTROLS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the words range slider
|
||||
*/
|
||||
initWordsSlider() {
|
||||
const wordsRange = document.getElementById('wordsRange');
|
||||
const wordsValue = document.getElementById('wordsValue');
|
||||
|
||||
wordsRange?.addEventListener('input', function() {
|
||||
const bits = this.value * 11;
|
||||
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize PIN/RSA option toggles
|
||||
*/
|
||||
initOptionToggles() {
|
||||
const usePinCheck = document.getElementById('usePinCheck');
|
||||
const useRsaCheck = document.getElementById('useRsaCheck');
|
||||
const pinOptions = document.getElementById('pinOptions');
|
||||
const rsaOptions = document.getElementById('rsaOptions');
|
||||
const rsaQrWarning = document.getElementById('rsaQrWarning');
|
||||
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
|
||||
|
||||
usePinCheck?.addEventListener('change', function() {
|
||||
pinOptions?.classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
|
||||
useRsaCheck?.addEventListener('change', function() {
|
||||
rsaOptions?.classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
|
||||
// RSA key size QR warning (>3072 bits)
|
||||
rsaBitsSelect?.addEventListener('change', function() {
|
||||
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// CREDENTIAL VISIBILITY
|
||||
// ========================================================================
|
||||
|
||||
pinHidden: false,
|
||||
passphraseHidden: false,
|
||||
|
||||
/**
|
||||
* Toggle PIN visibility
|
||||
*/
|
||||
togglePinVisibility() {
|
||||
const pinDigits = document.getElementById('pinDigits');
|
||||
const icon = document.getElementById('pinToggleIcon');
|
||||
const text = document.getElementById('pinToggleText');
|
||||
|
||||
this.pinHidden = !this.pinHidden;
|
||||
pinDigits?.classList.toggle('blurred', this.pinHidden);
|
||||
|
||||
if (icon) icon.className = this.pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
if (text) text.textContent = this.pinHidden ? 'Show' : 'Hide';
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle passphrase visibility
|
||||
*/
|
||||
togglePassphraseVisibility() {
|
||||
const display = document.getElementById('passphraseDisplay');
|
||||
const icon = document.getElementById('passphraseToggleIcon');
|
||||
const text = document.getElementById('passphraseToggleText');
|
||||
|
||||
this.passphraseHidden = !this.passphraseHidden;
|
||||
display?.classList.toggle('blurred', this.passphraseHidden);
|
||||
|
||||
if (icon) icon.className = this.passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
if (text) text.textContent = this.passphraseHidden ? 'Show' : 'Hide';
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// MEMORY AID STORY GENERATION
|
||||
// ========================================================================
|
||||
|
||||
currentStoryTemplate: 0,
|
||||
|
||||
/**
|
||||
* Story templates organized by word count (3-12 words supported)
|
||||
*/
|
||||
storyTemplates: {
|
||||
3: [
|
||||
w => `The ${w[0]} ${w[1]} ${w[2]}.`,
|
||||
w => `${w[0]} loves ${w[1]} and ${w[2]}.`,
|
||||
w => `A ${w[0]} found a ${w[1]} near the ${w[2]}.`,
|
||||
w => `${w[0]}, ${w[1]}, ${w[2]} — never forget.`,
|
||||
w => `The ${w[0]} hid the ${w[1]} under the ${w[2]}.`,
|
||||
],
|
||||
4: [
|
||||
w => `${w[0]} and ${w[1]} discovered a ${w[2]} made of ${w[3]}.`,
|
||||
w => `The ${w[0]} ${w[1]} ate ${w[2]} for ${w[3]}.`,
|
||||
w => `In the ${w[0]}, a ${w[1]} met a ${w[2]} carrying ${w[3]}.`,
|
||||
w => `${w[0]} said "${w[1]}" while holding a ${w[2]} ${w[3]}.`,
|
||||
w => `The secret: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}.`,
|
||||
],
|
||||
5: [
|
||||
w => `${w[0]} traveled to ${w[1]} seeking the ${w[2]} of ${w[3]} and ${w[4]}.`,
|
||||
w => `The ${w[0]} ${w[1]} lived in a ${w[2]} house with ${w[3]} ${w[4]}.`,
|
||||
w => `"${w[0]}!" shouted ${w[1]} as the ${w[2]} ${w[3]} flew toward ${w[4]}.`,
|
||||
w => `Captain ${w[0]} sailed the ${w[1]} ${w[2]} searching for ${w[3]} ${w[4]}.`,
|
||||
w => `In ${w[0]} kingdom, ${w[1]} guards protected the ${w[2]} ${w[3]} ${w[4]}.`,
|
||||
],
|
||||
6: [
|
||||
w => `${w[0]} met ${w[1]} at the ${w[2]}. Together they found ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||
w => `The ${w[0]} ${w[1]} wore a ${w[2]} hat while eating ${w[3]} ${w[4]} ${w[5]}.`,
|
||||
w => `Detective ${w[0]} found ${w[1]} ${w[2]} near the ${w[3]} ${w[4]} ${w[5]}.`,
|
||||
w => `In the ${w[0]} ${w[1]}, a ${w[2]} ${w[3]} sang about ${w[4]} ${w[5]}.`,
|
||||
w => `Chef ${w[0]} combined ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||
],
|
||||
7: [
|
||||
w => `${w[0]} and ${w[1]} walked through the ${w[2]} ${w[3]} to find the ${w[4]} ${w[5]} ${w[6]}.`,
|
||||
w => `The ${w[0]} professor studied ${w[1]} ${w[2]} while drinking ${w[3]} ${w[4]} with ${w[5]} ${w[6]}.`,
|
||||
w => `"${w[0]} ${w[1]}!" yelled ${w[2]} as ${w[3]} ${w[4]} attacked the ${w[5]} ${w[6]}.`,
|
||||
w => `In ${w[0]}, King ${w[1]} decreed that ${w[2]} ${w[3]} must honor ${w[4]} ${w[5]} ${w[6]}.`,
|
||||
],
|
||||
8: [
|
||||
w => `${w[0]} ${w[1]} and ${w[2]} ${w[3]} met at the ${w[4]} ${w[5]} to discuss ${w[6]} ${w[7]}.`,
|
||||
w => `The ${w[0]} ${w[1]} ${w[2]} traveled from ${w[3]} to ${w[4]} carrying ${w[5]} ${w[6]} ${w[7]}.`,
|
||||
w => `${w[0]} discovered that ${w[1]} ${w[2]} plus ${w[3]} ${w[4]} equals ${w[5]} ${w[6]} ${w[7]}.`,
|
||||
],
|
||||
9: [
|
||||
w => `${w[0]} ${w[1]} ${w[2]} watched as ${w[3]} ${w[4]} ${w[5]} danced with ${w[6]} ${w[7]} ${w[8]}.`,
|
||||
w => `In the ${w[0]} ${w[1]} ${w[2]}, three friends — ${w[3]}, ${w[4]}, ${w[5]} — found ${w[6]} ${w[7]} ${w[8]}.`,
|
||||
w => `The recipe: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}.`,
|
||||
],
|
||||
10: [
|
||||
w => `${w[0]} ${w[1]} told ${w[2]} ${w[3]} about the ${w[4]} ${w[5]} ${w[6]} hidden in ${w[7]} ${w[8]} ${w[9]}.`,
|
||||
w => `The ${w[0]} ${w[1]} ${w[2]} ${w[3]} ${w[4]} lived beside ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]}.`,
|
||||
],
|
||||
11: [
|
||||
w => `${w[0]} ${w[1]} ${w[2]} and ${w[3]} ${w[4]} ${w[5]} discovered ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||
w => `In ${w[0]} ${w[1]}, the ${w[2]} ${w[3]} ${w[4]} sang of ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||
],
|
||||
12: [
|
||||
w => `${w[0]} ${w[1]} ${w[2]} met ${w[3]} ${w[4]} ${w[5]} at the ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]} ${w[11]}.`,
|
||||
w => `The twelve treasures: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}, ${w[9]}, ${w[10]}, ${w[11]}.`,
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap word in highlight span
|
||||
*/
|
||||
hl(word) {
|
||||
return `<span class="passphrase-word">${word}</span>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a memory story for given words
|
||||
* @param {string[]} words - Array of passphrase words
|
||||
* @param {number|null} idx - Template index (null for current)
|
||||
* @returns {string} HTML story
|
||||
*/
|
||||
generateStory(words, idx = null) {
|
||||
const count = words.length;
|
||||
if (count === 0) return '';
|
||||
|
||||
// Clamp to supported range (3-12)
|
||||
const templateKey = Math.max(3, Math.min(12, count));
|
||||
const templates = this.storyTemplates[templateKey];
|
||||
|
||||
if (!templates || templates.length === 0) {
|
||||
// Fallback: just list the words
|
||||
return words.map(w => this.hl(w)).join(' — ');
|
||||
}
|
||||
|
||||
const templateIdx = (idx ?? this.currentStoryTemplate) % templates.length;
|
||||
// Apply highlighting to words
|
||||
const highlighted = words.map(w => this.hl(w));
|
||||
return templates[templateIdx](highlighted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle memory aid visibility
|
||||
* @param {string[]} words - Passphrase words array
|
||||
*/
|
||||
toggleMemoryAid(words) {
|
||||
const container = document.getElementById('memoryAidContainer');
|
||||
const icon = document.getElementById('memoryAidIcon');
|
||||
const text = document.getElementById('memoryAidText');
|
||||
|
||||
const isHidden = container?.classList.contains('d-none');
|
||||
container?.classList.toggle('d-none', !isHidden);
|
||||
|
||||
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
|
||||
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
|
||||
|
||||
if (isHidden) {
|
||||
document.getElementById('memoryStory').innerHTML = this.generateStory(words);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate story with next template
|
||||
* @param {string[]} words - Passphrase words array
|
||||
*/
|
||||
regenerateStory(words) {
|
||||
const count = words.length;
|
||||
const templateKey = Math.max(3, Math.min(12, count));
|
||||
const templates = this.storyTemplates[templateKey] || [];
|
||||
this.currentStoryTemplate = (this.currentStoryTemplate + 1) % Math.max(1, templates.length);
|
||||
document.getElementById('memoryStory').innerHTML = this.generateStory(words, this.currentStoryTemplate);
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// QR CODE PRINTING
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Print QR code in new window
|
||||
*/
|
||||
printQrCode() {
|
||||
const qrImg = document.getElementById('qrCodeImage');
|
||||
if (!qrImg) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>QR Code</title>
|
||||
<style>
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
img { max-width: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${qrImg.src}" alt="QR Code">
|
||||
<script>window.onload = function() { window.print(); }<\/script>
|
||||
</body>
|
||||
</html>`);
|
||||
printWindow.document.close();
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// INITIALIZATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize generate form page
|
||||
*/
|
||||
initForm() {
|
||||
this.initWordsSlider();
|
||||
this.initOptionToggles();
|
||||
}
|
||||
};
|
||||
|
||||
// Global function wrappers for onclick handlers
|
||||
function togglePinVisibility() {
|
||||
StegasooGenerate.togglePinVisibility();
|
||||
}
|
||||
|
||||
function togglePassphraseVisibility() {
|
||||
StegasooGenerate.togglePassphraseVisibility();
|
||||
}
|
||||
|
||||
function printQrCode() {
|
||||
StegasooGenerate.printQrCode();
|
||||
}
|
||||
|
||||
// Auto-init form controls
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.querySelector('[data-page="generate"]')) {
|
||||
StegasooGenerate.initForm();
|
||||
}
|
||||
});
|
||||