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" + } +}