Skip to content

English · Español

Theory 03 — Auth and the minivault

🇪🇸 La autenticación se descompone en tres preocupaciones independientes: cómo se almacena la contraseña (Argon2id con pepper), cómo se mantiene viva la sesión (cookie firmada con itsdangerous), y cómo se cifra el archivo en reposo (AES-256-GCM con clave derivada en memoria). Cada una resiste un fallo diferente. Saltarse cualquiera es como ponerse cinturón pero olvidar los frenos.

The three concerns

A local-deployment portal still needs auth that survives the realistic attack surface: someone reads the SQLite file off the laptop. The mitigation stack has three independent layers:

  1. Password hashing (defence against credential leaks if the row is read).
  2. Server pepper (defence against rainbow-table reuse across deployments).
  3. Minivault encryption-at-rest (defence against the entire DB file being copied off-disk).

Each layer alone is insufficient; together they yield a cost-of-compromise that exceeds the value of the data (a learner's progress on a grammar tutor curriculum). For Phase 37's threat model, this stack handles T6 (credential theft) and one path of T8 (DB exfiltration).

Password hashing: Argon2id

The Phase 37 supply chain policy (see security/supply-chain.md) pins argon2-cffi and forbids PyCryptodome for password hashing. The reason: Argon2id is the OWASP recommendation for new applications, won the PHC competition, and the argon2-cffi binding is widely audited.

The Argon2id parameters, per the OWASP password-storage cheat sheet (2026 revision):

Parameter Value Rationale
memory_cost 64 MiB (65536 KiB) Saturates GPU attacks on consumer cards.
time_cost 3 iterations Empirical: hash-and-verify ~50 ms on Borja's i5-8250U.
parallelism 2 Two threads; matches typical interactive auth budget.
hash_len 32 bytes 256-bit output; future-proofed.
salt_len 16 bytes Per-credential, random; stored in the row.

The verification cost of 50 ms is deliberate. It bounds online-guessing attacks (~20 guesses/second/IP) without rendering login annoying. CI runs the calibration script tests/integration/test_argon2_calibration.py and asserts 35 ms ≤ verify_time ≤ 80 ms, failing if the parameters drift out of the band.

The server pepper

In addition to the per-credential salt, the portal applies a server-wide pepper to every password before hashing:

hash = argon2id(password || pepper, salt)

The pepper:

  • Lives in environment variable PORTAL_PEPPER.
  • Is loaded at app start; never written to the database.
  • Is rotated independently of any single user's password (the rotation procedure re-hashes all credentials lazily on next login).

Why the pepper is not in the DB: if the attacker reads the SQLite file but not the host's environment, the hashes are unverifiable. Salts alone don't give this — salts are meant to be next to the hash. The pepper widens the gap between "stole the DB" and "can mount an offline guess."

The pepper is treated as secret material: never logged, never echoed, never committed. The Phase 37 secret-scan job (regex over git diff --staged) catches PORTAL_PEPPER=... literal strings before they land in git history.

No-password-by-default

The portal supports an unusual login flow: a student record can exist with no password. The flow:

  1. Admin creates the student record. Inserts into students with role = 'student'. Inserts into credentials with argon2_hash = NULL, salt = <random 16 bytes>, must_set_on_next_login = TRUE. Audit log records admin_create_student.
  2. Student logs in by username only. The login endpoint checks must_set_on_next_login. If TRUE, the password field is ignored — the student is authenticated with username alone — and the response redirects to /set-password.
  3. Student sets password. POST to /set-password writes argon2_hash = argon2id(password || pepper, salt), password_set_at = NOW(), must_set_on_next_login = FALSE. Audit log records password_set.
  4. Subsequent logins require password. Standard verify-against-hash flow.
  5. Forgot-password path. The admin re-flags must_set_on_next_login = TRUE from the admin view. The student logs in by username only again, sets a new password. No email infrastructure. This is the explicit out-of-scope decision: email auth is heavy (SMTP creds, deliverability, anti-spam, bounce handling) and the portal is single-learner. Admin reset is enough.

The threat model for step 2 is "anyone who guesses the admin-created username can log in." The mitigation is twofold: the username is treated as a weak secret (random tokens preferred over guessable handles) and the audit log records every login, allowing the admin to spot anomalies. The MVP accepts this tradeoff because the deployment is localhost only and the attacker model excludes someone with physical access to the running machine.

Why no email

The portal does not send email. Three reasons:

  1. No external dependency. Adding aiosmtplib + SMTP credentials + a deliverability monitoring concern multiplies the operational surface for a single-learner deployment.
  2. No identity verification. Email verifies "this person controls this inbox," which is meaningful only when the portal has data worth protecting beyond what the deployment itself protects. The local-only deployment closes this hole at the deployment layer.
  3. No forgotten-password chain. The admin (Borja, in the MVP) can re-flag the student in milliseconds. The email-driven reset flow exists to recover access when the admin is not available; that condition doesn't hold here.

If/when the portal is hosted, email becomes mandatory. The migration path is documented in BLUEPRINT.md §7.

The minivault: encryption at rest

The DB file (instance/portal.db) is treated as partially confidential: most columns are not secrets, but credentials.argon2_hash and audit_log.details_json are sensitive. The minivault encrypts these specific columns at rest using AES-256-GCM.

Key derivation

The vault key is never written to disk. It is derived at app start from a master passphrase loaded from environment:

master_pass = os.environ["PORTAL_MASTER_PASS"]
vault_key = argon2id(master_pass, salt=fixed_salt, hash_len=32)   # 256 bits

The fixed_salt is a build-time constant (compiled into the binary) — not a per-deployment secret. Its role is domain separation, not entropy. The master pass is the entropy source; the salt prevents the derived key from colliding with other Argon2 contexts (the credential pepper, say).

The vault key lives in process memory only. On process exit, it's gone. Restarting the portal requires PORTAL_MASTER_PASS to be re-supplied. There is no persistence of the vault key, by design.

AES-256-GCM contract

Each encrypted column write goes through:

nonce = os.urandom(12)                                         # 96 bits
ciphertext, tag = AES_GCM(vault_key).encrypt(nonce, plaintext)
stored = nonce || tag || ciphertext

Each read inverts:

nonce, tag, ciphertext = split(stored, [12, 16, ...])
plaintext = AES_GCM(vault_key).decrypt(nonce, tag || ciphertext)

GCM gives authenticated encryption — modifying the ciphertext invalidates the tag. The 96-bit nonce is fresh per write (collision probability bounded; the audit log writes ~10⁴ rows over the portal's lifetime, well below the 2⁴⁸ birthday bound for a 96-bit nonce).

What gets encrypted, what doesn't

Field Encrypted? Why
credentials.argon2_hash Yes Hashes leak even when salted+peppered.
audit_log.details_json Yes Contains login IPs, query strings, occasionally payload echoes.
students.username No Searched as a primary lookup key; encrypting destroys index utility.
journal_entries.body_markdown No Bulky; encryption cost is non-trivial; threat model rates leak severity at ⅖.
exam_responses.response_text No Same.

The choice is pragmatic. Encrypt only what carries the highest leak severity per byte. The boundary is documented in BLUEPRINT.md §4.

Decryption-on-read pattern

The minivault is invoked from a thin SQLAlchemy TypeDecorator layer: encrypted columns are typed VaultBytes, transparently encrypting on __set__ and decrypting on __get__. The application code never sees raw bytes.

This means a test can verify "the on-disk row contains ciphertext" by dropping to raw SQLite and reading the column directly:

SELECT hex(argon2_hash) FROM credentials WHERE student_id = 1;

The hex output should be a 12-byte nonce prefix followed by 16-byte tag and unrecognizable ciphertext — no embedded Argon2 marker ($argon2id$...). The Phase 41 lab includes this test.

Sessions

Sessions use signed cookies via itsdangerous, not server-side session tables for the bulk path. The cookie carries:

  • student_id
  • csrf_token
  • expires_at

… signed with a server-side HMAC key (PORTAL_SESSION_SECRET env). The signature is verified on every request; tampering invalidates the cookie.

A parallel sessions row exists in the DB (per the data model), but its purpose is revocation — listing active sessions, letting the admin terminate one. The cookie's bearer-token property means revocation isn't free; the sessions.token_hash index makes lookups O(log N) per request, which is fine for the single-learner workload.

Cookie attributes:

  • HttpOnly — JavaScript cannot read the cookie. Defence against XSS-driven session theft.
  • Secure — sent only over HTTPS. The MVP's localhost dev mode toggles this off via env; production has it on.
  • SameSite=Strict — defence against cross-site request forgery for state-changing operations.

The 30-day rolling expiry: each request bumps expires_at forward 30 days from now. Inactivity for 30 days expires the session. A separate absolute max-age of 90 days is enforced — even continuous activity logs out after 90 days, forcing a re-auth.

CSRF tokens

Every state-changing route (POST, PUT, DELETE) requires a CSRF token submitted in the request body or X-CSRF-Token header. The token is generated server-side, signed, and surfaced in the response of every GET. SameSite=Strict provides a parallel defence; CSRF tokens are belt-and-braces.

Injection filter

Phase 37's body_markdown injection filter is applied to every write into a markdown-bearing column (journal_entries.body_markdown, notes.body_markdown, exam_responses.response_text). The filter:

  • Strips zero-width characters (U+200B, U+200C, U+FEFF).
  • Rejects sequences that look like prompt-injection prefixes (<!--, ### SYSTEM:, <|im_start|>).
  • Length-caps at 100 KiB per write (DB-level guard against pathological inputs).

Phase 37 theory 03's threat T8 (trace storage as injection vector) is closed by this filter — content stored in the DB is no longer a vector if it's later fed back into an LLM.

What this auth design does NOT do

  • No 2FA. TOTP / WebAuthn out of scope. The threat model doesn't justify the complexity at single-learner scale.
  • No OAuth. No "Sign in with Google." Adding it means an external dependency and an out-of-band trust relationship.
  • No password complexity rules. The OWASP guidance (2026) explicitly recommends not enforcing complexity rules — they reduce entropy in practice by pushing users into predictable patterns. The portal enforces only minimum length (12 chars) and uses Argon2's slow verification as the rate limiter.
  • No leaked-password check. Future addition; would call haveibeenpwned's k-anonymity API but the network dependency is rejected in the MVP.

One-paragraph recap

Argon2id with 64 MiB memory cost and a server pepper handles password hashing. The "no password by default" flow lets the admin onboard a learner without a temporary-password leak. A minivault encrypts the hash column and the audit log at rest using AES-256-GCM with a key derived in-memory from a master passphrase. Sessions are signed cookies with HttpOnly + Secure + SameSite=Strict and a parallel DB row for revocation. CSRF tokens and the Phase 37 injection filter close the remaining HTTP-layer attack paths.

Next: theory/04-ux.md — three personas, four screens, ASCII wireframes.