From 500d7b53ef75b9bafc6a954fc3b5ddab35291ee3 Mon Sep 17 00:00:00 2001 From: RubenModamioGarcia Date: Fri, 13 Dec 2024 11:44:28 +0100 Subject: [PATCH] Add time window validation for the credential in the Verifiable Presentation --- CHANGELOG.md | 4 + build.gradle | 2 +- .../exception/CredentialExpiredException.java | 7 ++ .../CredentialNotActiveException.java | 7 ++ .../handler/GlobalExceptionHandler.java | 14 +++ .../credentials/lear/LEARCredential.java | 2 + .../verifier/service/impl/VpServiceImpl.java | 49 +++++++-- .../handler/GlobalExceptionHandlerTest.java | 29 ++++- .../verifier/service/VpServiceImplTest.java | 102 ++++++++++++++++++ 9 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 src/main/java/es/in2/verifier/exception/CredentialExpiredException.java create mode 100644 src/main/java/es/in2/verifier/exception/CredentialNotActiveException.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8389c..a9da6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.0.16](https://github.com/in2workspace/in2-verifier-api/releases/tag/v1.0.16) +### Fixed +- Add time window validation for the credential in the Verifiable Presentation + ## [v1.0.15](https://github.com/in2workspace/in2-verifier-api/releases/tag/v1.0.15) ### Fixed - Fix token serialization issue diff --git a/build.gradle b/build.gradle index f52685b..9752f09 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ plugins { } group = 'es.in2' -version = '1.0.15' +version = '1.0.16' java { toolchain { diff --git a/src/main/java/es/in2/verifier/exception/CredentialExpiredException.java b/src/main/java/es/in2/verifier/exception/CredentialExpiredException.java new file mode 100644 index 0000000..3a34f57 --- /dev/null +++ b/src/main/java/es/in2/verifier/exception/CredentialExpiredException.java @@ -0,0 +1,7 @@ +package es.in2.verifier.exception; + +public class CredentialExpiredException extends RuntimeException { + public CredentialExpiredException(String message) { + super(message); + } +} diff --git a/src/main/java/es/in2/verifier/exception/CredentialNotActiveException.java b/src/main/java/es/in2/verifier/exception/CredentialNotActiveException.java new file mode 100644 index 0000000..6e85e6b --- /dev/null +++ b/src/main/java/es/in2/verifier/exception/CredentialNotActiveException.java @@ -0,0 +1,7 @@ +package es.in2.verifier.exception; + +public class CredentialNotActiveException extends RuntimeException { + public CredentialNotActiveException(String message) { + super(message); + } +} diff --git a/src/main/java/es/in2/verifier/exception/handler/GlobalExceptionHandler.java b/src/main/java/es/in2/verifier/exception/handler/GlobalExceptionHandler.java index 75f4ab6..0f1bb91 100644 --- a/src/main/java/es/in2/verifier/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/es/in2/verifier/exception/handler/GlobalExceptionHandler.java @@ -52,6 +52,20 @@ public GlobalErrorMessage handleException(MismatchOrganizationIdentifierExceptio return new GlobalErrorMessage("","",""); } + @ExceptionHandler(CredentialExpiredException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public GlobalErrorMessage handleException(CredentialExpiredException ex) { + log.error("The credential has expired: ", ex); + return new GlobalErrorMessage("","",""); + } + + @ExceptionHandler(CredentialNotActiveException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public GlobalErrorMessage handleException(CredentialNotActiveException ex) { + log.error("The credential is not active yet: ", ex); + return new GlobalErrorMessage("","",""); + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public GlobalErrorMessage handleException(Exception ex) { diff --git a/src/main/java/es/in2/verifier/model/credentials/lear/LEARCredential.java b/src/main/java/es/in2/verifier/model/credentials/lear/LEARCredential.java index 5473339..58b671e 100644 --- a/src/main/java/es/in2/verifier/model/credentials/lear/LEARCredential.java +++ b/src/main/java/es/in2/verifier/model/credentials/lear/LEARCredential.java @@ -9,4 +9,6 @@ public interface LEARCredential { String issuerId(); // Adjusted to be common String mandateeId(); String mandatorOrganizationIdentifier(); + String validFrom(); + String validUntil(); } diff --git a/src/main/java/es/in2/verifier/service/impl/VpServiceImpl.java b/src/main/java/es/in2/verifier/service/impl/VpServiceImpl.java index 4b497bf..e7d70f3 100644 --- a/src/main/java/es/in2/verifier/service/impl/VpServiceImpl.java +++ b/src/main/java/es/in2/verifier/service/impl/VpServiceImpl.java @@ -18,6 +18,8 @@ import java.security.PublicKey; import java.text.ParseException; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -59,36 +61,39 @@ public boolean validateVerifiablePresentation(String verifiablePresentation) { // Step 1.1: Map the payload to a VerifiableCredential object LEARCredential learCredential = mapPayloadToVerifiableCredential(payload); - // Step 2: Validate the credential id is not in the revoked list + // Step 2: Validate the time window of the credential + log.debug("VpServiceImpl -- validateVerifiablePresentation -- Validating the time window of the credential"); + validateCredentialTimeWindow(learCredential); + + // Step 3: Validate the credential id is not in the revoked list log.debug("VpServiceImpl -- validateVerifiablePresentation -- Validating that the credential is not revoked"); validateCredentialNotRevoked(learCredential.id()); log.info("Credential is not revoked"); - // Step 3: Validate the issuer + // Step 4: Validate the issuer String credentialIssuerDid = learCredential.issuerId(); log.debug("VpServiceImpl -- validateVerifiablePresentation -- Retrieved issuer DID from payload: {}", credentialIssuerDid); - // Step 4: Extract and validate credential types + // Step 5: Extract and validate credential types List credentialTypes = learCredential.type(); log.debug("VpServiceImpl -- validateVerifiablePresentation -- Credential types extracted: {}", credentialTypes); - // Step 5: Retrieve the list of issuer capabilities + // Step 6: Retrieve the list of issuer capabilities log.debug("VpServiceImpl -- validateVerifiablePresentation -- Retrieving issuer capabilities for DID {}", credentialIssuerDid); List issuerCapabilitiesList = trustFrameworkService.getTrustedIssuerListData(credentialIssuerDid); log.info("Retrieved issuer capabilities"); - // Step 6: Validate credential type against issuer capabilities + // Step 7: Validate credential type against issuer capabilities log.debug("VpServiceImpl -- validateVerifiablePresentation -- Validating credential types against issuer capabilities"); validateCredentialTypeWithIssuerCapabilities(issuerCapabilitiesList, credentialTypes); log.info("Issuer DID {} is a trusted participant", credentialIssuerDid); // TODO remove step 7 after the advanced certificate validation component is implemented - // Step 7: Verify the signature and the organizationId of the credential signature - // FIXME: comented. need to be implemented when all users has a valid credentials - // Map vcHeader = jwtCredential.getHeader().toJSONObject(); - // certificateValidationService.extractAndVerifyCertificate(jwtCredential.serialize(),vcHeader, credentialIssuerDid.substring("did:elsi:".length())); // Extract public key from x5c certificate and validate OrganizationIdentifier + // Step 8: Verify the signature and the organizationId of the credential signature + Map vcHeader = jwtCredential.getHeader().toJSONObject(); + certificateValidationService.extractAndVerifyCertificate(jwtCredential.serialize(),vcHeader, credentialIssuerDid.substring("did:elsi:".length())); // Extract public key from x5c certificate and validate OrganizationIdentifier - // Step 8: Extract the mandator organization identifier from the Verifiable Credential + // Step 9: Extract the mandator organization identifier from the Verifiable Credential String mandatorOrganizationIdentifier = learCredential.mandatorOrganizationIdentifier(); log.debug("VpServiceImpl -- validateVerifiablePresentation -- Extracted Mandator Organization Identifier from Verifiable Credential: {}", mandatorOrganizationIdentifier); @@ -97,7 +102,7 @@ public boolean validateVerifiablePresentation(String verifiablePresentation) { trustFrameworkService.getTrustedIssuerListData(DID_ELSI_PREFIX + mandatorOrganizationIdentifier); log.info("Mandator OrganizationIdentifier {} is valid and allowed", mandatorOrganizationIdentifier); - // Step 9: Validate the VP's signature with the DIDService (the DID of the holder of the VP) + // Step 10: Validate the VP's signature with the DIDService (the DID of the holder of the VP) String mandateeId = learCredential.mandateeId(); PublicKey holderPublicKey = didService.getPublicKeyFromDid(mandateeId); // Get the holder's public key in bytes jwtService.verifyJWTWithECKey(verifiablePresentation, holderPublicKey); // Validate the VP was signed by the holder DID @@ -207,6 +212,28 @@ private void validateCredentialNotRevoked(String credentialId) { } + private void validateCredentialTimeWindow(LEARCredential credential) { + try { + ZonedDateTime validFrom = ZonedDateTime.parse(credential.validFrom()); + ZonedDateTime validUntil = ZonedDateTime.parse(credential.validUntil()); + ZonedDateTime now = ZonedDateTime.now(); + + // Check if the credential is not yet valid + if (now.isBefore(validFrom)) { + throw new CredentialNotActiveException("Credential is not yet valid. Valid from: " + validFrom); + } + + // Check if the credential has expired + if (now.isAfter(validUntil)) { + throw new CredentialExpiredException("Credential has expired. Valid until: " + validUntil); + } + + } catch (DateTimeParseException e) { + throw new CredentialMappingException("Invalid date format in credential: " + e.getMessage()); + } + } + + private JsonNode convertObjectToJSONNode(Object vcObject) throws JsonConversionException { JsonNode jsonNode; diff --git a/src/test/java/es/in2/vcverifier/exception/handler/GlobalExceptionHandlerTest.java b/src/test/java/es/in2/vcverifier/exception/handler/GlobalExceptionHandlerTest.java index 2519028..c0e7451 100644 --- a/src/test/java/es/in2/vcverifier/exception/handler/GlobalExceptionHandlerTest.java +++ b/src/test/java/es/in2/vcverifier/exception/handler/GlobalExceptionHandlerTest.java @@ -1,10 +1,6 @@ package es.in2.vcverifier.exception.handler; -import es.in2.verifier.exception.InvalidVPtokenException; -import es.in2.verifier.exception.CredentialRevokedException; -import es.in2.verifier.exception.MismatchOrganizationIdentifierException; -import es.in2.verifier.exception.QRCodeGenerationException; -import es.in2.verifier.exception.ResourceNotFoundException; +import es.in2.verifier.exception.*; import es.in2.verifier.exception.handler.GlobalExceptionHandler; import es.in2.verifier.model.GlobalErrorMessage; import jakarta.servlet.http.HttpServletRequest; @@ -14,6 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import javax.security.auth.login.CredentialExpiredException; import java.util.NoSuchElementException; import static org.assertj.core.api.Assertions.assertThat; @@ -107,4 +104,26 @@ void testHandleInvalidVPtokenException() { assertThat(response.message()).isEqualTo("Invalid VP token"); assertThat(response.path()).isEqualTo("/test-path"); } + + @Test + void testHandleCredentialExpiredException() { + CredentialExpiredException exception = new CredentialExpiredException("Credential expired"); + + GlobalErrorMessage response = globalExceptionHandler.handleException(exception); + + assertThat(response.title()).isEmpty(); + assertThat(response.message()).isEmpty(); + assertThat(response.path()).isEmpty(); + } + + @Test + void testHandleCredentialNotActiveException() { + CredentialNotActiveException exception = new CredentialNotActiveException("Credential not active"); + + GlobalErrorMessage response = globalExceptionHandler.handleException(exception); + + assertThat(response.title()).isEmpty(); + assertThat(response.message()).isEmpty(); + assertThat(response.path()).isEmpty(); + } } diff --git a/src/test/java/es/in2/verifier/service/VpServiceImplTest.java b/src/test/java/es/in2/verifier/service/VpServiceImplTest.java index bc3d85a..afac6bd 100644 --- a/src/test/java/es/in2/verifier/service/VpServiceImplTest.java +++ b/src/test/java/es/in2/verifier/service/VpServiceImplTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.Payload; import com.nimbusds.jose.shaded.gson.internal.LinkedTreeMap; import com.nimbusds.jwt.JWTClaimsSet; @@ -32,6 +33,7 @@ import java.security.interfaces.ECPublicKey; import java.security.spec.ECGenParameterSpec; import java.time.Instant; +import java.time.ZonedDateTime; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -313,6 +315,18 @@ void validateVerifiablePresentation_success() throws Exception { // Step 7: Validate the mandator with trusted issuer service when(trustFrameworkService.getTrustedIssuerListData(DID_ELSI_PREFIX + learCredentialEmployee.mandatorOrganizationIdentifier())).thenReturn(issuerCapabilitiesList); + // Step 7: Verify the signature and the organizationId of the credential signature + Map vcHeader = new HashMap<>(); + vcHeader.put("x5c", List.of("base64Cert")); + JWSHeader header = mock(JWSHeader.class); + when(jwtCredential.getHeader()).thenReturn(header); + when(header.toJSONObject()).thenReturn(vcHeader); + + + when(jwtCredential.serialize()).thenReturn(vcJwt); + + doNothing().when(certificateValidationService).extractAndVerifyCertificate(any(), eq(vcHeader),eq("issuer")); + // Step 8: Get the holder's public key PublicKey holderPublicKey = generateECPublicKey(); when(didService.getPublicKeyFromDid(learCredentialEmployee.mandateeId())).thenReturn(holderPublicKey); @@ -331,6 +345,92 @@ void validateVerifiablePresentation_success() throws Exception { } } + @Test + void validateVerifiablePresentation_invalidTimeWindowForExpired() throws Exception { + // Given + String invalidVP = "invalid-time-window.vp.jwt"; + ZonedDateTime now = ZonedDateTime.now(); + LEARCredentialEmployee expiredCredential = LEARCredentialEmployee.builder() + .validUntil(now.minusDays(1).toString()) + .validFrom(now.minusDays(2).toString()) + .build(); + + // Mock parsing del VP + SignedJWT vpSignedJWT = mock(SignedJWT.class); + try (MockedStatic mockedSignedJWT = mockStatic(SignedJWT.class)) { + mockedSignedJWT.when(() -> SignedJWT.parse(invalidVP)).thenReturn(vpSignedJWT); + + // Configurar claims del VP + JWTClaimsSet vpClaimsSet = mock(JWTClaimsSet.class); + when(vpSignedJWT.getJWTClaimsSet()).thenReturn(vpClaimsSet); + + Map vcClaimMap = new HashMap<>(); + String vcJwt = "invalid-time-window.vc.jwt"; + vcClaimMap.put("verifiableCredential", List.of(vcJwt)); + when(vpClaimsSet.getClaim("vp")).thenReturn(vcClaimMap); + + // Mock parsing del VC + SignedJWT jwtCredential = mock(SignedJWT.class); + mockedSignedJWT.when(() -> SignedJWT.parse(vcJwt)).thenReturn(jwtCredential); + + Payload payload = mock(Payload.class); + when(jwtService.getPayloadFromSignedJWT(jwtCredential)).thenReturn(payload); + + LinkedTreeMap vcFromPayload = new LinkedTreeMap<>(); + vcFromPayload.put("type", List.of("LEARCredentialEmployee")); + when(jwtService.getVCFromPayload(payload)).thenReturn(vcFromPayload); + when(objectMapper.convertValue(vcFromPayload, LEARCredentialEmployee.class)).thenReturn(expiredCredential); + + boolean result = vpServiceImpl.validateVerifiablePresentation(invalidVP); + + Assertions.assertThat(result).isFalse(); + + } + } + + @Test + void validateVerifiablePresentation_invalidTimeWindowForNotValidYet() throws Exception { + // Given + String invalidVP = "invalid-time-window.vp.jwt"; + ZonedDateTime now = ZonedDateTime.now(); + LEARCredentialEmployee expiredCredential = LEARCredentialEmployee.builder() + .validUntil(now.plusDays(1).toString()) + .validFrom(now.plusDays(1).toString()) + .build(); + + // Mock parsing del VP + SignedJWT vpSignedJWT = mock(SignedJWT.class); + try (MockedStatic mockedSignedJWT = mockStatic(SignedJWT.class)) { + mockedSignedJWT.when(() -> SignedJWT.parse(invalidVP)).thenReturn(vpSignedJWT); + + // Configurar claims del VP + JWTClaimsSet vpClaimsSet = mock(JWTClaimsSet.class); + when(vpSignedJWT.getJWTClaimsSet()).thenReturn(vpClaimsSet); + + Map vcClaimMap = new HashMap<>(); + String vcJwt = "invalid-time-window.vc.jwt"; + vcClaimMap.put("verifiableCredential", List.of(vcJwt)); + when(vpClaimsSet.getClaim("vp")).thenReturn(vcClaimMap); + + // Mock parsing del VC + SignedJWT jwtCredential = mock(SignedJWT.class); + mockedSignedJWT.when(() -> SignedJWT.parse(vcJwt)).thenReturn(jwtCredential); + + Payload payload = mock(Payload.class); + when(jwtService.getPayloadFromSignedJWT(jwtCredential)).thenReturn(payload); + + LinkedTreeMap vcFromPayload = new LinkedTreeMap<>(); + vcFromPayload.put("type", List.of("LEARCredentialEmployee")); + when(jwtService.getVCFromPayload(payload)).thenReturn(vcFromPayload); + when(objectMapper.convertValue(vcFromPayload, LEARCredentialEmployee.class)).thenReturn(expiredCredential); + + boolean result = vpServiceImpl.validateVerifiablePresentation(invalidVP); + + Assertions.assertThat(result).isFalse(); + + } + } + private ECPublicKey generateECPublicKey() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); @@ -360,6 +460,8 @@ private LEARCredentialEmployee getLEARCredentialEmployee(){ .id("urn:uuid:1234") .issuer("did:elsi:issuer") .credentialSubject(credentialSubject) + .validUntil(ZonedDateTime.now().plusDays(1).toString()) + .validFrom(ZonedDateTime.now().toString()) .build(); } }