mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-01 17:16:07 +00:00
266 lines
12 KiB
Python
266 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import heapq
|
|
import json
|
|
import os
|
|
import time
|
|
import typing
|
|
from collections.abc import Callable, Iterable
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from ..core.app import Application
|
|
|
|
|
|
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
|
|
LAUNCH_KIND = 'workspace.launch'
|
|
EXPECTED_ISSUER = 'langbot-space'
|
|
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
|
|
_CONSUMED_JTI_MAX_ENTRIES = 4096
|
|
_CONSUMED_JTI_HEAP_COMPACT_FLOOR = 64
|
|
_CONSUMED_JTI_HEAP_MAX_MULTIPLIER = 4
|
|
|
|
|
|
class SpaceLaunchError(ValueError):
|
|
"""Raised when a Space-issued Cloud launch assertion is not admissible."""
|
|
|
|
|
|
def _decode_base64url(value: str, *, label: str) -> bytes:
|
|
if not value or any(character.isspace() for character in value):
|
|
raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
|
|
try:
|
|
raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
|
|
except (binascii.Error, ValueError) as exc:
|
|
raise SpaceLaunchError(f'Launch assertion {label} is not valid base64url') from exc
|
|
if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value:
|
|
raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
|
|
return raw
|
|
|
|
|
|
def _strict_json_object(value: bytes, *, label: str) -> dict[str, typing.Any]:
|
|
def reject_duplicate_keys(pairs: Iterable[tuple[str, typing.Any]]) -> dict[str, typing.Any]:
|
|
result: dict[str, typing.Any] = {}
|
|
for key, item in pairs:
|
|
if key in result:
|
|
raise SpaceLaunchError(f'Launch assertion {label} contains duplicate key {key!r}')
|
|
result[key] = item
|
|
return result
|
|
|
|
try:
|
|
decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys)
|
|
except SpaceLaunchError:
|
|
raise
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise SpaceLaunchError(f'Launch assertion {label} is not valid JSON') from exc
|
|
if not isinstance(decoded, dict):
|
|
raise SpaceLaunchError(f'Launch assertion {label} must be a JSON object')
|
|
return decoded
|
|
|
|
|
|
def _required_string(claims: dict[str, typing.Any], name: str) -> str:
|
|
value = claims.get(name)
|
|
if not isinstance(value, str) or not value or value != value.strip():
|
|
raise SpaceLaunchError(f'Launch assertion claim {name} must be a non-empty string')
|
|
return value
|
|
|
|
|
|
def _required_int(claims: dict[str, typing.Any], name: str, *, minimum: int = 0) -> int:
|
|
value = claims.get(name)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
raise SpaceLaunchError(f'Launch assertion claim {name} must be an integer >= {minimum}')
|
|
return value
|
|
|
|
|
|
def _load_ed25519_public_key(encoded: str) -> Ed25519PublicKey:
|
|
value = encoded.strip()
|
|
if value.startswith('-----BEGIN'):
|
|
try:
|
|
key = serialization.load_pem_public_key(value.encode('ascii'))
|
|
except (ValueError, TypeError) as exc:
|
|
raise SpaceLaunchError('Space launch public key is not valid PEM') from exc
|
|
if not isinstance(key, Ed25519PublicKey):
|
|
raise SpaceLaunchError('Space launch public key must be Ed25519')
|
|
return key
|
|
|
|
try:
|
|
raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
|
|
except (binascii.Error, ValueError) as exc:
|
|
raise SpaceLaunchError('Space launch public key must be base64 encoded') from exc
|
|
if len(raw) != 32:
|
|
raise SpaceLaunchError('Space launch Ed25519 public key must contain 32 bytes')
|
|
return Ed25519PublicKey.from_public_bytes(raw)
|
|
|
|
|
|
class SpaceLaunchService:
|
|
"""Verify and single-use consume Space Cloud direct-launch assertions."""
|
|
|
|
def __init__(
|
|
self,
|
|
ap: Application,
|
|
*,
|
|
wall_time: Callable[[], float] = time.time,
|
|
) -> None:
|
|
self.ap = ap
|
|
self._wall_time = wall_time
|
|
self._replay_lock = asyncio.Lock()
|
|
self._consumed_jtis: dict[str, int] = {}
|
|
self._consumed_jti_expiry_heap: list[tuple[int, str]] = []
|
|
|
|
async def consume_assertion(
|
|
self,
|
|
assertion: str,
|
|
*,
|
|
expected_workspace_uuid: str | None = None,
|
|
) -> dict[str, str]:
|
|
claims = self._verify_assertion(assertion)
|
|
payload = claims.get('payload')
|
|
if not isinstance(payload, dict):
|
|
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
|
|
account_uuid = _required_string(payload, 'account_uuid')
|
|
workspace_uuid = _required_string(payload, 'workspace_uuid')
|
|
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
|
|
raise SpaceLaunchError('Launch assertion targets another Workspace')
|
|
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
|
return {
|
|
'account_uuid': account_uuid,
|
|
'workspace_uuid': workspace_uuid,
|
|
}
|
|
|
|
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
|
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
|
raise SpaceLaunchError('Space direct launch requires verified Cloud mode')
|
|
public_key, key_id, clock_skew_seconds = self._trust_config()
|
|
segments = token.split('.')
|
|
if len(segments) != 3:
|
|
raise SpaceLaunchError('Launch assertion must be a compact JWS')
|
|
encoded_header, encoded_claims, encoded_signature = segments
|
|
header = _strict_json_object(_decode_base64url(encoded_header, label='header'), label='header')
|
|
if set(header) != {'alg', 'kid', 'typ'}:
|
|
raise SpaceLaunchError('Launch assertion header contains unsupported fields')
|
|
if header.get('alg') != 'EdDSA':
|
|
raise SpaceLaunchError('Launch assertion algorithm must be EdDSA')
|
|
if header.get('kid') != key_id:
|
|
raise SpaceLaunchError('Launch assertion key ID does not match Cloud trust')
|
|
if header.get('typ') != CONTROL_PLANE_TYP:
|
|
raise SpaceLaunchError('Launch assertion type is not a control-plane payload')
|
|
|
|
signature = _decode_base64url(encoded_signature, label='signature')
|
|
if len(signature) != 64:
|
|
raise SpaceLaunchError('Launch assertion signature must contain 64 bytes')
|
|
try:
|
|
public_key.verify(signature, f'{encoded_header}.{encoded_claims}'.encode('ascii'))
|
|
except InvalidSignature as exc:
|
|
raise SpaceLaunchError('Launch assertion signature is invalid') from exc
|
|
|
|
claims = _strict_json_object(_decode_base64url(encoded_claims, label='claims'), label='claims')
|
|
instance_uuid = self.ap.workspace_service.instance_uuid
|
|
if _required_string(claims, 'iss') != EXPECTED_ISSUER:
|
|
raise SpaceLaunchError('Launch assertion issuer is not LangBot Space')
|
|
if _required_string(claims, 'aud') != EXPECTED_AUDIENCE:
|
|
raise SpaceLaunchError('Launch assertion audience does not target Cloud runtime')
|
|
if _required_string(claims, 'sub') != f'langbot-instance:{instance_uuid}':
|
|
raise SpaceLaunchError('Launch assertion subject targets another instance')
|
|
if _required_string(claims, 'instance_uuid') != instance_uuid:
|
|
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
|
|
if _required_string(claims, 'kind') != LAUNCH_KIND:
|
|
raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
|
|
|
|
issued_at = _required_int(claims, 'iat')
|
|
not_before = _required_int(claims, 'nbf')
|
|
expires_at = _required_int(claims, 'exp', minimum=1)
|
|
now = self._wall_time()
|
|
if issued_at > now + clock_skew_seconds:
|
|
raise SpaceLaunchError('Launch assertion was issued in the future')
|
|
if not_before > now + clock_skew_seconds:
|
|
raise SpaceLaunchError('Launch assertion is not active yet')
|
|
if expires_at <= now - clock_skew_seconds:
|
|
raise SpaceLaunchError('Launch assertion is expired')
|
|
if expires_at <= max(issued_at, not_before):
|
|
raise SpaceLaunchError('Launch assertion expiry must follow issue time')
|
|
return claims
|
|
|
|
def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]:
|
|
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
|
|
space_config = data.get('space', {})
|
|
launch_config = space_config.get('launch', {}) if isinstance(space_config, dict) else {}
|
|
if not isinstance(launch_config, dict):
|
|
launch_config = {}
|
|
public_key_value = (
|
|
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY', '').strip()
|
|
or str(launch_config.get('control_plane_public_key', '') or '').strip()
|
|
)
|
|
key_id = (
|
|
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_KEY_ID', '').strip()
|
|
or str(launch_config.get('control_plane_key_id', '') or '').strip()
|
|
or str(getattr(getattr(self.ap, 'deployment', None), 'verification_key_id', '') or '').strip()
|
|
)
|
|
if not public_key_value or not key_id:
|
|
raise SpaceLaunchError('Space launch control-plane trust is not configured')
|
|
clock_skew = self._bounded_float(
|
|
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_CLOCK_SKEW_SECONDS') or launch_config.get('clock_skew_seconds'),
|
|
default=30.0,
|
|
minimum=0.0,
|
|
maximum=300.0,
|
|
)
|
|
return _load_ed25519_public_key(public_key_value), key_id, clock_skew
|
|
|
|
async def _consume_jti(self, jti: str, expires_at: int) -> None:
|
|
digest = hashlib.sha256(jti.encode('utf-8')).hexdigest()
|
|
now = int(self._wall_time())
|
|
async with self._replay_lock:
|
|
self._prune_consumed_jtis(now)
|
|
if digest in self._consumed_jtis:
|
|
raise SpaceLaunchError('Launch assertion has already been consumed')
|
|
if len(self._consumed_jtis) >= _CONSUMED_JTI_MAX_ENTRIES:
|
|
# Evicting a still-valid digest would make a signed launch
|
|
# assertion replayable. Bound memory by failing closed instead.
|
|
raise SpaceLaunchError('Launch assertion replay cache capacity reached')
|
|
self._consumed_jtis[digest] = expires_at
|
|
heapq.heappush(
|
|
self._consumed_jti_expiry_heap,
|
|
(expires_at, digest),
|
|
)
|
|
|
|
def _prune_consumed_jtis(self, now: int) -> None:
|
|
while self._consumed_jti_expiry_heap:
|
|
expires_at, digest = self._consumed_jti_expiry_heap[0]
|
|
current_expiry = self._consumed_jtis.get(digest)
|
|
if current_expiry != expires_at:
|
|
heapq.heappop(self._consumed_jti_expiry_heap)
|
|
continue
|
|
if expires_at > now:
|
|
break
|
|
heapq.heappop(self._consumed_jti_expiry_heap)
|
|
self._consumed_jtis.pop(digest, None)
|
|
|
|
max_heap_entries = max(
|
|
_CONSUMED_JTI_HEAP_COMPACT_FLOOR,
|
|
len(self._consumed_jtis) * _CONSUMED_JTI_HEAP_MAX_MULTIPLIER,
|
|
)
|
|
if len(self._consumed_jti_expiry_heap) > max_heap_entries:
|
|
self._consumed_jti_expiry_heap[:] = [(expiry, digest) for digest, expiry in self._consumed_jtis.items()]
|
|
heapq.heapify(self._consumed_jti_expiry_heap)
|
|
|
|
@staticmethod
|
|
def _bounded_float(
|
|
value: typing.Any,
|
|
*,
|
|
default: float,
|
|
minimum: float,
|
|
maximum: float,
|
|
) -> float:
|
|
try:
|
|
result = float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
if not minimum <= result <= maximum:
|
|
return default
|
|
return result
|