Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`gateway.agent_identity.agent_key_thumbprint` scaffold (#425).** SAGE (via l33tdawg, agentrust-io/.github discussion #15) wanted an offline check that a downstream signature came from the agent a TRACE Claim describes. Investigating turned up that the issue's own premise did not hold: `AgentManifestBinding` carries no agent public key anywhere, and `subject_source` is a static config value today, not a live-authenticated credential (`svid` is a valid value but nothing produces it). So there was no key material anywhere in the runtime to hash.

Landed as a nullable, additive field instead: `agent_key_thumbprint` (RFC 7638 JWK thumbprint, `sha256:<hex>`) on `AgentIdentityInfo`/`AgentIdentityOut`, always `None` today since no code path supplies agent key bytes. The real, non-speculative piece is on the verifier side: `cmcp_verify.verify_trace_claim` now fails closed (`AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT`) on any claim that carries the field while `subject_source` is not live-authenticated, so a future producer cannot launder a config-supplied identity into something that looks like a hardware-attested key binding.

Populating the field for real needs either an `agent_manifest_sdk` schema change carrying the agent's public key, or a live mTLS/challenge-response credential wired into `AgentManifestBinding` - both explicitly out of scope here, tracked in the issue thread.

### Fixed

- **`TPM2_NV_Certify` could never have worked as shipped in #459 (hardware, 2026-08-01).** Two defects, both found by running it against a real Azure Trusted Launch vTPM and neither catchable by the unit tests as written:
Expand Down
10 changes: 9 additions & 1 deletion docs/spec/session-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ This binding answers "who acted" for the session. It does not replace `trace.sub

Offline verifiers SHOULD cross-check `gateway.agent_identity` against the signed manifest and trusted issuer key. This keeps the runtime boundary check and the evidence artifact self-checking.

`gateway.agent_identity` MAY also carry `agent_key_thumbprint`: an RFC 7638 JWK thumbprint of the agent's own
signing key, rendered as `sha256:<hex>`, distinct from `issuer_key_id` (the key that signed the manifest).
It is optional and is omitted from every claim the current runtime can produce, since no code path here has
access to agent key bytes yet. When a producer does populate it, it MUST only do so while `subject_source`
names a live-authenticated source (currently `svid`) - never `config` or `manifest-dev`, which are
operator-supplied assertions rather than proof of key possession. `cmcp_verify.verify_trace_claim` fails
closed (`AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT`) on any claim that violates this.

## TRACE Claim Fields from Session State

The following fields from session state are included in the TRACE attestation record for the session (written at session close):
Expand All @@ -189,4 +197,4 @@ The following fields from session state are included in the TRACE attestation re
|-------|------|-------------|
| `session_max_sensitivity` | string | The highest `max_sensitivity` value reached during the session. |
| `session_reset_count` | integer | Number of times `POST /session/reset` was called during the session lifetime. Normally `0`; a non-zero value warrants review. |
| `agent_identity` | object | Optional Agent Manifest binding: manifest ID, bound agent ID, authenticated subject, subject source, issuer key ID, policy hash, and catalog hash. Present only when `agent_manifest` is configured and verified. |
| `agent_identity` | object | Optional Agent Manifest binding: manifest ID, bound agent ID, authenticated subject, subject source, issuer key ID, policy hash, catalog hash, and an optional `agent_key_thumbprint`. Present only when `agent_manifest` is configured and verified. |
6 changes: 6 additions & 0 deletions docs/spec/verification-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ def verify_trace_claim(
verify_manifest() and cross-check gateway.agent_identity:
manifest_id, agent_id/authenticated_subject, subject_source, policy hash,
catalog hash, and manifest expiry.
5b. Unconditionally: if gateway.agent_identity.agent_key_thumbprint is
present, require subject_source to name a live-authenticated source
(currently just "svid"). Fails closed
(AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT) otherwise, since "config" and
"manifest-dev" are operator-supplied assertions, not proof of key
possession.
6. Check attestation freshness (timestamp within max_attestation_age_seconds)
7. Verify audit chain continuity (audit_chain_root, audit_chain_tip)

Expand Down
20 changes: 20 additions & 0 deletions src/cmcp_runtime/audit/trace_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ class AgentIdentityInfo:
issuer_key_id: str
policy_bundle_hash: str
tool_catalog_hash: str
# #425: RFC 7638 JWK thumbprint of the agent's own signing key, "sha256:<hex>".
# Distinct from issuer_key_id (the key that signed the manifest). Always None
# today: no code path supplies agent key bytes, and it must never be set while
# subject_source is a non-live-authenticated source (config, manifest-dev) - see
# AgentIdentityOut for the full contract.
agent_key_thumbprint: str | None = None


# ── Pydantic output models ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -138,6 +144,18 @@ class CatalogSummary(BaseModel):


class AgentIdentityOut(BaseModel):
"""gateway.agent_identity: the Agent Manifest binding for this session.

agent_key_thumbprint (#425): RFC 7638 JWK thumbprint of the agent's own signing
key, rendered as "sha256:<hex>" - see tee/base.py's jwk_thumbprint() for the
reference pre-image (canonical JSON of the OKP {crv, kty, x} members). Optional
and, as of this field's introduction, always None: no runtime code path has
agent key bytes to hash yet. A future producer of this field MUST only set it
when subject_source names a live-authenticated source (currently just "svid"),
never "config" or "manifest-dev" - cmcp_verify.verify_trace_claim fails closed
on any claim that violates this.
"""

model_config = ConfigDict(extra="forbid")

manifest_id: str
Expand All @@ -148,6 +166,7 @@ class AgentIdentityOut(BaseModel):
issuer_key_id: str
policy_bundle_hash: str
tool_catalog_hash: str
agent_key_thumbprint: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] | None = None


class ToolTranscriptEntry(BaseModel):
Expand Down Expand Up @@ -433,6 +452,7 @@ def generate_trace_claim(
issuer_key_id=agent_identity.issuer_key_id,
policy_bundle_hash=agent_identity.policy_bundle_hash,
tool_catalog_hash=agent_identity.tool_catalog_hash,
agent_key_thumbprint=agent_identity.agent_key_thumbprint,
)
if agent_identity is not None
else None
Expand Down
4 changes: 4 additions & 0 deletions src/cmcp_runtime/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@ def close_session(
if not isinstance(binding, AgentManifestBinding):
binding = None
if binding is not None:
# agent_key_thumbprint (#425) intentionally omitted: AgentManifestBinding
# carries no agent key bytes today, so it defaults to None. Wire it here
# once a real key source exists, gated on subject_source being live-
# authenticated (see AgentIdentityOut's docstring in audit/trace_claim.py).
agent_identity = AgentIdentityInfo(
manifest_id=binding.manifest_id,
agent_id=binding.agent_id,
Expand Down
27 changes: 27 additions & 0 deletions src/cmcp_verify/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def _jwk_thumbprint_sha256(x_b64url: str) -> bytes:


_SW_ONLY_FIRMWARE = "software-only-dev-mode"
# #425: gateway.agent_identity.subject_source values that reflect a live-
# authenticated credential rather than a config-supplied assertion. Mirrors
# agent_manifest._SUBJECT_SOURCES minus "config"/"manifest-dev".
_LIVE_AUTHENTICATED_SUBJECT_SOURCES = frozenset({"svid"})
_EXTERNAL_EVIDENCE_ERROR = "EXTERNAL_EVIDENCE_VERIFICATION_FAILED"
_EXTERNAL_EVIDENCE_HASH_RE = re.compile(r"^sha(256|384):[0-9a-f]+$")
_ISSUER_KEY_ID_RE = re.compile(r"^[0-9a-f]{64}$")
Expand Down Expand Up @@ -97,6 +101,7 @@ class VerificationError(StrEnum):
CLAIM_MALFORMED = "CLAIM_MALFORMED"
HARDWARE_ATTESTATION_FAILED = "HARDWARE_ATTESTATION_FAILED"
AGENT_MANIFEST_MISMATCH = "AGENT_MANIFEST_MISMATCH"
AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT = "AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT"


@dataclass
Expand Down Expand Up @@ -720,6 +725,28 @@ def verify_trace_claim(
failure = failure or VerificationError.AGENT_MANIFEST_MISMATCH
details["agent_manifest"] = str(exc)

# Step 5b: agent_key_thumbprint subject binding (#425). Unconditional - runs
# whether or not the caller requested the Step 5 manifest cross-check, because
# this guards against a claim that asserts a key binding the gateway did not
# actually authenticate. A thumbprint is only meaningful if subject_source
# names a live-authenticated credential; "config"/"manifest-dev" are assertions
# an operator typed in, not proof of key possession.
identity_for_thumbprint = claim_json.get("gateway", {}).get("agent_identity")
if isinstance(identity_for_thumbprint, dict) and identity_for_thumbprint.get(
"agent_key_thumbprint"
):
if identity_for_thumbprint.get("subject_source") not in _LIVE_AUTHENTICATED_SUBJECT_SOURCES:
unverified.append("agent_identity.agent_key_thumbprint")
failure = failure or VerificationError.AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT
details["agent_key_thumbprint"] = (
"gateway.agent_identity.agent_key_thumbprint is present but "
f"subject_source={identity_for_thumbprint.get('subject_source')!r} is not "
"live-authenticated - the claim asserts a key binding the gateway did "
"not actually authenticate"
)
else:
verified.append("agent_identity.agent_key_thumbprint")

# Step 6: Attestation freshness
age, is_fresh = _check_attestation_freshness(claim_json, max_attestation_age_seconds)
if is_fresh:
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/test_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ def test_close_session_claim_includes_agent_identity_binding() -> None:
assert claim["gateway"]["agent_identity"]["manifest_id"] == ctx.agent_manifest.manifest_id
assert claim["gateway"]["agent_identity"]["agent_id"] == ctx.agent_manifest.agent_id
assert claim["gateway"]["agent_identity"]["subject_source"] == "config"
# #425: no code path supplies agent key bytes yet, so close_session() must
# never emit this field (exclude_none drops it entirely).
assert "agent_key_thumbprint" not in claim["gateway"]["agent_identity"]


def test_close_session_attestation_stale_flag_false_when_fresh() -> None:
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/test_trace_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,47 @@ def test_generate_claim_agent_identity_binding():
== "spiffe://factory.example/agent/material-movement/dev"
)
assert claim.gateway.agent_identity.subject_source == "config"
# #425: no code path supplies agent key bytes yet, so this is always absent.
assert claim.gateway.agent_identity.agent_key_thumbprint is None


def test_generate_claim_agent_key_thumbprint_round_trips_when_supplied():
"""#425 schema-level check: if a producer does supply agent_key_thumbprint,
generate_trace_claim carries it through untouched. Not exercised by any real
runtime path yet (see manager.py) - this only proves the plumbing works.
"""
key = SigningKey()
chain = AuditChain("sess-002")
thumbprint = "sha256:" + "a" * 64
claim = generate_trace_claim(
session_id="sess-002",
signing_key=key,
attestation_report=_make_report(),
policy_bundle=PolicyBundleInfo(
hash="sha256:" + "0" * 64,
enforcement_mode="enforcing",
policy_version="1.0.0",
),
tool_catalog=ToolCatalogInfo(hash="sha256:" + "1" * 64),
call_summary=_make_call_summary(),
audit_chain_root=chain.chain_root,
audit_chain_tip=chain.chain_tip,
audit_chain_length=chain.length,
agent_identity=AgentIdentityInfo(
manifest_id="0197739a-8c00-7000-8000-000000000001",
agent_id="spiffe://factory.example/agent/material-movement/dev",
authenticated_subject="spiffe://factory.example/agent/material-movement/dev",
subject_source="svid",
issuer="spiffe://factory.example/signing-authority/development",
issuer_key_id="a" * 64,
policy_bundle_hash="sha256:" + "0" * 64,
tool_catalog_hash="sha256:" + "1" * 64,
agent_key_thumbprint=thumbprint,
),
do_sign=False,
)
assert claim.gateway.agent_identity is not None
assert claim.gateway.agent_identity.agent_key_thumbprint == thumbprint


# ── RuntimeClaim Pydantic validation ─────────────────────────────────────────
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,36 @@ def test_agent_manifest_binding_mismatch_fails():
assert result.failure_reason == VerificationError.AGENT_MANIFEST_MISMATCH


# -- agent_key_thumbprint subject binding (#425) -------------------------------


def test_agent_key_thumbprint_with_live_authenticated_subject_is_verified():
identity = _agent_identity()
identity.subject_source = "svid"
identity.agent_key_thumbprint = "sha256:" + "c" * 64
claim_dict, _ = _make_signed_claim(agent_identity=identity)
result = verify_trace_claim(claim_dict, _approved())
assert "agent_identity.agent_key_thumbprint" in result.verified_fields
assert result.failure_reason != VerificationError.AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT


def test_agent_key_thumbprint_with_config_subject_fails_closed():
identity = _agent_identity() # subject_source="config" by default
identity.agent_key_thumbprint = "sha256:" + "c" * 64
claim_dict, _ = _make_signed_claim(agent_identity=identity)
result = verify_trace_claim(claim_dict, _approved())
assert "agent_identity.agent_key_thumbprint" in result.unverified_fields
assert result.failure_reason == VerificationError.AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT


def test_claim_without_agent_key_thumbprint_is_unaffected():
claim_dict, _ = _make_signed_claim(agent_identity=_agent_identity())
result = verify_trace_claim(claim_dict, _approved())
assert "agent_identity.agent_key_thumbprint" not in result.verified_fields
assert "agent_identity.agent_key_thumbprint" not in result.unverified_fields
assert result.failure_reason != VerificationError.AGENT_KEY_THUMBPRINT_UNBOUND_SUBJECT


# -- Attestation freshness ----------------------------------------------------


Expand Down
Loading