mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Updating Android app for remove certs (#37640)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #37580 This PR adds certificate removal support and status report retry logic to the Android Fleet agent. It also includes overall code review fixes/improvements. I apologize for the large PR. I would prefer smaller PRs, but there was no one to review during the break. Key changes 1. Managed configuration interface change - certificate_templates now expects status and operation fields per certificate 2. Certificate removal flow - New cleanupRemovedCertificates() handles certificates with operation="remove" - Removes keypair from device keystore and reports status to Fleet server - Handles orphaned certificates (tracked locally but no longer in MDM config) 3. Status report retry logic - New statuses: INSTALLED_UNREPORTED and REMOVED_UNREPORTED - When install/removal succeeds but status report fails, state is persisted for retry - retryUnreportedStatuses() retries on next worker run (up to 10 attempts) - After max retries, transitions to final status (gives up reporting but cert action completed) 4. Dependency injection for testability - Converted CertificateOrchestrator from Kotlin object to class with constructor injection - Created CertificateApiClient interface (implemented by ApiClient) - Instance held in AgentApplication (Google's AppContainer pattern) - Added FakeCertificateApiClient for tests with call tracking 5. Naming improvements 6. Worker retries - Previously, worker would get permanently stuck after 5 retries. Now we recover after 15 minutes. We can extend this later if needed for load testing. 7. New UUID managed config field to trigger re-installs or re-removals of certificates. # Checklist for submitter ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced certificate management with an application-scoped orchestrator, improved state tracking, automatic retries and backoff for enrollments. * UI/Debug: shows host certificate entries and status/operation details; new localized strings for certificate template status and operation. * Managed Configuration: accepts certificate status and operation fields. * **Bug Fixes** * Enrollment now auto-runs only when needed; safer keystore handling and more robust error paths. * **Tests** * Expanded and refactored tests and test utilities for certificate workflows. * **Chores** * App version bumped to 1.0.1. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
6e5a306f48
commit
b4bb714fa5
30 changed files with 1759 additions and 907 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
|
|
@ -23,8 +24,8 @@ android {
|
|||
applicationId = "com.fleetdm.agent"
|
||||
minSdk = 33
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.0.0"
|
||||
versionCode = 4
|
||||
versionName = "1.0.1"
|
||||
|
||||
buildConfigField("String", "INFO_URL", "\"https://fleetdm.com/better\"")
|
||||
|
||||
|
|
@ -67,6 +68,12 @@ android {
|
|||
systemProperty("scep.url", project.property("scep.url").toString())
|
||||
systemProperty("scep.challenge", project.property("scep.challenge").toString())
|
||||
}
|
||||
|
||||
// Enable jacoco coverage for Robolectric tests
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
package com.fleetdm.agent
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.fleetdm.agent", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import org.junit.runner.RunWith
|
|||
class KeystoreManagerTest {
|
||||
|
||||
@Test
|
||||
fun testEncryptDecrypt() {
|
||||
fun `encrypt and decrypt round-trips correctly`() {
|
||||
val originalText = "test_api_key_12345"
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
|
|
@ -21,7 +21,7 @@ class KeystoreManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptProducesDifferentCiphertext() {
|
||||
fun `encrypt produces different ciphertext for same plaintext`() {
|
||||
val originalText = "test_api_key_12345"
|
||||
|
||||
val encrypted1 = KeystoreManager.encrypt(originalText)
|
||||
|
|
@ -34,12 +34,12 @@ class KeystoreManagerTest {
|
|||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecryptInvalidFormat() {
|
||||
fun `decrypt with invalid format throws exception`() {
|
||||
KeystoreManager.decrypt("invalid_format")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptEmptyString() {
|
||||
fun `encrypt handles empty string`() {
|
||||
val originalText = ""
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
|
|
@ -49,7 +49,7 @@ class KeystoreManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptLongString() {
|
||||
fun `encrypt handles long string`() {
|
||||
val originalText = "a".repeat(10000)
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
|
|
@ -59,7 +59,7 @@ class KeystoreManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptSpecialCharacters() {
|
||||
fun `encrypt handles special characters`() {
|
||||
val originalText = "!@#\$%^&*()_+-=[]{}|;':\",./<>?~`"
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import android.content.Context
|
|||
import android.content.RestrictionsManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -21,8 +23,20 @@ import kotlinx.coroutines.launch
|
|||
* Runs when the app process starts (triggered by broadcasts, not by user).
|
||||
*/
|
||||
class AgentApplication : Application() {
|
||||
/** Certificate orchestrator instance for the app */
|
||||
lateinit var certificateOrchestrator: CertificateOrchestrator
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private const val TAG = "fleet-app"
|
||||
|
||||
/**
|
||||
* Gets the CertificateOrchestrator instance from the Application.
|
||||
* @param context Any context (will use applicationContext)
|
||||
* @return The shared CertificateOrchestrator instance
|
||||
*/
|
||||
fun getCertificateOrchestrator(context: Context): CertificateOrchestrator =
|
||||
(context.applicationContext as AgentApplication).certificateOrchestrator
|
||||
}
|
||||
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
|
@ -30,7 +44,11 @@ class AgentApplication : Application() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Fleet agent process started")
|
||||
|
||||
// Initialize dependencies
|
||||
ApiClient.initialize(this)
|
||||
certificateOrchestrator = CertificateOrchestrator()
|
||||
|
||||
refreshEnrollmentCredentials()
|
||||
schedulePeriodicCertificateEnrollment()
|
||||
}
|
||||
|
|
@ -55,13 +73,14 @@ class AgentApplication : Application() {
|
|||
computerName = "${Build.BRAND} ${Build.MODEL}",
|
||||
)
|
||||
|
||||
// Trigger auto-enrollment if node key is missing
|
||||
// This also fetches initial orbit config
|
||||
val configResult = ApiClient.getOrbitConfig()
|
||||
configResult.onSuccess {
|
||||
Log.d(TAG, "Successfully enrolled and fetched initial orbit config")
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "Auto-enrollment on startup failed: ${error.message}")
|
||||
// Only enroll if not already enrolled
|
||||
if (ApiClient.getApiKey() == null) {
|
||||
val configResult = ApiClient.getOrbitConfig()
|
||||
configResult.onSuccess {
|
||||
Log.d(TAG, "Successfully enrolled host with Fleet server")
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "Host enrollment failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "MDM enrollment credentials not available")
|
||||
|
|
@ -76,6 +95,10 @@ class AgentApplication : Application() {
|
|||
val workRequest = PeriodicWorkRequestBuilder<CertificateEnrollmentWorker>(
|
||||
15, // 15 minutes is the minimum
|
||||
TimeUnit.MINUTES,
|
||||
).setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
).setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,27 @@ import kotlinx.serialization.json.JsonElement
|
|||
|
||||
val Context.prefDataStore: DataStore<Preferences> by preferencesDataStore(name = "pref_datastore")
|
||||
|
||||
object ApiClient {
|
||||
/**
|
||||
* Result of fetching a certificate template, including the computed SCEP URL.
|
||||
*/
|
||||
data class CertificateTemplateResult(val template: GetCertificateTemplateResponse, val scepUrl: String)
|
||||
|
||||
/**
|
||||
* Interface for certificate-related API operations.
|
||||
* Used by CertificateOrchestrator for dependency injection and testability.
|
||||
*/
|
||||
interface CertificateApiClient {
|
||||
suspend fun getCertificateTemplate(certificateId: Int): Result<CertificateTemplateResult>
|
||||
suspend fun updateCertificateStatus(
|
||||
certificateId: Int,
|
||||
status: UpdateCertificateStatusStatus,
|
||||
operationType: UpdateCertificateStatusOperation,
|
||||
detail: String? = null,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
||||
object ApiClient : CertificateApiClient {
|
||||
private const val TAG = "fleet-ApiClient"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
|
|
@ -38,7 +58,7 @@ object ApiClient {
|
|||
private val enrollmentMutex = Mutex()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
Log.d("fleet-apiClient", "initializing api client")
|
||||
Log.d(TAG, "initializing api client")
|
||||
if (!::dataStore.isInitialized) {
|
||||
dataStore = context.applicationContext.prefDataStore
|
||||
}
|
||||
|
|
@ -60,7 +80,7 @@ object ApiClient {
|
|||
return try {
|
||||
KeystoreManager.decrypt(encrypted)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiClient", "Failed to decrypt API key", e)
|
||||
Log.e(TAG, "Failed to decrypt API key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -115,8 +135,9 @@ object ApiClient {
|
|||
readTimeout = 15000
|
||||
|
||||
if (body != null && method != "GET") {
|
||||
requireNotNull(bodySerializer) { "bodySerializer required when body is provided" }
|
||||
doOutput = true
|
||||
val bodyJson = json.encodeToString(value = body, serializer = bodySerializer!!)
|
||||
val bodyJson = json.encodeToString(value = body, serializer = bodySerializer)
|
||||
outputStream.use { it.write(bodyJson.toByteArray()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +150,7 @@ object ApiClient {
|
|||
?: "HTTP $responseCode"
|
||||
}
|
||||
|
||||
Log.d("ApiClient", "server response from $method $endpoint ($responseCode): $response")
|
||||
Log.d(TAG, "server response from $method $endpoint ($responseCode): $response")
|
||||
|
||||
if (responseCode in 200..299) {
|
||||
val parsed = json.decodeFromString(string = response, deserializer = responseSerializer)
|
||||
|
|
@ -164,7 +185,7 @@ object ApiClient {
|
|||
setApiKey(value.orbitNodeKey)
|
||||
}
|
||||
resp.onFailure { exception ->
|
||||
Log.d("ApiClient.enroll", "Enrollment failed: ${exception.message}")
|
||||
Log.d(TAG, "Enrollment failed: ${exception.message}")
|
||||
}
|
||||
|
||||
return resp
|
||||
|
|
@ -196,7 +217,7 @@ object ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun getCertificateTemplate(certificateId: Int): Result<GetCertificateTemplateResponse> {
|
||||
override suspend fun getCertificateTemplate(certificateId: Int): Result<CertificateTemplateResult> {
|
||||
val nodeKeyResult = getNodeKeyOrEnroll()
|
||||
val orbitNodeKey = nodeKeyResult.getOrElse { error ->
|
||||
return Result.failure(error)
|
||||
|
|
@ -212,29 +233,26 @@ object ApiClient {
|
|||
responseSerializer = GetCertificateTemplateResponseWrapper.serializer(),
|
||||
).fold(
|
||||
onSuccess = { wrapper ->
|
||||
val res = wrapper.certificate
|
||||
Log.i("ApiClient", "successfully retrieved certificate template ${res.id}: ${res.name}")
|
||||
Result.success(
|
||||
res.apply {
|
||||
setUrl(
|
||||
serverUrl = credentials.baseUrl,
|
||||
hostUUID = credentials.hardwareUUID,
|
||||
)
|
||||
},
|
||||
val template = wrapper.certificate
|
||||
Log.i(TAG, "successfully retrieved certificate template ${template.id}: ${template.name}")
|
||||
val scepUrl = template.buildScepUrl(
|
||||
serverUrl = credentials.baseUrl,
|
||||
hostUUID = credentials.hardwareUUID,
|
||||
)
|
||||
Result.success(CertificateTemplateResult(template, scepUrl))
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
Log.e("ApiClient", "failed to get certificate template $certificateId")
|
||||
Log.e(TAG, "failed to get certificate template $certificateId")
|
||||
Result.failure(throwable)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateCertificateStatus(
|
||||
override suspend fun updateCertificateStatus(
|
||||
certificateId: Int,
|
||||
status: UpdateCertificateStatusStatus,
|
||||
operationType: UpdateCertificateStatusOperation,
|
||||
detail: String? = null,
|
||||
detail: String?,
|
||||
): Result<Unit> = makeRequest(
|
||||
endpoint = "/api/fleetd/certificates/$certificateId/status",
|
||||
method = "PUT",
|
||||
|
|
@ -248,15 +266,15 @@ object ApiClient {
|
|||
).fold(
|
||||
onSuccess = { response ->
|
||||
if (response.error != null) {
|
||||
Log.e("ApiClient", "failed to update certificate status $certificateId: ${response.error}")
|
||||
Log.e(TAG, "failed to update certificate status $certificateId: ${response.error}")
|
||||
Result.failure(Exception(response.error))
|
||||
} else {
|
||||
Log.i("ApiClient", "successfully updated certificate status for $certificateId to $status")
|
||||
Log.i(TAG, "successfully updated certificate status for $certificateId to $status")
|
||||
Result.success(Unit)
|
||||
}
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
Log.e("ApiClient", "failed to update certificate status $certificateId: ${throwable.message}")
|
||||
Log.e(TAG, "failed to update certificate status $certificateId: ${throwable.message}")
|
||||
Result.failure(throwable)
|
||||
},
|
||||
)
|
||||
|
|
@ -289,18 +307,18 @@ object ApiClient {
|
|||
}
|
||||
|
||||
// Node key is missing, attempt auto-enrollment
|
||||
Log.d("ApiClient", "Orbit node key missing, attempting auto-enrollment")
|
||||
Log.d(TAG, "Orbit node key missing, attempting auto-enrollment")
|
||||
|
||||
// Re-enroll
|
||||
val enrollResult = enroll()
|
||||
|
||||
return enrollResult.fold(
|
||||
onSuccess = { response ->
|
||||
Log.d("ApiClient", "Auto-enrollment successful")
|
||||
Log.d(TAG, "Auto-enrollment successful")
|
||||
Result.success(response.orbitNodeKey)
|
||||
},
|
||||
onFailure = { error ->
|
||||
Log.e("ApiClient", "Auto-enrollment failed: ${error.message}")
|
||||
Log.e(TAG, "Auto-enrollment failed: ${error.message}")
|
||||
Result.failure(error)
|
||||
},
|
||||
)
|
||||
|
|
@ -485,21 +503,20 @@ data class GetCertificateTemplateResponse(
|
|||
val status: String,
|
||||
|
||||
@SerialName("scep_challenge")
|
||||
val scepChallenge: String? = "",
|
||||
val scepChallenge: String? = null,
|
||||
|
||||
@SerialName("fleet_challenge")
|
||||
val fleetChallenge: String? = "",
|
||||
val fleetChallenge: String? = null,
|
||||
|
||||
@Transient
|
||||
val keyLength: Int = 2048,
|
||||
|
||||
@Transient
|
||||
val signatureAlgorithm: String = "SHA256withRSA",
|
||||
)
|
||||
|
||||
@Transient
|
||||
var url: String? = null,
|
||||
) {
|
||||
fun setUrl(serverUrl: String, hostUUID: String) {
|
||||
url = "$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,${fleetChallenge ?: ""}"
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds the SCEP proxy URL for this certificate template.
|
||||
*/
|
||||
fun GetCertificateTemplateResponse.buildScepUrl(serverUrl: String, hostUUID: String): String =
|
||||
"$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,${fleetChallenge ?: ""}"
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
|
|
@ -17,11 +20,15 @@ class BootReceiver : BroadcastReceiver() {
|
|||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
Log.i(TAG, "Device boot completed. Triggering certificate enrollment.")
|
||||
|
||||
context?.let {
|
||||
Log.i(TAG, "Device boot completed. Triggering certificate enrollment.")
|
||||
// Trigger immediate certificate enrollment on boot
|
||||
val workRequest = OneTimeWorkRequestBuilder<CertificateEnrollmentWorker>()
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
|
|
@ -37,7 +44,7 @@ class BootReceiver : BroadcastReceiver() {
|
|||
)
|
||||
|
||||
Log.d(TAG, "Scheduled certificate enrollment after boot")
|
||||
}
|
||||
} ?: Log.w(TAG, "Device boot completed but context is null, cannot schedule enrollment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ package com.fleetdm.agent
|
|||
|
||||
import com.fleetdm.agent.scep.ScepCertificateException
|
||||
import com.fleetdm.agent.scep.ScepClient
|
||||
import com.fleetdm.agent.scep.ScepConfig
|
||||
import com.fleetdm.agent.scep.ScepCsrException
|
||||
import com.fleetdm.agent.scep.ScepEnrollmentException
|
||||
import com.fleetdm.agent.scep.ScepKeyGenerationException
|
||||
import com.fleetdm.agent.scep.ScepNetworkException
|
||||
import com.fleetdm.agent.scep.ScepResult
|
||||
import org.json.JSONObject
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.Certificate
|
||||
|
||||
|
|
@ -31,15 +28,16 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v
|
|||
*/
|
||||
sealed class EnrollmentResult {
|
||||
data class Success(val alias: String) : EnrollmentResult()
|
||||
data class Failure(val reason: String, val exception: Exception? = null) : EnrollmentResult()
|
||||
data class Failure(val reason: String, val exception: Exception? = null, val isRetryable: Boolean = false) : EnrollmentResult()
|
||||
data class PermanentlyFailed(val alias: String) : EnrollmentResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Main enrollment flow: parse config, enroll via SCEP, install certificate.
|
||||
*/
|
||||
suspend fun handleEnrollment(config: GetCertificateTemplateResponse): EnrollmentResult = try {
|
||||
suspend fun handleEnrollment(config: GetCertificateTemplateResponse, scepUrl: String): EnrollmentResult = try {
|
||||
// Perform SCEP enrollment
|
||||
val result = scepClient.enroll(config)
|
||||
val result = scepClient.enroll(config, scepUrl)
|
||||
|
||||
// Install certificate
|
||||
val installed = certificateInstaller.installCertificate(
|
||||
|
|
@ -55,24 +53,24 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v
|
|||
}
|
||||
} catch (e: ScepEnrollmentException) {
|
||||
// SCEP server rejected enrollment (e.g., PENDING status, invalid challenge)
|
||||
EnrollmentResult.Failure("SCEP enrollment failed: ${e.message}", e)
|
||||
EnrollmentResult.Failure("SCEP enrollment failed: ${e.message}", e, isRetryable = false)
|
||||
} catch (e: ScepNetworkException) {
|
||||
// Network communication failure - likely transient, can retry
|
||||
EnrollmentResult.Failure("Network error during SCEP enrollment: ${e.message}", e)
|
||||
EnrollmentResult.Failure("Network error during SCEP enrollment: ${e.message}", e, isRetryable = true)
|
||||
} catch (e: ScepCertificateException) {
|
||||
// Certificate validation or processing failed
|
||||
EnrollmentResult.Failure("Certificate validation failed: ${e.message}", e)
|
||||
EnrollmentResult.Failure("Certificate validation failed: ${e.message}", e, isRetryable = false)
|
||||
} catch (e: ScepKeyGenerationException) {
|
||||
// Key generation failed - device cryptography issue
|
||||
EnrollmentResult.Failure("Failed to generate key pair: ${e.message}", e)
|
||||
EnrollmentResult.Failure("Failed to generate key pair: ${e.message}", e, isRetryable = false)
|
||||
} catch (e: ScepCsrException) {
|
||||
// CSR creation failed - likely configuration issue
|
||||
EnrollmentResult.Failure("Failed to create CSR: ${e.message}", e)
|
||||
EnrollmentResult.Failure("Failed to create CSR: ${e.message}", e, isRetryable = false)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Configuration validation failed
|
||||
EnrollmentResult.Failure("Invalid configuration: ${e.message}", e)
|
||||
EnrollmentResult.Failure("Invalid configuration: ${e.message}", e, isRetryable = false)
|
||||
} catch (e: Exception) {
|
||||
// Unexpected errors
|
||||
EnrollmentResult.Failure("Unexpected error during enrollment: ${e.message}", e)
|
||||
EnrollmentResult.Failure("Unexpected error during enrollment: ${e.message}", e, isRetryable = false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import androidx.work.WorkerParameters
|
|||
* WorkManager worker that handles certificate enrollment operations in the background.
|
||||
*
|
||||
* This worker:
|
||||
* - Gets all certificate IDs from managed configuration
|
||||
* - Gets host certificates from managed configuration
|
||||
* - Calls CertificateOrchestrator to enroll all certificates in parallel
|
||||
* - Returns appropriate Result based on enrollment outcomes
|
||||
* - Supports automatic retry for transient failures
|
||||
|
|
@ -17,102 +17,123 @@ import androidx.work.WorkerParameters
|
|||
class CertificateEnrollmentWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val attemptCount = runAttemptCount
|
||||
Log.d(TAG, "Starting certificate enrollment worker (attempt $attemptCount)")
|
||||
return try {
|
||||
Log.d(TAG, "Starting certificate enrollment worker (attempt ${runAttemptCount + 1})")
|
||||
|
||||
// Limit retries to avoid infinite loops
|
||||
if (attemptCount >= MAX_RETRY_ATTEMPTS) {
|
||||
Log.e(TAG, "Maximum retry attempts ($MAX_RETRY_ATTEMPTS) reached, giving up")
|
||||
return Result.failure()
|
||||
}
|
||||
// Get orchestrator from Application
|
||||
val orchestrator = AgentApplication.getCertificateOrchestrator(applicationContext)
|
||||
|
||||
val certificateIds = CertificateOrchestrator.getCertificateIDs(applicationContext)
|
||||
|
||||
// STEP 1: Cleanup removed certificates BEFORE enrolling new ones
|
||||
// This runs even if certificateIds is empty to clean up any orphaned certificates
|
||||
val currentIds = certificateIds ?: emptyList()
|
||||
val cleanupResults = CertificateOrchestrator.cleanupRemovedCertificates(
|
||||
context = applicationContext,
|
||||
currentCertificateIds = currentIds,
|
||||
)
|
||||
|
||||
// Log cleanup results
|
||||
cleanupResults.forEach { (certId, result) ->
|
||||
when (result) {
|
||||
is CleanupResult.Success ->
|
||||
Log.i(TAG, "Cleaned up certificate $certId (alias: ${result.alias})")
|
||||
is CleanupResult.AlreadyRemoved ->
|
||||
Log.i(TAG, "Certificate $certId already removed (alias: ${result.alias})")
|
||||
is CleanupResult.Failure ->
|
||||
Log.e(TAG, "Failed to cleanup certificate $certId: ${result.reason}", result.exception)
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: If no certificates to enroll, we're done
|
||||
if (certificateIds.isNullOrEmpty()) {
|
||||
Log.d(TAG, "No certificates to enroll")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// STEP 3: Enroll new/updated certificates
|
||||
Log.i(TAG, "Enrolling ${certificateIds.size} certificate(s)")
|
||||
|
||||
val results = CertificateOrchestrator.enrollCertificates(
|
||||
context = applicationContext,
|
||||
certificateIds = certificateIds,
|
||||
)
|
||||
|
||||
// Analyze results to determine worker outcome
|
||||
var hasSuccess = false
|
||||
var hasTransientFailure = false
|
||||
var hasPermanentFailure = false
|
||||
|
||||
results.forEach { (certificateId, result) ->
|
||||
when (result) {
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.Success -> {
|
||||
Log.i(TAG, "Certificate $certificateId enrolled successfully: ${result.alias}")
|
||||
hasSuccess = true
|
||||
// STEP 0: Retry any unreported statuses from previous runs
|
||||
val unreportedResults = orchestrator.retryUnreportedStatuses(applicationContext)
|
||||
unreportedResults.forEach { (certId, success) ->
|
||||
if (success) {
|
||||
Log.i(TAG, "Successfully reported unreported status for certificate $certId")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to report unreported status for certificate $certId, will retry next run")
|
||||
}
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
|
||||
Log.e(TAG, "Certificate $certificateId enrollment failed: ${result.reason}", result.exception)
|
||||
if (shouldRetry(result.reason)) {
|
||||
hasTransientFailure = true
|
||||
} else {
|
||||
hasPermanentFailure = true
|
||||
}
|
||||
|
||||
val hostCertificates = orchestrator.getHostCertificates(applicationContext) ?: emptyList()
|
||||
|
||||
// STEP 1: Cleanup certificates marked for removal and orphaned certificates
|
||||
val cleanupResults = orchestrator.cleanupRemovedCertificates(
|
||||
context = applicationContext,
|
||||
hostCertificates = hostCertificates,
|
||||
)
|
||||
|
||||
// Log cleanup results
|
||||
cleanupResults.forEach { (certId, result) ->
|
||||
when (result) {
|
||||
is CleanupResult.Success ->
|
||||
Log.i(TAG, "Cleaned up certificate $certId (alias: ${result.alias})")
|
||||
is CleanupResult.AlreadyRemoved ->
|
||||
Log.d(TAG, "Certificate $certId already removed (alias: ${result.alias})")
|
||||
is CleanupResult.Failure ->
|
||||
Log.e(TAG, "Failed to cleanup certificate $certId: ${result.reason}", result.exception)
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Filter to only certificates marked for install
|
||||
val certificatesToInstall = hostCertificates.filter { it.shouldInstall() }
|
||||
|
||||
// If no certificates to enroll, we're done
|
||||
if (certificatesToInstall.isEmpty()) {
|
||||
Log.d(TAG, "No certificates to enroll")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// STEP 3: Enroll new/updated certificates
|
||||
Log.i(TAG, "Enrolling ${certificatesToInstall.size} certificate(s): ${certificatesToInstall.map { it.id }}")
|
||||
|
||||
val results = orchestrator.enrollCertificates(
|
||||
context = applicationContext,
|
||||
hostCertificates = certificatesToInstall,
|
||||
)
|
||||
|
||||
// Analyze results to determine worker outcome
|
||||
var hasSuccess = false
|
||||
var hasTransientFailure = false
|
||||
var hasPermanentFailure = false
|
||||
|
||||
results.forEach { (certificateId, result) ->
|
||||
when (result) {
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.Success -> {
|
||||
Log.i(TAG, "Certificate $certificateId enrolled successfully: ${result.alias}")
|
||||
hasSuccess = true
|
||||
}
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.PermanentlyFailed -> {
|
||||
Log.d(TAG, "Certificate $certificateId previously failed permanently: ${result.alias}")
|
||||
// Treat as handled - no retry needed
|
||||
}
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
|
||||
Log.e(TAG, "Certificate $certificateId enrollment failed: ${result.reason}", result.exception)
|
||||
if (result.isRetryable) {
|
||||
hasTransientFailure = true
|
||||
} else {
|
||||
hasPermanentFailure = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return result based on outcomes
|
||||
return when {
|
||||
hasTransientFailure -> {
|
||||
Log.w(TAG, "Some certificates had transient failures, will retry (attempt $attemptCount of $MAX_RETRY_ATTEMPTS)")
|
||||
Result.retry()
|
||||
}
|
||||
hasPermanentFailure -> {
|
||||
if (hasSuccess) {
|
||||
Log.w(TAG, "Some certificates succeeded, some failed permanently")
|
||||
// Return result based on outcomes
|
||||
when {
|
||||
hasTransientFailure -> {
|
||||
if (runAttemptCount >= MAX_RETRY_ATTEMPTS - 1) {
|
||||
// Exhausted retries, return success to reset and let periodic schedule take over
|
||||
Log.w(
|
||||
TAG,
|
||||
"Some certificates had transient failures, exhausted $MAX_RETRY_ATTEMPTS retries, will retry in 15 minutes",
|
||||
)
|
||||
Result.success()
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Some certificates had transient failures, will retry (attempt ${runAttemptCount + 1} of $MAX_RETRY_ATTEMPTS)",
|
||||
)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
hasPermanentFailure -> {
|
||||
if (hasSuccess) {
|
||||
Log.w(TAG, "Some certificates succeeded, some failed permanently")
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "All ${results.size} certificate(s) enrolled successfully")
|
||||
Result.success()
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "All ${results.size} certificate(s) enrolled successfully")
|
||||
Result.success()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unexpected error in certificate enrollment", e)
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "certificate_enrollment"
|
||||
private const val TAG = "CertEnrollmentWorker"
|
||||
private const val TAG = "fleet-CertificateEnrollmentWorker"
|
||||
private const val MAX_RETRY_ATTEMPTS = 5
|
||||
|
||||
private fun shouldRetry(reason: String): Boolean {
|
||||
// Retry on network/API failures, not on invalid config
|
||||
return reason.contains("network", ignoreCase = true) ||
|
||||
reason.contains("Failed to fetch", ignoreCase = true) ||
|
||||
reason.contains("timeout", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,36 +22,27 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.json.Json
|
||||
|
||||
const val MAX_CERT_INSTALL_RETRIES = 3
|
||||
const val MAX_STATUS_REPORT_RETRIES = 10
|
||||
|
||||
/**
|
||||
* Orchestrates certificate enrollment operations by coordinating API calls,
|
||||
* SCEP enrollment, and certificate installation.
|
||||
*
|
||||
* This object provides a neutral orchestration layer that can be called from
|
||||
* This class provides a neutral orchestration layer that can be called from
|
||||
* multiple contexts (Service, Worker, direct calls) while maintaining separation
|
||||
* of concerns between Android framework code and business logic.
|
||||
*
|
||||
* ## Usage Examples
|
||||
*
|
||||
* Single certificate:
|
||||
* ```
|
||||
* val result = CertificateOrchestrator.enrollCertificate(
|
||||
* context = applicationContext,
|
||||
* certificateId = 123
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* Batch processing:
|
||||
* ```
|
||||
* val certificateIds = CertificateOrchestrator.getCertificateIDs(context)
|
||||
* val results = CertificateOrchestrator.enrollCertificates(
|
||||
* context = applicationContext,
|
||||
* certificateIds = certificateIds ?: emptyList()
|
||||
* )
|
||||
* ```
|
||||
* @param apiClient Client for Fleet server API calls
|
||||
* @param scepClient Client for SCEP enrollment operations
|
||||
*/
|
||||
object CertificateOrchestrator {
|
||||
private const val TAG = "CertificateOrchestrator"
|
||||
class CertificateOrchestrator(
|
||||
private val apiClient: CertificateApiClient = ApiClient,
|
||||
private val scepClient: ScepClient = ScepClientImpl(),
|
||||
private val deviceKeystoreManager: DeviceKeystoreManager? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "fleet-CertificateOrchestrator"
|
||||
}
|
||||
|
||||
// DataStore key for storing installed certificates map as JSON
|
||||
private val INSTALLED_CERTIFICATES_KEY = stringPreferencesKey("installed_certificates")
|
||||
|
|
@ -67,28 +58,34 @@ object CertificateOrchestrator {
|
|||
// Mutex to protect concurrent access to certificate storage
|
||||
private val certificateStorageMutex = Mutex()
|
||||
|
||||
fun installedCertsFlow(context: Context): Flow<CertStatusMap> = context.prefDataStore.data.map { preferences ->
|
||||
try {
|
||||
val jsonStr = preferences[INSTALLED_CERTIFICATES_KEY]
|
||||
json.decodeFromString(jsonStr!!)
|
||||
} catch (e: Exception) {
|
||||
Log.d("installedCertsFlow", e.toString())
|
||||
emptyMap()
|
||||
}
|
||||
fun installedCertsFlow(context: Context): Flow<CertificateStateMap> = context.prefDataStore.data.map { preferences ->
|
||||
preferences[INSTALLED_CERTIFICATES_KEY]?.let { jsonStr ->
|
||||
runCatching { json.decodeFromString<CertificateStateMap>(jsonStr) }
|
||||
.onFailure { Log.d(TAG, "Failed to parse installed certificates: $it") }
|
||||
.getOrNull()
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads certificate IDs from Android Managed Configuration.
|
||||
* Reads certificate templates from Android Managed Configuration.
|
||||
*
|
||||
* @param context Android context
|
||||
* @return List of certificate IDs to enroll, or null if none configured
|
||||
* @return List of certificate templates, or null if none configured
|
||||
*/
|
||||
fun getCertificateIDs(context: Context): List<Int>? {
|
||||
val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager
|
||||
fun getHostCertificates(context: Context): List<HostCertificate>? {
|
||||
val restrictionsManager = context.getSystemService(android.content.RestrictionsManager::class.java)
|
||||
?: return null
|
||||
val appRestrictions = restrictionsManager.applicationRestrictions
|
||||
|
||||
val certRequestList = appRestrictions.getParcelableArray("certificate_templates", Bundle::class.java)?.toList()
|
||||
return certRequestList?.map { bundle -> bundle.getInt("id") }
|
||||
return certRequestList?.map { bundle ->
|
||||
HostCertificate(
|
||||
id = bundle.getInt("id"),
|
||||
status = bundle.getString("status", ""),
|
||||
operation = bundle.getString("operation", HostCertificate.OPERATION_INSTALL),
|
||||
uuid = bundle.getString("uuid", ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,7 +94,7 @@ object CertificateOrchestrator {
|
|||
* @param context Android context
|
||||
* @return Map of certificate ID to alias, or empty map if none stored
|
||||
*/
|
||||
internal suspend fun getCertificateInstallInfos(context: Context): CertStatusMap {
|
||||
internal suspend fun getCertificateStates(context: Context): CertificateStateMap {
|
||||
certificateStorageMutex.withLock {
|
||||
return try {
|
||||
val prefs = context.prefDataStore.data.first()
|
||||
|
|
@ -108,9 +105,7 @@ object CertificateOrchestrator {
|
|||
return emptyMap()
|
||||
}
|
||||
|
||||
val map = json.decodeFromString<CertStatusMap>(jsonString)
|
||||
Log.d(TAG, "Loaded ${map.size} installed certificate(s) from DataStore")
|
||||
map
|
||||
json.decodeFromString<CertificateStateMap>(jsonString)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to read installed certificates from DataStore: ${e.message}", e)
|
||||
emptyMap()
|
||||
|
|
@ -118,34 +113,35 @@ object CertificateOrchestrator {
|
|||
}
|
||||
}
|
||||
|
||||
internal suspend fun getCertificateInstallInfo(context: Context, certificateId: Int): CertificateInstallInfo? {
|
||||
val certs = getCertificateInstallInfos(context = context)
|
||||
internal suspend fun getCertificateState(context: Context, certificateId: Int): CertificateState? {
|
||||
val certs = getCertificateStates(context = context)
|
||||
return certs[certificateId]
|
||||
}
|
||||
|
||||
internal suspend fun markCertificateInstalled(context: Context, certificateId: Int, alias: String) {
|
||||
val existingInfo = getCertificateInstallInfo(context = context, certificateId = certificateId)
|
||||
?: CertificateInstallInfo(alias = alias, status = CertificateInstallStatus.INSTALLED, retries = 0)
|
||||
internal suspend fun markCertificateInstalled(context: Context, certificateId: Int, alias: String, uuid: String) {
|
||||
val existingInfo = getCertificateState(context = context, certificateId = certificateId)
|
||||
?: CertificateState(alias = alias, status = CertificateStatus.INSTALLED, retries = 0, uuid = uuid)
|
||||
|
||||
val newInfo = existingInfo.copy(alias = alias, status = CertificateInstallStatus.INSTALLED, retries = 0)
|
||||
storeCertificateInstallationInfo(context = context, certificateId = certificateId, certInstallInfo = newInfo)
|
||||
val newInfo = existingInfo.copy(alias = alias, status = CertificateStatus.INSTALLED, retries = 0, uuid = uuid)
|
||||
storeCertificateState(context = context, certificateId = certificateId, certInstallInfo = newInfo)
|
||||
}
|
||||
|
||||
internal suspend fun markCertificateFailure(context: Context, certificateId: Int, alias: String): CertificateInstallInfo {
|
||||
val existingInfo = getCertificateInstallInfo(context = context, certificateId = certificateId)
|
||||
?: CertificateInstallInfo(alias = alias, status = CertificateInstallStatus.RETRY, retries = 0)
|
||||
internal suspend fun markCertificateFailure(context: Context, certificateId: Int, alias: String): CertificateState {
|
||||
val existingInfo = getCertificateState(context = context, certificateId = certificateId)
|
||||
?: CertificateState(alias = alias, status = CertificateStatus.RETRY, retries = 0)
|
||||
|
||||
if (existingInfo.status != CertificateInstallStatus.RETRY) {
|
||||
if (existingInfo.status != CertificateStatus.RETRY) {
|
||||
Log.d(TAG, "markCertificateFailure: skipping cert $certificateId, status is ${existingInfo.status}")
|
||||
return existingInfo
|
||||
}
|
||||
|
||||
var newInfo = existingInfo.copy(retries = existingInfo.retries + 1)
|
||||
|
||||
if (newInfo.retries >= MAX_CERT_INSTALL_RETRIES) {
|
||||
newInfo = newInfo.copy(status = CertificateInstallStatus.FAILED)
|
||||
newInfo = newInfo.copy(status = CertificateStatus.FAILED)
|
||||
}
|
||||
|
||||
storeCertificateInstallationInfo(context = context, certificateId = certificateId, newInfo)
|
||||
storeCertificateState(context = context, certificateId = certificateId, newInfo)
|
||||
|
||||
return newInfo
|
||||
}
|
||||
|
|
@ -158,7 +154,7 @@ object CertificateOrchestrator {
|
|||
* @param certificateId Certificate template ID
|
||||
* @param alias Certificate alias used during installation
|
||||
*/
|
||||
internal suspend fun storeCertificateInstallationInfo(context: Context, certificateId: Int, certInstallInfo: CertificateInstallInfo) {
|
||||
internal suspend fun storeCertificateState(context: Context, certificateId: Int, certInstallInfo: CertificateState) {
|
||||
certificateStorageMutex.withLock {
|
||||
try {
|
||||
context.prefDataStore.edit { preferences ->
|
||||
|
|
@ -166,7 +162,7 @@ object CertificateOrchestrator {
|
|||
val existingJsonString = preferences[INSTALLED_CERTIFICATES_KEY]
|
||||
val existingMap = if (existingJsonString != null) {
|
||||
try {
|
||||
json.decodeFromString<CertStatusMap>(existingJsonString)
|
||||
json.decodeFromString<CertificateStateMap>(existingJsonString)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse existing certificates JSON, starting fresh: ${e.message}")
|
||||
emptyMap()
|
||||
|
|
@ -199,14 +195,14 @@ object CertificateOrchestrator {
|
|||
* @param context Android context
|
||||
* @param certificateId Certificate template ID to remove
|
||||
*/
|
||||
internal suspend fun removeCertificateInstallInfo(context: Context, certificateId: Int) {
|
||||
internal suspend fun removeCertificateState(context: Context, certificateId: Int) {
|
||||
certificateStorageMutex.withLock {
|
||||
try {
|
||||
context.prefDataStore.edit { preferences ->
|
||||
val existingJsonString = preferences[INSTALLED_CERTIFICATES_KEY]
|
||||
val existingMap = if (existingJsonString != null) {
|
||||
try {
|
||||
json.decodeFromString<CertStatusMap>(existingJsonString)
|
||||
json.decodeFromString<CertificateStateMap>(existingJsonString)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse existing certificates JSON: ${e.message}")
|
||||
emptyMap()
|
||||
|
|
@ -241,12 +237,18 @@ object CertificateOrchestrator {
|
|||
* @return Certificate alias if previously installed, null otherwise
|
||||
*/
|
||||
internal suspend fun getCertificateAlias(context: Context, certificateId: Int): String? {
|
||||
val installedCerts = getCertificateInstallInfos(context)
|
||||
val installedCerts = getCertificateStates(context)
|
||||
val status = installedCerts[certificateId]
|
||||
Log.d(TAG, "Certificate $certificateId alias lookup: ${status?.alias ?: "not found"}")
|
||||
return status?.alias
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the device keystore manager, using injected instance or creating a default one.
|
||||
*/
|
||||
private fun getDeviceKeystoreManager(context: Context): DeviceKeystoreManager =
|
||||
deviceKeystoreManager ?: AndroidDeviceKeystoreManager(context)
|
||||
|
||||
/**
|
||||
* Checks if a certificate is installed in the Android keystore.
|
||||
*
|
||||
|
|
@ -254,14 +256,10 @@ object CertificateOrchestrator {
|
|||
* @param alias Certificate alias
|
||||
* @return True if certificate exists in keystore
|
||||
*/
|
||||
private fun isCertificateInstalled(context: Context, alias: String): Boolean = try {
|
||||
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
|
||||
val hasKeyPair = dpm.hasKeyPair(alias)
|
||||
private fun isCertificateInstalled(context: Context, alias: String): Boolean {
|
||||
val hasKeyPair = getDeviceKeystoreManager(context).hasKeyPair(alias)
|
||||
Log.d(TAG, "Certificate '$alias' installation check: $hasKeyPair")
|
||||
hasKeyPair
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking if certificate '$alias' is installed: ${e.message}", e)
|
||||
false
|
||||
return hasKeyPair
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -271,35 +269,7 @@ object CertificateOrchestrator {
|
|||
* @param alias Certificate alias to remove
|
||||
* @return True if removal was successful or certificate doesn't exist
|
||||
*/
|
||||
private fun removeKeyPair(context: Context, alias: String): Boolean {
|
||||
return try {
|
||||
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
|
||||
|
||||
// First check if keypair exists
|
||||
if (!dpm.hasKeyPair(alias)) {
|
||||
Log.i(TAG, "Certificate '$alias' doesn't exist in keystore, considering removal successful")
|
||||
return true
|
||||
}
|
||||
|
||||
// Attempt to remove the keypair
|
||||
// admin component is null because we're using delegated certificate management
|
||||
val removed = dpm.removeKeyPair(null, alias)
|
||||
|
||||
if (removed) {
|
||||
Log.i(TAG, "Successfully removed certificate keypair with alias: $alias")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to remove certificate keypair '$alias'. Check MDM policy and delegation status.")
|
||||
}
|
||||
|
||||
removed
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception removing certificate '$alias': ${e.message}", e)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing certificate '$alias': ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
private fun removeKeyPair(context: Context, alias: String): Boolean = getDeviceKeystoreManager(context).removeKeyPair(alias)
|
||||
|
||||
/**
|
||||
* Checks if a certificate ID has been successfully installed and still exists in keystore.
|
||||
|
|
@ -327,86 +297,300 @@ object CertificateOrchestrator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Cleans up certificates that were removed from managed configuration.
|
||||
* Cleans up certificates based on host certificate assignments.
|
||||
*
|
||||
* This function:
|
||||
* 1. Identifies certificates in DataStore that are no longer in current config
|
||||
* 2. Removes the corresponding keypairs from the device using DevicePolicyManager
|
||||
* 3. Cleans up the DataStore tracking
|
||||
* 4. Reports removal status to the server
|
||||
* This function handles certificate removal in two cases:
|
||||
* 1. Certificates with operation="remove": Process removal and mark as REMOVED
|
||||
* 2. Orphaned certificates with status=REMOVED: Delete tracking entry (cleanup complete)
|
||||
*
|
||||
* Removal flow:
|
||||
* - operation="remove" + status=INSTALLED: remove keypair, notify server, mark REMOVED
|
||||
* - operation="remove" + status=REMOVED: skip (already done)
|
||||
* - operation="remove" + not in DataStore: notify server as verified, save as REMOVED
|
||||
* - Orphaned + status=REMOVED: delete from DataStore
|
||||
*
|
||||
* @param context Android context for certificate operations
|
||||
* @param currentCertificateIds List of certificate IDs from current managed configuration
|
||||
* @param hostCertificates List of host certificates from managed configuration
|
||||
* @return Map of certificate ID to cleanup result
|
||||
*/
|
||||
suspend fun cleanupRemovedCertificates(context: Context, currentCertificateIds: List<Int>): Map<Int, CleanupResult> {
|
||||
Log.d(TAG, "Starting certificate cleanup. Current IDs: $currentCertificateIds")
|
||||
suspend fun cleanupRemovedCertificates(context: Context, hostCertificates: List<HostCertificate>): Map<Int, CleanupResult> {
|
||||
Log.d(TAG, "Starting certificate cleanup. Host certificates: ${hostCertificates.map { "${it.id}:${it.operation}" }}")
|
||||
|
||||
// Get all installed certificates from DataStore
|
||||
val installedCerts = getCertificateInstallInfos(context)
|
||||
Log.d(TAG, "Found ${installedCerts.size} certificate(s) in DataStore")
|
||||
|
||||
// Identify certificates to remove (in DataStore but not in current config)
|
||||
val certificatesToRemove = installedCerts.keys.filter { it !in currentCertificateIds }
|
||||
|
||||
if (certificatesToRemove.isEmpty()) {
|
||||
Log.d(TAG, "No certificates to remove")
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Removing ${certificatesToRemove.size} certificate(s): $certificatesToRemove")
|
||||
val certificateStates = getCertificateStates(context)
|
||||
Log.d(TAG, "Found ${certificateStates.size} certificate(s) in DataStore")
|
||||
|
||||
val results = mutableMapOf<Int, CleanupResult>()
|
||||
|
||||
for (certificateId in certificatesToRemove) {
|
||||
val certInfo = installedCerts[certificateId]
|
||||
if (certInfo == null) {
|
||||
Log.w(TAG, "Certificate ID $certificateId not found in DataStore, skipping")
|
||||
continue
|
||||
}
|
||||
// Step 1: Process certificates with operation="remove"
|
||||
val certificatesToRemove = hostCertificates.filter { it.shouldRemove() }
|
||||
Log.d(TAG, "Certificates marked for removal: ${certificatesToRemove.map { it.id }}")
|
||||
|
||||
val alias = certInfo.alias
|
||||
Log.d(TAG, "Removing certificate ID $certificateId with alias '$alias' (status: ${certInfo.status})")
|
||||
for (hostCert in certificatesToRemove) {
|
||||
val certId = hostCert.id
|
||||
val certState = certificateStates[certId]
|
||||
|
||||
// Attempt to remove the keypair
|
||||
val removed = removeKeyPair(context, alias)
|
||||
|
||||
if (removed) {
|
||||
// Report successful removal to server
|
||||
ApiClient.updateCertificateStatus(
|
||||
certificateId = certificateId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
).onFailure { error ->
|
||||
Log.e(TAG, "Failed to report certificate removal status for ID $certificateId: ${error.message}", error)
|
||||
when {
|
||||
certState?.status == CertificateStatus.REMOVED -> {
|
||||
if (certState.uuid == hostCert.uuid) {
|
||||
// Already removed with same uuid, skip
|
||||
Log.d(TAG, "Certificate ID $certId already removed (uuid: ${certState.uuid}), skipping")
|
||||
results[certId] = CleanupResult.AlreadyRemoved(certState.alias)
|
||||
} else {
|
||||
// UUID changed - report to server and update stored uuid
|
||||
Log.d(
|
||||
TAG,
|
||||
"Certificate ID $certId already removed but uuid changed (${certState.uuid} -> ${hostCert.uuid}), reporting to server",
|
||||
)
|
||||
val reportResult = apiClient.updateCertificateStatus(
|
||||
certificateId = certId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
)
|
||||
if (reportResult.isSuccess) {
|
||||
markCertificateRemoved(context, certId, certState.alias, hostCert.uuid)
|
||||
} else {
|
||||
Log.e(TAG, "Failed to report removal status for ID $certId: ${reportResult.exceptionOrNull()?.message}")
|
||||
// Mark as unreported so retry logic will handle it
|
||||
markCertificateUnreported(context, certId, certState.alias, hostCert.uuid, isInstall = false)
|
||||
}
|
||||
results[certId] = CleanupResult.Success(certState.alias)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up DataStore
|
||||
removeCertificateInstallInfo(context, certificateId)
|
||||
|
||||
results[certificateId] = CleanupResult.Success(alias)
|
||||
Log.i(TAG, "Successfully removed certificate ID $certificateId (alias: '$alias')")
|
||||
} else {
|
||||
// Report failure to server
|
||||
val errorDetail = "Failed to remove certificate keypair from device"
|
||||
ApiClient.updateCertificateStatus(
|
||||
certificateId = certificateId,
|
||||
status = UpdateCertificateStatusStatus.FAILED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
detail = errorDetail,
|
||||
).onFailure { error ->
|
||||
Log.e(TAG, "Failed to report certificate removal failure for ID $certificateId: ${error.message}", error)
|
||||
certState?.status == CertificateStatus.REMOVED_UNREPORTED -> {
|
||||
if (certState.uuid == hostCert.uuid) {
|
||||
// Already removed but not yet reported to server; skip removal, will be retried
|
||||
Log.d(TAG, "Certificate ID $certId already removed (unreported, uuid: ${certState.uuid}), skipping")
|
||||
results[certId] = CleanupResult.AlreadyRemoved(certState.alias)
|
||||
} else {
|
||||
// UUID changed - report to server and update stored uuid
|
||||
Log.d(
|
||||
TAG,
|
||||
"Certificate ID $certId already removed (unreported) but uuid changed (${certState.uuid} -> ${hostCert.uuid}), reporting to server",
|
||||
)
|
||||
val reportResult = apiClient.updateCertificateStatus(
|
||||
certificateId = certId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
)
|
||||
if (reportResult.isSuccess) {
|
||||
markCertificateRemoved(context, certId, certState.alias, hostCert.uuid)
|
||||
} else {
|
||||
Log.e(TAG, "Failed to report removal status for ID $certId: ${reportResult.exceptionOrNull()?.message}")
|
||||
// Keep as unreported with new uuid so retry logic will handle it
|
||||
markCertificateUnreported(context, certId, certState.alias, hostCert.uuid, isInstall = false)
|
||||
}
|
||||
results[certId] = CleanupResult.Success(certState.alias)
|
||||
}
|
||||
}
|
||||
certState != null -> {
|
||||
// Certificate exists in DataStore, remove it
|
||||
val result = removeCertificateFromDevice(context, certId, certState.alias, hostCert.uuid)
|
||||
results[certId] = result
|
||||
}
|
||||
else -> {
|
||||
// Not in DataStore (never installed or already cleaned up)
|
||||
// Notify server and save as REMOVED to prevent re-notification
|
||||
Log.d(TAG, "Certificate ID $certId not in DataStore, notifying server as verified")
|
||||
val alias = "cert-$certId"
|
||||
apiClient.updateCertificateStatus(
|
||||
certificateId = certId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
).onFailure { error ->
|
||||
Log.e(TAG, "Failed to report removal status for ID $certId: ${error.message}", error)
|
||||
}
|
||||
markCertificateRemoved(context, certId, alias, hostCert.uuid)
|
||||
results[certId] = CleanupResult.Success(alias)
|
||||
}
|
||||
|
||||
results[certificateId] = CleanupResult.Failure(
|
||||
reason = errorDetail,
|
||||
exception = null,
|
||||
shouldRetry = false, // Permission or configuration issue, don't retry
|
||||
)
|
||||
Log.e(TAG, "Failed to remove certificate ID $certificateId (alias: '$alias')")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Clean up orphaned certificates with REMOVED status
|
||||
val hostCertIds = hostCertificates.map { it.id }.toSet()
|
||||
val orphanedCerts = certificateStates.filter { it.key !in hostCertIds }
|
||||
Log.d(TAG, "Orphaned certificates: ${orphanedCerts.keys}")
|
||||
|
||||
for ((certId, certState) in orphanedCerts) {
|
||||
if (certState.status == CertificateStatus.REMOVED || certState.status == CertificateStatus.REMOVED_UNREPORTED) {
|
||||
// Removal complete (or unreported) and host certificate gone, clean up tracking
|
||||
Log.d(TAG, "Cleaning up tracking for removed certificate ID $certId")
|
||||
removeCertificateState(context, certId)
|
||||
results[certId] = CleanupResult.AlreadyRemoved(certState.alias)
|
||||
} else {
|
||||
// Orphaned but not removed - this is unexpected, remove it
|
||||
Log.w(TAG, "Orphaned certificate ID $certId with status ${certState.status}, removing")
|
||||
val result = removeCertificateFromDevice(context, certId, certState.alias, certState.uuid)
|
||||
results[certId] = result
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a certificate from the device and updates tracking.
|
||||
*/
|
||||
private suspend fun removeCertificateFromDevice(context: Context, certificateId: Int, alias: String, uuid: String): CleanupResult {
|
||||
Log.d(TAG, "Removing certificate ID $certificateId with alias '$alias'")
|
||||
|
||||
val removed = removeKeyPair(context, alias)
|
||||
|
||||
return if (removed) {
|
||||
// First, mark as unreported (persisted before network call)
|
||||
markCertificateUnreported(context, certificateId, alias, uuid = uuid, isInstall = false)
|
||||
|
||||
// Attempt to report status
|
||||
val reportResult = apiClient.updateCertificateStatus(
|
||||
certificateId = certificateId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
)
|
||||
|
||||
if (reportResult.isSuccess) {
|
||||
// Status reported successfully, mark as fully removed
|
||||
markCertificateRemoved(context, certificateId, alias, uuid)
|
||||
} else {
|
||||
// Status report failed; leave as REMOVED_UNREPORTED for retry later
|
||||
Log.w(
|
||||
TAG,
|
||||
"Removal status report failed for certificate $certificateId, will retry later: ${reportResult.exceptionOrNull()?.message}",
|
||||
)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Successfully removed certificate ID $certificateId (alias: '$alias')")
|
||||
CleanupResult.Success(alias)
|
||||
} else {
|
||||
val errorDetail = "Failed to remove certificate keypair from device"
|
||||
apiClient.updateCertificateStatus(
|
||||
certificateId = certificateId,
|
||||
status = UpdateCertificateStatusStatus.FAILED,
|
||||
operationType = UpdateCertificateStatusOperation.REMOVE,
|
||||
detail = errorDetail,
|
||||
).onFailure { error ->
|
||||
Log.e(TAG, "Failed to report removal failure for ID $certificateId: ${error.message}", error)
|
||||
}
|
||||
|
||||
Log.e(TAG, "Failed to remove certificate ID $certificateId (alias: '$alias')")
|
||||
CleanupResult.Failure(
|
||||
reason = errorDetail,
|
||||
exception = null,
|
||||
shouldRetry = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a certificate as removed in DataStore.
|
||||
*/
|
||||
private suspend fun markCertificateRemoved(context: Context, certificateId: Int, alias: String, uuid: String) {
|
||||
val info = CertificateState(alias = alias, status = CertificateStatus.REMOVED, uuid = uuid)
|
||||
storeCertificateState(context, certificateId, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a certificate as unreported after successful install/remove.
|
||||
* We persist this state before attempting the network call so that we can retry later if needed.
|
||||
*
|
||||
* @param context Android context
|
||||
* @param certificateId Certificate template ID
|
||||
* @param alias Certificate alias
|
||||
* @param isInstall True for install operation, false for remove operation
|
||||
*/
|
||||
internal suspend fun markCertificateUnreported(context: Context, certificateId: Int, alias: String, uuid: String, isInstall: Boolean) {
|
||||
val status = if (isInstall) {
|
||||
CertificateStatus.INSTALLED_UNREPORTED
|
||||
} else {
|
||||
CertificateStatus.REMOVED_UNREPORTED
|
||||
}
|
||||
val info = CertificateState(alias = alias, status = status, statusReportRetries = 0, uuid = uuid)
|
||||
storeCertificateState(context, certificateId, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the status report retry count for a certificate.
|
||||
* If max retries reached, transitions to final status (INSTALLED or REMOVED).
|
||||
*
|
||||
* @param context Android context
|
||||
* @param certificateId Certificate template ID
|
||||
* @return The updated CertificateState, or null if not found
|
||||
*/
|
||||
internal suspend fun incrementStatusReportRetries(context: Context, certificateId: Int): CertificateState? {
|
||||
val existingState = getCertificateState(context, certificateId) ?: return null
|
||||
|
||||
val newRetries = existingState.statusReportRetries + 1
|
||||
val newStatus = when {
|
||||
newRetries >= MAX_STATUS_REPORT_RETRIES -> {
|
||||
// Max retries reached, transition to final status
|
||||
when (existingState.status) {
|
||||
CertificateStatus.INSTALLED_UNREPORTED -> CertificateStatus.INSTALLED
|
||||
CertificateStatus.REMOVED_UNREPORTED -> CertificateStatus.REMOVED
|
||||
else -> existingState.status
|
||||
}
|
||||
}
|
||||
else -> existingState.status
|
||||
}
|
||||
|
||||
val updatedState = existingState.copy(
|
||||
status = newStatus,
|
||||
statusReportRetries = newRetries,
|
||||
)
|
||||
storeCertificateState(context, certificateId, updatedState)
|
||||
|
||||
if (newRetries >= MAX_STATUS_REPORT_RETRIES) {
|
||||
Log.w(TAG, "Certificate $certificateId reached max status report retries ($MAX_STATUS_REPORT_RETRIES), giving up")
|
||||
}
|
||||
|
||||
return updatedState
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries unreported statuses for certificates that were installed/removed
|
||||
* but whose status wasn't successfully reported to the server.
|
||||
*
|
||||
* @param context Android context
|
||||
* @return Map of certificate ID to success (true) or failure (false)
|
||||
*/
|
||||
suspend fun retryUnreportedStatuses(context: Context): Map<Int, Boolean> {
|
||||
val states = getCertificateStates(context)
|
||||
val results = mutableMapOf<Int, Boolean>()
|
||||
|
||||
val unreportedStates = states.filter { (_, state) ->
|
||||
state.status == CertificateStatus.INSTALLED_UNREPORTED ||
|
||||
state.status == CertificateStatus.REMOVED_UNREPORTED
|
||||
}
|
||||
|
||||
for ((certId, state) in unreportedStates) {
|
||||
val isInstall = state.status == CertificateStatus.INSTALLED_UNREPORTED
|
||||
val operationType = if (isInstall) UpdateCertificateStatusOperation.INSTALL else UpdateCertificateStatusOperation.REMOVE
|
||||
val operationName = if (isInstall) "install" else "removal"
|
||||
|
||||
Log.d(TAG, "Retrying status report for $operationName certificate $certId (attempt ${state.statusReportRetries + 1})")
|
||||
|
||||
val result = apiClient.updateCertificateStatus(
|
||||
certificateId = certId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = operationType,
|
||||
)
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (isInstall) {
|
||||
markCertificateInstalled(context, certId, state.alias, state.uuid)
|
||||
} else {
|
||||
markCertificateRemoved(context, certId, state.alias, state.uuid)
|
||||
}
|
||||
Log.i(TAG, "Successfully reported $operationName status for certificate $certId")
|
||||
results[certId] = true
|
||||
} else {
|
||||
val updatedState = incrementStatusReportRetries(context, certId)
|
||||
Log.w(TAG, "Failed to report $operationName status for certificate $certId: ${result.exceptionOrNull()?.message}")
|
||||
results[certId] = false
|
||||
|
||||
val finalStatus = if (isInstall) CertificateStatus.INSTALLED else CertificateStatus.REMOVED
|
||||
if (updatedState?.status == finalStatus) {
|
||||
Log.w(TAG, "Gave up reporting $operationName status for certificate $certId after $MAX_STATUS_REPORT_RETRIES attempts")
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
|
|
@ -416,40 +600,50 @@ object CertificateOrchestrator {
|
|||
*
|
||||
* @param context Android context for certificate installation
|
||||
* @param certificateId ID of the certificate template to enroll
|
||||
* @param scepClient SCEP client implementation (defaults to ScepClientImpl)
|
||||
* @param uuid Unique identifier from managed config, used to detect when reinstallation is needed
|
||||
* @param certificateInstaller Certificate installer implementation (defaults to AndroidCertificateInstaller)
|
||||
* @return EnrollmentResult indicating success or failure with details
|
||||
*/
|
||||
suspend fun enrollCertificate(
|
||||
context: Context,
|
||||
certificateId: Int,
|
||||
scepClient: ScepClient = ScepClientImpl(),
|
||||
uuid: String,
|
||||
certificateInstaller: CertificateEnrollmentHandler.CertificateInstaller? = null,
|
||||
): CertificateEnrollmentHandler.EnrollmentResult {
|
||||
Log.d(TAG, "Starting certificate enrollment for certificate ID: $certificateId")
|
||||
Log.d(TAG, "Starting certificate enrollment for certificate ID: $certificateId (uuid: $uuid)")
|
||||
|
||||
// Check if certificate is already installed (BEFORE API call)
|
||||
if (isCertificateIdInstalled(context, certificateId)) {
|
||||
val alias = getCertificateAlias(context, certificateId)!!
|
||||
Log.i(TAG, "Certificate ID $certificateId (alias: '$alias') is already installed, skipping enrollment")
|
||||
return CertificateEnrollmentHandler.EnrollmentResult.Success(alias)
|
||||
// Check if certificate is already installed with matching uuid (BEFORE API call)
|
||||
val storedState = getCertificateState(context, certificateId)
|
||||
if (storedState != null) {
|
||||
val existsInKeystore = isCertificateInstalled(context, storedState.alias)
|
||||
val isInstalled = storedState.status == CertificateStatus.INSTALLED ||
|
||||
storedState.status == CertificateStatus.INSTALLED_UNREPORTED
|
||||
if (existsInKeystore && storedState.uuid == uuid && isInstalled) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Certificate ID $certificateId (alias: '${storedState.alias}', uuid: $uuid) is already installed, skipping enrollment",
|
||||
)
|
||||
return CertificateEnrollmentHandler.EnrollmentResult.Success(storedState.alias)
|
||||
}
|
||||
if (existsInKeystore && storedState.uuid != uuid) {
|
||||
Log.i(TAG, "Certificate ID $certificateId uuid changed (${storedState.uuid} -> $uuid), will reinstall")
|
||||
}
|
||||
}
|
||||
|
||||
// Skip enrollment if already marked as permanently failed (max retries exceeded).
|
||||
// Returns Success to prevent retry loops - the failure has already been reported
|
||||
// to the Fleet server via updateCertificateStatus().
|
||||
val storedInfo = getCertificateInstallInfo(context = context, certificateId = certificateId)
|
||||
if (storedInfo?.status == CertificateInstallStatus.FAILED) {
|
||||
return CertificateEnrollmentHandler.EnrollmentResult.Success(storedInfo.alias)
|
||||
// Skip enrollment if already marked as permanently failed (max retries exceeded),
|
||||
// unless the uuid changed (server wants a fresh install).
|
||||
if (storedState?.status == CertificateStatus.FAILED && storedState.uuid == uuid) {
|
||||
return CertificateEnrollmentHandler.EnrollmentResult.PermanentlyFailed(storedState.alias)
|
||||
}
|
||||
|
||||
// Fetch certificate template from API (only if not already installed)
|
||||
val templateResult = ApiClient.getCertificateTemplate(certificateId)
|
||||
val template = templateResult.getOrElse { error ->
|
||||
val templateResult = apiClient.getCertificateTemplate(certificateId)
|
||||
val (template, scepUrl) = templateResult.getOrElse { error ->
|
||||
Log.e(TAG, "Failed to fetch certificate template for ID $certificateId: ${error.message}", error)
|
||||
return CertificateEnrollmentHandler.EnrollmentResult.Failure(
|
||||
reason = "Failed to fetch certificate template: ${error.message}",
|
||||
exception = error as? Exception,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -473,27 +667,38 @@ object CertificateOrchestrator {
|
|||
|
||||
// Step 5: Perform enrollment
|
||||
Log.d(TAG, "Starting SCEP enrollment for certificate: ${template.name}: $template")
|
||||
val result = handler.handleEnrollment(template)
|
||||
val result = handler.handleEnrollment(template, scepUrl)
|
||||
|
||||
when (result) {
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.Success -> {
|
||||
Log.i(TAG, "Certificate enrollment successful for ID $certificateId with alias: ${result.alias}")
|
||||
ApiClient.updateCertificateStatus(
|
||||
|
||||
// First, mark as unreported (persisted before network call)
|
||||
markCertificateUnreported(context, certificateId, template.name, uuid = uuid, isInstall = true)
|
||||
|
||||
// Attempt to report status
|
||||
val reportResult = apiClient.updateCertificateStatus(
|
||||
certificateId = certificateId,
|
||||
status = UpdateCertificateStatusStatus.VERIFIED,
|
||||
operationType = UpdateCertificateStatusOperation.INSTALL,
|
||||
).onFailure { error ->
|
||||
Log.e(TAG, "Failed to update certificate status to verified for ID $certificateId: ${error.message}", error)
|
||||
}
|
||||
)
|
||||
|
||||
// Store certificate installation in DataStore
|
||||
markCertificateInstalled(context, certificateId = certificateId, alias = template.name)
|
||||
if (reportResult.isSuccess) {
|
||||
// Status reported successfully, mark as fully installed
|
||||
markCertificateInstalled(context, certificateId = certificateId, alias = template.name, uuid = uuid)
|
||||
} else {
|
||||
// Status report failed - leave as INSTALLED_UNREPORTED for retry later
|
||||
Log.w(
|
||||
TAG,
|
||||
"Status report failed for certificate $certificateId, will retry later: ${reportResult.exceptionOrNull()?.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
|
||||
val updatedInfo = markCertificateFailure(context = context, certificateId = certificateId, alias = template.name)
|
||||
if (!updatedInfo.shouldRetry()) {
|
||||
Log.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception)
|
||||
ApiClient.updateCertificateStatus(
|
||||
apiClient.updateCertificateStatus(
|
||||
certificateId = certificateId,
|
||||
status = UpdateCertificateStatusStatus.FAILED,
|
||||
operationType = UpdateCertificateStatusOperation.INSTALL,
|
||||
|
|
@ -503,6 +708,9 @@ object CertificateOrchestrator {
|
|||
}
|
||||
}
|
||||
}
|
||||
is CertificateEnrollmentHandler.EnrollmentResult.PermanentlyFailed -> {
|
||||
// No action needed - already in terminal state
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
@ -512,20 +720,19 @@ object CertificateOrchestrator {
|
|||
* Enrolls multiple certificates in parallel.
|
||||
*
|
||||
* @param context Android context for certificate installation
|
||||
* @param certificateIds List of certificate template IDs to enroll
|
||||
* @param scepClient SCEP client implementation (defaults to ScepClientImpl)
|
||||
* @param hostCertificates List of certificate templates to enroll
|
||||
* @return Map of certificate ID to enrollment result
|
||||
*/
|
||||
suspend fun enrollCertificates(
|
||||
context: Context,
|
||||
certificateIds: List<Int>,
|
||||
scepClient: ScepClient = ScepClientImpl(),
|
||||
hostCertificates: List<HostCertificate>,
|
||||
certificateInstaller: CertificateEnrollmentHandler.CertificateInstaller? = null,
|
||||
): Map<Int, CertificateEnrollmentHandler.EnrollmentResult> = coroutineScope {
|
||||
Log.d(TAG, "Starting batch certificate enrollment for ${certificateIds.size} certificates")
|
||||
Log.d(TAG, "Starting batch certificate enrollment for ${hostCertificates.size} certificates")
|
||||
|
||||
certificateIds.associateWith { certificateId ->
|
||||
async {
|
||||
enrollCertificate(context, certificateId, scepClient)
|
||||
hostCertificates.associate { cert ->
|
||||
cert.id to async {
|
||||
enrollCertificate(context, cert.id, cert.uuid, certificateInstaller)
|
||||
}
|
||||
}.mapValues { it.value.await() }
|
||||
}
|
||||
|
|
@ -538,10 +745,13 @@ object CertificateOrchestrator {
|
|||
* delegated by the Device Policy Controller.
|
||||
*/
|
||||
class AndroidCertificateInstaller(private val context: Context) : CertificateEnrollmentHandler.CertificateInstaller {
|
||||
private val TAG = "AndroidCertInstaller"
|
||||
companion object {
|
||||
private const val TAG = "fleet-AndroidCertInstaller"
|
||||
}
|
||||
|
||||
override fun installCertificate(alias: String, privateKey: PrivateKey, certificateChain: Array<Certificate>): Boolean {
|
||||
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
|
||||
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||
?: error("DevicePolicyManager not available")
|
||||
|
||||
// The admin component is null because the caller is a DELEGATED application,
|
||||
// not the Device Policy Controller itself. The DPM recognizes the delegation
|
||||
|
|
@ -565,30 +775,44 @@ object CertificateOrchestrator {
|
|||
}
|
||||
}
|
||||
|
||||
typealias CertStatusMap = Map<Int, CertificateInstallInfo>
|
||||
typealias CertificateStateMap = Map<Int, CertificateState>
|
||||
|
||||
@Serializable
|
||||
enum class CertificateInstallStatus {
|
||||
enum class CertificateStatus {
|
||||
@SerialName("installed")
|
||||
INSTALLED,
|
||||
|
||||
@SerialName("installed_unreported")
|
||||
INSTALLED_UNREPORTED,
|
||||
|
||||
@SerialName("failed")
|
||||
FAILED,
|
||||
|
||||
@SerialName("retry")
|
||||
RETRY,
|
||||
|
||||
@SerialName("removed")
|
||||
REMOVED,
|
||||
|
||||
@SerialName("removed_unreported")
|
||||
REMOVED_UNREPORTED,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CertificateInstallInfo(
|
||||
data class CertificateState(
|
||||
@SerialName("alias")
|
||||
val alias: String,
|
||||
@SerialName("status")
|
||||
val status: CertificateInstallStatus,
|
||||
val status: CertificateStatus,
|
||||
@SerialName("retries")
|
||||
val retries: Int = 0,
|
||||
@SerialName("status_report_retries")
|
||||
val statusReportRetries: Int = 0,
|
||||
@SerialName("uuid")
|
||||
val uuid: String = "",
|
||||
) {
|
||||
fun shouldRetry(): Boolean = status == CertificateInstallStatus.RETRY && retries < (MAX_CERT_INSTALL_RETRIES)
|
||||
fun shouldRetry(): Boolean = status == CertificateStatus.RETRY && retries < (MAX_CERT_INSTALL_RETRIES)
|
||||
fun shouldRetryStatusReport(): Boolean = statusReportRetries < MAX_STATUS_REPORT_RETRIES
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -599,3 +823,21 @@ sealed class CleanupResult {
|
|||
data class Failure(val reason: String, val exception: Exception?, val shouldRetry: Boolean) : CleanupResult()
|
||||
data class AlreadyRemoved(val alias: String) : CleanupResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a certificate template from managed configuration.
|
||||
*
|
||||
* @property id Certificate template ID
|
||||
* @property status Current status of the certificate template
|
||||
* @property operation Operation to perform: "install" or "remove"
|
||||
* @property uuid Unique identifier changed by server to trigger reinstallation
|
||||
*/
|
||||
data class HostCertificate(val id: Int, val status: String, val operation: String, val uuid: String) {
|
||||
companion object {
|
||||
const val OPERATION_INSTALL = "install"
|
||||
const val OPERATION_REMOVE = "remove"
|
||||
}
|
||||
|
||||
fun shouldInstall(): Boolean = operation == OPERATION_INSTALL
|
||||
fun shouldRemove(): Boolean = operation == OPERATION_REMOVE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package com.fleetdm.agent
|
||||
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Interface for device keystore operations (certificate management via DevicePolicyManager).
|
||||
* Abstracted to allow mocking in tests.
|
||||
*/
|
||||
interface DeviceKeystoreManager {
|
||||
/**
|
||||
* Check if a keypair with the given alias exists in the keystore.
|
||||
*/
|
||||
fun hasKeyPair(alias: String): Boolean
|
||||
|
||||
/**
|
||||
* Remove a keypair with the given alias from the keystore.
|
||||
* @return True if removal was successful or keypair doesn't exist
|
||||
*/
|
||||
fun removeKeyPair(alias: String): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Real implementation of DeviceKeystoreManager using DevicePolicyManager.
|
||||
*/
|
||||
class AndroidDeviceKeystoreManager(private val context: Context) : DeviceKeystoreManager {
|
||||
companion object {
|
||||
private const val TAG = "fleet-DeviceKeystoreManager"
|
||||
}
|
||||
|
||||
private val dpm: DevicePolicyManager by lazy {
|
||||
context.getSystemService(DevicePolicyManager::class.java)
|
||||
?: error("DevicePolicyManager not available")
|
||||
}
|
||||
|
||||
override fun hasKeyPair(alias: String): Boolean = try {
|
||||
dpm.hasKeyPair(alias)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking if certificate '$alias' exists: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
|
||||
override fun removeKeyPair(alias: String): Boolean = try {
|
||||
if (!dpm.hasKeyPair(alias)) {
|
||||
Log.i(TAG, "Certificate '$alias' doesn't exist in keystore, considering removal successful")
|
||||
true
|
||||
} else {
|
||||
val removed = dpm.removeKeyPair(null, alias)
|
||||
if (removed) {
|
||||
Log.i(TAG, "Successfully removed certificate keypair with alias: $alias")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to remove certificate keypair '$alias'. Check MDM policy and delegation status.")
|
||||
}
|
||||
removed
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception removing certificate '$alias': ${e.message}", e)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing certificate '$alias': ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,8 @@ object KeystoreManager {
|
|||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
return keyStore.getKey(KEY_ALIAS, null) as? SecretKey
|
||||
?: error("Key $KEY_ALIAS exists but could not be retrieved")
|
||||
}
|
||||
|
||||
fun encrypt(plaintext: String): String {
|
||||
|
|
|
|||
|
|
@ -115,9 +115,10 @@ fun AppNavigation() {
|
|||
@Composable
|
||||
fun MainScreen(onNavigateToDebug: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val orchestrator = remember { AgentApplication.getCertificateOrchestrator(context) }
|
||||
|
||||
var versionClicks by remember { mutableStateOf(0) }
|
||||
val installedCerts by CertificateOrchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
val installedCerts by orchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -142,7 +143,8 @@ fun MainScreen(onNavigateToDebug: () -> Unit) {
|
|||
if (++versionClicks >= CLICKS_TO_DEBUG) {
|
||||
onNavigateToDebug()
|
||||
} else if (versionClicks == 1) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java)
|
||||
?: error("ClipboardManager not available")
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("", "Fleet Android Agent: ${BuildConfig.VERSION_NAME}"))
|
||||
Toast.makeText(context, "Fleet Agent version copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
|
@ -156,32 +158,28 @@ fun MainScreen(onNavigateToDebug: () -> Unit) {
|
|||
fun DebugScreen(onNavigateBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val restrictionsManager = context.getSystemService(RESTRICTIONS_SERVICE) as RestrictionsManager
|
||||
val restrictionsManager = context.getSystemService(RestrictionsManager::class.java)
|
||||
?: error("RestrictionsManager not available")
|
||||
val appRestrictions = restrictionsManager.applicationRestrictions
|
||||
val dpm = context.getSystemService(DEVICE_POLICY_SERVICE) as DevicePolicyManager
|
||||
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||
?: error("DevicePolicyManager not available")
|
||||
|
||||
val orchestrator = remember { AgentApplication.getCertificateOrchestrator(context) }
|
||||
val delegatedScopes = remember { dpm.getDelegatedScopes(null, context.packageName).toList() }
|
||||
val enrollmentSpecificID = remember { appRestrictions.getString("host_uuid")?.let { "****" + it.takeLast(4) } }
|
||||
val certIds = remember { CertificateOrchestrator.getCertificateIDs(context) }
|
||||
val hostCertificates = remember { orchestrator.getHostCertificates(context) }
|
||||
val permissionsList = remember {
|
||||
val grantedPermissions = mutableListOf<String>()
|
||||
val packageInfo: PackageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
|
||||
packageInfo.requestedPermissions?.let {
|
||||
for (i in it.indices) {
|
||||
if ((
|
||||
packageInfo.requestedPermissionsFlags?.get(i)
|
||||
?.and(PackageInfo.REQUESTED_PERMISSION_GRANTED)
|
||||
) != 0
|
||||
) {
|
||||
grantedPermissions.add(it[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
grantedPermissions.toList()
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
|
||||
val permissions = packageInfo.requestedPermissions ?: return@remember emptyList()
|
||||
val flags = packageInfo.requestedPermissionsFlags ?: return@remember emptyList()
|
||||
|
||||
permissions.zip(flags.toList())
|
||||
.filter { (_, flag) -> flag and PackageInfo.REQUESTED_PERMISSION_GRANTED != 0 }
|
||||
.map { (permission, _) -> permission }
|
||||
}
|
||||
val fleetBaseUrl = remember { appRestrictions.getString("server_url") }
|
||||
val baseUrl by ApiClient.baseUrlFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
val installedCerts by CertificateOrchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
val installedCerts by orchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -211,7 +209,7 @@ fun DebugScreen(onNavigateBack: () -> Unit) {
|
|||
KeyValue("host_uuid (MC)", enrollmentSpecificID)
|
||||
KeyValue("server_url (MC)", fleetBaseUrl)
|
||||
KeyValue("server_url (DS)", baseUrl)
|
||||
KeyValue("certificate_templates->id", certIds.toString())
|
||||
KeyValue("host_certificates", hostCertificates?.map { "${it.id}:${it.operation}" }.toString())
|
||||
DebugCertificateList(certificates = installedCerts)
|
||||
PermissionList(
|
||||
permissionsList = permissionsList,
|
||||
|
|
@ -222,7 +220,7 @@ fun DebugScreen(onNavigateBack: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun DebugCertificateList(certificates: CertStatusMap) {
|
||||
fun DebugCertificateList(certificates: CertificateStateMap) {
|
||||
Column {
|
||||
Text("certificate status:", fontWeight = FontWeight.Bold)
|
||||
certificates.forEach { (key, value) ->
|
||||
|
|
@ -302,7 +300,7 @@ fun LogoHeader(modifier: Modifier = Modifier) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun CertificateList(modifier: Modifier = Modifier, certificates: CertStatusMap) {
|
||||
fun CertificateList(modifier: Modifier = Modifier, certificates: CertificateStateMap) {
|
||||
Column(modifier = modifier.padding(all = 20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.certificate_list_title),
|
||||
|
|
@ -313,7 +311,7 @@ fun CertificateList(modifier: Modifier = Modifier, certificates: CertStatusMap)
|
|||
Text(text = stringResource(R.string.certificate_list_no_certificates))
|
||||
}
|
||||
certificates.forEach { (_, value) ->
|
||||
if (value.status == CertificateInstallStatus.INSTALLED) {
|
||||
if (value.status == CertificateStatus.INSTALLED || value.status == CertificateStatus.INSTALLED_UNREPORTED) {
|
||||
Text(text = value.alias)
|
||||
}
|
||||
}
|
||||
|
|
@ -352,8 +350,8 @@ fun FleetScreenPreview() {
|
|||
HorizontalDivider()
|
||||
CertificateList(
|
||||
certificates = mapOf(
|
||||
1 to CertificateInstallInfo(alias = "WIFI-1", status = CertificateInstallStatus.INSTALLED),
|
||||
2 to CertificateInstallInfo(alias = "VPN-3", status = CertificateInstallStatus.FAILED),
|
||||
1 to CertificateState(alias = "WIFI-1", status = CertificateStatus.INSTALLED),
|
||||
2 to CertificateState(alias = "VPN-3", status = CertificateStatus.FAILED),
|
||||
),
|
||||
)
|
||||
AppVersion(onClick = {})
|
||||
|
|
@ -367,8 +365,8 @@ fun DebugCertificateListPreview() {
|
|||
MyApplicationTheme {
|
||||
DebugCertificateList(
|
||||
certificates = mapOf(
|
||||
1 to CertificateInstallInfo(alias = "WIFI-1", status = CertificateInstallStatus.INSTALLED),
|
||||
2 to CertificateInstallInfo(alias = "VPN-3", status = CertificateInstallStatus.FAILED),
|
||||
1 to CertificateState(alias = "WIFI-1", status = CertificateStatus.INSTALLED),
|
||||
2 to CertificateState(alias = "VPN-3", status = CertificateStatus.FAILED),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ import com.google.android.managementapi.notification.NotificationReceiverService
|
|||
*/
|
||||
class RoleNotificationReceiverService : NotificationReceiverService() {
|
||||
companion object {
|
||||
private const val TAG = "fleet-notification"
|
||||
private const val TAG = "fleet-RoleNotificationReceiverService"
|
||||
}
|
||||
|
||||
override fun getAppRolesListener(): AppRolesListener = object : AppRolesListener {
|
||||
override fun onAppRolesSet(request: AppRolesSetRequest): AppRolesSetResponse {
|
||||
Log.i(TAG, "App roles set by Android Device Policy")
|
||||
Log.i(TAG, "App started via MDM role assignment")
|
||||
return AppRolesSetResponse.getDefaultInstance()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ interface ScepClient {
|
|||
* Performs SCEP enrollment to obtain a certificate from a SCEP server.
|
||||
*
|
||||
* @param config The SCEP enrollment configuration
|
||||
* @param scepUrl The SCEP server URL to enroll against
|
||||
* @return ScepResult containing the private key and certificate chain
|
||||
* @throws ScepException if enrollment fails
|
||||
*/
|
||||
suspend fun enroll(config: GetCertificateTemplateResponse): ScepResult
|
||||
suspend fun enroll(config: GetCertificateTemplateResponse, scepUrl: String): ScepResult
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.fleetdm.agent.scep
|
||||
|
||||
import android.util.Log
|
||||
import com.fleetdm.agent.GetCertificateTemplateResponse
|
||||
import org.bouncycastle.asn1.DERPrintableString
|
||||
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers
|
||||
|
|
@ -33,8 +32,6 @@ import kotlinx.coroutines.withContext
|
|||
*/
|
||||
class ScepClientImpl : ScepClient {
|
||||
|
||||
val TAG = "ScepClientImpl"
|
||||
|
||||
companion object {
|
||||
private const val SCEP_PROFILE = "NDESCA" // Network Device Enrollment Service CA
|
||||
private const val SELF_SIGNED_CERT_VALIDITY_DAYS = 100L
|
||||
|
|
@ -47,10 +44,8 @@ class ScepClientImpl : ScepClient {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun enroll(config: GetCertificateTemplateResponse): ScepResult = withContext(Dispatchers.IO) {
|
||||
override suspend fun enroll(config: GetCertificateTemplateResponse, scepUrl: String): ScepResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Log calls removed to avoid test failures on JVM (use logcat in Android Studio)
|
||||
|
||||
// Step 1: Generate key pair
|
||||
val keyPair = generateKeyPair(config.keyLength)
|
||||
|
||||
|
|
@ -70,9 +65,9 @@ class ScepClientImpl : ScepClient {
|
|||
|
||||
// Step 4: Create SCEP client
|
||||
val server = try {
|
||||
URL(config.url)
|
||||
URL(scepUrl)
|
||||
} catch (e: Exception) {
|
||||
throw ScepNetworkException("Invalid SCEP URL: ${config.url}", e)
|
||||
throw ScepNetworkException("Invalid SCEP URL: $scepUrl", e)
|
||||
}
|
||||
|
||||
// OptimisticCertificateVerifier is used intentionally because:
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package com.fleetdm.agent.scep
|
||||
|
||||
/**
|
||||
* Configuration for SCEP enrollment.
|
||||
*
|
||||
* @property url The SCEP server enrollment URL
|
||||
* @property challenge The challenge password for authentication
|
||||
* @property alias The certificate alias for silent installation
|
||||
* @property subject The X.500 distinguished name subject (e.g., "CN=Device123,O=FleetDM")
|
||||
* @property keyLength RSA key length in bits (default: 2048)
|
||||
* @property signatureAlgorithm Signature algorithm to use (default: SHA256withRSA)
|
||||
*/
|
||||
data class ScepConfig(
|
||||
val url: String,
|
||||
val challenge: String,
|
||||
val alias: String,
|
||||
val subject: String,
|
||||
val keyLength: Int = 2048,
|
||||
val signatureAlgorithm: String = "SHA256withRSA",
|
||||
) {
|
||||
init {
|
||||
require(url.isNotBlank()) { "SCEP URL cannot be blank" }
|
||||
require(url.startsWith("http://") || url.startsWith("https://")) {
|
||||
"SCEP URL must start with http:// or https://"
|
||||
}
|
||||
require(challenge.isNotBlank()) { "Challenge password cannot be blank" }
|
||||
require(alias.isNotBlank()) { "Certificate alias cannot be blank" }
|
||||
require(subject.isNotBlank()) { "Subject cannot be blank" }
|
||||
require(keyLength >= 2048) { "Key length must be at least 2048 bits" }
|
||||
require(signatureAlgorithm.isNotBlank()) { "Signature algorithm cannot be blank" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +1,19 @@
|
|||
package com.fleetdm.agent.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
// private val DarkColorScheme = darkColorScheme(
|
||||
// primary = Purple80,
|
||||
// secondary = PurpleGrey80,
|
||||
// tertiary = Pink80,
|
||||
// )
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MyApplicationTheme(
|
||||
// darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
// dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = LightColorScheme
|
||||
// val colorScheme = when {
|
||||
// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
// val context = LocalContext.current
|
||||
// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
// }
|
||||
//
|
||||
// darkTheme -> DarkColorScheme
|
||||
// else -> LightColorScheme
|
||||
// }
|
||||
|
||||
fun MyApplicationTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
colorScheme = LightColorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@
|
|||
<string name="certificate_template_description">Certificate template information</string>
|
||||
<string name="certificate_template_id_title">Certificate template ID</string>
|
||||
<string name="certificate_template_id_description">Certificate template ID to be requested</string>
|
||||
<string name="certificate_template_status_title">Certificate template status</string>
|
||||
<string name="certificate_template_status_description">Current status of the certificate template</string>
|
||||
<string name="certificate_template_operation_title">Certificate template operation</string>
|
||||
<string name="certificate_template_operation_description">Operation to perform (install or remove)</string>
|
||||
<string name="host_uuid_title">Host UUID</string>
|
||||
<string name="host_uuid_description">The host UUID to present to Fleet during enrollment</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@
|
|||
android:title="@string/certificate_template_id_title"
|
||||
android:description="@string/certificate_template_id_description"
|
||||
android:restrictionType="integer" />
|
||||
<restriction
|
||||
android:key="status"
|
||||
android:title="@string/certificate_template_status_title"
|
||||
android:description="@string/certificate_template_status_description"
|
||||
android:restrictionType="string" />
|
||||
<restriction
|
||||
android:key="operation"
|
||||
android:title="@string/certificate_template_operation_title"
|
||||
android:description="@string/certificate_template_operation_description"
|
||||
android:restrictionType="string" />
|
||||
</restriction>
|
||||
</restriction>
|
||||
</restrictions>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.fleetdm.agent
|
||||
|
||||
import com.fleetdm.agent.scep.MockScepClient
|
||||
import com.fleetdm.agent.testutil.MockCertificateInstaller
|
||||
import com.fleetdm.agent.testutil.TestCertificateTemplateFactory
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
|
@ -8,8 +10,6 @@ import org.junit.Assert.assertNotNull
|
|||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.Certificate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
/**
|
||||
|
|
@ -39,42 +39,19 @@ class CertificateEnrollmentHandlerTest {
|
|||
mockInstaller.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock certificate installer for testing.
|
||||
*/
|
||||
class MockCertificateInstaller : CertificateEnrollmentHandler.CertificateInstaller {
|
||||
var wasInstallCalled = false
|
||||
var capturedAlias: String? = null
|
||||
var capturedPrivateKey: PrivateKey? = null
|
||||
var capturedCertificateChain: Array<Certificate>? = null
|
||||
var shouldSucceed = true
|
||||
|
||||
override fun installCertificate(alias: String, privateKey: PrivateKey, certificateChain: Array<Certificate>): Boolean {
|
||||
wasInstallCalled = true
|
||||
capturedAlias = alias
|
||||
capturedPrivateKey = privateKey
|
||||
capturedCertificateChain = certificateChain
|
||||
return shouldSucceed
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
wasInstallCalled = false
|
||||
capturedAlias = null
|
||||
capturedPrivateKey = null
|
||||
capturedCertificateChain = null
|
||||
shouldSucceed = true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handler enrolls with valid certificate template`() = runTest {
|
||||
val template = createValidCertificateTemplate()
|
||||
val template = TestCertificateTemplateFactory.create(
|
||||
name = "device-cert",
|
||||
scepChallenge = "secret123",
|
||||
subjectName = "CN=Device123,O=FleetDM",
|
||||
)
|
||||
|
||||
val result = handler.handleEnrollment(template)
|
||||
val result = handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify SCEP client was called with correct config
|
||||
// Verify SCEP client was called with correct config and URL
|
||||
assertNotNull(mockScepClient.capturedConfig)
|
||||
assertEquals("https://scep.example.com/cgi-bin/pkiclient.exe", mockScepClient.capturedConfig?.url)
|
||||
assertEquals(TestCertificateTemplateFactory.DEFAULT_SCEP_URL, mockScepClient.capturedScepUrl)
|
||||
assertEquals("secret123", mockScepClient.capturedConfig?.scepChallenge)
|
||||
assertEquals("device-cert", mockScepClient.capturedConfig?.name)
|
||||
assertEquals("CN=Device123,O=FleetDM", mockScepClient.capturedConfig?.subjectName)
|
||||
|
|
@ -85,9 +62,9 @@ class CertificateEnrollmentHandlerTest {
|
|||
|
||||
@Test
|
||||
fun `handler installs certificate after successful enrollment`() = runTest {
|
||||
val template = createValidCertificateTemplate()
|
||||
val template = TestCertificateTemplateFactory.create(name = "device-cert")
|
||||
|
||||
val result = handler.handleEnrollment(template)
|
||||
val result = handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify certificate installer was called
|
||||
assertTrue(mockInstaller.wasInstallCalled)
|
||||
|
|
@ -104,53 +81,56 @@ class CertificateEnrollmentHandlerTest {
|
|||
fun `handler handles enrollment failure gracefully`() = runTest {
|
||||
mockScepClient.shouldThrowEnrollmentException = true
|
||||
|
||||
val template = createValidCertificateTemplate()
|
||||
val template = TestCertificateTemplateFactory.create()
|
||||
|
||||
val result = handler.handleEnrollment(template)
|
||||
val result = handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify certificate installer was NOT called since enrollment failed
|
||||
assertFalse(mockInstaller.wasInstallCalled)
|
||||
|
||||
// Verify failure result
|
||||
// Verify failure result - enrollment failures are not retryable
|
||||
assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure)
|
||||
assertFalse((result as CertificateEnrollmentHandler.EnrollmentResult.Failure).isRetryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handler handles network exception gracefully`() = runTest {
|
||||
mockScepClient.shouldThrowNetworkException = true
|
||||
|
||||
val template = createValidCertificateTemplate()
|
||||
val template = TestCertificateTemplateFactory.create()
|
||||
|
||||
val result = handler.handleEnrollment(template)
|
||||
val result = handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify certificate installer was NOT called
|
||||
assertFalse(mockInstaller.wasInstallCalled)
|
||||
|
||||
// Verify failure result
|
||||
// Verify failure result - network failures are retryable
|
||||
assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure)
|
||||
assertTrue((result as CertificateEnrollmentHandler.EnrollmentResult.Failure).isRetryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handler handles installation failure`() = runTest {
|
||||
mockInstaller.shouldSucceed = false
|
||||
|
||||
val template = createValidCertificateTemplate()
|
||||
val template = TestCertificateTemplateFactory.create()
|
||||
|
||||
val result = handler.handleEnrollment(template)
|
||||
val result = handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify enrollment succeeded but installation failed
|
||||
// Verify enrollment succeeded but installation failed - installation failures are not retryable
|
||||
assertTrue(mockInstaller.wasInstallCalled)
|
||||
assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure)
|
||||
assertFalse((result as CertificateEnrollmentHandler.EnrollmentResult.Failure).isRetryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handler uses custom key length and signature algorithm`() = runTest {
|
||||
val template = createValidCertificateTemplate(
|
||||
val template = TestCertificateTemplateFactory.create(
|
||||
keyLength = 4096,
|
||||
signatureAlgorithm = "SHA512withRSA",
|
||||
)
|
||||
|
||||
handler.handleEnrollment(template)
|
||||
handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify config was used correctly
|
||||
assertEquals(4096, mockScepClient.capturedConfig?.keyLength)
|
||||
|
|
@ -159,38 +139,12 @@ class CertificateEnrollmentHandlerTest {
|
|||
|
||||
@Test
|
||||
fun `handler uses default values for optional parameters`() = runTest {
|
||||
val template = createValidCertificateTemplate()
|
||||
val template = TestCertificateTemplateFactory.create()
|
||||
|
||||
handler.handleEnrollment(template)
|
||||
handler.handleEnrollment(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
|
||||
// Verify defaults were used
|
||||
assertEquals(2048, mockScepClient.capturedConfig?.keyLength)
|
||||
assertEquals("SHA256withRSA", mockScepClient.capturedConfig?.signatureAlgorithm)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
private fun createValidCertificateTemplate(
|
||||
id: Int = 1,
|
||||
name: String = "device-cert",
|
||||
scepUrl: String = "https://scep.example.com/cgi-bin/pkiclient.exe",
|
||||
scepChallenge: String = "secret123",
|
||||
subjectName: String = "CN=Device123,O=FleetDM",
|
||||
keyLength: Int = 2048,
|
||||
signatureAlgorithm: String = "SHA256withRSA",
|
||||
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
|
||||
id = id,
|
||||
name = name,
|
||||
certificateAuthorityId = 123,
|
||||
certificateAuthorityName = "Test CA",
|
||||
createdAt = "2024-01-01T00:00:00Z",
|
||||
subjectName = subjectName,
|
||||
certificateAuthorityType = "SCEP",
|
||||
status = "active",
|
||||
scepChallenge = scepChallenge,
|
||||
fleetChallenge = "fleet-secret",
|
||||
keyLength = keyLength,
|
||||
signatureAlgorithm = signatureAlgorithm,
|
||||
url = scepUrl,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -26,6 +26,7 @@ class MockScepClient : ScepClient {
|
|||
var shouldThrowCertificateException = false
|
||||
var enrollmentDelay = 0L
|
||||
var capturedConfig: GetCertificateTemplateResponse? = null
|
||||
var capturedScepUrl: String? = null
|
||||
|
||||
init {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
|
|
@ -33,8 +34,9 @@ class MockScepClient : ScepClient {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun enroll(config: GetCertificateTemplateResponse): ScepResult {
|
||||
override suspend fun enroll(config: GetCertificateTemplateResponse, scepUrl: String): ScepResult {
|
||||
capturedConfig = config
|
||||
capturedScepUrl = scepUrl
|
||||
|
||||
if (enrollmentDelay > 0) {
|
||||
kotlinx.coroutines.delay(enrollmentDelay)
|
||||
|
|
@ -92,5 +94,6 @@ class MockScepClient : ScepClient {
|
|||
shouldThrowCertificateException = false
|
||||
enrollmentDelay = 0L
|
||||
capturedConfig = null
|
||||
capturedScepUrl = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.fleetdm.agent.scep
|
||||
|
||||
import com.fleetdm.agent.GetCertificateTemplateResponse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import com.fleetdm.agent.testutil.TestCertificateTemplateFactory
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
|
|
@ -25,10 +24,11 @@ class ScepClientImplTest {
|
|||
|
||||
@Test
|
||||
fun `enroll with malformed URL throws ScepNetworkException`() = runTest {
|
||||
val template = createCertificateTemplate(url = "http://[invalid")
|
||||
val template = TestCertificateTemplateFactory.create()
|
||||
val malformedUrl = "http://[invalid"
|
||||
|
||||
try {
|
||||
scepClient.enroll(template)
|
||||
scepClient.enroll(template, malformedUrl)
|
||||
fail("Expected ScepNetworkException to be thrown")
|
||||
} catch (e: ScepNetworkException) {
|
||||
assertTrue(e.message?.contains("Invalid SCEP URL") == true)
|
||||
|
|
@ -37,10 +37,10 @@ class ScepClientImplTest {
|
|||
|
||||
@Test
|
||||
fun `enroll with invalid subject throws ScepCsrException`() = runTest {
|
||||
val template = createCertificateTemplate(subjectName = "invalid-subject-format")
|
||||
val template = TestCertificateTemplateFactory.create(subjectName = "invalid-subject-format")
|
||||
|
||||
try {
|
||||
scepClient.enroll(template)
|
||||
scepClient.enroll(template, TestCertificateTemplateFactory.DEFAULT_SCEP_URL)
|
||||
fail("Expected ScepCsrException to be thrown")
|
||||
} catch (e: ScepCsrException) {
|
||||
assertTrue(e.message?.contains("Invalid X.500 subject name") == true)
|
||||
|
|
@ -49,39 +49,17 @@ class ScepClientImplTest {
|
|||
|
||||
@Test
|
||||
fun `enroll with unreachable server throws ScepNetworkException`() = runTest {
|
||||
val template = createCertificateTemplate(
|
||||
url = "https://invalid-scep-server-that-does-not-exist.example.com/scep",
|
||||
)
|
||||
val template = TestCertificateTemplateFactory.create()
|
||||
val unreachableUrl = "https://invalid-scep-server-that-does-not-exist.example.com/scep"
|
||||
|
||||
try {
|
||||
scepClient.enroll(template)
|
||||
scepClient.enroll(template, unreachableUrl)
|
||||
fail("Expected ScepNetworkException to be thrown")
|
||||
} catch (e: ScepNetworkException) {
|
||||
assertTrue(e.message?.contains("Failed to communicate") == true)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function
|
||||
private fun createCertificateTemplate(
|
||||
url: String = "https://scep.example.com/cgi-bin/pkiclient.exe",
|
||||
subjectName: String = "CN=Test,O=Example",
|
||||
scepChallenge: String = "secret",
|
||||
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
|
||||
id = 1,
|
||||
name = "test-cert",
|
||||
certificateAuthorityId = 123,
|
||||
certificateAuthorityName = "Test CA",
|
||||
createdAt = "2024-01-01T00:00:00Z",
|
||||
subjectName = subjectName,
|
||||
certificateAuthorityType = "SCEP",
|
||||
status = "active",
|
||||
scepChallenge = scepChallenge,
|
||||
fleetChallenge = "fleet-secret",
|
||||
keyLength = 2048,
|
||||
signatureAlgorithm = "SHA256withRSA",
|
||||
url = url,
|
||||
)
|
||||
|
||||
// Note: Testing successful enrollment requires a mock SCEP server or extensive mocking
|
||||
// of jScep's Client class. Integration tests should be used for this scenario.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
package com.fleetdm.agent.scep
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for ScepConfig data model.
|
||||
* Tests validation logic and default values.
|
||||
*/
|
||||
class ScepConfigTest {
|
||||
|
||||
@Test
|
||||
fun `valid config creates successfully`() {
|
||||
val config = ScepConfig(
|
||||
url = "https://scep.example.com/cgi-bin/pkiclient.exe",
|
||||
challenge = "secret123",
|
||||
alias = "device-cert",
|
||||
subject = "CN=Device123,O=FleetDM",
|
||||
)
|
||||
|
||||
assertEquals("https://scep.example.com/cgi-bin/pkiclient.exe", config.url)
|
||||
assertEquals("secret123", config.challenge)
|
||||
assertEquals("device-cert", config.alias)
|
||||
assertEquals("CN=Device123,O=FleetDM", config.subject)
|
||||
assertEquals(2048, config.keyLength) // default
|
||||
assertEquals("SHA256withRSA", config.signatureAlgorithm) // default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config with custom key length and algorithm`() {
|
||||
val config = ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
keyLength = 4096,
|
||||
signatureAlgorithm = "SHA512withRSA",
|
||||
)
|
||||
|
||||
assertEquals(4096, config.keyLength)
|
||||
assertEquals("SHA512withRSA", config.signatureAlgorithm)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `blank url throws exception`() {
|
||||
ScepConfig(
|
||||
url = "",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `blank challenge throws exception`() {
|
||||
ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `blank alias throws exception`() {
|
||||
ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `blank subject throws exception`() {
|
||||
ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "",
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `key length below 2048 throws exception`() {
|
||||
ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
keyLength = 1024,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minimum key length 2048 is accepted`() {
|
||||
val config = ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
keyLength = 2048,
|
||||
)
|
||||
|
||||
assertEquals(2048, config.keyLength)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `url without scheme throws exception`() {
|
||||
ScepConfig(
|
||||
url = "scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `http url is accepted`() {
|
||||
val config = ScepConfig(
|
||||
url = "http://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
|
||||
assertEquals("http://scep.example.com", config.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config equality works correctly`() {
|
||||
val config1 = ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
|
||||
val config2 = ScepConfig(
|
||||
url = "https://scep.example.com",
|
||||
challenge = "secret",
|
||||
alias = "cert",
|
||||
subject = "CN=Test",
|
||||
)
|
||||
|
||||
assertEquals(config1, config2)
|
||||
assertEquals(config1.hashCode(), config2.hashCode())
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.fleetdm.agent.scep
|
|||
import com.fleetdm.agent.GetCertificateTemplateResponse
|
||||
import com.fleetdm.agent.IntegrationTest
|
||||
import com.fleetdm.agent.IntegrationTestRule
|
||||
import com.fleetdm.agent.testutil.TestCertificateTemplateFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
|
|
@ -36,52 +37,30 @@ class ScepIntegrationTest {
|
|||
|
||||
private lateinit var scepClient: ScepClientImpl
|
||||
private lateinit var testTemplate: GetCertificateTemplateResponse
|
||||
private lateinit var testScepUrl: String
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
scepClient = ScepClientImpl()
|
||||
|
||||
// Use placeholder values for non-integration tests, real values provided by build config for integration tests
|
||||
val scepUrl = System.getProperty("scep.url") ?: "https://scep.example.com/scep"
|
||||
testScepUrl = System.getProperty("scep.url") ?: "https://scep.example.com/scep"
|
||||
val challenge = System.getProperty("scep.challenge") ?: "test-challenge"
|
||||
|
||||
// Generate unique subject DN to avoid duplicates on SCEP server
|
||||
val uniqueId = System.currentTimeMillis()
|
||||
testTemplate = createTemplate(
|
||||
url = scepUrl,
|
||||
challenge = challenge,
|
||||
testTemplate = TestCertificateTemplateFactory.create(
|
||||
scepChallenge = challenge,
|
||||
name = "integration-test-cert-$uniqueId",
|
||||
subject = "CN=IntegrationTestDevice-$uniqueId,O=FleetDM,C=US",
|
||||
subjectName = "CN=IntegrationTestDevice-$uniqueId,O=FleetDM,C=US",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTemplate(
|
||||
url: String,
|
||||
challenge: String,
|
||||
name: String,
|
||||
subject: String,
|
||||
keyLength: Int = 2048,
|
||||
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
|
||||
id = 1,
|
||||
name = name,
|
||||
certificateAuthorityId = 123,
|
||||
certificateAuthorityName = "Test CA",
|
||||
createdAt = "2024-01-01T00:00:00Z",
|
||||
subjectName = subject,
|
||||
certificateAuthorityType = "SCEP",
|
||||
status = "active",
|
||||
scepChallenge = challenge,
|
||||
fleetChallenge = "fleet-secret",
|
||||
keyLength = keyLength,
|
||||
signatureAlgorithm = "SHA256withRSA",
|
||||
url = url,
|
||||
)
|
||||
|
||||
@IntegrationTest
|
||||
@Test
|
||||
fun `successful enrollment with real SCEP server`() = runTest {
|
||||
// This test requires a real SCEP server with auto-approval
|
||||
val result = scepClient.enroll(testTemplate)
|
||||
val result = scepClient.enroll(testTemplate, testScepUrl)
|
||||
|
||||
// Verify result structure
|
||||
assertNotNull("Private key should not be null", result.privateKey)
|
||||
|
|
@ -95,8 +74,6 @@ class ScepIntegrationTest {
|
|||
val leafCert = result.certificateChain[0] as java.security.cert.X509Certificate
|
||||
assertEquals("Certificate type should be X.509", "X.509", leafCert.type)
|
||||
assertNotNull("Certificate subject should not be null", leafCert.subjectX500Principal)
|
||||
|
||||
println("Successfully enrolled certificate: ${leafCert.subjectX500Principal.name}")
|
||||
}
|
||||
|
||||
@IntegrationTest
|
||||
|
|
@ -105,11 +82,11 @@ class ScepIntegrationTest {
|
|||
val invalidTemplate = testTemplate.copy(scepChallenge = "invalid-challenge-that-should-fail")
|
||||
|
||||
try {
|
||||
scepClient.enroll(invalidTemplate)
|
||||
scepClient.enroll(invalidTemplate, testScepUrl)
|
||||
fail("Expected ScepEnrollmentException for invalid challenge")
|
||||
} catch (e: ScepEnrollmentException) {
|
||||
// Expected - enrollment should fail with invalid challenge
|
||||
println("Correctly failed with: ${e.message}")
|
||||
assertNotNull("Exception should have a message", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,18 +97,16 @@ class ScepIntegrationTest {
|
|||
|
||||
keySizes.forEach { keySize ->
|
||||
val uniqueId = System.currentTimeMillis()
|
||||
val template = createTemplate(
|
||||
url = testTemplate.url ?: "https://scep.example.com/scep",
|
||||
challenge = testTemplate.scepChallenge ?: "test-challenge",
|
||||
val template = TestCertificateTemplateFactory.create(
|
||||
scepChallenge = testTemplate.scepChallenge ?: "test-challenge",
|
||||
name = "test-cert-$keySize-$uniqueId",
|
||||
subject = "CN=IntegrationTestDevice-$keySize-$uniqueId,O=FleetDM,C=US",
|
||||
subjectName = "CN=IntegrationTestDevice-$keySize-$uniqueId,O=FleetDM,C=US",
|
||||
keyLength = keySize,
|
||||
)
|
||||
|
||||
val result = scepClient.enroll(template)
|
||||
val result = scepClient.enroll(template, testScepUrl)
|
||||
|
||||
assertNotNull(result.privateKey)
|
||||
println("Successfully enrolled with key size: $keySize")
|
||||
assertNotNull("Private key should not be null for key size $keySize", result.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,34 +115,31 @@ class ScepIntegrationTest {
|
|||
fun `enrollment performance test`() = runTest {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val result = scepClient.enroll(testTemplate)
|
||||
val result = scepClient.enroll(testTemplate, testScepUrl)
|
||||
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
|
||||
assertNotNull(result)
|
||||
println("Enrollment completed in ${duration}ms")
|
||||
|
||||
// Typical SCEP enrollment should complete within 30 seconds
|
||||
assertTrue("Enrollment should complete within 30 seconds", duration < 30000)
|
||||
assertTrue("Enrollment should complete within 30 seconds (took ${duration}ms)", duration < 30000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enrollment with unreachable server fails quickly`() = runTest {
|
||||
val unreachableTemplate = testTemplate.copy(
|
||||
url = "https://unreachable-scep-server.invalid/scep",
|
||||
)
|
||||
val unreachableUrl = "https://unreachable-scep-server.invalid/scep"
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
scepClient.enroll(unreachableTemplate)
|
||||
scepClient.enroll(testTemplate, unreachableUrl)
|
||||
fail("Expected ScepNetworkException")
|
||||
} catch (e: ScepNetworkException) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
println("Failed as expected in ${duration}ms: ${e.message}")
|
||||
|
||||
// Should fail within reasonable timeout
|
||||
assertTrue("Should fail within 30 seconds", duration < 30000)
|
||||
assertTrue("Should fail within 30 seconds (took ${duration}ms)", duration < 30000)
|
||||
assertNotNull("Exception should have a message", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package com.fleetdm.agent.testutil
|
||||
|
||||
import com.fleetdm.agent.CertificateApiClient
|
||||
import com.fleetdm.agent.CertificateTemplateResult
|
||||
import com.fleetdm.agent.UpdateCertificateStatusOperation
|
||||
import com.fleetdm.agent.UpdateCertificateStatusStatus
|
||||
|
||||
/**
|
||||
* Represents a captured call to updateCertificateStatus for test assertions.
|
||||
*/
|
||||
data class UpdateStatusCall(
|
||||
val certificateId: Int,
|
||||
val status: UpdateCertificateStatusStatus,
|
||||
val operationType: UpdateCertificateStatusOperation,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fake implementation of CertificateApiClient for testing.
|
||||
* Provides configurable handlers and captures calls for assertions.
|
||||
*/
|
||||
class FakeCertificateApiClient : CertificateApiClient {
|
||||
var getCertificateTemplateHandler: (Int) -> Result<CertificateTemplateResult> = {
|
||||
Result.failure(Exception("getCertificateTemplate not configured"))
|
||||
}
|
||||
var updateCertificateStatusHandler: (UpdateStatusCall) -> Result<Unit> = { Result.success(Unit) }
|
||||
|
||||
private val _updateStatusCalls = mutableListOf<UpdateStatusCall>()
|
||||
val updateStatusCalls: List<UpdateStatusCall> get() = _updateStatusCalls.toList()
|
||||
|
||||
private val _getCertificateTemplateCalls = mutableListOf<Int>()
|
||||
val getCertificateTemplateCalls: List<Int> get() = _getCertificateTemplateCalls.toList()
|
||||
|
||||
override suspend fun getCertificateTemplate(certificateId: Int): Result<CertificateTemplateResult> {
|
||||
_getCertificateTemplateCalls.add(certificateId)
|
||||
return getCertificateTemplateHandler(certificateId)
|
||||
}
|
||||
|
||||
override suspend fun updateCertificateStatus(
|
||||
certificateId: Int,
|
||||
status: UpdateCertificateStatusStatus,
|
||||
operationType: UpdateCertificateStatusOperation,
|
||||
detail: String?,
|
||||
): Result<Unit> {
|
||||
val call = UpdateStatusCall(certificateId, status, operationType, detail)
|
||||
_updateStatusCalls.add(call)
|
||||
return updateCertificateStatusHandler(call)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
getCertificateTemplateHandler = { Result.failure(Exception("getCertificateTemplate not configured")) }
|
||||
updateCertificateStatusHandler = { Result.success(Unit) }
|
||||
_updateStatusCalls.clear()
|
||||
_getCertificateTemplateCalls.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.fleetdm.agent.testutil
|
||||
|
||||
import com.fleetdm.agent.DeviceKeystoreManager
|
||||
|
||||
/**
|
||||
* Fake implementation of DeviceKeystoreManager for testing.
|
||||
* Provides configurable behavior for simulating keystore operations.
|
||||
*/
|
||||
class FakeDeviceKeystoreManager : DeviceKeystoreManager {
|
||||
val installedCerts = mutableSetOf<String>()
|
||||
var removeKeyPairShouldSucceed = true
|
||||
|
||||
override fun hasKeyPair(alias: String): Boolean = alias in installedCerts
|
||||
|
||||
override fun removeKeyPair(alias: String): Boolean {
|
||||
if (!removeKeyPairShouldSucceed) return false
|
||||
installedCerts.remove(alias)
|
||||
return true
|
||||
}
|
||||
|
||||
fun installCert(alias: String) {
|
||||
installedCerts.add(alias)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
installedCerts.clear()
|
||||
removeKeyPairShouldSucceed = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.fleetdm.agent.testutil
|
||||
|
||||
import com.fleetdm.agent.CertificateEnrollmentHandler
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.Certificate
|
||||
|
||||
/**
|
||||
* Mock certificate installer for testing.
|
||||
* Provides configurable behavior for different test scenarios.
|
||||
*/
|
||||
class MockCertificateInstaller : CertificateEnrollmentHandler.CertificateInstaller {
|
||||
var shouldSucceed = true
|
||||
var wasInstallCalled = false
|
||||
var capturedAlias: String? = null
|
||||
var capturedPrivateKey: PrivateKey? = null
|
||||
var capturedCertificateChain: Array<Certificate>? = null
|
||||
|
||||
override fun installCertificate(alias: String, privateKey: PrivateKey, certificateChain: Array<Certificate>): Boolean {
|
||||
wasInstallCalled = true
|
||||
capturedAlias = alias
|
||||
capturedPrivateKey = privateKey
|
||||
capturedCertificateChain = certificateChain
|
||||
return shouldSucceed
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
shouldSucceed = true
|
||||
wasInstallCalled = false
|
||||
capturedAlias = null
|
||||
capturedPrivateKey = null
|
||||
capturedCertificateChain = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fleetdm.agent.testutil
|
||||
|
||||
import com.fleetdm.agent.GetCertificateTemplateResponse
|
||||
|
||||
/**
|
||||
* Factory for creating GetCertificateTemplateResponse instances in tests.
|
||||
* Provides sensible defaults for all fields to reduce boilerplate.
|
||||
*/
|
||||
object TestCertificateTemplateFactory {
|
||||
|
||||
const val DEFAULT_SCEP_URL = "https://scep.example.com/test"
|
||||
|
||||
fun create(
|
||||
id: Int = 1,
|
||||
name: String = "test-cert",
|
||||
certificateAuthorityId: Int = 123,
|
||||
certificateAuthorityName: String = "Test CA",
|
||||
createdAt: String = "2024-01-01T00:00:00Z",
|
||||
subjectName: String = "CN=Test,O=FleetDM",
|
||||
certificateAuthorityType: String = "SCEP",
|
||||
status: String = "active",
|
||||
scepChallenge: String = "test-challenge",
|
||||
fleetChallenge: String = "fleet-secret",
|
||||
keyLength: Int = 2048,
|
||||
signatureAlgorithm: String = "SHA256withRSA",
|
||||
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
|
||||
id = id,
|
||||
name = name,
|
||||
certificateAuthorityId = certificateAuthorityId,
|
||||
certificateAuthorityName = certificateAuthorityName,
|
||||
createdAt = createdAt,
|
||||
subjectName = subjectName,
|
||||
certificateAuthorityType = certificateAuthorityType,
|
||||
status = status,
|
||||
scepChallenge = scepChallenge,
|
||||
fleetChallenge = fleetChallenge,
|
||||
keyLength = keyLength,
|
||||
signatureAlgorithm = signatureAlgorithm,
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,9 @@ flag_management:
|
|||
- name: frontend
|
||||
paths:
|
||||
- frontend/
|
||||
- name: android
|
||||
paths:
|
||||
- android/
|
||||
|
||||
ignore:
|
||||
- "server/mock"
|
||||
|
|
|
|||
Loading…
Reference in a new issue