PasskeyCredentialSelectionActivity.kt

9.7 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import android.view.WindowManager9import androidx.credentials.GetCredentialResponse10import androidx.credentials.GetPublicKeyCredentialOption11import androidx.credentials.CreatePublicKeyCredentialRequest12import androidx.credentials.CreatePublicKeyCredentialResponse13import androidx.credentials.PublicKeyCredential14import androidx.credentials.exceptions.CreateCredentialUnsupportedException15import androidx.credentials.exceptions.GetCredentialUnsupportedException16import androidx.credentials.provider.PendingIntentHandler17import androidx.fragment.app.FragmentActivity18import androidx.lifecycle.lifecycleScope19import com.peachpasswords.BuildConfig20import com.peachpasswords.shared.extractDomain21import com.peachpasswords.ui.lock.Biometrics22import com.peachpasswords.vault.VaultRepository23import com.peachpasswords.vault.isLogin24import dagger.hilt.android.AndroidEntryPoint25import javax.inject.Inject26import kotlinx.coroutines.launch27import kotlinx.coroutines.suspendCancellableCoroutine28import kotlinx.serialization.json.Json29import kotlinx.serialization.json.contentOrNull30import kotlinx.serialization.json.jsonObject31import kotlinx.serialization.json.jsonPrimitive32import kotlin.coroutines.resume3334@AndroidEntryPoint35class PasskeyCredentialSelectionActivity : FragmentActivity() {36    @Inject lateinit var vault: VaultRepository3738    override fun onCreate(savedInstanceState: Bundle?) {39        super.onCreate(savedInstanceState)40        // H-05: separate window from MainActivity — sign assertion and registration41        // operate on passkey material. Block screenshots / recents in release.42        if (!BuildConfig.DEBUG) {43            window.setFlags(44                WindowManager.LayoutParams.FLAG_SECURE,45                WindowManager.LayoutParams.FLAG_SECURE,46            )47        }48        lifecycleScope.launch { completeSelection() }49    }5051    private suspend fun completeSelection() {52        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {53            completeCreate()54            return55        }56        val result = Intent()57        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)58        val providerRequest = runCatching {59            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)60        }.getOrNull()61        val option = providerRequest?.credentialOptions62            ?.filterIsInstance<GetPublicKeyCredentialOption>()63            ?.firstOrNull()64        val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)65        val entry = entryId?.let(vault::getEntry)66        val origin = providerRequest?.callingAppInfo?.let { info ->67            runCatching { info.getOrigin(info.packageName) }.getOrNull()68        }69        // H-07: WebAuthn's UV flag must reflect actual user verification, not a70        // hard-coded bit. If the relying party demands UV, run the platform71        // biometric / device-credential prompt and only sign with UV=1 on success.72        val userVerified = if (entry != null && requestJson != null &&73            userVerificationRequirement(requestJson) == UV_REQUIRED74        ) {75            runUserVerificationPrompt()76        } else {77            false78        }79        val assertion = if (entry != null && requestJson != null) {80            PasskeyAssertion.build(81                entry = entry,82                requestJson = requestJson,83                origin = origin,84                clientDataHashOverride = option?.clientDataHash,85                userVerified = userVerified,86            )87        } else {88            null89        }9091        if (entry != null && assertion != null) {92            val passkey = entry.passkey93            val persisted = if (passkey != null) {94                val now = System.currentTimeMillis()95                runCatching {96                    vault.updateEntry(97                        entry.copy(98                            passkey = passkey.copy(signCount = assertion.nextSignCount),99                            signCount = assertion.nextSignCount,100                            lastUsedAt = now,101                        ),102                    )103                }.isSuccess104            } else {105                false106            }107            if (persisted) {108                PendingIntentHandler.setGetCredentialResponse(109                    result,110                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),111                )112            } else {113                PendingIntentHandler.setGetCredentialException(114                    result,115                    GetCredentialUnsupportedException("Peach could not update this passkey."),116                )117            }118        } else {119            PendingIntentHandler.setGetCredentialException(120                result,121                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),122            )123        }124        setResult(RESULT_OK, result)125        finish()126    }127128    private suspend fun completeCreate() {129        val result = Intent()130        val providerRequest = runCatching {131            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)132        }.getOrNull()133        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest134        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)135        val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->136            runCatching { info.getOrigin(info.packageName) }.getOrNull()137        }138        val registration = requestJson?.let { PasskeyRegistration.build(it, origin) }139        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false140        if (registration != null && stored) {141            PendingIntentHandler.setCreateCredentialResponse(142                result,143                CreatePublicKeyCredentialResponse(registration.responseJson),144            )145        } else {146            PendingIntentHandler.setCreateCredentialException(147                result,148                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),149            )150        }151        setResult(RESULT_OK, result)152        finish()153    }154155    private fun userVerificationRequirement(requestJson: String): String? =156        runCatching {157            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]158                ?.jsonPrimitive?.contentOrNull159        }.getOrNull()160161    private suspend fun runUserVerificationPrompt(): Boolean {162        if (!Biometrics.isAvailable(this)) return false163        return suspendCancellableCoroutine { cont ->164            Biometrics.authenticate(165                activity = this,166                title = "Verify it's you",167                subtitle = "Confirm to use this passkey",168                onSuccess = { if (cont.isActive) cont.resume(true) },169                onFail = { if (cont.isActive) cont.resume(false) },170            )171        }172    }173174    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {175        val rpId = draft.passkey?.rpId ?: return false176        val target = vault.getEntries().firstOrNull { entry ->177                entry.deletedAt == null &&178                entry.passkey == null &&179                entry.isLogin() &&180                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())181                    .any { extractDomain(it) == rpId }182        }183        return runCatching {184            if (target != null) {185                vault.updateEntry(186                    target.copy(187                        passkey = draft.passkey,188                        username = target.username ?: draft.username,189                    ),190                )191            } else {192                vault.addEntry(draft)193            }194        }.isSuccess195    }196197    companion object {198        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"199        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"200        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"201        private const val MODE_CREATE = "create"202        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.203        // Only "required" forces a biometric / device-credential prompt here;204        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.205        private const val UV_REQUIRED = "required"206207        fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {208            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)209                .putExtra(EXTRA_ENTRY_ID, entryId)210                .putExtra(EXTRA_REQUEST_JSON, requestJson)211            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0212            return PendingIntent.getActivity(213                context,214                "$entryId:$requestJson".hashCode(),215                intent,216                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,217            )218        }219220        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {221            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)222                .putExtra(EXTRA_MODE, MODE_CREATE)223                .putExtra(EXTRA_REQUEST_JSON, requestJson)224            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0225            return PendingIntent.getActivity(226                context,227                requestJson.hashCode(),228                intent,229                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,230            )231        }232    }233}