Аутентификация через биометрию в Kotlin Multiplatform или Koncierge

от автора

Вступление

Наконец-то неплохо продвинувшись в написании своего мультиплатформенного приложения Modern Money мне понадобилась аутентификация для Kotlin Multiplatform.

Хотелось иметь один API для PIN-кода и биометрии, а под капотом использовать Android Biometric Prompt, Windows Hello, Touch ID.

Так появился Koncierge — набор KMP-модулей biometric, auth и auth-compose

Казалось бы, задача довольно стандартная: объявить expect-класс и написать несколько actual-реализаций.На Android это примерно так и получилось. А вот Windows Hello, JVM-модуль и публикация всех артефактов заставили немного разобраться в том, как Kotlin Multiplatform работает с нативным кодом.

Что хотелось получить

На уровне общего кода API должен был выглядеть максимально просто:

expect class Koncierge {    fun isBiometricAvailable(): Boolean    suspend fun authenticate(        message: String,        timeout: Duration = Duration.INFINITE,    ): BiometricResults}

Дальше каждая платформа сама решает, каким способом показать запрос на аутентификацию.

when (val result = Koncierge().authenticate("Подтвердите личность")) {    BiometricResults.AuthenticationSuccessful -> {        // Можно открывать защищенный экран.    }    BiometricResults.AuthenticationCancelled -> {        // Пользователь отменил запрос.    }    else -> {        // Нет оборудования, не настроена биометрия или произошла ошибка.    }}

Самым интересным местом оказался Windows.

Windows Hello

Сначала я пытался найти простой способ вызвать Windows Hello прямо из Kotlin/Native. Но API Windows построен вокруг WinRT, COM-интерфейсов, фабрик активации и асинхронных операций. Даже простой вызов проверки доступности быстро перестает быть простым.

CMake, потом Gradle

Первую рабочую версию C-моста я собирал через CMake. Это было логично: отдельный CMakeLists.txtadd_library, список системных библиотек — и через несколько команд получался libwindows_hello.a.

add_library(windows_hello STATIC    src/windows_hello.c)target_include_directories(windows_hello PUBLIC include)target_link_libraries(windows_hello PRIVATE    runtimeobject    user32)

На этом этапе все работало, но в проекте появились две системы сборки. Kotlin/Native ожидал статическую библиотеку в определенной директории, а CMake отдельно должен был сконфигурировать проект, выбрать генератор и собрать нужную конфигурацию.

Для локального эксперимента это терпимо. Для Gradle-задач, CI и публикации библиотека уже начинала жить своей жизнью. Поэтому я решил отказаться от отдельного CMake-шага и перенести сборку C-кода в Gradle.

На Windows Kotlin/Native все равно устанавливает MinGW в свой toolchain. Оставалось найти его gcc и зарегистрировать обычные Exec-задачи:

val compileWindowsHelloNative = tasks.register<Exec>("compileWindowsHelloNative") {    inputs.dir(windowsHelloNativeDir)    outputs.file(windowsHelloNativeObjectFile)    doFirst {        commandLine(            gcc.absolutePath,            "-std=c11",            "-DWIN32_LEAN_AND_MEAN",            "-DNOMINMAX",            "-DWINDOWS_HELLO_STATIC",            "-I${windowsHelloNativeDir.dir("include").asFile}",            "-c",            windowsHelloNativeDir.file("src/windows_hello.c").asFile,            "-o",            windowsHelloNativeObjectFile.get().asFile,        )    }}

Следующая задача превращает объектный файл в статическую библиотеку:

val buildWindowsHelloNative = tasks.register<Exec>("buildWindowsHelloNative") {    dependsOn(compileWindowsHelloNative)    inputs.file(windowsHelloNativeObjectFile)    outputs.file(windowsHelloNativeArchiveFile)    doFirst {        commandLine(            ar.absolutePath,            "rcs",            windowsHelloNativeArchiveFile.get().asFile,            windowsHelloNativeObjectFile.get().asFile,        )    }}

Здесь важны не сами вызовы gcc и ar, а связи между задачами. cinteropWindowsHelloMingwX64 и линковка Windows-бинарников зависят от buildWindowsHelloNative. Gradle понимает, какой файл является входом, какой — результатом, и может собрать все в правильном порядке.

В результате CMake остался удобным воспоминанием о первой версии, а проект перешел на чистый Gradle. Точнее, компилятор по-прежнему нативный, но точка входа для сборки теперь одна — Gradle.

Небольшой C-мост

В итоге я написал небольшую обертку на C. Она скрывает детали Windows API и оставляет Kotlin всего две функции:

typedef struct WindowsHelloResult {    int32_t code;    int32_t hresult;} WindowsHelloResult;WINDOWS_HELLO_API int32_t windows_hello_check_availability(    WindowsHelloResult* out_result);WINDOWS_HELLO_API int32_t windows_hello_request_verification(    uint64_t owner_window_handle,    const wchar_t* prompt,    WindowsHelloResult* out_result);

За этой границей происходит вся работа с WinRT:

  • через RoGetActivationFactory получается фабрика UserConsentVerifier;

  • вызывается CheckAvailabilityAsync или RequestVerificationForWindowAsync;

  • асинхронная операция опрашивается до завершения;

  • код результата и HRESULT записываются в простую C-структуру.

Особенно пригодился именно C API. Kotlin/Native умеет достаточно хорошо работать с C-заголовками через cinterop, а C++-ABI и WinRT-заголовки добавиляли еще один слой, и требовали хотя бы CMake. На стороне Kotlin/Native вызов теперь выглядит почти как обычная функция:

fun requestVerification(    prompt: String,    ownerWindowHandle: Long = 0,): WindowsHelloVerification = memScoped {    val out = alloc<WindowsHelloResult>()    windows_hello_request_verification(        ownerWindowHandle.toULong(),        prompt.wcstr.ptr,        out.ptr,    )    WindowsHelloVerification(        result = verificationFromCode(out.code),        hresult = out.hresult,    )}

JVM или почему мы не можем просто использовать Kotlin/Native-библиотеку

Почти сразу после Windows-реализации появился еще один вопрос: а что делать с JVM?

Kotlin/Native использует C-заголовок через cinterop и линкует статическую библиотеку:

headers = windows_hello.hpackage = windowshellocompilerOpts = -DWINDOWS_HELLO_STATIC -Isrc/nativeInterop/windows-hello/includestaticLibraries = libwindows_hello.alibraryPaths = build/windows-hello-native/bin/Release build/windows-hello-native/binlinkerOpts = -lruntimeobject -luser32

Но JVM не умеет использовать сгенерированный пакет windowshello и статический .a-архив. Для нее нужен другой путь: динамическая библиотека и механизм загрузки. Поэтому один и тот же C-файл собирается в два разных результата:

Потребитель

Результат

Как подключается

Kotlin/Native mingwX64

libwindows_hello.a

cinterop и линковка native-бинарника

JVM

windows_hello.dll

JNA и загрузка из JVM-ресурса

На JVM я использовал JNA. Сначала объявил интерфейс библиотеки и структуру результата:

private interface WindowsHelloLibrary : Library {    fun windows_hello_check_availability(        outResult: WindowsHelloResult,    ): Int    fun windows_hello_request_verification(        ownerWindowHandle: Long,        prompt: WString,        outResult: WindowsHelloResult,    ): Int}

JVM-библиотеку я не стал добавлять в исходники вручную. Gradle собирает DLL во временную build-директорию, а затем задача syncWindowsHelloJvmNativeResource кладет ее в ресурс native/windows/x86-64/windows_hello.dll

Во время запуска библиотека извлекается из JAR во временный файл и загружается через JNA:

val resource = javaClass.getResourceAsStream(resourcePath)val libraryFile = Files.createTempFile("koncierge-windows-hello", ".dll")resource.use { input ->    libraryFile.outputStream().use { output ->        input.copyTo(output)    }}Native.load(libraryFile.absolutePathString(), WindowsHelloLibrary::class.java)

Прямо загрузить DLL изнутри JAR нельзя, поэтому промежуточный файл здесь необходим.

Именно на этом месте пришлось явно связать JVM-модуль с native-целями и задачами сборки. Это не обычная зависимость вида implementation(project(...)). JVM-части нужна нативная DLL, а mingwX64-части — статическая библиотека. Значит, Gradle-граф должен знать про обе ветки:

jvmProcessResources    └── syncWindowsHelloJvmNativeResource            └── buildWindowsHelloJvmNative                    └── compileWindowsHelloJvmNativecinteropWindowsHelloMingwX64    └── buildWindowsHelloNative            └── compileWindowsHelloNative

На Windows это связывание получается естественно. На macOS пришлось сделать еще один шаг: при подготовке публикации включать mingwX64 в метаданные, даже если сам Windows-бинарник на этом агенте не собирается. Иначе macOS-агент просто не знает, что в итоговой KMP-публикации должен присутствовать Windows target.

Публикация: почему одного build-агента оказалось недостаточно

Локально можно собрать нужный target на подходящей машине. В CI ситуация менее романтичная: разные native targets требуют разные host-системы.

В моем случае Windows-агент должен собрать:

  • mingwX64-публикацию;

  • Windows Hello DLL для JVM.

А macOS-агент собирает:

  • macOS ARM64-библиотеку для Touch ID;

  • JVM- и Android-публикации;

  • общие KMP-метаданные;

  • финальный набор для отправки в Maven Central.

Сначала я пытался представить, что это можно выполнить одним универсальным Gradle-вызовом на любом агенте. На практике невозможно создать все libraries на одном build-агенте: Windows-артефакты требуют Windows, а macOS-артефакты — macOS.

Поэтому workflow разделен на две основные части.

Windows job

На Windows выполняются задачи:

:biometric:publishMingwX64PublicationToWindowsStagingRepository:biometric:syncWindowsHelloJvmNativeResource

После этого workflow сохраняет два артефакта:

  • подписанную mingwX64-публикацию;

  • windows_hello.dll для JVM.

Важный момент: Windows job не пытается публиковать только часть библиотеки прямо в Maven Central. Она складывает результат в staging-репозиторий и передает его дальше.

macOS job

macOS-агент скачивает оба Windows-артефакта в заранее известные директории:

biometric/build/windows-maven-repositorybiometric/build/generated/windows-hello-jvm/resources/native/windows/x86-64

Затем собирает остальные targets и объединяет Windows-публикацию с общим набором:

    tasks.register<Sync>("assembleMavenCentralStagingRepository") {        group = "publishing"        description = "Combines the biometric publications built on macOS and Windows for Maven Central."        dependsOn(            "prepareMavenCentralPublishing",            "publishAndroidPublicationToMacosStagingRepository",            "publishJvmPublicationToMacosStagingRepository",            "publishKotlinMultiplatformPublicationToMacosStagingRepository",            "publishMacosArm64PublicationToMacosStagingRepository",        )        from(macosStaging)        from(layout.buildDirectory.dir("windows-maven-repository"))        into(mavenCentralStaging)        doLast {            val version = project.version.toString()            val groupPath = project.group.toString().replace('.', '/')            val repositoryRoot = mavenCentralStaging.get().asFile            val expectedArtifactIds = listOf(                "biometric",                "biometric-android",                "biometric-jvm",                "biometric-macosarm64",                "biometric-mingwx64",            )            expectedArtifactIds.forEach { artifactId ->                val pom = repositoryRoot.resolve("$groupPath/$artifactId/$version/$artifactId-$version.pom")                if (!pom.isFile) {                    throw GradleException("Maven Central staging is missing $artifactId:$version (${pom.absolutePath}).")                }            }        }    }

В итоге единый Maven Central bundle создается на macOS, но содержит и уже собранный на Windows mingwX64-артефакт.

Что получилось в итоге

Пользователь библиотеки видит один API:

val koncierge = Koncierge()if (koncierge.isBiometricAvailable()) {    val result = koncierge.authenticate("Подтвердите вход")}

А внутри произошло довольно много платформенной работы:

  • Windows Hello спрятан за C-мостом;

  • Kotlin/Native использует cinterop и статическую библиотеку;

  • JVM использует JNA и DLL, упакованную в ресурс;

  • Gradle управляет и C-сборкой, и зависимостями задач;

  • Windows и macOS собирают разные части публикации;

  • Maven Central получает единый набор подписанных артефактов.

Получилось, как мне кажется, создать довольно уникальную библиотеку, которая здорово облегчит добавление аутентификации через biometric в KMP решения.

Пришлось разобраться с интеграцией нативного кода в Kotlin, углубить понимание Gradle сборок, вспомнить первый курс университета и C, наконец!

Что дальше?

Сейчас хочется улучшить несколько вещей: добавить полноценные тесты для нативных реализаций, аккуратнее обрабатывать все коды Windows Hello и проверить публикацию на большем количестве архитектур.

Возможно, со временем получится отказаться и от части ручного кода вокруг WinRT. Но пока C-мост оказался вполне разумной границей: Windows API остается на своей стороне, а Kotlin получает небольшой и предсказуемый интерфейс.

Так же в процессе работы смог подключить WinBio, но пока этот код очень сырой.

Спасибо, что дочитали! Буду благодарен за любые идеи по развитию библиотеки или contributing’а.

ссылка на оригинал статьи https://habr.com/ru/articles/1059174/