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