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 requestJson = selectionRequestJson(109 frameworkJson = option?.requestJson,110 authenticatedJson = intent.getStringExtra(EXTRA_REQUEST_JSON),111 )112 val clientDataHash = selectionClientDataHash(113 frameworkHash = option?.clientDataHash,114 authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),115 )116 117 118 119 val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->120 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))121 }122 val origin = selectionOrigin(123 frameworkOrigin = frameworkOrigin,124 authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),125 )126 completeAssertion(entryId, requestJson, origin, clientDataHash)127 }128129 private fun showManualChooser() {130 val request = runCatching {131 PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)132 }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)133 ?.let(BeginGetCredentialRequest::fromBundle)134 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)135 val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }136 ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)137 val requestChoices = request?.beginGetCredentialOptions138 ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()139 ?.flatMap { option ->140 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)141 .map {142 ManualChoice(143 it.entryId,144 it.displayName,145 it.username,146 option.requestJson,147 option.clientDataHash,148 )149 }150 }151 .orEmpty()152 val choices = requestChoices.ifEmpty {153 val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)154 val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)155 if (requestJson == null) emptyList()156 else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)157 .map {158 ManualChoice(159 entryId = it.entryId,160 displayName = it.displayName,161 username = it.username,162 requestJson = requestJson,163 clientDataHash = authenticatedHash,164 )165 }166 }167 if (choices.isEmpty()) {168 finish()169 return170 }171 val settings = vault.settings.value172 val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT173 setContent {174 PeachTheme(accent = accent, textSize = settings.textSize) {175 var query by remember { mutableStateOf("") }176 val visible = choices.filter {177 query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||178 it.username.contains(query, ignoreCase = true)179 }180 Column(181 Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),182 ) {183 Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)184 androidx.compose.foundation.text.BasicTextField(185 value = query,186 onValueChange = { query = it },187 singleLine = true,188 textStyle = androidx.compose.ui.text.TextStyle(189 color = peachColors.T0,190 fontFamily = PeachType.Mono,191 fontSize = 14.sp,192 ),193 modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)194 .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),195 decorationBox = { inner ->196 if (query.isEmpty()) {197 Text(198 "Search eligible Peach passkeys…",199 color = peachColors.T2,200 fontFamily = PeachType.Mono,201 fontSize = 12.sp,202 )203 }204 inner()205 },206 )207 LazyColumn {208 items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->209 Column(210 Modifier.fillMaxWidth().clickable {211 lifecycleScope.launch {212 completeAssertion(213 choice.entryId,214 choice.requestJson,215 origin,216 choice.clientDataHash,217 )218 }219 }.padding(vertical = 12.dp),220 ) {221 Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)222 Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)223 }224 }225 }226 }227 }228 }229 }230231 private suspend fun completeAssertion(232 entryId: String?,233 requestJson: String?,234 origin: String?,235 clientDataHash: ByteArray?,236 ) {237 val result = Intent()238 val entry = entryId?.let(vault::getEntry)239 240 241 242 243 244 val uvRequired = entry != null && requestJson != null &&245 userVerificationRequirement(requestJson) == UV_REQUIRED246 val userVerified = if (uvRequired) {247 runUserVerificationPrompt()248 } else {249 false250 }251 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {252 PendingIntentHandler.setGetCredentialException(253 result,254 GetCredentialUnsupportedException(255 "User verification is required but could not be completed.",256 ),257 )258 setResult(RESULT_OK, result)259 finish()260 return261 }262 val assertion = if (entry != null && requestJson != null) {263 PasskeyAssertion.build(264 entry = entry,265 requestJson = requestJson,266 origin = origin,267 clientDataHashOverride = clientDataHash,268 userVerified = userVerified,269 )270 } else {271 null272 }273274 if (entry != null && assertion != null) {275 val passkey = entry.passkey276 val persisted = if (passkey != null) {277 val now = System.currentTimeMillis()278 runCatching {279 vault.updateEntry(280 entry.copy(281 passkey = passkey.copy(signCount = assertion.nextSignCount),282 signCount = assertion.nextSignCount,283 lastUsedAt = now,284 ),285 )286 }.isSuccess287 } else {288 false289 }290 if (persisted) {291 PendingIntentHandler.setGetCredentialResponse(292 result,293 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),294 )295 } else {296 PendingIntentHandler.setGetCredentialException(297 result,298 GetCredentialUnsupportedException("Peach could not update this passkey."),299 )300 }301 } else {302 PendingIntentHandler.setGetCredentialException(303 result,304 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),305 )306 }307 setResult(RESULT_OK, result)308 finish()309 }310311 private suspend fun completeCreate() {312 val result = Intent()313 val providerRequest = runCatching {314 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)315 }.getOrNull()316 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest317 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)318 319 320 321 val origin = providerRequest?.callingAppInfo?.let { info ->322 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))323 }324 325 326 327 328 329 val uvRequired = requestJson != null &&330 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED331 332 333 334 335 val userVerified = runUserVerificationPrompt()336 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {337 PendingIntentHandler.setCreateCredentialException(338 result,339 CreateCredentialUnsupportedException(340 "User verification is required but could not be completed.",341 ),342 )343 setResult(RESULT_OK, result)344 finish()345 return346 }347 val registration = requestJson?.let {348 PasskeyRegistration.build(it, origin, userVerified = userVerified)349 }350 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false351 if (registration != null && stored) {352 PendingIntentHandler.setCreateCredentialResponse(353 result,354 CreatePublicKeyCredentialResponse(registration.responseJson),355 )356 } else {357 PendingIntentHandler.setCreateCredentialException(358 result,359 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),360 )361 }362 setResult(RESULT_OK, result)363 finish()364 }365366 367 368 369 370 371 private fun registrationUserVerificationRequirement(requestJson: String): String? =372 runCatching {373 val root = Json.parseToJsonElement(requestJson).jsonObject374 val fromSelection = root["authenticatorSelection"]?.jsonObject375 ?.get("userVerification")?.jsonPrimitive?.contentOrNull376 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull377 }.getOrNull()378379 private fun userVerificationRequirement(requestJson: String): String? =380 runCatching {381 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]382 ?.jsonPrimitive?.contentOrNull383 }.getOrNull()384385 private suspend fun runUserVerificationPrompt(): Boolean {386 if (!Biometrics.isAvailable(this)) return false387 return suspendCancellableCoroutine { cont ->388 Biometrics.authenticate(389 activity = this,390 title = "Verify it's you",391 subtitle = "Confirm to use this passkey",392 onSuccess = { if (cont.isActive) cont.resume(true) },393 onFail = { if (cont.isActive) cont.resume(false) },394 )395 }396 }397398 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {399 val rpId = draft.passkey?.rpId ?: return false400 val target = vault.getEntries().firstOrNull { entry ->401 entry.deletedAt == null &&402 entry.passkey == null &&403 entry.isLogin() &&404 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())405 .any { extractDomain(it) == rpId }406 }407 return runCatching {408 if (target != null) {409 vault.updateEntry(410 target.copy(411 passkey = draft.passkey,412 username = target.username ?: draft.username,413 ),414 )415 } else {416 vault.addEntry(draft)417 }418 }.isSuccess419 }420421 companion object {422 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"423 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"424 private const val EXTRA_AUTHENTICATED_ORIGIN =425 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"426 private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =427 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"428 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"429 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"430 private const val MODE_CREATE = "create"431 private const val MODE_CHOOSE = "choose"432 433 434 435 private const val UV_REQUIRED = "required"436437 fun pendingIntent(438 context: Context,439 entryId: String,440 requestJson: String,441 authenticatedOrigin: String?,442 authenticatedClientDataHash: ByteArray?,443 ): PendingIntent {444 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)445 .putExtra(EXTRA_ENTRY_ID, entryId)446 .putExtra(EXTRA_REQUEST_JSON, requestJson)447 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)448 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)449 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0450 return PendingIntent.getActivity(451 context,452 "$entryId:$requestJson".hashCode(),453 intent,454 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,455 )456 }457458 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {459 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)460 .putExtra(EXTRA_MODE, MODE_CREATE)461 .putExtra(EXTRA_REQUEST_JSON, requestJson)462 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0463 return PendingIntent.getActivity(464 context,465 requestJson.hashCode(),466 intent,467 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,468 )469 }470471 fun choosePendingIntent(472 context: Context,473 request: BeginGetCredentialRequest,474 authenticatedOrigin: String?,475 ): PendingIntent {476 val option = request.beginGetCredentialOptions477 .filterIsInstance<BeginGetPublicKeyCredentialOption>()478 .firstOrNull()479 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)480 .putExtra(EXTRA_MODE, MODE_CHOOSE)481 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))482 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)483 .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)484 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)485 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0486 return PendingIntent.getActivity(487 context,488 "choose:${request.hashCode()}".hashCode(),489 intent,490 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,491 )492 }493 }494495 private data class ManualChoice(496 val entryId: String,497 val displayName: String,498 val username: String,499 val requestJson: String,500 val clientDataHash: ByteArray?,501 )502}503504505506507508509510511internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =512 frameworkOrigin?.takeIf { it.isNotBlank() }513 ?: authenticatedOrigin?.takeIf { it.isNotBlank() }514515516517518519520521internal fun selectionClientDataHash(522 frameworkHash: ByteArray?,523 authenticatedHash: ByteArray?,524): ByteArray? = authenticatedHash ?: frameworkHash525526527internal fun selectionRequestJson(528 frameworkJson: String?,529 authenticatedJson: String?,530): String? = authenticatedJson ?: frameworkJson