-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement bitstring revocation service
- Loading branch information
1 parent
f0ebf46
commit 12176d3
Showing
24 changed files
with
1,392 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
16 changes: 16 additions & 0 deletions
16
core/issuerservice/issuerservice-credential-revocation/build.gradle.kts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"))) | ||
|
||
} |
35 changes: 35 additions & 0 deletions
35
...ava/org/eclipse/edc/issuerservice/statuslist/StatusListCredentialFactoryRegistryImpl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, StatusListInfoFactory> 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); | ||
} | ||
} |
68 changes: 68 additions & 0 deletions
68
...on/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceExtension.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
193 changes: 193 additions & 0 deletions
193
...ocation/src/main/java/org/eclipse/edc/issuerservice/statuslist/StatusListServiceImpl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, Object>> 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<String> privateKeyAlias; | ||
private final StatusListCredentialFactoryRegistry statusListCredentialFactoryRegistry; | ||
|
||
public StatusListServiceImpl(CredentialStore credentialStore, | ||
TransactionContext transactionContext, | ||
ObjectMapper objectMapper, | ||
Monitor monitor, | ||
TokenGenerationService tokenGenerationService, | ||
Supplier<String> 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<Void> 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<Void> suspendCredential(String credentialId, @Nullable String reason) { | ||
throw new UnsupportedOperationException("Not supported by this implementation."); | ||
} | ||
|
||
@Override | ||
public ServiceResult<Void> resumeCredential(String credentialId, @Nullable String reason) { | ||
throw new UnsupportedOperationException("Not supported by this implementation."); | ||
} | ||
|
||
@Override | ||
public ServiceResult<String> getCredentialStatus(String credentialId) { | ||
return transactionContext.execute(() -> getCredential(credentialId) | ||
.compose(this::getRevocationInfo) | ||
.compose(r -> from(r.getStatus()))); | ||
} | ||
|
||
private ServiceResult<VerifiableCredentialResource> 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<StatusListInfo> getRevocationInfo(VerifiableCredentialResource resource) { | ||
return getRevocationInfo(resource, REVOCATION); | ||
} | ||
|
||
private ServiceResult<StatusListInfo> 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()))); | ||
|
||
} | ||
|
||
} |
Oops, something went wrong.