1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import android.util.Log9import androidx.activity.compose.setContent10import androidx.compose.foundation.background11import androidx.compose.foundation.clickable12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.lazy.LazyColumn17import androidx.compose.foundation.lazy.items18import androidx.compose.foundation.shape.RoundedCornerShape19import androidx.compose.material3.Text20import androidx.compose.runtime.getValue21import androidx.compose.runtime.mutableStateOf22import androidx.compose.runtime.remember23import androidx.compose.runtime.setValue24import androidx.compose.ui.Modifier25import androidx.compose.ui.text.font.FontWeight26import androidx.compose.ui.unit.dp27import androidx.compose.ui.unit.sp28import androidx.credentials.GetCredentialResponse29import androidx.credentials.GetPublicKeyCredentialOption30import androidx.credentials.CreatePublicKeyCredentialRequest31import androidx.credentials.CreatePublicKeyCredentialResponse32import androidx.credentials.PublicKeyCredential33import androidx.credentials.exceptions.CreateCredentialUnsupportedException34import androidx.credentials.exceptions.GetCredentialUnsupportedException35import androidx.credentials.provider.PendingIntentHandler36import androidx.credentials.provider.BeginGetCredentialRequest37import androidx.credentials.provider.BeginGetPublicKeyCredentialOption38import androidx.fragment.app.FragmentActivity39import androidx.lifecycle.lifecycleScope40import com.peachpasswords.BuildConfig41import com.peachpasswords.shared.extractDomain42import com.peachpasswords.ui.lock.Biometrics43import com.peachpasswords.ui.components.QaFlags44import com.peachpasswords.ui.components.SecureWindowPolicy45import com.peachpasswords.ui.theme.ACCENT_COLORS46import com.peachpasswords.ui.theme.DEFAULT_ACCENT47import com.peachpasswords.ui.theme.PeachTheme48import com.peachpasswords.ui.theme.PeachType49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.vault.VaultRepository51import com.peachpasswords.vault.isLogin52import dagger.hilt.android.AndroidEntryPoint53import javax.inject.Inject54import kotlinx.coroutines.launch55import kotlinx.coroutines.suspendCancellableCoroutine56import kotlinx.serialization.json.Json57import kotlinx.serialization.json.contentOrNull58import kotlinx.serialization.json.jsonObject59import kotlinx.serialization.json.jsonPrimitive60import kotlin.coroutines.resume6162@AndroidEntryPoint63class PasskeyCredentialSelectionActivity : FragmentActivity() {64 @Inject lateinit var vault: VaultRepository65 private var debugSecureLease = false6667 override fun onCreate(savedInstanceState: Bundle?) {68 super.onCreate(savedInstanceState)69 70 71 72 if (!BuildConfig.DEBUG) {73 SecureWindowPolicy.setUserSecure(window, true)74 } else if (!qaSecureCaptureBypass()) {75 SecureWindowPolicy.acquire(window)76 debugSecureLease = true77 }78 if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser()79 else lifecycleScope.launch { completeSelection() }80 }8182 override fun onDestroy() {83 if (debugSecureLease) {84 SecureWindowPolicy.release(window)85 debugSecureLease = false86 }87 super.onDestroy()88 }8990 private fun qaSecureCaptureBypass(): Boolean =91 BuildConfig.DEBUG && (92 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||93 QaFlags.disableSecureCaptureForQa94 )9596 private suspend fun completeSelection() {97 if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {98 completeCreate()99 return100 }101 val result = Intent()102 val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)103 val providerRequest = runCatching {104 PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)105 }.getOrNull()106 val option = providerRequest?.credentialOptions107 ?.singleOrNull() as? GetPublicKeyCredentialOption108 if (providerRequest == null || option == null) {109 Log.w(TAG, "flow=row stage=final_request_invalid")110 failGet("Peach did not receive one complete passkey request from Android.")111 return112 }113 val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->114 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))115 }116 val biometricResult = providerRequest.biometricPromptResult117 val route = passkeyVerificationRoute(118 verifiedByUnlock = intent.getBooleanExtra(EXTRA_VERIFIED_BY_UNLOCK, false),119 hasEmbeddedResult = biometricResult != null,120 embeddedSucceeded = biometricResult?.isSuccessful == true,121 )122 if (route == PasskeyVerificationRoute.REJECT) {123 Log.w(TAG, "flow=row stage=embedded_verification_rejected")124 failGet("Android could not verify the user for this passkey.")125 return126 }127 val userVerified = when (route) {128 PasskeyVerificationRoute.ACCEPT -> true129 PasskeyVerificationRoute.PROMPT_IN_PEACH -> runUserVerificationPrompt()130 PasskeyVerificationRoute.REJECT -> false131 }132 if (!userVerified) {133 Log.w(TAG, "flow=row stage=verification_incomplete")134 failGet("User verification is required but could not be completed.")135 return136 }137 Log.i(TAG, "flow=row stage=assertion_started verification=${route.name.lowercase()}")138 completeAssertion(139 entryId,140 option.requestJson,141 frameworkOrigin,142 option.clientDataHash,143 verifiedUser = true,144 )145 }146147 private fun showManualChooser() {148 val request = runCatching {149 PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)150 }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)151 ?.let(BeginGetCredentialRequest::fromBundle)152 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)153 val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }154 val requestChoices = request?.beginGetCredentialOptions155 ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()156 ?.flatMap { option ->157 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)158 .map {159 ManualChoice(160 it.entryId,161 it.displayName,162 it.username,163 option.requestJson,164 option.clientDataHash,165 )166 }167 }168 .orEmpty()169 val choices = requestChoices170 if (choices.isEmpty()) {171 Log.w(TAG, "flow=manual stage=begin_request_invalid")172 failGet("Peach did not receive an eligible passkey request from Android.")173 return174 }175 val settings = vault.settings.value176 val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT177 setContent {178 PeachTheme(accent = accent, textSize = settings.textSize) {179 var query by remember { mutableStateOf("") }180 val visible = choices.filter {181 query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||182 it.username.contains(query, ignoreCase = true)183 }184 Column(185 Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),186 ) {187 Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)188 androidx.compose.foundation.text.BasicTextField(189 value = query,190 onValueChange = { query = it },191 singleLine = true,192 textStyle = androidx.compose.ui.text.TextStyle(193 color = peachColors.T0,194 fontFamily = PeachType.Mono,195 fontSize = 14.sp,196 ),197 modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)198 .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),199 decorationBox = { inner ->200 if (query.isEmpty()) {201 Text(202 "Search eligible Peach passkeys…",203 color = peachColors.T2,204 fontFamily = PeachType.Mono,205 fontSize = 12.sp,206 )207 }208 inner()209 },210 )211 LazyColumn {212 items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->213 Column(214 Modifier.fillMaxWidth().clickable {215 lifecycleScope.launch {216 val verifiedByUnlock = intent.getBooleanExtra(217 EXTRA_VERIFIED_BY_UNLOCK,218 false,219 )220 val userVerified = verifiedByUnlock || runUserVerificationPrompt()221 if (!userVerified) {222 Log.w(TAG, "flow=manual stage=verification_incomplete")223 failGet("User verification is required but could not be completed.")224 return@launch225 }226 Log.i(TAG, "flow=manual stage=assertion_started")227 completeAssertion(228 choice.entryId,229 choice.requestJson,230 origin,231 choice.clientDataHash,232 verifiedUser = true,233 )234 }235 }.padding(vertical = 12.dp),236 ) {237 Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)238 Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)239 }240 }241 }242 }243 }244 }245 }246247 private suspend fun completeAssertion(248 entryId: String?,249 requestJson: String?,250 origin: String?,251 clientDataHash: ByteArray?,252 uvRequiredOverride: Boolean? = null,253 verifiedUser: Boolean? = null,254 ) {255 val result = Intent()256 val entry = entryId?.let(vault::getEntry)257 258 259 260 261 262 val uvRequired = entry != null && requestJson != null &&263 (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))264 val userVerified = verifiedUser ?: if (uvRequired) {265 runUserVerificationPrompt()266 } else {267 false268 }269 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {270 PendingIntentHandler.setGetCredentialException(271 result,272 GetCredentialUnsupportedException(273 "User verification is required but could not be completed.",274 ),275 )276 setResult(RESULT_OK, result)277 finish()278 return279 }280 val assertionBuild = if (entry != null && requestJson != null) {281 PasskeyAssertion.buildDetailed(282 entry = entry,283 requestJson = requestJson,284 origin = origin,285 clientDataHashOverride = clientDataHash,286 userVerified = userVerified,287 )288 } else null289 val assertion = (assertionBuild as? PasskeyAssertionBuild.Success)?.result290 if (assertionBuild is PasskeyAssertionBuild.Failure) {291 Log.w(TAG, "flow=assertion stage=build_failed reason=${assertionBuild.reason.name.lowercase()}")292 }293294 if (entry != null && assertion != null) {295 val passkey = entry.passkey296 val persisted = if (passkey != null) {297 val now = System.currentTimeMillis()298 runCatching {299 vault.updateEntry(300 entry.copy(301 passkey = passkey.copy(signCount = assertion.nextSignCount),302 signCount = assertion.nextSignCount,303 lastUsedAt = now,304 ),305 )306 }.isSuccess307 } else {308 false309 }310 if (persisted) {311 Log.i(TAG, "flow=assertion stage=response_ready")312 PendingIntentHandler.setGetCredentialResponse(313 result,314 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),315 )316 } else {317 Log.w(TAG, "flow=assertion stage=persist_failed")318 PendingIntentHandler.setGetCredentialException(319 result,320 GetCredentialUnsupportedException("Peach could not update this passkey."),321 )322 }323 } else {324 if (assertionBuild == null) Log.w(TAG, "flow=assertion stage=build_failed reason=missing_input")325 PendingIntentHandler.setGetCredentialException(326 result,327 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),328 )329 }330 setResult(RESULT_OK, result)331 finish()332 }333334 private fun failGet(message: String) {335 val result = Intent()336 PendingIntentHandler.setGetCredentialException(337 result,338 GetCredentialUnsupportedException(message),339 )340 setResult(RESULT_OK, result)341 finish()342 }343344 private suspend fun completeCreate() {345 val result = Intent()346 val providerRequest = runCatching {347 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)348 }.getOrNull()349 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest350 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)351 352 353 354 val origin = providerRequest?.callingAppInfo?.let { info ->355 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))356 }357 358 359 360 361 362 val uvRequired = requestJson != null &&363 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED364 365 366 367 368 val userVerified = runUserVerificationPrompt()369 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {370 PendingIntentHandler.setCreateCredentialException(371 result,372 CreateCredentialUnsupportedException(373 "User verification is required but could not be completed.",374 ),375 )376 setResult(RESULT_OK, result)377 finish()378 return379 }380 val registration = requestJson?.let {381 PasskeyRegistration.build(it, origin, userVerified = userVerified)382 }383 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false384 if (registration != null && stored) {385 PendingIntentHandler.setCreateCredentialResponse(386 result,387 CreatePublicKeyCredentialResponse(registration.responseJson),388 )389 } else {390 PendingIntentHandler.setCreateCredentialException(391 result,392 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),393 )394 }395 setResult(RESULT_OK, result)396 finish()397 }398399 400 401 402 403 404 private fun registrationUserVerificationRequirement(requestJson: String): String? =405 runCatching {406 val root = Json.parseToJsonElement(requestJson).jsonObject407 val fromSelection = root["authenticatorSelection"]?.jsonObject408 ?.get("userVerification")?.jsonPrimitive?.contentOrNull409 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull410 }.getOrNull()411412 private fun userVerificationRequirement(requestJson: String): String? =413 runCatching {414 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]415 ?.jsonPrimitive?.contentOrNull416 }.getOrNull()417418 private suspend fun runUserVerificationPrompt(): Boolean {419 if (!Biometrics.isAvailable(this)) return false420 return suspendCancellableCoroutine { cont ->421 Biometrics.authenticate(422 activity = this,423 title = "Verify it's you",424 subtitle = "Confirm to use this passkey",425 onSuccess = { if (cont.isActive) cont.resume(true) },426 onFail = { if (cont.isActive) cont.resume(false) },427 )428 }429 }430431 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {432 val rpId = draft.passkey?.rpId ?: return false433 val target = vault.getEntries().firstOrNull { entry ->434 entry.deletedAt == null &&435 entry.passkey == null &&436 entry.isLogin() &&437 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())438 .any { extractDomain(it) == rpId }439 }440 return runCatching {441 if (target != null) {442 vault.updateEntry(443 target.copy(444 passkey = draft.passkey,445 username = target.username ?: draft.username,446 ),447 )448 } else {449 vault.addEntry(draft)450 }451 }.isSuccess452 }453454 companion object {455 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"456 private const val EXTRA_VERIFIED_BY_UNLOCK =457 "com.peachpasswords.passkeys.EXTRA_VERIFIED_BY_UNLOCK"458 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"459 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"460 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"461 private const val MODE_CREATE = "create"462 private const val MODE_CHOOSE = "choose"463 464 465 466 private const val UV_REQUIRED = "required"467 private const val TAG = "PeachPasskey"468469 fun pendingIntent(470 context: Context,471 entryId: String,472 verifiedByUnlock: Boolean = false,473 ): PendingIntent {474 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)475 .putExtra(EXTRA_ENTRY_ID, entryId)476 .putExtra(EXTRA_VERIFIED_BY_UNLOCK, verifiedByUnlock)477 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0478 return PendingIntent.getActivity(479 context,480 PasskeyPendingIntentIds.next(),481 intent,482 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,483 )484 }485486 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {487 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)488 .putExtra(EXTRA_MODE, MODE_CREATE)489 .putExtra(EXTRA_REQUEST_JSON, requestJson)490 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0491 return PendingIntent.getActivity(492 context,493 PasskeyPendingIntentIds.next(),494 intent,495 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,496 )497 }498499 fun choosePendingIntent(500 context: Context,501 request: BeginGetCredentialRequest,502 verifiedByUnlock: Boolean = false,503 ): PendingIntent {504 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)505 .putExtra(EXTRA_MODE, MODE_CHOOSE)506 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))507 .putExtra(EXTRA_VERIFIED_BY_UNLOCK, verifiedByUnlock)508 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0509 return PendingIntent.getActivity(510 context,511 PasskeyPendingIntentIds.next(),512 intent,513 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,514 )515 }516 }517518 private data class ManualChoice(519 val entryId: String,520 val displayName: String,521 val username: String,522 val requestJson: String,523 val clientDataHash: ByteArray?,524 )525}