fix: close open DCP conformance gaps in issuance and presentation - #1063
Merged
Conversation
…-STOR-05) An Issuer that rejects a request after accepting it reports this by sending a CredentialMessage with status REJECTED. The Storage API acknowledged such a message with a 200 and then did nothing, leaving the request in REQUESTED, waiting for credentials that were never coming. Only the status-endpoint poll would eventually notice. The Holder now fails the request when a rejection arrives, recording the Issuer's rejectionReason in the error detail. The reason travels on a new optional CredentialMessage property; the DCP JSON-LD context already defined the term, only the model and the inbound transformer were missing it. Rejections go through the same origin checks as a delivery: the request must belong to the addressed participant context, the rejection must come from the Issuer the request was sent to, and it must carry that request's issuerPid. A rejection that trails a completed issuance is acknowledged but does not undo it, since there is nothing for the Issuer to retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
…Message (RT-03) The Issuer only ever sent CredentialMessages with status ISSUED, so an issuance that failed after it had been accepted was never communicated. The Holder was left to discover it by polling the Credential Request Status API, which the spec does not oblige it to do. An issuance process reaching its terminal error state now sends a CredentialMessage with status REJECTED to the Holder's Storage API. It carries the same issuerPid and holderPid a successful delivery would, so the Holder correlates it with the request it is waiting on, no credentials, and the process error detail as the OPTIONAL rejectionReason. The notice is best effort and deliberately cannot fail the transition: the process is already terminal and its state is served by the status API, so a Holder that cannot be reached still learns about the rejection by polling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
DCP MUSTs support for the vc.id alias, which grants read access to a single verifiable credential by its id. Only the vc.type alias was implemented, and the scope tokenizer additionally insisted on a three-part scope string, so an id scope was rejected as malformed before it could be interpreted. The alias now resolves to an equality criterion on the credential id. Unlike the type alias it has no trailing operation part, so everything after the first separator is taken as the id, which keeps ids that themselves contain separators - URNs, for instance - intact. The alias flows through the ordinary scope machinery, so an access token scoped to one credential still cannot be escalated into a query for another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
…META-02) DCP requires every CredentialObject in credentialsSupported to carry all of its OPTIONAL properties. Two were wrong. credentialSchema was missing outright: the property existed in neither the model nor the transformers, and the vendored copy of the DCP JSON-LD context had drifted behind the published one, which does define the term. It is now part of the round trip and is populated from the credential definition's JSON schema URL. issuancePolicy was built with a freshly generated UUID on every request, so two fetches of the same metadata produced different objects. Clients reference and cache CredentialObjects by id, so the policy id is now derived from the credential definition and is stable across fetches. The vendored context is otherwise unchanged; the only other difference against the published document is a deliberate @container on CredentialRequestMessage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
Credential offers reuse the CredentialObjects published by the Issuer Metadata API verbatim, and those carry a fixed offerReason of "reissue". Every offer therefore claimed to be a reissuance, including the proof-key-revocation case the spec names explicitly. The Credential Offer API now takes an optional offerReason and restates the offered credentials with it, defaulting to "reissue" when the caller does not say. The two reasons the spec names are available as constants; the property is an open value space, so others are accepted. The reason stays on the metadata objects because §6.7 requires every CredentialObject in credentialsSupported to carry all OPTIONAL properties, offerReason included, even though metadata makes no offer. That is a spec tension worth raising with the CIP editors rather than resolving here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
…2, CS-PRES-13) Published DID documents listed every key as a bare verificationMethod and declared no verification relationships at all. DCP §5.4.3 has a verifier accept a Verifiable Presentation only when the DID document declares its signing key for authentication, so every VP this Credential Service produces would be rejected by a verifier that enforces that rule. Activating a key pair now also declares it under authentication, and revoking one withdraws that declaration along with the verification method. CS-PRES-12 needed no production change - expired, not-yet-valid and revoked credentials were already filtered - but only one at a time was covered. The conformance case is a mixed set of one type, so that is what is asserted now. Note that capabilityInvocation, which DCP §4.3.3 requires of Self-Issued ID token signing keys, cannot be declared yet: the upstream DidDocument model has no such property. That part of TOK-10 is blocked on the Connector. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
…sages (TOK-09, TOK-11) The Storage API and Credential Offer API resolve the signing key from the DID named in the token's 'kid' header, while the sender identifies itself with the 'iss' claim. Nothing tied the two together, so a token signed with a key from any resolvable DID document validated even when 'iss' named somebody else. On the Storage API that is enough to deliver credentials that appear to come from a trusted issuer, since the trust check compares against the 'iss' claim. Both endpoints now apply IssuerKeyIdValidationRule, which the Issuer's own token verifier already used, so the 'kid' must be a key of the DID that claims to have sent the message. A token without a 'kid' cannot be bound to a sender at all and is refused before validation. TOK-09 (unknown 'kid') and TOK-11 (absent 'kid' with a multi-key DID document) are handled by the upstream key resolver, which fails to resolve in both cases; this makes the resolution answer binding rather than advisory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9
jimmarino
approved these changes
Aug 28, 2026
wolf4ood
approved these changes
Aug 28, 2026
paullatzelsperger
added a commit
to Metaform/cx-ve
that referenced
this pull request
Aug 29, 2026
eclipse-edc/IdentityHub#1063 closes nine of the cases this plan had open: CS-STOR-05 and RT-03 (the REJECTED CredentialMessage path, previously unimplemented on both sides), CS-PRES-11 (the vc.id scope alias), IS-META-02 (CredentialObject completeness and stability), IS-OFF-03 (per-offer reasons), CS-PRES-12 and CS-PRES-13, and TOK-09 and TOK-11. Those rows are green with their Notes naming the pull request, so they can be read back to amber if it does not land. Two of the fixes were not gaps this plan had identified, and both are recorded in the summary because a BYOW Credential Service can get them wrong the same way. The holder's Storage and Offer APIs resolved a signing key from the DID in the token's kid header while identifying the sender by its iss claim, with nothing binding the two, which let any resolvable DID pass the trusted-issuer check as somebody else. And published DID documents declared no verification relationships at all, so no generated presentation satisfied §5.4.3. TOK-10 moves from unknown to blocked: the upstream DidDocument model has no capabilityInvocation property, so IH can neither publish nor enforce the relationship. RT-06's note now says the key rotation e2e exists but is disabled pending its own runtime. Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jimmarino
approved these changes
Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Works through the conformance cases the DCP test plan still had open for IdentityHub. One commit per case.
Two of these were not on the list. They surfaced while establishing why the neighbouring cases were unverified, and both are security-relevant:
0bf553b4). The Storage API and Credential Offer API resolve a signing key from the DID named in the token'skidheader, but identify the sender by itsissclaim. Nothing tied the two together. A holder of any resolvable DID could sign aCredentialMessagewithissset to a trusted issuer, and it would authenticate as that issuer — the Storage API's trusted-issuer check compares againstiss, so it passes. The Issuer's own token verifier already appliedIssuerKeyIdValidationRule; both holder endpoints now do too, and a token with nokidis refused before validation.c354a70b). Published DID documents listed keys as bareverificationMethodentries and declared no verification relationships. DCP §5.4.3 has a verifier accept a Verifiable Presentation only when the DID document declares its signing key forauthentication, so every VP this Credential Service produces would be rejected by a verifier enforcing that rule.The listed cases
REJECTEDCredentialMessagewas acknowledged with 200 and otherwise ignored, leaving the request inREQUESTEDwaiting for credentials that were never coming. It now fails the request, recording the issuer'srejectionReason. That reason travels on a new optionalCredentialMessageproperty — the DCP JSON-LD context already defined the term, only the model and inbound transformer lacked it.status: ISSUED. A process reaching its terminal error state now sends aREJECTEDCredentialMessagewith the same pids and no credentials. Best effort by design: the process is already terminal and its state is served by the status API, so an unreachable holder still learns of it by polling.org.eclipse.dspace.dcp.vc.idalias is a spec MUST and was unimplemented. The scope tokenizer also insisted on a three-part scope, so an id scope was rejected as malformed before it could be interpreted. Everything after the first separator is taken as the id, which keeps URNs intact.credentialSchemawas missing from the model, the transformers and the vendored JSON-LD context, which had drifted behind the published document that does define it.issuancePolicywas built with a fresh UUID per request, so two fetches of the same metadata produced different objects even though clients cache them by id.CredentialObjects published by the Issuer Metadata API verbatim, and those carry a fixedofferReasonofreissue. Every offer therefore claimed to be a reissuance, including theproof-key-revocationcase the spec names explicitly. The reason is now the caller's, defaulting toreissue.kidand an absent one against a multi-key document. The binding fix above makes that resolution answer authoritative rather than advisory.Deliberately left out
@Disabled. It mutates state shared by its whole test class and needs its own runtime.issuancePolicyVP enforcement is a feature, not a conformance fix.DidDocumenthas nocapabilityInvocationproperty, so the relationship DCP §4.3.3 requires of Self-Issued ID token keys cannot be expressed.A note on verification
Unit,
@ApiTestand@PostgresqlIntegrationTestsuites pass, along with checkstyle and full test compilation. The end-to-end suites could not be run: on a cleanmain, 336 of 336 tests inidentity-api-testsfail during setup with a nullparticipantContextId, against a local0.19.0-SNAPSHOTof the Connector. That is independent of this branch — I confirmed it by stashing the changes and re-running. The two e2e assertions updated here therefore compile but have not executed, and RT-03's behaviour is covered at unit level only.🤖 Generated with Claude Code
https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9