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.installRelease(window)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 val origin = providerRequest?.callingAppInfo?.let { info ->84 runCatching { info.getOrigin(info.packageName) }.getOrNull()85 }86 87 88 89 90 91 val uvRequired = entry != null && requestJson != null &&92 userVerificationRequirement(requestJson) == UV_REQUIRED93 val userVerified = if (uvRequired) {94 runUserVerificationPrompt()95 } else {96 false97 }98 if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {99 PendingIntentHandler.setGetCredentialException(100 result,101 GetCredentialUnsupportedException(102 "User verification is required but could not be completed.",103 ),104 )105 setResult(RESULT_OK, result)106 finish()107 return108 }109 val assertion = if (entry != null && requestJson != null) {110 PasskeyAssertion.build(111 entry = entry,112 requestJson = requestJson,113 origin = origin,114 clientDataHashOverride = option?.clientDataHash,115 userVerified = userVerified,116 )117 } else {118 null119 }120121 if (entry != null && assertion != null) {122 val passkey = entry.passkey123 val persisted = if (passkey != null) {124 val now = System.currentTimeMillis()125 runCatching {126 vault.updateEntry(127 entry.copy(128 passkey = passkey.copy(signCount = assertion.nextSignCount),129 signCount = assertion.nextSignCount,130 lastUsedAt = now,131 ),132 )133 }.isSuccess134 } else {135 false136 }137 if (persisted) {138 PendingIntentHandler.setGetCredentialResponse(139 result,140 GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),141 )142 } else {143 PendingIntentHandler.setGetCredentialException(144 result,145 GetCredentialUnsupportedException("Peach could not update this passkey."),146 )147 }148 } else {149 PendingIntentHandler.setGetCredentialException(150 result,151 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),152 )153 }154 setResult(RESULT_OK, result)155 finish()156 }157158 private suspend fun completeCreate() {159 val result = Intent()160 val providerRequest = runCatching {161 PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)162 }.getOrNull()163 val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest164 val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)165 val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->166 runCatching { info.getOrigin(info.packageName) }.getOrNull()167 }168 169 170 171 172 173 val uvRequired = requestJson != null &&174 registrationUserVerificationRequirement(requestJson) == UV_REQUIRED175 176 177 178 179 val userVerified = runUserVerificationPrompt()180 if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {181 PendingIntentHandler.setCreateCredentialException(182 result,183 CreateCredentialUnsupportedException(184 "User verification is required but could not be completed.",185 ),186 )187 setResult(RESULT_OK, result)188 finish()189 return190 }191 val registration = requestJson?.let {192 PasskeyRegistration.build(it, origin, userVerified = userVerified)193 }194 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false195 if (registration != null && stored) {196 PendingIntentHandler.setCreateCredentialResponse(197 result,198 CreatePublicKeyCredentialResponse(registration.responseJson),199 )200 } else {201 PendingIntentHandler.setCreateCredentialException(202 result,203 CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),204 )205 }206 setResult(RESULT_OK, result)207 finish()208 }209210 211 212 213 214 215 private fun registrationUserVerificationRequirement(requestJson: String): String? =216 runCatching {217 val root = Json.parseToJsonElement(requestJson).jsonObject218 val fromSelection = root["authenticatorSelection"]?.jsonObject219 ?.get("userVerification")?.jsonPrimitive?.contentOrNull220 fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull221 }.getOrNull()222223 private fun userVerificationRequirement(requestJson: String): String? =224 runCatching {225 Json.parseToJsonElement(requestJson).jsonObject["userVerification"]226 ?.jsonPrimitive?.contentOrNull227 }.getOrNull()228229 private suspend fun runUserVerificationPrompt(): Boolean {230 if (!Biometrics.isAvailable(this)) return false231 return suspendCancellableCoroutine { cont ->232 Biometrics.authenticate(233 activity = this,234 title = "Verify it's you",235 subtitle = "Confirm to use this passkey",236 onSuccess = { if (cont.isActive) cont.resume(true) },237 onFail = { if (cont.isActive) cont.resume(false) },238 )239 }240 }241242 private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {243 val rpId = draft.passkey?.rpId ?: return false244 val target = vault.getEntries().firstOrNull { entry ->245 entry.deletedAt == null &&246 entry.passkey == null &&247 entry.isLogin() &&248 listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())249 .any { extractDomain(it) == rpId }250 }251 return runCatching {252 if (target != null) {253 vault.updateEntry(254 target.copy(255 passkey = draft.passkey,256 username = target.username ?: draft.username,257 ),258 )259 } else {260 vault.addEntry(draft)261 }262 }.isSuccess263 }264265 companion object {266 private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"267 private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"268 private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"269 private const val MODE_CREATE = "create"270 271 272 273 private const val UV_REQUIRED = "required"274275 fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {276 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)277 .putExtra(EXTRA_ENTRY_ID, entryId)278 .putExtra(EXTRA_REQUEST_JSON, requestJson)279 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0280 return PendingIntent.getActivity(281 context,282 "$entryId:$requestJson".hashCode(),283 intent,284 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,285 )286 }287288 fun createPendingIntent(context: Context, requestJson: String): PendingIntent {289 val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)290 .putExtra(EXTRA_MODE, MODE_CREATE)291 .putExtra(EXTRA_REQUEST_JSON, requestJson)292 val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0293 return PendingIntent.getActivity(294 context,295 requestJson.hashCode(),296 intent,297 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,298 )299 }300 }301}