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 33 commits
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
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,6 @@ Example of initialzing an okhttp client:
}


You can overwrite resources when you want, just have a look at the `res/strings`
directory. Especially `certificate_notification_connection_security` and
`trust_certificate_unknown_certificate_found` should contain your app name.


# License

Copyright (C) Ricki Hirner and [contributors](https://github.com/bitfireAT/cert4android/graphs/contributors).
Expand Down
6 changes: 0 additions & 6 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,11 @@ publishing {

dependencies {
implementation(libs.kotlin.stdlib)

implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.conscrypt)

// Jetpack Compose
implementation(libs.androidx.activityCompose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
implementation(libs.compose.runtime.livedata)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.toolingPreview)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
package at.bitfire.cert4android

import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assume.assumeNotNull
import org.junit.Before
import org.junit.Test
Expand All @@ -19,6 +23,7 @@ class CustomCertManagerTest {

private lateinit var certManager: CustomCertManager
private lateinit var paranoidCertManager: CustomCertManager
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)

private var siteCerts: List<X509Certificate>? =
try {
Expand All @@ -32,10 +37,14 @@ class CustomCertManagerTest {

@Before
fun createCertManager() {
certManager = CustomCertManager(context, true, null)
paranoidCertManager = CustomCertManager(context, false, null)
certManager = CustomCertManager(context, true, scope, getUserDecision = { true })
paranoidCertManager = CustomCertManager(context, false, scope, getUserDecision = { false })
}

@After
fun cleanUp() {
scope.cancel()
}

@Test(expected = CertificateException::class)
fun testCheckClientCertificate() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
package at.bitfire.cert4android

import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.security.cert.X509Certificate
import java.util.Collections
import java.util.concurrent.Semaphore
import kotlin.concurrent.thread

class UserDecisionRegistryTest {

Expand All @@ -27,9 +29,7 @@ class UserDecisionRegistryTest {

@Before
fun setUp() {
mockkObject(NotificationUtils)
mockkObject(registry)
every { registry.requestDecision(any(), any(), any()) } returns Unit
}

@After
Expand All @@ -41,86 +41,73 @@ class UserDecisionRegistryTest {

@Test
fun testCheck_FirstDecision_Negative() {
every { registry.requestDecision(testCert, any(), any()) } answers {
registry.onUserDecision(testCert, false)
}
assertFalse(runBlocking {
registry.check(testCert, true)
registry.check(testCert, this) { false }
})
}

@Test
fun testCheck_FirstDecision_Positive() {
every { registry.requestDecision(testCert, any(), any()) } answers {
registry.onUserDecision(testCert, true)
}
assertTrue(runBlocking {
registry.check(testCert, true)
registry.check(testCert, this) { true }
})
}

@Test
fun testCheck_MultipleDecisionsForSameCert_Negative() {
val canSendFeedback = Semaphore(0)
every { registry.requestDecision(testCert, any(), any()) } answers {
thread {
canSendFeedback.acquire()
registry.onUserDecision(testCert, false)
val getUserDecision: suspend (X509Certificate) -> Boolean = mockk {
coEvery { this@mockk(testCert) } coAnswers {
canSendFeedback.acquire() // block call until released
false
}
}
val results = Collections.synchronizedList(mutableListOf<Boolean>())
runBlocking {
runBlocking(Dispatchers.Default) {
// launch 5 getUserDecision calls (each will be blocked by the semaphore)
repeat(5) {
launch(Dispatchers.Default) {
results += registry.check(testCert, true)
launch {
results += registry.check(testCert, this, getUserDecision)
}
}
canSendFeedback.release()
delay(1000) // wait a bit for all getUserDecision calls to be launched and blocked
canSendFeedback.release() // now unblock all calls at the same time
}

// pendingDecisions should be empty
synchronized(registry.pendingDecisions) {
assertFalse(registry.pendingDecisions.containsKey(testCert))
}
assertEquals(5, results.size)
assertTrue(results.all { !it })
verify(exactly = 1) { registry.requestDecision(any(), any(), any()) }
assertEquals(5, results.size) // should be 5 results
assertTrue(results.all { result -> !result }) // all results should be false
coVerify(exactly = 1) { getUserDecision(testCert) } // getUserDecision should be called only once
}

@Test
fun testCheck_MultipleDecisionsForSameCert_Positive() {
val canSendFeedback = Semaphore(0)
every { registry.requestDecision(testCert, any(), any()) } answers {
thread {
val getUserDecision: suspend (X509Certificate) -> Boolean = mockk {
coEvery { this@mockk(testCert) } coAnswers {
canSendFeedback.acquire()
registry.onUserDecision(testCert, true)
true
}
}
val results = Collections.synchronizedList(mutableListOf<Boolean>())
runBlocking {
runBlocking(Dispatchers.Default) {
repeat(5) {
launch(Dispatchers.Default) {
results += registry.check(testCert, true)
launch {
results += registry.check(testCert, this, getUserDecision)
}
}
delay(1000)
canSendFeedback.release()
}
synchronized(registry.pendingDecisions) {
assertFalse(registry.pendingDecisions.containsKey(testCert))
}
assertEquals(5, results.size)
assertTrue(results.all { it })
verify(exactly = 1) { registry.requestDecision(any(), any(), any()) }
}

@Test
fun testCheck_UserDecisionImpossible() {
every { NotificationUtils.notificationsPermitted(any()) } returns false
assertFalse(runBlocking {
// should return instantly
registry.check(testCert, false)
})
verify(inverse = true) {
registry.requestDecision(any(), any(), any())
}
coVerify(exactly = 1) { getUserDecision(testCert) }
}

}
10 changes: 0 additions & 10 deletions lib/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,4 @@

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>

<activity
android:name=".TrustCertificateActivity"
android:label="@string/certificate_notification_connection_security"
android:launchMode="singleInstance"
android:excludeFromRecents="true"
android:exported="true"/>

</application>
</manifest>
16 changes: 0 additions & 16 deletions lib/src/main/java/at/bitfire/cert4android/Cert4Android.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@
package at.bitfire.cert4android

import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import java.util.logging.Level
import java.util.logging.Logger

Expand All @@ -27,15 +22,4 @@ object Cert4Android {
Level.INFO
}


// theme

var theme: @Composable (content: @Composable () -> Unit) -> Unit = { content ->
MaterialTheme {
Box(Modifier.safeDrawingPadding()) {
content()
}
}
}

}
53 changes: 53 additions & 0 deletions lib/src/main/java/at/bitfire/cert4android/CertificateDetails.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package at.bitfire.cert4android

import java.security.cert.X509Certificate
import java.security.spec.MGF1ParameterSpec.SHA1
import java.security.spec.MGF1ParameterSpec.SHA256
import java.text.DateFormat

/**
* Human-readable certificate details.
*
* Usually created with [CertificateDetails.fromX509] and used by [TrustCertificateDialog].
*/
data class CertificateDetails(
val issuedFor: String? = null,
val issuedBy: String? = null,
val validFrom: String? = null,
val validTo: String? = null,
val sha1: String? = null,
val sha256: String? = null
) {

companion object {

/**
* Creates [CertificateDetails] from [X509Certificate].
*
* @param cert X509Certificate
* @return CertificateDetails
*/
fun fromX509(cert: X509Certificate): CertificateDetails {
val subject = cert.subjectAlternativeNames?.let { altNames ->
val sb = StringBuilder()
for (altName in altNames) {
val name = altName[1]
if (name is String)
sb.append("[").append(altName[0]).append("]").append(name).append(" ")
}
sb.toString()
} ?: /* use CN if alternative names are not available */ cert.subjectDN.name

val timeFormatter = DateFormat.getDateInstance(DateFormat.LONG)
return CertificateDetails(
issuedFor = subject,
issuedBy = cert.issuerDN.toString(),
validFrom = timeFormatter.format(cert.notBefore),
validTo = timeFormatter.format(cert.notAfter),
sha1 = CertUtils.fingerprint(cert, SHA1.digestAlgorithm),
sha256 = CertUtils.fingerprint(cert, SHA256.digestAlgorithm)
)
}
}

}
19 changes: 10 additions & 9 deletions lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package at.bitfire.cert4android

import android.annotation.SuppressLint
import android.content.Context
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.CoroutineScope
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLSession
Expand All @@ -15,18 +15,19 @@ import javax.net.ssl.X509TrustManager
/**
* TrustManager to handle custom certificates.
*
* 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 scope coroutine scope which [getUserDecision] will be launched in. On
* cancellation of the scope waiting for a user decision is stopped.
* Cancelling the scope is responsibility of the creator
* @param getUserDecision suspend 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 scope: CoroutineScope,
private val getUserDecision: suspend (X509Certificate) -> Boolean
): X509TrustManager {

val certStore = CustomCertStore.getInstance(context)
Expand All @@ -47,7 +48,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, scope, getUserDecision))
throw CertificateException("Certificate chain not trusted")
}

Expand All @@ -71,7 +72,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, scope, getUserDecision))
return true
}

Expand Down
Loading