PeachCredentialProviderService.kt

11.5 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                        callerOrigin,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 verified app-link127     * domains. No trust gate — the caller package is framework-authenticated128     * (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 = if (!webOrigin.isNullOrBlank()) {139            setOfNotNull(extractDomain(webOrigin))140        } else {141            appLinkDomains(this, info.packageName).toSet()142        }143144        val candidates = PasswordCredentialPlanner.candidates(145            vault.getEntries(),146            domains,147            packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },148            allowedUserIds = option.allowedUserIds,149        )150        if (candidates.isEmpty()) return emptyList()151152        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)153        return candidates.map { candidate ->154            PasswordCredentialEntry.Builder(155                this,156                candidate.username,157                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),158                option,159            )160                .setDisplayName(candidate.title)161                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))162                .setIcon(icon)163                .setAutoSelectAllowed(false)164                .build()165        }166    }167168    override fun onBeginCreateCredentialRequest(169        request: BeginCreateCredentialRequest,170        cancellationSignal: CancellationSignal,171        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,172    ) {173        if (!vault.isUnlocked.value) {174            callback.onResult(BeginCreateCredentialResponse())175            return176        }177        when (request) {178            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)179            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)180            else -> callback.onResult(BeginCreateCredentialResponse())181        }182    }183184    /**185     * "Save password to Peach" in the system save sheet. Not Pro-gated and not186     * toggle-gated — saving is a core capability, exactly like the autofill187     * save flow. The actual add/update decision happens at tap time in188     * PasswordCredentialSelectionActivity via planAutofillSave.189     */190    private fun passwordCreateEntry(191        request: BeginCreatePasswordCredentialRequest,192        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,193    ) {194        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants195        // are @RestrictTo-internal in Kotlin but are the stable wire keys the196        // library serializes create requests with — read them directly.197        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")198        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")199        if (id.isNullOrBlank() || password.isNullOrBlank()) {200            callback.onResult(BeginCreateCredentialResponse())201            return202        }203        val entries = vault.getEntries()204        val entry = CreateEntry(205            accountName = "Peach",206            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),207            description = "Save password to Peach",208            lastUsedTime = Instant.now(),209            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),210            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },211            publicKeyCredentialCount = entries.count { it.passkey != null },212            totalCredentialCount = entries.size,213            isAutoSelectAllowed = false,214        )215        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))216    }217218    private fun passkeyCreateEntry(219        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,220        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,221    ) {222        if (!vault.settings.value.passkeyProviderEnabled) {223            callback.onResult(BeginCreateCredentialResponse())224            return225        }226        if (!license.hasFeature(ProFeature.Passkeys)) {227            proNoticeStore.record(ProFeature.Passkeys)228            callback.onResult(BeginCreateCredentialResponse())229            return230        }231        val count = vault.getEntries().count { it.passkey != null }232        val entry = CreateEntry(233            accountName = "Peach",234            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),235            description = "Save passkey to Peach",236            lastUsedTime = Instant.now(),237            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),238            passwordCredentialCount = null,239            publicKeyCredentialCount = count,240            totalCredentialCount = vault.getEntries().size,241            isAutoSelectAllowed = false,242        )243        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))244    }245246    override fun onClearCredentialStateRequest(247        request: ProviderClearCredentialStateRequest,248        cancellationSignal: CancellationSignal,249        callback: OutcomeReceiver<Void?, ClearCredentialException>,250    ) {251        callback.onResult(null)252    }253}