Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,18 @@ internal actual class KeyPairManagerImpl : KeyPairManager {
}.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) }
}

actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor<String, Failure.KeyManagement.KeyNotFound> {
actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? {
val userPassKey: File = withContext(Dispatchers.IO) {
appCtx.filesDir.listFiles()
}?.find {
predicate(it.name)
} ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys"))
} ?: return null

val keyId =
userPassKey.name.substring(userPassKey.name.indexOfFirst { it == '-' } + 1,
userPassKey.name.indexOfLast { it == '-' })
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<Unit, Failure.KeyManagement.KeyNotFound> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.auth.lib.utils
package com.infomaniak.auth.lib.internal.utils

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.invoke
import splitties.init.appCtx
import java.io.File

actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO {
internal actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO {
File(appCtx.filesDir, name).exists()
}

actual suspend fun createFile(name: String, content: String) {
/**
* **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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,28 +126,32 @@ internal actual class KeyPairManagerImpl : KeyPairManager {
}

@OptIn(BetaInteropApi::class)
actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor<String, Failure.KeyManagement.KeyNotFound> =
actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO {
memScoped {
//TODO[ik-auth]: Test this code somehow.
val (resultsArray, count) = getAllPrivateKeysQuery()

if (resultsArray == null || count == 0) {
return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain"))
}
if (resultsArray == null || count == 0) return@memScoped null

for (i in 0 until count) {
val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong()))

if (tag != null && predicate(tag)) {
val keyId = tag.substring(tag.indexOfFirst { it == '-' } + 1, tag.indexOfLast { it == '-' })
return@memScoped Xor.First(keyId)
val keyId = tag.substring(
startIndex = tag.indexOfFirst { it == '-' } + 1,
endIndex = tag.indexOfLast { it == '-' }
)
return@memScoped keyId
}
}

Xor.Second(Failure.KeyManagement.KeyNotFound("No key found matching $predicate"))
return@memScoped null
}
}

actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor<Unit, Failure.KeyManagement.KeyNotFound> =
actual override suspend fun deleteKeysMatching(
predicate: (name: String) -> Boolean
): Xor<Unit, Failure.KeyManagement.KeyNotFound> = Dispatchers.IO {
memScoped {
val (resultsArray, count) = getAllPrivateKeysQuery()

Expand All @@ -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<CFArrayRef?, Int> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.auth.lib.utils
package com.infomaniak.auth.lib.internal.utils

import com.infomaniak.auth.lib.internal.extensions.firstOrElse
import com.infomaniak.auth.lib.internal.extensions.toNsData
Expand All @@ -27,12 +27,12 @@ import platform.Foundation.NSURL
import platform.Foundation.NSURLIsExcludedFromBackupKey
import platform.Foundation.NSUserDomainMask

actual suspend fun checkFileExists(name: String): Boolean {
internal actual suspend fun checkFileExists(name: String): Boolean {
return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name")
}

@OptIn(ExperimentalForeignApi::class)
actual suspend fun createFile(name: String, content: String) {
internal actual suspend fun createBackupExcludedFile(name: String, content: String) {
val path = "${getApplicationSupportDirectory()}/$name"
NSFileManager.defaultManager.createFileAtPath(
path = path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,7 @@ internal class AuthenticatorFacadeImpl(
registrationAttempts(entity)
}
AccountEntity.Status.RestoringFromBackup -> {
migrationManager.restore(account = entity) { userId, token ->
authenticatorBridge.persistTokenForAccount(userId, token)
}
restoreFromBackupAttempts(account = entity)
}
AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice.
}
Expand Down Expand Up @@ -365,6 +363,14 @@ internal class AuthenticatorFacadeImpl(
}
}

private suspend fun FlowCollector<Account.Status.NotConnected>.restoreFromBackupAttempts(account: AccountEntity) {
withRetries(userId = account.id) {
migrationManager.restore(account = account) { userId, token ->
authenticatorBridge.persistTokenForAccount(userId, token)
}
}
}


private suspend inline fun <R> FlowCollector<Account.Status.NotConnected>.withRetries(
userId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ internal sealed interface Failure {
sealed interface KeyManagement : Failure {
data class GenerationFailed(val details: String) : KeyManagement
data class KeyExtractionFailed(val details: String) : KeyManagement
data class KeyNotFound(val details: String) : KeyManagement
data class KeyNotFound(val details: String, val userId: Long? = null) : KeyManagement
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal interface KeyPairManager {

suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor<ByteArray, Failure.KeyManagement.KeyExtractionFailed>

suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor<String, Failure.KeyManagement.KeyNotFound>
suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String?

suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor<Unit, Failure.KeyManagement.KeyNotFound>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ internal expect class KeyPairManagerImpl() : KeyPairManager {
keyId: String
): Xor<ByteArray, Failure.KeyManagement.KeyExtractionFailed>

override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor<String, Failure.KeyManagement.KeyNotFound>
override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String?
override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor<Unit, Failure.KeyManagement.KeyNotFound>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
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) {
val restorationAlreadyHandled = doesRestoreHandledFileExist()
if (restorationAlreadyHandled) return
block()
markRestorationAsHandled()
}

private suspend fun doesRestoreHandledFileExist(): 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ internal class AuthenticatorManager(
suspend fun getToken(
clientId: String,
userId: Long,
keyIdFromOldPasskey: String? = null,
keyIdOrDefault: String? = null,
): Xor<ApiToken, Failure.KeyManagement.KeyNotFound> {
val keyId = keyIdFromOldPasskey ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull()
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)
Expand Down Expand Up @@ -124,7 +124,7 @@ internal class AuthenticatorManager(
}

suspend fun removeAccount(token: String, userId: Long) {
val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull()
val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") }

if (passkeyId != null) {
// If we have a passkey for this account, revoke it against the backend and delete it
Expand All @@ -140,6 +140,6 @@ internal class AuthenticatorManager(
}

suspend fun getKeyIdFor(userId: Long): String? {
return keyPairManager.findKeyIdFor({ it.startsWith("$userId-") }).firstOrNull()
return keyPairManager.findKeyIdFor { it.startsWith("$userId-") }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package com.infomaniak.auth.lib.internal.managers

import com.infomaniak.auth.lib.internal.MigrationAuthentication
import com.infomaniak.auth.lib.internal.RestoreFromBackupDetector
import com.infomaniak.auth.lib.internal.db.AccountEntity
import com.infomaniak.auth.lib.internal.db.AccountsDatabase
import com.infomaniak.auth.lib.internal.extensions.cancellable
Expand All @@ -34,8 +35,6 @@ import com.infomaniak.auth.lib.internal.otp.needMigration
import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository
import com.infomaniak.auth.lib.models.migration.ApiToken
import com.infomaniak.auth.lib.network.exceptions.ApiException
import com.infomaniak.auth.lib.utils.checkFileExists
import com.infomaniak.auth.lib.utils.createFile
import com.osmerion.kotlin.io.encoding.Base32
import io.ktor.utils.io.core.toByteArray
import kotlinx.io.IOException
Expand All @@ -53,9 +52,11 @@ internal class MigrationManager(
private val dao = accountsDatabase.getDao()

suspend fun setBackedUpAccountsStatus() {
if (!doesAccountInitializationFileExist()) {
dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup)
createAccountInitializationFile()
RestoreFromBackupDetector.runRestoreOperationIfNeeded {
dao.updateStatus(
currentStatus = AccountEntity.Status.LoggedIn,
newStatus = AccountEntity.Status.RestoringFromBackup
)
}
}

Expand All @@ -65,16 +66,16 @@ internal class MigrationManager(
val token = authenticatorManager.getToken(
clientId = clientId,
userId = account.id,
keyIdFromOldPasskey = keyId,
).firstOrNull()!!
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,
keyIdFromOldPasskey = newKeyId,
).firstOrNull()!!
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
Expand Down Expand Up @@ -142,7 +143,7 @@ internal class MigrationManager(
}

authenticatorManager.deleteKeysFor(userId)
authenticatorManager.registerPasskey(
val _ = authenticatorManager.registerPasskey(
token = temporaryToken.accessToken,
userId = userId
)
Expand Down Expand Up @@ -180,20 +181,4 @@ internal class MigrationManager(
scope = this.scope,
)
}

companion object {
private const val ACCOUNT_INITIALIZATION_FILE_NAME = "51756f69203f"
private const val ACCOUNT_INITIALIZATION_FILE_CONTENT = "466575722021"

suspend fun doesAccountInitializationFileExist(): Boolean {
return checkFileExists(name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString())
}

suspend fun createAccountInitializationFile() {
createFile(
name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(),
content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.auth.lib.utils
package com.infomaniak.auth.lib.internal.utils

expect suspend fun createFile(name: String, content: String)
@RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " +
"Backup rules or logic in a BackupAgent are required on Android.")
annotation class BackupExclusionOnlyApplePlatforms

expect suspend fun checkFileExists(name: String): Boolean
/**
* **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)
Comment thread
tevincent marked this conversation as resolved.

internal expect suspend fun checkFileExists(name: String): Boolean
Loading