fix(auth): authenticate producer-signed decisions before authorization
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 07:51:44 +02:00
parent 6bd7129eac
commit c80a944105
30 changed files with 2059 additions and 32 deletions

View file

@ -22,7 +22,8 @@ from secrets_engine.approval_auth import approval_auth_configured, approval_toke
from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline
from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim
from secrets_engine.catalog import human_control_required
from secrets_engine.decision_check import check_decision
from secrets_engine.decision_check import check_decision, require_supported_pdp_address
from secrets_engine.decision_signature import load_trust
from secrets_engine.authorization import (
build_action_request,
validate_decision_envelope,
@ -300,6 +301,16 @@ def authorize_action(
configured at all, which leaves production fail-closed. A configured but
failing path raises: a partial deployment must not read as an absent one.
"""
serving = (getattr(cfg, "approval_url", "") and approval_auth_configured(cfg)
and _authorization_id(entry, decision))
if serving or (getattr(cfg, "pdp_url", "") and getattr(cfg, "pdp_token_file", None)):
if not getattr(cfg, "pdp_url", "") or not getattr(cfg, "pdp_token_file", None):
raise DecisionError("production action requires configured access-engine URL/token and responder trust")
require_supported_pdp_address(str(cfg.pdp_url))
load_trust(getattr(cfg, "pdp_trust_file", None))
verifier = getattr(cfg, "pdp_verifier", None)
if verifier is None or not Path(verifier).is_absolute() or not Path(verifier).is_file():
raise DecisionError("access-engine signature verifier is not installed")
observation = resolve_approval_observation(
cfg, entry, action, decision,
fields=fields,
@ -333,6 +344,8 @@ def authorize_action(
token_file=Path(pdp_token),
request=expected_request,
opener=pdp_opener,
trust_file=getattr(cfg, "pdp_trust_file", None),
verifier=getattr(cfg, "pdp_verifier", None),
)
if not getattr(cfg, "clock_trust_file", None):
wait_for_decision_start(envelope)

View file

@ -10,6 +10,10 @@ from dataclasses import dataclass
from pathlib import Path
# Public State Hub domain identifier, not an authentication credential.
DEFAULT_TOPIC_ID = "cee7bedf-2b48-46ef-8601-006474f2ad7a"
def repo_root() -> Path:
"""Repo root = nearest ancestor containing pyproject.toml (fallback: cwd)."""
here = Path(__file__).resolve()
@ -52,6 +56,8 @@ class Config:
authorization_min_approvals: int = 1
pdp_url: str = ""
pdp_token_file: Path | None = None
pdp_trust_file: Path | None = None
pdp_verifier: Path | None = None
clock_trust_file: Path | None = None
@classmethod
@ -69,7 +75,7 @@ class Config:
hub_url=os.environ.get("SECRETS_ENGINE_HUB_URL", "http://127.0.0.1:8000"),
bao_addr=os.environ.get("BAO_ADDR", os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200")),
topic_id=os.environ.get(
"SECRETS_ENGINE_TOPIC_ID", "cee7bedf-2b48-46ef-8601-006474f2ad7a"
"SECRETS_ENGINE_TOPIC_ID", DEFAULT_TOPIC_ID
),
approval_url=os.environ.get("SECRETS_ENGINE_APPROVAL_URL", ""),
approval_token_file=Path(token_file) if token_file else None,
@ -95,5 +101,7 @@ class Config:
),
pdp_url=os.environ.get("SECRETS_ENGINE_PDP_URL", ""),
pdp_token_file=Path(pdp_token) if pdp_token else None,
pdp_trust_file=Path(os.environ["SECRETS_ENGINE_PDP_TRUST_FILE"]) if os.environ.get("SECRETS_ENGINE_PDP_TRUST_FILE") else None,
pdp_verifier=Path(os.environ["SECRETS_ENGINE_PDP_VERIFIER"]) if os.environ.get("SECRETS_ENGINE_PDP_VERIFIER") else None,
clock_trust_file=Path(os.environ["SECRETS_ENGINE_CLOCK_TRUST_FILE"]) if os.environ.get("SECRETS_ENGINE_CLOCK_TRUST_FILE") else None,
)

View file

@ -19,6 +19,7 @@ from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
from secrets_engine.errors import DecisionError
from secrets_engine.decision_signature import strict_json, load_trust, authenticate_response
from secrets_engine.openbao import read_strict_token_file
_MAX_BODY = 512 * 1024
@ -95,6 +96,9 @@ def check_decision(
base_url: str,
token_file: Path,
request: dict[str, Any],
trust_file: Path | None = None,
verifier: Path | None = None,
allow_unsigned_test_fixture: bool = False,
timeout_seconds: float = 3,
opener: Callable[..., Any] | None = None,
) -> dict[str, Any]:
@ -103,9 +107,13 @@ def check_decision(
Silence is never permission: an unreachable PDP raises rather than
returning a permissive default.
"""
require_signature = not allow_unsigned_test_fixture
if not base_url or not base_url.startswith(("http://", "https://")):
raise DecisionError("access-engine check URL is missing or invalid")
require_supported_pdp_address(base_url)
trust = load_trust(trust_file) if require_signature else None
if require_signature and (verifier is None or not Path(verifier).is_absolute() or not Path(verifier).is_file()):
raise DecisionError("access-engine signature verifier is not installed")
token = read_strict_token_file(Path(token_file), purpose="access-engine credential")
encoded = json.dumps(request).encode("utf-8")
http_request = Request(
@ -123,13 +131,15 @@ def check_decision(
body = response.read(_MAX_BODY + 1)
if len(body) > _MAX_BODY:
raise DecisionError("access-engine check body is too large")
payload = json.loads(body.decode("utf-8"))
payload = strict_json(body.decode("utf-8"))
except HTTPError as e:
raise DecisionError(f"access-engine check refused: {_status_message(e.code)}") from e
except URLError as e:
raise DecisionError("access-engine is unreachable for check") from e
except (json.JSONDecodeError, UnicodeDecodeError) as e:
except (ValueError, UnicodeDecodeError, RecursionError) as e:
raise DecisionError("access-engine check returned a non-JSON body") from e
if not isinstance(payload, dict):
raise DecisionError("access-engine check returned a non-object body")
if require_signature:
return authenticate_response(body, trust=trust, verifier=verifier)
return payload

View file

@ -0,0 +1,83 @@
"""Independently admitted Access Engine responder keys, never responder discovery.
The bundled Go helper uses the exact producer types and verifier. No Python
canonicalization or unsigned compatibility mode participates in authorization.
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
import stat
import subprocess
from typing import Any
from secrets_engine.errors import DecisionError
def _unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON member")
result[key] = value
return result
def strict_json(data):
return json.loads(data, object_pairs_hook=_unique_object,
parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number")))
def load_trust(path: Path | None) -> dict[str, Any]:
"""Read an operator-owned public admission file; missing trust is a refusal.
Admission config is a local authority input like the existing policy pin.
File provenance/approval and the executable's installation are rollout gates,
not established by a successful local signature test.
"""
try:
if path is None or not Path(path).is_absolute() or Path(path).is_symlink():
raise ValueError("trust path must be absolute and regular")
with Path(path).open('rb') as stream:
import os
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022:
raise ValueError("trust must not be group/world writable")
raw = stream.read(65537)
if len(raw) > 65536:
raise ValueError("trust is oversized")
trust = strict_json(raw)
if set(trust) != {'schema', 'evaluator', 'mode', 'policy_package', 'policy_version', 'policy_package_digest', 'keys'}:
raise ValueError("invalid trust fields")
if trust['schema'] != 'secrets-engine.pdp-trust.v1':
raise ValueError("unsupported trust schema")
if any(not isinstance(trust[k], str) or not trust[k] for k in trust if k != 'keys'):
raise ValueError("missing issuer/pin")
if not isinstance(trust['keys'], dict) or not trust['keys'] or len(trust['keys']) > 16:
raise ValueError("missing admitted keys")
if any(not isinstance(k,str) or not k or not isinstance(v,str) or not v for k,v in trust['keys'].items()):
raise ValueError("invalid keys")
for key in trust["keys"].values():
if len(key) != 43 or len(base64.b64decode(key + "=", altchars=b"-_", validate=True)) != 32:
raise ValueError("invalid Ed25519 public key")
return trust
except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc:
raise DecisionError("access-engine responder trust is missing or invalid") from exc
def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict:
if verifier is None or not Path(verifier).is_absolute():
raise DecisionError("access-engine signature verifier is not installed")
try:
result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}',
capture_output=True, env={}, timeout=3, check=False)
if result.returncode != 0 or len(result.stdout) > 524288:
raise ValueError("signature refused")
authenticated = strict_json(result.stdout)
provenance = authenticated['provenance']
if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')):
raise ValueError("issuer/pin mismatch")
return authenticated
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc:
raise DecisionError("access-engine responder authentication failed") from exc