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.
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.