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.Inject3940414243444546474849505152535455@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 70 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 109 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 133134135136137138139 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 194195196197198199 private fun passwordCreateEntry(200 request: BeginCreatePasswordCredentialRequest,201 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,202 ) {203 204 205 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}