# Under what conditions must an instance refuse a peer's runtime proof despite a valid verifiable credential **Date of Record:** 2026 November 8 **Scope:** Applies to agent-to-agent delegation where a peer presents a W3C Verifiable Credential (VC) asserting a capability alongside a runtime proof (nonce-signed challenge, TEE quote, or liveness attestation). Does not apply to purely offline credential verification, nor to human-in-the-loop authorization flows where a separate policy engine adjudicates. ## Core Distinction A verifiable credential attests that *some issuer, at some point, asserted* that a subject holds a capability. A runtime proof attests that *this live process, right now* is that subject and is exercising that capability. These are orthogonal facts. The common failure is treating a valid VC plus a valid signature as sufficient: the VC proves authorization-to-hold, the proof proves possession-of-key, but neither proves that the *binding* between the credential's subject identifier and the runtime identity is the one the verifier intends. The boundary condition is **subject-identifier substitution across the proof's freshness window** — the credential is unexpired, the signature verifies, the nonce is fresh, and yet the peer is not the entity the issuer vouched for. ## What Was Observed In a three-hop delegation chain (orchestrator → planner → executor), the planner held a VC with `credentialSubject.id = did:web:planner.example` and a capability scope `execute:read-only`. The executor's verifier checked: (1) VC signature against the issuer's DID document, (2) `expirationDate > now`, (3) runtime proof signature over the executor-issued nonce, (4) `proof.verificationMethod` resolvable. All four passed. The executor granted `execute:read-only`. The defect: the runtime proof's `verificationMethod` resolved to `did:web:planner.example#key-2`, but the VC's `credentialSubject.id` was matched against the *DID*, not the *key*. The planner's DID document had been updated (legitimately, via its own key rotation) to add `key-2` under a controller that was a *different* operational team's HSM — a key that could sign runtime proofs but had never been authorized by the issuer for the `execute` scope. The VC was valid; the key was valid; the composition was not. The capability was exercised by a principal the issuer never scoped. A second, subtler instance: a TEE quote carried `MRENCLAVE` matching the expected enclave measurement, and the VC asserted the enclave's DID. But the quote's `report_data` field bound the nonce and a *public key*, not the DID; the verifier checked the nonce but not the key-to-DID binding, so an enclave running the correct code on behalf of a different tenant could present the same quote shape. The measurement proved *code identity*, not *principal identity*. ## Condition of Validity The composite (VC, runtime proof) is valid **only if** the verifier establishes, in order: 1. The VC's `credentialSubject.id` is bound to a specific key or key-set, and that binding is asserted *inside the credential* (e.g., a `keyBinding` or `verificationMethod` claim in the subject block), not inferred from the DID document's current state. 2. The runtime proof's signing key is a member of that credential-asserted key-set — not merely resolvable under the subject DID at verification time. 3. The proof's freshness window is strictly contained within the credential's validity interval *as of the proof's issuance time*, and the credential has not been revoked since (check `credentialStatus` against a status list whose snapshot is at or after the proof timestamp). 4. For TEE proofs, `report_data` binds the verifier-chosen nonce **and** the subject's key or DID hash — not just the nonce. If any of these cannot be established from the presented material alone, the peer must not be treated as the credential's subject, regardless of signature validity. ## Decision Boundary Refuse the proof and downgrade the peer to *unauthenticated-but-credentialed* when: - The VC subject block does not name the key used in the runtime proof, and the verifier cannot obtain a credential-asserted binding. - The proof's `verificationMethod` was added to the subject's DID document *after* the VC's `issuanceDate` (key added post-issuance is not issuer-scoped). - The status-list snapshot the verifier holds predates the proof timestamp and the credential's `expirationDate` has passed since that snapshot. - The TEE `report_data` does not contain a hash of the subject key the verifier independently associates with the credential. Do **not** refuse when the only mismatch is DID-document *format* (e.g., `did:web` vs `did:key` representation of the same key) and the credential explicitly carries the key binding — that is a serialization difference, not a substitution. ## Operational Effect Downgraded peers may still be served on read paths that require only *some* authenticated identity, but must be denied any capability whose scope was granted on the assumption that the subject is the issuer-vouched principal. In practice this means: cache the credential-asserted key-set at first verification and compare subsequent proofs against the *cached* set, not the live DID document; treat DID-document key additions as requiring a fresh credential to take effect; and log the `(credential_id, proof_key, binding_source)` triple on every grant so substitution events are reconstructible post-hoc. The cost is a small amount of extra state; the alternative is a silent scope escalation that no signature check will ever flag. ```python class CapabilityBindingError(RuntimeError): """Raised when a runtime proof does not bind to the credential's asserted subject key.""" pass def verify_peer_capability(credential: dict, runtime_proof: dict, now: int, status_snapshot_ts: int) -> bool: # 1. Credential must assert the key binding inside the subject block. subject = credential.get("credentialSubject", {}) asserted_keys = set(subject.get("keyBinding", [])) if not asserted_keys: raise CapabilityBindingError( "credential subject carries no keyBinding; cannot bind runtime proof" ) # 2. Proof key must be a member of the credential-asserted set. proof_key = runtime_proof.get("verificationMethod") if proof_key not in asserted_keys: raise CapabilityBindingError( f"proof key {proof_key!r} not in credential-asserted set {sorted(asserted_keys)}" ) # 3. Freshness containment: proof window must sit inside credential validity. issued, expires = credential["issuanceDate"], credential["expirationDate"] proof_ts = runtime_proof["timestamp"] if not (issued <= proof_ts <= expires): raise CapabilityBindingError( f"proof timestamp {proof_ts} outside credential interval [{issued}, {expires}]" ) # 4. Revocation must be checked against a snapshot at or after the proof. if status_snapshot_ts < proof_ts: raise CapabilityBindingError( "status snapshot predates proof; revocation state unknown" ) if credential.get("credentialStatus", {}).get("revoked") is True: raise CapabilityBindingError("credential revoked") # 5. TEE proofs must bind the subject key inside report_data. if runtime_proof.get("type") == "TeeQuote": report_data = runtime_proof.get("report_data", b"") if subject["id"].encode() not in report_data and proof_key.encode() not in report_data: raise CapabilityBindingError( "TEE report_data does not bind subject key or DID" ) return True ```