Skip to content

Security: mufasa159/notes-web

SECURITY.md

Security

Threat model

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.

What the design defends against

  • Loss / theft of the SQLite file. An attacker with a copy of ~/.notes/db/notes.db learns 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. Secure cookie attribute is on by default; Strict-Transport-Security is 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 — the admin role 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.key mode 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.

What the design does NOT defend against

  • 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 sessionStorage directly.
  • 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.

Cryptographic primitives

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

Key rotation

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:

  1. Client derives a fresh symmetric wrap key from the current password against a fresh salt.
  2. Client generates a fresh RSA-4096 keypair and a fresh ML-KEM-768 keypair.
  3. Client wraps both new private keys under the new derived key.
  4. 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.
  5. Single atomic POST /api/auth/keys/rotate either applies all changes or none.

The per-note content keys themselves do not rotate. Only their wrappers. This is the standard hybrid-rotation shape.

Multi-factor authentication

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 to mfa:totp:<user_id>:<cred_id> and is never returned after enrollment. A totp_last_step replay 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; a sign_count that fails to advance is flagged as a possible cloned authenticator (auth.mfa.login.regression, critical). WebAuthn requires a secure context (HTTPS, or localhost in 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").

What gets logged

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.

Round-2 closures

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.

Deferred

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.

Operator residual responsibility

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_URL for 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 is audit.alerting.CRITICAL_EVENTS. The audit log is at-rest; the webhook is the real-time channel.
  • Backing up keys/audit.key to a different location from db/notes.db. An attacker holding both can decrypt audit metadata.
  • Monitoring journalctl -u notes -f for service crashes or sandbox denials.

Reporting a vulnerability

(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.

There aren't any published security advisories