This app is end-to-end encrypted. The server stores ciphertext and a password hash. Plaintext content, note titles, and the symmetric encryption key never leave the browser.
- Loss / theft of the SQLite file. An attacker with a copy of
~/.notes/db/notes.dblearns ciphertext blobs, password hashes, and metadata. They learn no plaintext. Mounting an offline dictionary attack against the password requires beating Argon2id (m=64MiB, t=3) — measured in hundreds of milliseconds per guess on contemporary hardware. - Read-only compromise of the server process or disk. Same property — ciphertext only.
- Network attacker on a hostile LAN. The app expects to run behind HTTPS in production.
Securecookie attribute is on by default;Strict-Transport-Securityis stamped on HTTPS responses. - Online password guessing. Per-account lockout escalates 30m → 2h → 8h → 32h after each set of 3 failed logins. Per-IP token bucket on
/login,/register,/api/auth/refresh(10/min, 100/hour). - Horizontal privilege escalation. Every per-user query carries a
WHERE user_id = ?predicate. Cross-user resource access returns 404, not 403, so resource existence isn't disclosed. Admin gating is server-side only — theadminrole isn't a client claim. - Stolen access token. Access cookie has a 15-minute TTL. Refresh-token rotation with reuse detection: any replay of a previously-rotated token revokes the entire family and emits a critical audit event.
- Tampering with the audit log. Each row's hash chains off the previous; any in-place edit breaks verification at the tampered row. Immutability triggers in SQLite block direct UPDATE/DELETE on chain-relevant columns. The audit-log AES key is stored in the OS keychain by default (file fallback at
~/.notes/keys/audit.keymode 0600). - "Harvest now, decrypt later" quantum attacks against ciphertext exfiltrated today. Each note's per-note AES key is wrapped under both RSA-4096 and ML-KEM-768. A future CRQC that breaks RSA but not ML-KEM still cannot decrypt notes encrypted today.
- An attacker who controls the server while a user is actively logged in. They can serve modified JS that exfiltrates the in-memory key. This is a fundamental limit of browser-delivered E2EE; Signal Desktop, Bitwarden, Proton all have the same property. Mitigation is operational (HTTPS, code review, supply-chain hygiene), not cryptographic.
- An attacker with root on the user's device. They can read
sessionStoragedirectly. - An attacker who has the audit-log key AND DB write access. They can forge entries. The hash chain catches in-place tampering but cannot prevent a fully-recreated chain. Mitigation: keep the audit key in the OS keychain.
- Side channels (timing attacks against Argon2, RSA decryption timing in the browser, etc.) beyond what the underlying libraries already mitigate.
- Lost passwords. A mandatory recovery code issued at registration wraps a second copy of the account Master Key. A user who still holds that code can reset their password without losing data via the zero-knowledge recovery flow at
/reset(GET /api/auth/recovery/material+POST /api/auth/recovery/reset); the server never sees the recovery code, the Master Key, or any plaintext, and the reset requires a Master-Key-derived verifier so a third party cannot overwrite the credential. There is no email reset (the project has no SMTP). Lose both the password and the recovery code and the data is unrecoverable by anyone, including the operator. - Online password-only login. An optional second factor (TOTP or WebAuthn) gates session issuance after the password step (see "Multi-factor authentication" below). When enabled, a phished / guessed / reused password yields no session without the second factor.
| Use | Algorithm | Where |
|---|---|---|
| Password hash (server) | Argon2id (m=64MiB, t=3, p=1, len=32) | argon2-cffi |
| KDF (browser, derives wrap key) | Argon2id (same params, separate salt) | argon2-browser (WASM) |
| Per-note content key | AES-256-GCM (random 32 bytes) | Web Crypto |
| Wrap key 1 (classical) | RSA-OAEP-4096 | Web Crypto |
| Wrap key 2 (PQ) | ML-KEM-768 KEM-DEM construction | mlkem (npm, vendored) |
| Audit log encryption | AES-256-GCM under app-wide key | cryptography.hazmat |
| Audit chain hash | sha256 over prev_hash || encrypted_payload || event_type || actor || target_type || target_id || created_at |
stdlib |
| Cookie tokens | secrets.token_urlsafe(32) (256 bits), stored as sha256 |
stdlib |
| Cookies | HttpOnly + Secure + SameSite=Lax. Refresh path-scoped to /api/auth/refresh. CSRF via double-submit. |
Starlette |
The user's asymmetric keypair pair (RSA + ML-KEM) rotates every 90 days. Triggered automatically at login when due; can be forced via /settings. Rotation:
- Client derives a fresh symmetric wrap key from the current password against a fresh salt.
- Client generates a fresh RSA-4096 keypair and a fresh ML-KEM-768 keypair.
- Client wraps both new private keys under the new derived key.
- A Web Worker decrypts each per-note AES key using the OLD private keys (KEM preferred — sub-millisecond; RSA fallback) and re-wraps under the NEW public keys.
- Single atomic
POST /api/auth/keys/rotateeither applies all changes or none.
The per-note content keys themselves do not rotate. Only their wrappers. This is the standard hybrid-rotation shape.
MFA is an authentication gate only. A confirmed second factor decides whether the server issues session cookies after the password step; it never touches the Master Key, the password-derived wrap_key, the recovery envelope, or any per-note key. The client holds wrap_key in memory across the MFA prompt and still unwraps everything client-side, so the E2EE properties above are unchanged. MFA is opt-in per user and can be admin-enforced per user or per role.
Two factors are supported:
- TOTP (RFC 6238, stdlib): HMAC-SHA1, 6 digits, 30s period, ±1 step skew. The server verifies the code, so it must hold the shared secret; the secret is stored
AES-256-GCM(audit_key, secret)with AAD bound tomfa:totp:<user_id>:<cred_id>and is never returned after enrollment. Atotp_last_stepreplay guard rejects a code (or an earlier-window code) that has already been accepted. - WebAuthn (
webauthn/ py_webauthn): the server stores only the credential public key +sign_count. Assertions are verified server-side; challenges are server-generated and single-use; asign_countthat fails to advance is flagged as a possible cloned authenticator (auth.mfa.login.regression, critical). WebAuthn requires a secure context (HTTPS, orlocalhostin dev) and a configured relying party (webauthn_rp_id/webauthn_origin).
The login intermediate state is an mfa_pending ticket: after the password succeeds the server stores sha256(ticket) and delivers the raw ticket as the path-scoped notes_mfa cookie (HttpOnly, Secure, path=/api/auth/mfa, ~5m). No access/refresh/csrf cookie is issued until /api/auth/mfa/login-verify succeeds. Lost-factor fallbacks are 10 one-time backup codes (high-entropy, HMAC-SHA256(server_secret, …), single-use) and the recovery-code reset, which additionally wipes every factor (mfa_enabled=0) while preserving the admin mfa_required policy.
Failed MFA never trips the password lockout (it would let an attacker who holds the password lock out the legitimate user). Throttling is the per-ticket attempt cap (5) plus the per-(IP, path) rate-limit entries. TOTP brute force is infeasible (6 digits, 30s window, replay guard, cap, rate limit); the TOTP secret is encrypted under the audit key, so a DB-only dump yields no usable secret.
Explicit at-rest non-goal. MFA does not protect ciphertext at rest. An attacker who steals ~/.notes/db/notes.db and cracks the password derives wrap_key, unwraps the Master Key from master_key_wrapped, and decrypts every note offline — entirely independent of any second factor, because the data path is gated by the password, not by MFA. This is by design: MFA gates online session issuance, not the key hierarchy. The only mitigation today is the Argon2id cost (m=64MiB, t=3) on the password. Closing this gap requires a crypto-bound WebAuthn envelope (a hardware key's WebAuthn PRF / hmac-secret extension deriving a KEK that wraps the Master Key as a required unlock envelope); the Master-Key indirection already supports N envelopes, but this is deferred (see "Deferred").
Every security-relevant event lands in the encrypted, hash-chained logs table. Examples: auth.login.success, auth.login.fail, auth.lockout, auth.refresh.reuse_detected (severity critical), auth.session.rotated_after_password_change, crypto.rotation.complete, notes.key_version.mismatch (severity warning), note.pin / note.unpin, note.star / note.unstar, note.archive / note.unarchive, admin.user.delete (critical), admin.user.role_change.blocked_last_admin, admin.audit.read, admin.audit.export.row, admin.audit.export.batch, admin.settings.short_retention.acknowledged, config.update, system.log_chain.broken (critical), system.log_chain.rehashed_v2 (one-shot, emitted after migration 004), system.notes.flags_added (one-shot, emitted after migration 005). See docs/diagrams/audit-chain.md for the chain construction.
Note-flag mutation events carry metadata {flag, value} only — never the note title or content. The three flags themselves (is_pinned, is_starred, is_archived) are server-known booleans and are NOT covered by the E2EE invariant: the server can enumerate which notes a user has pinned, starred, or archived. Content + title + key material remain ciphertext-only.
What is never logged: note content, note titles, passwords, password hashes, derived symmetric keys, wrapped private keys, salt, KDF params, access/refresh tokens, the audit-log key. The audit module enforces a denylist of forbidden field names AND scans bare strings inside list metadata for password=, token=, etc. In dev it raises; in prod it drops the offending event with a system.log.dropped marker.
The audit log payload is AES-256-GCM-encrypted with AAD bound to row identity (event_type | actor_user_id | created_at); a payload swap between rows fails decrypt with InvalidTag. The chain hash binds eight fields including retention_until (formula v2, since migration 004): prev_hash || encrypted_payload || event_type || actor || target_type || target_id || created_at || retention_until.
The round-2 remediation pass landed the following items previously listed as deferred or partial:
| Finding | Status | Where it lives |
|---|---|---|
| HIBP breach-list check (ASVS V2.2.2) | IMPLEMENTED | auth/routes.py:breach_check (k-anon range query; opt-in via NOTES_HIBP_CHECK_ENABLED=1 to keep offline deployments offline) |
| Fresh-auth gate on destructive admin ops (ASVS V4.3.2) | IMPLEMENTED | middleware/auth.py:requires_fresh_auth (returns 428 when the access cookie is older than the freshness window) |
| Critical-event alerting (ISO/IEC 27001 A.8.16) | IMPLEMENTED | audit/alerting.py (out-of-band webhook for system.log_chain.broken, auth.refresh.reuse_detected, admin.user.delete; configure with NOTES_ALERT_WEBHOOK_URL) |
| CSP report endpoint | IMPLEMENTED | middleware/csp_report.py (drops parsed reports into the audit log as system.csp.violation) |
| Note-flags rate-limit dead code (M2-01 / N2-01) | FIXED | middleware/ratelimit.py LIMITS key now uses {note_id:str} to match the route |
safe_next_path backslash bypass (A2-01) |
FIXED | middleware/auth.py:safe_next_path rejects any backslash before the prefix-shape check |
| Rotation new-key bytes lifetime (C2-01) | FIXED | static/js/rotation.js scrubs newRsa.privateKey / newKem.privateKey immediately after each KeyStore.put* |
AAD collapse for actor_user_id == 0 (A3-01) |
FIXED | audit/log.py:_actor_field distinguishes integer 0 from None |
segment_rolled emitted post-COMMIT (A3-02) |
FIXED | audit/retention.py:sweep emits the marker inside the same BEGIN IMMEDIATE as the delete |
| Audit retention floor too low (A6-01) | FIXED | admin/settings.py:AUDIT_RETENTION_FLOOR_DAYS = 60 matches the chain immutability window |
| Admin audit-list audit-of-audit gap (TC-29) | FIXED | admin/audit.py:list_audit_api emits admin.audit.viewed with filter-shape metadata only |
| Admin deactivate/delete TOCTOU on last admin (TC-32) | FIXED | admin/users.py:_set_active_blocking and _delete_user_blocking wrap the guard in BEGIN IMMEDIATE |
| Folder cycle detection (N2-02 / F2-03) | FIXED | db.models.FolderCycleDetected (server) + visited-set guard in static/js/modules/export.js:_collectNotesUnderFolder (client) |
sanitizeFilename permits .. (N2-03) |
FIXED | static/js/modules/export.js:sanitizeFilename collapses path separators and rejects ./.. |
Recursive folder export ignores is_archived (TC-E29) |
FIXED | _collectNotesUnderFolder filters archived notes unless the active view is archived |
| Release tarball / install.sh self-update (Dep-01 / Dep-02) | FIXED | install.sh verifies sha256sum -c before tar -xzf; curl-pipe-bash self-update removed (operator downloads + verifies, then sudo bash install.sh) |
| systemd sandbox profile (Dep-03 / Dep-06 / Dep-08) | FIXED | install.sh system + user unit ship the full hardening directive set; systemd-analyze security target <= 4.5 |
| nginx TLS + DoS controls (Dep-04 / Dep-09) | FIXED | install.sh nginx vhost: ssl_ciphers, ssl_stapling, server_tokens off, limit_req_zone, proxy_request_buffering off, client_max_body_size 13m, HSTS at edge |
| Self-signed cert posture (Dep-05) | FIXED | install.sh defaults to RSA-4096 / 397-day validity (P-256 via NOTES_CERT_KEYTYPE=ec) |
| curl invocations missing TLS flags (Dep-11) | FIXED | install.sh CURL_OPTS array pins --tlsv1.2 --proto =https --fail --location --retry 0 |
| install.sh missing leading umask (Dep-12) | FIXED | install.sh declares umask 077 as the first non-comment statement |
| Deployment doc split (Dep-10) | FIXED | install.sh is the deployment source of truth; the old docs/plans/09-deployment.md and docs/plans/10-deloyment-local.md were retired |
Deployment hardening (systemd sandbox profile, nginx TLS + DoS controls, tarball sha256 verification, RSA-4096 / 397-day self-signed cert) is now anchored in install.sh; the structured header comment at the top of that file is the operator-facing reference.
| Finding | Rationale |
|---|---|
| Crypto-bound WebAuthn (hardware-key PRF envelope around the Master Key) | Would close the "DB theft + cracked password" at-rest gap by making a hardware key a required unlock envelope. The Master-Key indirection already supports N envelopes (a future column is additive), but a required crypto-bound factor sharply raises hard-lockout risk and cannot include TOTP, so it is out of scope. MFA as an auth gate (TOTP + WebAuthn) shipped (migration 008); see "Multi-factor authentication" above. |
After running install.sh the operator owns:
- Replacing the self-signed cert with a CA-issued one (e.g.
certbot --nginx -d <host> --redirect) for any deploy beyond a single trusted device. Self-signed is fine for loopback / personal LAN; browsers warn on first visit elsewhere. - Keeping nginx and the host OS patched (unattended-upgrades or equivalent). The systemd sandbox profile reduces blast radius but does not eliminate the need to patch.
- Configuring
NOTES_ALERT_WEBHOOK_URLfor real-time monitoring of the critical-severity audit events: audit chain break, refresh-token reuse, admin user delete/reset, recovery-code reset, MFA disable, admin MFA enforcement, and WebAuthn clone detection (auth.mfa.login.regression). The canonical set isaudit.alerting.CRITICAL_EVENTS. The audit log is at-rest; the webhook is the real-time channel. - Backing up
keys/audit.keyto a different location fromdb/notes.db. An attacker holding both can decrypt audit metadata. - Monitoring
journalctl -u notes -ffor service crashes or sandbox denials.
(set the disclosure address before release)
For now: open an issue on the repository, marking the title [security]. The maintainer will respond within 7 days. We don't yet have a CVE-numbering process; one will be set up before public release.
If the issue concerns active in-the-wild abuse, please redact specifics from the public issue and follow up by direct contact.