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/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/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/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/query/CredentialQueryResolverImplTest.java b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImplTest.java index 9e77d6288..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 @@ -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,85 @@ 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") + 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) { 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/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") 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/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/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..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 @@ -67,9 +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.ISSUED; +import static org.eclipse.edc.identityhub.spi.credential.request.model.HolderRequestState.ERROR; 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; @@ -309,15 +310,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 +340,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/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/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 } ] 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(); + } +} 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-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-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 } ] 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/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..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 @@ -27,13 +27,15 @@ import java.util.Collection; import java.util.List; -import java.util.UUID; +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 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(OFFER_REASON_REISSUE) .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/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/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/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-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..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 @@ -25,13 +25,23 @@ 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"; + /** + * 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"; 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 +54,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 +98,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/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/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() 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); } 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); } 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); }