From 704e6c5b98114c23dcb09ef4af51939ac9a29847 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:09:16 +0200 Subject: [PATCH 1/9] feat: fail the credential request on a REJECTED CredentialMessage (CS-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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../CredentialWriterImpl.java | 73 ++++++++++++- .../CredentialWriterImplTest.java | 102 ++++++++++++++++++ .../tests/StorageApiEndToEndTest.java | 12 ++- .../api/storage/StorageApiController.java | 12 ++- .../api/storage/StorageApiControllerTest.java | 37 ++++++- .../dcp/spi/model/CredentialMessage.java | 18 ++++ ...nObjectToCredentialMessageTransformer.java | 6 ++ .../generator/CredentialWriter.java | 14 ++- 8 files changed, 256 insertions(+), 18 deletions(-) diff --git a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImpl.java b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImpl.java index 23b7ed76b..ee68425d2 100644 --- a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImpl.java +++ b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImpl.java @@ -37,6 +37,7 @@ import org.eclipse.edc.transaction.spi.TransactionContext; import org.eclipse.edc.transform.spi.TypeTransformerRegistry; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.time.Instant; @@ -96,19 +97,83 @@ public ServiceResult write(String holderPid, String holderDid, String issu }); } - private ServiceResult writeCredentials(HolderCredentialRequest holderRequest, String holderPid, String holderDid, String issuerPid, String issuerDid, - Collection writeRequests, String participantContextId) { + @Override + public ServiceResult reject(String holderPid, String issuerPid, String issuerDid, @Nullable String rejectionReason, String participantContextId) { + return transactionContext.execute(() -> { + + var holderRequestResult = holderCredentialRequestStore.findByIdAndLease(holderPid); + if (holderRequestResult.failed()) { + return from(holderRequestResult).mapEmpty(); + } + + var holderRequest = holderRequestResult.getContent(); + + var result = rejectRequest(holderRequest, holderPid, issuerPid, issuerDid, rejectionReason, participantContextId); + if (result.failed()) { + holderCredentialRequestStore.breakLease(holderRequest); + } + return result; + }); + } + + private ServiceResult rejectRequest(HolderCredentialRequest holderRequest, String holderPid, String issuerPid, String issuerDid, + @Nullable String rejectionReason, String participantContextId) { + var origin = checkOrigin(holderRequest, holderPid, issuerDid, participantContextId); + if (origin.failed()) { + return origin; + } + + // once the Issuer's process ID is known it is fixed for this request, so a rejection reporting a different one + // belongs to a different issuance and must not fail this request + var knownIssuerPid = holderRequest.getIssuerPid(); + if (knownIssuerPid != null && !knownIssuerPid.isBlank() && !knownIssuerPid.equals(issuerPid)) { + return ServiceResult.badRequest("HolderCredentialRequest '%s' is tracked under issuerPid '%s', but the message reported '%s'" + .formatted(holderPid, knownIssuerPid, issuerPid)); + } + + // the credentials already arrived, so a rejection that trails a completed issuance must not undo it. It is + // acknowledged rather than rejected, because there is nothing for the Issuer to retry. + if (holderRequest.stateAsEnum() == ISSUED) { + holderCredentialRequestStore.breakLease(holderRequest); + return success(); + } + + if (holderRequest.stateAsEnum() != REQUESTED) { + return ServiceResult.badRequest("HolderCredentialRequest is expected to be in state '%s' but was '%s'".formatted(REQUESTED, holderRequest.stateAsString())); + } + + monitor.debug("Issuer '%s' rejected credential request '%s'".formatted(issuerDid, holderPid)); + holderRequest.transitionError(rejectionReason == null || rejectionReason.isBlank() + ? "The Issuer rejected the credential request '%s'".formatted(issuerPid) + : "The Issuer rejected the credential request '%s': %s".formatted(issuerPid, rejectionReason)); + holderCredentialRequestStore.save(holderRequest); + return success(); + } + + /** + * Establishes that an inbound message may act on this request at all: it must belong to the addressed participant + * context, and it must come from the Issuer the request was sent to. Asking that Issuer for the credentials is what + * makes it trusted for this request. + */ + private ServiceResult checkOrigin(HolderCredentialRequest holderRequest, String holderPid, String issuerDid, String participantContextId) { // requests of other participant contexts are not writable here, and their existence must not be observable either if (!holderRequest.getParticipantContextId().equals(participantContextId)) { return ServiceResult.notFound("HolderCredentialRequest with ID '%s' does not exist".formatted(holderPid)); } - // credentials are only accepted from the Issuer the request was addressed to: having asked that Issuer for them - // is what makes it trusted for this request if (!holderRequest.getIssuerDid().equals(issuerDid)) { return ServiceResult.unauthorized("HolderCredentialRequest '%s' was sent to Issuer '%s', so credentials delivered by '%s' are not accepted" .formatted(holderPid, holderRequest.getIssuerDid(), issuerDid)); } + return success(); + } + + private ServiceResult writeCredentials(HolderCredentialRequest holderRequest, String holderPid, String holderDid, String issuerPid, String issuerDid, + Collection writeRequests, String participantContextId) { + var origin = checkOrigin(holderRequest, holderPid, issuerDid, participantContextId); + if (origin.failed()) { + return origin; + } if (!ALLOWED_STATES.contains(holderRequest.stateAsEnum())) { return ServiceResult.badRequest("HolderCredentialRequest is expected to be in any of the states '%s' but was '%s'".formatted(ALLOWED_STATES, holderRequest.stateAsString())); diff --git a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImplTest.java b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImplTest.java index aa4687962..7c55c1ce8 100644 --- a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImplTest.java +++ b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/verifiablecredential/CredentialWriterImplTest.java @@ -14,12 +14,14 @@ package org.eclipse.edc.identityhub.core.services.verifiablecredential; +import org.assertj.core.api.Assertions; import org.eclipse.edc.iam.did.spi.resolution.DidPublicKeyResolver; import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat; import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialSubject; import org.eclipse.edc.iam.verifiablecredentials.spi.model.Issuer; import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; import org.eclipse.edc.identityhub.spi.credential.request.model.HolderCredentialRequest; +import org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState; import org.eclipse.edc.identityhub.spi.credential.request.store.HolderCredentialRequestStore; import org.eclipse.edc.identityhub.spi.verifiablecredentials.generator.CredentialWriteRequest; import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; @@ -276,6 +278,106 @@ void write_redeliveryToIssuedRequest_expectIdempotentNoOp() { verify(holderCredentialRequestStore, never()).save(any()); } + // A3.25 / CS-STOR-05: a REJECTED CredentialMessage fails the pending request instead of leaving it waiting forever + @Test + @DisplayName("A3.25: a rejection from the request's issuer moves the request to ERROR and stores nothing") + void reject_pendingRequest_expectTransitionToError() { + var request = HolderCredentialRequest.Builder.newInstance() + .issuerDid(ISSUER_DID) + .requestedCredential("test-id", TEST_CREDENTIAL_TYPE, TEST_CREDENTIAL_FORMAT) + .state(REQUESTED.code()) + .participantContextId(PARTICIPANT_ID) + .issuerPid("issuerPid") + .build(); + when(holderCredentialRequestStore.findByIdAndLease(anyString())).thenReturn(StoreResult.success(request)); + + var result = credentialWriter.reject("holderPid", "issuerPid", ISSUER_DID, "attestation not satisfied", PARTICIPANT_ID); + + assertThat(result).isSucceeded(); + Assertions.assertThat(request.stateAsEnum()).isEqualTo(HolderRequestState.ERROR); + Assertions.assertThat(request.getErrorDetail()).contains("attestation not satisfied"); + verify(holderCredentialRequestStore).save(request); + verifyNoInteractions(credentialStore); + } + + @Test + @DisplayName("A3.25: a rejection without a reason still fails the request") + void reject_withoutReason_expectTransitionToError() { + var request = HolderCredentialRequest.Builder.newInstance() + .issuerDid(ISSUER_DID) + .requestedCredential("test-id", TEST_CREDENTIAL_TYPE, TEST_CREDENTIAL_FORMAT) + .state(REQUESTED.code()) + .participantContextId(PARTICIPANT_ID) + .issuerPid("issuerPid") + .build(); + when(holderCredentialRequestStore.findByIdAndLease(anyString())).thenReturn(StoreResult.success(request)); + + var result = credentialWriter.reject("holderPid", "issuerPid", ISSUER_DID, null, PARTICIPANT_ID); + + assertThat(result).isSucceeded(); + Assertions.assertThat(request.stateAsEnum()).isEqualTo(HolderRequestState.ERROR); + Assertions.assertThat(request.getErrorDetail()).contains("issuerPid"); + } + + @Test + @DisplayName("A3.25: a rejection from an issuer other than the one addressed is not accepted") + void reject_fromDifferentIssuer_expectUnauthorized() { + var request = HolderCredentialRequest.Builder.newInstance() + .issuerDid(ISSUER_DID) + .requestedCredential("test-id", TEST_CREDENTIAL_TYPE, TEST_CREDENTIAL_FORMAT) + .state(REQUESTED.code()) + .participantContextId(PARTICIPANT_ID) + .issuerPid("issuerPid") + .build(); + when(holderCredentialRequestStore.findByIdAndLease(anyString())).thenReturn(StoreResult.success(request)); + + var result = credentialWriter.reject("holderPid", "issuerPid", "did:web:someone-else", "nope", PARTICIPANT_ID); + + assertThat(result).isFailed(); + Assertions.assertThat(request.stateAsEnum()).isEqualTo(REQUESTED); + verify(holderCredentialRequestStore, never()).save(any()); + verify(holderCredentialRequestStore).breakLease(request); + } + + @Test + @DisplayName("A3.25: a rejection trailing a completed issuance is acknowledged but does not undo it") + void reject_afterCredentialsIssued_expectNoOp() { + var request = HolderCredentialRequest.Builder.newInstance() + .issuerDid(ISSUER_DID) + .requestedCredential("test-id", TEST_CREDENTIAL_TYPE, TEST_CREDENTIAL_FORMAT) + .state(ISSUED.code()) + .participantContextId(PARTICIPANT_ID) + .issuerPid("issuerPid") + .build(); + when(holderCredentialRequestStore.findByIdAndLease(anyString())).thenReturn(StoreResult.success(request)); + + var result = credentialWriter.reject("holderPid", "issuerPid", ISSUER_DID, "too late", PARTICIPANT_ID); + + assertThat(result).isSucceeded(); + Assertions.assertThat(request.stateAsEnum()).isEqualTo(ISSUED); + verify(holderCredentialRequestStore, never()).save(any()); + verify(holderCredentialRequestStore).breakLease(request); + } + + @Test + @DisplayName("A3.25: a rejection for another participant context's request is not observable") + void reject_crossTenant_expectNotFound() { + var request = HolderCredentialRequest.Builder.newInstance() + .issuerDid(ISSUER_DID) + .requestedCredential("test-id", TEST_CREDENTIAL_TYPE, TEST_CREDENTIAL_FORMAT) + .state(REQUESTED.code()) + .participantContextId("another-participant") + .issuerPid("issuerPid") + .build(); + when(holderCredentialRequestStore.findByIdAndLease(anyString())).thenReturn(StoreResult.success(request)); + + var result = credentialWriter.reject("holderPid", "issuerPid", ISSUER_DID, "nope", PARTICIPANT_ID); + + assertThat(result).isFailed(); + Assertions.assertThat(request.stateAsEnum()).isEqualTo(REQUESTED); + verify(holderCredentialRequestStore).breakLease(request); + } + private VerifiableCredential.Builder createCredential() { return VerifiableCredential.Builder.newInstance() .types(List.of(TEST_CREDENTIAL_TYPE)) diff --git a/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java b/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java index 139138640..4fbd5488a 100644 --- a/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java +++ b/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java @@ -67,8 +67,10 @@ import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.CREDENTIALS_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.HOLDER_PID_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.ISSUER_PID_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.REJECTION_REASON_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.STATUS_TERM; import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.CREATED; +import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.ERROR; import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.ISSUED; import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.REQUESTED; import static org.eclipse.edc.identityhub.tests.fixtures.TestData.IH_RUNTIME_NAME; @@ -309,15 +311,16 @@ void storeCredential_whenFormatNotRequested(IdentityHub identityHub) throws JOSE } // A3.16: CredentialMessage with status=REJECTED -> 2xx, nothing stored, holder request ends in an error/rejected state, NOT in ISSUED (currently still transitions to ISSUED) - @DisplayName("A3.16: A CredentialMessage with status=REJECTED stores nothing and does not transition the request to ISSUED") + @DisplayName("A3.16: A CredentialMessage with status=REJECTED stores nothing and fails the request") @Test - void storeCredential_whenStatusRejected_shouldNotStoreAndNotTransitionToIssued(IdentityHub identityHub, CredentialStore credentialStore, HolderCredentialRequestStore requestStore) throws JOSEException { + void storeCredential_whenStatusRejected_shouldNotStoreAndTransitionToError(IdentityHub identityHub, CredentialStore credentialStore, HolderCredentialRequestStore requestStore) throws JOSEException { // valid issuer key + a REJECTED message correlating to the pending request from setup() when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq(PROVIDER_DID + "#key1"))).thenReturn(Result.success(PROVIDER_KEY.toPublicKey())); var rejectedMessage = Json.createObjectBuilder() .add(DSPACE_DCP_NAMESPACE_V_1_0.toIri(STATUS_TERM), "REJECTED") .add(DSPACE_DCP_NAMESPACE_V_1_0.toIri(ISSUER_PID_TERM), "test-request-id") + .add(DSPACE_DCP_NAMESPACE_V_1_0.toIri(REJECTION_REASON_TERM), "attestation could not be satisfied") .add(DSPACE_DCP_NAMESPACE_V_1_0.toIri(HOLDER_PID_TERM), "test-holder-id") .add(DSPACE_DCP_NAMESPACE_V_1_0.toIri(CREDENTIALS_TERM), Json.createArrayBuilder() .add(Json.createObjectBuilder() @@ -338,10 +341,11 @@ void storeCredential_whenStatusRejected_shouldNotStoreAndNotTransitionToIssued(I // nothing was stored assertThat(credentialStore.query(QuerySpec.max()).getContent()).isEmpty(); - // the holder request must end in an error/rejected state, NOT in ISSUED + // the holder request is failed, so the holder stops waiting for credentials that will never arrive var holderRequest = requestStore.findById("test-holder-id"); assertThat(holderRequest).isNotNull(); - assertThat(holderRequest.stateAsEnum()).isNotEqualTo(ISSUED); + assertThat(holderRequest.stateAsEnum()).isEqualTo(ERROR); + assertThat(holderRequest.getErrorDetail()).contains("attestation could not be satisfied"); } // A3.18: delivery signed by a DIFFERENT issuer DID than the one the request was addressed to (valid SI token for that other DID, correct aud) -> 4xx, nothing stored (currently accepted when type/format match) diff --git a/protocols/dcp/dcp-identityhub/storage-api/src/main/java/org/eclipse/edc/identityhub/api/storage/StorageApiController.java b/protocols/dcp/dcp-identityhub/storage-api/src/main/java/org/eclipse/edc/identityhub/api/storage/StorageApiController.java index 9a52821d4..dcda0944b 100644 --- a/protocols/dcp/dcp-identityhub/storage-api/src/main/java/org/eclipse/edc/identityhub/api/storage/StorageApiController.java +++ b/protocols/dcp/dcp-identityhub/storage-api/src/main/java/org/eclipse/edc/identityhub/api/storage/StorageApiController.java @@ -104,13 +104,17 @@ public Response storeCredential(@PathParam("participantContextId") String partic // the token was verified against the DID document of its issuer, so the 'iss' claim identifies who delivers here var issuerDid = issuerClaims.getStringClaim(JwtRegisteredClaimNames.ISSUER); - if (Objects.equals(credentialMessage.getStatus(), CredentialMessage.STATUS_REJECTED)) { - return Response.ok().build(); - } - var holderPid = credentialMessage.getHolderPid(); var issuerPid = credentialMessage.getIssuerPid(); + // the Issuer reports that it will not issue the credentials, so the request is failed instead of waiting for a + // delivery that is never coming + if (Objects.equals(credentialMessage.getStatus(), CredentialMessage.STATUS_REJECTED)) { + return credentialWriter.reject(holderPid, issuerPid, issuerDid, credentialMessage.getRejectionReason(), participantContextId) + .map(v -> Response.ok().build()) + .orElseThrow(exceptionMapper(CredentialMessage.class, null)); + } + var writeRequests = credentialMessage.getCredentials().stream().map(c -> new CredentialWriteRequest(c.payload(), c.format())).toList(); return credentialWriter.write(holderPid, holderDid, issuerPid, issuerDid, writeRequests, participantContextId) .onSuccess(v -> monitor.debug("HolderCredentialRequest %s is now in state %s".formatted(holderPid, HolderRequestState.ISSUED))) diff --git a/protocols/dcp/dcp-identityhub/storage-api/src/test/java/org/eclipse/edc/identityhub/api/storage/StorageApiControllerTest.java b/protocols/dcp/dcp-identityhub/storage-api/src/test/java/org/eclipse/edc/identityhub/api/storage/StorageApiControllerTest.java index deda18aaa..feb442029 100644 --- a/protocols/dcp/dcp-identityhub/storage-api/src/test/java/org/eclipse/edc/identityhub/api/storage/StorageApiControllerTest.java +++ b/protocols/dcp/dcp-identityhub/storage-api/src/test/java/org/eclipse/edc/identityhub/api/storage/StorageApiControllerTest.java @@ -211,16 +211,20 @@ void storeCredential_writerReturnsNotAuthorized_shouldReturn403() { // A3.16: a CredentialMessage with status=REJECTED must not store anything and must not transition the request to ISSUED; the request must end in an error/rejected state @Test - @DisplayName("A3.16: a REJECTED credential message stores nothing and does not transition the request to ISSUED") - void storeCredential_statusRejected_shouldNotStoreAndNotTransitionToIssued() { + @DisplayName("A3.16: a REJECTED credential message stores nothing and fails the request instead") + void storeCredential_statusRejected_shouldRejectRequestAndNotStore() { // message carries status=REJECTED and no credentials when(validatorRegistry.validate(any(), any())).thenReturn(ValidationResult.success()); + var issuerPid = UUID.randomUUID().toString(); + var holderPid = UUID.randomUUID().toString(); when(transformerRegistry.transform(isA(JsonObject.class), eq(CredentialMessage.class))) .thenReturn(Result.success(CredentialMessage.Builder.newInstance() - .issuerPid(UUID.randomUUID().toString()) - .holderPid(UUID.randomUUID().toString()) + .issuerPid(issuerPid) + .holderPid(holderPid) .status("REJECTED") + .rejectionReason("attestation not satisfied") .build())); + when(credentialWriter.reject(anyString(), anyString(), anyString(), any(), anyString())).thenReturn(ServiceResult.success()); baseRequest() .header("Authorization", "Bearer " + generateJwt()) @@ -230,8 +234,31 @@ void storeCredential_statusRejected_shouldNotStoreAndNotTransitionToIssued() { .log().ifValidationFails() .statusCode(200); - // the writer (which stores credentials and transitions the request to ISSUED) must never be invoked for a REJECTED message + // nothing is stored, but the request is failed so the holder stops waiting for it verify(credentialWriter, never()).write(anyString(), anyString(), anyString(), anyString(), anyCollection(), anyString()); + verify(credentialWriter).reject(eq(holderPid), eq(issuerPid), anyString(), eq("attestation not satisfied"), anyString()); + } + + @Test + @DisplayName("A3.25: a rejection the writer refuses is surfaced as an error, not acknowledged") + void storeCredential_statusRejected_whenWriterFails_shouldReturn403() { + when(validatorRegistry.validate(any(), any())).thenReturn(ValidationResult.success()); + when(transformerRegistry.transform(isA(JsonObject.class), eq(CredentialMessage.class))) + .thenReturn(Result.success(CredentialMessage.Builder.newInstance() + .issuerPid(UUID.randomUUID().toString()) + .holderPid(UUID.randomUUID().toString()) + .status("REJECTED") + .build())); + // e.g. the rejection came from an Issuer the request was never sent to + when(credentialWriter.reject(anyString(), anyString(), anyString(), any(), anyString())).thenReturn(ServiceResult.unauthorized("not your request")); + + baseRequest() + .header("Authorization", "Bearer " + generateJwt()) + .body(credentialMessageJson()) + .post() + .then() + .log().ifValidationFails() + .statusCode(403); } // A4.7: token-verification failure must return the same status code on both DCP endpoints — aligned on 401 (this endpoint already returns 401; the Credential Offer API currently returns 403 and must be adjusted) diff --git a/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialMessage.java b/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialMessage.java index e2338d639..6069d1b30 100644 --- a/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialMessage.java +++ b/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialMessage.java @@ -14,6 +14,8 @@ package org.eclipse.edc.identityhub.protocols.dcp.spi.model; +import org.jetbrains.annotations.Nullable; + import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -26,6 +28,7 @@ public class CredentialMessage { public static final String ISSUER_PID_TERM = "issuerPid"; public static final String HOLDER_PID_TERM = "holderPid"; public static final String STATUS_TERM = "status"; + public static final String REJECTION_REASON_TERM = "rejectionReason"; public static final String CREDENTIAL_MESSAGE_TERM = "CredentialMessage"; public static final String STATUS_ISSUED = "ISSUED"; public static final String STATUS_REJECTED = "REJECTED"; @@ -35,6 +38,7 @@ public class CredentialMessage { private String issuerPid; private String holderPid; private String status; + private String rejectionReason; public String getStatus() { return status; @@ -52,6 +56,15 @@ public String getHolderPid() { return holderPid; } + /** + * Why the Issuer rejected the request. Only carried on {@link #STATUS_REJECTED} messages, and {@code null} even + * then unless the Issuer supplied one. + */ + @Nullable + public String getRejectionReason() { + return rejectionReason; + } + public static final class Builder { private final CredentialMessage credentialMessage; @@ -88,6 +101,11 @@ public Builder status(String status) { return this; } + public Builder rejectionReason(String rejectionReason) { + this.credentialMessage.rejectionReason = rejectionReason; + return this; + } + public CredentialMessage build() { requireNonNull(credentialMessage.issuerPid, "issuerPid"); requireNonNull(credentialMessage.holderPid, "holderPid"); diff --git a/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialMessageTransformer.java b/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialMessageTransformer.java index bd0d1be24..1b7c5b009 100644 --- a/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialMessageTransformer.java +++ b/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialMessageTransformer.java @@ -63,6 +63,12 @@ public JsonObjectToCredentialMessageTransformer(TypeManager typeManager, String var status = transformString(jsonObject.get(forNamespace(CredentialMessage.STATUS_TERM)), transformerContext); requestMessage.status(status); + // OPTIONAL, and only meaningful on a rejection + var rejectionReason = jsonObject.get(forNamespace(CredentialMessage.REJECTION_REASON_TERM)); + if (rejectionReason != null) { + requestMessage.rejectionReason(transformString(rejectionReason, transformerContext)); + } + if (credentials != null) { ofNullable(readCredentialContainers(credentials, transformerContext)) .map(requestMessage::credentials); diff --git a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/generator/CredentialWriter.java b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/generator/CredentialWriter.java index 4cb64bd40..0cf892c97 100644 --- a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/generator/CredentialWriter.java +++ b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/generator/CredentialWriter.java @@ -15,6 +15,7 @@ package org.eclipse.edc.identityhub.spi.verifiablecredentials.generator; import org.eclipse.edc.spi.result.ServiceResult; +import org.jetbrains.annotations.Nullable; import java.util.Collection; @@ -23,7 +24,6 @@ * after a CredentialMessage * was received. Credentials can be in several formats, thus the {@link CredentialWriter} uses delegate credential parsers to extract metadata. */ -@FunctionalInterface public interface CredentialWriter { /** * Writes a credential object to storage received by an Issuer when issuing credentials @@ -36,4 +36,16 @@ public interface CredentialWriter { * @param participantContextId the participant context the credentials belong to */ ServiceResult write(String holderPid, String holderDid, String issuerPid, String issuerDid, Collection credentials, String participantContextId); + + /** + * Records that an Issuer rejected a credential request. The request is failed, so the Holder stops waiting for + * credentials that will never arrive. Nothing is stored. + * + * @param holderPid identifies the Holder's credential request that was rejected + * @param issuerPid the issuance process ID as reported by the Issuer + * @param issuerDid the DID of the Issuer that reported the rejection, as authenticated from its Self-Issued ID token + * @param rejectionReason why the Issuer rejected the request, or {@code null} if it did not say + * @param participantContextId the participant context the request belongs to + */ + ServiceResult reject(String holderPid, String issuerPid, String issuerDid, @Nullable String rejectionReason, String participantContextId); } From a3446f49c94f199319231ab595dc6cba6153a263 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:11:13 +0200 Subject: [PATCH 2/9] feat: report a failed issuance to the Holder as a REJECTED CredentialMessage (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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../process/IssuanceProcessManagerImpl.java | 16 ++++++ .../IssuanceProcessManagerImplTest.java | 35 ++++++++++++ .../issuer/DcpCredentialStorageClient.java | 40 +++++++++++--- .../DcpCredentialStorageClientTest.java | 53 +++++++++++++++++++ .../delivery/CredentialStorageClient.java | 10 ++++ 5 files changed, 148 insertions(+), 6 deletions(-) diff --git a/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java index dd3aeaa60..5032660b5 100644 --- a/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java +++ b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java @@ -187,9 +187,25 @@ private void transitionToApproved(IssuanceProcess process) { private void transitionToError(IssuanceProcess process) { process.transitionToError(); update(process); + notifyHolderOfRejection(process); discardHolderAccessToken(process); } + /** + * Tells the Holder that the issuance it was told had been accepted has failed, so it stops waiting for credentials + * that are never coming. Best effort: the failure is already recorded and is served by the Credential Request Status + * API, so a Holder that never receives this still learns about it by polling. + */ + private void notifyHolderOfRejection(IssuanceProcess process) { + try { + credentialStorageClient.deliverRejection(process, process.getErrorDetail()) + .onFailure(f -> monitor.debug("Could not notify the Holder that issuance process '%s' was rejected: %s" + .formatted(process.getId(), f.getFailureDetail()))); + } catch (Exception e) { + monitor.debug("Could not notify the Holder that issuance process '%s' was rejected: %s".formatted(process.getId(), e.getMessage())); + } + } + /** * Removes the Holder's access token from the vault. The process is in a terminal state, so the token will not be * used again and must not be kept around. diff --git a/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImplTest.java b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImplTest.java index 958ab154b..2ba68affa 100644 --- a/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImplTest.java +++ b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImplTest.java @@ -324,6 +324,41 @@ void approved_shouldRetryAndEventuallyError_whenDeliveryFails() { // detail, so 'holder unreachable' is not preserved in the errorDetail today assertThat(process.getErrorDetail()).isNotNull(); verify(listener).errored(eq(process), any()); + // RT-03: the holder is told the issuance it was told had been accepted is not coming + verify(credentialStorageClient).deliverRejection(eq(process), any()); + }); + } + + // RT-03: the rejection notice is best effort - a holder that cannot be reached must not keep the process out of its + // terminal state, because the failure is still served by the Credential Request Status API + @DisplayName("B3.6: a failing rejection notice does not keep the process out of ERRORED") + @Test + void error_whenRejectionNoticeFails_stillTransitionsToErrored() { + var process = IssuanceProcess.Builder.newInstance().state(APPROVED.code()) + .holderId("holderId") + .participantContextId("participantContextId") + .holderPid("holderPid") + .credentialFormats(Map.of("membership-credential-id", VC1_0_JWT)) + .stateCount(1) + .build(); + + var savedTransitions = new CopyOnWriteArrayList(); + when(issuanceProcessStore.save(any())).thenAnswer(invocation -> { + savedTransitions.add(invocation.getArgument(0, IssuanceProcess.class).getState()); + return StoreResult.success(); + }); + when(issuanceProcessStore.nextNotLeased(anyInt(), stateIs(APPROVED.code()))) + .thenReturn(List.of(process)) + .thenReturn(emptyList()); + // no credential definition -> the process fails outright + when(credentialDefinitionStore.query(any())).thenReturn(StoreResult.generalError("no definitions")); + when(credentialStorageClient.deliverRejection(any(), any())).thenReturn(Result.failure("holder unreachable")); + + issuanceProcessManager.start(); + + await().untilAsserted(() -> { + verify(credentialStorageClient).deliverRejection(eq(process), any()); + assertThat(savedTransitions).contains(ERRORED.code()); }); } diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClient.java b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClient.java index aae283b9f..0efc678a5 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClient.java +++ b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClient.java @@ -46,6 +46,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.function.Supplier; import static jakarta.json.stream.JsonCollectors.toJsonArray; import static org.eclipse.edc.iam.decentralizedclaims.spi.DcpConstants.DSPACE_DCP_V_1_0_CONTEXT; @@ -53,6 +54,8 @@ import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.CREDENTIAL_MESSAGE_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.HOLDER_PID_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.ISSUER_PID_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.REJECTION_REASON_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.STATUS_REJECTED; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.STATUS_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.TYPE_TERM; import static org.eclipse.edc.identityhub.spi.verification.SelfIssuedTokenConstants.TOKEN_CLAIM; @@ -91,7 +94,15 @@ public DcpCredentialStorageClient(EdcHttpClient httpClient, ParticipantContextSt @Override public Result deliverCredentials(IssuanceProcess issuanceProcess, Collection credentials) { + return send(issuanceProcess, () -> createCredentialMessage(issuanceProcess, credentials), "Error delivering credentials"); + } + @Override + public Result deliverRejection(IssuanceProcess issuanceProcess, @Nullable String rejectionReason) { + return send(issuanceProcess, () -> createRejectionMessage(issuanceProcess, rejectionReason), "Error delivering rejection"); + } + + private Result send(IssuanceProcess issuanceProcess, Supplier messageSupplier, String errorMessage) { try { var issuerDid = participantContextStore.findById(issuanceProcess.getParticipantContextId()).map(ParticipantContext::getIdentity) .orElseThrow(failure -> new EdcException("Participant context not found")); @@ -105,14 +116,11 @@ public Result deliverCredentials(IssuanceProcess issuanceProcess, Collecti var selfIssuedTokenJwt = getAuthToken(issuanceProcess.getParticipantContextId(), participantDid, issuerDid, resolveHolderAccessToken(issuanceProcess)) .orElseThrow(failure -> new EdcException("Error creating self-issued token")); - var credentialMessage = createCredentialMessage(issuanceProcess, credentials); - - return sendRequest(credentialMessage, url, selfIssuedTokenJwt); + return sendRequest(messageSupplier.get(), url, selfIssuedTokenJwt); } catch (EdcException e) { - monitor.warning("Error delivering credentials", e); - return failure("Error delivering credentials: %s".formatted(e.getMessage())); + monitor.warning(errorMessage, e); + return failure("%s: %s".formatted(errorMessage, e.getMessage())); } - } private @NotNull Result sendRequest(JsonObject credentialMessage, String url, TokenRepresentation selfIssuedTokenJwt) { @@ -151,6 +159,26 @@ private JsonObject createCredentialMessage(IssuanceProcess issuanceProcess, Coll .build(); } + /** + * A {@code CredentialMessage} reporting that the request was rejected. It carries the same pids as a successful + * delivery would, so the Holder correlates it with the request it is waiting on, and no credentials. + */ + private JsonObject createRejectionMessage(IssuanceProcess issuanceProcess, @Nullable String rejectionReason) { + var builder = Json.createObjectBuilder() + .add(JsonLdKeywords.CONTEXT, Json.createArrayBuilder() + .add(DSPACE_DCP_V_1_0_CONTEXT)) + .add(TYPE_TERM, CREDENTIAL_MESSAGE_TERM) + .add(ISSUER_PID_TERM, issuanceProcess.getId()) + .add(HOLDER_PID_TERM, issuanceProcess.getHolderPid()) + .add(STATUS_TERM, STATUS_REJECTED); + + // the reason is OPTIONAL, and the spec requires that it discloses nothing confidential + if (rejectionReason != null && !rejectionReason.isBlank()) { + builder.add(REJECTION_REASON_TERM, rejectionReason); + } + return builder.build(); + } + private JsonObject toJson(VerifiableCredentialContainer credential) { return Json.createObjectBuilder() .add("credentialType", credential.credential().getType().stream().filter("VerifiableCredential"::equals).findFirst().orElseThrow()) diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClientTest.java b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClientTest.java index 4600f5c06..95bc68556 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClientTest.java +++ b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpCredentialStorageClientTest.java @@ -58,6 +58,7 @@ import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.CREDENTIALS_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.HOLDER_PID_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.ISSUER_PID_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.REJECTION_REASON_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.STATUS_TERM; import static org.eclipse.edc.identityhub.spi.verifiablecredentials.model.CredentialProfile.DCP_PROFILE_VC11; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; @@ -264,6 +265,58 @@ private Holder holder() { .build(); } + // RT-03: an issuance that failed after acceptance is reported to the Holder as a CredentialMessage with status + // REJECTED, correlating on the same pids as a delivery would, and carrying no credentials + @DisplayName("B5.6: a rejection POSTs a CredentialMessage with status REJECTED, the same pids and no credentials") + @Test + void deliverRejection_success() throws IOException { + var process = issuanceProcess(); + + var result = client.deliverRejection(process, "attestation could not be satisfied"); + + assertThat(result).isSucceeded(); + + var requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(httpClient).execute(requestCaptor.capture()); + var request = requestCaptor.getValue(); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.url().toString()).isEqualTo(CREDENTIAL_SERVICE_URL + "/credentials"); + assertThat(request.header("Authorization")).isEqualTo("Bearer si-token"); + + var buffer = new Buffer(); + request.body().writeTo(buffer); + var message = Json.createReader(new StringReader(buffer.readUtf8())).readObject(); + assertThat(message.getString(ISSUER_PID_TERM)).isEqualTo(process.getId()); + assertThat(message.getString(HOLDER_PID_TERM)).isEqualTo(process.getHolderPid()); + assertThat(message.getString(STATUS_TERM)).isEqualTo("REJECTED"); + assertThat(message.getString(REJECTION_REASON_TERM)).isEqualTo("attestation could not be satisfied"); + assertThat(message).doesNotContainKey(CREDENTIALS_TERM); + } + + @DisplayName("B5.6: a rejection without a reason omits the rejectionReason property") + @Test + void deliverRejection_withoutReason_omitsRejectionReason() throws IOException { + var result = client.deliverRejection(issuanceProcess(), null); + + assertThat(result).isSucceeded(); + + var requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(httpClient).execute(requestCaptor.capture()); + var buffer = new Buffer(); + requestCaptor.getValue().body().writeTo(buffer); + var message = Json.createReader(new StringReader(buffer.readUtf8())).readObject(); + assertThat(message.getString(STATUS_TERM)).isEqualTo("REJECTED"); + assertThat(message).doesNotContainKey(REJECTION_REASON_TERM); + } + + @DisplayName("B5.6: a rejection fails when the holder's Credential Service rejects it") + @Test + void deliverRejection_whenNonSuccessfulResponse_returnsFailure() throws IOException { + when(httpClient.execute(any(Request.class))).thenReturn(response(400)); + + assertThat(client.deliverRejection(issuanceProcess(), "nope")).isFailed(); + } + private IssuanceProcess issuanceProcess() { return IssuanceProcess.Builder.newInstance() .id(ISSUANCE_PROCESS_ID) diff --git a/spi/issuerservice/issuerservice-issuance-spi/src/main/java/org/eclipse/edc/issuerservice/spi/issuance/delivery/CredentialStorageClient.java b/spi/issuerservice/issuerservice-issuance-spi/src/main/java/org/eclipse/edc/issuerservice/spi/issuance/delivery/CredentialStorageClient.java index 49ca6d1f6..2abef4557 100644 --- a/spi/issuerservice/issuerservice-issuance-spi/src/main/java/org/eclipse/edc/issuerservice/spi/issuance/delivery/CredentialStorageClient.java +++ b/spi/issuerservice/issuerservice-issuance-spi/src/main/java/org/eclipse/edc/issuerservice/spi/issuance/delivery/CredentialStorageClient.java @@ -19,6 +19,7 @@ import org.eclipse.edc.issuerservice.spi.issuance.model.IssuanceProcess; import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; import org.eclipse.edc.spi.result.Result; +import org.jetbrains.annotations.Nullable; import java.util.Collection; @@ -29,4 +30,13 @@ public interface CredentialStorageClient { Result deliverCredentials(IssuanceProcess issuanceProcess, Collection credentials); + + /** + * Tells the Holder that an issuance it was told had been accepted will not produce any credentials, so it can stop + * waiting for them. No credentials are sent. + * + * @param issuanceProcess the failed issuance process, supplying the pids the Holder correlates on + * @param rejectionReason why the request was rejected, or {@code null}. Must not disclose anything confidential. + */ + Result deliverRejection(IssuanceProcess issuanceProcess, @Nullable String rejectionReason); } From ff7270ef90a8e25044c852368d998c48c58b2e5d Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:14:20 +0200 Subject: [PATCH 3/9] feat: support the org.eclipse.dspace.dcp.vc.id scope alias (CS-PRES-11) 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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../EdcScopeToCriterionTransformer.java | 24 +++++++++ .../EdcScopeToCriterionTransformerTest.java | 34 ++++++++++++ .../CredentialQueryResolverImplTest.java | 53 ++++++++++++++++++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformer.java b/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformer.java index 074ea5dcf..20a5368fc 100644 --- a/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformer.java +++ b/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformer.java @@ -44,6 +44,9 @@ public class EdcScopeToCriterionTransformer implements ScopeToCriterionTransform // this has to include the "@" for Postgres queries to work because they operate on JSON public static final String CONTEXT_OPERAND = "verifiableCredential.credential.@context"; public static final String ALIAS_LITERAL = "org.eclipse.dspace.dcp.vc.type"; + public static final String ID_OPERAND = "verifiableCredential.credential.id"; + public static final String ALIAS_LITERAL_ID = "org.eclipse.dspace.dcp.vc.id"; + public static final String EQUALS_OPERATOR = "="; public static final String CONTAINS_OPERATOR = "contains"; private static final String SCOPE_SEPARATOR = ":"; private final List allowedOperations = List.of("read", "*", "all"); @@ -56,6 +59,15 @@ public EdcScopeToCriterionTransformer(DiscriminatorMappingRegistry discriminator @Override public Result> transformScope(String scope) { + if (scope == null) { + return failure("Scope was null"); + } + + var idAliasPrefix = ALIAS_LITERAL_ID + SCOPE_SEPARATOR; + if (scope.regionMatches(true, 0, idAliasPrefix, 0, idAliasPrefix.length())) { + return convertIdAlias(scope.substring(idAliasPrefix.length())); + } + var tokens = tokenize(scope); if (tokens.failed()) { return failure("Scope string cannot be converted: %s".formatted(tokens.getFailureDetail())); @@ -93,6 +105,18 @@ protected Result tokenize(String scope) { return success(tokens); } + /** + * Converts the {@code org.eclipse.dspace.dcp.vc.id} alias, which grants read access to one credential by id. Unlike + * the type alias it carries no operation part, and everything after the first separator is the id, so ids that + * themselves contain separators (URNs, for instance) survive intact. + */ + private Result> convertIdAlias(String credentialId) { + if (credentialId.isBlank()) { + return failure("Scope string cannot be converted: no credential ID given after the '%s' alias".formatted(ALIAS_LITERAL_ID)); + } + return success(List.of(new Criterion(ID_OPERAND, EQUALS_OPERATOR, credentialId))); + } + private Result> convertDiscriminator(String discriminator) { if (discriminator == null) { return failure("discriminator was null"); diff --git a/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformerTest.java b/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformerTest.java index 7debefa62..153993677 100644 --- a/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformerTest.java +++ b/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformerTest.java @@ -14,6 +14,7 @@ package org.eclipse.edc.identityhub.defaults; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -61,4 +62,37 @@ void transform_withAlias() { void transform_invalidScope(String scope) { assertThat(transformer.transformScope(scope)).isFailed(); } + + // CS-PRES-11: the org.eclipse.dspace.dcp.vc.id alias MUST be supported and selects one credential by its id + @Test + @DisplayName("CS-PRES-11: the vc.id alias resolves to an equality criterion on the credential id") + void transform_idAlias() { + assertThat(transformer.transformScope("org.eclipse.dspace.dcp.vc.id:8247b87d-8d72-47e1-8128-9ce47e3d829d")) + .isSucceeded() + .satisfies(criteria -> { + assertThat(criteria).hasSize(1); + assertThat(criteria.get(0).getOperandLeft()).isEqualTo("verifiableCredential.credential.id"); + assertThat(criteria.get(0).getOperator()).isEqualTo("="); + assertThat(criteria.get(0).getOperandRight()).isEqualTo("8247b87d-8d72-47e1-8128-9ce47e3d829d"); + }); + } + + // the alias carries no operation part, so everything after the first separator belongs to the id + @Test + @DisplayName("CS-PRES-11: an id containing separators is not truncated") + void transform_idAlias_idWithSeparators() { + assertThat(transformer.transformScope("org.eclipse.dspace.dcp.vc.id:urn:uuid:8247b87d-8d72-47e1-8128-9ce47e3d829d")) + .isSucceeded() + .satisfies(criteria -> assertThat(criteria.get(0).getOperandRight()).isEqualTo("urn:uuid:8247b87d-8d72-47e1-8128-9ce47e3d829d")); + } + + @ParameterizedTest + @ValueSource(strings = { + "org.eclipse.dspace.dcp.vc.id:", + "org.eclipse.dspace.dcp.vc.id: ", + }) + @DisplayName("CS-PRES-11: the vc.id alias without an id is rejected") + void transform_idAlias_withoutId(String scope) { + assertThat(transformer.transformScope(scope)).isFailed(); + } } diff --git a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java index 9e77d6288..e6136a60d 100644 --- a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java +++ b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java @@ -34,6 +34,7 @@ import org.eclipse.edc.spi.result.StoreResult; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.Instant; @@ -52,6 +53,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -354,8 +356,57 @@ void query_whenRevokedCredential_doesNotInclude() { verify(monitor).warning(eq("Credential '%s' not valid: revoked".formatted(credential.getId()))); } + // CS-PRES-11: a query for the vc.id alias returns exactly the credential with that id + @Test + @DisplayName("CS-PRES-11: a vc.id scope resolves to exactly that credential") + void query_byCredentialId() { + var credential = createCredential("TestCredential").build(); + var resource = createCredentialResource(credential).build(); + var idScope = "org.eclipse.dspace.dcp.vc.id:" + credential.getId(); + when(storeMock.query(queryingFor(credential.getId()))).thenReturn(success(List.of(resource))); + + var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID, createPresentationQuery(idScope), List.of(idScope)); + + assertThat(res).isSucceeded(); + assertThat(res.getContent()).containsExactly(resource.getVerifiableCredential()); + // the credential is selected by id, not by type + verify(storeMock, atLeastOnce()).query(argThat(q -> q.getFilterExpression().stream() + .anyMatch(c -> c.getOperandLeft().equals("verifiableCredential.credential.id") && c.getOperandRight().equals(credential.getId())))); + } + + // negative variant: an id nobody holds yields an empty presentation rather than an error that leaks its absence + @Test + @DisplayName("CS-PRES-11: an unknown credential id yields an empty result, not an error") + void query_byCredentialId_whenUnknown_returnsEmpty() { + var idScope = "org.eclipse.dspace.dcp.vc.id:" + UUID.randomUUID(); + when(storeMock.query(any())).thenAnswer(i -> success(List.of())); + + var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID, createPresentationQuery(idScope), List.of(idScope)); + + assertThat(res).isSucceeded(); + assertThat(res.getContent()).isEmpty(); + } + + // scope escalation must be caught for the id alias too: an access token for one credential does not unlock another + @Test + @DisplayName("CS-PRES-11: a vc.id query for a credential the token does not authorize returns nothing") + void query_byCredentialId_whenNotAuthorized_returnsEmpty() { + var authorized = createCredential("TestCredential").build(); + var other = createCredential("TestCredential").build(); + when(storeMock.query(queryingFor(authorized.getId()))).thenReturn(success(List.of(createCredentialResource(authorized).build()))); + when(storeMock.query(queryingFor(other.getId()))).thenReturn(success(List.of(createCredentialResource(other).build()))); + + var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID, + createPresentationQuery("org.eclipse.dspace.dcp.vc.id:" + other.getId()), + List.of("org.eclipse.dspace.dcp.vc.id:" + authorized.getId())); + + assertThat(res).isSucceeded(); + assertThat(res.getContent()).isEmpty(); + } + private QuerySpec queryingFor(String slug) { - return argThat(q -> q.getFilterExpression().stream().anyMatch(c -> c.getOperandRight().toString().contains(slug))); + // null-safe: with more than one stubbed matcher, Mockito applies each to the placeholder argument of the others + return argThat(q -> q != null && q.getFilterExpression().stream().anyMatch(c -> c.getOperandRight().toString().contains(slug))); } private VerifiableCredentialResource.Builder createCredentialResource(VerifiableCredential cred) { From ac37ee3ab218b8506aff4abefa3f2b17d159a224 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:29:16 +0200 Subject: [PATCH 4/9] feat: complete the CredentialObject published in issuer metadata (IS-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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../src/main/resources/dcp.v1.0.jsonld | 3 + .../dcp/issuer/api/v1beta/ApiSchema.java | 1 + .../issuer/DcpIssuerMetadataServiceImpl.java | 16 ++- .../DcpIssuerMetadataServiceImplTest.java | 118 ++++++++++++++++++ .../dcp/spi/model/CredentialObject.java | 14 +++ ...ObjectFromCredentialObjectTransformer.java | 11 +- ...onObjectToCredentialObjectTransformer.java | 5 + ...ctFromCredentialObjectTransformerTest.java | 3 + ...jectToCredentialObjectTransformerTest.java | 3 + 9 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImplTest.java diff --git a/core/common-core/src/main/resources/dcp.v1.0.jsonld b/core/common-core/src/main/resources/dcp.v1.0.jsonld index 82b6e28e3..fe992a984 100644 --- a/core/common-core/src/main/resources/dcp.v1.0.jsonld +++ b/core/common-core/src/main/resources/dcp.v1.0.jsonld @@ -47,6 +47,9 @@ "credentialType": { "@id": "dcp:credentialType" }, + "credentialSchema": { + "@id": "dcp:credentialSchema" + }, "offerReason": { "@id": "dcp:offerReason", "@type": "xsd:string" diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/api/v1beta/ApiSchema.java b/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/api/v1beta/ApiSchema.java index 72c9089cc..e78995c8c 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/api/v1beta/ApiSchema.java +++ b/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/api/v1beta/ApiSchema.java @@ -131,6 +131,7 @@ record IssuerMetadataSchema( { "type": "CredentialObject", "credentialType": "MembershipCredential", + "credentialSchema": "https://example.org/schemas/membership.json", "offerReason": "reissue", "bindingMethods": [ "did:web" diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java index 18d2cac90..e424753f0 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java +++ b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java @@ -27,13 +27,15 @@ import java.util.Collection; import java.util.List; -import java.util.UUID; import static org.eclipse.edc.participantcontext.spi.types.ParticipantResource.queryByParticipantContextId; public class DcpIssuerMetadataServiceImpl implements DcpIssuerMetadataService { + private static final String BINDING_METHOD_DID_WEB = "did:web"; + private static final String DEFAULT_OFFER_REASON = "reissue"; + private final CredentialDefinitionService credentialDefinitionService; private final DcpProfileRegistry profileRegistry; @@ -68,10 +70,16 @@ public ServiceResult> toCredentialObject(CredentialDefini .map(profile -> CredentialObject.Builder.newInstance() .id(credentialDefinition.getId()) .credentialType(credentialDefinition.getCredentialType()) - .bindingMethod("did:web") - .offerReason("reissue") // todo hardcoded? + .credentialSchema(credentialDefinition.getJsonSchemaUrl()) + .bindingMethod(BINDING_METHOD_DID_WEB) + // §6.7 requires every CredentialObject in credentialsSupported to carry all OPTIONAL properties, + // offerReason among them, even though metadata advertises what can be requested rather than + // making an offer. Callers of the Credential Offer API supply the reason that actually applies. + .offerReason(DEFAULT_OFFER_REASON) .profile(profile) - .issuancePolicy(PresentationDefinition.Builder.newInstance().id(UUID.randomUUID().toString()).build()) + // clients cache CredentialObjects by id, so the policy must not change between two fetches. It + // is derived from the definition instead of freshly generated. + .issuancePolicy(PresentationDefinition.Builder.newInstance().id(credentialDefinition.getId()).build()) .build()) .toList(); diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImplTest.java b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImplTest.java new file mode 100644 index 000000000..a0b36c6fa --- /dev/null +++ b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImplTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.protocols.dcp.issuer; + +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat; +import org.eclipse.edc.identityhub.protocols.dcp.spi.DcpProfileRegistry; +import org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject; +import org.eclipse.edc.identityhub.protocols.dcp.spi.model.DcpProfile; +import org.eclipse.edc.identityhub.spi.participantcontext.model.IdentityHubParticipantContext; +import org.eclipse.edc.issuerservice.spi.issuance.credentialdefinition.CredentialDefinitionService; +import org.eclipse.edc.issuerservice.spi.issuance.model.CredentialDefinition; +import org.eclipse.edc.spi.result.ServiceResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DcpIssuerMetadataServiceImplTest { + + private static final String PARTICIPANT_CONTEXT_ID = "issuer-context"; + private static final String ISSUER_DID = "did:web:issuer"; + private static final String DEFINITION_ID = "membership-credential-definition"; + private static final String SCHEMA_URL = "https://example.org/schemas/membership.json"; + + private final CredentialDefinitionService credentialDefinitionService = mock(); + private final DcpProfileRegistry profileRegistry = mock(); + private final DcpIssuerMetadataServiceImpl service = new DcpIssuerMetadataServiceImpl(credentialDefinitionService, profileRegistry); + + private final IdentityHubParticipantContext participantContext = IdentityHubParticipantContext.Builder.newInstance() + .participantContextId(PARTICIPANT_CONTEXT_ID) + .did(ISSUER_DID) + .apiTokenAlias("apiAlias") + .build(); + + // IS-META-01: the metadata reports the issuer DID and one CredentialObject per configured definition + @Test + @DisplayName("B7.5: issuer metadata reports the issuer DID and the configured credential definitions") + void getIssuerMetadata_reportsIssuerAndSupportedCredentials() { + stubDefinitions(credentialDefinition()); + + var result = service.getIssuerMetadata(participantContext); + + assertThat(result.succeeded()).isTrue(); + assertThat(result.getContent().getIssuer()).isEqualTo(ISSUER_DID); + assertThat(result.getContent().getCredentialsSupported()).hasSize(1); + } + + // IS-META-02: §6.7 requires every CredentialObject in credentialsSupported to carry ALL optional properties + @Test + @DisplayName("B7.6: every CredentialObject carries all optional properties with well-formed values") + void getIssuerMetadata_credentialObjectIsComplete() { + stubDefinitions(credentialDefinition()); + + var credentialObject = single(service.getIssuerMetadata(participantContext).getContent().getCredentialsSupported()); + + assertThat(credentialObject.getId()).isEqualTo(DEFINITION_ID); + assertThat(credentialObject.getCredentialType()).isEqualTo("MembershipCredential"); + assertThat(credentialObject.getCredentialSchema()).isEqualTo(SCHEMA_URL); + assertThat(credentialObject.getBindingMethods()).containsExactly("did:web"); + assertThat(credentialObject.getProfile()).isEqualTo("vc11-sl2021/jwt"); + assertThat(credentialObject.getOfferReason()).isNotBlank(); + assertThat(credentialObject.getIssuancePolicy()).isNotNull(); + assertThat(credentialObject.getIssuancePolicy().getId()).isNotBlank(); + } + + // IS-META-03: clients reference and cache CredentialObjects by id, so nothing about them may change between fetches + @Test + @DisplayName("B7.7: two metadata fetches produce identical CredentialObjects") + void getIssuerMetadata_isStableAcrossFetches() { + stubDefinitions(credentialDefinition()); + + var first = single(service.getIssuerMetadata(participantContext).getContent().getCredentialsSupported()); + var second = single(service.getIssuerMetadata(participantContext).getContent().getCredentialsSupported()); + + assertThat(first.getId()).isEqualTo(second.getId()); + // the issuance policy used to be generated with a random id, which made the object differ on every fetch + assertThat(first.getIssuancePolicy().getId()).isEqualTo(second.getIssuancePolicy().getId()); + } + + private CredentialObject single(java.util.Collection credentialObjects) { + assertThat(credentialObjects).hasSize(1); + return credentialObjects.iterator().next(); + } + + private void stubDefinitions(CredentialDefinition... definitions) { + when(profileRegistry.profilesFor(CredentialFormat.VC1_0_JWT)) + .thenReturn(List.of(new DcpProfile("vc11-sl2021/jwt", CredentialFormat.VC1_0_JWT, "StatusList2021Entry"))); + when(credentialDefinitionService.queryCredentialDefinitions(any())).thenReturn(ServiceResult.success(List.of(definitions))); + } + + private CredentialDefinition credentialDefinition() { + return CredentialDefinition.Builder.newInstance() + .id(DEFINITION_ID) + .credentialType("MembershipCredential") + .jsonSchemaUrl(SCHEMA_URL) + .jsonSchema("{}") + .participantContextId(PARTICIPANT_CONTEXT_ID) + .formatFrom(CredentialFormat.VC1_0_JWT) + .build(); + } +} diff --git a/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java b/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java index 66c84ca25..0a26d02e1 100644 --- a/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java +++ b/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java @@ -25,6 +25,7 @@ public class CredentialObject { public static final String CREDENTIAL_OBJECT_TERM = "CredentialObject"; public static final String CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM = "credentialType"; + public static final String CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM = "credentialSchema"; public static final String CREDENTIAL_OBJECT_OFFER_REASON_TERM = "offerReason"; public static final String CREDENTIAL_OBJECT_PROFILE_TERM = "profile"; public static final String CREDENTIAL_OBJECT_BINDING_METHODS_TERM = "bindingMethods"; @@ -32,6 +33,7 @@ public class CredentialObject { private String profile; private String id; private String credentialType; + private String credentialSchema; private String offerReason; private List bindingMethods = new ArrayList<>(); private PresentationDefinition issuancePolicy; @@ -44,6 +46,13 @@ public String getCredentialType() { return credentialType; } + /** + * URL of the schema the issued credential's {@code credentialSubject} adheres to. + */ + public String getCredentialSchema() { + return credentialSchema; + } + public List getBindingMethods() { return bindingMethods; } @@ -81,6 +90,11 @@ public Builder credentialType(String credentialType) { return this; } + public Builder credentialSchema(String credentialSchema) { + credentialObject.credentialSchema = credentialSchema; + return this; + } + public Builder offerReason(String offerReason) { credentialObject.offerReason = offerReason; return this; diff --git a/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformer.java b/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformer.java index c220359d3..c5fedf3da 100644 --- a/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformer.java +++ b/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformer.java @@ -27,6 +27,7 @@ import static jakarta.json.stream.JsonCollectors.toJsonArray; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_BINDING_METHODS_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_OFFER_REASON_TERM; @@ -65,15 +66,19 @@ public JsonObjectFromCredentialObjectTransformer(TypeManager typeManager, String .map(bindingMethod -> createValue(bindingMethod, xsdNamespace.toIri("string"))) .collect(toJsonArray()); - return Json.createObjectBuilder() + var builder = Json.createObjectBuilder() .add(ID, credentialObject.getId()) .add(TYPE, forNamespace(CREDENTIAL_OBJECT_TERM)) .add(forNamespace(CREDENTIAL_OBJECT_OFFER_REASON_TERM), createValue(credentialObject.getOfferReason(), xsdNamespace.toIri("string"))) .add(forNamespace(CREDENTIAL_OBJECT_PROFILE_TERM), p) .add(forNamespace(CREDENTIAL_OBJECT_BINDING_METHODS_TERM), bindingMethods) .add(forNamespace(CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM), credentialObject.getCredentialType()) - .add(forNamespace(CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM), issuancePolicyJson) - .build(); + .add(forNamespace(CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM), issuancePolicyJson); + + if (credentialObject.getCredentialSchema() != null) { + builder.add(forNamespace(CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM), credentialObject.getCredentialSchema()); + } + return builder.build(); } diff --git a/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformer.java b/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformer.java index ffe1fcfe5..c679f767a 100644 --- a/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformer.java +++ b/protocols/dcp/dcp-transform-lib/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformer.java @@ -33,6 +33,7 @@ import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.Builder; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_BINDING_METHODS_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_OFFER_REASON_TERM; @@ -69,6 +70,10 @@ public JsonObjectToCredentialObjectTransformer(TypeManager typeManager, String t .map(credentialType -> transformString(credentialType, transformerContext)) .ifPresent(credentialObject::credentialType); + Optional.ofNullable(jsonObject.get(forNamespace(CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM))) + .map(credentialSchema -> transformString(credentialSchema, transformerContext)) + .ifPresent(credentialObject::credentialSchema); + Optional.ofNullable(jsonObject.get(forNamespace(CREDENTIAL_OBJECT_PROFILE_TERM))) .ifPresent(credentialType -> transformArrayOrObject(credentialType, Object.class, (obj) -> credentialObject.profile(obj.toString()), transformerContext)); diff --git a/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformerTest.java b/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformerTest.java index d02c486d8..d3b6b6496 100644 --- a/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformerTest.java +++ b/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/from/JsonObjectFromCredentialObjectTransformerTest.java @@ -32,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.edc.iam.decentralizedclaims.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_BINDING_METHODS_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_OFFER_REASON_TERM; @@ -62,6 +63,7 @@ void transform() { .profile("profile1") .bindingMethods(List.of("binding1")) .credentialType("MembershipCredential") + .credentialSchema("https://example.org/schemas/membership.json") .issuancePolicy(PresentationDefinition.Builder.newInstance().id("id").build()) .offerReason("myReason") .build(); @@ -73,6 +75,7 @@ void transform() { assertThat(jsonLd.getJsonObject(toIri(CREDENTIAL_OBJECT_PROFILE_TERM)).getString("@value")).isEqualTo("profile1"); assertThat(jsonLd.getJsonArray(toIri(CREDENTIAL_OBJECT_BINDING_METHODS_TERM))).contains(stringValue("binding1")); assertThat(jsonLd.getString(toIri(CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM))).isEqualTo("MembershipCredential"); + assertThat(jsonLd.getString(toIri(CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM))).isEqualTo("https://example.org/schemas/membership.json"); assertThat(jsonLd.getJsonObject(toIri(CREDENTIAL_OBJECT_OFFER_REASON_TERM))).isEqualTo(stringValue("myReason")); assertThat(jsonLd.getJsonArray(toIri(CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM))).first() .satisfies(jsonValue -> { diff --git a/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformerTest.java b/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformerTest.java index e743a744c..f97fd7cea 100644 --- a/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformerTest.java +++ b/protocols/dcp/dcp-transform-lib/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/transform/to/JsonObjectToCredentialObjectTransformerTest.java @@ -33,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.edc.iam.decentralizedclaims.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_BINDING_METHODS_TERM; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM; import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.CREDENTIAL_OBJECT_OFFER_REASON_TERM; @@ -73,6 +74,7 @@ void transform() { .add(toIri(CREDENTIAL_OBJECT_PROFILE_TERM), Json.createArrayBuilder(List.of("profile"))) .add(toIri(CREDENTIAL_OBJECT_OFFER_REASON_TERM), "offerReason") .add(toIri(CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM), "MembershipCredential") + .add(toIri(CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM), "https://example.org/schemas/membership.json") .add(toIri(CREDENTIAL_OBJECT_BINDING_METHODS_TERM), Json.createArrayBuilder(List.of("binding"))) .build(); @@ -82,6 +84,7 @@ void transform() { assertThat(credentialObject.getId()).isNotNull(); assertThat(credentialObject.getOfferReason()).isEqualTo("offerReason"); assertThat(credentialObject.getCredentialType()).isEqualTo("MembershipCredential"); + assertThat(credentialObject.getCredentialSchema()).isEqualTo("https://example.org/schemas/membership.json"); assertThat(credentialObject.getProfile()).isEqualTo("profile"); assertThat(credentialObject.getBindingMethods()).hasSize(1).contains("binding"); assertThat(credentialObject.getIssuancePolicy()).isNotNull() From b4bd9b5e617fc399139c79fe3e1368063ccbdaad Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:43:08 +0200 Subject: [PATCH 5/9] feat: let the caller state why a credential is being offered (IS-OFF-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../IssuerCredentialOfferServiceImpl.java | 25 +++++++++- .../IssuerCredentialOfferServiceImplTest.java | 47 +++++++++++++++---- .../IssuerCredentialsAdminApiController.java | 2 +- .../v1/unstable/model/CredentialOfferDto.java | 9 +++- ...suerCredentialsAdminApiControllerTest.java | 16 +++---- .../issuer/DcpIssuerMetadataServiceImpl.java | 4 +- .../dcp/spi/model/CredentialObject.java | 8 ++++ .../IssuerCredentialOfferService.java | 5 +- 8 files changed, 94 insertions(+), 22 deletions(-) diff --git a/core/issuerservice/issuerservice-credentials/src/main/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImpl.java b/core/issuerservice/issuerservice-credentials/src/main/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImpl.java index 9339d681f..c267a9395 100644 --- a/core/issuerservice/issuerservice-credentials/src/main/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImpl.java +++ b/core/issuerservice/issuerservice-credentials/src/main/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImpl.java @@ -36,6 +36,7 @@ import org.eclipse.edc.spi.result.ServiceResult; import org.eclipse.edc.transaction.spi.TransactionContext; import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.time.Instant; @@ -46,6 +47,7 @@ import static java.util.stream.Collectors.toSet; import static org.eclipse.edc.identityhub.protocols.dcp.spi.DcpConstants.DCP_SCOPE_V_1_0; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.OFFER_REASON_REISSUE; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.EXPIRATION_TIME; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUED_AT; @@ -86,7 +88,7 @@ public IssuerCredentialOfferServiceImpl(TransactionContext transactionContext, } @Override - public ServiceResult sendCredentialOffer(String participantContextId, String holderId, Collection credentialObjectIds) { + public ServiceResult sendCredentialOffer(String participantContextId, String holderId, Collection credentialObjectIds, @Nullable String offerReason) { return transactionContext.execute(() -> { var holder = holderStore.findById(holderId); if (holder.failed()) { @@ -99,6 +101,7 @@ public ServiceResult sendCredentialOffer(String participantContextId, Stri var requestResult = // get credential objects based on IDs getCredentialObjects(participantContext, credentialObjectIds) + .map(offered -> withOfferReason(offered, offerReason)) .compose(offeredCredentials -> credentialServiceUrlResolver.resolve(holderDid) .compose(url -> getAuthToken(participantContextId, holderDid, participantContext.getDid()) //compose CredentialOfferMessage @@ -111,6 +114,26 @@ public ServiceResult sendCredentialOffer(String participantContextId, Stri }); } + /** + * Restates the offered credentials with the reason this particular offer is being made. The objects come from the + * Issuer Metadata API, whose {@code offerReason} is a placeholder: metadata advertises what can be requested rather + * than making an offer, so the reason only becomes meaningful here. + */ + private Collection withOfferReason(Collection credentialObjects, @Nullable String offerReason) { + var reason = offerReason == null || offerReason.isBlank() ? OFFER_REASON_REISSUE : offerReason; + return credentialObjects.stream() + .map(co -> CredentialObject.Builder.newInstance() + .id(co.getId()) + .credentialType(co.getCredentialType()) + .credentialSchema(co.getCredentialSchema()) + .bindingMethods(co.getBindingMethods()) + .profile(co.getProfile()) + .issuancePolicy(co.getIssuancePolicy()) + .offerReason(reason) + .build()) + .toList(); + } + /** * Retrieves a list of {@link CredentialObject}s based on the provided IDs from the issuer's metadata. These * credential objects are the same ones that would be published in the IssuerMetadata API. diff --git a/core/issuerservice/issuerservice-credentials/src/test/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImplTest.java b/core/issuerservice/issuerservice-credentials/src/test/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImplTest.java index 7d46eae0e..8c36f97ee 100644 --- a/core/issuerservice/issuerservice-credentials/src/test/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImplTest.java +++ b/core/issuerservice/issuerservice-credentials/src/test/java/org/eclipse/edc/issuerservice/credentials/offers/IssuerCredentialOfferServiceImplTest.java @@ -17,10 +17,12 @@ import jakarta.json.Json; import jakarta.json.JsonObject; import okhttp3.Response; +import org.assertj.core.api.Assertions; import org.eclipse.edc.http.spi.EdcHttpClient; import org.eclipse.edc.iam.decentralizedclaims.spi.CredentialServiceUrlResolver; import org.eclipse.edc.identityhub.protocols.dcp.issuer.spi.DcpIssuerMetadataService; import org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject; +import org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialOfferMessage; import org.eclipse.edc.identityhub.protocols.dcp.spi.model.IssuerMetadata; import org.eclipse.edc.identityhub.spi.authentication.ParticipantSecureTokenService; import org.eclipse.edc.identityhub.spi.participantcontext.IdentityHubParticipantContextService; @@ -36,12 +38,16 @@ import org.eclipse.edc.transaction.spi.NoopTransactionContext; import org.eclipse.edc.transform.spi.TypeTransformerRegistry; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.List; import java.util.function.Function; import static org.eclipse.edc.identityhub.protocols.dcp.spi.DcpConstants.DCP_SCOPE_V_1_0; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.OFFER_REASON_PROOF_KEY_REVOCATION; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.OFFER_REASON_REISSUE; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.eclipse.edc.spi.result.Result.success; import static org.mockito.ArgumentMatchers.any; @@ -103,9 +109,34 @@ void setUp() { .build())); } + // IS-OFF-03: the offer carries the reason the caller gave, not the placeholder the Issuer Metadata API publishes + @Test + @DisplayName("B6.4: the offered CredentialObjects carry the requested offer reason") + void sendCredentialOffer_usesRequestedOfferReason() { + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), OFFER_REASON_PROOF_KEY_REVOCATION); + + assertThat(result).isSucceeded(); + var messageCaptor = ArgumentCaptor.forClass(CredentialOfferMessage.class); + verify(typeTransformerRegistry).transform(messageCaptor.capture(), eq(JsonObject.class)); + Assertions.assertThat(messageCaptor.getValue().getCredentials()) + .allSatisfy(co -> Assertions.assertThat(co.getOfferReason()).isEqualTo(OFFER_REASON_PROOF_KEY_REVOCATION)); + } + + @Test + @DisplayName("B6.4: an offer without a stated reason defaults to reissue") + void sendCredentialOffer_withoutReason_defaultsToReissue() { + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); + + assertThat(result).isSucceeded(); + var messageCaptor = ArgumentCaptor.forClass(CredentialOfferMessage.class); + verify(typeTransformerRegistry).transform(messageCaptor.capture(), eq(JsonObject.class)); + Assertions.assertThat(messageCaptor.getValue().getCredentials()) + .allSatisfy(co -> Assertions.assertThat(co.getOfferReason()).isEqualTo(OFFER_REASON_REISSUE)); + } + @Test void sendCredentialOffer_success() { - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isSucceeded(); verify(holderStore).findById(eq(HOLDER_ID)); @@ -119,7 +150,7 @@ void sendCredentialOffer_success() { @Test void sendCredentialOffer_credentialObjectNotExists() { - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of("not-exist")); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of("not-exist"), null); assertThat(result).isFailed(); verify(holderStore).findById(eq(HOLDER_ID)); @@ -130,7 +161,7 @@ void sendCredentialOffer_credentialObjectNotExists() { @Test void sendCredentialOffer_holderNotExist() { when(holderStore.findById(HOLDER_ID)).thenReturn(StoreResult.notFound("foobar")); - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isFailed().detail().contains("foobar"); verify(holderStore).findById(eq(HOLDER_ID)); @@ -141,7 +172,7 @@ void sendCredentialOffer_holderNotExist() { @Test void sendCredentialOffer_offerRequestFailure() { when(httpClient.execute(any(), (Function>) any())).thenReturn(Result.failure("not reachable")); - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isFailed().detail().contains("not reachable"); verify(holderStore).findById(eq(HOLDER_ID)); @@ -155,7 +186,7 @@ void sendCredentialOffer_offerRequestFailure() { @Test void sendCredentialOffer_participantContextNotExist() { when(participantContextService.getParticipantContext(eq(PARTICIPANT_CONTEXT_ID))).thenReturn(ServiceResult.notFound("not found")); - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isFailed().detail().contains("not found"); verify(holderStore).findById(eq(HOLDER_ID)); @@ -166,7 +197,7 @@ void sendCredentialOffer_participantContextNotExist() { @Test void sendCredentialOffer_holderDidNotResolvable() { when(credentialServiceUrlResolver.resolve(any())).thenReturn(Result.failure("not resolvable")); - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isFailed().detail().contains("not resolvable"); verify(holderStore).findById(eq(HOLDER_ID)); @@ -178,7 +209,7 @@ void sendCredentialOffer_holderDidNotResolvable() { @Test void sendCredentialOffer_stsFails() { when(sts.createToken(anyString(), anyMap(), isNull())).thenReturn(Result.failure("random STS failure")); - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isFailed().detail().contains("random STS failure"); verify(holderStore).findById(eq(HOLDER_ID)); @@ -196,7 +227,7 @@ void sendCredentialOffer_webRequestFails() { void sendCredentialOffer_transformationFails() { when(typeTransformerRegistry.transform(any(), eq(JsonObject.class))).thenReturn(Result.failure("transformation failure")); - var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD)); + var result = credentialOfferService.sendCredentialOffer(PARTICIPANT_CONTEXT_ID, HOLDER_ID, List.of(CREDENTIAL_OBJECT_UD), null); assertThat(result).isFailed().detail().contains("transformation failure"); verify(holderStore).findById(eq(HOLDER_ID)); diff --git a/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiController.java b/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiController.java index 484ddb30c..8f117dc1c 100644 --- a/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiController.java +++ b/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiController.java @@ -123,7 +123,7 @@ public void sendCredentialOffer(@PathParam("participantContextId") String partic var result = authorizationService.authorize(context, decodedParticipantContextId, holderId, Holder.class); result.orElseThrow(exceptionMapper(Holder.class, holderId)); - credentialOfferService.sendCredentialOffer(decodedParticipantContextId, holderId, credentialOffer.credentials()) + credentialOfferService.sendCredentialOffer(decodedParticipantContextId, holderId, credentialOffer.credentials(), credentialOffer.offerReason()) .orElseThrow(InvalidRequestException::new); } diff --git a/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/model/CredentialOfferDto.java b/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/model/CredentialOfferDto.java index 760f81956..3f25bdb5b 100644 --- a/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/model/CredentialOfferDto.java +++ b/extensions/api/issuer-admin-api/credentials-api/src/main/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/model/CredentialOfferDto.java @@ -18,6 +18,13 @@ import java.util.Collection; +/** + * The credentials an Issuer offers to a Holder. + * + * @param offerReason why the credential is offered, e.g. {@code reissue} or {@code proof-key-revocation}. Optional; + * defaults to {@code reissue}. + */ public record CredentialOfferDto(@JsonProperty(required = true) String holderId, - @JsonProperty(required = true) Collection credentials) { + @JsonProperty(required = true) Collection credentials, + String offerReason) { } diff --git a/extensions/api/issuer-admin-api/credentials-api/src/test/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiControllerTest.java b/extensions/api/issuer-admin-api/credentials-api/src/test/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiControllerTest.java index 34a36d168..0c1b396c3 100644 --- a/extensions/api/issuer-admin-api/credentials-api/src/test/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiControllerTest.java +++ b/extensions/api/issuer-admin-api/credentials-api/src/test/java/org/eclipse/edc/issuerservice/api/admin/credentials/v1/unstable/IssuerCredentialsAdminApiControllerTest.java @@ -68,7 +68,7 @@ class IssuerCredentialsAdminApiControllerTest extends RestControllerTestBase { @BeforeEach void setUp() { when(authorizationService.authorize(any(), anyString(), anyString(), any())).thenReturn(ServiceResult.success()); - when(credentialOfferService.sendCredentialOffer(anyString(), anyString(), anyCollection())).thenReturn(ServiceResult.success()); + when(credentialOfferService.sendCredentialOffer(anyString(), anyString(), anyCollection(), any())).thenReturn(ServiceResult.success()); } @Test @@ -184,13 +184,13 @@ void checkRevocationStatus_whenNotFound() { @Test void sendCredentialOffer() { baseRequest() - .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID))) + .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID), null)) .post("/offer") .then() .log().ifValidationFails() .statusCode(204); - verify(credentialOfferService).sendCredentialOffer(anyString(), eq("holder"), anyCollection()); + verify(credentialOfferService).sendCredentialOffer(anyString(), eq("holder"), anyCollection(), any()); } @Test @@ -198,7 +198,7 @@ void sendCredentialOffer_whenHolderNotFound() { when(authorizationService.authorize(any(), anyString(), anyString(), any())) .thenReturn(ServiceResult.notFound("holder")); baseRequest() - .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID))) + .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID), null)) .post("/offer") .then() .log().ifValidationFails() @@ -214,7 +214,7 @@ void sendCredentialOffer_whenNotAuthorized() { when(authorizationService.authorize(any(), anyString(), anyString(), any())) .thenReturn(ServiceResult.unauthorized("barbaz")); baseRequest() - .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID))) + .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID), null)) .post("/offer") .then() .log().ifValidationFails() @@ -227,17 +227,17 @@ void sendCredentialOffer_whenNotAuthorized() { @Test void sendCredentialOffer_whenServiceFails() { - when(credentialOfferService.sendCredentialOffer(anyString(), anyString(), anyCollection())) + when(credentialOfferService.sendCredentialOffer(anyString(), anyString(), anyCollection(), any())) .thenReturn(ServiceResult.notFound("foo")); baseRequest() - .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID))) + .body(new CredentialOfferDto("holder", List.of(CREDENTIAL_OBJECT_ID), null)) .post("/offer") .then() .log().ifValidationFails() .statusCode(400) .body(containsString("foo")); - verify(credentialOfferService).sendCredentialOffer(anyString(), eq("holder"), anyCollection()); + verify(credentialOfferService).sendCredentialOffer(anyString(), eq("holder"), anyCollection(), any()); } @Override diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java index e424753f0..2276c1b1d 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java +++ b/protocols/dcp/dcp-issuer/dcp-issuer-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpIssuerMetadataServiceImpl.java @@ -28,13 +28,13 @@ import java.util.Collection; import java.util.List; +import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialObject.OFFER_REASON_REISSUE; import static org.eclipse.edc.participantcontext.spi.types.ParticipantResource.queryByParticipantContextId; public class DcpIssuerMetadataServiceImpl implements DcpIssuerMetadataService { private static final String BINDING_METHOD_DID_WEB = "did:web"; - private static final String DEFAULT_OFFER_REASON = "reissue"; private final CredentialDefinitionService credentialDefinitionService; @@ -75,7 +75,7 @@ public ServiceResult> toCredentialObject(CredentialDefini // §6.7 requires every CredentialObject in credentialsSupported to carry all OPTIONAL properties, // offerReason among them, even though metadata advertises what can be requested rather than // making an offer. Callers of the Credential Offer API supply the reason that actually applies. - .offerReason(DEFAULT_OFFER_REASON) + .offerReason(OFFER_REASON_REISSUE) .profile(profile) // clients cache CredentialObjects by id, so the policy must not change between two fetches. It // is derived from the definition instead of freshly generated. diff --git a/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java b/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java index 0a26d02e1..83c571a8b 100644 --- a/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java +++ b/protocols/dcp/dcp-spi/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/spi/model/CredentialObject.java @@ -27,6 +27,14 @@ public class CredentialObject { public static final String CREDENTIAL_OBJECT_CREDENTIAL_TYPE_TERM = "credentialType"; public static final String CREDENTIAL_OBJECT_CREDENTIAL_SCHEMA_TERM = "credentialSchema"; public static final String CREDENTIAL_OBJECT_OFFER_REASON_TERM = "offerReason"; + /** + * The credential is offered again before the one the Holder has expires. + */ + public static final String OFFER_REASON_REISSUE = "reissue"; + /** + * The credential is offered again because the key its proof was made with is no longer valid. + */ + public static final String OFFER_REASON_PROOF_KEY_REVOCATION = "proof-key-revocation"; public static final String CREDENTIAL_OBJECT_PROFILE_TERM = "profile"; public static final String CREDENTIAL_OBJECT_BINDING_METHODS_TERM = "bindingMethods"; public static final String CREDENTIAL_OBJECT_ISSUANCE_POLICY_TERM = "issuancePolicy"; diff --git a/spi/issuerservice/issuerservice-credential-spi/src/main/java/org/eclipse/edc/issuerservice/spi/credentials/IssuerCredentialOfferService.java b/spi/issuerservice/issuerservice-credential-spi/src/main/java/org/eclipse/edc/issuerservice/spi/credentials/IssuerCredentialOfferService.java index 3e75cf400..05123cb52 100644 --- a/spi/issuerservice/issuerservice-credential-spi/src/main/java/org/eclipse/edc/issuerservice/spi/credentials/IssuerCredentialOfferService.java +++ b/spi/issuerservice/issuerservice-credential-spi/src/main/java/org/eclipse/edc/issuerservice/spi/credentials/IssuerCredentialOfferService.java @@ -15,6 +15,7 @@ package org.eclipse.edc.issuerservice.spi.credentials; import org.eclipse.edc.spi.result.ServiceResult; +import org.jetbrains.annotations.Nullable; import java.util.Collection; @@ -30,7 +31,9 @@ public interface IssuerCredentialOfferService { * @param participantContextId the ID of the current issuer participant context * @param holderId the ID of the holder. * @param credentialObjectIds a list of IDs of the {@code CredentialObject} objects that should be offered to the holder. + * @param offerReason why the credential is being offered, e.g. {@code reissue} or {@code proof-key-revocation}. + * Defaults to {@code reissue} when {@code null}. * @return a result to indicate whether the {@code CredentialOfferMessage} was sent successfully. */ - ServiceResult sendCredentialOffer(String participantContextId, String holderId, Collection credentialObjectIds); + ServiceResult sendCredentialOffer(String participantContextId, String holderId, Collection credentialObjectIds, @Nullable String offerReason); } From c354a70b39cea4c68e2aa8056d920abcc2fd30f4 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:50:40 +0200 Subject: [PATCH 6/9] feat: declare presentation signing keys for authentication (CS-PRES-12, CS-PRES-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../CredentialQueryResolverImplTest.java | 28 +++++++++++++++++++ .../did/DidDocumentServiceImpl.java | 11 +++++++- .../did/DidDocumentServiceImplTest.java | 6 ++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java index e6136a60d..a4ceb0172 100644 --- a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java +++ b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java @@ -356,6 +356,34 @@ void query_whenRevokedCredential_doesNotInclude() { verify(monitor).warning(eq("Credential '%s' not valid: revoked".formatted(credential.getId()))); } + // CS-PRES-12: a mixed set of one type - valid, expired and revoked - must present only the valid credential + @Test + @DisplayName("CS-PRES-12: only the valid credential of a valid/expired/revoked set is presented") + void query_mixedValidity_returnsOnlyValid() { + var valid = createCredential("TestCredential").expirationDate(Instant.now().plus(1, ChronoUnit.DAYS)).build(); + var expired = createCredential("TestCredential").expirationDate(Instant.now().minus(1, ChronoUnit.DAYS)).build(); + var revoked = createCredential("TestCredential") + .credentialStatus(new CredentialStatus("status-id", "StatusList2021Entry", + Map.of("statusListCredential", "https://university.example/credentials/status/3", + "statusPurpose", "revocation", + "statusListIndex", 42))) + .build(); + // only the revoked one carries a status list entry, so only it is checked against the registry + when(revocationServiceRegistry.checkValidity(revoked)).thenReturn(Result.failure("revoked")); + // built once: the resolver queries the store twice, and the two result sets are correlated by resource id + var resources = List.of(createCredentialResource(valid).build(), + createCredentialResource(expired).build(), + createCredentialResource(revoked).build()); + when(storeMock.query(any())).thenAnswer(i -> success(resources)); + + var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID, + createPresentationQuery("org.eclipse.dspace.dcp.vc.type:TestCredential:read"), + List.of("org.eclipse.dspace.dcp.vc.type:TestCredential:read")); + + assertThat(res).isSucceeded(); + assertThat(res.getContent().map(c -> c.credential().getId())).containsExactly(valid.getId()); + } + // CS-PRES-11: a query for the vc.id alias returns exactly the credential with that id @Test @DisplayName("CS-PRES-11: a vc.id scope resolves to exactly that credential") diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java index 81f1f5b0a..24c5129e5 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java @@ -309,6 +309,11 @@ private void keyPairActivated(KeyPairActivated event) { .controller(dd.getDocument().getId()) .type(event.getKeyType()) .build()); + // the key signs this participant's Verifiable Presentations, and a verifier only accepts a VP + // whose signing key the DID document declares for authentication (DCP §5.4.3) + if (!dd.getDocument().getAuthentication().contains(event.getKeyId())) { + dd.getDocument().getAuthentication().add(event.getKeyId()); + } return ServiceResult.from(didResourceStore.update(dd)) .compose(v -> publish(dd.getDid())); }) @@ -336,7 +341,11 @@ private void keypairRevoked(KeyPairRevoked event) { var keyId = event.getKeyId(); var errors = didResources.stream() - .peek(didResource -> didResource.getDocument().getVerificationMethod().removeIf(vm -> vm.getId().equals(keyId))) + .peek(didResource -> { + didResource.getDocument().getVerificationMethod().removeIf(vm -> vm.getId().equals(keyId)); + // a revoked key must no longer be offered for authentication either + didResource.getDocument().getAuthentication().removeIf(keyId::equals); + }) .map(didResourceStore::update) .filter(StoreResult::failed) .map(AbstractResult::getFailureDetail) diff --git a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java index 1473eaca9..c570b5df3 100644 --- a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java +++ b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java @@ -20,6 +20,7 @@ import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import org.assertj.core.api.Assertions; import org.eclipse.edc.iam.did.spi.document.DidDocument; import org.eclipse.edc.iam.did.spi.document.Service; import org.eclipse.edc.iam.did.spi.document.VerificationMethod; @@ -576,6 +577,8 @@ void onKeyPairActivated() throws JOSEException { verify(didResourceStoreMock).findById(did); // happens during the publishing verifyNoMoreInteractions(didResourceStoreMock); verify(publisherMock).publish(eq(did)); + // CS-PRES-13: a verifier only accepts a VP whose signing key the DID document declares for authentication + Assertions.assertThat(doc.getAuthentication()).containsExactly(keyId); } @SuppressWarnings("unchecked") @@ -697,6 +700,7 @@ void onKeyPairRevoked() throws JOSEException { .id(keyId) .publicKeyJwk(new ECKeyGenerator(Curve.P_256).keyID(keyId).generate().toJSONObject()) .build())) + .authentication(List.of(keyId)) .build(); var did = doc.getId(); var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); @@ -721,6 +725,8 @@ void onKeyPairRevoked() throws JOSEException { verify(didResourceStoreMock).update(argThat(dr -> dr.getDocument().getVerificationMethod().stream().noneMatch(vm -> vm.getId().equals(keyId)))); verifyNoMoreInteractions(didResourceStoreMock); verifyNoInteractions(publisherMock); + // a revoked key must not stay behind as an authentication method either + Assertions.assertThat(doc.getAuthentication()).doesNotContain(keyId); } @SuppressWarnings("unchecked") From 0bf553b4ec4ccfa2c908f40ed2942437d556384a Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Fri, 28 Aug 2026 12:58:38 +0200 Subject: [PATCH 7/9] fix: bind the signing key to the sender's identity on inbound DCP messages (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 Claude-Session: https://claude.ai/code/session_01QXL1LjCxfNnVtKkQFShci9 --- .../tests/StorageApiEndToEndTest.java | 1 - .../dcp/issuer/DcpHolderCoreExtension.java | 24 +++ .../issuer/DcpHolderCoreExtensionTest.java | 153 ++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtensionTest.java diff --git a/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java b/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java index 4fbd5488a..be9c6df33 100644 --- a/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java +++ b/e2e-tests/identity-api-tests/src/test/java/org/eclipse/edc/identityhub/tests/StorageApiEndToEndTest.java @@ -71,7 +71,6 @@ import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.STATUS_TERM; import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.CREATED; import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.ERROR; -import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.ISSUED; import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.REQUESTED; import static org.eclipse.edc.identityhub.tests.fixtures.TestData.IH_RUNTIME_NAME; import static org.eclipse.edc.identityhub.tests.fixtures.TestData.JWT_VC_EXAMPLE; diff --git a/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtension.java b/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtension.java index 54afa5ea1..97acbcec7 100644 --- a/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtension.java +++ b/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/main/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtension.java @@ -14,6 +14,7 @@ package org.eclipse.edc.identityhub.protocols.dcp.issuer; +import com.nimbusds.jwt.SignedJWT; import org.eclipse.edc.iam.did.spi.resolution.DidPublicKeyResolver; import org.eclipse.edc.identityhub.protocols.dcp.spi.DcpIssuerTokenVerifier; import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; @@ -22,6 +23,7 @@ import org.eclipse.edc.runtime.metamodel.annotation.Provider; import org.eclipse.edc.runtime.metamodel.annotation.Setting; import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.token.rules.AudienceValidationRule; @@ -31,10 +33,13 @@ import org.eclipse.edc.token.spi.TokenValidationRule; import org.eclipse.edc.token.spi.TokenValidationService; import org.eclipse.edc.verifiablecredentials.jwt.rules.IssuerEqualsSubjectRule; +import org.eclipse.edc.verifiablecredentials.jwt.rules.IssuerKeyIdValidationRule; +import java.text.ParseException; import java.time.Clock; import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Extension("DCP Holder Core Extension") public class DcpHolderCoreExtension implements ServiceExtension { @@ -73,9 +78,28 @@ public void initialize(ServiceExtensionContext context) { @Provider public DcpIssuerTokenVerifier createTokenVerifier() { return (participantContext, tokenRepresentation) -> { + // the signing key is resolved from the DID named in the 'kid' header, while the sender identifies itself + // with the 'iss' claim. Unless the two are tied together, anyone holding a resolvable DID could sign a + // message that claims to come from somebody else. + var kid = getKid(tokenRepresentation.getToken()); + if (kid.failed()) { + return kid.mapFailure(); + } + var newRules = new ArrayList<>(rules); newRules.add(new AudienceValidationRule(participantContext.getDid())); + newRules.add(new IssuerKeyIdValidationRule(kid.getContent())); return tokenValidationService.validate(tokenRepresentation.getToken(), didPublicKeyResolver, newRules); }; } + + private Result getKid(String token) { + try { + return Optional.ofNullable(SignedJWT.parse(token).getHeader().getKeyID()) + .map(Result::success) + .orElseGet(() -> Result.failure("Kid not present")); + } catch (ParseException e) { + return Result.failure("Failed to decode token"); + } + } } diff --git a/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtensionTest.java b/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtensionTest.java new file mode 100644 index 000000000..df3f7829c --- /dev/null +++ b/protocols/dcp/dcp-identityhub/dcp-identityhub-core/src/test/java/org/eclipse/edc/identityhub/protocols/dcp/issuer/DcpHolderCoreExtensionTest.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.protocols.dcp.issuer; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.boot.system.injection.ObjectFactory; +import org.eclipse.edc.identityhub.spi.participantcontext.model.IdentityHubParticipantContext; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.iam.ClaimToken; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.token.spi.TokenValidationRule; +import org.eclipse.edc.token.spi.TokenValidationService; +import org.eclipse.edc.verifiablecredentials.jwt.rules.IssuerKeyIdValidationRule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; + +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(DependencyInjectionExtension.class) +class DcpHolderCoreExtensionTest { + + private static final String HOLDER_DID = "did:web:holder"; + private static final String ISSUER_DID = "did:web:issuer"; + + private final TokenValidationService tokenValidationService = mock(); + + private final IdentityHubParticipantContext participantContext = IdentityHubParticipantContext.Builder.newInstance() + .participantContextId("holder-context") + .did(HOLDER_DID) + .apiTokenAlias("apiAlias") + .build(); + + @BeforeEach + void setUp(ServiceExtensionContext context) { + context.registerService(TokenValidationService.class, tokenValidationService); + } + + // TOK-09/TOK-11: the signing key is resolved from the DID named in the 'kid' header, so a token whose 'kid' points at + // a different DID than the 'iss' claim must not authenticate as that issuer + @Test + @DisplayName("A3.26: a token whose 'kid' names a different DID than 'iss' is rejected") + void createTokenVerifier_kidFromForeignDid_isRejected(ServiceExtensionContext context, ObjectFactory factory) throws JOSEException { + var extension = factory.constructInstance(DcpHolderCoreExtension.class); + extension.initialize(context); + when(tokenValidationService.validate(anyString(), any(), anyList())) + .thenAnswer(invocation -> Result.success(ClaimToken.Builder.newInstance() + .claim("iss", ISSUER_DID) + .build())); + + // signed with an attacker's key, but claiming to come from the issuer + var token = signedToken(ISSUER_DID, "did:web:attacker#key1"); + extension.createTokenVerifier().verify(participantContext, TokenRepresentation.Builder.newInstance().token(token).build()); + + var rulesCaptor = ArgumentCaptor.forClass(List.class); + verify(tokenValidationService).validate(anyString(), any(), rulesCaptor.capture()); + @SuppressWarnings("unchecked") + var rules = (List) rulesCaptor.getValue(); + var claims = ClaimToken.Builder.newInstance().claim("iss", ISSUER_DID).build(); + assertThat(keyBindingRule(rules).checkRule(claims, null).failed()) + .as("the key binding rule must reject a 'kid' that does not belong to the 'iss' DID") + .isTrue(); + } + + @Test + @DisplayName("A3.26: a token whose 'kid' belongs to the 'iss' DID passes the key binding rule") + void createTokenVerifier_kidFromIssuerDid_passesBinding(ServiceExtensionContext context, ObjectFactory factory) throws JOSEException { + var extension = factory.constructInstance(DcpHolderCoreExtension.class); + extension.initialize(context); + when(tokenValidationService.validate(anyString(), any(), anyList())) + .thenAnswer(invocation -> Result.success(ClaimToken.Builder.newInstance().build())); + + var token = signedToken(ISSUER_DID, ISSUER_DID + "#key1"); + extension.createTokenVerifier().verify(participantContext, TokenRepresentation.Builder.newInstance().token(token).build()); + + var rulesCaptor = ArgumentCaptor.forClass(List.class); + verify(tokenValidationService).validate(anyString(), any(), rulesCaptor.capture()); + @SuppressWarnings("unchecked") + var rules = (List) rulesCaptor.getValue(); + var claims = ClaimToken.Builder.newInstance().claim("iss", ISSUER_DID).build(); + assertThat(keyBindingRule(rules).checkRule(claims, null).succeeded()).isTrue(); + } + + // a token without a 'kid' cannot be bound to its issuer at all, so it never reaches validation + @Test + @DisplayName("A3.26: a token without a 'kid' header is rejected before validation") + void createTokenVerifier_withoutKid_isRejected(ServiceExtensionContext context, ObjectFactory factory) throws JOSEException { + var extension = factory.constructInstance(DcpHolderCoreExtension.class); + extension.initialize(context); + + var token = signedToken(ISSUER_DID, null); + var result = extension.createTokenVerifier().verify(participantContext, TokenRepresentation.Builder.newInstance().token(token).build()); + + assertThat(result.failed()).isTrue(); + verifyNoInteractions(tokenValidationService); + } + + private TokenValidationRule keyBindingRule(List rules) { + return rules.stream() + .filter(IssuerKeyIdValidationRule.class::isInstance) + .findFirst() + .orElseThrow(() -> new AssertionError("no key binding rule was applied")); + } + + private String signedToken(String issuer, String kid) throws JOSEException { + var key = new ECKeyGenerator(Curve.P_256).keyID("key1").generate(); + var headerBuilder = new JWSHeader.Builder(JWSAlgorithm.ES256); + if (kid != null) { + headerBuilder.keyID(kid); + } + var jwt = new SignedJWT(headerBuilder.build(), new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(issuer) + .audience(HOLDER_DID) + .expirationTime(new Date(System.currentTimeMillis() + 60_000)) + .build()); + jwt.sign(new ECDSASigner(key.toECPrivateKey())); + return jwt.serialize(); + } +} From acae9964e44d565c6abca58ed9daaf4856a4bd34 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Sat, 29 Aug 2026 07:34:56 +0200 Subject: [PATCH 8/9] update version file --- .../dcp-issuer-api/src/main/resources/issuer-api-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json b/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json index 2f279ab45..c7f892a67 100644 --- a/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json +++ b/protocols/dcp/dcp-issuer/dcp-issuer-api/src/main/resources/issuer-api-version.json @@ -2,7 +2,7 @@ { "version": "1.0.0", "urlPath": "/v1beta", - "lastUpdated": "2026-05-28T11:00:00Z", + "lastUpdated": "2026-08-28T11:00:00Z", "maturity": null } ] From 0909cfa43ba7739a64bb83c630d53d1b4eecb16c Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Sat, 29 Aug 2026 07:40:48 +0200 Subject: [PATCH 9/9] update version file --- .../src/main/resources/issuer-admin-api-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json b/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json index d07045839..1dd02cdcc 100644 --- a/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json +++ b/extensions/api/issuer-admin-api/issuer-admin-api-configuration/src/main/resources/issuer-admin-api-version.json @@ -2,7 +2,7 @@ { "version": "1.0.0-beta", "urlPath": "/v1beta", - "lastUpdated": "2026-07-21T13:00:00Z", + "lastUpdated": "2026-08-28T13:00:00Z", "maturity": null } ]