From 8ff5f664bef2540443bcb160518907e85cb853af Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 16:11:50 +0200 Subject: [PATCH 01/14] chore: Put arguments on separate lines with names --- .../kotlin/internal/KeyPairManagerImpl.android.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index fa087b54..f61d97bf 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -64,9 +64,10 @@ internal actual class KeyPairManagerImpl : KeyPairManager { predicate(it.name) } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys")) - val keyId = - userPassKey.name.substring(userPassKey.name.indexOfFirst { it == '-' } + 1, - userPassKey.name.indexOfLast { it == '-' }) + val keyId = userPassKey.name.substring( + startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, + endIndex = userPassKey.name.indexOfLast { it == '-' } + ) return Xor.First(keyId) } From 610439f9c7fe01ff75127dfcc54431c231a3bcf7 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 16:41:35 +0200 Subject: [PATCH 02/14] chore: Rename parameter name for clarity --- .../kotlin/internal/managers/AuthenticatorManager.kt | 4 ++-- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index d919bec2..72f6b2bb 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-") }.firstOrNull() ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 8167a464..2de78777 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -65,7 +65,7 @@ internal class MigrationManager( val token = authenticatorManager.getToken( clientId = clientId, userId = account.id, - keyIdFromOldPasskey = keyId, + keyIdOrDefault = keyId, ).firstOrNull()!! // Register a new passkey val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) @@ -73,7 +73,7 @@ internal class MigrationManager( val tokenWithNewPassKey = authenticatorManager.getToken( clientId = clientId, userId = account.id, - keyIdFromOldPasskey = newKeyId, + keyIdOrDefault = newKeyId, ).firstOrNull()!! persistToken(account.id, tokenWithNewPassKey.accessToken) dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) From eb9e0365aa9f1fd8dcf5254ae880667219c2d136 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:11:39 +0200 Subject: [PATCH 03/14] chore: Add optional userId to KeyNotFound --- multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt | 2 +- .../kotlin/internal/managers/AuthenticatorManager.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 72f6b2bb..d5cc1d6b 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -74,8 +74,9 @@ internal class AuthenticatorManager( userId: Long, keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() - ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrElse { + return Xor.Second(it.copy(userId = userId)) + } val authenticationOptions = webAuthnRepository.challenge(clientId) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() From 709b0f1df7fb1cea8b7757edf3bf9e130d2ecdcc Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:16:08 +0200 Subject: [PATCH 04/14] chore: Remove unused returned value warning --- .../src/commonMain/kotlin/internal/managers/MigrationManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 2de78777..5ee54891 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -142,7 +142,7 @@ internal class MigrationManager( } authenticatorManager.deleteKeysFor(userId) - authenticatorManager.registerPasskey( + val _ = authenticatorManager.registerPasskey( token = temporaryToken.accessToken, userId = userId ) From ecc1b3117fdfaee4de19f1d7f0fa5a7b85b45583 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:21:28 +0200 Subject: [PATCH 05/14] chore: Replace NPEs with ISEs with error messages --- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 5ee54891..5cf25586 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -66,7 +66,7 @@ internal class MigrationManager( clientId = clientId, userId = account.id, keyIdOrDefault = keyId, - ).firstOrNull()!! + ).firstOrElse { error(it) } // Register a new passkey val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) // Getting a new token with the new passkey @@ -74,7 +74,7 @@ internal class MigrationManager( clientId = clientId, userId = account.id, keyIdOrDefault = newKeyId, - ).firstOrNull()!! + ).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 From 026aac83bd9fbff373c8ea6c1a28c6d914098fad Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:35:56 +0200 Subject: [PATCH 06/14] chore: Replace unnecessary Xor wrapping with nullable String --- .../internal/KeyPairManagerImpl.android.kt | 6 ++-- .../internal/KeyPairManagerImpl.apple.kt | 30 +++++++++---------- .../commonMain/kotlin/internal/KeyManager.kt | 2 +- .../kotlin/internal/KeyPairManagerImpl.kt | 2 +- .../internal/managers/AuthenticatorManager.kt | 9 +++--- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index f61d97bf..9641472b 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -57,18 +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( startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, endIndex = userPassKey.name.indexOfLast { it == '-' } ) - return Xor.First(keyId) + return keyId } actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 4a775e7e..e79f802d 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -126,27 +126,27 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor = - memScoped { - //TODO[ik-auth]: Test this code somehow. - val (resultsArray, count) = getAllPrivateKeysQuery() + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = 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())) + 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) - } + if (tag != null && predicate(tag)) { + 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 = memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() 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/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index d5cc1d6b..aef5e36c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -74,9 +74,8 @@ internal class AuthenticatorManager( userId: Long, keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrElse { - return Xor.Second(it.copy(userId = userId)) - } + 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) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() @@ -125,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 @@ -141,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-") } } } From 0102fddd2de64818949e9839f1acf2c4f67942b1 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 18:09:14 +0200 Subject: [PATCH 07/14] chore: Make FileUtils contents internal --- .../kotlin/{ => internal}/utils/FileUtils.android.kt | 6 +++--- .../kotlin/{ => internal}/utils/FileUtils.apple.kt | 6 +++--- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 ++-- .../src/commonMain/kotlin/{ => internal}/utils/FileUtils.kt | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) rename multiplatform-lib/src/androidMain/kotlin/{ => internal}/utils/FileUtils.android.kt (83%) rename multiplatform-lib/src/appleMain/kotlin/{ => internal}/utils/FileUtils.apple.kt (92%) rename multiplatform-lib/src/commonMain/kotlin/{ => internal}/utils/FileUtils.kt (80%) 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 83% 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..5b982aeb 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,18 @@ * 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) { +internal actual suspend fun createFile(name: String, content: String) { File(appCtx.filesDir, name).apply { createNewFile() writeText(content) 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 92% 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..b44c4b30 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 createFile(name: String, content: String) { val path = "${getApplicationSupportDirectory()}/$name" NSFileManager.defaultManager.createFileAtPath( path = path, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 5cf25586..7cc77010 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -34,8 +34,8 @@ 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.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createFile import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException diff --git a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt similarity index 80% rename from multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt rename to multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 52bde963..bb5cfc14 100644 --- a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -15,8 +15,8 @@ * 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) +internal expect suspend fun createFile(name: String, content: String) -expect suspend fun checkFileExists(name: String): Boolean +internal expect suspend fun checkFileExists(name: String): Boolean From b65972fd8f673c89f1ecdc5597dbc7b9b1e7c651 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:16:29 +0200 Subject: [PATCH 08/14] chore: Rename createFile to createBackupExcludedFile --- .../androidMain/kotlin/internal/utils/FileUtils.android.kt | 6 +++++- .../src/appleMain/kotlin/internal/utils/FileUtils.apple.kt | 2 +- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 6 +++--- .../src/commonMain/kotlin/internal/utils/FileUtils.kt | 6 +++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt index 5b982aeb..2267b783 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt @@ -26,7 +26,11 @@ internal actual suspend fun checkFileExists(name: String): Boolean = Dispatchers File(appCtx.filesDir, name).exists() } -internal 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/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt index b44c4b30..6e986016 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt @@ -32,7 +32,7 @@ internal actual suspend fun checkFileExists(name: String): Boolean { } @OptIn(ExperimentalForeignApi::class) -internal 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/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 7cc77010..0ff3163a 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -32,10 +32,10 @@ import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import com.infomaniak.auth.lib.models.migration.ApiToken import com.infomaniak.auth.lib.network.exceptions.ApiException -import com.infomaniak.auth.lib.internal.utils.checkFileExists -import com.infomaniak.auth.lib.internal.utils.createFile import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException @@ -190,7 +190,7 @@ internal class MigrationManager( } suspend fun createAccountInitializationFile() { - createFile( + createBackupExcludedFile( name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(), content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(), ) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index bb5cfc14..12081bcb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -17,6 +17,10 @@ */ package com.infomaniak.auth.lib.internal.utils -internal expect 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 expect suspend fun createBackupExcludedFile(name: String, content: String) internal expect suspend fun checkFileExists(name: String): Boolean From 31d785d55fb372cffdf8c8b56ef7e16714ee77f7 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:17:39 +0200 Subject: [PATCH 09/14] refactor: Introduce RestoreFromBackupDetector --- .../internal/RestoreFromBackupDetector.kt | 48 +++++++++++++++++++ .../internal/managers/MigrationManager.kt | 22 +-------- 2 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt 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..ac1159ac --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -0,0 +1,48 @@ +/* + * 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.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() { + 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/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 0ff3163a..f54138bf 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 @@ -32,8 +33,6 @@ import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository -import com.infomaniak.auth.lib.internal.utils.checkFileExists -import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import com.infomaniak.auth.lib.models.migration.ApiToken import com.infomaniak.auth.lib.network.exceptions.ApiException import com.osmerion.kotlin.io.encoding.Base32 @@ -53,9 +52,8 @@ internal class MigrationManager( private val dao = accountsDatabase.getDao() suspend fun setBackedUpAccountsStatus() { - if (!doesAccountInitializationFileExist()) { + RestoreFromBackupDetector.runRestoreOperationIfNeeded { dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup) - createAccountInitializationFile() } } @@ -180,20 +178,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() { - createBackupExcludedFile( - name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(), - content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(), - ) - } - } } From 51ac4f6824532ed98d84ac19c00653aa4ff90bbc Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:17:58 +0200 Subject: [PATCH 10/14] chore: Put arguments on separate lines --- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index f54138bf..9a030b4e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -53,7 +53,10 @@ internal class MigrationManager( suspend fun setBackedUpAccountsStatus() { RestoreFromBackupDetector.runRestoreOperationIfNeeded { - dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup) + dao.updateStatus( + currentStatus = AccountEntity.Status.LoggedIn, + newStatus = AccountEntity.Status.RestoringFromBackup + ) } } From a169305ca01833e975c21b33a9183ff789c322b8 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:18:53 +0200 Subject: [PATCH 11/14] fix: Handle exceptions and retries for restoration from backup Otherwise, the app will crash after restoring if there's a network or other kind of communication issue. --- .../kotlin/internal/AuthenticatorFacadeImpl.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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, From d3b6abde9db43083f42d8d39a56ed46937475d1d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 18:09:47 +0200 Subject: [PATCH 12/14] perf: Use Dispatchers.IO for keychain operations --- .../internal/KeyPairManagerImpl.apple.kt | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index e79f802d..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): String? = memScoped { - //TODO[ik-auth]: Test this code somehow. - val (resultsArray, count) = getAllPrivateKeysQuery() - - if (resultsArray == null || count == 0) return@memScoped null + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { + memScoped { + //TODO[ik-auth]: Test this code somehow. + val (resultsArray, count) = getAllPrivateKeysQuery() - for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + if (resultsArray == null || count == 0) return@memScoped null - if (tag != null && predicate(tag)) { - val keyId = tag.substring( - startIndex = tag.indexOfFirst { it == '-' } + 1, - endIndex = tag.indexOfLast { it == '-' } - ) - return@memScoped keyId + for (i in 0 until count) { + val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + + if (tag != null && predicate(tag)) { + val keyId = tag.substring( + startIndex = tag.indexOfFirst { it == '-' } + 1, + endIndex = tag.indexOfLast { it == '-' } + ) + return@memScoped keyId + } } - } - return@memScoped null + 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 { From f91aa50654725b15b3d1860da1b4cf093d33a728 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 28 Apr 2026 09:12:35 +0200 Subject: [PATCH 13/14] chore: Add BackupExclusionGotcha opt-in requirement annotation --- .../commonMain/kotlin/internal/RestoreFromBackupDetector.kt | 2 ++ .../src/commonMain/kotlin/internal/utils/FileUtils.kt | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt index ac1159ac..225c57ee 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.auth.lib.internal +import com.infomaniak.auth.lib.internal.utils.BackupExclusionGotcha import com.infomaniak.auth.lib.internal.utils.checkFileExists import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import kotlin.random.Random @@ -37,6 +38,7 @@ internal object RestoreFromBackupDetector { } private suspend fun markRestorationAsHandled() { + @OptIn(BackupExclusionGotcha::class) createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 12081bcb..3f47f0f5 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -17,10 +17,15 @@ */ package com.infomaniak.auth.lib.internal.utils +@RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " + + "Backup rules or logic in a BackupAgent are required on Android.") +annotation class BackupExclusionGotcha + /** * **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. */ +@BackupExclusionGotcha internal expect suspend fun createBackupExcludedFile(name: String, content: String) internal expect suspend fun checkFileExists(name: String): Boolean From b4b8fbc5922c8acd7a227a080b503923b676f451 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 28 Apr 2026 09:23:47 +0200 Subject: [PATCH 14/14] chore: Rename BackupExclusionGotcha to BackupExclusionOnlyApplePlatforms --- .../commonMain/kotlin/internal/RestoreFromBackupDetector.kt | 4 ++-- .../src/commonMain/kotlin/internal/utils/FileUtils.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt index 225c57ee..7b5d0817 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -17,7 +17,7 @@ */ package com.infomaniak.auth.lib.internal -import com.infomaniak.auth.lib.internal.utils.BackupExclusionGotcha +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 @@ -38,7 +38,7 @@ internal object RestoreFromBackupDetector { } private suspend fun markRestorationAsHandled() { - @OptIn(BackupExclusionGotcha::class) + @OptIn(BackupExclusionOnlyApplePlatforms::class) createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 3f47f0f5..a63834d0 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -19,13 +19,13 @@ package com.infomaniak.auth.lib.internal.utils @RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " + "Backup rules or logic in a BackupAgent are required on Android.") -annotation class BackupExclusionGotcha +annotation class BackupExclusionOnlyApplePlatforms /** * **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. */ -@BackupExclusionGotcha +@BackupExclusionOnlyApplePlatforms internal expect suspend fun createBackupExcludedFile(name: String, content: String) internal expect suspend fun checkFileExists(name: String): Boolean