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

Merged
merged 15 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/src/main/kotlin/com/github/gotify/GotifyApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import android.app.Application
import android.app.NotificationManager
import android.os.Build
import androidx.preference.PreferenceManager
import com.github.gotify.api.CertUtils
import com.github.gotify.log.LoggerHelper
import com.github.gotify.log.UncaughtExceptionHandler
import com.github.gotify.settings.ThemeHelper
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import org.tinylog.kotlin.Logger

class GotifyApplication : Application() {
Expand All @@ -26,6 +30,22 @@ class GotifyApplication : Application() {
)
}

val settings = Settings(this)
if (settings.legacyCert != null) {
Logger.info("Migrating legacy CA cert to new location")
try {
val legacyCert = settings.legacyCert
settings.legacyCert = null
val caCertFile = File(settings.filesDir, CertUtils.CA_CERT_NAME)
FileOutputStream(caCertFile).use {
it.write(legacyCert?.encodeToByteArray())
}
settings.caCertPath = caCertFile.absolutePath
Logger.info("Migration of legacy CA cert succeeded")
} catch (e: IOException) {
Logger.error(e, "Migration of legacy CA cert failed")
}
}
super.onCreate()
}
}
7 changes: 6 additions & 1 deletion app/src/main/kotlin/com/github/gotify/SSLSettings.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
package com.github.gotify
cyb3rko marked this conversation as resolved.
Show resolved Hide resolved

internal class SSLSettings(val validateSSL: Boolean, val cert: String?)
internal class SSLSettings(
val validateSSL: Boolean,
val caCertPath: String?,
val clientCertPath: String?,
val clientCertPassword: String?
)
30 changes: 26 additions & 4 deletions app/src/main/kotlin/com/github/gotify/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.github.gotify.client.model.User

internal class Settings(context: Context) {
private val sharedPreferences: SharedPreferences
val filesDir: String
var url: String
get() = sharedPreferences.getString("url", "")!!
set(value) = sharedPreferences.edit().putString("url", value).apply()
Expand All @@ -26,15 +27,25 @@ internal class Settings(context: Context) {
var serverVersion: String
cyb3rko marked this conversation as resolved.
Show resolved Hide resolved
get() = sharedPreferences.getString("version", "UNKNOWN")!!
set(value) = sharedPreferences.edit().putString("version", value).apply()
var cert: String?
var legacyCert: String?
get() = sharedPreferences.getString("cert", null)
set(value) = sharedPreferences.edit().putString("cert", value).apply()
set(value) = sharedPreferences.edit().putString("cert", value).commit().toUnit()
var caCertPath: String?
get() = sharedPreferences.getString("caCertPath", null)
set(value) = sharedPreferences.edit().putString("caCertPath", value).commit().toUnit()
var validateSSL: Boolean
get() = sharedPreferences.getBoolean("validateSSL", true)
set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply()
var clientCertPath: String?
get() = sharedPreferences.getString("clientCertPath", null)
set(value) = sharedPreferences.edit().putString("clientCertPath", value).apply()
var clientCertPassword: String?
get() = sharedPreferences.getString("clientCertPass", null)
set(value) = sharedPreferences.edit().putString("clientCertPass", value).apply()

init {
sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE)
filesDir = context.filesDir.absolutePath
}

fun tokenExists(): Boolean = !token.isNullOrEmpty()
Expand All @@ -43,14 +54,25 @@ internal class Settings(context: Context) {
url = ""
token = null
validateSSL = true
cert = null
legacyCert = null
caCertPath = null
clientCertPath = null
clientCertPassword = null
}

fun setUser(name: String?, admin: Boolean) {
sharedPreferences.edit().putString("username", name).putBoolean("admin", admin).apply()
}

fun sslSettings(): SSLSettings {
return SSLSettings(validateSSL, cert)
return SSLSettings(
validateSSL,
caCertPath,
clientCertPath,
clientCertPassword
)
}

@Suppress("UnusedReceiverParameter")
private fun Any?.toUnit() = Unit
}
24 changes: 0 additions & 24 deletions app/src/main/kotlin/com/github/gotify/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import coil.target.Target
import com.github.gotify.client.JSON
import com.google.android.material.snackbar.Snackbar
import com.google.gson.Gson
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
Expand All @@ -24,7 +20,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okio.Buffer
import org.threeten.bp.OffsetDateTime
import org.tinylog.kotlin.Logger

Expand Down Expand Up @@ -80,25 +75,6 @@ internal object Utils {
}
}

fun readFileFromStream(inputStream: InputStream): String {
val sb = StringBuilder()
var currentLine: String?
try {
BufferedReader(InputStreamReader(inputStream)).use { reader ->
while (reader.readLine().also { currentLine = it } != null) {
sb.append(currentLine).append("\n")
}
}
} catch (e: IOException) {
throw IllegalArgumentException("failed to read input")
}
return sb.toString()
}

fun stringToInputStream(str: String?): InputStream? {
return if (str == null) null else Buffer().writeUtf8(str).inputStream()
}

fun AppCompatActivity.launchCoroutine(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
action: suspend (coroutineScope: CoroutineScope) -> Unit
Expand Down
87 changes: 58 additions & 29 deletions app/src/main/kotlin/com/github/gotify/api/CertUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package com.github.gotify.api

import android.annotation.SuppressLint
import com.github.gotify.SSLSettings
import com.github.gotify.Utils
import java.io.IOException
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
Expand All @@ -18,6 +21,9 @@ import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger

internal object CertUtils {
const val CA_CERT_NAME = "ca-cert.crt"
const val CLIENT_CERT_NAME = "client-cert.p12"

@SuppressLint("CustomX509TrustManager")
private val trustAll = object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
Expand All @@ -31,10 +37,10 @@ internal object CertUtils {
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}

fun parseCertificate(cert: String): Certificate {
fun parseCertificate(inputStream: InputStream): Certificate {
try {
val certificateFactory = CertificateFactory.getInstance("X509")
return certificateFactory.generateCertificate(Utils.stringToInputStream(cert))
return certificateFactory.generateCertificate(inputStream)
} catch (e: Exception) {
throw IllegalArgumentException("certificate is invalid")
}
Expand All @@ -43,24 +49,34 @@ internal object CertUtils {
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
// Modified from ApiClient.applySslSettings in the client package.
try {
if (!settings.validateSSL) {
val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), arrayOf<TrustManager>(trustAll), SecureRandom())
builder.sslSocketFactory(context.socketFactory, trustAll)
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 }
return
}
val cert = settings.cert
if (cert != null) {
val trustManagers = certToTrustManager(cert)
if (trustManagers.isNotEmpty()) {
val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), trustManagers, SecureRandom())
builder.sslSocketFactory(
context.socketFactory,
trustManagers[0] as X509TrustManager
)
settings.clientCertPath?.let {
keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword))
}
if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty()) {
if (trustManagers.isEmpty()) {
// Fall back to system trust managers
trustManagers.addAll(defaultSystemTrustManager())
}
val context = SSLContext.getInstance("TLS")
context.init(
keyManagers.toTypedArray(),
trustManagers.toTypedArray(),
SecureRandom()
)
builder.sslSocketFactory(
context.socketFactory,
trustManagers.elementAt(0) as X509TrustManager
)
}
} catch (e: Exception) {
// We shouldn't have issues since the cert is verified on login.
Expand All @@ -69,12 +85,14 @@ internal object CertUtils {
}

@Throws(GeneralSecurityException::class)
private fun certToTrustManager(cert: String): Array<TrustManager> {
private fun certToTrustManager(certPath: String): Array<TrustManager> {
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificates = certificateFactory.generateCertificates(Utils.stringToInputStream(cert))
val certificates = FileInputStream(File(certPath)).use(
certificateFactory::generateCertificates
)
require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" }

val caKeyStore = newEmptyKeyStore()
val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) }
certificates.forEachIndexed { index, certificate ->
val certificateAlias = "ca$index"
caKeyStore.setCertificateEntry(certificateAlias, certificate)
Expand All @@ -86,13 +104,24 @@ internal object CertUtils {
}

@Throws(GeneralSecurityException::class)
private fun newEmptyKeyStore(): KeyStore {
return try {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
keyStore
} catch (e: IOException) {
throw AssertionError(e)
private fun certToKeyManager(certPath: String, certPassword: String?): Array<KeyManager> {
require(certPassword != null) { "empty client certificate password" }

val keyStore = KeyStore.getInstance("PKCS12")
cyb3rko marked this conversation as resolved.
Show resolved Hide resolved
FileInputStream(File(certPath)).use {
keyStore.load(it, certPassword.toCharArray())
}
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, certPassword.toCharArray())
return keyManagerFactory.keyManagers
}

private fun defaultSystemTrustManager(): Array<TrustManager> {
val trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
)
trustManagerFactory.init(null as KeyStore?)
return trustManagerFactory.trustManagers
}
}
42 changes: 21 additions & 21 deletions app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,55 +9,55 @@ import com.github.gotify.client.auth.ApiKeyAuth
import com.github.gotify.client.auth.HttpBasicAuth

internal object ClientFactory {
private fun unauthorized(baseUrl: String, sslSettings: SSLSettings): ApiClient {
return defaultClient(arrayOf(), "$baseUrl/", sslSettings)
private fun unauthorized(
settings: Settings,
sslSettings: SSLSettings,
baseUrl: String
): ApiClient {
return defaultClient(arrayOf(), settings, sslSettings, baseUrl)
}

fun basicAuth(
baseUrl: String,
settings: Settings,
sslSettings: SSLSettings,
username: String,
password: String
): ApiClient {
val client = defaultClient(
arrayOf("basicAuth"),
"$baseUrl/",
sslSettings
)
val client = defaultClient(arrayOf("basicAuth"), settings, sslSettings)
val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth
auth.username = username
auth.password = password
return client
}

fun clientToken(baseUrl: String, sslSettings: SSLSettings, token: String?): ApiClient {
val client = defaultClient(
arrayOf("clientTokenHeader"),
"$baseUrl/",
sslSettings
)
fun clientToken(settings: Settings, token: String? = settings.token): ApiClient {
val client = defaultClient(arrayOf("clientTokenHeader"), settings)
val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth
tokenAuth.apiKey = token
return client
}

fun versionApi(baseUrl: String, sslSettings: SSLSettings): VersionApi {
return unauthorized(baseUrl, sslSettings).createService(VersionApi::class.java)
fun versionApi(
settings: Settings,
sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
jmattheis marked this conversation as resolved.
Show resolved Hide resolved
): VersionApi {
return unauthorized(settings, sslSettings, baseUrl).createService(VersionApi::class.java)
}

fun userApiWithToken(settings: Settings): UserApi {
return clientToken(settings.url, settings.sslSettings(), settings.token)
.createService(UserApi::class.java)
return clientToken(settings).createService(UserApi::class.java)
}

private fun defaultClient(
authentications: Array<String>,
baseUrl: String,
sslSettings: SSLSettings
settings: Settings,
sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
): ApiClient {
val client = ApiClient(authentications)
CertUtils.applySslSettings(client.okBuilder, sslSettings)
client.adapterBuilder.baseUrl(baseUrl)
client.adapterBuilder.baseUrl("$baseUrl/")
return client
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ internal class InitializationActivity : AppCompatActivity() {
callback: SuccessCallback<VersionInfo>,
errorCallback: Callback.ErrorCallback
) {
ClientFactory.versionApi(settings.url, settings.sslSettings())
ClientFactory.versionApi(settings)
.version
.enqueue(Callback.callInUI(this, callback, errorCallback))
}
Expand Down
Loading
Loading