diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..bb33e62
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,197 @@
+# AGENTS.md - Authenticator Multiplatform Library
+
+> For the Android app norms, see `app/AGENTS.md`. For Core library norms, see `Core/AGENTS.md`. For the composite build overview,
+> see the root `AGENTS.md`.
+
+## Module Summary
+
+`:multiplatform-lib` is the Kotlin Multiplatform module that holds the shared business logic of the Infomaniak Authenticator. It
+is consumed:
+
+- by the Android app via `implementation(project(":multiplatform-lib"))` (see `app/build.gradle.kts`);
+- by the iOS / macOS Authenticator as a static XCFramework named `CoreAuthenticator`, distributed through the root
+ `Package.swift`.
+
+It owns the OTP engine (TOTP/HOTP), the API client, the local Room database, the account/2FA repositories, the WebAuthn / passkey
+logic and the migration models exchanged between platforms.
+
+## High-Level Tech Stack
+
+- **Kotlin Multiplatform** with targets: `androidLibrary` (namespace `com.infomaniak.auth.multiplatform`), `iosArm64`,
+ `iosSimulatorArm64`, `macosArm64`.
+- **SKIE** for idiomatic Swift interop (default arguments, sealed classes, suspend functions).
+- **Ktor client** for HTTP (engines: OkHttp on Android, Darwin on Apple).
+- **kotlinx.serialization** (`json`, `cbor`) - JSON is configured in `internal/network/ApiClientProvider.kt` with
+ `coerceInputValues = true`, `ignoreUnknownKeys = true`, and `decodeEnumsCaseInsensitive`.
+- **AndroidX Room** (multiplatform Room with `androidx.sqlite.bundled`) - schemas are exported to `multiplatform-lib/schemas`.
+- **okio** for hashing.
+- **kotlin-base32** (`osmerion-kotlin-base32`) for OTP computation.
+- **Coroutines** (`kotlinx.coroutines.core`, `kotlinx.coroutines.test`).
+- **Parcelize** plugin enabled for Android-only `@Parcelize` models.
+
+## Context Map
+
+```
+multiplatform-lib/
+├── build.gradle.kts # KMP setup: targets, XCFramework, SKIE, Room
+├── schemas/ # Exported Room schemas (commit when DB schema changes)
+└── src/
+ ├── commonMain/kotlin/
+ │ ├── Account.kt # Public KMP model for an Authenticator account
+ │ ├── AppStatus.kt
+ │ ├── AuthenticatorFacade.kt # Main public entry point for both Android and iOS
+ │ ├── CredentialsForMigration.kt
+ │ ├── Issue.kt
+ │ ├── matomo/ # Shared Matomo tracking helpers
+ │ ├── models/ # Public DTOs / domain models
+ │ │ └── migration/ # Shared* models exposed to iOS (e.g. SharedApiToken, SharedUserProfile)
+ │ ├── network/ # Public network types
+ │ │ ├── exceptions/
+ │ │ └── interfaces/
+ │ ├── repository/ # Public repository interfaces
+ │ ├── room/ # Public Room entities / DAOs (DBs are constructed per platform)
+ │ │ └── appsettings/
+ │ └── internal/ # Everything `internal` (not exported)
+ │ ├── KeyManager.kt # `KeyPairManager()` companion delegates to expect/actual createKeyPairManager()
+ │ ├── db/
+ │ ├── extensions/
+ │ ├── managers/ # e.g. MigrationManager (deletes legacy account + DB after migration)
+ │ ├── models/
+ │ ├── network/ # ApiClientProvider, request builders
+ │ ├── otp/ # TOTP/HOTP code generation
+ │ ├── repositories/
+ │ ├── requests/
+ │ ├── utils/
+ │ └── webauthn/
+ ├── androidMain/kotlin/
+ │ ├── db/ # Android Room DB builder
+ │ └── internal/
+ │ ├── KeyPairManagerImpl.android.kt # Stores keys as files under appCtx.filesDir/passkeys
+ │ ├── db/
+ │ ├── network/utils/
+ │ ├── otp/
+ │ ├── room/
+ │ │ └── legacy/ # Legacy DB used by MigrationManager
+ │ └── utils/
+ ├── appleMain/kotlin/
+ │ ├── db/ # Apple Room DB builder
+ │ └── internal/
+ │ ├── KeyGen.apple.kt # Keychain-backed key generation (tags: "$userId-$keyId" / "$userId-$keyId.pub")
+ │ ├── KeyPairManagerImpl.apple.kt
+ │ ├── db/
+ │ ├── extensions/
+ │ ├── network/utils/
+ │ ├── otp/
+ │ └── utils/
+ ├── iosMain/kotlin/internal/utils/
+ ├── macosMain/kotlin/internal/utils/
+ ├── commonTest/kotlin/internal/ # Shared tests (kotlin.test + ktor-client-mock)
+ ├── androidHostTest/kotlin/internal/ # JVM unit tests for android-specific code
+ ├── androidDeviceTest/kotlin/internal/ # Espresso / AndroidJUnit tests (require a device)
+ └── appleTest/kotlin/internal/ # Tests for Apple targets
+```
+
+## Local Norms
+
+### Architecture & Design
+
+- **Public surface**: anything not marked `internal` is exported to the Android app and to Swift (via the `CoreAuthenticator`
+ XCFramework). Be deliberate about visibility - default to `internal`.
+- **Entry point**: `AuthenticatorFacade.kt` is the main public facade. Prefer adding/extending facade methods over exposing
+ internal classes.
+- **expect/actual**: Cross-platform abstractions use either `expect`/`actual` declarations or a common abstract class with a
+ per-platform `createX()` factory (see `internal/KeyManager.kt` - `KeyPairManager()` companion `invoke` delegates to an
+ expect/actual `createKeyPairManager()`).
+- **Internal package**: All non-API code lives under `internal/` packages and is marked `internal`. Do not move internal types
+ into the public packages just to share them across modules - use a typealias or a thin public wrapper instead.
+- **iOS naming**: Models exported to iOS that may collide with Swift built-ins use the `Shared*` prefix (e.g. `SharedApiToken`,
+ `SharedUserProfile`, `SharedSecurity`). Follow this convention when adding new migration / interop models.
+- **Migration**: `MigrationManager` is responsible for moving data from legacy storage. After a successful migration it deletes
+ the legacy account and deletes the legacy DB when no legacy accounts remain - preserve this invariant when modifying it.
+- **API client**: build all HTTP clients through `internal/network/ApiClientProvider.kt`. Keep its
+ `Json { coerceInputValues = true; ignoreUnknownKeys = true; decodeEnumsCaseInsensitive }` configuration intact - the API
+ tolerates unknown fields and case differences by design.
+- **Key storage**:
+ - Android: files under `appCtx.filesDir/passkeys` named `"$userId-$keyId-(public|private).key"`.
+ - Apple Keychain tags: `"$userId-$keyId"` (private) and `"$userId-$keyId.pub"` (public).
+ Maintain these conventions when adding key-management code; do not rename existing entries (existing users would lose their
+ keys).
+- **Room schemas**: When you change a Room entity, the generated schema under `multiplatform-lib/schemas/` must be committed
+ alongside a migration.
+
+### Commands (run from repo root)
+
+```bash
+# Initialize Core submodule (required for any Gradle command)
+git submodule update --init --recursive
+
+# Assemble the Android variant of the library
+./gradlew :multiplatform-lib:assemble
+
+# Compile all KMP targets
+./gradlew :multiplatform-lib:build
+
+# Common (JVM) tests
+./gradlew :multiplatform-lib:commonTest
+./gradlew :multiplatform-lib:androidHostTest
+
+# All tests (host + simulator)
+./gradlew :multiplatform-lib:allTests
+
+# Apple tests (require macOS host)
+./gradlew :multiplatform-lib:iosSimulatorArm64Test
+./gradlew :multiplatform-lib:macosArm64Test
+
+# Build the iOS / macOS XCFramework (CoreAuthenticator.xcframework)
+./buildXCFramework
+```
+
+### Code Style
+
+Same general Kotlin rules as the app (see `app/AGENTS.md`):
+
+- 130-char line limit (exceptions: single-line comments, imports, hardcoded strings).
+- Max 1 consecutive blank line; 1 blank line after early-return blocks.
+- GPLv3 copyright header in every file. No blank line between the closing `*/` and `package`.
+- Official Kotlin code style (`kotlin.code.style=official`).
+- Trivial control flow: one-liner. Non-trivial: braces + newlines.
+
+KMP-specific rules:
+
+- Use `expect`/`actual` for platform APIs; keep the `expect` signature minimal and document any platform constraints.
+- Prefer `kotlinx.coroutines.flow.Flow` / `suspend` functions in the public API - SKIE turns them into idiomatic Swift
+ `AsyncSequence` / `async` functions.
+- Avoid Java-only APIs in `commonMain` / `appleMain`. Use `kotlinx.io`, `okio`, or `kotlinx.datetime` instead.
+- Keep `androidMain` dependencies behind `expect`/`actual` - do not add `androidx.*` imports to `commonMain`.
+- For Apple-only types in public APIs, mark them with `@Throws(...)` where Swift needs to handle errors.
+
+### Testing
+
+- **commonTest**: pure-Kotlin tests using `kotlin.test` and `ktor-client-mock` for HTTP. Add new business-logic tests here
+ whenever possible so they run on every target.
+- **androidHostTest**: JVM unit tests for code that needs Android-host-side resources (no device required).
+- **androidDeviceTest**: instrumented tests (`androidx.test.junit`, `espresso-core`) - require a device/emulator.
+- **appleTest**: tests for the Apple actuals.
+
+### Public API stability
+
+- The Android app and the iOS / macOS app consume this module as a binary contract. Treat any change to non-`internal`
+ declarations as a breaking change candidate: prefer adding to the API over modifying it.
+- When renaming a public class/function that is consumed by iOS, also check `Package.swift` and the Swift side for impact (the
+ framework name `CoreAuthenticator` and `bundleId` `com.infomaniak.multiplatform-authenticator.CoreAuthenticator` must stay in
+ sync with the iOS project).
+
+## Learned Preferences
+
+Add KMP-specific corrections here as they occur.
+
+- Prefer `internal` visibility by default; only widen to `public` when the symbol is part of the cross-platform API surface.
+- Use the `Shared*` prefix for migration / interop models exposed to Swift to avoid name collisions.
+
+## Self-correction
+
+- **Stale Map**: Update when you encounter new source sets, packages, or expect/actual splits not listed.
+- **Schema drift**: If `./gradlew :multiplatform-lib:build` regenerates files under `schemas/`, commit them as part of the same
+ change.
+- **Reference Core / app**: When editing code that is bridged into the Android app, cross-check `app/AGENTS.md`; when editing Core
+ imports, check `Core/AGENTS.md`.
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..1692c27
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,140 @@
+import co.touchlab.skie.configuration.DefaultArgumentInterop
+import com.android.build.api.dsl.androidLibrary
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(core.plugins.android.kmp.library)
+ alias(core.plugins.kotlin.serialization)
+ alias(libs.plugins.skie)
+ alias(libs.plugins.androidx.room)
+ alias(libs.plugins.ksp)
+ kotlin("plugin.parcelize")
+}
+
+val androidCompileSdk: Int by rootProject.extra
+val androidMinSdk: Int by rootProject.extra
+val javaVersion: JavaVersion by rootProject.extra
+
+kotlin {
+ @Suppress("UnstableApiUsage")
+ androidLibrary {
+ namespace = "com.infomaniak.auth.multiplatform"
+ compileSdk = androidCompileSdk
+ minSdk = androidMinSdk
+
+ withDeviceTestBuilder {
+ sourceSetTreeName = "test"
+ }
+ withHostTest {}
+ compilerOptions {
+ jvmTarget.set(JvmTarget.fromTarget(javaVersion.toString()))
+ }
+ }
+
+ val xcframeworkName = "CoreAuthenticator"
+ val xcf = project.XCFramework(xcframeworkName)
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ macosArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = xcframeworkName
+ binaryOption("bundleId", "com.infomaniak.multiplatform-authenticator.${xcframeworkName}")
+ xcf.add(this)
+ linkerOpts.add("-lsqlite3")
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.sqlite.bundled)
+ }
+ }
+
+ sourceSets {
+ commonMain {
+ dependencies {
+ implementation(core.kotlinx.coroutines.core)
+ implementation(core.kotlinx.serialization.json)
+ implementation(core.kotlinx.serialization.cbor)
+ implementation(core.ktor.client.core)
+ implementation(core.ktor.client.auth)
+ implementation(core.ktor.client.content.negociation)
+ implementation(core.ktor.client.json)
+ implementation(core.ktor.client.encoding)
+ implementation(core.okio)
+ implementation(libs.osmerion.kotlin.base32)
+ }
+ }
+ commonTest {
+ dependencies {
+ implementation(kotlin("test"))
+ implementation(core.kotlinx.coroutines.test)
+ implementation(core.ktor.client.mock)
+ }
+ }
+ androidMain {
+ dependencies {
+ implementation(core.ktor.client.okhttp)
+ implementation(core.splitties.appctx)
+ implementation(core.splitties.bitflags)
+ }
+ }
+ appleMain {
+ dependencies {
+ implementation(core.ktor.client.darwin)
+ }
+ }
+
+ listOf("iosArm64", "iosSimulatorArm64", "macosArm64").forEach { target ->
+ getByName("${target}Main") {
+ kotlin.srcDir(layout.buildDirectory.dir("generated/ksp/$target/${target}Main/kotlin"))
+ }
+ }
+
+ val androidDeviceTest by getting {
+ dependencies {
+ implementation(core.androidx.junit)
+ implementation(core.androidx.espresso.core)
+ }
+ }
+ }
+
+ compilerOptions {
+ freeCompilerArgs.add("-Xexpect-actual-classes")
+ freeCompilerArgs.add("-Xreturn-value-checker=full")
+ }
+}
+
+skie {
+ features {
+ group {
+ DefaultArgumentInterop.Enabled(true)
+ DefaultArgumentInterop.MaximumDefaultArgumentCount(7)
+ }
+ }
+ build {
+ produceDistributableFramework()
+ }
+}
+
+room {
+ schemaDirectory("$projectDir/schemas")
+}
+
+dependencies {
+ add("kspAndroid", libs.androidx.room.compiler)
+ add("kspIosSimulatorArm64", libs.androidx.room.compiler)
+ add("kspIosArm64", libs.androidx.room.compiler)
+ add("kspMacosArm64", libs.androidx.room.compiler)
+}
+
+listOf("IosArm64", "IosSimulatorArm64", "MacosArm64").forEach { target ->
+ tasks.named("compileKotlin$target") {
+ dependsOn("kspKotlin$target")
+ }
+}
diff --git a/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json b/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json
new file mode 100644
index 0000000..12af54c
--- /dev/null
+++ b/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json
@@ -0,0 +1,70 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "969c0701051de7596ddc1d094141aed5",
+ "entities": [
+ {
+ "tableName": "AccountEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fullName` TEXT NOT NULL, `initials` TEXT NOT NULL, `email` TEXT NOT NULL, `avatarUrl` TEXT, `status` INTEGER NOT NULL, `securityScore` INTEGER, `lastPasswordUpdate` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fullName",
+ "columnName": "fullName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "initials",
+ "columnName": "initials",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "email",
+ "columnName": "email",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatarUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "securityScore",
+ "columnName": "securityScore",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastPasswordUpdate",
+ "columnName": "lastPasswordUpdate",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '969c0701051de7596ddc1d094141aed5')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/2.json b/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/2.json
new file mode 100644
index 0000000..6331ffc
--- /dev/null
+++ b/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/2.json
@@ -0,0 +1,70 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "969c0701051de7596ddc1d094141aed5",
+ "entities": [
+ {
+ "tableName": "AccountEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fullName` TEXT NOT NULL, `initials` TEXT NOT NULL, `email` TEXT NOT NULL, `avatarUrl` TEXT, `status` INTEGER NOT NULL, `securityScore` INTEGER, `lastPasswordUpdate` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fullName",
+ "columnName": "fullName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "initials",
+ "columnName": "initials",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "email",
+ "columnName": "email",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatarUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "securityScore",
+ "columnName": "securityScore",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastPasswordUpdate",
+ "columnName": "lastPasswordUpdate",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '969c0701051de7596ddc1d094141aed5')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/schemas/com.infomaniak.auth.lib.internal.room.legacy.OTPUserDatabase/1.json b/schemas/com.infomaniak.auth.lib.internal.room.legacy.OTPUserDatabase/1.json
new file mode 100644
index 0000000..bd74b6a
--- /dev/null
+++ b/schemas/com.infomaniak.auth.lib.internal.room.legacy.OTPUserDatabase/1.json
@@ -0,0 +1,54 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "e323696b211b6b5fc6c6f1899072972e",
+ "entities": [
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userid` INTEGER NOT NULL, `email` TEXT NOT NULL, `displayname` TEXT NOT NULL, `avatar` TEXT, `secret` TEXT NOT NULL, PRIMARY KEY(`userid`))",
+ "fields": [
+ {
+ "fieldPath": "userID",
+ "columnName": "userid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "email",
+ "columnName": "email",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayname",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "avatar",
+ "columnName": "avatar",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "secret",
+ "columnName": "secret",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userid"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e323696b211b6b5fc6c6f1899072972e')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/schemas/com.infomaniak.auth.lib.room.appsettings.AppSettingsDatabase/1.json b/schemas/com.infomaniak.auth.lib.room.appsettings.AppSettingsDatabase/1.json
new file mode 100644
index 0000000..2f8d5bc
--- /dev/null
+++ b/schemas/com.infomaniak.auth.lib.room.appsettings.AppSettingsDatabase/1.json
@@ -0,0 +1,43 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "d5b1ea8c03002a235143ded6c41868c8",
+ "entities": [
+ {
+ "tableName": "AppSettingsEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isAppLockEnabled` INTEGER NOT NULL, `theme` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isAppLockEnabled",
+ "columnName": "isAppLockEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "theme",
+ "columnName": "theme",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5b1ea8c03002a235143ded6c41868c8')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/androidDeviceTest/kotlin/WebAuthnTest.kt b/src/androidDeviceTest/kotlin/WebAuthnTest.kt
new file mode 100644
index 0000000..1c3da8b
--- /dev/null
+++ b/src/androidDeviceTest/kotlin/WebAuthnTest.kt
@@ -0,0 +1,77 @@
+/*
+ * 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
+
+import com.infomaniak.auth.lib.internal.CryptoObjectsBuilder
+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
+import com.infomaniak.auth.lib.internal.models.User
+import com.infomaniak.auth.lib.internal.webauthn.KeyAlgorithm
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlin.test.Test
+import kotlin.test.fail
+
+class WebAuthnTest {
+
+ @OptIn(ExperimentalSerializationApi::class)
+ @Test
+ fun registerPasskeyGeneration() = runTest {
+ // This is sent from [GET] /api/authenticator/passkeys/options
+ val passkeysOptions = PasskeysOptions(
+ session = "a-beautiful-session",
+ challenge = "U3NkRnF6RlVwUnpKRGhVMw",
+ relyingParty = RelyingParty(
+ id = "infomaniak.com",
+ name = "Infomaniak",
+ iconUrl = null,
+ ),
+ user = User(
+ id = "MQ",
+ name = "test@user.com",
+ displayName = "Test"
+ ),
+ pubKeyCredParams = listOf(
+ PubKeyCredParam(
+ type = "public-key",
+ algorithm = KeyAlgorithm.ES256
+ )
+ ),
+ excludeCredentials = emptyList(),
+ )
+
+ // Just getting the public key to generate RegisterPasskey object
+ val cryptoObjectsBuilder = CryptoObjectsBuilder()
+ val keyPairManager = KeyPairManager()
+ val userId = 12345L
+ val keyIdAsByteArray = cryptoObjectsBuilder.getKeyIds().first
+ val keyIdAsString = cryptoObjectsBuilder.getKeyIds().second
+ keyPairManager.generateNewKey(userId, keyIdAsString)?.let { fail("Key generation failed: ${it.details}") }
+ val publicKeyAsByteArray = keyPairManager.retrievePublicKey(userId, keyIdAsString).firstOrNull()!!
+
+ // Nothing to test on the generated object for now
+ val _ = cryptoObjectsBuilder.buildRegisterPasskey(
+ publicKey = publicKeyAsByteArray,
+ passkeysOptions = passkeysOptions,
+ rawId = keyIdAsByteArray,
+ id = keyIdAsString,
+ )
+ }
+}
diff --git a/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt b/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt
new file mode 100644
index 0000000..23c663c
--- /dev/null
+++ b/src/androidDeviceTest/kotlin/internal/KeyPairManagerTest.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertNull
+import kotlin.test.fail
+
+class KeyPairManagerTest {
+
+ @Test
+ fun testKeyPairManager() {
+ val keyPairManager = KeyPairManager()
+
+ runTest {
+ val userId = 12345L
+ val keyId = "keyId"
+ val error = keyPairManager.generateNewKey(userId, keyId)
+ assertNull(error)
+
+ val publicKey = keyPairManager.retrievePublicKey(userId, keyId)
+ when (publicKey) {
+ is Xor.First -> Unit // OK
+ is Xor.Second -> fail("Couldn't generate the key")
+ }
+ }
+ }
+}
diff --git a/src/androidHostTest/kotlin/KeyCoordinatesTest.kt b/src/androidHostTest/kotlin/KeyCoordinatesTest.kt
new file mode 100644
index 0000000..bd74118
--- /dev/null
+++ b/src/androidHostTest/kotlin/KeyCoordinatesTest.kt
@@ -0,0 +1,161 @@
+/*
+ * 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
+
+import com.infomaniak.auth.lib.internal.AsnOneTypes
+import com.infomaniak.auth.lib.internal.generateEcKeyPair
+import com.infomaniak.auth.lib.internal.utils.getKeyCoordinates
+import com.infomaniak.auth.lib.internal.utils.keyCoordinatesOf
+import com.infomaniak.auth.lib.internal.webauthn.PublicKeyXY
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class KeyCoordinatesTest {
+
+ @Test
+ fun `test generated extracted coordinates are of the right size`() = runTest {
+ val publicKeyData = generateEcKeyPair().getOrThrow().public.encoded
+ println("key: ${publicKeyData.toHexString()}")
+ val (x, y) = getKeyCoordinates(publicKeyData)
+ println("x: ${x.toHexString()}")
+ println("y: ${y.toHexString()}")
+ assertEquals(expected = 32, actual = x.size)
+ assertEquals(expected = 32, actual = y.size)
+ }
+
+ @Test
+ fun `test generated key coordinates manual and API powered extraction match`() = runTest {
+ val publicKeyData = generateEcKeyPair().getOrThrow().public.encoded
+ `check Java Security API and manual x509 key coordinates extraction match`(publicKeyData)
+ }
+
+ @Test
+ fun `test hardcoded key coordinates manual and API powered extraction match`() = runTest {
+ val publicKeyData = x509key.hexToByteArray()
+ `check Java Security API and manual x509 key coordinates extraction match`(publicKeyData)
+ }
+
+ private fun `check Java Security API and manual x509 key coordinates extraction match`(x509Key: ByteArray) {
+ println("x509Key: ${x509Key.toHexString()}")
+ val (x, y) = getKeyCoordinates(x509Key)
+ val (manualX, manualY) = getKeyCoordinatesFromX509Key(x509Key)
+ println("x: ${x.toHexString()}")
+ println("y: ${y.toHexString()}")
+ assertEquals(expected = x.toHexString(), actual = manualX.toHexString())
+ assertEquals(expected = y.toHexString(), actual = manualY.toHexString())
+ }
+
+ @Test
+ fun `test X509 key coordinates extraction`() = runTest {
+ val publicKeyData = x509key.hexToByteArray()
+ println("key: ${publicKeyData.toHexString()}")
+ val (x, y) = getKeyCoordinatesFromX509Key(publicKeyData)
+ println("x: ${x.toHexString()}")
+ println("y: ${y.toHexString()}")
+ assertEquals(expected = x509keyX, actual = x.toHexString())
+ assertEquals(expected = x509keyY, actual = y.toHexString())
+ }
+
+ @Test
+ fun `test iOS-generated key coordinates extraction`() = runTest {
+ val publicKeyData = iosGeneratedP256Key.hexToByteArray()
+ println("key(size = ${publicKeyData.size}): ${publicKeyData.toHexString()}")
+ assertEquals(expected = 65, actual = publicKeyData.size)
+ val (x, y) = keyCoordinatesOf(uncompressedP256Key = publicKeyData)
+ println("x: ${x.toHexString()}")
+ println("y: ${y.toHexString()}")
+ assertEquals(expected = iosGeneratedP256KeyX, actual = x.toHexString())
+ assertEquals(expected = iosGeneratedP256KeyY, actual = y.toHexString())
+ }
+}
+
+// iOS Generates P256 uncompressed keys (65 bytes long). The first byte is a "marker". The remaining bytes are the 2 coordinates.
+private const val iosGeneratedP256Key =
+ "04294dbea2e9f02fc4884f31de5f2db9986b976e51cb71011efdabaca9e42ee50af1d1db7e11194f006ab1ba9610815bb63e04d9f532a1ca998bdc8b16dc3a9b28"
+private const val iosGeneratedP256KeyX = "294dbea2e9f02fc4884f31de5f2db9986b976e51cb71011efdabaca9e42ee50a"
+private const val iosGeneratedP256KeyY = "f1d1db7e11194f006ab1ba9610815bb63e04d9f532a1ca998bdc8b16dc3a9b28"
+
+// Android Generates x509 keys. There are APIs in java.security (X509EncodedKeySpec and ECPublicKey) to extract the key coords.
+private const val x509key =
+ "3059301306072a8648ce3d020106082a8648ce3d0301070342000400735909928aa144938662fc225059415ba2bfad884540e7e332949ef0f483d68024795a478fededd856f3823721f210bc0f31648f732959459a48bb5c5a3959"
+private const val x509keyX = "00735909928aa144938662fc225059415ba2bfad884540e7e332949ef0f483d6"
+private const val x509keyY = "8024795a478fededd856f3823721f210bc0f31648f732959459a48bb5c5a3959"
+
+private fun getKeyCoordinatesFromX509Key(key: ByteArray): PublicKeyXY {
+ val uncompressedKey = parseX509SubjectPublicKeyInfoIntoCompressedP256UncompressedKey(key)
+ require(uncompressedKey[0] == 0x04.toByte()) { "Expected uncompressed format" }
+ require(uncompressedKey.size == 65) { "Invalid key length: ${uncompressedKey.size}" }
+
+ val x = uncompressedKey.copyOfRange(1, 33)
+ val y = uncompressedKey.copyOfRange(33, 65)
+
+ return PublicKeyXY(x, y)
+}
+
+private fun parseX509SubjectPublicKeyInfoIntoCompressedP256UncompressedKey(bytes: ByteArray): ByteArray {
+ var offset = 0
+
+ // SubjectPublicKeyInfo SEQUENCE
+ require(bytes[offset++] == AsnOneTypes.SEQUENCE)
+ val seqLength = readAsn1Length(bytes, offset)
+ offset += getLengthBytes(seqLength)
+
+ // AlgorithmIdentifier SEQUENCE
+ require(bytes[offset++] == AsnOneTypes.SEQUENCE)
+ val algoIdLength = readAsn1Length(bytes, offset)
+ offset += getLengthBytes(algoIdLength)
+ offset += algoIdLength // Also skip the algorithm identifier sub-sequence
+
+ // BIT STRING
+ require(bytes[offset++] == AsnOneTypes.BIT_STRING)
+ val bitStringLength = readAsn1Length(bytes, offset)
+ offset += getLengthBytes(bitStringLength)
+
+ // Skip unused bits
+ offset++
+
+ return bytes.copyOfRange(offset, bytes.size)
+}
+
+/**
+ * ASN.1 (Abstract Syntax Notation One)
+ */
+private fun readAsn1Length(bytes: ByteArray, offset: Int): Int {
+ val firstByte = bytes[offset].toInt() and 0xFF
+ return if (firstByte and 0x80 == 0) {
+ firstByte
+ } else {
+ val numBytes = firstByte and 0x7F
+ var length = 0
+ for (i in 1..numBytes) {
+ length = (length shl 8) or (bytes[offset + i].toInt() and 0xFF)
+ }
+ length
+ }
+}
+
+private fun getLengthBytes(length: Int): Int {
+ return when {
+ length < 0x80 -> 1
+ length < 0x100 -> 2
+ length < 0x10000 -> 3
+ else -> 4
+ }
+}
diff --git a/src/androidHostTest/kotlin/KeyCoseTest.kt b/src/androidHostTest/kotlin/KeyCoseTest.kt
new file mode 100644
index 0000000..c295e77
--- /dev/null
+++ b/src/androidHostTest/kotlin/KeyCoseTest.kt
@@ -0,0 +1,80 @@
+/*
+ * 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
+
+import com.infomaniak.auth.lib.internal.webauthn.PublicKeyXY
+import com.infomaniak.auth.lib.internal.webauthn.keyCoseOf
+import okio.Buffer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class KeyCoseTest {
+
+ @Test
+ fun testKeyCoseOfResult() {
+ // Arbitrary binary data for testing
+ val x = ByteArray(32) { it.toByte() }
+ val y = ByteArray(32) { (it + 32).toByte() }
+
+ // Check that the keyCoseOf function, using kotlinx.serialization yields the same result as our former manual approach.
+
+ val publicKeyCose = keyCoseOf(x = x, y = y)
+ val manualResult = encodeKeyCoseManually(PublicKeyXY(x, y))
+
+ assertEquals(
+ expected = publicKeyCose.map { it.toHexString() },
+ actual = manualResult.map { it.toHexString() }
+ )
+ }
+}
+
+private fun encodeKeyCoseManually(publicKeyXY: PublicKeyXY): ByteArray {
+ return Buffer().apply {
+ // Map with 5 elements
+ writeByte(0xA5)
+
+ // 1 (kty) -> 2 (EC2)
+ writeByte(0x01) // unsigned int 1
+ writeByte(0x02) // unsigned int 2
+
+ // 3 (alg) -> -7 (ES256)
+ writeByte(0x03) // unsigned int 3
+ writeByte(0x26) // negative int -7 (0x26 = -1 - 6)
+
+ // -1 (crv) -> 1 (P-256)
+ writeByte(0x20) // negative int -1 (0x20 = -1 - 0)
+ writeByte(0x01) // unsigned int 1
+
+
+ // -2 (x) -> ByteString(32)
+ writeByte(0x21) // negative int -2 (0x21 = -1 - 1)
+ writeByteString(publicKeyXY.x)
+
+ // -3 (y) -> ByteString(32)
+ writeByte(0x22) // negative int -3 (0x22 = -1 - 2)
+ writeByteString(publicKeyXY.y)
+ }.readByteArray()
+}
+
+private fun Buffer.writeByteString(bytes: ByteArray) {
+ // Byte string of 32 bytes (0x58 0x20)
+ writeByte(0x58) // byte string, 1-byte length
+ writeByte(bytes.size)
+ write(bytes)
+}
diff --git a/src/androidHostTest/kotlin/WebAuthnAttestationObjectTest.kt b/src/androidHostTest/kotlin/WebAuthnAttestationObjectTest.kt
new file mode 100644
index 0000000..0f53877
--- /dev/null
+++ b/src/androidHostTest/kotlin/WebAuthnAttestationObjectTest.kt
@@ -0,0 +1,96 @@
+/*
+ * 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:Suppress("SameParameterValue")
+
+package com.infomaniak.auth.lib
+
+import com.infomaniak.auth.lib.internal.webauthn.createEncodedWebAuthnAttestationObject
+import okio.Buffer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class WebAuthnAttestationObjectTest {
+
+ @Test
+ fun testResultMatchesTheManualWay() {
+ // Arbitrary binary data for testing
+ val fmt = "lol - ah!"
+ val authData = "Whatever".toByteArray()
+
+
+ // Check that the kotlinx.serialization way yields the same result as our former manual approach.
+
+ val serializationResult = createEncodedWebAuthnAttestationObject(fmt, authData)
+ val manualResult = encodeWebAuthnAttestationObjectManually(fmt, authData)
+
+ assertEquals(
+ expected = serializationResult.map { it.toHexString() },
+ actual = manualResult.map { it.toHexString() }
+ )
+ }
+
+
+}
+
+private fun encodeWebAuthnAttestationObjectManually(
+ fmt: String,
+ authData: ByteArray
+): ByteArray {
+ return Buffer().apply {
+
+ // Map start
+ writeByte(0xA2) // 2 items
+
+ // "fmt": "none"
+ writeText("fmt")
+ writeText(fmt)
+
+ // "authData":
+ writeText("authData")
+ buffer.writeByteString(authData)
+ }.readByteArray()
+}
+
+private fun Buffer.writeText(text: String) {
+ val bytes = text.encodeToByteArray()
+ writeByte(0x60 + bytes.size) // 0x60 = text string base
+ write(bytes)
+}
+
+private fun Buffer.writeByteString(bytes: ByteArray) {
+ when {
+ bytes.size <= 23 -> {
+ writeByte(0x40 + bytes.size) // 0x40 = byte string base
+ }
+ bytes.size <= 255 -> {
+ writeByte(0x58) // byte string, 1-byte length follows
+ writeByte(bytes.size)
+ }
+ bytes.size <= 65535 -> {
+ writeByte(0x59) // byte string, 2-byte length follows
+ writeByte(bytes.size shr 8)
+ writeByte(bytes.size and 0xFF)
+ }
+ else -> {
+ writeByte(0x5A) // byte string, 4-byte length follows
+ writeInt(bytes.size)
+ }
+ }
+ write(bytes)
+}
diff --git a/src/androidHostTest/kotlin/internal/SigningTest.android.kt b/src/androidHostTest/kotlin/internal/SigningTest.android.kt
new file mode 100644
index 0000000..d2c8fe1
--- /dev/null
+++ b/src/androidHostTest/kotlin/internal/SigningTest.android.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 .
+ */
+
+package com.infomaniak.auth.lib.internal
+
+import kotlin.test.Test
+
+class SigningTest : SigningTestBase() {
+
+ @Test
+ fun `__this is a test class with tests in the super class`() {}
+
+ override fun getKeyPair(): Pair {
+ val keyPair = generateEcKeyPair().getOrThrow()
+ return keyPair.private.encoded to keyPair.public.encoded
+ }
+
+ override fun getTestDataSet(): List = listOf(
+ TestData(
+ privateKey = "3041020100301306072a8648ce3d020106082a8648ce3d0301070427302502010104208e6a72ffa2594ad40a338d943925a8512690127a07488e839630f6cd96556cc0".hexToByteArray(),
+ publicKey = "3059301306072a8648ce3d020106082a8648ce3d0301070342000403f2c76fea988e5fee5fb1c9c7097b8e4e094813043065e4f6e1a8b7170aedce9fafbc81d2acab90037f0d201a5253681a772c4e6d1bde2141c9a43ed61b8dfa".hexToByteArray(),
+ dataToSign = "4c4f4c".hexToByteArray(),
+ signature = "3045022100eb6c66fbc84920cc153e3c1090a397f5b1cb4aa0c190a4d0ad268e9015c1df260220320675c44cf0decc1578818682137603bf169a98676118cef8a89f9002543563".hexToByteArray(),
+ )
+ )
+}
diff --git a/src/androidMain/kotlin/db/AppSettingsDatabase.android.kt b/src/androidMain/kotlin/db/AppSettingsDatabase.android.kt
new file mode 100644
index 0000000..790bd1d
--- /dev/null
+++ b/src/androidMain/kotlin/db/AppSettingsDatabase.android.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.db
+
+import android.content.Context
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.infomaniak.auth.lib.room.appsettings.AppSettingsDatabase
+import com.infomaniak.auth.lib.room.appsettings.getAppSettingsRoomDatabase
+
+fun getAppSettingsDatabaseBuilder(context: Context): RoomDatabase.Builder {
+ val appContext = context.applicationContext
+ val dbFile = appContext.getDatabasePath("app_settings.db")
+ return Room.databaseBuilder(
+ context = appContext,
+ name = dbFile.absolutePath
+ )
+}
+
+fun getAppSettingsRoomDatabase(context: Context): AppSettingsDatabase {
+ return getAppSettingsRoomDatabase(getAppSettingsDatabaseBuilder(context))
+}
diff --git a/src/androidMain/kotlin/internal/KeyGen.android.kt b/src/androidMain/kotlin/internal/KeyGen.android.kt
new file mode 100644
index 0000000..82f3087
--- /dev/null
+++ b/src/androidMain/kotlin/internal/KeyGen.android.kt
@@ -0,0 +1,114 @@
+/*
+ * 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 android.content.pm.PackageManager
+import android.os.Build.VERSION.SDK_INT
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import splitties.bitflags.withFlag
+import splitties.init.appCtx
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.spec.ECGenParameterSpec
+import kotlin.time.Duration.Companion.seconds
+
+internal const val keyStoreProvider = "AndroidKeyStore"
+
+internal fun generateEcKeyPair(): Result = runCatching {
+ val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC)
+ val ecGenParamSpec = ECGenParameterSpec("secp256r1")
+
+ keyPairGenerator.initialize(ecGenParamSpec)
+ keyPairGenerator.generateKeyPair()
+}
+
+internal fun generateKeyPairInTheKeystore(
+ alias: String,
+ privateKeyPurposes: KeyPurposes = KeyPurposes.privateKeyDefaults,
+ publicKeyPurposes: KeyPurposes = KeyPurposes.publicKeyDefaults,
+ keyAccessGuard: KeyAccessGuard,
+ preferStrongbox: Boolean,
+): Result = runCatching {
+ val keyPairGenerator = KeyPairGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_EC,
+ keyStoreProvider
+ )
+ val parameterSpec = KeyGenParameterSpec.Builder(
+ alias,
+ (privateKeyPurposes + publicKeyPurposes).asFlags()
+ ).also {
+ it.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
+ if (preferStrongbox && SDK_INT >= 28) {
+ if (appCtx.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)) {
+ // Note: We can see the Strongbox version since API 32 (Android 12).
+ // See the Javadoc of `PackageManager.FEATURE_STRONGBOX_KEYSTORE` (mentioned just above).
+ it.setIsStrongBoxBacked(true)
+ }
+ it.setUserPresenceRequired(true)
+ }
+ keyAccessGuard.applyTo(it)
+ }.build()
+
+ keyPairGenerator.initialize(parameterSpec)
+ val _ = keyPairGenerator.generateKeyPair()
+}
+
+private fun KeyPurposes.asFlags(): Int = 0
+ .withFlag(if (signing) KeyProperties.PURPOSE_SIGN else 0)
+ .withFlag(if (verifying) KeyProperties.PURPOSE_VERIFY else 0)
+ .withFlag(if (encrypting) KeyProperties.PURPOSE_ENCRYPT else 0)
+ .withFlag(if (decrypting) KeyProperties.PURPOSE_DECRYPT else 0)
+ .withFlag(if (SDK_INT >= 28 && (wrapping || unwrapping)) KeyProperties.PURPOSE_WRAP_KEY else 0)
+// PURPOSE_ATTEST_KEY and PURPOSE_AGREE_KEY left out as we don't need them at the moment.
+
+private fun KeyAccessGuard.applyTo(builder: KeyGenParameterSpec.Builder) {
+ when (this) {
+ is KeyAccessGuard.Authenticated -> applyTo(builder)
+ KeyAccessGuard.UserConfirmation -> {
+ if (SDK_INT >= 28) builder.setUserConfirmationRequired(true)
+ }
+ KeyAccessGuard.Unguarded -> Unit
+ }
+}
+
+private fun KeyAccessGuard.Authenticated.applyTo(builder: KeyGenParameterSpec.Builder) {
+ builder.setUserAuthenticationRequired(true)
+ builder.setInvalidatedByBiometricEnrollment(this is KeyAccessGuard.Biometry.Current)
+ if (SDK_INT >= 30) {
+ val flags = when (this) {
+ is KeyAccessGuard.Biometry -> KeyProperties.AUTH_BIOMETRIC_STRONG
+ KeyAccessGuard.DevicePasscode -> KeyProperties.AUTH_DEVICE_CREDENTIAL
+ KeyAccessGuard.DevicePasscodeOrNewBiometrics -> {
+ KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
+ }
+ }
+ builder.setUserAuthenticationParameters(0, flags)
+ } else {
+ when (this) {
+ is KeyAccessGuard.DevicePasscode, is KeyAccessGuard.DevicePasscodeOrNewBiometrics -> {
+ // Before API 30, setting this to a positive value is the only way to allow passcode.
+ val validityDuration = 10.seconds
+ @Suppress("deprecation")
+ builder.setUserAuthenticationValidityDurationSeconds(validityDuration.inWholeSeconds.toInt())
+ }
+ else -> Unit
+ }
+ }
+}
diff --git a/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt
new file mode 100644
index 0000000..215f081
--- /dev/null
+++ b/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt
@@ -0,0 +1,134 @@
+/*
+ * 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
+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 fun createKeyPairManager(): KeyPairManager = KeyPairManagerAndroidImpl()
+
+private class KeyPairManagerAndroidImpl : KeyPairManager() {
+
+ private val keysDir by lazy {
+ appCtx.filesDir.resolve("passkeys").also { passkeysDir ->
+ passkeysDir.mkdir()
+ }
+ }
+
+ @Throws(Exception::class)
+ override suspend fun generateNewKey(userId: Long, keyId: String): Failure.KeyManagement.GenerationFailed? {
+ val keyPair = generateEcKeyPair().getOrElse {
+ return Failure.KeyManagement.GenerationFailed(it.toString())
+ }
+
+ Dispatchers.IO {
+ keyFile(userId = userId, keyId = keyId, isPublic = false).writeBytes(keyPair.private.encoded)
+ keyFile(userId = userId, keyId = keyId, isPublic = true).writeBytes(keyPair.public.encoded)
+ }
+ return null
+ }
+
+ override suspend fun retrievePublicKey(
+ userId: Long,
+ keyId: String,
+ ): Xor = Dispatchers.IO {
+ val file = keyFile(userId = userId, keyId = keyId, isPublic = true)
+ runCatching {
+ Xor.First(file.readBytes())
+ }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) }
+ }
+
+ override suspend fun retrievePrivateKey(
+ userId: Long,
+ keyId: String,
+ ): Xor = Dispatchers.IO {
+ val file = keyFile(userId = userId, keyId = keyId, isPublic = false)
+ runCatching {
+ Xor.First(file.readBytes())
+ }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) }
+ }
+
+ override suspend fun getSortedKeyIds(matchOn: MatchOn): List {
+ val files = withContext(Dispatchers.IO) {
+ keysDir.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) {
+ keysDir.listFiles()
+ }?.find {
+ predicate(it.name)
+ } ?: return null
+
+ return extractKeyIdFromFileName(userPassKey.name)
+ }
+
+ override suspend fun deleteKeysMatching(matchOn: MatchOn): Xor {
+ val predicate = matchOn.asFilterPredicate()
+ val keys = withContext(Dispatchers.IO) {
+ keysDir.listFiles()
+ }?.filter {
+ predicate(it.name)
+ } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys"))
+
+ Dispatchers.IO { keys.forEach { it.delete() } }
+
+ 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 fun keyFile(userId: Long, keyId: String, isPublic: Boolean): File {
+ val visibility = if (isPublic) "public" else "private"
+ return keysDir.resolve("$userId-$keyId-$visibility.key")
+ }
+}
diff --git a/src/androidMain/kotlin/internal/db/AccountsDatabase.android.kt b/src/androidMain/kotlin/internal/db/AccountsDatabase.android.kt
new file mode 100644
index 0000000..d71a92a
--- /dev/null
+++ b/src/androidMain/kotlin/internal/db/AccountsDatabase.android.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.db
+
+import androidx.room.Room
+import splitties.init.appCtx
+
+internal actual fun getAccountsRoomDatabase(databaseNameOrPath: String?): AccountsDatabase {
+ val dbBuilder = Room.databaseBuilder(
+ context = appCtx,
+ name = databaseNameOrPath ?: "accounts.db"
+ )
+ return getAccountsRoomDatabase(dbBuilder)
+}
diff --git a/src/androidMain/kotlin/internal/network/utils/HttpClientEngine.android.kt b/src/androidMain/kotlin/internal/network/utils/HttpClientEngine.android.kt
new file mode 100644
index 0000000..5a51284
--- /dev/null
+++ b/src/androidMain/kotlin/internal/network/utils/HttpClientEngine.android.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.network.utils
+
+import io.ktor.client.engine.HttpClientEngine
+import io.ktor.client.engine.okhttp.OkHttp
+
+internal actual fun getHttpClientEngine(): HttpClientEngine {
+ return OkHttp.create {
+ config {
+ followRedirects(true)
+ }
+ }
+}
diff --git a/src/androidMain/kotlin/internal/otp/TotpGenerator.android.kt b/src/androidMain/kotlin/internal/otp/TotpGenerator.android.kt
new file mode 100644
index 0000000..0552f7a
--- /dev/null
+++ b/src/androidMain/kotlin/internal/otp/TotpGenerator.android.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.otp
+
+import com.infomaniak.auth.lib.internal.models.LegacyUser
+import com.infomaniak.auth.lib.internal.room.legacy.OTPUserDatabase
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+internal actual suspend fun getLegacyAccounts(): List {
+ return OTPUserDatabase.instance.otpUserDao().getAllUsers()
+}
+
+internal actual suspend fun deleteLegacyAccount(userId: String) {
+ withContext(Dispatchers.IO) {
+ OTPUserDatabase.instance.otpUserDao().deleteById(userId.toInt())
+ }
+}
+
+internal actual suspend fun deleteLegacyDB() {
+ withContext(Dispatchers.IO) {
+ appCtx.getDatabasePath("Infomaniak.db").delete()
+ }
+}
+
+internal actual suspend fun getSecretFor(userId: Long): String? {
+ return getLegacyAccounts().find { it.userId.toLong() == userId }?.secret
+}
+
+internal actual suspend fun needMigration() = withContext(Dispatchers.IO) { appCtx.getDatabasePath("Infomaniak.db").exists() }
diff --git a/src/androidMain/kotlin/internal/room/legacy/OTPUserDao.kt b/src/androidMain/kotlin/internal/room/legacy/OTPUserDao.kt
new file mode 100644
index 0000000..b2b26d9
--- /dev/null
+++ b/src/androidMain/kotlin/internal/room/legacy/OTPUserDao.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.room.legacy
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Query
+import com.infomaniak.auth.lib.internal.models.LegacyUser
+
+@Dao
+internal interface OTPUserDao {
+
+ @Delete
+ suspend fun delete(user: LegacyUser)
+
+ @Query("DELETE FROM users WHERE userid = :userId")
+ suspend fun deleteById(userId: Int)
+
+ @Query("SELECT * FROM users WHERE userid = :userId")
+ suspend fun findUserById(userId: Int): LegacyUser?
+
+ @Query("SELECT * FROM users")
+ suspend fun getAllUsers(): List
+}
diff --git a/src/androidMain/kotlin/internal/room/legacy/OTPUserDatabase.kt b/src/androidMain/kotlin/internal/room/legacy/OTPUserDatabase.kt
new file mode 100644
index 0000000..78cd2ef
--- /dev/null
+++ b/src/androidMain/kotlin/internal/room/legacy/OTPUserDatabase.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 .
+ */
+package com.infomaniak.auth.lib.internal.room.legacy
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.infomaniak.auth.lib.internal.models.LegacyUser
+import splitties.init.appCtx
+
+@Database(entities = [LegacyUser::class], version = 1)
+internal abstract class OTPUserDatabase : RoomDatabase() {
+
+ abstract fun otpUserDao(): OTPUserDao
+
+ companion object {
+ val instance = buildDatabase(appCtx)
+
+ private fun buildDatabase(context: Context) =
+ Room.databaseBuilder(
+ context.applicationContext,
+ OTPUserDatabase::class.java, "Infomaniak.db"
+ ).build()
+ }
+}
diff --git a/src/androidMain/kotlin/internal/utils/DeviceInfo.android.kt b/src/androidMain/kotlin/internal/utils/DeviceInfo.android.kt
new file mode 100644
index 0000000..6286dbf
--- /dev/null
+++ b/src/androidMain/kotlin/internal/utils/DeviceInfo.android.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 android.os.Build
+import com.infomaniak.auth.lib.internal.webauthn.DeviceInfo
+
+internal actual fun getDeviceInfo(): DeviceInfo {
+ return DeviceInfo(
+ brand = Build.BRAND,
+ model = Build.MODEL,
+ platform = "android",
+ )
+}
diff --git a/src/androidMain/kotlin/internal/utils/FileUtils.android.kt b/src/androidMain/kotlin/internal/utils/FileUtils.android.kt
new file mode 100644
index 0000000..2267b78
--- /dev/null
+++ b/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/src/androidMain/kotlin/internal/utils/KeyCoordinates.android.kt b/src/androidMain/kotlin/internal/utils/KeyCoordinates.android.kt
new file mode 100644
index 0000000..5c8bbfe
--- /dev/null
+++ b/src/androidMain/kotlin/internal/utils/KeyCoordinates.android.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.trimOrPadStart
+import com.infomaniak.auth.lib.internal.webauthn.PublicKeyXY
+import java.security.KeyFactory
+import java.security.PublicKey
+import java.security.interfaces.ECPublicKey
+import java.security.spec.X509EncodedKeySpec
+
+internal actual fun getKeyCoordinates(key: ByteArray): PublicKeyXY {
+ val publicKey = getPublicKeyFromByteArray(key) as ECPublicKey
+ val w = publicKey.w
+ val x = w.affineX.toByteArray().trimOrPadStart(32)
+ val y = w.affineY.toByteArray().trimOrPadStart(32)
+ return PublicKeyXY(x, y)
+}
+
+private fun getPublicKeyFromByteArray(bytes: ByteArray): PublicKey {
+ val keySpec = X509EncodedKeySpec(bytes)
+ val keyFactory = KeyFactory.getInstance("EC")
+ return keyFactory.generatePublic(keySpec)
+}
diff --git a/src/androidMain/kotlin/internal/utils/SignUtils.kt b/src/androidMain/kotlin/internal/utils/SignUtils.kt
new file mode 100644
index 0000000..fc6bab0
--- /dev/null
+++ b/src/androidMain/kotlin/internal/utils/SignUtils.kt
@@ -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 .
+ */
+package com.infomaniak.auth.lib.internal.utils
+
+import java.security.KeyFactory
+import java.security.Signature
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.X509EncodedKeySpec
+
+internal actual object SignUtils {
+
+ actual fun signWithPrivateKey(privateKey: ByteArray, data: ByteArray): ByteArray {
+ val keyFactory = KeyFactory.getInstance("EC")
+ val keySpec = PKCS8EncodedKeySpec(privateKey)
+ val key = keyFactory.generatePrivate(keySpec)
+
+ val signature = Signature.getInstance("SHA256withECDSA")
+ signature.initSign(key)
+ signature.update(data)
+
+ return signature.sign()
+ }
+
+ actual fun verifySignature(publicKey: ByteArray, data: ByteArray, signatureData: ByteArray): Boolean {
+ val keyFactory = KeyFactory.getInstance("EC")
+ val keySpec = X509EncodedKeySpec(publicKey)
+ val key = keyFactory.generatePublic(keySpec)
+
+ val signature = Signature.getInstance("SHA256withECDSA")
+ signature.initVerify(key)
+ signature.update(data)
+
+ return signature.verify(signatureData)
+ }
+}
diff --git a/src/appleMain/kotlin/db/AppSettingsDatabase.ios.kt b/src/appleMain/kotlin/db/AppSettingsDatabase.ios.kt
new file mode 100644
index 0000000..bf8fe1a
--- /dev/null
+++ b/src/appleMain/kotlin/db/AppSettingsDatabase.ios.kt
@@ -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 .
+ */
+package com.infomaniak.auth.lib.db
+
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.infomaniak.auth.lib.room.appsettings.AppSettingsDatabase
+import com.infomaniak.auth.lib.room.appsettings.getAppSettingsRoomDatabase
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDocumentDirectory
+import platform.Foundation.NSFileManager
+import platform.Foundation.NSUserDomainMask
+
+fun getAppSettingsDatabaseBuilder(): RoomDatabase.Builder {
+ val dbFilePath = documentDirectory() + "/app_settings.db"
+ return Room.databaseBuilder(
+ name = dbFilePath,
+ )
+}
+
+fun getAppSettingsRoomDatabase(): AppSettingsDatabase {
+ return getAppSettingsRoomDatabase(getAppSettingsDatabaseBuilder())
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private fun documentDirectory(): String {
+ val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
+ directory = NSDocumentDirectory,
+ inDomain = NSUserDomainMask,
+ appropriateForURL = null,
+ create = false,
+ error = null,
+ )
+ return requireNotNull(documentDirectory?.path)
+}
diff --git a/src/appleMain/kotlin/internal/KeyAccessibility.kt b/src/appleMain/kotlin/internal/KeyAccessibility.kt
new file mode 100644
index 0000000..f10eb31
--- /dev/null
+++ b/src/appleMain/kotlin/internal/KeyAccessibility.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 .
+ */
+package com.infomaniak.auth.lib.internal
+
+internal sealed interface KeyAccessibility {
+ sealed interface SecureEnclaveCompatible : KeyAccessibility
+
+ sealed interface WhenUnlocked : KeyAccessibility {
+ companion object : WhenUnlocked
+ data object ThisDeviceOnly : WhenUnlocked, SecureEnclaveCompatible
+ }
+
+ sealed interface AfterFirstUnlock : KeyAccessibility {
+ companion object : AfterFirstUnlock
+ data object ThisDeviceOnly : AfterFirstUnlock, SecureEnclaveCompatible
+ }
+
+ sealed interface WhenPasscodeSet : KeyAccessibility {
+ data object ThisDeviceOnly : WhenPasscodeSet, SecureEnclaveCompatible
+ }
+
+ sealed interface Always : KeyAccessibility {
+ companion object : Always
+ data object ThisDeviceOnly : Always, SecureEnclaveCompatible
+ }
+}
diff --git a/src/appleMain/kotlin/internal/KeyGen.apple.kt b/src/appleMain/kotlin/internal/KeyGen.apple.kt
new file mode 100644
index 0000000..d0425f2
--- /dev/null
+++ b/src/appleMain/kotlin/internal/KeyGen.apple.kt
@@ -0,0 +1,201 @@
+/*
+ * 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(ExperimentalForeignApi::class)
+
+package com.infomaniak.auth.lib.internal
+
+import com.infomaniak.auth.lib.internal.extensions.buildCFDictionary
+import com.infomaniak.auth.lib.internal.extensions.firstOrElse
+import com.infomaniak.auth.lib.internal.extensions.set
+import com.infomaniak.auth.lib.internal.extensions.toNsData
+import com.infomaniak.auth.lib.internal.extensions.tryIt
+import com.infomaniak.auth.lib.internal.utils.Xor
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.CoreFoundation.CFMutableDictionaryRef
+import platform.CoreFoundation.kCFAllocatorDefault
+import platform.Foundation.NSError
+import platform.Security.SecAccessControlCreateWithFlags
+import platform.Security.SecKeyCreateRandomKey
+import platform.Security.SecKeyRef
+import platform.Security.kSecAttrAccessControl
+import platform.Security.kSecAttrApplicationTag
+import platform.Security.kSecAttrCanDecrypt
+import platform.Security.kSecAttrCanDerive
+import platform.Security.kSecAttrCanEncrypt
+import platform.Security.kSecAttrCanSign
+import platform.Security.kSecAttrCanUnwrap
+import platform.Security.kSecAttrCanVerify
+import platform.Security.kSecAttrCanWrap
+import platform.Security.kSecAttrIsPermanent
+import platform.Security.kSecAttrKeySizeInBits
+import platform.Security.kSecAttrKeyType
+import platform.Security.kSecAttrKeyTypeECSECPrimeRandom
+import platform.Security.kSecAttrTokenID
+import platform.Security.kSecAttrTokenIDSecureEnclave
+import platform.Security.kSecPrivateKeyAttrs
+import platform.Security.kSecPublicKeyAttrs
+
+/**
+ * Generates an EC private key in the KeyChain for future use.
+ *
+ * A public key can be generated later from it.
+ */
+internal fun generateEcPrivateKeyInTheKeychain(
+ tag: String,
+ privateKeyPurposes: KeyPurposes = KeyPurposes.privateKeyDefaults,
+ publicKeyPurposes: KeyPurposes? = null,
+ keyAccessGuard: KeyAccessGuard,
+ accessibility: KeyAccessibility
+): Xor = generatePrivateKey(
+ tag = tag,
+ privateKeyPurposes = privateKeyPurposes,
+ publicKeyPurposes = publicKeyPurposes,
+ keyAccessGuard = keyAccessGuard,
+ accessibility = accessibility,
+ storageLocation = KeyStorageLocation.KeyChain
+)
+
+/**
+ * Generates an EC private key in the device Secure Enclave for future use.
+ *
+ * A public key can be generated later from it.
+ *
+ * See [Apple doc](https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave?language=objc)
+ */
+internal fun generatePrivateKeyInTheSecureEnclave(
+ tag: String,
+ privateKeyPurposes: KeyPurposes = KeyPurposes.privateKeyDefaults,
+ publicKeyPurposes: KeyPurposes? = null,
+ keyAccessGuard: KeyAccessGuard,
+ accessibility: KeyAccessibility.SecureEnclaveCompatible // Don't allow wrong accessibility flags.
+): Xor = generatePrivateKey(
+ tag = tag,
+ privateKeyPurposes = privateKeyPurposes,
+ publicKeyPurposes = publicKeyPurposes,
+ keyAccessGuard = keyAccessGuard,
+ accessibility = accessibility,
+ storageLocation = KeyStorageLocation.SecureEnclave
+)
+
+/**
+ * Generates an EC private key.
+ *
+ * A public key can be generated from it.
+ */
+internal fun generateEcPrivateKeyInMemory(
+ tag: String,
+ privateKeyPurposes: KeyPurposes = KeyPurposes.privateKeyDefaults,
+ publicKeyPurposes: KeyPurposes? = null,
+ keyAccessGuard: KeyAccessGuard,
+ accessibility: KeyAccessibility
+): Xor = generatePrivateKey(
+ tag = tag,
+ privateKeyPurposes = privateKeyPurposes,
+ publicKeyPurposes = publicKeyPurposes,
+ keyAccessGuard = keyAccessGuard,
+ accessibility = accessibility,
+ storageLocation = null
+)
+
+private fun generatePrivateKey(
+ tag: String,
+ privateKeyPurposes: KeyPurposes = KeyPurposes.privateKeyDefaults,
+ publicKeyPurposes: KeyPurposes? = null,
+ keyAccessGuard: KeyAccessGuard,
+ accessibility: KeyAccessibility,
+ storageLocation: KeyStorageLocation?,
+): Xor {
+ val attributes = createKeyAttributes(
+ tag = tag,
+ privateKeyPurposes = privateKeyPurposes,
+ publicKeyPurposes = publicKeyPurposes,
+ accessControl = keyAccessGuard,
+ accessibility = accessibility,
+ storageLocation = storageLocation,
+ )
+
+ return tryIt { e -> SecKeyCreateRandomKey(attributes, e) }
+}
+
+private fun createKeyAttributes(
+ tag: String,
+ privateKeyPurposes: KeyPurposes?,
+ publicKeyPurposes: KeyPurposes?,
+ accessControl: KeyAccessGuard,
+ accessibility: KeyAccessibility,
+ storageLocation: KeyStorageLocation?,
+) = buildCFDictionary {
+ // See https://developer.apple.com/documentation/security/generating-new-cryptographic-keys#Creating-an-Asymmetric-Key-Pair
+ // See all key gen attributes here: https://developer.apple.com/documentation/security/key-generation-attributes
+ this[kSecAttrKeyType] = kSecAttrKeyTypeECSECPrimeRandom
+ this[kSecAttrKeySizeInBits] = 256
+ when (storageLocation) {
+ KeyStorageLocation.SecureEnclave -> {
+ this[kSecAttrTokenID] = kSecAttrTokenIDSecureEnclave
+ }
+ KeyStorageLocation.KeyChain, null -> Unit
+ }
+ this[kSecPrivateKeyAttrs] = buildCFDictionary {
+ privateKeyPurposes?.applyTo(this)
+ this[kSecAttrIsPermanent] = storageLocation != null
+ this[kSecAttrAccessControl] = createAccessControl(
+ accessControl = accessControl,
+ accessibility = accessibility,
+ isForSecureEnclave = storageLocation == KeyStorageLocation.SecureEnclave
+ )
+ // No need to specify kSecAttrAccessible since it's already set in kSecAttrAccessControl just above.
+ /*
+ * You also specify the kSecAttrApplicationTag attribute with a unique NSData value
+ * so that you can find and retrieve it from the keychain later.
+ * The tag data is constructed from a string, using reverse DNS notation,
+ * though any unique tag will do.
+ */
+ this[kSecAttrApplicationTag] = tag.toNsData()
+ }
+ if (publicKeyPurposes != null) this[kSecPublicKeyAttrs] = buildCFDictionary {
+ this[kSecAttrIsPermanent] = storageLocation != null
+ publicKeyPurposes.applyTo(this)
+ this[kSecAttrApplicationTag] = "$tag.pub".toNsData()
+ }
+}
+
+private fun KeyPurposes.applyTo(dictionary: CFMutableDictionaryRef?) {
+ dictionary[kSecAttrCanSign] = signing
+ dictionary[kSecAttrCanVerify] = verifying
+
+ dictionary[kSecAttrCanEncrypt] = encrypting
+ dictionary[kSecAttrCanDecrypt] = decrypting
+
+ dictionary[kSecAttrCanDerive] = deriving
+
+ dictionary[kSecAttrCanWrap] = wrapping
+ dictionary[kSecAttrCanUnwrap] = unwrapping
+}
+
+private fun createAccessControl(
+ accessControl: KeyAccessGuard,
+ accessibility: KeyAccessibility,
+ isForSecureEnclave: Boolean,
+) = tryIt { errorPointer ->
+ SecAccessControlCreateWithFlags(
+ allocator = kCFAllocatorDefault,
+ protection = accessibility.toKSecAttrAccessible(),
+ flags = accessControl.toAccessControlFlags(isForSecureEnclave),
+ error = errorPointer
+ )
+}.firstOrElse { error -> throw Exception("Error creating access control: $error") }
diff --git a/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt
new file mode 100644
index 0000000..af44eb8
--- /dev/null
+++ b/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt
@@ -0,0 +1,268 @@
+/*
+ * 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(ExperimentalForeignApi::class)
+
+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
+import com.infomaniak.auth.lib.internal.utils.Xor
+import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.MemScope
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.value
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.invoke
+import platform.CoreFoundation.CFArrayRef
+import platform.CoreFoundation.CFDataRef
+import platform.CoreFoundation.CFDateRef
+import platform.CoreFoundation.CFDictionaryRef
+import platform.CoreFoundation.CFRelease
+import platform.CoreFoundation.CFTypeRefVar
+import platform.Foundation.timeIntervalSince1970
+import platform.Security.SecItemCopyMatching
+import platform.Security.SecItemDelete
+import platform.Security.SecKeyCopyExternalRepresentation
+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
+import platform.Security.kSecAttrKeyTypeECSECPrimeRandom
+import platform.Security.kSecClass
+import platform.Security.kSecClassKey
+import platform.Security.kSecMatchLimit
+import platform.Security.kSecMatchLimitAll
+import platform.Security.kSecReturnAttributes
+import platform.Security.kSecReturnRef
+
+internal actual fun createKeyPairManager(): KeyPairManager = KeyPairManagerAppleImpl()
+
+private class KeyPairManagerAppleImpl : KeyPairManager() {
+
+ override suspend fun generateNewKey(
+ userId: Long,
+ keyId: String,
+ ): Failure.KeyManagement.GenerationFailed? = Dispatchers.IO {
+
+ val result = generateEcPrivateKeyInTheKeychain(
+ tag = "$userId-$keyId",
+ privateKeyPurposes = KeyPurposes.privateKeyDefaults,
+ publicKeyPurposes = KeyPurposes.publicKeyDefaults,
+ keyAccessGuard = KeyAccessGuard.Unguarded,
+ accessibility = KeyAccessibility.AfterFirstUnlock,
+ )
+ when (result) {
+ is Xor.First -> result.value.use { null }
+ is Xor.Second -> Failure.KeyManagement.GenerationFailed(result.value.toString())
+ }
+ }
+
+ @OptIn(ExperimentalForeignApi::class)
+ override suspend fun retrievePublicKey(
+ userId: Long,
+ keyId: String,
+ ): Xor = Dispatchers.IO {
+ memScoped {
+ // Get private key to retrieve public key
+ getPrivateKeyRef("$userId-$keyId").use { privateKeyRef ->
+ SecKeyCopyPublicKey(privateKeyRef) ?: throw Exception("Failed to extract public key from private key")
+ }.use { publicKeyRef ->
+
+ val result = tryIt { errorPointer -> SecKeyCopyExternalRepresentation(publicKeyRef, errorPointer) }
+
+ val publicKeyData = when (result) {
+ is Xor.First -> result.value.toNSData()
+ is Xor.Second -> return@IO Xor.Second(Failure.KeyManagement.KeyExtractionFailed(result.value.toString()))
+ }
+
+ Xor.First(publicKeyData.toByteArray())
+ }
+ }
+ }
+
+ override suspend fun retrievePrivateKey(
+ userId: Long,
+ keyId: String
+ ): Xor {
+ memScoped {
+ getPrivateKeyRef("$userId-$keyId").use { privateKeyRef ->
+ val result = tryIt { errorPointer ->
+ SecKeyCopyExternalRepresentation(privateKeyRef, errorPointer)
+ }
+
+ return when (result) {
+ is Xor.First -> Xor.First(result.value.toNSData().toByteArray())
+ is Xor.Second -> Xor.Second(Failure.KeyManagement.KeyExtractionFailed(result.value.toString()))
+ }
+ }
+ }
+ }
+
+ 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)
+ override suspend fun findKeyIdFor(matchOn: MatchOn): String? = Dispatchers.IO {
+ memScoped {
+ val resultsArray = getAllPrivateKeysQuery()
+
+ if (resultsArray.isNullOrEmpty()) return@memScoped null
+ val predicate = matchOn.asFilterPredicate()
+
+ for (i in 0 until resultsArray.size) {
+ val tag = extractTagFromItem(resultsArray[i]) ?: continue
+
+ if (predicate(tag)) return@memScoped extractKeyIdFromTag(tag)
+ }
+
+ return@memScoped null
+ }
+ }
+
+ override suspend fun deleteKeysMatching(matchOn: MatchOn): Xor = Dispatchers.IO {
+ memScoped {
+ val resultsArray = getAllPrivateKeysQuery()
+
+ 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 resultsArray.size) {
+ val tag = extractTagFromItem(resultsArray[i]) ?: continue
+
+ if (predicate(tag)) {
+ deleteKeyByTag(tag)
+ hasDeletedAtLeastOneKey = true
+ }
+ }
+
+ if (hasDeletedAtLeastOneKey) {
+ Xor.First(Unit)
+ } else {
+ Xor.Second(Failure.KeyManagement.KeyNotFound("No key containing $predicate"))
+ }
+ }
+ }
+
+ 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(): CFArrayRef? {
+ val query = buildCFDictionary {
+ this[kSecClass] = kSecClassKey
+ this[kSecAttrKeyClass] = kSecAttrKeyClassPrivate
+ this[kSecAttrKeyType] = kSecAttrKeyTypeECSECPrimeRandom
+ this[kSecReturnAttributes] = true
+ this[kSecMatchLimit] = kSecMatchLimitAll
+ }
+
+ val resultRef = alloc()
+ val status = SecItemCopyMatching(query, resultRef.ptr)
+ CFRelease(query)
+
+ return if (status == errSecSuccess && resultRef.value != null) {
+ @Suppress("unchecked_cast")
+ val resultsArray = resultRef.value as CFArrayRef
+ resultsArray
+ } else {
+ null
+ }
+ }
+
+ private fun extractTagFromItem(item: CFDictionaryRef?): String? {
+ val tagData: CFDataRef = item[kSecAttrApplicationTag] ?: return null
+ return tagData.toNSData().toByteArray().decodeToString()
+ }
+
+ private fun deleteKeyByTag(tag: String) {
+ val deleteQuery = buildCFDictionary {
+ this[kSecClass] = kSecClassKey
+ this[kSecAttrApplicationTag] = tag.toNsData()
+ }
+ SecItemDelete(deleteQuery)
+ CFRelease(deleteQuery)
+ }
+
+ @OptIn(ExperimentalForeignApi::class)
+ private suspend fun MemScope.getPrivateKeyRef(keyAlias: String): SecKeyRef {
+ val query = buildCFDictionary {
+ this[kSecAttrKeyType] = kSecAttrKeyTypeECSECPrimeRandom
+ this[kSecAttrKeyClass] = kSecAttrKeyClassPrivate
+ this[kSecClass] = kSecClassKey
+ this[kSecAttrApplicationTag] = keyAlias.toNsData()
+ this[kSecReturnRef] = true
+ }
+
+ val privateKeyRefVar = alloc()
+ val resultStatus = Dispatchers.IO { SecItemCopyMatching(query, privateKeyRefVar.ptr) }
+
+ if (resultStatus != errSecSuccess || privateKeyRefVar.value == null) {
+ throw Exception("Failed to retrieve private key from KeyChain (error: $resultStatus)")
+ }
+
+ CFRelease(query)
+
+ @Suppress("UNCHECKED_CAST")
+ return privateKeyRefVar.value as SecKeyRef
+ }
+}
diff --git a/src/appleMain/kotlin/internal/KeyStorageLocation.kt b/src/appleMain/kotlin/internal/KeyStorageLocation.kt
new file mode 100644
index 0000000..32b340f
--- /dev/null
+++ b/src/appleMain/kotlin/internal/KeyStorageLocation.kt
@@ -0,0 +1,20 @@
+/*
+ * 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
+
+enum class KeyStorageLocation { SecureEnclave, KeyChain; }
diff --git a/src/appleMain/kotlin/internal/db/AccountsDatabase.ios.kt b/src/appleMain/kotlin/internal/db/AccountsDatabase.ios.kt
new file mode 100644
index 0000000..829bbcf
--- /dev/null
+++ b/src/appleMain/kotlin/internal/db/AccountsDatabase.ios.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.db
+
+import androidx.room.Room
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDocumentDirectory
+import platform.Foundation.NSFileManager
+import platform.Foundation.NSUserDomainMask
+
+internal actual fun getAccountsRoomDatabase(databaseNameOrPath: String?): AccountsDatabase {
+ val dbBuilder = Room.databaseBuilder(
+ name = databaseNameOrPath ?: (documentDirectory() + "/accounts.db"),
+ )
+ return getAccountsRoomDatabase(dbBuilder)
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private fun documentDirectory(): String {
+ val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
+ directory = NSDocumentDirectory,
+ inDomain = NSUserDomainMask,
+ appropriateForURL = null,
+ create = false,
+ error = null,
+ )
+ return requireNotNull(documentDirectory?.path)
+}
diff --git a/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt b/src/appleMain/kotlin/internal/extensions/CFArrayRef.kt
new file mode 100644
index 0000000..843c076
--- /dev/null
+++ b/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/src/appleMain/kotlin/internal/extensions/CFErrors.kt b/src/appleMain/kotlin/internal/extensions/CFErrors.kt
new file mode 100644
index 0000000..47bfc9b
--- /dev/null
+++ b/src/appleMain/kotlin/internal/extensions/CFErrors.kt
@@ -0,0 +1,97 @@
+/*
+ * 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(ExperimentalForeignApi::class)
+
+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
+import kotlinx.cinterop.value
+import platform.CoreFoundation.CFErrorRefVar
+import platform.Foundation.NSError
+
+/**
+ * Helpful for C-style CoreFoundation functions that take a pointer of an error ref.
+ *
+ * Example usage:
+ * ```
+ * val result = tryIt { errorPointer -> SecKeyCopyExternalRepresentation(publicKeyRef, errorPointer) }
+ *
+ * when (result) {
+ * is Xor.First -> return result.value // Successful
+ * is Xor.Second -> {
+ * println("Error: ${result.value.localizedDescription}")
+ * handleNSError(result.value)
+ * return null
+ * }
+ * }
+ * ```
+ *
+ * See [tryIt2] for the full NSError variant (no C-style CoreFoundation).
+ */
+internal inline fun tryIt(block: (errorPointer: CPointer) -> R?): Xor = memScoped {
+ val errorVar = alloc()
+ val result = block(errorVar.ptr)
+ when (val error = errorVar.value?.toNSError()) {
+ null -> Xor.First(result!!)
+ else -> 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/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt b/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt
new file mode 100644
index 0000000..c131e11
--- /dev/null
+++ b/src/appleMain/kotlin/internal/extensions/CFMutableDictionaryRef.kt
@@ -0,0 +1,86 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+import kotlinx.cinterop.CPointer
+import kotlinx.cinterop.CValuesRef
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.cValuesOf
+import kotlinx.cinterop.ptr
+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
+import platform.CoreFoundation.kCFNumberIntType
+import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks
+import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks
+import platform.Foundation.CFBridgingRetain
+import platform.Foundation.NSData
+
+@ExperimentalForeignApi
+internal fun buildCFDictionary(
+ capacity: CFIndex = 0L,
+ builderAction: CFMutableDictionaryRef?.() -> Unit
+): CFDictionaryRef? {
+ return CFDictionaryCreateMutable(
+ allocator = kCFAllocatorDefault,
+ capacity = capacity,
+ keyCallBacks = kCFTypeDictionaryKeyCallBacks.ptr,
+ valueCallBacks = kCFTypeDictionaryValueCallBacks.ptr
+ ).apply(builderAction)
+}
+
+@ExperimentalForeignApi
+internal operator fun CFMutableDictionaryRef?.set(key: CValuesRef<*>?, value: CValuesRef<*>?) {
+ CFDictionaryAddValue(this, key, value)
+}
+
+@ExperimentalForeignApi
+internal operator fun CFMutableDictionaryRef?.set(key: CValuesRef<*>?, value: Boolean) {
+ this[key] = if (value) kCFBooleanTrue else kCFBooleanFalse
+}
+
+@ExperimentalForeignApi
+internal operator fun CFMutableDictionaryRef?.set(key: CValuesRef<*>?, value: Int) {
+ this[key] = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, cValuesOf(value))
+}
+
+@ExperimentalForeignApi
+internal operator fun CFMutableDictionaryRef?.set(key: CValuesRef<*>?, value: NSData?) {
+ // The cast below is fine because it's a "toll-free bridged" type.
+ // See Apple doc archive on it:
+ // https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFDesignConcepts/Articles/tollFreeBridgedTypes.html
+ @Suppress("UNCHECKED_CAST")
+ this[key] = CFBridgingRetain(value) as CFDataRef
+}
+
+@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/src/appleMain/kotlin/internal/extensions/CFTypeRef.kt b/src/appleMain/kotlin/internal/extensions/CFTypeRef.kt
new file mode 100644
index 0000000..1c31e16
--- /dev/null
+++ b/src/appleMain/kotlin/internal/extensions/CFTypeRef.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.ExperimentalForeignApi
+import platform.CoreFoundation.CFRelease
+import platform.CoreFoundation.CFTypeRef
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+@OptIn(ExperimentalForeignApi::class)
+internal inline fun T.use(block: (T) -> R): R {
+ contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
+ try {
+ return block(this)
+ } finally {
+ CFRelease(this)
+ }
+}
diff --git a/src/appleMain/kotlin/internal/extensions/NSData-ByteArray-conversion.kt b/src/appleMain/kotlin/internal/extensions/NSData-ByteArray-conversion.kt
new file mode 100644
index 0000000..4646a4f
--- /dev/null
+++ b/src/appleMain/kotlin/internal/extensions/NSData-ByteArray-conversion.kt
@@ -0,0 +1,46 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.allocArrayOf
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.usePinned
+import platform.Foundation.NSData
+import platform.Foundation.create
+import platform.Foundation.getBytes
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun NSData.toByteArray(): ByteArray {
+ val length = this.length.toInt()
+ return ByteArray(length).apply {
+ usePinned { pinned ->
+ this@toByteArray.getBytes(pinned.addressOf(0))
+ }
+ }
+}
+
+@OptIn(ExperimentalUnsignedTypes::class, ExperimentalForeignApi::class, BetaInteropApi::class)
+internal fun ByteArray.toNSData(): NSData = memScoped {
+ NSData.create(
+ bytes = allocArrayOf(this@toNSData),
+ length = size.toULong()
+ )
+}
diff --git a/src/appleMain/kotlin/internal/extensions/String-NSData-conversion.kt b/src/appleMain/kotlin/internal/extensions/String-NSData-conversion.kt
new file mode 100644
index 0000000..fd7cada
--- /dev/null
+++ b/src/appleMain/kotlin/internal/extensions/String-NSData-conversion.kt
@@ -0,0 +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 .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+import kotlinx.cinterop.BetaInteropApi
+import platform.Foundation.NSData
+import platform.Foundation.NSString
+import platform.Foundation.NSUTF8StringEncoding
+import platform.Foundation.create
+import platform.Foundation.dataUsingEncoding
+
+internal fun String.toNsData(): NSData? {
+ @OptIn(BetaInteropApi::class)
+ @Suppress("RedundantNullableReturnType", "RedundantSuppression") // Nullability seems to differ from machine to machine.
+ val nsString: NSString? = NSString.create(this)
+ return nsString?.dataUsingEncoding(NSUTF8StringEncoding)
+}
diff --git a/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt b/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt
new file mode 100644
index 0000000..e9c19df
--- /dev/null
+++ b/src/appleMain/kotlin/internal/extensions/TollFreeBridgedTypes.kt
@@ -0,0 +1,43 @@
+/*
+ * 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(ExperimentalForeignApi::class)
+
+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.
+// See Apple doc archive on it:
+// https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFDesignConcepts/Articles/tollFreeBridgedTypes.html#//apple_ref/doc/uid/TP40010677
+
+@Suppress("unchecked_cast") // It works. Source: trust us.
+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/src/appleMain/kotlin/internal/kSecConversion.kt b/src/appleMain/kotlin/internal/kSecConversion.kt
new file mode 100644
index 0000000..71769b3
--- /dev/null
+++ b/src/appleMain/kotlin/internal/kSecConversion.kt
@@ -0,0 +1,67 @@
+/*
+ * 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 kotlinx.cinterop.ExperimentalForeignApi
+import platform.CoreFoundation.CFStringRef
+import platform.Security.SecAccessControlCreateFlags
+import platform.Security.kSecAccessControlAnd
+import platform.Security.kSecAccessControlBiometryAny
+import platform.Security.kSecAccessControlBiometryCurrentSet
+import platform.Security.kSecAccessControlDevicePasscode
+import platform.Security.kSecAccessControlPrivateKeyUsage
+import platform.Security.kSecAccessControlUserPresence
+import platform.Security.kSecAttrAccessibleAfterFirstUnlock
+import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+import platform.Security.kSecAttrAccessibleAlways
+import platform.Security.kSecAttrAccessibleAlwaysThisDeviceOnly
+import platform.Security.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
+import platform.Security.kSecAttrAccessibleWhenUnlocked
+import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+
+@ExperimentalForeignApi
+internal fun KeyAccessibility.toKSecAttrAccessible(): CFStringRef? = when (this) {
+ KeyAccessibility.WhenUnlocked.ThisDeviceOnly -> kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ KeyAccessibility.WhenUnlocked -> kSecAttrAccessibleWhenUnlocked
+ KeyAccessibility.AfterFirstUnlock.ThisDeviceOnly -> kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ KeyAccessibility.AfterFirstUnlock -> kSecAttrAccessibleAfterFirstUnlock
+ KeyAccessibility.WhenPasscodeSet.ThisDeviceOnly -> kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
+ KeyAccessibility.Always -> kSecAttrAccessibleAlways
+ KeyAccessibility.Always.ThisDeviceOnly -> kSecAttrAccessibleAlwaysThisDeviceOnly
+}
+
+/**
+ * Converts [KeyAccessGuard] to [SecAccessControlCreateFlags].
+ *
+ * [See Apple doc](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags?language=objc)
+ * on `SecAccessControlCreateFlags`.
+ */
+internal fun KeyAccessGuard.toAccessControlFlags(isForSecureEnclave: Boolean): SecAccessControlCreateFlags = when (this) {
+ KeyAccessGuard.Biometry.Current -> kSecAccessControlBiometryCurrentSet
+ KeyAccessGuard.Biometry.CurrentAndFuture -> kSecAccessControlBiometryAny
+ KeyAccessGuard.DevicePasscode -> kSecAccessControlDevicePasscode
+ // See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/userpresence?language=objc
+ KeyAccessGuard.DevicePasscodeOrNewBiometrics -> kSecAccessControlUserPresence
+ KeyAccessGuard.UserConfirmation -> 0uL // Not supported on iOS.
+ KeyAccessGuard.Unguarded -> 0uL
+}.let {
+ when {
+ isForSecureEnclave -> it or kSecAccessControlPrivateKeyUsage or kSecAccessControlAnd
+ else -> it
+ }
+}
diff --git a/src/appleMain/kotlin/internal/network/utils/HttpClientEngine.apple.kt b/src/appleMain/kotlin/internal/network/utils/HttpClientEngine.apple.kt
new file mode 100644
index 0000000..96e3020
--- /dev/null
+++ b/src/appleMain/kotlin/internal/network/utils/HttpClientEngine.apple.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.network.utils
+
+import io.ktor.client.engine.HttpClientEngine
+import io.ktor.client.engine.darwin.Darwin
+
+internal actual fun getHttpClientEngine(): HttpClientEngine {
+ return Darwin.create {
+ configureSession {
+ timeoutIntervalForRequest = 10.0
+ }
+ }
+}
diff --git a/src/appleMain/kotlin/internal/otp/TotpGenerator.apple.kt b/src/appleMain/kotlin/internal/otp/TotpGenerator.apple.kt
new file mode 100644
index 0000000..cf18483
--- /dev/null
+++ b/src/appleMain/kotlin/internal/otp/TotpGenerator.apple.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.otp
+
+import com.infomaniak.auth.lib.internal.extensions.toByteArray
+import com.infomaniak.auth.lib.internal.models.LegacyUser
+import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import platform.Foundation.NSData
+import platform.Foundation.NSUserDefaults
+
+@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class)
+internal actual suspend fun getLegacyAccounts(): List = withContext(Dispatchers.IO) {
+ val userDefaults = NSUserDefaults.standardUserDefaults
+ val usersData = userDefaults.objectForKey("ALL_USERS") as? List<*> ?: emptyList()
+
+ val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ explicitNulls = false
+ }
+
+ usersData.mapNotNull { item ->
+ val data = item as? NSData ?: return@mapNotNull null
+ runCatching {
+ json.decodeFromString(data.toByteArray().decodeToString())
+ }.getOrNull()
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual suspend fun deleteLegacyAccount(userId: String) {
+ withContext(Dispatchers.IO) {
+ val userDefaults = NSUserDefaults.standardUserDefaults
+ val usersData = userDefaults.objectForKey("ALL_USERS") as? MutableList<*> ?: return@withContext false
+
+ val updatedList = usersData.mapNotNull { item ->
+ val data = item as? NSData ?: return@mapNotNull item
+ val jsonString = data.toByteArray().decodeToString()
+
+ try {
+ val id = Json.parseToJsonElement(jsonString).jsonObject["id"]?.jsonPrimitive?.int
+ if (id == userId.toInt()) null else item
+ } catch (_: Exception) {
+ item
+ }
+ }
+
+ if (updatedList.size < usersData.size) {
+ userDefaults.setObject(updatedList, "ALL_USERS")
+ }
+ }
+}
+
+internal actual suspend fun deleteLegacyDB() {
+ withContext(Dispatchers.IO) {
+ NSUserDefaults.standardUserDefaults.removeObjectForKey("ALL_USERS")
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual suspend fun getSecretFor(userId: Long): String? = withContext(Dispatchers.IO) {
+ getLegacyAccounts().find {
+ it.userId.toLong() == userId
+ }?.secret
+}
+
+internal actual suspend fun needMigration(): Boolean = withContext(Dispatchers.IO) {
+ val userDefaults = NSUserDefaults.standardUserDefaults
+ userDefaults.objectForKey("ALL_USERS") as? List<*> != null
+}
diff --git a/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt b/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt
new file mode 100644
index 0000000..6e98601
--- /dev/null
+++ b/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/src/appleMain/kotlin/internal/utils/KeyCoordinates.apple.kt b/src/appleMain/kotlin/internal/utils/KeyCoordinates.apple.kt
new file mode 100644
index 0000000..ec1e957
--- /dev/null
+++ b/src/appleMain/kotlin/internal/utils/KeyCoordinates.apple.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.webauthn.PublicKeyXY
+
+internal actual fun getKeyCoordinates(key: ByteArray): PublicKeyXY {
+ return keyCoordinatesOf(uncompressedP256Key = key)
+}
diff --git a/src/appleMain/kotlin/internal/utils/SignUtils.kt b/src/appleMain/kotlin/internal/utils/SignUtils.kt
new file mode 100644
index 0000000..4f28e69
--- /dev/null
+++ b/src/appleMain/kotlin/internal/utils/SignUtils.kt
@@ -0,0 +1,173 @@
+/*
+ * 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.AsnOneTypes
+import com.infomaniak.auth.lib.internal.encodeAsn1Integer
+import com.infomaniak.auth.lib.internal.extensions.buildCFDictionary
+import com.infomaniak.auth.lib.internal.extensions.firstOrElse
+import com.infomaniak.auth.lib.internal.extensions.set
+import com.infomaniak.auth.lib.internal.extensions.toByteArray
+import com.infomaniak.auth.lib.internal.extensions.toCFDataRef
+import com.infomaniak.auth.lib.internal.extensions.toNSData
+import com.infomaniak.auth.lib.internal.extensions.trimOrPadStart
+import com.infomaniak.auth.lib.internal.extensions.tryIt
+import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.memScoped
+import platform.CoreFoundation.CFDataRef
+import platform.CoreFoundation.CFRelease
+import platform.Security.SecKeyCreateSignature
+import platform.Security.SecKeyCreateWithData
+import platform.Security.SecKeyRef
+import platform.Security.SecKeyVerifySignature
+import platform.Security.kSecAttrKeyClass
+import platform.Security.kSecAttrKeyClassPrivate
+import platform.Security.kSecAttrKeyClassPublic
+import platform.Security.kSecAttrKeySizeInBits
+import platform.Security.kSecAttrKeyType
+import platform.Security.kSecAttrKeyTypeECSECPrimeRandom
+import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA256
+
+@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class)
+internal actual object SignUtils {
+
+ actual fun signWithPrivateKey(privateKey: ByteArray, data: ByteArray): ByteArray = memScoped {
+ val privateKeyRef = importPrivateKeyFromBytes(privateKey)
+ ?: throw IllegalArgumentException("Failed to import private key")
+
+ val dataToSign = data.toNSData().toCFDataRef()
+
+ val signatureResult = tryIt { errorPtr ->
+ SecKeyCreateSignature(
+ key = privateKeyRef,
+ algorithm = kSecKeyAlgorithmECDSASignatureMessageX962SHA256,
+ dataToSign = dataToSign,
+ error = errorPtr
+ )
+ }
+
+ CFRelease(privateKeyRef)
+
+ //TODO[Authenticator]: Check the code below works properly, with SignUtilsTest.
+
+ when (signatureResult) {
+ is Xor.First -> convertX962ToDer(signatureResult.value.toByteArray())
+ is Xor.Second -> throw IllegalStateException("Signing failed: ${signatureResult.value.localizedDescription}")
+ }
+ }
+
+ actual fun verifySignature(publicKey: ByteArray, data: ByteArray, signatureData: ByteArray): Boolean {
+ val attributes = buildCFDictionary {
+ this[kSecAttrKeyType] = kSecAttrKeyTypeECSECPrimeRandom
+ this[kSecAttrKeyClass] = kSecAttrKeyClassPublic
+ this[kSecAttrKeySizeInBits] = 256
+ }
+ val key = tryIt { errorPtr ->
+ SecKeyCreateWithData(publicKey.toNSData().toCFDataRef(), attributes, errorPtr)
+ }.firstOrElse {
+ println(it)
+ return false
+ }
+ return tryIt { errorPtr ->
+ SecKeyVerifySignature(
+ key = key,
+ algorithm = kSecKeyAlgorithmECDSASignatureMessageX962SHA256,
+ signedData = data.toNSData().toCFDataRef(),
+ signature = signatureData.toNSData().toCFDataRef(),
+ error = errorPtr
+ )
+ }.firstOrElse {
+ println(it)
+ return false
+ }
+ }
+
+ private fun convertX962ToDer(x962Signature: ByteArray): ByteArray {
+ val (r, s) = convertDerToRawSignature(x962Signature)
+
+ val sequenceContent = r.encodeAsn1Integer() + s.encodeAsn1Integer()
+
+ val sequenceLength = sequenceContent.size
+ return if (sequenceLength <= 127) {
+ byteArrayOf(AsnOneTypes.SEQUENCE, sequenceLength.toByte()) + sequenceContent
+ } else {
+ val lengthBytes = byteArrayOf(
+ (sequenceLength shr 8).toByte(),
+ sequenceLength.toByte()
+ )
+ byteArrayOf(AsnOneTypes.SEQUENCE, 0x82.toByte()) + lengthBytes + sequenceContent
+ }
+ }
+
+ private fun convertDerToRawSignature(derSignature: ByteArray): Pair {
+ require(derSignature.size in 8..72) { // Typical length is between 68 and 72, but can theoretically be lower.
+ "Invalid DER signature length: ${derSignature.size}, expected 8-72"
+ }
+
+ // Position after SEQUENCE header (0x30, length)
+ var pos = 2
+
+ // Parse INTEGER r
+ require(derSignature[pos] == AsnOneTypes.INTEGER) { "Expected INTEGER tag for r" }
+ pos++
+ val rLen = derSignature[pos].toInt() and 0xFF
+ pos++
+ val r = derSignature.copyOfRange(pos, pos + rLen).trimOrPadStart(32)
+ pos += rLen
+
+ // Parse INTEGER s
+ require(derSignature[pos] == AsnOneTypes.INTEGER) { "Expected INTEGER tag for s" }
+ pos++
+ val sLen = derSignature[pos].toInt() and 0xFF
+ pos++
+ val s = derSignature.copyOfRange(pos, pos + sLen).trimOrPadStart(32)
+
+ return r to s
+ }
+
+ private fun importPrivateKeyFromBytes(keyBytes: ByteArray): SecKeyRef? = memScoped {
+ val keyData = keyBytes.toNSData().toCFDataRef()
+
+ val attributes = buildCFDictionary {
+ this[kSecAttrKeyType] = kSecAttrKeyTypeECSECPrimeRandom
+ this[kSecAttrKeyClass] = kSecAttrKeyClassPrivate
+ this[kSecAttrKeySizeInBits] = 256
+ }
+
+ val privateKey = tryIt { errorPtr ->
+ SecKeyCreateWithData(
+ keyData = keyData,
+ attributes = attributes,
+ error = errorPtr
+ )
+ }
+
+ CFRelease(attributes)
+
+ return when (privateKey) {
+ is Xor.First -> privateKey.firstOrNull()
+ is Xor.Second -> {
+ println("Error importing key: ${privateKey.value.localizedDescription}")
+ null
+ }
+ }
+ }
+
+ private fun CFDataRef.toByteArray(): ByteArray = this.toNSData().toByteArray()
+}
diff --git a/src/appleTest/kotlin/internal/KeyPairManagerTest.kt b/src/appleTest/kotlin/internal/KeyPairManagerTest.kt
new file mode 100644
index 0000000..d8b7041
--- /dev/null
+++ b/src/appleTest/kotlin/internal/KeyPairManagerTest.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.Xor
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertNull
+import kotlin.test.fail
+
+class KeyPairManagerTest {
+
+ @Test
+ fun testKeyPairManager() {
+ val keyPairManager = KeyPairManager()
+
+ runTest {
+ val userId = 12345L
+ val keyId = "keyId"
+ //NOTE: The default KeyChain is not available on headless simulators,
+ // so we need to remove this test, or update it to use in-memory keys instead.
+ // Right now, it just fails.
+ val error = keyPairManager.generateNewKey(userId, keyId)
+ assertNull(error)
+
+ val publicKey = keyPairManager.retrievePublicKey(userId, keyId)
+ when (publicKey) {
+ is Xor.First -> Unit // OK
+ is Xor.Second -> fail("Couldn't generate the key")
+ }
+ }
+ }
+}
diff --git a/src/appleTest/kotlin/internal/SigningTest.apple.kt b/src/appleTest/kotlin/internal/SigningTest.apple.kt
new file mode 100644
index 0000000..8b62619
--- /dev/null
+++ b/src/appleTest/kotlin/internal/SigningTest.apple.kt
@@ -0,0 +1,65 @@
+/*
+ * 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(ExperimentalForeignApi::class)
+
+package com.infomaniak.auth.lib.internal
+
+import com.infomaniak.auth.lib.internal.extensions.firstOrElse
+import com.infomaniak.auth.lib.internal.extensions.toByteArray
+import com.infomaniak.auth.lib.internal.extensions.toNSData
+import com.infomaniak.auth.lib.internal.extensions.tryIt
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Security.SecKeyCopyExternalRepresentation
+import platform.Security.SecKeyCopyPublicKey
+import kotlin.test.Test
+import kotlin.test.fail
+
+class SigningTest : SigningTestBase() {
+
+ @Test
+ fun `__this is a test class with tests in the super class`() {
+ }
+
+ override fun getKeyPair(): Pair {
+ val privateKey = generateEcPrivateKeyInMemory(
+ tag = "whatever",
+ keyAccessGuard = KeyAccessGuard.Unguarded,
+ accessibility = KeyAccessibility.AfterFirstUnlock.ThisDeviceOnly,
+ ).firstOrElse { error -> fail("Error generating private key: $error") }
+
+ val privateKeyData = tryIt { errorPtr ->
+ SecKeyCopyExternalRepresentation(privateKey, errorPtr)
+ }.firstOrElse { error -> fail("Error copying private key: $error") }.toNSData()
+
+ val publicKey = SecKeyCopyPublicKey(privateKey) ?: fail("Failed to extract public key from private key")
+
+ val publicKeyData = tryIt { errorPtr ->
+ SecKeyCopyExternalRepresentation(publicKey, errorPtr)
+ }.firstOrElse { error -> fail("Error copying public key: $error") }.toNSData()
+ return privateKeyData.toByteArray() to publicKeyData.toByteArray()
+ }
+
+ override fun getTestDataSet(): List = listOf(
+ TestData(
+ privateKey = "0480780672c97a37ea97b9a7e11ba1d4b8a0a55f7dcdf6f188312306c81b37c978f2d40b7f70b9213ece44606f73410ed42f7fa4d6d8ef3fc23d608b76edb2942a1ca57a786db47d4b9a50c9cc6df7b87c1ee07ce3d41c791afd6ad469917d3244".hexToByteArray(),
+ publicKey = "0480780672c97a37ea97b9a7e11ba1d4b8a0a55f7dcdf6f188312306c81b37c978f2d40b7f70b9213ece44606f73410ed42f7fa4d6d8ef3fc23d608b76edb2942a".hexToByteArray(),
+ dataToSign = "4c4f4c".hexToByteArray(),
+ signature = "30450220205023db9fd540084a67f9439a858ebc3fd0b8b39380874d25f854868ad7bc4d022100be4669bc12a35bce32ae08d5f801e95363b58bf4502ecb916d386e8832ccf8bf".hexToByteArray(),
+ )
+ )
+}
diff --git a/src/commonMain/kotlin/Account.kt b/src/commonMain/kotlin/Account.kt
new file mode 100644
index 0000000..9caa45d
--- /dev/null
+++ b/src/commonMain/kotlin/Account.kt
@@ -0,0 +1,77 @@
+/*
+ * 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
+
+data class Account(
+ val id: Long,
+ val fullName: String,
+ val initials: String,
+ val email: String,
+ val avatarUrl: String? = null,
+ val status: Status,
+) {
+ sealed interface Status {
+
+ /**
+ * @property securityScore can range from 0 to 5
+ * @property passwordChangedAck is set when the password changed. Call it to acknowledge the change and dismiss it.
+ */
+ data class LoggedIn(
+ val securityScore: Int? = null,
+ val passwordChangedAck: (() -> Unit)? = null,
+ ) : Status {
+ val isSecured: Boolean get() = securityScore == 5
+ }
+
+ sealed interface NotConnected : Status {
+
+ sealed interface AttemptingToConnect : NotConnected {
+ data object ToBeMigrated : AttemptingToConnect
+ companion object : AttemptingToConnect
+ }
+
+ /**
+ * The actual email of the account might have changed since then, and we can't know about it.
+ * So, the UI is supposed to pre-fill it with the one in [legacyAccount], and prompt the user to check it's
+ * correct, letting them replace it if needed (editable text field).
+ */
+ data class ReLogin(
+ val legacyAccount: Account,
+ val hadIncorrectPassword: Boolean = false,
+ val lastIssue: DismissableIssue?,
+ val sendCredentials: ((CredentialsForMigration) -> Unit)?,
+ ) : NotConnected {
+
+ data class DismissableIssue(
+ val dismiss: () -> Unit,
+ val cause: Issue.Retriable.Cause,
+ )
+
+ val isSendingCredentials: Boolean get() = sendCredentials == null
+ }
+
+ /**
+ * **IMPORTANT:** Make sure to remove the user from the app's user db BEFORE calling [removeAccount] from here,
+ * so the operation is recoverable in all possible edge cases (like the app process dying).
+ */
+ data class Disconnected(val removeAccount: () -> Unit) : NotConnected
+
+ data class LoginFailed(val issue: Issue) : NotConnected
+ }
+ }
+}
diff --git a/src/commonMain/kotlin/AppStatus.kt b/src/commonMain/kotlin/AppStatus.kt
new file mode 100644
index 0000000..a3858e0
--- /dev/null
+++ b/src/commonMain/kotlin/AppStatus.kt
@@ -0,0 +1,61 @@
+/*
+ * 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
+
+sealed interface AppStatus {
+
+ sealed interface LoginRequired : AppStatus {
+
+ /**
+ * The list of accounts that are pending migration can be found in [AuthenticatorFacade.accounts].
+ */
+ data class MigratingFromLegacyKAuth(val proceed: () -> Unit) : LoginRequired
+
+ /**
+ * [AuthenticatorFacade.appStatus] will automatically change to
+ * [LoggingIn] after [AuthenticatorFacade.addAccounts] is called.
+ */
+ data object NotMigrating : LoginRequired
+
+ data class MustReLogin(
+ val accountId: Long,
+ val skip: () -> Unit
+ ) : LoginRequired
+ }
+
+ /**
+ * This status is emitted by [AuthenticatorFacade.appStatus] once either
+ * [AuthenticatorFacade.addAccounts] or [LoginRequired.MigratingFromLegacyKAuth.proceed] is called.
+ *
+ * After the 1st login completes, [AuthenticatorFacade.appStatus] will switch to [EverythingReady], if at least one
+ * account was successfully connected/migrated, or straight to [SetupComplete] otherwise, with the errors being
+ * surfaced in the [Account.status] property from the accounts in [AuthenticatorFacade.accounts].
+ */
+ data object LoggingIn : AppStatus
+
+ /**
+ * Comes right after [AppStatus.LoggingIn], if at least one account was successfully connected/migrated.
+ *
+ * Calling [proceed] will lead [AuthenticatorFacade.appStatus] to switch to [SetupComplete].
+ */
+ data class EverythingReady(val proceed: () -> Unit) : AppStatus
+
+ data class SetupComplete(val addAnAccount: () -> Unit) : AppStatus
+
+ data class AddingAnAccount(val cancel: () -> Unit) : AppStatus
+}
diff --git a/src/commonMain/kotlin/AuthenticatorFacade.kt b/src/commonMain/kotlin/AuthenticatorFacade.kt
new file mode 100644
index 0000000..8efa4d1
--- /dev/null
+++ b/src/commonMain/kotlin/AuthenticatorFacade.kt
@@ -0,0 +1,137 @@
+/*
+ * 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
+
+import com.infomaniak.auth.lib.internal.AuthenticatorFacadeImpl
+import com.infomaniak.auth.lib.internal.db.AccountEntity
+import com.infomaniak.auth.lib.internal.db.getAccountsRoomDatabase
+import com.infomaniak.auth.lib.internal.extensions.firstOrElse
+import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager
+import com.infomaniak.auth.lib.internal.managers.MigrationManager
+import com.infomaniak.auth.lib.internal.network.ApiClientProvider
+import com.infomaniak.auth.lib.internal.network.ApiRoutes
+import com.infomaniak.auth.lib.internal.repositories.AccountsRepository
+import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequests
+import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests
+import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile
+import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge
+import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
+
+abstract class AuthenticatorFacade internal constructor() {
+
+ abstract val accounts: Flow>
+
+ abstract val appStatus: SharedFlow
+
+ inline fun appStatusOrNull(): T? = appStatus.replayCache.first() as? T
+
+ /**
+ * Add successfully connected accounts.
+ *
+ * Will lead to [appStatus] to switch to the [AppStatus.LoggingIn] case.
+ */
+ abstract suspend fun addAccounts(connectedAccounts: List)
+
+ /**
+ * Remove account from the authenticator.
+ */
+ @Throws(Exception::class)
+ abstract suspend fun removeAccount(token: String?, id: Long)
+
+ /**
+ * Refresh the token for the specific userId
+ */
+ @Throws(Exception::class)
+ abstract suspend fun refreshTokenFor(userId: Long)
+
+ abstract fun refreshUserProfiles()
+
+ companion object {
+
+ fun create(
+ apiHost: String,
+ userAgent: String,
+ clientId: String,
+ databaseNameOrPath: String? = null,
+ crashReport: CrashReportInterface,
+ authenticatorBridge: AuthenticatorBridge,
+ logStatusChanges: Boolean = false,
+ scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
+ ): AuthenticatorFacade {
+ val routes = ApiRoutes(apiHost)
+ val apiClientProvider = ApiClientProvider(
+ scope = scope,
+ userAgent = userAgent,
+ routes = routes,
+ crashReport = crashReport,
+ )
+ val httpClient = apiClientProvider.httpClient
+ val webAuthnRequests = WebAuthnRequests(httpClient = httpClient, routes = routes)
+ val accountsDatabase = getAccountsRoomDatabase(databaseNameOrPath)
+ val accountsRepository = AccountsRepository(accountsDatabase)
+ val authenticatorManager = AuthenticatorManager(
+ webAuthnRequests = webAuthnRequests,
+ accountsRepository = accountsRepository
+ )
+ val migrationManager = MigrationManager(
+ coroutineScope = scope,
+ crashReport = crashReport,
+ accountsDatabase = accountsDatabase,
+ authenticatorManager = authenticatorManager,
+ webAuthnRequests = webAuthnRequests,
+ clientId = clientId,
+ )
+ val authenticatorRequests: AuthenticatorRequests by lazy {
+ AuthenticatorRequests(
+ createHttpClient = apiClientProvider::createHttpClient,
+ getTokenForUser = authenticatorBridge::getTokenFromDatabase,
+ refreshToken = { userId ->
+ authenticatorManager.getToken(clientId, userId).firstOrElse { error(it) }.also { newToken ->
+ authenticatorBridge.attemptPersistingTokenForAccount(userId, newToken)
+ }
+ },
+ disconnectAccount = { userId ->
+ accountsDatabase.getDao().updateStatusForUser(
+ userId = userId,
+ newStatus = AccountEntity.Status.Disconnected
+ )
+ },
+ routes = routes,
+ accountsDao = accountsDatabase.getDao(),
+ coroutineScope = scope
+ )
+ }
+ return AuthenticatorFacadeImpl(
+ accountsDatabase = accountsDatabase,
+ clientId = clientId,
+ authenticatorRequests = authenticatorRequests,
+ authenticatorManager = authenticatorManager,
+ migrationManager = migrationManager,
+ authenticatorBridge = authenticatorBridge,
+ crashReport = crashReport,
+ shouldLogStatusChanges = logStatusChanges,
+ coroutineScope = scope,
+ )
+ }
+
+ }
+}
diff --git a/src/commonMain/kotlin/CredentialsForMigration.kt b/src/commonMain/kotlin/CredentialsForMigration.kt
new file mode 100644
index 0000000..efd308a
--- /dev/null
+++ b/src/commonMain/kotlin/CredentialsForMigration.kt
@@ -0,0 +1,23 @@
+/*
+ * 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
+
+data class CredentialsForMigration(
+ val confirmedEmail: String,
+ val password: String,
+)
diff --git a/src/commonMain/kotlin/Issue.kt b/src/commonMain/kotlin/Issue.kt
new file mode 100644
index 0000000..9a6ad24
--- /dev/null
+++ b/src/commonMain/kotlin/Issue.kt
@@ -0,0 +1,40 @@
+/*
+ * 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
+
+sealed interface Issue {
+
+ /**
+ * When coming from kAuth, if at least one account was successfully migrated, we don't have this at all.
+ * @property proceed Typically called when the user presses a button labeled "Skip" or "Retry".
+ */
+ data class Retriable(
+ val cause: Cause,
+ val proceed: (shouldRetry: Boolean) -> Unit
+ ) : Issue {
+ sealed interface Cause {
+ data object NetworkIssue : Cause
+ data object ServerUnavailable : Cause
+ data class Other(val errorCode: Int, val message: String) : Cause
+ }
+ }
+
+ /** Should never happen, since it's linked to normally impossible app-internal cases. */
+ data class NonRetriable(val message: String) : Issue
+
+}
diff --git a/src/commonMain/kotlin/internal/ASN.1.kt b/src/commonMain/kotlin/internal/ASN.1.kt
new file mode 100644
index 0000000..8b85e7f
--- /dev/null
+++ b/src/commonMain/kotlin/internal/ASN.1.kt
@@ -0,0 +1,43 @@
+/*
+ * 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
+
+// See https://www.oss.com/asn1/resources/asn1-made-simple/asn1-quick-reference.html#Types
+internal object AsnOneTypes {
+ const val SEQUENCE: Byte = 0x30
+ const val BIT_STRING: Byte = 0x03
+ const val INTEGER: Byte = 0x02
+}
+
+internal fun ByteArray.encodeAsn1Integer(): ByteArray {
+ val needsPadding = this[0].toInt() and 0x80 != 0
+ val length = size + if (needsPadding) 1 else 0
+ val result = ByteArray(2 + length)
+
+ result[0] = AsnOneTypes.INTEGER
+ result[1] = length.toByte()
+
+ if (needsPadding) {
+ result[2] = 0x00
+ copyInto(result, 3)
+ } else {
+ copyInto(result, 2)
+ }
+
+ return result
+}
diff --git a/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt
new file mode 100644
index 0000000..f5cca37
--- /dev/null
+++ b/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt
@@ -0,0 +1,562 @@
+/*
+ * 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(ExperimentalCoroutinesApi::class)
+
+package com.infomaniak.auth.lib.internal
+
+import com.infomaniak.auth.lib.Account
+import com.infomaniak.auth.lib.Account.Status.NotConnected.ReLogin
+import com.infomaniak.auth.lib.AppStatus
+import com.infomaniak.auth.lib.AuthenticatorFacade
+import com.infomaniak.auth.lib.CredentialsForMigration
+import com.infomaniak.auth.lib.Issue
+import com.infomaniak.auth.lib.Issue.Retriable.Cause
+import com.infomaniak.auth.lib.internal.db.AccountEntity
+import com.infomaniak.auth.lib.internal.db.AccountEntity.Status
+import com.infomaniak.auth.lib.internal.db.AccountsDatabase
+import com.infomaniak.auth.lib.internal.extensions.cancellable
+import com.infomaniak.auth.lib.internal.extensions.firstOrElse
+import com.infomaniak.auth.lib.internal.extensions.toAccount
+import com.infomaniak.auth.lib.internal.extensions.toAccountEntity
+import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager
+import com.infomaniak.auth.lib.internal.managers.MigrationManager
+import com.infomaniak.auth.lib.internal.otp.deleteLegacyAccount
+import com.infomaniak.auth.lib.internal.otp.deleteLegacyDB
+import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts
+import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequests
+import com.infomaniak.auth.lib.internal.utils.buildFlowWithElements
+import com.infomaniak.auth.lib.internal.utils.dynamicLazyMapOfSharedFlow
+import com.infomaniak.auth.lib.internal.utils.launchRacer
+import com.infomaniak.auth.lib.internal.utils.race
+import com.infomaniak.auth.lib.internal.utils.raceOf
+import com.infomaniak.auth.lib.internal.utils.waitForComplete
+import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull
+import com.infomaniak.auth.lib.logging.BlockLogger
+import com.infomaniak.auth.lib.logging.breadcrumbsLogger
+import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile
+import com.infomaniak.auth.lib.network.exceptions.ApiException
+import com.infomaniak.auth.lib.network.exceptions.NetworkException
+import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge
+import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CompletableJob
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.transformLatest
+import kotlinx.coroutines.launch
+import kotlinx.io.IOException
+import kotlin.time.Duration.Companion.seconds
+
+internal class AuthenticatorFacadeImpl(
+ accountsDatabase: AccountsDatabase,
+ private val clientId: String,
+ private val authenticatorRequests: AuthenticatorRequests,
+ private val authenticatorManager: AuthenticatorManager,
+ private val migrationManager: MigrationManager,
+ private val authenticatorBridge: AuthenticatorBridge,
+ private val crashReport: CrashReportInterface,
+ shouldLogStatusChanges: Boolean,
+ coroutineScope: CoroutineScope,
+) : AuthenticatorFacade() {
+
+ private val blockLogger = BlockLogger.breadcrumbsLogger(crashReport, "lib.kmp")
+
+ private val dao = accountsDatabase.getDao()
+
+ private val accountEntities = flow {
+ migrationManager.setBackedUpAccountsStatus()
+ migrationManager.addLegacyAccountsToDB()
+ emitAll(dao.getAccountsAsFlow())
+ }.shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
+
+ private val atLeastOneConnectedAccount: Flow = accountEntities.map { entities ->
+ entities.any { entity -> entity.isLoggedIn }
+ }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 1)
+
+ private val userIdsToStatusFlows = coroutineScope.dynamicLazyMapOfSharedFlow(
+ cacheManager = { _, _ ->
+ delay(5.seconds) // Should be more than enough to keep the state between re-uses.
+ }
+ ) { userId: Long ->
+ accountStatusForUser(userId)
+ }
+
+ private val proceedMigration: CompletableJob = Job()
+
+ private val profileRefreshesTrigger = MutableSharedFlow(extraBufferCapacity = 1)
+
+ /** [Account.status] values come from the [accountStatusForUser] function. */
+ override val accounts: Flow> = accountEntities.flatMapLatest { entities ->
+ if (entities.isEmpty()) return@flatMapLatest flowOf(emptyList())
+
+ val userIds = entities.mapTo(mutableSetOf()) { it.id }
+
+ userIdsToStatusFlows.buildFlowWithElements(userIds) { userIdsToStatusFlow ->
+ val flowsOfStatus = entities.map { userIdsToStatusFlow.getValue(it.id) }
+ combine(flowsOfStatus) { statuses ->
+ entities.mapIndexed { index, entity -> entity.toAccount(statuses[index]) }
+ }
+ }
+ }.flowOn(Dispatchers.Default).conflate().distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
+
+ override val appStatus: SharedFlow = appStatusFlow()
+ .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
+
+ init {
+ if (shouldLogStatusChanges) coroutineScope.launch { logStatusChanges() }
+ }
+
+ override suspend fun addAccounts(connectedAccounts: List) {
+ connectedAccounts.forEach { authenticatorBridge.persistUserProfile(it) }
+ val entities = connectedAccounts.map {
+ it.toAccountEntity(Status.PasskeyRegistrationPending)
+ }
+ dao.upsert(entities)
+ }
+
+ override suspend fun removeAccount(token: String?, id: Long) {
+ authenticatorManager.removeAccount(token, id)
+ }
+
+ override suspend fun refreshTokenFor(userId: Long) {
+ val token = authenticatorManager.getToken(clientId, userId).firstOrElse {
+ error("Could not get the key for user $userId from the storage: $it")
+ }
+ authenticatorBridge.attemptPersistingTokenForAccount(userId, token)
+ }
+
+ override fun refreshUserProfiles() {
+ profileRefreshesTrigger.tryEmit(Unit)
+ }
+
+ private fun appStatusFlow(): Flow = flow {
+ var needsToShowEverythingReady = false
+
+ val appStatusFlow: Flow = accounts.transformLatest { accounts ->
+ val atLeastOneConnectedAccount = accounts.any { it.status is Account.Status.LoggedIn }
+ val noConnectedAccount = !atLeastOneConnectedAccount
+
+ if (noConnectedAccount) {
+ needsToShowEverythingReady = true
+ if (accounts.isEmpty()) {
+ emit(AppStatus.LoginRequired.NotMigrating)
+ /** Waiting for [addAccounts] to be called, which will cancel this, as `accountEntities` emits. */
+ awaitCancellation()
+ } else {
+ val needsMigration = accounts.any { it.status == Account.Status.NotConnected.AttemptingToConnect.ToBeMigrated }
+ if (needsMigration) {
+ emit(AppStatus.LoginRequired.MigratingFromLegacyKAuth(proceed = proceedMigration::complete))
+ proceedMigration.join()
+ }
+ emit(AppStatus.LoggingIn)
+ handleLoggingInStatus(accounts)
+ }
+ } else if (needsToShowEverythingReady) {
+ waitForComplete { proceedAsync ->
+ emit(AppStatus.EverythingReady(proceed = proceedAsync::complete))
+ }
+ }
+ while (true) {
+ needsToShowEverythingReady = false
+ waitForComplete { addAnAccountAsync ->
+ emit(AppStatus.SetupComplete(addAnAccount = addAnAccountAsync::complete))
+ }
+ needsToShowEverythingReady = true
+ waitForComplete { backAsync ->
+ emit(AppStatus.AddingAnAccount(cancel = backAsync::complete))
+ }
+ }
+ }
+
+ emitAll(appStatusFlow)
+ }.distinctUntilChanged()
+
+ private suspend fun FlowCollector.handleLoggingInStatus(accounts: List) {
+ val stillTryingToConnect = accounts.any { account ->
+ account.status is Account.Status.NotConnected.AttemptingToConnect
+ }
+ if (stillTryingToConnect) {
+ awaitCancellation() // If at least one account is still loading, wait for the next update (i.e. cancellation)
+ }
+
+ val accountToRelogin: Account = when (accounts.size) {
+ 1 if accounts.single().status is ReLogin -> accounts.single()
+ else -> {
+ /** Continue towards [AppStatus.SetupComplete] to let the user handle failures, or re-login accounts separately */
+ return
+ }
+ }
+ /** Continue towards [AppStatus.SetupComplete] if the user asks to skip. */
+ waitForComplete { skipAsync ->
+ emit(AppStatus.LoginRequired.MustReLogin(accountToRelogin.id, skipAsync::complete))
+ }
+ }
+
+ private fun accountStatusForUser(userId: Long): Flow =
+ dao.getAccountAsFlow(userId).transformLatest { entity ->
+ val status = entity?.status
+ blockLogger.withLog("accountStatusForUser($status)") {
+ when (status) {
+ Status.ToBeMigrated -> migrationAttempts(entity)
+ Status.PasskeyRegistrationPending, Status.FirstPasskeyAuthenticationPending -> {
+ registrationAttempts(entity)
+ }
+ Status.RestoringFromBackup, Status.DeletingOldKeyAfterRestoration -> {
+ restoreFromBackupAttempts(account = entity)
+ }
+ Status.LoggedIn, Status.PasswordChanged -> {
+ handledLoggedInState(entity)
+ }
+ Status.Disconnected -> {
+ handleDisconnectedState(entity)
+ }
+ null -> Unit // Should not happen in practice.
+ }
+ }
+ }
+
+ private suspend fun shouldTryImmediateLogin(): Boolean = raceOf(
+ {
+ proceedMigration.join()
+ true
+ },
+ {
+ atLeastOneConnectedAccount.first { it }
+ false
+ },
+ ) || proceedMigration.isCompleted // In case it finished after a connected account was added.
+
+ private suspend fun FlowCollector.registrationAttempts(notRegisteredAccount: AccountEntity) {
+ val passKeyAlreadyRegistered = when (val accountStatus = notRegisteredAccount.status) {
+ Status.PasskeyRegistrationPending -> false
+ Status.FirstPasskeyAuthenticationPending -> true
+ else -> throw IllegalArgumentException("registrationAttempts doesn't support $accountStatus")
+ }
+ val userId = notRegisteredAccount.id
+ withRetries(userId = userId) {
+ emit(Account.Status.NotConnected.AttemptingToConnect)
+ if (!passKeyAlreadyRegistered) {
+ val temporaryToken = authenticatorBridge.getTokenFromDatabase(userId)
+ ?: error("Temporary token missing in DB for userId=$userId")
+ // Just in case orphans passkeys are lying around, we want to make sure to start from a clean state.
+ authenticatorManager.deleteKeysFor(notRegisteredAccount.id)
+ val _ = authenticatorManager.registerPasskey(temporaryToken.accessToken, userId)
+ dao.upsert(notRegisteredAccount.copy(status = Status.FirstPasskeyAuthenticationPending))
+ // The DB update above is expected to cause the cancellation & restart of this.
+ }
+
+ val token = authenticatorManager.getToken(
+ clientId = clientId,
+ userId = userId,
+ ).firstOrElse { error("Key not found: ${it.details}") }
+ authenticatorBridge.attemptPersistingTokenForAccount(userId, token)
+ val profile = authenticatorManager.getUserProfile(token.accessToken).also { it.apiToken = token }
+ authenticatorBridge.persistUserProfile(profile)
+
+ cleanupLegacyAccountIfNeeded(userId)
+ dao.upsert(
+ notRegisteredAccount.copy(
+ securityScore = profile.preferences.security?.score,
+ lastPasswordUpdate = profile.preferences.security?.dateLastChangedPassword,
+ status = Status.LoggedIn
+ )
+ )
+ }
+ }
+
+ private suspend fun cleanupLegacyAccountIfNeeded(userId: Long) {
+ if (getLegacyAccounts().none { it.userId.toLong() == userId }) return
+ deleteLegacyAccount(userId.toString())
+ if (getLegacyAccounts().isEmpty()) deleteLegacyDB()
+ }
+
+ /**
+ * Tries to migrate the given account from kAuth to Infomaniak Authenticator, with retries,
+ * and emits the relevant [Account.Status.NotConnected] as needed.
+ *
+ * 1. Tries to perform a login (3 different ways)
+ * 2. Starts a migration session against the backend
+ * 3. Registers a passkey
+ * 4. Authenticate with it, getting a new access token
+ */
+ private suspend fun FlowCollector.migrationAttempts(accountToMigrate: AccountEntity) {
+ require(accountToMigrate.status == Status.ToBeMigrated)
+ emit(Account.Status.NotConnected.AttemptingToConnect.ToBeMigrated)
+
+ if (shouldTryImmediateLogin()) {
+ blockLogger.withLog("migrationAttempts.tryCrossAppLogin") {
+ tryCrossAppLogin(accountToMigrate) { return }
+ }
+ blockLogger.withLog("migrationAttempts.tryToMigrateViaOngoingLogin") {
+ tryToMigrateViaOngoingLogin(accountToMigrate) { return }
+ }
+ }
+ blockLogger.withLog("migrationAttempts.tryMigratingWithReLogin") {
+ tryMigratingWithReLogin(accountToMigrate)
+ }
+ }
+
+ private suspend inline fun FlowCollector.tryCrossAppLogin(
+ notConnectedAccount: AccountEntity,
+ onLoginSuccess: () -> Nothing
+ ) {
+ val userId = notConnectedAccount.id
+ withRetries(userId, onGiveUp = { return }) {
+ emit(Account.Status.NotConnected.AttemptingToConnect)
+ val temporaryToken = withTimeoutOrNull(
+ waitForTimeout = {
+ delay(8.seconds)
+ "getTokenFromCrossAppLogin timed out"
+ },
+ onTimeout = { message -> crashReport.capture(userId, message) }
+ ) {
+ authenticatorBridge.getTokenFromCrossAppLogin(userId)
+ } ?: return
+ val authentication = MigrationAuthentication.CrossAppLogin(temporaryToken)
+ if (attemptMigration(notConnectedAccount, authentication)) onLoginSuccess() else return
+ }
+ }
+
+ private suspend inline fun FlowCollector.tryToMigrateViaOngoingLogin(
+ notConnectedAccount: AccountEntity,
+ onLoginSuccess: () -> Nothing
+ ) {
+ withRetries(notConnectedAccount.id, onGiveUp = { return }) {
+ emit(Account.Status.NotConnected.AttemptingToConnect)
+ val authentication = MigrationAuthentication.OngoingLogin
+ if (attemptMigration(notConnectedAccount, authentication)) onLoginSuccess() else return
+ }
+ }
+
+ private suspend fun attemptMigration(
+ notConnectedAccount: AccountEntity,
+ authentication: MigrationAuthentication,
+ ): Boolean {
+ val userId = notConnectedAccount.id
+ val succeeded = migrationManager.tryMigrating(
+ userId = userId,
+ authentication = authentication,
+ )
+
+ if (!succeeded) return false
+
+ dao.upsert(notConnectedAccount.copy(status = Status.FirstPasskeyAuthenticationPending))
+
+ return true
+ }
+
+ private suspend fun FlowCollector.tryMigratingWithReLogin(accountToMigrate: AccountEntity) {
+ var status = ReLogin(
+ legacyAccount = accountToMigrate.toAccount(Account.Status.NotConnected.AttemptingToConnect),
+ hadIncorrectPassword = false,
+ lastIssue = null,
+ sendCredentials = null
+ )
+ val issueDismissals = Channel(capacity = Channel.CONFLATED)
+ loop@ while (true) {
+ status = runCatching {
+ val credentialsAsync = CompletableDeferred()
+ status = status.copy(sendCredentials = credentialsAsync::complete)
+ emit(status)
+ val credentialsForMigration = awaitCredentialsWhileAllowingIssueDismissal(
+ credentialsAsync = credentialsAsync,
+ issueDismissals = issueDismissals,
+ lastStatus = status,
+ onStatusUpdate = { newStatus ->
+ status = newStatus
+ emit(status)
+ }
+ )
+ status = status.copy(hadIncorrectPassword = false, lastIssue = null, sendCredentials = null)
+ emit(status)
+ val authentication = MigrationAuthentication.NoOngoingLogin(credentialsForMigration.password)
+ val succeeded = attemptMigration(accountToMigrate, authentication)
+ when {
+ succeeded -> return
+ else -> status.copy(hadIncorrectPassword = true)
+ }
+ }.cancellable().getOrElse {
+ it.printStackTrace()
+ // TODO Delete the capture method here after investigation
+ crashReport.capture(accountToMigrate.id, "re-login migration attempt failed", it)
+ // TODO Uncomment this when investigation is done
+ // it.reportIfNeeded(accountToMigrate.id, message = "re-login migration attempt failed")
+ val issue = ReLogin.DismissableIssue(
+ dismiss = { issueDismissals.trySend(Unit) },
+ cause = it.toIssueCause()
+ )
+ status.copy(lastIssue = issue)
+ }
+ }
+ }
+
+ private suspend inline fun awaitCredentialsWhileAllowingIssueDismissal(
+ credentialsAsync: CompletableDeferred,
+ issueDismissals: ReceiveChannel,
+ lastStatus: ReLogin,
+ onStatusUpdate: (newStatus: ReLogin) -> Unit,
+ ): CredentialsForMigration = race {
+ launchRacer { credentialsAsync.await() }
+ if (lastStatus.lastIssue != null) launchRacer {
+ issueDismissals.receive()
+ null
+ }
+ } ?: run {
+ onStatusUpdate(lastStatus.copy(lastIssue = null))
+ credentialsAsync.await()
+ }
+
+ private suspend fun FlowCollector.restoreFromBackupAttempts(account: AccountEntity) {
+ withRetries(userId = account.id) {
+ emit(Account.Status.NotConnected.AttemptingToConnect)
+ migrationManager.restore(account = account) { userId, token ->
+ authenticatorBridge.attemptPersistingTokenForAccount(userId, token)
+ }
+ }
+ }
+
+ private suspend fun FlowCollector.handledLoggedInState(account: AccountEntity) {
+ val needsToAcknowledgePasswordUpdate: Boolean = account.status == Status.PasswordChanged
+ if (needsToAcknowledgePasswordUpdate) {
+ val previousStatus = waitForComplete { passwordChangedAcknowledgedAsync ->
+ Account.Status.LoggedIn(
+ securityScore = account.securityScore,
+ passwordChangedAck = passwordChangedAcknowledgedAsync::complete
+ ).also { emit(it) }
+ }
+ emit(previousStatus.copy(passwordChangedAck = null))
+ dao.upsert(account.copy(status = Status.LoggedIn))
+ } else {
+ emit(Account.Status.LoggedIn(securityScore = account.securityScore))
+ }
+ updateUserProfileLoop(account)
+ }
+
+ private suspend fun FlowCollector.handleDisconnectedState(account: AccountEntity) {
+ waitForComplete { disconnectionRequest ->
+ val status = Account.Status.NotConnected.Disconnected(disconnectionRequest::complete)
+ emit(status)
+ }
+ authenticatorManager.removeAccount(token = null, userId = account.id)
+ }
+
+ private suspend fun updateUserProfileLoop(account: AccountEntity) {
+ require(account.isLoggedIn)
+ while (true) {
+ runCatching {
+ val profile = authenticatorRequests.getUserProfile(account.id)
+ val profileSecurity = requireNotNull(profile.preferences.security)
+ val newStatus = when (account.lastPasswordUpdate) {
+ profileSecurity.dateLastChangedPassword -> account.status
+ else -> Status.PasswordChanged
+ }
+ val updatedAccount = profile.toAccountEntity(status = newStatus)
+ if (updatedAccount != account) { // Avoid re-trigger loops when we're up to date.
+ dao.upsert(updatedAccount)
+ authenticatorBridge.persistUserProfile(profile)
+ }
+ }.cancellable().onFailure {
+ it.printStackTrace()
+ it.reportIfNeeded(account.id, message = "profile update refresh failed")
+ }
+ profileRefreshesTrigger.first()
+ }
+ }
+
+ private suspend inline fun FlowCollector.withRetries(
+ userId: Long,
+ onGiveUp: () -> Unit = {},
+ block: () -> R
+ ): R {
+ while (true) {
+ runCatching {
+ return block()
+ }.cancellable().onFailure {
+ it.printStackTrace()
+ it.reportIfNeeded(userId, "account connection attempt failed")
+ if (it is IllegalStateException || it is IllegalArgumentException) { // Local errors, no recourse.
+ val issue = Issue.NonRetriable(it.message ?: it::class.simpleName ?: "$it")
+ emit(Account.Status.NotConnected.LoginFailed(issue))
+ awaitCancellation()
+ }
+ val issueCause = it.toIssueCause()
+ val shouldRetryAsync = CompletableDeferred()
+ val issue = Issue.Retriable(cause = issueCause, proceed = shouldRetryAsync::complete)
+ emit(Account.Status.NotConnected.LoginFailed(issue))
+ val shouldRetry = shouldRetryAsync.await()
+ if (shouldRetry) continue else onGiveUp()
+ }
+ }
+ }
+
+ private fun Throwable.toIssueCause(): Cause = when (this) {
+ is NetworkException, is IOException -> Cause.NetworkIssue
+ is ApiException if (statusCode == 503) -> Cause.ServerUnavailable
+ is ApiException.ApiErrorException -> {
+ Cause.Other(12_000 + statusCode, "http $statusCode $errorCode $errorMessage")
+ }
+ is ApiException.UnexpectedApiErrorFormatException -> {
+ Cause.Other(22_000 + statusCode, "http $statusCode $bodyResponse")
+ }
+ else -> {
+ Cause.Other(11_000, message ?: this::class.simpleName ?: "$this")
+ }
+ }
+
+ private fun Throwable.reportIfNeeded(userId: Long, message: String) {
+ when (this) {
+ is NetworkException, is IOException -> Unit
+ is ApiException if (statusCode == 503) -> Unit
+ else -> crashReport.capture(userId, message, this)
+ }
+ }
+
+ private suspend fun logStatusChanges(): Nothing = coroutineScope {
+ launch {
+ accounts.collect { accounts ->
+ accounts.forEach { account ->
+ println("Account ${account.id} (${account.initials}) has status: ${account.status}")
+ }
+ }
+ }
+ appStatus.collect { println("appStatus: $it") }
+ }
+}
diff --git a/src/commonMain/kotlin/internal/CryptoObjectsBuilder.kt b/src/commonMain/kotlin/internal/CryptoObjectsBuilder.kt
new file mode 100644
index 0000000..4cd0808
--- /dev/null
+++ b/src/commonMain/kotlin/internal/CryptoObjectsBuilder.kt
@@ -0,0 +1,126 @@
+/*
+ * 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.models.ClientExtensionResults
+import com.infomaniak.auth.lib.internal.models.PasskeysOptions
+import com.infomaniak.auth.lib.internal.models.RegisterPasskey
+import com.infomaniak.auth.lib.internal.models.RegisterPasskeyResponse
+import com.infomaniak.auth.lib.internal.models.WebAuthnClientData
+import com.infomaniak.auth.lib.internal.utils.getDeviceInfo
+import com.infomaniak.auth.lib.internal.utils.getKeyCoordinates
+import com.infomaniak.auth.lib.internal.webauthn.KeyAlgorithm
+import com.infomaniak.auth.lib.internal.webauthn.createEncodedWebAuthnAttestationObject
+import com.infomaniak.auth.lib.internal.webauthn.keyCoseOf
+import io.ktor.utils.io.core.toByteArray
+import kotlinx.io.Buffer
+import kotlinx.io.readByteArray
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import okio.ByteString.Companion.encodeUtf8
+import kotlin.io.encoding.Base64
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@OptIn(ExperimentalUuidApi::class, ExperimentalSerializationApi::class)
+internal class CryptoObjectsBuilder {
+
+ internal val base64UrlSafeNoPadding = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
+ internal val base64NoPadding = Base64.withPadding(Base64.PaddingOption.ABSENT)
+
+ fun getKeyIds(): Pair {
+ //TODO[ik-auth]: Check if we can bypass the hexString, converting the Uuid directly to a ByteArray.
+ val randomUuid = Uuid.random().toHexString()
+ val rawId = randomUuid.toByteArray()
+ val id = base64NoPadding.encode(rawId)
+
+ return rawId to id
+ }
+
+ fun buildRegisterPasskey(
+ publicKey: ByteArray,
+ passkeysOptions: PasskeysOptions,
+ rawId: ByteArray,
+ id: String,
+ ): RegisterPasskey {
+ val authenticatorData = generateAuthenticatorData(
+ publicKey = publicKey,
+ rpId = passkeysOptions.relyingParty.id,
+ credentialId = rawId,
+ )
+ val clientData = buildClientData(passkeysOptions.challenge)
+
+ val attestationObject = createEncodedWebAuthnAttestationObject(
+ fmt = "none",
+ authData = authenticatorData
+ )
+ val response = RegisterPasskeyResponse(
+ attestationObject = base64UrlSafeNoPadding.encode(attestationObject),
+ clientDataJSON = base64UrlSafeNoPadding.encode(Json.encodeToString(clientData).encodeToByteArray()),
+ transports = listOf("internal"),
+ publicKey = base64UrlSafeNoPadding.encode(publicKey),
+ authenticatorData = base64UrlSafeNoPadding.encode(authenticatorData),
+ publicKeyAlgorithm = KeyAlgorithm.ES256,
+ )
+ val type = "public-key"
+ val clientExtensionResult = ClientExtensionResults
+ val authenticatorAttachment = "platform"
+
+ return RegisterPasskey(
+ session = passkeysOptions.session,
+ device = getDeviceInfo(),
+ id = id,
+ rawId = base64UrlSafeNoPadding.encode(rawId),
+ registerPasskeyResponse = response,
+ type = type,
+ clientExtensionResults = clientExtensionResult,
+ authenticatorAttachment = authenticatorAttachment,
+ )
+ }
+
+ fun buildClientData(challenge: String): WebAuthnClientData = WebAuthnClientData(
+ type = "webauthn.create",
+ challenge = challenge,
+ origin = "https://infomaniak.ch",
+ crossOrigin = false,
+ )
+
+ fun generateAuthenticatorData(publicKey: ByteArray, rpId: String, credentialId: ByteArray): ByteArray {
+ val keyCoordinates = getKeyCoordinates(publicKey)
+ val publicKeyCose = keyCoseOf(
+ x = keyCoordinates.x,
+ y = keyCoordinates.y
+ )
+ val rpIdHash = rpId.encodeUtf8().sha256().toByteArray()
+ val flags: Byte = 0x41
+ val signCount = ByteArray(4)
+
+ val aaguid = Uuid.NIL.toByteArray() //TODO Might need to do this only once to have something unique for the App
+ val credentialIdLength = byteArrayOf((credentialId.size shr 8).toByte(), (credentialId.size and 0xFF).toByte())
+
+ val buffer = Buffer()
+ buffer.write(rpIdHash)
+ buffer.writeByte(flags)
+ buffer.write(signCount)
+ buffer.write(aaguid)
+ buffer.write(credentialIdLength)
+ buffer.write(credentialId)
+ buffer.write(publicKeyCose)
+ return buffer.readByteArray()
+ }
+}
diff --git a/src/commonMain/kotlin/internal/Failure.kt b/src/commonMain/kotlin/internal/Failure.kt
new file mode 100644
index 0000000..f151106
--- /dev/null
+++ b/src/commonMain/kotlin/internal/Failure.kt
@@ -0,0 +1,26 @@
+/*
+ * 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
+
+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
+ }
+}
diff --git a/src/commonMain/kotlin/internal/KeyAccessGuard.kt b/src/commonMain/kotlin/internal/KeyAccessGuard.kt
new file mode 100644
index 0000000..7df6a62
--- /dev/null
+++ b/src/commonMain/kotlin/internal/KeyAccessGuard.kt
@@ -0,0 +1,48 @@
+/*
+ * 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
+
+internal sealed interface KeyAccessGuard {
+
+ sealed interface Authenticated : KeyAccessGuard
+
+ sealed interface Biometry : Authenticated {
+ data object Current : Biometry
+ data object CurrentAndFuture : Biometry
+ }
+
+ data object DevicePasscode : Authenticated
+
+ /**
+ * Accepts:
+ * - the device passcode
+ * - current & future biometrics
+ *
+ * Survives biometrics removal, but not device passcode removal.
+ */
+ data object DevicePasscodeOrNewBiometrics : Authenticated
+
+ /**
+ * Android-only (not supported on all devices).
+ *
+ * Requires a physical user-action, protecting from remote attacks.
+ */
+ data object UserConfirmation : KeyAccessGuard
+
+ data object Unguarded : KeyAccessGuard
+}
diff --git a/src/commonMain/kotlin/internal/KeyManager.kt b/src/commonMain/kotlin/internal/KeyManager.kt
new file mode 100644
index 0000000..6ca729e
--- /dev/null
+++ b/src/commonMain/kotlin/internal/KeyManager.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.Failure.KeyManagement.GenerationFailed
+import com.infomaniak.auth.lib.internal.Failure.KeyManagement.KeyExtractionFailed
+import com.infomaniak.auth.lib.internal.Failure.KeyManagement.KeyNotFound
+import com.infomaniak.auth.lib.internal.utils.Xor
+
+internal expect fun 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)
+ */
+ abstract suspend fun generateNewKey(userId: Long, keyId: String): GenerationFailed?
+
+ abstract suspend fun retrievePublicKey(userId: Long, keyId: String): Xor
+
+ abstract suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor
+
+ /** Sorted by creation date. */
+ abstract suspend fun getSortedKeyIds(matchOn: MatchOn): List
+
+ abstract suspend fun findKeyIdFor(matchOn: MatchOn): String?
+
+ 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()
+ }
+
+ 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/src/commonMain/kotlin/internal/KeyPurposes.kt b/src/commonMain/kotlin/internal/KeyPurposes.kt
new file mode 100644
index 0000000..8f3c45d
--- /dev/null
+++ b/src/commonMain/kotlin/internal/KeyPurposes.kt
@@ -0,0 +1,92 @@
+/*
+ * 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
+
+@ExposedCopyVisibility
+internal data class KeyPurposes private constructor(
+ val signing: Boolean,
+ val deriving: Boolean,
+ val verifying: Boolean,
+ val encrypting: Boolean,
+ val decrypting: Boolean,
+ val wrapping: Boolean,
+ val unwrapping: Boolean,
+) {
+
+ operator fun plus(other: KeyPurposes): KeyPurposes = KeyPurposes(
+ signing = signing || other.signing,
+ deriving = deriving || other.deriving,
+ verifying = verifying || other.verifying,
+ encrypting = encrypting || other.encrypting,
+ decrypting = decrypting || other.decrypting,
+ wrapping = wrapping || other.wrapping,
+ unwrapping = unwrapping || other.unwrapping
+ )
+ companion object {
+ val privateKeyDefaults = forPrivateKey()
+ val publicKeyDefaults = forPublicKey()
+
+ operator fun invoke(
+ signing: Boolean = false,
+ deriving: Boolean = false,
+ verifying: Boolean = false,
+ encrypting: Boolean = false,
+ decrypting: Boolean = false,
+ wrapping: Boolean = false,
+ unwrapping: Boolean = false,
+ ): KeyPurposes = KeyPurposes(
+ signing = signing,
+ deriving = deriving,
+ verifying = verifying,
+ encrypting = encrypting,
+ decrypting = decrypting,
+ wrapping = wrapping,
+ unwrapping = unwrapping,
+ )
+
+ fun forPrivateKey(
+ signing: Boolean = true,
+ decrypting: Boolean = true,
+ unwrapping: Boolean = true,
+ deriving: Boolean = true,
+ ): KeyPurposes = KeyPurposes(
+ signing = signing,
+ decrypting = decrypting,
+ unwrapping = unwrapping,
+ deriving = deriving,
+ verifying = false,
+ encrypting = false,
+ wrapping = false,
+ )
+
+ fun forPublicKey(
+ verifying: Boolean = true,
+ encrypting: Boolean = true,
+ wrapping: Boolean = true,
+ deriving: Boolean = true,
+ ): KeyPurposes = KeyPurposes(
+ verifying = verifying,
+ encrypting = encrypting,
+ wrapping = wrapping,
+ deriving = deriving,
+ signing = false,
+ decrypting = false,
+ unwrapping = false,
+ )
+ }
+}
diff --git a/src/commonMain/kotlin/internal/MigrationAuthentication.kt b/src/commonMain/kotlin/internal/MigrationAuthentication.kt
new file mode 100644
index 0000000..978f4b8
--- /dev/null
+++ b/src/commonMain/kotlin/internal/MigrationAuthentication.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.models.migration.SharedApiToken
+
+internal sealed interface MigrationAuthentication {
+ data class CrossAppLogin(val derivedToken: SharedApiToken) : MigrationAuthentication
+ data class NoOngoingLogin(val password: String) : MigrationAuthentication
+ data object OngoingLogin : MigrationAuthentication
+}
diff --git a/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt
new file mode 100644
index 0000000..b8a3edc
--- /dev/null
+++ b/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/src/commonMain/kotlin/internal/db/AccountEntity.kt b/src/commonMain/kotlin/internal/db/AccountEntity.kt
new file mode 100644
index 0000000..95447da
--- /dev/null
+++ b/src/commonMain/kotlin/internal/db/AccountEntity.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.db
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.infomaniak.auth.lib.AuthenticatorFacade
+
+@Entity
+internal data class AccountEntity(
+ @PrimaryKey val id: Long = 0,
+ val fullName: String,
+ val initials: String,
+ val email: String,
+ val avatarUrl: String? = null,
+ val status: Status,
+ val securityScore: Int? = null,
+ val lastPasswordUpdate: Long? = null
+) {
+ val isLoggedIn: Boolean get() = status == Status.LoggedIn || status == Status.PasswordChanged
+
+ enum class Status {
+
+ /*
+ WARNING to editors:
+ Since the enum ordinal (index) is used in the DB, what it represents must never change.
+ In particular:
+ 1. NEVER CHANGE THE ORDER of the enum entries
+ 2. NEVER REMOVE AN ENTRY (deprecation and renaming are okay)
+ 3. As a result, NEW ENTRIES must always be added AT THE END.
+ */
+
+ /** Account from kAuth that has not yet been migrated. */
+ ToBeMigrated,
+
+ /** Account added via [AuthenticatorFacade.addAccounts], with passkey registration not complete yet. */
+ PasskeyRegistrationPending,
+
+ /**
+ * Transition status, right after [PasskeyRegistrationPending] or [ToBeMigrated],
+ * before the 1st passkey-bound token is obtained.
+ */
+ FirstPasskeyAuthenticationPending,
+
+ /** Account successfully connected, with registered passkey. */
+ LoggedIn,
+
+ RestoringFromBackup,
+
+ DeletingOldKeyAfterRestoration,
+
+ PasswordChanged,
+
+ Disconnected,
+ }
+}
diff --git a/src/commonMain/kotlin/internal/db/AccountsDao.kt b/src/commonMain/kotlin/internal/db/AccountsDao.kt
new file mode 100644
index 0000000..e27752f
--- /dev/null
+++ b/src/commonMain/kotlin/internal/db/AccountsDao.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+internal interface AccountsDao {
+
+ @Query("SELECT * FROM AccountEntity")
+ fun getAccountsAsFlow(): Flow>
+
+ @Query("SELECT * FROM AccountEntity WHERE id = :id")
+ fun getAccountAsFlow(id: Long): Flow
+
+ @Query("SELECT * FROM AccountEntity WHERE id = :id")
+ suspend fun getAccount(id: Long): AccountEntity?
+
+ @Upsert
+ suspend fun upsert(account: AccountEntity)
+
+ @Upsert
+ suspend fun upsert(accounts: List)
+
+ @Query("UPDATE AccountEntity SET status = :newStatus WHERE status = :currentStatus")
+ suspend fun updateStatus(currentStatus: AccountEntity.Status, newStatus: AccountEntity.Status)
+
+ @Query("UPDATE AccountEntity SET status = :newStatus WHERE id = :userId")
+ suspend fun updateStatusForUser(userId: Long, newStatus: AccountEntity.Status)
+
+ @Insert
+ suspend fun insert(account: AccountEntity)
+
+ @Query("DELETE FROM AccountEntity WHERE id = :id")
+ suspend fun delete(id: Long)
+}
diff --git a/src/commonMain/kotlin/internal/db/AccountsDatabase.kt b/src/commonMain/kotlin/internal/db/AccountsDatabase.kt
new file mode 100644
index 0000000..f904b04
--- /dev/null
+++ b/src/commonMain/kotlin/internal/db/AccountsDatabase.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.db
+
+import androidx.room.AutoMigration
+import androidx.room.ConstructedBy
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.RoomDatabaseConstructor
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+
+internal expect fun getAccountsRoomDatabase(databaseNameOrPath: String?): AccountsDatabase
+
+@Database(
+ entities = [AccountEntity::class],
+ version = 2,
+ autoMigrations = [
+ AutoMigration(from = 1, to = 2),
+ ]
+)
+@TypeConverters(AccountStatusConverter::class)
+@ConstructedBy(AccountsDatabaseConstructor::class)
+internal abstract class AccountsDatabase : RoomDatabase() {
+ abstract fun getDao(): AccountsDao
+}
+
+@Suppress("KotlinNoActualForExpect", "RedundantSuppression")
+internal expect object AccountsDatabaseConstructor : RoomDatabaseConstructor {
+ override fun initialize(): AccountsDatabase
+}
+
+internal fun getAccountsRoomDatabase(builder: RoomDatabase.Builder): AccountsDatabase {
+ return builder
+ .setDriver(BundledSQLiteDriver())
+ .setQueryCoroutineContext(Dispatchers.IO)
+ .build()
+}
+
+internal class AccountStatusConverter {
+
+ @TypeConverter
+ fun fromStatus(status: AccountEntity.Status) = status.ordinal
+
+ @TypeConverter
+ fun toStatus(ordinal: Int) = AccountEntity.Status.entries[ordinal]
+}
diff --git a/src/commonMain/kotlin/internal/extensions/BitFlags.kt b/src/commonMain/kotlin/internal/extensions/BitFlags.kt
new file mode 100644
index 0000000..6f7b2cc
--- /dev/null
+++ b/src/commonMain/kotlin/internal/extensions/BitFlags.kt
@@ -0,0 +1,56 @@
+/*
+ * 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:Suppress("NOTHING_TO_INLINE")
+
+package com.infomaniak.auth.lib.internal.extensions
+
+import kotlin.experimental.and
+import kotlin.experimental.inv
+import kotlin.experimental.or
+
+internal inline fun Long.hasFlag(flag: Long): Boolean = flag and this == flag
+internal inline fun Long.withFlag(flag: Long): Long = this or flag
+internal inline fun Long.minusFlag(flag: Long): Long = this and flag.inv()
+
+internal inline fun Int.hasFlag(flag: Int): Boolean = flag and this == flag
+internal inline fun Int.withFlag(flag: Int): Int = this or flag
+internal inline fun Int.minusFlag(flag: Int): Int = this and flag.inv()
+
+internal inline fun Short.hasFlag(flag: Short): Boolean = flag and this == flag
+internal inline fun Short.withFlag(flag: Short): Short = this or flag
+internal inline fun Short.minusFlag(flag: Short): Short = this and flag.inv()
+
+internal inline fun Byte.hasFlag(flag: Byte): Boolean = flag and this == flag
+internal inline fun Byte.withFlag(flag: Byte): Byte = this or flag
+internal inline fun Byte.minusFlag(flag: Byte): Byte = this and flag.inv()
+
+internal inline fun ULong.hasFlag(flag: ULong): Boolean = flag and this == flag
+internal inline fun ULong.withFlag(flag: ULong): ULong = this or flag
+internal inline fun ULong.minusFlag(flag: ULong): ULong = this and flag.inv()
+
+internal inline fun UInt.hasFlag(flag: UInt): Boolean = flag and this == flag
+internal inline fun UInt.withFlag(flag: UInt): UInt = this or flag
+internal inline fun UInt.minusFlag(flag: UInt): UInt = this and flag.inv()
+
+internal inline fun UShort.hasFlag(flag: UShort): Boolean = flag and this == flag
+internal inline fun UShort.withFlag(flag: UShort): UShort = this or flag
+internal inline fun UShort.minusFlag(flag: UShort): UShort = this and flag.inv()
+
+internal inline fun UByte.hasFlag(flag: UByte): Boolean = flag and this == flag
+internal inline fun UByte.withFlag(flag: UByte): UByte = this or flag
+internal inline fun UByte.minusFlag(flag: UByte): UByte = this and flag.inv()
diff --git a/src/commonMain/kotlin/internal/extensions/ByteArray.kt b/src/commonMain/kotlin/internal/extensions/ByteArray.kt
new file mode 100644
index 0000000..c44bf92
--- /dev/null
+++ b/src/commonMain/kotlin/internal/extensions/ByteArray.kt
@@ -0,0 +1,26 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+internal fun ByteArray.trimOrPadStart(length: Int): ByteArray {
+ return when {
+ size == length -> this
+ size > length -> this.copyOfRange(fromIndex = size - length, toIndex = size)
+ else -> ByteArray(length - size) + this
+ }
+}
diff --git "a/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" "b/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt"
new file mode 100644
index 0000000..917f772
--- /dev/null
+++ "b/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt"
@@ -0,0 +1,61 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+import com.infomaniak.auth.lib.Account
+import com.infomaniak.auth.lib.internal.db.AccountEntity
+import com.infomaniak.auth.lib.internal.models.LegacyUser
+import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile
+
+internal fun AccountEntity.toAccount(status: Account.Status): Account {
+ return Account(
+ id = id,
+ fullName = fullName,
+ initials = initials,
+ email = email,
+ avatarUrl = avatarUrl,
+ status = status
+ )
+}
+
+internal fun SharedUserProfile.toAccountEntity(status: AccountEntity.Status): AccountEntity {
+ return AccountEntity(
+ id = id.toLong(),
+ fullName = "$firstname $lastname",
+ initials = getInitials(),
+ email = email,
+ avatarUrl = avatar,
+ status = status,
+ securityScore = preferences.security?.score,
+ lastPasswordUpdate = preferences.security?.dateLastChangedPassword,
+ )
+}
+
+
+internal fun LegacyUser.toEntity(): AccountEntity {
+ val initials = "${displayName.firstOrNull()?.uppercase()}" +
+ "${displayName.substring(displayName.indexOf(" ") + 1).firstOrNull()?.uppercase()}"
+ return AccountEntity(
+ id = userId.toLong(),
+ fullName = displayName,
+ initials = initials,
+ email = email,
+ avatarUrl = avatar,
+ status = AccountEntity.Status.ToBeMigrated
+ )
+}
diff --git a/src/commonMain/kotlin/internal/extensions/Result.kt b/src/commonMain/kotlin/internal/extensions/Result.kt
new file mode 100644
index 0000000..474d6ab
--- /dev/null
+++ b/src/commonMain/kotlin/internal/extensions/Result.kt
@@ -0,0 +1,25 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+import kotlinx.coroutines.CancellationException
+
+@Suppress("RedundantSuspendModifier")
+internal suspend inline fun Result.cancellable(): Result = onFailure {
+ if (it is CancellationException) throw it
+}
diff --git a/src/commonMain/kotlin/internal/extensions/Xor.extensions.kt b/src/commonMain/kotlin/internal/extensions/Xor.extensions.kt
new file mode 100644
index 0000000..183b0a5
--- /dev/null
+++ b/src/commonMain/kotlin/internal/extensions/Xor.extensions.kt
@@ -0,0 +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 .
+ */
+package com.infomaniak.auth.lib.internal.extensions
+
+import com.infomaniak.auth.lib.internal.utils.Xor
+import com.infomaniak.auth.lib.internal.utils.Xor.First
+import com.infomaniak.auth.lib.internal.utils.Xor.Second
+
+internal inline fun Xor.firstOrElse(block: (SecondT) -> FirstT): FirstT = when (this) {
+ is First -> value
+ is Second -> block(value)
+}
+
+internal inline fun Xor.secondOrElse(block: (FirstT) -> SecondT): SecondT = when (this) {
+ is First -> block(value)
+ is Second -> value
+}
diff --git a/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/src/commonMain/kotlin/internal/managers/AccountRestorer.kt
new file mode 100644
index 0000000..996494a
--- /dev/null
+++ b/src/commonMain/kotlin/internal/managers/AccountRestorer.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.requests.WebAuthnRequests
+import com.infomaniak.auth.lib.models.migration.SharedApiToken
+
+internal class AccountRestorer(
+ accountsDatabase: AccountsDatabase,
+ private val authenticatorManager: AuthenticatorManager,
+ private val webAuthnRequests: WebAuthnRequests,
+ private val clientId: String,
+) {
+
+ private val dao = accountsDatabase.getDao()
+
+ suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: SharedApiToken) -> 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()
+ webAuthnRequests.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)
+ // 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 ->
+ webAuthnRequests.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.PasswordChanged,
+ AccountEntity.Status.ToBeMigrated,
+ AccountEntity.Status.PasskeyRegistrationPending,
+ AccountEntity.Status.FirstPasskeyAuthenticationPending,
+ AccountEntity.Status.Disconnected,
+ 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/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt
new file mode 100644
index 0000000..8ce7be5
--- /dev/null
+++ b/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt
@@ -0,0 +1,152 @@
+/*
+ * 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.CryptoObjectsBuilder
+import com.infomaniak.auth.lib.internal.Failure
+import com.infomaniak.auth.lib.internal.KeyPairManager
+import com.infomaniak.auth.lib.internal.KeyPairManager.MatchOn
+import com.infomaniak.auth.lib.internal.extensions.firstOrElse
+import com.infomaniak.auth.lib.internal.models.ClientExtensionResults
+import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData
+import com.infomaniak.auth.lib.internal.models.VerifyResponse
+import com.infomaniak.auth.lib.internal.otp.deleteLegacyAccount
+import com.infomaniak.auth.lib.internal.otp.deleteLegacyDB
+import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts
+import com.infomaniak.auth.lib.internal.otp.needMigration
+import com.infomaniak.auth.lib.internal.repositories.AccountsRepository
+import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests
+import com.infomaniak.auth.lib.internal.utils.SignUtils
+import com.infomaniak.auth.lib.internal.utils.Xor
+import com.infomaniak.auth.lib.models.migration.SharedApiToken
+import io.ktor.utils.io.core.toByteArray
+import kotlinx.serialization.json.Json
+import okio.ByteString.Companion.toByteString
+
+internal class AuthenticatorManager(
+ private val webAuthnRequests: WebAuthnRequests,
+ private val accountsRepository: AccountsRepository,
+) {
+
+ private val cryptoObjectsBuilder by lazy { CryptoObjectsBuilder() }
+ val keyPairManager: KeyPairManager by lazy { KeyPairManager() }
+
+ private val base64NoPadding get() = cryptoObjectsBuilder.base64UrlSafeNoPadding
+
+ suspend fun getUserProfile(token: String) = webAuthnRequests.getUserProfile(token)
+
+ suspend fun registerPasskey(token: String, userId: Long): String {
+ val passkeysOptions = webAuthnRequests.getPasskeysOptions(token)
+ val keyIds = cryptoObjectsBuilder.getKeyIds()
+ val keyIdAsByteArray = keyIds.first
+ val keyIdAsString = keyIds.second
+ keyPairManager.generateNewKey(userId, keyIdAsString)?.let { failure ->
+ error("Couldn't generate new key: ${failure.details}")
+ }
+ val publicKeyAsByteArray = keyPairManager.retrievePublicKey(userId, keyIdAsString).firstOrElse {
+ error("Couldn't retrieve public key (registerPasskey): ${it.details}")
+ }
+
+ val registerPasskey = cryptoObjectsBuilder.buildRegisterPasskey(
+ publicKey = publicKeyAsByteArray,
+ passkeysOptions = passkeysOptions,
+ rawId = keyIdAsByteArray,
+ id = keyIdAsString,
+ )
+
+ webAuthnRequests.registerPasskey(token, registerPasskey)
+
+ return keyIdAsString
+ }
+
+ suspend fun getToken(
+ clientId: String,
+ userId: Long,
+ keyIdOrDefault: String? = null,
+ ): Xor {
+ val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor(MatchOn.UserId(userId))
+ ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId"))
+
+ val authenticationOptions = webAuthnRequests.challenge(clientId)
+ val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull()
+ ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No public key found for $userId"))
+ val rawAuthenticatorData = cryptoObjectsBuilder.generateAuthenticatorData(
+ publicKey = publicKey,
+ rpId = "infomaniak.ch",
+ credentialId = keyId.toByteArray(),
+ )
+ val authenticatorData = base64NoPadding.encode(rawAuthenticatorData)
+
+ val clientData = cryptoObjectsBuilder.buildClientData(authenticationOptions.challenge)
+ val clientDataJsonBytes = Json.encodeToString(clientData).encodeToByteArray()
+ val clientDataJsonHash = clientDataJsonBytes.toByteString().sha256().toByteArray()
+
+ val privateKey = keyPairManager.retrievePrivateKey(userId, keyId).firstOrNull()
+ ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No private key found for $userId"))
+ val verifyAuthenticationData = VerifyAuthenticationData(
+ clientId = clientId,
+ session = authenticationOptions.session,
+ id = keyId,
+ rawId = keyId,
+ response = VerifyResponse(
+ authenticatorData = authenticatorData,
+ clientDataJSON = base64NoPadding.encode(clientDataJsonBytes),
+ signature = base64NoPadding.encode(
+ SignUtils.signWithPrivateKey(
+ privateKey = privateKey,
+ data = rawAuthenticatorData + clientDataJsonHash,
+ )
+ ),
+ userHandle = base64NoPadding.encode(userId.toString().toByteArray())
+ ),
+ type = "public-key",
+ clientExtensionResults = ClientExtensionResults,
+ authenticatorAttachment = "platform",
+ )
+ val verifyAuthData = webAuthnRequests.verify(verifyAuthenticationData)
+ val apiToken = SharedApiToken(
+ 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(MatchOn.UserId(userId))
+
+ if (passkeyId != null) {
+ val needsToRevokePasskey = token != null
+ // If we have a passkey for this account, revoke it against the backend and delete it
+ if (needsToRevokePasskey) webAuthnRequests.deletePasskeyIfExists(token, passkeyId)
+ val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(passkeyId))
+ }
+
+ accountsRepository.deleteAccount(userId)
+
+ if (needMigration()) {
+ deleteLegacyAccount(userId.toString())
+ if (getLegacyAccounts().isEmpty()) deleteLegacyDB()
+ }
+ }
+
+ suspend fun deleteKeysFor(userId: Long) {
+ val _ = keyPairManager.deleteKeysMatching(MatchOn.UserId(userId))
+ }
+}
diff --git a/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/src/commonMain/kotlin/internal/managers/MigrationManager.kt
new file mode 100644
index 0000000..904a4c7
--- /dev/null
+++ b/src/commonMain/kotlin/internal/managers/MigrationManager.kt
@@ -0,0 +1,187 @@
+/*
+ * 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
+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.toEntity
+import com.infomaniak.auth.lib.internal.models.AuthResult
+import com.infomaniak.auth.lib.internal.models.OtpPayload
+import com.infomaniak.auth.lib.internal.otp.TotpGenerator
+import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts
+import com.infomaniak.auth.lib.internal.otp.getSecretFor
+import com.infomaniak.auth.lib.internal.otp.needMigration
+import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests
+import com.infomaniak.auth.lib.models.migration.SharedApiToken
+import com.infomaniak.auth.lib.network.exceptions.ApiException
+import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface
+import com.osmerion.kotlin.io.encoding.Base32
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.io.IOException
+import okio.ByteString.Companion.encodeUtf8
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+internal class MigrationManager(
+ private val coroutineScope: CoroutineScope,
+ private val crashReport: CrashReportInterface,
+ private val accountsDatabase: AccountsDatabase,
+ private val authenticatorManager: AuthenticatorManager,
+ private val webAuthnRequests: WebAuthnRequests,
+ 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: SharedApiToken) -> Unit) {
+ val restorer = AccountRestorer(
+ accountsDatabase = accountsDatabase,
+ authenticatorManager = authenticatorManager,
+ webAuthnRequests = webAuthnRequests,
+ clientId = clientId
+ )
+ restorer.restore(account, persistToken)
+ }
+
+ suspend fun addLegacyAccountsToDB() {
+ if (!needMigration()) return
+
+ val legacyAccounts = getLegacyAccounts().ifEmpty { return }
+ accountsDatabase.getDao().upsert(legacyAccounts.map { it.toEntity() })
+ }
+
+ /**
+ * @return false if the backend returned the `access_denied`, which means a correct password is needed (in [authentication]).
+ *
+ * @throws IOException in case of networking or I/O issues
+ * @throws ApiException in case the backend returns a non-successful response (except for "access_denied")
+ * @throws IllegalStateException in case of local issues (not supposed to happen & not recoverable)
+ */
+ suspend fun tryMigrating(
+ userId: Long,
+ authentication: MigrationAuthentication,
+ ): Boolean {
+ if (alreadyHasValidPasskey(userId)) return true
+
+ @OptIn(ExperimentalUuidApi::class)
+ val deviceId = Uuid.random().toHexDashString()
+ val secret = checkNotNull(getSecretFor(userId)) { "Couldn't find the secret for user $userId" }
+ val migrationOptions = webAuthnRequests.getMigrationOptions(
+ deviceId = deviceId,
+ userId = userId,
+ )
+ val temporaryToken = when (authentication) {
+ is MigrationAuthentication.CrossAppLogin -> authentication.derivedToken
+ else -> {
+ val otp = getOtp(secret = secret, timestampSeconds = migrationOptions.timestamp)
+ val assertion = "${migrationOptions.session}:${migrationOptions.timestamp}"
+ .encodeUtf8()
+ .hmacSha256(secret.encodeUtf8())
+ .hex()
+ val password = when (authentication) {
+ is MigrationAuthentication.NoOngoingLogin -> authentication.password
+ is MigrationAuthentication.OngoingLogin -> null
+ }
+
+ runCatching {
+ webAuthnRequests.getTokenForMigration(
+ sessionId = migrationOptions.session,
+ otpPayload = OtpPayload(
+ deviceId = deviceId,
+ userId = userId,
+ code = otp,
+ assertion = assertion,
+ password = password,
+ )
+ ).toApiToken()
+ }.cancellable().getOrElse {
+ if (it !is ApiException.ApiErrorException) throw it
+ when (it.errorCode) {
+ "access_denied", "not_authorized" -> return false
+ else -> throw it
+ }
+ }
+ }
+ }
+
+ authenticatorManager.deleteKeysFor(userId)
+ val _ = authenticatorManager.registerPasskey(
+ token = temporaryToken.accessToken,
+ userId = userId
+ )
+ //TODO: Figure out which state we were in with the bug, and see if we can reliably fix it up, without
+ // affecting other unrelated cases.
+
+ coroutineScope.launch { // TODO: Remove this whole block once the backend is updated to do it automatically.
+ runCatching {
+ webAuthnRequests.completeMigration(
+ token = temporaryToken.accessToken,
+ sessionId = migrationOptions.session,
+ deviceId = deviceId
+ )
+ }.cancellable().onFailure { crashReport.capture(userId = userId, "completeMigration failed", it) }
+ }
+ return true
+ }
+
+ private suspend fun alreadyHasValidPasskey(userId: Long): Boolean {
+ val keyId = authenticatorManager.keyPairManager.findKeyIdFor(KeyPairManager.MatchOn.UserId(userId))
+ ?: return false // No passkey for this user.
+ return runCatching {
+ val result = authenticatorManager.getToken(clientId = clientId, userId = userId, keyIdOrDefault = keyId)
+ result.firstOrNull() != null // If we can get a token successfully, the passkey is valid.
+ }.cancellable().getOrElse {
+ when (it) {
+ is ApiException.ApiErrorException if it.errorCode == "not_authorized" -> false // Invalid passkey
+ else -> throw it // Other error, propagate it.
+ }
+ }
+ }
+
+ private fun getOtp(secret: String, timestampSeconds: Long): String {
+ val generator = TotpGenerator(
+ secret = Base32.decode(secret),
+ digits = 6,
+ algorithm = TotpGenerator.Algorithm.SHA1,
+ )
+ return generator.generate(timestampSeconds)
+ }
+
+ private fun AuthResult.toApiToken(): SharedApiToken {
+ return SharedApiToken(
+ accessToken = this.accessToken,
+ tokenType = this.tokenType,
+ userId = this.userId.toInt(),
+ scope = this.scope,
+ )
+ }
+}
diff --git a/src/commonMain/kotlin/internal/models/AllowCredential.kt b/src/commonMain/kotlin/internal/models/AllowCredential.kt
new file mode 100644
index 0000000..7bd8990
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/AllowCredential.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AllowCredential(
+ val type: String,
+ val id: String,
+ val transports: List,
+)
diff --git a/src/commonMain/kotlin/internal/models/ApiError.kt b/src/commonMain/kotlin/internal/models/ApiError.kt
new file mode 100644
index 0000000..7ba3f82
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/ApiError.kt
@@ -0,0 +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 .
+ */
+package com.infomaniak.auth.lib.internal.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class ApiResponseForError(
+ val result: ApiResponseStatus = ApiResponseStatus.ERROR,
+ val error: ApiErrorV2
+)
+
+@Serializable
+data class ApiErrorV2(
+ val code: String,
+ val description: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/ApiResponseStatus.kt b/src/commonMain/kotlin/internal/models/ApiResponseStatus.kt
new file mode 100644
index 0000000..1e2a3db
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/ApiResponseStatus.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class ApiResponseStatus {
+
+ @SerialName("error")
+ ERROR,
+
+ @SerialName("success")
+ SUCCESS,
+
+ @SerialName("asynchronous")
+ ASYNCHRONOUS,
+
+ @SerialName("unknown")
+ UNKNOWN;
+}
diff --git a/src/commonMain/kotlin/internal/models/AuthResult.kt b/src/commonMain/kotlin/internal/models/AuthResult.kt
new file mode 100644
index 0000000..e430f54
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/AuthResult.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AuthResult(
+ @SerialName("token_type")
+ val tokenType: String,
+ val scope: String,
+ @SerialName("user_id")
+ val userId: Long,
+ @SerialName("access_token")
+ val accessToken: String,
+) {
+ override fun toString() = "AuthResult(tokenType='$tokenType', scope='$scope', userId=$userId, accessToken=██)"
+}
diff --git a/src/commonMain/kotlin/internal/models/AuthenticationOptions.kt b/src/commonMain/kotlin/internal/models/AuthenticationOptions.kt
new file mode 100644
index 0000000..aededae
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/AuthenticationOptions.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AuthenticationOptions(
+ val session: String,
+ val challenge: String,
+ @SerialName("rpId")
+ val relyingPartyId: String,
+ val allowCredentials: List,
+)
diff --git a/src/commonMain/kotlin/internal/models/ClientExtensionResults.kt b/src/commonMain/kotlin/internal/models/ClientExtensionResults.kt
new file mode 100644
index 0000000..c969320
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/ClientExtensionResults.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data object ClientExtensionResults // No particular extensions for now
diff --git a/src/commonMain/kotlin/internal/models/ExcludeCredential.kt b/src/commonMain/kotlin/internal/models/ExcludeCredential.kt
new file mode 100644
index 0000000..a06972c
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/ExcludeCredential.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class ExcludeCredential(
+ val id: String,
+ val type: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/LegacyUser.kt b/src/commonMain/kotlin/internal/models/LegacyUser.kt
new file mode 100644
index 0000000..0c7f23e
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/LegacyUser.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.models
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * This is the model that was used for users in kAuth (version 1.X of the app).
+ * It contains basic info, with the [email] being potentially outdated,
+ * and the [secret] for the (OTP) one-time-password generation.
+ *
+ * On Android, this was saved in a SQLite database (managed by Room).
+ * On iOS, this was saved in NSUserDefaults, encoded in JSON.
+ *
+ * This model is used only for migration of users from kAuth,
+ * and the whole data is set to be deleted right after it completes.
+ */
+@Entity(tableName = "users")
+@Serializable
+internal data class LegacyUser(
+
+ @PrimaryKey
+ @ColumnInfo(name = "userid")
+ @SerialName("id")
+ val userId: Int,
+
+ val email: String,
+
+ @ColumnInfo(name = "displayname")
+ @SerialName("display_name")
+ val displayName: String,
+
+ val avatar: String?,
+
+ val secret: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/MigrationOptions.kt b/src/commonMain/kotlin/internal/models/MigrationOptions.kt
new file mode 100644
index 0000000..5ba33f9
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/MigrationOptions.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class MigrationOptions(
+ val session: String,
+ val timestamp: Long,
+)
diff --git a/src/commonMain/kotlin/internal/models/OtpPayload.kt b/src/commonMain/kotlin/internal/models/OtpPayload.kt
new file mode 100644
index 0000000..5bdeab4
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/OtpPayload.kt
@@ -0,0 +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 .
+ */
+package com.infomaniak.auth.lib.internal.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class OtpPayload(
+ @SerialName("device")
+ val deviceId: String,
+ @SerialName("id")
+ val userId: Long,
+ val assertion: String,
+ val code: String,
+ val password: String? = null,
+)
diff --git a/src/commonMain/kotlin/internal/models/PasskeysOptions.kt b/src/commonMain/kotlin/internal/models/PasskeysOptions.kt
new file mode 100644
index 0000000..982be25
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/PasskeysOptions.kt
@@ -0,0 +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 .
+ */
+package com.infomaniak.auth.lib.internal.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class PasskeysOptions(
+ val session: String,
+ val challenge: String,
+ @SerialName("rp")
+ val relyingParty: RelyingParty,
+ val user: User,
+ val pubKeyCredParams: List,
+ val excludeCredentials: List,
+)
diff --git a/src/commonMain/kotlin/internal/models/PubKeyCredParam.kt b/src/commonMain/kotlin/internal/models/PubKeyCredParam.kt
new file mode 100644
index 0000000..7e3106b
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/PubKeyCredParam.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.models
+
+import com.infomaniak.auth.lib.internal.webauthn.KeyAlgorithm
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class PubKeyCredParam(
+ val type: String,
+ @SerialName("alg")
+ val algorithm: KeyAlgorithm,
+)
diff --git a/src/commonMain/kotlin/internal/models/RegisterPasskey.kt b/src/commonMain/kotlin/internal/models/RegisterPasskey.kt
new file mode 100644
index 0000000..1af4a66
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/RegisterPasskey.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.models
+
+import com.infomaniak.auth.lib.internal.webauthn.DeviceInfo
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class RegisterPasskey(
+ val session: String,
+ val device: DeviceInfo,
+ val id: String,
+ val rawId: String,
+ @SerialName("response")
+ val registerPasskeyResponse: RegisterPasskeyResponse,
+ val type: String,
+ val clientExtensionResults: ClientExtensionResults,
+ val authenticatorAttachment: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/RegisterPasskeyResponse.kt b/src/commonMain/kotlin/internal/models/RegisterPasskeyResponse.kt
new file mode 100644
index 0000000..ef6ec1e
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/RegisterPasskeyResponse.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.models
+
+import com.infomaniak.auth.lib.internal.webauthn.KeyAlgorithm
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class RegisterPasskeyResponse(
+ val attestationObject: String,
+ val clientDataJSON: String,
+ val transports: List,
+ val publicKeyAlgorithm: KeyAlgorithm,
+ val publicKey: String,
+ val authenticatorData: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/RelyingParty.kt b/src/commonMain/kotlin/internal/models/RelyingParty.kt
new file mode 100644
index 0000000..2f000b3
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/RelyingParty.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class RelyingParty(
+ val id: String,
+ val name: String,
+ @SerialName("icon")
+ val iconUrl: String?,
+)
diff --git a/src/commonMain/kotlin/internal/models/SuccessfulApiResponse.kt b/src/commonMain/kotlin/internal/models/SuccessfulApiResponse.kt
new file mode 100644
index 0000000..e6058dc
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/SuccessfulApiResponse.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class SuccessfulApiResponse(
+ val result: ApiResponseStatus = ApiResponseStatus.SUCCESS,
+ val data: T,
+)
diff --git a/src/commonMain/kotlin/internal/models/User.kt b/src/commonMain/kotlin/internal/models/User.kt
new file mode 100644
index 0000000..d2ff6c0
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/User.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class User(
+ val id: String,
+ val name: String,
+ val displayName: String?,
+)
diff --git a/src/commonMain/kotlin/internal/models/VerifyAuthenticationData.kt b/src/commonMain/kotlin/internal/models/VerifyAuthenticationData.kt
new file mode 100644
index 0000000..9eb6985
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/VerifyAuthenticationData.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class VerifyAuthenticationData(
+ @SerialName("client_id")
+ val clientId: String,
+ val session: String,
+ val id: String,
+ val rawId: String,
+ val response: VerifyResponse,
+ val type: String,
+ val clientExtensionResults: ClientExtensionResults,
+ val authenticatorAttachment: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/VerifyResponse.kt b/src/commonMain/kotlin/internal/models/VerifyResponse.kt
new file mode 100644
index 0000000..fb3d52a
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/VerifyResponse.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class VerifyResponse(
+ val authenticatorData: String,
+ val clientDataJSON: String,
+ val signature: String,
+ val userHandle: String,
+)
diff --git a/src/commonMain/kotlin/internal/models/WebAuthnClientData.kt b/src/commonMain/kotlin/internal/models/WebAuthnClientData.kt
new file mode 100644
index 0000000..4c9fddb
--- /dev/null
+++ b/src/commonMain/kotlin/internal/models/WebAuthnClientData.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class WebAuthnClientData(
+ val type: String,
+ val challenge: String,
+ val origin: String,
+ val crossOrigin: Boolean
+)
diff --git a/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/src/commonMain/kotlin/internal/network/ApiClientProvider.kt
new file mode 100644
index 0000000..3fe1b5e
--- /dev/null
+++ b/src/commonMain/kotlin/internal/network/ApiClientProvider.kt
@@ -0,0 +1,184 @@
+/*
+ * 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.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
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpResponseValidator
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.UserAgent
+import io.ktor.client.plugins.compression.ContentEncoding
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.HttpRequest
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.client.statement.request
+import io.ktor.http.ContentType
+import io.ktor.http.contentLength
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.json.json
+import io.ktor.utils.io.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.async
+import kotlinx.io.IOException
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlin.time.Duration.Companion.seconds
+
+internal class ApiClientProvider(
+ scope: CoroutineScope,
+ private val userAgent: String,
+ private val routes: ApiRoutes,
+ private val crashReport: CrashReportInterface? = null,
+) {
+
+ private val jsonConfig = Json {
+ /** From [io.ktor.serialization.kotlinx.json.DefaultJson] */
+ encodeDefaults = true
+ isLenient = true
+ allowSpecialFloatingPointValues = true
+ allowStructuredMapKeys = true
+ prettyPrint = false
+ useArrayPolymorphism = false
+
+ // Use-case specific config:
+ coerceInputValues = true // Use default values if not recognized (used for enums).
+ ignoreUnknownKeys = true // Don't break if keys are added.
+ @OptIn(ExperimentalSerializationApi::class)
+ decodeEnumsCaseInsensitive = true
+ useAlternativeNames = false
+ }
+
+ val httpClient: suspend () -> HttpClient = { httpClientAsync.await() }
+ private val httpClientAsync = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { createHttpClient() }
+
+ fun createHttpClient(
+ authenticationConfig: (HttpClientConfig<*>.() -> Unit)? = null
+ ) = HttpClient(getHttpClientEngine()) {
+ if (authenticationConfig != null) authenticationConfig()
+ install(UserAgent) {
+ agent = userAgent
+ }
+ install(ContentNegotiation) {
+ json(jsonConfig)
+ }
+ install(ContentEncoding) {
+ gzip()
+ }
+ install(HttpTimeout) {
+ // Each value can be fine-tuned independently, hence the value not being shared.
+ connectTimeoutMillis = 10.seconds.inWholeMilliseconds
+ socketTimeoutMillis = 10.seconds.inWholeMilliseconds
+ }
+ install(HttpRequestRetry) {
+ retryOnExceptionIf(maxRetries = MAX_RETRY) { _, cause ->
+ cause.isNetworkException()
+ }
+ delayMillis { retry ->
+ retry * 500L
+ }
+ }
+
+ defaultRequest {
+ url(routes.apiBaseUrl())
+ contentType(ContentType.Application.Json)
+ }
+
+ HttpResponseValidator {
+ validateResponse { validateResponse(it, skipUnauthorizedConversion = authenticationConfig != null) }
+ handleResponseExceptionWithRequest(::handleResponseExceptionWithRequest)
+ }
+ }
+
+ private suspend fun validateResponse(response: HttpResponse, skipUnauthorizedConversion: Boolean) {
+ val requestContextId = response.getRequestContextId()
+ val statusCode = response.status.value
+
+ addSentryUrlBreadcrumb(response, statusCode, requestContextId)
+
+ if (skipUnauthorizedConversion && statusCode == 401) return // Let 401 bubble up to ktor auth unchanged.
+ if (statusCode >= 300) {
+ val bodyResponse = response.bodyAsText()
+ val apiError = runCatching {
+ jsonConfig.decodeFromString(bodyResponse)
+ }.getOrElse {
+ throw ApiException.UnexpectedApiErrorFormatException(statusCode, bodyResponse, null, requestContextId)
+ }
+ throw ApiException.ApiErrorException(statusCode, apiError.error.code, apiError.error.description, requestContextId)
+ }
+ }
+
+ private suspend fun handleResponseExceptionWithRequest(cause: Throwable, request: HttpRequest) {
+ when (cause) {
+ is IOException -> throw NetworkException("Network error: ${cause.message}", cause)
+ is ApiException, is CancellationException -> throw cause
+ else -> {
+ val response = runCatching { request.call.response }.getOrNull()
+ val requestContextId = response?.getRequestContextId() ?: ""
+ val bodyResponse = response?.bodyAsText() ?: cause.message ?: ""
+ val statusCode = response?.status?.value ?: -1
+ throw ApiException.UnexpectedApiErrorFormatException(
+ statusCode = statusCode,
+ bodyResponse = bodyResponse,
+ cause = cause,
+ requestContextId = requestContextId
+ )
+ }
+ }
+ }
+
+ private fun addSentryUrlBreadcrumb(response: HttpResponse, statusCode: Int, requestContextId: String) {
+ val requestUrl = response.request.url
+ val data = buildMap {
+ put("url", "${requestUrl.protocol.name}://${requestUrl.host}${requestUrl.encodedPath}")
+ put("method", response.request.method.value)
+ put("status_code", "$statusCode")
+ if (requestUrl.encodedQuery.isNotEmpty()) put("http.query", requestUrl.encodedQuery)
+ put("request_id", requestContextId)
+ put("http.start_timestamp", "${response.requestTime.timestamp}")
+ put("http.end_timestamp", "${response.responseTime.timestamp}")
+ response.contentLength()?.let { put("response_content_length", "$it") }
+ }
+ crashReport?.addBreadcrumb(
+ message = "",
+ category = "http",
+ level = CrashReportLevel.INFO,
+ type = BreadcrumbType.HTTP,
+ data = data
+ )
+ }
+
+ private fun Throwable.isNetworkException() = this is IOException
+
+ companion object {
+ private const val MAX_RETRY = 3
+ }
+}
diff --git a/src/commonMain/kotlin/internal/network/ApiRoutes.kt b/src/commonMain/kotlin/internal/network/ApiRoutes.kt
new file mode 100644
index 0000000..ad46f13
--- /dev/null
+++ b/src/commonMain/kotlin/internal/network/ApiRoutes.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.network
+
+internal class ApiRoutes(val host: String) {
+
+ fun apiBaseUrl(): String {
+ return "https://login.${host}/api/"
+ }
+
+ fun passkeysOptions() = "users/me/passkeys/options"
+ fun registerPasskey() = "users/me/passkeys"
+ fun delete(passkeyId: String) = "users/me/passkeys/$passkeyId"
+ fun migrationsOptions() = "authenticator/migrations"
+ fun verifyMigration(sessionId: String) = "authenticator/migrations/$sessionId/verify"
+ fun finishMigration(sessionId: String) = "users/me/authenticator/migrations/$sessionId"
+ fun challenge() = "authenticator/challenge"
+ fun verify() = "authenticator/verify"
+
+ fun userProfile() = "https://api.$host/2/profile?no_avatar_default=1"
+}
diff --git a/src/commonMain/kotlin/internal/network/utils/ApiExt.kt b/src/commonMain/kotlin/internal/network/utils/ApiExt.kt
new file mode 100644
index 0000000..b09773b
--- /dev/null
+++ b/src/commonMain/kotlin/internal/network/utils/ApiExt.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.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
+
+internal const val CONTENT_REQUEST_ID_HEADER = "x-request-id"
+
+internal fun HttpResponse.getRequestContextId() = headers[CONTENT_REQUEST_ID_HEADER] ?: ""
+
+internal suspend inline fun HttpResponse.decode(): R = runCatching {
+ body()
+}.getOrElse { exception ->
+ when (exception) {
+ is CancellationException, is NetworkException, is ApiException -> throw exception
+ else -> throw UnknownException(exception)
+ }
+}
diff --git a/src/commonMain/kotlin/internal/network/utils/HttpClientEngine.kt b/src/commonMain/kotlin/internal/network/utils/HttpClientEngine.kt
new file mode 100644
index 0000000..0a03524
--- /dev/null
+++ b/src/commonMain/kotlin/internal/network/utils/HttpClientEngine.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.network.utils
+
+import io.ktor.client.engine.HttpClientEngine
+
+internal expect fun getHttpClientEngine(): HttpClientEngine
diff --git a/src/commonMain/kotlin/internal/otp/TotpGenerator.kt b/src/commonMain/kotlin/internal/otp/TotpGenerator.kt
new file mode 100644
index 0000000..faf9795
--- /dev/null
+++ b/src/commonMain/kotlin/internal/otp/TotpGenerator.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.otp
+
+import com.infomaniak.auth.lib.internal.models.LegacyUser
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import kotlin.math.pow
+
+internal class TotpGenerator(
+ secret: ByteArray,
+ private val digits: Int = 6,
+ private val timeStep: Long = 30L, // seconds
+ private val algorithm: Algorithm = Algorithm.SHA1
+) {
+ private val secret = secret.toByteString()
+
+ enum class Algorithm {
+ SHA1, SHA256, SHA512
+ }
+
+ fun generate(timestamp: Long): String {
+ val counter = timestamp / timeStep
+ return generateOtp(counter)
+ }
+
+ fun generateOtp(counter: Long): String {
+ val counterBytes = counter.toByteArrayBigEndian()
+ val hash = hmac(counterBytes.toByteString())
+ val offset = hash.last().toInt() and 0x0F
+ val binary = ((hash[offset].toInt() and 0x7F) shl 24) or
+ ((hash[offset + 1].toInt() and 0xFF) shl 16) or
+ ((hash[offset + 2].toInt() and 0xFF) shl 8) or
+ (hash[offset + 3].toInt() and 0xFF)
+ val otp = binary % 10.0.pow(digits).toInt()
+ return otp.toString().padStart(digits, '0')
+ }
+
+ private fun hmac(data: ByteString): ByteArray {
+ return when (algorithm) {
+ Algorithm.SHA1 -> data.hmacSha1(secret)
+ Algorithm.SHA256 -> data.hmacSha256(secret)
+ Algorithm.SHA512 -> data.hmacSha512(secret)
+ }.toByteArray()
+ }
+
+ private fun Long.toByteArrayBigEndian(): ByteArray {
+ return ByteArray(8) { i ->
+ ((this shr (56 - i * 8)) and 0xFF).toByte()
+ }
+ }
+}
+
+internal expect suspend fun needMigration(): Boolean
+internal expect suspend fun getLegacyAccounts(): List
+internal expect suspend fun deleteLegacyAccount(userId: String)
+internal expect suspend fun deleteLegacyDB()
+internal expect suspend fun getSecretFor(userId: Long): String?
diff --git a/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt b/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt
new file mode 100644
index 0000000..34f1158
--- /dev/null
+++ b/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.repositories
+
+import com.infomaniak.auth.lib.internal.db.AccountEntity
+import com.infomaniak.auth.lib.internal.db.AccountsDatabase
+
+internal class AccountsRepository(database: AccountsDatabase) {
+ private val dao = database.getDao()
+
+ fun getAccounts() = dao.getAccountsAsFlow()
+
+ suspend fun upsertAccount(account: AccountEntity) {
+ dao.upsert(account)
+ }
+
+ suspend fun upsertAccounts(accounts: List) {
+ dao.upsert(accounts)
+ }
+
+ suspend fun insertAccount(account: AccountEntity) {
+ dao.insert(account)
+ }
+
+ suspend fun deleteAccount(id: Long) {
+ dao.delete(id)
+ }
+}
diff --git a/src/commonMain/kotlin/internal/requests/AuthenticatorRequests.kt b/src/commonMain/kotlin/internal/requests/AuthenticatorRequests.kt
new file mode 100644
index 0000000..0a05509
--- /dev/null
+++ b/src/commonMain/kotlin/internal/requests/AuthenticatorRequests.kt
@@ -0,0 +1,118 @@
+/*
+ * 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.requests
+
+import com.infomaniak.auth.lib.internal.db.AccountsDao
+import com.infomaniak.auth.lib.internal.models.SuccessfulApiResponse
+import com.infomaniak.auth.lib.internal.network.ApiRoutes
+import com.infomaniak.auth.lib.internal.network.utils.decode
+import com.infomaniak.auth.lib.internal.utils.dynamicLazyMap
+import com.infomaniak.auth.lib.models.migration.SharedApiToken
+import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile
+import com.infomaniak.auth.lib.network.exceptions.ApiException
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.client.plugins.auth.providers.bearer
+import io.ktor.client.request.get
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.job
+
+internal class AuthenticatorRequests(
+ private val createHttpClient: (userConfiguration: HttpClientConfig<*>.() -> Unit) -> HttpClient,
+ private val getTokenForUser: suspend (userId: Long) -> SharedApiToken?,
+ private val refreshToken: suspend (userId: Long) -> SharedApiToken,
+ private val disconnectAccount: suspend (userId: Long) -> Unit,
+ private val routes: ApiRoutes,
+ private val accountsDao: AccountsDao,
+ coroutineScope: CoroutineScope,
+) {
+
+ private val perUserHttpClient = coroutineScope.dynamicLazyMap(
+ cacheManager = { userId: Long, _ ->
+ accountsDao.getAccountAsFlow(userId).first { it == null }
+ }
+ ) { userId: Long ->
+ val parentJob = this.coroutineContext.job
+ async(Dispatchers.IO) {
+ createHttpClient { configureHttpClientForUser(userId) }.also { httpClient ->
+ parentJob.invokeOnCompletion {
+ httpClient.close()
+ httpClient.engine.close()
+ }
+ }
+ }
+ }
+
+ suspend fun getUserProfile(
+ userId: Long,
+ ): SharedUserProfile {
+ val url = "${routes.userProfile()}&with=security"
+
+ return httpClientForUser(userId).get(url).decode>().data
+ }
+
+ private suspend fun httpClientForUser(userId: Long): HttpClient {
+ return perUserHttpClient.useElement(userId) { it.await() }
+ }
+
+ private fun HttpClientConfig<*>.configureHttpClientForUser(userId: Long) {
+ install(Auth) {
+ bearer {
+ sendWithoutRequest { true }
+ refreshTokens {
+ val latestToken = getTokenForUser(userId)
+ when (latestToken?.accessToken) {
+ null -> { // Should never happen, because we are supposed to either:
+ // - replace tokens, without in-between removal.
+ // - remove users
+ null
+ }
+ oldTokens?.accessToken -> refreshTokenOrDisconnectAccount(userId)
+ else -> latestToken.toBearerTokens() // Previously loaded token is stale.
+ }
+ }
+ loadTokens { getTokenForUser(userId)?.toBearerTokens() }
+ }
+ }
+ }
+
+ private suspend fun refreshTokenOrDisconnectAccount(userId: Long): BearerTokens? = try {
+ refreshToken(userId).toBearerTokens()
+ } catch (e: ApiException) {
+ if (e.statusCode == 401 || e.isBrokenInvalidPasskeyResponse()) {
+ disconnectAccount(userId)
+ null
+ } else throw e
+ }
+
+ private fun ApiException.isBrokenInvalidPasskeyResponse(): Boolean {
+ //TODO[Authenticator-DONT-SHIP]: Remove this and its usage before public release.
+ return this is ApiException.ApiErrorException && statusCode == 422 && errorCode == "invalid_passkey"
+ }
+
+ private fun SharedApiToken.toBearerTokens() = BearerTokens(
+ accessToken = accessToken,
+ refreshToken = null // Not needed, we're doing it with the passkey.
+ )
+}
diff --git a/src/commonMain/kotlin/internal/requests/WebAuthnRequests.kt b/src/commonMain/kotlin/internal/requests/WebAuthnRequests.kt
new file mode 100644
index 0000000..540eca4
--- /dev/null
+++ b/src/commonMain/kotlin/internal/requests/WebAuthnRequests.kt
@@ -0,0 +1,176 @@
+/*
+ * 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.requests
+
+import com.infomaniak.auth.lib.internal.models.AuthResult
+import com.infomaniak.auth.lib.internal.models.AuthenticationOptions
+import com.infomaniak.auth.lib.internal.models.MigrationOptions
+import com.infomaniak.auth.lib.internal.models.OtpPayload
+import com.infomaniak.auth.lib.internal.models.PasskeysOptions
+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.network.ApiRoutes
+import com.infomaniak.auth.lib.internal.network.utils.decode
+import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile
+import com.infomaniak.auth.lib.network.exceptions.ApiException
+import io.ktor.client.HttpClient
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.delete
+import io.ktor.client.request.get
+import io.ktor.client.request.headers
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+
+internal class WebAuthnRequests(
+ private val httpClient: suspend () -> HttpClient,
+ private val routes: ApiRoutes,
+) {
+
+ //region Passkey
+
+ /**
+ * Retrieves options (including a challenge) prior to registering a public key credential with [registerPasskey].
+ */
+ suspend fun getPasskeysOptions(token: String): PasskeysOptions {
+ return httpClient().get(routes.passkeysOptions()) {
+ addAuthenticationHeader(token)
+ }.decode>().data
+ }
+
+ /**
+ * Registers a public key credential (from the on-device generated private/public key pair),
+ * after [getPasskeysOptions] is done.
+ */
+ suspend fun registerPasskey(token: String, registerPasskey: RegisterPasskey) {
+ httpClient().post(routes.registerPasskey()) {
+ addAuthenticationHeader(token)
+ setBody(registerPasskey)
+ }
+ }
+
+ /**
+ * Retrieves the backend-generated challenge, prior to authenticating with [verify].
+ */
+ suspend fun challenge(clientId: String): AuthenticationOptions {
+ return httpClient().post(routes.challenge()) {
+ setBody(mapOf("client_id" to clientId))
+ }.decode>().data
+ }
+
+ /**
+ * Authenticates with [VerifyAuthenticationData], which contains private-key signed data.
+ *
+ * That data includes the challenge retrieved in [challenge].
+ *
+ * @return An [AuthResult] that includes an access token.
+ */
+ suspend fun verify(verifyAuthenticationData: VerifyAuthenticationData): AuthResult {
+ return httpClient().post(routes.verify()) {
+ setBody(verifyAuthenticationData)
+ }.decode>().data
+ }
+
+ /**
+ * Delete an existing passkey.
+ *
+ * @param token The access token of the user.
+ * @param passkeyId The id of the passkey to delete.
+ */
+ suspend fun deletePasskeyIfExists(token: String, passkeyId: String) {
+ try {
+ deletePasskey(token, passkeyId)
+ } catch (e: ApiException) {
+ if (e.statusCode == 404) return
+ throw e
+ }
+ }
+
+ private suspend fun deletePasskey(token: String, passkeyId: String) {
+ httpClient().delete(routes.delete(passkeyId)) {
+ addAuthenticationHeader(token)
+ }
+ }
+
+ //endregion
+
+ //region Migration
+
+ /**
+ * Get migration options (see [MigrationOptions]), prior to migration from kAuth.
+ *
+ * @param deviceId The id of the device.
+ * @param userId The id of the user.
+ */
+ suspend fun getMigrationOptions(deviceId: String, userId: Long): MigrationOptions {
+ return httpClient().post(routes.migrationsOptions()) {
+ setBody(mapOf("device" to deviceId, "id" to userId.toString()))
+ }.decode>().data
+ }
+
+ /**
+ * Starts the passkey migration process from kAuth.
+ *
+ * Requires a call to [getMigrationOptions] to get the time data that will be used to generate the OTP to put in [otpPayload],
+ * and the [sessionId].
+ *
+ * The returned [AuthResult] contains an access token, to be used to register a passkey with [registerPasskey].
+ *
+ * @param sessionId ID of the session you get from [getMigrationOptions].
+ * @param otpPayload Object representing the data we need to send to the server to get an access token
+ */
+ suspend fun getTokenForMigration(
+ sessionId: String,
+ otpPayload: OtpPayload,
+ ): AuthResult {
+ return httpClient().post(routes.verifyMigration(sessionId)) {
+ setBody(otpPayload)
+ }.decode>().data
+ }
+
+ /**
+ * Completes the passkey migration process from kAuth, after [getTokenForMigration] has succeeded.
+ *
+ * @param token The access token of the user.
+ * @param deviceId ID of the device.
+ */
+ suspend fun completeMigration(token: String, sessionId: String, deviceId: String) {
+ httpClient().delete(routes.finishMigration(sessionId)) {
+ addAuthenticationHeader(token)
+ setBody(mapOf("device" to deviceId))
+ }
+ }
+
+ //endregion
+
+ suspend fun getUserProfile(
+ token: String,
+ ): SharedUserProfile {
+ val url = "${routes.userProfile()}&with=security"
+
+ return httpClient().get(url) {
+ addAuthenticationHeader(token)
+ }.decode>().data
+ }
+
+ private fun HttpRequestBuilder.addAuthenticationHeader(token: String) {
+ headers {
+ append("Authorization", "Bearer $token")
+ }
+ }
+}
diff --git a/src/commonMain/kotlin/internal/utils/DeviceInfo.kt b/src/commonMain/kotlin/internal/utils/DeviceInfo.kt
new file mode 100644
index 0000000..940743f
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/DeviceInfo.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.webauthn.DeviceInfo
+
+internal expect fun getDeviceInfo(): DeviceInfo
diff --git a/src/commonMain/kotlin/internal/utils/DynamicLazyMap.kt b/src/commonMain/kotlin/internal/utils/DynamicLazyMap.kt
new file mode 100644
index 0000000..9c5c458
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/DynamicLazyMap.kt
@@ -0,0 +1,327 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2025-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(InternalAPI::class) //TODO: Replace this once https://youtrack.jetbrains.com/issue/KT-85032 is resolved.
+
+package com.infomaniak.auth.lib.internal.utils
+
+import androidx.collection.ScatterMap
+import androidx.collection.mutableObjectIntMapOf
+import androidx.collection.mutableObjectListOf
+import androidx.collection.mutableScatterMapOf
+import com.infomaniak.auth.lib.internal.extensions.hasFlag
+import com.infomaniak.auth.lib.internal.extensions.withFlag
+import io.ktor.utils.io.InternalAPI
+import io.ktor.utils.io.locks.ReentrantLock
+import io.ktor.utils.io.locks.reentrantLock
+import io.ktor.utils.io.locks.withLock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+import kotlin.jvm.JvmInline
+
+/**
+ * Allows sharing and caching an element for a given key.
+ *
+ * It's similar to a read-only [Map], with these differences:
+ * - Elements are lazily created with the passed [createElement] lambda.
+ * - The use scope of an element is taken into account (with the block passed to [useElement] or [useElements]).
+ * - An element is removed only after these 2 things happen without cancellation:
+ * 1. It's no longer used
+ * 2. The `waitForCacheExpiration` function of the passed [cacheManager] lambda completes.
+ *
+ * Note that [cacheManager] can access the [DynamicLazyMap] instance it's running on, giving access these [StateFlow] properties:
+ * - [cachedElementsCount]
+ * - [usedElementsCount]
+ * - [totalElementsCount]
+ *
+ * This can be used to clean the cache dynamically, if it grows past a certain threshold, under certain circumstances.
+ *
+ * [DynamicLazyMap] Satisfies [this request de-duplication use-case](https://github.com/Kotlin/kotlinx.coroutines/issues/1097),
+ * and other ones.
+ */
+@OptIn(DynamicLazyMap.Internals::class, ExperimentalContracts::class)
+internal class DynamicLazyMap(
+ private val cacheManager: CacheManager? = null,
+ private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default),
+ private val createElement: CoroutineScope.(K) -> E,
+) {
+
+ companion object;
+
+ fun interface CacheManager {
+ companion object;
+
+ /**
+ * Called when the last usage of a given element ends.
+ * If [OnUnusedBehavior.cacheUntilExpired] is true (default), the [waitForCacheExpiration] function will be called,
+ * and if it completes without the element being used again, the element will be evicted from the cache, and
+ * further attempts to request the same element will lead to [DynamicLazyMap.createElement] being called again.
+ *
+ * If [OnUnusedBehavior.cacheUntilExpired] is false, the element will not be cached.
+ */
+ fun onUnused(
+ key: K,
+ element: E,
+ currentCacheSize: Int,
+ usedElementsCount: Int,
+ ): OnUnusedBehavior = OnUnusedBehavior(cacheUntilExpired = true, evictOldest = false)
+
+ suspend fun DynamicLazyMap.waitForCacheExpiration(key: K, element: E)
+ }
+
+ // We use an inline value class with bit flags to avoid the memory allocation overhead.
+ @JvmInline
+ value class OnUnusedBehavior private constructor(private val flags: UInt) {
+ constructor(
+ cacheUntilExpired: Boolean,
+ evictOldest: Boolean,
+ ) : this(
+ flags = 0u
+ .withFlag(if (cacheUntilExpired) Flags.cacheUntilExpired else 0u)
+ .withFlag(if (evictOldest) Flags.evictOldest else 0u)
+ )
+
+ val cacheUntilExpired: Boolean get() = flags.hasFlag(Flags.cacheUntilExpired)
+ val evictOldest: Boolean get() = flags.hasFlag(Flags.evictOldest)
+
+ private object Flags {
+ //@formatter:off
+ val cacheUntilExpired: UInt = 0b0000_0000_0000_0000_0000_0000_0000_0001u
+ val evictOldest: UInt = 0b0000_0000_0000_0000_0000_0000_0000_0010u
+ //@formatter:on
+ }
+ }
+
+ /** Get and use an element for the given [key]. */
+ inline fun useElement(
+ key: K,
+ block: (E) -> R
+ ): R {
+ contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
+ val element = getOrCreateElementWithRefCounting(key)
+ return try {
+ block(element)
+ } finally {
+ releaseRefForElement(key, element)
+ }
+ }
+
+ /** Get and use all elements for the given [keys]. */
+ inline fun useElements(
+ keys: Set,
+ block: (Map) -> R
+ ): R {
+ contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
+ val elementMap = getOrCreateElementsWithRefCounting(keys)
+ return try {
+ block(elementMap)
+ } finally {
+ releaseRefForElements(elementMap)
+ }
+ }
+
+ /**
+ * The number of elements that are not used, but still in the cache.
+ *
+ * @see usedElementsCount
+ * @see totalElementsCount
+ */
+ val cachedElementsCount: StateFlow
+
+ /**
+ * The number of elements actively used.
+ *
+ * @see cachedElementsCount
+ * @see totalElementsCount
+ */
+ val usedElementsCount: StateFlow
+
+ /**
+ * The number of elements that are present (used, or cached).
+ *
+ * @see cachedElementsCount
+ * @see usedElementsCount
+ */
+ val totalElementsCount: StateFlow
+
+ @RequiresOptIn
+ @Retention(AnnotationRetention.BINARY)
+ private annotation class Internals
+
+ private inner class Entry(
+ val element: E,
+ val subScope: CoroutineScope
+ )
+
+ private var _cachedElementsCount by MutableStateFlow(0).also { cachedElementsCount = it.asStateFlow() }::value
+ private var _usedElementsCount by MutableStateFlow(0).also { usedElementsCount = it.asStateFlow() }::value
+ private var _totalElementsCount by MutableStateFlow(0).also { totalElementsCount = it.asStateFlow() }::value
+
+ /**
+ * Contains the number of concurrent usages per element.
+ * The entry is removed when the last usage ends, so all the values inside are strictly greater than 1.
+ */
+ private val refCounts = mutableObjectIntMapOf()
+
+ /** Contains used **and** cached entries. */
+ private val elements = mutableScatterMapOf()
+
+ /**
+ * Contains references to coroutines that are running the [CacheManager.waitForCacheExpiration] function,
+ * and that will remove the cached element, if not cancelled because a new usage.
+ * @see removersOrderedKeys
+ */
+ private val removers = mutableScatterMapOf()
+
+ /**
+ * Keeps the insertion order of [removers], so we can evict the oldest when [OnUnusedBehavior.evictOldest] is true.
+ * We can't just use [removers] because it is a [ScatterMap], which doesn't keep the insertion order.
+ */
+ private val removersOrderedKeys = mutableObjectListOf() // Because `removers` is a ScatterMap, which doesn't keep order.
+ private val mapsEditLock: ReentrantLock = reentrantLock()
+
+ private fun updateCounts() {
+ // check(mapsEditLock.isHeldByCurrentThread) //TODO: Uncomment after https://youtrack.jetbrains.com/issue/KT-85032
+ val total = elements.size
+ val removersCount = removers.size
+ _cachedElementsCount = removersCount
+ _usedElementsCount = total - removersCount
+ _totalElementsCount = total
+ }
+
+ @Internals
+ @PublishedApi
+ internal fun getOrCreateElementWithRefCounting(key: K): E = mapsEditLock.withLock {
+ getOrCreateElementWithRefCountingUnchecked(key).also { updateCounts() }
+ }
+
+ @Internals
+ @PublishedApi
+ internal fun getOrCreateElementsWithRefCounting(keys: Set): Map = mapsEditLock.withLock {
+ keys.associateWith { key ->
+ getOrCreateElementWithRefCountingUnchecked(key)
+ }.also { updateCounts() }
+ }
+
+ @Internals
+ @PublishedApi
+ internal fun releaseRefForElement(key: K, element: E) = mapsEditLock.withLock {
+ releaseRefForElementUnchecked(key, element)
+ updateCounts()
+ }
+
+ @Internals
+ @PublishedApi
+ internal fun releaseRefForElements(elementMap: Map) = mapsEditLock.withLock {
+ elementMap.forEach { (key, element) -> releaseRefForElementUnchecked(key, element) }
+ updateCounts()
+ }
+
+ private fun getOrCreateElementWithRefCountingUnchecked(key: K): E {
+ // check(mapsEditLock.isHeldByCurrentThread) //TODO: Uncomment after https://youtrack.jetbrains.com/issue/KT-85032
+ val currentCount = refCounts.getOrDefault(key = key, defaultValue = 0)
+ refCounts[key] = currentCount + 1
+ val remover = removers[key]
+ if (remover != null) {
+ remover.cancel()
+ removers.remove(key)
+ removersOrderedKeys.remove(key)
+ }
+ return elements.getOrPut(key) {
+ val newJob = Job(parent = coroutineScope.coroutineContext.job)
+ val subScope = coroutineScope + newJob
+ Entry(
+ element = with(subScope) { createElement(key) },
+ subScope = subScope
+ )
+ }.element
+ }
+
+ private fun releaseRefForElementUnchecked(key: K, element: E) {
+ // check(mapsEditLock.isHeldByCurrentThread) //TODO: Uncomment after https://youtrack.jetbrains.com/issue/KT-85032
+ val newCount = refCounts[key] - 1
+ when (newCount) {
+ 0 -> releaseOrCacheUnusedElement(key, element)
+ else -> refCounts[key] = newCount
+ }
+ }
+
+ private fun releaseOrCacheUnusedElement(key: K, element: E) {
+ // check(mapsEditLock.isHeldByCurrentThread) //TODO: Uncomment after https://youtrack.jetbrains.com/issue/KT-85032
+ refCounts.remove(key)
+ if (cacheManager == null) {
+ removeElement(key)
+ return
+ }
+ val behavior = cacheManager.onUnused(
+ key = key,
+ element = element,
+ currentCacheSize = removers.size,
+ usedElementsCount = elements.size - removers.size
+ )
+ if (behavior.evictOldest) {
+ if (removersOrderedKeys.isNotEmpty()) {
+ val keyOfOldestElement = removersOrderedKeys.first()
+ removeFromCache(keyOfOldestElement)
+ } else if (behavior.cacheUntilExpired.not()) {
+ removeElement(key)
+ return
+ }
+ }
+ if (behavior.cacheUntilExpired) {
+ removersOrderedKeys.add(key)
+ removers[key] = coroutineScope.launch {
+ with(cacheManager) { waitForCacheExpiration(key, element) }
+ mapsEditLock.withLock {
+ if (key !in refCounts) { // A new usage might have popped-up as cache was about to expire.
+ removeFromCache(
+ key = key,
+ cancelRemover = false // Because we're in said remover, which is about to complete.
+ )
+ updateCounts()
+ }
+ }
+ }
+ } else {
+ removeElement(key)
+ }
+ }
+
+ private fun removeElement(key: K) {
+ elements.remove(key)?.subScope?.cancel()
+ }
+
+ private fun removeFromCache(key: K, cancelRemover: Boolean = true) {
+ // check(mapsEditLock.isHeldByCurrentThread) //TODO: Uncomment after https://youtrack.jetbrains.com/issue/KT-85032
+ removers.remove(key)?.also {
+ if (cancelRemover) it.cancel()
+ }
+ removersOrderedKeys.remove(key)
+ removeElement(key)
+ }
+}
diff --git a/src/commonMain/kotlin/internal/utils/DynamicLazyMapExtensions.kt b/src/commonMain/kotlin/internal/utils/DynamicLazyMapExtensions.kt
new file mode 100644
index 0000000..de52bb4
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/DynamicLazyMapExtensions.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.flow.Flow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+
+/**
+ * Creates a Flow with the passed [block] using the values from this [DynamicLazyMap] corresponding to the passed [keys],
+ * while it is hot.
+ */
+internal fun DynamicLazyMap.buildFlowWithElements(
+ keys: Set,
+ block: (map: Map) -> Flow
+): Flow = flow {
+ useElements(keys) {
+ emitAll(block(it))
+ }
+}
diff --git a/src/commonMain/kotlin/internal/utils/DynamicLazyMapHelpers.kt b/src/commonMain/kotlin/internal/utils/DynamicLazyMapHelpers.kt
new file mode 100644
index 0000000..c1b0bc7
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/DynamicLazyMapHelpers.kt
@@ -0,0 +1,97 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025-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.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.shareIn
+
+/**
+ * Helper to create a [DynamicLazyMap] of [SharedFlow]s with a [Flow] factory.
+ *
+ * It's equivalent to [dynamicLazyMapOfSharedFlow].
+ *
+ * @see flowForKey
+ */
+internal fun DynamicLazyMap.Companion.sharedFlow(
+ cacheManager: DynamicLazyMap.CacheManager>? = null,
+ coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default),
+ createFlow: CoroutineScope.(K) -> Flow,
+): DynamicLazyMap> = DynamicLazyMap>(
+ cacheManager = cacheManager,
+ coroutineScope = coroutineScope,
+ createElement = { key -> createFlow(key).shareIn(this, SharingStarted.Lazily, replay = 1) }
+)
+
+/**
+ * Helper to directly get a [Flow] from a [DynamicLazyMap] containing [SharedFlow]s.
+ *
+ * @see DynamicLazyMap.Companion.sharedFlow
+ */
+internal fun DynamicLazyMap>.flowForKey(key: K): Flow = flow {
+ useElement(key) { sharedFlow: SharedFlow ->
+ emitAll(sharedFlow)
+ }
+}
+
+/**
+ * Creates a [DynamicLazyMap].
+ *
+ * @see DynamicLazyMap
+ */
+internal fun CoroutineScope.dynamicLazyMap(
+ cacheManager: DynamicLazyMap.CacheManager? = null,
+ createElement: CoroutineScope.(K) -> E
+): DynamicLazyMap {
+ return DynamicLazyMap(
+ cacheManager = cacheManager,
+ coroutineScope = this,
+ createElement = createElement
+ )
+}
+
+/**
+ * Helper to create a [DynamicLazyMap] of [SharedFlow]s with a [Flow] factory.
+ *
+ * It's equivalent to [sharedFlow].
+ *
+ * @see flowForKey
+ */
+internal fun CoroutineScope.dynamicLazyMapOfSharedFlow(
+ cacheManager: DynamicLazyMap.CacheManager>? = null,
+ createFlow: CoroutineScope.(K) -> Flow,
+): DynamicLazyMap> {
+ return DynamicLazyMap.sharedFlow(
+ cacheManager = cacheManager,
+ coroutineScope = this,
+ createFlow = createFlow
+ )
+}
+
+internal inline fun DynamicLazyMap>.combineFor(
+ keys: Set,
+ crossinline transform: suspend (Array) -> R
+): Flow = flow {
+ useElements(keys) { emitAll(combine(it.values, transform)) }
+}
diff --git a/src/commonMain/kotlin/internal/utils/FileUtils.kt b/src/commonMain/kotlin/internal/utils/FileUtils.kt
new file mode 100644
index 0000000..a63834d
--- /dev/null
+++ b/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/src/commonMain/kotlin/internal/utils/KeyCoordinates.kt b/src/commonMain/kotlin/internal/utils/KeyCoordinates.kt
new file mode 100644
index 0000000..ab80240
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/KeyCoordinates.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.webauthn.PublicKeyXY
+
+internal expect fun getKeyCoordinates(key: ByteArray): PublicKeyXY
+
+internal fun keyCoordinatesOf(uncompressedP256Key: ByteArray): PublicKeyXY {
+
+ require(uncompressedP256Key[0] == 0x04.toByte()) {
+ "Invalid key type. Expected 0x04, but found ${uncompressedP256Key[0]}."
+ }
+ require(uncompressedP256Key.size == 65) {
+ "Invalid key length. Expected 65 bytes, but found ${uncompressedP256Key.size} bytes."
+ }
+
+ val x = uncompressedP256Key.copyOfRange(1, 33)
+ val y = uncompressedP256Key.copyOfRange(fromIndex = 33, toIndex = 65)
+ return PublicKeyXY(x, y)
+}
diff --git a/src/commonMain/kotlin/internal/utils/Racing.kt b/src/commonMain/kotlin/internal/utils/Racing.kt
new file mode 100644
index 0000000..0f2cba0
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/Racing.kt
@@ -0,0 +1,127 @@
+/*
+ * 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:Suppress("NOTHING_TO_INLINE")
+
+package com.infomaniak.auth.lib.internal.utils
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.selects.select
+import kotlin.experimental.ExperimentalTypeInference
+import kotlinx.coroutines.CoroutineStart.UNDISPATCHED as Undispatched
+
+/**
+ * Pass at least one racer to use [raceOf], or use [race] if the racers need to be launched
+ * dynamically.
+ */
+@Suppress("DeprecatedCallableAddReplaceWith", "RedundantSuspendModifier")
+@Deprecated("A race needs racers.", level = DeprecationLevel.ERROR) // FOOL GUARD, DO NOT REMOVE
+internal suspend fun raceOf(): T = throw UnsupportedOperationException("A race needs racers.")
+
+/**
+ * Races all the [racers] concurrently. Once the winner completes, all other racers are cancelled,
+ * then the value of the winner is returned.
+ *
+ * Use [race] if the racers need to be launched dynamically.
+ */
+internal suspend fun raceOf(vararg racers: suspend CoroutineScope.() -> T): T {
+ require(racers.isNotEmpty()) { "A race needs racers." }
+ return coroutineScope {
+ val racersScope = CoroutineScope(Job(parent = coroutineContext[Job]))
+ @Suppress("RemoveExplicitTypeArguments")
+ select {
+ racers.forEach { racer ->
+ racersScope.async(
+ start = Undispatched,
+ block = racer
+ ).onAwait { resultOfWinner: T ->
+ racersScope.cancel()
+ return@onAwait resultOfWinner
+ }
+ }
+ }
+ }
+}
+
+/**
+ * A scope meant to be used in [race] lambda receiver.
+ *
+ * You should not implement this interface yourself.
+ */
+internal interface RacingScope : CoroutineScope {
+ @Deprecated(
+ message = "Internal API",
+ replaceWith = ReplaceWith("launchRacer(block)", "splitties.coroutines.launchRacer")
+ )
+ fun launchRacerInternal(block: suspend CoroutineScope.() -> T)
+}
+
+/**
+ * Launches a racer in this scope.
+ * **Must be cancellable**, it will suspend [race] completion otherwise.
+ *
+ * Use it inside the lambda passed to the [race] function.
+ */
+internal inline fun RacingScope.launchRacer(noinline block: suspend CoroutineScope.() -> T) {
+ @Suppress("DEPRECATION")
+ launchRacerInternal(block)
+}
+
+/**
+ * Starts a [RacingScope] with the suspending [builder] lambda in which you can call [launchRacer]
+ * each time you want to launch a racer coroutine. Once a racer completes, the [builder] and all
+ * racers are cancelled, then the value of the winning racer is returned.
+ *
+ * For races where the number of racers is static, you can use the slightly more efficient [raceOf]
+ * function and directly pass the cancellable lambdas you want to race concurrently.
+ */
+@OptIn(ExperimentalTypeInference::class)
+internal suspend fun race(
+ @BuilderInference
+ builder: suspend RacingScope.() -> Unit
+): T = coroutineScope {
+ @Suppress("RemoveExplicitTypeArguments")
+ select {
+ val builderScope = CoroutineScope(Job(parent = coroutineContext[Job]))
+
+ val racingScope = object : RacingScope, CoroutineScope by this@coroutineScope {
+
+ var raceWon = false
+
+ @Suppress("OverridingDeprecatedMember", "OVERRIDE_DEPRECATION")
+ override fun launchRacerInternal(block: suspend CoroutineScope.() -> T) {
+ if (raceWon) return // A racer already completed.
+ builderScope.async(
+ start = Undispatched,
+ block = block
+ ).onAwait { resultOfWinner: T ->
+ raceWon = true
+ builderScope.cancel()
+ return@onAwait resultOfWinner
+ }
+ }
+ }
+ builderScope.launch(start = Undispatched) {
+ racingScope.builder()
+ }
+ }
+}
diff --git a/src/commonMain/kotlin/internal/utils/SentryTimeout.kt b/src/commonMain/kotlin/internal/utils/SentryTimeout.kt
new file mode 100644
index 0000000..7338f51
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/SentryTimeout.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
+
+/**
+ * Tries to execute [block], but if [waitForTimeout] finishes first, cancels [block],
+ * and reports the timeout via [onTimeout], using the message returned by [waitForTimeout].
+ */
+internal suspend fun withTimeoutOrNull(
+ waitForTimeout: suspend () -> String,
+ onTimeout: (message: String) -> Unit,
+ block: suspend () -> R
+): R? {
+ val result: TimeoutResult = raceOf(
+ { TimeoutResult.Returned(block()) },
+ { TimeoutResult.TimedOut(waitForTimeout()) }
+ )
+ return when (result) {
+ is TimeoutResult.Returned -> result.value
+ is TimeoutResult.TimedOut -> {
+ onTimeout(result.message)
+ null
+ }
+ }
+}
+
+private sealed interface TimeoutResult {
+ class TimedOut(val message: String) : TimeoutResult
+ class Returned(val value: T) : TimeoutResult
+}
diff --git a/src/commonMain/kotlin/internal/utils/SignUtils.kt b/src/commonMain/kotlin/internal/utils/SignUtils.kt
new file mode 100644
index 0000000..9529737
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/SignUtils.kt
@@ -0,0 +1,25 @@
+/*
+ * 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
+
+internal expect object SignUtils {
+
+ fun signWithPrivateKey(privateKey: ByteArray, data: ByteArray): ByteArray
+
+ fun verifySignature(publicKey: ByteArray, data: ByteArray, signatureData: ByteArray): Boolean
+}
diff --git a/src/commonMain/kotlin/internal/utils/WaitForComplete.kt b/src/commonMain/kotlin/internal/utils/WaitForComplete.kt
new file mode 100644
index 0000000..7df9034
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/WaitForComplete.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.CompletableJob
+import kotlinx.coroutines.Job
+
+internal suspend inline fun waitForComplete(block: (completable: CompletableJob) -> R): R {
+ val completable: CompletableJob = Job()
+ return block(completable).also { completable.join() }
+}
diff --git a/src/commonMain/kotlin/internal/utils/Xor.kt b/src/commonMain/kotlin/internal/utils/Xor.kt
new file mode 100644
index 0000000..5c352a9
--- /dev/null
+++ b/src/commonMain/kotlin/internal/utils/Xor.kt
@@ -0,0 +1,35 @@
+/*
+ * 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
+
+/**
+ * This class is a substitute for union types (that will exist as "error types" in Kotlin 2.3+).
+ *
+ * It is similar to `Either` from Arrow, but with fewer features (because some are not needed).
+ *
+ * XOR stands for eXclusive OR.
+ */
+internal sealed class Xor {
+
+ fun firstOrNull(): FirstT? = if (this is First) this.value else null
+ fun secondOrNull(): SecondT? = if (this is Second) this.value else null
+
+ data class First(val value: LeftT) : Xor()
+
+ data class Second(val value: RightT) : Xor()
+}
diff --git a/src/commonMain/kotlin/internal/webauthn/DeviceInfo.kt b/src/commonMain/kotlin/internal/webauthn/DeviceInfo.kt
new file mode 100644
index 0000000..97ee57a
--- /dev/null
+++ b/src/commonMain/kotlin/internal/webauthn/DeviceInfo.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.webauthn
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class DeviceInfo(
+ val brand: String,
+ val model: String,
+ val platform: String,
+)
diff --git a/src/commonMain/kotlin/internal/webauthn/KeyAlgorithm.kt b/src/commonMain/kotlin/internal/webauthn/KeyAlgorithm.kt
new file mode 100644
index 0000000..018846f
--- /dev/null
+++ b/src/commonMain/kotlin/internal/webauthn/KeyAlgorithm.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.webauthn
+
+import kotlinx.serialization.Serializable
+import kotlin.jvm.JvmInline
+
+@Serializable
+@JvmInline
+internal value class KeyAlgorithm(val constant: Byte) {
+ companion object {
+ val ES256 = KeyAlgorithm(-7)
+ }
+}
diff --git a/src/commonMain/kotlin/internal/webauthn/KeyCose.kt b/src/commonMain/kotlin/internal/webauthn/KeyCose.kt
new file mode 100644
index 0000000..2f2f01a
--- /dev/null
+++ b/src/commonMain/kotlin/internal/webauthn/KeyCose.kt
@@ -0,0 +1,70 @@
+/*
+ * 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(ExperimentalSerializationApi::class)
+
+package com.infomaniak.auth.lib.internal.webauthn
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.cbor.ByteString
+import kotlinx.serialization.cbor.Cbor
+import kotlinx.serialization.cbor.CborLabel
+import kotlinx.serialization.encodeToByteArray
+
+/**
+ * Returns the COSE of the key for our WebAuthn usage,
+ * given its x and y coordinates (retrieved via OS specific APIs).
+ *
+ * **COSE** stands for **CBOR Object Signing and Encryption**.
+ *
+ * **CBOR** stands for **Concise Binary Object Representation**.
+ */
+internal fun keyCoseOf(x: ByteArray, y: ByteArray): ByteArray {
+ require(x.size == 32) { "Invalid x coordinate length: ${x.size}" }
+ require(y.size == 32) { "Invalid y coordinate length: ${y.size}" }
+ val cose = KeyCose(
+ kty = 2, // EC2
+ alg = KeyAlgorithm.ES256,
+ crv = 1, // P-256
+ x = x,
+ y = y,
+ )
+ return Cbor.CoseCompliant.encodeToByteArray(cose)
+}
+
+@Suppress("unused") // The properties are used by CBOR serialization, to be sent to the backend.
+@Serializable
+private class KeyCose(
+
+ @CborLabel(1)
+ val kty: Byte,
+
+ @CborLabel(3)
+ val alg: KeyAlgorithm,
+
+ @CborLabel(-1)
+ val crv: Byte,
+
+ @ByteString
+ @CborLabel(-2)
+ val x: ByteArray,
+
+ @ByteString
+ @CborLabel(-3)
+ val y: ByteArray,
+)
diff --git a/src/commonMain/kotlin/internal/webauthn/PublicKeyXY.kt b/src/commonMain/kotlin/internal/webauthn/PublicKeyXY.kt
new file mode 100644
index 0000000..eae8162
--- /dev/null
+++ b/src/commonMain/kotlin/internal/webauthn/PublicKeyXY.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.webauthn
+
+internal class PublicKeyXY(
+ val x: ByteArray,
+ val y: ByteArray,
+) {
+ operator fun component1(): ByteArray = x
+ operator fun component2(): ByteArray = y
+}
diff --git a/src/commonMain/kotlin/internal/webauthn/WebAuthnAttestationObject.kt b/src/commonMain/kotlin/internal/webauthn/WebAuthnAttestationObject.kt
new file mode 100644
index 0000000..d941d09
--- /dev/null
+++ b/src/commonMain/kotlin/internal/webauthn/WebAuthnAttestationObject.kt
@@ -0,0 +1,42 @@
+/*
+ * 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(ExperimentalSerializationApi::class)
+
+package com.infomaniak.auth.lib.internal.webauthn
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.cbor.ByteString
+import kotlinx.serialization.cbor.Cbor
+import kotlinx.serialization.encodeToByteArray
+
+internal fun createEncodedWebAuthnAttestationObject(fmt: String, authData: ByteArray): ByteArray {
+ val attestationObject = WebAuthnAttestationObject(
+ fmt = fmt,
+ authData = authData,
+ )
+ return Cbor.CoseCompliant.encodeToByteArray(attestationObject)
+}
+
+@Suppress("unused") // The properties are used by CBOR serialization, to be sent to the backend.
+@Serializable
+private class WebAuthnAttestationObject(
+ val fmt: String,
+ @ByteString
+ val authData: ByteArray,
+)
diff --git a/src/commonMain/kotlin/logging/BlockLogger.kt b/src/commonMain/kotlin/logging/BlockLogger.kt
new file mode 100644
index 0000000..8196140
--- /dev/null
+++ b/src/commonMain/kotlin/logging/BlockLogger.kt
@@ -0,0 +1,61 @@
+/*
+ * 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(ExperimentalAtomicApi::class)
+
+package com.infomaniak.auth.lib.logging
+
+import kotlin.concurrent.atomics.AtomicLong
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.concurrent.atomics.incrementAndFetch
+
+class BlockLogger(
+ @PublishedApi internal val callbacks: Callbacks
+) {
+ companion object {
+ @PublishedApi
+ internal val logId = AtomicLong(0)
+ }
+
+ inline fun withLog(
+ blockIdentity: BlockIdentity,
+ block: () -> R
+ ): R {
+ val invocationId = logId.incrementAndFetch()
+ var returnedOrThrew = false
+ try {
+ callbacks.blockEntered(blockIdentity, invocationId)
+ return block().also {
+ returnedOrThrew = true
+ callbacks.blockReturned(blockIdentity, invocationId, it)
+ }
+ } catch (t: Throwable) {
+ returnedOrThrew = true
+ callbacks.blockThrew(blockIdentity, invocationId, t)
+ throw t
+ } finally {
+ if (!returnedOrThrew) callbacks.blockReturnedEarly(blockIdentity, invocationId)
+ }
+ }
+
+ abstract class Callbacks {
+ abstract fun blockEntered(blockIdentity: BlockIdentity, invocationId: Long)
+ abstract fun blockReturned(blockIdentity: BlockIdentity, invocationId: Long, value: Any?)
+ abstract fun blockReturnedEarly(blockIdentity: BlockIdentity, invocationId: Long)
+ abstract fun blockThrew(blockIdentity: BlockIdentity, invocationId: Long, throwable: Throwable)
+ }
+}
diff --git a/src/commonMain/kotlin/logging/ReportingBlockLogger.kt b/src/commonMain/kotlin/logging/ReportingBlockLogger.kt
new file mode 100644
index 0000000..aaa9f8c
--- /dev/null
+++ b/src/commonMain/kotlin/logging/ReportingBlockLogger.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.logging
+
+import com.infomaniak.auth.lib.network.interfaces.BreadcrumbType
+import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface
+import com.infomaniak.auth.lib.network.interfaces.CrashReportLevel
+import kotlin.coroutines.cancellation.CancellationException
+
+fun BlockLogger.Companion.breadcrumbsLogger(
+ crashReportInterface: CrashReportInterface,
+ category: String,
+): BlockLogger {
+ val callbacks = object : BlockLogger.Callbacks() {
+
+ override fun blockEntered(blockIdentity: String, invocationId: Long) {
+ crashReportInterface.addBreadcrumb(
+ message = "↘ $blockIdentity#$invocationId entered",
+ category = category,
+ level = CrashReportLevel.INFO,
+ type = BreadcrumbType.Default,
+ data = null
+ )
+ }
+
+ override fun blockReturned(blockIdentity: String, invocationId: Long, value: Any?) {
+ crashReportInterface.addBreadcrumb(
+ message = "↖ $blockIdentity#$invocationId returned",
+ category = category,
+ level = CrashReportLevel.INFO,
+ type = BreadcrumbType.Default,
+ data = buildMap {
+ runCatching { this["value"] = value.toString() }.onFailure {
+ this["value.toString() failure"] = it.toString()
+ }
+ if (value != null) this["value::class"] = value::class.qualifiedName ?: "null"
+ }
+ )
+ }
+
+ override fun blockReturnedEarly(blockIdentity: String, invocationId: Long) {
+ crashReportInterface.addBreadcrumb(
+ message = "↖ $blockIdentity#$invocationId returned early",
+ category = category,
+ level = CrashReportLevel.INFO,
+ type = BreadcrumbType.Default,
+ data = null
+ )
+ }
+
+ override fun blockThrew(blockIdentity: String, invocationId: Long, throwable: Throwable) {
+ val isCancellation = throwable is CancellationException
+ crashReportInterface.addBreadcrumb(
+ message = "↖ $blockIdentity#$invocationId ${if (isCancellation) "got cancelled" else "threw" }",
+ category = category,
+ level = CrashReportLevel.INFO,
+ type = BreadcrumbType.Default,
+ data = buildMap {
+ this["exception message"] = throwable.message ?: "null"
+ this["exception type"] = throwable::class.qualifiedName ?: "unknown"
+ //TODO: Add causes recursively
+ }
+ )
+ }
+ }
+ return BlockLogger(callbacks)
+}
diff --git a/src/commonMain/kotlin/matomo/MatomoCategory.kt b/src/commonMain/kotlin/matomo/MatomoCategory.kt
new file mode 100644
index 0000000..7447066
--- /dev/null
+++ b/src/commonMain/kotlin/matomo/MatomoCategory.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.matomo
+
+enum class MatomoCategory(val value: String) {
+
+ //region Common
+ Account("account"),
+ SettingsGeneral("settingsGeneral"),
+ Migration("migration"),
+ //endregion
+
+ //region iOS
+ //endregion
+}
diff --git a/src/commonMain/kotlin/matomo/MatomoName.kt b/src/commonMain/kotlin/matomo/MatomoName.kt
new file mode 100644
index 0000000..aed9cbd
--- /dev/null
+++ b/src/commonMain/kotlin/matomo/MatomoName.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.matomo
+
+enum class MatomoName(val value: String) {
+
+ //region Common
+ AskAddAccount("askAddAccount"),
+ AskRefreshChallenge("askRefreshChallenge"),
+ ToggleBiometry("toggleBiometry"),
+ LoggedIn("loggedIn"),
+ OpenLoginWebview("openLoginWebview"),
+ OpenCreationWebview("openCreationWebview"),
+ OpenHistoryWebview("openHistoryWebview"),
+ OpenSettingsWebview("openSettingsWebview"),
+ OpenNotificationSettings("openNotificationSettings"),
+ OpenFeedbackWebview("openFeedbackWebview"),
+ OpenSupportWebview("openSupportWebview"),
+ Disconnect("disconnect"),
+ MigrationStart("migrationStart"),
+ ShowRecoverableAccounts("showRecoverableAccounts"),
+ OpenForgotPasswordWebview("openForgotPasswordWebview"),
+ //endregion
+
+ //region iOS
+ //endregion
+}
diff --git a/src/commonMain/kotlin/matomo/MatomoScreen.kt b/src/commonMain/kotlin/matomo/MatomoScreen.kt
new file mode 100644
index 0000000..7889d4a
--- /dev/null
+++ b/src/commonMain/kotlin/matomo/MatomoScreen.kt
@@ -0,0 +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 .
+ */
+
+package com.infomaniak.auth.lib.matomo
+
+enum class MatomoScreen(val value: String) {
+ MigrationScreen("Migration"),
+ AccountListScreen("AccountList"),
+ AccountDetailsScreen("AccountDetails"),
+ SecuringAccountScreen("SecuringAccount"),
+ SettingsScreen("Settings"),
+ OnboardingStartScreen("OnboardingStart"),
+ OnboardingCompleteScreen("OnboardingComplete"),
+ NotificationPermissionScreen("NotificationPermission"),
+ ThemeSettingsScreen("ThemeSettings"),
+ PrivacyManagementScreen("PrivacyManagement"),
+}
diff --git a/src/commonMain/kotlin/models/UrlConstants.kt b/src/commonMain/kotlin/models/UrlConstants.kt
new file mode 100644
index 0000000..d5c0ef8
--- /dev/null
+++ b/src/commonMain/kotlin/models/UrlConstants.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.models
+
+object UrlConstants {
+ fun createAccountUrl(host: String) = "https://welcome.$host/signup"
+ fun createAccountSuccessUrl(host: String) = "manager.$host"
+ fun createAccountCancelUrl(host: String) = "welcome.$host"
+ fun managerUrl(host: String, path: String) = "https://manager.$host/v3/$path"
+ fun autologUrl(host: String, url: String) = "https://manager.$host/v3/$AUTOLOG_URL/?url=$url"
+
+ private const val AUTOLOG_URL = "mobile_login"
+ const val ACTIVITY_MANAGER_URL = "ng/profile/user/connection-history/activity?no-header=true"
+ const val SETTINGS_MANAGER_URL = "ng/profile/user/security-and-recovery-parameters/dashboard?global-settings=user-account-security&global-settings-prevent-closing=true&global-settings-prevent-popups=true&no-header=true"
+ const val SETTINGS_2FA_MANAGER_URL = "ng/profile/user/security-and-recovery-parameters/dashboard?global-settings=user-account-security-2fa&global-settings-prevent-closing=true&global-settings-prevent-popups=true&no-header=true"
+ const val SETTINGS_ACCOUNT_SECURITY_URL = "ng/profile/user/security-and-recovery-parameters/dashboard?no-header=true"
+
+ const val RECOVER_PASSWORD_URL = "https://login.infomaniak.com/recover"
+ const val HELP_SUPPORT_URL = "https://www.infomaniak.com/gtl/help"
+}
diff --git a/src/commonMain/kotlin/models/migration/SharedApiToken.kt b/src/commonMain/kotlin/models/migration/SharedApiToken.kt
new file mode 100644
index 0000000..a6b8a0c
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/SharedApiToken.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.models.migration
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+
+@Serializable
+data class SharedApiToken(
+ @SerialName("access_token") val accessToken: String,
+ @SerialName("refresh_token") val refreshToken: String? = null,
+ @SerialName("token_type") val tokenType: String,
+ @SerialName("expires_in") val expiresIn: Int = 7200,
+ @SerialName("user_id") val userId: Int,
+ @SerialName("scope") val scope: String? = null,
+ @Transient var expiresAt: Long? = null,
+ @Transient var isTemporary: Boolean = false
+) {
+ override fun toString() = "SharedApiToken(accessToken=██, " +
+ "refreshToken=██, " +
+ "tokenType='$tokenType', " +
+ "expiresIn=$expiresIn, " +
+ "userId=$userId, " +
+ "scope=$scope, " +
+ "expiresAt=$expiresAt, " +
+ "isTemporary=$isTemporary)"
+}
diff --git a/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt b/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt
new file mode 100644
index 0000000..3dbe679
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt
@@ -0,0 +1,49 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user
+
+import com.infomaniak.auth.lib.models.migration.SharedApiToken
+import com.infomaniak.auth.lib.models.migration.user.preferences.Preferences
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+
+@Serializable
+data class SharedUserProfile(
+ val id: Int,
+ @SerialName("display_name")
+ val displayName: String?,
+ @SerialName("first_name")
+ val firstname: String,
+ @SerialName("last_name")
+ val lastname: String,
+ val email: String,
+ val avatar: String?,
+ val login: String,
+ @SerialName("is_staff")
+ val isStaff: Boolean = false,
+ val preferences: Preferences,
+
+ /**
+ * Local
+ */
+ @Transient
+ var apiToken: SharedApiToken = SharedApiToken(accessToken = "", tokenType = "", userId = 0),
+) {
+ fun getInitials() = "${firstname.firstOrNull()?.uppercase() ?: ""}${lastname.firstOrNull()?.uppercase() ?: ""}"
+}
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt b/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt
new file mode 100644
index 0000000..0b246ff
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt
@@ -0,0 +1,29 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences
+
+import com.infomaniak.auth.lib.models.migration.user.preferences.security.SharedSecurity
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Preferences(
+ var security: SharedSecurity?,
+ @SerialName("account")
+ var organizationPreference: SharedOrganizationPreference,
+)
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/SharedCountry.kt b/src/commonMain/kotlin/models/migration/user/preferences/SharedCountry.kt
new file mode 100644
index 0000000..eabb448
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/SharedCountry.kt
@@ -0,0 +1,29 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SharedCountry(
+ @SerialName("short_name")
+ var shortName: String,
+ @SerialName("is_enabled")
+ var isEnabled: Boolean,
+) : SharedPreferenceTemplate()
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/SharedLanguage.kt b/src/commonMain/kotlin/models/migration/user/preferences/SharedLanguage.kt
new file mode 100644
index 0000000..bd167ed
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/SharedLanguage.kt
@@ -0,0 +1,32 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences
+
+import androidx.room.ColumnInfo
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SharedLanguage(
+ @SerialName("short_name")
+ var shortName: String,
+ @ColumnInfo(defaultValue = "")
+ var locale: String,
+ @SerialName("short_locale")
+ var shortLocale: String,
+) : SharedPreferenceTemplate()
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/SharedOrganizationPreference.kt b/src/commonMain/kotlin/models/migration/user/preferences/SharedOrganizationPreference.kt
new file mode 100644
index 0000000..c83492d
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/SharedOrganizationPreference.kt
@@ -0,0 +1,27 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SharedOrganizationPreference(
+ @SerialName("current_account_id")
+ var currentOrganizationId: Int,
+)
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/SharedPreferenceTemplate.kt b/src/commonMain/kotlin/models/migration/user/preferences/SharedPreferenceTemplate.kt
new file mode 100644
index 0000000..a0b2f43
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/SharedPreferenceTemplate.kt
@@ -0,0 +1,26 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+abstract class SharedPreferenceTemplate(
+ var id: Int = 0,
+ var name: String = "",
+)
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/SharedTimeZone.kt b/src/commonMain/kotlin/models/migration/user/preferences/SharedTimeZone.kt
new file mode 100644
index 0000000..9835e3f
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/SharedTimeZone.kt
@@ -0,0 +1,25 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SharedTimeZone(
+ var gmt: String,
+) : SharedPreferenceTemplate()
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/security/SharedAuthDevices.kt b/src/commonMain/kotlin/models/migration/user/preferences/security/SharedAuthDevices.kt
new file mode 100644
index 0000000..1fd55df
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/security/SharedAuthDevices.kt
@@ -0,0 +1,40 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences.security
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SharedAuthDevices(
+ var id: Int,
+ var name: String,
+ @SerialName("last_connexion")
+ var lastConnexion: Long?,
+ @SerialName("user_agent")
+ var userAgent: String,
+ @SerialName("user_ip")
+ var userIp: String,
+ var device: String,
+ @SerialName("created_at")
+ var createdAt: Long,
+ @SerialName("updated_at")
+ var updatedAt: Long,
+ @SerialName("deleted_at")
+ var deletedAt: Long?,
+)
diff --git a/src/commonMain/kotlin/models/migration/user/preferences/security/SharedSecurity.kt b/src/commonMain/kotlin/models/migration/user/preferences/security/SharedSecurity.kt
new file mode 100644
index 0000000..ea6e49a
--- /dev/null
+++ b/src/commonMain/kotlin/models/migration/user/preferences/security/SharedSecurity.kt
@@ -0,0 +1,28 @@
+/*
+ * Infomaniak Authenticator - Android
+ * Copyright (C) 2022-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.models.migration.user.preferences.security
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SharedSecurity(
+ var score: Int,
+ @SerialName("date_last_changed_password")
+ var dateLastChangedPassword: Long?,
+)
diff --git a/src/commonMain/kotlin/network/exceptions/ApiException.kt b/src/commonMain/kotlin/network/exceptions/ApiException.kt
new file mode 100644
index 0000000..680ecd5
--- /dev/null
+++ b/src/commonMain/kotlin/network/exceptions/ApiException.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.network.exceptions
+
+/**
+ * Parent class of API calls exception.
+ *
+ * This exception is used to represent errors returned by an API, with an associated [requestContextId]
+ * and message describing the problem.
+ *
+ * @param errorMessage The detailed error message explaining the cause of the failure.
+ * @param cause The cause of the exception if exists otherwise null
+ * @property requestContextId The request context id used to track what happened during calls session by the backend
+ */
+sealed class ApiException(
+ val statusCode: Int,
+ errorMessage: String,
+ cause: Throwable?,
+ val requestContextId: String,
+) : Exception(errorMessage, cause) {
+
+ /**
+ * Thrown when an API call fails due to an error identified by a specific error code.
+ *
+ * This exception is used to represent errors returned by an API, with an associated error code
+ * and message describing the problem.
+ *
+ * @property errorCode The specific error code returned by the API.
+ * @property errorMessage The detailed error message explaining the cause of the failure.
+ * @param requestContextId The request context id send by the backend to track the call
+ */
+ open class ApiErrorException(
+ statusCode: Int,
+ val errorCode: String,
+ val errorMessage: String,
+ requestContextId: String,
+ ) : ApiException(
+ statusCode = statusCode,
+ errorMessage = errorMessage,
+ cause = null,
+ requestContextId = requestContextId,
+ )
+
+ /**
+ * Thrown when an API call returns an error in an unexpected format that cannot be parsed.
+ *
+ * This exception indicates that the API response format is different from what was expected,
+ * preventing proper parsing of the error details.
+ *
+ * @property statusCode The HTTP status code returned by the API.
+ * @property bodyResponse The raw response body from the API that could not be parsed.
+ * @param cause The cause of the exception if exists otherwise null
+ * @param requestContextId The request context id send by the backend to track the call
+ */
+ class UnexpectedApiErrorFormatException(
+ statusCode: Int,
+ val bodyResponse: String,
+ cause: Throwable?,
+ requestContextId: String,
+ ) : ApiException(
+ statusCode = statusCode,
+ errorMessage = bodyResponse,
+ cause = cause,
+ requestContextId = requestContextId,
+ )
+}
diff --git a/src/commonMain/kotlin/network/exceptions/NetworkException.kt b/src/commonMain/kotlin/network/exceptions/NetworkException.kt
new file mode 100644
index 0000000..8a6a09d
--- /dev/null
+++ b/src/commonMain/kotlin/network/exceptions/NetworkException.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.network.exceptions
+
+/**
+ * Thrown when a network-related error occurs, such as connectivity issues or timeouts.
+ *
+ * @param message A detailed message describing the network error.
+ */
+class NetworkException(message: String, cause: Throwable) : Exception(message, cause)
diff --git a/src/commonMain/kotlin/network/exceptions/UnknownException.kt b/src/commonMain/kotlin/network/exceptions/UnknownException.kt
new file mode 100644
index 0000000..8d4e81a
--- /dev/null
+++ b/src/commonMain/kotlin/network/exceptions/UnknownException.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.network.exceptions
+
+/**
+ * Represents an unknown exception that can occur during the execution of the application.
+ *
+ * This exception is used to encapsulate unexpected or unknown errors that are not covered
+ * by other specific exception types.
+ *
+ * @constructor Creates an instance of `UnknownException` with a detailed error message and an optional cause.
+ *
+ * @param cause The underlying exception that caused this exception.
+ *
+ * @property message The detailed message describing the error.
+ */
+class UnknownException(cause: Throwable) : Exception(cause) {
+ override val message: String = cause.message ?: cause.toString()
+}
diff --git a/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt b/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt
new file mode 100644
index 0000000..472cfe1
--- /dev/null
+++ b/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.network.interfaces
+
+import com.infomaniak.auth.lib.models.migration.SharedApiToken
+import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile
+
+interface AuthenticatorBridge {
+ suspend fun getTokenFromCrossAppLogin(userId: Long): SharedApiToken?
+ suspend fun getTokenFromDatabase(userId: Long): SharedApiToken?
+ suspend fun attemptPersistingTokenForAccount(userId: Long, token: SharedApiToken)
+ suspend fun persistUserProfile(userProfile: SharedUserProfile)
+}
diff --git a/src/commonMain/kotlin/network/interfaces/CrashReportInterface.kt b/src/commonMain/kotlin/network/interfaces/CrashReportInterface.kt
new file mode 100644
index 0000000..9245317
--- /dev/null
+++ b/src/commonMain/kotlin/network/interfaces/CrashReportInterface.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.network.interfaces
+
+enum class CrashReportLevel {
+ DEBUG,
+ INFO,
+ WARNING,
+ ERROR,
+ FATAL
+}
+
+/**
+ * Breadcrumb types for Sentry. Controls how the icon/color is displayed in the UI on Sentry's webpage.
+ *
+ * @param value The value used in the JSON payload.
+ * @see Sentry Documentation
+ */
+enum class BreadcrumbType(val value: String) {
+ Default("default"),
+ HTTP("http"),
+}
+
+interface CrashReportInterface {
+ /**
+ * Adds a breadcrumb to the crash reporting system to provide contextual information
+ * leading up to a potential crash.
+ *
+ * @param message A descriptive message for the breadcrumb, explaining the event or action.
+ * @param category A category string to group related breadcrumbs (e.g., "UI", "Network").
+ * @param level The severity level of the breadcrumb (e.g., info, warning, error).
+ * @param type Sentry internal attribute that controls how breadcrumbs are categorized.
+ * @param data Optional additional data providing more context about the event.
+ */
+ fun addBreadcrumb(
+ message: String,
+ category: String,
+ level: CrashReportLevel,
+ type: BreadcrumbType = BreadcrumbType.Default,
+ data: Map? = null,
+ )
+
+ /**
+ * Captures and reports an error to the crash reporting system with optional context
+ * and additional metadata.
+ *
+ * @param message A custom message to be reported (e.g., an error message or event description).
+ * @param error The [Throwable] to be reported.
+ * @param data Optional contextual data to provide more insight into the environment or state when the error occurred.
+ */
+ fun capture(
+ userId: Long,
+ message: String,
+ error: Throwable,
+ data: Map? = null,
+ )
+
+ /**
+ * Captures a custom message and reports it to the crash reporting system with optional context,
+ * severity level, and additional metadata.
+ *
+ * @param message The custom message to be reported (e.g., an error message or event description).
+ * @param data Optional contextual data that provides additional information about the environment
+ * or state when the message was logged.
+ * @param level The severity level of the message (e.g., `info`, `warning`, `error`).
+ */
+ fun capture(
+ userId: Long,
+ message: String,
+ data: Map? = null,
+ level: CrashReportLevel? = null
+ )
+}
diff --git a/src/commonMain/kotlin/repository/AppSettingsRepository.kt b/src/commonMain/kotlin/repository/AppSettingsRepository.kt
new file mode 100644
index 0000000..552691a
--- /dev/null
+++ b/src/commonMain/kotlin/repository/AppSettingsRepository.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.repository
+
+import com.infomaniak.auth.lib.room.appsettings.AppSettingsDatabase
+import com.infomaniak.auth.lib.room.appsettings.AppSettingsEntity
+import com.infomaniak.auth.lib.room.appsettings.Theme
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flatMapLatest
+
+class AppSettingsRepository(database: AppSettingsDatabase) {
+ private val dao = database.getDao()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun getSettings(): Flow = dao.getAsFlow().flatMapLatest { appSettings ->
+ if (appSettings == null) dao.save(AppSettingsEntity())
+ dao.getAsFlow()
+ }
+
+ suspend fun setIsAppLockEnabled(isAppLockEnabled: Boolean) {
+ dao.setIsAppLockEnabled(isAppLockEnabled)
+ }
+
+ suspend fun setTheme(theme: Theme) {
+ dao.setTheme(theme)
+ }
+}
diff --git a/src/commonMain/kotlin/room/appsettings/AppSettingsDao.kt b/src/commonMain/kotlin/room/appsettings/AppSettingsDao.kt
new file mode 100644
index 0000000..b9ebd37
--- /dev/null
+++ b/src/commonMain/kotlin/room/appsettings/AppSettingsDao.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.room.appsettings
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AppSettingsDao {
+
+ @Query("SELECT * FROM AppSettingsEntity WHERE id = 0")
+ fun getAsFlow(): Flow
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun save(item: AppSettingsEntity)
+
+ @Query("UPDATE AppSettingsEntity SET isAppLockEnabled = :isAppLockEnabled")
+ suspend fun setIsAppLockEnabled(isAppLockEnabled: Boolean)
+
+ @Query("UPDATE AppSettingsEntity SET theme = :theme")
+ suspend fun setTheme(theme: Theme)
+}
diff --git a/src/commonMain/kotlin/room/appsettings/AppSettingsDatabase.kt b/src/commonMain/kotlin/room/appsettings/AppSettingsDatabase.kt
new file mode 100644
index 0000000..51f3993
--- /dev/null
+++ b/src/commonMain/kotlin/room/appsettings/AppSettingsDatabase.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.room.appsettings
+
+import androidx.room.ConstructedBy
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.RoomDatabaseConstructor
+import androidx.room.TypeConverters
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+
+@Database(entities = [AppSettingsEntity::class], version = 1)
+@TypeConverters(ThemeConverter::class)
+@ConstructedBy(AppSettingsDatabaseConstructor::class)
+abstract class AppSettingsDatabase : RoomDatabase() {
+ abstract fun getDao(): AppSettingsDao
+}
+
+@Suppress("KotlinNoActualForExpect")
+expect object AppSettingsDatabaseConstructor : RoomDatabaseConstructor {
+ override fun initialize(): AppSettingsDatabase
+}
+
+fun getAppSettingsRoomDatabase(builder: RoomDatabase.Builder): AppSettingsDatabase {
+ return builder
+ .setDriver(BundledSQLiteDriver())
+ .setQueryCoroutineContext(Dispatchers.IO)
+ .build()
+}
diff --git a/src/commonMain/kotlin/room/appsettings/AppSettingsEntity.kt b/src/commonMain/kotlin/room/appsettings/AppSettingsEntity.kt
new file mode 100644
index 0000000..31e57f0
--- /dev/null
+++ b/src/commonMain/kotlin/room/appsettings/AppSettingsEntity.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.room.appsettings
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity
+data class AppSettingsEntity(
+ @PrimaryKey val id: Long = 0,
+ val isAppLockEnabled: Boolean = false,
+ val theme: Theme = Theme.System,
+)
diff --git a/src/commonMain/kotlin/room/appsettings/Theme.kt b/src/commonMain/kotlin/room/appsettings/Theme.kt
new file mode 100644
index 0000000..ebbf81d
--- /dev/null
+++ b/src/commonMain/kotlin/room/appsettings/Theme.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.room.appsettings
+
+import androidx.room.TypeConverter
+
+enum class Theme {
+ Light,
+ Dark,
+ System;
+}
+
+class ThemeConverter {
+
+ @TypeConverter
+ fun fromTheme(theme: Theme): String {
+ return theme.name
+ }
+
+ @TypeConverter
+ fun toTheme(value: String): Theme {
+ return Theme.valueOf(value)
+ }
+}
diff --git a/src/commonTest/kotlin/internal/SigningTestBase.kt b/src/commonTest/kotlin/internal/SigningTestBase.kt
new file mode 100644
index 0000000..57ef3fd
--- /dev/null
+++ b/src/commonTest/kotlin/internal/SigningTestBase.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
+
+import com.infomaniak.auth.lib.internal.utils.SignUtils
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+abstract class SigningTestBase {
+
+ @Test
+ fun `test and verify signature with test data`() {
+ getTestDataSet().forEach { dataSet ->
+ val isTestDataConsistent = SignUtils.verifySignature(
+ publicKey = dataSet.publicKey,
+ data = dataSet.dataToSign,
+ signatureData = dataSet.signature
+ )
+ assertTrue(isTestDataConsistent, "Verification of the signature of test data failed")
+ val result = SignUtils.verifySignature(
+ publicKey = dataSet.publicKey,
+ data = dataSet.dataToSign,
+ signatureData = SignUtils.signWithPrivateKey(dataSet.privateKey, dataSet.dataToSign)
+ )
+ assertTrue(result, "Failed to verify the signature of data signed on-the-fly")
+ }
+ }
+
+ @Test
+ fun `test and verify signature with on-the-fly generated key`() {
+ val (privateKey, publicKey) = getKeyPair()
+ val someData = "Hello Kotlin Multiplatform!".encodeToByteArray()
+ val signature = SignUtils.signWithPrivateKey(privateKey, someData)
+ val result = SignUtils.verifySignature(publicKey, someData, signature)
+ assertTrue(result)
+ }
+
+ /** Private key 1st, public key 2nd. */
+ protected abstract fun getKeyPair(): Pair
+
+ protected abstract fun getTestDataSet(): List
+
+ protected class TestData(
+ val privateKey: ByteArray,
+ val publicKey: ByteArray,
+ val dataToSign: ByteArray,
+ val signature: ByteArray,
+ )
+}
diff --git a/src/iosMain/kotlin/internal/utils/DeviceInfo.ios.kt b/src/iosMain/kotlin/internal/utils/DeviceInfo.ios.kt
new file mode 100644
index 0000000..bca8c9a
--- /dev/null
+++ b/src/iosMain/kotlin/internal/utils/DeviceInfo.ios.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
+
+import com.infomaniak.auth.lib.internal.webauthn.DeviceInfo
+import platform.UIKit.UIDevice
+
+internal actual fun getDeviceInfo(): DeviceInfo {
+ val device = UIDevice.currentDevice
+
+ return DeviceInfo(
+ brand = "Apple",
+ model = device.model,
+ platform = "ios",
+ )
+}
diff --git a/src/macosMain/kotlin/internal/utils/DeviceInfo.macos.kt b/src/macosMain/kotlin/internal/utils/DeviceInfo.macos.kt
new file mode 100644
index 0000000..0ea7f22
--- /dev/null
+++ b/src/macosMain/kotlin/internal/utils/DeviceInfo.macos.kt
@@ -0,0 +1,57 @@
+/*
+ * 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(ExperimentalForeignApi::class)
+
+package com.infomaniak.auth.lib.internal.utils
+
+import com.infomaniak.auth.lib.internal.webauthn.DeviceInfo
+import kotlinx.cinterop.ByteVar
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.allocArray
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.toKString
+import kotlinx.cinterop.value
+import platform.darwin.sysctlbyname
+import platform.posix.size_tVar
+
+internal actual fun getDeviceInfo(): DeviceInfo {
+ return DeviceInfo(
+ brand = "Apple",
+ model = getHardwareModel(),
+ platform = "macos",
+ )
+}
+
+private fun getHardwareModel(): String = memScoped {
+ val sizePtr = alloc()
+ sysctlbyname("hw.model", null, sizePtr.ptr, null, 0uL)
+ val size = sizePtr.value
+
+ if (size > 0uL) {
+ val buffer = allocArray(size.toInt())
+
+ when (sysctlbyname("hw.model", buffer, sizePtr.ptr, null, 0uL)) {
+ 0 -> buffer.toKString()
+ else -> "Unknown Mac"
+ }
+ } else {
+ "Unknown Mac"
+ }
+}