PeachCredentialProviderService.kt

11.8 KB · raw

1package com.peachpasswords.passkeys23import android.os.Build4import android.os.CancellationSignal5import android.graphics.drawable.Icon6import androidx.credentials.provider.BeginCreateCredentialRequest7import androidx.credentials.provider.BeginCreateCredentialResponse8import androidx.credentials.provider.BeginCreatePasswordCredentialRequest9import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest10import androidx.credentials.provider.BeginGetCredentialRequest11import androidx.credentials.provider.BeginGetCredentialResponse12import androidx.credentials.provider.BeginGetPasswordOption13import androidx.credentials.provider.BeginGetPublicKeyCredentialOption14import androidx.credentials.provider.CredentialEntry15import androidx.credentials.provider.CreateEntry16import androidx.credentials.provider.AuthenticationAction17import androidx.credentials.provider.Action18import androidx.credentials.provider.CredentialProviderService19import androidx.credentials.provider.ProviderClearCredentialStateRequest20import androidx.credentials.provider.PublicKeyCredentialEntry21import androidx.credentials.provider.PasswordCredentialEntry22import android.os.OutcomeReceiver23import androidx.credentials.exceptions.ClearCredentialException24import androidx.credentials.exceptions.CreateCredentialException25import androidx.credentials.exceptions.GetCredentialException26import androidx.annotation.RequiresApi27import com.peachpasswords.R28import com.peachpasswords.autofill.appLinkDomains29import com.peachpasswords.credentials.PasswordCredentialPlanner30import com.peachpasswords.credentials.PasswordCredentialSelectionActivity31import com.peachpasswords.license.LicenseRepository32import com.peachpasswords.license.ProFeature33import com.peachpasswords.license.ProNoticeStore34import com.peachpasswords.shared.extractDomain35import com.peachpasswords.vault.VaultRepository36import dagger.hilt.android.AndroidEntryPoint37import java.time.Instant38import javax.inject.Inject3940/**41 * Android 14+ credential provider surface for Peach.42 *43 * The Begin* callbacks MUST be answered or the Credential Manager times out and44 * reports a provider error; the previous scaffold left them uninvoked. This45 * version always completes the callbacks with a valid (possibly empty)46 * response, so Peach registers cleanly as a provider without erroring.47 *48 * Begin-get surfaces two independent families:49 * - passkeys (Pro-gated, opt-in toggle, fail-closed caller binding) —50 *   selection completes the full ES256/Ed25519 assertion or registration51 *   ceremony via PasskeyCredentialSelectionActivity;52 * - passwords (always offered, content-matched like autofill) — selection53 *   releases the credential via PasswordCredentialSelectionActivity.54 */55@AndroidEntryPoint56@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)57class PeachCredentialProviderService : CredentialProviderService() {5859    @Inject lateinit var vault: VaultRepository60    @Inject lateinit var license: LicenseRepository61    @Inject lateinit var proNoticeStore: ProNoticeStore6263    override fun onBeginGetCredentialRequest(64        request: BeginGetCredentialRequest,65        cancellationSignal: CancellationSignal,66        callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,67    ) {68        if (!vault.isUnlocked.value) {69            // Password rows are always eligible, so a locked vault always gets70            // the unlock action regardless of the passkey toggle.71            callback.onResult(72                BeginGetCredentialResponse(73                    authenticationActions = listOf(74                        AuthenticationAction(75                            title = "Unlock Peach",76                            pendingIntent = PasskeyUnlockIntent.pendingIntent(this, request),77                        ),78                    ),79                ),80            )81            return82        }83        val passkeys = passkeyEntries(request)84        val entries = passkeys + passwordEntries(request)85        val actions = if (passkeys.isNotEmpty()) {86            listOf(87                Action(88                    "Choose another Peach passkey",89                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request),90                    "Search eligible passkeys",91                ),92            )93        } else emptyList()94        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries, actions = actions))95    }9697    private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {98        if (!vault.settings.value.passkeyProviderEnabled) return emptyList()99        if (!license.hasFeature(ProFeature.Passkeys)) {100            proNoticeStore.record(ProFeature.Passkeys)101            return emptyList()102        }103        val passkeyOptions = request.beginGetCredentialOptions104            .filterIsInstance<BeginGetPublicKeyCredentialOption>()105        if (passkeyOptions.isEmpty()) return emptyList()106107        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)108        // F1: resolve the framework-verified caller origin once (privileged109        // browser allowlist, else apk-key-hash). Unknown caller -> no rows.110        val callerOrigin = request.callingAppInfo?.let {111            PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))112        }113        return passkeyOptions.flatMap { option ->114            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->115                PasskeyCredentialEntryFactory.create(116                    context = this,117                    username = candidate.username,118                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(119                        this,120                        candidate.entryId,121                    ),122                    option = option,123                    displayName = candidate.displayName,124                    lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),125                    icon = icon,126                )127            }128        }129    }130131    /**132     * Password rows for the system credential picker. Matching mirrors133     * autofill: privileged-browser callers match on the web origin's134     * registrable domain; native callers match on their verified app-link135     * domains. No trust gate — the caller package is framework-authenticated136     * (queue #3 stance).137     */138    private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {139        val option = request.beginGetCredentialOptions140            .filterIsInstance<BeginGetPasswordOption>()141            .firstOrNull() ?: return emptyList()142        val info = request.callingAppInfo ?: return emptyList()143144        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)145        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()146        val domains = if (!webOrigin.isNullOrBlank()) {147            setOfNotNull(extractDomain(webOrigin))148        } else {149            appLinkDomains(this, info.packageName).toSet()150        }151152        val candidates = PasswordCredentialPlanner.candidates(153            vault.getEntries(),154            domains,155            packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },156            allowedUserIds = option.allowedUserIds,157        )158        if (candidates.isEmpty()) return emptyList()159160        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)161        return candidates.map { candidate ->162            PasswordCredentialEntry.Builder(163                this,164                candidate.username,165                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),166                option,167            )168                .setDisplayName(candidate.title)169                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))170                .setIcon(icon)171                .setAutoSelectAllowed(false)172                .build()173        }174    }175176    override fun onBeginCreateCredentialRequest(177        request: BeginCreateCredentialRequest,178        cancellationSignal: CancellationSignal,179        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,180    ) {181        if (!vault.isUnlocked.value) {182            callback.onResult(BeginCreateCredentialResponse())183            return184        }185        when (request) {186            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)187            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)188            else -> callback.onResult(BeginCreateCredentialResponse())189        }190    }191192    /**193     * "Save password to Peach" in the system save sheet. Not Pro-gated and not194     * toggle-gated — saving is a core capability, exactly like the autofill195     * save flow. The actual add/update decision happens at tap time in196     * PasswordCredentialSelectionActivity via planAutofillSave.197     */198    private fun passwordCreateEntry(199        request: BeginCreatePasswordCredentialRequest,200        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,201    ) {202        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants203        // are @RestrictTo-internal in Kotlin but are the stable wire keys the204        // library serializes create requests with — read them directly.205        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")206        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")207        if (id.isNullOrBlank() || password.isNullOrBlank()) {208            callback.onResult(BeginCreateCredentialResponse())209            return210        }211        val entries = vault.getEntries()212        val entry = CreateEntry(213            accountName = "Peach",214            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),215            description = "Save password to Peach",216            lastUsedTime = Instant.now(),217            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),218            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },219            publicKeyCredentialCount = entries.count { it.passkey != null },220            totalCredentialCount = entries.size,221            isAutoSelectAllowed = false,222        )223        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))224    }225226    private fun passkeyCreateEntry(227        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,228        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,229    ) {230        if (!vault.settings.value.passkeyProviderEnabled) {231            callback.onResult(BeginCreateCredentialResponse())232            return233        }234        if (!license.hasFeature(ProFeature.Passkeys)) {235            proNoticeStore.record(ProFeature.Passkeys)236            callback.onResult(BeginCreateCredentialResponse())237            return238        }239        val count = vault.getEntries().count { it.passkey != null }240        val entry = CreateEntry(241            accountName = "Peach",242            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),243            description = "Save passkey to Peach",244            lastUsedTime = Instant.now(),245            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),246            passwordCredentialCount = null,247            publicKeyCredentialCount = count,248            totalCredentialCount = vault.getEntries().size,249            isAutoSelectAllowed = false,250        )251        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))252    }253254    override fun onClearCredentialStateRequest(255        request: ProviderClearCredentialStateRequest,256        cancellationSignal: CancellationSignal,257        callback: OutcomeReceiver<Void?, ClearCredentialException>,258    ) {259        callback.onResult(null)260    }261}