diff --git a/.gitignore b/.gitignore index 1f6b3484..91a8ec3a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ target .project .settings .classpath + +dependency-reduced-pom.xml diff --git a/NOTICE b/NOTICE index 4fa4feb8..9a67370a 100644 --- a/NOTICE +++ b/NOTICE @@ -12,6 +12,7 @@ This software includes third party software subject to the following licenses: Apache Commons Lang under Apache License, Version 2.0 Apache HttpClient under Apache License, Version 2.0 Apache HttpCore under Apache License, Version 2.0 + Digipost Certificate Validator under The Apache Software License, Version 2.0 istack common utility code runtime under Eclipse Distribution License - v 1.0 Jakarta Activation under EDL 1.0 Jakarta Activation API jar under EDL 1.0 diff --git a/pom.xml b/pom.xml index a78e4f4a..43f36356 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,17 @@ signature-api-specification-jaxb ${signature.api.version} + + no.digipost + certificate-validator + 2.3 + + + org.bouncycastle + * + + + org.apache.httpcomponents httpclient @@ -205,11 +216,6 @@ src/main/resources true - - src/main/certificates - false - no/digipost/signature/client/certificates - @@ -269,6 +275,37 @@ + + maven-shade-plugin + + 3.2.1 + + true + + + no.digipost:certificate-validator + + + + + no.digipost.* + + META-INF/*.MF + + + + + + no.digipost.security + no.digipost.signature.client.core.internal.security.certificate_validator + + + + maven-surefire-plugin 3.0.0-M5 @@ -363,6 +400,16 @@ + + maven-shade-plugin + + + + shade + + + + diff --git a/src/main/certificates/prod/BPClass3CA3.cer b/src/main/certificates/prod/BPClass3CA3.cer deleted file mode 100644 index a29ff185..00000000 Binary files a/src/main/certificates/prod/BPClass3CA3.cer and /dev/null differ diff --git a/src/main/certificates/prod/BPClass3RootCA.cer b/src/main/certificates/prod/BPClass3RootCA.cer deleted file mode 100644 index 24e5adbb..00000000 Binary files a/src/main/certificates/prod/BPClass3RootCA.cer and /dev/null differ diff --git a/src/main/certificates/prod/commfides_ca.cer b/src/main/certificates/prod/commfides_ca.cer deleted file mode 100644 index 7451ab3e..00000000 Binary files a/src/main/certificates/prod/commfides_ca.cer and /dev/null differ diff --git a/src/main/certificates/prod/commfides_root_ca.cer b/src/main/certificates/prod/commfides_root_ca.cer deleted file mode 100644 index b6611754..00000000 Binary files a/src/main/certificates/prod/commfides_root_ca.cer and /dev/null differ diff --git a/src/main/certificates/test/Buypass_Class_3_Test4_CA_3.cer b/src/main/certificates/test/Buypass_Class_3_Test4_CA_3.cer deleted file mode 100644 index 0e02b93c..00000000 Binary files a/src/main/certificates/test/Buypass_Class_3_Test4_CA_3.cer and /dev/null differ diff --git a/src/main/certificates/test/Buypass_Class_3_Test4_Root_CA.cer b/src/main/certificates/test/Buypass_Class_3_Test4_Root_CA.cer deleted file mode 100644 index 751257c3..00000000 Binary files a/src/main/certificates/test/Buypass_Class_3_Test4_Root_CA.cer and /dev/null differ diff --git a/src/main/certificates/test/commfides_test_ca.cer b/src/main/certificates/test/commfides_test_ca.cer deleted file mode 100644 index 8cacb78b..00000000 Binary files a/src/main/certificates/test/commfides_test_ca.cer and /dev/null differ diff --git a/src/main/certificates/test/commfides_test_root_ca.cer b/src/main/certificates/test/commfides_test_root_ca.cer deleted file mode 100644 index 283971e5..00000000 Binary files a/src/main/certificates/test/commfides_test_root_ca.cer and /dev/null differ diff --git a/src/main/certificates/test/digipost_test_root_ca.pem b/src/main/certificates/test/digipost_test_root_ca.pem deleted file mode 100644 index 322e8df8..00000000 --- a/src/main/certificates/test/digipost_test_root_ca.pem +++ /dev/null @@ -1,33 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFtzCCA5+gAwIBAgIJAKlim8GZ3ABzMA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNV -BAYTAk5PMQ0wCwYDVQQIDARPc2xvMRgwFgYDVQQKDA9Qb3N0ZW4gTm9yZ2UgQVMx -ETAPBgNVBAsMCERpZ2lwb3N0MR4wHAYDVQQDDBVEaWdpcG9zdCBUZXN0IFJvb3Qg -Q0EwIBcNMTUxMTAzMjEyNTE3WhgPMjA1MDA5MTYyMTI1MTdaMGkxCzAJBgNVBAYT -Ak5PMQ0wCwYDVQQIDARPc2xvMRgwFgYDVQQKDA9Qb3N0ZW4gTm9yZ2UgQVMxETAP -BgNVBAsMCERpZ2lwb3N0MR4wHAYDVQQDDBVEaWdpcG9zdCBUZXN0IFJvb3QgQ0Ew -ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDS9pw3Ax73oSAHAvPFseeb -M3M1hUYlpxTV7iEary7mXHmcQf4mtztqEnYINMKoPgh2HpLauiMc2ZS6axrE/frh -wPOUS9iK7c/QBnr0galeqyr4YmjTUdLNH8ifVbrx8J2qNsxgcakDX9cOj9YIIiYQ -Or+J78x9uopOBYfJgHBYgaBbHguQ7Ackf0Y9S117/nqzWfVEQs+9P03YP3pOuS2N -xY0iugeNfMO0Wfq/mShv0ZqtkpRs9bAhQ+EOaZzSRdRn0+DqmkiQtwyspDsWINU6 -hu+tuWLIRzX2XVEWa0h2oEs2xDLIr0oU7LHHruULcH6pneQjwk+K2o8F9Oh4sMjL -mp921edh6SGYqxiQs2c4j5m0jNbewbkfcdFL1v/GbzUbp4bmYYBAP6pm9EHMZb/r -q2+RVWHJIiGy7nqPNzBs7d3lGTgkGx7zHNgx76fe/DCrsKjQGwY4IO/hm8nh3ujo -ITj2EBbt2npxHu/9C5Fs4Z3jts2Kw+pBeL16ODh8zh11GlT9KnzmSEGuaQbNNfag -LDsUU7J9pqqk/wSgGh7FB6PMW+HqsBEeq43JQxlEAqIPzNbTdGQENYI9LEMr9FfV -I5g+murFlVF9ZNHutLe3ufnGvVBx9Ay3ES65guBupW8hBtpWDsTKnOBg8nfhZev/ -tyOxWy1lYtnryqXi3WEJBQIDAQABo2AwXjAdBgNVHQ4EFgQUFLAeb496K7Xdwrp/ -37LcatO4WX0wHwYDVR0jBBgwFoAUFLAeb496K7Xdwrp/37LcatO4WX0wDwYDVR0T -AQH/BAUwAwEB/zALBgNVHQ8EBAMCAgQwDQYJKoZIhvcNAQELBQADggIBAAaAk62u -gTtTMD/UqvXlSQRZztVLVH5HLWFACRtm/nl55FinGygFETLSKXWa52T7rcC7zs6F -5BGjQSLkb+/Kogg3GL3Ve2nts8HG6iaM5SDBu2PA/rVAG1W4xOmccFGJ79rWBw1o -0IQg9k2+34A02B9LJ8/EzRW7SdKyYfhQibMIwwypTUhJBA4dvf797dQAey+wbsyS -XhqzwGsk3EcBAXWIh1yUx7tpHXGjEFmmKGp7hKUSxiI+fXkFbcuKHqYkuaGlBcWj -ybVe97flx+pPNnlj8CaXadsqE3F8xB4cuE7GmD1QsBSsCkDgETDxf1raJqEa9Lpf -ks7BH8x6MjEbAiAKa6hBh7L1+6CcDGYFaVowAuhbxs7+GqX/pw3A6mgDkY1RTTZ1 -ZH3sU1tIwagccE0gNuocz/c/+iFgY3DXHQsqz5sFwu9AMppOZ2LMMwOXZYebBCl7 -BmpNmoBal7mFr7YfpRwKbieuGOa6pBvkHa+m78uiWy2h9BYpnLkwpr51qKETFCcG -Fe2nPzqWGxSljPWeKcwFGzuB+Mq2oBpnA90PqkWhdhpf/26FjmMaPEz1bkC4n+Lc -BXZ4V8e11X6sm4PqDcNFJRE8pg0gaq5xeGbWskh3Oe8RWIQZhtuHo3HQNcVqVG0P -M0BKDEb+eUJjbZnjl/cVoV2gPllIWlkW/5Bh ------END CERTIFICATE----- diff --git a/src/main/java/no/digipost/signature/client/Certificates.java b/src/main/java/no/digipost/signature/client/Certificates.java index dba6998a..9dc89879 100644 --- a/src/main/java/no/digipost/signature/client/Certificates.java +++ b/src/main/java/no/digipost/signature/client/Certificates.java @@ -1,7 +1,6 @@ package no.digipost.signature.client; import java.util.List; -import java.util.function.Function; import java.util.stream.Stream; import static java.util.stream.Collectors.toList; @@ -11,13 +10,27 @@ public enum Certificates { TEST( "test/Buypass_Class_3_Test4_CA_3.cer", "test/Buypass_Class_3_Test4_Root_CA.cer", + + "test/BPCl3CaG2HTBS.cer", + "test/BPCl3CaG2STBS.cer", + "test/BPCl3RootCaG2HT.cer", + "test/BPCl3RootCaG2ST.cer", + "test/commfides_test_ca.cer", "test/commfides_test_root_ca.cer", - "test/digipost_test_root_ca.pem" + + "test/digipost_test_root_ca.cert.pem" + ), PRODUCTION( "prod/BPClass3CA3.cer", "prod/BPClass3RootCA.cer", + + "prod/BPCl3CaG2HTBS.cer", + "prod/BPCl3CaG2STBS.cer", + "prod/BPCl3RootCaG2HT.cer", + "prod/BPCl3RootCaG2ST.cer", + "prod/commfides_ca.cer", "prod/commfides_root_ca.cer" ); @@ -25,18 +38,9 @@ public enum Certificates { final List certificatePaths; Certificates(String ... certificatePaths) { - this.certificatePaths = Stream.of(certificatePaths).map(FullCertificateClassPathUri.instance).collect(toList()); + this.certificatePaths = Stream.of(certificatePaths) + .map("classpath:/certificates/"::concat) + .collect(toList()); } -} - - -final class FullCertificateClassPathUri implements Function { - static final FullCertificateClassPathUri instance = new FullCertificateClassPathUri(); - private static final String root = "/" + Certificates.class.getPackage().getName().replace('.', '/') + "/certificates/"; - - @Override - public String apply(String resourceName) { - return "classpath:" + root + resourceName; - } } diff --git a/src/main/java/no/digipost/signature/client/ClientConfiguration.java b/src/main/java/no/digipost/signature/client/ClientConfiguration.java index c6270dbc..d0d4feb1 100644 --- a/src/main/java/no/digipost/signature/client/ClientConfiguration.java +++ b/src/main/java/no/digipost/signature/client/ClientConfiguration.java @@ -8,14 +8,14 @@ import no.digipost.signature.client.core.exceptions.KeyException; import no.digipost.signature.client.core.internal.http.AddRequestHeaderFilter; import no.digipost.signature.client.core.internal.http.HttpIntegrationConfiguration; -import no.digipost.signature.client.core.internal.http.PostenEnterpriseCertificateStrategy; +import no.digipost.signature.client.core.internal.http.SignatureApiTrustStrategy; import no.digipost.signature.client.core.internal.security.ProvidesCertificateResourcePaths; import no.digipost.signature.client.core.internal.security.TrustStoreLoader; import no.digipost.signature.client.core.internal.xml.JaxbMessageReaderWriterProvider; +import no.digipost.signature.client.security.CertificateChainValidation; import no.digipost.signature.client.security.KeyStoreConfig; +import no.digipost.signature.client.security.OrganizationNumberValidation; import org.apache.commons.lang3.StringUtils; -import org.apache.http.ssl.PrivateKeyDetails; -import org.apache.http.ssl.PrivateKeyStrategy; import org.apache.http.ssl.SSLContexts; import org.glassfish.jersey.client.ClientConfig; import org.glassfish.jersey.client.ClientProperties; @@ -28,8 +28,8 @@ import javax.ws.rs.core.Configurable; import javax.ws.rs.core.Configuration; import javax.ws.rs.core.HttpHeaders; + import java.io.InputStream; -import java.net.Socket; import java.net.URI; import java.nio.file.Path; import java.security.KeyManagementException; @@ -39,7 +39,6 @@ import java.time.Clock; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.function.Consumer; @@ -89,14 +88,13 @@ public final class ClientConfiguration implements ProvidesCertificateResourcePat private final Optional sender; private final URI signatureServiceRoot; private final Iterable documentBundleProcessors; + private final CertificateChainValidation serverCertificateValidation; private final Clock clock; - - private ClientConfiguration( KeyStoreConfig keyStoreConfig, Configurable jaxrsConfig, Optional sender, URI serviceRoot, Iterable certificatePaths, - Iterable documentBundleProcessors, Clock clock) { + Iterable documentBundleProcessors, CertificateChainValidation serverCertificateValidation, Clock clock) { this.keyStoreConfig = keyStoreConfig; this.jaxrsConfig = jaxrsConfig; @@ -104,6 +102,7 @@ private ClientConfiguration( this.signatureServiceRoot = serviceRoot; this.certificatePaths = certificatePaths; this.documentBundleProcessors = documentBundleProcessors; + this.serverCertificateValidation = serverCertificateValidation; this.clock = clock; } @@ -153,14 +152,9 @@ public Configuration getJaxrsConfiguration() { @Override public SSLContext getSSLContext() { try { - return SSLContexts.custom() - .loadKeyMaterial(keyStoreConfig.keyStore, keyStoreConfig.privatekeyPassword.toCharArray(), new PrivateKeyStrategy() { - @Override - public String chooseAlias(Map aliases, Socket socket) { - return keyStoreConfig.alias; - } - }) - .loadTrustMaterial(TrustStoreLoader.build(this), new PostenEnterpriseCertificateStrategy()) + return SSLContexts.custom() + .loadKeyMaterial(keyStoreConfig.keyStore, keyStoreConfig.privatekeyPassword.toCharArray(), (aliases, socket) -> keyStoreConfig.alias) + .loadTrustMaterial(TrustStoreLoader.build(this), new SignatureApiTrustStrategy(serverCertificateValidation)) .build(); } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException | UnrecoverableKeyException e) { if (e instanceof UnrecoverableKeyException && "Given final block not properly padded".equals(e.getMessage())) { @@ -194,6 +188,7 @@ public static class Builder { private URI serviceRoot = ServiceUri.PRODUCTION.uri; private Optional globalSender = Optional.empty(); private Iterable certificatePaths = Certificates.PRODUCTION.certificatePaths; + private CertificateChainValidation serverCertificateTrustStrategy = new OrganizationNumberValidation("984661185"); // Posten Norge AS organization number private Optional loggingFeature = Optional.empty(); private List documentBundleProcessors = new ArrayList<>(); private Clock clock = Clock.systemDefaultZone(); @@ -350,6 +345,35 @@ public Builder customizeJaxRs(Consumernot + * be overridden unless you have a specific need such as doing testing against your own + * stubbed implementation of the Posten signering API. + * + * @param serverOrganizationNumber the organization number expected in the server's enterprise certificate + */ + public Builder serverOrganizationNumber(String serverOrganizationNumber) { + return serverCertificateTrustStrategy(new OrganizationNumberValidation(serverOrganizationNumber)); + } + + + /** + * Override the validation of the server's certificate. This method is mainly + * intended for tests if you need to override (or even disable) the default + * validation that the server identifies itself as "Posten Norge AS". + * + * Calling this method for a production deployment is probably not what you intend to do! + * + * @param certificateChainValidation the validation for the server's certificate + */ + public Builder serverCertificateTrustStrategy(CertificateChainValidation certificateChainValidation) { + LOG.warn("Overriding server certificate TrustStrategy! This should NOT be done for any integration with Posten signering."); + this.serverCertificateTrustStrategy = certificateChainValidation; + return this; + } + /** * Allows for overriding which {@link Clock} is used to convert between Java and XML, * may be useful for e.g. automated tests. @@ -368,7 +392,9 @@ public ClientConfiguration build() { jaxrsConfig.register(JaxbMessageReaderWriterProvider.class); jaxrsConfig.register(new AddRequestHeaderFilter(USER_AGENT, createUserAgentString())); this.loggingFeature.ifPresent(jaxrsConfig::register); - return new ClientConfiguration(keyStoreConfig, jaxrsConfig, globalSender, serviceRoot, certificatePaths, documentBundleProcessors, clock); + return new ClientConfiguration( + keyStoreConfig, jaxrsConfig, globalSender, serviceRoot, certificatePaths, + documentBundleProcessors, serverCertificateTrustStrategy, clock); } String createUserAgentString() { diff --git a/src/main/java/no/digipost/signature/client/core/internal/http/PostenEnterpriseCertificateStrategy.java b/src/main/java/no/digipost/signature/client/core/internal/http/PostenEnterpriseCertificateStrategy.java deleted file mode 100644 index 54221a29..00000000 --- a/src/main/java/no/digipost/signature/client/core/internal/http/PostenEnterpriseCertificateStrategy.java +++ /dev/null @@ -1,56 +0,0 @@ -package no.digipost.signature.client.core.internal.http; - -import no.digipost.signature.client.core.exceptions.SecurityException; -import org.apache.http.conn.ssl.TrustStrategy; - -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; - -public class PostenEnterpriseCertificateStrategy implements TrustStrategy { - - private static final String POSTEN_ORGANIZATION_NUMBER = "984661185"; - - /** - * Used by some obscure cases to embed Norwegian "organisasjonsnummer" in certificates. - */ - private static final String COMMON_NAME_POSTEN = "CN=" + POSTEN_ORGANIZATION_NUMBER; - - /** - * Most common way to embed Norwegian "organisasjonsnummer" in certificates. - */ - private static final String SERIALNUMBER_POSTEN = "SERIALNUMBER=" + POSTEN_ORGANIZATION_NUMBER; - - - /** - * Verify that the server certificate is issued to Posten Norge AS. - * - * Note that we have to throw an Exception to make sure that invalid certificates will be denied. - * The http client TrustStrategy can only be used to used to state that a server certificate is to be - * trusted without consulting the standard Java certificate verification process. - * - * Always returns false to make sure http client will run the Java certificate verification process, which - * will verify the certificate against the trust store, making sure that it's actually issued by a trusted CA. - * - * @see javax.net.ssl.X509TrustManager#checkServerTrusted(X509Certificate[], String) - */ - @Override - public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException { - String subjectDN = chain[0].getSubjectDN().getName(); - - if (!isPostenEnterpriseCertiticate(subjectDN)) { - throw new SecurityException("Could not find correct organization number in server certificate. Make sure the server URI is correct.\n" + - "Actual certificate: " + subjectDN + ".\n" + - "Expected certificate issued to organization number " + POSTEN_ORGANIZATION_NUMBER + "\n" + - "This could indicate a misconfiguration of the client or server, or potentially a man-in-the-middle attack."); - } - - return false; - } - - private boolean isPostenEnterpriseCertiticate(String subjectDN) { - String lowerCaseSubjectDN = subjectDN.toLowerCase(); - return lowerCaseSubjectDN.contains(SERIALNUMBER_POSTEN.toLowerCase()) || - lowerCaseSubjectDN.contains(COMMON_NAME_POSTEN.toLowerCase()); - } - -} diff --git a/src/main/java/no/digipost/signature/client/core/internal/http/SignatureApiTrustStrategy.java b/src/main/java/no/digipost/signature/client/core/internal/http/SignatureApiTrustStrategy.java new file mode 100644 index 00000000..a3af6747 --- /dev/null +++ b/src/main/java/no/digipost/signature/client/core/internal/http/SignatureApiTrustStrategy.java @@ -0,0 +1,46 @@ +package no.digipost.signature.client.core.internal.http; + +import no.digipost.signature.client.core.exceptions.SecurityException; +import no.digipost.signature.client.security.CertificateChainValidation; +import no.digipost.signature.client.security.CertificateChainValidation.Result; +import org.apache.http.conn.ssl.TrustStrategy; + +import java.security.cert.X509Certificate; + +public final class SignatureApiTrustStrategy implements TrustStrategy { + + private final CertificateChainValidation certificateChainValidation; + + public SignatureApiTrustStrategy(CertificateChainValidation certificateChainValidation) { + this.certificateChainValidation = certificateChainValidation; + } + + /** + * Verify that the server certificate is trusted. + * + * Note that we have to throw an Exception to make sure that invalid certificates will be denied. + * The http client TrustStrategy can only be used to used to state that a server certificate is to be + * trusted without consulting the standard Java certificate verification process. + * + * Unintuitively returns {@code false} when the {@link CertificateChainValidation} determines the chain + * to be {@link Result#TRUSTED} to make sure http client will run the Java certificate verification process, which + * will verify the certificate against the trust store, making sure that it's actually issued by a trusted CA. + * + * @see javax.net.ssl.X509TrustManager#checkServerTrusted(X509Certificate[], String) + */ + @Override + public boolean isTrusted(X509Certificate[] chain, String authType) { + Result result = certificateChainValidation.validate(chain); + switch (result) { + case TRUSTED_AND_SKIP_FURTHER_VALIDATION: return true; + case TRUSTED: return false; + case UNTRUSTED: default: + String subjectDN = chain[0].getSubjectDN().getName(); + throw new SecurityException( + "Untrusted server certificate, according to " + certificateChainValidation + ". " + + "Make sure the server URI is correct. Actual certificate: " + subjectDN + ". " + + "This could indicate a misconfiguration of the client or server, or potentially a man-in-the-middle attack."); + } + } + +} diff --git a/src/main/java/no/digipost/signature/client/core/internal/security/TrustStoreLoader.java b/src/main/java/no/digipost/signature/client/core/internal/security/TrustStoreLoader.java index b85de30c..3eb4d5a7 100644 --- a/src/main/java/no/digipost/signature/client/core/internal/security/TrustStoreLoader.java +++ b/src/main/java/no/digipost/signature/client/core/internal/security/TrustStoreLoader.java @@ -1,15 +1,17 @@ package no.digipost.signature.client.core.internal.security; -import no.digipost.signature.client.Certificates; import no.digipost.signature.client.core.exceptions.ConfigurationException; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; -import java.io.File; -import java.io.FileInputStream; + import java.io.IOException; import java.io.InputStream; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -17,6 +19,10 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static java.nio.file.Files.isDirectory; public class TrustStoreLoader { @@ -30,92 +36,117 @@ public static KeyStore build(ProvidesCertificateResourcePaths hasCertificatePath } return trustStore; - } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException | KeyManagementException e) { + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) { throw new ConfigurationException("Unable to load certificates into truststore", e); } } - private static void loadCertificatesInto(String certificateFolder, final KeyStore trustStore) throws IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException, KeyManagementException { + private static void loadCertificatesInto(String certificateLocation, KeyStore trustStore) { ResourceLoader certificateLoader; - if (certificateFolder.indexOf("classpath:") == 0) { - certificateLoader = new ClassPathFileLoader(certificateFolder); + if (certificateLocation.startsWith(ClassPathResourceLoader.CLASSPATH_PATH_PREFIX)) { + certificateLoader = new ClassPathResourceLoader(certificateLocation); } else { - certificateLoader = new FileLoader(certificateFolder); + certificateLoader = new FileLoader(certificateLocation); } - certificateLoader.forEachFile(new ForFile() { - @Override - void call(String fileName, InputStream contents) { - try { - X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(contents); - trustStore.setCertificateEntry(fileName, ca); - } catch (CertificateException | KeyStoreException e) { - throw new ConfigurationException("Unable to load certificate in " + fileName); - } - } + certificateLoader.forEachFile((fileName, contents) -> { + X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(contents); + trustStore.setCertificateEntry(fileName, ca); }); + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(trustStore); - - SSLContext context = SSLContext.getInstance("TLS"); - context.init(null, tmf.getTrustManagers(), null); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, tmf.getTrustManagers(), null); + } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) { + throw new ConfigurationException("Error initializing SSLContext for certification location " + certificateLocation, e); + } } - private static class ClassPathFileLoader implements ResourceLoader { + + private static class ClassPathResourceLoader implements ResourceLoader { static final String CLASSPATH_PATH_PREFIX = "classpath:"; - private final String certificatePath; + private final String resourceName; - ClassPathFileLoader(String certificateFolder) { - this.certificatePath = certificateFolder.substring(CLASSPATH_PATH_PREFIX.length()); + ClassPathResourceLoader(String resourceName) { + this.resourceName = resourceName.replaceFirst(CLASSPATH_PATH_PREFIX, ""); } @Override - public void forEachFile(ForFile forEachFile) throws IOException { - URL contentsUrl = Certificates.class.getResource(certificatePath); + public void forEachFile(ForFile forEachFile) { + URL resourceUrl = TrustStoreLoader.class.getResource(resourceName); + if (resourceUrl == null) { + throw new ConfigurationException(resourceName + " not found on classpath"); + } - try (InputStream inputStream = contentsUrl.openStream()){ - forEachFile.call(new File(contentsUrl.getFile()).getName(), inputStream); + try (InputStream inputStream = resourceUrl.openStream()) { + forEachFile.call(generateAlias(resourceName), inputStream); + } catch (Exception e) { + throw new ConfigurationException("Unable to load certificate from classpath: " + resourceName, e); } } } private static class FileLoader implements ResourceLoader { - private final File path; + private final Path path; FileLoader(String certificateFolder) { - this.path = new File(certificateFolder); + this.path = Paths.get(certificateFolder); } @Override - public void forEachFile(ForFile forEachFile) throws IOException { - if (!this.path.isDirectory()) { + public void forEachFile(ForFile forEachFile) { + if (!isDirectory(path)) { throw new ConfigurationException("Certificate path '" + this.path + "' is not a directory. " + "It should point to a directory containing certificates."); } - File[] files = this.path.listFiles(); - if (files == null) { - throw new ConfigurationException("Unable to read certificates from '" + path + "'. Make sure it's the correct path."); - } - for (File file : files) { - try (InputStream contents = new FileInputStream(file)) { - forEachFile.call(file.getName(), contents); - } + try (Stream files = Files.list(path)) { + files.forEach(file -> { + try (InputStream contents = Files.newInputStream(file)) { + forEachFile.call(generateAlias(file), contents); + } catch (Exception e) { + throw new ConfigurationException("Unable to load certificate from file " + file, e); + } + }); + } catch (IOException e) { + throw new ConfigurationException("Error reading certificates from " + path, e); } } } private interface ResourceLoader { - void forEachFile(ForFile forEachFile) throws IOException; + void forEachFile(ForFile forEachFile); } - private abstract static class ForFile { - abstract void call(String fileName, InputStream contents); + @FunctionalInterface + private interface ForFile { + void call(String fileName, InputStream contents) throws IOException, GeneralSecurityException; } + static String generateAlias(Path location) { + return generateAlias(location.toString()); + } + + private static final AtomicInteger aliasSequence = new AtomicInteger(); + + static String generateAlias(String resourceName) { + if (resourceName == null || resourceName.trim().isEmpty()) { + return "certificate-alias-" + aliasSequence.getAndIncrement(); + } + String[] splitOnSlashes = resourceName.split("/"); + int size = splitOnSlashes.length; + if (size == 1) { + return splitOnSlashes[0]; + } else { + return splitOnSlashes[size - 2] + ":" + splitOnSlashes[size - 1]; + } + } + + } diff --git a/src/main/java/no/digipost/signature/client/security/CertificateChainValidation.java b/src/main/java/no/digipost/signature/client/security/CertificateChainValidation.java new file mode 100644 index 00000000..313748bf --- /dev/null +++ b/src/main/java/no/digipost/signature/client/security/CertificateChainValidation.java @@ -0,0 +1,34 @@ +package no.digipost.signature.client.security; + +import javax.net.ssl.SSLContext; + +import java.security.cert.X509Certificate; + +public interface CertificateChainValidation { + + enum Result { + /** + * Indicates that the certificate chain is trusted by this particular + * validation, but is subject to further validation by the {@link SSLContext}'s + * configured trust manager. This should be considered as the default result + * from a successful validation. + */ + TRUSTED, + + /** + * The certificate is determined to be trusted, and validation by the + * {@link SSLContext}'s trust manager should be skipped. This result is not appropriate + * for any integration with Posten signering, as it will effectively skip validating + * the certificate to be issued by the trusted CA hierarchy. + */ + TRUSTED_AND_SKIP_FURTHER_VALIDATION, + + /** + * The certificate chain has been determined to be not trusted. + */ + UNTRUSTED + } + + Result validate(X509Certificate[] certChain); + +} diff --git a/src/main/java/no/digipost/signature/client/security/OrganizationNumberValidation.java b/src/main/java/no/digipost/signature/client/security/OrganizationNumberValidation.java new file mode 100644 index 00000000..723edc93 --- /dev/null +++ b/src/main/java/no/digipost/signature/client/security/OrganizationNumberValidation.java @@ -0,0 +1,34 @@ +package no.digipost.signature.client.security; + +import no.digipost.security.X509; + +import java.security.cert.X509Certificate; + +/** + * Validates that the first certificate in a given certificate chain + * is issued to a specific Norwegian enterprise by inspecting its + * organization number. + */ +public class OrganizationNumberValidation implements CertificateChainValidation { + + private final String trustedOrganizationNumber; + + public OrganizationNumberValidation(String trustedOrganizationNumber) { + this.trustedOrganizationNumber = trustedOrganizationNumber; + } + + @Override + public Result validate(X509Certificate[] certChain) { + return X509 + .findOrganisasjonsnummer(certChain[0]) + .filter(trustedOrganizationNumber::equals) + .map(trusted -> Result.TRUSTED) + .orElse(Result.UNTRUSTED); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " trusting '" + trustedOrganizationNumber + "'"; + } + +} diff --git a/src/test/java/no/digipost/signature/client/TestCertificates.java b/src/test/java/no/digipost/signature/client/TestCertificates.java index 95b21d33..2cdae57d 100644 --- a/src/test/java/no/digipost/signature/client/TestCertificates.java +++ b/src/test/java/no/digipost/signature/client/TestCertificates.java @@ -2,6 +2,9 @@ import no.digipost.signature.client.security.KeyStoreConfig; +import java.io.IOException; +import java.io.InputStream; + public class TestCertificates { private static final String password = "yJPvczYAoirFfC9M"; @@ -29,17 +32,19 @@ public class TestCertificates { */ public static KeyStoreConfig getJavaKeyStore() { - return KeyStoreConfig.fromOrganizationCertificate( - TestCertificates.class.getResourceAsStream("/bring-expired-certificate-for-testing.p12"), - password - ); + try (InputStream p12InputStream = TestCertificates.class.getResourceAsStream("/bring-expired-certificate-for-testing.p12")) { + return KeyStoreConfig.fromOrganizationCertificate(p12InputStream, password); + } catch (IOException e) { + throw new RuntimeException(e.getMessage(), e); + } } public static KeyStoreConfig getOrganizationCertificateKeyStore() { - return KeyStoreConfig.fromJavaKeyStore( - TestCertificates.class.getResourceAsStream("/bring-expired-keystore-for-testing.jks"), - "digipost testintegrasjon for digital post", password, password - ); + try (InputStream jksInputStream = TestCertificates.class.getResourceAsStream("/bring-expired-keystore-for-testing.jks")) { + return KeyStoreConfig.fromJavaKeyStore(jksInputStream, "digipost testintegrasjon for digital post", password, password); + } catch (IOException e) { + throw new RuntimeException(e.getMessage(), e); + } } diff --git a/src/test/java/no/digipost/signature/client/core/internal/http/SignatureApiTrustStrategyTest.java b/src/test/java/no/digipost/signature/client/core/internal/http/SignatureApiTrustStrategyTest.java new file mode 100644 index 00000000..4c3b3b75 --- /dev/null +++ b/src/test/java/no/digipost/signature/client/core/internal/http/SignatureApiTrustStrategyTest.java @@ -0,0 +1,41 @@ +package no.digipost.signature.client.core.internal.http; + +import no.digipost.signature.client.TestCertificates; +import no.digipost.signature.client.core.exceptions.SecurityException; +import no.digipost.signature.client.security.CertificateChainValidation.Result; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; + +import static no.digipost.signature.client.security.CertificateChainValidation.Result.TRUSTED; +import static no.digipost.signature.client.security.CertificateChainValidation.Result.TRUSTED_AND_SKIP_FURTHER_VALIDATION; +import static no.digipost.signature.client.security.CertificateChainValidation.Result.UNTRUSTED; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static uk.co.probablyfine.matchers.Java8Matchers.where; + +class SignatureApiTrustStrategyTest { + + private static final X509Certificate[] certChain = new X509Certificate[]{TestCertificates.getOrganizationCertificateKeyStore().getCertificate()}; + + @Test + void translates_TRUSTED_to_false_to_not_override_SSLContext_validation() { + assertThat(TRUSTED, where(SignatureApiTrustStrategyTest::httpClientTrustStrategyTrust, is(false))); + } + + @Test + void translates_TRUSTED_AND_SKIP_FURTHER_VALIDATION_to_true() { + assertThat(TRUSTED_AND_SKIP_FURTHER_VALIDATION, where(SignatureApiTrustStrategyTest::httpClientTrustStrategyTrust, is(true))); + } + + @Test + void translates_UNTRUSTED_to_throwing_exception() { + assertThrows(SecurityException.class, () -> httpClientTrustStrategyTrust(UNTRUSTED)); + } + + private static boolean httpClientTrustStrategyTrust(Result result) { + return new SignatureApiTrustStrategy(certChain -> result).isTrusted(certChain, "authType"); + } + +} diff --git a/src/test/java/no/digipost/signature/client/core/internal/security/TrustStoreLoaderTest.java b/src/test/java/no/digipost/signature/client/core/internal/security/TrustStoreLoaderTest.java index 0ccb60fe..5595c3b7 100644 --- a/src/test/java/no/digipost/signature/client/core/internal/security/TrustStoreLoaderTest.java +++ b/src/test/java/no/digipost/signature/client/core/internal/security/TrustStoreLoaderTest.java @@ -2,63 +2,172 @@ import no.digipost.signature.client.ClientConfiguration; import no.digipost.signature.client.TestKonfigurasjon; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.nio.file.Paths; import java.security.KeyStore; import java.security.KeyStoreException; +import java.util.List; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import static java.util.Collections.list; +import static java.util.stream.Collectors.toList; import static no.digipost.signature.client.Certificates.PRODUCTION; import static no.digipost.signature.client.Certificates.TEST; +import static no.digipost.signature.client.core.internal.security.TrustStoreLoader.generateAlias; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.hamcrest.Matchers.matchesRegex; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.quicktheories.QuickTheory.qt; +import static org.quicktheories.generators.SourceDSL.strings; +import static uk.co.probablyfine.matchers.Java8Matchers.where; -public class TrustStoreLoaderTest { +class TrustStoreLoaderTest { private ClientConfiguration.Builder configBuilder; @BeforeEach - public void setUp() { + void setUp() { configBuilder = ClientConfiguration.builder(TestKonfigurasjon.CLIENT_KEYSTORE); } @Test - public void loads_productions_certificates_by_default() throws KeyStoreException { - KeyStore keyStore = TrustStoreLoader.build(configBuilder.build()); + void loads_productions_certificates_by_default() throws KeyStoreException { + KeyStore trustStore = TrustStoreLoader.build(configBuilder.build()); - assertThat(keyStore.size(), is(4)); - assertTrue(keyStore.containsAlias("bpclass3rootca.cer"), "Trust store should contain BuyPass root CA"); + assertTrue(trustStore.containsAlias("prod:bpclass3rootca.cer"), "Trust store should contain BuyPass root CA"); + assertThat(trustStore.size(), is(8)); } @Test - public void loads_productions_certificates() throws KeyStoreException { + void loads_productions_certificates() throws KeyStoreException { ClientConfiguration config = configBuilder.trustStore(PRODUCTION).build(); - KeyStore keyStore = TrustStoreLoader.build(config); - - assertThat(keyStore.size(), is(4)); - assertTrue(keyStore.containsAlias("bpclass3rootca.cer"), "Trust store should contain bp root ca"); - assertFalse(keyStore.containsAlias("buypass_class_3_test4_root_ca.cer"), "Trust store should not contain buypass test root ca"); + KeyStore trustStore = TrustStoreLoader.build(config); + + assertThat(trustStore, containsExactlyTheAliases( + "prod:bpclass3rootca.cer", + "prod:bpclass3ca3.cer", + "prod:bpcl3rootcag2st.cer", + "prod:bpcl3cag2stbs.cer", + "prod:bpcl3rootcag2ht.cer", + "prod:bpcl3cag2htbs.cer", + "prod:commfides_root_ca.cer", + "prod:commfides_ca.cer")); } @Test - public void loads_test_certificates() throws KeyStoreException { + void loads_test_certificates() throws KeyStoreException { ClientConfiguration config = configBuilder.trustStore(TEST).build(); - KeyStore keyStore = TrustStoreLoader.build(config); - - assertThat(keyStore.size(), is(5)); - assertFalse(keyStore.containsAlias("bpclass3rootca.cer"), "Trust store should not buypass root ca"); - assertTrue(keyStore.containsAlias("buypass_class_3_test4_root_ca.cer"), "Trust store should contain buypass test root ca"); + KeyStore trustStore = TrustStoreLoader.build(config); + + assertThat(trustStore, containsExactlyTheAliases( + "test:buypass_class_3_test4_root_ca.cer", + "test:buypass_class_3_test4_ca_3.cer", + "test:bpcl3rootcag2st.cer", + "test:bpcl3cag2stbs.cer", + "test:bpcl3rootcag2ht.cer", + "test:bpcl3cag2htbs.cer", + "test:commfides_test_root_ca.cer", + "test:commfides_test_ca.cer", + "test:digipost_test_root_ca.cert.pem")); } @Test - public void loads_certificates_from_file_location() throws KeyStoreException { + void loads_certificates_from_file_location() throws KeyStoreException { ClientConfiguration config = configBuilder.trustStore("./src/test/files/certificateTest").build(); - KeyStore keyStore = TrustStoreLoader.build(config); + KeyStore trustStore = TrustStoreLoader.build(config); + + assertThat(trustStore, containsExactlyTheAliases("certificatetest:commfides_test_ca.cer")); + } + + @Nested + class GenerateAlias { + @Test + void generateAliasFromUnixPath() { + assertThat(generateAlias(Paths.get("/blah/blah/funny/env/MyCert.cer")), is("env:MyCert.cer")); + } + + @Test + void generateAliasFromWindowsPath() { + assertThat(generateAlias(Paths.get("C:/blah/blah/funny/env/MyCert.cer")), is("env:MyCert.cer")); + } + + @Test + void generateAliasFromUnixFileInJarUrlString() { + assertThat(generateAlias("/blah/fun/prod/WEB-INF/lib/mylib.jar!/certificates/env/MyCert.cer"), is("env:MyCert.cer")); + } + + @Test + void generateAliasFromWindowsFileInJarUrlString() { + assertThat(generateAlias("file:/C:/blah/fun/prod/WEB-INF/lib/mylib.jar!/certificates/env/MyCert.cer"), is("env:MyCert.cer")); + } + + @Test + void generateUniqueDefaultAliasesForNullsAndEmptyStrings() { + List defaultAliases = Stream.of(null, "", " ", " \n ").map(s -> TrustStoreLoader.generateAlias(s)).collect(toList()); + int aliasCount = defaultAliases.size(); + assertAll("default aliases", + () -> assertThat(defaultAliases, everyItem(matchesRegex("certificate-alias-\\d+"))), + () -> assertAll(IntStream.range(0, aliasCount).mapToObj(defaultAliases::get).map(alias -> () -> { + List otherAliases = defaultAliases.stream().filter(a -> !alias.equals(a)).collect(toList()); + assertThat("other alises than '" + alias + "'", otherAliases, hasSize(aliasCount - 1)); + }))); + } + + @Test + void alwaysGeneratesAnAlias() { + qt() + .forAll(strings().allPossible().ofLengthBetween(0, 100)) + .checkAssert(s -> assertThat(s, where(TrustStoreLoader::generateAlias, notNullValue()))); + } + - assertThat(keyStore.size(), is(1)); } + private static Matcher containsExactlyTheAliases(String ... certAliases) { + return new TypeSafeDiagnosingMatcher() { + + @Override + public void describeTo(Description description) { + description + .appendText("key store containing " + certAliases.length + " certificates with aliases: ") + .appendValueList("", ", ", "", certAliases); + } + + @Override + protected boolean matchesSafely(KeyStore keyStore, Description mismatchDescription) { + try { + List actualAliases = list(keyStore.aliases()); + Matcher> expectedAliases = containsInAnyOrder(certAliases); + if (!expectedAliases.matches(actualAliases)) { + expectedAliases.describeMismatch(actualAliases, mismatchDescription); + return false; + } else if (keyStore.size() != certAliases.length) { + mismatchDescription.appendText("contained " + keyStore.size() + " certificates"); + } + return true; + } catch (KeyStoreException e) { + mismatchDescription + .appendText("threw exception retrieving the aliases from the key store: ") + .appendValue(e.getClass().getSimpleName()); + throw new RuntimeException(e.getMessage(), e); + } + } + + }; + } + } diff --git a/src/test/java/no/digipost/signature/client/security/OrganizationNumberValidationTest.java b/src/test/java/no/digipost/signature/client/security/OrganizationNumberValidationTest.java new file mode 100644 index 00000000..33994179 --- /dev/null +++ b/src/test/java/no/digipost/signature/client/security/OrganizationNumberValidationTest.java @@ -0,0 +1,45 @@ +package no.digipost.signature.client.security; + +import no.digipost.signature.client.TestCertificates; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import static no.digipost.signature.client.security.CertificateChainValidation.Result.TRUSTED; +import static no.digipost.signature.client.security.CertificateChainValidation.Result.UNTRUSTED; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static uk.co.probablyfine.matchers.Java8Matchers.where; + +public class OrganizationNumberValidationTest { + + @Test + void trusts_SEID1_enterprise_certificate() { + X509Certificate seid1Cert = TestCertificates.getOrganizationCertificateKeyStore().getCertificate(); + assertThat(seid1Cert.getSubjectX500Principal() + "is trusted", + seid1Cert, where(c -> new OrganizationNumberValidation("988015814").validate(new X509Certificate[]{c}), is(TRUSTED))); + } + + @Test + void trusts_SEID2_enterprise_certificate() throws CertificateException, IOException { + X509Certificate seid2Cert; + try (InputStream certContents = getClass().getResourceAsStream("/test4-autentiseringssertifikat-vid-europa.cer")) { + seid2Cert = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(certContents); + } + assertThat(seid2Cert.getSubjectX500Principal() + "is trusted", + seid2Cert, where(c -> new OrganizationNumberValidation("100101688").validate(new X509Certificate[]{c}), is(TRUSTED))); + } + + @Test + void unexpected_organization_number_is_untrusted() { + X509Certificate cert = TestCertificates.getOrganizationCertificateKeyStore().getCertificate(); + assertThat(cert.getSubjectX500Principal() + "is trusted", + cert, where(c -> new OrganizationNumberValidation("0000").validate(new X509Certificate[]{c}), is(UNTRUSTED))); + } + + +} diff --git a/src/test/resources/test4-autentiseringssertifikat-vid-europa.cer b/src/test/resources/test4-autentiseringssertifikat-vid-europa.cer new file mode 100644 index 00000000..bde76975 Binary files /dev/null and b/src/test/resources/test4-autentiseringssertifikat-vid-europa.cer differ