From 72d002571ddef0d803e4a97bb127669dd44b12c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 26 Jun 2023 19:27:50 +0200 Subject: [PATCH] Add missing dependencies to feature app --- app-feature-preview/build.gradle.kts | 2 + .../k9mail/feature/preview/FeatureModule.kt | 11 + .../auth/AndroidKeyStoreDirectoryProvider.kt | 11 + .../auth/DefaultTrustedSocketFactory.java | 167 +++++++++++++++ .../preview/auth/KeyChainKeyManager.java | 191 ++++++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/AndroidKeyStoreDirectoryProvider.kt create mode 100644 app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/DefaultTrustedSocketFactory.java create mode 100644 app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/KeyChainKeyManager.java diff --git a/app-feature-preview/build.gradle.kts b/app-feature-preview/build.gradle.kts index 195cad7bd..b601ae760 100644 --- a/app-feature-preview/build.gradle.kts +++ b/app-feature-preview/build.gradle.kts @@ -66,8 +66,10 @@ android { dependencies { implementation(projects.core.ui.compose.designsystem) implementation(projects.core.common) + implementation(projects.mail.common) implementation(projects.feature.onboarding) implementation(projects.feature.account.setup) implementation(libs.okhttp) + implementation(libs.timber) } diff --git a/app-feature-preview/src/main/java/app/k9mail/feature/preview/FeatureModule.kt b/app-feature-preview/src/main/java/app/k9mail/feature/preview/FeatureModule.kt index 007c013e5..f5b96fa26 100644 --- a/app-feature-preview/src/main/java/app/k9mail/feature/preview/FeatureModule.kt +++ b/app-feature-preview/src/main/java/app/k9mail/feature/preview/FeatureModule.kt @@ -2,7 +2,13 @@ package app.k9mail.feature.preview import app.k9mail.core.common.oauth.OAuthConfigurationFactory import app.k9mail.feature.account.setup.featureAccountSetupModule +import app.k9mail.feature.preview.auth.AndroidKeyStoreDirectoryProvider import app.k9mail.feature.preview.auth.AppOAuthConfigurationFactory +import app.k9mail.feature.preview.auth.DefaultTrustedSocketFactory +import com.fsck.k9.mail.ssl.KeyStoreDirectoryProvider +import com.fsck.k9.mail.ssl.LocalKeyStore +import com.fsck.k9.mail.ssl.TrustManagerFactory +import com.fsck.k9.mail.ssl.TrustedSocketFactory import okhttp3.OkHttpClient import org.koin.core.module.Module import org.koin.dsl.module @@ -16,5 +22,10 @@ val featureModule: Module = module { single { AppOAuthConfigurationFactory() } + factory { AndroidKeyStoreDirectoryProvider(context = get()) } + single { LocalKeyStore(directoryProvider = get()) } + single { TrustManagerFactory.createInstance(get()) } + single { DefaultTrustedSocketFactory(get(), get()) } + includes(featureAccountSetupModule) } diff --git a/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/AndroidKeyStoreDirectoryProvider.kt b/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/AndroidKeyStoreDirectoryProvider.kt new file mode 100644 index 000000000..c89fce2ac --- /dev/null +++ b/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/AndroidKeyStoreDirectoryProvider.kt @@ -0,0 +1,11 @@ +package app.k9mail.feature.preview.auth + +import android.content.Context +import com.fsck.k9.mail.ssl.KeyStoreDirectoryProvider +import java.io.File + +internal class AndroidKeyStoreDirectoryProvider(private val context: Context) : KeyStoreDirectoryProvider { + override fun getDirectory(): File { + return context.getDir("KeyStore", Context.MODE_PRIVATE) + } +} diff --git a/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/DefaultTrustedSocketFactory.java b/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/DefaultTrustedSocketFactory.java new file mode 100644 index 000000000..d4781e899 --- /dev/null +++ b/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/DefaultTrustedSocketFactory.java @@ -0,0 +1,167 @@ +package app.k9mail.feature.preview.auth; + + +import java.io.IOException; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import android.content.Context; +import android.net.SSLCertificateSocketFactory; +import android.os.Build; +import android.text.TextUtils; + +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.ssl.TrustManagerFactory; +import com.fsck.k9.mail.ssl.TrustedSocketFactory; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import timber.log.Timber; + + +public class DefaultTrustedSocketFactory implements TrustedSocketFactory { + private static final String[] ENABLED_CIPHERS; + private static final String[] ENABLED_PROTOCOLS; + + private static final String[] DISALLOWED_CIPHERS = { + "SSL_RSA_WITH_DES_CBC_SHA", + "SSL_DHE_RSA_WITH_DES_CBC_SHA", + "SSL_DHE_DSS_WITH_DES_CBC_SHA", + "SSL_RSA_EXPORT_WITH_RC4_40_MD5", + "SSL_RSA_EXPORT_WITH_DES40_CBC_SHA", + "SSL_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", + "SSL_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA", + "SSL_RSA_WITH_3DES_EDE_CBC_SHA", + "SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA", + "SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDHE_RSA_WITH_RC4_128_SHA", + "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", + "TLS_ECDH_RSA_WITH_RC4_128_SHA", + "TLS_ECDH_ECDSA_WITH_RC4_128_SHA", + "SSL_RSA_WITH_RC4_128_SHA", + "SSL_RSA_WITH_RC4_128_MD5", + "TLS_ECDH_RSA_WITH_NULL_SHA", + "TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDH_anon_WITH_NULL_SHA", + "TLS_ECDH_anon_WITH_RC4_128_SHA", + "TLS_RSA_WITH_NULL_SHA256" + }; + + private static final String[] DISALLOWED_PROTOCOLS = { + "SSLv3" + }; + + static { + String[] enabledCiphers = null; + String[] supportedProtocols = null; + + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + SSLSocketFactory sf = sslContext.getSocketFactory(); + SSLSocket sock = (SSLSocket) sf.createSocket(); + enabledCiphers = sock.getEnabledCipherSuites(); + + /* + * Retrieve all supported protocols, not just the (default) enabled + * ones. TLSv1.1 & TLSv1.2 are supported on API levels 16+, but are + * only enabled by default on API levels 20+. + */ + supportedProtocols = sock.getSupportedProtocols(); + } catch (Exception e) { + Timber.e(e, "Error getting information about available SSL/TLS ciphers and protocols"); + } + + ENABLED_CIPHERS = (enabledCiphers == null) ? null : remove(enabledCiphers, DISALLOWED_CIPHERS); + ENABLED_PROTOCOLS = (supportedProtocols == null) ? null : remove(supportedProtocols, DISALLOWED_PROTOCOLS); + } + + private final Context context; + private final TrustManagerFactory trustManagerFactory; + + public DefaultTrustedSocketFactory(Context context, TrustManagerFactory trustManagerFactory) { + this.context = context; + this.trustManagerFactory = trustManagerFactory; + } + + protected static String[] remove(String[] enabled, String[] disallowed) { + List items = new ArrayList<>(); + Collections.addAll(items, enabled); + + if (disallowed != null) { + for (String item : disallowed) { + items.remove(item); + } + } + + return items.toArray(new String[0]); + } + + public Socket createSocket(Socket socket, String host, int port, String clientCertificateAlias) + throws NoSuchAlgorithmException, KeyManagementException, MessagingException, IOException { + + TrustManager[] trustManagers = new TrustManager[] { trustManagerFactory.getTrustManagerForDomain(host, port) }; + KeyManager[] keyManagers = null; + if (!TextUtils.isEmpty(clientCertificateAlias)) { + keyManagers = new KeyManager[] { new KeyChainKeyManager(context, clientCertificateAlias) }; + } + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers, trustManagers, null); + SSLSocketFactory socketFactory = sslContext.getSocketFactory(); + Socket trustedSocket; + if (socket == null) { + trustedSocket = socketFactory.createSocket(); + } else { + trustedSocket = socketFactory.createSocket(socket, host, port, true); + } + + SSLSocket sslSocket = (SSLSocket) trustedSocket; + + hardenSocket(sslSocket); + + setSniHost(socketFactory, sslSocket, host); + + return trustedSocket; + } + + private static void hardenSocket(SSLSocket sock) { + if (ENABLED_CIPHERS != null) { + sock.setEnabledCipherSuites(ENABLED_CIPHERS); + } + if (ENABLED_PROTOCOLS != null) { + sock.setEnabledProtocols(ENABLED_PROTOCOLS); + } + } + + public static void setSniHost(SSLSocketFactory factory, SSLSocket socket, String hostname) { + if (factory instanceof SSLCertificateSocketFactory) { + SSLCertificateSocketFactory sslCertificateSocketFactory = (SSLCertificateSocketFactory) factory; + sslCertificateSocketFactory.setHostname(socket, hostname); + } else if (Build.VERSION.SDK_INT >= 24) { + SSLParameters sslParameters = socket.getSSLParameters(); + List sniServerNames = Collections.singletonList(new SNIHostName(hostname)); + sslParameters.setServerNames(sniServerNames); + socket.setSSLParameters(sslParameters); + } else { + setHostnameViaReflection(socket, hostname); + } + } + + private static void setHostnameViaReflection(SSLSocket socket, String hostname) { + try { + socket.getClass().getMethod("setHostname", String.class).invoke(socket, hostname); + } catch (Throwable e) { + Timber.e(e, "Could not call SSLSocket#setHostname(String) method "); + } + } +} diff --git a/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/KeyChainKeyManager.java b/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/KeyChainKeyManager.java new file mode 100644 index 000000000..0242df8d4 --- /dev/null +++ b/app-feature-preview/src/main/java/app/k9mail/feature/preview/auth/KeyChainKeyManager.java @@ -0,0 +1,191 @@ + +package app.k9mail.feature.preview.auth; + + +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import android.content.Context; +import android.security.KeyChain; +import android.security.KeyChainException; + +import com.fsck.k9.mail.CertificateValidationException; +import com.fsck.k9.mail.MessagingException; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.security.auth.x500.X500Principal; +import timber.log.Timber; + +import static com.fsck.k9.mail.CertificateValidationException.Reason; +import static com.fsck.k9.mail.CertificateValidationException.Reason.RetrievalFailure; + + +/** + * For client certificate authentication! Provide private keys and certificates + * during the TLS handshake using the Android 4.0 KeyChain API. + */ +class KeyChainKeyManager extends X509ExtendedKeyManager { + private final String mAlias; + private final X509Certificate[] mChain; + private final PrivateKey mPrivateKey; + + + /** + * @param alias Must not be null nor empty + * @throws MessagingException + * Indicates an error in retrieving the certificate for the alias + * (likely because the alias is invalid or the certificate was deleted) + */ + public KeyChainKeyManager(Context context, String alias) throws MessagingException { + mAlias = alias; + + try { + mChain = fetchCertificateChain(context, alias); + mPrivateKey = fetchPrivateKey(context, alias); + } catch (KeyChainException e) { + // The certificate was possibly deleted. Notify user of error. + throw new CertificateValidationException(e.getMessage(), RetrievalFailure, alias); + } catch (InterruptedException e) { + throw new CertificateValidationException(e.getMessage(), RetrievalFailure, alias); + } + } + + private X509Certificate[] fetchCertificateChain(Context context, String alias) + throws KeyChainException, InterruptedException, MessagingException { + + X509Certificate[] chain = KeyChain.getCertificateChain(context, alias); + if (chain == null || chain.length == 0) { + throw new MessagingException("No certificate chain found for: " + alias); + } + try { + for (X509Certificate certificate : chain) { + certificate.checkValidity(); + } + } catch (CertificateException e) { + throw new CertificateValidationException(e.getMessage(), Reason.Expired, alias); + } + + return chain; + } + + private PrivateKey fetchPrivateKey(Context context, String alias) throws KeyChainException, + InterruptedException, MessagingException { + + PrivateKey privateKey = KeyChain.getPrivateKey(context, alias); + if (privateKey == null) { + throw new MessagingException("No private key found for: " + alias); + } + + return privateKey; + } + + @Override + public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) { + return chooseAlias(keyTypes, issuers); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return (mAlias.equals(alias) ? mChain : null); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return (mAlias.equals(alias) ? mPrivateKey : null); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return chooseAlias(new String[] { keyType }, issuers); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + final String al = chooseAlias(new String[] { keyType }, issuers); + return (al == null ? null : new String[] { al }); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + final String al = chooseAlias(new String[] { keyType }, issuers); + return (al == null ? null : new String[] { al }); + } + + @Override + public String chooseEngineClientAlias(String[] keyTypes, Principal[] issuers, SSLEngine engine) { + return chooseAlias(keyTypes, issuers); + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return chooseAlias(new String[] { keyType }, issuers); + } + + private String chooseAlias(String[] keyTypes, Principal[] issuers) { + if (keyTypes == null || keyTypes.length == 0) { + return null; + } + final X509Certificate cert = mChain[0]; + final String certKeyAlg = cert.getPublicKey().getAlgorithm(); + final String certSigAlg = cert.getSigAlgName().toUpperCase(Locale.US); + for (String keyAlgorithm : keyTypes) { + if (keyAlgorithm == null) { + continue; + } + final String sigAlgorithm; + // handle cases like EC_EC and EC_RSA + int index = keyAlgorithm.indexOf('_'); + if (index == -1) { + sigAlgorithm = null; + } else { + sigAlgorithm = keyAlgorithm.substring(index + 1); + keyAlgorithm = keyAlgorithm.substring(0, index); + } + // key algorithm does not match + if (!certKeyAlg.equals(keyAlgorithm)) { + continue; + } + /* + * TODO find a more reliable test for signature + * algorithm. Unfortunately value varies with + * provider. For example for "EC" it could be + * "SHA1WithECDSA" or simply "ECDSA". + */ + // sig algorithm does not match + if (sigAlgorithm != null && certSigAlg != null + && !certSigAlg.contains(sigAlgorithm)) { + continue; + } + // no issuers to match + if (issuers == null || issuers.length == 0) { + return mAlias; + } + List issuersList = Arrays.asList(issuers); + // check that a certificate in the chain was issued by one of the specified issuers + for (X509Certificate certFromChain : mChain) { + /* + * Note use of X500Principal from + * getIssuerX500Principal as opposed to Principal + * from getIssuerDN. Principal.equals test does + * not work in the case where + * xcertFromChain.getIssuerDN is a bouncycastle + * org.bouncycastle.jce.X509Principal. + */ + X500Principal issuerFromChain = certFromChain.getIssuerX500Principal(); + if (issuersList.contains(issuerFromChain)) { + return mAlias; + } + } + Timber.w("Client certificate %s not issued by any of the requested issuers", mAlias); + return null; + } + Timber.w("Client certificate %s does not match any of the requested key types", mAlias); + return null; + } +}