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
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- Range: < 1.2.2
Patches
19111d6fbb939EPA-265: TLS Certificate Validation by Default (#36)
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- github.com/advisories/GHSA-gqx7-6552-67hfghsaADVISORY
- github.com/oviva-ag/epa4all-client/commit/9111d6fbb939007036a7f74b2a93bb278cb5af32ghsa
- github.com/oviva-ag/epa4all-client/pull/36ghsa
- github.com/oviva-ag/epa4all-client/releases/tag/v1.2.2ghsa
- github.com/oviva-ag/epa4all-client/security/advisories/GHSA-gqx7-6552-67hfghsa
News mentions
0No linked articles in our index yet.