feat: provision private Hermes assistants for authenticated Hope accounts

This commit is contained in:
snowspeeder committed 2026-10-01 13:30:18 -04:00
1 parent da65da68b3
commit 2aedea84b5
33 files changed
+3063 -1743

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
"""SQLite persistence. No credential-bearing row is ever a public API schema."""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
import uuid
from pathlib import Path
PUBLIC_FIELDS = ('id', 'username', 'display_name', 'role', 'status', 'container_name', 'error')
SLUG = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z')
def public_user(user):
return {key: user[key] for key in PUBLIC_FIELDS}
def validate_username(value):
if not isinstance(value, str) or not 1 <= len(value) <= 48 or not SLUG.fullmatch(value):
raise ValueError('Username must be a lowercase slug (1–48 characters)')
return value
def validate_password(value):
if not isinstance(value, str) or not 12 <= len(value) <= 256 or len(value.encode('utf-8')) > 1024:
raise ValueError('Password must contain 12–256 characters')
return value
def password_hash(password):
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32)
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
def password_matches(password, encoded):
try:
_, n, r, p, salt, digest = encoded.split('$')
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(actual.hex(), digest)
except (ValueError, TypeError):
return False
def token_hash(token):
return hashlib.sha256(token.encode()).hexdigest()
class Accounts:
def __init__(self, path):
if path != ':memory:':
Path(path).parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.lock = threading.RLock()
if path != ':memory:':
# Create privately before SQLite can create journal companions.
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
os.close(fd)
os.chmod(path, 0o600)
self.db = sqlite3.connect(path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.execute('PRAGMA foreign_keys=ON')
self.db.execute('PRAGMA busy_timeout=5000')
self.db.execute('PRAGMA journal_mode=WAL')
self.db.executescript('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, role TEXT NOT NULL,
status TEXT NOT NULL, container_name TEXT NOT NULL,
error TEXT, password_hash TEXT NOT NULL,
hermes_url TEXT, hermes_api_key TEXT, desired TEXT NOT NULL DEFAULT 'ready'
);
CREATE TABLE IF NOT EXISTS sessions (
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
room_name TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL, session_hash TEXT
);
''')
if 'session_hash' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
self.db.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT')
self.db.commit()
if path != ':memory:':
os.chmod(path, 0o600)
for suffix in ('-wal', '-shm'):
companion = Path(path + suffix)
if companion.exists():
os.chmod(companion, 0o600)
def close(self):
self.db.close()
def users(self):
with self.lock:
return [dict(r) for r in self.db.execute('SELECT * FROM users ORDER BY username')]
def get(self, user_id):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
return dict(row) if row else None
def by_username(self, username):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE username=?', (username,)).fetchone()
return dict(row) if row else None
def create(self, username, display_name, role, password):
validate_username(username)
validate_password(password)
if role not in ('admin', 'user'):
raise ValueError('Invalid role')
if not isinstance(display_name, str) or not 1 <= len(display_name.strip()) <= 100:
raise ValueError('Display name must contain 1–100 characters')
user_id = uuid.uuid4().hex
hashed = password_hash(password)
with self.lock, self.db:
self.db.execute('INSERT INTO users(id,username,display_name,role,status,container_name,password_hash) VALUES(?,?,?,?,?,?,?)',
(user_id, username, display_name.strip(), role, 'provisioning', f'hope-hermes-{username}-{user_id[:8]}', hashed))
return self.get(user_id)
def update(self, user_id, **values):
allowed = {'status', 'error', 'hermes_url', 'hermes_api_key', 'container_name'}
if not values or not set(values) <= allowed:
raise ValueError('Invalid account update')
with self.lock, self.db:
guard = " AND desired!='deleted'" if values.get('status') in ('ready', 'provisioning', 'error') else ''
self.db.execute('UPDATE users SET ' + ','.join(f'{k}=?' for k in values) + ' WHERE id=?' + guard, (*values.values(), user_id))
def begin_delete(self, user_id):
with self.lock, self.db:
user = self.get(user_id)
if user is None:
raise KeyError(user_id)
if user['role'] == 'admin' and user['desired'] != 'deleted':
count = self.db.execute("SELECT count(*) FROM users WHERE role='admin' AND desired!='deleted'").fetchone()[0]
if count <= 1:
raise ValueError('Cannot delete the last administrator')
self.db.execute("UPDATE users SET desired='deleted',status='deleting',error=NULL WHERE id=?", (user_id,))
self.db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
self.db.execute('DELETE FROM rooms WHERE user_id=?', (user_id,))
return self.get(user_id)
def finish_delete(self, user_id):
with self.lock, self.db:
self.db.execute("DELETE FROM users WHERE id=? AND desired='deleted'", (user_id,))
def reset_password(self, user_id, password):
hashed = password_hash(validate_password(password))
with self.lock, self.db:
user = self.get(user_id)
if user is None or user['desired'] == 'deleted':
raise KeyError(user_id)
self.db.execute('UPDATE users SET password_hash=? WHERE id=?', (hashed, user_id))
self.db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
self.db.execute('DELETE FROM rooms WHERE user_id=?', (user_id,))
def issue_session(self, user_id, ttl, expected_password_hash):
token = secrets.token_urlsafe(32)
expires = time.time() + ttl
with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE expires_at<=?', (time.time(),))
# Single SQL statement atomically compares the verified credential
# and inserts; reset/delete cannot interleave with this check.
cursor = self.db.execute("INSERT INTO sessions(hash,user_id,expires_at) SELECT ?,id,? FROM users WHERE id=? AND desired!='deleted' AND password_hash=?",
(token_hash(token), expires, user_id, expected_password_hash))
if cursor.rowcount != 1:
raise ValueError('Account credentials changed or unavailable')
return token, expires
def resolve_session(self, token):
if not isinstance(token, str) or not 20 <= len(token) <= 256:
return None
with self.lock:
row = self.db.execute("SELECT u.* FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.hash=? AND s.expires_at>? AND u.desired!='deleted'", (token_hash(token), time.time())).fetchone()
return dict(row) if row else None
def revoke(self, token):
with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),))
def grant_room(self, user_id, room_name, ttl, session_token):
with self.lock, self.db:
user = self.get(user_id)
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
raise ValueError('Account not ready')
if not isinstance(session_token, str) or not 20 <= len(session_token) <= 256:
raise ValueError('Invalid session')
hashed = token_hash(session_token)
session = self.db.execute('SELECT expires_at FROM sessions WHERE hash=? AND user_id=? AND expires_at>?', (hashed, user_id, time.time())).fetchone()
if session is None:
raise ValueError('Invalid session')
self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),))
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash) VALUES(?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed))
def resolve_room(self, room_name):
with self.lock:
row = self.db.execute("SELECT u.* FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone()
return dict(row) if row else None