Skip to content

Commit

Permalink
Provide callback instead of doing UI tasks of calling app
Browse files Browse the repository at this point in the history
  • Loading branch information
sunkup committed Feb 19, 2025
1 parent 62ae0e6 commit f3d1cc0
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 205 deletions.
11 changes: 5 additions & 6 deletions lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@ import javax.net.ssl.X509TrustManager
* Initializes Conscrypt when it is first loaded.
*
* @param trustSystemCerts whether system certificates will be trusted
* @param appInForeground - `true`: if needed, directly launches [TrustCertificateActivity] and shows notification (if possible)
* - `false`: if needed, shows notification (if possible)
* - `null`: non-interactive mode: does not show notification or launch activity
* @param getUserDecision anonymous function to retrieve user decision on whether to trust a
* certificate; should return *true* if the user trusts the certificate
*/
@SuppressLint("CustomX509TrustManager")
class CustomCertManager @JvmOverloads constructor(
context: Context,
val trustSystemCerts: Boolean = true,
var appInForeground: StateFlow<Boolean>?
private val getUserDecision: suspend (X509Certificate) -> Boolean
): X509TrustManager {

val certStore = CustomCertStore.getInstance(context)
Expand All @@ -47,7 +46,7 @@ class CustomCertManager @JvmOverloads constructor(
*/
@Throws(CertificateException::class)
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
if (!certStore.isTrusted(chain, authType, trustSystemCerts, appInForeground))
if (!certStore.isTrusted(chain, authType, trustSystemCerts, getUserDecision))
throw CertificateException("Certificate chain not trusted")
}

Expand All @@ -71,7 +70,7 @@ class CustomCertManager @JvmOverloads constructor(
// Allow users to explicitly accept certificates that have a bad hostname here
(session.peerCertificates.firstOrNull() as? X509Certificate)?.let { cert ->
// Check without trusting system certificates so that the user will be asked even for system-trusted certificates
if (certStore.isTrusted(arrayOf(cert), "RSA", false, appInForeground))
if (certStore.isTrusted(arrayOf(cert), "RSA", false, getUserDecision))
return true
}

Expand Down
15 changes: 7 additions & 8 deletions lib/src/main/java/at/bitfire/cert4android/CustomCertStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import android.annotation.SuppressLint
import android.content.Context
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.conscrypt.Conscrypt
Expand Down Expand Up @@ -77,7 +76,12 @@ class CustomCertStore internal constructor(
/**
* Determines whether a certificate chain is trusted.
*/
fun isTrusted(chain: Array<X509Certificate>, authType: String, trustSystemCerts: Boolean, appInForeground: StateFlow<Boolean>?): Boolean {
fun isTrusted(
chain: Array<X509Certificate>,
authType: String,
trustSystemCerts: Boolean,
getUserDecision: suspend (X509Certificate) -> Boolean
): Boolean {
if (chain.isEmpty())
throw IllegalArgumentException("Certificate chain must not be empty")
val cert = chain[0]
Expand All @@ -103,17 +107,12 @@ class CustomCertStore internal constructor(
}
}

if (appInForeground == null) {
Cert4Android.log.log(Level.INFO, "Certificate not known and running in non-interactive mode, rejecting")
return false
}

return runBlocking {
val ui = UserDecisionRegistry.getInstance(context)

try {
withTimeout(userTimeout) {
ui.check(cert, appInForeground.value)
ui.check(cert, getUserDecision)
}
} catch (_: TimeoutCancellationException) {
Cert4Android.log.log(Level.WARNING, "User timeout while waiting for certificate decision, rejecting")
Expand Down
124 changes: 27 additions & 97 deletions lib/src/main/java/at/bitfire/cert4android/UserDecisionRegistry.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package at.bitfire.cert4android

import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import java.security.cert.X509Certificate
import kotlin.coroutines.Continuation
Expand Down Expand Up @@ -41,113 +38,46 @@ class UserDecisionRegistry private constructor(
* Thread-safe, can handle multiple requests for various certificates and/or the same certificate at once.
*
* @param cert certificate to ask user about
* @param appInForeground whether the app is currently in foreground = whether it can directly launch an Activity
* @param getUserDecision anonymous function to retrieve user decision
* @return *true* if the user explicitly trusts the certificate, *false* if unknown or untrusted
*/
suspend fun check(cert: X509Certificate, appInForeground: Boolean): Boolean = suspendCancellableCoroutine { cont ->
// check whether we're able to retrieve user feedback (= start an Activity and/or show a notification)
val notificationsPermitted = NotificationUtils.notificationsPermitted(context)
val userDecisionPossible = appInForeground || notificationsPermitted

if (userDecisionPossible) {
// User decision possible → remember request in pendingDecisions so that a later decision will be applied to this request

cont.invokeOnCancellation {
// remove from pending decisions on cancellation
synchronized(pendingDecisions) {
pendingDecisions[cert]?.remove(cont)
}

val nm = NotificationUtils.createChannels(context)
nm.cancel(CertUtils.getTag(cert), NotificationUtils.ID_CERT_DECISION)
}

val requestDecision: Boolean
suspend fun check(cert: X509Certificate, getUserDecision: suspend (X509Certificate) -> Boolean): Boolean = suspendCancellableCoroutine { cont ->
cont.invokeOnCancellation {
// remove from pending decisions on cancellation
synchronized(pendingDecisions) {
if (pendingDecisions.containsKey(cert)) {
// There are already pending decisions for this request, just add our request
pendingDecisions[cert]!! += cont
requestDecision = false
} else {
// First decision for this certificate, show UI
pendingDecisions[cert] = mutableListOf(cont)
requestDecision = true
}
pendingDecisions[cert]?.remove(cont)
}
}

if (requestDecision)
requestDecision(cert, launchActivity = appInForeground, showNotification = notificationsPermitted)

} else {
// We're not able to retrieve user feedback, directly reject request
Cert4Android.log.warning("App not in foreground and missing notification permission, rejecting certificate")
cont.resume(false)
val requestDecision: Boolean
synchronized(pendingDecisions) {
if (pendingDecisions.containsKey(cert)) {
// There are already pending decisions for this request, just add our request
pendingDecisions[cert]!! += cont
requestDecision = false
} else {
// First decision for this certificate, show UI
pendingDecisions[cert] = mutableListOf(cont)
requestDecision = true
}
}

if (requestDecision)
runBlocking {
requestDecision(cert, getUserDecision)
}
}

/**
* Starts UI for retrieving feedback (accept/reject) for a certificate from the user.
* ...
*
* Ensure that required permissions are granted/conditions are met before setting [launchActivity]
* or [showNotification].
*
* @param cert certificate to ask user about
* @param launchActivity whether to launch a [TrustCertificateActivity]
* @param showNotification whether to show a certificate notification (caller must check notification permissions before passing *true*)
*
* @throws IllegalArgumentException when both [launchActivity] and [showNotification] are *false*
*/
@SuppressLint("MissingPermission")
internal fun requestDecision(cert: X509Certificate, launchActivity: Boolean, showNotification: Boolean) {
if (!launchActivity && !showNotification)
throw IllegalArgumentException("User decision requires certificate Activity and/or notification")

val rawCert = cert.encoded
val decisionIntent = Intent(context, TrustCertificateActivity::class.java).apply {
putExtra(TrustCertificateActivity.EXTRA_CERTIFICATE, rawCert)
}

if (showNotification) {
val rejectIntent = Intent(context, TrustCertificateActivity::class.java).apply {
putExtra(TrustCertificateActivity.EXTRA_CERTIFICATE, rawCert)
putExtra(TrustCertificateActivity.EXTRA_TRUSTED, false)
}

val id = rawCert.contentHashCode()
val notify = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_CERTIFICATES)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setSmallIcon(R.drawable.ic_lock_open_white)
.setContentTitle(context.getString(R.string.certificate_notification_connection_security))
.setContentText(context.getString(R.string.certificate_notification_user_interaction))
.setSubText(cert.subjectDN.name)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(
TaskStackBuilder.create(context)
.addNextIntent(decisionIntent)
.getPendingIntent(id, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.setDeleteIntent(
TaskStackBuilder.create(context)
.addNextIntent(rejectIntent)
.getPendingIntent(id + 1, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.build()

val nm = NotificationUtils.createChannels(context)
nm.notify(CertUtils.getTag(cert), NotificationUtils.ID_CERT_DECISION, notify)
}

if (launchActivity) {
decisionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(decisionIntent)
}
internal suspend fun requestDecision(cert: X509Certificate, getUserDecision: suspend (X509Certificate) -> Boolean) {
val userDecision = getUserDecision(cert)
onUserDecision(cert, userDecision)
}

fun onUserDecision(cert: X509Certificate, trusted: Boolean) {
// cancel notification
val nm = NotificationUtils.createChannels(context)
nm.cancel(CertUtils.getTag(cert), NotificationUtils.ID_CERT_DECISION)

// save decision
val customCertStore = CustomCertStore.getInstance(context)
if (trusted)
Expand Down
Loading

0 comments on commit f3d1cc0

Please sign in to comment.