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")