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 choices = request?.beginGetCredentialOptions135 ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()136 ?.flatMap { option ->137 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)138 .map { ManualChoice(it.entryId, it.displayName, it.username, option) }139 }140 .orEmpty()141 if (choices.isEmpty()) {142 finish()143 return144 }145 val settings = vault.settings.value146 val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT147 setContent {148 PeachTheme(accent = accent, textSize = settings.textSize) {149 var query by remember { mutableStateOf("") }150 val visible = choices.filter {151 query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||152 it.username.contains(query, ignoreCase = true)153 }154 Column(155 Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),156 ) {157 Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)158 androidx.compose.foundation.text.BasicTextField(159 value = query,160 onValueChange = { query = it },161 singleLine = true,162 textStyle = androidx.compose.ui.text.TextStyle(163 color = peachColors.T0,164 fontFamily = PeachType.Mono,165 fontSize = 14.sp,166 ),167 modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)168 .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),169 decorationBox = { inner ->170 if (query.isEmpty()) {171 Text(172 "Search eligible Peach passkeys…",173 color = peachColors.T2,174 fontFamily = PeachType.Mono,175 fontSize = 12.sp,176 )177 }178 inner()179 },180 )181 LazyColumn {182 items(visible, key = { "${it.entryId}:${it.option.requestJson.hashCode()}" }) { choice ->183 Column(184 Modifier.fillMaxWidth().clickable {185 lifecycleScope.launch {186 completeAssertion(187 choice.entryId,188 choice.option.requestJson,189 origin,190 choice.option.clientDataHash,191 )192 }193 }.padding(vertical = 12.dp),194 ) {195 Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)196 Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)197 }198 }199 }200 }201 }202 }203 }204205 private suspend fun completeAssertion(206 entryId: String?,207 requestJson: String?,208 origin: String?,209 clientDataHash: ByteArray?,210 ) {211 val result = Intent()212 val entry = entryId?.let(vault::getEntry)213 214 215 216 217 218 val uvRequired = entry != null && requestJson != null &&219 userVerificationRequirement(requestJson) == UV_REQUIRED220 val userVerified = if (uvRequired) {221 runUserVerificationPrompt()222 } else {223 false224 }225 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {226 PendingIntentHandler.setGetCredentialException(227 result,228 GetCredentialUnsupportedException(229 "User verification is required but could not be completed.",230 ),231 )232 setResult(RESULT_OK, result)233 finish()234 return235 }236 val assertion = if (entry != null && requestJson != null) {237 PasskeyAssertion.build(238 entry = entry,239 requestJson = requestJson,240 origin = origin,241 clientDataHashOverride = clientDataHash,242 userVerified = userVerified,243 )244 } else {245 null246 }247248 if (entry != null && assertion != null) {249 val passkey = entry.passkey250 val persisted = if (passkey != null) {251 val now = System.currentTimeMillis()252 runCatching {253 vault.updateEntry(254 entry.copy(255 passkey = passkey.copy(signCount = assertion.nextSignCount),256 signCount = assertion.nextSignCount,257 lastUsedAt = now,258 ),259 )260 }.isSuccess261 } else {262 false263 }264 if (persisted) {265 PendingIntentHandler.setGetCredentialResponse(266 result,267 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),268 )269 } else {270 PendingIntentHandler.setGetCredentialException(271 result,272 GetCredentialUnsupportedException("Peach could not update this passkey."),273 )274 }275 } else {276 PendingIntentHandler.setGetCredentialException(277 result,278 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),279 )280 }281 setResult(RESULT_OK, result)282 finish()283 }284285 private suspend fun completeCreate() {286 val result = Intent()287 val providerRequest = runCatching {288 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)289 }.getOrNull()290 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest291 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)292 293 294 295 val origin = providerRequest?.callingAppInfo?.let { info ->296 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))297 }298 299 300 301 302 303 val uvRequired = requestJson != null &&304 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED305 306 307 308 309 val userVerified = runUserVerificationPrompt()310 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {311 PendingIntentHandler.setCreateCredentialException(312 result,313 CreateCredentialUnsupportedException(314 "User verification is required but could not be completed.",315 ),316 )317 setResult(RESULT_OK, result)318 finish()319 return320 }321 val registration = requestJson?.let {322 PasskeyRegistration.build(it, origin, userVerified = userVerified)323 }324 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false325 if (registration != null && stored) {326 PendingIntentHandler.setCreateCredentialResponse(327 result,328 CreatePublicKeyCredentialResponse(registration.responseJson),329 )330 } else {331 PendingIntentHandler.setCreateCredentialException(332 result,333 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),334 )335 }336 setResult(RESULT_OK, result)337 finish()338 }339340 341 342 343 344 345 private fun registrationUserVerificationRequirement(requestJson: String): String? =346 runCatching {347 val root = Json.parseToJsonElement(requestJson).jsonObject348 val fromSelection = root["authenticatorSelection"]?.jsonObject349 ?.get("userVerification")?.jsonPrimitive?.contentOrNull350 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull351 }.getOrNull()352353 private fun userVerificationRequirement(requestJson: String): String? =354 runCatching {355 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]356 ?.jsonPrimitive?.contentOrNull357 }.getOrNull()358359 private suspend fun runUserVerificationPrompt(): Boolean {360 if (!Biometrics.isAvailable(this)) return false361 return suspendCancellableCoroutine { cont ->362 Biometrics.authenticate(363 activity = this,364 title = "Verify it's you",365 subtitle = "Confirm to use this passkey",366 onSuccess = { if (cont.isActive) cont.resume(true) },367 onFail = { if (cont.isActive) cont.resume(false) },368 )369 }370 }371372 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {373 val rpId = draft.passkey?.rpId ?: return false374 val target = vault.getEntries().firstOrNull { entry ->375 entry.deletedAt == null &&376 entry.passkey == null &&377 entry.isLogin() &&378 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())379 .any { extractDomain(it) == rpId }380 }381 return runCatching {382 if (target != null) {383 vault.updateEntry(384 target.copy(385 passkey = draft.passkey,386 username = target.username ?: draft.username,387 ),388 )389 } else {390 vault.addEntry(draft)391 }392 }.isSuccess393 }394395 companion object {396 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"397 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"398 private const val EXTRA_AUTHENTICATED_ORIGIN =399 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"400 private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =401 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"402 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"403 private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"404 private const val MODE_CREATE = "create"405 private const val MODE_CHOOSE = "choose"406 407 408 409 private const val UV_REQUIRED = "required"410411 fun pendingIntent(412 context: Context,413 entryId: String,414 requestJson: String,415 authenticatedOrigin: String?,416 authenticatedClientDataHash: ByteArray?,417 ): PendingIntent {418 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)419 .putExtra(EXTRA_ENTRY_ID, entryId)420 .putExtra(EXTRA_REQUEST_JSON, requestJson)421 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)422 .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)423 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0424 return PendingIntent.getActivity(425 context,426 "$entryId:$requestJson".hashCode(),427 intent,428 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,429 )430 }431432 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {433 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)434 .putExtra(EXTRA_MODE, MODE_CREATE)435 .putExtra(EXTRA_REQUEST_JSON, requestJson)436 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0437 return PendingIntent.getActivity(438 context,439 requestJson.hashCode(),440 intent,441 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,442 )443 }444445 fun choosePendingIntent(446 context: Context,447 request: BeginGetCredentialRequest,448 authenticatedOrigin: String?,449 ): PendingIntent {450 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)451 .putExtra(EXTRA_MODE, MODE_CHOOSE)452 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))453 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)454 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0455 return PendingIntent.getActivity(456 context,457 "choose:${request.hashCode()}".hashCode(),458 intent,459 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,460 )461 }462 }463464 private data class ManualChoice(465 val entryId: String,466 val displayName: String,467 val username: String,468 val option: BeginGetPublicKeyCredentialOption,469 )470}471472473474475476477478479internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =480 frameworkOrigin?.takeIf { it.isNotBlank() }481 ?: authenticatedOrigin?.takeIf { it.isNotBlank() }482483484485486487488internal fun selectionClientDataHash(489 frameworkHash: ByteArray?,490 authenticatedHash: ByteArray?,491): ByteArray? = frameworkHash ?: authenticatedHash