Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`ca2a start` can demand caller attestation (#160).** The `attestation` block
gains `require_caller_attestation` (`none` | `any` | `hardware`),
`caller_verifier` (`platform` plus a `trusted_roots_path` PEM bundle), and
`challenge_ttl_seconds`, threaded through `bootstrap.build_peer_node` to the
`PeerNode` arguments that have existed since the mutual-attestation work. Until
now those arguments were reachable only from Python, so a config-driven callee
could never appraise its callers. All new paths fail closed: an unknown rung, a
`hardware` rung with no verifier, a missing or empty roots file, and a verifier
platform with no report-level verifier (`sev-snp`, `tdx`) are each a
`CONFIG_ERROR` at startup naming the field, not a callee that appears to appraise
and does not. Only `tpm` can be built today. The startup line now prints the rung
alongside the provider, and a node with no verifier is told that hardware offers
will be refused as unappraisable. `require_holder_proof` is deliberately not
exposed.

- **PLATFORM_INFO appraisal on the SEV-SNP path (LIMITATIONS: "Platform state is not
appraised").** `SevSnpReport.platform_info` decodes the bitfield at offset `0x40`, and
`verify_sev_snp_report` takes `require_platform`, `forbid_platform` and
Expand Down
15 changes: 14 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ transport.
attestation:
provider: auto # auto | tpm | sev-snp | tdx | opaque | software-only
enforcement_mode: enforcing # enforcing | advisory | silent
require_caller_attestation: none # none | any | hardware
# caller_verifier: # required for `hardware`, optional for `any`
# platform: tpm # tpm today; sev-snp and tdx are refused (see below)
# trusted_roots_path: vtpm-roots.pem
challenge_ttl_seconds: 60

max_delegation_depth: 8 # reject chains deeper than this
listen_addr: "127.0.0.1:8443"
Expand All @@ -33,6 +38,9 @@ local_policy: ["read", "write"] # allow-set for scope intersection (or use Ced
|---|---|---|
| `attestation.provider` | `auto` | TEE provider for peer attestation. `auto` selects a detected hardware provider and fails if there is none; it never falls back to `software-only`, which has to be named explicitly. `opaque` is not implemented. |
| `attestation.enforcement_mode` | `enforcing` | Intended mode. The peer path always fails closed on cA2A denials today; advisory and silent are accepted in config but not applied on the wire. |
| `attestation.require_caller_attestation` | `none` | What the callee demands of a caller's own attestation, per [mutual-attestation.md](spec/mutual-attestation.md). `none` records the outcome and demands nothing; `any` requires an offer that appraises, software assurance included; `hardware` requires hardware assurance. At every rung an offer that is present and does not appraise is refused. |
| `attestation.caller_verifier` | none | How to appraise a hardware report a caller offers. `platform` plus `trusted_roots_path` (a PEM bundle, resolved relative to the config file). Required when the rung is `hardware`; without it, a hardware offer at `none` or `any` is refused as unappraisable rather than accepted. Only `tpm` can be built today, via `ca2a_verify.tpm.tpm_verifier`. `sev-snp` and `tdx` are accepted by the vocabulary and refused at startup with the reason: their verifiers take raw evidence and a certificate chain, and no report-level wrapper exists yet. |
| `attestation.challenge_ttl_seconds` | `60` | Lifetime of the challenge the callee issues for a caller to bind its offer into. The secret behind it is per-process and never persisted. |
| `max_delegation_depth` | `8` | Chains deeper than this are rejected with `DELEGATION_DEPTH_EXCEEDED`. |
| `listen_addr` | `127.0.0.1:8443` | Address `ca2a start` binds. The host is never defaulted, so serving on every interface has to be written out. |
| `trusted_root_issuers` | none | Ed25519 public keys allowed to originate delegation chains. At least one is required by `ca2a start`; an internally valid chain from any other root is denied before policy evaluation. |
Expand All @@ -55,9 +63,14 @@ ca2a validate-config --config examples/minimal/ca2a-config.yaml
ca2a start --config examples/minimal/ca2a-config.yaml
# note: software-only provider, callers appraise this channel key as
# assurance="none" and the seal carries no hardware guarantee
# ca2a listening on 127.0.0.1:8443 (provider=software-only)
# note: require_caller_attestation=none with no caller_verifier; software offers
# appraise, hardware offers are refused as unappraisable
# ca2a listening on 127.0.0.1:8443 (provider=software-only, require_caller_attestation=none)
```

`require_holder_proof` is not a config field. It is on for every node `ca2a start`
builds; a program with a reason to turn it off constructs the `PeerNode` itself.

`ca2a start` needs no extra install: the reference transport is standard library
only. It is one way to run the peer path, not part of the profile. A program that
already has a `Policy` and a provider can build a `PeerNode` and serve it from
Expand Down
5 changes: 5 additions & 0 deletions docs/spec/mutual-attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ client.send_task(base_url, chain, "read", "r0",
payload=b"...", caller_provider=SoftwareProvider())
```

The same rung is reachable from a config file. `ca2a start` reads
`attestation.require_caller_attestation`, builds the `caller_verifier` from
`attestation.caller_verifier` (TPM roots today; see
[configuration](../configuration.md)), and passes both to the `PeerNode` it serves.

| Piece | Where |
|---|---|
| Stateless challenge (`v1.<expiry>.<random>.<mac>`) | `ca2a_runtime.challenge` |
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ message = attach_ca2a_metadata(message, request)

## Running the reference transport

A library caller builds a `PeerNode` and hands it to `ca2a_runtime.transport.server.serve`. `ca2a start` is the same thing driven from a config file: it resolves the policy (`local_policy` or a Cedar `policy_bundle_path`) and the attestation provider, builds the node, and binds `listen_addr`.
A library caller builds a `PeerNode` and hands it to `ca2a_runtime.transport.server.serve`. `ca2a start` is the same thing driven from a config file: it resolves the policy (`local_policy` or a Cedar `policy_bundle_path`), the attestation provider, and the caller-appraisal posture (`require_caller_attestation` and, where one can be built, a `caller_verifier`), builds the node, and binds `listen_addr`.

```bash
ca2a start --config examples/minimal/ca2a-config.yaml
Expand Down
4 changes: 4 additions & 0 deletions examples/minimal/ca2a-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
attestation:
provider: software-only # auto | tpm | sev-snp | tdx | opaque | software-only
enforcement_mode: enforcing # the peer path always fails closed today
require_caller_attestation: none # none | any | hardware; see docs/configuration.md
# caller_verifier: # needed for `hardware`; TPM roots only today
# platform: tpm
# trusted_roots_path: vtpm-roots.pem

max_delegation_depth: 8
listen_addr: "127.0.0.1:8443"
Expand Down
61 changes: 60 additions & 1 deletion src/ca2a_runtime/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
Provider selection fails closed. ``software-only`` carries no hardware guarantee,
so it is only ever used when the config names it: a hardware provider whose
device node is absent is a startup error, not a silent downgrade.

Caller appraisal is the same shape. ``attestation.caller_verifier`` names a
platform and a roots file; only platforms with a report-level verifier in
:mod:`ca2a_verify` can be built, and naming one that has none is a startup error
rather than a callee that looks like it appraises and does not.
"""

from __future__ import annotations
Expand All @@ -21,6 +26,7 @@
load_agent_manifest_trust_anchor,
verify_agent_manifest_binding,
)
from ca2a_runtime.attestation import Verifier
from ca2a_runtime.cedar import CedarPolicy
from ca2a_runtime.config import Ca2aConfig
from ca2a_runtime.errors import ConfigError
Expand All @@ -31,13 +37,25 @@
from ca2a_runtime.tee.software import SoftwareProvider
from ca2a_runtime.tee.tdx import TdxProvider
from ca2a_runtime.tee.tpm import TpmProvider
from ca2a_verify.tpm import tpm_verifier

_HARDWARE_PROVIDERS: dict[str, type[BaseProvider]] = {
"sev-snp": SevSnpProvider,
"tdx": TdxProvider,
"tpm": TpmProvider,
}

# Platforms whose appraisal can be expressed as a Verifier over an
# AttestationReport. SEV-SNP and TDX have offline verifiers (ca2a_verify.sev_snp,
# ca2a_verify.tdx) but they take raw evidence plus a certificate chain, and the
# SNP collector deliberately drops the auxblob rather than guess at its format, so
# neither can yet be built from a report and a roots file alone.
_REPORT_VERIFIERS: dict[str, str] = {
"sev-snp": "verify_sev_snp_report needs the VCEK chain, which the collector does not "
"carry on the report yet",
"tdx": "verify_tdx_quote has no AttestationReport-level wrapper yet",
}


def load_policy(config: Ca2aConfig, *, config_dir: Path | None = None) -> Policy:
"""Resolve the callee policy from ``policy_bundle_path`` or ``local_policy``.
Expand Down Expand Up @@ -97,8 +115,46 @@ def select_provider(config: Ca2aConfig) -> BaseProvider:
raise ConfigError(f"attestation provider {name!r} is not implemented")


def build_caller_verifier(config: Ca2aConfig, *, config_dir: Path | None = None) -> Verifier | None:
"""Resolve ``attestation.caller_verifier`` into a :data:`Verifier`, or ``None``.

``None`` means the config named no verifier. Under ``require_caller_attestation:
none`` or ``any`` that is a legitimate posture (a caller offering a hardware
report is then refused as present-and-unappraisable, per the mutual attestation
spec); under ``hardware`` the config layer has already rejected it.
"""
platform = config.caller_verifier_platform
if platform is None:
return None

if platform in _REPORT_VERIFIERS:
raise ConfigError(
f"attestation.caller_verifier.platform {platform!r} cannot be appraised from a "
"config file yet",
detail=_REPORT_VERIFIERS[platform],
)
if platform != "tpm":
raise ConfigError(f"attestation.caller_verifier.platform {platform!r} is not implemented")

roots_path = Path(config.caller_verifier_trusted_roots_path or "")
if not roots_path.is_absolute() and config_dir is not None:
roots_path = config_dir / roots_path
if not roots_path.is_file():
raise ConfigError(f"attestation.caller_verifier.trusted_roots_path not found: {roots_path}")
try:
roots_pem = roots_path.read_bytes()
except OSError as exc:
raise ConfigError(
f"cannot read attestation.caller_verifier.trusted_roots_path: {roots_path}",
detail=str(exc),
) from exc
if not roots_pem.strip():
raise ConfigError(f"attestation.caller_verifier.trusted_roots_path is empty: {roots_path}")
return tpm_verifier(roots_pem)


def build_peer_node(config: Ca2aConfig, *, config_dir: Path | None = None) -> PeerNode:
"""Build the node ``ca2a start`` serves: policy, provider, and depth limit."""
"""Build the node ``ca2a start`` serves: policy, provider, caller appraisal, depth."""
policy = load_policy(config, config_dir=config_dir)
if not config.trusted_root_issuers:
raise ConfigError(
Expand Down Expand Up @@ -133,6 +189,9 @@ def build_peer_node(config: Ca2aConfig, *, config_dir: Path | None = None) -> Pe
policy,
provider=select_provider(config),
max_depth=config.max_delegation_depth,
require_caller_attestation=config.require_caller_attestation,
caller_verifier=build_caller_verifier(config, config_dir=config_dir),
challenge_ttl_seconds=config.challenge_ttl_seconds,
trusted_root_issuers=config.trusted_root_issuers,
agent_manifest=manifest_binding,
)
21 changes: 19 additions & 2 deletions src/ca2a_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ def _cmd_start(args: argparse.Namespace) -> int:
node = build_peer_node(cfg, config_dir=Path(args.config).resolve().parent)
host, port = cfg.listen_host_port()
except ConfigError as exc:
print(f"invalid config: {exc}", file=sys.stderr)
# The detail is the part that says what to change, so it goes with the message.
suffix = f" ({exc.detail})" if exc.detail else ""
print(f"invalid config: {exc}{suffix}", file=sys.stderr)
return 1

if cfg.enforcement_mode != "enforcing":
Expand All @@ -180,6 +182,17 @@ def _cmd_start(args: argparse.Namespace) -> int:
file=sys.stderr,
)

if node.caller_verifier is None:
# "Demands nothing" still refuses a caller whose offer is present and does
# not appraise, and with no verifier every hardware offer is exactly that.
# Say so, or the first hardware-attested caller looks like a bug.
print(
f"note: require_caller_attestation={node.require_caller_attestation} with no "
"caller_verifier; software offers appraise, hardware offers are refused as "
"unappraisable",
file=sys.stderr,
)

try:
server = serve(node, host=host, port=port)
except OSError as exc:
Expand All @@ -188,7 +201,11 @@ def _cmd_start(args: argparse.Namespace) -> int:

# Flushed, because an operator redirecting stdout to a log needs to see the
# peer come up rather than wait on a full buffer.
print(f"ca2a listening on {host}:{port} (provider={node.provider.platform})", flush=True)
print(
f"ca2a listening on {host}:{port} (provider={node.provider.platform}, "
f"require_caller_attestation={node.require_caller_attestation})",
flush=True,
)
try:
server.serve_forever()
except KeyboardInterrupt:
Expand Down
55 changes: 55 additions & 0 deletions src/ca2a_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@

import yaml

from ca2a_runtime.challenge import DEFAULT_TTL_SECONDS
from ca2a_runtime.errors import ConfigError
from ca2a_runtime.peer import REQUIRE_HARDWARE, REQUIRE_NONE, REQUIREMENT_VALUES

VALID_PROVIDERS = frozenset({"auto", "tpm", "sev-snp", "tdx", "opaque", "software-only"})
VALID_ENFORCEMENT = frozenset({"enforcing", "advisory", "silent"})

# Platforms a config can name under ``attestation.caller_verifier.platform``. This
# is the set of platforms the config *vocabulary* knows, not the set that can be
# appraised today; bootstrap refuses the ones with no report-level verifier and
# says why, so a config that names ``sev-snp`` fails at startup rather than
# silently appraising nothing.
VALID_VERIFIER_PLATFORMS = frozenset({"tpm", "sev-snp", "tdx"})

DEFAULT_LISTEN_ADDR = "127.0.0.1:8443"


Expand Down Expand Up @@ -55,6 +64,10 @@ class Ca2aConfig:

provider: str = "auto"
enforcement_mode: str = "enforcing"
require_caller_attestation: str = REQUIRE_NONE
caller_verifier_platform: str | None = None
caller_verifier_trusted_roots_path: str | None = None
challenge_ttl_seconds: int = DEFAULT_TTL_SECONDS
max_delegation_depth: int = 8
policy_bundle_path: str | None = None
local_policy: frozenset[str] | None = None
Expand Down Expand Up @@ -85,6 +98,44 @@ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig:
detail=f"expected one of {sorted(VALID_ENFORCEMENT)}",
)

requirement = attestation.get("require_caller_attestation", REQUIRE_NONE)
if requirement not in REQUIREMENT_VALUES:
raise ConfigError(
f"unknown attestation.require_caller_attestation: {requirement!r}",
detail=f"expected one of {sorted(REQUIREMENT_VALUES)}",
)

verifier = attestation.get("caller_verifier")
verifier_platform: str | None = None
verifier_roots: str | None = None
if verifier is not None:
if not isinstance(verifier, dict):
raise ConfigError("attestation.caller_verifier must be a mapping")
verifier_platform = verifier.get("platform")
verifier_roots = verifier.get("trusted_roots_path")
if verifier_platform not in VALID_VERIFIER_PLATFORMS:
raise ConfigError(
f"unknown attestation.caller_verifier.platform: {verifier_platform!r}",
detail=f"expected one of {sorted(VALID_VERIFIER_PLATFORMS)}",
)
if not isinstance(verifier_roots, str) or not verifier_roots:
raise ConfigError(
"attestation.caller_verifier.trusted_roots_path must be a non-empty path",
detail="a PEM bundle of roots the caller's attestation key chain must reach",
)
if requirement == REQUIRE_HARDWARE and verifier is None:
# PeerNode refuses this at construction too. Naming the config field
# here means the operator sees which line to add, not which argument.
raise ConfigError(
"attestation.require_caller_attestation 'hardware' needs attestation.caller_verifier",
detail="a hardware report cannot be appraised without one, so every call "
"would be refused",
)

ttl = attestation.get("challenge_ttl_seconds", DEFAULT_TTL_SECONDS)
if not isinstance(ttl, int) or isinstance(ttl, bool) or ttl < 1:
raise ConfigError("attestation.challenge_ttl_seconds must be a positive integer")

depth = data.get("max_delegation_depth", 8)
if not isinstance(depth, int) or depth < 1:
raise ConfigError("max_delegation_depth must be a positive integer")
Expand Down Expand Up @@ -140,6 +191,10 @@ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig:
return cls(
provider=provider,
enforcement_mode=enforcement,
require_caller_attestation=requirement,
caller_verifier_platform=verifier_platform,
caller_verifier_trusted_roots_path=verifier_roots,
challenge_ttl_seconds=ttl,
max_delegation_depth=depth,
policy_bundle_path=bundle,
local_policy=local_policy,
Expand Down
Loading
Loading