diff --git a/Core b/Core index 9874b93f..52e413f8 160000 --- a/Core +++ b/Core @@ -1 +1 @@ -Subproject commit 9874b93fdfe67493150a0460e051a229e7a8f2b4 +Subproject commit 52e413f8597aad73b3e88d872d14362997a038a5 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64e893d2..afa090c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { alias(core.plugins.compose.compiler) alias(core.plugins.dagger.hilt) alias(core.plugins.kotlin.serialization) - alias(libs.plugins.ksp) + alias(core.plugins.ksp) alias(core.plugins.sentry.plugin) alias(core.plugins.google.services) } @@ -188,6 +188,7 @@ dependencies { // Notification "standardImplementation"(core.infomaniak.core.notifications.registration) "standardImplementation"(core.firebase.messaging.ktx) + "standardImplementation"(libs.play.services.block.store) // Room implementation(core.room.ktx) diff --git a/app/src/fdroid/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt new file mode 100644 index 00000000..ed5db7a2 --- /dev/null +++ b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt @@ -0,0 +1,37 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.auth.backup + +import android.app.backup.FullBackupDataOutput +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.backup.FullBackupAgent +import com.infomaniak.multiplatform_authenticator.core.PasskeysStorageLocation +import kotlinx.coroutines.runBlocking + +class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBackedUpFiles) { + + override fun onFullBackup(data: FullBackupDataOutput) { + PasskeysStorageLocation.dir.listFiles().orEmpty().forEach { file -> + fullBackupFile(file, data) + } + super.onFullBackup(data) + } +} diff --git a/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt new file mode 100644 index 00000000..a1c54f4e --- /dev/null +++ b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt @@ -0,0 +1,22 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.auth.backup + +object BlockStoreBackup { + suspend fun restorePasskeys() = Unit +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed99669e..760f5899 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,8 +8,10 @@ accountUtils.getHttpClient(userId) } + fun provideTwoFactorAuthManager( + authenticatorFacade: AuthenticatorFacade, + ): TwoFactorAuthManager = TwoFactorAuthManager( + coroutineScope = appScope, + connectedHttpClients = context(appScope) { authenticatorFacade.httpClients().toDynamicLazyMap() } + ) private fun createCrashReportInterface() = object : CrashReportInterface { override fun addBreadcrumb( @@ -217,5 +224,9 @@ object ApplicationModule { } } } + + override suspend fun restorePasskeys() { + BlockStoreBackup.restorePasskeys() // No-op on the F-Droid variant. + } } } diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 3c27a914..8a1b8568 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -18,11 +18,8 @@ + path="datastore" /> - diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 04a942a2..3ba455cb 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -16,17 +16,22 @@ ~ along with this program. If not, see . --> + + + + + path="datastore" /> - diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt new file mode 100644 index 00000000..18d624bc --- /dev/null +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt @@ -0,0 +1,67 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.auth.backup + +import android.app.backup.FullBackupDataOutput +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.backup.FullBackupAgent +import kotlinx.coroutines.runBlocking + +class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBackedUpFiles) { + + override fun onFullBackup(data: FullBackupDataOutput) { + val blockStoreBackupSucceeded = runBlocking { BlockStoreBackup.backupPasskeys() } + if (!blockStoreBackupSucceeded) return // Don't backup anything if we can't save the passkeys. + + val db = UserDatabase() + // We don't want to keep tokens in the db for backup, so we remove them temporarily. + // Note that the app can perfectly recover from this state if the backup process is aborted, here's why: + // Authenticated API calls with an empty token will result in a 401 http status code, + // which will lead to the token being refreshed using the passkey. + val usersWithTokens = runBlocking { db.getUsersAndRemoveTokens() } + try { + super.onFullBackup(data) + } finally { + runBlocking { db.putTokensBack(usersWithTokens) } + } + } + + private suspend fun UserDatabase.getUsersAndRemoveTokens(): List = useWriterConnection { transactor -> + transactor.immediateTransaction { + userDao().allUsers().also { users -> + users.forEach { user -> + userDao().update(user = user.copy(apiToken = user.apiToken.copy(accessToken = "", refreshToken = null))) + } + } + } + } + + private suspend fun UserDatabase.putTokensBack(usersWithTokens: List) { + useWriterConnection { transactor -> + transactor.immediateTransaction { + usersWithTokens.forEach { user -> + // We don't need the refreshToken even if it's there because we're using passkeys instead. + userDao().updateUserToken(user.id, user.apiToken.accessToken) + } + } + } + } +} diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt new file mode 100644 index 00000000..43df1ebe --- /dev/null +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt @@ -0,0 +1,142 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.infomaniak.auth.backup + +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.BlockstoreClient +import com.google.android.gms.auth.blockstore.RetrieveBytesRequest +import com.google.android.gms.auth.blockstore.StoreBytesData +import com.infomaniak.core.common.cancellable +import com.infomaniak.core.sentry.SentryLog +import com.infomaniak.multiplatform_authenticator.core.PasskeysStorageLocation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.invoke +import kotlinx.coroutines.tasks.await +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import splitties.init.appCtx +import java.io.File + +object BlockStoreBackup { + private val blockstoreClient = Blockstore.getClient(appCtx) + private const val PASSKEYS_KEY = "pk" + private const val TAG = "BlockStoreBackup" + + suspend fun backupPasskeys(): Boolean { + val backupContent = dumpPasskeys() + val alreadyBackedUpContent = readPasskeysBackup() + val bytes = ProtoBuf.encodeToByteArray(backupContent) + if (alreadyBackedUpContent != null && bytes contentEquals ProtoBuf.encodeToByteArray(alreadyBackedUpContent)) return true + return writePasskeysBackup(bytes) + } + + suspend fun restorePasskeys() { + val passKeysBackup = readPasskeysBackup() ?: return + applyPasskeysBackup(passKeysBackup) + } + + private suspend fun writePasskeysBackup(protobufEncodedBytes: ByteArray): Boolean { + val keySizeInBytes = PASSKEYS_KEY.toByteArray().size + val contentSizeInBytes = protobufEncodedBytes.size + val entireSize = contentSizeInBytes + keySizeInBytes + if (entireSize > BlockstoreClient.MAX_SIZE) { + SentryLog.e(TAG, "Too many passkeys to backup") { scope -> + scope.setExtra("Max size", "${BlockstoreClient.MAX_SIZE}B") + scope.setExtra("Actual size", "${keySizeInBytes}B + ${contentSizeInBytes}B = ${entireSize}B") + } + return false + } + val storeRequest = StoreBytesData.Builder() + .setKey(PASSKEYS_KEY) + .setShouldBackupToCloud(true) + .setBytes(protobufEncodedBytes) + .build() + return runCatching { + blockstoreClient.storeBytes(storeRequest).await() + true + }.cancellable().getOrElse { throwable -> + SentryLog.wtf(TAG, "Failed to backup passkeys", throwable) + false + } + } + + private suspend fun dumpPasskeys(): PasskeysBackup = Dispatchers.IO { + PasskeysBackup( + entries = keyRefs().map { keyPairRef -> + PasskeysBackup.PasskeyEntry( + keyPairRef.userId, + keyPairRef.keyId, + private = keyFile(keyPairRef, isPublic = false).readBytes(), + public = keyFile(keyPairRef, isPublic = true).readBytes(), + ) + } + ) + } + + private suspend fun readPasskeysBackup(): PasskeysBackup? { + val retrieveRequest = RetrieveBytesRequest.Builder() + .setKeys(listOf(PASSKEYS_KEY)) + .build() + val bytes = blockstoreClient.retrieveBytes(retrieveRequest).await().blockstoreDataMap[PASSKEYS_KEY]?.bytes + ?: return null + return ProtoBuf.decodeFromByteArray(bytes) + } + + private suspend fun applyPasskeysBackup(backup: PasskeysBackup) { + Dispatchers.IO { + backup.entries.forEach { entry -> + val keyPairRef = KeyPairReference(entry.userId, entry.keyId) + keyFile(keyPairRef, isPublic = false).writeBytes(entry.private) + keyFile(keyPairRef, isPublic = true).writeBytes(entry.public) + } + } + } + + private suspend fun keyRefs(): List = Dispatchers.IO { + PasskeysStorageLocation.dir.list().orEmpty() + }.mapNotNull { fileName -> + val userIdAndKeyId = fileName + .substringBefore( + delimiter = ".key", + missingDelimiterValue = "" + ) + .ifEmpty { return@mapNotNull null } + .substringBeforeLast('-') + KeyPairReference( + userId = userIdAndKeyId.substringBefore('-').toLong(), + keyId = userIdAndKeyId.substringAfter('-') + ) + }.distinct() + + private fun keyFile(reference: KeyPairReference, isPublic: Boolean): File = PasskeysStorageLocation.keyFile( + userId = reference.userId, + keyId = reference.keyId, + isPublic = isPublic, + ) + + private suspend fun isE2eeAvailable(): Boolean = blockstoreClient.isEndToEndEncryptionAvailable.await() + + private data class KeyPairReference( + val userId: Long, + val keyId: String, + ) +} diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/PasskeysBackup.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/PasskeysBackup.kt new file mode 100644 index 00000000..97059b8e --- /dev/null +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/PasskeysBackup.kt @@ -0,0 +1,42 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.infomaniak.auth.backup + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +class PasskeysBackup( + @ProtoNumber(1) + val entries: List, +) { + @Serializable + class PasskeyEntry( + @ProtoNumber(1) + val userId: Long, + @ProtoNumber(2) + val keyId: String, + @ProtoNumber(3) + val private: ByteArray, + @ProtoNumber(4) + val public: ByteArray, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index ce37dab8..2700f631 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,9 +25,7 @@ plugins { alias(core.plugins.kotlin.serialization) apply false alias(core.plugins.sentry.plugin) apply false - alias(libs.plugins.kotlin.multiplatform) apply false - alias(libs.plugins.ksp) apply false - alias(libs.plugins.skie) apply false + alias(core.plugins.ksp) apply false alias(libs.plugins.sonarqube) id("com.infomaniak.core.compose.lint") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77c79085..7ccc3b78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,13 @@ [versions] accompanistPermissions = "0.37.3" -authenticatorCore = "0.0.12" -kotlin = "2.3.21" -osmerionkotlinBase32 = "1.0.0" -ksp = "2.3.9" -room = "2.8.4" -skie = "0.10.12" +authenticatorCore = "1.0.0" sonarqube = "7.3.1.8318" -sqlite = "2.6.2" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } -androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } -androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } -androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } -osmerion-kotlin-base32 = { module = "com.osmerion.kotlin:kotlin-base32", version.ref = "osmerionkotlinBase32" } infomaniak-multiplatform-authenticator-core-submodule = { module = "com.infomaniak.multiplaform-authenticator:multiplatform-authenticator" } infomaniak-multiplatform-authenticator-core = { group = "com.infomaniak.multiplatform_authenticator", name = "AuthenticatorCore", version.ref = "authenticatorCore" } +play-services-block-store = { module = "com.google.android.gms:play-services-auth-blockstore", version = "16.4.0" } [plugins] -androidx-room = { id = "androidx.room", version.ref = "room" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -skie = { id = "co.touchlab.skie", version.ref = "skie" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } diff --git a/multiplatform-authenticator b/multiplatform-authenticator index 1f8c1698..25146748 160000 --- a/multiplatform-authenticator +++ b/multiplatform-authenticator @@ -1 +1 @@ -Subproject commit 1f8c16988fb251d465af39cf16235323899fe9cc +Subproject commit 25146748a1e9929a175c511d9e19730af0bbe477