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 assertionBuild = if (entry != null && requestJson != null) {276 PasskeyAssertion.buildDetailed(277 entry = entry,278 requestJson = requestJson,279 origin = origin,280 clientDataHashOverride = clientDataHash,281 userVerified = userVerified,282 )283 } else null284 val assertion = (assertionBuild as? PasskeyAssertionBuild.Success)?.result285 if (assertionBuild is PasskeyAssertionBuild.Failure) {286 Log.w(TAG, "flow=assertion stage=build_failed reason=${assertionBuild.reason.name.lowercase()}")287 }288289 if (entry != null && assertion != null) {290 val passkey = entry.passkey291 val persisted = if (passkey != null) {292 val now = System.currentTimeMillis()293 runCatching {294 vault.updateEntry(295 entry.copy(296 passkey = passkey.copy(signCount = assertion.nextSignCount),297 signCount = assertion.nextSignCount,298 lastUsedAt = now,299 ),300 )301 }.isSuccess302 } else {303 false304 }305 if (persisted) {306 Log.i(TAG, "flow=assertion stage=response_ready")307 PendingIntentHandler.setGetCredentialResponse(308 result,309 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),310 )311 } else {312 Log.w(TAG, "flow=assertion stage=persist_failed")313 PendingIntentHandler.setGetCredentialException(314 result,315 GetCredentialUnsupportedException("Peach could not update this passkey."),316 )317 }318 } else {319 if (assertionBuild == null) Log.w(TAG, "flow=assertion stage=build_failed reason=missing_input")320 PendingIntentHandler.setGetCredentialException(321 result,322 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),323 )324 }325 setResult(RESULT_OK, result)326 finish()327 }328329 private fun failGet(message: String) {330 val result = Intent()331 PendingIntentHandler.setGetCredentialException(332 result,333 GetCredentialUnsupportedException(message),334 )335 setResult(RESULT_OK, result)336 finish()337 }338339 private suspend fun completeCreate() {340 val result = Intent()341 val providerRequest = runCatching {342 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)343 }.getOrNull()344 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest345 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)346 347 348 349 val origin = providerRequest?.callingAppInfo?.let { info ->350 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))351 }352 353 354 355 356 357 val uvRequired = requestJson != null &&358 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED359 360 361 362 363 val userVerified = runUserVerificationPrompt()364 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {365 PendingIntentHandler.setCreateCredentialException(366 result,367 CreateCredentialUnsupportedException(368 "User verification is required but could not be completed.",369 ),370 )371 setResult(RESULT_OK, result)372 finish()373 return374 }375 val registration = requestJson?.let {376 PasskeyRegistration.build(it, origin, userVerified = userVerified)377 }378 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false379 if (registration != null && stored) {380 PendingIntentHandler.setCreateCredentialResponse(381 result,382 CreatePublicKeyCredentialResponse(registration.responseJson),383 )384 } else {385 PendingIntentHandler.setCreateCredentialException(386 result,387 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),388 )389 }390 setResult(RESULT_OK, result)391 finish()392 }393394 395 396 397 398 399 private fun registrationUserVerificationRequirement(requestJson: String): String? =400 runCatching {401 val root = Json.parseToJsonElement(requestJson).jsonObject402 val fromSelection = root["authenticatorSelection"]?.jsonObject403 ?.get("userVerification")?.jsonPrimitive?.contentOrNull404 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull405 }.getOrNull()406407 private fun userVerificationRequirement(requestJson: String): String? =408 runCatching {409 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]410 ?.jsonPrimitive?.contentOrNull411 }.getOrNull()412413 private suspend fun runUserVerificationPrompt(): Boolean {414 if (!Biometrics.isAvailable(this)) return false415 return suspendCancellableCoroutine { cont ->416 Biometrics.authenticate(417 activity = this,418 title = "Verify it's you",419 subtitle = "Confirm to use this passkey",420 onSuccess = { if (cont.isActive) cont.resume(true) },421 onFail = { if (cont.isActive) cont.resume(false) },422 )423 }424 }425426 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {427 val rpId = draft.passkey?.rpId ?: return false428 val target = vault.getEntries().firstOrNull { entry ->429 entry.deletedAt == null &&430 entry.passkey == null &&431 entry.isLogin() &&432 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())433 .any { extractDomain(it) == rpId }434 }435 return runCatching {436 if (target != null) {437 vault.updateEntry(438 target.copy(439 passkey = draft.passkey,440 username = target.username ?: draft.username,441 ),442 )443 } else {444 vault.addEntry(draft)445 }446 }.isSuccess447 }448449 companion object {450 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"451 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"452 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"453 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"454 private const val MODE_CREATE = "create"455 private const val MODE_CHOOSE = "choose"456 457 458 459 private const val UV_REQUIRED = "required"460 private const val TAG = "PeachPasskey"461462 fun pendingIntent(463 context: Context,464 entryId: String,465 ): PendingIntent {466 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)467 .putExtra(EXTRA_ENTRY_ID, entryId)468 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0469 return PendingIntent.getActivity(470 context,471 PasskeyPendingIntentIds.next(),472 intent,473 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,474 )475 }476477 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {478 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)479 .putExtra(EXTRA_MODE, MODE_CREATE)480 .putExtra(EXTRA_REQUEST_JSON, requestJson)481 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0482 return PendingIntent.getActivity(483 context,484 PasskeyPendingIntentIds.next(),485 intent,486 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,487 )488 }489490 fun choosePendingIntent(491 context: Context,492 request: BeginGetCredentialRequest,493 ): PendingIntent {494 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)495 .putExtra(EXTRA_MODE, MODE_CHOOSE)496 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))497 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0498 return PendingIntent.getActivity(499 context,500 PasskeyPendingIntentIds.next(),501 intent,502 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,503 )504 }505 }506507 private data class ManualChoice(508 val entryId: String,509 val displayName: String,510 val username: String,511 val requestJson: String,512 val clientDataHash: ByteArray?,513 )514}