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/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..1d6fa101 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -21,13 +21,18 @@ 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 +import java.nio.file.attribute.BasicFileAttributes -internal actual class KeyPairManagerImpl : KeyPairManager { +internal actual fun createKeyPairManager(): 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 +42,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 +52,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,21 +62,46 @@ 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(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 fileTimestampMillis = Dispatchers.IO { + try { + Files.readAttributes(file.toPath(), BasicFileAttributes::class.java).creationTime().toMillis() + } catch (_: IOException) { + file.lastModified() + } + } + add(extractKeyIdFromFileName(fileName) to fileTimestampMillis) + } + } + }.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? { + 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 + //TODO 2: Put keys into a dedicated dir + return extractKeyIdFromFileName(userPassKey.name) } - actual 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 { @@ -83,6 +113,13 @@ internal actual class KeyPairManagerImpl : 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 d5159398..03414b7d 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -20,9 +20,13 @@ 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 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 +41,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 +55,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,17 +67,19 @@ import platform.Security.kSecMatchLimitAll import platform.Security.kSecReturnAttributes import platform.Security.kSecReturnRef -internal actual class KeyPairManagerImpl : KeyPairManager { +internal actual fun createKeyPairManager(): KeyPairManager = KeyPairManagerAppleImpl() - actual override suspend fun generateNewKey( +private class KeyPairManagerAppleImpl : KeyPairManager() { + + override suspend fun generateNewKey( userId: Long, keyId: String, ): Failure.KeyManagement.GenerationFailed? = Dispatchers.IO { 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, ) @@ -85,7 +90,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 +112,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } } - actual override suspend fun retrievePrivateKey( + override suspend fun retrievePrivateKey( userId: Long, keyId: String ): Xor { @@ -125,43 +130,60 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } } + override suspend fun getSortedKeyIds(matchOn: MatchOn): List = Dispatchers.IO { + memScoped { + val resultsArray = getAllPrivateKeysQuery() + if (resultsArray.isNullOrEmpty()) return@memScoped emptyList() + val predicate = matchOn.asFilterPredicate() + + buildList { + for (i in 0 until resultsArray.size) { + val item: CFDictionaryRef = resultsArray[i] + val tag = extractTagFromItem(item) ?: continue + val dateRef: CFDateRef = item[kSecAttrCreationDate] + + if (predicate(tag)) { + add(extractKeyIdFromTag(tag) to dateRef.toNSDate()) + } + } + }.sortedBy { (_, date) -> + date.timeIntervalSince1970 + }.map { (keyId, _) -> + keyId + }.distinct() // Private/public keys pairs have a common id, so we filter duplicates. + } + } + @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { + override suspend fun findKeyIdFor(matchOn: MatchOn): String? = Dispatchers.IO { memScoped { - //TODO[ik-auth]: Test this code somehow. - 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(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + for (i in 0 until resultsArray.size) { + val tag = extractTagFromItem(resultsArray[i]) ?: continue - if (tag != null && 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 } } - actual 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() + 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(CFArrayGetValueAtIndex(resultsArray, i.toLong())) ?: continue + for (i in 0 until resultsArray.size) { + val tag = extractTagFromItem(resultsArray[i]) ?: continue if (predicate(tag)) { deleteKeyByTag(tag) @@ -177,8 +199,15 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } } + 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) + @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) - private fun MemScope.getAllPrivateKeysQuery(): Pair { + private fun MemScope.getAllPrivateKeysQuery(): CFArrayRef? { val query = buildCFDictionary { this[kSecClass] = kSecClassKey this[kSecAttrKeyClass] = kSecAttrKeyClassPrivate @@ -192,18 +221,17 @@ internal actual class KeyPairManagerImpl : 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, CFArrayGetCount(resultsArray).toInt()) + resultsArray } else { - Pair(null, 0) + null } } - @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() } @@ -213,6 +241,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { this[kSecAttrApplicationTag] = tag.toNsData() } SecItemDelete(deleteQuery) + CFRelease(deleteQuery) } @OptIn(ExperimentalForeignApi::class) 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..843c076e --- /dev/null +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt @@ -0,0 +1,41 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalContracts::class) + +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 +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@ExperimentalForeignApi +@Suppress("unchecked_cast") +internal operator fun ?> CFArrayRef.get(index: Long): T = CFArrayGetValueAtIndex(this, index) as T + +@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 +} 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 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 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..6ca729eb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -17,31 +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 interface KeyPairManager { +internal expect fun createKeyPairManager(): 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 - suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? + /** Sorted by creation date. */ + abstract suspend fun getSortedKeyIds(matchOn: MatchOn): List - suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor + abstract suspend fun findKeyIdFor(matchOn: MatchOn): String? - companion object { - val privateKeyPurposes = KeyPurposes.privateKeyDefaults - val publicKeyPurposes = KeyPurposes.publicKeyDefaults + abstract suspend fun deleteKeysMatching(matchOn: MatchOn): Xor + + sealed interface MatchOn { + class UserId(val id: Long) : MatchOn + class PasskeyId(val id: String) : MatchOn + } + + //region MatchOn to predicates + protected fun MatchOn.asFilterPredicate(): (name: String) -> Boolean = when (this) { + is MatchOn.PasskeyId -> asFilterPredicate() + is MatchOn.UserId -> asFilterPredicate() } - object Filters { - fun forUserId(userId: Long): (name: String) -> Boolean = { it.startsWith("$userId-") } - fun forPasskeyId(passkeyId: String): (name: String) -> Boolean = { "-$passkeyId-" in it } + 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/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/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 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..a1b890a3 --- /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.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 + +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(MatchOn.UserId(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(MatchOn.PasskeyId(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 { keyId -> + webAuthnRepository.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, keyId) + val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(keyId)) + } + 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..f1218eea 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -20,7 +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.KeyPairManagerImpl +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 @@ -33,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, @@ -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 @@ -76,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) @@ -126,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.deletePasskey(token, passkeyId) - val _ = keyPairManager.deleteKeysMatching(KeyFilters.forPasskeyId(passkeyId)) + webAuthnRepository.deletePasskeyIfExists(token, 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)) } } 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)