197 lines
7.3 KiB
Python
197 lines
7.3 KiB
Python
"""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)
|