v1.1 security release: PBKDF2 passwords, session expiry, rate limiting, SSE streaming

- store.py: passwords now PBKDF2-HMAC-SHA256 (390k iters, random salt,
  constant-time compare). Legacy unsalted-SHA256 hashes upgrade transparently
  on successful login — zero impact on existing users.
- Sessions expire after 30 days (lazy cleanup on token lookup).
- Rate limiting on register (5/hr/IP), login (10/15min per IP AND per
  username), chat (30/hr/IP) — SQLite-backed, shared across gunicorn workers.
- Registration now requires 8+ char passwords.
- New /api/chat/stream SSE endpoint: citations first ('meta'), streamed
  answer deltas, 'done'. Client auto-falls back to non-streaming.
- nginx: proxy_buffering off for SSE.
This commit is contained in:
drjones
2026-09-07 19:03:37 -07:00
parent 9f9d812320
commit f02124c9de
4 changed files with 281 additions and 15 deletions

View File

@@ -1,14 +1,54 @@
# -*- coding: utf-8 -*-
"""Astraea data layer — no-KYC auth, profiles (avatar + About Me), and settings.
SQLite at /opt/astraea/astraea.db. stdlib-only.
Security model (v1.1):
- Passwords: PBKDF2-HMAC-SHA256, 390k iterations (OWASP 2023 rec), 16-byte salt,
constant-time compare. Legacy unsalted-SHA256 hashes transparently upgrade
to PBKDF2 on successful login (zero user impact).
- Sessions: 30-day sliding expiry, cleaned up lazily.
"""
import hashlib
import hmac
import json
import secrets
import sqlite3
import time
DB_PATH = "/opt/astraea/astraea.db"
# ── Password hashing (PBKDF2, stdlib) ────────────────────────────────────────
PBKDF2_ITERATIONS = 390_000
PBKDF2_PREFIX = "pbkdf2_sha256$" # format: pbkdf2_sha256$<iters>$<salt_hex>$<hash_hex>
SESSION_TTL = 30 * 24 * 3600 # 30 days
def hash_password(pw):
"""Hash a password with PBKDF2-HMAC-SHA256 + random salt. Returns a
self-describing string so algorithms can evolve independently."""
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, PBKDF2_ITERATIONS)
return f"{PBKDF2_PREFIX}{PBKDF2_ITERATIONS}${salt.hex()}${dk.hex()}"
def _legacy_sha256(pw):
return hashlib.sha256(pw.encode()).hexdigest()
def _verify_password(stored_hash, pw):
"""Return (ok, needs_upgrade). Handles both legacy unsalted SHA-256 and
PBKDF2 hashes. Uses constant-time comparisons."""
if stored_hash.startswith(PBKDF2_PREFIX):
try:
_, iters, salt_hex, hash_hex = stored_hash.split("$", 3)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(),
bytes.fromhex(salt_hex), int(iters))
return hmac.compare_digest(dk.hex(), hash_hex), False
except Exception:
return False, False
# legacy unsalted sha256 (64 hex chars)
return hmac.compare_digest(stored_hash, _legacy_sha256(pw)), True
def _conn():
c = sqlite3.connect(DB_PATH)
@@ -90,18 +130,25 @@ def init_db():
c.commit()
except Exception:
pass
# speed up session expiry cleanup
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)")
c.commit()
except Exception:
pass
c.close()
def hash_password(pw):
return hashlib.sha256(pw.encode()).hexdigest()
def user_from_token(token):
"""Resolve a session token to a user id. Sessions expire after 30 days;
expired tokens are deleted on sight (lazy cleanup)."""
if not token:
return None
c = _conn()
cutoff = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() - SESSION_TTL))
c.execute("DELETE FROM sessions WHERE created_at < ?", (cutoff,))
row = c.execute("SELECT user_id FROM sessions WHERE token=?", (token,)).fetchone()
c.commit()
c.close()
return row["user_id"] if row else None
@@ -129,13 +176,27 @@ def issue_token(uid):
def authenticate(username, password):
"""Verify credentials. Legacy (unsalted-SHA256) hashes upgrade in place on
successful login — the user notices nothing. Returns user id or None."""
c = _conn()
row = c.execute("SELECT id, password_hash FROM users WHERE username=?",
(username,)).fetchone()
c.close()
if row and row["password_hash"] == hash_password(password):
return row["id"]
return None
if not row:
return None
ok, needs_upgrade = _verify_password(row["password_hash"], password)
if not ok:
return None
if needs_upgrade:
try:
c = _conn()
c.execute("UPDATE users SET password_hash=? WHERE id=?",
(hash_password(password), row["id"]))
c.commit()
c.close()
except Exception:
pass # upgrade is best-effort; auth already succeeded
return row["id"]
def get_profile(uid):
@@ -164,8 +225,6 @@ def set_profile(uid, display_name=None, avatar=None, about_me=None, profile_pic=
c.commit()
c.close()
return {"display_name": dn, "avatar": av, "about_me": am, "profile_pic": pp}
def get_settings(uid):
c = _conn()
row = c.execute("SELECT * FROM settings WHERE user_id=?", (uid,)).fetchone()