1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.credentials.GetCredentialResponse9import androidx.credentials.GetPublicKeyCredentialOption10import androidx.credentials.CreatePublicKeyCredentialRequest11import androidx.credentials.CreatePublicKeyCredentialResponse12import androidx.credentials.PublicKeyCredential13import androidx.credentials.exceptions.CreateCredentialUnsupportedException14import androidx.credentials.exceptions.GetCredentialUnsupportedException15import androidx.credentials.provider.PendingIntentHandler16import androidx.fragment.app.FragmentActivity17import androidx.lifecycle.lifecycleScope18import com.peachpasswords.BuildConfig19import com.peachpasswords.shared.extractDomain20import com.peachpasswords.ui.lock.Biometrics21import com.peachpasswords.ui.components.QaFlags22import com.peachpasswords.ui.components.SecureWindowPolicy23import com.peachpasswords.vault.VaultRepository24import com.peachpasswords.vault.isLogin25import dagger.hilt.android.AndroidEntryPoint26import javax.inject.Inject27import kotlinx.coroutines.launch28import kotlinx.coroutines.suspendCancellableCoroutine29import kotlinx.serialization.json.Json30import kotlinx.serialization.json.contentOrNull31import kotlinx.serialization.json.jsonObject32import kotlinx.serialization.json.jsonPrimitive33import kotlin.coroutines.resume3435@AndroidEntryPoint36class PasskeyCredentialSelectionActivity : FragmentActivity() {37 @Inject lateinit var vault: VaultRepository38 private var debugSecureLease = false3940 override fun onCreate(savedInstanceState: Bundle?) {41 super.onCreate(savedInstanceState)42 43 44 45 if (!BuildConfig.DEBUG) {46 SecureWindowPolicy.setUserSecure(window, true)47 } else if (!qaSecureCaptureBypass()) {48 SecureWindowPolicy.acquire(window)49 debugSecureLease = true50 }51 lifecycleScope.launch { completeSelection() }52 }5354 override fun onDestroy() {55 if (debugSecureLease) {56 SecureWindowPolicy.release(window)57 debugSecureLease = false58 }59 super.onDestroy()60 }6162 private fun qaSecureCaptureBypass(): Boolean =63 BuildConfig.DEBUG && (64 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||65 QaFlags.disableSecureCaptureForQa66 )6768 private suspend fun completeSelection() {69 if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {70 completeCreate()71 return72 }73 val result = Intent()74 val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)75 val providerRequest = runCatching {76 PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)77 }.getOrNull()78 val option = providerRequest?.credentialOptions79 ?.filterIsInstance<GetPublicKeyCredentialOption>()80 ?.firstOrNull()81 val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)82 val entry = entryId?.let(vault::getEntry)83 84 85 86 val origin = providerRequest?.callingAppInfo?.let { info ->87 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))88 }89 90 91 92 93 94 val uvRequired = entry != null && requestJson != null &&95 userVerificationRequirement(requestJson) == UV_REQUIRED96 val userVerified = if (uvRequired) {97 runUserVerificationPrompt()98 } else {99 false100 }101 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {102 PendingIntentHandler.setGetCredentialException(103 result,104 GetCredentialUnsupportedException(105 "User verification is required but could not be completed.",106 ),107 )108 setResult(RESULT_OK, result)109 finish()110 return111 }112 val assertion = if (entry != null && requestJson != null) {113 PasskeyAssertion.build(114 entry = entry,115 requestJson = requestJson,116 origin = origin,117 clientDataHashOverride = option?.clientDataHash,118 userVerified = userVerified,119 )120 } else {121 null122 }123124 if (entry != null && assertion != null) {125 val passkey = entry.passkey126 val persisted = if (passkey != null) {127 val now = System.currentTimeMillis()128 runCatching {129 vault.updateEntry(130 entry.copy(131 passkey = passkey.copy(signCount = assertion.nextSignCount),132 signCount = assertion.nextSignCount,133 lastUsedAt = now,134 ),135 )136 }.isSuccess137 } else {138 false139 }140 if (persisted) {141 PendingIntentHandler.setGetCredentialResponse(142 result,143 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),144 )145 } else {146 PendingIntentHandler.setGetCredentialException(147 result,148 GetCredentialUnsupportedException("Peach could not update this passkey."),149 )150 }151 } else {152 PendingIntentHandler.setGetCredentialException(153 result,154 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),155 )156 }157 setResult(RESULT_OK, result)158 finish()159 }160161 private suspend fun completeCreate() {162 val result = Intent()163 val providerRequest = runCatching {164 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)165 }.getOrNull()166 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest167 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)168 169 170 171 val origin = providerRequest?.callingAppInfo?.let { info ->172 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))173 }174 175 176 177 178 179 val uvRequired = requestJson != null &&180 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED181 182 183 184 185 val userVerified = runUserVerificationPrompt()186 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {187 PendingIntentHandler.setCreateCredentialException(188 result,189 CreateCredentialUnsupportedException(190 "User verification is required but could not be completed.",191 ),192 )193 setResult(RESULT_OK, result)194 finish()195 return196 }197 val registration = requestJson?.let {198 PasskeyRegistration.build(it, origin, userVerified = userVerified)199 }200 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false201 if (registration != null && stored) {202 PendingIntentHandler.setCreateCredentialResponse(203 result,204 CreatePublicKeyCredentialResponse(registration.responseJson),205 )206 } else {207 PendingIntentHandler.setCreateCredentialException(208 result,209 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),210 )211 }212 setResult(RESULT_OK, result)213 finish()214 }215216 217 218 219 220 221 private fun registrationUserVerificationRequirement(requestJson: String): String? =222 runCatching {223 val root = Json.parseToJsonElement(requestJson).jsonObject224 val fromSelection = root["authenticatorSelection"]?.jsonObject225 ?.get("userVerification")?.jsonPrimitive?.contentOrNull226 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull227 }.getOrNull()228229 private fun userVerificationRequirement(requestJson: String): String? =230 runCatching {231 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]232 ?.jsonPrimitive?.contentOrNull233 }.getOrNull()234235 private suspend fun runUserVerificationPrompt(): Boolean {236 if (!Biometrics.isAvailable(this)) return false237 return suspendCancellableCoroutine { cont ->238 Biometrics.authenticate(239 activity = this,240 title = "Verify it's you",241 subtitle = "Confirm to use this passkey",242 onSuccess = { if (cont.isActive) cont.resume(true) },243 onFail = { if (cont.isActive) cont.resume(false) },244 )245 }246 }247248 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {249 val rpId = draft.passkey?.rpId ?: return false250 val target = vault.getEntries().firstOrNull { entry ->251 entry.deletedAt == null &&252 entry.passkey == null &&253 entry.isLogin() &&254 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())255 .any { extractDomain(it) == rpId }256 }257 return runCatching {258 if (target != null) {259 vault.updateEntry(260 target.copy(261 passkey = draft.passkey,262 username = target.username ?: draft.username,263 ),264 )265 } else {266 vault.addEntry(draft)267 }268 }.isSuccess269 }270271 companion object {272 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"273 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"274 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"275 private const val MODE_CREATE = "create"276 277 278 279 private const val UV_REQUIRED = "required"280281 fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {282 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)283 .putExtra(EXTRA_ENTRY_ID, entryId)284 .putExtra(EXTRA_REQUEST_JSON, requestJson)285 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0286 return PendingIntent.getActivity(287 context,288 "$entryId:$requestJson".hashCode(),289 intent,290 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,291 )292 }293294 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {295 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)296 .putExtra(EXTRA_MODE, MODE_CREATE)297 .putExtra(EXTRA_REQUEST_JSON, requestJson)298 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0299 return PendingIntent.getActivity(300 context,301 requestJson.hashCode(),302 intent,303 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,304 )305 }306 }307}