VYPR
High severity7.4GHSA Advisory· Published May 15, 2026· Updated May 15, 2026

Improper Verification of Cryptographic Signature in com.oviva.telematik:epa4all-client

CVE-2026-45575

Description

Impact

An attacker who can MITM the TLS connection between the client and the IDP (within the TI network) can substitute a forged discovery document. The forged document redirects u ri_puk_idp_enc and uri_puk_idp_sig to attacker-controlled URLs. The client then encrypts the SMC-B-signed challenge response to the attacker's encryption key and POSTs it to the attacker's auth endpoint. This captures the signed authentication material.

Patches

#36

Workarounds

None.

### Resources - MS-OVIVA-EPA4ALL-d453c1

Credits

Machine Spirits (contact@machinespirits.de)

  • Dr. rer. nat. Simon Weber
  • Dipl.-Inf. Volker Schönefeld
  • Chiara Fliegner

Affected products

1

Patches

1
9111d6fbb939

EPA-265: TLS Certificate Validation by Default (#36)

https://github.com/oviva-ag/epa4all-clientThomas RichnerMay 8, 2026via ghsa
22 files changed · +1429 39
  • epa4all-client/src/main/java/com/oviva/telematik/epa4all/client/Epa4AllClientFactoryBuilder.java+2 2 modified
    @@ -72,9 +72,9 @@ private KeyStore determineTrustStore(boolean isPu, KeyStore providedTrustStore)
         if (providedTrustStore != null) {
           return providedTrustStore;
         } else if (isPu) {
    -      return TelematikTrustRoots.loadPuTruststore();
    +      return TelematikTrustRoots.loadPuTrustStore();
         } else {
    -      return TelematikTrustRoots.loadRuTruststore();
    +      return TelematikTrustRoots.loadRuTrustStore();
         }
       }
     }
    
  • epa4all-client/src/main/java/com/oviva/telematik/epa4all/client/internal/Epa4AllClientFactory.java+13 8 modified
    @@ -60,16 +60,17 @@ public static Epa4AllClientFactory create(
           KonnektorService konnektorService,
           InetSocketAddress konnektorProxyAddress,
           Environment environment,
    -      KeyStore trustStore,
    +      KeyStore tiTrustStore,
           String telematikId) {
     
    -    var telematikSslContext = SslContextBuilder.buildSslContext(trustStore);
    -    var outerHttpClientTelematik = buildOuterHttpClient(konnektorProxyAddress, telematikSslContext);
    +    var telematikSslContext = SslContextBuilder.buildSslContext(tiTrustStore);
    +    var outerHttpClientTelematik =
    +        buildOuterHttpClientWithTlsContext(konnektorProxyAddress, telematikSslContext);
     
         var informationService = buildInformationService(environment, outerHttpClientTelematik);
     
         var proxyServer =
    -        buildVauProxy(environment, konnektorProxyAddress, trustStore, telematikSslContext);
    +        buildVauProxy(environment, konnektorProxyAddress, tiTrustStore, telematikSslContext);
     
         var serverInfo = proxyServer.start();
         var vauProxyServerListener = serverInfo.listenAddress();
    @@ -83,7 +84,8 @@ public static Epa4AllClientFactory create(
         var outerHttpClient = buildOuterHttpClient(konnektorProxyAddress);
     
         var authorizationService =
    -        buildAuthorizationService(innerVauClient, outerHttpClient, konnektorService, card);
    +        buildAuthorizationService(
    +            tiTrustStore, innerVauClient, outerHttpClient, konnektorService, card);
     
         var client =
             new SoapClientFactory(
    @@ -99,6 +101,7 @@ public Epa4AllClient newClient() {
       }
     
       private static AuthorizationService buildAuthorizationService(
    +      KeyStore telematikTrustStore,
           com.oviva.telematik.vau.httpclient.HttpClient innerVauClient,
           HttpClient outerHttpClient,
           KonnektorService konnektorService,
    @@ -107,7 +110,9 @@ private static AuthorizationService buildAuthorizationService(
         var signer = new EccSignatureAdapter(konnektorService, card);
     
         var authnChallengeResponder =
    -        new AuthnChallengeResponder(signer, new OidcClient(outerHttpClient));
    +        new AuthnChallengeResponder(
    +            signer,
    +            new OidcClient(outerHttpClient, new OidcDiscoveryValidatorImpl(telematikTrustStore)));
         var authnClientAttester = new AuthnClientAttester(signer);
         return new AuthorizationService(
             innerVauClient, outerHttpClient, authnChallengeResponder, authnClientAttester);
    @@ -203,13 +208,13 @@ private static com.oviva.telematik.vau.httpclient.HttpClient buildInnerHttpClien
     
       private static HttpClient buildOuterHttpClient(InetSocketAddress konnektorProxyAddress) {
         try {
    -      return buildOuterHttpClient(konnektorProxyAddress, SSLContext.getDefault());
    +      return buildOuterHttpClientWithTlsContext(konnektorProxyAddress, SSLContext.getDefault());
         } catch (NoSuchAlgorithmException e) {
           throw new ClientException("failed creating client", e);
         }
       }
     
    -  private static HttpClient buildOuterHttpClient(
    +  private static HttpClient buildOuterHttpClientWithTlsContext(
           InetSocketAddress konnektorProxyAddress, SSLContext sslContext) {
     
         return HttpClient.newBuilder()
    
  • epa4all-client/src/main/java/com/oviva/telematik/epa4all/client/internal/TelematikTrustRoots.java+25 2 modified
    @@ -6,17 +6,30 @@
     import java.security.NoSuchAlgorithmException;
     import java.security.NoSuchProviderException;
     import java.security.cert.CertificateException;
    +import javax.net.ssl.TrustManager;
    +import javax.net.ssl.TrustManagerFactory;
     import org.bouncycastle.jce.provider.BouncyCastleProvider;
     
     public class TelematikTrustRoots {
    +  private TelematikTrustRoots() {}
     
    +  // this is nothing secret - its just that Java and PKCS12 don't work well if there is no password
    +  // set at all
       private static final String TRUSTSTORE_PW = "1234";
     
    -  public static KeyStore loadRuTruststore() {
    +  public static TrustManager createPuTrustManager() {
    +    return convertToTrustManager(loadPuTrustStore());
    +  }
    +
    +  public static TrustManager createRuTrustManager() {
    +    return convertToTrustManager(loadRuTrustStore());
    +  }
    +
    +  public static KeyStore loadRuTrustStore() {
         return loadP12KeyStore("/truststore-test.p12");
       }
     
    -  public static KeyStore loadPuTruststore() {
    +  public static KeyStore loadPuTrustStore() {
         return loadP12KeyStore("/truststore-pu.p12");
       }
     
    @@ -36,4 +49,14 @@ private static KeyStore loadP12KeyStore(String path) {
           throw new IllegalStateException("failed to load keystore: " + path, e);
         }
       }
    +
    +  private static TrustManager convertToTrustManager(KeyStore trustStore) {
    +    try {
    +      var tmf = TrustManagerFactory.getInstance("PKIX");
    +      tmf.init(trustStore);
    +      return tmf.getTrustManagers()[0];
    +    } catch (KeyStoreException | NoSuchAlgorithmException e) {
    +      throw new IllegalStateException("failed to initialize TrustManager from KeyStore", e);
    +    }
    +  }
     }
    
  • epa4all-client/src/test/java/com/oviva/telematik/epa4all/client/Epa4AllClientFactoryBuilderTest.java+2 2 modified
    @@ -79,7 +79,7 @@ void build_shouldLoadRuTruststoreWhenNotProvidedAndEnvRu() {
             MockedStatic<Epa4AllClientFactory> factoryMock =
                 Mockito.mockStatic(Epa4AllClientFactory.class)) {
     
    -      trustRoots.when(TelematikTrustRoots::loadRuTruststore).thenReturn(ruTrustStore);
    +      trustRoots.when(TelematikTrustRoots::loadRuTrustStore).thenReturn(ruTrustStore);
     
           factoryMock
               .when(
    @@ -112,7 +112,7 @@ void build_shouldLoadPuTruststoreWhenNotProvidedAndEnvPu() {
             MockedStatic<Epa4AllClientFactory> factoryMock =
                 Mockito.mockStatic(Epa4AllClientFactory.class)) {
     
    -      trustRoots.when(TelematikTrustRoots::loadPuTruststore).thenReturn(puTrustStore);
    +      trustRoots.when(TelematikTrustRoots::loadPuTrustStore).thenReturn(puTrustStore);
     
           factoryMock
               .when(
    
  • epa4all-client/src/test/java/com/oviva/telematik/epa4all/client/internal/Epa4AllClientFactoryTest.java+126 0 modified
    @@ -5,12 +5,17 @@
     
     import com.oviva.epa.client.KonnektorService;
     import com.oviva.epa.client.model.SmcbCard;
    +import com.oviva.telematik.epa4all.client.Environment;
     import com.oviva.telematik.epaapi.SoapClientFactory;
     import com.oviva.telematik.vau.epa4all.client.Epa4AllClientException;
     import com.oviva.telematik.vau.epa4all.client.authz.AuthorizationService;
     import com.oviva.telematik.vau.epa4all.client.info.InformationService;
     import com.oviva.telematik.vau.proxy.VauProxy;
    +import java.net.InetSocketAddress;
    +import java.security.KeyStore;
    +import java.security.NoSuchAlgorithmException;
     import java.util.List;
    +import javax.net.ssl.SSLContext;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.Test;
     import org.junit.jupiter.api.extension.ExtendWith;
    @@ -128,4 +133,125 @@ void findSmcBCard_shouldThrowWhenTelematikIdNotFound() {
                 .getMessage()
                 .contains("no SMC-B card found for telematikId [ nonexistintent-telematik-id ]"));
       }
    +
    +  @Test
    +  void findSmcBCard_singleCard_nullTelematikId_returnsCard() {
    +    when(konnektorService.listSmcbCards()).thenReturn(List.of(mockCard1));
    +
    +    var result = Epa4AllClientFactory.findSmcBCard(konnektorService, null);
    +
    +    assertEquals(mockCard1, result);
    +  }
    +
    +  @Test
    +  void findSmcBCard_shouldSelectSecondCardByTelematikId() {
    +    when(konnektorService.listSmcbCards()).thenReturn(List.of(mockCard1, mockCard2));
    +
    +    var result = Epa4AllClientFactory.findSmcBCard(konnektorService, "test-telematik-id-2");
    +
    +    assertEquals(mockCard2, result);
    +  }
    +
    +  @Test
    +  void findSmcBCard_notFound_errorMessageContainsAvailableCardDetails() {
    +    when(konnektorService.listSmcbCards()).thenReturn(List.of(mockCard1, mockCard2));
    +
    +    var exception =
    +        assertThrows(
    +            Epa4AllClientException.class,
    +            () -> Epa4AllClientFactory.findSmcBCard(konnektorService, "unknown-id"));
    +
    +    var msg = exception.getMessage();
    +    assertTrue(msg.contains("'test-telematik-id-1' (Test Holder)"));
    +    assertTrue(msg.contains("'test-telematik-id-2' (Test Holder)"));
    +  }
    +
    +  @Test
    +  void create_noCardsFound_throwsEpa4AllClientException() throws NoSuchAlgorithmException {
    +    var proxyAddr = new InetSocketAddress("127.0.0.1", 9999);
    +
    +    var ks = mock(KonnektorService.class);
    +    when(ks.listSmcbCards()).thenReturn(List.of());
    +
    +    var trustStore = mock(KeyStore.class);
    +    var proxyListenAddr = new InetSocketAddress("127.0.0.1", 18080);
    +
    +    try (var sslMock = mockStatic(SslContextBuilder.class);
    +        var ignored =
    +            mockConstruction(
    +                VauProxy.class,
    +                (proxy, ctx) ->
    +                    when(proxy.start()).thenReturn(new VauProxy.ServerInfo(proxyListenAddr)))) {
    +      sslMock
    +          .when(() -> SslContextBuilder.buildSslContext(any()))
    +          .thenReturn(SSLContext.getDefault());
    +
    +      // When & Then
    +      assertThrows(
    +          Epa4AllClientException.class,
    +          () -> Epa4AllClientFactory.create(ks, proxyAddr, Environment.RU, trustStore, null));
    +    }
    +  }
    +
    +  @Test
    +  void create_withSingleCard_returnsFactory() throws NoSuchAlgorithmException {
    +    var proxyAddr = new InetSocketAddress("127.0.0.1", 9999);
    +    var ks = mock(KonnektorService.class);
    +    var card = mock(SmcbCard.class);
    +    when(ks.listSmcbCards()).thenReturn(List.of(card));
    +    when(card.telematikId()).thenReturn("test-id");
    +    var trustStore = mock(KeyStore.class);
    +    var proxyListenAddr = new InetSocketAddress("127.0.0.1", 18080);
    +
    +    try (var sslMock = mockStatic(SslContextBuilder.class);
    +        var ignored =
    +            mockConstruction(
    +                VauProxy.class,
    +                (proxy, ctx) ->
    +                    when(proxy.start()).thenReturn(new VauProxy.ServerInfo(proxyListenAddr)));
    +        var ignored2 = mockConstruction(SoapClientFactory.class)) {
    +      sslMock
    +          .when(() -> SslContextBuilder.buildSslContext(any()))
    +          .thenReturn(SSLContext.getDefault());
    +
    +      // When
    +      var result =
    +          Epa4AllClientFactory.create(ks, proxyAddr, Environment.RU, trustStore, "test-id");
    +
    +      // Then
    +      assertNotNull(result);
    +      assertInstanceOf(Epa4AllClientFactory.class, result);
    +    }
    +  }
    +
    +  @Test
    +  void create_withTelematikId_selectsMatchingCard() throws NoSuchAlgorithmException {
    +    var proxyAddr = new InetSocketAddress("127.0.0.1", 9999);
    +    var ks = mock(KonnektorService.class);
    +    var card1 = mock(SmcbCard.class);
    +    var card2 = mock(SmcbCard.class);
    +    when(card1.telematikId()).thenReturn("id-1");
    +    when(card2.telematikId()).thenReturn("id-2");
    +    when(ks.listSmcbCards()).thenReturn(List.of(card1, card2));
    +    var trustStore = mock(KeyStore.class);
    +    var proxyListenAddr = new InetSocketAddress("127.0.0.1", 18080);
    +
    +    try (var sslMock = mockStatic(SslContextBuilder.class);
    +        var ignored =
    +            mockConstruction(
    +                VauProxy.class,
    +                (proxy, ctx) ->
    +                    when(proxy.start()).thenReturn(new VauProxy.ServerInfo(proxyListenAddr)));
    +        var ignored2 = mockConstruction(SoapClientFactory.class)) {
    +      sslMock
    +          .when(() -> SslContextBuilder.buildSslContext(any()))
    +          .thenReturn(SSLContext.getDefault());
    +
    +      // When
    +      var result = Epa4AllClientFactory.create(ks, proxyAddr, Environment.RU, trustStore, "id-2");
    +
    +      // Then
    +      assertNotNull(result);
    +    }
    +  }
     }
    
  • epa4all-client/src/test/java/com/oviva/telematik/epa4all/client/internal/TelematikTrustRootsTest.java+88 0 added
    @@ -0,0 +1,88 @@
    +package com.oviva.telematik.epa4all.client.internal;
    +
    +import static org.junit.jupiter.api.Assertions.*;
    +
    +import java.security.Security;
    +import java.security.cert.X509Certificate;
    +import javax.net.ssl.X509TrustManager;
    +import org.bouncycastle.jce.provider.BouncyCastleProvider;
    +import org.junit.jupiter.api.BeforeAll;
    +import org.junit.jupiter.api.Test;
    +
    +class TelematikTrustRootsTest {
    +
    +  @BeforeAll
    +  static void setUp() {
    +    Security.addProvider(new BouncyCastleProvider());
    +  }
    +
    +  @Test
    +  void loadRuTrustStore_returnsNonEmptyKeyStore() throws Exception {
    +    var ks = TelematikTrustRoots.loadRuTrustStore();
    +
    +    assertNotNull(ks);
    +    assertTrue(ks.size() > 0, "RU trust store must contain at least one certificate");
    +  }
    +
    +  @Test
    +  void loadPuTrustStore_returnsNonEmptyKeyStore() throws Exception {
    +    var ks = TelematikTrustRoots.loadPuTrustStore();
    +
    +    assertNotNull(ks);
    +    assertTrue(ks.size() > 0, "PU trust store must contain at least one certificate");
    +  }
    +
    +  @Test
    +  void loadRuTrustStore_containsX509Certificates() throws Exception {
    +    var ks = TelematikTrustRoots.loadRuTrustStore();
    +
    +    var aliases = ks.aliases();
    +    while (aliases.hasMoreElements()) {
    +      var alias = aliases.nextElement();
    +      assertInstanceOf(X509Certificate.class, ks.getCertificate(alias));
    +    }
    +  }
    +
    +  @Test
    +  void loadPuTrustStore_containsX509Certificates() throws Exception {
    +    var ks = TelematikTrustRoots.loadPuTrustStore();
    +
    +    var aliases = ks.aliases();
    +    while (aliases.hasMoreElements()) {
    +      var alias = aliases.nextElement();
    +      assertInstanceOf(X509Certificate.class, ks.getCertificate(alias));
    +    }
    +  }
    +
    +  @Test
    +  void createRuTrustManager_returnsNonNull() {
    +    var tm = TelematikTrustRoots.createRuTrustManager();
    +
    +    assertNotNull(tm);
    +  }
    +
    +  @Test
    +  void createPuTrustManager_returnsNonNull() {
    +    var tm = TelematikTrustRoots.createPuTrustManager();
    +
    +    assertNotNull(tm);
    +  }
    +
    +  @Test
    +  void createRuTrustManager_hasAcceptedIssuers() {
    +    var tm = (X509TrustManager) TelematikTrustRoots.createRuTrustManager();
    +
    +    var issuers = tm.getAcceptedIssuers();
    +    assertNotNull(issuers);
    +    assertTrue(issuers.length > 0, "RU trust manager must have accepted issuers");
    +  }
    +
    +  @Test
    +  void createPuTrustManager_hasAcceptedIssuers() {
    +    var tm = (X509TrustManager) TelematikTrustRoots.createPuTrustManager();
    +
    +    var issuers = tm.getAcceptedIssuers();
    +    assertNotNull(issuers);
    +    assertTrue(issuers.length > 0, "PU trust manager must have accepted issuers");
    +  }
    +}
    
  • epa4all-client/src/test/java/com/oviva/telematik/epa4all/client/internal/TestKonnektors.java+2 1 modified
    @@ -37,12 +37,13 @@ public static KonnektorService riseKonnektor_RU() {
           var keys = loadKeys(keystoreFile, keystorePassword);
           var uri = URI.create(tiKonnektorUri);
     
    +      var tm = TelematikTrustRoots.createRuTrustManager();
           var cf =
               KonnektorConnectionFactoryBuilder.newBuilder()
                   .clientKeys(keys)
                   .konnektorUri(uri)
                   .proxyServer(proxyAdress, proxyPort)
    -              .trustAllServers() // currently we don't validate the server's certificate
    +              .trustManagers(List.of(tm))
                   .build();
     
           var conn = cf.connect();
    
  • epa4all-rest-service/src/main/java/com/oviva/telematik/epa4all/restservice/KeyStores.java+4 1 modified
    @@ -32,7 +32,10 @@ public static List<KeyManager> loadKeys(@NonNull Path keystoreFile, @Nullable St
             | KeyStoreException
             | CertificateException
             | UnrecoverableKeyException e) {
    -      throw new IllegalArgumentException("failed to load keys from %s".formatted(keystoreFile), e);
    +      throw new IllegalArgumentException(
    +          "failed to load keys from '%s', absolute path '%s'"
    +              .formatted(keystoreFile, keystoreFile.toAbsolutePath()),
    +          e);
         }
       }
     
    
  • epa4all-rest-service/src/main/java/com/oviva/telematik/epa4all/restservice/Main.java+11 2 modified
    @@ -9,6 +9,7 @@
     import com.oviva.telematik.epa4all.client.DuplicateDocumentClientException;
     import com.oviva.telematik.epa4all.client.Environment;
     import com.oviva.telematik.epa4all.client.NotAuthorizedClientException;
    +import com.oviva.telematik.epa4all.client.internal.TelematikTrustRoots;
     import com.oviva.telematik.epa4all.restservice.cfg.ConfigProvider;
     import com.oviva.telematik.epa4all.restservice.cfg.EnvConfigProvider;
     import io.undertow.Handlers;
    @@ -23,6 +24,7 @@
     import java.security.*;
     import java.util.*;
     import javax.net.ssl.KeyManager;
    +import javax.net.ssl.TrustManager;
     import org.bouncycastle.jce.provider.BouncyCastleProvider;
     import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
     import org.slf4j.Logger;
    @@ -115,7 +117,7 @@ private KonnektorService buildKonnektorService(
           KonnektorConnectionFactory factory, Config config) {
         return KonnektorServiceBuilder.newBuilder()
             .connection(factory.connect())
    -        .clientSystemId(config.clientSystemId)
    +        .clientSystemId(config.clientSystemId())
             .mandantId(config.mandantId())
             .workplaceId(config.workplaceId())
             .userId(config.userId())
    @@ -127,10 +129,17 @@ private KonnektorConnectionFactory buildFactory(Config cfg) {
             .clientKeys(cfg.clientKeys())
             .konnektorUri(cfg.konnektorUri())
             .proxyServer(cfg.proxyAddress(), cfg.proxyPort())
    -        .trustAllServers() // currently we don't validate the server's certificate
    +        .trustManagers(loadTrustManagers(cfg.environment()))
             .build();
       }
     
    +  private List<TrustManager> loadTrustManagers(Environment env) {
    +    return switch (env) {
    +      case PU -> List.of(TelematikTrustRoots.createPuTrustManager());
    +      case RU -> List.of(TelematikTrustRoots.createRuTrustManager());
    +    };
    +  }
    +
       record Config(
           URI konnektorUri,
           String proxyAddress,
    
  • epa4all-vau-client/src/main/java/com/oviva/telematik/vau/epa4all/client/authz/internal/jose/CertificateUtil.java+70 0 added
    @@ -0,0 +1,70 @@
    +package com.oviva.telematik.vau.epa4all.client.authz.internal.jose;
    +
    +import java.io.ByteArrayInputStream;
    +import java.io.IOException;
    +import java.security.NoSuchProviderException;
    +import java.security.cert.CertificateEncodingException;
    +import java.security.cert.CertificateException;
    +import java.security.cert.CertificateFactory;
    +import java.security.cert.X509Certificate;
    +import java.util.Optional;
    +import org.bouncycastle.asn1.ASN1Encodable;
    +import org.bouncycastle.asn1.ASN1ObjectIdentifier;
    +import org.bouncycastle.asn1.isismtt.ISISMTTObjectIdentifiers;
    +import org.bouncycastle.asn1.isismtt.x509.AdmissionSyntax;
    +import org.bouncycastle.cert.X509CertificateHolder;
    +import org.bouncycastle.jce.provider.BouncyCastleProvider;
    +
    +public class CertificateUtil {
    +  private CertificateUtil() {}
    +
    +  public static X509Certificate parseDer(byte[] bytes) throws CertificateException {
    +    try (var certInputStream = new ByteArrayInputStream(bytes)) {
    +      // MUST be bouncycastle to deal with the brainpool certificates
    +      var certFactory = CertificateFactory.getInstance("X.509", BouncyCastleProvider.PROVIDER_NAME);
    +      var cert = certFactory.generateCertificate(certInputStream);
    +      if (cert instanceof X509Certificate x509Cert) {
    +        return x509Cert;
    +      }
    +      throw new CertificateEncodingException("not an X.509 certificate");
    +    } catch (IOException | NoSuchProviderException e) {
    +      throw new CertificateException("failed to parse certificate", e);
    +    }
    +  }
    +
    +  public static Optional<ASN1ObjectIdentifier> getProfessionOid(
    +      X509Certificate trustedSigningCertificate) {
    +
    +    ASN1Encodable asn1Admission = null;
    +    try {
    +      asn1Admission =
    +          new X509CertificateHolder(trustedSigningCertificate.getEncoded())
    +              .getExtensions()
    +              .getExtensionParsedValue(ISISMTTObjectIdentifiers.id_isismtt_at_admission);
    +    } catch (IOException | CertificateEncodingException e) {
    +      throw new IllegalArgumentException("bad certificate", e);
    +    }
    +
    +    var admissionInstance = AdmissionSyntax.getInstance(asn1Admission);
    +
    +    var contents = admissionInstance.getContentsOfAdmissions();
    +    if (contents.length != 1) {
    +      return Optional.empty();
    +    }
    +
    +    var content = contents[0];
    +    var profInfos = content.getProfessionInfos();
    +    if (profInfos.length != 1) {
    +      return Optional.empty();
    +    }
    +
    +    var profInfo = profInfos[0];
    +
    +    var oids = profInfo.getProfessionOIDs();
    +    if (oids.length != 1) {
    +      return Optional.empty();
    +    }
    +
    +    return Optional.ofNullable(oids[0]);
    +  }
    +}
    
  • epa4all-vau-client/src/main/java/com/oviva/telematik/vau/epa4all/client/authz/internal/OidcClient.java+15 7 modified
    @@ -3,8 +3,8 @@
     import static java.util.function.Predicate.not;
     
     import com.fasterxml.jackson.annotation.JsonProperty;
    -import com.nimbusds.jose.JWSObject;
     import com.nimbusds.jose.jwk.JWK;
    +import com.nimbusds.jwt.SignedJWT;
     import com.nimbusds.oauth2.sdk.GeneralException;
     import com.nimbusds.oauth2.sdk.id.Issuer;
     import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
    @@ -24,9 +24,11 @@
     public class OidcClient {
     
       private final HttpClient httpClient;
    +  private final DiscoveryValidator discoveryValidator;
     
    -  public OidcClient(HttpClient httpClient) {
    +  public OidcClient(HttpClient httpClient, DiscoveryValidator discoveryValidator) {
         this.httpClient = httpClient;
    +    this.discoveryValidator = discoveryValidator;
       }
     
       public record OidcDiscoveryResponse(
    @@ -71,17 +73,19 @@ private OidcDiscoveryResponse parseResponse(HttpResponse<String> response) {
                             "Missing content-type header in discovery document response"));
     
         if (contentType.equals("application/jwt")) {
    -      return parseFromJwt(response.body());
    -    } else if (contentType.equals(MimeTypes.APPLICATION_JSON)) {
    -      return JsonCodec.readString(response.body(), OidcDiscoveryResponse.class);
    +      return verifyAndParse(response.body());
         }
         throw new AuthorizationException(
             "Unsupported content-type in discovery document response: " + contentType);
       }
     
    -  private OidcDiscoveryResponse parseFromJwt(String jwt) {
    +  private OidcDiscoveryResponse verifyAndParse(String jwt) {
         try {
    -      var payload = JWSObject.parse(jwt).getPayload();
    +
    +      // establish trust in the discovery document
    +      discoveryValidator.validate(SignedJWT.parse(jwt));
    +
    +      var payload = SignedJWT.parse(jwt).getPayload();
           return JsonCodec.readBytes(payload.toBytes(), OidcDiscoveryResponse.class);
         } catch (ParseException e) {
           throw new AuthorizationException("Failed to parse JWT", e);
    @@ -137,4 +141,8 @@ private <T> void verifyContentType(HttpResponse<T> res, String expectedContentTy
                   .formatted(res.uri(), expectedContentType, contentType));
         }
       }
    +
    +  public interface DiscoveryValidator {
    +    void validate(SignedJWT jwsObject);
    +  }
     }
    
  • epa4all-vau-client/src/main/java/com/oviva/telematik/vau/epa4all/client/authz/internal/OidcDiscoveryValidatorImpl.java+147 0 added
    @@ -0,0 +1,147 @@
    +package com.oviva.telematik.vau.epa4all.client.authz.internal;
    +
    +import com.nimbusds.jose.JOSEException;
    +import com.nimbusds.jwt.JWTClaimsSet;
    +import com.nimbusds.jwt.SignedJWT;
    +import com.nimbusds.jwt.proc.BadJWTException;
    +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
    +import com.oviva.telematik.vau.epa4all.client.authz.AuthorizationException;
    +import com.oviva.telematik.vau.epa4all.client.authz.internal.jose.BrainpoolJwsVerifier;
    +import com.oviva.telematik.vau.epa4all.client.authz.internal.jose.CertificateUtil;
    +import java.security.*;
    +import java.security.cert.*;
    +import java.security.interfaces.ECPublicKey;
    +import java.text.ParseException;
    +import java.util.List;
    +import java.util.Optional;
    +import java.util.Set;
    +import org.bouncycastle.asn1.ASN1ObjectIdentifier;
    +import org.bouncycastle.jce.provider.BouncyCastleProvider;
    +import org.slf4j.Logger;
    +import org.slf4j.LoggerFactory;
    +
    +public class OidcDiscoveryValidatorImpl implements OidcClient.DiscoveryValidator {
    +
    +  // https://gemspec.gematik.de/docs/gemSpec/gemSpec_OID/gemSpec_OID_V3.23.0/#GS-A_4446-17
    +  private static final ASN1ObjectIdentifier OID_IDPD =
    +      new ASN1ObjectIdentifier("1.2.276.0.76.4.260");
    +
    +  private static final Logger logger = LoggerFactory.getLogger(OidcDiscoveryValidatorImpl.class);
    +
    +  private final KeyStore trustStore;
    +
    +  public OidcDiscoveryValidatorImpl(KeyStore trustStore) {
    +    this.trustStore = trustStore;
    +  }
    +
    +  @Override
    +  public void validate(SignedJWT jwt) {
    +
    +    verifySignature(jwt);
    +    verifyClaims(jwt);
    +  }
    +
    +  private void verifySignature(SignedJWT jwt) {
    +
    +    var cert = signingCertificcate(jwt);
    +    verifyTrustChainAgainstRoot(cert);
    +    verifyRole(cert);
    +    verifySignature(jwt, cert);
    +  }
    +
    +  private X509Certificate signingCertificcate(SignedJWT jwt) {
    +
    +    var x5cRaw =
    +        Optional.ofNullable(jwt.getHeader())
    +            .map(h -> h.getX509CertChain())
    +            .orElse(List.of())
    +            .stream()
    +            // the first one MUST be the one used for signing
    +            // https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6
    +            .findFirst()
    +            .orElseThrow(
    +                () ->
    +                    new AuthorizationException(
    +                        "OIDC discovery document 'x5c' header claim is missing"));
    +    try {
    +      return CertificateUtil.parseDer(x5cRaw.decode());
    +    } catch (CertificateException e) {
    +      throw new AuthorizationException(
    +          "Failed to parse OIDC discovery document x509 header claim: " + e.getMessage(), e);
    +    }
    +  }
    +
    +  private void verifyTrustChainAgainstRoot(X509Certificate endUserCertificate) {
    +
    +    try {
    +
    +      var target = new X509CertSelector();
    +      target.setCertificate(endUserCertificate);
    +
    +      var params = new PKIXBuilderParameters(trustStore, target);
    +
    +      // there are no CRLs to be found
    +      params.setRevocationEnabled(false);
    +
    +      var builder = CertPathBuilder.getInstance("PKIX", BouncyCastleProvider.PROVIDER_NAME);
    +
    +      var result = (PKIXCertPathBuilderResult) builder.build(params);
    +      logger.atDebug().log(
    +          "certificate '{}' verified with trust anchor: '{}'",
    +          endUserCertificate.getSubjectX500Principal().getName(),
    +          result.getTrustAnchor().getTrustedCert().getSubjectX500Principal().getName());
    +
    +    } catch (CertPathBuilderException
    +        | NoSuchAlgorithmException
    +        | InvalidAlgorithmParameterException e) {
    +      var name = endUserCertificate.getSubjectX500Principal().getName();
    +      throw new AuthorizationException(
    +          "failed to validate IDP discovery document signing certificate, bad certificate: " + name,
    +          e);
    +    } catch (NoSuchProviderException | KeyStoreException e) {
    +      throw new AuthorizationException("unexpected crypto exception", e);
    +    }
    +  }
    +
    +  private void verifyRole(X509Certificate trustedSigningCertificate) {
    +    var oid =
    +        CertificateUtil.getProfessionOid(trustedSigningCertificate)
    +            .orElseThrow(
    +                () -> new AuthorizationException("missing profession OID for IDP certificate"));
    +    if (!oid.equals(OID_IDPD)) {
    +      throw new AuthorizationException("expected OID %s, got %s".formatted(OID_IDPD, oid));
    +    }
    +  }
    +
    +  private void verifySignature(SignedJWT jwt, X509Certificate trustedSigningCertificate) {
    +    var verifier = new BrainpoolJwsVerifier((ECPublicKey) trustedSigningCertificate.getPublicKey());
    +    try {
    +      if (!verifier.verify(jwt.getHeader(), jwt.getSigningInput(), jwt.getSignature())) {
    +        throw new AuthorizationException("bad signature");
    +      }
    +    } catch (JOSEException e) {
    +      throw new AuthorizationException("failed to verify signature", e);
    +    }
    +  }
    +
    +  private void verifyClaims(SignedJWT jwt) {
    +
    +    var claims = parseClaims(jwt);
    +    var claimsVerifier =
    +        new DefaultJWTClaimsVerifier<>(
    +            null, Set.of("iat", "exp", "issuer", "uri_puk_idp_enc", "uri_puk_idp_sig", "jwks_uri"));
    +    try {
    +      claimsVerifier.verify(claims, null);
    +    } catch (BadJWTException e) {
    +      throw new AuthorizationException("Bad discovery document", e);
    +    }
    +  }
    +
    +  private JWTClaimsSet parseClaims(SignedJWT jwt) {
    +    try {
    +      return jwt.getJWTClaimsSet();
    +    } catch (ParseException e) {
    +      throw new AuthorizationException("Failed to parse JWT claims", e);
    +    }
    +  }
    +}
    
  • epa4all-vau-client/src/test/java/com/oviva/telematik/vau/epa4all/client/authz/internal/jose/CertificateUtilTest.java+180 0 added
    @@ -0,0 +1,180 @@
    +package com.oviva.telematik.vau.epa4all.client.authz.internal.jose;
    +
    +import static org.junit.jupiter.api.Assertions.*;
    +
    +import java.math.BigInteger;
    +import java.security.*;
    +import java.security.cert.CertificateException;
    +import java.security.cert.X509Certificate;
    +import java.security.spec.ECGenParameterSpec;
    +import java.time.Instant;
    +import java.util.Base64;
    +import java.util.Date;
    +import org.bouncycastle.asn1.ASN1Encodable;
    +import org.bouncycastle.asn1.ASN1ObjectIdentifier;
    +import org.bouncycastle.asn1.DERSequence;
    +import org.bouncycastle.asn1.isismtt.ISISMTTObjectIdentifiers;
    +import org.bouncycastle.asn1.isismtt.x509.AdmissionSyntax;
    +import org.bouncycastle.asn1.isismtt.x509.Admissions;
    +import org.bouncycastle.asn1.isismtt.x509.ProfessionInfo;
    +import org.bouncycastle.asn1.x500.DirectoryString;
    +import org.bouncycastle.asn1.x500.X500Name;
    +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
    +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
    +import org.bouncycastle.jce.provider.BouncyCastleProvider;
    +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
    +import org.junit.jupiter.api.BeforeAll;
    +import org.junit.jupiter.api.Test;
    +
    +class CertificateUtilTest {
    +
    +  // RISE IDP test certificate (TEST-ONLY, NOT-VALID)
    +  private static final String IDP_CERT_PEM =
    +      """
    +                  MIIC9zCCAp6gAwIBAgIDALXqMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEf\
    +                  MB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9u\
    +                  ZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dF\
    +                  TS5LT01QLUNBNTYgVEVTVC1PTkxZMB4XDTI2MDEyMTE1MzkzOFoXDTMxMDEyMDE1\
    +                  MzkzN1owfTELMAkGA1UEBhMCQVQxKDAmBgNVBAoMH1JJU0UgR21iSCBURVNULU9O\
    +                  TFkgLSBOT1QtVkFMSUQxKTAnBgNVBAUTIDM4Nzc4LVYwMUkwMDA0VDIwMjYwMTIx\
    +                  MTUyMzI4MjExMRkwFwYDVQQDDBBkaXNjLnJ1LmlkcC5yaXNlMFowFAYHKoZIzj0C\
    +                  AQYJKyQDAwIIAQEHA0IABGL+nmChjSvGhVBH/o14iuUsK9CSZBAyO+UCNs6D7nZa\
    +                  O5xaTLrVNCdA4Zb+HjjoCucQjahDYZfmvu3CzCf4RAajggECMIH/MB0GA1UdDgQW\
    +                  BBSOp8MJLLkrkstNfHHkAKwwUrJUHTAfBgNVHSMEGDAWgBTVuBx5iaOlrcWNtv5b\
    +                  /hA3A50DwzBNBggrBgEFBQcBAQRBMD8wPQYIKwYBBQUHMAGGMWh0dHA6Ly9kb3du\
    +                  bG9hZC10ZXN0cmVmLmNybC50aS1kaWVuc3RlLmRlL29jc3AvZWMwDgYDVR0PAQH/\
    +                  BAQDAgeAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgSMwCgYIKoIUAEwEgUswDAYDVR0T\
    +                  AQH/BAIwADAtBgUrJAgDAwQkMCIwIDAeMBwwGjAMDApJRFAtRGllbnN0MAoGCCqC\
    +                  FABMBIIEMAoGCCqGSM49BAMCA0cAMEQCIEYDbjgvR6IbcNQxGv1FQKg0qCqHlfBl\
    +                  8kbrNXXOF3+aAiBYjajQzxmWpQAewatkepSE8HQtBLaRNAnWGvgmxLRWFQ==""";
    +
    +  // oid_idpd_gematik: IDP-Dienst OID per gematik gemSpec_OID
    +  private static final ASN1ObjectIdentifier IDP_DIENST_OID =
    +      new ASN1ObjectIdentifier("1.2.276.0.76.4.260");
    +
    +  @BeforeAll
    +  static void setUp() {
    +    Security.addProvider(new BouncyCastleProvider());
    +  }
    +
    +  @Test
    +  void parseDer_validCertificate_returnsX509Certificate() throws CertificateException {
    +    var derBytes = Base64.getDecoder().decode(IDP_CERT_PEM);
    +
    +    var cert = CertificateUtil.parseDer(derBytes);
    +
    +    assertNotNull(cert);
    +    assertTrue(cert.getSubjectX500Principal().getName().contains("CN=disc.ru.idp.rise"));
    +  }
    +
    +  @Test
    +  void parseDer_invalidBytes_throwsCertificateException() {
    +    var garbage = new byte[] {0x00, 0x01, 0x02, 0x03};
    +
    +    assertThrows(CertificateException.class, () -> CertificateUtil.parseDer(garbage));
    +  }
    +
    +  @Test
    +  void parseDer_emptyBytes_throwsCertificateException() {
    +    assertThrows(CertificateException.class, () -> CertificateUtil.parseDer(new byte[0]));
    +  }
    +
    +  @Test
    +  void getProfessionOid_idpCertificate_returnsIdpDienstOid() throws CertificateException {
    +    var derBytes = Base64.getDecoder().decode(IDP_CERT_PEM);
    +    var cert = CertificateUtil.parseDer(derBytes);
    +
    +    var oid = CertificateUtil.getProfessionOid(cert);
    +
    +    assertTrue(oid.isPresent());
    +    assertEquals(IDP_DIENST_OID, oid.get());
    +  }
    +
    +  @Test
    +  void getProfessionOid_multipleAdmissions_returnsEmpty() throws Exception {
    +    var profInfo = buildProfInfo(IDP_DIENST_OID);
    +    var admissions1 = new Admissions(null, null, new ProfessionInfo[] {profInfo});
    +    var admissions2 = new Admissions(null, null, new ProfessionInfo[] {profInfo});
    +    var admissionSyntax =
    +        new AdmissionSyntax(null, new DERSequence(new ASN1Encodable[] {admissions1, admissions2}));
    +    var cert = buildCertWithAdmission(admissionSyntax);
    +
    +    var result = CertificateUtil.getProfessionOid(cert);
    +
    +    assertTrue(result.isEmpty());
    +  }
    +
    +  @Test
    +  void getProfessionOid_multipleProfessionInfos_returnsEmpty() throws Exception {
    +    var profInfo1 = buildProfInfo(IDP_DIENST_OID);
    +    var profInfo2 = buildProfInfo(IDP_DIENST_OID);
    +    var admissions = new Admissions(null, null, new ProfessionInfo[] {profInfo1, profInfo2});
    +    var admissionSyntax = new AdmissionSyntax(null, new DERSequence(admissions));
    +    var cert = buildCertWithAdmission(admissionSyntax);
    +
    +    var result = CertificateUtil.getProfessionOid(cert);
    +
    +    assertTrue(result.isEmpty());
    +  }
    +
    +  @Test
    +  void getProfessionOid_multipleOids_returnsEmpty() throws Exception {
    +    var unrelatedOid = new ASN1ObjectIdentifier("1.2.276.0.76.4.999");
    +    var profInfo = buildProfInfo(IDP_DIENST_OID, unrelatedOid);
    +    var admissions = new Admissions(null, null, new ProfessionInfo[] {profInfo});
    +    var admissionSyntax = new AdmissionSyntax(null, new DERSequence(admissions));
    +    var cert = buildCertWithAdmission(admissionSyntax);
    +
    +    var result = CertificateUtil.getProfessionOid(cert);
    +
    +    assertTrue(result.isEmpty());
    +  }
    +
    +  @Test
    +  void getProfessionOid_noOids_returnsEmpty() throws Exception {
    +    var profInfo = buildProfInfo();
    +    var admissions = new Admissions(null, null, new ProfessionInfo[] {profInfo});
    +    var admissionSyntax = new AdmissionSyntax(null, new DERSequence(admissions));
    +    var cert = buildCertWithAdmission(admissionSyntax);
    +
    +    var result = CertificateUtil.getProfessionOid(cert);
    +
    +    assertTrue(result.isEmpty());
    +  }
    +
    +  // -- helpers --
    +
    +  private static ProfessionInfo buildProfInfo(ASN1ObjectIdentifier... oids) {
    +    return new ProfessionInfo(null, new DirectoryString[0], oids, null, null);
    +  }
    +
    +  private static X509Certificate buildCertWithAdmission(AdmissionSyntax admissionSyntax)
    +      throws Exception {
    +    var keyPair = generateKeyPair();
    +    var now = Instant.now();
    +    var name = new X500Name("CN=Test");
    +    var certBuilder =
    +        new JcaX509v3CertificateBuilder(
    +            name,
    +            BigInteger.ONE,
    +            Date.from(now.minusSeconds(60)),
    +            Date.from(now.plusSeconds(3600)),
    +            name,
    +            keyPair.getPublic());
    +    certBuilder.addExtension(
    +        ISISMTTObjectIdentifiers.id_isismtt_at_admission, false, admissionSyntax);
    +    var signer =
    +        new JcaContentSignerBuilder("SHA256withECDSA")
    +            .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +            .build(keyPair.getPrivate());
    +    return new JcaX509CertificateConverter()
    +        .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +        .getCertificate(certBuilder.build(signer));
    +  }
    +
    +  private static KeyPair generateKeyPair() throws Exception {
    +    var kpg = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME);
    +    kpg.initialize(new ECGenParameterSpec("brainpoolP256r1"));
    +    return kpg.generateKeyPair();
    +  }
    +}
    
  • epa4all-vau-client/src/test/java/com/oviva/telematik/vau/epa4all/client/authz/internal/OidcClientTest.java+218 0 added
    @@ -0,0 +1,218 @@
    +package com.oviva.telematik.vau.epa4all.client.authz.internal;
    +
    +import static org.junit.jupiter.api.Assertions.*;
    +import static org.mockito.ArgumentMatchers.any;
    +import static org.mockito.Mockito.*;
    +
    +import com.nimbusds.jose.jwk.JWK;
    +import com.oviva.telematik.vau.epa4all.client.authz.AuthorizationException;
    +import java.net.URI;
    +import java.net.http.HttpClient;
    +import java.net.http.HttpHeaders;
    +import java.net.http.HttpResponse;
    +import java.util.Optional;
    +import org.junit.jupiter.api.BeforeEach;
    +import org.junit.jupiter.api.Test;
    +import org.junit.jupiter.api.extension.ExtendWith;
    +import org.mockito.Mock;
    +import org.mockito.junit.jupiter.MockitoExtension;
    +
    +@ExtendWith(MockitoExtension.class)
    +class OidcClientTest {
    +
    +  // Real signed discovery document JWT (TEST-ONLY)
    +  private static final String DISCOVERY_JWT =
    +"""
    +  eyJhbGciOiJCUDI1NlIxIiwia2lkIjoicHVrX2Rpc2Nfc2lnIiwieDVjIjpbIk1JSUM5ekNDQXA2Z0F3SUJBZ0lEQUxYcU1Bb0dDQ3FHU00\
    +  0OUJBTUNNSUdFTVFzd0NRWURWUVFHRXdKRVJURWZNQjBHQTFVRUNnd1daMlZ0WVhScGF5QkhiV0pJSUU1UFZDMVdRVXhKUkRFeU1EQUdBMV\
    +  VFQ3d3cFMyOXRjRzl1Wlc1MFpXNHRRMEVnWkdWeUlGUmxiR1Z0WVhScGEybHVabkpoYzNSeWRXdDBkWEl4SURBZUJnTlZCQU1NRjBkRlRTN\
    +  UxUMDFRTFVOQk5UWWdWRVZUVkMxUFRreFpNQjRYRFRJMk1ERXlNVEUxTXprek9Gb1hEVE14TURFeU1ERTFNemt6TjFvd2ZURUxNQWtHQTFV\
    +  RUJoTUNRVlF4S0RBbUJnTlZCQW9NSDFKSlUwVWdSMjFpU0NCVVJWTlVMVTlPVEZrZ0xTQk9UMVF0VmtGTVNVUXhLVEFuQmdOVkJBVVRJRE0\
    +  0TnpjNExWWXdNVWt3TURBMFZESXdNall3TVRJeE1UVXlNekk0TWpFeE1Sa3dGd1lEVlFRRERCQmthWE5qTG5KMUxtbGtjQzV5YVhObE1Gb3\
    +  dGQVlIS29aSXpqMENBUVlKS3lRREF3SUlBUUVIQTBJQUJHTCtubUNoalN2R2hWQkgvbzE0aXVVc0s5Q1NaQkF5TytVQ05zNkQ3blphTzV4Y\
    +  VRMclZOQ2RBNFpiK0hqam9DdWNRamFoRFlaZm12dTNDekNmNFJBYWpnZ0VDTUlIL01CMEdBMVVkRGdRV0JCU09wOE1KTExrcmtzdE5mSEhr\
    +  QUt3d1VySlVIVEFmQmdOVkhTTUVHREFXZ0JUVnVCeDVpYU9scmNXTnR2NWIvaEEzQTUwRHd6Qk5CZ2dyQmdFRkJRY0JBUVJCTUQ4d1BRWUl\
    +  Ld1lCQlFVSE1BR0dNV2gwZEhBNkx5OWtiM2R1Ykc5aFpDMTBaWE4wY21WbUxtTnliQzUwYVMxa2FXVnVjM1JsTG1SbEwyOWpjM0F2WldNd0\
    +  RnWURWUjBQQVFIL0JBUURBZ2VBTUNFR0ExVWRJQVFhTUJnd0NnWUlLb0lVQUV3RWdTTXdDZ1lJS29JVUFFd0VnVXN3REFZRFZSMFRBUUgvQ\
    +  kFJd0FEQXRCZ1VySkFnREF3UWtNQ0l3SURBZU1Cd3dHakFNREFwSlJGQXRSR2xsYm5OME1Bb0dDQ3FDRkFCTUJJSUVNQW9HQ0NxR1NNNDlC\
    +  QU1DQTBjQU1FUUNJRVlEYmpndlI2SWJjTlF4R3YxRlFLZzBxQ3FIbGZCbDhrYnJOWFhPRjMrYUFpQllqYWpRenhtV3BRQWV3YXRrZXBTRTh\
    +  IUXRCTGFSTkFuV0d2Z214TFJXRlE9PSJdLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE3NzgxNDIzODUsImV4cCI6MTc3ODIyODc4NSwiaXNzdWV\
    +  yIjoiaHR0cHM6Ly9pZHAtcmVmLnplbnRyYWwuaWRwLnNwbGl0ZG5zLnRpLWRpZW5zdGUuZGUiLCJqd2tzX3VyaSI6Imh0dHBzOi8vaWRwLX\
    +  JlZi56ZW50cmFsLmlkcC5zcGxpdGRucy50aS1kaWVuc3RlLmRlL2NlcnRzIiwidXJpX2Rpc2MiOiJodHRwczovL2lkcC1yZWYuemVudHJhb\
    +  C5pZHAuc3BsaXRkbnMudGktZGllbnN0ZS5kZS8ud2VsbC1rbm93bi9vcGVuaWQtY29uZmlndXJhdGlvbiIsImF1dGhvcml6YXRpb25fZW5k\
    +  cG9pbnQiOiJodHRwczovL2lkcC1yZWYuemVudHJhbC5pZHAuc3BsaXRkbnMudGktZGllbnN0ZS5kZS9hdXRoIiwic3NvX2VuZHBvaW50Ijo\
    +  iaHR0cHM6Ly9pZHAtcmVmLnplbnRyYWwuaWRwLnNwbGl0ZG5zLnRpLWRpZW5zdGUuZGUvYXV0aC9zc29fcmVzcG9uc2UiLCJ0b2tlbl9lbm\
    +  Rwb2ludCI6Imh0dHBzOi8vaWRwLXJlZi56ZW50cmFsLmlkcC5zcGxpdGRucy50aS1kaWVuc3RlLmRlL3Rva2VuIiwidXJpX3B1a19pZHBfZ\
    +  W5jIjoiaHR0cHM6Ly9pZHAtcmVmLnplbnRyYWwuaWRwLnNwbGl0ZG5zLnRpLWRpZW5zdGUuZGUvY2VydHMvcHVrX2lkcF9lbmMiLCJ1cmlf\
    +  cHVrX2lkcF9zaWciOiJodHRwczovL2lkcC1yZWYuemVudHJhbC5pZHAuc3BsaXRkbnMudGktZGllbnN0ZS5kZS9jZXJ0cy9wdWtfaWRwX3N\
    +  pZyIsImNvZGVfY2hhbGxlbmdlX21ldGhvZHNfc3VwcG9ydGVkIjpbIlMyNTYiXSwicmVzcG9uc2VfdHlwZXNfc3VwcG9ydGVkIjpbImNvZG\
    +  UiXSwiZ3JhbnRfdHlwZXNfc3VwcG9ydGVkIjpbImF1dGhvcml6YXRpb25fY29kZSJdLCJpZF90b2tlbl9zaWduaW5nX2FsZ192YWx1ZXNfc\
    +  3VwcG9ydGVkIjpbIkJQMjU2UjEiXSwiYWNyX3ZhbHVlc19zdXBwb3J0ZWQiOlsiZ2VtYXRpay1laGVhbHRoLWxvYS1oaWdoIl0sInJlc3Bv\
    +  bnNlX21vZGVzX3N1cHBvcnRlZCI6WyJxdWVyeSJdLCJ0b2tlbl9lbmRwb2ludF9hdXRoX21ldGhvZHNfc3VwcG9ydGVkIjpbIm5vbmUiXSw\
    +  ic2NvcGVzX3N1cHBvcnRlZCI6WyJvcGVuaWQiLCJlLXJlemVwdCIsImUtcmV6ZXB0LWRldiIsImVQQS1QUy1nZW10ayIsImVQQS1ibXQtcX\
    +  QiLCJlUEEtYm10LXF1IiwiZVBBLWJtdC1ydCIsImVQQS1ibXQtcnUiLCJlUEEtaWJtLXJ1LWludCIsImVQQS1pYm0xIiwiZVBBLWlibTIiL\
    +  CJlYnRtLWJkciIsImVidG0tYmRyMiIsImZoLWZva3VzLWRlbWlzIiwiZmhpci12emQiLCJnZW0tYXV0aCIsImdtdGlrLWRlbWlzIiwiZ210\
    +  aWstZGVtaXMtZmtiIiwiZ210aWstZGVtaXMtZnJhIiwiZ210aWstZGVtaXMtcXMiLCJnbXRpay1kZW1pcy1yZWYiLCJnbXRpay1kZW1pcy1\
    +  ydS10ZXN0IiwiZ210aWstZmhpcmRpcmVjdG9yeS1zc3AiLCJnbXRpay16ZXJvdHJ1c3QtcG9jIiwiaXJkLWJtZyIsImt2c2gtb3B0Iiwib2\
    +  dyLW5leGVuaW8tZGVtbyIsIm9nci1uZXhlbmlvLWRldiIsIm9nci1uZXhlbmlvLXByZXByb2QiLCJvZ3ItbmV4ZW5pby10ZXN0Iiwib3JnY\
    +  W5zcGVuZGUtcmVnaXN0ZXIiLCJwYWlyaW5nIiwicnBkb2MtZW1tYSIsInJwZG9jLWVtbWEtcGhhYiIsInRpLW1lc3NlbmdlciIsInRpLXNj\
    +  b3JlIiwidGktc2NvcmUyIiwienZyLWJub3RrIl0sInN1YmplY3RfdHlwZXNfc3VwcG9ydGVkIjpbInBhaXJ3aXNlIl19.RDczQM7RLwCV_U\
    +  6V_LLwNgSqm9CH4KDq3nXjGe-hKHiEwYOb5d4MoWoAuIpl0lfH0HhoSodiFJZEX5uOQ_XzsA
    +""";
    +
    +  private static final String P256_JWK_JSON =
    +      """
    +      {
    +        "kty": "EC",
    +        "crv": "P-256",
    +        "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
    +        "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
    +      }
    +      """;
    +
    +  @Mock HttpClient httpClient;
    +  @Mock OidcClient.DiscoveryValidator discoveryValidator;
    +  @Mock HttpResponse<String> mockResponse;
    +  @Mock HttpHeaders mockHeaders;
    +
    +  private OidcClient oidcClient;
    +
    +  @BeforeEach
    +  void setUp() {
    +    oidcClient = new OidcClient(httpClient, discoveryValidator);
    +  }
    +
    +  // -- fetchOidcDiscoveryDocument --
    +
    +  @Test
    +  void fetchOidcDiscoveryDocument_validJwt_returnsResponse() throws Exception {
    +    var issuer = URI.create("https://idp.example.test");
    +    setupMockResponse(200, "application/jwt", DISCOVERY_JWT);
    +
    +    var response = oidcClient.fetchOidcDiscoveryDocument(issuer);
    +
    +    assertNotNull(response);
    +    assertEquals(
    +        URI.create("https://idp-ref.zentral.idp.splitdns.ti-dienste.de"), response.issuer());
    +    assertEquals(
    +        URI.create("https://idp-ref.zentral.idp.splitdns.ti-dienste.de/certs/puk_idp_enc"),
    +        response.uriPukIdpEnc());
    +    assertEquals(
    +        URI.create("https://idp-ref.zentral.idp.splitdns.ti-dienste.de/certs/puk_idp_sig"),
    +        response.uriPukIdpSig());
    +    assertEquals(
    +        URI.create("https://idp-ref.zentral.idp.splitdns.ti-dienste.de/certs"), response.jwksUri());
    +  }
    +
    +  @Test
    +  void fetchOidcDiscoveryDocument_validatesJwt() throws Exception {
    +    var issuer = URI.create("https://idp.example.test");
    +    setupMockResponse(200, "application/jwt", DISCOVERY_JWT);
    +
    +    oidcClient.fetchOidcDiscoveryDocument(issuer);
    +
    +    verify(discoveryValidator).validate(any());
    +  }
    +
    +  @Test
    +  void fetchOidcDiscoveryDocument_validationFails_throwsAuthorizationException() throws Exception {
    +    var issuer = URI.create("https://idp.example.test");
    +    setupMockResponse(200, "application/jwt", DISCOVERY_JWT);
    +
    +    doThrow(new AuthorizationException("untrusted")).when(discoveryValidator).validate(any());
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchOidcDiscoveryDocument(issuer));
    +  }
    +
    +  @Test
    +  void fetchOidcDiscoveryDocument_non200Status_throwsAuthorizationException() throws Exception {
    +    var issuer = URI.create("https://idp.example.test");
    +    setupMockResponse(404, "application/jwt", DISCOVERY_JWT);
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchOidcDiscoveryDocument(issuer));
    +  }
    +
    +  @Test
    +  void fetchOidcDiscoveryDocument_missingContentType_throwsAuthorizationException()
    +      throws Exception {
    +    var issuer = URI.create("https://idp.example.test");
    +    doReturn(mockResponse).when(httpClient).send(any(), any());
    +    when(mockResponse.statusCode()).thenReturn(200);
    +    when(mockResponse.headers()).thenReturn(mockHeaders);
    +    when(mockHeaders.firstValue("content-type")).thenReturn(Optional.empty());
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchOidcDiscoveryDocument(issuer));
    +  }
    +
    +  @Test
    +  void fetchOidcDiscoveryDocument_unsupportedContentType_throwsAuthorizationException()
    +      throws Exception {
    +    var issuer = URI.create("https://idp.example.test");
    +    setupMockResponse(200, "application/json", DISCOVERY_JWT);
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchOidcDiscoveryDocument(issuer));
    +  }
    +
    +  // -- fetchJwk --
    +
    +  @Test
    +  void fetchJwk_validJson_returnsJwk() throws Exception {
    +    var jwksUri = URI.create("https://idp.example.test/certs/puk_idp_enc");
    +    setupMockResponse(200, "application/json", P256_JWK_JSON);
    +
    +    var jwk = oidcClient.fetchJwk(jwksUri);
    +
    +    assertNotNull(jwk);
    +    assertInstanceOf(JWK.class, jwk);
    +  }
    +
    +  @Test
    +  void fetchJwk_non200Status_throwsAuthorizationException() throws Exception {
    +    var jwksUri = URI.create("https://idp.example.test/certs/puk_idp_enc");
    +    setupMockResponse(404, "application/json", P256_JWK_JSON);
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchJwk(jwksUri));
    +  }
    +
    +  @Test
    +  void fetchJwk_missingContentType_throwsAuthorizationException() throws Exception {
    +    var jwksUri = URI.create("https://idp.example.test/certs/puk_idp_enc");
    +    doReturn(mockResponse).when(httpClient).send(any(), any());
    +    when(mockResponse.statusCode()).thenReturn(200);
    +    when(mockResponse.headers()).thenReturn(mockHeaders);
    +    when(mockHeaders.firstValue("content-type")).thenReturn(Optional.empty());
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchJwk(jwksUri));
    +  }
    +
    +  @Test
    +  void fetchJwk_wrongContentType_throwsAuthorizationException() throws Exception {
    +    var jwksUri = URI.create("https://idp.example.test/certs/puk_idp_enc");
    +    setupMockResponse(200, "text/plain", P256_JWK_JSON);
    +
    +    assertThrows(AuthorizationException.class, () -> oidcClient.fetchJwk(jwksUri));
    +  }
    +
    +  @Test
    +  void fetchJwk_contentTypeWithCharset_isAccepted() throws Exception {
    +    var jwksUri = URI.create("https://idp.example.test/certs/puk_idp_enc");
    +    setupMockResponse(200, "application/json; charset=utf-8", P256_JWK_JSON);
    +
    +    var jwk = oidcClient.fetchJwk(jwksUri);
    +
    +    assertNotNull(jwk);
    +  }
    +
    +  // -- helpers --
    +
    +  @SuppressWarnings("unchecked")
    +  private void setupMockResponse(int status, String contentType, String body) throws Exception {
    +    doReturn(mockResponse).when(httpClient).send(any(), any());
    +    when(mockResponse.statusCode()).thenReturn(status);
    +    if (status == 200) {
    +      when(mockResponse.headers()).thenReturn(mockHeaders);
    +      when(mockHeaders.firstValue("content-type")).thenReturn(Optional.of(contentType));
    +      lenient().when(mockResponse.body()).thenReturn(body);
    +    }
    +  }
    +}
    
  • epa4all-vau-client/src/test/java/com/oviva/telematik/vau/epa4all/client/authz/internal/OidcDiscoveryValidatorImplTest.java+253 0 added
    @@ -0,0 +1,253 @@
    +package com.oviva.telematik.vau.epa4all.client.authz.internal;
    +
    +import static org.junit.jupiter.api.Assertions.*;
    +
    +import com.nimbusds.jose.JWSHeader;
    +import com.nimbusds.jose.crypto.impl.ECDSA;
    +import com.nimbusds.jose.util.Base64;
    +import com.nimbusds.jose.util.Base64URL;
    +import com.nimbusds.jwt.JWTClaimsSet;
    +import com.nimbusds.jwt.SignedJWT;
    +import com.oviva.telematik.vau.epa4all.client.authz.AuthorizationException;
    +import com.oviva.telematik.vau.epa4all.client.authz.internal.jose.BrainpoolAlgorithms;
    +import java.math.BigInteger;
    +import java.security.*;
    +import java.security.cert.X509Certificate;
    +import java.security.spec.ECGenParameterSpec;
    +import java.time.Instant;
    +import java.util.Date;
    +import java.util.List;
    +import org.bouncycastle.asn1.ASN1ObjectIdentifier;
    +import org.bouncycastle.asn1.DERSequence;
    +import org.bouncycastle.asn1.isismtt.ISISMTTObjectIdentifiers;
    +import org.bouncycastle.asn1.isismtt.x509.AdmissionSyntax;
    +import org.bouncycastle.asn1.isismtt.x509.Admissions;
    +import org.bouncycastle.asn1.isismtt.x509.ProfessionInfo;
    +import org.bouncycastle.asn1.x500.X500Name;
    +import org.bouncycastle.asn1.x509.BasicConstraints;
    +import org.bouncycastle.asn1.x509.Extension;
    +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
    +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
    +import org.bouncycastle.jce.provider.BouncyCastleProvider;
    +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
    +import org.junit.jupiter.api.BeforeAll;
    +import org.junit.jupiter.api.Test;
    +
    +class OidcDiscoveryValidatorImplTest {
    +
    +  private static final ASN1ObjectIdentifier IDP_DIENST_OID =
    +      new ASN1ObjectIdentifier("1.2.276.0.76.4.260");
    +  private static final ASN1ObjectIdentifier UNRELATED_OID =
    +      new ASN1ObjectIdentifier("1.2.276.0.76.4.999");
    +
    +  @BeforeAll
    +  static void setUp() {
    +    Security.addProvider(new BouncyCastleProvider());
    +  }
    +
    +  @Test
    +  void validate_validDiscoveryJwt_succeeds() throws Exception {
    +    var caKeyPair = generateKeyPair();
    +    var eeKeyPair = generateKeyPair();
    +    var caCert = buildCaCert(caKeyPair);
    +    var eeCert = buildEndEntityCert(eeKeyPair, caKeyPair, caCert, IDP_DIENST_OID);
    +    var trustStore = buildTrustStore(caCert);
    +
    +    var jwt = buildDiscoveryJwt(eeKeyPair, eeCert);
    +    var validator = new OidcDiscoveryValidatorImpl(trustStore);
    +
    +    assertDoesNotThrow(() -> validator.validate(jwt));
    +  }
    +
    +  @Test
    +  void validate_untrustedCertificate_throwsAuthorizationException() throws Exception {
    +    var eeKeyPair = generateKeyPair();
    +    var selfSignedCert = buildSelfSignedCert(eeKeyPair, IDP_DIENST_OID);
    +    var emptyTrustStore = buildTrustStore();
    +
    +    var jwt = buildDiscoveryJwt(eeKeyPair, selfSignedCert);
    +    var validator = new OidcDiscoveryValidatorImpl(emptyTrustStore);
    +
    +    assertThrows(AuthorizationException.class, () -> validator.validate(jwt));
    +  }
    +
    +  @Test
    +  void validate_wrongProfessionOid_throwsAuthorizationException() throws Exception {
    +    var caKeyPair = generateKeyPair();
    +    var eeKeyPair = generateKeyPair();
    +    var caCert = buildCaCert(caKeyPair);
    +    var eeCert = buildEndEntityCert(eeKeyPair, caKeyPair, caCert, UNRELATED_OID);
    +    var trustStore = buildTrustStore(caCert);
    +
    +    var jwt = buildDiscoveryJwt(eeKeyPair, eeCert);
    +    var validator = new OidcDiscoveryValidatorImpl(trustStore);
    +
    +    assertThrows(AuthorizationException.class, () -> validator.validate(jwt));
    +  }
    +
    +  @Test
    +  void validate_badSignature_throwsAuthorizationException() throws Exception {
    +    var caKeyPair = generateKeyPair();
    +    var eeKeyPair = generateKeyPair();
    +    var wrongKeyPair = generateKeyPair();
    +    var caCert = buildCaCert(caKeyPair);
    +    var eeCert = buildEndEntityCert(eeKeyPair, caKeyPair, caCert, IDP_DIENST_OID);
    +    var trustStore = buildTrustStore(caCert);
    +
    +    // signed with a key that doesn't match the cert's public key
    +    var jwt = buildDiscoveryJwt(wrongKeyPair, eeCert);
    +    var validator = new OidcDiscoveryValidatorImpl(trustStore);
    +
    +    assertThrows(AuthorizationException.class, () -> validator.validate(jwt));
    +  }
    +
    +  @Test
    +  void validate_missingRequiredClaims_throwsAuthorizationException() throws Exception {
    +    var caKeyPair = generateKeyPair();
    +    var eeKeyPair = generateKeyPair();
    +    var caCert = buildCaCert(caKeyPair);
    +    var eeCert = buildEndEntityCert(eeKeyPair, caKeyPair, caCert, IDP_DIENST_OID);
    +    var trustStore = buildTrustStore(caCert);
    +
    +    var claims = new JWTClaimsSet.Builder().build(); // no required claims
    +    var jwt = buildSignedJwt(eeKeyPair, eeCert, claims);
    +    var validator = new OidcDiscoveryValidatorImpl(trustStore);
    +
    +    assertThrows(AuthorizationException.class, () -> validator.validate(jwt));
    +  }
    +
    +  // -- helpers --
    +
    +  private static KeyPair generateKeyPair() throws Exception {
    +    var kpg = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME);
    +    kpg.initialize(new ECGenParameterSpec("brainpoolP256r1"));
    +    return kpg.generateKeyPair();
    +  }
    +
    +  private static KeyStore buildTrustStore(X509Certificate... trustedCerts) throws Exception {
    +    var ks = KeyStore.getInstance(KeyStore.getDefaultType());
    +    ks.load(null, null);
    +    for (int i = 0; i < trustedCerts.length; i++) {
    +      ks.setCertificateEntry("ca-" + i, trustedCerts[i]);
    +    }
    +    return ks;
    +  }
    +
    +  private static X509Certificate buildCaCert(KeyPair keyPair) throws Exception {
    +    var now = Instant.now();
    +    var name = new X500Name("CN=Test-CA");
    +    var certBuilder =
    +        new JcaX509v3CertificateBuilder(
    +            name,
    +            BigInteger.ONE,
    +            Date.from(now.minusSeconds(60)),
    +            Date.from(now.plusSeconds(3600)),
    +            name,
    +            keyPair.getPublic());
    +    certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
    +    var signer =
    +        new JcaContentSignerBuilder("SHA256withECDSA")
    +            .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +            .build(keyPair.getPrivate());
    +    return new JcaX509CertificateConverter()
    +        .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +        .getCertificate(certBuilder.build(signer));
    +  }
    +
    +  private static X509Certificate buildSelfSignedCert(
    +      KeyPair keyPair, ASN1ObjectIdentifier professionOid) throws Exception {
    +    var now = Instant.now();
    +    var name = new X500Name("CN=Test-IDP");
    +    var certBuilder =
    +        new JcaX509v3CertificateBuilder(
    +            name,
    +            BigInteger.ONE,
    +            Date.from(now.minusSeconds(60)),
    +            Date.from(now.plusSeconds(3600)),
    +            name,
    +            keyPair.getPublic());
    +    addAdmissionExtension(certBuilder, professionOid);
    +    var signer =
    +        new JcaContentSignerBuilder("SHA256withECDSA")
    +            .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +            .build(keyPair.getPrivate());
    +    return new JcaX509CertificateConverter()
    +        .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +        .getCertificate(certBuilder.build(signer));
    +  }
    +
    +  private static X509Certificate buildEndEntityCert(
    +      KeyPair eeKeyPair,
    +      KeyPair caKeyPair,
    +      X509Certificate caCert,
    +      ASN1ObjectIdentifier professionOid)
    +      throws Exception {
    +    var now = Instant.now();
    +    var certBuilder =
    +        new JcaX509v3CertificateBuilder(
    +            caCert,
    +            BigInteger.TWO,
    +            Date.from(now.minusSeconds(60)),
    +            Date.from(now.plusSeconds(3600)),
    +            new javax.security.auth.x500.X500Principal("CN=Test-IDP"),
    +            eeKeyPair.getPublic());
    +    addAdmissionExtension(certBuilder, professionOid);
    +    var signer =
    +        new JcaContentSignerBuilder("SHA256withECDSA")
    +            .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +            .build(caKeyPair.getPrivate());
    +    return new JcaX509CertificateConverter()
    +        .setProvider(BouncyCastleProvider.PROVIDER_NAME)
    +        .getCertificate(certBuilder.build(signer));
    +  }
    +
    +  private static void addAdmissionExtension(
    +      JcaX509v3CertificateBuilder certBuilder, ASN1ObjectIdentifier professionOid)
    +      throws Exception {
    +    var profInfo =
    +        new ProfessionInfo(
    +            null,
    +            new org.bouncycastle.asn1.x500.DirectoryString[0],
    +            new ASN1ObjectIdentifier[] {professionOid},
    +            null,
    +            null);
    +    var admissions = new Admissions(null, null, new ProfessionInfo[] {profInfo});
    +    var admissionSyntax = new AdmissionSyntax(null, new DERSequence(admissions));
    +    certBuilder.addExtension(
    +        ISISMTTObjectIdentifiers.id_isismtt_at_admission, false, admissionSyntax);
    +  }
    +
    +  private static SignedJWT buildDiscoveryJwt(KeyPair signingKeyPair, X509Certificate signingCert)
    +      throws Exception {
    +    var now = Instant.now();
    +    var claims =
    +        new JWTClaimsSet.Builder()
    +            .issueTime(Date.from(now))
    +            .expirationTime(Date.from(now.plusSeconds(300)))
    +            .claim("issuer", "https://idp.example.test")
    +            .claim("uri_puk_idp_enc", "https://idp.example.test/enc")
    +            .claim("uri_puk_idp_sig", "https://idp.example.test/sig")
    +            .claim("jwks_uri", "https://idp.example.test/jwks.json")
    +            .build();
    +    return buildSignedJwt(signingKeyPair, signingCert, claims);
    +  }
    +
    +  private static SignedJWT buildSignedJwt(
    +      KeyPair keyPair, X509Certificate cert, JWTClaimsSet claims) throws Exception {
    +    var x5c = List.of(Base64.encode(cert.getEncoded()));
    +    var header = new JWSHeader.Builder(BrainpoolAlgorithms.BS256R1).x509CertChain(x5c).build();
    +    var jwt = new SignedJWT(header, claims);
    +
    +    var sig = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);
    +    sig.initSign((java.security.interfaces.ECPrivateKey) keyPair.getPrivate());
    +    sig.update(jwt.getSigningInput());
    +    var jwsSignature = ECDSA.transcodeSignatureToConcat(sig.sign(), 64);
    +
    +    return SignedJWT.parse(
    +        new SignedJWT(
    +                jwt.getHeader().toBase64URL(),
    +                jwt.getPayload().toBase64URL(),
    +                Base64URL.encode(jwsSignature))
    +            .serialize());
    +  }
    +}
    
  • konnektor/konnektor-client/src/main/java/com/oviva/epa/client/konn/internal/KonnektorConnectionConfiguration.java+6 1 modified
    @@ -38,7 +38,12 @@ public record TlsConfig(
           // 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
           // 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
           // 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA']")
    -      List<String> ciphersuites) {}
    +      List<String> ciphersuites,
    +      /*
    +       * Subject Alternative Name (SAN) for hostname verification - usually on konnektor is only
    +       * reachable by IP
    +       */
    +      String subjectAlternativeName) {}
     
       public record ProxyAddressConfig(String address, Integer port, boolean enabled) {}
     
    
  • konnektor/konnektor-client/src/main/java/com/oviva/epa/client/konn/internal/KonnektorConnectionFactoryImpl.java+6 3 modified
    @@ -27,8 +27,7 @@
     import java.util.Objects;
     import java.util.Optional;
     import java.util.function.Consumer;
    -import javax.net.ssl.KeyManager;
    -import javax.net.ssl.TrustManager;
    +import javax.net.ssl.*;
     import org.apache.cxf.configuration.jsse.TLSClientParameters;
     import org.apache.cxf.configuration.security.AuthorizationPolicy;
     import org.apache.cxf.ext.logging.LoggingFeature;
    @@ -155,7 +154,11 @@ private <T> T getClientProxyImpl(
     
       protected TLSClientParameters tlsClientParameters() {
         final TLSClientParameters tlsParams = new TLSClientParameters();
    -    tlsParams.setDisableCNCheck(true);
    +
    +    if (configuration.tlsConfig().subjectAlternativeName() != null) {
    +      tlsParams.setHostnameVerifier(
    +          new StaticHostnameVerifier(configuration.tlsConfig().subjectAlternativeName()));
    +    }
     
         tlsParams.setTrustManagers(
             configuration.tlsConfig().trustManagers().toArray(new TrustManager[0]));
    
  • konnektor/konnektor-client/src/main/java/com/oviva/epa/client/konn/internal/StaticHostnameVerifier.java+65 0 added
    @@ -0,0 +1,65 @@
    +package com.oviva.epa.client.konn.internal;
    +
    +import java.security.cert.CertificateParsingException;
    +import java.security.cert.X509Certificate;
    +import java.util.List;
    +import javax.net.ssl.HostnameVerifier;
    +import javax.net.ssl.SSLPeerUnverifiedException;
    +import javax.net.ssl.SSLSession;
    +import org.bouncycastle.asn1.x509.GeneralName;
    +import org.slf4j.Logger;
    +import org.slf4j.LoggerFactory;
    +
    +class StaticHostnameVerifier implements HostnameVerifier {
    +
    +  private static final Logger logger = LoggerFactory.getLogger(StaticHostnameVerifier.class);
    +
    +  private final String konnektorDnsSan;
    +
    +  StaticHostnameVerifier(String konnektorDnsSan) {
    +    this.konnektorDnsSan = konnektorDnsSan;
    +  }
    +
    +  @Override
    +  public boolean verify(String hostname, SSLSession session) {
    +    try {
    +      // throws if cert is otherwise invalid
    +      var peerCertificates = session.getPeerCertificates();
    +      if (peerCertificates == null || peerCertificates.length == 0) {
    +        return false;
    +      }
    +
    +      if (!(peerCertificates[0] instanceof X509Certificate leaf)) {
    +        return false;
    +      }
    +
    +      // we allow the special SAN to be used as the hostname of the server
    +      try {
    +        var sans = leaf.getSubjectAlternativeNames();
    +        if (sans == null) {
    +          return false;
    +        }
    +        return sans.stream().anyMatch(this::matchesDnsSubjectAlternateName);
    +      } catch (CertificateParsingException e) {
    +        logger.error(
    +            "failed to parse peer certificate: %s".formatted(leaf.getSubjectX500Principal()), e);
    +        return false;
    +      }
    +    } catch (SSLPeerUnverifiedException e) {
    +      logger.error("unverified peer", e);
    +      return false;
    +    }
    +  }
    +
    +  private boolean matchesDnsSubjectAlternateName(List<?> sanTuple) {
    +    if (sanTuple == null || sanTuple.size() != 2) {
    +      return false;
    +    }
    +
    +    if (!(sanTuple.get(0) instanceof Integer sanType && sanType == GeneralName.dNSName)) {
    +      return false;
    +    }
    +
    +    return konnektorDnsSan.equals(sanTuple.get(1));
    +  }
    +}
    
  • konnektor/konnektor-client/src/main/java/com/oviva/epa/client/konn/KonnektorConnectionFactoryBuilder.java+2 9 modified
    @@ -5,7 +5,6 @@
     import com.oviva.epa.client.konn.internal.KonnektorConnectionConfiguration.ProxyAddressConfig;
     import com.oviva.epa.client.konn.internal.KonnektorConnectionConfiguration.TlsConfig;
     import com.oviva.epa.client.konn.internal.KonnektorConnectionFactoryImpl;
    -import com.oviva.epa.client.konn.internal.util.NaiveTrustManager;
     import edu.umd.cs.findbugs.annotations.NonNull;
     import java.io.IOException;
     import java.net.URI;
    @@ -22,6 +21,7 @@
     
     public class KonnektorConnectionFactoryBuilder {
     
    +  private static final String DEFAULT_KONNEKTOR_DNS_SAN = "konnektor.konlan";
       private static final List<String> DEFAULT_TLS_CIPHERSUITES =
           List.of(
               "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
    @@ -101,13 +101,6 @@ public KonnektorConnectionFactoryBuilder trustManagers(
         return this;
       }
     
    -  /** DO NOT USE IN PRODUCTION! */
    -  @NonNull
    -  public KonnektorConnectionFactoryBuilder trustAllServers() {
    -    this.trustManagers = List.of(new NaiveTrustManager());
    -    return this;
    -  }
    -
       @NonNull
       public KonnektorConnectionFactoryBuilder clientKeysFromP12(
           @NonNull Path keyStorePath, String password) {
    @@ -125,7 +118,7 @@ public KonnektorConnectionFactory build() {
           var kms = Optional.ofNullable(this.keyManagers).orElse(List.of());
           var tms = Optional.ofNullable(this.trustManagers).orElse(List.of());
     
    -      tlsConfig = new TlsConfig(kms, tms, ciphersuites);
    +      tlsConfig = new TlsConfig(kms, tms, ciphersuites, DEFAULT_KONNEKTOR_DNS_SAN);
         }
     
         var cfg =
    
  • konnektor/konnektor-client/src/test/java/com/oviva/epa/client/KonnektorServiceAcceptanceTest.java+3 1 modified
    @@ -4,6 +4,7 @@
     import static org.junit.jupiter.api.Assumptions.assumeTrue;
     
     import com.oviva.epa.client.konn.KonnektorConnectionFactoryBuilder;
    +import com.oviva.epa.client.konn.internal.util.NaiveTrustManager;
     import java.io.IOException;
     import java.net.URI;
     import java.nio.charset.StandardCharsets;
    @@ -78,7 +79,8 @@ private KonnektorService buildService() throws Exception {
                 .clientKeys(keys)
                 .konnektorUri(uri)
                 .proxyServer(PROXY_ADDRESS, PROXY_PORT)
    -            .trustAllServers() // currently we don't validate the server's certificate
    +            .trustManagers(
    +                List.of(new NaiveTrustManager())) // ignoring server-certificate for testing
                 .build();
     
         var conn = cf.connect();
    
  • konnektor/konnektor-client/src/test/java/com/oviva/epa/client/konn/internal/KonnektorConnectionFactoryImplTest.java+93 0 added
    @@ -0,0 +1,93 @@
    +package com.oviva.epa.client.konn.internal;
    +
    +import static org.junit.jupiter.api.Assertions.*;
    +import static org.mockito.Mockito.*;
    +
    +import java.net.URI;
    +import java.util.List;
    +import javax.net.ssl.TrustManager;
    +import org.junit.jupiter.api.Test;
    +
    +class KonnektorConnectionFactoryImplTest {
    +
    +  @Test
    +  void constructor_httpsUri_setsTlsPreferredTrue() {
    +    var factory = new KonnektorConnectionFactoryImpl(buildConfig("https://konnektor.example.com"));
    +
    +    assertTrue(factory.isTlsPreferred);
    +  }
    +
    +  @Test
    +  void constructor_httpUri_setsTlsPreferredFalse() {
    +    var factory = new KonnektorConnectionFactoryImpl(buildConfig("http://konnektor.example.com"));
    +
    +    assertFalse(factory.isTlsPreferred);
    +  }
    +
    +  @Test
    +  void constructor_uppercaseHttpsUri_setsTlsPreferredTrue() {
    +    var factory = new KonnektorConnectionFactoryImpl(buildConfig("HTTPS://konnektor.example.com"));
    +
    +    assertTrue(factory.isTlsPreferred);
    +  }
    +
    +  @Test
    +  void tlsClientParameters_withSubjectAlternativeName_setsStaticHostnameVerifier() {
    +    var factory =
    +        new KonnektorConnectionFactoryImpl(
    +            buildConfig("https://konnektor.example.com", "konnektor.konlan"));
    +
    +    var params = factory.tlsClientParameters();
    +
    +    assertNotNull(params.getHostnameVerifier());
    +    assertInstanceOf(StaticHostnameVerifier.class, params.getHostnameVerifier());
    +  }
    +
    +  @Test
    +  void tlsClientParameters_withoutSubjectAlternativeName_hasNoCustomHostnameVerifier() {
    +    var factory = new KonnektorConnectionFactoryImpl(buildConfig("https://konnektor.example.com"));
    +
    +    var params = factory.tlsClientParameters();
    +
    +    assertNull(params.getHostnameVerifier());
    +  }
    +
    +  @Test
    +  void tlsClientParameters_withTrustManagers_configuresTrustManagers() {
    +    var trustManager = mock(TrustManager.class);
    +    var factory =
    +        new KonnektorConnectionFactoryImpl(
    +            buildConfig("https://konnektor.example.com", null, List.of(trustManager)));
    +
    +    var params = factory.tlsClientParameters();
    +
    +    assertArrayEquals(new TrustManager[] {trustManager}, params.getTrustManagers());
    +  }
    +
    +  @Test
    +  void tlsClientParameters_emptyTrustManagers_setsEmptyArray() {
    +    var factory = new KonnektorConnectionFactoryImpl(buildConfig("https://konnektor.example.com"));
    +
    +    var params = factory.tlsClientParameters();
    +
    +    assertNotNull(params.getTrustManagers());
    +    assertEquals(0, params.getTrustManagers().length);
    +  }
    +
    +  // -- helpers --
    +
    +  private static KonnektorConnectionConfiguration buildConfig(String uriStr) {
    +    return buildConfig(uriStr, null, List.of());
    +  }
    +
    +  private static KonnektorConnectionConfiguration buildConfig(String uriStr, String san) {
    +    return buildConfig(uriStr, san, List.of());
    +  }
    +
    +  private static KonnektorConnectionConfiguration buildConfig(
    +      String uriStr, String san, List<TrustManager> trustManagers) {
    +    var tlsConfig =
    +        new KonnektorConnectionConfiguration.TlsConfig(List.of(), trustManagers, List.of(), san);
    +    return new KonnektorConnectionConfiguration(URI.create(uriStr), tlsConfig, null, null);
    +  }
    +}
    
  • konnektor/konnektor-client/src/test/java/com/oviva/epa/client/konn/internal/StaticHostnameVerifierTest.java+98 0 added
    @@ -0,0 +1,98 @@
    +package com.oviva.epa.client.konn.internal;
    +
    +import static org.junit.jupiter.api.Assertions.assertFalse;
    +import static org.junit.jupiter.api.Assertions.assertTrue;
    +import static org.mockito.Mockito.mock;
    +import static org.mockito.Mockito.when;
    +
    +import java.security.cert.CertificateParsingException;
    +import java.security.cert.X509Certificate;
    +import java.util.List;
    +import javax.net.ssl.SSLPeerUnverifiedException;
    +import javax.net.ssl.SSLSession;
    +import org.bouncycastle.asn1.x509.GeneralName;
    +import org.junit.jupiter.api.BeforeEach;
    +import org.junit.jupiter.api.Test;
    +
    +class StaticHostnameVerifierTest {
    +
    +  private static final String KONNEKTOR_DNS_SAN = "konnektor.konlan";
    +
    +  private StaticHostnameVerifier verifier;
    +  private SSLSession session;
    +
    +  @BeforeEach
    +  void setUp() {
    +    verifier = new StaticHostnameVerifier(KONNEKTOR_DNS_SAN);
    +    session = mock(SSLSession.class);
    +  }
    +
    +  @Test
    +  void verify_returnsFalse_whenPeerUnverified() throws SSLPeerUnverifiedException {
    +    when(session.getPeerCertificates()).thenThrow(new SSLPeerUnverifiedException("not verified"));
    +
    +    assertFalse(verifier.verify("hostname", session));
    +  }
    +
    +  @Test
    +  void verify_returnsFalse_whenCertHasNoSans() throws Exception {
    +    var cert = mock(X509Certificate.class);
    +    when(session.getPeerCertificates()).thenReturn(new java.security.cert.Certificate[] {cert});
    +    when(cert.getSubjectAlternativeNames()).thenReturn(null);
    +
    +    assertFalse(verifier.verify("hostname", session));
    +  }
    +
    +  @Test
    +  void verify_returnsFalse_whenDnsSanDoesNotMatch() throws Exception {
    +    var cert = mock(X509Certificate.class);
    +    when(session.getPeerCertificates()).thenReturn(new java.security.cert.Certificate[] {cert});
    +    when(cert.getSubjectAlternativeNames())
    +        .thenReturn(List.of(List.of(GeneralName.dNSName, "other.konlan")));
    +
    +    assertFalse(verifier.verify("hostname", session));
    +  }
    +
    +  @Test
    +  void verify_returnsTrue_whenDnsSanMatches() throws Exception {
    +    var cert = mock(X509Certificate.class);
    +    when(session.getPeerCertificates()).thenReturn(new java.security.cert.Certificate[] {cert});
    +    when(cert.getSubjectAlternativeNames())
    +        .thenReturn(List.of(List.of(GeneralName.dNSName, KONNEKTOR_DNS_SAN)));
    +
    +    assertTrue(verifier.verify("hostname", session));
    +  }
    +
    +  @Test
    +  void verify_returnsFalse_whenOnlyNonDnsSanPresent() throws Exception {
    +    var cert = mock(X509Certificate.class);
    +    when(session.getPeerCertificates()).thenReturn(new java.security.cert.Certificate[] {cert});
    +    when(cert.getSubjectAlternativeNames())
    +        .thenReturn(List.of(List.of(GeneralName.iPAddress, "10.0.0.1")));
    +
    +    assertFalse(verifier.verify("hostname", session));
    +  }
    +
    +  @Test
    +  void verify_returnsFalse_whenMatchingCertInIntermediate() throws Exception {
    +    var cert1 = mock(X509Certificate.class);
    +    var cert2 = mock(X509Certificate.class);
    +    when(session.getPeerCertificates())
    +        .thenReturn(new java.security.cert.Certificate[] {cert1, cert2});
    +    when(cert1.getSubjectAlternativeNames())
    +        .thenReturn(List.of(List.of(GeneralName.dNSName, "other.konlan")));
    +    when(cert2.getSubjectAlternativeNames())
    +        .thenReturn(List.of(List.of(GeneralName.dNSName, KONNEKTOR_DNS_SAN)));
    +
    +    assertFalse(verifier.verify("hostname", session));
    +  }
    +
    +  @Test
    +  void verify_returnsFalse_whenCertificateParsingFails() throws Exception {
    +    var cert = mock(X509Certificate.class);
    +    when(session.getPeerCertificates()).thenReturn(new java.security.cert.Certificate[] {cert});
    +    when(cert.getSubjectAlternativeNames()).thenThrow(new CertificateParsingException("bad cert"));
    +
    +    assertFalse(verifier.verify("hostname", session));
    +  }
    +}
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

5

News mentions

0

No linked articles in our index yet.