End-to-end encrypted notes app. Local-first, zero-knowledge, hybrid post-quantum (RSA-4096 + ML-KEM-768). The server stores ciphertext only; plaintext never leaves the browser. See SECURITY.md for the threat model and CLAUDE.md for the architecture.
notes_v2.0.0.mp4
Download install.sh and its sha256 sidecar from the latest release page, verify the script against the posted hash, and run it. The installer writes a hardened systemd unit, optionally configures nginx + a self-signed cert, and preserves data across upgrades. Every operator-facing detail (modes, env vars, data layout, security posture, troubleshooting) is documented in the structured header comment at the top of install.sh itself; read it before running.
curl -fsSLO https://github.com/mufasa159/notes-web/releases/latest/download/install.sh
curl -fsSLO https://github.com/mufasa159/notes-web/releases/latest/download/install.sh.sha256
sha256sum -c install.sh.sha256
sudo bash install.sh --systemRequires Python 3.12+.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py # http://127.0.0.1:8000
NOTES_DEBUG=1 python app.py # uvicorn --reload + ./.notes/ for stateThe first user to register becomes admin.
app.py, config.py, storage.py, migrate.py entry points
auth/ login, register, sessions, tokens, lockout
notes/ /api/notes + /api/folders
audit/ encrypted-at-rest, hash-chained log
admin/ /admin/users, /admin/audit, /admin/settings
crypto/ server-side rotation payload validation
db/ shared async aiosqlite helpers
jobs/ background tasks (backups, chain verify, retention)
middleware/ access, csrf, headers, ratelimit, ip allowlist, validation
migrations/ numbered .sql + .py files
templates/, static/, tests/, docs/
See CLAUDE.md for module-level detail and docs/ for design.
| Var | Default | Effect |
|---|---|---|
NOTES_HOME |
~/.notes |
Where state (DB, keys, backups, logs) lives. |
NOTES_DEBUG |
unset | When 1: Starlette debug + uvicorn --reload, pins state to ./.notes/, drops the Secure cookie attribute. |
NOTES_INSECURE_COOKIES |
unset | When 1: drops the Secure cookie attribute. For HTTP deployments without NOTES_DEBUG. |
NOTES_AUDIT_KEY |
unset | When file: forces audit-log key onto disk (~/.notes/keys/audit.key, mode 0600) instead of OS keychain. |
NOTES_DISABLE_BACKGROUND_JOBS |
unset | When 1: skips the backup, chain-verify, and retention tasks. |
- KDF in browser: Argon2id over the password, split via HKDF-SHA256 into
auth_hash(sent to server) andwrap_key(stays local). - Each user holds two keypairs generated in the browser: RSA-OAEP-4096 and ML-KEM-768.
- Each note has a per-note AES-256-GCM key, wrapped under both public keys.
- Keys auto-rotate every 90 days at login in a Web Worker (configurable).
- Audit log is AES-256-GCM encrypted, hash-chained, immutable for 60 days.
See docs/diagrams/ for sequence + activity diagrams covering each flow.
Schema lives in migrations/, numbered NNN_name.{sql,py}. The runner applies pending files at startup, refuses to run if a checksum drifted or a gap is inserted. Never edit a migration after it ships; write a new one.
python migrate.py --status # applied vs pending
python migrate.py # apply pendingpytest # full suite
pytest tests/auth # one domainSource files under static/{js,css} are served as-is in dev. For production, run the bundler to emit content-hashed copies under static/dist/ plus a manifest.json:
python build.py # write static/dist/
python build.py --clean # wipe and rebuildCSS is minified; JS modules are copied with hashed filenames and their relative imports rewritten. Vendor (static/vendor/*) and Monaco assets are never bundled. Templates resolve assets via the Jinja static_path() helper, which picks /static/dist/... when a manifest is present and NOTES_DEBUG is not set, otherwise falls back to source paths.
Three layers of integrity, smallest to largest:
1. Hash-pinned Python dependencies. requirements.lock pins every direct + transitive dependency to an exact version and a sha256 of the wheel/sdist. Production installs use:
pip install --require-hashes -r requirements.lock--require-hashes makes pip refuse any wheel whose digest doesn't match — a tampered PyPI mirror or compromised maintainer account cannot silently swap a release. requirements.txt stays the human-readable source of truth (direct deps with version pins). To regenerate the lock after editing requirements.txt, see CONTRIBUTING.md.
2. Content-hashed dist filenames. python build.py rewrites static/js/*.js and static/css/*.css to static/dist/<name>.<sha256-prefix>.<ext>. The hash in the URL is the file's content fingerprint, so any byte change forces a new URL — clients cannot serve a cached, stale copy of an old payload after a security fix ships.
3. Subresource Integrity (SRI) on vendored scripts. build.py also writes static/dist/sri.json with sha384 hashes for every directly-loaded vendor entry point (argon2-browser, mlkem, the monaco loader). Templates render <script integrity="sha384-..." crossorigin="anonymous"> so the browser refuses to execute a vendor file whose body has been altered after build time. Note: Monaco's loader bootstraps further chunks at runtime — those lazily-loaded chunks fall outside the SRI list (best effort; documented in templates/notes.html).