From 1e217dd947983483aafcb79492287ff248456cc1 Mon Sep 17 00:00:00 2001 From: Dipika Ranabhat Date: Tue, 4 Aug 2026 13:54:05 -0500 Subject: [PATCH] feat: add agent_key_thumbprint scaffold to gateway.agent_identity Closes #425. SAGE 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 on current code: the Agent Manifest binding carries no agent public key anywhere, and subject_source is a static config value today, not a live authenticated credential. There is no key material anywhere in the runtime to hash yet, confirmed and refined together with the issue author in the comment thread. Landed as a nullable, additive field instead. agent_key_thumbprint, an RFC 7638 JWK thumbprint rendered as sha256 hex, sits on AgentIdentityInfo and AgentIdentityOut and stays None today since no code path supplies agent key bytes. The real behavior change is on the verifier side: verify_trace_claim now fails closed with 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 what 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 or challenge response credential wired into the binding. Both are out of scope here and tracked in the issue thread. Signed-off-by: Dipika Ranabhat --- CHANGELOG.md | 8 ++++++ docs/spec/session-policy.md | 10 ++++++- docs/spec/verification-library.md | 6 ++++ src/cmcp_runtime/audit/trace_claim.py | 20 +++++++++++++ src/cmcp_runtime/session/manager.py | 4 +++ src/cmcp_verify/verify.py | 27 ++++++++++++++++++ tests/unit/test_session_manager.py | 3 ++ tests/unit/test_trace_claim.py | 41 +++++++++++++++++++++++++++ tests/unit/test_verify.py | 30 ++++++++++++++++++++ 9 files changed, 148 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2acedf3..0807997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`) 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: diff --git a/docs/spec/session-policy.md b/docs/spec/session-policy.md index 9722f73..73dfd07 100644 --- a/docs/spec/session-policy.md +++ b/docs/spec/session-policy.md @@ -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:`, 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): @@ -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. | diff --git a/docs/spec/verification-library.md b/docs/spec/verification-library.md index e484458..d52e2ed 100644 --- a/docs/spec/verification-library.md +++ b/docs/spec/verification-library.md @@ -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) diff --git a/src/cmcp_runtime/audit/trace_claim.py b/src/cmcp_runtime/audit/trace_claim.py index 5adbdae..4deeec0 100644 --- a/src/cmcp_runtime/audit/trace_claim.py +++ b/src/cmcp_runtime/audit/trace_claim.py @@ -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:". + # 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 ───────────────────────────────────────────────────── @@ -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:" - 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 @@ -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): @@ -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 diff --git a/src/cmcp_runtime/session/manager.py b/src/cmcp_runtime/session/manager.py index 681be43..02f3748 100644 --- a/src/cmcp_runtime/session/manager.py +++ b/src/cmcp_runtime/session/manager.py @@ -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, diff --git a/src/cmcp_verify/verify.py b/src/cmcp_verify/verify.py index c149b28..906df4a 100644 --- a/src/cmcp_verify/verify.py +++ b/src/cmcp_verify/verify.py @@ -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}$") @@ -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 @@ -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: diff --git a/tests/unit/test_session_manager.py b/tests/unit/test_session_manager.py index 07c01f1..a545144 100644 --- a/tests/unit/test_session_manager.py +++ b/tests/unit/test_session_manager.py @@ -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: diff --git a/tests/unit/test_trace_claim.py b/tests/unit/test_trace_claim.py index 9adef1f..5cc9d05 100644 --- a/tests/unit/test_trace_claim.py +++ b/tests/unit/test_trace_claim.py @@ -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 ───────────────────────────────────────── diff --git a/tests/unit/test_verify.py b/tests/unit/test_verify.py index 8cf3e43..ed830d1 100644 --- a/tests/unit/test_verify.py +++ b/tests/unit/test_verify.py @@ -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 ----------------------------------------------------