diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index e1739326e..e2bfdb8ea 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -43,6 +43,7 @@ jobs: exit 1 fi - name: Validate release in org.cryptomator.Cryptomator.metainfo.xml file + if: ${{ ! (contains(github.event.head_commit.message, '[skip metadata check]') || contains(github.event.head_commit.message, '[metadata check skip]')) }} run: | if ! grep -q "" dist/linux/common/org.cryptomator.Cryptomator.metainfo.xml; then echo "Release not set in dist/linux/common/org.cryptomator.Cryptomator.metainfo.xml" diff --git a/pom.xml b/pom.xml index d93a96cc2..fddfd38c8 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.cryptomator cryptomator - 1.18.0 + 1.18.1 Cryptomator Desktop App diff --git a/src/main/java/org/cryptomator/networking/SSLContextDifferentTrustStoreBase.java b/src/main/java/org/cryptomator/networking/SSLContextDifferentTrustStoreBase.java index 87e1b82ef..5da47597a 100644 --- a/src/main/java/org/cryptomator/networking/SSLContextDifferentTrustStoreBase.java +++ b/src/main/java/org/cryptomator/networking/SSLContextDifferentTrustStoreBase.java @@ -18,7 +18,7 @@ abstract class SSLContextDifferentTrustStoreBase implements SSLContextProvider { public SSLContext getContext(SecureRandom csprng) throws SSLContextBuildException { try { KeyStore truststore = getTruststore(); - truststore.load(null, null); + ensureLoaded(truststore); TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init(truststore); @@ -30,4 +30,13 @@ abstract class SSLContextDifferentTrustStoreBase implements SSLContextProvider { throw new SSLContextBuildException(e); } } + + static void ensureLoaded(KeyStore truststore) throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { + try { + truststore.aliases(); + } catch (KeyStoreException e) { + // Not initialized yet (e.g. custom KeyStore SPI); initialize without replacing preloaded stores. + truststore.load(null, null); + } + } } diff --git a/src/main/java/org/cryptomator/networking/SSLContextWithWindowsCertStore.java b/src/main/java/org/cryptomator/networking/SSLContextWithWindowsCertStore.java index 0f1ea04b5..83815b611 100644 --- a/src/main/java/org/cryptomator/networking/SSLContextWithWindowsCertStore.java +++ b/src/main/java/org/cryptomator/networking/SSLContextWithWindowsCertStore.java @@ -1,21 +1,73 @@ package org.cryptomator.networking; +import org.cryptomator.common.Nullable; import org.cryptomator.integrations.common.OperatingSystem; +import org.jetbrains.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyStore; import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.cert.CertificateException; +import java.util.List; +import java.util.Properties; /** - * SSLContextProvider for Windows using the Windows certificate store as trust store + * SSLContextProvider for Windows using the Windows certificate store as trust store and the bundled JDK cacerts as fallback *

* In order to work, the jdk.crypto.mscapi jmod is needed */ @OperatingSystem(OperatingSystem.Value.WINDOWS) public class SSLContextWithWindowsCertStore extends SSLContextDifferentTrustStoreBase implements SSLContextProvider { + private static final Logger LOG = LoggerFactory.getLogger(SSLContextWithWindowsCertStore.class); + private static final String DEFAULT_TRUSTSTORE_PASSWORD = "changeit"; //default JDK cacerts password + @Override - KeyStore getTruststore() throws KeyStoreException { - return KeyStore.getInstance("WINDOWS-ROOT"); + KeyStore getTruststore() throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { + var windowsKeyStore = KeyStore.getInstance("WINDOWS-ROOT"); + var jdkKeyStore = getShippedCaCertsStore(); + if (jdkKeyStore == null) { + return windowsKeyStore; + } + + ensureLoaded(windowsKeyStore); + ensureLoaded(jdkKeyStore); + try { + CombinedKeyStoreSpi spi = CombinedKeyStoreSpi.create(windowsKeyStore, jdkKeyStore); + Provider dummyProvider = new Provider("CombinedKeyStoreProvider", "1.0", "Provides a combined, read-only KeyStore") {}; + return new KeyStore(spi, dummyProvider, "CombinedKeyStoreProvider") {}; + } catch (IllegalArgumentException e) { + throw new KeyStoreException(e); + } + } + + @Nullable + KeyStore getShippedCaCertsStore() { + return getCaCertsStoreByProperties(System.getProperties()); + } + + //for testability + @VisibleForTesting + @Nullable + KeyStore getCaCertsStoreByProperties(Properties props) { + var javaHome = Path.of(props.getProperty("java.home")); + var trustStorePassword = props.getProperty("javax.net.ssl.trustStorePassword", DEFAULT_TRUSTSTORE_PASSWORD).toCharArray(); + for (var candidate : List.of(javaHome.resolve("lib/security/cacerts"), javaHome.resolve("conf/security/cacerts"))) { + try { + if (Files.isRegularFile(candidate)) { + return KeyStore.getInstance(candidate.toFile(), trustStorePassword); + } + } catch (CertificateException | KeyStoreException | IOException | NoSuchAlgorithmException e) { + LOG.info("Unable to load fallback cacerts {} file. Skipping fallback.", candidate, e); + } + } + return null; } } diff --git a/src/test/java/org/cryptomator/networking/SSLContextWithWindowsCertStoreTest.java b/src/test/java/org/cryptomator/networking/SSLContextWithWindowsCertStoreTest.java new file mode 100644 index 000000000..dca3c3ced --- /dev/null +++ b/src/test/java/org/cryptomator/networking/SSLContextWithWindowsCertStoreTest.java @@ -0,0 +1,97 @@ +package org.cryptomator.networking; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Properties; + +public class SSLContextWithWindowsCertStoreTest { + + private static final String JAVA_HOME_PROP = "java.home"; + private static final String TRUSTSTORE_PASSWORD_PROP = "javax.net.ssl.trustStorePassword"; + + @TempDir + Path tmpDir; + + @Test + public void testGetCaCertsStoreByPropertiesReturnsNullIfNoCandidateExists() { + var props = propsFor(tmpDir, null); + + var inTest = new SSLContextWithWindowsCertStore(); + + Assertions.assertNull(inTest.getCaCertsStoreByProperties(props)); + } + + @Test + public void testGetCaCertsStoreByPropertiesLoadsLibSecurityCacertsByDefault() throws Exception { + var cacerts = tmpDir.resolve("lib/security/cacerts"); + writePkcs12Keystore(cacerts, "changeit".toCharArray()); + var props = propsFor(tmpDir, null); + + var inTest = new SSLContextWithWindowsCertStore(); + + Assertions.assertNotNull(inTest.getCaCertsStoreByProperties(props)); + } + + @Test + public void testGetCaCertsStoreByPropertiesTriesSecondCandidateAfterFirstFails() throws Exception { + var invalidLibCacerts = tmpDir.resolve("lib/security/cacerts"); + Files.createDirectories(invalidLibCacerts.getParent()); + Files.writeString(invalidLibCacerts, "not a keystore"); + var confCacerts = tmpDir.resolve("conf/security/cacerts"); + writePkcs12Keystore(confCacerts, "changeit".toCharArray()); + var props = propsFor(tmpDir, null); + + var inTest = new SSLContextWithWindowsCertStore(); + + Assertions.assertNotNull(inTest.getCaCertsStoreByProperties(props)); + } + + @Test + public void testGetCaCertsStoreByPropertiesReturnsNullOnWrongPassword() throws Exception { + var cacerts = tmpDir.resolve("lib/security/cacerts"); + writePkcs12Keystore(cacerts, "changeit".toCharArray()); + var props = propsFor(tmpDir, "wrong-password"); + + var inTest = new SSLContextWithWindowsCertStore(); + + Assertions.assertNull(inTest.getCaCertsStoreByProperties(props)); + } + + @Test + public void testGetCaCertsStoreByPropertiesUsesCustomPasswordProperty() throws Exception { + var cacerts = tmpDir.resolve("lib/security/cacerts"); + writePkcs12Keystore(cacerts, "custom-password".toCharArray()); + var props = propsFor(tmpDir, "custom-password"); + + var inTest = new SSLContextWithWindowsCertStore(); + + Assertions.assertNotNull(inTest.getCaCertsStoreByProperties(props)); + } + + private static void writePkcs12Keystore(Path target, char[] password) throws CertificateException, IOException, NoSuchAlgorithmException, java.security.KeyStoreException { + Files.createDirectories(target.getParent()); + var keystore = KeyStore.getInstance("PKCS12"); + keystore.load(null, null); + try (var out = Files.newOutputStream(target)) { + keystore.store(out, password); + } + } + + private static Properties propsFor(Path javaHome, String truststorePassword) { + var props = new Properties(); + props.setProperty(JAVA_HOME_PROP, javaHome.toString()); + if (truststorePassword != null) { + props.setProperty(TRUSTSTORE_PASSWORD_PROP, truststorePassword); + } + return props; + } + +}