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 hasEmbeddedResult = biometricResult != null,119 embeddedSucceeded = biometricResult?.isSuccessful == true,120 )121 if (route == PasskeyVerificationRoute.REJECT) {122 Log.w(TAG, "flow=row stage=embedded_verification_rejected")123 failGet("Android could not verify the user for this passkey.")124 return125 }126 val userVerified = when (route) {127 PasskeyVerificationRoute.ACCEPT -> true128 PasskeyVerificationRoute.PROMPT_IN_PEACH -> runUserVerificationPrompt()129 PasskeyVerificationRoute.REJECT -> false130 }131 if (!userVerified) {132 Log.w(TAG, "flow=row stage=verification_incomplete")133 failGet("User verification is required but could not be completed.")134 return135 }136 Log.i(TAG, "flow=row stage=assertion_started verification=${route.name.lowercase()}")137 completeAssertion(138 entryId,139 option.requestJson,140 frameworkOrigin,141 option.clientDataHash,142 verifiedUser = true,143 )144 }145146 private fun showManualChooser() {147 val request = runCatching {148 PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)149 }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)150 ?.let(BeginGetCredentialRequest::fromBundle)151 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)152 val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }153 val requestChoices = request?.beginGetCredentialOptions154 ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()155 ?.flatMap { option ->156 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)157 .map {158 ManualChoice(159 it.entryId,160 it.displayName,161 it.username,162 option.requestJson,163 option.clientDataHash,164 )165 }166 }167 .orEmpty()168 val choices = requestChoices169 if (choices.isEmpty()) {170 Log.w(TAG, "flow=manual stage=begin_request_invalid")171 failGet("Peach did not receive an eligible passkey request from Android.")172 return173 }174 val settings = vault.settings.value175 val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT176 setContent {177 PeachTheme(accent = accent, textSize = settings.textSize) {178 var query by remember { mutableStateOf("") }179 val visible = choices.filter {180 query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||181 it.username.contains(query, ignoreCase = true)182 }183 Column(184 Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),185 ) {186 Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)187 androidx.compose.foundation.text.BasicTextField(188 value = query,189 onValueChange = { query = it },190 singleLine = true,191 textStyle = androidx.compose.ui.text.TextStyle(192 color = peachColors.T0,193 fontFamily = PeachType.Mono,194 fontSize = 14.sp,195 ),196 modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)197 .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),198 decorationBox = { inner ->199 if (query.isEmpty()) {200 Text(201 "Search eligible Peach passkeys…",202 color = peachColors.T2,203 fontFamily = PeachType.Mono,204 fontSize = 12.sp,205 )206 }207 inner()208 },209 )210 LazyColumn {211 items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->212 Column(213 Modifier.fillMaxWidth().clickable {214 lifecycleScope.launch {215 val userVerified = runUserVerificationPrompt()216 if (!userVerified) {217 Log.w(TAG, "flow=manual stage=verification_incomplete")218 failGet("User verification is required but could not be completed.")219 return@launch220 }221 Log.i(TAG, "flow=manual stage=assertion_started")222 completeAssertion(223 choice.entryId,224 choice.requestJson,225 origin,226 choice.clientDataHash,227 verifiedUser = true,228 )229 }230 }.padding(vertical = 12.dp),231 ) {232 Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)233 Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)234 }235 }236 }237 }238 }239 }240 }241242 private suspend fun completeAssertion(243 entryId: String?,244 requestJson: String?,245 origin: String?,246 clientDataHash: ByteArray?,247 uvRequiredOverride: Boolean? = null,248 verifiedUser: Boolean? = null,249 ) {250 val result = Intent()251 val entry = entryId?.let(vault::getEntry)252 253 254 255 256 257 val uvRequired = entry != null && requestJson != null &&258 (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))259 val userVerified = verifiedUser ?: if (uvRequired) {260 runUserVerificationPrompt()261 } else {262 false263 }264 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {265 PendingIntentHandler.setGetCredentialException(266 result,267 GetCredentialUnsupportedException(268 "User verification is required but could not be completed.",269 ),270 )271 setResult(RESULT_OK, result)272 finish()273 return274 }275 val assertion = if (entry != null && requestJson != null) {276 PasskeyAssertion.build(277 entry = entry,278 requestJson = requestJson,279 origin = origin,280 clientDataHashOverride = clientDataHash,281 userVerified = userVerified,282 )283 } else {284 null285 }286287 if (entry != null && assertion != null) {288 val passkey = entry.passkey289 val persisted = if (passkey != null) {290 val now = System.currentTimeMillis()291 runCatching {292 vault.updateEntry(293 entry.copy(294 passkey = passkey.copy(signCount = assertion.nextSignCount),295 signCount = assertion.nextSignCount,296 lastUsedAt = now,297 ),298 )299 }.isSuccess300 } else {301 false302 }303 if (persisted) {304 Log.i(TAG, "flow=assertion stage=response_ready")305 PendingIntentHandler.setGetCredentialResponse(306 result,307 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),308 )309 } else {310 Log.w(TAG, "flow=assertion stage=persist_failed")311 PendingIntentHandler.setGetCredentialException(312 result,313 GetCredentialUnsupportedException("Peach could not update this passkey."),314 )315 }316 } else {317 Log.w(TAG, "flow=assertion stage=build_failed")318 PendingIntentHandler.setGetCredentialException(319 result,320 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),321 )322 }323 setResult(RESULT_OK, result)324 finish()325 }326327 private fun failGet(message: String) {328 val result = Intent()329 PendingIntentHandler.setGetCredentialException(330 result,331 GetCredentialUnsupportedException(message),332 )333 setResult(RESULT_OK, result)334 finish()335 }336337 private suspend fun completeCreate() {338 val result = Intent()339 val providerRequest = runCatching {340 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)341 }.getOrNull()342 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest343 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)344 345 346 347 val origin = providerRequest?.callingAppInfo?.let { info ->348 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))349 }350 351 352 353 354 355 val uvRequired = requestJson != null &&356 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED357 358 359 360 361 val userVerified = runUserVerificationPrompt()362 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {363 PendingIntentHandler.setCreateCredentialException(364 result,365 CreateCredentialUnsupportedException(366 "User verification is required but could not be completed.",367 ),368 )369 setResult(RESULT_OK, result)370 finish()371 return372 }373 val registration = requestJson?.let {374 PasskeyRegistration.build(it, origin, userVerified = userVerified)375 }376 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false377 if (registration != null && stored) {378 PendingIntentHandler.setCreateCredentialResponse(379 result,380 CreatePublicKeyCredentialResponse(registration.responseJson),381 )382 } else {383 PendingIntentHandler.setCreateCredentialException(384 result,385 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),386 )387 }388 setResult(RESULT_OK, result)389 finish()390 }391392 393 394 395 396 397 private fun registrationUserVerificationRequirement(requestJson: String): String? =398 runCatching {399 val root = Json.parseToJsonElement(requestJson).jsonObject400 val fromSelection = root["authenticatorSelection"]?.jsonObject401 ?.get("userVerification")?.jsonPrimitive?.contentOrNull402 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull403 }.getOrNull()404405 private fun userVerificationRequirement(requestJson: String): String? =406 runCatching {407 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]408 ?.jsonPrimitive?.contentOrNull409 }.getOrNull()410411 private suspend fun runUserVerificationPrompt(): Boolean {412 if (!Biometrics.isAvailable(this)) return false413 return suspendCancellableCoroutine { cont ->414 Biometrics.authenticate(415 activity = this,416 title = "Verify it's you",417 subtitle = "Confirm to use this passkey",418 onSuccess = { if (cont.isActive) cont.resume(true) },419 onFail = { if (cont.isActive) cont.resume(false) },420 )421 }422 }423424 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {425 val rpId = draft.passkey?.rpId ?: return false426 val target = vault.getEntries().firstOrNull { entry ->427 entry.deletedAt == null &&428 entry.passkey == null &&429 entry.isLogin() &&430 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())431 .any { extractDomain(it) == rpId }432 }433 return runCatching {434 if (target != null) {435 vault.updateEntry(436 target.copy(437 passkey = draft.passkey,438 username = target.username ?: draft.username,439 ),440 )441 } else {442 vault.addEntry(draft)443 }444 }.isSuccess445 }446447 companion object {448 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"449 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"450 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"451 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"452 private const val MODE_CREATE = "create"453 private const val MODE_CHOOSE = "choose"454 455 456 457 private const val UV_REQUIRED = "required"458 private const val TAG = "PeachPasskey"459460 fun pendingIntent(461 context: Context,462 entryId: String,463 ): PendingIntent {464 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)465 .putExtra(EXTRA_ENTRY_ID, entryId)466 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0467 return PendingIntent.getActivity(468 context,469 PasskeyPendingIntentIds.next(),470 intent,471 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,472 )473 }474475 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {476 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)477 .putExtra(EXTRA_MODE, MODE_CREATE)478 .putExtra(EXTRA_REQUEST_JSON, requestJson)479 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0480 return PendingIntent.getActivity(481 context,482 PasskeyPendingIntentIds.next(),483 intent,484 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,485 )486 }487488 fun choosePendingIntent(489 context: Context,490 request: BeginGetCredentialRequest,491 ): PendingIntent {492 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)493 .putExtra(EXTRA_MODE, MODE_CHOOSE)494 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))495 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0496 return PendingIntent.getActivity(497 context,498 PasskeyPendingIntentIds.next(),499 intent,500 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,501 )502 }503 }504505 private data class ManualChoice(506 val entryId: String,507 val displayName: String,508 val username: String,509 val requestJson: String,510 val clientDataHash: ByteArray?,511 )512}