带客户端身份验证的 HTTPS 在 Android 上不起作用

HTTPS with client authentication not working on Android

我目前正在编写一个 Android 应用程序(最小 SDK 16)来查询 HTTPS 服务器的数据。服务器(Debian 8 上的 Apache 2.4)使用我们自己的 CA 签名的证书,并要求客户端也有一个由它签名的证书。在以 PKCS 格式导入 CA 和客户端证书后,这与 Firefox 完美配合。

但是,我无法在 Android 中使用它。我正在使用 HttpsURLConnections,因为最近 Android 已弃用 Apache HTTP 客户端。相信我们的自定义 CA 是有效的,但只要我需要客户端证书,我就会收到以下异常:

java.lang.reflect.InvocationTargetException
        [...]
    Caused by: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
        at com.android.org.conscrypt.TrustManagerImpl.checkTrusted(TrustManagerImpl.java:282) 
        at com.android.org.conscrypt.TrustManagerImpl.checkServerTrusted(TrustManagerImpl.java:192) 
        at eu.olynet.olydorfapp.resources.CustomTrustManager.checkServerTrusted(CustomTrustManager.java:96) 
        at com.android.org.conscrypt.OpenSSLSocketImpl.verifyCertificateChain(OpenSSLSocketImpl.java:614) 
        at com.android.org.conscrypt.NativeCrypto.SSL_do_handshake(Native Method) 
        at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:406) 
        at com.android.okhttp.Connection.upgradeToTls(Connection.java:146) 
        at com.android.okhttp.Connection.connect(Connection.java:107) 
        at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:294) 
        at com.android.okhttp.internal.http.HttpEngine.sendSocketRequest(HttpEngine.java:255) 
        at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:206) 
        at com.android.okhttp.internal.http.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:345) 
        at com.android.okhttp.internal.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:296) 
        at com.android.okhttp.internal.http.HttpURLConnectionImpl.getResponseCode(HttpURLConnectionImpl.java:503) 
        at com.android.okhttp.internal.http.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:136) 
        at org.jboss.resteasy.client.jaxrs.engines.URLConnectionEngine.invoke(URLConnectionEngine.java:49) 
        at org.jboss.resteasy.client.jaxrs.internal.ClientInvocation.invoke(ClientInvocation.java:436) 
        at org.jboss.resteasy.client.jaxrs.internal.proxy.ClientInvoker.invoke(ClientInvoker.java:102) 
        at org.jboss.resteasy.client.jaxrs.internal.proxy.ClientProxy.invoke(ClientProxy.java:64) 
        at $Proxy9.getMetaNews(Native Method) 
        at java.lang.reflect.Method.invokeNative(Native Method) 
        at java.lang.reflect.Method.invoke(Method.java:515) 
        at eu.olynet.olydorfapp.resources.ResourceManager.fetchMetaItems(ResourceManager.java:372) 
        at eu.olynet.olydorfapp.resources.ResourceManager.getTreeOfMetaItems(ResourceManager.java:542) 
        at eu.olynet.olydorfapp.tabs.NewsTab.doInBackground(NewsTab.java:51) 
        at eu.olynet.olydorfapp.tabs.NewsTab.doInBackground(NewsTab.java:45) 
        at android.os.AsyncTask.call(AsyncTask.java:288) 
        at java.util.concurrent.FutureTask.run(FutureTask.java:237) 
        at android.os.AsyncTask$SerialExecutor.run(AsyncTask.java:231) 
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1112) 
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:587) 
        at java.lang.Thread.run(Thread.java:841) 

在我看来,这似乎无法验证服务器证书,但事实并非如此。

代码如下所示:

private static final String CA_FILE = "ca.pem";
private static final String CERTIFICATE_FILE = "app_01.pfx";
private static final char[] CERTIFICATE_KEY = "password".toCharArray();

[...]

CertificateFactory cf = CertificateFactory.getInstance("X.509");
String algorithm = TrustManagerFactory.getDefaultAlgorithm();

InputStream ca = this.context.getAssets().open(CA_FILE);
KeyStore trustStore = KeyStore.getInstance("PKCS12");
trustStore.load(null);
Certificate caCert = cf.generateCertificate(ca);
trustStore.setCertificateEntry("CA Name", caCert);
CustomTrustManager tm = new CustomTrustManager(trustStore);
ca.close();

InputStream clientCert = this.context.getAssets().open(CERTIFICATE_FILE);
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(clientCert, CERTIFICATE_KEY);
Log.e("KeyStore", "Size: " + keyStore.size());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(algorithm);
kmf.init(keyStore, CERTIFICATE_KEY);
clientCert.close();

SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), new TrustManager[]{tm}, null);

[...]

((HttpsURLConnection) con).setSSLSocketFactory(sslContext.getSocketFactory());

CustomTrustManager(localTrustManager 只包含我们的 CA,defaultTrustManager 是系统的 CA)的相关函数:

public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        try {
            localTrustManager.checkServerTrusted(chain, authType);
        } catch (CertificateException ce) {
            defaultTrustManager.checkServerTrusted(chain, authType);
        }
    }

我已经尝试过将 PKCS 文件转换为 BKS 文件(当然还要调整 KeyStore),但没有成功。我在这里也看到了类似的问题,但 none 的解决方案对我有用。

我发现除了根 CA 之外,添加中间 CA(直接签署服务器证书的那个)也很有效。我不明白为什么这是必要的,因为如果服务器不需要客户端证书,验证仅适用于根 CA。对我来说,这似乎是 HttpsURLConnections 或相关 class 的 Android 实现中的某种错误。如有不妥请多多指教

工作代码:

private static final String CA_FILE = "ca.pem";
private static final String INTERMEDIATE_FILE = "intermediate.pem";
private static final String CERTIFICATE_FILE = "app_01.pfx";
private static final char[] CERTIFICATE_KEY = "password".toCharArray();

[...]

CertificateFactory cf = CertificateFactory.getInstance("X.509");
String algorithm = TrustManagerFactory.getDefaultAlgorithm();

/* trust setup */
InputStream ca = this.context.getAssets().open(CA_FILE);
InputStream intermediate = this.context.getAssets().open(INTERMEDIATE_FILE);
KeyStore trustStore = KeyStore.getInstance("PKCS12");
trustStore.load(null);
Certificate caCert = cf.generateCertificate(ca);
Certificate intermediateCert = cf.generateCertificate(intermediate);
trustStore.setCertificateEntry("CA Name", caCert);
trustStore.setCertificateEntry("Intermediate Name", intermediateCert);
CustomTrustManager tm = new CustomTrustManager(trustStore);
ca.close();
intermediate.close();

/* client certificate setup */
InputStream clientCert = this.context.getAssets().open(CERTIFICATE_FILE);
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(clientCert, CERTIFICATE_KEY);
KeyManagerFactory kmf = KeyManagerFactory.getInstance(algorithm);
kmf.init(keyStore, CERTIFICATE_KEY);
clientCert.close();

/* SSLContext setup */
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), new TrustManager[]{tm}, null);

[...]

((HttpsURLConnection) con).setSSLSocketFactory(sslContext.getSocketFactory());