Skip to content

Commit

Permalink
feat: implement bitstring revocation service
Browse files Browse the repository at this point in the history
  • Loading branch information
paullatzelsperger committed Feb 6, 2025
1 parent f0ebf46 commit 12176d3
Show file tree
Hide file tree
Showing 24 changed files with 1,392 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -36,4 +39,11 @@ protected QueryResolver<VerifiableCredentialResource> createQueryResolver() {
criterionOperatorRegistry.registerPropertyLookup(new CredentialResourceLookup());
return new ReflectionBasedQueryResolver<>(VerifiableCredentialResource.class, criterionOperatorRegistry);
}

@Override
public StoreResult<VerifiableCredentialResource> findById(String credentialId) {
return ofNullable(this.store.get(credentialId))
.map(StoreResult::success)
.orElseGet(() -> StoreResult.notFound(notFoundErrorMessage(credentialId)));
}
}
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")))

}
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);
}
}
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;
}
}
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())));

}

}
Loading

0 comments on commit 12176d3

Please sign in to comment.