PeachCredentialProviderService.kt

11.4 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.CredentialProviderService18import androidx.credentials.provider.ProviderClearCredentialStateRequest19import androidx.credentials.provider.PublicKeyCredentialEntry20import androidx.credentials.provider.PasswordCredentialEntry21import android.os.OutcomeReceiver22import androidx.credentials.exceptions.ClearCredentialException23import androidx.credentials.exceptions.CreateCredentialException24import androidx.credentials.exceptions.GetCredentialException25import androidx.annotation.RequiresApi26import com.peachpasswords.R27import com.peachpasswords.autofill.appLinkDomains28import com.peachpasswords.credentials.PasswordCredentialPlanner29import com.peachpasswords.credentials.PasswordCredentialSelectionActivity30import com.peachpasswords.license.LicenseRepository31import com.peachpasswords.license.ProFeature32import com.peachpasswords.license.ProNoticeStore33import com.peachpasswords.shared.extractDomain34import com.peachpasswords.vault.VaultRepository35import dagger.hilt.android.AndroidEntryPoint36import java.time.Instant37import javax.inject.Inject3839/**40 * Android 14+ credential provider surface for Peach.41 *42 * The Begin* callbacks MUST be answered or the Credential Manager times out and43 * reports a provider error; the previous scaffold left them uninvoked. This44 * version always completes the callbacks with a valid (possibly empty)45 * response, so Peach registers cleanly as a provider without erroring.46 *47 * Begin-get surfaces two independent families:48 * - passkeys (Pro-gated, opt-in toggle, fail-closed caller binding) —49 *   selection completes the full ES256/Ed25519 assertion or registration50 *   ceremony via PasskeyCredentialSelectionActivity;51 * - passwords (always offered, content-matched like autofill) — selection52 *   releases the credential via PasswordCredentialSelectionActivity.53 */54@AndroidEntryPoint55@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)56class PeachCredentialProviderService : CredentialProviderService() {5758    @Inject lateinit var vault: VaultRepository59    @Inject lateinit var license: LicenseRepository60    @Inject lateinit var proNoticeStore: ProNoticeStore6162    override fun onBeginGetCredentialRequest(63        request: BeginGetCredentialRequest,64        cancellationSignal: CancellationSignal,65        callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,66    ) {67        if (!vault.isUnlocked.value) {68            // Password rows are always eligible, so a locked vault always gets69            // the unlock action regardless of the passkey toggle.70            callback.onResult(71                BeginGetCredentialResponse(72                    authenticationActions = listOf(73                        AuthenticationAction(74                            title = "Unlock Peach",75                            pendingIntent = PasskeyUnlockIntent.pendingIntent(this, request),76                        ),77                    ),78                ),79            )80            return81        }82        val entries = passkeyEntries(request) + passwordEntries(request)83        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))84    }8586    private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {87        if (!vault.settings.value.passkeyProviderEnabled) return emptyList()88        if (!license.hasFeature(ProFeature.Passkeys)) {89            proNoticeStore.record(ProFeature.Passkeys)90            return emptyList()91        }92        val passkeyOptions = request.beginGetCredentialOptions93            .filterIsInstance<BeginGetPublicKeyCredentialOption>()94        if (passkeyOptions.isEmpty()) return emptyList()9596        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)97        // F1: resolve the framework-verified caller origin once (privileged98        // browser allowlist, else apk-key-hash). Unknown caller -> no rows.99        val callerOrigin = request.callingAppInfo?.let {100            PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))101        }102        return passkeyOptions.flatMap { option ->103            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->104                PublicKeyCredentialEntry(105                    context = this,106                    username = candidate.username,107                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(108                        this,109                        candidate.entryId,110                        option.requestJson,111                    ),112                    beginGetPublicKeyCredentialOption = option,113                    displayName = candidate.displayName,114                    lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),115                    icon = icon,116                    isAutoSelectAllowed = false,117                )118            }119        }120    }121122    /**123     * Password rows for the system credential picker. Matching mirrors124     * autofill: privileged-browser callers match on the web origin's125     * registrable domain; native callers match on their verified app-link126     * domains. No trust gate — the caller package is framework-authenticated127     * (queue #3 stance).128     */129    private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {130        val option = request.beginGetCredentialOptions131            .filterIsInstance<BeginGetPasswordOption>()132            .firstOrNull() ?: return emptyList()133        val info = request.callingAppInfo ?: return emptyList()134135        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)136        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()137        val domains = if (!webOrigin.isNullOrBlank()) {138            setOfNotNull(extractDomain(webOrigin))139        } else {140            appLinkDomains(this, info.packageName).toSet()141        }142143        val candidates = PasswordCredentialPlanner.candidates(144            vault.getEntries(),145            domains,146            packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },147            allowedUserIds = option.allowedUserIds,148        )149        if (candidates.isEmpty()) return emptyList()150151        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)152        return candidates.map { candidate ->153            PasswordCredentialEntry.Builder(154                this,155                candidate.username,156                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),157                option,158            )159                .setDisplayName(candidate.title)160                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))161                .setIcon(icon)162                .setAutoSelectAllowed(false)163                .build()164        }165    }166167    override fun onBeginCreateCredentialRequest(168        request: BeginCreateCredentialRequest,169        cancellationSignal: CancellationSignal,170        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,171    ) {172        if (!vault.isUnlocked.value) {173            callback.onResult(BeginCreateCredentialResponse())174            return175        }176        when (request) {177            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)178            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)179            else -> callback.onResult(BeginCreateCredentialResponse())180        }181    }182183    /**184     * "Save password to Peach" in the system save sheet. Not Pro-gated and not185     * toggle-gated — saving is a core capability, exactly like the autofill186     * save flow. The actual add/update decision happens at tap time in187     * PasswordCredentialSelectionActivity via planAutofillSave.188     */189    private fun passwordCreateEntry(190        request: BeginCreatePasswordCredentialRequest,191        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,192    ) {193        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants194        // are @RestrictTo-internal in Kotlin but are the stable wire keys the195        // library serializes create requests with — read them directly.196        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")197        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")198        if (id.isNullOrBlank() || password.isNullOrBlank()) {199            callback.onResult(BeginCreateCredentialResponse())200            return201        }202        val entries = vault.getEntries()203        val entry = CreateEntry(204            accountName = "Peach",205            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),206            description = "Save password to Peach",207            lastUsedTime = Instant.now(),208            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),209            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },210            publicKeyCredentialCount = entries.count { it.passkey != null },211            totalCredentialCount = entries.size,212            isAutoSelectAllowed = false,213        )214        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))215    }216217    private fun passkeyCreateEntry(218        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,219        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,220    ) {221        if (!vault.settings.value.passkeyProviderEnabled) {222            callback.onResult(BeginCreateCredentialResponse())223            return224        }225        if (!license.hasFeature(ProFeature.Passkeys)) {226            proNoticeStore.record(ProFeature.Passkeys)227            callback.onResult(BeginCreateCredentialResponse())228            return229        }230        val count = vault.getEntries().count { it.passkey != null }231        val entry = CreateEntry(232            accountName = "Peach",233            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),234            description = "Save passkey to Peach",235            lastUsedTime = Instant.now(),236            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),237            passwordCredentialCount = null,238            publicKeyCredentialCount = count,239            totalCredentialCount = vault.getEntries().size,240            isAutoSelectAllowed = false,241        )242        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))243    }244245    override fun onClearCredentialStateRequest(246        request: ProviderClearCredentialStateRequest,247        cancellationSignal: CancellationSignal,248        callback: OutcomeReceiver<Void?, ClearCredentialException>,249    ) {250        callback.onResult(null)251    }252}