PeachCredentialProviderService.kt

11.6 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.appLabel28import 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),77                        ),78                    ),79                ),80            )81            return82        }83        val entries = passkeyEntries(request) + passwordEntries(request)84        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))85    }8687    private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {88        if (!vault.settings.value.passkeyProviderEnabled) return emptyList()89        if (!license.hasFeature(ProFeature.Passkeys)) {90            proNoticeStore.record(ProFeature.Passkeys)91            return emptyList()92        }93        val passkeyOptions = request.beginGetCredentialOptions94            .filterIsInstance<BeginGetPublicKeyCredentialOption>()95        if (passkeyOptions.isEmpty()) return emptyList()9697        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)98        // F1: resolve the framework-verified caller origin once (privileged99        // browser allowlist, else apk-key-hash). Unknown caller -> no rows.100        val callerOrigin = request.callingAppInfo?.let {101            PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))102        }103        return passkeyOptions.flatMap { option ->104            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->105                PublicKeyCredentialEntry(106                    context = this,107                    username = candidate.username,108                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(109                        this,110                        candidate.entryId,111                        option.requestJson,112                    ),113                    beginGetPublicKeyCredentialOption = option,114                    displayName = candidate.displayName,115                    lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),116                    icon = icon,117                    isAutoSelectAllowed = false,118                )119            }120        }121    }122123    /**124     * Password rows for the system credential picker. Matching mirrors125     * autofill: privileged-browser callers match on the web origin's126     * registrable domain; native callers match on their app-link domains and127     * display label. No trust gate — the caller package is framework-128     * authenticated (queue #3 stance).129     */130    private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {131        val option = request.beginGetCredentialOptions132            .filterIsInstance<BeginGetPasswordOption>()133            .firstOrNull() ?: return emptyList()134        val info = request.callingAppInfo ?: return emptyList()135136        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)137        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()138        val domains: Set<String>139        val appTitle: String?140        if (!webOrigin.isNullOrBlank()) {141            domains = setOfNotNull(extractDomain(webOrigin))142            appTitle = null143        } else {144            domains = appLinkDomains(this, info.packageName).toSet()145            appTitle = appLabel(this, info.packageName)146        }147148        val candidates = PasswordCredentialPlanner.candidates(149            vault.getEntries(),150            domains,151            appTitle,152            option.allowedUserIds,153        )154        if (candidates.isEmpty()) return emptyList()155156        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)157        return candidates.map { candidate ->158            PasswordCredentialEntry.Builder(159                this,160                candidate.username,161                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),162                option,163            )164                .setDisplayName(candidate.title)165                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))166                .setIcon(icon)167                .setAutoSelectAllowed(false)168                .build()169        }170    }171172    override fun onBeginCreateCredentialRequest(173        request: BeginCreateCredentialRequest,174        cancellationSignal: CancellationSignal,175        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,176    ) {177        if (!vault.isUnlocked.value) {178            callback.onResult(BeginCreateCredentialResponse())179            return180        }181        when (request) {182            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)183            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)184            else -> callback.onResult(BeginCreateCredentialResponse())185        }186    }187188    /**189     * "Save password to Peach" in the system save sheet. Not Pro-gated and not190     * toggle-gated — saving is a core capability, exactly like the autofill191     * save flow. The actual add/update decision happens at tap time in192     * PasswordCredentialSelectionActivity via planAutofillSave.193     */194    private fun passwordCreateEntry(195        request: BeginCreatePasswordCredentialRequest,196        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,197    ) {198        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants199        // are @RestrictTo-internal in Kotlin but are the stable wire keys the200        // library serializes create requests with — read them directly.201        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")202        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")203        if (id.isNullOrBlank() || password.isNullOrBlank()) {204            callback.onResult(BeginCreateCredentialResponse())205            return206        }207        val entries = vault.getEntries()208        val entry = CreateEntry(209            accountName = "Peach",210            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),211            description = "Save password to Peach",212            lastUsedTime = Instant.now(),213            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),214            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },215            publicKeyCredentialCount = entries.count { it.passkey != null },216            totalCredentialCount = entries.size,217            isAutoSelectAllowed = false,218        )219        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))220    }221222    private fun passkeyCreateEntry(223        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,224        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,225    ) {226        if (!vault.settings.value.passkeyProviderEnabled) {227            callback.onResult(BeginCreateCredentialResponse())228            return229        }230        if (!license.hasFeature(ProFeature.Passkeys)) {231            proNoticeStore.record(ProFeature.Passkeys)232            callback.onResult(BeginCreateCredentialResponse())233            return234        }235        val count = vault.getEntries().count { it.passkey != null }236        val entry = CreateEntry(237            accountName = "Peach",238            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),239            description = "Save passkey to Peach",240            lastUsedTime = Instant.now(),241            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),242            passwordCredentialCount = null,243            publicKeyCredentialCount = count,244            totalCredentialCount = vault.getEntries().size,245            isAutoSelectAllowed = false,246        )247        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))248    }249250    override fun onClearCredentialStateRequest(251        request: ProviderClearCredentialStateRequest,252        cancellationSignal: CancellationSignal,253        callback: OutcomeReceiver<Void?, ClearCredentialException>,254    ) {255        callback.onResult(null)256    }257}