diff --git a/.gitignore b/.gitignore
index e33e1725f0..167ee9ebcf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -168,3 +168,8 @@ app/outputs
# K2 generated directory
.kotlin/
+.envrc
+
+# Rust build artifacts
+rust/target/
+Cargo.lock
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0e196da7c3..0072aa35c5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -43,6 +43,7 @@ dependencies {
implementation(projects.format.common)
implementation(projects.passgen.diceware)
implementation(projects.passgen.random)
+ implementation(projects.passkeys.provider)
implementation(projects.ui.compose)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
@@ -51,6 +52,8 @@ dependencies {
implementation(libs.androidx.biometricKtx)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.play.services)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.fragment.ktx)
implementation(libs.bundles.androidxLifecycle)
@@ -67,6 +70,7 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
implementation(libs.aps.sublimeFuzzy)
implementation(libs.aps.zxingAndroidEmbedded)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f74dd71d2..c3d606dce1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -124,6 +124,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt
new file mode 100644
index 0000000000..13c0abd4c9
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.injection.passkeys
+
+import android.content.Context
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.crypto.PGPainlessCryptoHandler
+import app.passwordstore.passkeys.BiometricPasskeyAuthenticator
+import app.passwordstore.passkeys.crypto.ES256CryptoHandler
+import app.passwordstore.passkeys.crypto.PasskeyCryptoHandler
+import app.passwordstore.passkeys.provider.PasskeyAuthenticator
+import app.passwordstore.passkeys.storage.FilePasskeyStorage
+import app.passwordstore.passkeys.storage.IndexedPasskeyStorage
+import app.passwordstore.passkeys.storage.PasskeyStorage
+import app.passwordstore.passkeys.storage.PasskeyStorageConfig
+import com.github.michaelbull.result.get
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import java.io.File
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object PasskeysModule {
+
+ @Provides
+ @Singleton
+ fun providePasskeyCryptoHandler(): PasskeyCryptoHandler = ES256CryptoHandler()
+
+ @Provides
+ @Singleton
+ fun providePasskeyAuthenticator(): PasskeyAuthenticator = BiometricPasskeyAuthenticator()
+
+ @Provides
+ @Singleton
+ fun providePasskeyStorage(
+ @ApplicationContext context: Context,
+ cryptoHandler: PGPainlessCryptoHandler,
+ keyManager: PGPKeyManager,
+ ): PasskeyStorage {
+ val repositoryRoot = File(context.filesDir, "store")
+ val passkeyConfig = PasskeyStorageConfig(passkeyDirectory = "fido2", fileExtension = ".gpg")
+ val fileStorage =
+ FilePasskeyStorage(
+ repositoryRoot = repositoryRoot,
+ cryptoHandler = cryptoHandler,
+ decryptionKeys = { keyManager.getAllKeys().get() ?: emptyList() },
+ decryptionPassphrase = { null },
+ encryptionKeys = { keyManager.getAllKeys().get() ?: emptyList() },
+ decryptionOptions = app.passwordstore.crypto.PGPDecryptOptions.Builder().build(),
+ encryptionOptions = app.passwordstore.crypto.PGPEncryptOptions.Builder().build(),
+ config = passkeyConfig,
+ )
+ return IndexedPasskeyStorage(fileStorage)
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt
new file mode 100644
index 0000000000..98c4aedede
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys
+
+import androidx.annotation.RequiresApi
+import app.passwordstore.passkeys.crypto.PasskeyCryptoHandler
+import app.passwordstore.passkeys.provider.PasskeyCredentialProviderService
+import app.passwordstore.passkeys.storage.PasskeyStorage
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+@RequiresApi(34)
+class AppPasskeyCredentialProviderService : PasskeyCredentialProviderService() {
+
+ private val entryPoint: PasskeysEntryPoint
+ get() = EntryPointAccessors.fromApplication(applicationContext)
+
+ override val passkeyStorage: PasskeyStorage
+ get() = entryPoint.passkeyStorage()
+
+ override val cryptoHandler: PasskeyCryptoHandler
+ get() = entryPoint.passkeyCryptoHandler()
+
+ override val providerActivity: Class
+ get() = AppPasskeyProviderActivity::class.java
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface PasskeysEntryPoint {
+
+ fun passkeyStorage(): PasskeyStorage
+
+ fun passkeyCryptoHandler(): PasskeyCryptoHandler
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt
new file mode 100644
index 0000000000..cd2c71293d
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt
@@ -0,0 +1,276 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import androidx.annotation.RequiresApi
+import androidx.credentials.CreatePublicKeyCredentialRequest
+import androidx.credentials.CreatePublicKeyCredentialResponse
+import androidx.credentials.GetCredentialResponse
+import androidx.credentials.GetPublicKeyCredentialOption
+import androidx.credentials.PublicKeyCredential
+import androidx.credentials.exceptions.CreateCredentialUnknownException
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialUnknownException
+import androidx.credentials.provider.PendingIntentHandler
+import app.passwordstore.passkeys.crypto.PasskeyCryptoHandler
+import app.passwordstore.passkeys.provider.PasskeyAuthenticator
+import app.passwordstore.passkeys.provider.PasskeyCredentialProviderService
+import app.passwordstore.passkeys.provider.PasskeyProviderUtils
+import app.passwordstore.passkeys.storage.PasskeyStorage
+import app.passwordstore.ui.git.base.BaseGitActivity
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.fold
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import logcat.LogPriority
+import logcat.logcat
+
+class AppPasskeyProviderActivity : BaseGitActivity() {
+
+ @Inject lateinit var passkeyStorage: PasskeyStorage
+ @Inject lateinit var cryptoHandler: PasskeyCryptoHandler
+ @Inject lateinit var authenticator: PasskeyAuthenticator
+
+ private fun maybeSyncToGit() {
+ if (!sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true)) return
+ if (gitSettings.url == null) return
+ CoroutineScope(dispatcherProvider.io()).launch {
+ launchGitOperation(GitOp.SYNC)
+ .fold(
+ success = { logcat { "Passkey auto-sync completed" } },
+ failure = { logcat(LogPriority.WARN) { "Passkey auto-sync failed: $it" } },
+ )
+ }
+ }
+
+ @RequiresApi(34)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ CoroutineScope(dispatcherProvider.mainImmediate()).launch { handleProviderRequest() }
+ }
+
+ @RequiresApi(34)
+ private suspend fun handleProviderRequest() {
+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.let {
+ handleGetCredential(it)
+ return
+ }
+
+ PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let {
+ handleCreateCredential(it)
+ return
+ }
+
+ finishWithGetError(GetCredentialUnknownException("Missing provider request"))
+ }
+
+ @RequiresApi(34)
+ private suspend fun handleGetCredential(
+ request: androidx.credentials.provider.ProviderGetCredentialRequest
+ ) {
+ @Suppress("InlinedApi")
+ val selectedCredentialId =
+ intent.getStringExtra(PasskeyCredentialProviderService.EXTRA_CREDENTIAL_ID)
+ if (selectedCredentialId == null) {
+ finishWithGetError(GetCredentialCancellationException("No passkey was selected"))
+ return
+ }
+
+ val option =
+ request.credentialOptions.filterIsInstance().firstOrNull()
+ if (option == null) {
+ finishWithGetError(GetCredentialUnknownException("Missing passkey get option"))
+ return
+ }
+
+ val parsedRequest =
+ PasskeyProviderUtils.json.decodeFromString<
+ app.passwordstore.passkeys.provider.WebAuthnGetRequest
+ >(
+ option.requestJson
+ )
+
+ val credentialId = PasskeyProviderUtils.decodeBase64Url(selectedCredentialId)
+ val credential =
+ passkeyStorage
+ .getCredential(credentialId)
+ .fold(
+ success = { it },
+ failure = {
+ logcat(LogPriority.ERROR) { "Failed reading stored passkey: $it" }
+ null
+ },
+ )
+ if (credential == null) {
+ finishWithGetError(GetCredentialUnknownException("Selected passkey is unavailable"))
+ return
+ }
+
+ if (authenticator.canAuthenticate(this)) {
+ when (val authResult = authenticator.authenticateForPasskey(this, credential.rpId)) {
+ is PasskeyAuthenticator.Result.Success -> {}
+ is PasskeyAuthenticator.Result.Canceled -> {
+ finishWithGetError(GetCredentialCancellationException("Authentication canceled"))
+ return
+ }
+ is PasskeyAuthenticator.Result.NotAvailable -> {
+ logcat(LogPriority.WARN) { "Biometric auth not available, proceeding without it" }
+ }
+ is PasskeyAuthenticator.Result.Failure -> {
+ finishWithGetError(
+ GetCredentialUnknownException("Authentication failed: ${authResult.message}")
+ )
+ return
+ }
+ }
+ }
+
+ val constantSignatureCounter =
+ sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_CONSTANT_SIGNATURE_COUNTER, true)
+ val newSignCount = if (constantSignatureCounter) 0u else credential.signCount + 1u
+ passkeyStorage
+ .updateSignCount(credential.credentialId, newSignCount)
+ .fold(
+ success = {},
+ failure = { logcat(LogPriority.WARN) { "Failed to update sign count: $it" } },
+ )
+
+ val requestJson = option.requestJson
+ val assertion =
+ cryptoHandler
+ .getAssertion(
+ credential = credential.copy(signCount = newSignCount),
+ rpId = credential.rpId,
+ challenge = PasskeyProviderUtils.decodeBase64Url(parsedRequest.challenge),
+ origin = parsedRequest.origin ?: "https://${credential.rpId}",
+ )
+ .fold(
+ success = { it },
+ failure = {
+ logcat(LogPriority.ERROR) { "Failed building assertion: $it" }
+ null
+ },
+ )
+ if (assertion == null) {
+ finishWithGetError(GetCredentialUnknownException("Failed generating passkey assertion"))
+ return
+ }
+
+ val responseJson =
+ PasskeyProviderUtils.buildAssertionResponse(assertion, credential, requestJson)
+ val resultIntent = Intent()
+ PendingIntentHandler.setGetCredentialResponse(
+ resultIntent,
+ GetCredentialResponse(PublicKeyCredential(responseJson)),
+ )
+ setResult(Activity.RESULT_OK, resultIntent)
+ maybeSyncToGit()
+ finish()
+ }
+
+ @RequiresApi(34)
+ private suspend fun handleCreateCredential(
+ request: androidx.credentials.provider.ProviderCreateCredentialRequest
+ ) {
+ val createRequest = request.callingRequest as? CreatePublicKeyCredentialRequest
+ if (createRequest == null) {
+ finishWithCreateError(CreateCredentialUnknownException("Missing passkey create request"))
+ return
+ }
+
+ val parsedRequest =
+ PasskeyProviderUtils.json.decodeFromString<
+ app.passwordstore.passkeys.provider.WebAuthnCreateRequest
+ >(
+ createRequest.requestJson
+ )
+
+ if (authenticator.canAuthenticate(this)) {
+ when (val authResult = authenticator.authenticateForCreation(this, parsedRequest.rp.id)) {
+ is PasskeyAuthenticator.Result.Success -> {}
+ is PasskeyAuthenticator.Result.Canceled -> {
+ finishWithCreateError(CreateCredentialUnknownException("Authentication canceled"))
+ return
+ }
+ is PasskeyAuthenticator.Result.NotAvailable -> {
+ logcat(LogPriority.WARN) { "Biometric auth not available, proceeding without it" }
+ }
+ is PasskeyAuthenticator.Result.Failure -> {
+ finishWithCreateError(
+ CreateCredentialUnknownException("Authentication failed: ${authResult.message}")
+ )
+ return
+ }
+ }
+ }
+
+ val createdCredential =
+ cryptoHandler
+ .createCredential(
+ rpId = parsedRequest.rp.id,
+ userId = PasskeyProviderUtils.decodeBase64Url(parsedRequest.user.id),
+ userName = parsedRequest.user.name ?: "",
+ userDisplayName = parsedRequest.user.displayName ?: parsedRequest.user.name ?: "",
+ challenge = PasskeyProviderUtils.decodeBase64Url(parsedRequest.challenge),
+ )
+ .fold(
+ success = { it },
+ failure = {
+ logcat(LogPriority.ERROR) { "Failed creating passkey: $it" }
+ null
+ },
+ )
+ if (createdCredential == null) {
+ finishWithCreateError(CreateCredentialUnknownException("Failed creating passkey"))
+ return
+ }
+
+ val saveResult = passkeyStorage.saveCredential(createdCredential)
+ if (saveResult.isErr) {
+ saveResult.fold(
+ success = {},
+ failure = { logcat(LogPriority.ERROR) { "Failed storing passkey: $it" } },
+ )
+ finishWithCreateError(CreateCredentialUnknownException("Failed storing passkey"))
+ return
+ }
+
+ val responseJson =
+ PasskeyProviderUtils.buildAttestationResponse(createdCredential, createRequest.requestJson)
+ val resultIntent = Intent()
+ PendingIntentHandler.setCreateCredentialResponse(
+ resultIntent,
+ CreatePublicKeyCredentialResponse(responseJson),
+ )
+ setResult(Activity.RESULT_OK, resultIntent)
+ maybeSyncToGit()
+ finish()
+ }
+
+ private fun finishWithGetError(
+ exception: androidx.credentials.exceptions.GetCredentialException
+ ) {
+ val resultIntent = Intent()
+ PendingIntentHandler.setGetCredentialException(resultIntent, exception)
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ }
+
+ private fun finishWithCreateError(
+ exception: androidx.credentials.exceptions.CreateCredentialException
+ ) {
+ val resultIntent = Intent()
+ PendingIntentHandler.setCreateCredentialException(resultIntent, exception)
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/passkeys/BiometricPasskeyAuthenticator.kt b/app/src/main/java/app/passwordstore/passkeys/BiometricPasskeyAuthenticator.kt
new file mode 100644
index 0000000000..6649625e87
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/passkeys/BiometricPasskeyAuthenticator.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys
+
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.R
+import app.passwordstore.passkeys.provider.PasskeyAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+@Singleton
+class BiometricPasskeyAuthenticator @Inject constructor() : PasskeyAuthenticator {
+
+ override suspend fun authenticateForPasskey(
+ activity: FragmentActivity,
+ rpId: String,
+ ): PasskeyAuthenticator.Result = suspendCancellableCoroutine { continuation ->
+ BiometricAuthenticator.authenticate(
+ activity = activity,
+ dialogTitleRes = R.string.passkey_auth_title,
+ dialogDescriptionRes = R.string.passkey_auth_description,
+ allowPin = true,
+ ) { result ->
+ if (continuation.isActive) {
+ continuation.resume(convertResult(result))
+ }
+ }
+ }
+
+ override suspend fun authenticateForCreation(
+ activity: FragmentActivity,
+ rpId: String,
+ ): PasskeyAuthenticator.Result = suspendCancellableCoroutine { continuation ->
+ BiometricAuthenticator.authenticate(
+ activity = activity,
+ dialogTitleRes = R.string.passkey_create_auth_title,
+ dialogDescriptionRes = R.string.passkey_auth_description,
+ allowPin = true,
+ ) { result ->
+ if (continuation.isActive) {
+ continuation.resume(convertResult(result))
+ }
+ }
+ }
+
+ override fun canAuthenticate(activity: FragmentActivity): Boolean {
+ return BiometricAuthenticator.canAuthenticate(activity, allowPin = true)
+ }
+
+ private fun convertResult(result: BiometricAuthenticator.Result): PasskeyAuthenticator.Result {
+ return when (result) {
+ is BiometricAuthenticator.Result.Success -> PasskeyAuthenticator.Result.Success
+ is BiometricAuthenticator.Result.Failure ->
+ PasskeyAuthenticator.Result.Failure(result.message.toString())
+ is BiometricAuthenticator.Result.Retry ->
+ PasskeyAuthenticator.Result.Failure("Authentication retry required")
+ is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled ->
+ PasskeyAuthenticator.Result.NotAvailable
+ is BiometricAuthenticator.Result.CanceledByUser -> PasskeyAuthenticator.Result.Canceled
+ is BiometricAuthenticator.Result.CanceledBySystem -> PasskeyAuthenticator.Result.Canceled
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasskeySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasskeySettings.kt
new file mode 100644
index 0000000000..61bb236dd9
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/PasskeySettings.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.R
+import app.passwordstore.util.settings.PreferenceKeys
+import de.Maxr1998.modernpreferences.PreferenceScreen
+import de.Maxr1998.modernpreferences.helpers.switch
+
+class PasskeySettings(private val activity: FragmentActivity) : SettingsProvider {
+
+ override fun provideSettings(builder: PreferenceScreen.Builder) {
+ builder.apply {
+ switch(PreferenceKeys.PASSKEY_CONSTANT_SIGNATURE_COUNTER) {
+ defaultValue = true
+ titleRes = R.string.pref_passkey_constant_signature_counter_title
+ summaryRes = R.string.pref_passkey_constant_signature_counter_summary
+ }
+ switch(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC) {
+ defaultValue = true
+ titleRes = R.string.pref_passkey_auto_git_sync_title
+ summaryRes = R.string.pref_passkey_auto_git_sync_summary
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
index 5b74b7a793..ab2a79ac2d 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
@@ -30,6 +30,7 @@ class SettingsActivity : AppCompatActivity() {
val repositorySettings = RepositorySettings(this)
private val generalSettings = GeneralSettings(this)
private val pgpSettings = PGPSettings(this)
+ private val passkeySettings = PasskeySettings(this)
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
private val preferencesAdapter: PreferencesAdapter
@@ -54,6 +55,12 @@ class SettingsActivity : AppCompatActivity() {
iconRes = R.drawable.ic_wysiwyg_24px
autofillSettings.provideSettings(this)
}
+ subScreen {
+ collapseIcon = true
+ titleRes = R.string.pref_category_passkey_title
+ iconRes = R.drawable.ic_passkey_24px
+ passkeySettings.provideSettings(this)
+ }
subScreen {
collapseIcon = true
titleRes = R.string.pref_category_passwords_title
diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
index 12e7ba24db..a654e8b701 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
@@ -112,4 +112,7 @@ object PreferenceKeys {
const val CLEAR_PASSPHRASE_CACHE = "pgpainless_auto_clear_passphrase_cache_screen_off"
const val CACHE_PASSPHRASE = "cache_passphrase_until_screen_off"
+
+ const val PASSKEY_CONSTANT_SIGNATURE_COUNTER = "passkey_constant_signature_counter"
+ const val PASSKEY_AUTO_GIT_SYNC = "passkey_auto_git_sync"
}
diff --git a/app/src/main/res/drawable/ic_passkey_24px.xml b/app/src/main/res/drawable/ic_passkey_24px.xml
new file mode 100644
index 0000000000..ebb25acc79
--- /dev/null
+++ b/app/src/main/res/drawable/ic_passkey_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values-v34/bools.xml b/app/src/main/res/values-v34/bools.xml
new file mode 100644
index 0000000000..76f86aca59
--- /dev/null
+++ b/app/src/main/res/values-v34/bools.xml
@@ -0,0 +1,8 @@
+
+
+
+ true
+
diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml
index 229e99a86a..87fcc423c5 100644
--- a/app/src/main/res/values/bools.xml
+++ b/app/src/main/res/values/bools.xml
@@ -7,4 +7,5 @@
true
false
true
+ false
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4a952d82bc..e54ddfb824 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -133,6 +133,11 @@
Autofill
Enable Autofill
Misc
+ Passkeys
+ Constant signature counter
+ Keep signCount at 0 to help detect cloned authenticators (passless compatible).
+ Auto Git sync
+ Automatically sync passkeys with Git remote when repository is configured.
Scramble clipboard history
Flood clipboard history (Samsung devices, Gboard) with random numbers, wiping out any passwords.
Deletes local (hidden) repository.
@@ -261,6 +266,9 @@
Unable to locate HEAD
Abort and push
Authenticate to continue
+ Use passkey
+ Create passkey
+ Authenticate to use this passkey
Authentication failure: %s
Enable authentication on app start
Password Store asks you to verify your fingerprint or the device PIN upon startup.
diff --git a/app/src/main/res/xml/passkey_provider.xml b/app/src/main/res/xml/passkey_provider.xml
new file mode 100644
index 0000000000..fe82d16802
--- /dev/null
+++ b/app/src/main/res/xml/passkey_provider.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt
index 9917a16867..851a4b62c8 100644
--- a/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt
@@ -39,6 +39,8 @@ object LintConfig {
disable += "TrulyRandom"
// I can't do anything about this
disable += "ObsoleteLintCustomCheck"
+ // Third-party libraries may reference packages not in Android
+ disable += "InvalidPackage"
if (!isJVM) {
// Enable compose-lint-checks' Material 2 detector
enable += "ComposeM2Api"
@@ -49,6 +51,8 @@ object LintConfig {
disable += "FragmentFieldInjection"
// Too pedantic
disable += "ArgInFormattedQuantityStringRes"
+ // Third-party dispatchers check not applicable
+ disable += "RawDispatchersUse"
}
baseline = project.file("lint-baseline.xml")
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1532fd18ca..33217419ac 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,6 +22,8 @@ androidx-autofill = "androidx.autofill:autofill:1.3.0"
androidx-biometricKtx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.1"
androidx-core-ktx = "androidx.core:core-ktx:1.18.0"
+androidx-credentials = "androidx.credentials:credentials:1.5.0"
+androidx-credentials-play-services = "androidx.credentials:credentials-play-services-auth:1.5.0"
androidx-documentfile = "androidx.documentfile:documentfile:1.1.0"
androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.8.9"
androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" }
@@ -62,6 +64,8 @@ kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immut
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.6.2"
+kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1"
testing-junit = "junit:junit:4.13.2"
testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
testing-robolectric = "org.robolectric:robolectric:4.16.1"
@@ -103,4 +107,5 @@ testDependencies = [
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/passkeys/core/build.gradle.kts b/passkeys/core/build.gradle.kts
new file mode 100644
index 0000000000..7f64522ca8
--- /dev/null
+++ b/passkeys/core/build.gradle.kts
@@ -0,0 +1,19 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+plugins {
+ id("com.github.android-password-store.kotlin-jvm-library")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+dependencies {
+ api(projects.crypto.common)
+ implementation(libs.thirdparty.kotlinResult)
+ implementation(libs.thirdparty.bouncycastle.bcprov)
+ implementation(libs.thirdparty.logcat)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.kotlinx.serialization.json)
+ testImplementation(libs.bundles.testDependencies)
+}
diff --git a/passkeys/core/lint-baseline.xml b/passkeys/core/lint-baseline.xml
new file mode 100644
index 0000000000..8cf328fb80
--- /dev/null
+++ b/passkeys/core/lint-baseline.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/cbor/Cbor.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/cbor/Cbor.kt
new file mode 100644
index 0000000000..a72b2c01cc
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/cbor/Cbor.kt
@@ -0,0 +1,489 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.cbor
+
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.math.BigInteger
+
+public class Cbor private constructor(private val data: CborValue) {
+
+ public fun asMap(): CborMap =
+ (data as? CborValue.Map)?.value
+ ?: throw CborException("Expected map, got ${data::class.simpleName}")
+
+ public fun asArray(): CborArray =
+ (data as? CborValue.Array)?.value
+ ?: throw CborException("Expected array, got ${data::class.simpleName}")
+
+ public fun asString(): String =
+ (data as? CborValue.TextString)?.value
+ ?: throw CborException("Expected text string, got ${data::class.simpleName}")
+
+ public fun asBytes(): ByteArray =
+ when (data) {
+ is CborValue.ByteString -> data.value
+ is CborValue.Array -> data.value.toByteArray()
+ else -> throw CborException("Expected byte string or array, got ${data::class.simpleName}")
+ }
+
+ public fun asInt(): Int =
+ when (data) {
+ is CborValue.UnsignedInteger -> {
+ val value = data.value
+ if (value > BigInteger.valueOf(Int.MAX_VALUE.toLong())) {
+ throw CborException("Integer value too large for Int: $value")
+ }
+ value.toInt()
+ }
+ is CborValue.NegativeInteger -> {
+ val value = data.value
+ if (value < BigInteger.valueOf(Int.MIN_VALUE.toLong())) {
+ throw CborException("Integer value too small for Int: $value")
+ }
+ value.toInt()
+ }
+ else -> throw CborException("Expected integer, got ${data::class.simpleName}")
+ }
+
+ public fun asLong(): Long =
+ when (data) {
+ is CborValue.UnsignedInteger -> {
+ val value = data.value
+ if (value > BigInteger.valueOf(Long.MAX_VALUE)) {
+ throw CborException("Integer value too large for Long: $value")
+ }
+ value.toLong()
+ }
+ is CborValue.NegativeInteger -> {
+ val value = data.value
+ if (value < BigInteger.valueOf(Long.MIN_VALUE)) {
+ throw CborException("Integer value too small for Long: $value")
+ }
+ value.toLong()
+ }
+ else -> throw CborException("Expected integer, got ${data::class.simpleName}")
+ }
+
+ public fun asBoolean(): Boolean =
+ when (data) {
+ is CborValue.True -> true
+ is CborValue.False -> false
+ else -> throw CborException("Expected boolean, got ${data::class.simpleName}")
+ }
+
+ public fun isNull(): Boolean = data is CborValue.Null
+
+ public fun toBytes(): ByteArray = CborWriter.write(data)
+
+ public companion object {
+ public fun parse(bytes: ByteArray): Cbor = Cbor(CborReader.read(bytes))
+
+ public fun fromMap(map: CborMap): Cbor = Cbor(CborValue.Map(map))
+
+ public fun fromArray(array: CborArray): Cbor = Cbor(CborValue.Array(array))
+
+ internal fun fromValue(value: CborValue): Cbor = Cbor(value)
+ }
+}
+
+public sealed class CborValue {
+ public data class UnsignedInteger(val value: BigInteger) : CborValue()
+
+ public data class NegativeInteger(val value: BigInteger) : CborValue()
+
+ public data class ByteString(val value: ByteArray) : CborValue() {
+ override fun equals(other: Any?): Boolean =
+ other is ByteString && value.contentEquals(other.value)
+
+ override fun hashCode(): Int = value.contentHashCode()
+ }
+
+ public data class TextString(val value: String) : CborValue()
+
+ public data class Array(val value: CborArray) : CborValue()
+
+ public data class Map(val value: CborMap) : CborValue()
+
+ public data object True : CborValue()
+
+ public data object False : CborValue()
+
+ public data object Null : CborValue()
+}
+
+public class CborMap private constructor(private val entries: MutableMap) {
+
+ public val keys: Set
+ get() = entries.keys
+
+ public operator fun get(key: String): Cbor? = entries[key]?.let { Cbor.fromValue(it) }
+
+ public fun getString(key: String): String? = (entries[key] as? CborValue.TextString)?.value
+
+ public fun getBytes(key: String): ByteArray? =
+ when (val value = entries[key]) {
+ is CborValue.ByteString -> value.value
+ is CborValue.Array -> value.value.toByteArray()
+ else -> null
+ }
+
+ public fun getInt(key: String): Int? =
+ when (val value = entries[key]) {
+ is CborValue.UnsignedInteger -> {
+ if (value.value > BigInteger.valueOf(Int.MAX_VALUE.toLong())) {
+ throw CborException("Integer value too large for Int at key '$key': ${value.value}")
+ }
+ value.value.toInt()
+ }
+ is CborValue.NegativeInteger -> {
+ if (value.value < BigInteger.valueOf(Int.MIN_VALUE.toLong())) {
+ throw CborException("Integer value too small for Int at key '$key': ${value.value}")
+ }
+ value.value.toInt()
+ }
+ else -> null
+ }
+
+ public fun getLong(key: String): Long? =
+ when (val value = entries[key]) {
+ is CborValue.UnsignedInteger -> {
+ if (value.value > BigInteger.valueOf(Long.MAX_VALUE)) {
+ throw CborException("Integer value too large for Long at key '$key': ${value.value}")
+ }
+ value.value.toLong()
+ }
+ is CborValue.NegativeInteger -> {
+ if (value.value < BigInteger.valueOf(Long.MIN_VALUE)) {
+ throw CborException("Integer value too small for Long at key '$key': ${value.value}")
+ }
+ value.value.toLong()
+ }
+ else -> null
+ }
+
+ public fun getBoolean(key: String): Boolean? =
+ when (entries[key]) {
+ is CborValue.True -> true
+ is CborValue.False -> false
+ else -> null
+ }
+
+ public fun getMap(key: String): CborMap? = (entries[key] as? CborValue.Map)?.value
+
+ public fun isArray(key: String): Boolean = entries[key] is CborValue.Array
+
+ public fun isNull(key: String): Boolean = entries[key] is CborValue.Null
+
+ public fun contains(key: String): Boolean = entries.containsKey(key)
+
+ public fun toMutableMap(): MutableMap = entries
+
+ public companion object {
+ public fun create(): CborMap = CborMap(mutableMapOf())
+
+ public fun from(entries: Map): CborMap = CborMap(entries.toMutableMap())
+ }
+}
+
+public class CborArray private constructor(private val elements: MutableList) {
+
+ public val size: Int
+ get() = elements.size
+
+ public operator fun get(index: Int): Cbor? = elements.getOrNull(index)?.let { Cbor.fromValue(it) }
+
+ public fun toList(): List = elements.toList()
+
+ public fun toByteArray(): ByteArray {
+ return elements
+ .filterIsInstance()
+ .map {
+ val intValue = it.value.toInt()
+ if (intValue < 0 || intValue > 255) {
+ throw CborException("Byte value out of range: $intValue")
+ }
+ intValue.toByte()
+ }
+ .toByteArray()
+ }
+
+ public companion object {
+ public fun create(): CborArray = CborArray(mutableListOf())
+
+ public fun from(elements: List): CborArray = CborArray(elements.toMutableList())
+ }
+}
+
+public class CborException(message: String) : Exception(message)
+
+private object CborReader {
+ private const val MAJOR_UNSIGNED = 0
+ private const val MAJOR_NEGATIVE = 1
+ private const val MAJOR_BYTES = 2
+ private const val MAJOR_TEXT = 3
+ private const val MAJOR_ARRAY = 4
+ private const val MAJOR_MAP = 5
+ private const val MAJOR_TAG = 6
+ private const val MAJOR_SIMPLE = 7
+
+ private const val SIMPLE_FALSE = 20
+ private const val SIMPLE_TRUE = 21
+ private const val SIMPLE_NULL = 22
+
+ private const val MAX_COLLECTION_SIZE = 100000
+ private const val MAX_DEPTH = 100
+
+ fun read(bytes: ByteArray): CborValue {
+ val input = DataInputStream(ByteArrayInputStream(bytes))
+ return readValue(input, 0)
+ }
+
+ private fun readValue(input: DataInputStream, depth: Int): CborValue {
+ if (depth > MAX_DEPTH) {
+ throw CborException("Maximum nesting depth ($MAX_DEPTH) exceeded")
+ }
+ val firstByte = input.readUnsignedByte()
+ val majorType = firstByte shr 5
+ val additionalInfo = firstByte and 0x1F
+
+ return when (majorType) {
+ MAJOR_UNSIGNED -> CborValue.UnsignedInteger(readUnsignedInteger(input, additionalInfo))
+ MAJOR_NEGATIVE ->
+ CborValue.NegativeInteger(
+ BigInteger.valueOf(-1) - readUnsignedInteger(input, additionalInfo)
+ )
+ MAJOR_BYTES -> CborValue.ByteString(readByteString(input, additionalInfo))
+ MAJOR_TEXT -> CborValue.TextString(readTextString(input, additionalInfo))
+ MAJOR_ARRAY -> CborValue.Array(readArray(input, additionalInfo, depth))
+ MAJOR_MAP -> CborValue.Map(readMap(input, additionalInfo, depth))
+ MAJOR_TAG -> {
+ readUnsignedInteger(input, additionalInfo)
+ readValue(input, depth + 1)
+ }
+ MAJOR_SIMPLE -> readSimple(additionalInfo)
+ else -> throw CborException("Unknown major type: $majorType")
+ }
+ }
+
+ private fun readUnsignedInteger(input: DataInputStream, additionalInfo: Int): BigInteger {
+ return when (additionalInfo) {
+ in 0..23 -> BigInteger.valueOf(additionalInfo.toLong())
+ 24 -> BigInteger.valueOf(input.readUnsignedByte().toLong())
+ 25 -> BigInteger.valueOf(input.readUnsignedShort().toLong())
+ 26 -> BigInteger.valueOf(input.readInt().toLong() and 0xFFFFFFFF)
+ 27 -> BigInteger(input.readNBytes(8).reversedArray())
+ else -> throw CborException("Invalid additional info for unsigned integer: $additionalInfo")
+ }
+ }
+
+ private fun readByteString(input: DataInputStream, additionalInfo: Int): ByteArray {
+ val length = readLength(input, additionalInfo)
+ if (length > Int.MAX_VALUE) {
+ throw CborException("Byte string length too large: $length")
+ }
+ return input.readNBytes(length.toInt())
+ }
+
+ private fun readTextString(input: DataInputStream, additionalInfo: Int): String {
+ val length = readLength(input, additionalInfo)
+ if (length > Int.MAX_VALUE) {
+ throw CborException("Text string length too large: $length")
+ }
+ return String(input.readNBytes(length.toInt()), Charsets.UTF_8)
+ }
+
+ private fun readArray(input: DataInputStream, additionalInfo: Int, depth: Int): CborArray {
+ val length = readLength(input, additionalInfo)
+ if (length > MAX_COLLECTION_SIZE) {
+ throw CborException("Array size too large: $length (max $MAX_COLLECTION_SIZE)")
+ }
+ val elements = mutableListOf()
+ repeat(length.toInt()) { elements.add(readValue(input, depth + 1)) }
+ return CborArray.from(elements)
+ }
+
+ private fun readMap(input: DataInputStream, additionalInfo: Int, depth: Int): CborMap {
+ val length = readLength(input, additionalInfo)
+ if (length > MAX_COLLECTION_SIZE) {
+ throw CborException("Map size too large: $length (max $MAX_COLLECTION_SIZE)")
+ }
+ val map = mutableMapOf()
+ repeat(length.toInt()) {
+ val key =
+ when (val keyValue = readValue(input, depth + 1)) {
+ is CborValue.TextString -> keyValue.value
+ is CborValue.UnsignedInteger -> keyValue.value.toString()
+ is CborValue.NegativeInteger -> keyValue.value.toString()
+ else ->
+ throw CborException(
+ "Map key must be text or integer, got ${keyValue::class.simpleName}"
+ )
+ }
+ val value = readValue(input, depth + 1)
+ map[key] = value
+ }
+ return CborMap.from(map)
+ }
+
+ private fun readSimple(additionalInfo: Int): CborValue {
+ return when (additionalInfo) {
+ SIMPLE_FALSE -> CborValue.False
+ SIMPLE_TRUE -> CborValue.True
+ SIMPLE_NULL -> CborValue.Null
+ else -> throw CborException("Unknown simple value: $additionalInfo")
+ }
+ }
+
+ private fun readLength(input: DataInputStream, additionalInfo: Int): Long {
+ return when (additionalInfo) {
+ in 0..23 -> additionalInfo.toLong()
+ 24 -> input.readUnsignedByte().toLong()
+ 25 -> input.readUnsignedShort().toLong()
+ 26 -> input.readInt().toLong() and 0xFFFFFFFF
+ 27 -> input.readLong()
+ 31 -> throw CborException("Indefinite length not supported")
+ else -> throw CborException("Invalid additional info for length: $additionalInfo")
+ }
+ }
+}
+
+private object CborWriter {
+ private const val MAJOR_UNSIGNED = 0
+ private const val MAJOR_NEGATIVE = 1
+ private const val MAJOR_BYTES = 2
+ private const val MAJOR_TEXT = 3
+ private const val MAJOR_ARRAY = 4
+ private const val MAJOR_MAP = 5
+ private const val MAJOR_SIMPLE = 7
+
+ private const val SIMPLE_FALSE = 20
+ private const val SIMPLE_TRUE = 21
+ private const val SIMPLE_NULL = 22
+
+ fun write(value: CborValue): ByteArray {
+ val output = ByteArrayOutputStream()
+ val dataOutput = DataOutputStream(output)
+ writeValue(dataOutput, value)
+ return output.toByteArray()
+ }
+
+ private fun writeValue(output: DataOutputStream, value: CborValue) {
+ when (value) {
+ is CborValue.UnsignedInteger -> writeUnsignedInteger(output, value.value)
+ is CborValue.NegativeInteger -> writeNegativeInteger(output, value.value)
+ is CborValue.ByteString -> writeByteString(output, value.value)
+ is CborValue.TextString -> writeTextString(output, value.value)
+ is CborValue.Array -> writeArray(output, value.value)
+ is CborValue.Map -> writeMap(output, value.value)
+ is CborValue.True -> output.writeByte((MAJOR_SIMPLE shl 5) or SIMPLE_TRUE)
+ is CborValue.False -> output.writeByte((MAJOR_SIMPLE shl 5) or SIMPLE_FALSE)
+ is CborValue.Null -> output.writeByte((MAJOR_SIMPLE shl 5) or SIMPLE_NULL)
+ }
+ }
+
+ private fun writeUnsignedInteger(output: DataOutputStream, value: BigInteger) {
+ val longValue = value.toLong()
+ when {
+ longValue in 0..23 -> output.writeByte((MAJOR_UNSIGNED shl 5) or longValue.toInt())
+ longValue in 0..255 -> {
+ output.writeByte((MAJOR_UNSIGNED shl 5) or 24)
+ output.writeByte(longValue.toInt())
+ }
+ longValue in 0..65535 -> {
+ output.writeByte((MAJOR_UNSIGNED shl 5) or 25)
+ output.writeShort(longValue.toInt())
+ }
+ longValue in 0..4294967295L -> {
+ output.writeByte((MAJOR_UNSIGNED shl 5) or 26)
+ output.writeInt(longValue.toInt())
+ }
+ else -> {
+ output.writeByte((MAJOR_UNSIGNED shl 5) or 27)
+ output.writeLong(longValue)
+ }
+ }
+ }
+
+ private fun writeNegativeInteger(output: DataOutputStream, value: BigInteger) {
+ val cborValue = (-value.toLong()) - 1
+ when {
+ cborValue in 0..23 -> output.writeByte((MAJOR_NEGATIVE shl 5) or cborValue.toInt())
+ cborValue in 0..255 -> {
+ output.writeByte((MAJOR_NEGATIVE shl 5) or 24)
+ output.writeByte(cborValue.toInt())
+ }
+ cborValue in 0..65535 -> {
+ output.writeByte((MAJOR_NEGATIVE shl 5) or 25)
+ output.writeShort(cborValue.toInt())
+ }
+ cborValue in 0..4294967295L -> {
+ output.writeByte((MAJOR_NEGATIVE shl 5) or 26)
+ output.writeInt(cborValue.toInt())
+ }
+ else -> {
+ output.writeByte((MAJOR_NEGATIVE shl 5) or 27)
+ output.writeLong(cborValue)
+ }
+ }
+ }
+
+ private fun writeByteString(output: DataOutputStream, value: ByteArray) {
+ writeLength(output, MAJOR_BYTES, value.size.toLong())
+ output.write(value)
+ }
+
+ private fun writeTextString(output: DataOutputStream, value: String) {
+ val bytes = value.toByteArray(Charsets.UTF_8)
+ writeLength(output, MAJOR_TEXT, bytes.size.toLong())
+ output.write(bytes)
+ }
+
+ private fun writeArray(output: DataOutputStream, array: CborArray) {
+ writeLength(output, MAJOR_ARRAY, array.size.toLong())
+ array.toList().forEach { writeValue(output, it) }
+ }
+
+ private fun writeMap(output: DataOutputStream, map: CborMap) {
+ writeLength(output, MAJOR_MAP, map.keys.size.toLong())
+ map.toMutableMap().forEach { (key, value) ->
+ writeTextString(output, key)
+ writeValue(output, value)
+ }
+ }
+
+ private fun writeLength(output: DataOutputStream, majorType: Int, length: Long) {
+ when {
+ length in 0..23 -> output.writeByte((majorType shl 5) or length.toInt())
+ length in 0..255 -> {
+ output.writeByte((majorType shl 5) or 24)
+ output.writeByte(length.toInt())
+ }
+ length in 0..65535 -> {
+ output.writeByte((majorType shl 5) or 25)
+ output.writeShort(length.toInt())
+ }
+ length in 0..4294967295L -> {
+ output.writeByte((majorType shl 5) or 26)
+ output.writeInt(length.toInt())
+ }
+ else -> {
+ output.writeByte((majorType shl 5) or 27)
+ output.writeLong(length)
+ }
+ }
+ }
+}
+
+public fun ByteArray.toCborIntegerArray(): CborValue.Array {
+ val elements =
+ this.map { byte ->
+ CborValue.UnsignedInteger(BigInteger.valueOf((byte.toInt() and 0xFF).toLong()))
+ }
+ return CborValue.Array(CborArray.from(elements))
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandler.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandler.kt
new file mode 100644
index 0000000000..c1e2c3be80
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandler.kt
@@ -0,0 +1,238 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.crypto
+
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.fold
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.security.Signature
+import java.security.spec.ECGenParameterSpec
+import kotlinx.datetime.Clock
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
+
+public class ES256CryptoHandler : PasskeyCryptoHandler {
+
+ private val secureRandom = SecureRandom()
+
+ override fun generateKeyPair(): Pair {
+ val keyPairGenerator = KeyPairGenerator.getInstance("EC")
+ keyPairGenerator.initialize(ECGenParameterSpec("secp256r1"), secureRandom)
+ val keyPair = keyPairGenerator.generateKeyPair()
+
+ val publicKeyBytes =
+ SubjectPublicKeyInfo.getInstance(keyPair.public.encoded).publicKeyData.bytes
+ val privateKeyBytes = keyPair.private.encoded
+
+ return Pair(privateKeyBytes, publicKeyBytes)
+ }
+
+ override fun sign(
+ privateKey: ByteArray,
+ authenticatorData: ByteArray,
+ clientDataHash: ByteArray,
+ ): Result {
+ if (privateKey.isEmpty()) return Err(IllegalArgumentException("Private key cannot be empty"))
+ if (authenticatorData.isEmpty())
+ return Err(IllegalArgumentException("Authenticator data cannot be empty"))
+ if (clientDataHash.isEmpty())
+ return Err(IllegalArgumentException("Client data hash cannot be empty"))
+
+ return try {
+ val keyFactory = java.security.KeyFactory.getInstance("EC")
+ val keySpec = java.security.spec.PKCS8EncodedKeySpec(privateKey)
+ val privateKeyObj = keyFactory.generatePrivate(keySpec)
+
+ val dataToSign = authenticatorData + clientDataHash
+
+ val signature = Signature.getInstance("SHA256withECDSA")
+ signature.initSign(privateKeyObj)
+ signature.update(dataToSign)
+ val derSignature = signature.sign()
+
+ Ok(derSignature)
+ } catch (e: Exception) {
+ Err(e)
+ }
+ }
+
+ override fun verify(
+ publicKey: ByteArray,
+ signature: ByteArray,
+ authenticatorData: ByteArray,
+ clientDataHash: ByteArray,
+ ): Result {
+ if (publicKey.isEmpty()) return Err(IllegalArgumentException("Public key cannot be empty"))
+ if (signature.isEmpty()) return Err(IllegalArgumentException("Signature cannot be empty"))
+ if (authenticatorData.isEmpty())
+ return Err(IllegalArgumentException("Authenticator data cannot be empty"))
+ if (clientDataHash.isEmpty())
+ return Err(IllegalArgumentException("Client data hash cannot be empty"))
+
+ return try {
+ val keyFactory = java.security.KeyFactory.getInstance("EC")
+ val keySpec = java.security.spec.X509EncodedKeySpec(unwrapRawPublicKey(publicKey))
+ val publicKeyObj = keyFactory.generatePublic(keySpec)
+
+ val dataToVerify = authenticatorData + clientDataHash
+
+ val sig = Signature.getInstance("SHA256withECDSA")
+ sig.initVerify(publicKeyObj)
+ sig.update(dataToVerify)
+ Ok(sig.verify(signature))
+ } catch (e: Exception) {
+ Err(e)
+ }
+ }
+
+ override fun createCredential(
+ rpId: String,
+ userId: ByteArray,
+ userName: String,
+ userDisplayName: String,
+ challenge: ByteArray,
+ ): Result {
+ if (rpId.isBlank()) return Err(IllegalArgumentException("RP ID cannot be blank"))
+ if (userId.isEmpty()) return Err(IllegalArgumentException("User ID cannot be empty"))
+
+ return try {
+ val (privateKey, publicKey) = generateKeyPair()
+ val credentialId = generateCredentialId()
+
+ Ok(
+ PasskeyCredential(
+ credentialId = credentialId,
+ privateKey = privateKey,
+ publicKey = publicKey,
+ rpId = rpId,
+ user = FidoUser(id = userId, name = userName, displayName = userDisplayName),
+ signCount = 0u,
+ createdAt = Clock.System.now(),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ )
+ } catch (e: Exception) {
+ Err(e)
+ }
+ }
+
+ override fun getAssertion(
+ credential: PasskeyCredential,
+ rpId: String,
+ challenge: ByteArray,
+ origin: String,
+ ): Result {
+ if (rpId.isBlank()) return Err(IllegalArgumentException("RP ID cannot be blank"))
+ if (challenge.isEmpty()) return Err(IllegalArgumentException("Challenge cannot be empty"))
+ if (origin.isBlank()) return Err(IllegalArgumentException("Origin cannot be blank"))
+ if (credential.privateKey.isEmpty())
+ return Err(IllegalArgumentException("Credential has no private key"))
+
+ return try {
+ val authenticatorData = buildAuthenticatorData(rpId, credential.signCount)
+ val (clientDataJson, clientDataHash) = buildClientData(challenge, origin, "webauthn.get")
+
+ sign(credential.privateKey, authenticatorData, clientDataHash)
+ .fold(
+ success = { signature ->
+ Ok(
+ AssertionResult(
+ credentialId = credential.credentialId,
+ authenticatorData = authenticatorData,
+ signature = signature,
+ userHandle = credential.user.id,
+ clientDataJSON = clientDataJson,
+ )
+ )
+ },
+ failure = { Err(it) },
+ )
+ } catch (e: Exception) {
+ Err(e)
+ }
+ }
+
+ private fun generateCredentialId(): ByteArray {
+ val idBytes = ByteArray(32)
+ secureRandom.nextBytes(idBytes)
+ return idBytes
+ }
+
+ private fun buildAuthenticatorData(rpId: String, signCount: ULong): ByteArray {
+ val rpIdHash = MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray())
+ val flags = (FLAG_USER_PRESENT.toInt() or FLAG_USER_VERIFIED.toInt()).toByte()
+ val signCountBytes =
+ byteArrayOf(
+ ((signCount shr 24) and 0xFFu).toByte(),
+ ((signCount shr 16) and 0xFFu).toByte(),
+ ((signCount shr 8) and 0xFFu).toByte(),
+ (signCount and 0xFFu).toByte(),
+ )
+ return rpIdHash + flags + signCountBytes
+ }
+
+ private fun buildClientData(
+ challenge: ByteArray,
+ origin: String,
+ type: String,
+ ): Pair {
+ val clientDataJson =
+ """{"type":"$type","challenge":"${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(challenge)}","origin":"$origin","crossOrigin":false}"""
+ return Pair(
+ clientDataJson,
+ MessageDigest.getInstance("SHA-256").digest(clientDataJson.toByteArray()),
+ )
+ }
+
+ private fun unwrapRawPublicKey(rawPublicKey: ByteArray): ByteArray {
+ require(rawPublicKey.size == 65 && rawPublicKey.first() == 0x04.toByte()) {
+ "Expected 65-byte uncompressed P-256 public key starting with 0x04, got ${rawPublicKey.size} bytes starting with 0x${rawPublicKey.first().toInt().and(0xFF).toString(16)}"
+ }
+ return P256_EC_OID_PREFIX + rawPublicKey
+ }
+
+ public companion object {
+ public const val FLAG_USER_PRESENT: Byte = 0x01
+ public const val FLAG_USER_VERIFIED: Byte = 0x04
+ public const val FLAG_ATTESTED_CREDENTIAL_DATA: Byte = 0x40
+
+ private val P256_EC_OID_PREFIX =
+ byteArrayOf(
+ 0x30,
+ 0x59,
+ 0x30,
+ 0x13,
+ 0x06,
+ 0x07,
+ 0x2A.toByte(),
+ 0x86.toByte(),
+ 0x48,
+ 0xCE.toByte(),
+ 0x3D,
+ 0x02,
+ 0x01,
+ 0x06,
+ 0x08,
+ 0x2A.toByte(),
+ 0x86.toByte(),
+ 0x48,
+ 0xCE.toByte(),
+ 0x3D,
+ 0x03,
+ 0x01,
+ 0x07,
+ 0x03,
+ 0x42,
+ 0x00,
+ )
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyCryptoHandler.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyCryptoHandler.kt
new file mode 100644
index 0000000000..b70fe09915
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/crypto/PasskeyCryptoHandler.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.crypto
+
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.Result
+
+/**
+ * Interface for cryptographic operations required by the WebAuthn/FIDO2 passkey implementation.
+ *
+ * Implementations must support ES256 (P-256 with SHA-256) for signature operations.
+ */
+public interface PasskeyCryptoHandler {
+
+ /**
+ * Generates a new P-256 ECDSA key pair.
+ *
+ * @return A pair of (privateKey, publicKey) where:
+ * - privateKey is a PKCS#8 encoded private key
+ * - publicKey is a raw 65-byte uncompressed EC point (0x04 || x || y)
+ */
+ public fun generateKeyPair(): Pair
+
+ /**
+ * Signs authenticator data and client data hash using ES256.
+ *
+ * @param privateKey PKCS#8 encoded private key
+ * @param authenticatorData 37-byte authenticator data structure
+ * @param clientDataHash 32-byte SHA-256 hash of client data JSON
+ * @return 64-byte raw signature (R || S) or an error
+ */
+ public fun sign(
+ privateKey: ByteArray,
+ authenticatorData: ByteArray,
+ clientDataHash: ByteArray,
+ ): Result
+
+ /**
+ * Verifies an ES256 signature.
+ *
+ * @param publicKey Raw 65-byte uncompressed P-256 public key
+ * @param signature 64-byte raw signature (R || S)
+ * @param authenticatorData 37-byte authenticator data structure
+ * @param clientDataHash 32-byte SHA-256 hash of client data JSON
+ * @return True if signature is valid, false otherwise, or an error
+ */
+ public fun verify(
+ publicKey: ByteArray,
+ signature: ByteArray,
+ authenticatorData: ByteArray,
+ clientDataHash: ByteArray,
+ ): Result
+
+ /**
+ * Creates a new passkey credential.
+ *
+ * @param rpId Relying Party identifier (e.g., "example.com")
+ * @param userId User identifier from the relying party
+ * @param userName Username for display purposes
+ * @param userDisplayName Display name for the user
+ * @param challenge Challenge from the WebAuthn ceremony
+ * @return A new PasskeyCredential or an error
+ */
+ public fun createCredential(
+ rpId: String,
+ userId: ByteArray,
+ userName: String,
+ userDisplayName: String,
+ challenge: ByteArray,
+ ): Result
+
+ /**
+ * Generates a WebAuthn assertion for authentication.
+ *
+ * @param credential The stored passkey credential
+ * @param rpId Relying Party identifier
+ * @param challenge Challenge from the WebAuthn ceremony
+ * @param origin Origin of the WebAuthn request (e.g., "https://example.com")
+ * @return An AssertionResult containing the signature or an error
+ */
+ public fun getAssertion(
+ credential: PasskeyCredential,
+ rpId: String,
+ challenge: ByteArray,
+ origin: String,
+ ): Result
+}
+
+/**
+ * Result of a WebAuthn assertion (authentication) operation.
+ *
+ * @property credentialId The credential identifier
+ * @property authenticatorData 37-byte authenticator data structure
+ * @property signature 64-byte raw ES256 signature
+ * @property userHandle Optional user handle returned to the relying party
+ * @property clientDataJSON The client data JSON string used for signing
+ */
+public data class AssertionResult(
+ public val credentialId: ByteArray,
+ public val authenticatorData: ByteArray,
+ public val signature: ByteArray,
+ public val userHandle: ByteArray?,
+ public val clientDataJSON: String,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is AssertionResult) return false
+ if (!credentialId.contentEquals(other.credentialId)) return false
+ if (!authenticatorData.contentEquals(other.authenticatorData)) return false
+ if (!signature.contentEquals(other.signature)) return false
+ if (userHandle != null) {
+ if (other.userHandle == null) return false
+ if (!userHandle.contentEquals(other.userHandle)) return false
+ } else if (other.userHandle != null) return false
+ if (clientDataJSON != other.clientDataJSON) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = credentialId.contentHashCode()
+ result = 31 * result + authenticatorData.contentHashCode()
+ result = 31 * result + signature.contentHashCode()
+ result = 31 * result + (userHandle?.contentHashCode() ?: 0)
+ result = 31 * result + clientDataJSON.hashCode()
+ return result
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/FidoUser.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/FidoUser.kt
new file mode 100644
index 0000000000..6c4b6bbb44
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/FidoUser.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class FidoUser(
+ public val id: ByteArray,
+ public val name: String,
+ public val displayName: String,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is FidoUser) return false
+ return id.contentEquals(other.id) && name == other.name && displayName == other.displayName
+ }
+
+ override fun hashCode(): Int {
+ var result = id.contentHashCode()
+ result = 31 * result + name.hashCode()
+ result = 31 * result + displayName.hashCode()
+ return result
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/PasskeyCredential.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/PasskeyCredential.kt
new file mode 100644
index 0000000000..6d676711e6
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/PasskeyCredential.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.model
+
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class PasskeyCredential(
+ public val credentialId: ByteArray,
+ public val privateKey: ByteArray,
+ public val publicKey: ByteArray,
+ public val rpId: String,
+ public val user: FidoUser,
+ public val signCount: ULong = 0u,
+ public val createdAt: Instant,
+ public val transports: List = listOf("internal"),
+ public val uvInitialized: Boolean = true,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is PasskeyCredential) return false
+ if (!credentialId.contentEquals(other.credentialId)) return false
+ if (!privateKey.contentEquals(other.privateKey)) return false
+ if (!publicKey.contentEquals(other.publicKey)) return false
+ if (rpId != other.rpId) return false
+ if (user != other.user) return false
+ if (signCount != other.signCount) return false
+ if (createdAt != other.createdAt) return false
+ if (transports != other.transports) return false
+ if (uvInitialized != other.uvInitialized) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = credentialId.contentHashCode()
+ result = 31 * result + privateKey.contentHashCode()
+ result = 31 * result + publicKey.contentHashCode()
+ result = 31 * result + rpId.hashCode()
+ result = 31 * result + user.hashCode()
+ result = 31 * result + signCount.hashCode()
+ result = 31 * result + createdAt.hashCode()
+ result = 31 * result + transports.hashCode()
+ result = 31 * result + uvInitialized.hashCode()
+ return result
+ }
+
+ public fun incrementSignCount(): PasskeyCredential {
+ return copy(signCount = signCount + 1u)
+ }
+
+ public fun credentialIdBase64(): String {
+ return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(credentialId)
+ }
+
+ public fun displayNameOrName(): String {
+ return user.displayName.takeIf { it.isNotBlank() } ?: user.name
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt
new file mode 100644
index 0000000000..2453a9d13f
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.model
+
+import app.passwordstore.passkeys.cbor.Cbor
+import app.passwordstore.passkeys.cbor.CborMap
+import app.passwordstore.passkeys.cbor.CborValue
+import app.passwordstore.passkeys.cbor.toCborIntegerArray
+import java.math.BigInteger
+import kotlinx.datetime.Instant
+
+public data class StoredCredential(
+ val id: ByteArray,
+ val rp: RelyingParty,
+ val user: User,
+ val signCount: UInt,
+ val alg: Int,
+ val privateKey: ByteArray,
+ val publicKey: ByteArray? = null,
+ val created: Long,
+ val discoverable: Boolean = true,
+ val extensions: Extensions = Extensions(),
+) {
+ public fun toCbor(): ByteArray {
+ val map = mutableMapOf()
+ map["id"] = id.toCborIntegerArray()
+ map["rp"] = CborValue.Map(rp.toCborMap())
+ map["user"] = CborValue.Map(user.toCborMap())
+ map["sign_count"] = CborValue.UnsignedInteger(BigInteger.valueOf(signCount.toLong()))
+ map["alg"] = CborValue.NegativeInteger(BigInteger.valueOf(alg.toLong()))
+ map["private_key"] = privateKey.toCborIntegerArray()
+ publicKey?.let { map["public_key"] = it.toCborIntegerArray() }
+ ?: run { map["public_key"] = CborValue.Null }
+ map["created"] = CborValue.UnsignedInteger(BigInteger.valueOf(created))
+ map["discoverable"] = if (discoverable) CborValue.True else CborValue.False
+ map["extensions"] = CborValue.Map(extensions.toCborMap())
+ return Cbor.fromMap(CborMap.from(map)).toBytes()
+ }
+
+ public fun toPasskeyCredential(): PasskeyCredential {
+ return PasskeyCredential(
+ credentialId = id,
+ privateKey = privateKey,
+ publicKey = publicKey ?: ByteArray(65),
+ rpId = rp.id,
+ user = FidoUser(id = user.id, name = user.name ?: "", displayName = user.displayName ?: ""),
+ signCount = signCount.toULong(),
+ createdAt = Instant.fromEpochSeconds(created),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ }
+
+ public fun credentialIdHex(): String {
+ return id.joinToString("") { byte -> "%02x".format(byte) }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is StoredCredential) return false
+ if (!id.contentEquals(other.id)) return false
+ if (rp != other.rp) return false
+ if (user != other.user) return false
+ if (signCount != other.signCount) return false
+ if (alg != other.alg) return false
+ if (!privateKey.contentEquals(other.privateKey)) return false
+ if (publicKey != null) {
+ if (other.publicKey == null) return false
+ if (!publicKey.contentEquals(other.publicKey)) return false
+ } else if (other.publicKey != null) return false
+ if (created != other.created) return false
+ if (discoverable != other.discoverable) return false
+ if (extensions != other.extensions) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = id.contentHashCode()
+ result = 31 * result + rp.hashCode()
+ result = 31 * result + user.hashCode()
+ result = 31 * result + signCount.hashCode()
+ result = 31 * result + alg
+ result = 31 * result + privateKey.contentHashCode()
+ result = 31 * result + (publicKey?.contentHashCode() ?: 0)
+ result = 31 * result + created.hashCode()
+ result = 31 * result + discoverable.hashCode()
+ result = 31 * result + extensions.hashCode()
+ return result
+ }
+
+ public companion object {
+ public const val ALG_ES256: Int = -7
+
+ public fun fromCbor(bytes: ByteArray): StoredCredential {
+ val map = Cbor.parse(bytes).asMap()
+
+ val id = map.getBytes("id") ?: throw IllegalArgumentException("Missing 'id' field")
+ val rpMap = map.getMap("rp") ?: throw IllegalArgumentException("Missing 'rp' field")
+ val userMap = map.getMap("user") ?: throw IllegalArgumentException("Missing 'user' field")
+ val signCount =
+ map.getLong("sign_count")?.toUInt()
+ ?: throw IllegalArgumentException("Missing 'sign_count' field")
+ val alg = map.getInt("alg") ?: throw IllegalArgumentException("Missing 'alg' field")
+ val privateKey =
+ map.getBytes("private_key") ?: throw IllegalArgumentException("Missing 'private_key' field")
+ val publicKey = if (map.isNull("public_key")) null else map.getBytes("public_key")
+ val created =
+ map.getLong("created") ?: throw IllegalArgumentException("Missing 'created' field")
+ val discoverable = map.getBoolean("discoverable") ?: true
+ val extensionsMap = map.getMap("extensions")
+
+ return StoredCredential(
+ id = id,
+ rp = RelyingParty.fromCborMap(rpMap),
+ user = User.fromCborMap(userMap),
+ signCount = signCount,
+ alg = alg,
+ privateKey = privateKey,
+ publicKey = publicKey,
+ created = created,
+ discoverable = discoverable,
+ extensions = extensionsMap?.let { Extensions.fromCborMap(it) } ?: Extensions(),
+ )
+ }
+
+ public fun fromPasskeyCredential(credential: PasskeyCredential): StoredCredential {
+ return StoredCredential(
+ id = credential.credentialId,
+ rp = RelyingParty(id = credential.rpId, name = null),
+ user =
+ User(
+ id = credential.user.id,
+ name = credential.user.name,
+ displayName = credential.user.displayName,
+ ),
+ signCount = credential.signCount.toUInt(),
+ alg = ALG_ES256,
+ privateKey = credential.privateKey,
+ publicKey = credential.publicKey,
+ created = credential.createdAt.epochSeconds,
+ discoverable = true,
+ extensions = Extensions(),
+ )
+ }
+ }
+}
+
+public data class RelyingParty(val id: String, val name: String? = null) {
+ public fun toCborMap(): CborMap {
+ val map = mutableMapOf()
+ map["id"] = CborValue.TextString(id)
+ name?.let { map["name"] = CborValue.TextString(it) } ?: run { map["name"] = CborValue.Null }
+ return CborMap.from(map)
+ }
+
+ public companion object {
+ public fun fromCborMap(map: CborMap): RelyingParty {
+ return RelyingParty(
+ id = map.getString("id") ?: throw IllegalArgumentException("Missing 'rp.id' field"),
+ name = if (map.isNull("name")) null else map.getString("name"),
+ )
+ }
+ }
+}
+
+public data class User(
+ val id: ByteArray,
+ val name: String? = null,
+ val displayName: String? = null,
+) {
+ public fun toCborMap(): CborMap {
+ val map = mutableMapOf()
+ map["id"] = id.toCborIntegerArray()
+ name?.let { map["name"] = CborValue.TextString(it) } ?: run { map["name"] = CborValue.Null }
+ displayName?.let { map["display_name"] = CborValue.TextString(it) }
+ ?: run { map["display_name"] = CborValue.Null }
+ return CborMap.from(map)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is User) return false
+ if (!id.contentEquals(other.id)) return false
+ if (name != other.name) return false
+ if (displayName != other.displayName) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = id.contentHashCode()
+ result = 31 * result + (name?.hashCode() ?: 0)
+ result = 31 * result + (displayName?.hashCode() ?: 0)
+ return result
+ }
+
+ public companion object {
+ public fun fromCborMap(map: CborMap): User {
+ return User(
+ id = map.getBytes("id") ?: throw IllegalArgumentException("Missing 'user.id' field"),
+ name = if (map.isNull("name")) null else map.getString("name"),
+ displayName = if (map.isNull("display_name")) null else map.getString("display_name"),
+ )
+ }
+ }
+}
+
+public data class Extensions(
+ val credProtect: Int? = null,
+ val hmacSecret: Boolean? = null,
+ val credRandom: ByteArray? = null,
+) {
+ public fun toCborMap(): CborMap {
+ val map = mutableMapOf()
+ credProtect?.let {
+ map["cred_protect"] = CborValue.UnsignedInteger(BigInteger.valueOf(it.toLong()))
+ } ?: run { map["cred_protect"] = CborValue.Null }
+ hmacSecret?.let { map["hmac_secret"] = if (it) CborValue.True else CborValue.False }
+ ?: run { map["hmac_secret"] = CborValue.Null }
+ credRandom?.let { map["cred_random"] = it.toCborIntegerArray() }
+ return CborMap.from(map)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Extensions) return false
+ if (credProtect != other.credProtect) return false
+ if (hmacSecret != other.hmacSecret) return false
+ if (credRandom != null) {
+ if (other.credRandom == null) return false
+ if (!credRandom.contentEquals(other.credRandom)) return false
+ } else if (other.credRandom != null) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = credProtect ?: 0
+ result = 31 * result + (hmacSecret?.hashCode() ?: 0)
+ result = 31 * result + (credRandom?.contentHashCode() ?: 0)
+ return result
+ }
+
+ public companion object {
+ public fun fromCborMap(map: CborMap): Extensions {
+ return Extensions(
+ credProtect = if (map.isNull("cred_protect")) null else map.getInt("cred_protect"),
+ hmacSecret = if (map.isNull("hmac_secret")) null else map.getBoolean("hmac_secret"),
+ credRandom = map.getBytes("cred_random"),
+ )
+ }
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/FilePasskeyStorage.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/FilePasskeyStorage.kt
new file mode 100644
index 0000000000..60fa7e8c50
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/FilePasskeyStorage.kt
@@ -0,0 +1,265 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.storage
+
+import app.passwordstore.crypto.CryptoHandler
+import app.passwordstore.crypto.CryptoOptions
+import app.passwordstore.passkeys.model.PasskeyCredential
+import app.passwordstore.passkeys.model.StoredCredential
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.fold
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import logcat.LogPriority
+import logcat.logcat
+
+public class FilePasskeyStorage<
+ Key,
+ Identifier,
+ KeyPair,
+ EncOpts : CryptoOptions,
+ DecryptOpts : CryptoOptions,
+>(
+ private val repositoryRoot: File,
+ private val cryptoHandler: CryptoHandler,
+ private val decryptionKeys: () -> List,
+ private val decryptionPassphrase: () -> CharArray?,
+ private val encryptionKeys: () -> List,
+ private val decryptionOptions: DecryptOpts,
+ private val encryptionOptions: EncOpts,
+ private val config: PasskeyStorageConfig = PasskeyStorageConfig(),
+) : PasskeyStorage {
+
+ private val passkeyDir: File
+ get() = File(repositoryRoot, config.passkeyDirectory)
+
+ override suspend fun listCredentials(rpId: String?): Result, Throwable> =
+ withContext(Dispatchers.IO) {
+ try {
+ val dir = passkeyDir
+ if (!dir.exists() || !dir.isDirectory) {
+ return@withContext Ok(emptyList())
+ }
+
+ val targetDir = if (rpId != null) File(dir, sanitizeRpId(rpId)) else dir
+ if (!targetDir.exists() || !targetDir.isDirectory) {
+ return@withContext Ok(emptyList())
+ }
+
+ val credentials = mutableListOf()
+ targetDir
+ .walkTopDown()
+ .filter { it.isFile && it.extension == config.fileExtension.removePrefix(".") }
+ .forEach { file ->
+ decryptCredential(file)?.let { credentials.add(it.toPasskeyCredential()) }
+ }
+
+ Ok(credentials)
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Failed to list credentials: ${e.message}" }
+ Err(e)
+ }
+ }
+
+ override suspend fun getCredential(
+ credentialId: ByteArray
+ ): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val hexId = credentialId.joinToString("") { byte -> "%02x".format(byte) }
+
+ dir
+ .walkTopDown()
+ .filter { it.isFile && it.nameWithoutExtension == hexId }
+ .forEach { file ->
+ val credential = decryptCredential(file)
+ if (credential != null) {
+ return@withContext Ok(credential.toPasskeyCredential())
+ }
+ }
+
+ Ok(null)
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Failed to get credential: ${e.message}" }
+ Err(e)
+ }
+ }
+
+ override suspend fun saveCredential(credential: PasskeyCredential): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val dir = passkeyDir
+ if (!dir.exists()) {
+ if (!dir.mkdirs()) {
+ return@withContext Err(IllegalStateException("Failed to create passkey directory"))
+ }
+ }
+
+ val storedCred = StoredCredential.fromPasskeyCredential(credential)
+ val rpDir = File(dir, sanitizeRpId(credential.rpId))
+ if (!rpDir.exists()) {
+ if (!rpDir.mkdirs()) {
+ return@withContext Err(IllegalStateException("Failed to create RP directory"))
+ }
+ }
+
+ val fileName = storedCred.credentialIdHex() + config.fileExtension
+ val file = File(rpDir, fileName)
+
+ val plaintext = storedCred.toCbor()
+ val plaintextStream = ByteArrayInputStream(plaintext)
+ val outputStream = ByteArrayOutputStream()
+
+ cryptoHandler
+ .encrypt(
+ keys = encryptionKeys(),
+ passphrase = null,
+ plaintextStream = plaintextStream,
+ outputStream = outputStream,
+ options = encryptionOptions,
+ )
+ .fold(
+ success = {
+ file.writeBytes(outputStream.toByteArray())
+ logcat { "Saved passkey for ${credential.rpId}/${storedCred.credentialIdHex()}" }
+ Ok(Unit)
+ },
+ failure = { Err(it) },
+ )
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Failed to save credential: ${e.message}" }
+ Err(e)
+ }
+ }
+
+ override suspend fun deleteCredential(credentialId: ByteArray): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val hexId = credentialId.joinToString("") { byte -> "%02x".format(byte) }
+
+ dir
+ .walkTopDown()
+ .filter { it.isFile && it.nameWithoutExtension == hexId }
+ .forEach { file ->
+ val deleted = file.delete()
+ if (deleted) {
+ logcat { "Deleted passkey ${hexId}" }
+ cleanupEmptyDirectories(file.parentFile)
+ }
+ return@withContext Ok(deleted)
+ }
+
+ Ok(false)
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Failed to delete credential: ${e.message}" }
+ Err(e)
+ }
+ }
+
+ override suspend fun updateSignCount(
+ credentialId: ByteArray,
+ newSignCount: ULong,
+ ): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val hexId = credentialId.joinToString("") { byte -> "%02x".format(byte) }
+
+ dir
+ .walkTopDown()
+ .filter { it.isFile && it.nameWithoutExtension == hexId }
+ .forEach { file ->
+ val credential = decryptCredential(file)
+ if (credential != null) {
+ val updated = credential.copy(signCount = newSignCount.toUInt())
+ val plaintext = updated.toCbor()
+ val plaintextStream = ByteArrayInputStream(plaintext)
+ val outputStream = ByteArrayOutputStream()
+
+ cryptoHandler
+ .encrypt(
+ keys = encryptionKeys(),
+ passphrase = null,
+ plaintextStream = plaintextStream,
+ outputStream = outputStream,
+ options = encryptionOptions,
+ )
+ .fold(
+ success = {
+ file.writeBytes(outputStream.toByteArray())
+ logcat { "Updated sign count for ${hexId}" }
+ },
+ failure = {
+ return@withContext Err(it)
+ },
+ )
+ return@withContext Ok(Unit)
+ }
+ }
+
+ Err(IllegalArgumentException("Credential not found"))
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Failed to update sign count: ${e.message}" }
+ Err(e)
+ }
+ }
+
+ private val dir: File
+ get() = passkeyDir
+
+ private fun decryptCredential(file: File): StoredCredential? {
+ return try {
+ val ciphertext = file.readBytes()
+ val ciphertextStream = ByteArrayInputStream(ciphertext)
+ val outputStream = ByteArrayOutputStream()
+
+ val key = decryptionKeys().firstOrNull()
+ if (key == null) {
+ logcat(LogPriority.WARN) { "No decryption key available for ${file.name}" }
+ return null
+ }
+
+ cryptoHandler
+ .decrypt(
+ key = key,
+ passphrase = decryptionPassphrase(),
+ ciphertextStream = ciphertextStream,
+ outputStream = outputStream,
+ options = decryptionOptions,
+ )
+ .fold(
+ success = { StoredCredential.fromCbor(outputStream.toByteArray()) },
+ failure = {
+ logcat(LogPriority.WARN) { "Failed to decrypt ${file.name}: ${it.message}" }
+ null
+ },
+ )
+ } catch (e: Exception) {
+ logcat(LogPriority.WARN) { "Error decrypting ${file.name}: ${e.message}" }
+ null
+ }
+ }
+
+ private fun cleanupEmptyDirectories(dir: File?) {
+ var current = dir
+ while (current != null && current != passkeyDir) {
+ if (current.isDirectory && current.listFiles()?.isEmpty() == true) {
+ current.delete()
+ current = current.parentFile
+ } else {
+ break
+ }
+ }
+ }
+
+ private fun sanitizeRpId(rpId: String): String {
+ return rpId.replace("/", "_").replace("\\", "_").replace("..", "_")
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/InMemoryPasskeyStorage.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/InMemoryPasskeyStorage.kt
new file mode 100644
index 0000000000..4b7b179628
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/InMemoryPasskeyStorage.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.storage
+
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.Clock
+import kotlinx.serialization.encodeToString
+
+public class InMemoryPasskeyStorage : PasskeyStorage {
+
+ private val credentials = mutableMapOf()
+
+ private fun credentialIdKey(id: ByteArray): String {
+ return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(id)
+ }
+
+ override suspend fun listCredentials(rpId: String?): Result, Throwable> =
+ withContext(Dispatchers.Default) {
+ val filtered =
+ if (rpId != null) {
+ credentials.values.filter { it.rpId == rpId }
+ } else {
+ credentials.values.toList()
+ }
+ Ok(filtered)
+ }
+
+ override suspend fun getCredential(
+ credentialId: ByteArray
+ ): Result =
+ withContext(Dispatchers.Default) { Ok(credentials[credentialIdKey(credentialId)]) }
+
+ override suspend fun saveCredential(credential: PasskeyCredential): Result =
+ withContext(Dispatchers.Default) {
+ credentials[credentialIdKey(credential.credentialId)] = credential
+ Ok(Unit)
+ }
+
+ override suspend fun deleteCredential(credentialId: ByteArray): Result =
+ withContext(Dispatchers.Default) {
+ val key = credentialIdKey(credentialId)
+ val existed = credentials.containsKey(key)
+ credentials.remove(key)
+ Ok(existed)
+ }
+
+ override suspend fun updateSignCount(
+ credentialId: ByteArray,
+ newSignCount: ULong,
+ ): Result =
+ withContext(Dispatchers.Default) {
+ val key = credentialIdKey(credentialId)
+ val existing = credentials[key]
+ if (existing != null) {
+ credentials[key] = existing.copy(signCount = newSignCount)
+ Ok(Unit)
+ } else {
+ Err(IllegalArgumentException("Credential not found"))
+ }
+ }
+
+ public fun clear() {
+ credentials.clear()
+ }
+
+ public fun count(): Int = credentials.size
+
+ public companion object {
+ public fun withTestCredentials(vararg creds: PasskeyCredential): InMemoryPasskeyStorage {
+ val storage = InMemoryPasskeyStorage()
+ creds.forEach { storage.credentials[storage.credentialIdKey(it.credentialId)] = it }
+ return storage
+ }
+
+ public fun createTestCredential(
+ rpId: String = "example.com",
+ userName: String = "testuser",
+ credentialId: ByteArray = "test-cred-id".toByteArray(),
+ ): PasskeyCredential {
+ return PasskeyCredential(
+ credentialId = credentialId,
+ privateKey = ByteArray(32) { it.toByte() },
+ publicKey = ByteArray(65) { if (it == 0) 0x04.toByte() else it.toByte() },
+ rpId = rpId,
+ user = FidoUser(id = "user-id".toByteArray(), name = userName, displayName = "Test User"),
+ signCount = 0u,
+ createdAt = Clock.System.now(),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ }
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt
new file mode 100644
index 0000000000..c23395dca3
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.storage
+
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.fold
+import java.util.Base64
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import logcat.LogPriority
+import logcat.logcat
+
+public class IndexedPasskeyStorage(private val delegate: PasskeyStorage) : PasskeyStorage {
+
+ private val credentialIndex = ConcurrentHashMap()
+ private val rpIdIndex = ConcurrentHashMap>()
+ @Volatile private var indexLoaded = false
+ private val indexLoadMutex = Mutex()
+
+ private fun credentialKey(id: ByteArray): String {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(id)
+ }
+
+ private suspend fun ensureIndexLoaded() {
+ if (indexLoaded) return
+ indexLoadMutex.withLock {
+ if (indexLoaded) return
+ withContext(Dispatchers.IO) {
+ delegate
+ .listCredentials()
+ .fold(
+ success = { credentials ->
+ credentials.forEach { credential -> indexCredential(credential) }
+ indexLoaded = true
+ },
+ failure = { error ->
+ logcat(LogPriority.ERROR) { "Failed to load passkey index: $error" }
+ },
+ )
+ }
+ }
+ }
+
+ private fun indexCredential(credential: PasskeyCredential) {
+ val key = credentialKey(credential.credentialId)
+ credentialIndex[key] = credential
+ rpIdIndex.getOrPut(credential.rpId) { ConcurrentHashMap.newKeySet() }.add(key)
+ }
+
+ private fun removeFromIndex(credential: PasskeyCredential) {
+ val key = credentialKey(credential.credentialId)
+ credentialIndex.remove(key)
+ val rpSet = rpIdIndex[credential.rpId]
+ rpSet?.remove(key)
+ if (rpSet?.isEmpty() == true) {
+ rpIdIndex.remove(credential.rpId)
+ }
+ }
+
+ override suspend fun listCredentials(rpId: String?): Result, Throwable> {
+ ensureIndexLoaded()
+
+ return withContext(Dispatchers.Default) {
+ try {
+ val credentials =
+ if (rpId != null) {
+ rpIdIndex[rpId]?.mapNotNull { credentialIndex[it] } ?: emptyList()
+ } else {
+ credentialIndex.values.toList()
+ }
+ Ok(credentials)
+ } catch (e: Exception) {
+ Err(e)
+ }
+ }
+ }
+
+ override suspend fun getCredential(
+ credentialId: ByteArray
+ ): Result {
+ ensureIndexLoaded()
+
+ return withContext(Dispatchers.Default) {
+ try {
+ val key = credentialKey(credentialId)
+ Ok(credentialIndex[key])
+ } catch (e: Exception) {
+ Err(e)
+ }
+ }
+ }
+
+ override suspend fun saveCredential(credential: PasskeyCredential): Result {
+ return delegate.saveCredential(credential).also { result ->
+ if (result.isOk) {
+ indexCredential(credential)
+ }
+ }
+ }
+
+ override suspend fun deleteCredential(credentialId: ByteArray): Result {
+ val key = credentialKey(credentialId)
+ val credential = credentialIndex[key]
+
+ return delegate
+ .deleteCredential(credentialId)
+ .fold(
+ success = { deleted ->
+ if (deleted && credential != null) {
+ removeFromIndex(credential)
+ }
+ Ok(deleted)
+ },
+ failure = { Err(it) },
+ )
+ }
+
+ override suspend fun updateSignCount(
+ credentialId: ByteArray,
+ newSignCount: ULong,
+ ): Result {
+ val key = credentialKey(credentialId)
+ val existing = credentialIndex[key]
+
+ return if (existing != null) {
+ val updated = existing.copy(signCount = newSignCount)
+ delegate.saveCredential(updated).also { result ->
+ if (result.isOk) {
+ credentialIndex[key] = updated
+ }
+ }
+ } else {
+ delegate.updateSignCount(credentialId, newSignCount)
+ }
+ }
+
+ public fun clearIndex() {
+ credentialIndex.clear()
+ rpIdIndex.clear()
+ indexLoaded = false
+ }
+
+ public fun indexedCredentialCount(): Int = credentialIndex.size
+
+ public fun indexedRpIds(): Set = rpIdIndex.keys.toSet()
+
+ public fun hasRpId(rpId: String): Boolean {
+ return rpIdIndex.containsKey(rpId)
+ }
+
+ public fun credentialCountForRp(rpId: String): Int {
+ return rpIdIndex[rpId]?.size ?: 0
+ }
+}
diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyStorage.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyStorage.kt
new file mode 100644
index 0000000000..78ab3939bb
--- /dev/null
+++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyStorage.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.storage
+
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.Result
+
+/**
+ * Interface for storing and retrieving passkey credentials.
+ *
+ * Implementations should handle encryption of sensitive data (private keys) and provide thread-safe
+ * access to stored credentials.
+ */
+public interface PasskeyStorage {
+
+ /**
+ * Lists all stored credentials, optionally filtered by Relying Party ID.
+ *
+ * @param rpId Optional RP ID to filter credentials. If null, returns all credentials.
+ * @return A list of credentials or an error
+ */
+ public suspend fun listCredentials(
+ rpId: String? = null
+ ): Result, Throwable>
+
+ /**
+ * Retrieves a specific credential by its ID.
+ *
+ * @param credentialId The unique identifier for the credential
+ * @return The credential if found, null if not found, or an error
+ */
+ public suspend fun getCredential(credentialId: ByteArray): Result
+
+ /**
+ * Stores a new credential or updates an existing one.
+ *
+ * @param credential The credential to store
+ * @return Success or an error
+ */
+ public suspend fun saveCredential(credential: PasskeyCredential): Result
+
+ /**
+ * Deletes a credential by its ID.
+ *
+ * @param credentialId The unique identifier for the credential
+ * @return True if the credential was deleted, false if it didn't exist, or an error
+ */
+ public suspend fun deleteCredential(credentialId: ByteArray): Result
+
+ /**
+ * Updates the sign count for a credential.
+ *
+ * The sign count should be incremented after each successful authentication to help detect cloned
+ * authenticators.
+ *
+ * @param credentialId The unique identifier for the credential
+ * @param newSignCount The new sign count value
+ * @return Success or an error
+ */
+ public suspend fun updateSignCount(
+ credentialId: ByteArray,
+ newSignCount: ULong,
+ ): Result
+}
+
+/**
+ * Configuration for passkey storage.
+ *
+ * @property passkeyDirectory Directory name within the repository root for storing credentials
+ * @property fileExtension File extension for credential files (e.g., ".gpg")
+ */
+public data class PasskeyStorageConfig(
+ public val passkeyDirectory: String = "fido2",
+ public val fileExtension: String = ".gpg",
+)
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/cbor/CborTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/cbor/CborTest.kt
new file mode 100644
index 0000000000..80416e08a9
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/cbor/CborTest.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.cbor
+
+import java.math.BigInteger
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CborTest {
+
+ @Test
+ fun `parse fixture credential 1`() {
+ val bytes =
+ javaClass
+ .getResourceAsStream(
+ "/fixtures/07b36924d8924098bb427039d7d0f43b86b4cb52a9dec9aab04bf47472e02d7b.bin"
+ )!!
+ .readBytes()
+ val cbor = Cbor.parse(bytes)
+ val map = cbor.asMap()
+
+ assertEquals(
+ setOf(
+ "id",
+ "rp",
+ "user",
+ "sign_count",
+ "alg",
+ "private_key",
+ "created",
+ "discoverable",
+ "extensions",
+ ),
+ map.keys,
+ )
+
+ val id = map.getBytes("id")
+ assertNotNull(id)
+ assertEquals(32, id!!.size)
+ assertEquals(0x07, id[0].toInt() and 0xFF)
+ assertEquals(0xb3, id[1].toInt() and 0xFF)
+
+ val rp = map.getMap("rp")
+ assertNotNull(rp)
+ assertEquals("webauthn.io", rp!!.getString("id"))
+ assertTrue(rp.isNull("name"))
+
+ val user = map.getMap("user")
+ assertNotNull(user)
+ val userId = user!!.getBytes("id")
+ assertNotNull(userId)
+ assertEquals("webauthnio-soft-fido2", String(userId!!, Charsets.UTF_8))
+ assertEquals("soft-fido2", user.getString("name"))
+ assertTrue(user.isNull("display_name"))
+
+ assertEquals(0, map.getInt("sign_count"))
+ assertEquals(-8, map.getInt("alg"))
+
+ val privateKey = map.getBytes("private_key")
+ assertNotNull(privateKey)
+ assertEquals(32, privateKey!!.size)
+
+ assertNotNull(map.getLong("created"))
+ assertTrue(map.getBoolean("discoverable") ?: false)
+
+ val extensions = map.getMap("extensions")
+ assertNotNull(extensions)
+ assertEquals(3, extensions!!.getInt("cred_protect"))
+ assertTrue(extensions.isNull("hmac_secret"))
+ }
+
+ @Test
+ fun `parse fixture credential 2`() {
+ val bytes =
+ javaClass
+ .getResourceAsStream(
+ "/fixtures/1381816530c267f00fb7d8a844b65f765cbbc059d8d7c695a40b7a1dea48f139.bin"
+ )!!
+ .readBytes()
+ val cbor = Cbor.parse(bytes)
+ val map = cbor.asMap()
+
+ assertEquals("webauthn.io", map.getMap("rp")?.getString("id"))
+ assertEquals("passless", map.getMap("user")?.getString("name"))
+ assertEquals(-8, map.getInt("alg"))
+ assertEquals(3, map.getMap("extensions")?.getInt("cred_protect"))
+ }
+
+ @Test
+ fun `roundtrip credential`() {
+ val originalBytes =
+ javaClass
+ .getResourceAsStream(
+ "/fixtures/07b36924d8924098bb427039d7d0f43b86b4cb52a9dec9aab04bf47472e02d7b.bin"
+ )!!
+ .readBytes()
+ val parsed = Cbor.parse(originalBytes)
+ val reencoded = parsed.toBytes()
+
+ val reparsed = Cbor.parse(reencoded)
+ val originalMap = parsed.asMap()
+ val reparsedMap = reparsed.asMap()
+
+ assertArrayEquals(originalMap.getBytes("id"), reparsedMap.getBytes("id"))
+ assertArrayEquals(originalMap.getBytes("private_key"), reparsedMap.getBytes("private_key"))
+ assertEquals(originalMap.getString("rp"), reparsedMap.getString("rp"))
+ assertEquals(originalMap.getInt("alg"), reparsedMap.getInt("alg"))
+ }
+
+ @Test
+ fun `byte array as integer array`() {
+ val bytes = byteArrayOf(0x01, 0x02, 0x03, 0xFF.toByte())
+ val cborArray = bytes.toCborIntegerArray()
+
+ val elements = cborArray.value.toList()
+ assertEquals(4, elements.size)
+ assertEquals(BigInteger.valueOf(1), (elements[0] as CborValue.UnsignedInteger).value)
+ assertEquals(BigInteger.valueOf(2), (elements[1] as CborValue.UnsignedInteger).value)
+ assertEquals(BigInteger.valueOf(3), (elements[2] as CborValue.UnsignedInteger).value)
+ assertEquals(BigInteger.valueOf(255), (elements[3] as CborValue.UnsignedInteger).value)
+
+ val recovered = cborArray.value.toByteArray()
+ assertArrayEquals(bytes, recovered)
+ }
+
+ @Test
+ fun `write and parse simple map`() {
+ val map =
+ CborMap.create().apply {
+ toMutableMap()["hello"] = CborValue.TextString("world")
+ toMutableMap()["count"] = CborValue.UnsignedInteger(BigInteger.valueOf(42))
+ toMutableMap()["negative"] = CborValue.NegativeInteger(BigInteger.valueOf(-7))
+ toMutableMap()["flag"] = CborValue.True
+ toMutableMap()["bytes"] = byteArrayOf(0x01, 0x02, 0x03).toCborIntegerArray()
+ }
+
+ val cbor = Cbor.fromMap(map)
+ val bytes = cbor.toBytes()
+
+ val parsed = Cbor.parse(bytes).asMap()
+ assertEquals("world", parsed.getString("hello"))
+ assertEquals(42, parsed.getInt("count"))
+ assertEquals(-7, parsed.getInt("negative"))
+ assertTrue(parsed.getBoolean("flag") ?: false)
+ assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), parsed.getBytes("bytes"))
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandlerEdgeCasesTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandlerEdgeCasesTest.kt
new file mode 100644
index 0000000000..fdb4f59862
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandlerEdgeCasesTest.kt
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.crypto
+
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.getOrElse
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ES256CryptoHandlerEdgeCasesTest {
+
+ private val cryptoHandler = ES256CryptoHandler()
+
+ @Test
+ fun `sign rejects empty inputs`() {
+ val (privateKey, _) = cryptoHandler.generateKeyPair()
+
+ val emptyKeyResult =
+ cryptoHandler.sign(
+ privateKey = ByteArray(0),
+ authenticatorData = ByteArray(37) { it.toByte() },
+ clientDataHash = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(emptyKeyResult.isErr, "Should reject empty private key")
+
+ val emptyAuthDataResult =
+ cryptoHandler.sign(
+ privateKey = privateKey,
+ authenticatorData = ByteArray(0),
+ clientDataHash = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(emptyAuthDataResult.isErr, "Should reject empty authenticator data")
+
+ val emptyHashResult =
+ cryptoHandler.sign(
+ privateKey = privateKey,
+ authenticatorData = ByteArray(37) { it.toByte() },
+ clientDataHash = ByteArray(0),
+ )
+ assertTrue(emptyHashResult.isErr, "Should reject empty client data hash")
+ }
+
+ @Test
+ fun `verify rejects empty signature`() {
+ val (_, publicKey) = cryptoHandler.generateKeyPair()
+ val authData = ByteArray(37) { it.toByte() }
+ val clientDataHash = ByteArray(32) { it.toByte() }
+
+ val emptySig = ByteArray(0)
+ val result = cryptoHandler.verify(publicKey, emptySig, authData, clientDataHash)
+
+ assertTrue(result.isErr, "Should reject empty signature")
+ }
+
+ @Test
+ fun `verify rejects empty inputs`() {
+ val (_, publicKey) = cryptoHandler.generateKeyPair()
+ val signature = ByteArray(70) { it.toByte() }
+
+ val emptyKeyResult =
+ cryptoHandler.verify(
+ publicKey = ByteArray(0),
+ signature = signature,
+ authenticatorData = ByteArray(37) { it.toByte() },
+ clientDataHash = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(emptyKeyResult.isErr, "Should reject empty public key")
+
+ val emptyAuthDataResult =
+ cryptoHandler.verify(
+ publicKey = publicKey,
+ signature = signature,
+ authenticatorData = ByteArray(0),
+ clientDataHash = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(emptyAuthDataResult.isErr, "Should reject empty authenticator data")
+
+ val emptyHashResult =
+ cryptoHandler.verify(
+ publicKey = publicKey,
+ signature = signature,
+ authenticatorData = ByteArray(37) { it.toByte() },
+ clientDataHash = ByteArray(0),
+ )
+ assertTrue(emptyHashResult.isErr, "Should reject empty client data hash")
+ }
+
+ @Test
+ fun `createCredential rejects blank rpId`() {
+ val result =
+ cryptoHandler.createCredential(
+ rpId = "",
+ userId = "user".toByteArray(),
+ userName = "test",
+ userDisplayName = "Test",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(result.isErr, "Should reject blank RP ID")
+
+ val whitespaceResult =
+ cryptoHandler.createCredential(
+ rpId = " ",
+ userId = "user".toByteArray(),
+ userName = "test",
+ userDisplayName = "Test",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(whitespaceResult.isErr, "Should reject whitespace RP ID")
+ }
+
+ @Test
+ fun `createCredential rejects empty userId`() {
+ val result =
+ cryptoHandler.createCredential(
+ rpId = "example.com",
+ userId = ByteArray(0),
+ userName = "test",
+ userDisplayName = "Test",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ assertTrue(result.isErr, "Should reject empty user ID")
+ }
+
+ @Test
+ fun `getAssertion rejects blank rpId`() {
+ val credential = createValidCredential()
+
+ val result =
+ cryptoHandler.getAssertion(
+ credential = credential,
+ rpId = "",
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "https://example.com",
+ )
+ assertTrue(result.isErr, "Should reject blank RP ID")
+ }
+
+ @Test
+ fun `getAssertion rejects empty challenge`() {
+ val credential = createValidCredential()
+
+ val result =
+ cryptoHandler.getAssertion(
+ credential = credential,
+ rpId = credential.rpId,
+ challenge = ByteArray(0),
+ origin = "https://example.com",
+ )
+ assertTrue(result.isErr, "Should reject empty challenge")
+ }
+
+ @Test
+ fun `getAssertion rejects blank origin`() {
+ val credential = createValidCredential()
+
+ val result =
+ cryptoHandler.getAssertion(
+ credential = credential,
+ rpId = credential.rpId,
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "",
+ )
+ assertTrue(result.isErr, "Should reject blank origin")
+ }
+
+ @Test
+ fun `signature verification fails with different data`() {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ val authData = ByteArray(37) { it.toByte() }
+ val clientDataHash = ByteArray(32) { it.toByte() }
+
+ val signature =
+ cryptoHandler.sign(privateKey, authData, clientDataHash).getOrElse {
+ throw AssertionError("Sign failed")
+ }
+
+ val differentAuthData = ByteArray(37) { (it + 1).toByte() }
+ val result = cryptoHandler.verify(publicKey, signature, differentAuthData, clientDataHash)
+
+ assertTrue(result.isOk, "Verify should complete")
+ assertFalse(result.getOrElse { true }, "Signature should not verify with different data")
+ }
+
+ @Test
+ fun `signature verification fails with different key`() {
+ val (privateKey1, _) = cryptoHandler.generateKeyPair()
+ val (_, publicKey2) = cryptoHandler.generateKeyPair()
+ val authData = ByteArray(37) { it.toByte() }
+ val clientDataHash = ByteArray(32) { it.toByte() }
+
+ val signature =
+ cryptoHandler.sign(privateKey1, authData, clientDataHash).getOrElse {
+ throw AssertionError("Sign failed")
+ }
+
+ val result = cryptoHandler.verify(publicKey2, signature, authData, clientDataHash)
+
+ assertTrue(result.isOk, "Verify should complete")
+ assertFalse(result.getOrElse { true }, "Signature should not verify with different key")
+ }
+
+ @Test
+ fun `handles maximum sign count value`() {
+ val credential = createValidCredential().copy(signCount = ULong.MAX_VALUE - 1u)
+
+ val result =
+ cryptoHandler.getAssertion(
+ credential = credential,
+ rpId = credential.rpId,
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "https://example.com",
+ )
+
+ assertTrue(result.isOk, "Should handle large sign count")
+ }
+
+ @Test
+ fun `handles large challenge`() {
+ val credential = createValidCredential()
+
+ val result =
+ cryptoHandler.getAssertion(
+ credential = credential,
+ rpId = credential.rpId,
+ challenge = ByteArray(1000) { it.toByte() },
+ origin = "https://example.com",
+ )
+
+ assertTrue(result.isOk, "Should handle large challenge")
+ }
+
+ @Test
+ fun `handles unicode in user names`() {
+ val result =
+ cryptoHandler.createCredential(
+ rpId = "example.com",
+ userId = "user".toByteArray(),
+ userName = "用户名",
+ userDisplayName = "显示名称 🎉",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+
+ assertTrue(result.isOk, "Should handle unicode in names")
+ val credential = result.getOrElse { throw AssertionError("Failed") }
+ assertTrue(credential.user.name.contains("用户名"))
+ assertTrue(credential.user.displayName.contains("🎉"))
+ }
+
+ @Test
+ fun `handles long rpId`() {
+ val longRpId = "a".repeat(253) + ".com"
+
+ val result =
+ cryptoHandler.createCredential(
+ rpId = longRpId,
+ userId = "user".toByteArray(),
+ userName = "test",
+ userDisplayName = "Test",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+
+ assertTrue(result.isOk, "Should handle long RP ID")
+ }
+
+ private fun createValidCredential(): PasskeyCredential {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ return PasskeyCredential(
+ credentialId = ByteArray(32) { it.toByte() },
+ privateKey = privateKey,
+ publicKey = publicKey,
+ rpId = "example.com",
+ user =
+ app.passwordstore.passkeys.model.FidoUser(
+ id = "user-id".toByteArray(),
+ name = "testuser",
+ displayName = "Test User",
+ ),
+ signCount = 0u,
+ createdAt = kotlinx.datetime.Clock.System.now(),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandlerTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandlerTest.kt
new file mode 100644
index 0000000000..5387cdee6f
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/crypto/ES256CryptoHandlerTest.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.crypto
+
+import com.github.michaelbull.result.getOrElse
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class ES256CryptoHandlerTest {
+
+ private val cryptoHandler = ES256CryptoHandler()
+
+ @Test
+ fun `generateKeyPair returns non-empty keys`() {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+
+ assertTrue(privateKey.isNotEmpty(), "Private key should not be empty")
+ assertTrue(publicKey.isNotEmpty(), "Public key should not be empty")
+ }
+
+ @Test
+ fun `generateKeyPair generates different keys each time`() {
+ val (privateKey1, publicKey1) = cryptoHandler.generateKeyPair()
+ val (privateKey2, publicKey2) = cryptoHandler.generateKeyPair()
+
+ assertTrue(
+ !privateKey1.contentEquals(privateKey2) || !publicKey1.contentEquals(publicKey2),
+ "Key pairs should be different",
+ )
+ }
+
+ @Test
+ fun `sign and verify work correctly`() {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ val authenticatorData = ByteArray(37) { it.toByte() }
+ val clientDataHash = ByteArray(32) { (it * 2).toByte() }
+
+ val signResult = cryptoHandler.sign(privateKey, authenticatorData, clientDataHash)
+
+ assertTrue(signResult.isOk, "Sign should succeed")
+
+ val signature = signResult.getOrElse { throw AssertionError("Sign failed") }
+ assertTrue(signature.isNotEmpty(), "Signature should not be empty")
+
+ val verifyResult = cryptoHandler.verify(publicKey, signature, authenticatorData, clientDataHash)
+
+ assertTrue(verifyResult.isOk, "Verify should succeed")
+ assertTrue(verifyResult.getOrElse { false }, "Signature should be valid")
+ }
+
+ @Test
+ fun `verify fails with wrong signature`() {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ val authenticatorData = ByteArray(37) { it.toByte() }
+ val clientDataHash = ByteArray(32) { (it * 2).toByte() }
+
+ val wrongSignature = ByteArray(70) { 0 }
+
+ val verifyResult =
+ cryptoHandler.verify(publicKey, wrongSignature, authenticatorData, clientDataHash)
+
+ val isOkOrFalse = verifyResult.isOk && !verifyResult.getOrElse { true }
+ assertTrue(
+ isOkOrFalse || verifyResult.isErr,
+ "Verify should fail or return false for wrong signature",
+ )
+ }
+
+ @Test
+ fun `createCredential returns valid credential`() {
+ val result =
+ cryptoHandler.createCredential(
+ rpId = "example.com",
+ userId = "user123".toByteArray(),
+ userName = "testuser",
+ userDisplayName = "Test User",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+
+ assertTrue(result.isOk, "Create credential should succeed")
+
+ val credential = result.getOrElse { throw AssertionError("Create credential failed") }
+ assertNotNull(credential.credentialId)
+ assertNotNull(credential.privateKey)
+ assertNotNull(credential.publicKey)
+ assertEquals("example.com", credential.rpId)
+ assertEquals("testuser", credential.user.name)
+ assertEquals("Test User", credential.user.displayName)
+ assertEquals(0u, credential.signCount)
+ }
+
+ @Test
+ fun `getAssertion returns valid assertion`() {
+ val credentialResult =
+ cryptoHandler.createCredential(
+ rpId = "example.com",
+ userId = "user123".toByteArray(),
+ userName = "testuser",
+ userDisplayName = "Test User",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+
+ val credential = credentialResult.getOrElse {
+ throw AssertionError("Credential creation failed")
+ }
+
+ val assertionResult =
+ cryptoHandler.getAssertion(
+ credential = credential,
+ rpId = "example.com",
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "https://example.com",
+ )
+
+ assertTrue(assertionResult.isOk, "Get assertion should succeed")
+
+ val assertion = assertionResult.getOrElse { throw AssertionError("Get assertion failed") }
+ assertNotNull(assertion.credentialId)
+ assertNotNull(assertion.authenticatorData)
+ assertNotNull(assertion.signature)
+ assertNotNull(assertion.clientDataJSON)
+ assertTrue(
+ assertion.clientDataJSON.contains("\"type\":\"webauthn.get\""),
+ "Client data should have correct type",
+ )
+ assertTrue(
+ assertion.clientDataJSON.contains("\"crossOrigin\":false"),
+ "Client data should have crossOrigin",
+ )
+ assertEquals(37, assertion.authenticatorData.size, "Authenticator data should be 37 bytes")
+ assertEquals(
+ 0x05,
+ assertion.authenticatorData[32].toInt() and 0xFF,
+ "Authenticator flags should set UP and UV only",
+ )
+ assertTrue(
+ assertion.signature.size in 68..72,
+ "Signature should be DER-encoded (typically 68-72 bytes)",
+ )
+ }
+
+ @Test
+ fun `sign produces DER-encoded signature`() {
+ val (privateKey, _) = cryptoHandler.generateKeyPair()
+ val authenticatorData = ByteArray(37) { it.toByte() }
+ val clientDataHash = ByteArray(32) { (it * 2).toByte() }
+
+ val signResult = cryptoHandler.sign(privateKey, authenticatorData, clientDataHash)
+
+ assertTrue(signResult.isOk, "Sign should succeed")
+ val signature = signResult.getOrElse { throw AssertionError("Sign failed") }
+ assertTrue(
+ signature.size in 68..72,
+ "DER signature should typically be 68-72 bytes, got ${signature.size}",
+ )
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/integration/PasskeyIntegrationTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/integration/PasskeyIntegrationTest.kt
new file mode 100644
index 0000000000..b0d1b8f7e9
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/integration/PasskeyIntegrationTest.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.integration
+
+import app.passwordstore.passkeys.crypto.ES256CryptoHandler
+import app.passwordstore.passkeys.model.PasskeyCredential
+import app.passwordstore.passkeys.storage.InMemoryPasskeyStorage
+import app.passwordstore.passkeys.storage.IndexedPasskeyStorage
+import com.github.michaelbull.result.getOrElse
+import kotlin.system.measureTimeMillis
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlinx.coroutines.runBlocking
+
+class PasskeyIntegrationTest {
+
+ private lateinit var storage: IndexedPasskeyStorage
+ private lateinit var cryptoHandler: ES256CryptoHandler
+
+ @BeforeTest
+ fun setup() {
+ storage = IndexedPasskeyStorage(InMemoryPasskeyStorage())
+ cryptoHandler = ES256CryptoHandler()
+ }
+
+ @AfterTest
+ fun tearDown() {
+ storage.clearIndex()
+ }
+
+ @Test
+ fun `full credential lifecycle`() = runBlocking {
+ val credential =
+ cryptoHandler
+ .createCredential(
+ rpId = "example.com",
+ userId = "user-123".toByteArray(),
+ userName = "testuser",
+ userDisplayName = "Test User",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ .getOrElse { throw AssertionError("Create failed") }
+
+ val saveResult = storage.saveCredential(credential)
+ assertTrue(saveResult.isOk, "Save should succeed")
+
+ val listResult = storage.listCredentials("example.com")
+ assertTrue(listResult.isOk)
+ val credentials = listResult.getOrElse { emptyList() }
+ assertEquals(1, credentials.size, "Should have one credential")
+
+ val getResult = storage.getCredential(credential.credentialId)
+ assertTrue(getResult.isOk)
+ val retrieved = getResult.getOrElse { null }
+ assertTrue(retrieved != null, "Should retrieve credential")
+ assertEquals(credential.credentialIdBase64(), retrieved.credentialIdBase64())
+
+ val deleteResult = storage.deleteCredential(credential.credentialId)
+ assertTrue(deleteResult.isOk && deleteResult.getOrElse { false }, "Delete should succeed")
+
+ val afterDelete = storage.getCredential(credential.credentialId)
+ assertTrue(afterDelete.isOk)
+ assertTrue(afterDelete.getOrElse { "not-null" } == null, "Should be null after delete")
+ }
+
+ @Test
+ fun `multiple credentials for same rpId`() = runBlocking {
+ val cred1 = createAndSaveCredential("example.com", "user1")
+ val cred2 = createAndSaveCredential("example.com", "user2")
+ val cred3 = createAndSaveCredential("example.com", "user3")
+
+ val listResult = storage.listCredentials("example.com")
+ assertTrue(listResult.isOk)
+ val credentials = listResult.getOrElse { emptyList() }
+ assertEquals(3, credentials.size, "Should have three credentials")
+
+ val allResult = storage.listCredentials(null)
+ assertTrue(allResult.isOk)
+ assertEquals(3, allResult.getOrElse { emptyList() }.size, "Should list all without rpId filter")
+ }
+
+ @Test
+ fun `credentials isolated by rpId`() = runBlocking {
+ val exampleCred = createAndSaveCredential("example.com", "user1")
+ val otherCred = createAndSaveCredential("other.com", "user2")
+
+ val exampleResult = storage.listCredentials("example.com")
+ assertTrue(exampleResult.isOk)
+ val exampleCreds = exampleResult.getOrElse { emptyList() }
+ assertEquals(1, exampleCreds.size)
+ assertEquals("example.com", exampleCreds[0].rpId)
+
+ val otherResult = storage.listCredentials("other.com")
+ assertTrue(otherResult.isOk)
+ val otherCreds = otherResult.getOrElse { emptyList() }
+ assertEquals(1, otherCreds.size)
+ assertEquals("other.com", otherCreds[0].rpId)
+ }
+
+ @Test
+ fun `sign count tracking`() = runBlocking {
+ val credential = createAndSaveCredential("example.com", "user1")
+ assertEquals(0u, credential.signCount)
+
+ storage.updateSignCount(credential.credentialId, 1u)
+ val afterOne = storage.getCredential(credential.credentialId).getOrElse { null }!!
+ assertEquals(1u, afterOne.signCount)
+
+ storage.updateSignCount(credential.credentialId, 42u)
+ val afterFortyTwo = storage.getCredential(credential.credentialId).getOrElse { null }!!
+ assertEquals(42u, afterFortyTwo.signCount)
+ }
+
+ @Test
+ fun `assertion with stored credential`() = runBlocking {
+ val credential = createAndSaveCredential("example.com", "testuser")
+ val challenge = ByteArray(32) { it.toByte() }
+
+ val assertion =
+ cryptoHandler
+ .getAssertion(
+ credential = credential,
+ rpId = "example.com",
+ challenge = challenge,
+ origin = "https://example.com",
+ )
+ .getOrElse { throw AssertionError("Assertion failed") }
+
+ assertEquals(
+ credential.credentialIdBase64(),
+ java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(assertion.credentialId),
+ )
+ assertTrue(
+ assertion.signature.size in 70..72,
+ "Signature should be DER-encoded (typically 70-72 bytes)",
+ )
+ assertEquals(37, assertion.authenticatorData.size, "Auth data should be 37 bytes")
+ }
+
+ @Test
+ fun `indexed storage provides fast lookups`() = runBlocking {
+ for (i in 1..100) {
+ createAndSaveCredential("example.com", "user$i")
+ }
+
+ val duration = measureTimeMillis { repeat(1000) { storage.listCredentials("example.com") } }
+
+ assertTrue(
+ duration < 1000,
+ "1000 lookups should complete in under 1 second, took ${duration}ms",
+ )
+ }
+
+ @Test
+ fun `indexed storage credential count by rpId`() = runBlocking {
+ for (i in 1..10) {
+ createAndSaveCredential("example.com", "ex$i")
+ }
+ for (i in 1..5) {
+ createAndSaveCredential("other.com", "ot$i")
+ }
+
+ assertEquals(10, storage.credentialCountForRp("example.com"))
+ assertEquals(5, storage.credentialCountForRp("other.com"))
+ assertEquals(0, storage.credentialCountForRp("nonexistent.com"))
+ assertTrue(storage.hasRpId("example.com"))
+ assertFalse(storage.hasRpId("nonexistent.com"))
+ }
+
+ @Test
+ fun `credential rotation`() = runBlocking {
+ val oldCred = createAndSaveCredential("example.com", "old-user")
+
+ storage.deleteCredential(oldCred.credentialId)
+
+ val newCred = createAndSaveCredential("example.com", "new-user")
+
+ val listResult = storage.listCredentials("example.com")
+ assertTrue(listResult.isOk)
+ val credentials = listResult.getOrElse { emptyList() }
+ assertEquals(1, credentials.size)
+ assertEquals("new-user", credentials[0].user.name)
+ }
+
+ @Test
+ fun `concurrent access safety`() = runBlocking {
+ repeat(10) { i -> createAndSaveCredential("example.com", "user$i") }
+
+ val listResult = storage.listCredentials("example.com")
+ assertTrue(listResult.isOk)
+ assertEquals(10, listResult.getOrElse { emptyList() }.size)
+ }
+
+ private suspend fun createAndSaveCredential(rpId: String, userName: String): PasskeyCredential {
+ val credential =
+ cryptoHandler
+ .createCredential(
+ rpId = rpId,
+ userId = "$userName-id".toByteArray(),
+ userName = userName,
+ userDisplayName = userName,
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ .getOrElse { throw AssertionError("Create failed") }
+
+ storage.saveCredential(credential)
+ return credential
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/FidoUserTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/FidoUserTest.kt
new file mode 100644
index 0000000000..46cf6daa1e
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/FidoUserTest.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class FidoUserTest {
+
+ @Test
+ fun `FidoUser equals works correctly`() {
+ val user1 = FidoUser(id = "user123".toByteArray(), name = "testuser", displayName = "Test User")
+
+ val user2 = FidoUser(id = "user123".toByteArray(), name = "testuser", displayName = "Test User")
+
+ val user3 = FidoUser(id = "user456".toByteArray(), name = "testuser", displayName = "Test User")
+
+ assertEquals(user1, user2, "Users with same values should be equal")
+ assertNotEquals(user1, user3, "Users with different IDs should not be equal")
+ }
+
+ @Test
+ fun `FidoUser hashCode is consistent`() {
+ val user1 = FidoUser(id = "user123".toByteArray(), name = "testuser", displayName = "Test User")
+
+ val user2 = FidoUser(id = "user123".toByteArray(), name = "testuser", displayName = "Test User")
+
+ assertEquals(user1.hashCode(), user2.hashCode(), "Equal users should have same hash code")
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/PasskeyCredentialTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/PasskeyCredentialTest.kt
new file mode 100644
index 0000000000..84e35fc18a
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/PasskeyCredentialTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+import kotlinx.datetime.Clock
+
+class PasskeyCredentialTest {
+
+ @Test
+ fun `PasskeyCredential equals works correctly`() {
+ val now = Clock.System.now()
+ val credential1 =
+ PasskeyCredential(
+ credentialId = "cred123".toByteArray(),
+ privateKey = "private".toByteArray(),
+ publicKey = "public".toByteArray(),
+ rpId = "example.com",
+ user = FidoUser("user123".toByteArray(), "testuser", "Test User"),
+ signCount = 0u,
+ createdAt = now,
+ )
+
+ val credential2 =
+ PasskeyCredential(
+ credentialId = "cred123".toByteArray(),
+ privateKey = "private".toByteArray(),
+ publicKey = "public".toByteArray(),
+ rpId = "example.com",
+ user = FidoUser("user123".toByteArray(), "testuser", "Test User"),
+ signCount = 0u,
+ createdAt = now,
+ )
+
+ val credential3 =
+ PasskeyCredential(
+ credentialId = "cred456".toByteArray(),
+ privateKey = "private".toByteArray(),
+ publicKey = "public".toByteArray(),
+ rpId = "example.com",
+ user = FidoUser("user123".toByteArray(), "testuser", "Test User"),
+ signCount = 0u,
+ createdAt = now,
+ )
+
+ assertEquals(credential1, credential2, "Credentials with same values should be equal")
+ assertNotEquals(credential1, credential3, "Credentials with different IDs should not be equal")
+ }
+
+ @Test
+ fun `incrementSignCount increases sign count by 1`() {
+ val credential =
+ PasskeyCredential(
+ credentialId = "cred123".toByteArray(),
+ privateKey = "private".toByteArray(),
+ publicKey = "public".toByteArray(),
+ rpId = "example.com",
+ user = FidoUser("user123".toByteArray(), "testuser", "Test User"),
+ signCount = 5u,
+ createdAt = Clock.System.now(),
+ )
+
+ val incremented = credential.incrementSignCount()
+
+ assertEquals(6u, incremented.signCount, "Sign count should be incremented by 1")
+ assertEquals(5u, credential.signCount, "Original credential should not be modified")
+ }
+
+ @Test
+ fun `PasskeyCredential default values are correct`() {
+ val credential =
+ PasskeyCredential(
+ credentialId = "cred123".toByteArray(),
+ privateKey = "private".toByteArray(),
+ publicKey = "public".toByteArray(),
+ rpId = "example.com",
+ user = FidoUser("user123".toByteArray(), "testuser", "Test User"),
+ createdAt = Clock.System.now(),
+ )
+
+ assertEquals(0u, credential.signCount, "Default sign count should be 0")
+ assertEquals(listOf("internal"), credential.transports, "Default transports should be internal")
+ assertTrue(credential.uvInitialized, "Default uvInitialized should be true")
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/StoredCredentialTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/StoredCredentialTest.kt
new file mode 100644
index 0000000000..577bc01013
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/StoredCredentialTest.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.model
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StoredCredentialTest {
+
+ @Test
+ fun `parse fixture credential 1`() {
+ val bytes =
+ javaClass
+ .getResourceAsStream(
+ "/fixtures/07b36924d8924098bb427039d7d0f43b86b4cb52a9dec9aab04bf47472e02d7b.bin"
+ )!!
+ .readBytes()
+ val credential = StoredCredential.fromCbor(bytes)
+
+ assertEquals(32, credential.id.size)
+ assertEquals(0x07, credential.id[0].toInt() and 0xFF)
+ assertEquals(0xb3, credential.id[1].toInt() and 0xFF)
+
+ assertEquals("webauthn.io", credential.rp.id)
+ assertNull(credential.rp.name)
+
+ assertEquals("webauthnio-soft-fido2", String(credential.user.id, Charsets.UTF_8))
+ assertEquals("soft-fido2", credential.user.name)
+ assertNull(credential.user.displayName)
+
+ assertEquals(0u, credential.signCount)
+ assertEquals(-8, credential.alg)
+ assertEquals(32, credential.privateKey.size)
+ assertTrue(credential.discoverable)
+ assertEquals(3, credential.extensions.credProtect)
+ assertNull(credential.extensions.hmacSecret)
+ }
+
+ @Test
+ fun `parse fixture credential 2`() {
+ val bytes =
+ javaClass
+ .getResourceAsStream(
+ "/fixtures/1381816530c267f00fb7d8a844b65f765cbbc059d8d7c695a40b7a1dea48f139.bin"
+ )!!
+ .readBytes()
+ val credential = StoredCredential.fromCbor(bytes)
+
+ assertEquals("webauthn.io", credential.rp.id)
+ assertEquals("passless", credential.user.name)
+ assertEquals(-8, credential.alg)
+ assertEquals(3, credential.extensions.credProtect)
+ }
+
+ @Test
+ fun `roundtrip credential`() {
+ val original =
+ StoredCredential(
+ id = byteArrayOf(0x01, 0x02, 0x03, 0x04),
+ rp = RelyingParty(id = "example.com", name = "Example Site"),
+ user =
+ User(id = byteArrayOf(0x05, 0x06, 0x07), name = "testuser", displayName = "Test User"),
+ signCount = 42u,
+ alg = StoredCredential.ALG_ES256,
+ privateKey = byteArrayOf(0x10, 0x20, 0x30, 0x40),
+ created = 1234567890L,
+ discoverable = true,
+ extensions = Extensions(credProtect = 2, hmacSecret = true),
+ )
+
+ val encoded = original.toCbor()
+ val decoded = StoredCredential.fromCbor(encoded)
+
+ assertArrayEquals(original.id, decoded.id)
+ assertEquals(original.rp, decoded.rp)
+ assertEquals(original.user, decoded.user)
+ assertEquals(original.signCount, decoded.signCount)
+ assertEquals(original.alg, decoded.alg)
+ assertArrayEquals(original.privateKey, decoded.privateKey)
+ assertEquals(original.created, decoded.created)
+ assertEquals(original.discoverable, decoded.discoverable)
+ assertEquals(original.extensions, decoded.extensions)
+ }
+
+ @Test
+ fun `credential id hex`() {
+ val credential =
+ StoredCredential(
+ id = byteArrayOf(0x01, 0x02, 0x0a, 0x0f, 0xff.toByte()),
+ rp = RelyingParty(id = "test.com"),
+ user = User(id = byteArrayOf(0x01)),
+ signCount = 0u,
+ alg = StoredCredential.ALG_ES256,
+ privateKey = byteArrayOf(0x00),
+ created = 0L,
+ )
+
+ assertEquals("01020a0fff", credential.credentialIdHex())
+ }
+
+ @Test
+ fun `minimal credential`() {
+ val original =
+ StoredCredential(
+ id = byteArrayOf(0x01, 0x02),
+ rp = RelyingParty(id = "test.com"),
+ user = User(id = byteArrayOf(0x03)),
+ signCount = 0u,
+ alg = StoredCredential.ALG_ES256,
+ privateKey = byteArrayOf(0x00),
+ created = 0L,
+ )
+
+ val encoded = original.toCbor()
+ val decoded = StoredCredential.fromCbor(encoded)
+
+ assertEquals(original, decoded)
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/InMemoryPasskeyStorageTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/InMemoryPasskeyStorageTest.kt
new file mode 100644
index 0000000000..bd96c07e1f
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/InMemoryPasskeyStorageTest.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.storage
+
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.getOrElse
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlinx.coroutines.runBlocking
+import kotlinx.datetime.Clock
+
+class InMemoryPasskeyStorageTest {
+
+ private lateinit var storage: InMemoryPasskeyStorage
+
+ @BeforeTest
+ fun setup() {
+ storage = InMemoryPasskeyStorage()
+ }
+
+ @AfterTest
+ fun tearDown() {
+ storage.clear()
+ }
+
+ @Test
+ fun `listCredentials returns empty list when empty`() = runBlocking {
+ val result = storage.listCredentials()
+ assertTrue(result.isOk)
+ val credentials = result.getOrElse { emptyList() }
+ assertTrue(credentials.isEmpty())
+ }
+
+ @Test
+ fun `saveCredential and getCredential work correctly`() = runBlocking {
+ val credential = createTestCredential()
+
+ val saveResult = storage.saveCredential(credential)
+ assertTrue(saveResult.isOk)
+
+ val getResult = storage.getCredential(credential.credentialId)
+ assertTrue(getResult.isOk)
+ val retrieved = getResult.getOrElse { null }
+ assertNotNull(retrieved)
+ assertEquals(credential.rpId, retrieved.rpId)
+ assertEquals(credential.user.name, retrieved.user.name)
+ }
+
+ @Test
+ fun `getCredential returns null for non-existent id`() = runBlocking {
+ val result = storage.getCredential("non-existent".toByteArray())
+ assertTrue(result.isOk)
+ val credential = result.getOrElse { null }
+ assertNull(credential)
+ }
+
+ @Test
+ fun `listCredentials filters by rpId`() = runBlocking {
+ val cred1 =
+ createTestCredential(
+ rpId = "example.com",
+ userName = "user1",
+ credentialId = "cred1".toByteArray(),
+ )
+ val cred2 =
+ createTestCredential(
+ rpId = "example.com",
+ userName = "user2",
+ credentialId = "cred2".toByteArray(),
+ )
+ val cred3 =
+ createTestCredential(
+ rpId = "other.com",
+ userName = "user3",
+ credentialId = "cred3".toByteArray(),
+ )
+
+ storage.saveCredential(cred1)
+ storage.saveCredential(cred2)
+ storage.saveCredential(cred3)
+
+ val result = storage.listCredentials("example.com")
+ assertTrue(result.isOk)
+ val credentials = result.getOrElse { emptyList() }
+ assertEquals(2, credentials.size)
+ assertTrue(credentials.all { it.rpId == "example.com" })
+ }
+
+ @Test
+ fun `listCredentials returns all when rpId is null`() = runBlocking {
+ val cred1 = createTestCredential(rpId = "example.com", credentialId = "cred1".toByteArray())
+ val cred2 = createTestCredential(rpId = "other.com", credentialId = "cred2".toByteArray())
+
+ storage.saveCredential(cred1)
+ storage.saveCredential(cred2)
+
+ val result = storage.listCredentials(null)
+ assertTrue(result.isOk)
+ val credentials = result.getOrElse { emptyList() }
+ assertEquals(2, credentials.size)
+ }
+
+ @Test
+ fun `deleteCredential removes credential`() = runBlocking {
+ val credential = createTestCredential()
+ storage.saveCredential(credential)
+
+ val deleteResult = storage.deleteCredential(credential.credentialId)
+ assertTrue(deleteResult.isOk)
+ assertTrue(deleteResult.getOrElse { false })
+
+ val getResult = storage.getCredential(credential.credentialId)
+ assertTrue(getResult.isOk)
+ assertNull(getResult.getOrElse { null })
+ }
+
+ @Test
+ fun `deleteCredential returns false for non-existent`() = runBlocking {
+ val result = storage.deleteCredential("non-existent".toByteArray())
+ assertTrue(result.isOk)
+ assertFalse(result.getOrElse { true })
+ }
+
+ @Test
+ fun `updateSignCount updates sign count`() = runBlocking {
+ val credential = createTestCredential()
+ storage.saveCredential(credential)
+
+ val updateResult = storage.updateSignCount(credential.credentialId, 5u)
+ assertTrue(updateResult.isOk)
+
+ val getResult = storage.getCredential(credential.credentialId)
+ assertTrue(getResult.isOk)
+ val updated = getResult.getOrElse { null }
+ assertNotNull(updated)
+ assertEquals(5u, updated.signCount)
+ }
+
+ @Test
+ fun `updateSignCount fails for non-existent credential`() = runBlocking {
+ val result = storage.updateSignCount("non-existent".toByteArray(), 1u)
+ assertTrue(result.isErr)
+ }
+
+ @Test
+ fun `count tracks stored credentials`() = runBlocking {
+ assertEquals(0, storage.count())
+
+ storage.saveCredential(createTestCredential(credentialId = "cred1".toByteArray()))
+ assertEquals(1, storage.count())
+
+ storage.saveCredential(createTestCredential(credentialId = "cred2".toByteArray()))
+ assertEquals(2, storage.count())
+
+ storage.clear()
+ assertEquals(0, storage.count())
+ }
+
+ @Test
+ fun `saveCredential overwrites existing with same id`() = runBlocking {
+ val credential = createTestCredential()
+ storage.saveCredential(credential)
+
+ val updated = credential.copy(signCount = 10u)
+ storage.saveCredential(updated)
+
+ val result = storage.getCredential(credential.credentialId)
+ assertTrue(result.isOk)
+ val retrieved = result.getOrElse { null }
+ assertNotNull(retrieved)
+ assertEquals(10u, retrieved.signCount)
+ assertEquals(1, storage.count())
+ }
+
+ private fun createTestCredential(
+ rpId: String = "example.com",
+ userName: String = "testuser",
+ credentialId: ByteArray = "test-cred-id".toByteArray(),
+ ): PasskeyCredential {
+ return PasskeyCredential(
+ credentialId = credentialId,
+ privateKey = ByteArray(32) { it.toByte() },
+ publicKey = ByteArray(65) { if (it == 0) 0x04.toByte() else it.toByte() },
+ rpId = rpId,
+ user = FidoUser(id = "user-id".toByteArray(), name = userName, displayName = "Test User"),
+ signCount = 0u,
+ createdAt = Clock.System.now(),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ }
+}
diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageTest.kt
new file mode 100644
index 0000000000..1d8ae48dc4
--- /dev/null
+++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageTest.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.storage
+
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.getOrElse
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlinx.coroutines.runBlocking
+import kotlinx.datetime.Clock
+
+class IndexedPasskeyStorageTest {
+
+ private lateinit var delegateStorage: InMemoryPasskeyStorage
+ private lateinit var indexedStorage: IndexedPasskeyStorage
+
+ @BeforeTest
+ fun setup() {
+ delegateStorage = InMemoryPasskeyStorage()
+ indexedStorage = IndexedPasskeyStorage(delegateStorage)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ indexedStorage.clearIndex()
+ delegateStorage.clear()
+ }
+
+ @Test
+ fun `index starts empty`() {
+ assertEquals(0, indexedStorage.indexedCredentialCount())
+ assertFalse(indexedStorage.hasRpId("example.com"))
+ }
+
+ @Test
+ fun `saveCredential indexes credential`() = runBlocking {
+ val credential = createTestCredential()
+
+ indexedStorage.saveCredential(credential)
+
+ assertEquals(1, indexedStorage.indexedCredentialCount())
+ assertTrue(indexedStorage.hasRpId(credential.rpId))
+ assertEquals(1, indexedStorage.credentialCountForRp(credential.rpId))
+ }
+
+ @Test
+ fun `getCredential returns from index after save`() = runBlocking {
+ val credential = createTestCredential()
+ indexedStorage.saveCredential(credential)
+
+ val result = indexedStorage.getCredential(credential.credentialId)
+
+ assertTrue(result.isOk)
+ val retrieved = result.getOrElse { null }
+ assertEquals(credential.credentialIdBase64(), retrieved?.credentialIdBase64())
+ }
+
+ @Test
+ fun `listCredentials filters by rpId from index`() = runBlocking {
+ val cred1 = createTestCredential(rpId = "example.com", credentialId = "cred1".toByteArray())
+ val cred2 = createTestCredential(rpId = "example.com", credentialId = "cred2".toByteArray())
+ val cred3 = createTestCredential(rpId = "other.com", credentialId = "cred3".toByteArray())
+
+ indexedStorage.saveCredential(cred1)
+ indexedStorage.saveCredential(cred2)
+ indexedStorage.saveCredential(cred3)
+
+ val result = indexedStorage.listCredentials("example.com")
+
+ assertTrue(result.isOk)
+ val credentials = result.getOrElse { emptyList() }
+ assertEquals(2, credentials.size)
+ assertTrue(credentials.all { it.rpId == "example.com" })
+ }
+
+ @Test
+ fun `deleteCredential removes from index`() = runBlocking {
+ val credential = createTestCredential()
+ indexedStorage.saveCredential(credential)
+
+ indexedStorage.deleteCredential(credential.credentialId)
+
+ assertEquals(0, indexedStorage.indexedCredentialCount())
+ assertFalse(indexedStorage.hasRpId(credential.rpId))
+ }
+
+ @Test
+ fun `updateSignCount updates index`() = runBlocking {
+ val credential = createTestCredential()
+ indexedStorage.saveCredential(credential)
+
+ indexedStorage.updateSignCount(credential.credentialId, 42u)
+
+ val result = indexedStorage.getCredential(credential.credentialId)
+ assertTrue(result.isOk)
+ val updated = result.getOrElse { null }
+ assertEquals(42u, updated?.signCount)
+ }
+
+ @Test
+ fun `indexedRpIds returns all rp ids`() = runBlocking {
+ indexedStorage.saveCredential(createTestCredential(rpId = "example.com"))
+ indexedStorage.saveCredential(createTestCredential(rpId = "other.com"))
+
+ val rpIds = indexedStorage.indexedRpIds()
+
+ assertEquals(setOf("example.com", "other.com"), rpIds)
+ }
+
+ @Test
+ fun `clearIndex resets everything`() = runBlocking {
+ indexedStorage.saveCredential(createTestCredential())
+
+ indexedStorage.clearIndex()
+
+ assertEquals(0, indexedStorage.indexedCredentialCount())
+ assertTrue(indexedStorage.indexedRpIds().isEmpty())
+ }
+
+ private fun createTestCredential(
+ rpId: String = "example.com",
+ userName: String = "testuser",
+ credentialId: ByteArray = "test-cred-id".toByteArray(),
+ ): PasskeyCredential {
+ return PasskeyCredential(
+ credentialId = credentialId,
+ privateKey = ByteArray(32) { it.toByte() },
+ publicKey = ByteArray(65) { if (it == 0) 0x04.toByte() else it.toByte() },
+ rpId = rpId,
+ user = FidoUser(id = "user-id".toByteArray(), name = userName, displayName = "Test User"),
+ signCount = 0u,
+ createdAt = Clock.System.now(),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ }
+}
diff --git a/passkeys/core/src/test/resources/fixtures/07b36924d8924098bb427039d7d0f43b86b4cb52a9dec9aab04bf47472e02d7b.bin b/passkeys/core/src/test/resources/fixtures/07b36924d8924098bb427039d7d0f43b86b4cb52a9dec9aab04bf47472e02d7b.bin
new file mode 100644
index 0000000000..fb82d7ddb6
Binary files /dev/null and b/passkeys/core/src/test/resources/fixtures/07b36924d8924098bb427039d7d0f43b86b4cb52a9dec9aab04bf47472e02d7b.bin differ
diff --git a/passkeys/core/src/test/resources/fixtures/1381816530c267f00fb7d8a844b65f765cbbc059d8d7c695a40b7a1dea48f139.bin b/passkeys/core/src/test/resources/fixtures/1381816530c267f00fb7d8a844b65f765cbbc059d8d7c695a40b7a1dea48f139.bin
new file mode 100644
index 0000000000..d0d9103420
Binary files /dev/null and b/passkeys/core/src/test/resources/fixtures/1381816530c267f00fb7d8a844b65f765cbbc059d8d7c695a40b7a1dea48f139.bin differ
diff --git a/passkeys/provider/build.gradle.kts b/passkeys/provider/build.gradle.kts
new file mode 100644
index 0000000000..a2fadbf249
--- /dev/null
+++ b/passkeys/provider/build.gradle.kts
@@ -0,0 +1,35 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+@file:Suppress("UnstableApiUsage")
+
+plugins {
+ id("com.github.android-password-store.android-library")
+ id("com.github.android-password-store.kotlin-android")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ defaultConfig {
+ minSdk = 26
+ consumerProguardFiles("consumer-rules.pro")
+ }
+ namespace = "app.passwordstore.passkeys.provider"
+}
+
+dependencies {
+ api(projects.passkeys.core)
+ implementation(libs.androidx.annotation)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.recyclerview)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.thirdparty.bouncycastle.bcprov)
+ implementation(libs.thirdparty.kotlinResult)
+ implementation(libs.thirdparty.logcat)
+ testImplementation(libs.bundles.testDependencies)
+}
diff --git a/passkeys/provider/consumer-rules.pro b/passkeys/provider/consumer-rules.pro
new file mode 100644
index 0000000000..87e34285f2
--- /dev/null
+++ b/passkeys/provider/consumer-rules.pro
@@ -0,0 +1,8 @@
+# Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+# SPDX-License-Identifier: GPL-3.0-only
+
+# Keep WebAuthn model classes for serialization
+-keep class app.passwordstore.passkeys.provider.** { *; }
+-keep class app.passwordstore.passkeys.model.** { *; }
+-keep class app.passwordstore.passkeys.crypto.** { *; }
+-keep class app.passwordstore.passkeys.storage.** { *; }
\ No newline at end of file
diff --git a/passkeys/provider/lint-baseline.xml b/passkeys/provider/lint-baseline.xml
new file mode 100644
index 0000000000..8cf328fb80
--- /dev/null
+++ b/passkeys/provider/lint-baseline.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyAuthenticator.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyAuthenticator.kt
new file mode 100644
index 0000000000..3cc4919afd
--- /dev/null
+++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyAuthenticator.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import androidx.fragment.app.FragmentActivity
+
+/**
+ * Interface for handling user verification before passkey operations.
+ *
+ * WebAuthn requires user verification (biometrics, PIN, etc.) before creating or using passkeys.
+ * Implementations should integrate with the platform's biometric authentication system.
+ */
+public interface PasskeyAuthenticator {
+
+ /** Result of an authentication attempt. */
+ public sealed class Result {
+ /** Authentication was successful. */
+ public data object Success : Result()
+
+ /** User canceled the authentication prompt. */
+ public data object Canceled : Result()
+
+ /** Authentication is not available on this device. */
+ public data object NotAvailable : Result()
+
+ /** Authentication failed with an error. */
+ public data class Failure(val message: String) : Result()
+ }
+
+ /**
+ * Authenticates the user before using a passkey for authentication.
+ *
+ * @param activity The activity context for showing the biometric prompt
+ * @param rpId The Relying Party ID for display purposes
+ * @return The result of the authentication attempt
+ */
+ public suspend fun authenticateForPasskey(activity: FragmentActivity, rpId: String): Result
+
+ /**
+ * Authenticates the user before creating a new passkey.
+ *
+ * @param activity The activity context for showing the biometric prompt
+ * @param rpId The Relying Party ID for display purposes
+ * @return The result of the authentication attempt
+ */
+ public suspend fun authenticateForCreation(activity: FragmentActivity, rpId: String): Result
+
+ /**
+ * Checks if biometric authentication is available on this device.
+ *
+ * @param activity The activity context
+ * @return True if authentication is possible
+ */
+ public fun canAuthenticate(activity: FragmentActivity): Boolean
+}
diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyAutofillHelper.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyAutofillHelper.kt
new file mode 100644
index 0000000000..10aad340b9
--- /dev/null
+++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyAutofillHelper.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import android.content.Context
+import android.service.autofill.Dataset
+import android.service.autofill.FillResponse
+import android.view.autofill.AutofillId
+import android.view.autofill.AutofillValue
+import android.widget.RemoteViews
+import app.passwordstore.passkeys.model.PasskeyCredential
+import app.passwordstore.passkeys.storage.PasskeyStorage
+import com.github.michaelbull.result.fold
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import logcat.LogPriority
+import logcat.logcat
+
+public object PasskeyAutofillHelper {
+
+ @Suppress("RawDispatchersUse")
+ public fun addPasskeyDatasets(
+ builder: FillResponse.Builder,
+ context: Context,
+ usernameAutofillId: AutofillId?,
+ rpId: String,
+ passkeyStorage: PasskeyStorage,
+ maxDatasets: Int = 3,
+ ): Int {
+ if (usernameAutofillId == null) return 0
+
+ val credentials =
+ runBlocking(Dispatchers.IO) {
+ passkeyStorage
+ .listCredentials(rpId)
+ .fold(
+ success = { it },
+ failure = {
+ logcat(LogPriority.WARN) { "Failed to load passkeys for $rpId: $it" }
+ emptyList()
+ },
+ )
+ }
+
+ if (credentials.isEmpty()) return 0
+
+ var datasetCount = 0
+ for (credential in credentials.take(maxDatasets)) {
+ val dataset = makePasskeyDataset(context, usernameAutofillId, credential)
+ if (dataset != null) {
+ builder.addDataset(dataset)
+ datasetCount++
+ }
+ }
+
+ return datasetCount
+ }
+
+ @Suppress("DEPRECATION")
+ private fun makePasskeyDataset(
+ context: Context,
+ usernameAutofillId: AutofillId,
+ credential: PasskeyCredential,
+ ): Dataset? {
+ val builder = Dataset.Builder(createRemoteViews(context, credential.displayNameOrName()))
+ builder.setValue(usernameAutofillId, AutofillValue.forText(credential.user.name))
+ return builder.build()
+ }
+
+ private fun createRemoteViews(context: Context, displayText: String): RemoteViews {
+ val packageName = context.packageName
+ return RemoteViews(packageName, android.R.layout.simple_list_item_1).apply {
+ setTextViewText(android.R.id.text1, "Passkey: $displayText")
+ }
+ }
+
+ @Suppress("RawDispatchersUse")
+ public fun hasPasskeysForRp(passkeyStorage: PasskeyStorage, rpId: String): Boolean {
+ return runBlocking(Dispatchers.IO) {
+ passkeyStorage.listCredentials(rpId).fold(success = { it.isNotEmpty() }, failure = { false })
+ }
+ }
+
+ public fun extractRpIdFromPackageName(packageName: String): String {
+ val parts = packageName.split(".")
+ return if (parts.size >= 2) {
+ "${parts.last()}.${parts[parts.size - 2]}"
+ } else {
+ packageName
+ }
+ }
+
+ public fun extractRpIdFromWebDomain(domain: String): String {
+ val parts = domain.removePrefix("www.").removePrefix("m.").split(".")
+ return if (parts.size >= 2) {
+ parts.takeLast(2).joinToString(".")
+ } else {
+ domain
+ }
+ }
+
+ public fun matchRpId(possibleRpIds: Collection, targetRpId: String): String? {
+ return possibleRpIds.find { it.equals(targetRpId, ignoreCase = true) }
+ ?: possibleRpIds.find { rpId ->
+ val normalizedTarget = targetRpId.lowercase().removePrefix("www.").removePrefix("m.")
+ val normalizedRp = rpId.lowercase().removePrefix("www.").removePrefix("m.")
+ normalizedTarget == normalizedRp ||
+ normalizedTarget.endsWith(".$normalizedRp") ||
+ normalizedRp.endsWith(".$normalizedTarget")
+ }
+ }
+}
diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt
new file mode 100644
index 0000000000..3f88bfcc90
--- /dev/null
+++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.graphics.drawable.Icon
+import android.os.CancellationSignal
+import android.os.OutcomeReceiver
+import androidx.annotation.RequiresApi
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.credentials.exceptions.CreateCredentialException
+import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
+import androidx.credentials.exceptions.CreateCredentialUnknownException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.GetCredentialUnknownException
+import androidx.credentials.provider.BeginCreateCredentialRequest
+import androidx.credentials.provider.BeginCreateCredentialResponse
+import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
+import androidx.credentials.provider.BeginGetCredentialRequest
+import androidx.credentials.provider.BeginGetCredentialResponse
+import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
+import androidx.credentials.provider.CreateEntry
+import androidx.credentials.provider.CredentialProviderService
+import androidx.credentials.provider.ProviderClearCredentialStateRequest
+import androidx.credentials.provider.PublicKeyCredentialEntry
+import app.passwordstore.passkeys.crypto.PasskeyCryptoHandler
+import app.passwordstore.passkeys.model.PasskeyCredential
+import app.passwordstore.passkeys.storage.PasskeyStorage
+import com.github.michaelbull.result.fold
+import java.time.Instant
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import logcat.LogPriority
+import logcat.logcat
+
+@RequiresApi(34)
+public abstract class PasskeyCredentialProviderService : CredentialProviderService() {
+
+ protected abstract val passkeyStorage: PasskeyStorage
+ protected abstract val cryptoHandler: PasskeyCryptoHandler
+ protected abstract val providerActivity: Class
+
+ override fun onCreate() {
+ super.onCreate()
+ logcat { "PasskeyCredentialProviderService created" }
+ }
+
+ final override fun onBeginGetCredentialRequest(
+ request: BeginGetCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver,
+ ) {
+ try {
+ val options =
+ request.beginGetCredentialOptions.filterIsInstance()
+ if (options.isEmpty()) {
+ callback.onError(GetCredentialUnknownException("No passkey options available"))
+ return
+ }
+
+ val entries =
+ mutableListOf().apply {
+ for (option in options) {
+ val parsedRequest =
+ PasskeyProviderUtils.json.decodeFromString(option.requestJson)
+ val rpId =
+ parsedRequest.rpId ?: parsedRequest.allowCredentials.firstNotNullOfOrNull { it.rpId }
+ if (rpId == null) {
+ logcat(LogPriority.WARN) { "Skipping passkey option without RP ID" }
+ continue
+ }
+
+ @Suppress("RawDispatchersUse")
+ val credentials =
+ runBlocking(Dispatchers.IO) {
+ passkeyStorage
+ .listCredentials(rpId)
+ .fold(
+ success = {
+ PasskeyProviderUtils.selectCredentials(it, parsedRequest.allowCredentials)
+ },
+ failure = {
+ logcat(LogPriority.ERROR) { "Failed loading passkeys for $rpId: $it" }
+ emptyList()
+ },
+ )
+ }
+
+ addAll(credentials.map { credential -> buildCredentialEntry(option, credential) })
+ }
+ }
+
+ if (entries.isEmpty()) {
+ callback.onError(GetCredentialUnknownException("No matching passkeys found"))
+ return
+ }
+
+ callback.onResult(
+ BeginGetCredentialResponse(
+ credentialEntries = entries,
+ actions = emptyList(),
+ authenticationActions = emptyList(),
+ remoteEntry = null,
+ )
+ )
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Unable to build get-credential response: $e" }
+ callback.onError(GetCredentialUnknownException(e.message ?: "Unknown passkey error"))
+ }
+ }
+
+ final override fun onBeginCreateCredentialRequest(
+ request: BeginCreateCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver,
+ ) {
+ try {
+ val createRequest = request as? BeginCreatePublicKeyCredentialRequest
+ if (createRequest == null) {
+ callback.onError(CreateCredentialNoCreateOptionException("Unsupported credential type"))
+ return
+ }
+
+ val parsedRequest =
+ PasskeyProviderUtils.json.decodeFromString(createRequest.requestJson)
+ val pendingIntent = buildCreatePendingIntent()
+ val description = parsedRequest.rp.name ?: parsedRequest.rp.id
+ val accountName =
+ parsedRequest.user.displayName ?: parsedRequest.user.name ?: parsedRequest.rp.id
+ val entry =
+ CreateEntry(
+ accountName,
+ pendingIntent,
+ description,
+ Instant.now(),
+ providerIcon(),
+ null,
+ 1,
+ 1,
+ true,
+ )
+
+ callback.onResult(
+ BeginCreateCredentialResponse(createEntries = listOf(entry), remoteEntry = null)
+ )
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { "Unable to build create-credential response: $e" }
+ callback.onError(CreateCredentialUnknownException(e.message ?: "Unknown passkey error"))
+ }
+ }
+
+ final override fun onClearCredentialStateRequest(
+ request: ProviderClearCredentialStateRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver,
+ ) {
+ callback.onResult(null)
+ }
+
+ private fun buildCredentialEntry(
+ option: BeginGetPublicKeyCredentialOption,
+ credential: PasskeyCredential,
+ ): PublicKeyCredentialEntry {
+ return PublicKeyCredentialEntry(
+ this,
+ credential.user.name,
+ buildGetPendingIntent(credential),
+ option,
+ credential.user.displayName,
+ Instant.ofEpochMilli(credential.createdAt.toEpochMilliseconds()),
+ providerIcon(),
+ true,
+ )
+ }
+
+ private fun buildGetPendingIntent(credential: PasskeyCredential): PendingIntent {
+ val intent =
+ Intent(this, providerActivity)
+ .putExtra(EXTRA_OPERATION, OPERATION_GET)
+ .putExtra(
+ EXTRA_CREDENTIAL_ID,
+ PasskeyProviderUtils.encodeBase64Url(credential.credentialId),
+ )
+ return PendingIntent.getActivity(
+ this,
+ credential.credentialId.contentHashCode(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+ }
+
+ private fun buildCreatePendingIntent(): PendingIntent {
+ val intent = Intent(this, providerActivity).putExtra(EXTRA_OPERATION, OPERATION_CREATE)
+ return PendingIntent.getActivity(
+ this,
+ OPERATION_CREATE.hashCode(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+ }
+
+ private fun providerIcon(): Icon {
+ return Icon.createWithResource(this, applicationInfo.icon)
+ }
+
+ public companion object {
+ public const val EXTRA_OPERATION: String = "passkey_operation"
+ public const val EXTRA_CREDENTIAL_ID: String = "passkey_credential_id"
+ public const val OPERATION_CREATE: String = "create"
+ public const val OPERATION_GET: String = "get"
+ }
+}
diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyPickerActivity.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyPickerActivity.kt
new file mode 100644
index 0000000000..6ff9db2cff
--- /dev/null
+++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyPickerActivity.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Gravity
+import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import app.passwordstore.passkeys.model.PasskeyCredential
+
+public class PasskeyPickerActivity : AppCompatActivity() {
+
+ private var credentials: List = emptyList()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val credentialIds = intent?.getStringArrayExtra(EXTRA_CREDENTIAL_IDS) ?: emptyArray()
+ val userNames = intent?.getStringArrayExtra(EXTRA_USER_NAMES) ?: emptyArray()
+ val displayNames = intent?.getStringArrayExtra(EXTRA_DISPLAY_NAMES) ?: emptyArray()
+ val rpId = intent?.getStringExtra(EXTRA_RP_ID) ?: ""
+
+ credentials = credentialIds.mapIndexed { index, id ->
+ CredentialSummary(
+ credentialId = id,
+ userName = userNames.getOrNull(index) ?: "",
+ displayName = displayNames.getOrNull(index) ?: "",
+ )
+ }
+
+ val recyclerView =
+ RecyclerView(this).apply {
+ layoutManager = LinearLayoutManager(this@PasskeyPickerActivity)
+ adapter =
+ CredentialAdapter(credentials) { credential ->
+ val resultIntent =
+ Intent().apply { putExtra(EXTRA_SELECTED_CREDENTIAL_ID, credential.credentialId) }
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ }
+ setPadding(16, 16, 16, 16)
+ }
+ setContentView(recyclerView)
+
+ title = rpId
+ }
+
+ private class CredentialAdapter(
+ private val credentials: List,
+ private val onCredentialSelected: (CredentialSummary) -> Unit,
+ ) : RecyclerView.Adapter() {
+
+ override fun onCreateViewHolder(
+ parent: android.view.ViewGroup,
+ viewType: Int,
+ ): CredentialViewHolder {
+ val view =
+ TextView(parent.context).apply {
+ setPadding(48, 32, 48, 32)
+ textSize = 16f
+ gravity = Gravity.START or Gravity.CENTER_VERTICAL
+ setOnClickListener { tag?.let { onCredentialSelected(it as CredentialSummary) } }
+ }
+ return CredentialViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: CredentialViewHolder, position: Int) {
+ holder.bind(credentials[position])
+ }
+
+ override fun getItemCount(): Int = credentials.size
+ }
+
+ private class CredentialViewHolder(private val textView: TextView) :
+ RecyclerView.ViewHolder(textView) {
+
+ fun bind(credential: CredentialSummary) {
+ val displayText = buildString {
+ append(credential.displayName.ifEmpty { credential.userName })
+ if (credential.displayName.isNotEmpty() && credential.userName.isNotEmpty()) {
+ append("\n")
+ append(credential.userName)
+ }
+ }
+ textView.text = displayText
+ textView.tag = credential
+ }
+ }
+
+ public data class CredentialSummary(
+ public val credentialId: String,
+ public val userName: String,
+ public val displayName: String,
+ )
+
+ public class Contract : ActivityResultContract() {
+ override fun createIntent(context: Context, input: PickerInput): Intent {
+ return Intent(context, PasskeyPickerActivity::class.java).apply {
+ putExtra(EXTRA_CREDENTIAL_IDS, input.credentialIds.toTypedArray())
+ putExtra(EXTRA_USER_NAMES, input.userNames.toTypedArray())
+ putExtra(EXTRA_DISPLAY_NAMES, input.displayNames.toTypedArray())
+ putExtra(EXTRA_RP_ID, input.rpId)
+ }
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): String? {
+ return if (resultCode == Activity.RESULT_OK) {
+ intent?.getStringExtra(EXTRA_SELECTED_CREDENTIAL_ID)
+ } else {
+ null
+ }
+ }
+ }
+
+ public data class PickerInput(
+ public val credentialIds: List,
+ public val userNames: List,
+ public val displayNames: List,
+ public val rpId: String,
+ ) {
+ public companion object {
+ public fun fromCredentials(credentials: List, rpId: String): PickerInput {
+ return PickerInput(
+ credentialIds = credentials.map { it.credentialIdBase64() },
+ userNames = credentials.map { it.user.name },
+ displayNames = credentials.map { it.user.displayName },
+ rpId = rpId,
+ )
+ }
+ }
+ }
+
+ public companion object {
+ public const val EXTRA_CREDENTIAL_IDS: String = "extra_credential_ids"
+ public const val EXTRA_USER_NAMES: String = "extra_user_names"
+ public const val EXTRA_DISPLAY_NAMES: String = "extra_display_names"
+ public const val EXTRA_RP_ID: String = "extra_rp_id"
+ public const val EXTRA_SELECTED_CREDENTIAL_ID: String = "extra_selected_credential_id"
+ }
+}
diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyProviderUtils.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyProviderUtils.kt
new file mode 100644
index 0000000000..6e6f80b7a3
--- /dev/null
+++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyProviderUtils.kt
@@ -0,0 +1,319 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import app.passwordstore.passkeys.crypto.AssertionResult
+import app.passwordstore.passkeys.crypto.ES256CryptoHandler
+import app.passwordstore.passkeys.model.PasskeyCredential
+import java.io.ByteArrayOutputStream
+import java.security.MessageDigest
+import java.util.Base64
+import kotlinx.serialization.json.Json
+
+/** Utility functions for WebAuthn/FIDO2 passkey operations. */
+public object PasskeyProviderUtils {
+
+ /** Shared JSON serializer for WebAuthn protocol messages. */
+ public val json: Json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ /** Decodes a base64url-encoded string to bytes. */
+ public fun decodeBase64Url(value: String): ByteArray {
+ return Base64.getUrlDecoder().decode(value)
+ }
+
+ /** Encodes bytes to a base64url string without padding. */
+ public fun encodeBase64Url(value: ByteArray): String {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(value)
+ }
+
+ /**
+ * Filters credentials based on the allowCredentials list from a WebAuthn request.
+ *
+ * @param credentials All available credentials
+ * @param allowCredentials List of allowed credential IDs from the request
+ * @return Filtered list of credentials
+ */
+ internal fun selectCredentials(
+ credentials: List,
+ allowCredentials: List,
+ ): List {
+ if (allowCredentials.isEmpty()) return credentials
+ val allowedIds = allowCredentials.mapTo(hashSetOf()) { it.id }
+ return credentials.filter { credential ->
+ encodeBase64Url(credential.credentialId) in allowedIds
+ }
+ }
+
+ /**
+ * Builds a WebAuthn assertion response JSON for authentication.
+ *
+ * @param assertion The assertion result from signing
+ * @param credential The credential that was used
+ * @param requestJson The original request JSON
+ * @return JSON-encoded assertion response
+ */
+ public fun buildAssertionResponse(
+ assertion: AssertionResult,
+ credential: PasskeyCredential,
+ requestJson: String,
+ ): String {
+ val request = json.decodeFromString(requestJson)
+ return buildAssertionResponse(assertion, credential, request)
+ }
+
+ internal fun buildAssertionResponse(
+ assertion: AssertionResult,
+ credential: PasskeyCredential,
+ request: WebAuthnGetRequest,
+ ): String {
+ val response =
+ AssertionResponseJson(
+ id = encodeBase64Url(assertion.credentialId),
+ rawId = encodeBase64Url(assertion.credentialId),
+ type = "public-key",
+ response =
+ AssertionResponseData(
+ clientDataJSON = encodeBase64Url(assertion.clientDataJSON.toByteArray()),
+ authenticatorData = encodeBase64Url(assertion.authenticatorData),
+ signature = encodeBase64Url(assertion.signature),
+ userHandle = assertion.userHandle?.let(::encodeBase64Url),
+ ),
+ )
+ return json.encodeToString(response)
+ }
+
+ /**
+ * Builds a WebAuthn attestation response JSON for credential creation.
+ *
+ * @param credential The newly created credential
+ * @param requestJson The original request JSON
+ * @return JSON-encoded attestation response
+ */
+ public fun buildAttestationResponse(credential: PasskeyCredential, requestJson: String): String {
+ val request = json.decodeFromString(requestJson)
+ return buildAttestationResponse(credential, request)
+ }
+
+ internal fun buildAttestationResponse(
+ credential: PasskeyCredential,
+ request: WebAuthnCreateRequest,
+ ): String {
+ val origin = "https://${credential.rpId}"
+ val clientDataJson = buildClientDataJson("webauthn.create", request.challenge, origin)
+ val coseKey = encodeCoseEcPublicKey(credential.publicKey)
+ val authData = buildAttestedAuthenticatorData(credential, coseKey)
+ val spkiPublicKey = buildSpkiPublicKey(credential.publicKey)
+ val response =
+ AttestationResponseJson(
+ id = encodeBase64Url(credential.credentialId),
+ rawId = encodeBase64Url(credential.credentialId),
+ type = "public-key",
+ response =
+ AttestationResponseData(
+ clientDataJSON = encodeBase64Url(clientDataJson.toByteArray()),
+ attestationObject = encodeBase64Url(buildAttestationObjectFromAuthData(authData)),
+ transports = listOf("internal"),
+ publicKeyAlgorithm = -7L,
+ authenticatorData = encodeBase64Url(authData),
+ publicKey = encodeBase64Url(spkiPublicKey),
+ ),
+ )
+ return json.encodeToString(response)
+ }
+
+ private fun buildSpkiPublicKey(rawPublicKey: ByteArray): ByteArray {
+ require(rawPublicKey.size == 65 && rawPublicKey.first() == 0x04.toByte()) {
+ "Expected uncompressed P-256 public key"
+ }
+ val x = rawPublicKey.copyOfRange(1, 33)
+ val y = rawPublicKey.copyOfRange(33, 65)
+ val spkiPrefix =
+ byteArrayOf(
+ 0x30,
+ 0x59,
+ 0x30,
+ 0x13,
+ 0x06,
+ 0x07,
+ 0x2A,
+ 0x86.toByte(),
+ 0x48,
+ 0xCE.toByte(),
+ 0x3D,
+ 0x02,
+ 0x01,
+ 0x06,
+ 0x08,
+ 0x2A.toByte(),
+ 0x86.toByte(),
+ 0x48,
+ 0xCE.toByte(),
+ 0x3D,
+ 0x03,
+ 0x01,
+ 0x07,
+ 0x03,
+ 0x42,
+ 0x00,
+ 0x04,
+ )
+ return spkiPrefix + x + y
+ }
+
+ private fun buildAttestationObjectFromAuthData(authData: ByteArray): ByteArray {
+ val fields =
+ listOf(
+ cborText("fmt") to cborText("none"),
+ cborText("attStmt") to cborMap(emptyList()),
+ cborText("authData") to cborBytes(authData),
+ )
+ return cborMap(fields)
+ }
+
+ private fun buildClientDataJson(type: String, challenge: String, origin: String): String {
+ return json.encodeToString(ClientDataJson(type = type, challenge = challenge, origin = origin))
+ }
+
+ private fun buildAttestedAuthenticatorData(
+ credential: PasskeyCredential,
+ coseKey: ByteArray,
+ ): ByteArray {
+ require(credential.credentialId.size <= 1023) {
+ "Credential ID too large: ${credential.credentialId.size} bytes (max 1023)"
+ }
+ require(credential.credentialId.size <= 65535) {
+ "Credential ID exceeds 16-bit length encoding: ${credential.credentialId.size}"
+ }
+ val rpIdHash = MessageDigest.getInstance("SHA-256").digest(credential.rpId.toByteArray())
+ val flags =
+ (ES256CryptoHandler.FLAG_USER_PRESENT.toInt() or
+ ES256CryptoHandler.FLAG_USER_VERIFIED.toInt() or
+ ES256CryptoHandler.FLAG_ATTESTED_CREDENTIAL_DATA.toInt())
+ .toByte()
+ val signCountBytes =
+ byteArrayOf(
+ ((credential.signCount shr 24) and 0xFFu).toByte(),
+ ((credential.signCount shr 16) and 0xFFu).toByte(),
+ ((credential.signCount shr 8) and 0xFFu).toByte(),
+ (credential.signCount and 0xFFu).toByte(),
+ )
+ val aaguid =
+ byteArrayOf(
+ 0x41,
+ 0x50,
+ 0x53,
+ 0x32,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ )
+ val credentialIdLength =
+ byteArrayOf(
+ ((credential.credentialId.size shr 8) and 0xFF).toByte(),
+ (credential.credentialId.size and 0xFF).toByte(),
+ )
+ return rpIdHash +
+ byteArrayOf(flags) +
+ signCountBytes +
+ aaguid +
+ credentialIdLength +
+ credential.credentialId +
+ coseKey
+ }
+
+ private fun encodeCoseEcPublicKey(rawPublicKey: ByteArray): ByteArray {
+ require(rawPublicKey.size == 65 && rawPublicKey.first() == 0x04.toByte()) {
+ "Expected uncompressed P-256 public key"
+ }
+ val x = rawPublicKey.copyOfRange(1, 33)
+ val y = rawPublicKey.copyOfRange(33, 65)
+ val entries =
+ listOf(
+ cborInt(1) to cborInt(2),
+ cborInt(3) to cborNegativeInt(-7),
+ cborNegativeInt(-1) to cborInt(1),
+ cborNegativeInt(-2) to cborBytes(x),
+ cborNegativeInt(-3) to cborBytes(y),
+ )
+ return cborMap(entries)
+ }
+
+ private fun cborMap(entries: List>): ByteArray {
+ val output = ByteArrayOutputStream()
+ output.write(encodeMajorType(5, entries.size.toLong()))
+ for ((key, value) in entries) {
+ output.write(key)
+ output.write(value)
+ }
+ return output.toByteArray()
+ }
+
+ private fun cborText(value: String): ByteArray {
+ val bytes = value.toByteArray()
+ return encodeMajorType(3, bytes.size.toLong()) + bytes
+ }
+
+ private fun cborBytes(value: ByteArray): ByteArray {
+ return encodeMajorType(2, value.size.toLong()) + value
+ }
+
+ private fun cborInt(value: Int): ByteArray {
+ require(value >= 0) { "CBOR unsigned integer must be non-negative, got $value" }
+ return encodeMajorType(0, value.toLong())
+ }
+
+ private fun cborNegativeInt(value: Int): ByteArray {
+ require(value < 0) { "CBOR negative integer must be negative, got $value" }
+ return encodeMajorType(1, (-1L - value))
+ }
+
+ private fun encodeMajorType(majorType: Int, value: Long): ByteArray {
+ require(value >= 0) { "Value must be non-negative, got $value" }
+ require(majorType in 0..7) { "Major type must be 0-7, got $majorType" }
+ return when {
+ value <= 23 -> byteArrayOf(((majorType shl 5) or value.toInt()).toByte())
+ value <= 0xFF -> byteArrayOf(((majorType shl 5) or 24).toByte(), value.toByte())
+ value <= 0xFFFF ->
+ byteArrayOf(
+ ((majorType shl 5) or 25).toByte(),
+ ((value shr 8) and 0xFF).toByte(),
+ (value and 0xFF).toByte(),
+ )
+ value <= 0xFFFF_FFFFL ->
+ byteArrayOf(
+ ((majorType shl 5) or 26).toByte(),
+ ((value shr 24) and 0xFF).toByte(),
+ ((value shr 16) and 0xFF).toByte(),
+ ((value shr 8) and 0xFF).toByte(),
+ (value and 0xFF).toByte(),
+ )
+ else ->
+ byteArrayOf(
+ ((majorType shl 5) or 27).toByte(),
+ ((value shr 56) and 0xFF).toByte(),
+ ((value shr 48) and 0xFF).toByte(),
+ ((value shr 40) and 0xFF).toByte(),
+ ((value shr 32) and 0xFF).toByte(),
+ ((value shr 24) and 0xFF).toByte(),
+ ((value shr 16) and 0xFF).toByte(),
+ ((value shr 8) and 0xFF).toByte(),
+ (value and 0xFF).toByte(),
+ )
+ }
+ }
+}
diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/WebAuthnModels.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/WebAuthnModels.kt
new file mode 100644
index 0000000000..9e87b825a6
--- /dev/null
+++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/WebAuthnModels.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class WebAuthnGetRequest(
+ @SerialName("rpId") val rpId: String? = null,
+ @SerialName("challenge") val challenge: String,
+ @SerialName("allowCredentials")
+ val allowCredentials: List = emptyList(),
+ @SerialName("userVerification") val userVerification: String? = null,
+ @SerialName("timeout") val timeout: Long? = null,
+ @SerialName("origin") val origin: String? = null,
+)
+
+@Serializable
+public data class WebAuthnCreateRequest(
+ @SerialName("rp") val rp: RpEntity,
+ @SerialName("user") val user: UserEntity,
+ @SerialName("challenge") val challenge: String,
+ @SerialName("pubKeyCredParams") val pubKeyCredParams: List = emptyList(),
+ @SerialName("timeout") val timeout: Long? = null,
+ @SerialName("authenticatorSelection") val authenticatorSelection: AuthenticatorSelection? = null,
+ @SerialName("attestation") val attestation: String? = null,
+)
+
+@Serializable
+public data class PublicKeyCredentialDescriptor(
+ @SerialName("type") val type: String,
+ @SerialName("id") val id: String,
+ @SerialName("transports") val transports: List? = null,
+ @SerialName("rpId") val rpId: String? = null,
+)
+
+@Serializable
+public data class RpEntity(
+ @SerialName("id") val id: String,
+ @SerialName("name") val name: String? = null,
+)
+
+@Serializable
+public data class UserEntity(
+ @SerialName("id") val id: String,
+ @SerialName("name") val name: String? = null,
+ @SerialName("displayName") val displayName: String? = null,
+)
+
+@Serializable
+public data class PubKeyCredParam(
+ @SerialName("type") val type: String,
+ @SerialName("alg") val alg: Long,
+)
+
+@Serializable
+public data class AuthenticatorSelection(
+ @SerialName("authenticatorAttachment") val authenticatorAttachment: String? = null,
+ @SerialName("residentKey") val residentKey: String? = null,
+ @SerialName("requireResidentKey") val requireResidentKey: Boolean? = null,
+ @SerialName("userVerification") val userVerification: String? = null,
+)
+
+@Serializable
+public data class AssertionResponseJson(
+ @SerialName("id") val id: String,
+ @SerialName("rawId") val rawId: String,
+ @SerialName("type") val type: String,
+ @SerialName("response") val response: AssertionResponseData,
+ @SerialName("authenticatorAttachment") val authenticatorAttachment: String = "platform",
+ @SerialName("clientExtensionResults")
+ val clientExtensionResults: ClientExtensionResults = ClientExtensionResults(),
+)
+
+@Serializable
+public data class AssertionResponseData(
+ @SerialName("clientDataJSON") val clientDataJSON: String,
+ @SerialName("authenticatorData") val authenticatorData: String,
+ @SerialName("signature") val signature: String,
+ @SerialName("userHandle") val userHandle: String? = null,
+)
+
+@Serializable
+public data class AttestationResponseJson(
+ @SerialName("id") val id: String,
+ @SerialName("rawId") val rawId: String,
+ @SerialName("type") val type: String,
+ @SerialName("response") val response: AttestationResponseData,
+ @SerialName("authenticatorAttachment") val authenticatorAttachment: String = "platform",
+ @SerialName("clientExtensionResults")
+ val clientExtensionResults: ClientExtensionResults = ClientExtensionResults(),
+)
+
+@Serializable
+public data class ClientExtensionResults(
+ @SerialName("credProps") val credProps: CredProps = CredProps()
+)
+
+@Serializable public data class CredProps(@SerialName("rk") val rk: Boolean = true)
+
+@Serializable
+public data class AttestationResponseData(
+ @SerialName("clientDataJSON") val clientDataJSON: String,
+ @SerialName("attestationObject") val attestationObject: String,
+ @SerialName("transports") val transports: List = listOf("internal"),
+ @SerialName("publicKeyAlgorithm") val publicKeyAlgorithm: Long = -7L,
+ @SerialName("authenticatorData") val authenticatorData: String,
+ @SerialName("publicKey") val publicKey: String,
+)
+
+@Serializable
+public data class ClientDataJson(
+ @SerialName("type") val type: String,
+ @SerialName("challenge") val challenge: String,
+ @SerialName("origin") val origin: String,
+ @SerialName("crossOrigin") val crossOrigin: Boolean = false,
+)
diff --git a/passkeys/provider/src/main/res/xml/passkey_provider.xml b/passkeys/provider/src/main/res/xml/passkey_provider.xml
new file mode 100644
index 0000000000..9ba55e27a1
--- /dev/null
+++ b/passkeys/provider/src/main/res/xml/passkey_provider.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/PasskeyProviderUtilsTest.kt b/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/PasskeyProviderUtilsTest.kt
new file mode 100644
index 0000000000..49d211e068
--- /dev/null
+++ b/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/PasskeyProviderUtilsTest.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import app.passwordstore.passkeys.crypto.AssertionResult
+import app.passwordstore.passkeys.crypto.ES256CryptoHandler
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.datetime.Clock
+import kotlinx.serialization.json.Json
+
+class PasskeyProviderUtilsTest {
+
+ private val json = Json { ignoreUnknownKeys = true }
+ private val cryptoHandler = ES256CryptoHandler()
+
+ @Test
+ fun `selectCredentials returns all credentials when allow list is empty`() {
+ val credentials = listOf(sampleCredential("one"), sampleCredential("two"))
+
+ val selected = PasskeyProviderUtils.selectCredentials(credentials, emptyList())
+
+ assertEquals(credentials, selected)
+ }
+
+ @Test
+ fun `selectCredentials filters by allow credential ids`() {
+ val first = sampleCredential("one")
+ val second = sampleCredential("two")
+
+ val selected =
+ PasskeyProviderUtils.selectCredentials(
+ listOf(first, second),
+ listOf(
+ PublicKeyCredentialDescriptor(
+ type = "public-key",
+ id = PasskeyProviderUtils.encodeBase64Url(second.credentialId),
+ )
+ ),
+ )
+
+ assertEquals(listOf(second), selected)
+ }
+
+ @Test
+ fun `buildAssertionResponse preserves assertion and request metadata`() {
+ val credential = sampleCredential("alice")
+ val assertion =
+ AssertionResult(
+ credentialId = credential.credentialId,
+ authenticatorData = ByteArray(37) { it.toByte() },
+ signature = ByteArray(64) { (it + 1).toByte() },
+ userHandle = credential.user.id,
+ clientDataJSON =
+ """{"type":"webauthn.get","challenge":"Y2hhbGxlbmdl","origin":"https://example.com","crossOrigin":false}""",
+ )
+
+ val responseJson =
+ PasskeyProviderUtils.buildAssertionResponse(
+ assertion,
+ credential,
+ """
+ {
+ "challenge": "Y2hhbGxlbmdl",
+ "origin": "https://example.com",
+ "allowCredentials": []
+ }
+ """
+ .trimIndent(),
+ )
+
+ val response = json.decodeFromString(AssertionResponseJson.serializer(), responseJson)
+ val clientDataJson =
+ PasskeyProviderUtils.decodeBase64Url(response.response.clientDataJSON).decodeToString()
+
+ assertEquals(PasskeyProviderUtils.encodeBase64Url(assertion.credentialId), response.id)
+ assertEquals(
+ PasskeyProviderUtils.encodeBase64Url(assertion.authenticatorData),
+ response.response.authenticatorData,
+ )
+ assertEquals(
+ PasskeyProviderUtils.encodeBase64Url(assertion.signature),
+ response.response.signature,
+ )
+ assertEquals(
+ PasskeyProviderUtils.encodeBase64Url(assertion.userHandle ?: credential.user.id),
+ response.response.userHandle,
+ )
+ assertTrue(clientDataJson.contains("\"type\":\"webauthn.get\""))
+ assertTrue(clientDataJson.contains("\"challenge\":\"Y2hhbGxlbmdl\""))
+ assertTrue(clientDataJson.contains("\"origin\":\"https://example.com\""))
+ }
+
+ @Test
+ fun `buildAttestationResponse encodes none attestation with auth data`() {
+ val credential = sampleCredential("alice")
+
+ val responseJson =
+ PasskeyProviderUtils.buildAttestationResponse(
+ credential,
+ """
+ {
+ "rp": { "id": "example.com", "name": "Example" },
+ "user": { "id": "dXNlcg", "name": "alice", "displayName": "Alice" },
+ "challenge": "Y2hhbGxlbmdl"
+ }
+ """
+ .trimIndent(),
+ )
+
+ val response = json.decodeFromString(AttestationResponseJson.serializer(), responseJson)
+ val clientDataJson =
+ PasskeyProviderUtils.decodeBase64Url(response.response.clientDataJSON).decodeToString()
+ val attestationObject =
+ PasskeyProviderUtils.decodeBase64Url(response.response.attestationObject)
+
+ assertEquals(PasskeyProviderUtils.encodeBase64Url(credential.credentialId), response.id)
+ assertTrue(clientDataJson.contains("\"type\":\"webauthn.create\""))
+ assertTrue(attestationObject.isNotEmpty())
+ assertEquals(0xA3, attestationObject.first().toInt() and 0xFF)
+ assertTrue(attestationObject.decodeToString().contains("none"))
+ assertTrue(attestationObject.indexOfSubsequence(credential.credentialId) >= 0)
+ assertTrue(attestationObject.indexOfSubsequence(credential.publicKey.copyOfRange(1, 33)) >= 0)
+ assertTrue(attestationObject.indexOfSubsequence(credential.publicKey.copyOfRange(33, 65)) >= 0)
+ }
+
+ private fun sampleCredential(userName: String): PasskeyCredential {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ return PasskeyCredential(
+ credentialId = "credential-$userName".toByteArray(),
+ privateKey = privateKey,
+ publicKey = publicKey,
+ rpId = "example.com",
+ user = FidoUser(id = "user-$userName".toByteArray(), name = userName, displayName = userName),
+ createdAt = Clock.System.now(),
+ )
+ }
+
+ private fun ByteArray.indexOfSubsequence(other: ByteArray): Int {
+ if (other.isEmpty() || other.size > size) return -1
+ for (index in 0..size - other.size) {
+ if (copyOfRange(index, index + other.size).contentEquals(other)) {
+ return index
+ }
+ }
+ return -1
+ }
+}
diff --git a/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/WebAuthnModelsTest.kt b/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/WebAuthnModelsTest.kt
new file mode 100644
index 0000000000..3816d35f72
--- /dev/null
+++ b/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/WebAuthnModelsTest.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+
+class WebAuthnModelsTest {
+
+ @Test
+ fun `WebAuthnGetRequest parses correctly`() {
+ val json =
+ """
+ {
+ "rpId": "example.com",
+ "challenge": "dGVzdC1jaGFsbGVuZ2U",
+ "allowCredentials": [
+ {"type": "public-key", "id": "Y3JlZGVudGlhbC1pZA"}
+ ],
+ "userVerification": "required"
+ }
+ """
+ .trimIndent()
+
+ val request = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals("example.com", request.rpId)
+ assertEquals("dGVzdC1jaGFsbGVuZ2U", request.challenge)
+ assertEquals(1, request.allowCredentials.size)
+ assertEquals("public-key", request.allowCredentials[0].type)
+ assertEquals("Y3JlZGVudGlhbC1pZA", request.allowCredentials[0].id)
+ assertEquals("required", request.userVerification)
+ }
+
+ @Test
+ fun `WebAuthnGetRequest handles missing optional fields`() {
+ val json =
+ """
+ {
+ "challenge": "dGVzdC1jaGFsbGVuZ2U"
+ }
+ """
+ .trimIndent()
+
+ val request = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals(null, request.rpId)
+ assertEquals("dGVzdC1jaGFsbGVuZ2U", request.challenge)
+ assertTrue(request.allowCredentials.isEmpty())
+ }
+
+ @Test
+ fun `WebAuthnCreateRequest parses correctly`() {
+ val json =
+ """
+ {
+ "rp": {"id": "example.com", "name": "Example Site"},
+ "user": {"id": "dXNlci1pZA", "name": "testuser", "displayName": "Test User"},
+ "challenge": "dGVzdC1jaGFsbGVuZ2U",
+ "pubKeyCredParams": [
+ {"type": "public-key", "alg": -7}
+ ],
+ "authenticatorSelection": {
+ "authenticatorAttachment": "platform",
+ "residentKey": "required",
+ "userVerification": "required"
+ }
+ }
+ """
+ .trimIndent()
+
+ val request = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals("example.com", request.rp.id)
+ assertEquals("Example Site", request.rp.name)
+ assertEquals("dXNlci1pZA", request.user.id)
+ assertEquals("testuser", request.user.name)
+ assertEquals("Test User", request.user.displayName)
+ assertEquals(1, request.pubKeyCredParams.size)
+ assertEquals(-7L, request.pubKeyCredParams[0].alg)
+ assertEquals("platform", request.authenticatorSelection?.authenticatorAttachment)
+ assertEquals("required", request.authenticatorSelection?.residentKey)
+ }
+
+ @Test
+ fun `AssertionResponseJson serializes correctly`() {
+ val response =
+ AssertionResponseJson(
+ id = "credential-id",
+ rawId = "credential-id",
+ type = "public-key",
+ response =
+ AssertionResponseData(
+ clientDataJSON = "client-data",
+ authenticatorData = "auth-data",
+ signature = "signature",
+ userHandle = "user-handle",
+ ),
+ )
+
+ val json = PasskeyProviderUtils.json.encodeToString(response)
+
+ assertTrue(json.contains("\"id\":\"credential-id\""))
+ assertTrue(json.contains("\"type\":\"public-key\""))
+ assertTrue(json.contains("\"clientDataJSON\":\"client-data\""))
+ assertTrue(json.contains("\"signature\":\"signature\""))
+ assertTrue(json.contains("\"userHandle\":\"user-handle\""))
+ }
+
+ @Test
+ fun `AttestationResponseJson serializes correctly`() {
+ val response =
+ AttestationResponseJson(
+ id = "credential-id",
+ rawId = "credential-id",
+ type = "public-key",
+ response =
+ AttestationResponseData(
+ clientDataJSON = "client-data",
+ attestationObject = "attestation-obj",
+ authenticatorData = "auth-data",
+ publicKey = "public-key",
+ ),
+ )
+
+ val json = PasskeyProviderUtils.json.encodeToString(response)
+
+ assertTrue(json.contains("\"id\":\"credential-id\""))
+ assertTrue(json.contains("\"type\":\"public-key\""))
+ assertTrue(json.contains("\"clientDataJSON\":\"client-data\""))
+ assertTrue(json.contains("\"attestationObject\":\"attestation-obj\""))
+ }
+
+ @Test
+ fun `ClientDataJson has correct structure`() {
+ val clientData =
+ ClientDataJson(
+ type = "webauthn.get",
+ challenge = "test-challenge",
+ origin = "https://example.com",
+ )
+
+ val json = PasskeyProviderUtils.json.encodeToString(clientData)
+
+ assertTrue(json.contains("\"type\":\"webauthn.get\""))
+ assertTrue(json.contains("\"challenge\":\"test-challenge\""))
+ assertTrue(json.contains("\"origin\":\"https://example.com\""))
+ }
+
+ @Test
+ fun `PublicKeyCredentialDescriptor handles optional fields`() {
+ val json =
+ """
+ {
+ "type": "public-key",
+ "id": "credential-id",
+ "transports": ["internal", "hybrid"]
+ }
+ """
+ .trimIndent()
+
+ val descriptor = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals("public-key", descriptor.type)
+ assertEquals("credential-id", descriptor.id)
+ assertEquals(listOf("internal", "hybrid"), descriptor.transports)
+ }
+
+ @Test
+ fun `RpEntity handles null name`() {
+ val json =
+ """
+ {
+ "id": "example.com"
+ }
+ """
+ .trimIndent()
+
+ val rp = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals("example.com", rp.id)
+ assertEquals(null, rp.name)
+ }
+
+ @Test
+ fun `UserEntity handles partial data`() {
+ val json =
+ """
+ {
+ "id": "user-id",
+ "name": "testuser"
+ }
+ """
+ .trimIndent()
+
+ val user = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals("user-id", user.id)
+ assertEquals("testuser", user.name)
+ assertEquals(null, user.displayName)
+ }
+
+ @Test
+ fun `AuthenticatorSelection handles all optional fields`() {
+ val json =
+ """
+ {
+ "userVerification": "preferred"
+ }
+ """
+ .trimIndent()
+
+ val selection = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals(null, selection.authenticatorAttachment)
+ assertEquals(null, selection.residentKey)
+ assertEquals(null, selection.requireResidentKey)
+ assertEquals("preferred", selection.userVerification)
+ }
+
+ @Test
+ fun `PubKeyCredParam handles ES256 algorithm`() {
+ val json =
+ """
+ {
+ "type": "public-key",
+ "alg": -7
+ }
+ """
+ .trimIndent()
+
+ val param = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals("public-key", param.type)
+ assertEquals(-7L, param.alg)
+ }
+
+ @Test
+ fun `multiple allowCredentials parse correctly`() {
+ val json =
+ """
+ {
+ "challenge": "test",
+ "allowCredentials": [
+ {"type": "public-key", "id": "cred1"},
+ {"type": "public-key", "id": "cred2"},
+ {"type": "public-key", "id": "cred3"}
+ ]
+ }
+ """
+ .trimIndent()
+
+ val request = PasskeyProviderUtils.json.decodeFromString(json)
+
+ assertEquals(3, request.allowCredentials.size)
+ assertEquals("cred1", request.allowCredentials[0].id)
+ assertEquals("cred2", request.allowCredentials[1].id)
+ assertEquals("cred3", request.allowCredentials[2].id)
+ }
+}
diff --git a/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/WebAuthnProtocolTest.kt b/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/WebAuthnProtocolTest.kt
new file mode 100644
index 0000000000..e76cc8085b
--- /dev/null
+++ b/passkeys/provider/src/test/kotlin/app/passwordstore/passkeys/provider/WebAuthnProtocolTest.kt
@@ -0,0 +1,286 @@
+/*
+ * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.passkeys.provider
+
+import app.passwordstore.passkeys.crypto.ES256CryptoHandler
+import app.passwordstore.passkeys.model.FidoUser
+import app.passwordstore.passkeys.model.PasskeyCredential
+import com.github.michaelbull.result.getOrElse
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.datetime.Clock
+
+class WebAuthnProtocolTest {
+
+ private val cryptoHandler = ES256CryptoHandler()
+
+ @Test
+ fun `authenticator data has correct structure for assertion`() {
+ val credential = createTestCredential()
+ val assertion =
+ cryptoHandler
+ .getAssertion(
+ credential = credential,
+ rpId = credential.rpId,
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "https://${credential.rpId}",
+ )
+ .getOrElse { throw AssertionError("Assertion failed") }
+
+ val authData = assertion.authenticatorData
+
+ assertEquals(37, authData.size, "Authenticator data should be 37 bytes")
+
+ val rpIdHash = authData.sliceArray(0..31)
+ assertEquals(32, rpIdHash.size, "RP ID hash should be 32 bytes")
+
+ val flags = authData[32].toInt() and 0xFF
+ assertTrue(flags and 0x01 != 0, "UP flag should be set")
+ assertTrue(flags and 0x04 != 0, "UV flag should be set")
+ assertTrue(flags and 0x40 == 0, "AT flag should NOT be set for assertions")
+
+ val signCount = authData.sliceArray(33..36)
+ assertEquals(4, signCount.size, "Sign count should be 4 bytes")
+ }
+
+ @Test
+ fun `attestation object has correct CBOR structure`() {
+ val credential = createTestCredential()
+ val requestJson =
+ """
+ {
+ "rp": {"id": "${credential.rpId}", "name": "Test"},
+ "user": {"id": "dXNlcg", "name": "test", "displayName": "Test User"},
+ "challenge": "Y2hhbGxlbmdl"
+ }
+ """
+ .trimIndent()
+
+ val responseJson = PasskeyProviderUtils.buildAttestationResponse(credential, requestJson)
+ val response =
+ PasskeyProviderUtils.json.decodeFromString(AttestationResponseJson.serializer(), responseJson)
+
+ assertEquals("public-key", response.type)
+ assertEquals(response.id, response.rawId)
+
+ val attestationObject =
+ PasskeyProviderUtils.decodeBase64Url(response.response.attestationObject)
+
+ assertTrue(attestationObject.size > 37, "Attestation object should contain auth data")
+
+ val firstByte = attestationObject[0].toInt() and 0xFF
+ assertTrue(firstByte in 0xA0..0xBF, "First byte should be CBOR map indicator")
+
+ val attestationString = attestationObject.decodeToString()
+ assertTrue(attestationString.contains("fmt"), "Should contain fmt field")
+ assertTrue(attestationString.contains("none"), "Should use none attestation")
+ assertTrue(attestationString.contains("authData"), "Should contain authData field")
+ }
+
+ @Test
+ fun `attested credential data is included in attestation`() {
+ val credential = createTestCredential()
+ val requestJson =
+ """
+ {
+ "rp": {"id": "${credential.rpId}", "name": "Test"},
+ "user": {"id": "dXNlcg", "name": "test", "displayName": "Test User"},
+ "challenge": "Y2hhbGxlbmdl"
+ }
+ """
+ .trimIndent()
+
+ val responseJson = PasskeyProviderUtils.buildAttestationResponse(credential, requestJson)
+ val response =
+ PasskeyProviderUtils.json.decodeFromString(AttestationResponseJson.serializer(), responseJson)
+
+ val attestationObject =
+ PasskeyProviderUtils.decodeBase64Url(response.response.attestationObject)
+
+ val authDataStart = findAuthDataInCbor(attestationObject)
+ assertTrue(authDataStart >= 0, "Should find authData in attestation object")
+
+ val flags = attestationObject[authDataStart + 32].toInt() and 0xFF
+ assertTrue(flags and 0x40 != 0, "AT flag should be set for attestation")
+ }
+
+ @Test
+ fun `client data JSON has correct format`() {
+ val credential = createTestCredential()
+ val requestJson =
+ """
+ {
+ "rp": {"id": "${credential.rpId}", "name": "Test"},
+ "user": {"id": "dXNlcg", "name": "test", "displayName": "Test User"},
+ "challenge": "test-challenge-base64"
+ }
+ """
+ .trimIndent()
+
+ val responseJson = PasskeyProviderUtils.buildAttestationResponse(credential, requestJson)
+ val response =
+ PasskeyProviderUtils.json.decodeFromString(AttestationResponseJson.serializer(), responseJson)
+
+ val clientDataJson =
+ PasskeyProviderUtils.decodeBase64Url(response.response.clientDataJSON).decodeToString()
+
+ assertTrue(clientDataJson.contains("\"type\":\"webauthn.create\""), "Should have correct type")
+ assertTrue(
+ clientDataJson.contains("\"challenge\":\"test-challenge-base64\""),
+ "Should preserve challenge",
+ )
+ assertTrue(
+ clientDataJson.contains("\"origin\":\"https://${credential.rpId}\""),
+ "Should have correct origin",
+ )
+ assertTrue(clientDataJson.contains("\"crossOrigin\":false"), "Should have crossOrigin field")
+ }
+
+ @Test
+ fun `assertion response has correct format`() {
+ val credential = createTestCredential()
+ val requestJson =
+ """
+ {
+ "challenge": "test-challenge",
+ "origin": "https://${credential.rpId}",
+ "allowCredentials": []
+ }
+ """
+ .trimIndent()
+
+ val assertion =
+ cryptoHandler
+ .getAssertion(
+ credential = credential,
+ rpId = credential.rpId,
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "https://${credential.rpId}",
+ )
+ .getOrElse { throw AssertionError("Assertion failed") }
+
+ val responseJson =
+ PasskeyProviderUtils.buildAssertionResponse(assertion, credential, requestJson)
+ val response =
+ PasskeyProviderUtils.json.decodeFromString(AssertionResponseJson.serializer(), responseJson)
+
+ assertEquals("public-key", response.type)
+ assertEquals(response.id, response.rawId)
+
+ val clientDataJson =
+ PasskeyProviderUtils.decodeBase64Url(response.response.clientDataJSON).decodeToString()
+ assertTrue(
+ clientDataJson.contains("\"type\":\"webauthn.get\""),
+ "Should have correct type for assertion",
+ )
+ assertTrue(clientDataJson.contains("\"crossOrigin\":false"), "Should have crossOrigin field")
+
+ val signatureBytes = PasskeyProviderUtils.decodeBase64Url(response.response.signature)
+ assertTrue(
+ signatureBytes.size in 70..72,
+ "Signature should be DER-encoded (typically 70-72 bytes)",
+ )
+ assertEquals(
+ 37,
+ PasskeyProviderUtils.decodeBase64Url(response.response.authenticatorData).size,
+ "Auth data should be 37 bytes",
+ )
+ }
+
+ @Test
+ fun `COSE key has correct structure for P-256`() {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ assertEquals(65, publicKey.size, "Public key should be 65 bytes uncompressed")
+ assertEquals(0x04, publicKey[0].toInt(), "Public key should start with 0x04 (uncompressed)")
+
+ val x = publicKey.sliceArray(1..32)
+ val y = publicKey.sliceArray(33..64)
+
+ assertTrue(x.any { it != 0.toByte() }, "X coordinate should not be all zeros")
+ assertTrue(y.any { it != 0.toByte() }, "Y coordinate should not be all zeros")
+ }
+
+ @Test
+ fun `credential ID is 32 bytes from SecureRandom`() {
+ val cred1 =
+ cryptoHandler
+ .createCredential(
+ rpId = "example.com",
+ userId = "user1".toByteArray(),
+ userName = "user1",
+ userDisplayName = "User One",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ .getOrElse { throw AssertionError("Failed") }
+
+ assertEquals(32, cred1.credentialId.size, "Credential ID should be 32 bytes")
+
+ val cred2 =
+ cryptoHandler
+ .createCredential(
+ rpId = "example.com",
+ userId = "user2".toByteArray(),
+ userName = "user2",
+ userDisplayName = "User Two",
+ challenge = ByteArray(32) { it.toByte() },
+ )
+ .getOrElse { throw AssertionError("Failed") }
+
+ assertTrue(
+ !cred1.credentialId.contentEquals(cred2.credentialId),
+ "Each credential should have unique ID",
+ )
+ }
+
+ @Test
+ fun `RP ID hash is SHA-256`() {
+ val rpId = "example.com"
+ val expectedHash = java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray())
+
+ val credential = createTestCredential(rpId = rpId)
+ val assertion =
+ cryptoHandler
+ .getAssertion(
+ credential = credential,
+ rpId = rpId,
+ challenge = ByteArray(32) { it.toByte() },
+ origin = "https://$rpId",
+ )
+ .getOrElse { throw AssertionError("Assertion failed") }
+
+ val actualHash = assertion.authenticatorData.sliceArray(0..31)
+ assertTrue(expectedHash.contentEquals(actualHash), "RP ID hash should match SHA-256 of RP ID")
+ }
+
+ private fun createTestCredential(
+ rpId: String = "example.com",
+ userName: String = "testuser",
+ ): PasskeyCredential {
+ val (privateKey, publicKey) = cryptoHandler.generateKeyPair()
+ return PasskeyCredential(
+ credentialId = ByteArray(32) { it.toByte() },
+ privateKey = privateKey,
+ publicKey = publicKey,
+ rpId = rpId,
+ user = FidoUser(id = "user-id".toByteArray(), name = userName, displayName = "Test User"),
+ signCount = 0u,
+ createdAt = Clock.System.now(),
+ transports = listOf("internal"),
+ uvInitialized = true,
+ )
+ }
+
+ private fun findAuthDataInCbor(data: ByteArray): Int {
+ for (i in 0..(data.size - 37)) {
+ val flags = data[i + 32].toInt() and 0xFF
+ if (flags and 0x45 == 0x45) {
+ return i
+ }
+ }
+ return -1
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ff5ec49ff5..bb97ab4f97 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -55,6 +55,7 @@ dependencyResolutionManagement {
includeGroupByRegex("androidx.*")
includeGroupByRegex("com.android.*")
includeGroup("com.google.android.gms")
+ includeGroup("com.google.android.libraries.identity.googleid")
includeModule("com.google.android.material", "material")
}
}
@@ -85,3 +86,7 @@ include("passgen:diceware")
include("passgen:random")
include("ui:compose")
+
+include("passkeys:core")
+
+include("passkeys:provider")