From 562038543a1fe85416d28dac3f8e2250e970bbca Mon Sep 17 00:00:00 2001 From: Iulia STANA Date: Mon, 20 Nov 2023 10:39:51 +0100 Subject: [PATCH] TIQR-334-TIQR-328: Explicit section for plugins in version catalog, use ksp when possible Investigate OTP after backup & restore Pass explicit dispatcher to repositories, fix unit tests. Ensure that opening the identity details will not trigger an update of the biometrics flag when binding gets executed. Update java & add proguard rules Update dependencies Use AGP 8.2.0 stable and remove retrofit sealed classes proguard rules --- app/build.gradle.kts | 36 +- app/proguard-rules.pro | 11 + .../runner/HiltAndroidTestRunner.kt | 13 +- build.gradle.kts | 35 +- core/build.gradle.kts | 115 ++-- core/proguard-rules.pro | 3 + .../main/java/org/tiqr/core/MainActivity.kt | 4 +- .../core/identity/IdentityDetailFragment.kt | 14 +- .../tiqr/core/identity/IdentityListAdapter.kt | 4 +- data/build.gradle.kts | 24 +- data/consumer-rules.pro | 81 ++- data/proguard-rules.pro | 5 +- .../main/java/org/tiqr/data/di/Qualifiers.kt | 12 + .../org/tiqr/data/module/CoroutineModule.kt | 34 ++ .../org/tiqr/data/module/RepositoryModule.kt | 22 +- .../repository/AuthenticationRepository.kt | 455 ++++++++------- .../data/repository/EnrollmentRepository.kt | 519 +++++++++--------- .../data/repository/IdentityRepository.kt | 22 +- .../tiqr/data/repository/TokenRepository.kt | 42 +- .../tiqr/data/service/PreferenceService.kt | 8 +- .../org/tiqr/data/service/SecretService.kt | 46 +- gradle.properties | 3 +- gradle/libs.versions.toml | 78 +-- gradle/wrapper/gradle-wrapper.properties | 3 +- settings.gradle.kts | 21 +- 25 files changed, 972 insertions(+), 638 deletions(-) create mode 100644 data/src/main/java/org/tiqr/data/module/CoroutineModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 14205a3f..e534812c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,18 +1,18 @@ plugins { id("com.android.application") - kotlin("android") - kotlin("kapt") - id("kotlin-parcelize") + id("org.jetbrains.kotlin.android") id("dagger.hilt.android.plugin") + id("kotlin-parcelize") + id("com.google.devtools.ksp") + kotlin("kapt") } -if (JavaVersion.current() < JavaVersion.VERSION_11) { - throw GradleException("Please use JDK ${JavaVersion.VERSION_11} or above") +if (JavaVersion.current() < JavaVersion.VERSION_17) { + throw GradleException("Please use JDK ${JavaVersion.VERSION_17} or above") } android { compileSdk = libs.versions.android.sdk.compile.get().toInt() - buildToolsVersion = libs.versions.android.buildTools.get() defaultConfig { applicationId = "org.tiqr.sample" @@ -62,10 +62,11 @@ android { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("debug") } } - packagingOptions { + packaging { resources.excludes.addAll( arrayOf( "META-INF/AL2.0", @@ -76,6 +77,7 @@ android { buildFeatures { dataBinding = true + buildConfig = true } sourceSets { @@ -84,8 +86,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) } kapt { @@ -95,11 +101,6 @@ android { option("-Xmaxerrs", 1000) } } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } - lint { abortOnError = false } @@ -108,11 +109,6 @@ android { dependencies { - repositories { - google() - mavenCentral() - } - implementation(project(":core")) implementation(project(":data")) @@ -149,7 +145,7 @@ dependencies { implementation(libs.betterLink) api(libs.moshi.moshi) - kapt(libs.moshi.codegen) + ksp(libs.moshi.codegen) api(libs.okhttp.okhttp) api(libs.okhttp.logging) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f1b42451..8b2e3783 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -19,3 +19,14 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +# Firebase +-keep public class com.google.firebase.** {*;} +-keep class com.google.android.gms.** { *; } +-keep class com.google.android.gms.internal.** { *; } +-keepclasseswithmembers class com.google.firebase.FirebaseException +-keep interface com.google.firebase.analytics.connector.AnalyticsConnector +-keep class ** implements com.google.firebase.analytics.connector.AnalyticsConnector { + *; +} +-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector diff --git a/app/src/androidTest/java/org/tiqr/authenticator/runner/HiltAndroidTestRunner.kt b/app/src/androidTest/java/org/tiqr/authenticator/runner/HiltAndroidTestRunner.kt index 833d535a..e121e604 100644 --- a/app/src/androidTest/java/org/tiqr/authenticator/runner/HiltAndroidTestRunner.kt +++ b/app/src/androidTest/java/org/tiqr/authenticator/runner/HiltAndroidTestRunner.kt @@ -27,18 +27,27 @@ * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +@file:Suppress("unused") + package org.tiqr.authenticator.runner import android.app.Application import android.content.Context import androidx.test.runner.AndroidJUnitRunner import dagger.hilt.android.testing.HiltTestApplication +import org.tiqr.data.model.TiqrConfig /** * Custom [AndroidJUnitRunner] to enable Hilt in tests. */ class HiltAndroidTestRunner : AndroidJUnitRunner() { - override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + val application = super.newApplication(cl, HiltTestApplication::class.java.name, context) + TiqrConfig.initialize(application) + return application } } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 316a4e47..7f4e7ed9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,26 +1,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - repositories { - google() - mavenCentral() - } - - dependencies { - classpath(libs.android.gradle) - classpath(libs.kotlin.gradle) - classpath(libs.dagger.hilt.gradle) - classpath(libs.androidx.navigation.gradle) - classpath(libs.google.gms.gradle) - } -} - -subprojects { - repositories { - google() - mavenCentral() - } -} - -tasks.register("clean", Delete::class) { - delete(rootProject.buildDir) +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.safe.args) apply false + //Try to avoid using KAPT, check if the library can work/supports KSP which is much faster. + alias(libs.plugins.kapt) apply false + //The plugin to use i.s.o. KAPT: https://developer.android.com/build/migrate-to-ksp + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.google.gms.gradle) apply false } \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 233d4fc7..bd7813ca 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -9,17 +9,15 @@ plugins { id("androidx.navigation.safeargs.kotlin") } -if (JavaVersion.current() < JavaVersion.VERSION_11) { - throw GradleException("Please use JDK ${JavaVersion.VERSION_11} or above") +if (JavaVersion.current() < JavaVersion.VERSION_17) { + throw GradleException("Please use JDK ${JavaVersion.VERSION_17} or above") } android { compileSdk = libs.versions.android.sdk.compile.get().toInt() - buildToolsVersion = libs.versions.android.buildTools.get() defaultConfig { minSdk = libs.versions.android.sdk.min.get().toInt() - targetSdk = libs.versions.android.sdk.target.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -28,7 +26,10 @@ android { buildTypes { getByName("release") { isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } buildFeatures { @@ -45,18 +46,22 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } namespace = "org.tiqr.core" } -fun loadCustomProperties(file: File): java.util.Properties { +kotlin { + jvmToolchain(17) +} + +fun loadCustomProperties(file: File): Properties { val properties = Properties() if (file.isFile) { properties.load(file.inputStream()) @@ -66,52 +71,52 @@ fun loadCustomProperties(file: File): java.util.Properties { val secureProperties = loadCustomProperties(file("../local.properties")) - dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.androidx.core) - implementation(libs.kotlinx.coroutines.playServices) - - implementation(libs.androidx.activity) - implementation(libs.androidx.autofill) - implementation(libs.androidx.biometric) - implementation(libs.androidx.camera.core) - implementation(libs.androidx.camera.camera2) - implementation(libs.androidx.camera.lifecycle) - implementation(libs.androidx.camera.view) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.core) - implementation(libs.androidx.concurrent) - implementation(libs.androidx.lifecycle.common) - implementation(libs.androidx.lifecycle.livedata) - implementation(libs.androidx.localBroadcastManager) - implementation(libs.androidx.navigation.fragment) - implementation(libs.androidx.navigation.ui) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.splashscreen) - implementation(libs.google.android.material) - implementation(libs.google.mlkit.barcode) - implementation(libs.google.firebase.messaging) - - implementation(project(":data")) - - implementation(libs.dagger.hilt.android) - implementation(libs.dagger.hilt.fragment) - kapt(libs.dagger.hilt.compiler) - - implementation(libs.permission) - implementation(libs.coil) - implementation(libs.betterLink) - - testImplementation(libs.junit) - androidTestImplementation(libs.junit) - androidTestImplementation(libs.androidx.testing.core) - androidTestImplementation(libs.androidx.testing.junit) - androidTestImplementation(libs.androidx.testing.rules) - androidTestImplementation(libs.androidx.testing.epsresso) - androidTestImplementation(libs.androidx.testing.uiautomator) - androidTestImplementation(libs.kotlinx.coroutines.test) +dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.core) + implementation(libs.kotlinx.coroutines.playServices) + + implementation(libs.androidx.activity) + implementation(libs.androidx.autofill) + implementation(libs.androidx.biometric) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.core) + implementation(libs.androidx.concurrent) + implementation(libs.androidx.lifecycle.common) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.localBroadcastManager) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.splashscreen) + implementation(libs.google.android.material) + implementation(libs.google.mlkit.barcode) + implementation(libs.google.firebase.messaging) + + implementation(project(":data")) + + implementation(libs.dagger.hilt.android) + implementation(libs.dagger.hilt.fragment) + kapt(libs.dagger.hilt.compiler) + + implementation(libs.permission) + implementation(libs.coil) + implementation(libs.betterLink) + + testImplementation(libs.junit) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.testing.core) + androidTestImplementation(libs.androidx.testing.junit) + androidTestImplementation(libs.androidx.testing.rules) + androidTestImplementation(libs.androidx.testing.epsresso) + androidTestImplementation(libs.androidx.testing.uiautomator) + androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.dagger.hilt.testing) kaptAndroidTest(libs.dagger.hilt.compiler) diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index 60c37ac7..4fc29e73 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -64,3 +64,6 @@ -keepclassmembers class * { @android.webkit.JavascriptInterface ; } + +#Required because https://issuetracker.google.com/issues/250197571#comment25 +-dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file diff --git a/core/src/main/java/org/tiqr/core/MainActivity.kt b/core/src/main/java/org/tiqr/core/MainActivity.kt index 216d11c6..5fc0d0c4 100644 --- a/core/src/main/java/org/tiqr/core/MainActivity.kt +++ b/core/src/main/java/org/tiqr/core/MainActivity.kt @@ -115,8 +115,8 @@ open class MainActivity : BaseActivity(), else -> { Timber.w("Could not parse the raw challenge") MaterialAlertDialogBuilder(this) - .setTitle(R.string.error_challenge_title_opened_from_invalid_url) - .setMessage(R.string.error_challenge_opened_from_invalid_url) + .setTitle(org.tiqr.data.R.string.error_challenge_title_opened_from_invalid_url) + .setMessage(org.tiqr.data.R.string.error_challenge_opened_from_invalid_url) .setPositiveButton(R.string.button_ok) { dialog, _ -> dialog.dismiss() } .show() } diff --git a/core/src/main/java/org/tiqr/core/identity/IdentityDetailFragment.kt b/core/src/main/java/org/tiqr/core/identity/IdentityDetailFragment.kt index 4f116775..21bcb45b 100644 --- a/core/src/main/java/org/tiqr/core/identity/IdentityDetailFragment.kt +++ b/core/src/main/java/org/tiqr/core/identity/IdentityDetailFragment.kt @@ -64,18 +64,22 @@ class IdentityDetailFragment : BaseFragment() { viewModel.identity.observe(viewLifecycleOwner) { it?.let { identity -> binding.model = identity - binding.executePendingBindings() binding.hasBiometric = requireContext().biometricUsable() binding.hasBiometricSecret = viewModel.hasBiometricSecret(identity.identity) + binding.executePendingBindings() } ?: findNavController().popBackStack() } - binding.biometric.setOnCheckedChangeListener { _, isChecked -> - viewModel.useBiometric(args.identity.identity, isChecked) + binding.biometric.setOnCheckedChangeListener { toggle, isChecked -> + if (toggle.isPressed) { + viewModel.useBiometric(args.identity.identity, isChecked) + } } - binding.biometricUpgrade.setOnCheckedChangeListener { _, isChecked -> - viewModel.upgradeToBiometric(args.identity.identity, isChecked) + binding.biometricUpgrade.setOnCheckedChangeListener { toggle, isChecked -> + if (toggle.isPressed) { + viewModel.upgradeToBiometric(args.identity.identity, isChecked) + } } binding.buttonDelete.setOnClickListener { diff --git a/core/src/main/java/org/tiqr/core/identity/IdentityListAdapter.kt b/core/src/main/java/org/tiqr/core/identity/IdentityListAdapter.kt index ad6dad18..e2bf0e7c 100644 --- a/core/src/main/java/org/tiqr/core/identity/IdentityListAdapter.kt +++ b/core/src/main/java/org/tiqr/core/identity/IdentityListAdapter.kt @@ -103,9 +103,9 @@ class IdentityListAdapter( private val onCancel: (RecyclerView.ViewHolder) -> Unit ) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { private val frameSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 80f, context.resources.displayMetrics) - private val background = ColorDrawable(context.getThemeColor(R.attr.colorError)) + private val background = ColorDrawable(context.getThemeColor(com.google.android.material.R.attr.colorError)) private val icon = ContextCompat.getDrawable(context, R.drawable.ic_delete)?.apply { - DrawableCompat.setTint(this, context.getThemeColor(R.attr.colorOnError)) + DrawableCompat.setTint(this, context.getThemeColor(com.google.android.material.R.attr.colorOnError)) } override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder) = false diff --git a/data/build.gradle.kts b/data/build.gradle.kts index c20fab44..7c17c579 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,9 +1,10 @@ -import java.util.* +import java.util.Properties plugins { id("com.android.library") kotlin("android") kotlin("kapt") + id("com.google.devtools.ksp") id("kotlin-parcelize") id("dagger.hilt.android.plugin") } @@ -14,7 +15,7 @@ if (JavaVersion.current() < JavaVersion.VERSION_11) { val secureProperties = loadCustomProperties(file("../local.properties")) -fun loadCustomProperties(file: File): java.util.Properties { +fun loadCustomProperties(file: File): Properties { val properties = Properties() if (file.isFile) { properties.load(file.inputStream()) @@ -24,11 +25,9 @@ fun loadCustomProperties(file: File): java.util.Properties { android { compileSdk = libs.versions.android.sdk.compile.get().toInt() - buildToolsVersion = libs.versions.android.buildTools.get() defaultConfig { minSdk = libs.versions.android.sdk.min.get().toInt() - targetSdk = libs.versions.android.sdk.target.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -62,16 +61,23 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } + kotlin { + jvmToolchain(17) + } + namespace = "org.tiqr.data" + buildFeatures { + buildConfig = true + } dependencies { implementation(libs.kotlin.stdlib) @@ -93,12 +99,12 @@ android { implementation(libs.retrofit.converter.scalars) api(libs.moshi.moshi) - kapt(libs.moshi.codegen) + ksp(libs.moshi.codegen) api(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.sqlite) - kapt(libs.androidx.room.compiler) + ksp(libs.androidx.room.compiler) api(libs.timber) diff --git a/data/consumer-rules.pro b/data/consumer-rules.pro index 676a06ae..f03846f8 100644 --- a/data/consumer-rules.pro +++ b/data/consumer-rules.pro @@ -1,3 +1,82 @@ # keep names for any Parcelabe & Serializable -keepnames class * extends android.os.Parcelable --keepnames class * extends java.io.Serializable \ No newline at end of file +-keepnames class * extends java.io.Serializable + +#Moshi serializing rules: +-keep,allowobfuscation,allowshrinking class com.squareup.moshi.JsonAdapter + +-keepclasseswithmembers class * { + @com.squareup.moshi.* ; +} + +-keep @com.squareup.moshi.JsonQualifier @interface * + +# Enum field names are used by the integrated EnumJsonAdapter. +# values() is synthesized by the Kotlin compiler and is used by EnumJsonAdapter indirectly +# Annotate enums with @JsonClass(generateAdapter = false) to use them with Moshi. +-keepclassmembers @com.squareup.moshi.JsonClass class * extends java.lang.Enum { + ; + **[] values(); +} + +# Keep helper method to avoid R8 optimisation that would keep all Kotlin Metadata when unwanted +-keepclassmembers class com.squareup.moshi.internal.Util { + private static java.lang.String getKotlinMetadataClassName(); +} + +# Keep ToJson/FromJson-annotated method from custom adapter implementations +-keepclassmembers class * { + @com.squareup.moshi.FromJson ; + @com.squareup.moshi.ToJson ; +} + +#Retrofit2 rules +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Keep annotation default values (e.g., retrofit2.http.Field.encoded). +-keepattributes AnnotationDefault + +# Retain service method parameters when optimizing. +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# Ignore annotation used for build tooling. +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. +-dontwarn kotlin.Unit + +# Top-level functions that can only be used by Kotlin. +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# Keep inherited services. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface * extends <1> + +# With R8 full mode generic signatures are stripped for classes that are not +# kept. Suspend functions are wrapped in continuations where the type argument +# is used. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# R8 full mode strips generic signatures from return types if not kept. +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + + # Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking interface retrofit2.Call + +# These are app specific sealed classes that are wrapping the ApiResponse +#-keep,allowobfuscation,allowshrinking class org.tiqr.data.api.response.ApiResponse \ No newline at end of file diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro index d615415a..a3ee86ed 100644 --- a/data/proguard-rules.pro +++ b/data/proguard-rules.pro @@ -21,4 +21,7 @@ #-renamesourcefileattribute SourceFile -dontwarn org.conscrypt.ConscryptHostnameVerifier -keepnames class ** { *; } --keep class ** { *; } \ No newline at end of file +-keep class ** { *; } + +#Required because https://issuetracker.google.com/issues/250197571#comment25 +-dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file diff --git a/data/src/main/java/org/tiqr/data/di/Qualifiers.kt b/data/src/main/java/org/tiqr/data/di/Qualifiers.kt index 6b1f51b2..366b6928 100644 --- a/data/src/main/java/org/tiqr/data/di/Qualifiers.kt +++ b/data/src/main/java/org/tiqr/data/di/Qualifiers.kt @@ -31,6 +31,18 @@ package org.tiqr.data.di import javax.inject.Qualifier +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class IoDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + /** * Dagger scope for marking OkHttp instance * as a base for further setup. diff --git a/data/src/main/java/org/tiqr/data/module/CoroutineModule.kt b/data/src/main/java/org/tiqr/data/module/CoroutineModule.kt new file mode 100644 index 00000000..e15593b2 --- /dev/null +++ b/data/src/main/java/org/tiqr/data/module/CoroutineModule.kt @@ -0,0 +1,34 @@ +package org.tiqr.data.module + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.tiqr.data.di.ApplicationScope +import org.tiqr.data.di.DefaultDispatcher +import org.tiqr.data.di.IoDispatcher +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoroutinesModule { + + @Provides + @IoDispatcher + fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @DefaultDispatcher + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @Singleton + @ApplicationScope + fun providesCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) +} \ No newline at end of file diff --git a/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt b/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt index 1d560f6c..9bca1a04 100644 --- a/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt +++ b/data/src/main/java/org/tiqr/data/module/RepositoryModule.kt @@ -36,8 +36,10 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import org.tiqr.data.api.TiqrApi import org.tiqr.data.api.TokenApi +import org.tiqr.data.di.DefaultDispatcher import org.tiqr.data.repository.AuthenticationRepository import org.tiqr.data.repository.EnrollmentRepository import org.tiqr.data.repository.IdentityRepository @@ -61,8 +63,9 @@ internal object RepositoryModule { resources: Resources, database: DatabaseService, secret: SecretService, - preferences: PreferenceService - ) = AuthenticationRepository(api, resources, database, secret, preferences) + preferences: PreferenceService, + @DefaultDispatcher dispatcher: CoroutineDispatcher + ) = AuthenticationRepository(api, resources, database, secret, preferences, dispatcher) @Provides @Singleton @@ -71,15 +74,17 @@ internal object RepositoryModule { resources: Resources, database: DatabaseService, secret: SecretService, - preferences: PreferenceService - ) = EnrollmentRepository(api, resources, database, secret, preferences) + preferences: PreferenceService, + @DefaultDispatcher dispatcher: CoroutineDispatcher + ) = EnrollmentRepository(api, resources, database, secret, preferences, dispatcher) @Provides @Singleton internal fun provideIdentityRepository( database: DatabaseService, - secret: SecretService - ) = IdentityRepository(database, secret) + secret: SecretService, + @DefaultDispatcher dispatcher: CoroutineDispatcher + ) = IdentityRepository(database, secret, dispatcher) } /** @@ -94,6 +99,7 @@ class TokenRepositoryModule { @Singleton internal fun provideTokenRepository( api: Lazy, - preferences: PreferenceService - ): TokenRegistrarRepository = TokenRepository(api, preferences) + preferences: PreferenceService, + @DefaultDispatcher dispatcher: CoroutineDispatcher + ): TokenRegistrarRepository = TokenRepository(api, preferences, dispatcher) } diff --git a/data/src/main/java/org/tiqr/data/repository/AuthenticationRepository.kt b/data/src/main/java/org/tiqr/data/repository/AuthenticationRepository.kt index 736867aa..f6ca698b 100644 --- a/data/src/main/java/org/tiqr/data/repository/AuthenticationRepository.kt +++ b/data/src/main/java/org/tiqr/data/repository/AuthenticationRepository.kt @@ -32,15 +32,35 @@ package org.tiqr.data.repository import android.content.res.Resources import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.tiqr.data.R import org.tiqr.data.algorithm.Ocra import org.tiqr.data.algorithm.Ocra.OcraException import org.tiqr.data.api.TiqrApi import org.tiqr.data.api.response.ApiResponse -import org.tiqr.data.model.* -import org.tiqr.data.model.AuthenticationResponse.Code.* +import org.tiqr.data.di.DefaultDispatcher +import org.tiqr.data.model.AuthenticationChallenge +import org.tiqr.data.model.AuthenticationCompleteFailure +import org.tiqr.data.model.AuthenticationCompleteRequest +import org.tiqr.data.model.AuthenticationParseFailure +import org.tiqr.data.model.AuthenticationResponse +import org.tiqr.data.model.AuthenticationResponse.Code.AUTH_RESULT_ACCOUNT_BLOCKED +import org.tiqr.data.model.AuthenticationResponse.Code.AUTH_RESULT_INVALID_CHALLENGE +import org.tiqr.data.model.AuthenticationResponse.Code.AUTH_RESULT_INVALID_REQUEST +import org.tiqr.data.model.AuthenticationResponse.Code.AUTH_RESULT_INVALID_RESPONSE +import org.tiqr.data.model.AuthenticationResponse.Code.AUTH_RESULT_INVALID_USER_ID +import org.tiqr.data.model.AuthenticationResponse.Code.AUTH_RESULT_SUCCESS +import org.tiqr.data.model.AuthenticationUrlParams +import org.tiqr.data.model.ChallengeCompleteFailure +import org.tiqr.data.model.ChallengeCompleteOtpResult +import org.tiqr.data.model.ChallengeCompleteRequest +import org.tiqr.data.model.ChallengeCompleteResult +import org.tiqr.data.model.ChallengeParseResult +import org.tiqr.data.model.Identity +import org.tiqr.data.model.SecretCredential +import org.tiqr.data.model.SecretType +import org.tiqr.data.model.TiqrConfig import org.tiqr.data.repository.base.ChallengeRepository import org.tiqr.data.security.SecurityFeaturesException import org.tiqr.data.service.DatabaseService @@ -52,85 +72,91 @@ import timber.log.Timber import java.io.IOException import java.security.InvalidKeyException import java.security.KeyStoreException -import java.util.* +import java.util.Locale /** * Repository to handle authentication challenges. */ class AuthenticationRepository( - override val api: TiqrApi, - override val resources: Resources, - override val database: DatabaseService, - override val secretService: SecretService, - override val preferences: PreferenceService + override val api: TiqrApi, + override val resources: Resources, + override val database: DatabaseService, + override val secretService: SecretService, + override val preferences: PreferenceService, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher, ) : ChallengeRepository() { override val challengeScheme: String = "${TiqrConfig.authScheme}://" /** * Checks if the challenge is valid */ - override fun isValidChallenge(rawChallenge: String) : Boolean { + override fun isValidChallenge(rawChallenge: String): Boolean { return AuthenticationUrlParams.parseFromUrl(rawChallenge) != null } /** * Validate the [rawChallenge] and request authentication. */ - override suspend fun parseChallenge(rawChallenge: String): ChallengeParseResult { - // Parse challenge, throw error if not valid - val challengeUrlParams = AuthenticationUrlParams.parseFromUrl(rawChallenge) - ?: return AuthenticationParseFailure( - reason = AuthenticationParseFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_qr) - ).run { - Timber.e("Invalid QR: $rawChallenge") - ChallengeParseResult.failure(this) - } + override suspend fun parseChallenge(rawChallenge: String): ChallengeParseResult = + withContext(dispatcher) { + // Parse challenge, throw error if not valid + val challengeUrlParams = AuthenticationUrlParams.parseFromUrl(rawChallenge) + ?: return@withContext AuthenticationParseFailure( + reason = AuthenticationParseFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_invalid_qr) + ).run { + Timber.e("Invalid QR: $rawChallenge") + ChallengeParseResult.failure(this) + } - // Check if identity provider is known - val identityProvider = database.getIdentityProviderByIdentifier(challengeUrlParams.serverIdentifier) - ?: return AuthenticationParseFailure( + // Check if identity provider is known + val identityProvider = + database.getIdentityProviderByIdentifier(challengeUrlParams.serverIdentifier) + ?: return@withContext AuthenticationParseFailure( reason = AuthenticationParseFailure.Reason.INVALID_IDENTITY_PROVIDER, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_unknown_identity_provider) - ).run { - Timber.e("Unknown identity provider: ${challengeUrlParams.serverIdentifier}") - ChallengeParseResult.failure(this) - } + ).run { + Timber.e("Unknown identity provider: ${challengeUrlParams.serverIdentifier}") + ChallengeParseResult.failure(this) + } - // Check if identity is known - val identity = if (!challengeUrlParams.username.isNullOrBlank()) { - database.getIdentity(challengeUrlParams.username, identityProvider.identifier) - ?: return AuthenticationParseFailure( - reason = AuthenticationParseFailure.Reason.INVALID_IDENTITY, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_unknown_identity) + // Check if identity is known + val identity = if (!challengeUrlParams.username.isNullOrBlank()) { + database.getIdentity(challengeUrlParams.username, identityProvider.identifier) + ?: return@withContext AuthenticationParseFailure( + reason = AuthenticationParseFailure.Reason.INVALID_IDENTITY, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_unknown_identity) ).run { Timber.e("Unknown identity: ${challengeUrlParams.username}") ChallengeParseResult.failure(this) } - } else { - database.getIdentity(identityProvider.identifier) - ?: return AuthenticationParseFailure( - reason = AuthenticationParseFailure.Reason.NO_IDENTITIES, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_no_identities) + } else { + database.getIdentity(identityProvider.identifier) + ?: return@withContext AuthenticationParseFailure( + reason = AuthenticationParseFailure.Reason.NO_IDENTITIES, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_no_identities) ).run { Timber.e("No identities for identity provider: ${identityProvider.identifier}") ChallengeParseResult.failure(this) } - } + } - // Check if there are multiple identities - val identities = database.getIdentities(identityProvider.identifier) - val multipleIdentities = challengeUrlParams.username.isNullOrBlank() && identities.size > 1 - if (multipleIdentities) { - Timber.d("Found ${identities.size} identities for ${identityProvider.identifier}," + - " and the challenge did not contain a specific username.") - } + // Check if there are multiple identities + val identities = database.getIdentities(identityProvider.identifier) + val multipleIdentities = + challengeUrlParams.username.isNullOrBlank() && identities.size > 1 + if (multipleIdentities) { + Timber.d( + "Found ${identities.size} identities for ${identityProvider.identifier}," + + " and the challenge did not contain a specific username." + ) + } - return AuthenticationChallenge( + return@withContext AuthenticationChallenge( protocolVersion = challengeUrlParams.protocolVersion, identityProvider = identityProvider, identity = if (multipleIdentities) null else identity, @@ -141,119 +167,146 @@ class AuthenticationRepository( isStepUpChallenge = !(challengeUrlParams.username.isNullOrBlank()), // what does this mean? to be used to check if raw-challenge already has an identity serviceProviderDisplayName = identityProvider.displayName, serviceProviderIdentifier = "" - ).run { - ChallengeParseResult.success(this) + ).run { + ChallengeParseResult.success(this) + } } - } - override suspend fun completeChallenge(request: ChallengeCompleteRequest): ChallengeCompleteResult { - if (request !is AuthenticationCompleteRequest) { - return ChallengeCompleteResult.failure(AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_response) - )) - } + override suspend fun completeChallenge(request: ChallengeCompleteRequest): ChallengeCompleteResult = + withContext(dispatcher) { + if (request !is AuthenticationCompleteRequest) { + return@withContext ChallengeCompleteResult.failure( + AuthenticationCompleteFailure( + reason = AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_invalid_response) + ) + ) + } - val identity = request.challenge.identity - ?: return ChallengeCompleteResult.failure(AuthenticationCompleteFailure( + val identity = request.challenge.identity + ?: return@withContext ChallengeCompleteResult.failure( + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.INVALID_CHALLENGE, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_invalid_challenge) - )) + ) + ) - try { - val otp = generateOtp(request.password, request.type, identity, request.challenge) + try { + val otp = generateOtp(request.password, request.type, identity, request.challenge) - api.authenticate( + api.authenticate( url = request.challenge.identityProvider.authenticationUrl, sessionKey = request.challenge.sessionKey, userId = identity.identifier, response = otp, language = Locale.getDefault().language, notificationAddress = preferences.notificationToken - ).run { - return when (this) { - is ApiResponse.Success -> handleResponse(request, body, headers.tiqrProtocol()) - is ApiResponse.Failure -> handleResponse(request, body, headers.tiqrProtocol()) - is ApiResponse.NetworkError -> AuthenticationCompleteFailure( + ).run { + return@withContext when (this) { + is ApiResponse.Success -> handleResponse( + request, + body, + headers.tiqrProtocol() + ) + + is ApiResponse.Failure -> handleResponse( + request, + body, + headers.tiqrProtocol() + ) + + is ApiResponse.NetworkError -> AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.CONNECTION, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_connect_error) - ).run { - Timber.e("Error completing authentication, API response failed") - ChallengeCompleteResult.failure(this) - } - is ApiResponse.Error -> AuthenticationCompleteFailure( + ).run { + + Timber.e(error,"Error completing authentication, Network error: API response failed") + ChallengeCompleteResult.failure(this) + } + + is ApiResponse.Error -> AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.UNKNOWN, title = resources.getString(R.string.error_title_unknown), message = resources.getString(R.string.error_auth_unknown_error) - ).run { - Timber.e("Error completing authentication, API response failed") - ChallengeCompleteResult.failure(this) + ).run { + Timber.e(error,"Error completing authentication, Api Error: API response failed with code $code") + ChallengeCompleteResult.failure(this) + } } } - } - } catch (e: Exception) { - Timber.e(e, "Authentication failed") - return when (e) { - is OcraException -> - AuthenticationCompleteFailure( + } catch (e: Exception) { + Timber.e(e, "Authentication failed") + return@withContext when (e) { + is OcraException -> + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.INVALID_CHALLENGE, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_invalid_challenge) - ) - is KeyStoreException -> - AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.SECURITY, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_keystore) - ) - is InvalidKeyException -> - AuthenticationCompleteFailure( + ) + + is KeyStoreException -> + AuthenticationCompleteFailure( + reason = AuthenticationCompleteFailure.Reason.SECURITY, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_invalid_keystore) + ) + + is InvalidKeyException -> + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.UNKNOWN, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_invalid_key) - ) - is SecurityFeaturesException -> - AuthenticationCompleteFailure( + ) + + is SecurityFeaturesException -> + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.DEVICE_INCOMPATIBLE, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_security_standards) - ) - is JsonDataException, - is JsonEncodingException -> - AuthenticationCompleteFailure( + ) + + is JsonDataException, + is JsonEncodingException -> + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.UNKNOWN, title = resources.getString(R.string.error_title_unknown), message = resources.getString(R.string.error_auth_unknown_error) - ) - is IOException -> - AuthenticationCompleteFailure( + ) + + is IOException -> + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.CONNECTION, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_connect_error) - ) - else -> - AuthenticationCompleteFailure( + ) + + else -> + AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.UNKNOWN, title = resources.getString(R.string.error_title_unknown), message = resources.getString(R.string.error_auth_unknown_error) - ) - }.run { - ChallengeCompleteResult.failure(this) + ) + }.run { + ChallengeCompleteResult.failure(this) + } } } - } /** * Handle the authenticate response */ - private fun handleResponse(request: AuthenticationCompleteRequest, response: AuthenticationResponse?, protocolVersion: Int) : ChallengeCompleteResult { + private fun handleResponse( + request: AuthenticationCompleteRequest, + response: AuthenticationResponse?, + protocolVersion: Int + ): ChallengeCompleteResult { val result = response ?: return AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_response) + reason = AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_invalid_response) ).run { Timber.e("Error completing authentication, API response is empty") ChallengeCompleteResult.failure(this) @@ -262,9 +315,12 @@ class AuthenticationRepository( if (!TiqrConfig.protocolCompatibilityMode) { if (protocolVersion <= TiqrConfig.protocolVersion) { return AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_protocol, "v$protocolVersion") + reason = AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title), + message = resources.getString( + R.string.error_auth_invalid_protocol, + "v$protocolVersion" + ) ).run { Timber.e("Error completing authentication, unsupported protocol version: v$protocolVersion") ChallengeCompleteResult.failure(this) @@ -280,90 +336,105 @@ class AuthenticationRepository( remainingAttempts > 1 -> { if (request.type == SecretType.BIOMETRIC) { AuthenticationCompleteFailure( - AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title_biometric), - message = resources.getString(R.string.error_auth_biometric_x_attempts_left, remainingAttempts), - remainingAttempts = remainingAttempts + AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title_biometric), + message = resources.getString( + R.string.error_auth_biometric_x_attempts_left, + remainingAttempts + ), + remainingAttempts = remainingAttempts ) } else { AuthenticationCompleteFailure( - AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title_pin), - message = resources.getString(R.string.error_auth_pin_x_attempts_left, remainingAttempts), - remainingAttempts = remainingAttempts + AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title_pin), + message = resources.getString( + R.string.error_auth_pin_x_attempts_left, + remainingAttempts + ), + remainingAttempts = remainingAttempts ) } } + remainingAttempts == 1 -> { if (request.type == SecretType.BIOMETRIC) { AuthenticationCompleteFailure( - AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title_biometric), - message = resources.getString(R.string.error_auth_biometric_one_attempt_left), - remainingAttempts = remainingAttempts + AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title_biometric), + message = resources.getString(R.string.error_auth_biometric_one_attempt_left), + remainingAttempts = remainingAttempts ) } else { AuthenticationCompleteFailure( - AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title_pin), - message = resources.getString(R.string.error_auth_pin_one_attempt_left), - remainingAttempts = remainingAttempts + AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title_pin), + message = resources.getString(R.string.error_auth_pin_one_attempt_left), + remainingAttempts = remainingAttempts ) } } + else -> { AuthenticationCompleteFailure( - AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_auth_title_blocked), - message = resources.getString(R.string.error_auth_blocked), - remainingAttempts = remainingAttempts + AuthenticationCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_auth_title_blocked), + message = resources.getString(R.string.error_auth_blocked), + remainingAttempts = remainingAttempts ) } }.run { ChallengeCompleteResult.failure(this) } } + AUTH_RESULT_INVALID_REQUEST -> { AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_REQUEST, - title = resources.getString(R.string.error_auth_title_request), - message = resources.getString(R.string.error_auth_request_invalid) + reason = AuthenticationCompleteFailure.Reason.INVALID_REQUEST, + title = resources.getString(R.string.error_auth_title_request), + message = resources.getString(R.string.error_auth_request_invalid) ).run { ChallengeCompleteResult.failure(this) } } + AUTH_RESULT_INVALID_CHALLENGE -> { AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_auth_title_challenge), - message = resources.getString(R.string.error_auth_challenge_invalid) + reason = AuthenticationCompleteFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_auth_title_challenge), + message = resources.getString(R.string.error_auth_challenge_invalid) ).run { ChallengeCompleteResult.failure(this) } } + AUTH_RESULT_ACCOUNT_BLOCKED -> { if (result.duration != null) { AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.ACCOUNT_TEMPORARY_BLOCKED, - title = resources.getString(R.string.error_auth_title_blocked_temporary), - message = resources.getString(R.string.error_auth_blocked_temporary, result.duration), - duration = result.duration + reason = AuthenticationCompleteFailure.Reason.ACCOUNT_TEMPORARY_BLOCKED, + title = resources.getString(R.string.error_auth_title_blocked_temporary), + message = resources.getString( + R.string.error_auth_blocked_temporary, + result.duration + ), + duration = result.duration ) } else { AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.ACCOUNT_BLOCKED, - title = resources.getString(R.string.error_auth_title_blocked), - message = resources.getString(R.string.error_auth_blocked) + reason = AuthenticationCompleteFailure.Reason.ACCOUNT_BLOCKED, + title = resources.getString(R.string.error_auth_title_blocked), + message = resources.getString(R.string.error_auth_blocked) ) }.run { ChallengeCompleteResult.failure(this) } } + AUTH_RESULT_INVALID_USER_ID -> { AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_USER, - title = resources.getString(R.string.error_auth_title_account), - message = resources.getString(R.string.error_auth_account_invalid) + reason = AuthenticationCompleteFailure.Reason.INVALID_USER, + title = resources.getString(R.string.error_auth_title_account), + message = resources.getString(R.string.error_auth_account_invalid) ).run { ChallengeCompleteResult.failure(this) } @@ -374,42 +445,55 @@ class AuthenticationRepository( /** * Complete the OTP generation */ - suspend fun completeOtp(credential: SecretCredential, identity: Identity, challenge: AuthenticationChallenge) : ChallengeCompleteOtpResult { - return try { - val otp = generateOtp(password = credential.password, type = credential.type, identity = identity, challenge = challenge) + suspend fun completeOtp( + credential: SecretCredential, + identity: Identity, + challenge: AuthenticationChallenge + ): ChallengeCompleteOtpResult = withContext(dispatcher) { + return@withContext try { + val otp = generateOtp( + password = credential.password, + type = credential.type, + identity = identity, + challenge = challenge + ) ChallengeCompleteOtpResult.success(otp) } catch (e: Exception) { Timber.e(e, "Authentication failed") - return when (e) { + return@withContext when (e) { is OcraException -> AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_challenge) + reason = AuthenticationCompleteFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_invalid_challenge) ) + is KeyStoreException -> AuthenticationCompleteFailure( reason = AuthenticationCompleteFailure.Reason.SECURITY, title = resources.getString(R.string.error_auth_title), message = resources.getString(R.string.error_auth_invalid_keystore) ) + is SecurityFeaturesException -> AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.DEVICE_INCOMPATIBLE, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_security_standards) + reason = AuthenticationCompleteFailure.Reason.DEVICE_INCOMPATIBLE, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_security_standards) ) + is InvalidKeyException -> AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.UNKNOWN, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_invalid_key) + reason = AuthenticationCompleteFailure.Reason.UNKNOWN, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_invalid_key) ) + else -> AuthenticationCompleteFailure( - reason = AuthenticationCompleteFailure.Reason.UNKNOWN, - title = resources.getString(R.string.error_auth_title), - message = resources.getString(R.string.error_auth_unknown_error) + reason = AuthenticationCompleteFailure.Reason.UNKNOWN, + title = resources.getString(R.string.error_auth_title), + message = resources.getString(R.string.error_auth_unknown_error) ) }.run { ChallengeCompleteOtpResult.failure(this) @@ -424,18 +508,21 @@ class AuthenticationRepository( * @throws SecurityFeaturesException * @throws OcraException */ - private suspend fun generateOtp(password: String, type: SecretType, identity: Identity, challenge: AuthenticationChallenge) : String { - return withContext(Dispatchers.IO) { - val sessionKey = secretService.createSessionKey(password) - val secretId = secretService.createSecretIdentity(identity, type) - val secret = secretService.load(secretId, sessionKey) - - Ocra.generate( - suite = challenge.identityProvider.ocraSuite, - key = secret.value.encoded.toHexString(), - question = challenge.challenge, - session = challenge.sessionKey - ) - } + private suspend fun generateOtp( + password: String, + type: SecretType, + identity: Identity, + challenge: AuthenticationChallenge + ): String = withContext(dispatcher) { + val sessionKey = secretService.createSessionKey(password) + val secretId = secretService.createSecretIdentity(identity, type) + val secret = secretService.load(secretId, sessionKey) + + Ocra.generate( + suite = challenge.identityProvider.ocraSuite, + key = secret.value.encoded.toHexString(), + question = challenge.challenge, + session = challenge.sessionKey + ) } } \ No newline at end of file diff --git a/data/src/main/java/org/tiqr/data/repository/EnrollmentRepository.kt b/data/src/main/java/org/tiqr/data/repository/EnrollmentRepository.kt index 93bf3ba4..2c5276e7 100644 --- a/data/src/main/java/org/tiqr/data/repository/EnrollmentRepository.kt +++ b/data/src/main/java/org/tiqr/data/repository/EnrollmentRepository.kt @@ -33,22 +33,40 @@ import android.content.res.Resources import android.net.Uri import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.tiqr.data.R import org.tiqr.data.api.TiqrApi import org.tiqr.data.api.response.ApiResponse -import org.tiqr.data.model.* +import org.tiqr.data.di.DefaultDispatcher +import org.tiqr.data.model.Challenge +import org.tiqr.data.model.ChallengeCompleteFailure +import org.tiqr.data.model.ChallengeCompleteRequest +import org.tiqr.data.model.ChallengeCompleteResult +import org.tiqr.data.model.ChallengeParseResult +import org.tiqr.data.model.EnrollmentChallenge +import org.tiqr.data.model.EnrollmentCompleteFailure +import org.tiqr.data.model.EnrollmentParseFailure +import org.tiqr.data.model.EnrollmentResponse +import org.tiqr.data.model.Identity +import org.tiqr.data.model.IdentityProvider +import org.tiqr.data.model.Secret +import org.tiqr.data.model.SecretType +import org.tiqr.data.model.TiqrConfig import org.tiqr.data.repository.base.ChallengeRepository import org.tiqr.data.service.DatabaseService import org.tiqr.data.service.PreferenceService import org.tiqr.data.service.SecretService -import org.tiqr.data.util.extension.* +import org.tiqr.data.util.extension.isHttpOrHttps +import org.tiqr.data.util.extension.tiqrProtocol +import org.tiqr.data.util.extension.toDecodedUrlStringOrNull +import org.tiqr.data.util.extension.toHexString +import org.tiqr.data.util.extension.toUrlOrNull import timber.log.Timber import java.io.IOException -import java.util.* +import java.util.Locale /** * Repository to handle enrollment challenges. @@ -58,7 +76,8 @@ class EnrollmentRepository( override val resources: Resources, override val database: DatabaseService, override val secretService: SecretService, - override val preferences: PreferenceService + override val preferences: PreferenceService, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher, ) : ChallengeRepository() { override val challengeScheme: String = "${TiqrConfig.enrollScheme}://" @@ -146,69 +165,68 @@ class EnrollmentRepository( /** * Validate the [rawChallenge] and request enrollment. */ - override suspend fun parseChallenge(rawChallenge: String): ChallengeParseResult { - // Check challenge validity - val isValid = isValidChallenge(rawChallenge) - val url: HttpUrl? = if (rawChallenge.startsWith(challengeScheme)) { - // Old format URL, with custom scheme - rawChallenge.substring(challengeScheme.length).toHttpUrlOrNull() - } else { - // New format URL, with https scheme - Uri.parse(rawChallenge).getQueryParameter("metadata")?.toHttpUrlOrNull() - } - if (isValid.not() || url == null || url.isHttpOrHttps().not()) { - return EnrollmentParseFailure( - reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_qr) - ).run { - Timber.e("Invalid QR: $url") - ChallengeParseResult.failure(this) + override suspend fun parseChallenge(rawChallenge: String): ChallengeParseResult = + withContext(dispatcher) { + // Check challenge validity + val isValid = isValidChallenge(rawChallenge) + val url: HttpUrl? = if (rawChallenge.startsWith(challengeScheme)) { + // Old format URL, with custom scheme + rawChallenge.substring(challengeScheme.length).toHttpUrlOrNull() + } else { + // New format URL, with https scheme + Uri.parse(rawChallenge).getQueryParameter("metadata")?.toHttpUrlOrNull() + } + if (isValid.not() || url == null || url.isHttpOrHttps().not()) { + return@withContext EnrollmentParseFailure( + reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_qr) + ).run { + Timber.e("Invalid QR: $url") + ChallengeParseResult.failure(this) + } } - } - - return try { - // Perform API call and return result - api.requestEnroll(url = url.toString()).run { - val enroll = body() - if (isSuccessful && enroll != null) { - val identityProvider = enroll.service.run { - IdentityProvider( - displayName = displayName, - identifier = identifier, - authenticationUrl = authenticationUrl, - infoUrl = infoUrl, - ocraSuite = ocraSuite, - logo = logoUrl - ) - } - val identity = enroll.identity.run { - Identity( - displayName = displayName, - identifier = identifier - ) - } + return@withContext try { + // Perform API call and return result + api.requestEnroll(url = url.toString()).run { + val enroll = body() + if (isSuccessful && enroll != null) { + val identityProvider = enroll.service.run { + IdentityProvider( + displayName = displayName, + identifier = identifier, + authenticationUrl = authenticationUrl, + infoUrl = infoUrl, + ocraSuite = ocraSuite, + logo = logoUrl + ) + } - // Check if identity is already enrolled - database.getIdentity( - identityId = identity.identifier, - identityProviderId = identityProvider.identifier - )?.let { - return EnrollmentParseFailure( - reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString( - R.string.error_enroll_duplicate_identity, - identity.displayName, - identityProvider.displayName + val identity = enroll.identity.run { + Identity( + displayName = displayName, + identifier = identifier ) - ).run { - ChallengeParseResult.failure(this) } - } - withContext(Dispatchers.IO) { + // Check if identity is already enrolled + database.getIdentity( + identityId = identity.identifier, + identityProviderId = identityProvider.identifier + )?.let { + return@withContext EnrollmentParseFailure( + reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString( + R.string.error_enroll_duplicate_identity, + identity.displayName, + identityProvider.displayName + ) + ).run { + ChallengeParseResult.failure(this) + } + } EnrollmentChallenge( identityProvider = identityProvider, identity = identity, @@ -219,230 +237,241 @@ class EnrollmentRepository( ).run { ChallengeParseResult.success(this) } - } - } else { - return EnrollmentParseFailure( - reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_qr) - ).run { - ChallengeParseResult.failure(this) + } else { + return@withContext EnrollmentParseFailure( + reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_qr) + ).run { + ChallengeParseResult.failure(this) + } } } - } - } catch (e: Exception) { - Timber.e(e, "Error parsing challenge") + } catch (e: Exception) { + Timber.e(e, "Error parsing challenge") - return when (e) { - is JsonDataException, - is JsonEncodingException -> - EnrollmentParseFailure( - reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_qr) - ) - is IOException -> - EnrollmentParseFailure( - reason = EnrollmentParseFailure.Reason.CONNECTION, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_connection) - ) - else -> - EnrollmentParseFailure( - reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_qr) - ) - }.run { - ChallengeParseResult.failure(this) + return@withContext when (e) { + is JsonDataException, + is JsonEncodingException -> + EnrollmentParseFailure( + reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_qr) + ) + + is IOException -> + EnrollmentParseFailure( + reason = EnrollmentParseFailure.Reason.CONNECTION, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_connection) + ) + + else -> + EnrollmentParseFailure( + reason = EnrollmentParseFailure.Reason.INVALID_CHALLENGE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_qr) + ) + }.run { + ChallengeParseResult.failure(this) + } } } - } /** * Complete the [Challenge] and store the Identity. */ - override suspend fun completeChallenge(request: ChallengeCompleteRequest): ChallengeCompleteResult { - return try { - val secret = secretService.createSecret() + override suspend fun completeChallenge(request: ChallengeCompleteRequest): ChallengeCompleteResult = + withContext(dispatcher) { + return@withContext try { + val secret = secretService.createSecret() - // Perform API call and return result - api.enroll( - url = request.challenge.enrollmentUrl, - secret = secret.value.encoded.toHexString(), - language = Locale.getDefault().language, - notificationAddress = preferences.notificationToken - ).run { - when (this) { - is ApiResponse.Success -> handleResponse( - request, - body, - secret, - headers.tiqrProtocol() - ) - is ApiResponse.Failure -> handleResponse( - request, - body, - secret, - headers.tiqrProtocol() - ) - is ApiResponse.NetworkError -> { - Timber.e( - error, - "Error completing enrollment, request to '${request.challenge.enrollmentUrl}' threw a network error" + // Perform API call and return result + api.enroll( + url = request.challenge.enrollmentUrl, + secret = secret.value.encoded.toHexString(), + language = Locale.getDefault().language, + notificationAddress = preferences.notificationToken + ).run { + when (this) { + is ApiResponse.Success -> handleResponse( + request, + body, + secret, + headers.tiqrProtocol() ) + + is ApiResponse.Failure -> handleResponse( + request, + body, + secret, + headers.tiqrProtocol() + ) + + is ApiResponse.NetworkError -> { + Timber.e( + error, + "Error completing enrollment, request to '${request.challenge.enrollmentUrl}' threw a network error" + ) + EnrollmentCompleteFailure( + error = error, + reason = EnrollmentCompleteFailure.Reason.CONNECTION, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_connection) + ).run { + ChallengeCompleteResult.failure(this) + } + } + + is ApiResponse.Error -> { + Timber.e( + error, + "Error completing enrollment, request to '${request.challenge.enrollmentUrl}' was unsuccessful (code: $code)" + ) + EnrollmentCompleteFailure( + error = error, + reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_response) + ).run { + ChallengeCompleteResult.failure(this) + } + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error completing enrollment") + + return@withContext when (e) { + is JsonDataException, + is JsonEncodingException -> EnrollmentCompleteFailure( - error = error, + error = e, + reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_response) + ) + + is IOException -> + EnrollmentCompleteFailure( + error = e, reason = EnrollmentCompleteFailure.Reason.CONNECTION, title = resources.getString(R.string.error_enroll_title), message = resources.getString(R.string.error_enroll_connection) - ).run { - ChallengeCompleteResult.failure(this) - } - } - is ApiResponse.Error -> { - Timber.e( - error, - "Error completing enrollment, request to '${request.challenge.enrollmentUrl}' was unsuccessful (code: $code)" ) + + else -> EnrollmentCompleteFailure( - error = error, - reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, + error = e, + reason = EnrollmentCompleteFailure.Reason.UNKNOWN, title = resources.getString(R.string.error_enroll_title), message = resources.getString(R.string.error_enroll_invalid_response) - ).run { - ChallengeCompleteResult.failure(this) - } - } + ) + }.run { + ChallengeCompleteResult.failure(this) } } - } catch (e: Exception) { - Timber.e(e, "Error completing enrollment") - - return when (e) { - is JsonDataException, - is JsonEncodingException -> - EnrollmentCompleteFailure( - error = e, - reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_response) - ) - is IOException -> - EnrollmentCompleteFailure( - error = e, - reason = EnrollmentCompleteFailure.Reason.CONNECTION, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_connection) - ) - else -> - EnrollmentCompleteFailure( - error = e, - reason = EnrollmentCompleteFailure.Reason.UNKNOWN, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_response) - ) - }.run { - ChallengeCompleteResult.failure(this) - } } - } private suspend fun handleResponse( request: ChallengeCompleteRequest, response: EnrollmentResponse?, secret: Secret, protocolVersion: Int - ): ChallengeCompleteResult { - val result = response ?: return EnrollmentCompleteFailure( - error = RuntimeException("Null response"), - reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_invalid_response) - ).run { - Timber.e("Error completing enrollment, API response is empty") - ChallengeCompleteResult.failure(this) - } + ): ChallengeCompleteResult = + withContext(dispatcher) { + val result = response ?: return@withContext EnrollmentCompleteFailure( + error = RuntimeException("Null response"), + reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_invalid_response) + ).run { + Timber.e("Error completing enrollment, API response is empty") + ChallengeCompleteResult.failure(this) + } + + if (!TiqrConfig.protocolCompatibilityMode) { + if (protocolVersion <= TiqrConfig.protocolVersion) { + return@withContext EnrollmentCompleteFailure( + error = RuntimeException("Unsupported protocol"), + reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString( + R.string.error_enroll_invalid_protocol, + "v$protocolVersion" + ) + ).run { + Timber.e("Error completing enrollment, unsupported protocol version: v$protocolVersion") + ChallengeCompleteResult.failure(this) + } + } + } - if (!TiqrConfig.protocolCompatibilityMode) { - if (protocolVersion <= TiqrConfig.protocolVersion) { - return EnrollmentCompleteFailure( - error = RuntimeException("Unsupported protocol"), + if (result.code != EnrollmentResponse.Code.ENROLL_RESULT_SUCCESS) { + return@withContext EnrollmentCompleteFailure( + error = RuntimeException("Invalid response code"), reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, title = resources.getString(R.string.error_enroll_title), message = resources.getString( - R.string.error_enroll_invalid_protocol, - "v$protocolVersion" + R.string.error_enroll_invalid_response_code, + result.code ) ).run { - Timber.e("Error completing enrollment, unsupported protocol version: v$protocolVersion") + Timber.e("Error completing enrollment, unexpected response code: ${result.code}") ChallengeCompleteResult.failure(this) } } - } - - if (result.code != EnrollmentResponse.Code.ENROLL_RESULT_SUCCESS) { - return EnrollmentCompleteFailure( - error = RuntimeException("Invalid response code"), - reason = EnrollmentCompleteFailure.Reason.INVALID_RESPONSE, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString( - R.string.error_enroll_invalid_response_code, - result.code - ) - ).run { - Timber.e("Error completing enrollment, unexpected response code: ${result.code}") - ChallengeCompleteResult.failure(this) - } - } - // Insert the IdentityProvider first - val identityProviderId = database.insertIdentityProvider(request.challenge.identityProvider) - if (identityProviderId == -1L) { - Timber.e("Error completing enrollment, saving identity provider failed") - return EnrollmentCompleteFailure( - error = RuntimeException("Saving identity provider failed"), - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_saving_identity_provider) - ).run { - ChallengeCompleteResult.failure(this) + // Insert the IdentityProvider first + val identityProviderId = + database.insertIdentityProvider(request.challenge.identityProvider) + if (identityProviderId == -1L) { + Timber.e("Error completing enrollment, saving identity provider failed") + return@withContext EnrollmentCompleteFailure( + error = RuntimeException("Saving identity provider failed"), + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_saving_identity_provider) + ).run { + ChallengeCompleteResult.failure(this) + } } - } - // Then insert the Identity using the id from above - val identityId = - database.insertIdentity(request.challenge.identity.copy(identityProvider = identityProviderId)) - if (identityId == -1L) { - Timber.e("Error completing enrollment, saving identity failed") - return EnrollmentCompleteFailure( - error = RuntimeException("Saving identity failed"), - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_saving_identity) - ).run { - ChallengeCompleteResult.failure(this) + // Then insert the Identity using the id from above + val identityId = + database.insertIdentity(request.challenge.identity.copy(identityProvider = identityProviderId)) + if (identityId == -1L) { + Timber.e("Error completing enrollment, saving identity failed") + return@withContext EnrollmentCompleteFailure( + error = RuntimeException("Saving identity failed"), + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_saving_identity) + ).run { + ChallengeCompleteResult.failure(this) + } } - } - // Copy identity with inserted id's - val identity = - request.challenge.identity.copy(id = identityId, identityProvider = identityProviderId) + // Copy identity with inserted id's + val identity = + request.challenge.identity.copy( + id = identityId, + identityProvider = identityProviderId + ) - // Save secrets - try { - val sessionKey = secretService.createSessionKey(request.password) - val secretId = secretService.createSecretIdentity(identity, SecretType.PIN) - secretService.save(secretId, secret, sessionKey) - } catch (e: Exception) { - Timber.e(e, "Error completing enrollment, failed to save secrets securely") - return EnrollmentCompleteFailure( - error = e, - reason = EnrollmentCompleteFailure.Reason.SECURITY, - title = resources.getString(R.string.error_enroll_title), - message = resources.getString(R.string.error_enroll_saving_secrets) - ).run { - ChallengeCompleteResult.failure(this) + // Save secrets + try { + val sessionKey = secretService.createSessionKey(request.password) + val secretId = secretService.createSecretIdentity(identity, SecretType.PIN) + secretService.save(secretId, secret, sessionKey) + } catch (e: Exception) { + Timber.e(e, "Error completing enrollment, failed to save secrets securely") + return@withContext EnrollmentCompleteFailure( + error = e, + reason = EnrollmentCompleteFailure.Reason.SECURITY, + title = resources.getString(R.string.error_enroll_title), + message = resources.getString(R.string.error_enroll_saving_secrets) + ).run { + ChallengeCompleteResult.failure(this) + } } - } - - return ChallengeCompleteResult.success() - } + return@withContext ChallengeCompleteResult.success() + } } \ No newline at end of file diff --git a/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt b/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt index 81906af6..974ddbe7 100644 --- a/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt +++ b/data/src/main/java/org/tiqr/data/repository/IdentityRepository.kt @@ -29,21 +29,28 @@ package org.tiqr.data.repository +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow -import org.tiqr.data.model.SecretType +import kotlinx.coroutines.withContext +import org.tiqr.data.di.DefaultDispatcher import org.tiqr.data.model.Identity import org.tiqr.data.model.IdentityWithProvider +import org.tiqr.data.model.SecretType import org.tiqr.data.service.DatabaseService import org.tiqr.data.service.SecretService /** * Repository to interact with [Identity]. */ -class IdentityRepository(private val database: DatabaseService, private val secret: SecretService) { +class IdentityRepository( + private val database: DatabaseService, private val secret: SecretService, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher, +) { /** * Get [Identity] with given [identifier] */ - fun identity(identifier: String): Flow = database.getIdentityWithProvider(identifier) + fun identity(identifier: String): Flow = + database.getIdentityWithProvider(identifier) /** * Get all [Identity]'s @@ -53,7 +60,7 @@ class IdentityRepository(private val database: DatabaseService, private val secr /** * Set [identity] to use (or not use) biometric */ - suspend fun useBiometric(identity: Identity, use: Boolean) { + suspend fun useBiometric(identity: Identity, use: Boolean) = withContext(dispatcher) { identity.copy(biometricInUse = use).run { database.updateIdentity(this) } @@ -62,7 +69,7 @@ class IdentityRepository(private val database: DatabaseService, private val secr /** * Upgrade [identity] to use (or not use) biometric */ - suspend fun upgradeToBiometric(identity: Identity, upgrade: Boolean) { + suspend fun upgradeToBiometric(identity: Identity, upgrade: Boolean) = withContext(dispatcher) { identity.copy(biometricOfferUpgrade = upgrade).run { database.updateIdentity(this) } @@ -71,7 +78,7 @@ class IdentityRepository(private val database: DatabaseService, private val secr /** * Delete [identity] */ - suspend fun delete(identity: Identity) { + suspend fun delete(identity: Identity) = withContext(dispatcher) { database.deleteIdentity(identity) secret.delete(identity) } @@ -81,7 +88,8 @@ class IdentityRepository(private val database: DatabaseService, private val secr */ fun hasBiometricSecret(identity: Identity): Boolean { return try { - val sessionKey = secret.encryption.keyFromPassword(password = SecretType.BIOMETRIC.key) + val sessionKey = + secret.encryption.keyFromPassword(password = SecretType.BIOMETRIC.key) val secretId = secret.createSecretIdentity(identity, SecretType.BIOMETRIC) secret.load(secretId, sessionKey) true diff --git a/data/src/main/java/org/tiqr/data/repository/TokenRepository.kt b/data/src/main/java/org/tiqr/data/repository/TokenRepository.kt index 9f52b80c..7356ab4f 100644 --- a/data/src/main/java/org/tiqr/data/repository/TokenRepository.kt +++ b/data/src/main/java/org/tiqr/data/repository/TokenRepository.kt @@ -30,9 +30,11 @@ package org.tiqr.data.repository import dagger.Lazy +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.tiqr.data.api.TokenApi +import org.tiqr.data.di.DefaultDispatcher import org.tiqr.data.model.TiqrConfig import org.tiqr.data.repository.base.TokenRegistrarRepository import org.tiqr.data.service.PreferenceService @@ -41,34 +43,38 @@ import timber.log.Timber /** * Repository to handle token exchange. */ -class TokenRepository(private val api: Lazy, private val preferences: PreferenceService) : +class TokenRepository( + private val api: Lazy, private val preferences: PreferenceService, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher, +) : TokenRegistrarRepository { companion object { private const val NOT_FOUND = "NOT FOUND" } - override suspend fun executeTokenMigrationIfNeeded(getDeviceTokenFunction: suspend () -> String?) { - if (TiqrConfig.tokenExchangeEnabled) { - // We are still using the TokenExchange, no migration. - return - } - if (preferences.notificationTokenMigrationExecuted) { - // Already migrated, no migration needed anymore. - return - } - // Remove the old token, get the current device token from firebase, and set it - preferences.notificationToken = null - val newToken = getDeviceTokenFunction() - if (newToken != null) { - preferences.notificationToken = newToken - preferences.notificationTokenMigrationExecuted = true + override suspend fun executeTokenMigrationIfNeeded(getDeviceTokenFunction: suspend () -> String?) = + withContext(dispatcher) { + if (TiqrConfig.tokenExchangeEnabled) { + // We are still using the TokenExchange, no migration. + return@withContext + } + if (preferences.notificationTokenMigrationExecuted) { + // Already migrated, no migration needed anymore. + return@withContext + } + // Remove the old token, get the current device token from firebase, and set it + preferences.notificationToken = null + val newToken = getDeviceTokenFunction() + if (newToken != null) { + preferences.notificationToken = newToken + preferences.notificationTokenMigrationExecuted = true + } } - } /** * Register the device token (received from Firebase) and save the resulting notification token. */ - override suspend fun registerDeviceToken(deviceToken: String) { + override suspend fun registerDeviceToken(deviceToken: String) = withContext(dispatcher) { if (TiqrConfig.tokenExchangeEnabled) { try { val newToken = api.get().registerDeviceToken( diff --git a/data/src/main/java/org/tiqr/data/service/PreferenceService.kt b/data/src/main/java/org/tiqr/data/service/PreferenceService.kt index 2125f9d8..36bb9a2f 100644 --- a/data/src/main/java/org/tiqr/data/service/PreferenceService.kt +++ b/data/src/main/java/org/tiqr/data/service/PreferenceService.kt @@ -31,9 +31,11 @@ package org.tiqr.data.service import android.content.Context import android.os.Build +import android.util.Log import androidx.core.content.edit import timber.log.Timber import java.io.File +import java.lang.RuntimeException /** * Service to save and retrieve data saved in shared preferences. @@ -67,7 +69,11 @@ class PreferenceService(private val context: Context) { var deviceKey: String? get() = securitySharedPreferences.getString(PREFS_KEY_DEVICE_KEY, null) - set(value) = securitySharedPreferences.edit { putString(PREFS_KEY_DEVICE_KEY, value) } + set(value) { + val stack = Log.getStackTraceString(RuntimeException("Saving device key called from")) + Timber.e("Saving device key here: $stack") + securitySharedPreferences.edit { putString(PREFS_KEY_DEVICE_KEY, value) } + } /** * When switching over from TokenExchange to non-TokenExchange, we need the make sure, diff --git a/data/src/main/java/org/tiqr/data/service/SecretService.kt b/data/src/main/java/org/tiqr/data/service/SecretService.kt index 1cef0a70..bf2a679f 100644 --- a/data/src/main/java/org/tiqr/data/service/SecretService.kt +++ b/data/src/main/java/org/tiqr/data/service/SecretService.kt @@ -31,10 +31,10 @@ package org.tiqr.data.service import android.content.Context import android.util.Base64 -import org.tiqr.data.model.SecretType import org.tiqr.data.model.Identity import org.tiqr.data.model.Secret import org.tiqr.data.model.SecretIdentity +import org.tiqr.data.model.SecretType import org.tiqr.data.model.SessionKey import org.tiqr.data.model.asSecret import org.tiqr.data.model.asSessionKey @@ -45,8 +45,22 @@ import org.tiqr.data.util.extension.toCharArray import org.tiqr.data.util.extension.toCharArrayCompat import timber.log.Timber import java.io.IOException -import java.security.* -import javax.crypto.* +import java.security.InvalidAlgorithmParameterException +import java.security.InvalidKeyException +import java.security.Key +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.security.UnrecoverableKeyException +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.KeyGenerator +import javax.crypto.NoSuchPaddingException +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.ShortBufferException import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec @@ -181,7 +195,6 @@ class SecretService(context: Context, preferenceService: PreferenceService) { private fun saveKeystore(sessionKey: SessionKey) { context.openFileOutput(KEYSTORE_FILENAME, Context.MODE_PRIVATE).use { try { - sessionKey.value.encoded.toCharArray() keyStore.store(it, sessionKey.value.encoded.toCharArray()) } catch (e: Exception) { Timber.e(e) @@ -262,12 +275,19 @@ class SecretService(context: Context, preferenceService: PreferenceService) { /** * Delete the [SecretKey] - */ + */ internal fun deleteSecretKey(alias: String) { listOf(alias, alias + IV_SUFFIX).run { - forEach { - if (keyStore.containsAlias(it)) { - keyStore.deleteEntry(it) + try { + forEach { + if (keyStore.containsAlias(it)) { + keyStore.deleteEntry(it) + } + } + } catch (e: KeyStoreException) { + if (e.isKeystoreNotInitialized()) { + Timber.e(e, "Keystore not initialized, failed to delete keys") + keyStore.load(null, null) } } } @@ -420,3 +440,13 @@ class SecretService(context: Context, preferenceService: PreferenceService) { } } } + +private fun KeyStoreException.isKeystoreNotInitialized() = this.message == "Uninitialized keystore" + +private fun KeyStore.isInitialized() = + try { + this.size() + true + } catch (e: KeyStoreException) { + !e.isKeystoreNotInitialized() + } diff --git a/gradle.properties b/gradle.properties index 492cf03b..9ed4df95 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,6 +3,5 @@ org.gradle.jvmargs=-XX:MaxMetaspaceSize=512m android.enableJetifier=false android.useAndroidX=true -android.databinding.incremental=true kapt.incremental.apt=true -kotlin.incremental.usePreciseJavaTracking=true \ No newline at end of file +kotlin.incremental.usePreciseJavaTracking=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 265d3dbf..4fb8fe24 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,62 +2,56 @@ android-sdk-compile = "34" android-sdk-target = "34" android-sdk-min = "24" -android-buildTools = "34.0.0" +agp = "8.2.0" +gms = "4.4.0" -kotlin = "1.8.0" -coroutines = "1.6.4" +kotlin = "1.9.20" +coroutines = "1.7.3" +ksp-plugin = "1.9.20-1.0.14" -hilt = "2.44" -navigation = "2.5.3" -room = "2.5.0-beta01" -lifecycle = "2.5.1" -camera = "1.3.0-rc01" +hilt = "2.48.1" +navigation = "2.7.5" +room = "2.6.1" +lifecycle = "2.6.2" +camera = "1.3.0" -okhttp = "4.9.2" +okhttp = "4.12.0" retrofit = "2.9.0" -moshi = "1.14.0" +moshi = "1.15.0" [libraries] -android-gradle = "com.android.tools.build:gradle:7.4.2" kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } -kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-playServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } -dagger-hilt-gradle = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } dagger-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } dagger-hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } -dagger-hilt-fragment = "androidx.hilt:hilt-navigation-fragment:1.0.0" +dagger-hilt-fragment = "androidx.hilt:hilt-navigation-fragment:1.1.0" -androidx-activity = "androidx.activity:activity-ktx:1.6.1" -androidx-appcompat = "androidx.appcompat:appcompat:1.3.1" +androidx-activity = "androidx.activity:activity-ktx:1.8.1" androidx-autofill = "androidx.autofill:autofill:1.1.0" androidx-biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05" androidx-concurrent = "androidx.concurrent:concurrent-futures-ktx:1.1.0" androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" -androidx-core = "androidx.core:core-ktx:1.9.0" -androidx-fragment = "androidx.fragment:fragment-ktx:1.3.6" +androidx-core = "androidx.core:core-ktx:1.12.0" androidx-localBroadcastManager = "androidx.localbroadcastmanager:localbroadcastmanager:1.1.0" -androidx-recyclerview = "androidx.recyclerview:recyclerview:1.2.1" -androidx-splashscreen = "androidx.core:core-splashscreen:1.0.0" -androidx-savedstate = "androidx.savedstate:savedstate-ktx:1.2.0" -androidx-testing-core = "androidx.test:core-ktx:1.5.0-rc01" -androidx-testing-epsresso = "androidx.test.espresso:espresso-core:3.5.0-rc01" -androidx-testing-junit = "androidx.test.ext:junit-ktx:1.1.4-rc01" -androidx-testing-rules = "androidx.test:rules:1.5.0-rc01" -androidx-testing-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha01" +androidx-recyclerview = "androidx.recyclerview:recyclerview:1.3.2" +androidx-splashscreen = "androidx.core:core-splashscreen:1.0.1" +androidx-savedstate = "androidx.savedstate:savedstate-ktx:1.2.1" +androidx-testing-core = "androidx.test:core-ktx:1.5.0" +androidx-testing-epsresso = "androidx.test.espresso:espresso-core:3.5.1" +androidx-testing-junit = "androidx.test.ext:junit-ktx:1.1.5" +androidx-testing-rules = "androidx.test:rules:1.5.0" +androidx-testing-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha05" androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } -androidx-lifecycle-compiler = { module = "androidx.lifecycle:lifecycle-compiler", version.ref = "lifecycle" } -androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycle" } androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } -androidx-navigation-gradle = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } androidx-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } @@ -65,18 +59,16 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } -androidx-room-sqlite = "androidx.sqlite:sqlite-ktx:2.3.0-beta01" +androidx-room-sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camera" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } -androidx-camera-extensions = "androidx.camera:camera-extensions:1.3.0-rc01" -google-gms-gradle = "com.google.gms:google-services:4.3.14" -google-android-material = "com.google.android.material:material:1.7.0" -google-mlkit-barcode = "com.google.mlkit:barcode-scanning:17.0.2" -google-firebase-messaging = "com.google.firebase:firebase-messaging-ktx:23.1.0" +google-android-material = "com.google.android.material:material:1.10.0" +google-mlkit-barcode = "com.google.mlkit:barcode-scanning:17.2.0" +google-firebase-messaging = "com.google.firebase:firebase-messaging:23.3.1" okhttp-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } @@ -86,12 +78,22 @@ retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", retrofit-converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } moshi-moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } -moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" } moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } -permission = "com.github.fondesa:kpermissions:3.3.0" -coil = "io.coil-kt:coil:2.2.2" +permission = "com.github.fondesa:kpermissions:3.4.0" +coil = "io.coil-kt:coil:2.5.0" betterLink = "me.saket:better-link-movement-method:2.2.0" timber = "com.jakewharton.timber:timber:5.0.1" junit = "junit:junit:4.13.2" + + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-plugin" } +safe-args = {id = "androidx.navigation.safeargs.kotlin", version.ref="navigation"} +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +google-gms-gradle = { id = "com.google.gms.google-services", version.ref = "gms" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661e..a9dadee0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Fri Nov 17 16:13:21 CET 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 362d9cb3..74cb0515 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,20 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + rootProject.name = "tiqr app core android" include(":app") include(":data") -include(":core") - -// Enable Gradle's version catalog support -// https://docs.gradle.org/current/userguide/platforms.html -enableFeaturePreview("VERSION_CATALOGS") \ No newline at end of file +include(":core") \ No newline at end of file