Skip to content

Provide callback instead of doing UI tasks of calling app #49 #50

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f3d1cc0
Provide callback instead of doing UI tasks of calling app
sunkup Feb 19, 2025
6bd6422
Use the right scope
sunkup Feb 20, 2025
b465b60
Use a deferred to await user decision instead of flow
sunkup Feb 20, 2025
11f6cf7
Pass scope only where needed
sunkup Feb 25, 2025
ce1d76e
Minor rearrangements
sunkup Feb 25, 2025
c91fbb4
Fix tests
sunkup Feb 25, 2025
0924124
Mark userDecision property as volatile
sunkup Feb 25, 2025
e32a205
Remove TrustCertificateActivity from manifest
sunkup Feb 25, 2025
96a102e
Remove unused strings
sunkup Feb 25, 2025
7ec1ded
Fix tests for CI
sunkup Feb 26, 2025
67c6a1b
Remove remaining notification related code
sunkup Feb 26, 2025
031f718
Update README.md
sunkup Feb 26, 2025
89d5990
Remove remaining unused string resources
sunkup Feb 26, 2025
8fb0ea2
Merge branch 'main' into 49-provide-callback-instead-of-doing-ui-task…
rfc2822 Feb 27, 2025
aa7fea6
Revert "Remove unused strings"
sunkup Mar 4, 2025
9d9bfdb
Revert "Remove remaining unused string resources"
sunkup Mar 4, 2025
a85285d
Extract and use composable from TrustCertificateActivity
sunkup Mar 4, 2025
42efa84
Merge branch 'main' into 49-provide-callback-instead-of-doing-ui-task…
sunkup Mar 10, 2025
7c65abb
Remove theme
sunkup Mar 10, 2025
cf358a1
Remove livedata, viewmodel and activity related dependencies
sunkup Mar 10, 2025
246a542
Minor UI changes
rfc2822 Mar 11, 2025
751d675
Move state of pending decision into Flow
rfc2822 Mar 11, 2025
863629d
Improve kdoc
sunkup Mar 11, 2025
8c6e7b7
Move companion object to the end of class
sunkup Mar 11, 2025
6d4263c
Make PendingDecision a data class
sunkup Mar 11, 2025
02427de
Update comment
sunkup Mar 11, 2025
106c60a
Revert "Make PendingDecision a data class"
sunkup Mar 11, 2025
47b9aca
Minor changes in structure, comments
rfc2822 Mar 12, 2025
e42a98a
Add param to kdoc
sunkup Mar 12, 2025
f4ace05
Replace iterator with forEach
sunkup Mar 12, 2025
c5bfc05
Revert "Replace iterator with forEach"
sunkup Mar 12, 2025
6b60ca4
More comments for iteration
rfc2822 Mar 12, 2025
12f1b45
Use Collections.synchronizedMap to synchronize access
rfc2822 Mar 12, 2025
0c0bbd5
Add test to check whether pendingDecisions are empty after cancellation
sunkup Mar 13, 2025
49bd354
Remove decisions list from pendingDecisions map if list is empty
sunkup Mar 18, 2025
3de7b87
Main activity: edge-to-edge
rfc2822 Apr 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading