package com.peachpasswords.passkeys import android.os.Build import android.os.CancellationSignal import android.graphics.drawable.Icon import androidx.credentials.provider.BeginCreateCredentialRequest import androidx.credentials.provider.BeginCreateCredentialResponse import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.BeginGetPasswordOption import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.CreateEntry import androidx.credentials.provider.AuthenticationAction import androidx.credentials.provider.CredentialProviderService import androidx.credentials.provider.ProviderClearCredentialStateRequest import androidx.credentials.provider.PublicKeyCredentialEntry import androidx.credentials.provider.PasswordCredentialEntry import android.os.OutcomeReceiver import androidx.credentials.exceptions.ClearCredentialException import androidx.credentials.exceptions.CreateCredentialException import androidx.credentials.exceptions.GetCredentialException import androidx.annotation.RequiresApi import com.peachpasswords.R import com.peachpasswords.autofill.appLabel import com.peachpasswords.autofill.appLinkDomains import com.peachpasswords.credentials.PasswordCredentialPlanner import com.peachpasswords.credentials.PasswordCredentialSelectionActivity import com.peachpasswords.license.LicenseRepository import com.peachpasswords.license.ProFeature import com.peachpasswords.license.ProNoticeStore import com.peachpasswords.shared.extractDomain import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.AndroidEntryPoint import java.time.Instant import javax.inject.Inject /** * Android 14+ credential provider surface for Peach. * * The Begin* callbacks MUST be answered or the Credential Manager times out and * reports a provider error; the previous scaffold left them uninvoked. This * version always completes the callbacks with a valid (possibly empty) * response, so Peach registers cleanly as a provider without erroring. * * Begin-get surfaces two independent families: * - passkeys (Pro-gated, opt-in toggle, fail-closed caller binding) — * selection completes the full ES256/Ed25519 assertion or registration * ceremony via PasskeyCredentialSelectionActivity; * - passwords (always offered, content-matched like autofill) — selection * releases the credential via PasswordCredentialSelectionActivity. */ @AndroidEntryPoint @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) class PeachCredentialProviderService : CredentialProviderService() { @Inject lateinit var vault: VaultRepository @Inject lateinit var license: LicenseRepository @Inject lateinit var proNoticeStore: ProNoticeStore override fun onBeginGetCredentialRequest( request: BeginGetCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { if (!vault.isUnlocked.value) { // Password rows are always eligible, so a locked vault always gets // the unlock action regardless of the passkey toggle. callback.onResult( BeginGetCredentialResponse( authenticationActions = listOf( AuthenticationAction( title = "Unlock Peach", pendingIntent = PasskeyUnlockIntent.pendingIntent(this), ), ), ), ) return } val entries = passkeyEntries(request) + passwordEntries(request) callback.onResult(BeginGetCredentialResponse(credentialEntries = entries)) } private fun passkeyEntries(request: BeginGetCredentialRequest): List { if (!vault.settings.value.passkeyProviderEnabled) return emptyList() if (!license.hasFeature(ProFeature.Passkeys)) { proNoticeStore.record(ProFeature.Passkeys) return emptyList() } val passkeyOptions = request.beginGetCredentialOptions .filterIsInstance() if (passkeyOptions.isEmpty()) return emptyList() val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark) // F1: resolve the framework-verified caller origin once (privileged // browser allowlist, else apk-key-hash). Unknown caller -> no rows. val callerOrigin = request.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this)) } return passkeyOptions.flatMap { option -> PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate -> PublicKeyCredentialEntry( context = this, username = candidate.username, pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent( this, candidate.entryId, option.requestJson, ), beginGetPublicKeyCredentialOption = option, displayName = candidate.displayName, lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L), icon = icon, isAutoSelectAllowed = false, ) } } } /** * Password rows for the system credential picker. Matching mirrors * autofill: privileged-browser callers match on the web origin's * registrable domain; native callers match on their app-link domains and * display label. No trust gate — the caller package is framework- * authenticated (queue #3 stance). */ private fun passwordEntries(request: BeginGetCredentialRequest): List { val option = request.beginGetCredentialOptions .filterIsInstance() .firstOrNull() ?: return emptyList() val info = request.callingAppInfo ?: return emptyList() val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this) val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull() val domains: Set val appTitle: String? if (!webOrigin.isNullOrBlank()) { domains = setOfNotNull(extractDomain(webOrigin)) appTitle = null } else { domains = appLinkDomains(this, info.packageName).toSet() appTitle = appLabel(this, info.packageName) } val candidates = PasswordCredentialPlanner.candidates( vault.getEntries(), domains, appTitle, option.allowedUserIds, ) if (candidates.isEmpty()) return emptyList() val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark) return candidates.map { candidate -> PasswordCredentialEntry.Builder( this, candidate.username, PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId), option, ) .setDisplayName(candidate.title) .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L)) .setIcon(icon) .setAutoSelectAllowed(false) .build() } } override fun onBeginCreateCredentialRequest( request: BeginCreateCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) { callback.onResult(BeginCreateCredentialResponse()) return } if (!license.hasFeature(ProFeature.Passkeys)) { proNoticeStore.record(ProFeature.Passkeys) callback.onResult(BeginCreateCredentialResponse()) return } val count = vault.getEntries().count { it.passkey != null } val entry = CreateEntry( accountName = "Peach", pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson), description = "Save passkey to Peach", lastUsedTime = Instant.now(), icon = Icon.createWithResource(this, R.drawable.ic_peach_mark), passwordCredentialCount = null, publicKeyCredentialCount = count, totalCredentialCount = vault.getEntries().size, isAutoSelectAllowed = false, ) callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry))) } override fun onClearCredentialStateRequest( request: ProviderClearCredentialStateRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { callback.onResult(null) } }