265 lines
9.4 KiB
Python
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,
|
|
}
|