diff --git a/docs/architecture/lease-broker-protocol.md b/docs/architecture/lease-broker-protocol.md index 468871d..01fa10e 100644 --- a/docs/architecture/lease-broker-protocol.md +++ b/docs/architecture/lease-broker-protocol.md @@ -13,6 +13,6 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c - `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`. - `consume_token`: authenticated identity plus `token`. -A higher generation for the same anchor atomically replaces the stored incarnation and consumes all prior tokens for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery. +A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery. -State replacement uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens. +State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state and restores that in-memory snapshot if commit fails, leaving durable state unchanged. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens. diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index 9c242e6..ca73b0e 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -5,7 +5,7 @@ - Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Framing and persistence failures fail closed. Sensitive tokens are not logged. -- Same-principal filesystem modes are minimum hardening, not socket authenticity against the same UID. Distinct-principal service isolation is required for that stronger claim. +- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. - WI-2+ security surfaces—receipts, promotion transactions, mutator gates, hooks, payload construction, and recovery—are explicitly out of scope. Coordinator security review must rerun the real socket/peercred acceptance suite on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index fd4da20..ed47fa6 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -16,4 +16,4 @@ There is no automated recovery workflow in WI-1. After a crash, preserve the pro ## Security posture -Directory `0700` plus socket/state `0600` is minimum same-principal hardening: it excludes other UIDs but does **not** stop the same UID from unlinking and counterfeiting the socket. It therefore does not close T-C same-UID replacement. The stronger deployment runs the broker as a distinct principal behind a protected service boundary whose clients cannot unlink or rebind the socket; that is the T-C-closing option. Server-side branch protection remains the irreducible backstop. +Directory `0700` plus socket/state `0600` is built-in same-principal hardening only: it excludes other UIDs but does **not** stop the same UID from unlinking and counterfeiting the socket. It therefore does not close T-C same-UID replacement. WI-1 does not provide a distinct-principal boundary. A stronger distinct-principal deployment requires an external protected proxy, ACL, or service boundary that clients cannot unlink or rebind and that preserves the authenticated client identity required by the broker's `SO_PEERCRED` and ancestry checks. Server-side branch protection remains the irreducible backstop. diff --git a/packages/mosaic/framework/tools/lease-broker/daemon.py b/packages/mosaic/framework/tools/lease-broker/daemon.py index 3ab417c..f313be2 100644 --- a/packages/mosaic/framework/tools/lease-broker/daemon.py +++ b/packages/mosaic/framework/tools/lease-broker/daemon.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import copy import errno import json import os @@ -19,6 +20,7 @@ from typing import Final MAX_FRAME: Final = 64 * 1024 MAX_STATE: Final = 4 * 1024 * 1024 +MAX_PENDING_TOKENS: Final = 256 STATE_VERSION: Final = 1 CONNECTION_DEADLINE_SECONDS: Final = 1.0 HEX_256_LENGTH: Final = 64 @@ -209,14 +211,16 @@ class StateStore: return tokens def commit(self) -> None: + payload = ( + json.dumps(self.value, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + if len(payload) > MAX_STATE: + raise BrokerFailure("STATE_TOO_LARGE") temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp") descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) replaced = False try: try: - payload = ( - json.dumps(self.value, sort_keys=True, separators=(",", ":")) + "\n" - ).encode() remaining = memoryview(payload) while remaining: written = os.write(descriptor, remaining) @@ -269,7 +273,6 @@ class Broker: if generation > current_generation: session["runtime_generation"] = generation self.revoke_session_tokens(session_id) - self.store.commit() return session_id, session def session_for_anchor( @@ -284,11 +287,24 @@ class Broker: return None def revoke_session_tokens(self, session_id: str) -> None: - for token in self.store.tokens().values(): - if token["session_id"] == session_id: - token["consumed"] = True + tokens = self.store.tokens() + for token_value in [ + value for value, token in tokens.items() if token["session_id"] == session_id + ]: + del tokens[token_value] def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: + previous = copy.deepcopy(self.store.value) + try: + response = self._handle(peer, request) + if self.store.value != previous: + self.store.commit() + return response + except Exception: + self.store.value = previous + raise + + def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: peer_pid, peer_uid, peer_gid = peer action = request.get("action") if action == "register_anchor": @@ -307,7 +323,6 @@ class Broker: "anchor_starttime": anchor_starttime, "runtime_generation": generation, } - self.store.commit() else: session_id, session = existing current_generation = session["runtime_generation"] @@ -316,7 +331,6 @@ class Broker: if generation > current_generation: session["runtime_generation"] = generation self.revoke_session_tokens(session_id) - self.store.commit() return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}} if action == "authenticate": self.authenticate(peer_pid, request) @@ -326,6 +340,8 @@ class Broker: binding = request.get("binding") if not valid_binding(binding): raise BrokerFailure("INVALID_BINDING") + if len(self.store.tokens()) >= MAX_PENDING_TOKENS: + raise BrokerFailure("TOKEN_CAPACITY") token = secrets.token_hex(32) self.store.tokens()[token] = { "session_id": session_id, @@ -333,7 +349,6 @@ class Broker: "binding": binding, "consumed": False, } - self.store.commit() return {"ok": True, "token": token} if action == "consume_token": session_id, _ = self.authenticate(peer_pid, request) @@ -341,8 +356,7 @@ class Broker: token = self.store.tokens().get(token_value) if isinstance(token_value, str) else None if not isinstance(token, dict) or token.get("session_id") != session_id or token.get("runtime_generation") != request.get("runtime_generation") or token.get("consumed") is not False: raise BrokerFailure("TOKEN_REPLAY") - token["consumed"] = True - self.store.commit() + del self.store.tokens()[token_value] return {"ok": True} raise BrokerFailure("UNKNOWN_ACTION")