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:
Victor Lyuboslavsky 2026-01-05 19:17:13 -06:00 committed by GitHub
parent 6e5a306f48
commit b4bb714fa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1759 additions and 907 deletions

View file

@ -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.*")
}
}
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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 ?: ""}"

View file

@ -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")
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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),
),
)
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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:

View file

@ -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" }
}
}

View file

@ -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,
)

View file

@ -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>

View file

@ -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>

View file

@ -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,
)
}

View file

@ -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
}
}

View file

@ -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.
}

View file

@ -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())
}
}

View file

@ -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)
}
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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,
)
}

View file

@ -22,6 +22,9 @@ flag_management:
- name: frontend
paths:
- frontend/
- name: android
paths:
- android/
ignore:
- "server/mock"