Skip to content

Commit

Permalink
feat: add credential subject validation (#4776)
Browse files Browse the repository at this point in the history
* feat: add rule to validate credential subjects

* improve fluent statement
  • Loading branch information
paullatzelsperger authored Feb 3, 2025
1 parent b78e5ab commit 893f6ca
Show file tree
Hide file tree
Showing 11 changed files with 407 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ public IdentityService createIdentityService(ServiceExtensionContext context) {
var validationAction = tokenValidationAction();

var credentialValidationService = new VerifiableCredentialValidationServiceImpl(createPresentationVerifier(context),
trustedIssuerRegistry, revocationServiceRegistry, clock);
trustedIssuerRegistry, revocationServiceRegistry, clock, typeManager.getMapper());

return new IdentityAndTrustService(secureTokenService, issuerId,
getCredentialServiceClient(context), validationAction, credentialServiceUrlResolver, claimTokenFunction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies {
api(project(":spi:common:verifiable-credentials-spi"))
api(project(":spi:common:http-spi"))
implementation(project(":core:common:lib:util-lib"))
implementation(libs.jsonschema)

testImplementation(testFixtures(project(":spi:common:verifiable-credentials-spi")))
testImplementation(libs.mockserver.netty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@

package org.eclipse.edc.iam.verifiablecredentials;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.edc.iam.verifiablecredentials.rules.HasValidIssuer;
import org.eclipse.edc.iam.verifiablecredentials.rules.HasValidSubjectIds;
import org.eclipse.edc.iam.verifiablecredentials.rules.HasValidSubjectSchema;
import org.eclipse.edc.iam.verifiablecredentials.rules.IsInValidityPeriod;
import org.eclipse.edc.iam.verifiablecredentials.rules.IsNotRevoked;
import org.eclipse.edc.iam.verifiablecredentials.spi.VerifiableCredentialValidationService;
Expand All @@ -40,12 +42,14 @@ public class VerifiableCredentialValidationServiceImpl implements VerifiableCred
private final TrustedIssuerRegistry trustedIssuerRegistry;
private final RevocationServiceRegistry revocationServiceRegistry;
private final Clock clock;
private final ObjectMapper mapper;

public VerifiableCredentialValidationServiceImpl(PresentationVerifier presentationVerifier, TrustedIssuerRegistry trustedIssuerRegistry, RevocationServiceRegistry revocationServiceRegistry, Clock clock) {
public VerifiableCredentialValidationServiceImpl(PresentationVerifier presentationVerifier, TrustedIssuerRegistry trustedIssuerRegistry, RevocationServiceRegistry revocationServiceRegistry, Clock clock, ObjectMapper mapper) {
this.presentationVerifier = presentationVerifier;
this.trustedIssuerRegistry = trustedIssuerRegistry;
this.revocationServiceRegistry = revocationServiceRegistry;
this.clock = clock;
this.mapper = mapper;
}

@Override
Expand All @@ -67,7 +71,8 @@ private Result<Void> validateVerifiableCredentials(List<VerifiableCredential> cr
new IsInValidityPeriod(clock),
new HasValidSubjectIds(presentationHolder),
new IsNotRevoked(revocationServiceRegistry),
new HasValidIssuer(trustedIssuerRegistry)));
new HasValidIssuer(trustedIssuerRegistry),
new HasValidSubjectSchema(mapper)));

filters.addAll(additionalRules);
var results = credentials
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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.iam.verifiablecredentials.rules;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential;
import org.eclipse.edc.iam.verifiablecredentials.spi.validation.CredentialValidationRule;
import org.eclipse.edc.spi.result.Result;

import java.net.URI;
import java.util.Objects;

/**
* Performs JSON Schema Validation of the credential subjects. Every credential subject must be validated against all
* credential schemas, and all validations must succeed in order for this rule to pass.
*/
public class HasValidSubjectSchema implements CredentialValidationRule {
private final ObjectMapper jsonMapper;
private final JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012, builder -> builder.enableSchemaCache(true));

public HasValidSubjectSchema(ObjectMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}


@Override
public Result<Void> apply(VerifiableCredential verifiableCredential) {
if (verifiableCredential.getCredentialSchema() == null || verifiableCredential.getCredentialSchema().isEmpty()) {
return Result.success();
}
return verifiableCredential.getCredentialSchema().stream().filter(Objects::nonNull).map(schema -> {
var schemaUrl = schema.id();
// returns the schema using the JsonSchemaFactory. The factory does some caching internally, so there is no need to cache again
var jsonSchema = factory.getSchema(URI.create(schemaUrl));

// validate all subjects against the current schema
var validationMessages = verifiableCredential.getCredentialSubject().stream()
.map(subject -> jsonMapper.convertValue(subject, JsonNode.class))
.flatMap(jsonNode -> jsonSchema.validate(jsonNode).stream())
.toList();
return validationMessages.isEmpty()
? Result.success()
: Result.<Void>failure("Error validating CredentialSubject against schema: " + validationMessages); //ValidationMessage overwrites toString()

}).reduce(Result::merge).orElseGet(Result::success);
}

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

package org.eclipse.edc.iam.verifiablecredentials;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialSchema;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialStatus;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialSubject;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.Issuer;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.RevocationServiceRegistry;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiablePresentationContainer;
import org.eclipse.edc.iam.verifiablecredentials.spi.validation.PresentationVerifier;
import org.eclipse.edc.iam.verifiablecredentials.spi.validation.TrustedIssuerRegistry;
import org.eclipse.edc.junit.testfixtures.TestUtils;
import org.eclipse.edc.spi.result.Result;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -47,22 +50,22 @@
@SuppressWarnings("unchecked")
class VerifiableCredentialValidationServiceImplTest {
public static final String CONSUMER_DID = "did:web:consumer";
private final PresentationVerifier verifierMock = mock();
private final TrustedIssuerRegistry trustedIssuerRegistryMock = mock();
private final PresentationVerifier verifier = mock();
private final TrustedIssuerRegistry trustedIssuerRegistry = mock();
private final RevocationServiceRegistry revocationServiceRegistry = mock();
private final VerifiableCredentialValidationServiceImpl service = new VerifiableCredentialValidationServiceImpl(verifierMock, trustedIssuerRegistryMock, revocationServiceRegistry, Clock.systemUTC());
private final VerifiableCredentialValidationServiceImpl validationService = new VerifiableCredentialValidationServiceImpl(verifier, trustedIssuerRegistry, revocationServiceRegistry, Clock.systemUTC(), new ObjectMapper());

@BeforeEach
void setUp() {
when(trustedIssuerRegistryMock.getSupportedTypes(TRUSTED_ISSUER)).thenReturn(Set.of(TrustedIssuerRegistry.WILDCARD));
when(trustedIssuerRegistry.getSupportedTypes(TRUSTED_ISSUER)).thenReturn(Set.of(TrustedIssuerRegistry.WILDCARD));
when(revocationServiceRegistry.checkValidity(any())).thenReturn(Result.success());
}

@Test
void cryptographicError() {
when(verifierMock.verifyPresentation(any())).thenReturn(Result.failure("Cryptographic error"));
when(verifier.verifyPresentation(any())).thenReturn(Result.failure("Cryptographic error"));
var presentations = List.of(createPresentationContainer());
var result = service.validate(presentations);
var result = validationService.validate(presentations);
assertThat(result).isFailed().detail().isEqualTo("Cryptographic error");
}

Expand All @@ -75,9 +78,9 @@ void notYetValid() {
.build()))
.build();
var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation);
when(verifierMock.verifyPresentation(any())).thenReturn(success());
when(verifier.verifyPresentation(any())).thenReturn(success());
var presentations = List.of(vpContainer);
var result = service.validate(presentations);
var result = validationService.validate(presentations);
assertThat(result).isFailed().messages()
.hasSizeGreaterThanOrEqualTo(1)
.contains("Credential is not yet valid.");
Expand All @@ -96,8 +99,8 @@ void oneInvalidSubjectId() {
.build()))
.build();
var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation);
when(verifierMock.verifyPresentation(any())).thenReturn(success());
var result = service.validate(List.of(vpContainer));
when(verifier.verifyPresentation(any())).thenReturn(success());
var result = validationService.validate(List.of(vpContainer));
assertThat(result).isFailed().messages()
.hasSizeGreaterThanOrEqualTo(1)
.contains("Not all credential subject IDs match the expected subject ID '%s'. Violating subject IDs: [invalid-subject-id]".formatted(CONSUMER_DID));
Expand All @@ -113,8 +116,8 @@ void credentialHasInvalidIssuer_issuerIsUrl() {
.build();

var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation);
when(verifierMock.verifyPresentation(any())).thenReturn(success());
var result = service.validate(List.of(vpContainer));
when(verifier.verifyPresentation(any())).thenReturn(success());
var result = validationService.validate(List.of(vpContainer));
assertThat(result).isFailed().messages()
.hasSizeGreaterThanOrEqualTo(1)
.contains("Credential types '[test-type]' are not supported for issuer 'invalid-issuer'");
Expand All @@ -133,8 +136,8 @@ void verify_singlePresentation_singleCredential() {
.build()))
.build();
var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation);
when(verifierMock.verifyPresentation(any())).thenReturn(success());
var result = service.validate(List.of(vpContainer));
when(verifier.verifyPresentation(any())).thenReturn(success());
var result = validationService.validate(List.of(vpContainer));
assertThat(result).isSucceeded();
}

Expand All @@ -157,8 +160,8 @@ void verify_singlePresentation_multipleCredentials() {
.build()))
.build();
var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation);
when(verifierMock.verifyPresentation(any())).thenReturn(success());
var result = service.validate(List.of(vpContainer));
when(verifier.verifyPresentation(any())).thenReturn(success());
var result = validationService.validate(List.of(vpContainer));
assertThat(result).isSucceeded();
}

Expand Down Expand Up @@ -200,9 +203,9 @@ void verify_multiplePresentations_multipleCredentialsEach() {
.build();
var vpContainer2 = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation2);

when(verifierMock.verifyPresentation(any())).thenReturn(success());
when(verifier.verifyPresentation(any())).thenReturn(success());

var result = service.validate(List.of(vpContainer1, vpContainer2));
var result = validationService.validate(List.of(vpContainer1, vpContainer2));
assertThat(result).isSucceeded();
}

Expand All @@ -220,12 +223,34 @@ void verify_revocationCheckFails() {
.build()))
.build();
var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_LD, presentation);
when(verifierMock.verifyPresentation(any())).thenReturn(success());
when(verifier.verifyPresentation(any())).thenReturn(success());
when(revocationServiceRegistry.checkValidity(any())).thenReturn(Result.failure("invalid"));

var result = service.validate(List.of(vpContainer));
var result = validationService.validate(List.of(vpContainer));
assertThat(result).isFailed()
.detail().isEqualTo("invalid");
}

@Test
void verify_subjectViolatesSchema() {
var presentation = createPresentationBuilder()
.type("VerifiablePresentation")
.holder(CONSUMER_DID)
.credentials(List.of(createCredentialBuilder()
.credentialSubjects(List.of(CredentialSubject.Builder.newInstance()
.id(CONSUMER_DID)
.claim("type", "PersonSchema")
.claim("name", "Foo Bar")
.claim("birthdate", 11237123) // violation: int instead of string
.build()))
.credentialSchema(new CredentialSchema(TestUtils.getResource("personSchema.json").toString(), "JsonSchema"))
.build()))
.build();
var vpContainer = new VerifiablePresentationContainer("test-vp", CredentialFormat.VC1_0_JWT, presentation);
when(verifier.verifyPresentation(any())).thenReturn(success());
var result = validationService.validate(List.of(vpContainer));
assertThat(result).isFailed().messages()
.hasSizeGreaterThanOrEqualTo(1)
.allMatch(s -> s.contains("Error validating CredentialSubject against schema"));
}
}
Loading

0 comments on commit 893f6ca

Please sign in to comment.