Use bounded Railiance time for protected validity checks
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
tegwick 2026-09-15 22:16:31 +02:00
parent 5841d8e88a
commit f4b4dd6b17
8 changed files with 514 additions and 14 deletions

View file

@ -0,0 +1,47 @@
"""Consumer-owned bounded time adapter; explicit opt-in, no configured fallback."""
from datetime import datetime, timezone, timedelta
from functools import lru_cache
from secrets_engine.errors import DecisionError
@lru_cache(maxsize=8)
def _clock(path):
try:
from railiance_clock.client import Clock, FileTrust
return Clock(FileTrust(path))
except (ImportError, OSError, ValueError, TypeError, KeyError) as exc:
raise DecisionError("Railiance clock unavailable or untrusted") from exc
def read_window(cfg):
path = getattr(cfg, "clock_trust_file", None)
if not path:
return None
try:
return _clock(str(path)).read()
except (OSError, ValueError) as exc:
raise DecisionError("Railiance clock unavailable or untrusted") from exc
def validity_bounds(now=None, window=None):
if window is None:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
return current, current
if now is not None:
raise DecisionError("choose interval evidence or explicit test time, not both")
try:
lower, upper = window.lower_ns, window.upper_ns
if type(lower) is not int or type(upper) is not int or not 0 <= lower <= upper:
raise ValueError("invalid interval")
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
return epoch + timedelta(microseconds=lower // 1000), epoch + timedelta(microseconds=(upper + 999) // 1000)
except (ValueError, TypeError, AttributeError, OverflowError) as exc:
raise DecisionError("invalid Railiance time interval") from exc
def recheck_binding(cfg, binding):
if not getattr(cfg, "clock_trust_file", None):
return
if not binding.decision_expires_at:
raise DecisionError("bounded-time consume requires decision validity")
lower, upper = validity_bounds(window=read_window(cfg))
expires = datetime.fromisoformat(binding.decision_expires_at.replace("Z", "+00:00"))
start = datetime.fromisoformat(binding.decision_not_before.replace("Z", "+00:00")) if binding.decision_not_before else None
if upper >= expires or (start is not None and lower < start):
raise DecisionError("decision validity does not contain the Railiance interval")

View file

@ -18,6 +18,7 @@ from datetime import datetime, timezone
from typing import Any
from secrets_engine.errors import DecisionError
from secrets_engine.application_time import validity_bounds
SCHEMA_VERSION = "0.1"
KIND = "approval-claim"
@ -104,6 +105,7 @@ def _validate_observation(
approval_id: str,
require_human_control: bool = False,
now: datetime | None = None,
time_window=None,
) -> dict[str, Any]:
"""Validate issuer, shape, validity and freshness; no action correspondence."""
if not isinstance(claim, dict):
@ -138,34 +140,34 @@ def _validate_observation(
raise DecisionError("approval claim binding must be an object")
if require_human_control and binding.get("human_control") is not True:
raise DecisionError("approval claim does not declare binding.human_control true; request a human-controlled approval at issue")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
lower, upper = validity_bounds(now, time_window)
freshness = claim.get("freshness")
if not isinstance(freshness, dict):
raise DecisionError("approval claim freshness must be an object")
if _parse_time(freshness.get("not_after"), "freshness.not_after") <= current:
if _parse_time(freshness.get("not_after"), "freshness.not_after") <= upper:
raise DecisionError("approval claim observation is stale; re-fetch")
validity = claim.get("validity")
if not isinstance(validity, dict):
raise DecisionError("approval claim validity must be an object")
if _parse_time(validity.get("expires_at"), "validity.expires_at") <= current:
if _parse_time(validity.get("expires_at"), "validity.expires_at") <= upper:
raise DecisionError("approval claim validity window has expired")
if validity.get("not_before") is not None:
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
if _parse_time(validity.get("not_before"), "validity.not_before") > lower:
raise DecisionError("approval claim is not yet valid")
return claim
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
require_human_control: bool = False,
now: datetime | None = None) -> dict[str, Any]:
now: datetime | None = None, time_window=None) -> dict[str, Any]:
"""Validate a fresh fact for submission to the PDP, not authority to consume.
Action correspondence cannot be established until the evaluator returns its
enriched approval binding. This function deliberately makes no such claim.
"""
observed = _validate_observation(claim, approval_id=approval_id,
require_human_control=require_human_control, now=now)
require_human_control=require_human_control, now=now, time_window=time_window)
binding = observed["binding"]
if binding.get("pdp_path") is not True:
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
@ -178,17 +180,17 @@ def observe_pdp_approval_claim(claim: object, *, approval_id: str,
def validate_approval_claim(claim: object, *, approval_id: str,
expected_binding_digest: str = "", expected_pdp_digest: str = "",
require_human_control: bool = False,
now: datetime | None = None) -> dict[str, Any]:
now: datetime | None = None, time_window=None) -> dict[str, Any]:
"""Validate the fact and compare an independently supplied binding.
A PDP digest supplied here must come from the evaluated decision, never a
local reconstruction of the unenriched request.
"""
observed = _validate_observation(claim, approval_id=approval_id,
require_human_control=require_human_control, now=now)
require_human_control=require_human_control, now=now, time_window=time_window)
if expected_pdp_digest:
observe_pdp_approval_claim(observed, approval_id=approval_id,
require_human_control=require_human_control, now=now)
require_human_control=require_human_control, now=now, time_window=time_window)
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
raise DecisionError("approval claim pdp digest does not match the request")
elif expected_binding_digest:

View file

@ -28,6 +28,7 @@ from secrets_engine.authorization import (
wait_for_decision_start,
)
from secrets_engine.errors import DecisionError
from secrets_engine.application_time import read_window, recheck_binding
from secrets_engine.openbao import read_strict_token_file
from secrets_engine.pep_stance import demo_exception_enabled
@ -43,6 +44,8 @@ class ConsumeBinding:
request_digest: str
decision_id: str = ""
human_control: bool = False
decision_not_before: str = ""
decision_expires_at: str = ""
@dataclass(frozen=True)
@ -256,7 +259,7 @@ def resolve_approval_observation(
opener=opener or credential_urlopen,
)
observe_pdp_approval_claim(claim, approval_id=authorization_id,
require_human_control=required_human_control)
require_human_control=required_human_control, time_window=read_window(cfg))
expected_request["context"]["approval"] = claim
return ApprovalObservation(authorization_id, expected_request, claim)
@ -313,19 +316,23 @@ def authorize_action(
request=expected_request,
opener=pdp_opener or urlopen,
)
wait_for_decision_start(envelope)
if not getattr(cfg, "clock_trust_file", None):
wait_for_decision_start(envelope)
window = read_window(cfg)
validated = validate_decision_envelope(
envelope,
expected_request,
accepted_policy_packages={package},
accepted_policy_versions={version},
expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"],
time_window=window,
)
# Recheck freshness after the network call before issuing a consume binding.
validate_approval_claim(
observation.claim, approval_id=observation.approval_id,
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
require_human_control=expected_request["context"].get("human_control") is True,
time_window=window,
)
if validated.action != action:
raise DecisionError("access-engine decision does not bind this action")
@ -335,6 +342,8 @@ def authorize_action(
request_digest=validated.request_digest,
decision_id=validated.decision_id,
human_control=observation.claim["binding"].get("human_control") is True,
decision_not_before=envelope["lifetime"].get("not_before") or "",
decision_expires_at=validated.expires_at,
),
decision_id=validated.decision_id,
expires_at=validated.expires_at,
@ -462,6 +471,7 @@ def require_production_consume(
"production OpenBao call requires approval-engine consume; "
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset"
)
recheck_binding(cfg, binding)
consumed = consume_approval(
base_url=base_url,
token_provider=lambda: approval_token(cfg, scope="approval:consume"),
@ -470,6 +480,7 @@ def require_production_consume(
)
if evidence is not None and hasattr(evidence, "mark_consumed"):
evidence.mark_consumed(consumed)
recheck_binding(cfg, binding)
return consumed

View file

@ -18,6 +18,7 @@ from typing import Any
from secrets_engine.catalog import CatalogEntry, human_control_required
from secrets_engine.errors import DecisionError
from secrets_engine.application_time import validity_bounds
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@ -335,6 +336,7 @@ def validate_decision_envelope(
accepted_policy_versions: set[str],
expected_approval_binding_digest: str = "",
now: datetime | None = None,
time_window=None,
) -> ValidatedDecision:
"""Validate a flex-auth DecisionEnvelope against the proposed action.
@ -377,13 +379,13 @@ def validate_decision_envelope(
raise DecisionError("flex-auth evaluated request digest is malformed")
_check_approval_binding_digest(binding, expected, expected_approval_binding_digest)
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
lower, upper = validity_bounds(now, time_window)
lifetime = _required_dict(envelope, "lifetime")
expires = _parse_time(lifetime.get("expires_at"), "expires_at")
if current >= expires:
if upper >= expires:
raise DecisionError("flex-auth decision lifetime has expired")
if lifetime.get("not_before") is not None:
if current < _parse_time(lifetime.get("not_before"), "not_before"):
if lower < _parse_time(lifetime.get("not_before"), "not_before"):
raise DecisionError("flex-auth decision lifetime has not started")
provenance = _required_dict(envelope, "provenance")

View file

@ -52,6 +52,7 @@ class Config:
authorization_min_approvals: int = 1
pdp_url: str = ""
pdp_token_file: Path | None = None
clock_trust_file: Path | None = None
@classmethod
def load(cls) -> "Config":
@ -94,4 +95,5 @@ class Config:
),
pdp_url=os.environ.get("SECRETS_ENGINE_PDP_URL", ""),
pdp_token_file=Path(pdp_token) if pdp_token else None,
clock_trust_file=Path(os.environ["SECRETS_ENGINE_CLOCK_TRUST_FILE"]) if os.environ.get("SECRETS_ENGINE_CLOCK_TRUST_FILE") else None,
)