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:
@@ -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`.
|
- `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`.
|
- `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.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ There is no automated recovery workflow in WI-1. After a crash, preserve the pro
|
|||||||
|
|
||||||
## Security posture
|
## 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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -19,6 +20,7 @@ from typing import Final
|
|||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
MAX_STATE: Final = 4 * 1024 * 1024
|
MAX_STATE: Final = 4 * 1024 * 1024
|
||||||
|
MAX_PENDING_TOKENS: Final = 256
|
||||||
STATE_VERSION: Final = 1
|
STATE_VERSION: Final = 1
|
||||||
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
||||||
HEX_256_LENGTH: Final = 64
|
HEX_256_LENGTH: Final = 64
|
||||||
@@ -209,14 +211,16 @@ class StateStore:
|
|||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
def commit(self) -> None:
|
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")
|
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)
|
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
replaced = False
|
replaced = False
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
payload = (
|
|
||||||
json.dumps(self.value, sort_keys=True, separators=(",", ":")) + "\n"
|
|
||||||
).encode()
|
|
||||||
remaining = memoryview(payload)
|
remaining = memoryview(payload)
|
||||||
while remaining:
|
while remaining:
|
||||||
written = os.write(descriptor, remaining)
|
written = os.write(descriptor, remaining)
|
||||||
@@ -269,7 +273,6 @@ class Broker:
|
|||||||
if generation > current_generation:
|
if generation > current_generation:
|
||||||
session["runtime_generation"] = generation
|
session["runtime_generation"] = generation
|
||||||
self.revoke_session_tokens(session_id)
|
self.revoke_session_tokens(session_id)
|
||||||
self.store.commit()
|
|
||||||
return session_id, session
|
return session_id, session
|
||||||
|
|
||||||
def session_for_anchor(
|
def session_for_anchor(
|
||||||
@@ -284,11 +287,24 @@ class Broker:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def revoke_session_tokens(self, session_id: str) -> None:
|
def revoke_session_tokens(self, session_id: str) -> None:
|
||||||
for token in self.store.tokens().values():
|
tokens = self.store.tokens()
|
||||||
if token["session_id"] == session_id:
|
for token_value in [
|
||||||
token["consumed"] = True
|
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]:
|
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
|
peer_pid, peer_uid, peer_gid = peer
|
||||||
action = request.get("action")
|
action = request.get("action")
|
||||||
if action == "register_anchor":
|
if action == "register_anchor":
|
||||||
@@ -307,7 +323,6 @@ class Broker:
|
|||||||
"anchor_starttime": anchor_starttime,
|
"anchor_starttime": anchor_starttime,
|
||||||
"runtime_generation": generation,
|
"runtime_generation": generation,
|
||||||
}
|
}
|
||||||
self.store.commit()
|
|
||||||
else:
|
else:
|
||||||
session_id, session = existing
|
session_id, session = existing
|
||||||
current_generation = session["runtime_generation"]
|
current_generation = session["runtime_generation"]
|
||||||
@@ -316,7 +331,6 @@ class Broker:
|
|||||||
if generation > current_generation:
|
if generation > current_generation:
|
||||||
session["runtime_generation"] = generation
|
session["runtime_generation"] = generation
|
||||||
self.revoke_session_tokens(session_id)
|
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"]}}
|
return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}}
|
||||||
if action == "authenticate":
|
if action == "authenticate":
|
||||||
self.authenticate(peer_pid, request)
|
self.authenticate(peer_pid, request)
|
||||||
@@ -326,6 +340,8 @@ class Broker:
|
|||||||
binding = request.get("binding")
|
binding = request.get("binding")
|
||||||
if not valid_binding(binding):
|
if not valid_binding(binding):
|
||||||
raise BrokerFailure("INVALID_BINDING")
|
raise BrokerFailure("INVALID_BINDING")
|
||||||
|
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
||||||
|
raise BrokerFailure("TOKEN_CAPACITY")
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
self.store.tokens()[token] = {
|
self.store.tokens()[token] = {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
@@ -333,7 +349,6 @@ class Broker:
|
|||||||
"binding": binding,
|
"binding": binding,
|
||||||
"consumed": False,
|
"consumed": False,
|
||||||
}
|
}
|
||||||
self.store.commit()
|
|
||||||
return {"ok": True, "token": token}
|
return {"ok": True, "token": token}
|
||||||
if action == "consume_token":
|
if action == "consume_token":
|
||||||
session_id, _ = self.authenticate(peer_pid, request)
|
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
|
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:
|
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")
|
raise BrokerFailure("TOKEN_REPLAY")
|
||||||
token["consumed"] = True
|
del self.store.tokens()[token_value]
|
||||||
self.store.commit()
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
raise BrokerFailure("UNKNOWN_ACTION")
|
raise BrokerFailure("UNKNOWN_ACTION")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user