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:
77
store.py
77
store.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user