k-9/k9mail-library/src/main/java/com/fsck/k9/mail/ssl/KeyChainKeyManager.java

214 lines
7.8 KiB
Java
Raw Normal View History

2014-05-25 16:45:14 -04:00
2014-12-12 07:34:57 -05:00
package com.fsck.k9.mail.ssl;
2014-05-25 16:45:14 -04:00
import java.net.Socket;
import java.security.Principal;
import java.security.PrivateKey;
2014-08-27 16:23:26 -04:00
import java.security.cert.CertificateException;
2014-05-25 16:45:14 -04:00
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
Remove ClientCertificateRequiredException With this commit, KeyChainKeyManager no longer throws the exception and AccountSetupCheckSettings no longer catches it. It was being thrown when the server requested a client certificate but no client certificate alias had been configured for the server. The code was making the incorrect assumption that the server would only request a client certificate when such a certificate was *required*. However, servers can be configured to accept multiple forms of authentication, including both password authentication and client certificate authentication. So a server may request a certificate without requiring it. If a user has not configured a client certificate, then that should not be treated as an error because the configuration may be valid and the server may accept it. The only indication that a certificate is *required* is when a SSLProtocolException is thrown, caused by a SSLHandshakeException resulting from a fatal handshake alert message received from the server. Unfortunately, such a message is fairly generic and only "indicates that the sender was unable to negotiate an acceptable set of security parameters given the options available." So there is no definitive way to know that a client certificate is required. Also, KeyChainKeyManager.getCertificateChain() and getPrivateKey() no longer throw IllegalStateException(). These methods are permitted to return null, and such a response is appropriate if the user has deleted client certificates from the device. Again, this may or may not cause the server to abort the connection, depending on whether the server *requires* a client certificate.
2014-07-21 19:18:16 -04:00
import javax.net.ssl.SSLEngine;
2014-05-25 16:45:14 -04:00
import javax.net.ssl.X509ExtendedKeyManager;
import javax.security.auth.x500.X500Principal;
2014-05-25 16:45:14 -04:00
import android.content.Context;
2014-05-25 16:45:14 -04:00
import android.os.Build;
import android.security.KeyChain;
import android.security.KeyChainException;
import android.util.Log;
import com.fsck.k9.mail.CertificateValidationException;
import com.fsck.k9.mail.MessagingException;
2014-05-25 16:45:14 -04:00
2014-12-16 06:51:52 -05:00
import static com.fsck.k9.mail.K9MailLib.LOG_TAG;
import static com.fsck.k9.mail.CertificateValidationException.Reason;
import static com.fsck.k9.mail.CertificateValidationException.Reason.RetrievalFailure;
2014-12-16 06:51:52 -05:00
2014-05-25 16:45:14 -04:00
/**
* For client certificate authentication! Provide private keys and certificates
Remove ClientCertificateRequiredException With this commit, KeyChainKeyManager no longer throws the exception and AccountSetupCheckSettings no longer catches it. It was being thrown when the server requested a client certificate but no client certificate alias had been configured for the server. The code was making the incorrect assumption that the server would only request a client certificate when such a certificate was *required*. However, servers can be configured to accept multiple forms of authentication, including both password authentication and client certificate authentication. So a server may request a certificate without requiring it. If a user has not configured a client certificate, then that should not be treated as an error because the configuration may be valid and the server may accept it. The only indication that a certificate is *required* is when a SSLProtocolException is thrown, caused by a SSLHandshakeException resulting from a fatal handshake alert message received from the server. Unfortunately, such a message is fairly generic and only "indicates that the sender was unable to negotiate an acceptable set of security parameters given the options available." So there is no definitive way to know that a client certificate is required. Also, KeyChainKeyManager.getCertificateChain() and getPrivateKey() no longer throw IllegalStateException(). These methods are permitted to return null, and such a response is appropriate if the user has deleted client certificates from the device. Again, this may or may not cause the server to abort the connection, depending on whether the server *requires* a client certificate.
2014-07-21 19:18:16 -04:00
* during the TLS handshake using the Android 4.0 KeyChain API.
2014-05-25 16:45:14 -04:00
*/
2014-12-12 07:34:57 -05:00
class KeyChainKeyManager extends X509ExtendedKeyManager {
2014-05-25 16:45:14 -04:00
private static PrivateKey sClientCertificateReferenceWorkaround;
2014-05-25 16:45:14 -04:00
private static synchronized void savePrivateKeyReference(PrivateKey privateKey) {
if (sClientCertificateReferenceWorkaround == null) {
sClientCertificateReferenceWorkaround = privateKey;
}
}
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);
2014-05-25 16:45:14 -04:00
}
}
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);
}
2014-08-27 16:23:26 -04:00
try {
for (X509Certificate certificate : chain) {
certificate.checkValidity();
}
} catch (CertificateException e) {
throw new CertificateValidationException(e.getMessage(), Reason.Expired, alias);
2014-08-27 16:23:26 -04:00
}
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);
}
/*
* We need to keep reference to the first private key retrieved so
* it won't get garbage collected. If it will then the whole app
* will crash on Android < 4.2 with "Fatal signal 11 code=1". See
* https://code.google.com/p/android/issues/detail?id=62319
*/
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
savePrivateKeyReference(privateKey);
}
return privateKey;
}
2014-05-25 16:45:14 -04:00
@Override
public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) {
return chooseAlias(keyTypes, issuers);
2014-05-25 16:45:14 -04:00
}
@Override
public X509Certificate[] getCertificateChain(String alias) {
return (mAlias.equals(alias) ? mChain : null);
2014-05-25 16:45:14 -04:00
}
@Override
public PrivateKey getPrivateKey(String alias) {
return (mAlias.equals(alias) ? mPrivateKey : null);
2014-05-25 16:45:14 -04:00
}
@Override
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
return chooseAlias(new String[] { keyType }, issuers);
2014-05-25 16:45:14 -04:00
}
@Override
public String[] getClientAliases(String keyType, Principal[] issuers) {
final String al = chooseAlias(new String[] { keyType }, issuers);
return (al == null ? null : new String[] { al });
2014-05-25 16:45:14 -04:00
}
@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<Principal> 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;
}
}
2014-12-16 06:51:52 -05:00
Log.w(LOG_TAG, "Client certificate " + mAlias + " not issued by any of the requested issuers");
return null;
}
2014-12-16 06:51:52 -05:00
Log.w(LOG_TAG, "Client certificate " + mAlias + " does not match any of the requested key types");
return null;
2014-05-25 16:45:14 -04:00
}
}