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)