Skip to content

fix: close open DCP conformance gaps in issuance and presentation - #1063

Merged
paullatzelsperger merged 9 commits into
mainfrom
fix/dcp-open-conformance-cases
Aug 29, 2026
Merged

fix: close open DCP conformance gaps in issuance and presentation#1063
paullatzelsperger merged 9 commits into
mainfrom
fix/dcp-open-conformance-cases

Conversation

@paullatzelsperger

Copy link
Copy Markdown
Member

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:

  • Inbound token binding (0bf553b4). The Storage API and Credential Offer API resolve a signing key from the DID named in the token's kid header, but identify the sender by its iss claim. Nothing tied the two together. A holder of any resolvable DID could sign a CredentialMessage with iss set to a trusted issuer, and it would authenticate as that issuer — the Storage API's trusted-issuer check compares against iss, so it passes. The Issuer's own token verifier already applied IssuerKeyIdValidationRule; both holder endpoints now do too, and a token with no kid is refused before validation.
  • Verification relationships (c354a70b). Published DID documents listed keys as bare verificationMethod entries 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 for authentication, so every VP this Credential Service produces would be rejected by a verifier enforcing that rule.

The listed cases

Case Change
CS-STOR-05 A REJECTED CredentialMessage was acknowledged with 200 and otherwise ignored, leaving the request in REQUESTED waiting for credentials that were never coming. It now fails the request, recording the issuer's rejectionReason. That reason travels on a new optional CredentialMessage property — the DCP JSON-LD context already defined the term, only the model and inbound transformer lacked it.
RT-03 The Issuer only ever sent status: ISSUED. A process reaching its terminal error state now sends a REJECTED CredentialMessage with 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.
CS-PRES-11 The org.eclipse.dspace.dcp.vc.id alias 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.
IS-META-02 credentialSchema was missing from the model, the transformers and the vendored JSON-LD context, which had drifted behind the published document that does define it. issuancePolicy was built with a fresh UUID per request, so two fetches of the same metadata produced different objects even though clients cache them by id.
IS-OFF-03 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 reason is now the caller's, defaulting to reissue.
CS-PRES-12 No production change needed — 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.
TOK-09 / TOK-11 Handled by the upstream key resolver, which fails to resolve both an unknown kid and an absent one against a multi-key document. The binding fix above makes that resolution answer authoritative rather than advisory.

Deliberately left out

  • CS-STOR-14 — needs the Holder to mint, persist and require back an access token. IdentityHub sends none today, so implementing only the verification half is vacuous, and implementing the issuing half makes IH reject deliveries from any Issuer that does not echo the token. Whether the DCP TCK's mock Issuer does is unverified.
  • RT-06 — the key-rotation e2e stays @Disabled. It mutates state shared by its whole test class and needs its own runtime.
  • IS-REQ-08issuancePolicy VP enforcement is a feature, not a conformance fix.
  • TOK-10 — blocked upstream: the Connector's DidDocument has no capabilityInvocation property, so the relationship DCP §4.3.3 requires of Self-Issued ID token keys cannot be expressed.

A note on verification

Unit, @ApiTest and @PostgresqlIntegrationTest suites pass, along with checkstyle and full test compilation. The end-to-end suites could not be run: on a clean main, 336 of 336 tests in identity-api-tests fail during setup with a null participantContextId, against a local 0.19.0-SNAPSHOT of 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

paullatzelsperger and others added 7 commits August 28, 2026 12:09
…-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
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>
@paullatzelsperger paullatzelsperger added enhancement New feature or request ai labels Aug 29, 2026
@paullatzelsperger
paullatzelsperger merged commit 543c760 into main Aug 29, 2026
16 checks passed
@paullatzelsperger
paullatzelsperger deleted the fix/dcp-open-conformance-cases branch August 29, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants