diff --git a/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt b/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt index 920393d..c278337 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * TimeLimit Copyright 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() \ No newline at end of file +class MailAddressNotWhitelistedException: RuntimeException() +class MailLoginBlockedForIntegrityReasonsException: RuntimeException() \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt b/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt index b1021b9..c11394d 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * TimeLimit Copyright 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 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) + } } diff --git a/app/src/main/java/io/timelimit/android/sync/network/api/X509ClientKeyManager.kt b/app/src/main/java/io/timelimit/android/sync/network/api/X509ClientKeyManager.kt new file mode 100644 index 0000000..8c8dd9c --- /dev/null +++ b/app/src/main/java/io/timelimit/android/sync/network/api/X509ClientKeyManager.kt @@ -0,0 +1,63 @@ +/* + * TimeLimit Copyright 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 . + */ +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 +): X509KeyManager { + companion object { + private const val ALIAS_NAME = "key" + } + + override fun chooseClientAlias( + keyType: Array, + principal: Array?, + socket: Socket? + ): String = ALIAS_NAME + + override fun chooseServerAlias( + keyType: String, + principal: Array?, + socket: Socket? + ): String? = null + + override fun getCertificateChain(alias: String): Array? = when (alias) { + ALIAS_NAME -> certificateChain.toTypedArray() + else -> null + } + + override fun getClientAliases( + keyType: String, + issuers: Array? + ): Array = arrayOf(ALIAS_NAME) + + override fun getPrivateKey(alias: String): PrivateKey? = when (alias) { + ALIAS_NAME -> privateKey + else -> null + } + + override fun getServerAliases( + keyType: String, + issuers: Array? + ): Array = emptyArray() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt b/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt index 642f19d..c091b95 100644 --- a/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt +++ b/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * TimeLimit Copyright 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) } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt b/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt index 3b7b3c8..8ff4bf0 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * TimeLimit Copyright 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) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e6adf82..1e96888 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,6 +1,6 @@