Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client certificate authentication (mTLS) #344

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

cyb3rko
Copy link
Contributor

@cyb3rko cyb3rko commented Apr 19, 2024

Continuation of #230
Closes #85
Related to gotify/server#416

I've adopted some of the code and translated it to Kotlin, but most parts are rewritten.
Here's a summary:

  • You can now configure a client certificate (PKCS#12) including password in the advanced settings (on the login screen)
  • The certificate contents are no longer stored in the shared preferences but the certificates are copied as a whole over to the app's file directory; the helper functions all just require input streams, so we cut the additional loading from shared preferences into another local bytestream beforehand, which then was forwared to the helper functions
  • Because of the new cert retrieval logic, upgrades from previous app versions will lead to ignored ca certs
  • We can not preview the CN of the client cert as with the CA cert as it's encrypted
  • I just know login and basic messages work when configuring no CA but a client cert

Testing the following cases is still to be done:

  • Fully functional when not configuring anything
  • Fully functional when only configuring CA cert
  • Fully functional when only configuring client cert (fixed in 60946e4)
  • Fully functional when configuring CA cert and client cert

Maybe taking a look at image loading is important as well.

And I will add some kind of hint that you have to give a password, it seems like (according to my trial and error) the Java client key implementation requires one.


For reference my reverse proxy settings (Caddy, docs for client_authentication):

(require_client_cert) {
    tls {
        client_auth {
            mode require
            trusted_leaf_cert_file client.crt
        }
    }
}

my.domain:1234 {
    import require_client_cert
    # gotify on port 8080
    reverse_proxy localhost:8080
    tls internal
}

@cyb3rko cyb3rko marked this pull request as draft April 19, 2024 00:16
@@ -26,12 +26,21 @@ internal class Settings(context: Context) {
var serverVersion: String
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of the new cert retrieval logic, upgrades from previous app versions will lead to ignored ca certs

Would it be difficult to make this backwards compatible?

Copy link
Contributor Author

@cyb3rko cyb3rko Apr 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about some kind of migration logic, should be possible.
Then I wondered how you want it to execute?

  1. Without interaction fully automatic when "finding" old settings?
  2. With a dialog with the option to migrate the configured ca file?
  3. ...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think 1 is preferred but 2 is also fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, I will try that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's now implemented in a3dd80c.
The check is performed before client creation, but because there are several clients created at the same time, the first request fails direct after cert migration.
Clicking retry then fixed it as the client then found the certificate.

A solution could be interactive dialog to restart the app or something, but then the migration does not work in the background without opening the app. With the current implementation the user can just update the app and when the websocket starts, it can (hopefully) migrate it by itself.

@cyb3rko
Copy link
Contributor Author

cyb3rko commented Apr 21, 2024

@jmattheis I've just wondered what the real advantage of the import of a CA cert inside the app is over the system-wide native CA import.
Or do some older Android versions not have this feature?

@jmattheis
Copy link
Member

I don't know. This feature was added added more than 5 years ago. I could imagine that some users may not want to globally trust the CA cert and what to only configure this for certain apps.

@cyb3rko cyb3rko marked this pull request as ready for review April 23, 2024 20:46
@cyb3rko cyb3rko marked this pull request as draft April 23, 2024 21:02
@cyb3rko
Copy link
Contributor Author

cyb3rko commented Apr 23, 2024

Oh wait, I wanted to implement some kind of hint to add a password when selecting a client cert.
The crypto library apparently enforces a non-empty password.

And btw, while merging the master another problem occured:
The refactor to coil also updated the okhttp version.
And this code

@Suppress("DEPRECATION")
builder.sslSocketFactory(context.socketFactory)

can not be suppressed anymore, it's now an error. So we can't just leave the trust manager empty anymore.

PS: Fixed it by inserting default trust managers

@cyb3rko cyb3rko marked this pull request as ready for review April 23, 2024 22:23
Copy link
Member

@jmattheis jmattheis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't tested the new feature yet.

@@ -43,24 +49,41 @@ internal object CertUtils {
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
// Modified from ApiClient.applySslSettings in the client package.
try {
var customManagers = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This boolean is a little overkill I think, we can check directly if the list is empty like this. What do you think?

val trustManagers = mutableListOf<TrustManager>()
val keyManagers = mutableListOf<KeyManager>()
settings.caCertPath?.let { trustManagers.addAll(certToTrustManager(it)) }
settings.clientCertPath?.let {
    keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword))
}

if (!settings.validateSSL) {
    trustManagers.add(trustAll)
    builder.hostnameVerifier { _, _ -> true }
}
if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty() || !settings.validateSSL) {
    val context = SSLContext.getInstance("TLS")
    context.init(
        keyManagers.toTypedArray(), trustManagers.toTypedArray(), SecureRandom()
    )
    if (trustManagers.isEmpty()) {
        // Fall back to system trust managers
        trustManagers.addAll(defaultSystemTrustManager())
    }
    builder.sslSocketFactory(
        context.socketFactory,
        trustManagers[0] as X509TrustManager
    )
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it. Especially the more Kotlin-like coding with suppression of nullable types and Kotlin helper functions. 👍

Copy link
Contributor Author

@cyb3rko cyb3rko Apr 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would then go with MutableSets instead of MutableLists.
Additionally I would not add trustAll for the case where !settings.validateSSL but rather overwrite previous trustManagers with trustAll because we trust all certificates then anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the || !settings.validateSSL wouldn't be necessary anymore because the trustManagers is already non-empty in that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this. But I still have to test it

val trustManagers = mutableSetOf<TrustManager>()
val keyManagers = mutableSetOf<KeyManager>()
if (settings.validateSSL) {
    // Custom SSL validation
    settings.caCertPath?.let { trustManagers.addAll(certToTrustManager(it)) }
} else {
    // Disable SSL validation
    trustManagers.add(trustAll)
    builder.hostnameVerifier { _, _ -> true }
}
settings.clientCertPath?.let {
    keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword))
}
if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty()) {
    val context = SSLContext.getInstance("TLS")
    context.init(
        keyManagers.toTypedArray(), trustManagers.toTypedArray(), SecureRandom()
    )
    if (trustManagers.isEmpty()) {
        // Fall back to system trust managers
        trustManagers.addAll(defaultSystemTrustManager())
    }
    builder.sslSocketFactory(
        context.socketFactory,
        trustManagers.elementAt(0) as X509TrustManager
    )
}

}

private fun showPasswordMissing(toggled: Boolean) {
if (::dialogDoneButton.isInitialized) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it possible that the dialogDoneButton is not initialized yet?

Copy link
Contributor Author

@cyb3rko cyb3rko Apr 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Everytime the user opens the dialog it checks if the clientCertPath is null or not.
  2. Based on the result it calls respective methods showSelectClientCertificate or showRemoveClientCertificate.
  3. They include the password check (and try to toggle the dialog button).
  4. BUT the button can only be accessed after the dialog has been created.
  5. When on the initial check when opening the dialog the button does not exist

Not the most beautiful code I've ever written but it does it's thing. I've tried to implement it without rewriting the whole thing.
Feel free to suggest improvements. :)

}
MaterialAlertDialogBuilder(context)
dialog = MaterialAlertDialogBuilder(context)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a local variable.

var dialog = ..

val ca = CertUtils.parseCertificate(content)
return (ca as X509Certificate).subjectDN.name
private fun getNameOfCertContent(file: File): String? {
val ca = CertUtils.parseCertificate(FileInputStream(file))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close the input stream.

Suggested change
val ca = CertUtils.parseCertificate(FileInputStream(file))
val ca = FileInputStream(file).use { CertUtils.parseCertificate(it) }

Comment on lines +68 to +70
caCertCN = getNameOfCertContent(destinationFile)!!
caCertPath = destinationFile.absolutePath
advancedDialog.showRemoveCaCertificate(caCertCN!!)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To prevent one NPE for a cert without a name.

Suggested change
caCertCN = getNameOfCertContent(destinationFile)!!
caCertPath = destinationFile.absolutePath
advancedDialog.showRemoveCaCertificate(caCertCN!!)
caCertCN = getNameOfCertContent(destinationFile) ?: "unknown"
caCertPath = destinationFile.absolutePath
advancedDialog.showRemoveCaCertificate(caCertCN!!)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Previously we've used (ca as X509Certificate).subjectDN.name which couldn't even return null.
This one was deprecated, that's why I've moved to (ca as X509Certificate).subjectX500Principal.name.
Never thought about it, but according to some people on StackOverflow it seems a CN isn't necessary for certificates.

Comment on lines +75 to +78
} catch (e: IOException) {
Logger.error(e, "Migration of legacy CA cert failed")
if (legacyCert != null) settings.legacyCert = legacyCert
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this occurs then all requests will likely fail because the cert isn't used right? I think then we can omit the fallback to reset legacyCert because it's unlikely that this recovers. We just let it in the invalid state and the user has to relogin.

Suggested change
} catch (e: IOException) {
Logger.error(e, "Migration of legacy CA cert failed")
if (legacyCert != null) settings.legacyCert = legacyCert
}
} catch (e: IOException) {
Logger.error(e, "Migration of legacy CA cert failed")
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright

): ApiClient {
val client = ApiClient(authentications)
if (settings.legacyCert != null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #344 (comment) you mentioned that some requests fail because multiple clients are created. Can this be fixed with synchronization? I'd expect this happens when two separate threads are trying to migrate the settings and then one sees the legacy cert, tries to migrate it while the other sees no legacy cert and no certPath because it's not completely migrated yet.

How about we add a new method to the Settings class which is executed on all entry points of the app: GotifyApplication and maybe WebSocketService but I'd expect the Application to be called before that.

fun migrate() {
    synchronized(this::class) {
        if (this.legacyCert != null) {
            Logger.info("Migrating legacy CA cert to new location")
            try {
                val legacyCert = this.legacyCert
                this.legacyCert = null
                val caCertFile = File(filesDir, CertUtils.CA_CERT_NAME)
                FileOutputStream(caCertFile).use {
                    it.write(legacyCert?.encodeToByteArray())
                }
                this.caCertPath = caCertFile.absolutePath
                Logger.info("Migration of legacy CA cert succeeded")
            } catch (e: IOException) {
                Logger.error(e, "Migration of legacy CA cert failed")
            }
        }
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried it out, unfortunately still gives an error, but clicking "Retry" now fixes it for me

Comment on lines +46 to +47
sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement (:.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I had to add the settings object to all client factory methods.
That's was the motivation to simplify and unify the method signatures overall.

private fun certToKeyManager(certPath: String, certPassword: String?): Array<KeyManager> {
require(certPassword != null) { "empty client certificate password" }

val keyStore = KeyStore.getInstance("PKCS12")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've debugged nearly an hour why this didn't worked for me and guess what the PKCS12 key provider didn't exist on my emulator. On my real device this works fine but on my emulator this error was logged. I couldn't really find anything online regarding to this, so it's probably some edge-case that doesn't happen in real devices.

Details

Failed to apply SSL settings: java.io.IOException: error constructing MAC: java.security.InvalidKeyException: No installed provider supports this key: com.android.org.bouncycastle.jcajce.PKCS12Key
    at com.android.org.bouncycastle.jcajce.provider.keystore.pkcs12.PKCS12KeyStoreSpi.engineLoad(PKCS12KeyStoreSpi.java:852)
    at java.security.KeyStore.load(KeyStore.java:1484)
    at com.github.gotify.api.CertUtils.certToKeyManager(CertUtils.kt:118)
    at com.github.gotify.api.CertUtils.applySslSettings(CertUtils.kt:63)
    at com.github.gotify.api.ClientFactory.defaultClient(ClientFactory.kt:80)
    at com.github.gotify.api.ClientFactory.unauthorized(ClientFactory.kt:21)
    at com.github.gotify.api.ClientFactory.versionApi(ClientFactory.kt:49)
    at com.github.gotify.login.LoginActivity.doCheckUrl(LoginActivity.kt:147)
    at com.github.gotify.login.LoginActivity.onPostCreate$lambda$6(LoginActivity.kt:118)
    at com.github.gotify.login.LoginActivity.$r8$lambda$YPO6IepZ2o2Scolja7nou-1QsOA(Unknown Source:0)
    at com.github.gotify.login.LoginActivity$$ExternalSyntheticLambda4.onClick(D8$$SyntheticClass:0)
    at android.view.View.performClick(View.java:7455)
    at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1218)
    at android.view.View.performClickInternal(View.java:7432)
    at android.view.View.access$3700(View.java:835)
    at android.view.View$PerformClick.run(View.java:28810)
    at android.os.Handler.handleCallback(Handler.java:938)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loopOnce(Looper.java:201)
    at android.os.Looper.loop(Looper.java:288)
    at android.app.ActivityThread.main(ActivityThread.java:7870)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)

Copy link
Contributor Author

@cyb3rko cyb3rko Apr 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh no, I've actually never tried it with an emulator :(
I suppose the MAC generation (via bouncy castle) is supported by the hardware security chip which does not exist in an emulator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could disallow mTLS on emulators.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

feature request: option to send certificate for NGINX ssl client verification
2 participants