diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index fa087b54..9641472b 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -57,17 +57,18 @@ internal actual class KeyPairManagerImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor { + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? { val userPassKey: File = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.find { predicate(it.name) - } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys")) + } ?: return null - val keyId = - userPassKey.name.substring(userPassKey.name.indexOfFirst { it == '-' } + 1, - userPassKey.name.indexOfLast { it == '-' }) - return Xor.First(keyId) + val keyId = userPassKey.name.substring( + startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, + endIndex = userPassKey.name.indexOfLast { it == '-' } + ) + return keyId } actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { diff --git a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt similarity index 71% rename from multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt rename to multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt index 8fa4ed53..2267b783 100644 --- a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt @@ -15,18 +15,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.auth.lib.utils +package com.infomaniak.auth.lib.internal.utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.invoke import splitties.init.appCtx import java.io.File -actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO { +internal actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO { File(appCtx.filesDir, name).exists() } -actual suspend fun createFile(name: String, content: String) { +/** + * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, + * or implement a BackupAgent to have the backup exclusion work. + */ +internal actual suspend fun createBackupExcludedFile(name: String, content: String) { File(appCtx.filesDir, name).apply { createNewFile() writeText(content) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 4a775e7e..d5159398 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -126,28 +126,32 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor = + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { memScoped { //TODO[ik-auth]: Test this code somehow. val (resultsArray, count) = getAllPrivateKeysQuery() - if (resultsArray == null || count == 0) { - return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) - } + if (resultsArray == null || count == 0) return@memScoped null for (i in 0 until count) { val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) if (tag != null && predicate(tag)) { - val keyId = tag.substring(tag.indexOfFirst { it == '-' } + 1, tag.indexOfLast { it == '-' }) - return@memScoped Xor.First(keyId) + val keyId = tag.substring( + startIndex = tag.indexOfFirst { it == '-' } + 1, + endIndex = tag.indexOfLast { it == '-' } + ) + return@memScoped keyId } } - Xor.Second(Failure.KeyManagement.KeyNotFound("No key found matching $predicate")) + return@memScoped null } + } - actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor = + actual override suspend fun deleteKeysMatching( + predicate: (name: String) -> Boolean + ): Xor = Dispatchers.IO { memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() @@ -171,6 +175,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { Xor.Second(Failure.KeyManagement.KeyNotFound("No key containing $predicate")) } } + } @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) private fun MemScope.getAllPrivateKeysQuery(): Pair { diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt similarity index 91% rename from multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt rename to multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt index 17067da1..6e986016 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.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.lib.utils +package com.infomaniak.auth.lib.internal.utils import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.extensions.toNsData @@ -27,12 +27,12 @@ import platform.Foundation.NSURL import platform.Foundation.NSURLIsExcludedFromBackupKey import platform.Foundation.NSUserDomainMask -actual suspend fun checkFileExists(name: String): Boolean { +internal actual suspend fun checkFileExists(name: String): Boolean { return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name") } @OptIn(ExperimentalForeignApi::class) -actual suspend fun createFile(name: String, content: String) { +internal actual suspend fun createBackupExcludedFile(name: String, content: String) { val path = "${getApplicationSupportDirectory()}/$name" NSFileManager.defaultManager.createFileAtPath( path = path, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 5c13b0c1..57575101 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -220,9 +220,7 @@ internal class AuthenticatorFacadeImpl( registrationAttempts(entity) } AccountEntity.Status.RestoringFromBackup -> { - migrationManager.restore(account = entity) { userId, token -> - authenticatorBridge.persistTokenForAccount(userId, token) - } + restoreFromBackupAttempts(account = entity) } AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. } @@ -365,6 +363,14 @@ internal class AuthenticatorFacadeImpl( } } + private suspend fun FlowCollector.restoreFromBackupAttempts(account: AccountEntity) { + withRetries(userId = account.id) { + migrationManager.restore(account = account) { userId, token -> + authenticatorBridge.persistTokenForAccount(userId, token) + } + } + } + private suspend inline fun FlowCollector.withRetries( userId: Long, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt b/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt index f151106e..e0cd52e3 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt @@ -21,6 +21,6 @@ internal sealed interface Failure { sealed interface KeyManagement : Failure { data class GenerationFailed(val details: String) : KeyManagement data class KeyExtractionFailed(val details: String) : KeyManagement - data class KeyNotFound(val details: String) : KeyManagement + data class KeyNotFound(val details: String, val userId: Long? = null) : KeyManagement } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt index b6eefbae..9c5f95cb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -31,7 +31,7 @@ internal interface KeyPairManager { suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor - suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor + suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt index b95d1834..8dae8a0c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt @@ -27,6 +27,6 @@ internal expect class KeyPairManagerImpl() : KeyPairManager { keyId: String ): Xor - override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor + override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt new file mode 100644 index 00000000..7b5d0817 --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -0,0 +1,50 @@ +/* + * 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.lib.internal + +import com.infomaniak.auth.lib.internal.utils.BackupExclusionOnlyApplePlatforms +import com.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile +import kotlin.random.Random + +internal object RestoreFromBackupDetector { + + private val restorationHandledMarkerFileName: String = "51756f69203f".hexToByteArray().decodeToString() + + suspend inline fun runRestoreOperationIfNeeded(block: () -> Unit) { + val restorationAlreadyHandled = doesRestoreHandledFileExist() + if (restorationAlreadyHandled) return + block() + markRestorationAsHandled() + } + + private suspend fun doesRestoreHandledFileExist(): Boolean { + return checkFileExists(restorationHandledMarkerFileName) + } + + private suspend fun markRestorationAsHandled() { + @OptIn(BackupExclusionOnlyApplePlatforms::class) + createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) + } + + private fun generateFileContent(): String { + val oldEnough = Random.nextBoolean() + val encodedContent = if (oldEnough) "466575722021" else "51756f69636f75626568" + return encodedContent.hexToByteArray().decodeToString() + } +} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index d919bec2..aef5e36c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -72,9 +72,9 @@ internal class AuthenticatorManager( suspend fun getToken( clientId: String, userId: Long, - keyIdFromOldPasskey: String? = null, + keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdFromOldPasskey ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) @@ -124,7 +124,7 @@ internal class AuthenticatorManager( } suspend fun removeAccount(token: String, userId: Long) { - val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() + val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") } if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it @@ -140,6 +140,6 @@ internal class AuthenticatorManager( } suspend fun getKeyIdFor(userId: Long): String? { - return keyPairManager.findKeyIdFor({ it.startsWith("$userId-") }).firstOrNull() + return keyPairManager.findKeyIdFor { it.startsWith("$userId-") } } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 8167a464..9a030b4e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -18,6 +18,7 @@ package com.infomaniak.auth.lib.internal.managers import com.infomaniak.auth.lib.internal.MigrationAuthentication +import com.infomaniak.auth.lib.internal.RestoreFromBackupDetector import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable @@ -34,8 +35,6 @@ import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.models.migration.ApiToken import com.infomaniak.auth.lib.network.exceptions.ApiException -import com.infomaniak.auth.lib.utils.checkFileExists -import com.infomaniak.auth.lib.utils.createFile import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException @@ -53,9 +52,11 @@ internal class MigrationManager( private val dao = accountsDatabase.getDao() suspend fun setBackedUpAccountsStatus() { - if (!doesAccountInitializationFileExist()) { - dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup) - createAccountInitializationFile() + RestoreFromBackupDetector.runRestoreOperationIfNeeded { + dao.updateStatus( + currentStatus = AccountEntity.Status.LoggedIn, + newStatus = AccountEntity.Status.RestoringFromBackup + ) } } @@ -65,16 +66,16 @@ internal class MigrationManager( val token = authenticatorManager.getToken( clientId = clientId, userId = account.id, - keyIdFromOldPasskey = keyId, - ).firstOrNull()!! + keyIdOrDefault = keyId, + ).firstOrElse { error(it) } // Register a new passkey val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) // Getting a new token with the new passkey val tokenWithNewPassKey = authenticatorManager.getToken( clientId = clientId, userId = account.id, - keyIdFromOldPasskey = newKeyId, - ).firstOrNull()!! + keyIdOrDefault = newKeyId, + ).firstOrElse { error(it) } persistToken(account.id, tokenWithNewPassKey.accessToken) dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore @@ -142,7 +143,7 @@ internal class MigrationManager( } authenticatorManager.deleteKeysFor(userId) - authenticatorManager.registerPasskey( + val _ = authenticatorManager.registerPasskey( token = temporaryToken.accessToken, userId = userId ) @@ -180,20 +181,4 @@ internal class MigrationManager( scope = this.scope, ) } - - companion object { - private const val ACCOUNT_INITIALIZATION_FILE_NAME = "51756f69203f" - private const val ACCOUNT_INITIALIZATION_FILE_CONTENT = "466575722021" - - suspend fun doesAccountInitializationFileExist(): Boolean { - return checkFileExists(name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString()) - } - - suspend fun createAccountInitializationFile() { - createFile( - name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(), - content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(), - ) - } - } } diff --git a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt similarity index 54% rename from multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt rename to multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 52bde963..a63834d0 100644 --- a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -15,8 +15,17 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.auth.lib.utils +package com.infomaniak.auth.lib.internal.utils -expect suspend fun createFile(name: String, content: String) +@RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " + + "Backup rules or logic in a BackupAgent are required on Android.") +annotation class BackupExclusionOnlyApplePlatforms -expect suspend fun checkFileExists(name: String): Boolean +/** + * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, + * or implement a BackupAgent to have the backup exclusion work. + */ +@BackupExclusionOnlyApplePlatforms +internal expect suspend fun createBackupExcludedFile(name: String, content: String) + +internal expect suspend fun checkFileExists(name: String): Boolean