fix(auth): authenticate producer-signed decisions before authorization
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
6bd7129eac
commit
c80a944105
30 changed files with 2059 additions and 32 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
src/secrets_engine/decision_signature.py
Normal file
83
src/secrets_engine/decision_signature.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue