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
* it under the terms of the GNU General Public License as published by
@ -60,4 +60,5 @@ interface ServerApi {
}
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
* it under the terms of the GNU General Public License as published by
@ -15,9 +15,14 @@
*/
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.JsonWriter
import android.util.Log
import de.wivewa.android.network.X509ClientKeyManager
import io.timelimit.android.BuildConfig
import io.timelimit.android.async.Threads
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.util.okio.LengthSink
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
@ -34,6 +40,14 @@ import okio.GzipSink
import okio.Sink
import okio.buffer
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 {
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(
"auth/send-mail-login-code-v2"
"auth/send-mail-login-code-v2",
client = client
) { writer ->
writer.beginObject()
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")
}
return sendMailLoginCode(mail, locale, null)
return@use sendMailLoginCode(mail, locale, null)
} else {
throw ex
}
@ -153,7 +168,7 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
val body = it.body!!
return Threads.network.executeAndWait {
return@use Threads.network.executeAndWait {
var response: String? = null
JsonReader(body.charStream()).use { reader ->
@ -172,6 +187,11 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
throw MailAddressNotWhitelistedException()
}
}
"blockedForIntegrityReasons" -> {
if (reader.nextBoolean()) {
throw MailLoginBlockedForIntegrityReasonsException()
}
}
else -> reader.skipValue()
}
}
@ -627,10 +647,11 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
private suspend fun postJsonRequest(
path: String,
client: OkHttpClient = httpClient,
requestBody: (writer: JsonWriter) -> Unit
): Response {
if (!sendContentLength) {
val response = postJsonRequest(path, requestBody, transmitContentLength = false)
val response = postJsonRequest(path, requestBody, transmitContentLength = false, client = client)
if (response.code != 411) return response
@ -639,17 +660,18 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
sendContentLength = true
}
return postJsonRequest(path, requestBody, transmitContentLength = true)
return postJsonRequest(path, requestBody, transmitContentLength = true, client = client)
}
private suspend fun postJsonRequest(
path: String,
requestBody: (writer: JsonWriter) -> Unit,
transmitContentLength: Boolean
transmitContentLength: Boolean,
client: OkHttpClient = httpClient
): Response {
val body = createJsonRequestBody(requestBody, transmitContentLength)
return httpClient.newCall(
return client.newCall(
Request.Builder()
.url("$endpointWithoutSlashAtEnd/$path")
.post(body)
@ -657,4 +679,53 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
.build()
).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
* 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),
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)
}
}

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
* it under the terms of the GNU General Public License as published by
@ -129,6 +129,7 @@ object MailAuthentication {
object RateLimit: ErrorDialog()
object BlockedMailServer: ErrorDialog()
object MailAddressNotAllowed: ErrorDialog()
object BlockedForIntegrityReasons: ErrorDialog()
data class ExceptionDetails(val message: String): ErrorDialog()
}
@ -188,6 +189,8 @@ object MailAuthentication {
updateState { it.withError(error = ErrorDialog.BlockedMailServer) }
} catch (ex: MailAddressNotWhitelistedException) {
updateState { it.withError(error = ErrorDialog.MailAddressNotAllowed) }
} catch (ex: MailLoginBlockedForIntegrityReasonsException) {
updateState { it.withError(error = ErrorDialog.BlockedForIntegrityReasons) }
} catch (ex: Exception) {
showGenericExceptionMessage(ex)
}

View file

@ -1,6 +1,6 @@
<?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
it under the terms of the GNU General Public License as published by
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
in dieser Whitelist
</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.
Diese Sperre wird automatisch deaktiviert.
</string>

View file

@ -206,6 +206,8 @@
side whitelist of mail addresses and the specified mail address is not
in this whitelist
</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.
Due to that, signing in is temporarily blocked.
This blocking is disabled automatically.