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 frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->87 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))88 }89 val origin = selectionOrigin(90 frameworkOrigin = frameworkOrigin,91 authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),92 )93 94 95 96 97 98 val uvRequired = entry != null && requestJson != null &&99 userVerificationRequirement(requestJson) == UV_REQUIRED100 val userVerified = if (uvRequired) {101 runUserVerificationPrompt()102 } else {103 false104 }105 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {106 PendingIntentHandler.setGetCredentialException(107 result,108 GetCredentialUnsupportedException(109 "User verification is required but could not be completed.",110 ),111 )112 setResult(RESULT_OK, result)113 finish()114 return115 }116 val assertion = if (entry != null && requestJson != null) {117 PasskeyAssertion.build(118 entry = entry,119 requestJson = requestJson,120 origin = origin,121 clientDataHashOverride = option?.clientDataHash,122 userVerified = userVerified,123 )124 } else {125 null126 }127128 if (entry != null && assertion != null) {129 val passkey = entry.passkey130 val persisted = if (passkey != null) {131 val now = System.currentTimeMillis()132 runCatching {133 vault.updateEntry(134 entry.copy(135 passkey = passkey.copy(signCount = assertion.nextSignCount),136 signCount = assertion.nextSignCount,137 lastUsedAt = now,138 ),139 )140 }.isSuccess141 } else {142 false143 }144 if (persisted) {145 PendingIntentHandler.setGetCredentialResponse(146 result,147 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),148 )149 } else {150 PendingIntentHandler.setGetCredentialException(151 result,152 GetCredentialUnsupportedException("Peach could not update this passkey."),153 )154 }155 } else {156 PendingIntentHandler.setGetCredentialException(157 result,158 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),159 )160 }161 setResult(RESULT_OK, result)162 finish()163 }164165 private suspend fun completeCreate() {166 val result = Intent()167 val providerRequest = runCatching {168 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)169 }.getOrNull()170 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest171 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)172 173 174 175 val origin = providerRequest?.callingAppInfo?.let { info ->176 PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))177 }178 179 180 181 182 183 val uvRequired = requestJson != null &&184 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED185 186 187 188 189 val userVerified = runUserVerificationPrompt()190 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {191 PendingIntentHandler.setCreateCredentialException(192 result,193 CreateCredentialUnsupportedException(194 "User verification is required but could not be completed.",195 ),196 )197 setResult(RESULT_OK, result)198 finish()199 return200 }201 val registration = requestJson?.let {202 PasskeyRegistration.build(it, origin, userVerified = userVerified)203 }204 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false205 if (registration != null && stored) {206 PendingIntentHandler.setCreateCredentialResponse(207 result,208 CreatePublicKeyCredentialResponse(registration.responseJson),209 )210 } else {211 PendingIntentHandler.setCreateCredentialException(212 result,213 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),214 )215 }216 setResult(RESULT_OK, result)217 finish()218 }219220 221 222 223 224 225 private fun registrationUserVerificationRequirement(requestJson: String): String? =226 runCatching {227 val root = Json.parseToJsonElement(requestJson).jsonObject228 val fromSelection = root["authenticatorSelection"]?.jsonObject229 ?.get("userVerification")?.jsonPrimitive?.contentOrNull230 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull231 }.getOrNull()232233 private fun userVerificationRequirement(requestJson: String): String? =234 runCatching {235 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]236 ?.jsonPrimitive?.contentOrNull237 }.getOrNull()238239 private suspend fun runUserVerificationPrompt(): Boolean {240 if (!Biometrics.isAvailable(this)) return false241 return suspendCancellableCoroutine { cont ->242 Biometrics.authenticate(243 activity = this,244 title = "Verify it's you",245 subtitle = "Confirm to use this passkey",246 onSuccess = { if (cont.isActive) cont.resume(true) },247 onFail = { if (cont.isActive) cont.resume(false) },248 )249 }250 }251252 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {253 val rpId = draft.passkey?.rpId ?: return false254 val target = vault.getEntries().firstOrNull { entry ->255 entry.deletedAt == null &&256 entry.passkey == null &&257 entry.isLogin() &&258 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())259 .any { extractDomain(it) == rpId }260 }261 return runCatching {262 if (target != null) {263 vault.updateEntry(264 target.copy(265 passkey = draft.passkey,266 username = target.username ?: draft.username,267 ),268 )269 } else {270 vault.addEntry(draft)271 }272 }.isSuccess273 }274275 companion object {276 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"277 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"278 private const val EXTRA_AUTHENTICATED_ORIGIN =279 "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"280 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"281 private const val MODE_CREATE = "create"282 283 284 285 private const val UV_REQUIRED = "required"286287 fun pendingIntent(288 context: Context,289 entryId: String,290 requestJson: String,291 authenticatedOrigin: String?,292 ): PendingIntent {293 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)294 .putExtra(EXTRA_ENTRY_ID, entryId)295 .putExtra(EXTRA_REQUEST_JSON, requestJson)296 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)297 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0298 return PendingIntent.getActivity(299 context,300 "$entryId:$requestJson".hashCode(),301 intent,302 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,303 )304 }305306 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {307 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)308 .putExtra(EXTRA_MODE, MODE_CREATE)309 .putExtra(EXTRA_REQUEST_JSON, requestJson)310 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0311 return PendingIntent.getActivity(312 context,313 requestJson.hashCode(),314 intent,315 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,316 )317 }318 }319}320321322323324325326327328internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =329 frameworkOrigin?.takeIf { it.isNotBlank() }330 ?: authenticatedOrigin?.takeIf { it.isNotBlank() }