Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -188,6 +188,7 @@ dependencies {
// Notification
"standardImplementation"(core.infomaniak.core.notifications.registration)
"standardImplementation"(core.firebase.messaging.ktx)
"standardImplementation"(libs.play.services.block.store)
Comment thread
Copilot marked this conversation as resolved.

// Room
implementation(core.room.ktx)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.auth.backup

object BlockStoreBackup {
suspend fun restorePasskeys() = Unit
}
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
<application
android:name=".MainApplication"
android:allowBackup="true"
android:backupAgent=".backup.AuthenticatorFullBackupAgent"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/appName"
android:supportsRtl="true"
Expand Down
15 changes: 13 additions & 2 deletions app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ 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
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
Expand All @@ -38,6 +40,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
Expand Down Expand Up @@ -107,8 +110,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() }
Comment thread
LouisCAD marked this conversation as resolved.
)

private fun createCrashReportInterface() = object : CrashReportInterface {
override fun addBreadcrumb(
Expand Down Expand Up @@ -217,5 +224,9 @@ object ApplicationModule {
}
}
}

override suspend fun restorePasskeys() {
BlockStoreBackup.restorePasskeys() // No-op on the F-Droid variant.
}
}
}
5 changes: 1 addition & 4 deletions app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,8 @@
<full-backup-content>
<include
domain="file"
path="." />
path="datastore" />
<include
domain="database"
path="." />
<exclude
domain="file"
path="Quoi ?" />
</full-backup-content>
13 changes: 9 additions & 4 deletions app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,22 @@
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<data-extraction-rules>
<cloud-backup>
<include
domain="file"
path="datastore" />
<include
domain="database"
path="." />
</cloud-backup>

<device-transfer>
<include
domain="file"
path="." />
path="datastore" />
<include
domain="database"
path="." />
<exclude
domain="file"
path="Quoi ?" />
</device-transfer>

</data-extraction-rules>
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<User> = useWriterConnection { transactor ->
transactor.immediateTransaction {
userDao().allUsers().also { users ->
users.forEach { user ->
userDao().update(user = user.copy(apiToken = user.apiToken.copy(accessToken = "", refreshToken = null)))
Comment thread
LouisCAD marked this conversation as resolved.
}
}
}
}

private suspend fun UserDatabase.putTokensBack(usersWithTokens: List<User>) {
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)
}
}
}
}
}
142 changes: 142 additions & 0 deletions app/src/standard/kotlin/com/infomaniak/auth/backup/BlockStoreBackup.kt
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
@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)
Comment thread
LouisCAD marked this conversation as resolved.
.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)
Comment thread
LouisCAD marked this conversation as resolved.
}

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<KeyPairReference> = 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,
)
}
Loading
Loading