# -*- 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$$$ 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) c.row_factory = sqlite3.Row return c def init_db(): c = _conn() c.executescript(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS profiles ( user_id INTEGER PRIMARY KEY, display_name TEXT DEFAULT '', avatar INTEGER DEFAULT 0, about_me TEXT DEFAULT '', profile_pic TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS settings ( user_id INTEGER PRIMARY KEY, twilio_sid TEXT DEFAULT '', twilio_auth_token TEXT DEFAULT '', twilio_from TEXT DEFAULT '', smtp_host TEXT DEFAULT '', smtp_port INTEGER DEFAULT 587, smtp_user TEXT DEFAULT '', smtp_pass TEXT DEFAULT '', email_from TEXT DEFAULT '', no_contact_order INTEGER DEFAULT 0, partner_name TEXT DEFAULT '', domestic_violence INTEGER DEFAULT 0, protection_order INTEGER DEFAULT 0, safety_notes TEXT DEFAULT '', tts_voice TEXT DEFAULT 'aria' ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, agent_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, citations TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, category TEXT DEFAULT 'Other', filename TEXT NOT NULL, orig_name TEXT NOT NULL, mime TEXT DEFAULT '', size INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """) c.commit() # migrations for pre-existing DBs for col, ddl in [("domestic_violence", "INTEGER DEFAULT 0"), ("protection_order", "INTEGER DEFAULT 0"), ("safety_notes", "TEXT DEFAULT ''")]: try: c.execute(f"ALTER TABLE settings ADD COLUMN {col} {ddl}") c.commit() except Exception: pass try: c.execute("ALTER TABLE profiles ADD COLUMN profile_pic TEXT DEFAULT ''") 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 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 def create_user(username, password): c = _conn() c.execute("INSERT INTO users (username, password_hash) VALUES (?,?)", (username, hash_password(password))) c.commit() uid = c.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"] c.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (uid,)) c.execute("INSERT OR IGNORE INTO settings (user_id) VALUES (?)", (uid,)) c.commit() c.close() return uid def issue_token(uid): token = secrets.token_hex(32) c = _conn() c.execute("INSERT INTO sessions (token, user_id) VALUES (?,?)", (token, uid)) c.commit() c.close() return token 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 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): c = _conn() row = c.execute("SELECT * FROM profiles WHERE user_id=?", (uid,)).fetchone() c.close() if not row: return {"display_name": "", "avatar": 0, "about_me": "", "profile_pic": ""} return {"display_name": row["display_name"], "avatar": row["avatar"], "about_me": row["about_me"], "profile_pic": row["profile_pic"] or ""} def set_profile(uid, display_name=None, avatar=None, about_me=None, profile_pic=None): cur = get_profile(uid) dn = cur["display_name"] if display_name is None else display_name av = cur["avatar"] if avatar is None else avatar am = cur["about_me"] if about_me is None else about_me pp = cur.get("profile_pic", "") if profile_pic is None else profile_pic c = _conn() c.execute("""INSERT INTO profiles (user_id, display_name, avatar, about_me, profile_pic, updated_at) VALUES (?,?,?,?,?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name, avatar=excluded.avatar, about_me=excluded.about_me, profile_pic=excluded.profile_pic, updated_at=CURRENT_TIMESTAMP""", (uid, dn, av, am, pp)) 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() c.close() if not row: return {} # strip auth secrets from the read-back for safety, but return everything to owner return dict(row) def save_message(uid, agent_id, role, content, citations=None): c = _conn() c.execute("INSERT INTO messages (user_id, agent_id, role, content, citations) VALUES (?,?,?,?,?)", (uid, agent_id, role, content, json.dumps(citations or []))) c.commit() c.close() def get_conversation(uid, agent_id, limit=60): c = _conn() rows = c.execute( "SELECT role, content, citations FROM messages WHERE user_id=? AND agent_id=? ORDER BY id DESC LIMIT ?", (uid, agent_id, limit)).fetchall() c.close() out = [] for r in reversed(rows): try: cit = json.loads(r["citations"]) if r["citations"] else [] except Exception: cit = [] out.append({"role": r["role"], "content": r["content"], "citations": cit}) return out def clear_conversation(uid, agent_id): c = _conn() c.execute("DELETE FROM messages WHERE user_id=? AND agent_id=?", (uid, agent_id)) c.commit() c.close() def set_settings(uid, **fields): allowed = {"twilio_sid", "twilio_auth_token", "twilio_from", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "email_from", "no_contact_order", "partner_name", "domestic_violence", "protection_order", "safety_notes", "tts_voice"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return get_settings(uid) cols = ", ".join(f"{k}=?" for k in updates) c = _conn() c.execute(f"UPDATE settings SET {cols} WHERE user_id=?", (*updates.values(), uid)) c.commit() c.close() return get_settings(uid) # ── Document vault ── def add_file(uid, category, filename, orig_name, mime, size): c = _conn() c.execute("INSERT INTO files (user_id, category, filename, orig_name, mime, size) VALUES (?,?,?,?,?,?)", (uid, category, filename, orig_name, mime, size)) c.commit() fid = c.execute("SELECT last_insert_rowid()").fetchone()[0] c.close() return fid def list_files(uid): c = _conn() rows = c.execute("SELECT * FROM files WHERE user_id=? ORDER BY created_at DESC, id DESC", (uid,)).fetchall() c.close() return [dict(r) for r in rows] def get_file(uid, fid): c = _conn() row = c.execute("SELECT * FROM files WHERE id=? AND user_id=?", (fid, uid)).fetchone() c.close() return dict(row) if row else None def delete_file(uid, fid): c = _conn() c.execute("DELETE FROM files WHERE id=? AND user_id=?", (fid, uid)) c.commit() c.close()