From faf764f8070c3f9adf7f315840e6c9b74311ec50 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 28 Apr 2026 17:45:20 +0200 Subject: [PATCH 01/12] chore: Add more extensions for Apple CoreFoundation types --- .../kotlin/internal/extensions/CFArrayRef.kt | 31 +++++++++++++++++++ .../extensions/CFMutableDictionaryRef.kt | 7 +++++ .../extensions/TollFreeBridgedTypes.kt | 4 +++ 3 files changed, 42 insertions(+) create mode 100644 multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt new file mode 100644 index 00000000..f8a9ec5d --- /dev/null +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt @@ -0,0 +1,31 @@ +/* + * 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.extensions + +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreFoundation.CFArrayGetCount +import platform.CoreFoundation.CFArrayGetValueAtIndex +import platform.CoreFoundation.CFArrayRef + +@ExperimentalForeignApi +@Suppress("unchecked_cast") +operator fun ?> CFArrayRef?.get(index: Long): T = CFArrayGetValueAtIndex(this, index) as T + +@ExperimentalForeignApi +internal val CFArrayRef?.size: Long inline get() = CFArrayGetCount(this) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt index 8596cdb3..c131e11a 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.auth.lib.internal.extensions +import kotlinx.cinterop.CPointer import kotlinx.cinterop.CValuesRef import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.cValuesOf @@ -25,10 +26,12 @@ import platform.CoreFoundation.CFDataRef import platform.CoreFoundation.CFDictionaryAddValue import platform.CoreFoundation.CFDictionaryCreateMutable import platform.CoreFoundation.CFDictionaryGetCount +import platform.CoreFoundation.CFDictionaryGetValue import platform.CoreFoundation.CFDictionaryRef import platform.CoreFoundation.CFIndex import platform.CoreFoundation.CFMutableDictionaryRef import platform.CoreFoundation.CFNumberCreate +import platform.CoreFoundation.CFStringRef import platform.CoreFoundation.kCFAllocatorDefault import platform.CoreFoundation.kCFBooleanFalse import platform.CoreFoundation.kCFBooleanTrue @@ -77,3 +80,7 @@ internal operator fun CFMutableDictionaryRef?.set(key: CValuesRef<*>?, value: NS @ExperimentalForeignApi internal val CFDictionaryRef?.size: Long inline get() = CFDictionaryGetCount(this) + +@Suppress("unchecked_cast") +@ExperimentalForeignApi +internal operator fun ?> CFDictionaryRef?.get(key: CFStringRef?): T = CFDictionaryGetValue(this, key) as T diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt index 4630e212..e9c19df6 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt @@ -21,10 +21,12 @@ package com.infomaniak.auth.lib.internal.extensions import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFDateRef import platform.CoreFoundation.CFErrorRef import platform.Foundation.CFBridgingRelease import platform.Foundation.CFBridgingRetain import platform.Foundation.NSData +import platform.Foundation.NSDate import platform.Foundation.NSError // The casts below are fine because they involve "toll-free bridged" types. @@ -36,4 +38,6 @@ internal fun NSData.toCFDataRef() = CFBridgingRetain(this) as CFDataRef internal fun CFDataRef.toNSData(): NSData = CFBridgingRelease(this) as NSData +internal fun CFDateRef.toNSDate(): NSDate = CFBridgingRelease(this) as NSDate + internal fun CFErrorRef.toNSError(): NSError = CFBridgingRelease(this) as NSError From f98aad05959749e710113cef072938a5cd2091a7 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 28 Apr 2026 17:49:28 +0200 Subject: [PATCH 02/12] fix: Make backup restoration restartable --- .../kotlin/internal/KeyPairManagerTest.kt | 7 +- .../internal/KeyPairManagerImpl.android.kt | 35 +++++-- .../internal/KeyPairManagerImpl.apple.kt | 62 ++++++++---- .../internal/AuthenticatorFacadeImpl.kt | 4 +- .../commonMain/kotlin/internal/KeyManager.kt | 5 + .../kotlin/internal/KeyPairManagerImpl.kt | 32 ------ .../kotlin/internal/db/AccountEntity.kt | 2 + .../internal/managers/AccountRestorer.kt | 98 +++++++++++++++++++ .../internal/managers/AuthenticatorManager.kt | 5 +- .../internal/managers/MigrationManager.kt | 28 ++---- .../repositories/WebAuthnRepository.kt | 10 +- 11 files changed, 199 insertions(+), 89 deletions(-) delete mode 100644 multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt create mode 100644 multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt diff --git a/multiplatform-lib/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt b/multiplatform-lib/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt index 205d1978..23c663cd 100644 --- a/multiplatform-lib/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt +++ b/multiplatform-lib/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt @@ -15,9 +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.internal +package com.infomaniak.auth.lib.internal -import com.infomaniak.auth.lib.internal.KeyPairManagerImpl import com.infomaniak.auth.lib.internal.utils.Xor import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -28,10 +27,10 @@ class KeyPairManagerTest { @Test fun testKeyPairManager() { - val keyPairManager = KeyPairManagerImpl() + val keyPairManager = KeyPairManager() runTest { - val userId = 12345 + val userId = 12345L val keyId = "keyId" val error = keyPairManager.generateNewKey(userId, keyId) assertNull(error) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index 9641472b..d62f9961 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -23,11 +23,15 @@ import kotlinx.coroutines.invoke import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.BasicFileAttributes -internal actual class KeyPairManagerImpl : KeyPairManager { +internal actual fun KeyPairManager(): KeyPairManager = KeyPairManagerAndroidImpl() + +private class KeyPairManagerAndroidImpl : KeyPairManager { @Throws(Exception::class) - actual override suspend fun generateNewKey(userId: Long, keyId: String): Failure.KeyManagement.GenerationFailed? { + override suspend fun generateNewKey(userId: Long, keyId: String): Failure.KeyManagement.GenerationFailed? { val keyPair = generateEcKeyPair().getOrElse { return Failure.KeyManagement.GenerationFailed(it.toString()) } @@ -37,7 +41,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return null } - actual override suspend fun retrievePublicKey( + override suspend fun retrievePublicKey( userId: Long, keyId: String, ): Xor = Dispatchers.IO { @@ -47,7 +51,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - actual override suspend fun retrievePrivateKey( + override suspend fun retrievePrivateKey( userId: Long, keyId: String, ): Xor = Dispatchers.IO { @@ -57,7 +61,26 @@ internal actual class KeyPairManagerImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? { + override suspend fun getSortedKeyIds(predicate: (name: String) -> Boolean): List { + val files = withContext(Dispatchers.IO) { + appCtx.filesDir.listFiles() + } ?: return emptyList() + return buildList { + for (file in files) { + val fileName = file.name + if (predicate(file.name)) { + val keyId = fileName.substring( + startIndex = fileName.indexOfFirst { it == '-' } + 1, + endIndex = fileName.indexOfLast { it == '-' } + ) + val attrs = Dispatchers.IO { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) } + add(keyId to attrs.creationTime()) + } + } + }.sortedBy { (_, creationTime) -> creationTime }.map { (keyId, _) -> keyId } + } + + override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? { val userPassKey: File = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.find { @@ -71,7 +94,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return keyId } - actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { + override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { val keys = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.filter { diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index d5159398..710fa5a3 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -20,9 +20,12 @@ package com.infomaniak.auth.lib.internal import com.infomaniak.auth.lib.internal.extensions.buildCFDictionary +import com.infomaniak.auth.lib.internal.extensions.get import com.infomaniak.auth.lib.internal.extensions.set +import com.infomaniak.auth.lib.internal.extensions.size import com.infomaniak.auth.lib.internal.extensions.toByteArray import com.infomaniak.auth.lib.internal.extensions.toNSData +import com.infomaniak.auth.lib.internal.extensions.toNSDate import com.infomaniak.auth.lib.internal.extensions.toNsData import com.infomaniak.auth.lib.internal.extensions.tryIt import com.infomaniak.auth.lib.internal.extensions.use @@ -37,15 +40,13 @@ import kotlinx.cinterop.value import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.invoke -import platform.CoreFoundation.CFArrayGetCount -import platform.CoreFoundation.CFArrayGetValueAtIndex import platform.CoreFoundation.CFArrayRef import platform.CoreFoundation.CFDataRef -import platform.CoreFoundation.CFDictionaryGetValue +import platform.CoreFoundation.CFDateRef import platform.CoreFoundation.CFDictionaryRef import platform.CoreFoundation.CFRelease -import platform.CoreFoundation.CFTypeRef import platform.CoreFoundation.CFTypeRefVar +import platform.Foundation.timeIntervalSince1970 import platform.Security.SecItemCopyMatching import platform.Security.SecItemDelete import platform.Security.SecKeyCopyExternalRepresentation @@ -53,6 +54,7 @@ import platform.Security.SecKeyCopyPublicKey import platform.Security.SecKeyRef import platform.Security.errSecSuccess import platform.Security.kSecAttrApplicationTag +import platform.Security.kSecAttrCreationDate import platform.Security.kSecAttrKeyClass import platform.Security.kSecAttrKeyClassPrivate import platform.Security.kSecAttrKeyType @@ -64,9 +66,11 @@ import platform.Security.kSecMatchLimitAll import platform.Security.kSecReturnAttributes import platform.Security.kSecReturnRef -internal actual class KeyPairManagerImpl : KeyPairManager { +internal actual fun KeyPairManager(): KeyPairManager = KeyPairManagerAppleImpl() - actual override suspend fun generateNewKey( +private class KeyPairManagerAppleImpl : KeyPairManager { + + override suspend fun generateNewKey( userId: Long, keyId: String, ): Failure.KeyManagement.GenerationFailed? = Dispatchers.IO { @@ -85,7 +89,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(ExperimentalForeignApi::class) - actual override suspend fun retrievePublicKey( + override suspend fun retrievePublicKey( userId: Long, keyId: String, ): Xor = Dispatchers.IO { @@ -107,7 +111,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } } - actual override suspend fun retrievePrivateKey( + override suspend fun retrievePrivateKey( userId: Long, keyId: String ): Xor { @@ -125,18 +129,40 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } } + override suspend fun getSortedKeyIds(predicate: (name: String) -> Boolean): List = Dispatchers.IO { + memScoped { + val (resultsArray, count) = getAllPrivateKeysQuery() + if (resultsArray == null || count == 0) return@memScoped emptyList() + + buildList { + for (i in 0 until count) { + val item: CFDictionaryRef = resultsArray[i.toLong()] + val tag = extractTagFromItem(item) ?: continue + val dateRef: CFDateRef = item[kSecAttrCreationDate] + + if (predicate(tag)) { + val keyId = tag.substring( + startIndex = tag.indexOfFirst { it == '-' } + 1, + endIndex = tag.indexOfLast { it == '-' } + ) + add(keyId to dateRef.toNSDate()) + } + } + }.sortedBy { (_, date) -> date.timeIntervalSince1970 }.map { (keyId, _) -> keyId } + } + } + @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { + 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 null for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + val tag = extractTagFromItem(resultsArray[i.toLong()]) ?: continue - if (tag != null && predicate(tag)) { + if (predicate(tag)) { val keyId = tag.substring( startIndex = tag.indexOfFirst { it == '-' } + 1, endIndex = tag.indexOfLast { it == '-' } @@ -149,7 +175,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } } - actual override suspend fun deleteKeysMatching( + override suspend fun deleteKeysMatching( predicate: (name: String) -> Boolean ): Xor = Dispatchers.IO { memScoped { @@ -161,7 +187,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { var hasDeletedAtLeastOneKey = false for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) ?: continue + val tag = extractTagFromItem(resultsArray[i.toLong()]) ?: continue if (predicate(tag)) { deleteKeyByTag(tag) @@ -194,16 +220,14 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return if (status == errSecSuccess && resultRef.value != null) { @Suppress("unchecked_cast") val resultsArray = resultRef.value as CFArrayRef - Pair(resultsArray, CFArrayGetCount(resultsArray).toInt()) + Pair(resultsArray, resultsArray.size.toInt()) } else { Pair(null, 0) } } - @OptIn(BetaInteropApi::class) - private fun extractTagFromItem(item: CFTypeRef?): String? { - @Suppress("unchecked_cast") - val tagData = CFDictionaryGetValue(item as CFDictionaryRef, kSecAttrApplicationTag) as? CFDataRef ?: return null + private fun extractTagFromItem(item: CFDictionaryRef?): String? { + val tagData: CFDataRef = item[kSecAttrApplicationTag] ?: return null return tagData.toNSData().toByteArray().decodeToString() } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index df1341b1..5499ce8d 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -219,7 +219,7 @@ internal class AuthenticatorFacadeImpl( AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending -> { registrationAttempts(entity) } - AccountEntity.Status.RestoringFromBackup -> { + AccountEntity.Status.RestoringFromBackup, AccountEntity.Status.DeletingOldKeyAfterRestoration -> { restoreFromBackupAttempts(account = entity) } AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. @@ -252,6 +252,7 @@ internal class AuthenticatorFacadeImpl( authenticatorManager.deleteKeysFor(notRegisteredAccount.id) val _ = authenticatorManager.registerPasskey(token, userId) dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.FirstPasskeyAuthenticationPending)) + // The DB update above is expected to cause the cancellation & restart of this. } val token = authenticatorManager.getToken( clientId = clientId, @@ -372,7 +373,6 @@ internal class AuthenticatorFacadeImpl( } } - private suspend inline fun FlowCollector.withRetries( userId: Long, onGiveUp: () -> Unit = {}, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt index a4c5449e..bfd77785 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -19,6 +19,8 @@ package com.infomaniak.auth.lib.internal import com.infomaniak.auth.lib.internal.utils.Xor +internal expect fun KeyPairManager(): KeyPairManager + internal interface KeyPairManager { /** @@ -31,6 +33,9 @@ internal interface KeyPairManager { suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor + /** Sorted by creation date. */ + suspend fun getSortedKeyIds(predicate: (name: String) -> Boolean): List + 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 deleted file mode 100644 index 8dae8a0c..00000000 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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.Xor - -internal expect class KeyPairManagerImpl() : KeyPairManager { - override suspend fun generateNewKey(userId: Long, keyId: String): Failure.KeyManagement.GenerationFailed? - override suspend fun retrievePublicKey(userId: Long, keyId: String): Xor - override suspend fun retrievePrivateKey( - userId: Long, - keyId: String - ): 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/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index d84c1177..9d417017 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -56,5 +56,7 @@ internal data class AccountEntity( LoggedIn, RestoringFromBackup, + + DeletingOldKeyAfterRestoration, } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt new file mode 100644 index 00000000..865e818d --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt @@ -0,0 +1,98 @@ +/* + * 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.managers + +import com.infomaniak.auth.lib.internal.db.AccountEntity +import com.infomaniak.auth.lib.internal.db.AccountsDatabase +import com.infomaniak.auth.lib.internal.extensions.firstOrElse +import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.internal.KeyPairManager.Filters as KeyFilters + +internal class AccountRestorer( + accountsDatabase: AccountsDatabase, + private val authenticatorManager: AuthenticatorManager, + private val webAuthnRepository: WebAuthnRepository, + private val clientId: String, +) { + + private val dao = accountsDatabase.getDao() + + suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { + val keyPairManager = authenticatorManager.keyPairManager + val existingKeyIds = keyPairManager.getSortedKeyIds(KeyFilters.forUserId(account.id)) + checkKeyCountIsOneOrTwo(existingKeyIds) + val needsToCreateNewKey = !account.hasNewKeyAlreadyBeenRegistered() + val oldKeyId: String? + val newKeyId: String + if (needsToCreateNewKey) { + oldKeyId = existingKeyIds.first() + val tokenFromOldPasskey = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdOrDefault = oldKeyId, + ).firstOrElse { error(it) } + + val previousRestorationAborted = existingKeyIds.size == 2 + if (previousRestorationAborted) { + val newKeyIdToDrop = existingKeyIds.last() + webAuthnRepository.deletePasskeyIfExists(tokenFromOldPasskey.accessToken, newKeyIdToDrop) + val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(newKeyIdToDrop)) + } + // Register a new passkey + newKeyId = authenticatorManager.registerPasskey(tokenFromOldPasskey.accessToken, account.id) + dao.upsert(account.copy(status = AccountEntity.Status.DeletingOldKeyAfterRestoration)) + // The DB update above is expected to cause the cancellation & restart of this. It's handled by the else case below. + } else { + val stillHasOldKey = existingKeyIds.size == 2 + oldKeyId = if (stillHasOldKey) existingKeyIds.first() else null + newKeyId = existingKeyIds.last() + } + // Getting a new token with the new passkey + val tokenWithNewPassKey = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdOrDefault = newKeyId, + ).firstOrElse { error(it) } + persistToken(account.id, tokenWithNewPassKey.accessToken) + // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore + oldKeyId?.let { + webAuthnRepository.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, it) + val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(it)) + } + dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) + } + + private fun AccountEntity.hasNewKeyAlreadyBeenRegistered() = when (status) { + AccountEntity.Status.RestoringFromBackup -> false + AccountEntity.Status.DeletingOldKeyAfterRestoration -> true + AccountEntity.Status.ToBeMigrated, + AccountEntity.Status.PasskeyRegistrationPending, + AccountEntity.Status.FirstPasskeyAuthenticationPending, + AccountEntity.Status.LoggedIn -> error("Unexpected status for restoration: $status") + } + + private fun checkKeyCountIsOneOrTwo(existingKeyIds: List) { + val keyCount = existingKeyIds.size + check(keyCount in 1..2) { + when (keyCount) { + 0 -> "No key found to restore with." + else -> "We should never have more than 2 keys during restoration, yet, $keyCount keys were found." + } + } + } +} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 58f4da9e..830f186f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -20,7 +20,6 @@ package com.infomaniak.auth.lib.internal.managers import com.infomaniak.auth.lib.internal.CryptoObjectsBuilder import com.infomaniak.auth.lib.internal.Failure import com.infomaniak.auth.lib.internal.KeyPairManager -import com.infomaniak.auth.lib.internal.KeyPairManagerImpl import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.models.ClientExtensionResults import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData @@ -41,7 +40,7 @@ internal class AuthenticatorManager( ) { private val cryptoObjectsBuilder by lazy { CryptoObjectsBuilder() } - val keyPairManager: KeyPairManager by lazy { KeyPairManagerImpl() } + val keyPairManager: KeyPairManager by lazy { KeyPairManager() } private val base64NoPadding get() = cryptoObjectsBuilder.base64UrlSafeNoPadding @@ -130,7 +129,7 @@ internal class AuthenticatorManager( if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it - webAuthnRepository.deletePasskey(token, passkeyId) + webAuthnRepository.deletePasskeyIfExists(token, passkeyId) val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(passkeyId)) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 7d2641d4..d01867a2 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -41,7 +41,6 @@ import kotlinx.io.IOException import org.kotlincrypto.macs.hmac.sha2.HmacSHA256 import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -import com.infomaniak.auth.lib.internal.KeyPairManager.Filters as keyFilters internal class MigrationManager( private val accountsDatabase: AccountsDatabase, @@ -62,26 +61,13 @@ internal class MigrationManager( } suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { - val oldKeyId = authenticatorManager.getKeyIdFor(account.id) ?: return //TODO: Handle multiple passkeys present. - // Get token with previous passkey - val tokenFromOldPasskey = authenticatorManager.getToken( - clientId = clientId, - userId = account.id, - keyIdOrDefault = oldKeyId, - ).firstOrElse { error(it) } - // Register a new passkey - val newKeyId = authenticatorManager.registerPasskey(tokenFromOldPasskey.accessToken, account.id) - // Getting a new token with the new passkey - val tokenWithNewPassKey = authenticatorManager.getToken( - clientId = clientId, - userId = account.id, - keyIdOrDefault = newKeyId, - ).firstOrElse { error(it) } - persistToken(account.id, tokenWithNewPassKey.accessToken) - // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore - webAuthnRepository.deletePasskey(tokenWithNewPassKey.accessToken, oldKeyId) - val _ = authenticatorManager.keyPairManager.deleteKeysMatching(keyFilters.forPasskeyId(oldKeyId)) - dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) + val restorer = AccountRestorer( + accountsDatabase = accountsDatabase, + authenticatorManager = authenticatorManager, + webAuthnRepository = webAuthnRepository, + clientId = clientId + ) + restorer.restore(account, persistToken) } suspend fun addLegacyAccountsToDB() { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt index 1dab7947..f2d2779f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt @@ -26,6 +26,7 @@ import com.infomaniak.auth.lib.internal.models.RegisterPasskey import com.infomaniak.auth.lib.internal.models.SuccessfulApiResponse import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequest +import com.infomaniak.auth.lib.network.exceptions.ApiException internal class WebAuthnRepository( private val authenticatorRequest: AuthenticatorRequest, @@ -44,8 +45,13 @@ internal class WebAuthnRepository( } // Deletion of existing passkey (authentified) - suspend fun deletePasskey(token: String, passkeyId: String) { - authenticatorRequest.deletePasskey(token, passkeyId) + suspend fun deletePasskeyIfExists(token: String, passkeyId: String) { + try { + authenticatorRequest.deletePasskey(token, passkeyId) + } catch (e: ApiException) { + if (e.statusCode == 404) return + throw e + } } // Authentification challenge (not authentified) From a514a2d0b523251e363515ce4b53218f7bdf1f82 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 09:05:21 +0200 Subject: [PATCH 03/12] fix: Fix test compilation --- .../src/androidDeviceTest/kotlin/WebAuthnTest.kt | 4 ++-- .../src/appleTest/kotlin/internal/KeyPairManagerTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/androidDeviceTest/kotlin/WebAuthnTest.kt b/multiplatform-lib/src/androidDeviceTest/kotlin/WebAuthnTest.kt index ae35c9dd..1c3da8bd 100644 --- a/multiplatform-lib/src/androidDeviceTest/kotlin/WebAuthnTest.kt +++ b/multiplatform-lib/src/androidDeviceTest/kotlin/WebAuthnTest.kt @@ -18,7 +18,7 @@ package com.infomaniak.auth.lib import com.infomaniak.auth.lib.internal.CryptoObjectsBuilder -import com.infomaniak.auth.lib.internal.KeyPairManagerImpl +import com.infomaniak.auth.lib.internal.KeyPairManager import com.infomaniak.auth.lib.internal.models.PasskeysOptions import com.infomaniak.auth.lib.internal.models.PubKeyCredParam import com.infomaniak.auth.lib.internal.models.RelyingParty @@ -59,7 +59,7 @@ class WebAuthnTest { // Just getting the public key to generate RegisterPasskey object val cryptoObjectsBuilder = CryptoObjectsBuilder() - val keyPairManager = KeyPairManagerImpl() + val keyPairManager = KeyPairManager() val userId = 12345L val keyIdAsByteArray = cryptoObjectsBuilder.getKeyIds().first val keyIdAsString = cryptoObjectsBuilder.getKeyIds().second diff --git a/multiplatform-lib/src/appleTest/kotlin/internal/KeyPairManagerTest.kt b/multiplatform-lib/src/appleTest/kotlin/internal/KeyPairManagerTest.kt index bdc6ef12..d8b70417 100644 --- a/multiplatform-lib/src/appleTest/kotlin/internal/KeyPairManagerTest.kt +++ b/multiplatform-lib/src/appleTest/kotlin/internal/KeyPairManagerTest.kt @@ -28,7 +28,7 @@ class KeyPairManagerTest { @Test fun testKeyPairManager() { - val keyPairManager = KeyPairManagerImpl() + val keyPairManager = KeyPairManager() runTest { val userId = 12345L From 417a61ca700a20335aea89ac6fbeb43d02e7cfde Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 11:28:55 +0200 Subject: [PATCH 04/12] refactor: Fix key retrieval by passkey id on iOS --- .../internal/KeyPairManagerImpl.android.kt | 32 +++++++------- .../internal/KeyPairManagerImpl.apple.kt | 37 +++++++--------- .../commonMain/kotlin/internal/KeyManager.kt | 43 +++++++++++++------ .../internal/managers/AccountRestorer.kt | 12 +++--- .../internal/managers/AuthenticatorManager.kt | 14 +++--- 5 files changed, 73 insertions(+), 65 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index d62f9961..a878dfb9 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -26,9 +26,9 @@ import java.io.File import java.nio.file.Files import java.nio.file.attribute.BasicFileAttributes -internal actual fun KeyPairManager(): KeyPairManager = KeyPairManagerAndroidImpl() +internal actual fun createKeyPairManager(): KeyPairManager = KeyPairManagerAndroidImpl() -private class KeyPairManagerAndroidImpl : KeyPairManager { +private class KeyPairManagerAndroidImpl : KeyPairManager() { @Throws(Exception::class) override suspend fun generateNewKey(userId: Long, keyId: String): Failure.KeyManagement.GenerationFailed? { @@ -61,40 +61,35 @@ private class KeyPairManagerAndroidImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - override suspend fun getSortedKeyIds(predicate: (name: String) -> Boolean): List { + override suspend fun getSortedKeyIds(matchOn: MatchOn): List { val files = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() } ?: return emptyList() return buildList { + val predicate = matchOn.asFilterPredicate() for (file in files) { val fileName = file.name if (predicate(file.name)) { - val keyId = fileName.substring( - startIndex = fileName.indexOfFirst { it == '-' } + 1, - endIndex = fileName.indexOfLast { it == '-' } - ) val attrs = Dispatchers.IO { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) } - add(keyId to attrs.creationTime()) + add(extractKeyIdFromFileName(fileName) to attrs.creationTime()) } } }.sortedBy { (_, creationTime) -> creationTime }.map { (keyId, _) -> keyId } } - override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? { + override suspend fun findKeyIdFor(matchOn: MatchOn): String? { + val predicate = matchOn.asFilterPredicate() val userPassKey: File = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.find { predicate(it.name) } ?: return null - val keyId = userPassKey.name.substring( - startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, - endIndex = userPassKey.name.indexOfLast { it == '-' } - ) - return keyId + return extractKeyIdFromFileName(userPassKey.name) } - override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { + override suspend fun deleteKeysMatching(matchOn: MatchOn): Xor { + val predicate = matchOn.asFilterPredicate() val keys = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.filter { @@ -106,6 +101,13 @@ private class KeyPairManagerAndroidImpl : KeyPairManager { return Xor.First(Unit) } + override fun MatchOn.PasskeyId.asFilterPredicate() = { name: String -> "-$id-" in name } + + private fun extractKeyIdFromFileName(name: String): String = name.substring( + startIndex = name.indexOfFirst { it == '-' } + 1, + endIndex = name.indexOfLast { it == '-' } + ) + private suspend fun saveFileToFilesDir(fileName: String, key: ByteArray) = Dispatchers.IO { val file = File(appCtx.filesDir, fileName) file.writeBytes(key) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 710fa5a3..70cbc8e1 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -66,9 +66,9 @@ import platform.Security.kSecMatchLimitAll import platform.Security.kSecReturnAttributes import platform.Security.kSecReturnRef -internal actual fun KeyPairManager(): KeyPairManager = KeyPairManagerAppleImpl() +internal actual fun createKeyPairManager(): KeyPairManager = KeyPairManagerAppleImpl() -private class KeyPairManagerAppleImpl : KeyPairManager { +private class KeyPairManagerAppleImpl : KeyPairManager() { override suspend fun generateNewKey( userId: Long, @@ -77,8 +77,8 @@ private class KeyPairManagerAppleImpl : KeyPairManager { val result = generateEcPrivateKeyInTheKeychain( tag = "$userId-$keyId", - privateKeyPurposes = KeyPairManager.privateKeyPurposes, - publicKeyPurposes = KeyPairManager.publicKeyPurposes, + privateKeyPurposes = KeyPurposes.privateKeyDefaults, + publicKeyPurposes = KeyPurposes.publicKeyDefaults, keyAccessGuard = KeyAccessGuard.Unguarded, accessibility = KeyAccessibility.AfterFirstUnlock.ThisDeviceOnly, ) @@ -129,10 +129,11 @@ private class KeyPairManagerAppleImpl : KeyPairManager { } } - override suspend fun getSortedKeyIds(predicate: (name: String) -> Boolean): List = Dispatchers.IO { + override suspend fun getSortedKeyIds(matchOn: MatchOn): List = Dispatchers.IO { memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() if (resultsArray == null || count == 0) return@memScoped emptyList() + val predicate = matchOn.asFilterPredicate() buildList { for (i in 0 until count) { @@ -141,11 +142,7 @@ private class KeyPairManagerAppleImpl : KeyPairManager { val dateRef: CFDateRef = item[kSecAttrCreationDate] if (predicate(tag)) { - val keyId = tag.substring( - startIndex = tag.indexOfFirst { it == '-' } + 1, - endIndex = tag.indexOfLast { it == '-' } - ) - add(keyId to dateRef.toNSDate()) + add(extractKeyIdFromTag(tag) to dateRef.toNSDate()) } } }.sortedBy { (_, date) -> date.timeIntervalSince1970 }.map { (keyId, _) -> keyId } @@ -153,37 +150,31 @@ private class KeyPairManagerAppleImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { + override suspend fun findKeyIdFor(matchOn: MatchOn): String? = Dispatchers.IO { memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() if (resultsArray == null || count == 0) return@memScoped null + val predicate = matchOn.asFilterPredicate() for (i in 0 until count) { val tag = extractTagFromItem(resultsArray[i.toLong()]) ?: continue - if (predicate(tag)) { - val keyId = tag.substring( - startIndex = tag.indexOfFirst { it == '-' } + 1, - endIndex = tag.indexOfLast { it == '-' } - ) - return@memScoped keyId - } + if (predicate(tag)) return@memScoped extractKeyIdFromTag(tag) } return@memScoped null } } - override suspend fun deleteKeysMatching( - predicate: (name: String) -> Boolean - ): Xor = Dispatchers.IO { + override suspend fun deleteKeysMatching(matchOn: MatchOn): Xor = Dispatchers.IO { memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() if (resultsArray == null || count == 0) { return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) } + val predicate = matchOn.asFilterPredicate() var hasDeletedAtLeastOneKey = false for (i in 0 until count) { @@ -203,6 +194,10 @@ private class KeyPairManagerAppleImpl : KeyPairManager { } } + override fun MatchOn.PasskeyId.asFilterPredicate() = { name: String -> name.endsWith(id) } + + private fun extractKeyIdFromTag(tag: String): String = tag.substring(startIndex = tag.indexOfFirst { it == '-' } + 1) + @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) private fun MemScope.getAllPrivateKeysQuery(): Pair { val query = buildCFDictionary { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt index bfd77785..6ca729eb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -17,36 +17,51 @@ */ package com.infomaniak.auth.lib.internal +import com.infomaniak.auth.lib.internal.Failure.KeyManagement.GenerationFailed +import com.infomaniak.auth.lib.internal.Failure.KeyManagement.KeyExtractionFailed +import com.infomaniak.auth.lib.internal.Failure.KeyManagement.KeyNotFound import com.infomaniak.auth.lib.internal.utils.Xor -internal expect fun KeyPairManager(): KeyPairManager +internal expect fun createKeyPairManager(): KeyPairManager -internal interface KeyPairManager { +internal abstract class KeyPairManager protected constructor() { + + companion object { + operator fun invoke(): KeyPairManager = createKeyPairManager() + } /** * Generates key pair for a new registration * (migrating from kAuth v1 or a backup, or a fresh new login) */ - suspend fun generateNewKey(userId: Long, keyId: String): Failure.KeyManagement.GenerationFailed? + abstract suspend fun generateNewKey(userId: Long, keyId: String): GenerationFailed? - suspend fun retrievePublicKey(userId: Long, keyId: String): Xor + abstract suspend fun retrievePublicKey(userId: Long, keyId: String): Xor - suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor + abstract suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor /** Sorted by creation date. */ - suspend fun getSortedKeyIds(predicate: (name: String) -> Boolean): List + abstract suspend fun getSortedKeyIds(matchOn: MatchOn): List - suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? + abstract suspend fun findKeyIdFor(matchOn: MatchOn): String? - suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor + abstract suspend fun deleteKeysMatching(matchOn: MatchOn): Xor - companion object { - val privateKeyPurposes = KeyPurposes.privateKeyDefaults - val publicKeyPurposes = KeyPurposes.publicKeyDefaults + sealed interface MatchOn { + class UserId(val id: Long) : MatchOn + class PasskeyId(val id: String) : MatchOn } - object Filters { - fun forUserId(userId: Long): (name: String) -> Boolean = { it.startsWith("$userId-") } - fun forPasskeyId(passkeyId: String): (name: String) -> Boolean = { "-$passkeyId-" in it } + //region MatchOn to predicates + protected fun MatchOn.asFilterPredicate(): (name: String) -> Boolean = when (this) { + is MatchOn.PasskeyId -> asFilterPredicate() + is MatchOn.UserId -> asFilterPredicate() } + + private fun MatchOn.UserId.asFilterPredicate() = { name: String -> + name.startsWith("$id-") // Same on both platforms. + } + + protected abstract fun MatchOn.PasskeyId.asFilterPredicate(): (name: String) -> Boolean // Platform dependent. + //endregion } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt index 865e818d..a1b890a3 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt @@ -17,11 +17,11 @@ */ package com.infomaniak.auth.lib.internal.managers +import com.infomaniak.auth.lib.internal.KeyPairManager.MatchOn import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository -import com.infomaniak.auth.lib.internal.KeyPairManager.Filters as KeyFilters internal class AccountRestorer( accountsDatabase: AccountsDatabase, @@ -34,7 +34,7 @@ internal class AccountRestorer( suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { val keyPairManager = authenticatorManager.keyPairManager - val existingKeyIds = keyPairManager.getSortedKeyIds(KeyFilters.forUserId(account.id)) + val existingKeyIds = keyPairManager.getSortedKeyIds(MatchOn.UserId(account.id)) checkKeyCountIsOneOrTwo(existingKeyIds) val needsToCreateNewKey = !account.hasNewKeyAlreadyBeenRegistered() val oldKeyId: String? @@ -51,7 +51,7 @@ internal class AccountRestorer( if (previousRestorationAborted) { val newKeyIdToDrop = existingKeyIds.last() webAuthnRepository.deletePasskeyIfExists(tokenFromOldPasskey.accessToken, newKeyIdToDrop) - val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(newKeyIdToDrop)) + val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(newKeyIdToDrop)) } // Register a new passkey newKeyId = authenticatorManager.registerPasskey(tokenFromOldPasskey.accessToken, account.id) @@ -70,9 +70,9 @@ internal class AccountRestorer( ).firstOrElse { error(it) } persistToken(account.id, tokenWithNewPassKey.accessToken) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore - oldKeyId?.let { - webAuthnRepository.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, it) - val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(it)) + oldKeyId?.let { keyId -> + webAuthnRepository.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, keyId) + val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(keyId)) } dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 830f186f..f1218eea 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -20,6 +20,7 @@ package com.infomaniak.auth.lib.internal.managers import com.infomaniak.auth.lib.internal.CryptoObjectsBuilder import com.infomaniak.auth.lib.internal.Failure import com.infomaniak.auth.lib.internal.KeyPairManager +import com.infomaniak.auth.lib.internal.KeyPairManager.MatchOn import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.models.ClientExtensionResults import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData @@ -32,7 +33,6 @@ import com.infomaniak.auth.lib.models.migration.ApiToken import io.ktor.utils.io.core.toByteArray import kotlinx.serialization.json.Json import okio.ByteString.Companion.toByteString -import com.infomaniak.auth.lib.internal.KeyPairManager.Filters as KeyFilters internal class AuthenticatorManager( private val webAuthnRepository: WebAuthnRepository, @@ -75,7 +75,7 @@ internal class AuthenticatorManager( userId: Long, keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor(KeyFilters.forUserId(userId)) + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor(MatchOn.UserId(userId)) ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) @@ -125,22 +125,18 @@ internal class AuthenticatorManager( } suspend fun removeAccount(token: String, userId: Long) { - val passkeyId = keyPairManager.findKeyIdFor(KeyFilters.forUserId(userId)) + val passkeyId = keyPairManager.findKeyIdFor(MatchOn.UserId(userId)) if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it webAuthnRepository.deletePasskeyIfExists(token, passkeyId) - val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(passkeyId)) + val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(passkeyId)) } accountsRepository.deleteAccount(userId) } suspend fun deleteKeysFor(userId: Long) { - val _ = keyPairManager.deleteKeysMatching(KeyFilters.forUserId(userId)) - } - - suspend fun getKeyIdFor(userId: Long): String? { - return keyPairManager.findKeyIdFor(KeyFilters.forUserId(userId)) + val _ = keyPairManager.deleteKeysMatching(MatchOn.UserId(userId)) } } From b5260e96fa35ba60e9d2c4bb4810c44ef1f0c955 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 11:49:37 +0200 Subject: [PATCH 05/12] fix: Filter out duplicate key ids --- .../kotlin/internal/KeyPairManagerImpl.android.kt | 7 ++++++- .../kotlin/internal/KeyPairManagerImpl.apple.kt | 11 +++++++++-- 2 files changed, 15 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 a878dfb9..e213f60a 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -74,7 +74,11 @@ private class KeyPairManagerAndroidImpl : KeyPairManager() { add(extractKeyIdFromFileName(fileName) to attrs.creationTime()) } } - }.sortedBy { (_, creationTime) -> creationTime }.map { (keyId, _) -> keyId } + }.sortedBy { (_, creationTime) -> + creationTime + }.map { (keyId, _) -> + keyId + }.distinct() // Private/public keys pairs have a common id, so we filter duplicates. } override suspend fun findKeyIdFor(matchOn: MatchOn): String? { @@ -85,6 +89,7 @@ private class KeyPairManagerAndroidImpl : KeyPairManager() { predicate(it.name) } ?: return null + //TODO 2: Put keys into a dedicated dir return extractKeyIdFromFileName(userPassKey.name) } diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 70cbc8e1..d5e320b8 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -145,7 +145,11 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { add(extractKeyIdFromTag(tag) to dateRef.toNSDate()) } } - }.sortedBy { (_, date) -> date.timeIntervalSince1970 }.map { (keyId, _) -> keyId } + }.sortedBy { (_, date) -> + date.timeIntervalSince1970 + }.map { (keyId, _) -> + keyId + }.distinct() // Private/public keys pairs have a common id, so we filter duplicates. } } @@ -194,7 +198,10 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { } } - override fun MatchOn.PasskeyId.asFilterPredicate() = { name: String -> name.endsWith(id) } + override fun MatchOn.PasskeyId.asFilterPredicate(): (String) -> Boolean { + val publicKeyEnd = "$id.pub" + return { name: String -> name.endsWith(id) || name.endsWith(publicKeyEnd) } + } private fun extractKeyIdFromTag(tag: String): String = tag.substring(startIndex = tag.indexOfFirst { it == '-' } + 1) From 46e8ff30f27ec513631a77dbf6f840856c2a6257 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 12:29:52 +0200 Subject: [PATCH 06/12] chore: Add missing internal modifier --- .../src/appleMain/kotlin/internal/extensions/CFArrayRef.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt index f8a9ec5d..5e32f3e2 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt @@ -25,7 +25,7 @@ import platform.CoreFoundation.CFArrayRef @ExperimentalForeignApi @Suppress("unchecked_cast") -operator fun ?> CFArrayRef?.get(index: Long): T = CFArrayGetValueAtIndex(this, index) as T +internal operator fun ?> CFArrayRef?.get(index: Long): T = CFArrayGetValueAtIndex(this, index) as T @ExperimentalForeignApi internal val CFArrayRef?.size: Long inline get() = CFArrayGetCount(this) From ed7f977c5d792291d7ae5ac6a28eb266e981468d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 12:54:28 +0200 Subject: [PATCH 07/12] chore: Handle potential IOException on retrieving file creation date on Android --- .../kotlin/internal/KeyPairManagerImpl.android.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index e213f60a..44073e8f 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -21,6 +21,7 @@ import com.infomaniak.auth.lib.internal.utils.Xor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.invoke import kotlinx.coroutines.withContext +import kotlinx.io.IOException import splitties.init.appCtx import java.io.File import java.nio.file.Files @@ -70,8 +71,14 @@ private class KeyPairManagerAndroidImpl : KeyPairManager() { for (file in files) { val fileName = file.name if (predicate(file.name)) { - val attrs = Dispatchers.IO { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) } - add(extractKeyIdFromFileName(fileName) to attrs.creationTime()) + val millis = Dispatchers.IO { + try { + Files.readAttributes(file.toPath(), BasicFileAttributes::class.java).creationTime().toMillis() + } catch (_: IOException) { + file.lastModified() + } + } + add(extractKeyIdFromFileName(fileName) to millis) } } }.sortedBy { (_, creationTime) -> From fdeda2ccf58a5ab7d780b82eb77e4a78cc74173f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 12:55:17 +0200 Subject: [PATCH 08/12] fix: Add missing CFRelease calls --- .../src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index d5e320b8..ae545c05 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -220,6 +220,7 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { CFRelease(query) return if (status == errSecSuccess && resultRef.value != null) { + defer { CFRelease(resultRef.value) } @Suppress("unchecked_cast") val resultsArray = resultRef.value as CFArrayRef Pair(resultsArray, resultsArray.size.toInt()) @@ -239,6 +240,7 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { this[kSecAttrApplicationTag] = tag.toNsData() } SecItemDelete(deleteQuery) + CFRelease(deleteQuery) } @OptIn(ExperimentalForeignApi::class) From bf9c1f95e3fafc6565b2775d51aacc275ff41f47 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 14:14:27 +0200 Subject: [PATCH 09/12] chore: Make CFArrayRef extensions have a non-null receiver --- .../src/appleMain/kotlin/internal/extensions/CFArrayRef.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt index 5e32f3e2..580ffbb4 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt @@ -25,7 +25,7 @@ import platform.CoreFoundation.CFArrayRef @ExperimentalForeignApi @Suppress("unchecked_cast") -internal operator fun ?> CFArrayRef?.get(index: Long): T = CFArrayGetValueAtIndex(this, index) as T +internal operator fun ?> CFArrayRef.get(index: Long): T = CFArrayGetValueAtIndex(this, index) as T @ExperimentalForeignApi -internal val CFArrayRef?.size: Long inline get() = CFArrayGetCount(this) +internal val CFArrayRef.size: Long inline get() = CFArrayGetCount(this) From 87c847f149c08b088f6c07e751163a0222c87b8a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 14:15:40 +0200 Subject: [PATCH 10/12] chore: Rename local property --- .../androidMain/kotlin/internal/KeyPairManagerImpl.android.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index 44073e8f..1d6fa101 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -71,14 +71,14 @@ private class KeyPairManagerAndroidImpl : KeyPairManager() { for (file in files) { val fileName = file.name if (predicate(file.name)) { - val millis = Dispatchers.IO { + val fileTimestampMillis = Dispatchers.IO { try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java).creationTime().toMillis() } catch (_: IOException) { file.lastModified() } } - add(extractKeyIdFromFileName(fileName) to millis) + add(extractKeyIdFromFileName(fileName) to fileTimestampMillis) } } }.sortedBy { (_, creationTime) -> From 3d55ba5b37a4dcf343b64b414e644f3d790c1e8c Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 14:46:17 +0200 Subject: [PATCH 11/12] fix: Remove obsolete function that was failing iOS compilation --- .../src/commonMain/kotlin/internal/db/AccountsDao.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index 9265f3bf..1f7cbfce 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -29,9 +29,6 @@ internal interface AccountsDao { @Query("SELECT * FROM AccountEntity") fun getAccountsAsFlow(): Flow> - @Query("SELECT * FROM AccountEntity WHERE status = :status") - fun getAccountsWith(status: AccountEntity.Status): List - @Query("SELECT * FROM AccountEntity WHERE id = :id") fun getAccountAsFlow(id: Long): Flow From 1e1f9c14e795b000e7975e1b3cbda2564dda8704 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Apr 2026 14:47:06 +0200 Subject: [PATCH 12/12] chore: Simplify getAllPrivateKeysQuery() --- .../internal/KeyPairManagerImpl.apple.kt | 31 ++++++++++--------- .../kotlin/internal/extensions/CFArrayRef.kt | 10 ++++++ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index ae545c05..03414b7d 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -21,6 +21,7 @@ package com.infomaniak.auth.lib.internal import com.infomaniak.auth.lib.internal.extensions.buildCFDictionary import com.infomaniak.auth.lib.internal.extensions.get +import com.infomaniak.auth.lib.internal.extensions.isNullOrEmpty import com.infomaniak.auth.lib.internal.extensions.set import com.infomaniak.auth.lib.internal.extensions.size import com.infomaniak.auth.lib.internal.extensions.toByteArray @@ -131,13 +132,13 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { override suspend fun getSortedKeyIds(matchOn: MatchOn): List = Dispatchers.IO { memScoped { - val (resultsArray, count) = getAllPrivateKeysQuery() - if (resultsArray == null || count == 0) return@memScoped emptyList() + val resultsArray = getAllPrivateKeysQuery() + if (resultsArray.isNullOrEmpty()) return@memScoped emptyList() val predicate = matchOn.asFilterPredicate() buildList { - for (i in 0 until count) { - val item: CFDictionaryRef = resultsArray[i.toLong()] + for (i in 0 until resultsArray.size) { + val item: CFDictionaryRef = resultsArray[i] val tag = extractTagFromItem(item) ?: continue val dateRef: CFDateRef = item[kSecAttrCreationDate] @@ -156,13 +157,13 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { @OptIn(BetaInteropApi::class) override suspend fun findKeyIdFor(matchOn: MatchOn): String? = Dispatchers.IO { memScoped { - val (resultsArray, count) = getAllPrivateKeysQuery() + val resultsArray = getAllPrivateKeysQuery() - if (resultsArray == null || count == 0) return@memScoped null + if (resultsArray.isNullOrEmpty()) return@memScoped null val predicate = matchOn.asFilterPredicate() - for (i in 0 until count) { - val tag = extractTagFromItem(resultsArray[i.toLong()]) ?: continue + for (i in 0 until resultsArray.size) { + val tag = extractTagFromItem(resultsArray[i]) ?: continue if (predicate(tag)) return@memScoped extractKeyIdFromTag(tag) } @@ -173,16 +174,16 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { override suspend fun deleteKeysMatching(matchOn: MatchOn): Xor = Dispatchers.IO { memScoped { - val (resultsArray, count) = getAllPrivateKeysQuery() + val resultsArray = getAllPrivateKeysQuery() - if (resultsArray == null || count == 0) { + if (resultsArray.isNullOrEmpty()) { return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) } val predicate = matchOn.asFilterPredicate() var hasDeletedAtLeastOneKey = false - for (i in 0 until count) { - val tag = extractTagFromItem(resultsArray[i.toLong()]) ?: continue + for (i in 0 until resultsArray.size) { + val tag = extractTagFromItem(resultsArray[i]) ?: continue if (predicate(tag)) { deleteKeyByTag(tag) @@ -206,7 +207,7 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { private fun extractKeyIdFromTag(tag: String): String = tag.substring(startIndex = tag.indexOfFirst { it == '-' } + 1) @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) - private fun MemScope.getAllPrivateKeysQuery(): Pair { + private fun MemScope.getAllPrivateKeysQuery(): CFArrayRef? { val query = buildCFDictionary { this[kSecClass] = kSecClassKey this[kSecAttrKeyClass] = kSecAttrKeyClassPrivate @@ -223,9 +224,9 @@ private class KeyPairManagerAppleImpl : KeyPairManager() { defer { CFRelease(resultRef.value) } @Suppress("unchecked_cast") val resultsArray = resultRef.value as CFArrayRef - Pair(resultsArray, resultsArray.size.toInt()) + resultsArray } else { - Pair(null, 0) + null } } diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt index 580ffbb4..843c076e 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt @@ -15,6 +15,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalContracts::class) + package com.infomaniak.auth.lib.internal.extensions import kotlinx.cinterop.CPointer @@ -22,6 +24,8 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreFoundation.CFArrayGetCount import platform.CoreFoundation.CFArrayGetValueAtIndex import platform.CoreFoundation.CFArrayRef +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract @ExperimentalForeignApi @Suppress("unchecked_cast") @@ -29,3 +33,9 @@ internal operator fun ?> CFArrayRef.get(index: Long): T = CFArra @ExperimentalForeignApi internal val CFArrayRef.size: Long inline get() = CFArrayGetCount(this) + +@ExperimentalForeignApi +internal fun CFArrayRef?.isNullOrEmpty(): Boolean { + contract { returns(false) implies (this@isNullOrEmpty != null) } + return this == null || size == 0L +}