Files

265 lines
9.4 KiB
Python

"""Persistent self-service caption rooms and deterministic relay credentials.
A room is identified publicly by a random ``public_id``. The admin link carries a
random secret in its URL fragment; only a SHA-256 digest is stored. Relay source
and output rooms, and the short-lived read/write tokens for them, are HMAC
derivations of the shared master secret, so the relay recomputes the same values
without per-room configuration.
"""
import base64
import hashlib
import hmac
import secrets
import sqlite3
import threading
import time
PUBLIC_ID_BYTES = 18
PUBLIC_ID_LENGTH = 24
ADMIN_BYTES = 32
ADMIN_LENGTH = 43
ROOM_LENGTH = 22
TOKEN_LENGTH = 43
MASTER_MIN_BYTES = 32
DEFAULT_TOKEN_TTL = 60 * 60
DEFAULT_SESSION_TTL = 30 * 24 * 3600
SCHEMA = """
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
admin_hash TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
session_version INTEGER NOT NULL DEFAULT 1,
last_seen INTEGER
);
CREATE INDEX IF NOT EXISTS rooms_created ON rooms(created);
"""
def new_public_id():
return secrets.token_urlsafe(PUBLIC_ID_BYTES)[:PUBLIC_ID_LENGTH]
def new_admin_secret():
return secrets.token_urlsafe(ADMIN_BYTES)[:ADMIN_LENGTH]
def _b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _digest(value):
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def generate_master():
return _b64(secrets.token_bytes(MASTER_MIN_BYTES))
def decode_master(value):
if not value:
return None
raw = value.strip()
try:
data = base64.urlsafe_b64decode(raw + '=' * (-len(raw) % 4))
except ValueError as exc:
raise ValueError('CAPTION_RELAY_MASTER must be base64url encoded') from exc
if len(data) < MASTER_MIN_BYTES:
raise ValueError('CAPTION_RELAY_MASTER must decode to at least 32 bytes')
return data
def derive_room(master, scope, public_id):
message = 'room:{0}:{1}'.format(scope, public_id).encode('utf-8')
return _b64(hmac.new(master, message, hashlib.sha256).digest())[:ROOM_LENGTH]
def derive_token(master, role, room, expires):
message = 'token:{0}:{1}:{2}'.format(role, room, expires).encode('utf-8')
signature = _b64(hmac.new(master, message, hashlib.sha256).digest())[:TOKEN_LENGTH]
return '{0}-{1}'.format(expires, signature)
def verify_token(master, role, room, token, now):
if not master or not isinstance(token, str) or '-' not in token:
return False
expires, _, signature = token.partition('-')
if not expires.isdigit() or len(signature) > 128:
return False
if int(expires) < now:
return False
return hmac.compare_digest(derive_token(master, role, room, int(expires)), token)
def _session_key(master):
return hmac.new(master, b'caption-room-session', hashlib.sha256).digest()
def session_token(master, public_id, version, expires):
message = 'v1.{0}.{1}.{2}'.format(public_id, version, expires)
signature = _b64(hmac.new(_session_key(master), message.encode('utf-8'), hashlib.sha256).digest())
return '{0}.{1}'.format(message, signature)
def verify_session(master, token, now):
if not master or not isinstance(token, str):
return None
parts = token.split('.')
if len(parts) != 5 or parts[0] != 'v1':
return None
_, public_id, version, expires, signature = parts
if not public_id or not version.isdigit() or not expires.isdigit() or len(signature) > 64:
return None
expected = session_token(master, public_id, int(version), int(expires))
if not hmac.compare_digest(expected, token):
return None
if int(expires) < now:
return None
return {'public_id': public_id, 'version': int(version), 'expires': int(expires)}
class RoomRegistry:
def __init__(self, path, master=None, token_ttl=DEFAULT_TOKEN_TTL,
session_ttl=DEFAULT_SESSION_TTL, max_rooms=400, clock=time.time):
self.master = master or None
self.token_ttl = int(token_ttl)
self.session_ttl = int(session_ttl)
self.max_rooms = int(max_rooms)
self.clock = clock
self._lock = threading.Lock()
self.conn = sqlite3.connect(path, check_same_thread=False)
self.conn.isolation_level = None
self.conn.row_factory = sqlite3.Row
self.conn.execute('PRAGMA journal_mode=WAL')
self.conn.executescript(SCHEMA)
def close(self):
with self._lock:
self.conn.close()
def _now(self):
return int(self.clock())
def count_rooms(self):
row = self.conn.execute('SELECT COUNT(*) FROM rooms WHERE revoked=0').fetchone()
return row[0]
def create_room(self, label=''):
if self.count_rooms() >= self.max_rooms:
raise RuntimeError('Room capacity reached')
now = self._now()
with self._lock:
while True:
public_id = new_public_id()
admin_secret = new_admin_secret()
try:
cur = self.conn.execute(
'INSERT INTO rooms(public_id,admin_hash,label,created) VALUES(?,?,?,?)',
(public_id, _digest(admin_secret), label[:128], now))
return cur.lastrowid, public_id, admin_secret
except sqlite3.IntegrityError:
continue
def get_room(self, public_id):
row = self.conn.execute('SELECT * FROM rooms WHERE public_id=?', (public_id,)).fetchone()
return dict(row) if row else None
def verify_admin(self, public_id, secret):
room = self.get_room(public_id)
if not room or room['revoked'] or not secret:
return None
if not hmac.compare_digest(_digest(secret), room['admin_hash']):
return None
return room
def touch(self, public_id):
with self._lock:
self.conn.execute('UPDATE rooms SET last_seen=? WHERE public_id=?', (self._now(), public_id))
def list_rooms(self):
rows = self.conn.execute(
'SELECT id,public_id,label,created,revoked,session_version,last_seen '
'FROM rooms ORDER BY id').fetchall()
return [dict(row) for row in rows]
def set_label(self, public_id, label):
with self._lock:
cur = self.conn.execute('UPDATE rooms SET label=? WHERE public_id=? AND revoked=0',
(label[:128], public_id))
return cur.rowcount == 1
def rotate_admin(self, public_id):
secret = new_admin_secret()
with self._lock:
cur = self.conn.execute(
'UPDATE rooms SET admin_hash=?, session_version=session_version+1 '
'WHERE public_id=? AND revoked=0', (_digest(secret), public_id))
if cur.rowcount != 1:
return None
return secret
def revoke_room(self, public_id):
with self._lock:
cur = self.conn.execute(
'UPDATE rooms SET revoked=1, session_version=session_version+1 '
'WHERE public_id=? AND revoked=0', (public_id,))
return cur.rowcount == 1
def issue_session(self, room, now=None):
if not self.master:
return None
now = int(now if now is not None else self._now())
expires = now + self.session_ttl
return session_token(self.master, room['public_id'], room['session_version'], expires), expires
def room_from_session(self, token, now=None):
now = int(now if now is not None else self._now())
parsed = verify_session(self.master, token, now)
if not parsed:
return None
room = self.get_room(parsed['public_id'])
if not room or room['revoked'] or room['session_version'] != parsed['version']:
return None
return room
def relay_config(self, public_id, address, site, ttl=None, now=None):
if not self.master or not public_id:
return None
now = int(now if now is not None else self._now())
expires = now + int(ttl if ttl is not None else self.token_ttl)
source = derive_room(self.master, 'source', public_id)
output = derive_room(self.master, 'output', public_id)
return {
'publicId': public_id,
'address': address,
'site': site,
'room': source,
'outputRoom': output,
'writeToken': derive_token(self.master, 'write', source, expires),
'readToken': derive_token(self.master, 'read', source, expires),
'outputWriteToken': derive_token(self.master, 'write', output, expires),
'outputReadToken': derive_token(self.master, 'read', output, expires),
'expires': expires,
}
def public_config(self, public_id, address, site, ttl=None, now=None):
if not self.master or not public_id:
return None
room = self.get_room(public_id)
if not room or room['revoked']:
return None
now = int(now if now is not None else self._now())
expires = now + int(ttl if ttl is not None else self.token_ttl)
output = derive_room(self.master, 'output', public_id)
return {
'publicId': public_id,
'address': address,
'site': site,
'room': output,
'readToken': derive_token(self.master, 'read', output, expires),
'expires': expires,
}