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()) {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                    requireCredentialManagerVerification = true,127                )128            }129        }130    }131132    /**133     * Password rows for the system credential picker. Matching mirrors134     * autofill: privileged-browser callers match on the web origin's135     * registrable domain; native callers match on their verified app-link136     * domains. No trust gate — the caller package is framework-authenticated137     * (queue #3 stance).138     */139    private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {140        val option = request.beginGetCredentialOptions141            .filterIsInstance<BeginGetPasswordOption>()142            .firstOrNull() ?: return emptyList()143        val info = request.callingAppInfo ?: return emptyList()144145        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)146        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()147        val domains = if (!webOrigin.isNullOrBlank()) {148            setOfNotNull(extractDomain(webOrigin))149        } else {150            appLinkDomains(this, info.packageName).toSet()151        }152153        val candidates = PasswordCredentialPlanner.candidates(154            vault.getEntries(),155            domains,156            packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },157            allowedUserIds = option.allowedUserIds,158        )159        if (candidates.isEmpty()) return emptyList()160161        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)162        return candidates.map { candidate ->163            PasswordCredentialEntry.Builder(164                this,165                candidate.username,166                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),167                option,168            )169                .setDisplayName(candidate.title)170                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))171                .setIcon(icon)172                .setAutoSelectAllowed(false)173                .build()174        }175    }176177    override fun onBeginCreateCredentialRequest(178        request: BeginCreateCredentialRequest,179        cancellationSignal: CancellationSignal,180        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,181    ) {182        if (!vault.isUnlocked.value) {183            callback.onResult(BeginCreateCredentialResponse())184            return185        }186        when (request) {187            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)188            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)189            else -> callback.onResult(BeginCreateCredentialResponse())190        }191    }192193    /**194     * "Save password to Peach" in the system save sheet. Not Pro-gated and not195     * toggle-gated — saving is a core capability, exactly like the autofill196     * save flow. The actual add/update decision happens at tap time in197     * PasswordCredentialSelectionActivity via planAutofillSave.198     */199    private fun passwordCreateEntry(200        request: BeginCreatePasswordCredentialRequest,201        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,202    ) {203        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants204        // are @RestrictTo-internal in Kotlin but are the stable wire keys the205        // library serializes create requests with — read them directly.206        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")207        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")208        if (id.isNullOrBlank() || password.isNullOrBlank()) {209            callback.onResult(BeginCreateCredentialResponse())210            return211        }212        val entries = vault.getEntries()213        val entry = CreateEntry(214            accountName = "Peach",215            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),216            description = "Save password to Peach",217            lastUsedTime = Instant.now(),218            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),219            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },220            publicKeyCredentialCount = entries.count { it.passkey != null },221            totalCredentialCount = entries.size,222            isAutoSelectAllowed = false,223        )224        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))225    }226227    private fun passkeyCreateEntry(228        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,229        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,230    ) {231        if (!vault.settings.value.passkeyProviderEnabled) {232            callback.onResult(BeginCreateCredentialResponse())233            return234        }235        if (!license.hasFeature()) {236            proNoticeStore.record(ProFeature.Passkeys)237            callback.onResult(BeginCreateCredentialResponse())238            return239        }240        val count = vault.getEntries().count { it.passkey != null }241        val entry = CreateEntry(242            accountName = "Peach",243            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),244            description = "Save passkey to Peach",245            lastUsedTime = Instant.now(),246            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),247            passwordCredentialCount = null,248            publicKeyCredentialCount = count,249            totalCredentialCount = vault.getEntries().size,250            isAutoSelectAllowed = false,251        )252        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))253    }254255    override fun onClearCredentialStateRequest(256        request: ProviderClearCredentialStateRequest,257        cancellationSignal: CancellationSignal,258        callback: OutcomeReceiver<Void?, ClearCredentialException>,259    ) {260        callback.onResult(null)261    }262}