Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: several smaller fixes re Credential Revocation #4097

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@
import org.eclipse.edc.verifiablecredentials.linkeddata.LdpVerifier;
import org.jetbrains.annotations.NotNull;

import java.net.URISyntaxException;
import java.time.Clock;
import java.util.Map;

import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.STATUSLIST_2021_URL;
import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD;
import static org.eclipse.edc.verifiablecredentials.jwt.JwtPresentationVerifier.JWT_VC_TOKEN_CONTEXT;

Expand Down Expand Up @@ -142,6 +144,12 @@ public void initialize(ServiceExtensionContext context) {
// TODO move in a separated extension?
signatureSuiteRegistry.register(JSON_2020_SIGNATURE_SUITE, new JwsSignature2020Suite(typeManager.getMapper(JSON_LD)));

try {
jsonLd.registerCachedDocument(STATUSLIST_2021_URL, getClass().getClassLoader().getResource("statuslist2021.json").toURI());
} catch (URISyntaxException e) {
context.getMonitor().warning("Could not load JSON-LD file", e);
}

participantAgentService.register(participantAgentServiceExtension);
}

Expand All @@ -151,7 +159,7 @@ public IdentityService createIdentityService(ServiceExtensionContext context) {
var validationAction = tokenValidationAction();

return new IdentityAndTrustService(secureTokenService, getOwnDid(context), getPresentationVerifier(context),
getCredentialServiceClient(context), validationAction, registry, clock, credentialServiceUrlResolver, claimTokenFunction, revocationListService);
getCredentialServiceClient(context), validationAction, registry, clock, credentialServiceUrlResolver, claimTokenFunction, createRevocationListService(context));
}

@Provider
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"@context": {
"@protected": true,
"StatusList2021Credential": {
"@id": "https://w3id.org/vc/status-list#StatusList2021Credential",
"@context": {
"@protected": true,
"id": "@id",
"type": "@type",
"description": "http://schema.org/description",
"name": "http://schema.org/name"
}
},
"StatusList2021": {
"@id": "https://w3id.org/vc/status-list#StatusList2021",
"@context": {
"@protected": true,
"id": "@id",
"type": "@type",
"statusPurpose": "https://w3id.org/vc/status-list#statusPurpose",
"encodedList": "https://w3id.org/vc/status-list#encodedList"
}
},
"StatusList2021Entry": {
"@id": "https://w3id.org/vc/status-list#StatusList2021Entry",
"@context": {
"@protected": true,
"id": "@id",
"type": "@type",
"statusPurpose": "https://w3id.org/vc/status-list#statusPurpose",
"statusListIndex": "https://w3id.org/vc/status-list#statusListIndex",
"statusListCredential": {
"@id": "https://w3id.org/vc/status-list#statusListCredential",
"@type": "@id"
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import org.junit.jupiter.api.extension.ExtendWith;

import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.atLeastOnce;
Expand All @@ -39,9 +38,7 @@ class IdentityAndTrustExtensionTest {
@BeforeEach
void setUp(ServiceExtensionContext context) {
context.registerService(SecureTokenService.class, mock());
TypeManager mockedTm = mock();
when(mockedTm.getMapper(eq(JSON_LD))).thenReturn(mock());
context.registerService(TypeManager.class, mockedTm);
context.registerService(TypeManager.class, new TypeManager());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import org.eclipse.edc.iam.identitytrust.service.validation.rules.HasValidIssuer;
import org.eclipse.edc.iam.identitytrust.service.validation.rules.HasValidSubjectIds;
import org.eclipse.edc.iam.identitytrust.service.validation.rules.IsNotExpired;
import org.eclipse.edc.iam.identitytrust.service.validation.rules.IsInValidityPeriod;
import org.eclipse.edc.iam.identitytrust.service.validation.rules.IsNotRevoked;
import org.eclipse.edc.iam.identitytrust.spi.ClaimTokenCreatorFunction;
import org.eclipse.edc.iam.identitytrust.spi.CredentialServiceClient;
Expand Down Expand Up @@ -189,7 +189,7 @@ private Result<Void> validateVerifiableCredentials(List<VerifiableCredential> cr

// in addition, verify that all VCs are valid
var filters = new ArrayList<>(List.of(
new IsNotExpired(clock),
new IsInValidityPeriod(clock),
new HasValidSubjectIds(issuer),
new IsNotRevoked(revocationListService),
new HasValidIssuer(getTrustedIssuerIds())));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,27 @@

import java.time.Clock;

public class IsNotExpired implements CredentialValidationRule {
/**
* Rule that verifies, that a credential is already valid ({@code issuanceDate} is before NOW), and that it is not yet expired.
* {@code expirationDate} is not mandatory, so expiration is only checked if it is present.
*/
public class IsInValidityPeriod implements CredentialValidationRule {
private final Clock clock;

public IsNotExpired(Clock clock) {
public IsInValidityPeriod(Clock clock) {
this.clock = clock;
}

@Override
public Result<Void> apply(VerifiableCredential credential) {
var now = clock.instant();
// issuance date can not be null, due to builder validation
return credential.getIssuanceDate().isAfter(clock.instant()) ?
Result.failure("Credential is not yet valid.") :
Result.success();
if (credential.getIssuanceDate().isAfter(now)) {
return Result.failure("Credential is not yet valid.");
}
if (credential.getExpirationDate() != null && credential.getExpirationDate().isBefore(now)) {
return Result.failure("Credential expired.");
}
return Result.success();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ private Result<Void> checkStatus(StatusListStatus status) {
var bitset = BitSet.valueOf(bytes);
var index = status.getStatusListIndex();
if (bitset.get(index)) {
return Result.failure("Credential status is '%s', status at index %d is '1''".formatted(purpose, index));
return Result.failure("Credential status is '%s', status at index %d is '1'".formatted(purpose, index));
}
return Result.success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@

import java.util.Map;

import static org.eclipse.edc.iam.verifiablecredentials.TestData.STATUS_LIST_CREDENTIAL;
import static org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusList2021Credential.STATUS_LIST_CREDENTIAL;
import static org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusList2021Credential.STATUS_LIST_INDEX;
import static org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusList2021Credential.STATUS_LIST_PURPOSE;
import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat;
import static org.eclipse.edc.util.io.Ports.getFreePort;
import static org.mockserver.model.HttpRequest.request;
Expand All @@ -42,25 +44,25 @@ class StatusList2021RevocationServiceTest {
void setup() {
clientAndServer = ClientAndServer.startClientAndServer("localhost", getFreePort());
clientAndServer.when(request().withMethod("GET").withPath("/credentials/status/3"))
.respond(HttpResponse.response().withStatusCode(200).withBody(STATUS_LIST_CREDENTIAL));
.respond(HttpResponse.response().withStatusCode(200).withBody(TestData.STATUS_LIST_CREDENTIAL));
}

@Test
void checkRevocation_whenNotCached_valid() {
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of("statusPurpose", "revocation",
"statusListIndex", NOT_REVOKED_INDEX,
"statusListCredential", "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
Map.of(STATUS_LIST_PURPOSE, "revocation",
STATUS_LIST_INDEX, NOT_REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.checkValidity(credential)).isSucceeded();
}

@Test
void checkRevocation_whenNotCached_credentialPurposeMismatch() {
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of("statusPurpose", "suspension",
"statusListIndex", NOT_REVOKED_INDEX,
"statusListCredential", "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
Map.of(STATUS_LIST_PURPOSE, "suspension",
STATUS_LIST_INDEX, NOT_REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.checkValidity(credential)).isFailed()
.detail().startsWith("Credential's statusPurpose value must match the status list's purpose:");
Expand All @@ -69,20 +71,20 @@ void checkRevocation_whenNotCached_credentialPurposeMismatch() {
@Test
void checkRevocation_whenNotCached_invalid() {
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of("statusPurpose", "revocation",
"statusListIndex", REVOKED_INDEX,
"statusListCredential", "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
Map.of(STATUS_LIST_PURPOSE, "revocation",
STATUS_LIST_INDEX, REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.checkValidity(credential)).isFailed()
.detail().isEqualTo("Credential status is 'revocation', status at index %d is '1''".formatted(REVOKED_INDEX));
.detail().isEqualTo("Credential status is 'revocation', status at index %d is '1'".formatted(REVOKED_INDEX));
}

@Test
void checkRevocation_whenCached_valid() {
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of("statusPurpose", "revocation",
"statusListIndex", NOT_REVOKED_INDEX,
"statusListCredential", "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021Entry",
Map.of(STATUS_LIST_PURPOSE, "revocation",
STATUS_LIST_INDEX, NOT_REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.checkValidity(credential)).isSucceeded();
assertThat(revocationService.checkValidity(credential)).isSucceeded();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ public class TestData {
"credentialSubject": [{
"id": "https://example.com/status/3#list",
"type": "StatusList2021",
"statusPurpose": "revocation",
"encodedList": "H4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA"
"https://w3id.org/vc/status-list#statusPurpose": "revocation",
"https://w3id.org/vc/status-list#encodedList": "H4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA"
}]
}
""";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ public interface VcConstants {
String JWS_2020_SIGNATURE_SUITE = "JsonWebSignature2020";
String ED25519_SIGNATURE_SUITE = "Ed25519Signature2020"; // not used right now
String VC_PREFIX_V2 = "https://www.w3.org/ns/credentials/v2";
String STATUSLIST_2021_URL = "https://w3id.org/vc/status-list/2021/v1";
String STATUSLIST_2021_PREFIX = "https://w3id.org/vc/status-list#";
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential;

import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.STATUSLIST_2021_PREFIX;

/**
* Represents a special {@link VerifiableCredential}, specifically a <a href="https://www.w3.org/TR/2023/WD-vc-status-list-20230427/">W3C StatusList2021</a> credential.
* That means that the shape of the {@link VerifiableCredential#getCredentialSubject()} is not arbitrary anymore, but must contain specific items.
Expand All @@ -26,8 +28,10 @@
public class StatusList2021Credential extends VerifiableCredential {
public static final String STATUSLIST_2021_TYPE = "StatusList2021";
public static final String STATUSLIST_2021_CREDENTIAL = STATUSLIST_2021_TYPE + "Credential";
public static final String ENCODED_LIST = "encodedList";
public static final String STATUS_PURPOSE = "statusPurpose";
public static final String STATUS_LIST_ENCODED_LIST = STATUSLIST_2021_PREFIX + "encodedList";
public static final String STATUS_LIST_CREDENTIAL = STATUSLIST_2021_PREFIX + "statusListCredential";
public static final String STATUS_LIST_INDEX = STATUSLIST_2021_PREFIX + "statusListIndex";
public static final String STATUS_LIST_PURPOSE = STATUSLIST_2021_PREFIX + "statusPurpose";

private StatusList2021Credential() {
}
Expand All @@ -47,11 +51,11 @@ public static StatusList2021Credential parse(VerifiableCredential rawCredential)
}

public String encodedList() {
return (String) credentialSubject.get(0).getClaims().get(ENCODED_LIST);
return (String) credentialSubject.get(0).getClaims().get(STATUS_LIST_ENCODED_LIST);
}

public String statusPurpose() {
return (String) credentialSubject.get(0).getClaims().get(STATUS_PURPOSE);
return (String) credentialSubject.get(0).getClaims().get(STATUS_LIST_PURPOSE);
}

public static class Builder extends VerifiableCredential.Builder<StatusList2021Credential, Builder> {
Expand Down Expand Up @@ -79,10 +83,10 @@ public StatusList2021Credential build() {

// check mandatory fields of the credentialSubject object
var subject = instance.credentialSubject.get(0);
if (!subject.getClaims().containsKey(ENCODED_LIST)) {
if (!subject.getClaims().containsKey(STATUS_LIST_ENCODED_LIST)) {
throw new IllegalArgumentException("Status list credentials must contain a 'credentialSubject.encodedList' field.");
}
if (!subject.getClaims().containsKey(STATUS_PURPOSE)) {
if (!subject.getClaims().containsKey(STATUS_LIST_PURPOSE)) {
throw new IllegalArgumentException("Status list credentials must contain a 'credentialSubject.statusPurpose' field.");
}
return instance;
Expand All @@ -93,4 +97,6 @@ protected Builder self() {
return this;
}
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,15 @@

import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus;

import java.util.Map;

import static java.util.Optional.ofNullable;

/**
* Specialized {@code credentialStatus}, that contains information mandated by the StatusList2021 standard.
*/
public class StatusListStatus {
public static final String STATUS_LIST_CREDENTIAL = "statusListCredential";
public static final String STATUS_LIST_INDEX = "statusListIndex";
public static final String STATUS_PURPOSE = "statusPurpose";


private String statusListPurpose;
private int statusListIndex;
private String statusListCredential;
Expand All @@ -35,24 +34,32 @@ private StatusListStatus() {

public static StatusListStatus parse(CredentialStatus status) {
var instance = new StatusListStatus();
instance.statusListCredential = ofNullable(status.additionalProperties().get(STATUS_LIST_CREDENTIAL))
instance.statusListCredential = ofNullable(getId(status))
.map(Object::toString)
.orElseThrow(() -> new IllegalArgumentException(missingProperty(STATUS_LIST_CREDENTIAL)));
.orElseThrow(() -> new IllegalArgumentException(missingProperty(StatusList2021Credential.STATUS_LIST_CREDENTIAL)));

instance.statusListIndex = ofNullable(status.additionalProperties().get(STATUS_LIST_INDEX))
instance.statusListIndex = ofNullable(status.additionalProperties().get(StatusList2021Credential.STATUS_LIST_INDEX))
.map(Object::toString)
.map(Integer::parseInt)
.orElseThrow(() -> new IllegalArgumentException(missingProperty(STATUS_LIST_INDEX)));
.orElseThrow(() -> new IllegalArgumentException(missingProperty(StatusList2021Credential.STATUS_LIST_INDEX)));

instance.statusListPurpose = ofNullable(status.additionalProperties().get(STATUS_PURPOSE))
instance.statusListPurpose = ofNullable(status.additionalProperties().get(StatusList2021Credential.STATUS_LIST_PURPOSE))
.map(Object::toString)
.orElseThrow(() -> new IllegalArgumentException(missingProperty(STATUS_PURPOSE)));
.orElseThrow(() -> new IllegalArgumentException(missingProperty(StatusList2021Credential.STATUS_LIST_PURPOSE)));

return instance;
}

private static Object getId(CredentialStatus status) {
var credentialId = status.additionalProperties().get(StatusList2021Credential.STATUS_LIST_CREDENTIAL);
if (credentialId instanceof Map<?, ?> map) {
return map.get("@id");
}
return credentialId;
}

private static String missingProperty(String property) {
return "A StatusList2021 credentialStatus must have a 'credentialStatus.%s' property".formatted(property);
return "A StatusList2021 credential must have a credentialStatus object with the '%s' property".formatted(property);
}

public String getStatusListPurpose() {
Expand All @@ -67,4 +74,5 @@ public String getStatusListCredential() {
return statusListCredential;
}


}
Loading
Loading