Add client attestion to the mail authentication

This commit is contained in:
Jonas Lochmann 2024-03-25 01:00:00 +01:00
parent a1151ca4a4
commit 96b7a1c265
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
7 changed files with 161 additions and 14 deletions

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -61,3 +61,4 @@ interface ServerApi {
class MailServerBlacklistedException: RuntimeException() class MailServerBlacklistedException: RuntimeException()
class MailAddressNotWhitelistedException: RuntimeException() class MailAddressNotWhitelistedException: RuntimeException()
class MailLoginBlockedForIntegrityReasonsException: RuntimeException()

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -15,9 +15,14 @@
*/ */
package io.timelimit.android.sync.network.api package io.timelimit.android.sync.network.api
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.JsonReader import android.util.JsonReader
import android.util.JsonWriter import android.util.JsonWriter
import android.util.Log import android.util.Log
import de.wivewa.android.network.X509ClientKeyManager
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.async.Threads import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.executeAndWait
@ -25,6 +30,7 @@ import io.timelimit.android.coroutines.waitForResponse
import io.timelimit.android.sync.network.* import io.timelimit.android.sync.network.*
import io.timelimit.android.util.okio.LengthSink import io.timelimit.android.util.okio.LengthSink
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.Response import okhttp3.Response
@ -34,6 +40,14 @@ import okio.GzipSink
import okio.Sink import okio.Sink
import okio.buffer import okio.buffer
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.KeyStore.PrivateKeyEntry
import java.security.cert.X509Certificate
import java.security.spec.ECGenParameterSpec
import java.util.Date
import java.util.UUID
import javax.net.ssl.SSLContext
class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi { class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
companion object { companion object {
@ -125,9 +139,10 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
} }
} }
override suspend fun sendMailLoginCode(mail: String, locale: String, deviceAuthToken: String?): String { override suspend fun sendMailLoginCode(mail: String, locale: String, deviceAuthToken: String?): String = withDeviceVerification { client ->
postJsonRequest( postJsonRequest(
"auth/send-mail-login-code-v2" "auth/send-mail-login-code-v2",
client = client
) { writer -> ) { writer ->
writer.beginObject() writer.beginObject()
writer.name(MAIL).value(mail) writer.name(MAIL).value(mail)
@ -145,7 +160,7 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
Log.d(LOG_TAG, "sendMailLoginCode() try again without deviceAuthToken") Log.d(LOG_TAG, "sendMailLoginCode() try again without deviceAuthToken")
} }
return sendMailLoginCode(mail, locale, null) return@use sendMailLoginCode(mail, locale, null)
} else { } else {
throw ex throw ex
} }
@ -153,7 +168,7 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
val body = it.body!! val body = it.body!!
return Threads.network.executeAndWait { return@use Threads.network.executeAndWait {
var response: String? = null var response: String? = null
JsonReader(body.charStream()).use { reader -> JsonReader(body.charStream()).use { reader ->
@ -172,6 +187,11 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
throw MailAddressNotWhitelistedException() throw MailAddressNotWhitelistedException()
} }
} }
"blockedForIntegrityReasons" -> {
if (reader.nextBoolean()) {
throw MailLoginBlockedForIntegrityReasonsException()
}
}
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -627,10 +647,11 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
private suspend fun postJsonRequest( private suspend fun postJsonRequest(
path: String, path: String,
client: OkHttpClient = httpClient,
requestBody: (writer: JsonWriter) -> Unit requestBody: (writer: JsonWriter) -> Unit
): Response { ): Response {
if (!sendContentLength) { if (!sendContentLength) {
val response = postJsonRequest(path, requestBody, transmitContentLength = false) val response = postJsonRequest(path, requestBody, transmitContentLength = false, client = client)
if (response.code != 411) return response if (response.code != 411) return response
@ -639,17 +660,18 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
sendContentLength = true sendContentLength = true
} }
return postJsonRequest(path, requestBody, transmitContentLength = true) return postJsonRequest(path, requestBody, transmitContentLength = true, client = client)
} }
private suspend fun postJsonRequest( private suspend fun postJsonRequest(
path: String, path: String,
requestBody: (writer: JsonWriter) -> Unit, requestBody: (writer: JsonWriter) -> Unit,
transmitContentLength: Boolean transmitContentLength: Boolean,
client: OkHttpClient = httpClient
): Response { ): Response {
val body = createJsonRequestBody(requestBody, transmitContentLength) val body = createJsonRequestBody(requestBody, transmitContentLength)
return httpClient.newCall( return client.newCall(
Request.Builder() Request.Builder()
.url("$endpointWithoutSlashAtEnd/$path") .url("$endpointWithoutSlashAtEnd/$path")
.post(body) .post(body)
@ -657,4 +679,53 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
.build() .build()
).waitForResponse() ).waitForResponse()
} }
private suspend fun <T> withDeviceVerification(block: suspend (client: OkHttpClient) -> T): T {
if (VERSION.SDK_INT >= VERSION_CODES.N) {
val keyStoreName = "AndroidKeyStore"
val keyStore = KeyStore.getInstance(keyStoreName).also { it.load(null) }
val keyId = "temp-" + UUID.randomUUID().toString()
val now = getTimeInMillis()
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, keyStoreName)
.also {
it.initialize(
KeyGenParameterSpec.Builder(
keyId,
KeyProperties.PURPOSE_SIGN
)
.setAlgorithmParameterSpec(
ECGenParameterSpec("prime256v1")
)
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512
)
.setCertificateNotBefore(Date(now - 1000 * 60))
.setCertificateNotAfter(Date(now + 1000 * 60))
.setAttestationChallenge(byteArrayOf())
.build()
)
}.genKeyPair()
try {
val key = keyStore.getEntry(keyId, null) as PrivateKeyEntry
val keyManager = X509ClientKeyManager(key.privateKey, key.certificateChain.map { it as X509Certificate })
val socketFactory = SSLContext.getInstance("TLS").also {
it.init(arrayOf(keyManager), null, null)
}.socketFactory
return block(
httpClient.newBuilder()
.sslSocketFactory(socketFactory, httpClient.x509TrustManager!!)
.build()
)
} finally {
keyStore.deleteEntry(keyId)
}
} else return block(httpClient)
}
} }

View file

@ -0,0 +1,63 @@
/*
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.wivewa.android.network
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.X509KeyManager
class X509ClientKeyManager(
private val privateKey: PrivateKey,
private val certificateChain: List<X509Certificate>
): X509KeyManager {
companion object {
private const val ALIAS_NAME = "key"
}
override fun chooseClientAlias(
keyType: Array<out String>,
principal: Array<out Principal>?,
socket: Socket?
): String = ALIAS_NAME
override fun chooseServerAlias(
keyType: String,
principal: Array<out Principal>?,
socket: Socket?
): String? = null
override fun getCertificateChain(alias: String): Array<X509Certificate>? = when (alias) {
ALIAS_NAME -> certificateChain.toTypedArray()
else -> null
}
override fun getClientAliases(
keyType: String,
issuers: Array<out Principal>?
): Array<String> = arrayOf(ALIAS_NAME)
override fun getPrivateKey(alias: String): PrivateKey? = when (alias) {
ALIAS_NAME -> privateKey
else -> null
}
override fun getServerAliases(
keyType: String,
issuers: Array<out Principal>?
): Array<String> = emptyArray()
}

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -154,6 +154,11 @@ fun AuthenticateByMailError(error: MailAuthentication.ErrorDialog, close: () ->
stringResource(R.string.authenticate_not_whitelisted_address_text), stringResource(R.string.authenticate_not_whitelisted_address_text),
close close
) )
MailAuthentication.ErrorDialog.BlockedForIntegrityReasons -> SimpleErrorDialog(
stringResource(R.string.authenticate_error_integrity_title),
stringResource(R.string.authenticate_error_integrity_text),
close
)
is MailAuthentication.ErrorDialog.ExceptionDetails -> DiagnoseExceptionDialog(error.message, close) is MailAuthentication.ErrorDialog.ExceptionDetails -> DiagnoseExceptionDialog(error.message, close)
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -129,6 +129,7 @@ object MailAuthentication {
object RateLimit: ErrorDialog() object RateLimit: ErrorDialog()
object BlockedMailServer: ErrorDialog() object BlockedMailServer: ErrorDialog()
object MailAddressNotAllowed: ErrorDialog() object MailAddressNotAllowed: ErrorDialog()
object BlockedForIntegrityReasons: ErrorDialog()
data class ExceptionDetails(val message: String): ErrorDialog() data class ExceptionDetails(val message: String): ErrorDialog()
} }
@ -188,6 +189,8 @@ object MailAuthentication {
updateState { it.withError(error = ErrorDialog.BlockedMailServer) } updateState { it.withError(error = ErrorDialog.BlockedMailServer) }
} catch (ex: MailAddressNotWhitelistedException) { } catch (ex: MailAddressNotWhitelistedException) {
updateState { it.withError(error = ErrorDialog.MailAddressNotAllowed) } updateState { it.withError(error = ErrorDialog.MailAddressNotAllowed) }
} catch (ex: MailLoginBlockedForIntegrityReasonsException) {
updateState { it.withError(error = ErrorDialog.BlockedForIntegrityReasons) }
} catch (ex: Exception) { } catch (ex: Exception) {
showGenericExceptionMessage(ex) showGenericExceptionMessage(ex)
} }

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -164,6 +164,8 @@
serverseitig eine Whitelist mit E-Mail-Adressen festgelegt und die angegebene E-Mail-Adresse war nicht serverseitig eine Whitelist mit E-Mail-Adressen festgelegt und die angegebene E-Mail-Adresse war nicht
in dieser Whitelist in dieser Whitelist
</string> </string>
<string name="authenticate_error_integrity_title">Anmeldung blockiert</string>
<string name="authenticate_error_integrity_text">Wegen einem Problem mit Ihrem Gerät oder Ihrer TimeLimit-Installation ist keine Anmeldung möglich.</string>
<string name="authenticate_too_many_requests_text">Es gab viele Anmeldeversuche, daher gibt es eine vorübergehende Anmeldesperre. <string name="authenticate_too_many_requests_text">Es gab viele Anmeldeversuche, daher gibt es eine vorübergehende Anmeldesperre.
Diese Sperre wird automatisch deaktiviert. Diese Sperre wird automatisch deaktiviert.
</string> </string>

View file

@ -206,6 +206,8 @@
side whitelist of mail addresses and the specified mail address is not side whitelist of mail addresses and the specified mail address is not
in this whitelist in this whitelist
</string> </string>
<string name="authenticate_error_integrity_title">Login blocked</string>
<string name="authenticate_error_integrity_text">Due to an issue with your TimeLimit installation or your device, you can not sign in.</string>
<string name="authenticate_too_many_requests_text">There were much login attempts. <string name="authenticate_too_many_requests_text">There were much login attempts.
Due to that, signing in is temporarily blocked. Due to that, signing in is temporarily blocked.
This blocking is disabled automatically. This blocking is disabled automatically.