diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5f6ebc67..7fabe4de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ + ~ 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 . + --> - - \ No newline at end of file + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b..04a942a2 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,32 @@ + ~ 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 . + --> - - - - - \ No newline at end of file + + diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index 61f51e20..9641472b 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -32,8 +32,8 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return Failure.KeyManagement.GenerationFailed(it.toString()) } - saveKeyToFilesDir("$userId-$keyId-private.key", keyPair.private.encoded) - saveKeyToFilesDir("$userId-$keyId-public.key", keyPair.public.encoded) + saveFileToFilesDir("$userId-$keyId-private.key", keyPair.private.encoded) + saveFileToFilesDir("$userId-$keyId-public.key", keyPair.public.encoded) return null } @@ -57,16 +57,18 @@ internal actual class KeyPairManagerImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - actual override suspend fun findKeyIdFor(userId: Long): Xor { - val fileNamePrefix = "$userId-" + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? { val userPassKey: File = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.find { - it.name.startsWith(fileNamePrefix) - } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys")) + predicate(it.name) + } ?: return null - val keyId = userPassKey.name.substringAfter(fileNamePrefix).substringBefore('-') - return Xor.First(keyId) + val keyId = userPassKey.name.substring( + startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, + endIndex = userPassKey.name.indexOfLast { it == '-' } + ) + return keyId } actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { @@ -81,7 +83,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return Xor.First(Unit) } - private suspend fun saveKeyToFilesDir(fileName: String, key: ByteArray) = Dispatchers.IO { + 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/androidMain/kotlin/internal/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt new file mode 100644 index 00000000..2267b783 --- /dev/null +++ b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt @@ -0,0 +1,38 @@ +/* + * 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.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.invoke +import splitties.init.appCtx +import java.io.File + +internal actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO { + File(appCtx.filesDir, name).exists() +} + +/** + * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, + * or implement a BackupAgent to have the backup exclusion work. + */ +internal actual suspend fun createBackupExcludedFile(name: String, content: String) { + File(appCtx.filesDir, name).apply { + createNewFile() + writeText(content) + } +} diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 55e43ea4..d5159398 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -126,28 +126,32 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(userId: Long): Xor = memScoped { - //TODO[ik-auth]: Test this code somehow. - val userIdPrefix = "$userId-" - val (resultsArray, count) = getAllPrivateKeysQuery() - - if (resultsArray == null || count == 0) { - return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) - } + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { + memScoped { + //TODO[ik-auth]: Test this code somehow. + val (resultsArray, count) = getAllPrivateKeysQuery() - for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + if (resultsArray == null || count == 0) return@memScoped null - if (tag?.startsWith(userIdPrefix) == true) { - val keyId = tag.removePrefix(userIdPrefix) - return@memScoped Xor.First(keyId) + for (i in 0 until count) { + val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + + if (tag != null && predicate(tag)) { + val keyId = tag.substring( + startIndex = tag.indexOfFirst { it == '-' } + 1, + endIndex = tag.indexOfLast { it == '-' } + ) + return@memScoped keyId + } } - } - Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for userId $userId")) + return@memScoped null + } } - actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor = + actual override suspend fun deleteKeysMatching( + predicate: (name: String) -> Boolean + ): Xor = Dispatchers.IO { memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() @@ -171,6 +175,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { Xor.Second(Failure.KeyManagement.KeyNotFound("No key containing $predicate")) } } + } @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) private fun MemScope.getAllPrivateKeysQuery(): Pair { diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt index d9e5b1b5..47bfc9b6 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt @@ -20,8 +20,10 @@ package com.infomaniak.auth.lib.internal.extensions import com.infomaniak.auth.lib.internal.utils.Xor +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr @@ -45,6 +47,8 @@ import platform.Foundation.NSError * } * } * ``` + * + * See [tryIt2] for the full NSError variant (no C-style CoreFoundation). */ internal inline fun tryIt(block: (errorPointer: CPointer) -> R?): Xor = memScoped { val errorVar = alloc() @@ -54,3 +58,40 @@ internal inline fun tryIt(block: (errorPointer: CPointer Xor.Second(error) } } + +/** + * Helpful for functions that take a pointer of an error ref. + * + * Example usage: + * ``` + * val result = tryIt2 { errorPointer -> + * NSFileManager.defaultManager.URLForDirectory( + * directory = NSApplicationSupportDirectory, + * inDomain = NSUserDomainMask, + * appropriateForURL = null, + * create = true, + * error = errorPointer, + * ) + * } + * + * when (result) { + * is Xor.First -> return result.value // Successful + * is Xor.Second -> { + * println("Error: ${result.value.localizedDescription}") + * handleNSError(result.value) + * return null + * } + * } + * ``` + * + * See [tryIt] For the C-style CoreFoundation compatible variant. + */ +@OptIn(BetaInteropApi::class) +internal inline fun tryIt2(block: (errorPtr: CPointer>) -> R?): Xor = memScoped { + val errorVar = alloc>() + val result = block(errorVar.ptr) + when (val error = errorVar.value) { + null -> Xor.First(result!!) + else -> Xor.Second(error) + } +} diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt new file mode 100644 index 00000000..6e986016 --- /dev/null +++ b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt @@ -0,0 +1,64 @@ +/* + * 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.utils + +import com.infomaniak.auth.lib.internal.extensions.firstOrElse +import com.infomaniak.auth.lib.internal.extensions.toNsData +import com.infomaniak.auth.lib.internal.extensions.tryIt2 +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSURLIsExcludedFromBackupKey +import platform.Foundation.NSUserDomainMask + +internal actual suspend fun checkFileExists(name: String): Boolean { + return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name") +} + +@OptIn(ExperimentalForeignApi::class) +internal actual suspend fun createBackupExcludedFile(name: String, content: String) { + val path = "${getApplicationSupportDirectory()}/$name" + NSFileManager.defaultManager.createFileAtPath( + path = path, + contents = content.toNsData(), + attributes = null + ) + val url = NSURL.fileURLWithPath(path) + val _ = tryIt2 { + url.setResourceValue( + value = true, + forKey = NSURLIsExcludedFromBackupKey, + error = it + ) + }.firstOrElse { error(it) } +} + +@OptIn(ExperimentalForeignApi::class) +private fun getApplicationSupportDirectory(): String { + val directory = tryIt2 { + NSFileManager.defaultManager.URLForDirectory( + directory = NSApplicationSupportDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = it, + ) + }.firstOrElse { error(it) } + return requireNotNull(directory.path) // No reason for it to be null given the code above. +} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 0d473760..57575101 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -39,6 +39,7 @@ import com.infomaniak.auth.lib.internal.utils.raceOf import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull +import com.infomaniak.auth.lib.network.exceptions.ApiException import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import kotlinx.coroutines.CompletableDeferred @@ -67,7 +68,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.io.IOException -import network.exceptions.ApiException import kotlin.time.Duration.Companion.seconds internal class AuthenticatorFacadeImpl( @@ -83,8 +83,9 @@ internal class AuthenticatorFacadeImpl( private val dao = accountsDatabase.getDao() private val accountEntities = flow { + migrationManager.setBackedUpAccountsStatus() migrationManager.addLegacyAccountsToDB() - emitAll(dao.getAsFlow()) + emitAll(dao.getAccountsAsFlow()) }.shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) private val atLeastOneConnectedAccount: Flow = accountEntities.map { entities -> @@ -118,7 +119,9 @@ internal class AuthenticatorFacadeImpl( .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) override suspend fun addAccounts(connectedAccounts: List) { - val entities = connectedAccounts.map { it.toEntity(AccountEntity.Status.PasskeyRegistrationPending) } + val entities = connectedAccounts.map { + it.toEntity(AccountEntity.Status.PasskeyRegistrationPending) + } dao.upsert(entities) } @@ -131,7 +134,7 @@ internal class AuthenticatorFacadeImpl( val token = authenticatorManager.getToken(clientId, userId).firstOrElse { error("Could not get the key for user $userId from the storage: $it") } - authenticatorBridge.persistTokenForAccount(userId, token) + authenticatorBridge.persistTokenForAccount(userId, token.accessToken) } private fun accountsFlow( @@ -208,16 +211,20 @@ internal class AuthenticatorFacadeImpl( emitAll(appStatusFlow) }.distinctUntilChanged() - private fun loginAttemptsFlow(userId: Long): Flow = dao.get(userId).transformLatest { entity -> - emit(null) - when (entity?.status) { - AccountEntity.Status.ToBeMigrated -> migrationAttempts(entity) - AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending -> { - registrationAttempts(entity) + private fun loginAttemptsFlow(userId: Long): Flow = + dao.getAccountAsFlow(userId).transformLatest { entity -> + emit(null) + when (entity?.status) { + AccountEntity.Status.ToBeMigrated -> migrationAttempts(entity) + AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending -> { + registrationAttempts(entity) + } + AccountEntity.Status.RestoringFromBackup -> { + restoreFromBackupAttempts(account = entity) + } + AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. } - AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. } - } private suspend fun shouldTryImmediateLogin(): Boolean = raceOf( { @@ -241,16 +248,16 @@ internal class AuthenticatorFacadeImpl( withRetries(userId = userId) { emit(Account.Status.NotConnected.AttemptingToConnect) if (!passKeyAlreadyRegistered) { - // TODO do that only if we don't need to use the backed up files + // Just in case orphans passkeys are lying around, we want to make sure to start from a clean state. authenticatorManager.deleteKeysFor(notRegisteredAccount.id) - authenticatorManager.registerPasskey(token, userId) + val _ = authenticatorManager.registerPasskey(token, userId) dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.FirstPasskeyAuthenticationPending)) } val token = authenticatorManager.getToken( clientId = clientId, userId = userId, ).firstOrElse { error("Key not found: ${it.details}") } - authenticatorBridge.persistTokenForAccount(userId, token) + authenticatorBridge.persistTokenForAccount(userId, token.accessToken) dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.LoggedIn)) } } @@ -356,6 +363,14 @@ internal class AuthenticatorFacadeImpl( } } + private suspend fun FlowCollector.restoreFromBackupAttempts(account: AccountEntity) { + withRetries(userId = account.id) { + migrationManager.restore(account = account) { userId, token -> + authenticatorBridge.persistTokenForAccount(userId, token) + } + } + } + private suspend inline fun FlowCollector.withRetries( userId: Long, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt index 5b13a18d..9c5f95cb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -31,7 +31,7 @@ internal interface KeyPairManager { suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor - suspend fun findKeyIdFor(userId: Long): Xor + suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt index e1143b7b..8dae8a0c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt @@ -27,6 +27,6 @@ internal expect class KeyPairManagerImpl() : KeyPairManager { keyId: String ): Xor - override suspend fun findKeyIdFor(userId: Long): Xor + override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt new file mode 100644 index 00000000..b8a3edc6 --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -0,0 +1,49 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.auth.lib.internal + +import com.infomaniak.auth.lib.internal.utils.BackupExclusionOnlyApplePlatforms +import com.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile +import kotlin.random.Random + +internal object RestoreFromBackupDetector { + + private val restorationHandledMarkerFileName: String = "51756f69203f".hexToByteArray().decodeToString() + + suspend inline fun runRestoreOperationIfNeeded(block: () -> Unit) { + if (restorationAlreadyHandled()) return + block() + markRestorationAsHandled() + } + + private suspend fun restorationAlreadyHandled(): Boolean { + return checkFileExists(restorationHandledMarkerFileName) + } + + private suspend fun markRestorationAsHandled() { + @OptIn(BackupExclusionOnlyApplePlatforms::class) + createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) + } + + private fun generateFileContent(): String { + val oldEnough = Random.nextBoolean() + val encodedContent = if (oldEnough) "466575722021" else "51756f69636f75626568" + return encodedContent.hexToByteArray().decodeToString() + } +} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index 77da3936..d84c1177 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -32,7 +32,6 @@ internal data class AccountEntity( ) { val isLoggedIn: Boolean get() = status == Status.LoggedIn - enum class Status { /* @@ -55,5 +54,7 @@ internal data class AccountEntity( /** Account successfully connected, with registered passkey. */ LoggedIn, + + RestoringFromBackup, } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index 378d4774..9265f3bf 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -27,10 +27,13 @@ import kotlinx.coroutines.flow.Flow internal interface AccountsDao { @Query("SELECT * FROM AccountEntity") - fun getAsFlow(): Flow> + fun getAccountsAsFlow(): Flow> + + @Query("SELECT * FROM AccountEntity WHERE status = :status") + fun getAccountsWith(status: AccountEntity.Status): List @Query("SELECT * FROM AccountEntity WHERE id = :id") - fun get(id: Long): Flow + fun getAccountAsFlow(id: Long): Flow @Upsert suspend fun upsert(account: AccountEntity) @@ -38,6 +41,9 @@ internal interface AccountsDao { @Upsert suspend fun upsert(accounts: List) + @Query("UPDATE AccountEntity SET status = :newStatus WHERE status = :currentStatus") + suspend fun updateStatus(currentStatus: AccountEntity.Status, newStatus: AccountEntity.Status) + @Insert suspend fun insert(account: AccountEntity) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 309ff98d..aef5e36c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -28,6 +28,7 @@ import com.infomaniak.auth.lib.internal.repositories.AccountsRepository import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.internal.utils.SignUtils import com.infomaniak.auth.lib.internal.utils.Xor +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 @@ -44,7 +45,7 @@ internal class AuthenticatorManager( suspend fun getUserProfile(token: String) = webAuthnRepository.getUserProfile(token) - suspend fun registerPasskey(token: String, userId: Long) { + suspend fun registerPasskey(token: String, userId: Long): String { val passkeysOptions = webAuthnRepository.getPasskeysOptions(token).data val keyIds = cryptoObjectsBuilder.getKeyIds() val keyIdAsByteArray = keyIds.first @@ -64,11 +65,17 @@ internal class AuthenticatorManager( ) webAuthnRepository.registerPasskey(token, registerPasskey) + + return keyIdAsString } - suspend fun getToken(clientId: String, userId: Long): Xor { - val keyId = keyPairManager.findKeyIdFor(userId).firstOrNull() - ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) + suspend fun getToken( + clientId: String, + userId: Long, + keyIdOrDefault: String? = null, + ): Xor { + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") } + ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() @@ -106,11 +113,18 @@ internal class AuthenticatorManager( clientExtensionResults = ClientExtensionResults, authenticatorAttachment = "platform", ) - return Xor.First(webAuthnRepository.verify(verifyAuthenticationData).accessToken) + val verifyAuthData = webAuthnRepository.verify(verifyAuthenticationData) + val apiToken = ApiToken( + accessToken = verifyAuthData.accessToken, + tokenType = verifyAuthData.tokenType, + userId = userId.toInt(), + scope = verifyAuthData.scope, + ) + return Xor.First(apiToken) } suspend fun removeAccount(token: String, userId: Long) { - val passkeyId = keyPairManager.findKeyIdFor(userId).firstOrNull() + val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") } if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it @@ -124,4 +138,8 @@ internal class AuthenticatorManager( suspend fun deleteKeysFor(userId: Long) { val _ = keyPairManager.deleteKeysMatching { it.startsWith("$userId-") } } + + suspend fun getKeyIdFor(userId: Long): String? { + return keyPairManager.findKeyIdFor { it.startsWith("$userId-") } + } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index c297857d..9a030b4e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -18,6 +18,8 @@ package com.infomaniak.auth.lib.internal.managers import com.infomaniak.auth.lib.internal.MigrationAuthentication +import com.infomaniak.auth.lib.internal.RestoreFromBackupDetector +import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable import com.infomaniak.auth.lib.internal.extensions.firstOrElse @@ -32,10 +34,10 @@ import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.models.migration.ApiToken +import com.infomaniak.auth.lib.network.exceptions.ApiException import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException -import network.exceptions.ApiException import org.kotlincrypto.macs.hmac.sha2.HmacSHA256 import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -47,6 +49,40 @@ internal class MigrationManager( private val clientId: String, ) { + private val dao = accountsDatabase.getDao() + + suspend fun setBackedUpAccountsStatus() { + RestoreFromBackupDetector.runRestoreOperationIfNeeded { + dao.updateStatus( + currentStatus = AccountEntity.Status.LoggedIn, + newStatus = AccountEntity.Status.RestoringFromBackup + ) + } + } + + suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { + val keyId = authenticatorManager.getKeyIdFor(account.id) ?: return + // Get token with previous passkey + val token = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdOrDefault = keyId, + ).firstOrElse { error(it) } + // Register a new passkey + val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) + // Getting a new token with the new passkey + val tokenWithNewPassKey = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdOrDefault = newKeyId, + ).firstOrElse { error(it) } + persistToken(account.id, tokenWithNewPassKey.accessToken) + dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) + // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore + authenticatorManager.deleteKeysFor(account.id) + webAuthnRepository.deletePasskey(tokenWithNewPassKey.accessToken, keyId) + } + suspend fun addLegacyAccountsToDB() { if (!needMigration()) return @@ -73,7 +109,7 @@ internal class MigrationManager( deviceId = deviceId, userId = userId, ) - val tokenToUse = when (authentication) { + val temporaryToken = when (authentication) { is MigrationAuthentication.CrossAppLogin -> authentication.derivedToken else -> { val otp = getOtp(secret = secret, timestampSeconds = migrationOptions.timestamp) @@ -107,16 +143,20 @@ internal class MigrationManager( } authenticatorManager.deleteKeysFor(userId) - authenticatorManager.registerPasskey( - token = tokenToUse.accessToken, + val _ = authenticatorManager.registerPasskey( + token = temporaryToken.accessToken, userId = userId ) - val token = authenticatorManager.getToken( + val apiTokenFromPasskey = authenticatorManager.getToken( clientId = clientId, userId = userId, ).firstOrElse { error("Didn't find the key locally: $it") } - persistUser(tokenToUse) - webAuthnRepository.completeMigration(token = token, sessionId = migrationOptions.session, deviceId = deviceId) + persistUser(apiTokenFromPasskey) + webAuthnRepository.completeMigration( + token = apiTokenFromPasskey.accessToken, + sessionId = migrationOptions.session, + deviceId = deviceId + ) deleteLegacyAccount(userId.toString()) if (getLegacyAccounts().isEmpty()) deleteLegacyDB() diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt index e57cc342..f9f9a59d 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt @@ -20,6 +20,8 @@ package com.infomaniak.auth.lib.internal.network import com.infomaniak.auth.lib.internal.models.ApiResponseForError import com.infomaniak.auth.lib.internal.network.utils.getHttpClientEngine import com.infomaniak.auth.lib.internal.network.utils.getRequestContextId +import com.infomaniak.auth.lib.network.exceptions.ApiException +import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.interfaces.BreadcrumbType import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import com.infomaniak.auth.lib.network.interfaces.CrashReportLevel @@ -43,8 +45,6 @@ import io.ktor.utils.io.CancellationException import kotlinx.io.IOException import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import network.exceptions.ApiException -import network.exceptions.NetworkException import kotlin.time.Duration.Companion.seconds internal class ApiClientProvider( diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt index 9f0c1012..b09773bb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt @@ -17,12 +17,12 @@ */ package com.infomaniak.auth.lib.internal.network.utils +import com.infomaniak.auth.lib.network.exceptions.ApiException +import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.exceptions.UnknownException import io.ktor.client.call.body import io.ktor.client.statement.HttpResponse import io.ktor.utils.io.CancellationException -import network.exceptions.ApiException -import network.exceptions.NetworkException internal const val CONTENT_REQUEST_ID_HEADER = "x-request-id" diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt index 47988239..34f1158c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt @@ -23,7 +23,7 @@ import com.infomaniak.auth.lib.internal.db.AccountsDatabase internal class AccountsRepository(database: AccountsDatabase) { private val dao = database.getDao() - fun getAccounts() = dao.getAsFlow() + fun getAccounts() = dao.getAccountsAsFlow() suspend fun upsertAccount(account: AccountEntity) { dao.upsert(account) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt new file mode 100644 index 00000000..a63834d0 --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.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.utils + +@RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " + + "Backup rules or logic in a BackupAgent are required on Android.") +annotation class BackupExclusionOnlyApplePlatforms + +/** + * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, + * or implement a BackupAgent to have the backup exclusion work. + */ +@BackupExclusionOnlyApplePlatforms +internal expect suspend fun createBackupExcludedFile(name: String, content: String) + +internal expect suspend fun checkFileExists(name: String): Boolean diff --git a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt index 239a2bd4..d5827aa6 100644 --- a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt +++ b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt @@ -44,7 +44,4 @@ data class UserProfile( */ @Transient var apiToken: ApiToken = ApiToken(accessToken = "", tokenType = "", userId = 0), - - ) { - private fun String.firstOrEmpty(): String = if (isNotEmpty()) first().toString() else "" -} +) diff --git a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt index c99d5fe3..680ecd5f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt +++ b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package network.exceptions +package com.infomaniak.auth.lib.network.exceptions /** * Parent class of API calls exception. diff --git a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt index b78ac853..23ff3bf3 100644 --- a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt +++ b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package network.exceptions +package com.infomaniak.auth.lib.network.exceptions /** * Thrown when a network-related error occurs, such as connectivity issues or timeouts.