PeachCredentialProviderService.kt

9.2 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.BeginCreatePublicKeyCredentialRequest9import androidx.credentials.provider.BeginGetCredentialRequest10import androidx.credentials.provider.BeginGetCredentialResponse11import androidx.credentials.provider.BeginGetPasswordOption12import androidx.credentials.provider.BeginGetPublicKeyCredentialOption13import androidx.credentials.provider.CredentialEntry14import androidx.credentials.provider.CreateEntry15import androidx.credentials.provider.AuthenticationAction16import androidx.credentials.provider.CredentialProviderService17import androidx.credentials.provider.ProviderClearCredentialStateRequest18import androidx.credentials.provider.PublicKeyCredentialEntry19import androidx.credentials.provider.PasswordCredentialEntry20import android.os.OutcomeReceiver21import androidx.credentials.exceptions.ClearCredentialException22import androidx.credentials.exceptions.CreateCredentialException23import androidx.credentials.exceptions.GetCredentialException24import androidx.annotation.RequiresApi25import com.peachpasswords.R26import com.peachpasswords.autofill.appLabel27import 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),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 app-link domains and126     * display label. No trust gate — the caller package is framework-127     * authenticated (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: Set<String>138        val appTitle: String?139        if (!webOrigin.isNullOrBlank()) {140            domains = setOfNotNull(extractDomain(webOrigin))141            appTitle = null142        } else {143            domains = appLinkDomains(this, info.packageName).toSet()144            appTitle = appLabel(this, info.packageName)145        }146147        val candidates = PasswordCredentialPlanner.candidates(148            vault.getEntries(),149            domains,150            appTitle,151            option.allowedUserIds,152        )153        if (candidates.isEmpty()) return emptyList()154155        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)156        return candidates.map { candidate ->157            PasswordCredentialEntry.Builder(158                this,159                candidate.username,160                PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),161                option,162            )163                .setDisplayName(candidate.title)164                .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))165                .setIcon(icon)166                .setAutoSelectAllowed(false)167                .build()168        }169    }170171    override fun onBeginCreateCredentialRequest(172        request: BeginCreateCredentialRequest,173        cancellationSignal: CancellationSignal,174        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,175    ) {176        val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest177        if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {178            callback.onResult(BeginCreateCredentialResponse())179            return180        }181        if (!license.hasFeature(ProFeature.Passkeys)) {182            proNoticeStore.record(ProFeature.Passkeys)183            callback.onResult(BeginCreateCredentialResponse())184            return185        }186        val count = vault.getEntries().count { it.passkey != null }187        val entry = CreateEntry(188            accountName = "Peach",189            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),190            description = "Save passkey to Peach",191            lastUsedTime = Instant.now(),192            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),193            passwordCredentialCount = null,194            publicKeyCredentialCount = count,195            totalCredentialCount = vault.getEntries().size,196            isAutoSelectAllowed = false,197        )198        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))199    }200201    override fun onClearCredentialStateRequest(202        request: ProviderClearCredentialStateRequest,203        cancellationSignal: CancellationSignal,204        callback: OutcomeReceiver<Void?, ClearCredentialException>,205    ) {206        callback.onResult(null)207    }208}