Search code examples
kotlinsslandroid-keystore

How can I use a User Certificate from Android's KeyChain in a HTTPS client?


My goal is to develop a small HTTPS client app in Android that allows the user to select one of their User Certificates from the Android KeyChain and perform HTTPS requests to a server that requires clients to be authenticated with their own certificate.

I have installed a User Certificate in an Android 11 device enrolled in Intune using a SCEP server, and it correctly shows in the settings:

User certificates in the system settings

All certificates have both 1 public and 1 private key.

Following the Android KeyChain documentation, I implemented this to let the user choose a certificate:

// Brings up the user certificate picker
KeyChain.choosePrivateKeyAlias(
    this,   // activity
    // Callback for the user selection
    {
        Log.d("choosePrivateKeyAlias", "User has chosen this alias: $it")
        if (it != null) {
            // Get private key and certificate chain
            val pk = KeyChain.getPrivateKey(this, it)
            val chain = KeyChain.getCertificateChain(this, it)

            // TODO use full chain instead of only last certificate
            val certEncoded =
                "-----BEGIN CERTIFICATE-----\n" +
                        Base64.toBase64String(chain!!.last().encoded) +
                        "\n-----END CERTIFICATE-----\n" +
                        "-----BEGIN PRIVATE KEY-----\n" +
                        // Fails because encoded is null
                        Base64.toBase64String(pk!!.encoded) +
                        "\n-----END PRIVATE KEY-----"
            
            // Decode into HeldCertificate
            val heldCertificate = HeldCertificate.decode(certEncoded)
            
            // heldCertificate is then passed to OkHttp [...]
        }

    },
    arrayOf("RSA"), null, "example.com", 443, null
)

This fails because encoded is null in pk (pk itself is not null).

After reading the Android KeyStore documentation further, it seems that there is some protection in Android to prevent private keys from being exported for security reasons.

Hence the issue: how can I use a client certificate for my HTTPS client app?

Note: I am open to using another library than OkHttp if required.


Solution

  • I ended up finding the way to go.

    First, we should provide an implementation of X509KeyManager:

    X509Impl.kt:

    package com.example.myapp
    
    import android.content.Context
    import kotlin.Throws
    import life.evam.configurationtest.X509Impl
    import android.security.KeyChain
    import android.security.KeyChainException
    import java.lang.RuntimeException
    import java.lang.UnsupportedOperationException
    import java.net.Socket
    import java.security.KeyManagementException
    import java.security.NoSuchAlgorithmException
    import java.security.Principal
    import java.security.PrivateKey
    import java.security.cert.CertificateException
    import java.security.cert.X509Certificate
    import javax.net.ssl.HttpsURLConnection
    import javax.net.ssl.KeyManager
    import javax.net.ssl.SSLContext
    import javax.net.ssl.X509KeyManager
    
    class X509Impl(
        private val alias: String,
        private val certChain: Array<X509Certificate>,
        private val privateKey: PrivateKey
    ) : X509KeyManager {
        override fun chooseClientAlias(
            arg0: Array<String>,
            arg1: Array<Principal>,
            arg2: Socket
        ): String {
            return alias
        }
    
        override fun getCertificateChain(alias: String): Array<X509Certificate> {
            return if (this.alias == alias) certChain else emptyArray()
        }
    
        override fun getPrivateKey(alias: String): PrivateKey? {
            return if (this.alias == alias) privateKey else null
        }
    
        // Methods unused (for client SSLSocket callbacks)
        override fun chooseServerAlias(
            keyType: String,
            issuers: Array<Principal>,
            socket: Socket
        ): String {
            throw UnsupportedOperationException()
        }
    
        override fun getClientAliases(keyType: String, issuers: Array<Principal>): Array<String> {
            throw UnsupportedOperationException()
        }
    
        override fun getServerAliases(keyType: String, issuers: Array<Principal>): Array<String> {
            throw UnsupportedOperationException()
        }
    
        companion object {
            fun setForConnection(
                con: HttpsURLConnection,
                context: Context?,
                alias: String
            ): SSLContext {
                var sslContext: SSLContext? = null
                sslContext = try {
                    SSLContext.getInstance("TLS")
                } catch (e: NoSuchAlgorithmException) {
                    throw RuntimeException("Should not happen...", e)
                }
                sslContext!!.init(arrayOf<KeyManager>(fromAlias(context, alias)), null, null)
                con.sslSocketFactory = sslContext.getSocketFactory()
                return sslContext
            }
    
            fun fromAlias(context: Context?, alias: String): X509Impl {
                val certChain: Array<X509Certificate>?
                val privateKey: PrivateKey?
                try {
                    certChain = KeyChain.getCertificateChain(context!!, alias)
                    privateKey = KeyChain.getPrivateKey(context, alias)
                } catch (e: KeyChainException) {
                    throw CertificateException(e)
                } catch (e: InterruptedException) {
                    throw CertificateException(e)
                }
                if (certChain == null || privateKey == null) {
                    throw CertificateException("Can't access certificate from keystore")
                }
                return X509Impl(alias, certChain, privateKey)
            }
        }
    }
    

    Then we can use it to create a socketFactory to be injected in Retrofit:

    MainActivity.kt:

    val trustManager = HandshakeCertificates.Builder()
                .addPlatformTrustedCertificates()
                .build()
                .trustManager
    
    KeyChain.choosePrivateKeyAlias(
        this,   // activity
        // Callback for the user selection
        {
    
            if (it != null) {
                Log.d("choosePrivateKeyAlias", "User has chosen this alias: $it")
                val x509 = X509Impl.setForConnection(
                    URL("https://example.com/").openConnection() as HttpsURLConnection,
                    this, it
                )
                val socketFactory = x509.socketFactory
    
                // Get private key and certificate chain
                val pk = KeyChain.getPrivateKey(this, it)
                val chain = KeyChain.getCertificateChain(this, it)
                val pke = PrivateKeyEntry(pk, chain)
    
                val interceptor = HttpLoggingInterceptor()
                interceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
                var clientBuilder = OkHttpClient.Builder()
                    .addInterceptor(interceptor)
                clientBuilder = clientBuilder
                    .sslSocketFactory(socketFactory, trustManager)
    
                val client = clientBuilder.build()
                val contentType = "application/json"
                val retrofit: Retrofit = Retrofit.Builder()
                    .baseUrl("https://example.com/")
                    .client(client)
                    .addConverterFactory(Json.asConverterFactory(contentType = contentType.toMediaType()))
                    .build()
    
                // Use this retrofit instance as usual
    

    The client certificate is now attached to requests made from this retrofit instance. You may verify it by replacing "example.com" in the code above by your own server configured to require client certificates.