fix(#828): bound persisted broker token state

Prune consumed and revoked tokens, cap pending token state, reject oversized serialization before replacement, and roll back in-memory mutations when persistence fails.\n\ncloses #828
This commit is contained in:
ms-lead-reviewer
2026-07-17 21:04:21 -05:00
parent 4e9b4cdf97
commit 0b953faf79
4 changed files with 30 additions and 16 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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")