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 ----------------------------------------------------