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 = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)109 val clientDataHash = selectionClientDataHash(110 frameworkHash = option?.clientDataHash,111 authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),112 )113 114 115 116 val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->117 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))118 }119 val origin = selectionOrigin(120 frameworkOrigin = frameworkOrigin,121 authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),122 )123 completeAssertion(entryId, requestJson, origin, clientDataHash)124 }125126 private fun showManualChooser() {127 val request = runCatching {128 PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)129 }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)130 ?.let(BeginGetCredentialRequest::fromBundle)131 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)132 val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }133 ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)134 val requestChoices = request?.beginGetCredentialOptions135 ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()136 ?.flatMap { option ->137 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)138 .map {139 ManualChoice(140 it.entryId,141 it.displayName,142 it.username,143 option.requestJson,144 option.clientDataHash,145 )146 }147 }148 .orEmpty()149 val choices = requestChoices.ifEmpty {150 val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)151 val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)152 if (requestJson == null) emptyList()153 else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)154 .map {155 ManualChoice(156 entryId = it.entryId,157 displayName = it.displayName,158 username = it.username,159 requestJson = requestJson,160 clientDataHash = authenticatedHash,161 )162 }163 }164 if (choices.isEmpty()) {165 finish()166 return167 }168 val settings = vault.settings.value169 val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT170 setContent {171 PeachTheme(accent = accent, textSize = settings.textSize) {172 var query by remember { mutableStateOf("") }173 val visible = choices.filter {174 query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||175 it.username.contains(query, ignoreCase = true)176 }177 Column(178 Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),179 ) {180 Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)181 androidx.compose.foundation.text.BasicTextField(182 value = query,183 onValueChange = { query = it },184 singleLine = true,185 textStyle = androidx.compose.ui.text.TextStyle(186 color = peachColors.T0,187 fontFamily = PeachType.Mono,188 fontSize = 14.sp,189 ),190 modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)191 .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),192 decorationBox = { inner ->193 if (query.isEmpty()) {194 Text(195 "Search eligible Peach passkeys…",196 color = peachColors.T2,197 fontFamily = PeachType.Mono,198 fontSize = 12.sp,199 )200 }201 inner()202 },203 )204 LazyColumn {205 items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->206 Column(207 Modifier.fillMaxWidth().clickable {208 lifecycleScope.launch {209 completeAssertion(210 choice.entryId,211 choice.requestJson,212 origin,213 choice.clientDataHash,214 )215 }216 }.padding(vertical = 12.dp),217 ) {218 Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)219 Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)220 }221 }222 }223 }224 }225 }226 }227228 private suspend fun completeAssertion(229 entryId: String?,230 requestJson: String?,231 origin: String?,232 clientDataHash: ByteArray?,233 ) {234 val result = Intent()235 val entry = entryId?.let(vault::getEntry)236 237 238 239 240 241 val uvRequired = entry != null && requestJson != null &&242 userVerificationRequirement(requestJson) == UV_REQUIRED243 val userVerified = if (uvRequired) {244 runUserVerificationPrompt()245 } else {246 false247 }248 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {249 PendingIntentHandler.setGetCredentialException(250 result,251 GetCredentialUnsupportedException(252 "User verification is required but could not be completed.",253 ),254 )255 setResult(RESULT_OK, result)256 finish()257 return258 }259 val assertion = if (entry != null && requestJson != null) {260 PasskeyAssertion.build(261 entry = entry,262 requestJson = requestJson,263 origin = origin,264 clientDataHashOverride = clientDataHash,265 userVerified = userVerified,266 )267 } else {268 null269 }270271 if (entry != null && assertion != null) {272 val passkey = entry.passkey273 val persisted = if (passkey != null) {274 val now = System.currentTimeMillis()275 runCatching {276 vault.updateEntry(277 entry.copy(278 passkey = passkey.copy(signCount = assertion.nextSignCount),279 signCount = assertion.nextSignCount,280 lastUsedAt = now,281 ),282 )283 }.isSuccess284 } else {285 false286 }287 if (persisted) {288 PendingIntentHandler.setGetCredentialResponse(289 result,290 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),291 )292 } else {293 PendingIntentHandler.setGetCredentialException(294 result,295 GetCredentialUnsupportedException("Peach could not update this passkey."),296 )297 }298 } else {299 PendingIntentHandler.setGetCredentialException(300 result,301 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),302 )303 }304 setResult(RESULT_OK, result)305 finish()306 }307308 private suspend fun completeCreate() {309 val result = Intent()310 val providerRequest = runCatching {311 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)312 }.getOrNull()313 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest314 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)315 316 317 318 val origin = providerRequest?.callingAppInfo?.let { info ->319 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))320 }321 322 323 324 325 326 val uvRequired = requestJson != null &&327 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED328 329 330 331 332 val userVerified = runUserVerificationPrompt()333 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {334 PendingIntentHandler.setCreateCredentialException(335 result,336 CreateCredentialUnsupportedException(337 "User verification is required but could not be completed.",338 ),339 )340 setResult(RESULT_OK, result)341 finish()342 return343 }344 val registration = requestJson?.let {345 PasskeyRegistration.build(it, origin, userVerified = userVerified)346 }347 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false348 if (registration != null && stored) {349 PendingIntentHandler.setCreateCredentialResponse(350 result,351 CreatePublicKeyCredentialResponse(registration.responseJson),352 )353 } else {354 PendingIntentHandler.setCreateCredentialException(355 result,356 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),357 )358 }359 setResult(RESULT_OK, result)360 finish()361 }362363 364 365 366 367 368 private fun registrationUserVerificationRequirement(requestJson: String): String? =369 runCatching {370 val root = Json.parseToJsonElement(requestJson).jsonObject371 val fromSelection = root["authenticatorSelection"]?.jsonObject372 ?.get("userVerification")?.jsonPrimitive?.contentOrNull373 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull374 }.getOrNull()375376 private fun userVerificationRequirement(requestJson: String): String? =377 runCatching {378 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]379 ?.jsonPrimitive?.contentOrNull380 }.getOrNull()381382 private suspend fun runUserVerificationPrompt(): Boolean {383 if (!Biometrics.isAvailable(this)) return false384 return suspendCancellableCoroutine { cont ->385 Biometrics.authenticate(386 activity = this,387 title = "Verify it's you",388 subtitle = "Confirm to use this passkey",389 onSuccess = { if (cont.isActive) cont.resume(true) },390 onFail = { if (cont.isActive) cont.resume(false) },391 )392 }393 }394395 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {396 val rpId = draft.passkey?.rpId ?: return false397 val target = vault.getEntries().firstOrNull { entry ->398 entry.deletedAt == null &&399 entry.passkey == null &&400 entry.isLogin() &&401 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())402 .any { extractDomain(it) == rpId }403 }404 return runCatching {405 if (target != null) {406 vault.updateEntry(407 target.copy(408 passkey = draft.passkey,409 username = target.username ?: draft.username,410 ),411 )412 } else {413 vault.addEntry(draft)414 }415 }.isSuccess416 }417418 companion object {419 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"420 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"421 private const val EXTRA_AUTHENTICATED_ORIGIN =422 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"423 private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =424 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"425 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"426 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"427 private const val MODE_CREATE = "create"428 private const val MODE_CHOOSE = "choose"429 430 431 432 private const val UV_REQUIRED = "required"433434 fun pendingIntent(435 context: Context,436 entryId: String,437 requestJson: String,438 authenticatedOrigin: String?,439 authenticatedClientDataHash: ByteArray?,440 ): PendingIntent {441 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)442 .putExtra(EXTRA_ENTRY_ID, entryId)443 .putExtra(EXTRA_REQUEST_JSON, requestJson)444 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)445 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)446 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0447 return PendingIntent.getActivity(448 context,449 "$entryId:$requestJson".hashCode(),450 intent,451 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,452 )453 }454455 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {456 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)457 .putExtra(EXTRA_MODE, MODE_CREATE)458 .putExtra(EXTRA_REQUEST_JSON, requestJson)459 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0460 return PendingIntent.getActivity(461 context,462 requestJson.hashCode(),463 intent,464 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,465 )466 }467468 fun choosePendingIntent(469 context: Context,470 request: BeginGetCredentialRequest,471 authenticatedOrigin: String?,472 ): PendingIntent {473 val option = request.beginGetCredentialOptions474 .filterIsInstance<BeginGetPublicKeyCredentialOption>()475 .firstOrNull()476 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)477 .putExtra(EXTRA_MODE, MODE_CHOOSE)478 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))479 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)480 .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)481 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)482 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0483 return PendingIntent.getActivity(484 context,485 "choose:${request.hashCode()}".hashCode(),486 intent,487 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,488 )489 }490 }491492 private data class ManualChoice(493 val entryId: String,494 val displayName: String,495 val username: String,496 val requestJson: String,497 val clientDataHash: ByteArray?,498 )499}500501502503504505506507508internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =509 frameworkOrigin?.takeIf { it.isNotBlank() }510 ?: authenticatedOrigin?.takeIf { it.isNotBlank() }511512513514515516517518internal fun selectionClientDataHash(519 frameworkHash: ByteArray?,520 authenticatedHash: ByteArray?,521): ByteArray? = authenticatedHash ?: frameworkHash