Skip to content

Commit b9b767f

Browse files
authored
Merge pull request #344 from cyb3rko/client-certificate-auth
Client certificate authentication (mTLS)
2 parents 7b8e0ba + 2dddbe4 commit b9b767f

15 files changed

Lines changed: 354 additions & 142 deletions

app/src/main/kotlin/com/github/gotify/GotifyApplication.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ import android.app.Application
44
import android.app.NotificationManager
55
import android.os.Build
66
import androidx.preference.PreferenceManager
7+
import com.github.gotify.api.CertUtils
78
import com.github.gotify.log.LoggerHelper
89
import com.github.gotify.log.UncaughtExceptionHandler
910
import com.github.gotify.settings.ThemeHelper
11+
import java.io.File
12+
import java.io.FileOutputStream
13+
import java.io.IOException
1014
import org.tinylog.kotlin.Logger
1115

1216
class GotifyApplication : Application() {
@@ -26,6 +30,22 @@ class GotifyApplication : Application() {
2630
)
2731
}
2832

33+
val settings = Settings(this)
34+
if (settings.legacyCert != null) {
35+
Logger.info("Migrating legacy CA cert to new location")
36+
try {
37+
val legacyCert = settings.legacyCert
38+
settings.legacyCert = null
39+
val caCertFile = File(settings.filesDir, CertUtils.CA_CERT_NAME)
40+
FileOutputStream(caCertFile).use {
41+
it.write(legacyCert?.encodeToByteArray())
42+
}
43+
settings.caCertPath = caCertFile.absolutePath
44+
Logger.info("Migration of legacy CA cert succeeded")
45+
} catch (e: IOException) {
46+
Logger.error(e, "Migration of legacy CA cert failed")
47+
}
48+
}
2949
super.onCreate()
3050
}
3151
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
package com.github.gotify
22

3-
internal class SSLSettings(val validateSSL: Boolean, val cert: String?)
3+
internal class SSLSettings(
4+
val validateSSL: Boolean,
5+
val caCertPath: String?,
6+
val clientCertPath: String?,
7+
val clientCertPassword: String?
8+
)

app/src/main/kotlin/com/github/gotify/Settings.kt

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.github.gotify.client.model.User
66

77
internal class Settings(context: Context) {
88
private val sharedPreferences: SharedPreferences
9+
val filesDir: String
910
var url: String
1011
get() = sharedPreferences.getString("url", "")!!
1112
set(value) = sharedPreferences.edit().putString("url", value).apply()
@@ -26,15 +27,25 @@ internal class Settings(context: Context) {
2627
var serverVersion: String
2728
get() = sharedPreferences.getString("version", "UNKNOWN")!!
2829
set(value) = sharedPreferences.edit().putString("version", value).apply()
29-
var cert: String?
30+
var legacyCert: String?
3031
get() = sharedPreferences.getString("cert", null)
31-
set(value) = sharedPreferences.edit().putString("cert", value).apply()
32+
set(value) = sharedPreferences.edit().putString("cert", value).commit().toUnit()
33+
var caCertPath: String?
34+
get() = sharedPreferences.getString("caCertPath", null)
35+
set(value) = sharedPreferences.edit().putString("caCertPath", value).commit().toUnit()
3236
var validateSSL: Boolean
3337
get() = sharedPreferences.getBoolean("validateSSL", true)
3438
set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply()
39+
var clientCertPath: String?
40+
get() = sharedPreferences.getString("clientCertPath", null)
41+
set(value) = sharedPreferences.edit().putString("clientCertPath", value).apply()
42+
var clientCertPassword: String?
43+
get() = sharedPreferences.getString("clientCertPass", null)
44+
set(value) = sharedPreferences.edit().putString("clientCertPass", value).apply()
3545

3646
init {
3747
sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE)
48+
filesDir = context.filesDir.absolutePath
3849
}
3950

4051
fun tokenExists(): Boolean = !token.isNullOrEmpty()
@@ -43,14 +54,25 @@ internal class Settings(context: Context) {
4354
url = ""
4455
token = null
4556
validateSSL = true
46-
cert = null
57+
legacyCert = null
58+
caCertPath = null
59+
clientCertPath = null
60+
clientCertPassword = null
4761
}
4862

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

5367
fun sslSettings(): SSLSettings {
54-
return SSLSettings(validateSSL, cert)
68+
return SSLSettings(
69+
validateSSL,
70+
caCertPath,
71+
clientCertPath,
72+
clientCertPassword
73+
)
5574
}
75+
76+
@Suppress("UnusedReceiverParameter")
77+
private fun Any?.toUnit() = Unit
5678
}

app/src/main/kotlin/com/github/gotify/Utils.kt

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ import coil.target.Target
1212
import com.github.gotify.client.JSON
1313
import com.google.android.material.snackbar.Snackbar
1414
import com.google.gson.Gson
15-
import java.io.BufferedReader
16-
import java.io.IOException
17-
import java.io.InputStream
18-
import java.io.InputStreamReader
1915
import java.net.MalformedURLException
2016
import java.net.URI
2117
import java.net.URISyntaxException
@@ -24,7 +20,6 @@ import kotlinx.coroutines.CoroutineDispatcher
2420
import kotlinx.coroutines.CoroutineScope
2521
import kotlinx.coroutines.Dispatchers
2622
import kotlinx.coroutines.launch
27-
import okio.Buffer
2823
import org.threeten.bp.OffsetDateTime
2924
import org.tinylog.kotlin.Logger
3025

@@ -80,25 +75,6 @@ internal object Utils {
8075
}
8176
}
8277

83-
fun readFileFromStream(inputStream: InputStream): String {
84-
val sb = StringBuilder()
85-
var currentLine: String?
86-
try {
87-
BufferedReader(InputStreamReader(inputStream)).use { reader ->
88-
while (reader.readLine().also { currentLine = it } != null) {
89-
sb.append(currentLine).append("\n")
90-
}
91-
}
92-
} catch (e: IOException) {
93-
throw IllegalArgumentException("failed to read input")
94-
}
95-
return sb.toString()
96-
}
97-
98-
fun stringToInputStream(str: String?): InputStream? {
99-
return if (str == null) null else Buffer().writeUtf8(str).inputStream()
100-
}
101-
10278
fun AppCompatActivity.launchCoroutine(
10379
dispatcher: CoroutineDispatcher = Dispatchers.IO,
10480
action: suspend (coroutineScope: CoroutineScope) -> Unit

app/src/main/kotlin/com/github/gotify/api/CertUtils.kt

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ package com.github.gotify.api
22

33
import android.annotation.SuppressLint
44
import com.github.gotify.SSLSettings
5-
import com.github.gotify.Utils
6-
import java.io.IOException
5+
import java.io.File
6+
import java.io.FileInputStream
7+
import java.io.InputStream
78
import java.security.GeneralSecurityException
89
import java.security.KeyStore
910
import java.security.SecureRandom
1011
import java.security.cert.Certificate
1112
import java.security.cert.CertificateFactory
1213
import java.security.cert.X509Certificate
14+
import javax.net.ssl.KeyManager
15+
import javax.net.ssl.KeyManagerFactory
1316
import javax.net.ssl.SSLContext
1417
import javax.net.ssl.TrustManager
1518
import javax.net.ssl.TrustManagerFactory
@@ -18,6 +21,9 @@ import okhttp3.OkHttpClient
1821
import org.tinylog.kotlin.Logger
1922

2023
internal object CertUtils {
24+
const val CA_CERT_NAME = "ca-cert.crt"
25+
const val CLIENT_CERT_NAME = "client-cert.p12"
26+
2127
@SuppressLint("CustomX509TrustManager")
2228
private val trustAll = object : X509TrustManager {
2329
@SuppressLint("TrustAllX509TrustManager")
@@ -31,10 +37,10 @@ internal object CertUtils {
3137
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
3238
}
3339

34-
fun parseCertificate(cert: String): Certificate {
40+
fun parseCertificate(inputStream: InputStream): Certificate {
3541
try {
3642
val certificateFactory = CertificateFactory.getInstance("X509")
37-
return certificateFactory.generateCertificate(Utils.stringToInputStream(cert))
43+
return certificateFactory.generateCertificate(inputStream)
3844
} catch (e: Exception) {
3945
throw IllegalArgumentException("certificate is invalid")
4046
}
@@ -43,24 +49,34 @@ internal object CertUtils {
4349
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
4450
// Modified from ApiClient.applySslSettings in the client package.
4551
try {
46-
if (!settings.validateSSL) {
47-
val context = SSLContext.getInstance("TLS")
48-
context.init(arrayOf(), arrayOf<TrustManager>(trustAll), SecureRandom())
49-
builder.sslSocketFactory(context.socketFactory, trustAll)
52+
val trustManagers = mutableSetOf<TrustManager>()
53+
val keyManagers = mutableSetOf<KeyManager>()
54+
if (settings.validateSSL) {
55+
// Custom SSL validation
56+
settings.caCertPath?.let { trustManagers.addAll(certToTrustManager(it)) }
57+
} else {
58+
// Disable SSL validation
59+
trustManagers.add(trustAll)
5060
builder.hostnameVerifier { _, _ -> true }
51-
return
5261
}
53-
val cert = settings.cert
54-
if (cert != null) {
55-
val trustManagers = certToTrustManager(cert)
56-
if (trustManagers.isNotEmpty()) {
57-
val context = SSLContext.getInstance("TLS")
58-
context.init(arrayOf(), trustManagers, SecureRandom())
59-
builder.sslSocketFactory(
60-
context.socketFactory,
61-
trustManagers[0] as X509TrustManager
62-
)
62+
settings.clientCertPath?.let {
63+
keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword))
64+
}
65+
if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty()) {
66+
if (trustManagers.isEmpty()) {
67+
// Fall back to system trust managers
68+
trustManagers.addAll(defaultSystemTrustManager())
6369
}
70+
val context = SSLContext.getInstance("TLS")
71+
context.init(
72+
keyManagers.toTypedArray(),
73+
trustManagers.toTypedArray(),
74+
SecureRandom()
75+
)
76+
builder.sslSocketFactory(
77+
context.socketFactory,
78+
trustManagers.elementAt(0) as X509TrustManager
79+
)
6480
}
6581
} catch (e: Exception) {
6682
// We shouldn't have issues since the cert is verified on login.
@@ -69,12 +85,14 @@ internal object CertUtils {
6985
}
7086

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

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

88106
@Throws(GeneralSecurityException::class)
89-
private fun newEmptyKeyStore(): KeyStore {
90-
return try {
91-
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
92-
keyStore.load(null, null)
93-
keyStore
94-
} catch (e: IOException) {
95-
throw AssertionError(e)
107+
private fun certToKeyManager(certPath: String, certPassword: String?): Array<KeyManager> {
108+
require(certPassword != null) { "empty client certificate password" }
109+
110+
val keyStore = KeyStore.getInstance("PKCS12")
111+
FileInputStream(File(certPath)).use {
112+
keyStore.load(it, certPassword.toCharArray())
96113
}
114+
val keyManagerFactory =
115+
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
116+
keyManagerFactory.init(keyStore, certPassword.toCharArray())
117+
return keyManagerFactory.keyManagers
118+
}
119+
120+
private fun defaultSystemTrustManager(): Array<TrustManager> {
121+
val trustManagerFactory = TrustManagerFactory.getInstance(
122+
TrustManagerFactory.getDefaultAlgorithm()
123+
)
124+
trustManagerFactory.init(null as KeyStore?)
125+
return trustManagerFactory.trustManagers
97126
}
98127
}

app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,55 +9,55 @@ import com.github.gotify.client.auth.ApiKeyAuth
99
import com.github.gotify.client.auth.HttpBasicAuth
1010

1111
internal object ClientFactory {
12-
private fun unauthorized(baseUrl: String, sslSettings: SSLSettings): ApiClient {
13-
return defaultClient(arrayOf(), "$baseUrl/", sslSettings)
12+
private fun unauthorized(
13+
settings: Settings,
14+
sslSettings: SSLSettings,
15+
baseUrl: String
16+
): ApiClient {
17+
return defaultClient(arrayOf(), settings, sslSettings, baseUrl)
1418
}
1519

1620
fun basicAuth(
17-
baseUrl: String,
21+
settings: Settings,
1822
sslSettings: SSLSettings,
1923
username: String,
2024
password: String
2125
): ApiClient {
22-
val client = defaultClient(
23-
arrayOf("basicAuth"),
24-
"$baseUrl/",
25-
sslSettings
26-
)
26+
val client = defaultClient(arrayOf("basicAuth"), settings, sslSettings)
2727
val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth
2828
auth.username = username
2929
auth.password = password
3030
return client
3131
}
3232

33-
fun clientToken(baseUrl: String, sslSettings: SSLSettings, token: String?): ApiClient {
34-
val client = defaultClient(
35-
arrayOf("clientTokenHeader"),
36-
"$baseUrl/",
37-
sslSettings
38-
)
33+
fun clientToken(settings: Settings, token: String? = settings.token): ApiClient {
34+
val client = defaultClient(arrayOf("clientTokenHeader"), settings)
3935
val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth
4036
tokenAuth.apiKey = token
4137
return client
4238
}
4339

44-
fun versionApi(baseUrl: String, sslSettings: SSLSettings): VersionApi {
45-
return unauthorized(baseUrl, sslSettings).createService(VersionApi::class.java)
40+
fun versionApi(
41+
settings: Settings,
42+
sslSettings: SSLSettings = settings.sslSettings(),
43+
baseUrl: String = settings.url
44+
): VersionApi {
45+
return unauthorized(settings, sslSettings, baseUrl).createService(VersionApi::class.java)
4646
}
4747

4848
fun userApiWithToken(settings: Settings): UserApi {
49-
return clientToken(settings.url, settings.sslSettings(), settings.token)
50-
.createService(UserApi::class.java)
49+
return clientToken(settings).createService(UserApi::class.java)
5150
}
5251

5352
private fun defaultClient(
5453
authentications: Array<String>,
55-
baseUrl: String,
56-
sslSettings: SSLSettings
54+
settings: Settings,
55+
sslSettings: SSLSettings = settings.sslSettings(),
56+
baseUrl: String = settings.url
5757
): ApiClient {
5858
val client = ApiClient(authentications)
5959
CertUtils.applySslSettings(client.okBuilder, sslSettings)
60-
client.adapterBuilder.baseUrl(baseUrl)
60+
client.adapterBuilder.baseUrl("$baseUrl/")
6161
return client
6262
}
6363
}

app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ internal class InitializationActivity : AppCompatActivity() {
167167
callback: SuccessCallback<VersionInfo>,
168168
errorCallback: Callback.ErrorCallback
169169
) {
170-
ClientFactory.versionApi(settings.url, settings.sslSettings())
170+
ClientFactory.versionApi(settings)
171171
.version
172172
.enqueue(Callback.callInUI(this, callback, errorCallback))
173173
}

0 commit comments

Comments
 (0)