mirror of
https://codeberg.org/timelimit/timelimit-android.git
synced 2025-10-03 01:39:22 +02:00
Add client attestion to the mail authentication
This commit is contained in:
parent
a1151ca4a4
commit
96b7a1c265
7 changed files with 161 additions and 14 deletions
|
@ -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()
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue