1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.activity.compose.setContent9import androidx.compose.foundation.background10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.padding15import androidx.compose.foundation.lazy.LazyColumn16import androidx.compose.foundation.lazy.items17import androidx.compose.foundation.shape.RoundedCornerShape18import androidx.compose.material3.Text19import androidx.compose.runtime.getValue20import androidx.compose.runtime.mutableStateOf21import androidx.compose.runtime.remember22import androidx.compose.runtime.setValue23import androidx.compose.ui.Modifier24import androidx.compose.ui.text.font.FontWeight25import androidx.compose.ui.unit.dp26import androidx.compose.ui.unit.sp27import androidx.credentials.GetCredentialResponse28import androidx.credentials.GetPublicKeyCredentialOption29import androidx.credentials.CreatePublicKeyCredentialRequest30import androidx.credentials.CreatePublicKeyCredentialResponse31import androidx.credentials.PublicKeyCredential32import androidx.credentials.exceptions.CreateCredentialUnsupportedException33import androidx.credentials.exceptions.GetCredentialUnsupportedException34import androidx.credentials.provider.PendingIntentHandler35import androidx.credentials.provider.BeginGetCredentialRequest36import androidx.credentials.provider.BeginGetPublicKeyCredentialOption37import androidx.fragment.app.FragmentActivity38import androidx.lifecycle.lifecycleScope39import com.peachpasswords.BuildConfig40import com.peachpasswords.shared.extractDomain41import com.peachpasswords.ui.lock.Biometrics42import com.peachpasswords.ui.components.QaFlags43import com.peachpasswords.ui.components.SecureWindowPolicy44import com.peachpasswords.ui.theme.ACCENT_COLORS45import com.peachpasswords.ui.theme.DEFAULT_ACCENT46import com.peachpasswords.ui.theme.PeachTheme47import com.peachpasswords.ui.theme.PeachType48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.vault.VaultRepository50import com.peachpasswords.vault.isLogin51import dagger.hilt.android.AndroidEntryPoint52import javax.inject.Inject53import kotlinx.coroutines.launch54import kotlinx.coroutines.suspendCancellableCoroutine55import kotlinx.serialization.json.Json56import kotlinx.serialization.json.contentOrNull57import kotlinx.serialization.json.jsonObject58import kotlinx.serialization.json.jsonPrimitive59import kotlin.coroutines.resume6061@AndroidEntryPoint62class PasskeyCredentialSelectionActivity : FragmentActivity() {63 @Inject lateinit var vault: VaultRepository64 private var debugSecureLease = false6566 override fun onCreate(savedInstanceState: Bundle?) {67 super.onCreate(savedInstanceState)68 69 70 71 if (!BuildConfig.DEBUG) {72 SecureWindowPolicy.setUserSecure(window, true)73 } else if (!qaSecureCaptureBypass()) {74 SecureWindowPolicy.acquire(window)75 debugSecureLease = true76 }77 if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser()78 else lifecycleScope.launch { completeSelection() }79 }8081 override fun onDestroy() {82 if (debugSecureLease) {83 SecureWindowPolicy.release(window)84 debugSecureLease = false85 }86 super.onDestroy()87 }8889 private fun qaSecureCaptureBypass(): Boolean =90 BuildConfig.DEBUG && (91 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||92 QaFlags.disableSecureCaptureForQa93 )9495 private suspend fun completeSelection() {96 if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {97 completeCreate()98 return99 }100 val result = Intent()101 val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)102 val providerRequest = runCatching {103 PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)104 }.getOrNull()105 val option = providerRequest?.credentialOptions106 ?.filterIsInstance<GetPublicKeyCredentialOption>()107 ?.firstOrNull()108 val authenticatedRequestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)109 val requestJson = selectionRequestJson(110 frameworkJson = option?.requestJson,111 authenticatedJson = authenticatedRequestJson,112 )113 val clientDataHash = selectionClientDataHash(114 frameworkHash = option?.clientDataHash,115 authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),116 )117 118 119 120 val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->121 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))122 }123 val origin = selectionOrigin(124 frameworkOrigin = frameworkOrigin,125 authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),126 )127 completeAssertion(128 entryId,129 requestJson,130 origin,131 clientDataHash,132 uvRequiredOverride = selectionRequiresUserVerification(133 frameworkJson = option?.requestJson,134 authenticatedJson = authenticatedRequestJson,135 ),136 )137 }138139 private fun showManualChooser() {140 val request = runCatching {141 PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)142 }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)143 ?.let(BeginGetCredentialRequest::fromBundle)144 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)145 val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }146 ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)147 val requestChoices = request?.beginGetCredentialOptions148 ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()149 ?.flatMap { option ->150 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)151 .map {152 ManualChoice(153 it.entryId,154 it.displayName,155 it.username,156 option.requestJson,157 option.clientDataHash,158 )159 }160 }161 .orEmpty()162 val choices = requestChoices.ifEmpty {163 val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)164 val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)165 if (requestJson == null) emptyList()166 else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)167 .map {168 ManualChoice(169 entryId = it.entryId,170 displayName = it.displayName,171 username = it.username,172 requestJson = requestJson,173 clientDataHash = authenticatedHash,174 )175 }176 }177 if (choices.isEmpty()) {178 finish()179 return180 }181 val settings = vault.settings.value182 val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT183 setContent {184 PeachTheme(accent = accent, textSize = settings.textSize) {185 var query by remember { mutableStateOf("") }186 val visible = choices.filter {187 query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||188 it.username.contains(query, ignoreCase = true)189 }190 Column(191 Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),192 ) {193 Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)194 androidx.compose.foundation.text.BasicTextField(195 value = query,196 onValueChange = { query = it },197 singleLine = true,198 textStyle = androidx.compose.ui.text.TextStyle(199 color = peachColors.T0,200 fontFamily = PeachType.Mono,201 fontSize = 14.sp,202 ),203 modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)204 .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),205 decorationBox = { inner ->206 if (query.isEmpty()) {207 Text(208 "Search eligible Peach passkeys…",209 color = peachColors.T2,210 fontFamily = PeachType.Mono,211 fontSize = 12.sp,212 )213 }214 inner()215 },216 )217 LazyColumn {218 items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->219 Column(220 Modifier.fillMaxWidth().clickable {221 lifecycleScope.launch {222 completeAssertion(223 choice.entryId,224 choice.requestJson,225 origin,226 choice.clientDataHash,227 )228 }229 }.padding(vertical = 12.dp),230 ) {231 Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)232 Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)233 }234 }235 }236 }237 }238 }239 }240241 private suspend fun completeAssertion(242 entryId: String?,243 requestJson: String?,244 origin: String?,245 clientDataHash: ByteArray?,246 uvRequiredOverride: Boolean? = null,247 ) {248 val result = Intent()249 val entry = entryId?.let(vault::getEntry)250 251 252 253 254 255 val uvRequired = entry != null && requestJson != null &&256 (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))257 val userVerified = if (uvRequired) {258 runUserVerificationPrompt()259 } else {260 false261 }262 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {263 PendingIntentHandler.setGetCredentialException(264 result,265 GetCredentialUnsupportedException(266 "User verification is required but could not be completed.",267 ),268 )269 setResult(RESULT_OK, result)270 finish()271 return272 }273 val assertion = if (entry != null && requestJson != null) {274 PasskeyAssertion.build(275 entry = entry,276 requestJson = requestJson,277 origin = origin,278 clientDataHashOverride = clientDataHash,279 userVerified = userVerified,280 )281 } else {282 null283 }284285 if (entry != null && assertion != null) {286 val passkey = entry.passkey287 val persisted = if (passkey != null) {288 val now = System.currentTimeMillis()289 runCatching {290 vault.updateEntry(291 entry.copy(292 passkey = passkey.copy(signCount = assertion.nextSignCount),293 signCount = assertion.nextSignCount,294 lastUsedAt = now,295 ),296 )297 }.isSuccess298 } else {299 false300 }301 if (persisted) {302 PendingIntentHandler.setGetCredentialResponse(303 result,304 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),305 )306 } else {307 PendingIntentHandler.setGetCredentialException(308 result,309 GetCredentialUnsupportedException("Peach could not update this passkey."),310 )311 }312 } else {313 PendingIntentHandler.setGetCredentialException(314 result,315 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),316 )317 }318 setResult(RESULT_OK, result)319 finish()320 }321322 private suspend fun completeCreate() {323 val result = Intent()324 val providerRequest = runCatching {325 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)326 }.getOrNull()327 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest328 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)329 330 331 332 val origin = providerRequest?.callingAppInfo?.let { info ->333 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))334 }335 336 337 338 339 340 val uvRequired = requestJson != null &&341 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED342 343 344 345 346 val userVerified = runUserVerificationPrompt()347 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {348 PendingIntentHandler.setCreateCredentialException(349 result,350 CreateCredentialUnsupportedException(351 "User verification is required but could not be completed.",352 ),353 )354 setResult(RESULT_OK, result)355 finish()356 return357 }358 val registration = requestJson?.let {359 PasskeyRegistration.build(it, origin, userVerified = userVerified)360 }361 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false362 if (registration != null && stored) {363 PendingIntentHandler.setCreateCredentialResponse(364 result,365 CreatePublicKeyCredentialResponse(registration.responseJson),366 )367 } else {368 PendingIntentHandler.setCreateCredentialException(369 result,370 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),371 )372 }373 setResult(RESULT_OK, result)374 finish()375 }376377 378 379 380 381 382 private fun registrationUserVerificationRequirement(requestJson: String): String? =383 runCatching {384 val root = Json.parseToJsonElement(requestJson).jsonObject385 val fromSelection = root["authenticatorSelection"]?.jsonObject386 ?.get("userVerification")?.jsonPrimitive?.contentOrNull387 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull388 }.getOrNull()389390 private fun userVerificationRequirement(requestJson: String): String? =391 runCatching {392 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]393 ?.jsonPrimitive?.contentOrNull394 }.getOrNull()395396 private suspend fun runUserVerificationPrompt(): Boolean {397 if (!Biometrics.isAvailable(this)) return false398 return suspendCancellableCoroutine { cont ->399 Biometrics.authenticate(400 activity = this,401 title = "Verify it's you",402 subtitle = "Confirm to use this passkey",403 onSuccess = { if (cont.isActive) cont.resume(true) },404 onFail = { if (cont.isActive) cont.resume(false) },405 )406 }407 }408409 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {410 val rpId = draft.passkey?.rpId ?: return false411 val target = vault.getEntries().firstOrNull { entry ->412 entry.deletedAt == null &&413 entry.passkey == null &&414 entry.isLogin() &&415 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())416 .any { extractDomain(it) == rpId }417 }418 return runCatching {419 if (target != null) {420 vault.updateEntry(421 target.copy(422 passkey = draft.passkey,423 username = target.username ?: draft.username,424 ),425 )426 } else {427 vault.addEntry(draft)428 }429 }.isSuccess430 }431432 companion object {433 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"434 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"435 private const val EXTRA_AUTHENTICATED_ORIGIN =436 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"437 private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =438 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"439 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"440 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"441 private const val MODE_CREATE = "create"442 private const val MODE_CHOOSE = "choose"443 444 445 446 private const val UV_REQUIRED = "required"447448 fun pendingIntent(449 context: Context,450 entryId: String,451 requestJson: String,452 authenticatedOrigin: String?,453 authenticatedClientDataHash: ByteArray?,454 ): PendingIntent {455 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)456 .putExtra(EXTRA_ENTRY_ID, entryId)457 .putExtra(EXTRA_REQUEST_JSON, requestJson)458 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)459 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)460 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0461 return PendingIntent.getActivity(462 context,463 "$entryId:$requestJson".hashCode(),464 intent,465 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,466 )467 }468469 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {470 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)471 .putExtra(EXTRA_MODE, MODE_CREATE)472 .putExtra(EXTRA_REQUEST_JSON, requestJson)473 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0474 return PendingIntent.getActivity(475 context,476 requestJson.hashCode(),477 intent,478 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,479 )480 }481482 fun choosePendingIntent(483 context: Context,484 request: BeginGetCredentialRequest,485 authenticatedOrigin: String?,486 ): PendingIntent {487 val option = request.beginGetCredentialOptions488 .filterIsInstance<BeginGetPublicKeyCredentialOption>()489 .firstOrNull()490 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)491 .putExtra(EXTRA_MODE, MODE_CHOOSE)492 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))493 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)494 .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)495 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)496 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0497 return PendingIntent.getActivity(498 context,499 "choose:${request.hashCode()}".hashCode(),500 intent,501 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,502 )503 }504 }505506 private data class ManualChoice(507 val entryId: String,508 val displayName: String,509 val username: String,510 val requestJson: String,511 val clientDataHash: ByteArray?,512 )513}514515516517518519520521522internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =523 frameworkOrigin?.takeIf { it.isNotBlank() }524 ?: authenticatedOrigin?.takeIf { it.isNotBlank() }525526527528529530531532internal fun selectionClientDataHash(533 frameworkHash: ByteArray?,534 authenticatedHash: ByteArray?,535): ByteArray? = authenticatedHash ?: frameworkHash536537538internal fun selectionRequestJson(539 frameworkJson: String?,540 authenticatedJson: String?,541): String? = frameworkJson ?: authenticatedJson542543544internal fun selectionRequiresUserVerification(545 frameworkJson: String?,546 authenticatedJson: String?,547): Boolean = listOfNotNull(frameworkJson, authenticatedJson).any { requestJson ->548 runCatching {549 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]550 ?.jsonPrimitive?.contentOrNull == "required"551 }.getOrDefault(false)552}