commit de38590f467713645098ad452102c9c5e7c61881 Author: Malcolm Roberts Date: Fri Oct 2 13:38:46 2026 -0500 docs: add book tracker design spec and auth ADRs Spec covers data model, API, auth, frontend, error handling, and testing. ADR-0001 records PBKDF2 + pepper (FIPS) over argon2; ADR-0002 records revocable server-side sessions over Flask's signed cookie. diff --git a/docs/adr/0001-password-hashing-pbkdf2.md b/docs/adr/0001-password-hashing-pbkdf2.md new file mode 100644 index 0000000..af7090d --- /dev/null +++ b/docs/adr/0001-password-hashing-pbkdf2.md @@ -0,0 +1,46 @@ +# ADR-0001: Password hashing with peppered PBKDF2-HMAC-SHA256 + +Date: 2026-10-02 +Status: Accepted + +## Context + +Users authenticate with a username and password. The application targets a healthcare firm, so +cryptographic choices should be FIPS 140-approved where practical. The assignment restricts the backend to +standard Python libraries (Flask and psycopg2 are provided by the environment). The runtime container has +0.5 GB of RAM. + +Memory-hard algorithms (Argon2id, scrypt) resist GPU cracking better, but neither is FIPS-approved and +Argon2 is not in the Python standard library. + +## Decision + +Hash passwords with PBKDF2-HMAC-SHA256 from `hashlib`, keyed by a secret pepper: + +``` +peppered = HMAC-SHA256(key=PEPPER, msg=password_utf8) +derived = hashlib.pbkdf2_hmac("sha256", peppered, salt, 600_000) +stored = "pbkdf2_sha256$600000$$" +``` + +- **Salt:** `secrets.token_bytes(16)` per user (128 bits; NIST SP 800-132 requires ≥ 128). +- **Iterations:** 600,000 (OWASP Password Storage Cheat Sheet figure for PBKDF2-HMAC-SHA256). Stored with + the hash; hashes below the current count are re-derived on the next successful login. +- **Pepper:** a secret held outside the database, applied as an HMAC key (NIST SP 800-63B's "secret salt" + recommendation). A database-only leak does not allow offline guessing. Source: `PASSWORD_PEPPER` + (≥ 32 chars) when set; otherwise a 32-byte random value generated once into `backend/.pepper` + (mode 0600, gitignored) with a startup warning that this fallback is for development only. +- **Comparison:** `hmac.compare_digest` (constant time). +- **Enumeration resistance:** a login for an unknown username verifies against a fixed dummy hash so + response time matches a real account; both failures return the same message. +- **Policy:** 12–1024 characters, no composition rules (NIST SP 800-63B). The upper bound caps CPU cost + per request. + +## Consequences + +- FIPS-approved primitives only (HMAC-SHA256, PBKDF2); no third-party dependency. +- Each login costs ~0.3–0.5 s of CPU and negligible memory, which fits the container. +- Weaker than Argon2id against GPU attacks on a leaked database; the pepper offsets this, because the + attacker also needs the application secret. +- Losing the pepper invalidates every password. Production must keep it in a secrets manager and add + pepper versioning for rotation (out of scope here). diff --git a/docs/adr/0002-server-side-sessions.md b/docs/adr/0002-server-side-sessions.md new file mode 100644 index 0000000..8b6daa6 --- /dev/null +++ b/docs/adr/0002-server-side-sessions.md @@ -0,0 +1,50 @@ +# ADR-0002: Server-side sessions with opaque tokens hashed at rest + +Date: 2026-10-02 +Status: Accepted + +## Context + +After login, the browser needs a credential for subsequent API calls. Flask's default session is a signed +cookie (itsdangerous, HMAC-SHA1 by default) holding the session data client-side. That cookie cannot be +revoked before it expires: logout only asks the browser to forget it, and a stolen cookie stays valid until +`SECRET_KEY` rotates. Healthcare applications are expected to support automatic logoff after inactivity +(HIPAA Security Rule, 45 CFR 164.312(a)(2)(iii)) and real revocation. + +## Decision + +Store sessions in PostgreSQL, keyed by the SHA-256 of an opaque random token. + +- **Issue:** on login/register, `token = secrets.token_urlsafe(32)` (256 bits). Insert `sha256(token)`, + `user_id`, `created_at`, `expires_at = now() + 30 minutes`. The raw token exists only in the cookie. +- **Cookie:** `sid=; HttpOnly; Secure; SameSite=Lax; Path=/api`. +- **Validate and slide:** one statement per authenticated request: + ```sql + UPDATE sessions + SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours') + WHERE token_hash = %s AND expires_at > now() + RETURNING user_id; + ``` + This gives a 30-minute idle timeout and a 12-hour absolute cap. No row → 401. +- **Revoke:** logout deletes the row. Deleting a user cascades to their sessions. +- **Cleanup:** a user's expired rows are deleted when they log in; no scheduled job. + +### CSRF + +The cookie is `SameSite=Lax`, so browsers do not send it on cross-site POST/PATCH/DELETE. In addition, +every mutating route requires `Content-Type: application/json`; a cross-origin request with that type +triggers a CORS preflight, and the API never grants CORS. Together these remove the need for a CSRF token. + +## Consequences + +- Logout and server-side expiry actually revoke access. +- A database leak exposes only token hashes, which cannot be replayed as cookies. +- One indexed UPDATE per authenticated request; negligible at this scale. +- Hashing tokens with plain SHA-256 (not a slow KDF) is correct here: tokens carry 256 bits of entropy, so + brute force is infeasible. + +## Known gaps (out of scope) + +- **No login rate limiting or lockout.** Online password guessing is bounded only by PBKDF2 cost. + Before production, add per-username and per-IP throttling. +- No "log out other devices" UI, though the schema supports it (`DELETE … WHERE user_id = %s`).