A zero-knowledge encrypted notes webapp. All note content is encrypted and decrypted in your browser — the server stores only ciphertext and never sees your data.
- Encryption: AES-256-GCM with PBKDF2 key derivation (600,000 iterations)
- Auth: SHA-256(password + salt) → Argon2id on the server
- Optional: TOTP two-factor authentication (via any authenticator app)
- PWA: Installable as a standalone app on mobile/desktop
Browser (HTTPS) ──► Reverse proxy ──► Container :8000
│
▼
SQLite (./data/)
Only one port (4861 by default) is needed — the container serves both the SPA frontend and the JSON API.
- Docker and Docker Compose (v2) on your server
- A reverse proxy (Caddy, nginx, Traefik, NPM, Synology) for HTTPS termination
- ~512 MB RAM for the container
git clone [email protected]:Matthew-Glt/zero-knowledge-notes.git
cd zero-knowledge-notesOption A — Use the included script (requires Python 3 + cryptography):
python scripts/generate-secrets.pyThis prints everything you need for .env. Copy the output.
Option B — Generate manually (no dependencies):
# FLASK_SECRET_KEY — 64 hex characters (signs login sessions)
python3 -c "import secrets; print(f'FLASK_SECRET_KEY={secrets.token_hex(32)}')"
# FERNET_KEY — encrypts TOTP seed at rest (requires Fernet)
python3 -c "from cryptography.fernet import Fernet; print(f'FERNET_KEY={Fernet.generate_key().decode()}')"If Python 3 + cryptography aren't available on the target machine, generate on any machine that has them and paste the values.
cp .env.example .env
chmod 600 .envEdit .env and paste the generated values:
FLASK_SECRET_KEY=a1b2c3...64-hex-chars...
FERNET_KEY=wNAUYHOZsMoyWGCEUA63cxbZ59ljSzqMk4WiI8ZiQu4=
NOTES_HTTPS=1
PORT=8000| Variable | Required? | What it does |
|---|---|---|
FLASK_SECRET_KEY |
✅ Yes | Signs session cookies. Never change after go-live. |
FERNET_KEY |
✅ Yes (for TOTP) | Encrypts TOTP seed at rest. Never change after enabling 2FA. |
NOTES_HTTPS |
✅ Yes | 1 = cookies use Secure flag (keep 1 behind HTTPS proxy). |
PORT |
❌ Optional | Internal container port. Leave as 8000. |
mkdir -p data
chmod 700 dataBuild the image and create the admin account (one-time setup):
sudo docker compose build
sudo docker compose run --rm notes python backend/setup.py --password "your-strong-password"This creates data/notes.db with the admin user. Your password is hashed with Argon2id and used to derive an encryption key — the server never sees the plaintext.
Alternative: skip
setup.pyand runsudo docker compose up -dthen open the site. The first-run browser setup wizard will appear (only works when no user exists).
sudo docker compose up -d
sudo docker compose logs --tail 20 notesVerify it's running:
curl -s http://127.0.0.1:4861/api/auth/saltExpected output: {"auth_salt":"...","enc_salt":"..."} (JSON, not HTML).
The container binds to 127.0.0.1:4861 (localhost only — not exposed to the network).
Configure your reverse proxy to forward https://notes.yourdomain.com → http://127.0.0.1:4861.
Caddy example:
notes.yourdomain.com {
reverse_proxy 127.0.0.1:4861
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
Content-Security-Policy "default-src 'self'; img-src 'self' https://api.qrserver.com data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
}nginx example:
server {
listen 443 ssl;
server_name notes.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:4861;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Important: The API under /api/* is served by the same container. Ensure your proxy forwards all paths (/ and /api/*) to the same backend — don't split static vs API routes.
- Open
https://notes.yourdomain.comin a browser - Sign in with the password you set in step 4
- Start creating encrypted notes
All encryption/decryption happens in JavaScript — the server stores only ciphertext.
sudo docker compose logs -f notesgit pull
sudo docker compose up -d --buildsudo docker compose down
sudo docker compose up -dcp data/notes.db data/notes-$(date +%F).dbThe entire database is a single SQLite file — back it up regularly.
sudo docker compose down
rm -f data/notes.db
sudo docker compose run --rm notes python backend/setup.py --password "new-password"
sudo docker compose up -d- Sign in → click the 🔐 button (or go to Settings)
- Scan the QR code with an authenticator app
- Enter the 6-digit code to confirm
- Save your backup codes somewhere safe
Before enabling TOTP, make sure FERNET_KEY is set in .env — the TOTP seed is encrypted at rest using Fernet.
Edit docker-compose.yaml and change the port mapping:
ports:
- "127.0.0.1:4861:8000"Change 4861 to your desired port. Update your reverse proxy target accordingly. Then:
sudo docker compose up -d├── Dockerfile # Production image (Python 3.11-slim + Gunicorn)
├── docker-compose.yaml # Compose stack
├── .env.example # Template — copy to .env and fill in secrets
├── .gitignore
├── scripts/
│ └── generate-secrets.py # One-shot secret generator
├── backend/
│ ├── app.py # Flask app routes
│ ├── auth.py # Session management
│ ├── config.py # Configuration from environment
│ ├── crypto_utils.py # Argon2id, Fernet, key wrapping
│ ├── ip_blocker.py # Rate limiting and IP bans
│ ├── models.py # SQLite schema and connection management
│ ├── setup.py # Admin user creation
│ └── requirements.txt
└── frontend/
├── index.html # SPA shell
├── app.js # Application controller
├── api.js # API client (REST calls)
├── crypto.js # Client-side AES-256-GCM + PBKDF2
├── notes.js # Notes UI
├── styles.css # Dark/light mode CSS
├── manifest.json # PWA manifest
├── sw.js # Service worker (app shell caching only)
└── icons/
├── icon-192.png
└── icon-512.png
| Symptom | Fix |
|---|---|
encryption not configured (FERNET_KEY not set) |
Add FERNET_KEY to .env, rebuild, restart |
| Blank page after login | Hard-refresh (Ctrl+Shift+R) or unregister service worker in DevTools → Application → Service Workers |
Login returns HTML instead of JSON (Unexpected token '<'...) |
Browser got HTML instead of JSON — ensure reverse proxy forwards /api/* to the container on port 4861. Test: curl -s http://127.0.0.1:4861/api/auth/salt should return {"auth_salt":"..."} |
| TOTP codes not accepted | Check server clock (date). TOTP requires the server time to be within ~30 seconds of the authenticator app. |
| Port already in use | Change 4861 in docker-compose.yaml and update your reverse proxy |
Permission denied on ./data |
chmod 700 data on the host; the container runs as root inside |
- Zero-knowledge: All note content is encrypted client-side. The server never has access to plaintext or the encryption key.
- Password hashing: SHA-256(password +
auth_salt) → Argon2id on the server. Argon2id is memory-hard (64 MB, 3 iterations, 4 parallelism). - Key derivation: PBKDF2-SHA256 at 600,000 iterations using
enc_saltto derive the wrapping key. - Session tokens: 64-byte random hex tokens, 24-hour expiry, HTTP-only cookies.
- TOTP seed: Encrypted at rest with Fernet (AES-128-CBC + HMAC) using
FERNET_KEY. - Rate limiting: IP-based bans after 3 failed attempts (5-min temp), 10 failures (1-hour long), 50 total events (permanent).
- HTTPS: Set
NOTES_HTTPS=1so session cookies get theSecureflag.
MIT