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
118 lines
3.5 KiB
Python
118 lines
3.5 KiB
Python
"""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 ""
|