PeachCredentialProviderService.kt

12.1 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            val origin = request.callingAppInfo?.let {87                PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))88            }89            listOf(90                Action(91                    "Choose another Peach passkey",92                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request, origin),93                    "Search eligible passkeys",94                ),95            )96        } else emptyList()97        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries, actions = actions))98    }99100    private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {101        if (!vault.settings.value.passkeyProviderEnabled) return emptyList()102        if (!license.hasFeature(ProFeature.Passkeys)) {103            proNoticeStore.record(ProFeature.Passkeys)104            return emptyList()105        }106        val passkeyOptions = request.beginGetCredentialOptions107            .filterIsInstance<BeginGetPublicKeyCredentialOption>()108        if (passkeyOptions.isEmpty()) return emptyList()109110        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)111        // F1: resolve the framework-verified caller origin once (privileged112        // browser allowlist, else apk-key-hash). Unknown caller -> no rows.113        val callerOrigin = request.callingAppInfo?.let {114            PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))115        }116        return passkeyOptions.flatMap { option ->117            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->118                PublicKeyCredentialEntry(119                    context = this,120                    username = candidate.username,121                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(122                        this,123                        candidate.entryId,124                        option.requestJson,125                        callerOrigin,126                        option.clientDataHash,127                    ),128                    beginGetPublicKeyCredentialOption = option,129                    displayName = candidate.displayName,130                    lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),131                    icon = icon,132                    isAutoSelectAllowed = false,133                )134            }135        }136    }137138    /**139     * Password rows for the system credential picker. Matching mirrors140     * autofill: privileged-browser callers match on the web origin's141     * registrable domain; native callers match on their verified app-link142     * domains. No trust gate — the caller package is framework-authenticated143     * (queue #3 stance).144     */145    private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {146        val option = request.beginGetCredentialOptions147            .filterIsInstance<BeginGetPasswordOption>()148            .firstOrNull() ?: return emptyList()149        val info = request.callingAppInfo ?: return emptyList()150151        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)152        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()153        val domains = if (!webOrigin.isNullOrBlank()) {154            setOfNotNull(extractDomain(webOrigin))155        } else {156            appLinkDomains(this, info.packageName).toSet()157        }158159        val candidates = PasswordCredentialPlanner.candidates(160            vault.getEntries(),161            domains,162            packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },163            allowedUserIds = option.allowedUserIds,164        )165        if (candidates.isEmpty()) return emptyList()166167        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)168        return candidates.map { candidate ->169            PasswordCredentialEntry.Builder(170                this,171                candidate.username,172                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),173                option,174            )175                .setDisplayName(candidate.title)176                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))177                .setIcon(icon)178                .setAutoSelectAllowed(false)179                .build()180        }181    }182183    override fun onBeginCreateCredentialRequest(184        request: BeginCreateCredentialRequest,185        cancellationSignal: CancellationSignal,186        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,187    ) {188        if (!vault.isUnlocked.value) {189            callback.onResult(BeginCreateCredentialResponse())190            return191        }192        when (request) {193            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)194            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)195            else -> callback.onResult(BeginCreateCredentialResponse())196        }197    }198199    /**200     * "Save password to Peach" in the system save sheet. Not Pro-gated and not201     * toggle-gated — saving is a core capability, exactly like the autofill202     * save flow. The actual add/update decision happens at tap time in203     * PasswordCredentialSelectionActivity via planAutofillSave.204     */205    private fun passwordCreateEntry(206        request: BeginCreatePasswordCredentialRequest,207        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,208    ) {209        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants210        // are @RestrictTo-internal in Kotlin but are the stable wire keys the211        // library serializes create requests with — read them directly.212        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")213        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")214        if (id.isNullOrBlank() || password.isNullOrBlank()) {215            callback.onResult(BeginCreateCredentialResponse())216            return217        }218        val entries = vault.getEntries()219        val entry = CreateEntry(220            accountName = "Peach",221            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),222            description = "Save password to Peach",223            lastUsedTime = Instant.now(),224            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),225            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },226            publicKeyCredentialCount = entries.count { it.passkey != null },227            totalCredentialCount = entries.size,228            isAutoSelectAllowed = false,229        )230        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))231    }232233    private fun passkeyCreateEntry(234        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,235        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,236    ) {237        if (!vault.settings.value.passkeyProviderEnabled) {238            callback.onResult(BeginCreateCredentialResponse())239            return240        }241        if (!license.hasFeature(ProFeature.Passkeys)) {242            proNoticeStore.record(ProFeature.Passkeys)243            callback.onResult(BeginCreateCredentialResponse())244            return245        }246        val count = vault.getEntries().count { it.passkey != null }247        val entry = CreateEntry(248            accountName = "Peach",249            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),250            description = "Save passkey to Peach",251            lastUsedTime = Instant.now(),252            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),253            passwordCredentialCount = null,254            publicKeyCredentialCount = count,255            totalCredentialCount = vault.getEntries().size,256            isAutoSelectAllowed = false,257        )258        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))259    }260261    override fun onClearCredentialStateRequest(262        request: ProviderClearCredentialStateRequest,263        cancellationSignal: CancellationSignal,264        callback: OutcomeReceiver<Void?, ClearCredentialException>,265    ) {266        callback.onResult(null)267    }268}