"""Caller identity resolved from the trusted hop's headers. The chat router, the Telegram relay and the Worker are the only callers. Each asserts identity in headers; the request body is never trusted for identity. Router, relay, and worker callers must each present their distinct shared key, compared in constant time against an inline or projected secret value. """ from __future__ import annotations import hmac import os import re import stat import tempfile from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from hux.errors import Unauthorized HEADER_SLOT = "X-Hermes-Tenant-Identity" HEADER_SUBJECT = "X-Hux-Subject" HEADER_SURFACE = "X-Hux-Surface" HEADER_TRUST = "X-Hux-Trust" HEADER_KEY = "X-Hux-Relay-Key" SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$") SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$") SURFACES = ("chat", "worker", "telegram", "voice", "api") TRUSTS = ("router", "relay", "worker", "evidence") KEY_ENV = { "router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY", "evidence": "HUX_RELEASE_EVIDENCE_KEY", } MAX_KEY_BYTES = 4096 MAX_SUBJECT_BYTES = 128 SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE" @dataclass(frozen=True) class Identity: """Who is calling, on which surface, asserted by which trusted hop.""" tenant_slot: str subject: str surface: str trust: str def record(self) -> dict[str, str]: """Serialise as ``common.identity``.""" return {"tenant_slot": self.tenant_slot, "subject": self.subject, "surface": self.surface, "trust": self.trust} def _header(headers: Mapping[str, str], name: str) -> str: for key, value in headers.items(): if key.lower() == name.lower(): return value.strip() return "" def _expected_key(environ: Mapping[str, str], trust: str) -> str: """Read a caller key, preferring a bounded Kubernetes secret file.""" if trust == "evidence": from hux.release_security import evidence_key try: return evidence_key(environ) except Exception: # noqa: BLE001 - authentication fails closed without revealing configuration detail return "" name = KEY_ENV[trust] key_file = environ.get(f"{name}_FILE", "") if key_file: try: path = Path(key_file) mode = path.stat().st_mode if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400: return "" data = path.read_bytes() except OSError: return "" if len(data) > MAX_KEY_BYTES: return "" try: return data.decode("utf-8", errors="strict").strip() except UnicodeDecodeError: return "" return environ.get(name, "") def _read_subject_binding(path: Path) -> str | None: """Read a complete, permission-constrained binding without following links.""" flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) try: descriptor = os.open(path, flags) except FileNotFoundError: return None except OSError as error: raise Unauthorized("subject binding is unavailable") from error try: mode = os.fstat(descriptor).st_mode if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}: raise Unauthorized("subject binding is invalid") with os.fdopen(descriptor, "rb", closefd=False) as binding: data = binding.read(MAX_SUBJECT_BYTES + 1) except OSError as error: raise Unauthorized("subject binding is unavailable") from error finally: os.close(descriptor) if not data or len(data) > MAX_SUBJECT_BYTES: raise Unauthorized("subject binding is invalid") try: value = data.decode("utf-8", errors="strict").strip() except UnicodeDecodeError as error: raise Unauthorized("subject binding is invalid") from error if not SUBJECT_RE.fullmatch(value): raise Unauthorized("subject binding is invalid") return value def _publish_subject_binding(path: Path, subject: str) -> None: """Publish an immutable first-writer binding; concurrent writers never see partial data.""" descriptor = -1 temporary = "" try: descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) os.fchmod(descriptor, 0o440) with os.fdopen(descriptor, "wb", closefd=False) as binding: binding.write((subject + "\n").encode("utf-8")) binding.flush() os.fsync(descriptor) os.close(descriptor) descriptor = -1 try: os.link(temporary, path, follow_symlinks=False) except FileExistsError: pass except OSError as error: raise Unauthorized("subject binding is unavailable") from error finally: if descriptor >= 0: os.close(descriptor) if temporary: try: os.unlink(temporary) except FileNotFoundError: pass except OSError: # The binding decision is still verified by a fresh read below. pass def _enforce_subject_binding(environ: Mapping[str, str], trust: str, subject: str) -> None: """Bind from an authenticated edge hop, then require every caller to match.""" raw_path = environ.get(SUBJECT_BINDING_ENV, "") if not raw_path: return path = Path(raw_path) bound = _read_subject_binding(path) if bound is None: if trust == "worker": raise Unauthorized("worker subject is not bound") _publish_subject_binding(path, subject) bound = _read_subject_binding(path) if bound is None or not hmac.compare_digest(bound, subject): raise Unauthorized("subject does not match trusted binding") def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity: """Build an Identity from request headers or raise Unauthorized. ``trust`` defaults to ``router``; every trusted hop requires its distinct shared key to be configured and presented. """ environ = os.environ if environ is None else environ slot = _header(headers, HEADER_SLOT) subject = _header(headers, HEADER_SUBJECT) surface = _header(headers, HEADER_SURFACE) or "chat" trust = _header(headers, HEADER_TRUST) or "router" if not SLOT_RE.match(slot): raise Unauthorized("missing or malformed tenant slot") own_slot = environ.get("HUX_TENANT_SLOT", "") if own_slot and slot != own_slot: raise Unauthorized("tenant slot does not belong to this pod") if not SUBJECT_RE.match(subject): raise Unauthorized("missing or malformed subject") if surface not in SURFACES or trust not in TRUSTS: raise Unauthorized("unknown surface or trust") expected = _expected_key(environ, trust) presented = _header(headers, HEADER_KEY) if not expected or not presented or not hmac.compare_digest(expected, presented): raise Unauthorized(f"{trust} key missing or wrong") if trust == "router" and surface == "worker": raise Unauthorized("worker surface needs worker trust") if trust == "evidence" and surface != "api": raise Unauthorized("evidence trust needs api surface") _enforce_subject_binding(environ, trust, subject) return Identity(slot, subject, surface, trust)