package com.peachpasswords.passkeys import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.credentials.GetCredentialResponse import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.PublicKeyCredential import androidx.credentials.exceptions.CreateCredentialUnsupportedException import androidx.credentials.exceptions.GetCredentialUnsupportedException import androidx.credentials.provider.PendingIntentHandler import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.peachpasswords.BuildConfig import com.peachpasswords.shared.extractDomain import com.peachpasswords.ui.lock.Biometrics import com.peachpasswords.ui.components.QaFlags import com.peachpasswords.ui.components.SecureWindowPolicy import com.peachpasswords.ui.theme.ACCENT_COLORS import com.peachpasswords.ui.theme.DEFAULT_ACCENT import com.peachpasswords.ui.theme.PeachTheme import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.isLogin import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.json.Json import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.resume @AndroidEntryPoint class PasskeyCredentialSelectionActivity : FragmentActivity() { @Inject lateinit var vault: VaultRepository private var debugSecureLease = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // H-05: separate window from MainActivity — sign assertion and registration // operate on passkey material. Block screenshots / recents in every build // unless an explicit debug QA bypass was placed on this activity's intent. if (!BuildConfig.DEBUG) { SecureWindowPolicy.setUserSecure(window, true) } else if (!qaSecureCaptureBypass()) { SecureWindowPolicy.acquire(window) debugSecureLease = true } if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser() else lifecycleScope.launch { completeSelection() } } override fun onDestroy() { if (debugSecureLease) { SecureWindowPolicy.release(window) debugSecureLease = false } super.onDestroy() } private fun qaSecureCaptureBypass(): Boolean = BuildConfig.DEBUG && ( intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) || QaFlags.disableSecureCaptureForQa ) private suspend fun completeSelection() { if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) { completeCreate() return } val result = Intent() val entryId = intent.getStringExtra(EXTRA_ENTRY_ID) val providerRequest = runCatching { PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) }.getOrNull() val option = providerRequest?.credentialOptions ?.singleOrNull() as? GetPublicKeyCredentialOption if (providerRequest == null || option == null) { Log.w(TAG, "flow=row stage=final_request_invalid") failGet("Peach did not receive one complete passkey request from Android.") return } val frameworkOrigin = providerRequest?.callingAppInfo?.let { info -> PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this)) } val biometricResult = providerRequest.biometricPromptResult val route = passkeyVerificationRoute( hasEmbeddedResult = biometricResult != null, embeddedSucceeded = biometricResult?.isSuccessful == true, ) if (route == PasskeyVerificationRoute.REJECT) { Log.w(TAG, "flow=row stage=embedded_verification_rejected") failGet("Android could not verify the user for this passkey.") return } val userVerified = when (route) { PasskeyVerificationRoute.ACCEPT -> true PasskeyVerificationRoute.PROMPT_IN_PEACH -> runUserVerificationPrompt() PasskeyVerificationRoute.REJECT -> false } if (!userVerified) { Log.w(TAG, "flow=row stage=verification_incomplete") failGet("User verification is required but could not be completed.") return } Log.i(TAG, "flow=row stage=assertion_started verification=${route.name.lowercase()}") completeAssertion( entryId, option.requestJson, frameworkOrigin, option.clientDataHash, verifiedUser = true, ) } private fun showManualChooser() { val request = runCatching { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST) ?.let(BeginGetCredentialRequest::fromBundle) val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this) val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) } val requestChoices = request?.beginGetCredentialOptions ?.filterIsInstance() ?.flatMap { option -> PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin) .map { ManualChoice( it.entryId, it.displayName, it.username, option.requestJson, option.clientDataHash, ) } } .orEmpty() val choices = requestChoices if (choices.isEmpty()) { Log.w(TAG, "flow=manual stage=begin_request_invalid") failGet("Peach did not receive an eligible passkey request from Android.") return } val settings = vault.settings.value val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT setContent { PeachTheme(accent = accent, textSize = settings.textSize) { var query by remember { mutableStateOf("") } val visible = choices.filter { query.isBlank() || it.displayName.contains(query, ignoreCase = true) || it.username.contains(query, ignoreCase = true) } Column( Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp), ) { Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp) androidx.compose.foundation.text.BasicTextField( value = query, onValueChange = { query = it }, singleLine = true, textStyle = androidx.compose.ui.text.TextStyle( color = peachColors.T0, fontFamily = PeachType.Mono, fontSize = 14.sp, ), modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp) .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp), decorationBox = { inner -> if (query.isEmpty()) { Text( "Search eligible Peach passkeys…", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp, ) } inner() }, ) LazyColumn { items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice -> Column( Modifier.fillMaxWidth().clickable { lifecycleScope.launch { val userVerified = runUserVerificationPrompt() if (!userVerified) { Log.w(TAG, "flow=manual stage=verification_incomplete") failGet("User verification is required but could not be completed.") return@launch } Log.i(TAG, "flow=manual stage=assertion_started") completeAssertion( choice.entryId, choice.requestJson, origin, choice.clientDataHash, verifiedUser = true, ) } }.padding(vertical = 12.dp), ) { Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium) Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp) } } } } } } } private suspend fun completeAssertion( entryId: String?, requestJson: String?, origin: String?, clientDataHash: ByteArray?, uvRequiredOverride: Boolean? = null, verifiedUser: Boolean? = null, ) { val result = Intent() val entry = entryId?.let(vault::getEntry) // H-07: WebAuthn's UV flag must reflect actual user verification, not a // hard-coded bit. If the relying party demands UV, run the platform // biometric / device-credential prompt and only sign with UV=1 on // success. If UV is required but verification fails, do NOT sign — // fail closed rather than produce a misleading UV=0 assertion. val uvRequired = entry != null && requestJson != null && (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED)) val userVerified = verifiedUser ?: if (uvRequired) { runUserVerificationPrompt() } else { false } if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) { PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException( "User verification is required but could not be completed.", ), ) setResult(RESULT_OK, result) finish() return } val assertionBuild = if (entry != null && requestJson != null) { PasskeyAssertion.buildDetailed( entry = entry, requestJson = requestJson, origin = origin, clientDataHashOverride = clientDataHash, userVerified = userVerified, ) } else null val assertion = (assertionBuild as? PasskeyAssertionBuild.Success)?.result if (assertionBuild is PasskeyAssertionBuild.Failure) { Log.w(TAG, "flow=assertion stage=build_failed reason=${assertionBuild.reason.name.lowercase()}") } if (entry != null && assertion != null) { val passkey = entry.passkey val persisted = if (passkey != null) { val now = System.currentTimeMillis() runCatching { vault.updateEntry( entry.copy( passkey = passkey.copy(signCount = assertion.nextSignCount), signCount = assertion.nextSignCount, lastUsedAt = now, ), ) }.isSuccess } else { false } if (persisted) { Log.i(TAG, "flow=assertion stage=response_ready") PendingIntentHandler.setGetCredentialResponse( result, GetCredentialResponse(PublicKeyCredential(assertion.responseJson)), ) } else { Log.w(TAG, "flow=assertion stage=persist_failed") PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException("Peach could not update this passkey."), ) } } else { if (assertionBuild == null) Log.w(TAG, "flow=assertion stage=build_failed reason=missing_input") PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException("Peach cannot sign this passkey yet."), ) } setResult(RESULT_OK, result) finish() } private fun failGet(message: String) { val result = Intent() PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException(message), ) setResult(RESULT_OK, result) finish() } private suspend fun completeCreate() { val result = Intent() val providerRequest = runCatching { PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) }.getOrNull() val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON) // F1/F2: request.origin is caller-supplied and therefore untrusted; // the only acceptable origins are the framework-verified ones. A null // origin makes PasskeyRegistration.build fail closed. val origin = providerRequest?.callingAppInfo?.let { info -> PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this)) } // H-07: WebAuthn's UV flag on registration must reflect an actual // user-verification ceremony, not a hard-coded bit. Read the RP's // requirement from the creation options, run the same platform // biometric / device-credential prompt the assertion path uses, and // fail closed if UV is required but verification did not succeed. val uvRequired = requestJson != null && registrationUserVerificationRequirement(requestJson) == UV_REQUIRED // Opportunistic in all cases: mirror assertion semantics for the // non-required branch (never hard-code UV=1). Biometrics.isAvailable // inside runUserVerificationPrompt already returns false when the // device cannot verify, so a missing authenticator yields UV=0. val userVerified = runUserVerificationPrompt() if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) { PendingIntentHandler.setCreateCredentialException( result, CreateCredentialUnsupportedException( "User verification is required but could not be completed.", ), ) setResult(RESULT_OK, result) finish() return } val registration = requestJson?.let { PasskeyRegistration.build(it, origin, userVerified = userVerified) } val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false if (registration != null && stored) { PendingIntentHandler.setCreateCredentialResponse( result, CreatePublicKeyCredentialResponse(registration.responseJson), ) } else { PendingIntentHandler.setCreateCredentialException( result, CreateCredentialUnsupportedException("Peach cannot create this passkey yet."), ) } setResult(RESULT_OK, result) finish() } // Reads the RP's userVerification requirement from a PublicKeyCredential // CreationOptions payload. Per WebAuthn, the canonical location is // `authenticatorSelection.userVerification`; we fall back to a top-level // `userVerification` field for clients that flatten it. Returns null when // the request does not specify a value (treated as not-required). private fun registrationUserVerificationRequirement(requestJson: String): String? = runCatching { val root = Json.parseToJsonElement(requestJson).jsonObject val fromSelection = root["authenticatorSelection"]?.jsonObject ?.get("userVerification")?.jsonPrimitive?.contentOrNull fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull }.getOrNull() private fun userVerificationRequirement(requestJson: String): String? = runCatching { Json.parseToJsonElement(requestJson).jsonObject["userVerification"] ?.jsonPrimitive?.contentOrNull }.getOrNull() private suspend fun runUserVerificationPrompt(): Boolean { if (!Biometrics.isAvailable(this)) return false return suspendCancellableCoroutine { cont -> Biometrics.authenticate( activity = this, title = "Verify it's you", subtitle = "Confirm to use this passkey", onSuccess = { if (cont.isActive) cont.resume(true) }, onFail = { if (cont.isActive) cont.resume(false) }, ) } } private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean { val rpId = draft.passkey?.rpId ?: return false val target = vault.getEntries().firstOrNull { entry -> entry.deletedAt == null && entry.passkey == null && entry.isLogin() && listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray()) .any { extractDomain(it) == rpId } } return runCatching { if (target != null) { vault.updateEntry( target.copy( passkey = draft.passkey, username = target.username ?: draft.username, ), ) } else { vault.addEntry(draft) } }.isSuccess } companion object { private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID" private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON" private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE" private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST" private const val MODE_CREATE = "create" private const val MODE_CHOOSE = "choose" // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values. // Only "required" forces a biometric / device-credential prompt here; // "preferred" (default) and "discouraged" leave UV=0 in authenticator data. private const val UV_REQUIRED = "required" private const val TAG = "PeachPasskey" fun pendingIntent( context: Context, entryId: String, ): PendingIntent { val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java) .putExtra(EXTRA_ENTRY_ID, entryId) val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 return PendingIntent.getActivity( context, PasskeyPendingIntentIds.next(), intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag, ) } fun createPendingIntent(context: Context, requestJson: String): PendingIntent { val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java) .putExtra(EXTRA_MODE, MODE_CREATE) .putExtra(EXTRA_REQUEST_JSON, requestJson) val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 return PendingIntent.getActivity( context, PasskeyPendingIntentIds.next(), intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag, ) } fun choosePendingIntent( context: Context, request: BeginGetCredentialRequest, ): PendingIntent { val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java) .putExtra(EXTRA_MODE, MODE_CHOOSE) .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request)) val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 return PendingIntent.getActivity( context, PasskeyPendingIntentIds.next(), intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag, ) } } private data class ManualChoice( val entryId: String, val displayName: String, val username: String, val requestJson: String, val clientDataHash: ByteArray?, ) }