Phase 8: optional accounts, per-user data, shared demo key

Guest-first multi-user mode behind AIDND_MULTI_USER (local installs
unchanged): signed-cookie guest sessions bootstrapped by /api/auth/me,
register upgrades the guest in place, login/logout, per-IP rate limits.
Every router scoped by user_id; Settings become per-user with the API
key Fernet-encrypted at rest and write-only through the API. Users
without a key get a server-funded demo key (OpenRouter free models,
20 turns/day, memory bank disabled on demo turns). Public read-only
demo scenarios (seed_demo.py); debug log restricted to local mode.
Frontend: auth modal + guest nudge, 401 re-establish/retry, demo
banner and key management in Settings.

Migrations 13-23 adopt existing data under a local user and encrypt
stored keys. Verified: migration on a copy of real data.db, two-session
isolation + register/login via curl and Chrome, demo cap 429, live
OpenRouter turn through the encrypted-key path, vite build + oxlint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
parththakkar106
2026-07-06 23:04:03 +05:30
co-authored by Claude Fable 5
parent 253b533d3b
commit de4db373f2
26 changed files with 1247 additions and 189 deletions
+117
View File
@@ -0,0 +1,117 @@
"""Phase 8 — secrets and crypto primitives for optional accounts.
Everything keys off one server-side secret:
- session cookies are HMAC-signed with it,
- stored LLM API keys are Fernet-encrypted with a key derived from it.
The secret comes from AIDND_SECRET_KEY, or is auto-generated once into
`secret.key` next to the database so local installs and Docker volumes work
with zero configuration (losing the file logs everyone out and orphans
stored API keys — users just re-enter them).
Passwords use hashlib.scrypt (stdlib, OpenSSL-backed) so we don't need a
separate hashing dependency.
"""
import base64
import hashlib
import hmac
import os
import secrets
from cryptography.fernet import Fernet, InvalidToken
from .database import DB_PATH
_SECRET_FILE = DB_PATH.parent / "secret.key"
def _load_secret() -> bytes:
env = os.environ.get("AIDND_SECRET_KEY")
if env:
return env.encode()
if _SECRET_FILE.exists():
return _SECRET_FILE.read_bytes().strip()
secret = secrets.token_urlsafe(48).encode()
_SECRET_FILE.write_bytes(secret)
return secret
SECRET_KEY = _load_secret()
_fernet = Fernet(base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY).digest()))
# ---------- Password hashing (scrypt) ----------
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 2**14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
key = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${key.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, key_hex = stored.split("$")
if scheme != "scrypt":
return False
key = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex),
n=int(n), r=int(r), p=int(p),
)
return hmac.compare_digest(key, bytes.fromhex(key_hex))
except (ValueError, AttributeError):
return False
# ---------- Session tokens ----------
# "v1.<user_id>.<hmac>" — no expiry (long-lived guest sessions are the point).
def sign_session(user_id: int) -> str:
payload = f"v1.{user_id}"
sig = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def verify_session(token: str) -> int | None:
try:
version, user_id, sig = token.split(".")
if version != "v1":
return None
payload = f"{version}.{user_id}"
expected = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
return int(user_id)
except (ValueError, AttributeError):
return None
# ---------- API-key encryption at rest ----------
# Stored values carry an "enc:" prefix so plaintext keys from pre-Phase-8
# databases can be recognized and migrated.
ENC_PREFIX = "enc:"
def encrypt_secret(plain: str) -> str:
if not plain:
return ""
return ENC_PREFIX + _fernet.encrypt(plain.encode()).decode()
def decrypt_secret(stored: str) -> str:
"""Returns the plaintext key. Tolerates legacy plaintext values (returned
as-is) and undecryptable tokens (secret rotated → treated as unset)."""
if not stored:
return ""
if not stored.startswith(ENC_PREFIX):
return stored
try:
return _fernet.decrypt(stored[len(ENC_PREFIX):].encode()).decode()
except (InvalidToken, ValueError):
return ""