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.view.WindowManager 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.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.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 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 release. if (!BuildConfig.DEBUG) { window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) } lifecycleScope.launch { completeSelection() } } 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 ?.filterIsInstance() ?.firstOrNull() val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON) val entry = entryId?.let(vault::getEntry) val origin = providerRequest?.callingAppInfo?.let { info -> runCatching { info.getOrigin(info.packageName) }.getOrNull() } // 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. val userVerified = if (entry != null && requestJson != null && userVerificationRequirement(requestJson) == UV_REQUIRED ) { runUserVerificationPrompt() } else { false } val assertion = if (entry != null && requestJson != null) { PasskeyAssertion.build( entry = entry, requestJson = requestJson, origin = origin, clientDataHashOverride = option?.clientDataHash, userVerified = userVerified, ) } else { null } 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) { PendingIntentHandler.setGetCredentialResponse( result, GetCredentialResponse(PublicKeyCredential(assertion.responseJson)), ) } else { PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException("Peach could not update this passkey."), ) } } else { PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException("Peach cannot sign this passkey yet."), ) } 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) val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info -> runCatching { info.getOrigin(info.packageName) }.getOrNull() } val registration = requestJson?.let { PasskeyRegistration.build(it, origin) } 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() } 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 MODE_CREATE = "create" // 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" fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent { val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java) .putExtra(EXTRA_ENTRY_ID, entryId) .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, "$entryId:$requestJson".hashCode(), 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, requestJson.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag, ) } } }