From 12176d36002606ad6a92146415d2b69b5fc2ce30 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Mon, 3 Feb 2025 21:27:05 +0100 Subject: [PATCH] feat: implement bitstring revocation service --- .../store/InMemoryCredentialStore.java | 10 + .../build.gradle.kts | 16 + ...atusListCredentialFactoryRegistryImpl.java | 35 ++ .../StatusListServiceExtension.java | 68 +++ .../statuslist/StatusListServiceImpl.java | 193 +++++++++ .../bitstring/BitstringStatusInfo.java | 112 +++++ .../bitstring/BitstringStatusListFactory.java | 59 +++ ...rg.eclipse.edc.spi.system.ServiceExtension | 14 + .../statuslist/StatusListServiceImplTest.java | 398 ++++++++++++++++++ .../issuerservice/statuslist/TestData.java | 159 +++++++ .../BitstringStatusListFactoryTest.java | 84 ++++ .../main/resources/identity-api-version.json | 2 +- .../resources/issuer-admin-api-version.json | 2 +- .../issuance-credentials/build.gradle.kts | 1 + .../AttestationPipelineImplTest.java | 14 +- .../sql/credentials/SqlCredentialStore.java | 15 + settings.gradle.kts | 2 + .../build.gradle.kts | 30 ++ .../StatusListCredentialFactoryRegistry.java | 37 ++ .../spi/statuslist/StatusListInfo.java | 34 ++ .../spi/statuslist/StatusListInfoFactory.java | 32 ++ .../spi/statuslist/StatusListService.java | 71 ++++ .../model/VerifiableCredentialResource.java | 4 + .../store/CredentialStore.java | 8 + 24 files changed, 1392 insertions(+), 8 deletions(-) create mode 100644 core/issuerservice/issuerservice-credential-revocation/build.gradle.kts create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListCredentialFactoryRegistryImpl.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceExtension.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImpl.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusInfo.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactory.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImplTest.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/TestData.java create mode 100644 core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactoryTest.java create mode 100644 spi/issuerservice/credential-revocation-spi/build.gradle.kts create mode 100644 spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListCredentialFactoryRegistry.java create mode 100644 spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfo.java create mode 100644 spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfoFactory.java create mode 100644 spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListService.java diff --git a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/defaults/store/InMemoryCredentialStore.java b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/defaults/store/InMemoryCredentialStore.java index 3314b8505..6523a88aa 100644 --- a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/defaults/store/InMemoryCredentialStore.java +++ b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/defaults/store/InMemoryCredentialStore.java @@ -19,8 +19,11 @@ import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; import org.eclipse.edc.identityhub.store.InMemoryEntityStore; import org.eclipse.edc.spi.query.QueryResolver; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.store.ReflectionBasedQueryResolver; +import static java.util.Optional.ofNullable; + /** * In-memory variant of the {@link CredentialStore} that is thread-safe. */ @@ -36,4 +39,11 @@ protected QueryResolver createQueryResolver() { criterionOperatorRegistry.registerPropertyLookup(new CredentialResourceLookup()); return new ReflectionBasedQueryResolver<>(VerifiableCredentialResource.class, criterionOperatorRegistry); } + + @Override + public StoreResult findById(String credentialId) { + return ofNullable(this.store.get(credentialId)) + .map(StoreResult::success) + .orElseGet(() -> StoreResult.notFound(notFoundErrorMessage(credentialId))); + } } diff --git a/core/issuerservice/issuerservice-credential-revocation/build.gradle.kts b/core/issuerservice/issuerservice-credential-revocation/build.gradle.kts new file mode 100644 index 000000000..3a1e6c9f0 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":spi:verifiable-credential-spi")) + api(project(":spi:issuerservice:credential-revocation-spi")) + implementation(libs.nimbus.jwt) + + implementation(libs.edc.spi.transaction) + implementation(libs.edc.lib.store) + testImplementation(project(":core:identity-hub-core")) + testImplementation(libs.edc.junit) + testImplementation(testFixtures(project(":spi:issuerservice:issuerservice-participant-spi"))) + +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListCredentialFactoryRegistryImpl.java b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListCredentialFactoryRegistryImpl.java new file mode 100644 index 000000000..c70af39b3 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListCredentialFactoryRegistryImpl.java @@ -0,0 +1,35 @@ +/* + * 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.issuerservice.statuslist; + +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListCredentialFactoryRegistry; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListInfoFactory; + +import java.util.HashMap; +import java.util.Map; + +public class StatusListCredentialFactoryRegistryImpl implements StatusListCredentialFactoryRegistry { + private final Map registry = new HashMap<>(); + + @Override + public StatusListInfoFactory getStatusListCredential(String type) { + return registry.get(type); + } + + @Override + public void register(String type, StatusListInfoFactory factory) { + registry.put(type, factory); + } +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceExtension.java b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceExtension.java new file mode 100644 index 000000000..1be99ac39 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceExtension.java @@ -0,0 +1,68 @@ +/* + * 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.issuerservice.statuslist; + +import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListCredentialFactoryRegistry; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListService; +import org.eclipse.edc.issuerservice.statuslist.bitstring.BitstringStatusListFactory; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.types.TypeManager; +import org.eclipse.edc.token.spi.TokenGenerationService; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import static org.eclipse.edc.issuerservice.statuslist.StatusListServiceExtension.NAME; +import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; + +@Extension(value = NAME) +public class StatusListServiceExtension implements ServiceExtension { + public static final String NAME = "Status List Service Extension"; + + @Setting(description = "Alias for the private key that is intended for signing status list credentials", key = "edc.issuer.statuslist.signing.key.alias") + private String privateKeyAlias; + @Inject + private TransactionContext transactionContext; + @Inject + private CredentialStore store; + @Inject + private TypeManager typeManager; + @Inject + private TokenGenerationService tokenGenerationService; + private StatusListCredentialFactoryRegistry factory; + + @Provider + public StatusListService getStatusListService(ServiceExtensionContext context) { + var fact = getFactory(); + + // Bitstring StatusList is provided by default. others can be added via extensions + fact.register("BitStringStatusListEntry", new BitstringStatusListFactory(store, typeManager.getMapper())); + + return new StatusListServiceImpl(store, transactionContext, typeManager.getMapper(JSON_LD), context.getMonitor(), tokenGenerationService, + () -> privateKeyAlias, fact); + } + + @Provider + public StatusListCredentialFactoryRegistry getFactory() { + if (factory == null) { + factory = new StatusListCredentialFactoryRegistryImpl(); + } + return factory; + } +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImpl.java b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImpl.java new file mode 100644 index 000000000..d3add0548 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImpl.java @@ -0,0 +1,193 @@ +/* + * 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.issuerservice.statuslist; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredentialContainer; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListCredentialFactoryRegistry; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListInfo; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListService; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.token.spi.TokenGenerationService; +import org.eclipse.edc.transaction.spi.TransactionContext; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.function.Supplier; + +import static java.util.Optional.ofNullable; +import static org.eclipse.edc.spi.result.ServiceResult.badRequest; +import static org.eclipse.edc.spi.result.ServiceResult.from; +import static org.eclipse.edc.spi.result.ServiceResult.fromFailure; +import static org.eclipse.edc.spi.result.ServiceResult.success; +import static org.eclipse.edc.spi.result.ServiceResult.unexpected; + +public class StatusListServiceImpl implements StatusListService { + public static final TypeReference> MAP_REF = new TypeReference<>() { + }; + private static final String REVOCATION = "revocation"; + private final CredentialStore credentialStore; + private final TransactionContext transactionContext; + private final ObjectMapper objectMapper; + private final Monitor monitor; + private final TokenGenerationService tokenGenerationService; + private final Supplier privateKeyAlias; + private final StatusListCredentialFactoryRegistry statusListCredentialFactoryRegistry; + + public StatusListServiceImpl(CredentialStore credentialStore, + TransactionContext transactionContext, + ObjectMapper objectMapper, + Monitor monitor, + TokenGenerationService tokenGenerationService, + Supplier privateKeyAlias, + StatusListCredentialFactoryRegistry statusListCredentialFactoryRegistry) { + this.credentialStore = credentialStore; + this.transactionContext = transactionContext; + this.objectMapper = objectMapper.copy() + .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) // technically, credential subjects and credential status can be objects AND Arrays + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); // e.g. @context + this.monitor = monitor; + this.tokenGenerationService = tokenGenerationService; + this.privateKeyAlias = privateKeyAlias; + this.statusListCredentialFactoryRegistry = statusListCredentialFactoryRegistry; + } + + @Override + public ServiceResult revokeCredential(String holderCredentialId, @Nullable String reason) { + return transactionContext.execute(() -> { + + var credentialResult = getCredential(holderCredentialId); + var result = credentialResult.compose(this::getRevocationInfo); + + if (result.failed()) { + return result.mapFailure(); + } + var revocationInfo = result.getContent(); + + var status = revocationInfo.getStatus(); + if (status.failed()) { + return result.mapFailure(); + } + + if (REVOCATION.equalsIgnoreCase(status.getContent())) { + monitor.info("Revocation not necessary, credential is already revoked."); + return success(); + } + + var setStatusResult = revocationInfo.setStatus(true); + if (setStatusResult.failed()) { + return unexpected(setStatusResult.getFailureDetail()); + } + + try { + // update status credential + var updatedRevocationCredential = updateStatusCredential(revocationInfo.statusListCredential()); + + // update user credential + var cred = credentialResult.getContent(); + cred.revoke(); + + var merged = credentialStore.update(updatedRevocationCredential) + .compose(v -> credentialStore.update(cred)); + + return from(merged); + } catch (JOSEException e) { + var msg = "Error signing BitstringStatusListCredential: %s".formatted(e.getMessage()); + monitor.warning(msg, e); + return unexpected(msg); + } + }); + } + + @Override + public ServiceResult suspendCredential(String credentialId, @Nullable String reason) { + throw new UnsupportedOperationException("Not supported by this implementation."); + } + + @Override + public ServiceResult resumeCredential(String credentialId, @Nullable String reason) { + throw new UnsupportedOperationException("Not supported by this implementation."); + } + + @Override + public ServiceResult getCredentialStatus(String credentialId) { + return transactionContext.execute(() -> getCredential(credentialId) + .compose(this::getRevocationInfo) + .compose(r -> from(r.getStatus()))); + } + + private ServiceResult getCredential(String credentialId) { + // credential not found -> error + var credentialResult = credentialStore.findById(credentialId); + if (credentialResult.failed()) { + return fromFailure(credentialResult); + } + // obtain the ID of the revocation list credential and the status index by reading the credential's status object, specifically + // the one with "statusPurpose = revocation" + var credential = credentialResult.getContent(); + return success(credential); + } + + /** + * updates the status list credential with the new bitstring. For this, the status list credential is converted into + * a JWT and signed with the private key. + */ + private VerifiableCredentialResource updateStatusCredential(VerifiableCredentialResource credentialResource) throws JOSEException { + // encode credential as JWT + var credential = credentialResource.getVerifiableCredential().credential(); + + var claims = objectMapper.convertValue(credential, MAP_REF); + var token = tokenGenerationService.generate(privateKeyAlias.get(), tokenParameters -> tokenParameters.claims(claims)); + + var newJwt = token.getContent().getToken(); + + var container = new VerifiableCredentialContainer(newJwt, credentialResource.getVerifiableCredential().format(), credential); + + return credentialResource.toBuilder() + .credential(container) + .build(); + } + + private ServiceResult getRevocationInfo(VerifiableCredentialResource resource) { + return getRevocationInfo(resource, REVOCATION); + } + + private ServiceResult getRevocationInfo(VerifiableCredentialResource holderCredential, String statusPurpose) { + + var statusObjects = holderCredential.getVerifiableCredential().credential().getCredentialStatus(); + + var revocationStatus = statusObjects.stream() + .filter(st -> st.additionalProperties().values().stream().anyMatch(v -> v.toString().endsWith(statusPurpose))) + .findFirst(); + + if (revocationStatus.isEmpty()) { + return badRequest("Credential did not contain a credentialStatus object with 'statusPurpose = revocation'"); + } + + var status = revocationStatus.get(); + var revocationInfo = ofNullable(statusListCredentialFactoryRegistry.getStatusListCredential(status.type())) + .map(cred -> cred.create(status)); + + return revocationInfo.orElseGet(() -> badRequest("No StatusList implementation for type '%s' found.".formatted(status.type()))); + + } + +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusInfo.java b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusInfo.java new file mode 100644 index 000000000..b9c8ff88c --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusInfo.java @@ -0,0 +1,112 @@ +/* + * 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.issuerservice.statuslist.bitstring; + +import org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.BitString; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.bitstringstatuslist.BitstringStatusListCredential; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListInfo; +import org.eclipse.edc.spi.result.Result; + +import java.util.Base64; + +import static org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.bitstringstatuslist.BitstringStatusListCredential.BITSTRING_ENCODED_LIST_LITERAL; +import static org.eclipse.edc.spi.result.Result.failure; +import static org.eclipse.edc.spi.result.Result.success; + + +/** + * {@link StatusListInfo} object specific for Bitstring Status List credentials. + *

+ * Note that at this time, {@code statusSize} and {@code statusMessage} are not supported, that means, the only valid + * status values are "set" (1) and "not set" (0). + * + * @param index the statusIndex of the credential in question + * @param statusListCredential the {@link VerifiableCredentialResource} of the status list credential + */ +record BitstringStatusInfo(int index, VerifiableCredentialResource statusListCredential) implements StatusListInfo { + + /** + * The status field of the holder's credential, e.g. "revocation". + * + * @return a string indicating the status, or null if the status is not set. + */ + public Result getStatus() { + + var uncompressedBitString = decode(); + return uncompressedBitString.map(bs -> bs.get(index) ? createBitStringCredential().statusPurpose() : null); + } + + /** + * sets the status bit in the bitstring of the status list credential + * + * @return the new compressed, encoded bitstring + */ + public Result setStatus(boolean status) { + + var uncompressedBitString = decode(); + // set "revoked" bit + return uncompressedBitString.compose(bs -> { + bs.set(index, status); + var res = BitString.Writer.newInstance().encoder(Base64.getUrlEncoder()).write(bs); + + + // update bitstring in credential status + + res.onSuccess(newBitString -> { + createBitStringCredential().getCredentialSubject().get(0) + .toBuilder() //modifies original instance + .claim(BITSTRING_ENCODED_LIST_LITERAL, newBitString) + .build(); + }); + + return res.mapEmpty(); + }); + } + + private BitstringStatusListCredential createBitStringCredential() { + var cred = statusListCredential.getVerifiableCredential().credential(); + return BitstringStatusListCredential.Builder.newInstance() + .credentialSubjects(cred.getCredentialSubject()) + .issuanceDate(cred.getIssuanceDate()) + .issuer(cred.getIssuer()) + .types(cred.getType()) + .expirationDate(cred.getExpirationDate()) + .id(cred.getId()) + .credentialStatus(cred.getCredentialStatus()) + .credentialSchemas(cred.getCredentialSchema()) + .dataModelVersion(cred.getDataModelVersion()) + .build(); + } + + private Result decode() { + var bitString = createBitStringCredential().encodedList(); + var decoder = Base64.getDecoder(); + + if (bitString.charAt(0) == 'u') { // base64 url + decoder = Base64.getUrlDecoder(); + bitString = bitString.substring(1); //chop off header + } else if (bitString.charAt(0) == 'z') { //base58btc + return failure("The encoded list is using the Base58-BTC alphabet ('z' multibase header), which is not supported."); + } + var decompressionResult = BitString.Parser.newInstance().decoder(decoder).parse(bitString); + if (decompressionResult.failed()) { + return failure("Failed to decode compressed BitString: '%s'".formatted(decompressionResult.getFailureDetail())); + } + return decompressionResult.succeeded() ? success(decompressionResult.getContent()) : failure(decompressionResult.getFailureDetail()); + } + + +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactory.java b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactory.java new file mode 100644 index 000000000..f26e962d5 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactory.java @@ -0,0 +1,59 @@ +/* + * 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.issuerservice.statuslist.bitstring; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListInfo; +import org.eclipse.edc.issuerservice.spi.statuslist.StatusListInfoFactory; +import org.eclipse.edc.spi.result.ServiceResult; + +import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.BITSTRING_STATUS_LIST_PREFIX; +import static org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.bitstringstatuslist.BitstringStatusListStatus.BITSTRING_STATUS_LIST_CREDENTIAL_LITERAL; +import static org.eclipse.edc.iam.verifiablecredentials.spi.model.revocation.bitstringstatuslist.BitstringStatusListStatus.BITSTRING_STATUS_LIST_INDEX_LITERAL; +import static org.eclipse.edc.spi.result.ServiceResult.success; + +public class BitstringStatusListFactory implements StatusListInfoFactory { + private final CredentialStore credentialStore; + private final ObjectMapper objectMapper; + + public BitstringStatusListFactory(CredentialStore credentialStore, ObjectMapper objectMapper) { + this.credentialStore = credentialStore; + this.objectMapper = objectMapper; + } + + @Override + public ServiceResult create(CredentialStatus credentialStatus) { + + var statusListCredentialId = credentialStatus.getProperty(BITSTRING_STATUS_LIST_PREFIX, BITSTRING_STATUS_LIST_CREDENTIAL_LITERAL); + var index = credentialStatus.getProperty(BITSTRING_STATUS_LIST_PREFIX, BITSTRING_STATUS_LIST_INDEX_LITERAL); + + if (statusListCredentialId == null) { + return ServiceResult.unexpected("The credential status with ID '%s' is invalid, the '%s' field is missing".formatted(credentialStatus.id(), BITSTRING_STATUS_LIST_CREDENTIAL_LITERAL)); + + } + if (index == null) { + return ServiceResult.unexpected("The credential status with ID '%s' is invalid, the '%s' field is missing".formatted(credentialStatus.id(), BITSTRING_STATUS_LIST_INDEX_LITERAL)); + } + + var ix = Integer.parseInt(index.toString()); + + var result = credentialStore.findById(statusListCredentialId.toString()); + return result.succeeded() + ? success(new BitstringStatusInfo(ix, result.getContent())) + : ServiceResult.fromFailure(result); + } +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/core/issuerservice/issuerservice-credential-revocation/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..2d00e5cbd --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,14 @@ +# +# 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 +# +# +org.eclipse.edc.issuerservice.statuslist.StatusListServiceExtension diff --git a/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImplTest.java b/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImplTest.java new file mode 100644 index 000000000..f17024ba0 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImplTest.java @@ -0,0 +1,398 @@ +/* + * 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.issuerservice.statuslist; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +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.ECKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredentialContainer; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; +import org.eclipse.edc.issuerservice.statuslist.bitstring.BitstringStatusListFactory; +import org.eclipse.edc.json.JacksonTypeManager; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.token.spi.TokenGenerationService; +import org.eclipse.edc.transaction.spi.NoopTransactionContext; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.text.ParseException; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.issuerservice.statuslist.TestData.EXAMPLE_CREDENTIAL; +import static org.eclipse.edc.issuerservice.statuslist.TestData.EXAMPLE_CREDENTIAL_JWT; +import static org.eclipse.edc.issuerservice.statuslist.TestData.EXAMPLE_REVOCATION_CREDENTIAL; +import static org.eclipse.edc.issuerservice.statuslist.TestData.EXAMPLE_REVOCATION_CREDENTIAL_JWT; +import static org.eclipse.edc.issuerservice.statuslist.TestData.EXAMPLE_REVOCATION_CREDENTIAL_JWT_WITH_STATUS_BIT_SET; +import static org.eclipse.edc.issuerservice.statuslist.TestData.EXAMPLE_REVOCATION_CREDENTIAL_WITH_STATUS_BIT_SET; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.spi.result.ServiceFailure.Reason.BAD_REQUEST; +import static org.eclipse.edc.spi.result.ServiceFailure.Reason.NOT_FOUND; +import static org.eclipse.edc.spi.result.ServiceFailure.Reason.UNEXPECTED; +import static org.eclipse.edc.spi.result.StoreResult.notFound; +import static org.eclipse.edc.spi.result.StoreResult.success; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class StatusListServiceImplTest { + + public static final TypeReference> MAP_REF = new TypeReference<>() { + }; + private static final String REVOCATION_CREDENTIAL_ID = "https://example.com/credentials/status/3"; + private static final String CREDENTIAL_ID = "https://example.com/credentials/23894672394"; + private final ObjectMapper objectMapper = new JacksonTypeManager().getMapper().copy() + .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + private final CredentialStore credentialStore = mock(); + private StatusListServiceImpl revocationService; + private TokenGenerationService tokenGenerationService; + private Monitor monitor; + private ECKey signingKey; + + @BeforeEach + void setUp() throws JOSEException { + signingKey = new ECKeyGenerator(Curve.P_256).generate(); + tokenGenerationService = mock(TokenGenerationService.class); + when(tokenGenerationService.generate(any(), any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().token("new-token").build())); + monitor = mock(); + var reg = new StatusListCredentialFactoryRegistryImpl(); + reg.register("BitstringStatusListEntry", new BitstringStatusListFactory(credentialStore, objectMapper)); + revocationService = new StatusListServiceImpl(credentialStore, new NoopTransactionContext(), objectMapper, + monitor, tokenGenerationService, () -> "some-private-key", reg); + } + + private SignedJWT sign(Map claims) { + + + var jwsHeader = new JWSHeader(JWSAlgorithm.ES256); + var claimsSet = new JWTClaimsSet.Builder(); + claims.forEach(claimsSet::claim); + var signedJwt = new SignedJWT(jwsHeader, claimsSet.build()); + try { + signedJwt.sign(new ECDSASigner(signingKey)); + return signedJwt; + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } + + private VerifiableCredentialResource createCredential(String credentialJson, @Nullable String rawVc) { + return createCredentialBuilder(credentialJson, rawVc) + .build(); + } + + private VerifiableCredentialResource.Builder createCredentialBuilder(String credentialJson, @Nullable String rawVc) { + try { + var credential = objectMapper.readValue(credentialJson, VerifiableCredential.class); + return VerifiableCredentialResource.Builder.newInstance() + .state(VcStatus.ISSUED) + .credential(new VerifiableCredentialContainer(rawVc, CredentialFormat.VC1_0_JWT, credential)) + .issuerId(credential.getIssuer().id()) + .holderId("did:web:testholder"); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Nested + class Revoke { + + @Test + void revokeCredential() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.findById(eq(CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_CREDENTIAL, EXAMPLE_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.update(any())).thenReturn(success()); + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isSucceeded(); + verify(tokenGenerationService).generate(any(), any()); + verify(credentialStore, times(2)).update(any()); + } + + @Test + void revokeCredential_credentialNotFound() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.findById(eq(CREDENTIAL_ID))).thenReturn(notFound("foo")); + when(credentialStore.update(any())).thenReturn(success()); + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isFailed().detail().isEqualTo("foo"); + assertThat(result.getFailure().getReason()).isEqualTo(NOT_FOUND); + + verifyNoInteractions(tokenGenerationService); + verify(credentialStore, never()).update(any()); + } + + @Test + void revokeCredential_whenAlreadyRevoked() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL_WITH_STATUS_BIT_SET, EXAMPLE_REVOCATION_CREDENTIAL_JWT_WITH_STATUS_BIT_SET.replace("\n", "")))); + when(credentialStore.findById(eq(CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_CREDENTIAL, EXAMPLE_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.update(any())).thenReturn(success()); + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isSucceeded(); + verifyNoInteractions(tokenGenerationService); + verify(credentialStore, never()).update(any()); + verify(monitor).info(eq("Revocation not necessary, credential is already revoked.")); + } + + @Test + void revokeCredential_noCredentialStatus() throws JsonProcessingException, ParseException { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + + + var claims = objectMapper.readValue(EXAMPLE_CREDENTIAL, MAP_REF); + claims.remove("credentialStatus"); + var jwt = sign(claims); + + + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(objectMapper.writeValueAsString(jwt.getJWTClaimsSet().getClaims()), jwt.serialize()))); + + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isFailed(); + assertThat(result.getFailure().getReason()).isEqualTo(BAD_REQUEST); + verifyNoInteractions(tokenGenerationService); + verify(credentialStore, never()).update(any()); + } + + @Test + void revokeCredential_noRevocationCredentialUrl() throws JsonProcessingException, ParseException { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + + + var claims = objectMapper.readValue(EXAMPLE_CREDENTIAL, MAP_REF); + // remove statusListCredential, which is the revocation credential URL + ((List>) claims.get("credentialStatus")).get(0).remove("statusListCredential"); + var jwt = sign(claims); + + + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(objectMapper.writeValueAsString(jwt.getJWTClaimsSet().getClaims()), jwt.serialize()))); + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isFailed() + .detail().containsSequence("is invalid, the 'statusListCredential' field is missing"); + assertThat(result.getFailure().getReason()).isEqualTo(UNEXPECTED); + verifyNoInteractions(tokenGenerationService); + verify(credentialStore, never()).update(any()); + } + + @Test + void revokeCredential_noStatusIndex() throws ParseException, JsonProcessingException { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + + + var claims = objectMapper.readValue(EXAMPLE_CREDENTIAL, MAP_REF); + + ((List>) claims.get("credentialStatus")).get(0).remove("statusListIndex"); + var jwt = sign(claims); + + + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(objectMapper.writeValueAsString(jwt.getJWTClaimsSet().getClaims()), jwt.serialize()))); + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isFailed() + .detail().containsSequence("is invalid, the 'statusListIndex' field is missing"); + assertThat(result.getFailure().getReason()).isEqualTo(UNEXPECTED); + verifyNoInteractions(tokenGenerationService); + verify(credentialStore, never()).update(any()); + } + + @Test + void revokeCredential_noRevocationCredentialFound() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))).thenReturn(notFound("foo")); + when(credentialStore.findById(eq(CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_CREDENTIAL, EXAMPLE_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.update(any())).thenReturn(success()); + + var result = revocationService.revokeCredential(CREDENTIAL_ID, "foo-reason"); + assertThat(result).isFailed().detail().isEqualTo("foo"); + assertThat(result.getFailure().getReason()).isEqualTo(NOT_FOUND); + + verifyNoInteractions(tokenGenerationService); + verify(credentialStore, never()).update(any()); + } + + } + + @Nested + class Suspend { + @Test + void suspend() { + assertThatThrownBy(() -> revocationService.suspendCredential(CREDENTIAL_ID, null)) + .isInstanceOf(UnsupportedOperationException.class); + } + } + + @Nested + class Resume { + @Test + void resume() { + assertThatThrownBy(() -> revocationService.resumeCredential(CREDENTIAL_ID, null)) + .isInstanceOf(UnsupportedOperationException.class); + } + } + + @Nested + class GetCredentialStatus { + @Test + void getCredentialStatus() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_CREDENTIAL, EXAMPLE_CREDENTIAL_JWT.replace("\n", "")))); + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isSucceeded().isNull(); + verifyNoInteractions(tokenGenerationService); + } + + @Test + void getCredentialStatus_whenRevoked() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL_WITH_STATUS_BIT_SET, EXAMPLE_REVOCATION_CREDENTIAL_JWT_WITH_STATUS_BIT_SET.replace("\n", "")))); + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredentialBuilder(EXAMPLE_CREDENTIAL, EXAMPLE_CREDENTIAL_JWT.replace("\n", "")) + .state(VcStatus.REVOKED) + .build())); + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isSucceeded().isEqualTo("revocation"); + verifyNoInteractions(tokenGenerationService); + } + + @Test + void getCredentialStatus_credentialNotFound() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + when(credentialStore.findById(eq(CREDENTIAL_ID))).thenReturn(notFound("foo")); + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isFailed().detail().isEqualTo("foo"); + assertThat(result.getFailure().getReason()).isEqualTo(NOT_FOUND); + + verifyNoInteractions(tokenGenerationService); + } + + @Test + void getCredentialStatus_noRevocationCredentialFound() { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))).thenReturn(notFound("foo")); + when(credentialStore.findById(eq(CREDENTIAL_ID))).thenReturn(success(createCredential(EXAMPLE_CREDENTIAL, EXAMPLE_CREDENTIAL_JWT.replace("\n", "")))); + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isFailed().detail().isEqualTo("foo"); + assertThat(result.getFailure().getReason()).isEqualTo(NOT_FOUND); + + verifyNoInteractions(tokenGenerationService); + } + + @Test + void getCredentialStatus_noCredentialStatus() throws ParseException, JsonProcessingException { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + + + var claims = objectMapper.readValue(EXAMPLE_CREDENTIAL, MAP_REF); + claims.remove("credentialStatus"); + var jwt = sign(claims); + + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(objectMapper.writeValueAsString(jwt.getJWTClaimsSet().getClaims()), jwt.serialize()))); + + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isFailed(); + assertThat(result.getFailure().getReason()).isEqualTo(BAD_REQUEST); + verifyNoInteractions(tokenGenerationService); + } + + @Test + void getCredentialStatus_noCredentialUrl() throws ParseException, JsonProcessingException { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + + + var claims = objectMapper.readValue(EXAMPLE_CREDENTIAL, MAP_REF); + // remove statusListCredential, which is the revocation credential URL + ((List>) claims.get("credentialStatus")).get(0).remove("statusListCredential"); + var jwt = sign(claims); + + + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(objectMapper.writeValueAsString(jwt.getJWTClaimsSet().getClaims()), jwt.serialize()))); + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isFailed() + .detail().containsSequence("is invalid, the 'statusListCredential' field is missing"); + assertThat(result.getFailure().getReason()).isEqualTo(UNEXPECTED); + verifyNoInteractions(tokenGenerationService); + } + + @Test + void getCredentialStatus_noStatusIndex() throws ParseException, JsonProcessingException { + when(credentialStore.findById(eq(REVOCATION_CREDENTIAL_ID))) + .thenReturn(success(createCredential(EXAMPLE_REVOCATION_CREDENTIAL, EXAMPLE_REVOCATION_CREDENTIAL_JWT.replace("\n", "")))); + + + var claims = objectMapper.readValue(EXAMPLE_CREDENTIAL, MAP_REF); + + ((List>) claims.get("credentialStatus")).get(0).remove("statusListIndex"); + var jwt = sign(claims); + + + when(credentialStore.findById(eq(CREDENTIAL_ID))) + .thenReturn(success(createCredential(objectMapper.writeValueAsString(jwt.getJWTClaimsSet().getClaims()), jwt.serialize()))); + + var result = revocationService.getCredentialStatus(CREDENTIAL_ID); + assertThat(result).isFailed() + .detail().containsSequence("is invalid, the 'statusListIndex' field is missing"); + assertThat(result.getFailure().getReason()).isEqualTo(UNEXPECTED); + verifyNoInteractions(tokenGenerationService); + } + + } + +} \ No newline at end of file diff --git a/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/TestData.java b/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/TestData.java new file mode 100644 index 000000000..60a699807 --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/TestData.java @@ -0,0 +1,159 @@ +/* + * 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.issuerservice.statuslist; + +public class TestData { + public static final String EXAMPLE_CREDENTIAL = """ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "id": "http://university.example/credentials/3732", + "type": [ + "VerifiableCredential", + "ExampleDegreeCredential", + "ExamplePersonCredential" + ], + "issuer": "https://university.example/issuers/14", + "validFrom": "2010-01-01T19:23:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "ExampleBachelorDegree", + "name": "Bachelor of Science and Arts" + }, + "alumniOf": { + "name": "Example University" + } + }, + "credentialSchema": [ + { + "id": "https://example.org/examples/degree.json", + "type": "JsonSchema" + }, + { + "id": "https://example.org/examples/alumni.json", + "type": "JsonSchema" + } + ], + "credentialStatus": [{ + "id": "https://example.com/credentials/status/3#94567", + "type": "BitstringStatusListEntry", + "statusPurpose": "revocation", + "statusListIndex": "94567", + "statusListCredential": "https://example.com/credentials/status/3" + }] + } + """; + + /** + * jwt representation of {@link TestData#EXAMPLE_CREDENTIAL}, signed with {@link TestData#SIGNING_KEY} + */ + public static final String EXAMPLE_CREDENTIAL_JWT = """ + eyJraWQiOiJFeEhrQk1XOWZtYmt2VjI2Nm1ScHVQMnNVWV9OX0VXSU4xbGFwVXpPOHJvIiwiYWxnIjoiRVMyNTYifQ.eyJAY29udGV4dCI6W + yJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvZXhhbXBsZ + XMvdjIiXSwiaWQiOiJodHRwOi8vdW5pdmVyc2l0eS5leGFtcGxlL2NyZWRlbnRpYWxzLzM3MzIiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZ + W50aWFsIiwiRXhhbXBsZURlZ3JlZUNyZWRlbnRpYWwiLCJFeGFtcGxlUGVyc29uQ3JlZGVudGlhbCJdLCJpc3N1ZXIiOiJodHRwczovL3Vua + XZlcnNpdHkuZXhhbXBsZS9pc3N1ZXJzLzE0IiwidmFsaWRGcm9tIjoiMjAxMC0wMS0wMVQxOToyMzoyNFoiLCJjcmVkZW50aWFsU3ViamVjd + CI6eyJpZCI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSIsImRlZ3JlZSI6eyJ0eXBlIjoiRXhhbXBsZUJhY2hlb + G9yRGVncmVlIiwibmFtZSI6IkJhY2hlbG9yIG9mIFNjaWVuY2UgYW5kIEFydHMifSwiYWx1bW5pT2YiOnsibmFtZSI6IkV4YW1wbGUgVW5pd + mVyc2l0eSJ9fSwiY3JlZGVudGlhbFNjaGVtYSI6W3siaWQiOiJodHRwczovL2V4YW1wbGUub3JnL2V4YW1wbGVzL2RlZ3JlZS5qc29uIiwid + HlwZSI6Ikpzb25TY2hlbWEifSx7ImlkIjoiaHR0cHM6Ly9leGFtcGxlLm9yZy9leGFtcGxlcy9hbHVtbmkuanNvbiIsInR5cGUiOiJKc29uU + 2NoZW1hIn1dLCJjcmVkZW50aWFsU3RhdHVzIjpbeyJpZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY3JlZGVudGlhbHMvc3RhdHVzLzMjOTQ1N + jciLCJ0eXBlIjoiQml0c3RyaW5nU3RhdHVzTGlzdEVudHJ5Iiwic3RhdHVzUHVycG9zZSI6InJldm9jYXRpb24iLCJzdGF0dXNMaXN0SW5kZ + XgiOiI5NDU2NyIsInN0YXR1c0xpc3RDcmVkZW50aWFsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9jcmVkZW50aWFscy9zdGF0dXMvMyJ9XX0.w + xGckEQrJ1UEoZXIHbzREAU3FbTHosAdLJ4isERdmi-5OARS6wH5HTpdQ_ADPuGpzqJ5ci3gvxLI_UzJDJaemw + """; + + /** + * the key that was used to generate {@link TestData#EXAMPLE_CREDENTIAL_JWT} out of {@link TestData#EXAMPLE_CREDENTIAL} + */ + public static final String SIGNING_KEY = """ + { + "kty": "EC", + "d": "SbKv_rIJJUI-8Whx5Zo1O20V-rOyKKQTKPpNY0UxtAY", + "use": "sig", + "crv": "P-256", + "kid": "key-1", + "x": "rKuOAlVttxmkLHz9NzxsR7Xj7xbzy2CcXfupHoA5VzA", + "y": "fBP7UiEd05cnGDqoOKnOYwSSfuifJybtwyg7tbYfdiM" + } + """; + + public static final String EXAMPLE_REVOCATION_CREDENTIAL = """ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "id": "https://example.com/credentials/status/3", + "type": ["VerifiableCredential", "BitstringStatusListCredential"], + "issuer": "did:example:12345", + "validFrom": "2021-04-05T14:27:40Z", + "credentialSubject": { + "id": "https://example.com/status/3#list", + "type": "BitstringStatusList", + "statusPurpose": "revocation", + "encodedList": "uH4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA" + } + } + """; + public static final String EXAMPLE_REVOCATION_CREDENTIAL_WITH_STATUS_BIT_SET = """ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "id": "https://example.com/credentials/status/3", + "type": ["VerifiableCredential", "BitstringStatusListCredential"], + "issuer": "did:example:12345", + "validFrom": "2021-04-05T14:27:40Z", + "credentialSubject": { + "id": "https://example.com/status/3#list", + "type": "BitstringStatusList", + "statusPurpose": "revocation", + "encodedList": "H4sIAAAAAAAA/+3OMQ0AAAgDsOHfNBp2kZBWQRMAAAAAAAAAAAAAAL6Z6wAAAAAAtQVQdb5gAEAAAA==" + } + } + """; + + /** + * JWT representation of the revocation credential ({@link TestData#EXAMPLE_REVOCATION_CREDENTIAL}), signed with {@link TestData#SIGNING_KEY} + */ + public static final String EXAMPLE_REVOCATION_CREDENTIAL_JWT = """ + eyJraWQiOiJFeEhrQk1XOWZtYmt2VjI2Nm1ScHVQMnNVWV9OX0VXSU4xbGFwVXpPOHJvIiwiYWxnIjoiRVMyNTYifQ.eyJAY29udGV4dCI6W + yJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiXSwiaWQiOiJodHRwczovL2V4YW1wbGUuY29tL2NyZWRlbnRpYWxzL3N0Y + XR1cy8zIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkJpdHN0cmluZ1N0YXR1c0xpc3RDcmVkZW50aWFsIl0sImlzc3VlciI6I + mRpZDpleGFtcGxlOjEyMzQ1IiwidmFsaWRGcm9tIjoiMjAyMS0wNC0wNVQxNDoyNzo0MFoiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6I + mh0dHBzOi8vZXhhbXBsZS5jb20vc3RhdHVzLzMjbGlzdCIsInR5cGUiOiJCaXRzdHJpbmdTdGF0dXNMaXN0Iiwic3RhdHVzUHVycG9zZSI6I + nJldm9jYXRpb24iLCJlbmNvZGVkTGlzdCI6InVINHNJQUFBQUFBQUFBLTNCTVFFQUFBRENvUFZQYlF3Zm9BQUFBQUFBQUFBQUFBQUFBQUFBQ + UlDM0FZYlNWS3NBUUFBQSJ9fQ.aPe5YXaNH-itNYYI7jE6FW3ttN2NzS5e1eNvkYw6BqW185w20xYKXQlZ7ETayqJXIcA7Q5HiyeVdKqPwkl + nyLQ + """; + + /** + * JWT representation of the revocation credential ({@link TestData#EXAMPLE_REVOCATION_CREDENTIAL}), signed with {@link TestData#SIGNING_KEY} + * but with the revocation bit at index 94567 set to "true" + */ + public static final String EXAMPLE_REVOCATION_CREDENTIAL_JWT_WITH_STATUS_BIT_SET = """ + eyJraWQiOiJFeEhrQk1XOWZtYmt2VjI2Nm1ScHVQMnNVWV9OX0VXSU4xbGFwVXpPOHJvIiwiYWxnIjoiRVMyNTYifQ.eyJAY29udGV4dCI6W + yJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiXSwiaWQiOiJodHRwczovL2V4YW1wbGUuY29tL2NyZWRlbnRpYWxzL3N0Y + XR1cy8zIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkJpdHN0cmluZ1N0YXR1c0xpc3RDcmVkZW50aWFsIl0sImlzc3VlciI6I + mRpZDpleGFtcGxlOjEyMzQ1IiwidmFsaWRGcm9tIjoiMjAyMS0wNC0wNVQxNDoyNzo0MFoiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6I + mh0dHBzOi8vZXhhbXBsZS5jb20vc3RhdHVzLzMjbGlzdCIsInR5cGUiOiJCaXRzdHJpbmdTdGF0dXNMaXN0Iiwic3RhdHVzUHVycG9zZSI6I + nJldm9jYXRpb24iLCJlbmNvZGVkTGlzdCI6Ikg0c0lBQUFBQUFBQS8rM09NUTBBQUFnRHNPSGZOQnAya1pCV1FSTUFBQUFBQUFBQUFBQUFBT + DZaNndBQUFBQUF0UVZRZGI1Z0FFQUFBQT09In19.EI-kWzpDykZxbvedDgEG0cOJRFfEDZHJtHlnGD6fbQEm13GcLGKBMVT_KJEmsdjBBhys + Sh0KW-2S2mm3jS9w1w + """; +} diff --git a/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactoryTest.java b/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactoryTest.java new file mode 100644 index 000000000..f22917f2e --- /dev/null +++ b/core/issuerservice/issuerservice-credential-revocation/src/test/java/org/eclipse/edc/issuerservice/statuslist/bitstring/BitstringStatusListFactoryTest.java @@ -0,0 +1,84 @@ +/* + * 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.issuerservice.statuslist.bitstring; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; +import org.eclipse.edc.spi.result.StoreResult; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BitstringStatusListFactoryTest { + + private final CredentialStore credentialStore = mock(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final BitstringStatusListFactory factory = new BitstringStatusListFactory(credentialStore, objectMapper); + + + @Test + void create_success() { + var status = new CredentialStatus("id", "BitstringStatusListEntry", Map.of("statusPurpose", "revocation", + "statusListIndex", "1234", + "statusListCredential", "https://example.com/credentials/status/1234")); + + when(credentialStore.findById(any())).thenReturn(StoreResult.success(null)); + + var result = factory.create(status); + assertThat(result).isSucceeded(); + assertThat(result.getContent()).isInstanceOf(BitstringStatusInfo.class); + } + + @Test + void create_whenNoIndex_expectFailure() { + var status = new CredentialStatus("id", "BitstringStatusListEntry", Map.of("statusPurpose", "revocation", + "statusListCredential", "https://example.com/credentials/status/1234")); + + when(credentialStore.findById(any())).thenReturn(StoreResult.success(null)); + + var result = factory.create(status); + assertThat(result).isFailed().detail().contains("the 'statusListIndex' field is missing"); + } + + @Test + void create_whenNoCredential_expectFailure() { + var status = new CredentialStatus("id", "BitstringStatusListEntry", Map.of("statusPurpose", "revocation", + "statusListIndex", "1234")); + + when(credentialStore.findById(any())).thenReturn(StoreResult.success(null)); + + var result = factory.create(status); + assertThat(result).isFailed().detail().contains("the 'statusListCredential' field is missing"); + } + + @Test + void create_whenRevocationCredentialNotFound_expectFailure() { + var status = new CredentialStatus("id", "BitstringStatusListEntry", Map.of("statusPurpose", "revocation", + "statusListIndex", "1234", + "statusListCredential", "https://example.com/credentials/status/1234")); + + when(credentialStore.findById(any())).thenReturn(StoreResult.notFound("foo")); + + var result = factory.create(status); + assertThat(result).isFailed().detail().isEqualTo("foo"); + } +} \ No newline at end of file diff --git a/extensions/api/identity-api/api-configuration/src/main/resources/identity-api-version.json b/extensions/api/identity-api/api-configuration/src/main/resources/identity-api-version.json index 04b770a91..b3a6ab66e 100644 --- a/extensions/api/identity-api/api-configuration/src/main/resources/identity-api-version.json +++ b/extensions/api/identity-api/api-configuration/src/main/resources/identity-api-version.json @@ -2,7 +2,7 @@ { "version": "1.0.0-alpha", "urlPath": "/v1alpha", - "lastUpdated": "2025-01-31T12:00:00Z", + "lastUpdated": "2025-02-05T12:00:00Z", "maturity": null } ] 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 f7544652b..809426703 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-alpha", "urlPath": "/v1alpha", - "lastUpdated": "2025-02-03T10:00:00Z", + "lastUpdated": "2025-02-05T10:00:00Z", "maturity": null } ] diff --git a/extensions/issuance/issuance-credentials/build.gradle.kts b/extensions/issuance/issuance-credentials/build.gradle.kts index 6d5631d7e..166b78dab 100644 --- a/extensions/issuance/issuance-credentials/build.gradle.kts +++ b/extensions/issuance/issuance-credentials/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(libs.edc.spi.validator) + testImplementation(libs.edc.junit) testImplementation(libs.edc.lib.json) testFixturesImplementation(libs.edc.spi.identity.did) diff --git a/extensions/issuance/issuance-credentials/src/test/java/org/eclipse/edc/identityhub/issuance/credentials/attestation/AttestationPipelineImplTest.java b/extensions/issuance/issuance-credentials/src/test/java/org/eclipse/edc/identityhub/issuance/credentials/attestation/AttestationPipelineImplTest.java index c31e06445..ff2de1993 100644 --- a/extensions/issuance/issuance-credentials/src/test/java/org/eclipse/edc/identityhub/issuance/credentials/attestation/AttestationPipelineImplTest.java +++ b/extensions/issuance/issuance-credentials/src/test/java/org/eclipse/edc/identityhub/issuance/credentials/attestation/AttestationPipelineImplTest.java @@ -21,12 +21,15 @@ import org.eclipse.edc.identityhub.spi.issuance.credentials.model.AttestationDefinition; import org.junit.jupiter.api.Test; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; import static java.util.Collections.emptyMap; import static java.util.Map.entry; import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.eclipse.edc.spi.result.Result.failure; import static org.eclipse.edc.spi.result.Result.success; import static org.mockito.ArgumentMatchers.eq; @@ -39,7 +42,7 @@ class AttestationPipelineImplTest { @Test - void verify_pipeline() { + void evaluate_whenSingle_success() { var attestationDefinition = new AttestationDefinition("a123", "testType", Map.of()); var store = mock(AttestationDefinitionStore.class); @@ -56,7 +59,7 @@ void verify_pipeline() { pipeline.registerFactory("testType", sourceFactory); var results = pipeline.evaluate(Set.of("a123"), new DefaultAttestationContext("123", emptyMap())); - assertThat(results.succeeded()).isTrue(); + assertThat(results).isSucceeded(); assertThat(results.getContent()).contains(entry("test", "value")); @@ -67,7 +70,7 @@ void verify_pipeline() { @Test - void verify_failFast() { + void evaluate_whenMultipleInvalid_shouldFailOnFirst() { var attestationDefinition1 = new AttestationDefinition("a123", "testType1", Map.of()); var attestationDefinition2 = new AttestationDefinition("a456", "testType1", Map.of()); @@ -85,13 +88,12 @@ void verify_failFast() { pipeline.registerFactory("testType1", sourceFactory); - var results = pipeline.evaluate(Set.of("a123", "a456"), new DefaultAttestationContext("123", emptyMap())); - assertThat(results.failed()).isTrue(); + var results = pipeline.evaluate(new LinkedHashSet<>(List.of("a123", "a456")), new DefaultAttestationContext("123", emptyMap())); + assertThat(results).isFailed(); verify(store).resolveDefinition("a123"); verify(sourceFactory, times(1)).createSource(isA(AttestationDefinition.class)); verify(failedSource, times(1)).execute(isA(AttestationContext.class)); } - } \ No newline at end of file diff --git a/extensions/store/sql/identity-hub-credentials-store-sql/src/main/java/org/eclipse/edc/identityhub/store/sql/credentials/SqlCredentialStore.java b/extensions/store/sql/identity-hub-credentials-store-sql/src/main/java/org/eclipse/edc/identityhub/store/sql/credentials/SqlCredentialStore.java index 92c464777..07eed3b32 100644 --- a/extensions/store/sql/identity-hub-credentials-store-sql/src/main/java/org/eclipse/edc/identityhub/store/sql/credentials/SqlCredentialStore.java +++ b/extensions/store/sql/identity-hub-credentials-store-sql/src/main/java/org/eclipse/edc/identityhub/store/sql/credentials/SqlCredentialStore.java @@ -36,6 +36,7 @@ import java.util.Collection; import java.util.Objects; +import static java.util.Optional.ofNullable; import static org.eclipse.edc.spi.result.StoreResult.alreadyExists; import static org.eclipse.edc.spi.result.StoreResult.success; @@ -142,6 +143,20 @@ public StoreResult deleteById(String id) { }); } + @Override + public StoreResult findById(String credentialId) { + Objects.requireNonNull(credentialId); + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + return ofNullable(findByIdInternal(connection, credentialId)) + .map(StoreResult::success) + .orElseGet(() -> StoreResult.notFound(notFoundErrorMessage(credentialId))); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + private VerifiableCredentialResource findByIdInternal(Connection connection, String id) { return transactionContext.execute(() -> { var stmt = statements.getFindByIdTemplate(); diff --git a/settings.gradle.kts b/settings.gradle.kts index 654224337..927f6098c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ include(":spi:issuance-credentials-spi") // IssuerService SPI modules include(":spi:issuerservice:issuerservice-participant-spi") +include(":spi:issuerservice:credential-revocation-spi") // IdentityHub core modules include(":core:identity-hub-core") @@ -42,6 +43,7 @@ include(":core:identity-hub-did") // IssuerService core modules include(":core:issuerservice:issuerservice-core") include(":core:issuerservice:issuerservice-participants") +include(":core:issuerservice:issuerservice-credential-revocation") // lib modules include(":core:lib:keypair-lib") diff --git a/spi/issuerservice/credential-revocation-spi/build.gradle.kts b/spi/issuerservice/credential-revocation-spi/build.gradle.kts new file mode 100644 index 000000000..b54f9c964 --- /dev/null +++ b/spi/issuerservice/credential-revocation-spi/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * 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 + * + */ + +plugins { + `java-library` + `java-test-fixtures` + `maven-publish` +} + +dependencies { + + api(project(":spi:verifiable-credential-spi")) + api(libs.edc.spi.core) + api(libs.edc.spi.vc) + + testFixturesImplementation(libs.edc.junit) + testFixturesImplementation(libs.assertj) + testFixturesImplementation(libs.junit.jupiter.api) +} diff --git a/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListCredentialFactoryRegistry.java b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListCredentialFactoryRegistry.java new file mode 100644 index 000000000..04126a4ec --- /dev/null +++ b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListCredentialFactoryRegistry.java @@ -0,0 +1,37 @@ +/* + * 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.issuerservice.spi.statuslist; + +/** + * Maintains specific implementations for statuslist factories. + */ +public interface StatusListCredentialFactoryRegistry { + /** + * Register a {@link StatusListInfoFactory} for a particular "type". This type must be the {@code credentialStatus.type} + * field of a holder verifiable credential, for example {@code "BitStringStatusListEntry} + * + * @param type the {@code credentialStatus.type} value of the holder credential + * @return returns the specific factory for that type + */ + StatusListInfoFactory getStatusListCredential(String type); + + /** + * Adds a {@link StatusListInfoFactory} for a specific status list type. + * + * @param type the type, i.e. the value of the {@code credentialStatus.type} field of the holder credential, e.g. {@code "BitStringStatusListEntry} + * @param factory the factory for that type, or null + */ + void register(String type, StatusListInfoFactory factory); +} diff --git a/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfo.java b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfo.java new file mode 100644 index 000000000..1c55f5016 --- /dev/null +++ b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfo.java @@ -0,0 +1,34 @@ +/* + * 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.issuerservice.spi.statuslist; + +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.spi.result.Result; + +/** + * A StatusListInfo is a proxy object that transparently allows getting and setting the status flag of a status list credential. + * This does not specify the status purpose, it merely gets and sets status bits on the status list credential. + *

+ * So, if there are two status list credentials, one for "revocation", one for "suspension", then there would be two {@link StatusListInfo} instances. + *

+ * {@link StatusListInfo} objects are created by a {@link StatusListInfoFactory}. + */ +public interface StatusListInfo { + Result getStatus(); + + Result setStatus(boolean status); + + VerifiableCredentialResource statusListCredential(); +} diff --git a/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfoFactory.java b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfoFactory.java new file mode 100644 index 000000000..56eeeed7e --- /dev/null +++ b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListInfoFactory.java @@ -0,0 +1,32 @@ +/* + * 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.issuerservice.spi.statuslist; + +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus; +import org.eclipse.edc.spi.result.ServiceResult; + +/** + * Creates {@link StatusListInfo} objects depending on the {@link CredentialStatus} object of the holder credential. This + * is independent of the status purpose, but a separate {@link StatusListInfo} should be created for each status purpose. + */ +public interface StatusListInfoFactory { + /** + * Creates a {@link StatusListInfo} object based on the credential status of the holder credential. Holder credential + * may have multiple status objects, and one {@link StatusListInfo} must be created each. + * + * @param credentialStatus The credential status + */ + ServiceResult create(CredentialStatus credentialStatus); +} diff --git a/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListService.java b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListService.java new file mode 100644 index 000000000..9b22fa099 --- /dev/null +++ b/spi/issuerservice/credential-revocation-spi/src/main/java/org/eclipse/edc/issuerservice/spi/statuslist/StatusListService.java @@ -0,0 +1,71 @@ +/* + * 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.issuerservice.spi.statuslist; + +import org.eclipse.edc.spi.result.ServiceResult; +import org.jetbrains.annotations.Nullable; + +/** + * Service to revoke, suspend, resume and query the status of VerifiableCredentials. This is agnostic of the status list + * implementation, as it delegates down to {@link StatusListInfo} objects that handle the concrete status list implementation. + * This service handles various operations on a high level. + */ +public interface StatusListService { + + /** + * Revokes a credential by adding its ID to the revocation list credential. Implementations may choose to also track + * the status in the internal database. This operation is irreversible. + *

+ * Note that the specific revocation credential is determined by inspecting the user credentials + * {@code credentialSubject.statusListCredential} field. + * + * @param credentialId The ID of the credential. + * @param reason An optional string indicating the reason for revocation, e.g. "offboarding", etc. + * @return a service result indicating success or failure + */ + ServiceResult revokeCredential(String credentialId, @Nullable String reason); + + /** + * Suspends a credential by adding its ID to the revocation list credential. Implementations may choose to also track + * the status in the internal database + * + * @param credentialId The ID of the credential. + * @param reason An optional string indicating the reason for suspension, e.g. "temporary account suspension", etc. + * @return a service result indicating success or failure + * @throws UnsupportedOperationException if this revocation service does not support suspension + */ + ServiceResult suspendCredential(String credentialId, @Nullable String reason); + + /** + * Removes the "suspended" state from the revocation credential. + * + * @param credentialId The ID of the credential. + * @param reason An optional string indicating the reason for resuming, e.g. "account reactivation", etc. + * @return a service result indicating success or failure + * @throws UnsupportedOperationException if this revocation service does not support suspension/resuming + */ + ServiceResult resumeCredential(String credentialId, @Nullable String reason); + + /** + * Obtains the status for a given credential. This is done by parsing the StatusList credential, decoding the bitstring + * and interpreting the status purpose. + *

+ * Alternatively, users can inspect {@code VerifiableCredentialResource#getState()} + * + * @param credentialId The ID of the credential. + * @return A string containing the credential status, null if the status is not set, or a failure to indicate an error. + */ + ServiceResult getCredentialStatus(String credentialId); +} diff --git a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/model/VerifiableCredentialResource.java b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/model/VerifiableCredentialResource.java index bffca81de..db38ebd26 100644 --- a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/model/VerifiableCredentialResource.java +++ b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/model/VerifiableCredentialResource.java @@ -99,6 +99,10 @@ public void setCredentialStatus(VcStatus status) { timeOfLastStatusUpdate = Instant.now(); } + public Builder toBuilder() { + return new Builder(this); + } + public static class Builder extends IdentityResource.Builder { protected Builder(VerifiableCredentialResource resource) { diff --git a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/store/CredentialStore.java b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/store/CredentialStore.java index 1b89c65c6..416c82218 100644 --- a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/store/CredentialStore.java +++ b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/store/CredentialStore.java @@ -65,4 +65,12 @@ default String alreadyExistsErrorMessage(String id) { default String notFoundErrorMessage(String id) { return "A VerifiableCredentialResource with ID '%s' does not exist.".formatted(id); } + + /** + * Obtains a single credential by its ID + * + * @param credentialId the credential ID + * @return a result containing the {@link VerifiableCredentialResource}, or an error if not found etc. + */ + StoreResult findById(String credentialId); }