c3d88387bfd8
feat: Surface passkeys in Credential Provider (v2.3.42)
- time
- 2026-06-26 13:39 UTC · 4 months ago
- machine
- koh2
- parent
- 93c2fbea8f2c
↓ peach-android-c3d88387.tar.gzatlas at this save
files changed6 files
app/build.gradle+2 −2app/src/main/AndroidManifest.xml+6 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialPlanner.kt+59 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+39 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+42 −7app/src/test/java/com/peachpasswords/passkeys/PasskeyCredentialPlannerTest.kt+62 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23410020- versionName "2.3.41"19+ versionCode 23420020+ versionName "2.3.42"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/AndroidManifest.xml+6 −0
@@ -58,6 +58,12 @@5858 android:excludeFromRecents="true"5959 android:theme="@style/Theme.Peach.Transparent" />6060 61+ <activity62+ android:name=".passkeys.PasskeyCredentialSelectionActivity"63+ android:exported="false"64+ android:excludeFromRecents="true"65+ android:theme="@style/Theme.Peach.Transparent" />66+6167 <provider6268 android:name="androidx.core.content.FileProvider"6369 android:authorities="${applicationId}.fileprovider"app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialPlanner.kt+59 −0
@@ -0,0 +1,59 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.VaultEntry4+import kotlinx.serialization.json.Json5+import kotlinx.serialization.json.contentOrNull6+import kotlinx.serialization.json.jsonArray7+import kotlinx.serialization.json.jsonObject8+import kotlinx.serialization.json.jsonPrimitive9+10+data class PasskeyCredentialCandidate(11+ val entryId: String,12+ val title: String,13+ val rpId: String,14+ val credentialId: String,15+ val username: String,16+ val displayName: String,17+ val lastUsedAt: Long?,18+)19+20+object PasskeyCredentialPlanner {21+ private val json = Json { ignoreUnknownKeys = true }22+23+ fun candidates(entries: List<VaultEntry>, requestJson: String): List<PasskeyCredentialCandidate> {24+ val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return emptyList()25+ val requestedRpId = request["rpId"]?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return emptyList()26+ val allowedIds = runCatching { request["allowCredentials"]?.jsonArray }.getOrNull()27+ ?.mapNotNull { item ->28+ runCatching { item.jsonObject["id"]?.jsonPrimitive?.contentOrNull }.getOrNull()29+ }30+ ?.filter { it.isNotBlank() }31+ ?.toSet()32+33+ return entries.asSequence()34+ .filter { it.deletedAt == null && it.passkey != null }35+ .mapNotNull { entry ->36+ val passkey = entry.passkey ?: return@mapNotNull null37+ val rpId = passkey.rpId.trim().lowercase()38+ val credentialId = passkey.credentialId39+ if (rpId != requestedRpId) return@mapNotNull null40+ if (!allowedIds.isNullOrEmpty() && credentialId !in allowedIds) return@mapNotNull null41+ val username = passkey.userName.ifBlank { entry.username ?: entry.email ?: entry.title }42+ PasskeyCredentialCandidate(43+ entryId = entry.id,44+ title = entry.title,45+ rpId = passkey.rpId,46+ credentialId = credentialId,47+ username = username,48+ displayName = passkey.userDisplayName?.takeIf { it.isNotBlank() } ?: entry.title,49+ lastUsedAt = entry.lastUsedAt,50+ )51+ }52+ .sortedWith(53+ compareByDescending<PasskeyCredentialCandidate> { it.lastUsedAt ?: Long.MIN_VALUE }54+ .thenBy { it.displayName.lowercase() }55+ .thenBy { it.username.lowercase() },56+ )57+ .toList()58+ }59+}app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+39 −0
@@ -0,0 +1,39 @@1+package com.peachpasswords.passkeys2+3+import android.app.Activity4+import android.app.PendingIntent5+import android.content.Context6+import android.content.Intent7+import android.os.Build8+import android.os.Bundle9+import androidx.credentials.exceptions.GetCredentialUnsupportedException10+import androidx.credentials.provider.PendingIntentHandler11+12+class PasskeyCredentialSelectionActivity : Activity() {13+ override fun onCreate(savedInstanceState: Bundle?) {14+ super.onCreate(savedInstanceState)15+ val result = Intent()16+ PendingIntentHandler.setGetCredentialException(17+ result,18+ GetCredentialUnsupportedException("Peach passkey signing is not available yet."),19+ )20+ setResult(RESULT_OK, result)21+ finish()22+ }23+24+ companion object {25+ private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"26+27+ fun pendingIntent(context: Context, entryId: String): PendingIntent {28+ val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)29+ .putExtra(EXTRA_ENTRY_ID, entryId)30+ val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 031+ return PendingIntent.getActivity(32+ context,33+ entryId.hashCode(),34+ intent,35+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,36+ )37+ }38+ }39+}app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+42 −7
@@ -2,17 +2,25 @@22 33 import android.os.Build44 import android.os.CancellationSignal5+import android.graphics.drawable.Icon56 import androidx.credentials.provider.BeginCreateCredentialRequest67 import androidx.credentials.provider.BeginCreateCredentialResponse78 import androidx.credentials.provider.BeginGetCredentialRequest89 import androidx.credentials.provider.BeginGetCredentialResponse10+import androidx.credentials.provider.BeginGetPublicKeyCredentialOption911 import androidx.credentials.provider.CredentialProviderService1012 import androidx.credentials.provider.ProviderClearCredentialStateRequest13+import androidx.credentials.provider.PublicKeyCredentialEntry1114 import android.os.OutcomeReceiver1215 import androidx.credentials.exceptions.ClearCredentialException1316 import androidx.credentials.exceptions.CreateCredentialException1417 import androidx.credentials.exceptions.GetCredentialException1518 import androidx.annotation.RequiresApi19+import com.peachpasswords.R20+import com.peachpasswords.vault.VaultRepository21+import dagger.hilt.android.AndroidEntryPoint22+import java.time.Instant23+import javax.inject.Inject1624 1725 /**1826 * Android 14+ credential provider surface for Peach passkeys.@@ -22,21 +30,48 @@2230 * version always completes the callbacks with a valid (currently empty)2331 * response, so Peach registers cleanly as a provider without erroring.2432 *25- * Surfacing real passkey entries and running the WebAuthn ceremony is gated on26- * one data-model addition: [com.peachpasswords.vault.PasskeyData] stores only27- * the public COSE key, so an assertion (get) cannot be signed yet. Completing28- * passkeys means persisting the per-credential ES256 private key in the vault29- * envelope, then emitting PublicKeyCredentialEntry rows here with a30- * PendingIntent into the same branded sheet the autofill picker uses.33+ * Begin-get now surfaces unlocked, matching passkeys as system credential rows.34+ * The selected pending intent still returns an explicit unsupported result until35+ * the ES256/WebAuthn assertion ceremony lands.3136 */37+@AndroidEntryPoint3238 @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)3339 class PeachCredentialProviderService : CredentialProviderService() {40+41+ @Inject lateinit var vault: VaultRepository42+3443 override fun onBeginGetCredentialRequest(3544 request: BeginGetCredentialRequest,3645 cancellationSignal: CancellationSignal,3746 callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,3847 ) {39- callback.onResult(BeginGetCredentialResponse())48+ if (!vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {49+ callback.onResult(BeginGetCredentialResponse())50+ return51+ }52+ val passkeyOptions = request.beginGetCredentialOptions53+ .filterIsInstance<BeginGetPublicKeyCredentialOption>()54+ if (passkeyOptions.isEmpty()) {55+ callback.onResult(BeginGetCredentialResponse())56+ return57+ }58+59+ val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)60+ val entries = passkeyOptions.flatMap { option ->61+ PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson).map { candidate ->62+ PublicKeyCredentialEntry(63+ context = this,64+ username = candidate.username,65+ pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(this, candidate.entryId),66+ beginGetPublicKeyCredentialOption = option,67+ displayName = candidate.displayName,68+ lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),69+ icon = icon,70+ isAutoSelectAllowed = false,71+ )72+ }73+ }74+ callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))4075 }4176 4277 override fun onBeginCreateCredentialRequest(app/src/test/java/com/peachpasswords/passkeys/PasskeyCredentialPlannerTest.kt+62 −0
@@ -0,0 +1,62 @@1+package com.peachpasswords.passkeys2+3+import com.peachpasswords.vault.PasskeyData4+import com.peachpasswords.vault.VaultEntry5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+class PasskeyCredentialPlannerTest {10+ @Test fun matches_passkeys_by_exact_rp_id() {11+ val matches = PasskeyCredentialPlanner.candidates(12+ listOf(13+ passkey("a", rpId = "example.com", username = "june"),14+ passkey("b", rpId = "evil-example.com", username = "lookalike"),15+ ),16+ """{"challenge":"c","rpId":"example.com"}""",17+ )18+19+ assertEquals(listOf("a"), matches.map { it.entryId })20+ assertEquals("june", matches.single().username)21+ }22+23+ @Test fun narrows_to_allow_credentials_when_present() {24+ val matches = PasskeyCredentialPlanner.candidates(25+ listOf(26+ passkey("a", credentialId = "cred-a", lastUsedAt = 10),27+ passkey("b", credentialId = "cred-b", lastUsedAt = 20),28+ ),29+ """{"challenge":"c","rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cred-a"}]}""",30+ )31+32+ assertEquals(listOf("a"), matches.map { it.entryId })33+ }34+35+ @Test fun empty_or_invalid_requests_offer_no_credentials() {36+ assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), """{"challenge":"c"}""").isEmpty())37+ assertTrue(PasskeyCredentialPlanner.candidates(listOf(passkey("a")), "not-json").isEmpty())38+ }39+40+ private fun passkey(41+ id: String,42+ rpId: String = "example.com",43+ credentialId: String = "cred-$id",44+ username: String = "user-$id",45+ lastUsedAt: Long? = null,46+ ) = VaultEntry(47+ id = id,48+ type = "passkey",49+ title = "Passkey $id",50+ passkey = PasskeyData(51+ credentialId = credentialId,52+ publicKeyCose = "cose-$id",53+ rpId = rpId,54+ userHandle = "handle-$id",55+ userName = username,56+ userDisplayName = "User $id",57+ ),58+ lastUsedAt = lastUsedAt,59+ createdAt = 1,60+ modifiedAt = 1,61+ )62+}