From bd6d6843306f4dfe5f709fa29c9eae85d6d6e8f9 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 8 Sep 2026 11:47:21 +0200 Subject: [PATCH 01/16] feat: Prepare Block Store passkeys backup (WIP) --- app/build.gradle.kts | 1 + .../backup/AuthenticatorFullBackupAgent.kt | 42 ++++++ .../auth/backup/BlockStoreBackup.kt | 141 ++++++++++++++++++ .../infomaniak/auth/backup/PasskeysBackup.kt | 42 ++++++ gradle/libs.versions.toml | 1 + 5 files changed, 227 insertions(+) create mode 100644 app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt create mode 100644 app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt create mode 100644 app/src/standard/kotlin/com/infomaniak/auth/backup/PasskeysBackup.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64e893d2..829307b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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/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..bea3fe78 --- /dev/null +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.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 . + */ +package com.infomaniak.auth.backup + +import android.app.backup.FullBackupDataOutput +import android.os.ParcelFileDescriptor +import com.infomaniak.core.common.backup.FullBackupAgent +import java.io.File + +class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBackedUpFiles) { + + override fun onFullBackup(data: FullBackupDataOutput) { + super.onFullBackup(data) + } + + override fun onRestoreFile( + data: ParcelFileDescriptor, + size: Long, + destination: File, + type: Int, + mode: Long, + mtime: Long + ) { + //TODO: Try to retrieve data from BlockStore first. + super.onRestoreFile(data, size, destination, type, mode, mtime) + } +} 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..7fe16e38 --- /dev/null +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt @@ -0,0 +1,141 @@ +/* + * 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 (bytes contentEquals ProtoBuf.encodeToByteArray(alreadyBackedUpContent)) return true + return writePasskeysBackup(bytes) + } + + suspend fun restorePasskeys() { + val passKeysBackup = readPasskeysBackup() + 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 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 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77c79085..e8eba5af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.r 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" } From 85f0eb01527a820ada2a98084f7f498822f9802f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 14 Sep 2026 15:14:03 +0200 Subject: [PATCH 02/16] feat: Ensure token refreshes are seamless by sharing http clients with 2FA In case a token expires or is revoked, the 1st API call for two-factor auth challenges will now automatically trigger a refresh with the passkey because the http client is reused and shares the same (re)authentication mechanism. --- Core | 2 +- .../kotlin/com/infomaniak/auth/di/ApplicationModule.kt | 10 ++++++++-- multiplatform-authenticator | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Core b/Core index 9874b93f..1f2ff927 160000 --- a/Core +++ b/Core @@ -1 +1 @@ -Subproject commit 9874b93fdfe67493150a0460e051a229e7a8f2b4 +Subproject commit 1f2ff927af6a6132fb19fc786309167820ce3d40 diff --git a/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt b/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt index 3f9505ed..cfbe9a19 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt @@ -30,6 +30,7 @@ import com.infomaniak.auth.utils.toUser import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.getAndroidId +import com.infomaniak.core.common.toDynamicLazyMap import com.infomaniak.core.crossapplogin.back.CrossAppLoginFacade import com.infomaniak.core.crossapplogin.back.CrossAppLoginFacade.AccountsCheckingStatus import com.infomaniak.core.login.InfomaniakLogin @@ -38,6 +39,7 @@ import com.infomaniak.core.network.LOGIN_ENDPOINT_URL import com.infomaniak.core.network.networking.HttpUtils import com.infomaniak.core.twofactorauth.back.TwoFactorAuthManager import com.infomaniak.multiplatform_authenticator.core.AuthenticatorFacade +import com.infomaniak.multiplatform_authenticator.core.httpClients import com.infomaniak.multiplatform_authenticator.core.models.migration.SharedApiToken import com.infomaniak.multiplatform_authenticator.core.models.migration.user.SharedUserProfile import com.infomaniak.multiplatform_authenticator.core.network.interfaces.AuthenticatorBridge @@ -107,8 +109,12 @@ object ApplicationModule { @Provides @Singleton - fun provideTwoFactorAuthManager(accountUtils: AccountUtils) = - TwoFactorAuthManager { userId -> 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( diff --git a/multiplatform-authenticator b/multiplatform-authenticator index 1f8c1698..ea81b89f 160000 --- a/multiplatform-authenticator +++ b/multiplatform-authenticator @@ -1 +1 @@ -Subproject commit 1f8c16988fb251d465af39cf16235323899fe9cc +Subproject commit ea81b89f9817c44d24cde7dc3917be50d01ff565 From fa819cd0506ca93eb54f676df6accf8b8e0e3b95 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 14 Sep 2026 18:14:02 +0200 Subject: [PATCH 03/16] feat: Backup passkeys with the Block Store API --- Core | 2 +- .../auth/backup/BlockStoreBackup.kt | 22 +++++++++ .../infomaniak/auth/di/ApplicationModule.kt | 5 ++ app/src/standard/AndroidManifest.xml | 2 + .../backup/AuthenticatorFullBackupAgent.kt | 47 ++++++++++++++----- .../auth/backup/BlockStoreBackup.kt | 9 ++-- multiplatform-authenticator | 2 +- 7 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 app/src/fdroid/com/infomaniak/auth/backup/BlockStoreBackup.kt diff --git a/Core b/Core index 1f2ff927..bdeaa1b7 160000 --- a/Core +++ b/Core @@ -1 +1 @@ -Subproject commit 1f2ff927af6a6132fb19fc786309167820ce3d40 +Subproject commit bdeaa1b7ae784dcc8b87f25c194bbedbaed72e76 diff --git a/app/src/fdroid/com/infomaniak/auth/backup/BlockStoreBackup.kt b/app/src/fdroid/com/infomaniak/auth/backup/BlockStoreBackup.kt new file mode 100644 index 00000000..56e1022f --- /dev/null +++ b/app/src/fdroid/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.di + +object BlockStoreBackup { + suspend fun restorePasskeys() = Unit +} diff --git a/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt b/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt index cfbe9a19..1408eb7a 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt @@ -23,6 +23,7 @@ import androidx.room.useWriterConnection import androidx.room.withTransaction import com.infomaniak.auth.BuildConfig import com.infomaniak.auth.MainApplication +import com.infomaniak.auth.backup.BlockStoreBackup import com.infomaniak.auth.utils.AccountUtils import com.infomaniak.auth.utils.toLoginApiToken import com.infomaniak.auth.utils.toSharedApiToken @@ -223,5 +224,9 @@ object ApplicationModule { } } } + + override suspend fun restorePasskeys() { + BlockStoreBackup.restorePasskeys() // No-op on the F-Droid variant. + } } } diff --git a/app/src/standard/AndroidManifest.xml b/app/src/standard/AndroidManifest.xml index f49878b8..c4e0d4ca 100644 --- a/app/src/standard/AndroidManifest.xml +++ b/app/src/standard/AndroidManifest.xml @@ -20,6 +20,8 @@ diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt index bea3fe78..32e69785 100644 --- a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt @@ -18,25 +18,46 @@ package com.infomaniak.auth.backup import android.app.backup.FullBackupDataOutput -import android.os.ParcelFileDescriptor +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 java.io.File +import kotlinx.coroutines.runBlocking class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBackedUpFiles) { override fun onFullBackup(data: FullBackupDataOutput) { - super.onFullBackup(data) + val blockStoreBackupSucceeded = runBlocking { BlockStoreBackup.backupPasskeys() } + if (!blockStoreBackupSucceeded) return super.onFullBackup(data) + + val db = UserDatabase() + // We don't want to keep tokens in the db for backup, so we remove them temporarily. + 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))) + } + } + } } - override fun onRestoreFile( - data: ParcelFileDescriptor, - size: Long, - destination: File, - type: Int, - mode: Long, - mtime: Long - ) { - //TODO: Try to retrieve data from BlockStore first. - super.onRestoreFile(data, size, destination, type, mode, mtime) + private suspend fun UserDatabase.putTokensBack(usersWithTokens: List) { + useWriterConnection { transactor -> + transactor.immediateTransaction { + usersWithTokens.forEach { user -> + 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 index 7fe16e38..293761ff 100644 --- a/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt @@ -45,12 +45,12 @@ object BlockStoreBackup { val backupContent = dumpPasskeys() val alreadyBackedUpContent = readPasskeysBackup() val bytes = ProtoBuf.encodeToByteArray(backupContent) - if (bytes contentEquals ProtoBuf.encodeToByteArray(alreadyBackedUpContent)) return true + if (alreadyBackedUpContent != null && bytes contentEquals ProtoBuf.encodeToByteArray(alreadyBackedUpContent)) return true return writePasskeysBackup(bytes) } suspend fun restorePasskeys() { - val passKeysBackup = readPasskeysBackup() + val passKeysBackup = readPasskeysBackup() ?: return applyPasskeysBackup(passKeysBackup) } @@ -92,11 +92,12 @@ object BlockStoreBackup { ) } - private suspend fun readPasskeysBackup(): PasskeysBackup { + private suspend fun readPasskeysBackup(): PasskeysBackup? { val retrieveRequest = RetrieveBytesRequest.Builder() .setKeys(listOf(PASSKEYS_KEY)) .build() - val bytes = blockstoreClient.retrieveBytes(retrieveRequest).await().blockstoreDataMap[PASSKEYS_KEY]!!.bytes + val bytes = blockstoreClient.retrieveBytes(retrieveRequest).await().blockstoreDataMap[PASSKEYS_KEY]?.bytes + ?: return null return ProtoBuf.decodeFromByteArray(bytes) } diff --git a/multiplatform-authenticator b/multiplatform-authenticator index ea81b89f..8a704756 160000 --- a/multiplatform-authenticator +++ b/multiplatform-authenticator @@ -1 +1 @@ -Subproject commit ea81b89f9817c44d24cde7dc3917be50d01ff565 +Subproject commit 8a704756fdb1d3073e31a69d7626519bce81cb79 From fbd9bf491ed98ebb558e61c20c05b79329edeada Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 14 Sep 2026 18:22:02 +0200 Subject: [PATCH 04/16] fix: Fix wrong directory for source file --- .../{ => kotlin}/com/infomaniak/auth/backup/BlockStoreBackup.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/fdroid/{ => kotlin}/com/infomaniak/auth/backup/BlockStoreBackup.kt (100%) diff --git a/app/src/fdroid/com/infomaniak/auth/backup/BlockStoreBackup.kt b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt similarity index 100% rename from app/src/fdroid/com/infomaniak/auth/backup/BlockStoreBackup.kt rename to app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt From c10ccc97d9cc24088fdd80d66fdbc6c22dd4c003 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 14 Sep 2026 18:27:36 +0200 Subject: [PATCH 05/16] fix: Fix wrong package directive --- .../kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt index 56e1022f..a1c54f4e 100644 --- a/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt +++ b/app/src/fdroid/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.auth.di +package com.infomaniak.auth.backup object BlockStoreBackup { suspend fun restorePasskeys() = Unit From a91285b7f59cff896b1cd70623e42fd7332066b4 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 11:08:20 +0200 Subject: [PATCH 06/16] chore: Update submodules --- Core | 2 +- multiplatform-authenticator | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Core b/Core index bdeaa1b7..52e413f8 160000 --- a/Core +++ b/Core @@ -1 +1 @@ -Subproject commit bdeaa1b7ae784dcc8b87f25c194bbedbaed72e76 +Subproject commit 52e413f8597aad73b3e88d872d14362997a038a5 diff --git a/multiplatform-authenticator b/multiplatform-authenticator index 8a704756..25146748 160000 --- a/multiplatform-authenticator +++ b/multiplatform-authenticator @@ -1 +1 @@ -Subproject commit 8a704756fdb1d3073e31a69d7626519bce81cb79 +Subproject commit 25146748a1e9929a175c511d9e19730af0bbe477 From d653d51fd74c343fc295c12e95033c52ec35165f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 11:17:06 +0200 Subject: [PATCH 07/16] chore: Update submodules and multiplatform-authenticator --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8eba5af..46de9b89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanistPermissions = "0.37.3" -authenticatorCore = "0.0.12" +authenticatorCore = "1.0.0" kotlin = "2.3.21" osmerionkotlinBase32 = "1.0.0" ksp = "2.3.9" From 617f30cc1a5229b24485175d3e2534981d92ae4f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 11:18:46 +0200 Subject: [PATCH 08/16] chore: Remove no longer needed dependency declarations --- gradle/libs.versions.toml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46de9b89..965c5dd4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,26 +2,17 @@ accompanistPermissions = "0.37.3" authenticatorCore = "1.0.0" kotlin = "2.3.21" -osmerionkotlinBase32 = "1.0.0" ksp = "2.3.9" -room = "2.8.4" skie = "0.10.12" 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" } From ad10dec41598af4dfb23d59fa190ee40d936627a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 11:27:17 +0200 Subject: [PATCH 09/16] chore: Remove no longer needed SKIE plugin --- build.gradle.kts | 1 - gradle/libs.versions.toml | 2 -- 2 files changed, 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index ce37dab8..ca9d06ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.ksp) apply false - alias(libs.plugins.skie) 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 965c5dd4..db33297f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,6 @@ accompanistPermissions = "0.37.3" authenticatorCore = "1.0.0" kotlin = "2.3.21" ksp = "2.3.9" -skie = "0.10.12" sonarqube = "7.3.1.8318" [libraries] @@ -15,5 +14,4 @@ play-services-block-store = { module = "com.google.android.gms:play-services-aut [plugins] 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" } From b19bc99ee6a77cb76b4968a7eeac6d569b9d5bc1 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 11:40:00 +0200 Subject: [PATCH 10/16] chore: Use KSP version from Core --- app/build.gradle.kts | 2 +- build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 829307b4..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) } diff --git a/build.gradle.kts b/build.gradle.kts index ca9d06ea..71550043 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,7 @@ plugins { alias(core.plugins.sentry.plugin) apply false alias(libs.plugins.kotlin.multiplatform) apply false - alias(libs.plugins.ksp) 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 db33297f..8263bc2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,6 @@ accompanistPermissions = "0.37.3" authenticatorCore = "1.0.0" kotlin = "2.3.21" -ksp = "2.3.9" sonarqube = "7.3.1.8318" [libraries] @@ -13,5 +12,4 @@ play-services-block-store = { module = "com.google.android.gms:play-services-aut [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } From 2fb48361f1a862fe86598706e8eaab9e5a539a66 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 11:40:16 +0200 Subject: [PATCH 11/16] chore: Remove unneeded plugin declaration --- build.gradle.kts | 1 - gradle/libs.versions.toml | 2 -- 2 files changed, 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 71550043..2700f631 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,6 @@ plugins { alias(core.plugins.kotlin.serialization) apply false alias(core.plugins.sentry.plugin) apply false - alias(libs.plugins.kotlin.multiplatform) apply false alias(core.plugins.ksp) apply false alias(libs.plugins.sonarqube) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8263bc2e..7ccc3b78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,6 @@ [versions] accompanistPermissions = "0.37.3" authenticatorCore = "1.0.0" -kotlin = "2.3.21" sonarqube = "7.3.1.8318" [libraries] @@ -11,5 +10,4 @@ infomaniak-multiplatform-authenticator-core = { group = "com.infomaniak.multipla play-services-block-store = { module = "com.google.android.gms:play-services-auth-blockstore", version = "16.4.0" } [plugins] -kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } From f5b57d775813edb47b7091afbed7926897d0ab6f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 13:02:41 +0200 Subject: [PATCH 12/16] chore: Add clarifying comment as to why removing tokens is safe --- .../com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt index 32e69785..b15bbd26 100644 --- a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt @@ -33,6 +33,9 @@ class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBacked 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) From d64283e05ddaa78f975c44a009dc07d1f312f987 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 13:11:52 +0200 Subject: [PATCH 13/16] chore: Add clarifying comment about refreshToken being removed --- .../com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt index b15bbd26..da3f6cfc 100644 --- a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt @@ -58,6 +58,7 @@ class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBacked 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) } } From 768f435443948833a4ebd2694084c2e74bc2671c Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 14:17:49 +0200 Subject: [PATCH 14/16] feat: Update backup rules and add BackupAgent for F-Droid --- .../backup/AuthenticatorFullBackupAgent.kt | 37 +++++++++++++++++++ app/src/main/AndroidManifest.xml | 2 + app/src/main/res/xml/backup_rules.xml | 5 +-- .../main/res/xml/data_extraction_rules.xml | 13 +++++-- app/src/standard/AndroidManifest.xml | 2 - 5 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 app/src/fdroid/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt 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/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 @@ + 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/AndroidManifest.xml b/app/src/standard/AndroidManifest.xml index c4e0d4ca..f49878b8 100644 --- a/app/src/standard/AndroidManifest.xml +++ b/app/src/standard/AndroidManifest.xml @@ -20,8 +20,6 @@ From 9daa9377f645a6e12aff0c2c212e12cb7ba6b603 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 14:18:51 +0200 Subject: [PATCH 15/16] fix: Make KeyPairReference a data class to fix distinct() --- .../kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt index 293761ff..43df1ebe 100644 --- a/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt @@ -135,7 +135,7 @@ object BlockStoreBackup { private suspend fun isE2eeAvailable(): Boolean = blockstoreClient.isEndToEndEncryptionAvailable.await() - private class KeyPairReference( + private data class KeyPairReference( val userId: Long, val keyId: String, ) From e8f526dfc83ce6856b99ec6ec1d70647ac9730b7 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 15 Sep 2026 14:49:53 +0200 Subject: [PATCH 16/16] chore: Don't back up anything if we can't store the passkeys in Block Store --- .../com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt index da3f6cfc..18d624bc 100644 --- a/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt +++ b/app/src/standard/kotlin/com/infomaniak/auth/backup/AuthenticatorFullBackupAgent.kt @@ -29,7 +29,7 @@ class AuthenticatorFullBackupAgent : FullBackupAgent(RestorationPolicy.AllBacked override fun onFullBackup(data: FullBackupDataOutput) { val blockStoreBackupSucceeded = runBlocking { BlockStoreBackup.backupPasskeys() } - if (!blockStoreBackupSucceeded) return super.onFullBackup(data) + 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.