Sync app with server vars, fix retry logic (#36923)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #36591

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [ ] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [ ] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [ ] Confirmed that the fix is not expected to adversely impact load
test results
- [ ] Alerted the release DRI if additional load testing is needed

## Database migrations

- [ ] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [ ] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [ ] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled

## fleetd/orbit/Fleet Desktop

- [ ] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [ ] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [ ] Verified that fleetd runs on macOS, Linux and Windows
- [ ] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added automatic retry mechanism for failed certificate installations
with up to 3 retry attempts.
  * Enhanced certificate installation status tracking and visibility.

* **Bug Fixes**
* Improved error handling and detailed error reporting for certificate
enrollment failures.

* **Tests**
* Added comprehensive test coverage for certificate enrollment and
status tracking workflows.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
This commit is contained in:
Dante Catalfamo
2025-12-10 17:50:38 -06:00
committed by GitHub
co-authored by Victor Lyuboslavsky
parent bfd90e9908
commit 7375b88e65
12 changed files with 637 additions and 100 deletions
@@ -19,6 +19,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@@ -146,6 +147,8 @@ object ApiClient {
?: "HTTP $responseCode"
}
Log.d("ApiClient", "server response from $method $endpoint ($responseCode): $response")
if (responseCode in 200..299) {
val parsed = json.decodeFromString(string = response, deserializer = responseSerializer)
Result.success(parsed)
@@ -220,14 +223,14 @@ object ApiClient {
val credentials = getEnrollmentCredentials() ?: return Result.failure(Exception("enroll credentials not set"))
return makeRequest(
endpoint = "/api/fleetd/orbit/certificates/$certificateId",
method = "POST",
endpoint = "/api/fleetd/certificates/$certificateId",
method = "GET",
body = GetCertificateTemplateRequest(orbitNodeKey = orbitNodeKey),
bodySerializer = GetCertificateTemplateRequest.serializer(),
responseSerializer = GetCertificateTemplateResponse.serializer(),
authorized = false,
responseSerializer = GetCertificateTemplateResponseWrapper.serializer(),
).fold(
onSuccess = { res ->
onSuccess = { wrapper ->
val res = wrapper.certificate
Log.i("ApiClient", "successfully retrieved certificate template ${res.id}: ${res.name}")
Result.success(
res.apply {
@@ -436,8 +439,15 @@ private data class UpdateCertificateStatusResponse(
val error: String? = null,
)
@Serializable
data class GetCertificateTemplateResponseWrapper(
@SerialName("certificate")
val certificate: GetCertificateTemplateResponse,
)
@Serializable
data class GetCertificateTemplateResponse(
// CertificateTemplateResponseSummary
@SerialName("id")
val id: Int,
@@ -445,7 +455,7 @@ data class GetCertificateTemplateResponse(
val name: String,
@SerialName("certificate_authority_id")
val certificateAuthorityId: String,
val certificateAuthorityId: Int,
@SerialName("certificate_authority_name")
val certificateAuthorityName: String,
@@ -453,6 +463,7 @@ data class GetCertificateTemplateResponse(
@SerialName("created_at")
val createdAt: String,
// CertificateTemplateResponseFull
@SerialName("subject_name")
val subjectName: String,
@@ -463,20 +474,21 @@ data class GetCertificateTemplateResponse(
val status: String,
@SerialName("scep_challenge")
val scepChallenge: String,
val scepChallenge: String? = "",
@SerialName("fleet_challenge")
val fleetChallenge: String?,
val fleetChallenge: String? = "",
@SerialName("key_length")
@Transient
val keyLength: Int = 2048,
@SerialName("signature_algorithm")
@Transient
val signatureAlgorithm: String = "SHA256withRSA",
var url: String?,
@Transient
var url: String? = null,
) {
fun setUrl(serverUrl: String, hostUUID: String) {
url = "$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,$fleetChallenge"
url = "$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,${fleetChallenge ?: ""}"
}
}
@@ -1,9 +1,12 @@
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.ScepException
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
@@ -34,47 +37,42 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v
/**
* Main enrollment flow: parse config, enroll via SCEP, install certificate.
*/
suspend fun handleEnrollment(config: GetCertificateTemplateResponse): EnrollmentResult {
return try {
// Step 2: Perform SCEP enrollment
val result = performEnrollment(config) ?: return EnrollmentResult.Failure(
reason = "SCEP enrollment failed or returned null",
exception = null,
)
suspend fun handleEnrollment(config: GetCertificateTemplateResponse): EnrollmentResult = try {
// Perform SCEP enrollment
val result = scepClient.enroll(config)
// Step 3: Install certificate
val installed = certificateInstaller.installCertificate(
config.name,
result.privateKey,
result.certificateChain.toTypedArray(),
)
// Install certificate
val installed = certificateInstaller.installCertificate(
config.name,
result.privateKey,
result.certificateChain.toTypedArray(),
)
if (installed) {
EnrollmentResult.Success(config.name)
} else {
EnrollmentResult.Failure("Certificate installation failed")
}
} catch (e: IllegalArgumentException) {
EnrollmentResult.Failure("Invalid configuration: ${e.message}", e)
} catch (e: Exception) {
EnrollmentResult.Failure("Unexpected error: ${e.message}", e)
if (installed) {
EnrollmentResult.Success(config.name)
} else {
EnrollmentResult.Failure("Certificate installation failed")
}
}
/**
* Performs SCEP enrollment, returning result or null on failure.
*/
@Suppress("SwallowedException")
suspend fun performEnrollment(config: GetCertificateTemplateResponse): ScepResult? = try {
scepClient.enroll(config)
} catch (e: ScepEnrollmentException) {
// Enrollment failure is expected in some scenarios (pending approval, invalid challenge)
null
} catch (e: ScepException) {
// SCEP protocol errors are expected in some scenarios
null
// SCEP server rejected enrollment (e.g., PENDING status, invalid challenge)
EnrollmentResult.Failure("SCEP enrollment failed: ${e.message}", e)
} catch (e: ScepNetworkException) {
// Network communication failure - likely transient, can retry
EnrollmentResult.Failure("Network error during SCEP enrollment: ${e.message}", e)
} catch (e: ScepCertificateException) {
// Certificate validation or processing failed
EnrollmentResult.Failure("Certificate validation failed: ${e.message}", e)
} catch (e: ScepKeyGenerationException) {
// Key generation failed - device cryptography issue
EnrollmentResult.Failure("Failed to generate key pair: ${e.message}", e)
} catch (e: ScepCsrException) {
// CSR creation failed - likely configuration issue
EnrollmentResult.Failure("Failed to create CSR: ${e.message}", e)
} catch (e: IllegalArgumentException) {
// Configuration validation failed
EnrollmentResult.Failure("Invalid configuration: ${e.message}", e)
} catch (e: Exception) {
// Unexpected errors are logged by the SCEP client
null
// Unexpected errors
EnrollmentResult.Failure("Unexpected error during enrollment: ${e.message}", e)
}
}
@@ -12,12 +12,17 @@ import java.security.PrivateKey
import java.security.cert.Certificate
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
const val MAX_CERT_INSTALL_RETRIES = 3
/**
* Orchestrates certificate enrollment operations by coordinating API calls,
* SCEP enrollment, and certificate installation.
@@ -60,6 +65,17 @@ 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]
Log.d("installedCertsFlow", "json: $jsonStr")
json.decodeFromString(jsonStr!!)
} catch (e: Exception) {
Log.d("installedCertsFlow", e.toString())
emptyMap()
}
}
/**
* Reads certificate IDs from Android Managed Configuration.
*
@@ -70,8 +86,8 @@ object CertificateOrchestrator {
val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager
val appRestrictions = restrictionsManager.applicationRestrictions
val certRequestList = appRestrictions.getParcelableArray("certificates", Bundle::class.java)?.toList()
return certRequestList?.map { bundle -> bundle.getInt("certificate_id") }
val certRequestList = appRestrictions.getParcelableArray("certificate_templates", Bundle::class.java)?.toList()
return certRequestList?.map { bundle -> bundle.getInt("id") }
}
/**
@@ -80,7 +96,7 @@ object CertificateOrchestrator {
* @param context Android context
* @return Map of certificate ID to alias, or empty map if none stored
*/
internal suspend fun getInstalledCertificates(context: Context): Map<Int, String> {
internal suspend fun getCertificateInstallInfos(context: Context): CertStatusMap {
certificateStorageMutex.withLock {
return try {
val prefs = context.prefDataStore.data.first()
@@ -91,7 +107,7 @@ object CertificateOrchestrator {
return emptyMap()
}
val map = json.decodeFromString<Map<Int, String>>(jsonString)
val map = json.decodeFromString<CertStatusMap>(jsonString)
Log.d(TAG, "Loaded ${map.size} installed certificate(s) from DataStore")
map
} catch (e: Exception) {
@@ -101,6 +117,38 @@ object CertificateOrchestrator {
}
}
internal suspend fun getCertificateInstallInfo(context: Context, certificateId: Int): CertificateInstallInfo? {
val certs = getCertificateInstallInfos(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)
val newInfo = existingInfo.copy(alias = alias, status = CertificateInstallStatus.INSTALLED, retries = 0)
storeCertificateInstallationInfo(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)
if (existingInfo.status != CertificateInstallStatus.RETRY) {
return existingInfo
}
var newInfo = existingInfo.copy(retries = existingInfo.retries + 1)
if (newInfo.retries >= MAX_CERT_INSTALL_RETRIES) {
newInfo = newInfo.copy(status = CertificateInstallStatus.FAILED)
}
storeCertificateInstallationInfo(context = context, certificateId = certificateId, newInfo)
return newInfo
}
/**
* Stores a certificate IDalias mapping in DataStore after successful installation.
* This performs a read-modify-write operation to update the map.
@@ -109,7 +157,7 @@ object CertificateOrchestrator {
* @param certificateId Certificate template ID
* @param alias Certificate alias used during installation
*/
internal suspend fun storeCertificateInstallation(context: Context, certificateId: Int, alias: String) {
internal suspend fun storeCertificateInstallationInfo(context: Context, certificateId: Int, certInstallInfo: CertificateInstallInfo) {
certificateStorageMutex.withLock {
try {
context.prefDataStore.edit { preferences ->
@@ -117,7 +165,7 @@ object CertificateOrchestrator {
val existingJsonString = preferences[INSTALLED_CERTIFICATES_KEY]
val existingMap = if (existingJsonString != null) {
try {
json.decodeFromString<Map<Int, String>>(existingJsonString)
json.decodeFromString<CertStatusMap>(existingJsonString)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse existing certificates JSON, starting fresh: ${e.message}")
emptyMap()
@@ -128,14 +176,14 @@ object CertificateOrchestrator {
// Add new mapping
val updatedMap = existingMap.toMutableMap().apply {
put(certificateId, alias)
put(certificateId, certInstallInfo)
}
// Serialize and store
val updatedJsonString = json.encodeToString(updatedMap)
preferences[INSTALLED_CERTIFICATES_KEY] = updatedJsonString
Log.d(TAG, "Stored certificate mapping: $certificateId$alias (total: ${updatedMap.size})")
Log.d(TAG, "Stored certificate mapping: $certificateId${certInstallInfo.alias} (total: ${updatedMap.size})")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to store certificate installation: ${e.message}", e)
@@ -152,10 +200,10 @@ object CertificateOrchestrator {
* @return Certificate alias if previously installed, null otherwise
*/
internal suspend fun getCertificateAlias(context: Context, certificateId: Int): String? {
val installedCerts = getInstalledCertificates(context)
val alias = installedCerts[certificateId]
Log.d(TAG, "Certificate $certificateId alias lookup: ${alias ?: "not found"}")
return alias
val installedCerts = getCertificateInstallInfos(context)
val status = installedCerts[certificateId]
Log.d(TAG, "Certificate $certificateId alias lookup: ${status?.alias ?: "not found"}")
return status?.alias
}
/**
@@ -218,14 +266,22 @@ object CertificateOrchestrator {
): CertificateEnrollmentHandler.EnrollmentResult {
Log.d(TAG, "Starting certificate enrollment for certificate ID: $certificateId")
// Step 1: Check if certificate is already installed (BEFORE API call)
// 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)
}
// Step 2: Fetch certificate template from API (only if not already installed)
// 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)
}
// Fetch certificate template from API (only if not already installed)
val templateResult = ApiClient.getCertificateTemplate(certificateId)
val template = templateResult.getOrElse { error ->
Log.e(TAG, "Failed to fetch certificate template for ID $certificateId: ${error.message}", error)
@@ -247,7 +303,7 @@ object CertificateOrchestrator {
)
// Step 5: Perform enrollment
Log.d(TAG, "Starting SCEP enrollment for certificate: ${template.name}")
Log.d(TAG, "Starting SCEP enrollment for certificate: ${template.name}: $template")
val result = handler.handleEnrollment(template)
when (result) {
@@ -261,16 +317,19 @@ object CertificateOrchestrator {
}
// Store certificate installation in DataStore
storeCertificateInstallation(context, certificateId, result.alias)
markCertificateInstalled(context, certificateId = certificateId, alias = template.name)
}
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
Log.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception)
ApiClient.updateCertificateStatus(
certificateId = certificateId,
status = "failed",
detail = result.reason,
).onFailure { error ->
Log.e(TAG, "Failed to update certificate status to failed for ID $certificateId: ${error.message}", error)
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(
certificateId = certificateId,
status = "failed",
detail = result.reason,
).onFailure { error ->
Log.e(TAG, "Failed to update certificate status to failed for ID $certificateId: ${error.message}", error)
}
}
}
}
@@ -334,3 +393,29 @@ object CertificateOrchestrator {
}
}
}
typealias CertStatusMap = Map<Int, CertificateInstallInfo>
@Serializable
enum class CertificateInstallStatus {
@SerialName("installed")
INSTALLED,
@SerialName("failed")
FAILED,
@SerialName("retry")
RETRY,
}
@Serializable
data class CertificateInstallInfo(
@SerialName("alias")
val alias: String,
@SerialName("status")
val status: CertificateInstallStatus,
@SerialName("retries")
val retries: Int = 0,
) {
fun shouldRetry(): Boolean = status == CertificateInstallStatus.RETRY && retries < (MAX_CERT_INSTALL_RETRIES)
}
@@ -36,11 +36,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.stringPreferencesKey
import com.fleetdm.agent.ui.theme.MyApplicationTheme
import java.security.KeyStore
import java.security.cert.X509Certificate
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -62,10 +64,7 @@ class MainActivity : ComponentActivity() {
}
val androidID by remember { mutableStateOf(Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)) }
val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("host_uuid")) }
val certRequestList by remember {
mutableStateOf(appRestrictions.getParcelableArray("certificates", Bundle::class.java)?.toList())
}
val certIds by remember { mutableStateOf(certRequestList?.map { bundle -> bundle.getInt("certificate_id") }) }
val certIds by remember { mutableStateOf(CertificateOrchestrator.getCertificateIDs(this)) }
val permissionsList by remember {
val grantedPermissions = mutableListOf<String>()
val packageInfo: PackageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS)
@@ -88,6 +87,7 @@ class MainActivity : ComponentActivity() {
var installedCertificates: List<CertificateInfo> by remember { mutableStateOf(listOf()) }
val apiKey by ApiClient.apiKeyFlow.collectAsState(initial = null)
val baseUrl by ApiClient.baseUrlFlow.collectAsState(initial = null)
val allegedInstalledCerts by CertificateOrchestrator.installedCertsFlow(this).collectAsState(initial = "")
LaunchedEffect(Unit) {
installedCertificates = listKeystoreCertificates()
@@ -112,7 +112,8 @@ class MainActivity : ComponentActivity() {
KeyValue("server_url (MC)", fleetBaseUrl)
KeyValue("orbit_node_key (datastore)", apiKey)
KeyValue("base_url (datastore)", baseUrl)
KeyValue("certificate_ids", certIds.toString())
KeyValue("certificate_templates->id", certIds.toString())
KeyValue("alleged_installed", allegedInstalledCerts.toString())
PermissionList(
permissionsList = permissionsList,
)
@@ -1,5 +1,6 @@
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
@@ -32,6 +33,8 @@ 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
@@ -81,7 +84,7 @@ class ScepClientImpl : ScepClient {
val client = Client(server, verifier)
// Step 5: Build Certificate Signing Request (CSR)
val csr = buildCsr(entity, keyPair, config.scepChallenge, config.signatureAlgorithm)
val csr = buildCsr(entity, keyPair, config.scepChallenge ?: "", config.signatureAlgorithm)
// Step 6: Send enrollment request
val response = try {
+6 -6
View File
@@ -5,12 +5,12 @@
<string name="enroll_secret_description">Secret used to enroll in a fleet instance</string>
<string name="server_url_title">Fleet Base URL</string>
<string name="server_url_description">The base URL of the fleet server</string>
<string name="certificates_title">Certificates</string>
<string name="certificates_description">Array of bundles containing certificate information</string>
<string name="certificate_title">Certificate</string>
<string name="certificate_description">Certificate information</string>
<string name="certificate_id_title">Certificate ID</string>
<string name="certificate_id_description">Certificate ID to be requested</string>
<string name="certificate_templates_title">Certificates templates</string>
<string name="certificate_templates_description">Array of bundles containing certificate template information</string>
<string name="certificate_template_title">Certificate template</string>
<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="host_uuid_title">Host UUID</string>
<string name="host_uuid_description">The host UUID to present to fleet during enrollment</string>
</resources>
@@ -20,19 +20,19 @@
android:description="@string/host_uuid_description" />
<restriction
android:key="certificates"
android:title="@string/certificates_title"
android:description="@string/certificates_description"
android:key="certificate_templates"
android:title="@string/certificate_templates_title"
android:description="@string/certificate_templates_description"
android:restrictionType="bundle_array" >
<restriction
android:key="certificate"
android:title="@string/certificate_title"
android:description="@string/certificate_description"
android:key="certificate_template"
android:title="@string/certificate_template_title"
android:description="@string/certificate_template_description"
android:restrictionType="bundle">
<restriction
android:key="certificate_id"
android:title="@string/certificate_id_title"
android:description="@string/certificate_id_description"
android:key="id"
android:title="@string/certificate_template_id_title"
android:description="@string/certificate_template_id_description"
android:restrictionType="integer" />
</restriction>
</restriction>
@@ -181,7 +181,7 @@ class CertificateEnrollmentHandlerTest {
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
id = id,
name = name,
certificateAuthorityId = "ca-123",
certificateAuthorityId = 123,
certificateAuthorityName = "Test CA",
createdAt = "2024-01-01T00:00:00Z",
subjectName = subjectName,
@@ -0,0 +1,438 @@
package com.fleetdm.agent
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.fleetdm.agent.scep.MockScepClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.security.PrivateKey
import java.security.cert.Certificate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
/**
* Unit tests for CertificateOrchestrator with DataStore-based certificate tracking.
*
* Tests:
* - DataStore certificate tracking (JSON storage)
* - Mutex protection for concurrent operations
* - Optimized API call avoidance
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33]) // Target SDK 33 for testing
class CertificateOrchestratorTest {
private lateinit var context: Context
private lateinit var mockScepClient: MockScepClient
private lateinit var mockInstaller: MockCertificateInstaller
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Before
fun setup() = runTest {
context = RuntimeEnvironment.getApplication()
mockScepClient = MockScepClient()
mockInstaller = MockCertificateInstaller()
// Clear DataStore before each test
clearDataStore()
}
@After
fun tearDown() = runTest {
clearDataStore()
mockScepClient.reset()
mockInstaller.reset()
}
// ========== Helper Functions ==========
private suspend fun clearDataStore() {
context.prefDataStore.edit { preferences ->
preferences.clear()
}
}
private suspend fun getStoredCertificates(): CertStatusMap {
val prefs = context.prefDataStore.data.first()
val jsonString = prefs[stringPreferencesKey("installed_certificates")] ?: return emptyMap()
return json.decodeFromString<CertStatusMap>(jsonString)
}
private suspend fun storeTestCertificateInDataStore(
certificateId: Int,
alias: String,
status: CertificateInstallStatus = CertificateInstallStatus.INSTALLED,
retries: Int = 0,
) {
context.prefDataStore.edit { preferences ->
val existing = preferences[stringPreferencesKey("installed_certificates")]?.let {
json.decodeFromString<CertStatusMap>(it)
} ?: emptyMap()
val certInfo = CertificateInstallInfo(alias, status, retries)
val updated = existing.toMutableMap().apply {
put(certificateId, certInfo)
}
val jsonString = json.encodeToString(updated)
preferences[stringPreferencesKey("installed_certificates")] = jsonString
}
}
// ========== Mock Certificate Installer ==========
class MockCertificateInstaller : CertificateEnrollmentHandler.CertificateInstaller {
var shouldSucceed = true
var wasInstallCalled = false
var capturedAlias: String? = null
val installedCertificates = mutableSetOf<String>()
override fun installCertificate(alias: String, privateKey: PrivateKey, certificateChain: Array<Certificate>): Boolean {
wasInstallCalled = true
capturedAlias = alias
if (shouldSucceed) {
installedCertificates.add(alias)
}
return shouldSucceed
}
fun hasKeyPair(alias: String): Boolean = installedCertificates.contains(alias)
fun reset() {
shouldSucceed = true
wasInstallCalled = false
capturedAlias = null
installedCertificates.clear()
}
}
// ========== Test Category 1: DataStore Certificate Tracking ==========
@Test
fun `storeCertificateInstallation stores certificate in DataStore`() = runTest {
// Act
CertificateOrchestrator.markCertificateInstalled(context, 123, "test-cert-1")
// Assert
val stored = getStoredCertificates()
assertEquals(1, stored.size)
assertEquals("test-cert-1", stored[123]?.alias)
}
@Test
fun `storeCertificateInstallation handles multiple certificates`() = runTest {
// Act
CertificateOrchestrator.markCertificateInstalled(context, 123, "cert-1")
CertificateOrchestrator.markCertificateInstalled(context, 456, "cert-2")
CertificateOrchestrator.markCertificateInstalled(context, 789, "cert-3")
// Assert
val stored = getStoredCertificates()
assertEquals(3, stored.size)
assertEquals("cert-1", stored[123]?.alias)
assertEquals("cert-2", stored[456]?.alias)
assertEquals("cert-3", stored[789]?.alias)
}
@Test
fun `storeCertificateInstallation updates existing certificate`() = runTest {
// Arrange
CertificateOrchestrator.markCertificateInstalled(context, 123, "old-alias")
// Act - Update the same certificate ID
CertificateOrchestrator.markCertificateInstalled(context, 123, "new-alias")
// Assert
val stored = getStoredCertificates()
assertEquals(1, stored.size) // Should not duplicate
assertEquals("new-alias", stored[123]?.alias) // Should be updated
}
@Test
fun `getCertificateAlias returns null for non-existent certificate`() = runTest {
// Act
val alias = CertificateOrchestrator.getCertificateAlias(context, 999)
// Assert
assertNull(alias)
}
@Test
fun `getCertificateAlias retrieves stored certificate`() = runTest {
// Arrange
CertificateOrchestrator.markCertificateInstalled(context, 456, "my-cert")
// Act
val alias = CertificateOrchestrator.getCertificateAlias(context, 456)
// Assert
assertEquals("my-cert", alias)
}
@Test
fun `getInstalledCertificates returns empty map when DataStore is empty`() = runTest {
// Act
val certificates = CertificateOrchestrator.getCertificateInstallInfos(context)
// Assert
assertTrue(certificates.isEmpty())
}
@Test
fun `getInstalledCertificates recovers from malformed JSON`() = runTest {
// Arrange: Manually corrupt DataStore with invalid JSON
context.prefDataStore.edit { preferences ->
preferences[stringPreferencesKey("installed_certificates")] = "{ invalid json }"
}
// Act: Should not throw, returns empty map
val certificates = CertificateOrchestrator.getCertificateInstallInfos(context)
// Assert
assertTrue(certificates.isEmpty())
// Verify we can still store new certificates after recovery
CertificateOrchestrator.markCertificateInstalled(context, 111, "recovered-cert")
val stored = getStoredCertificates()
assertEquals(1, stored.size)
assertEquals("recovered-cert", stored[111]?.alias)
}
// ========== Test Category 2: Optimized API Call Avoidance ==========
@Ignore("Requires DevicePolicyManager mocking - TODO: redesign test or add DI")
@Test
fun `isCertificateIdInstalled returns true when certificate tracked and in keystore`() = runTest {
// Arrange
val certificateId = 123
val alias = "device-cert"
storeTestCertificateInDataStore(certificateId, alias)
mockInstaller.installedCertificates.add(alias)
// Act
val result = CertificateOrchestrator.isCertificateIdInstalled(context, certificateId)
// Assert
assertTrue(result)
}
@Test
fun `isCertificateIdInstalled returns false when certificate not in DataStore`() = runTest {
// Act
val result = CertificateOrchestrator.isCertificateIdInstalled(context, 999)
// Assert
assertFalse(result)
}
@Test
fun `isCertificateIdInstalled returns false when certificate tracked but missing from keystore`() = runTest {
// Arrange: Store in DataStore but not in keystore
val certificateId = 456
val alias = "missing-cert"
storeTestCertificateInDataStore(certificateId, alias)
// Don't add to mockInstaller.installedCertificates
// Note: isCertificateInstalled() uses real DevicePolicyManager, not mockInstaller
// So this test verifies DataStore logic only. The keystore check will return false
// because the certificate doesn't actually exist in Robolectric's shadow DPM.
// Act
val result = CertificateOrchestrator.isCertificateIdInstalled(context, certificateId)
// Assert
assertFalse(result)
}
// ========== Test Category 3: Mutex Protection (Concurrency) ==========
@Test
fun `concurrent certificate storage does not lose data`() = runTest {
// Arrange: 10 different certificate IDs
val certificateIds = (1..10).toList()
// Act: Store all in parallel
val jobs = certificateIds.map { certId ->
launch {
CertificateOrchestrator.markCertificateInstalled(
context,
certId,
"cert-$certId",
)
}
}
jobs.forEach { it.join() }
// Assert: All 10 certificates should be stored
val stored = getStoredCertificates()
assertEquals("All 10 certificates should be stored", 10, stored.size)
// Verify each certificate is present
certificateIds.forEach { certId ->
assertEquals("cert-$certId", stored[certId]?.alias)
}
}
@Test
fun `rapid sequential certificate storage preserves all data`() = runTest {
// Act: Store 5 certificates rapidly in sequence
repeat(5) { index ->
CertificateOrchestrator.markCertificateInstalled(context, index * 100, "cert-$index")
}
// Assert: All 5 should be stored
val stored = getStoredCertificates()
assertEquals(5, stored.size)
repeat(5) { index ->
assertEquals("cert-$index", stored[index * 100]?.alias)
}
}
@Test
fun `concurrent reads during writes see consistent data`() = runTest {
// Arrange: Pre-populate with some certificates
CertificateOrchestrator.markCertificateInstalled(context, 1, "cert-1")
CertificateOrchestrator.markCertificateInstalled(context, 2, "cert-2")
// Act: Concurrent write and read
val writeJob = launch {
CertificateOrchestrator.markCertificateInstalled(context, 3, "cert-3")
}
val readJob = launch {
val certificates = CertificateOrchestrator.getCertificateInstallInfos(context)
// Should see either 2 or 3 certificates (before or after write), but data should be consistent
assertTrue(certificates.size >= 2)
}
writeJob.join()
readJob.join()
// Assert: Final state should have all 3
val stored = getStoredCertificates()
assertEquals(3, stored.size)
}
// ========== Test Category 4: Integration Tests ==========
@Test
fun `full enrollment flow stores certificate in DataStore after success`() = runTest {
// Note: This test is limited because we can't easily mock ApiClient (it's an object)
// Instead, we verify that if enrollment succeeds, DataStore storage happens
// We'll test this by verifying the storeCertificateInstallation call happens
// after a successful mock enrollment via the handler directly
val template = createMockTemplate(123, "test-cert")
// Create handler with mock client and installer
val handler = CertificateEnrollmentHandler(
scepClient = mockScepClient,
certificateInstaller = mockInstaller,
)
// Act: Perform enrollment
val result = handler.handleEnrollment(template)
// Assert: Enrollment succeeded
assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Success)
// Manually verify the pattern - orchestrator would call storeCertificateInstallation
val alias = (result as CertificateEnrollmentHandler.EnrollmentResult.Success).alias
CertificateOrchestrator.markCertificateInstalled(context, 123, alias)
// Verify it was stored
val storedAlias = CertificateOrchestrator.getCertificateAlias(context, 123)
assertNotNull(storedAlias)
assertEquals(alias, storedAlias)
}
@Test
fun `failed enrollment does not store in DataStore`() = runTest {
// Arrange: Make SCEP enrollment fail
mockScepClient.shouldThrowEnrollmentException = true
val template = createMockTemplate(456, "failing-cert")
val handler = CertificateEnrollmentHandler(
scepClient = mockScepClient,
certificateInstaller = mockInstaller,
)
// Act
val result = handler.handleEnrollment(template)
// Assert: Enrollment failed
assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure)
// Verify nothing was stored (orchestrator wouldn't call store on failure)
val stored = getStoredCertificates()
assertTrue(stored.isEmpty())
}
@Test
fun `enrollment with custom installer uses provided installer`() = runTest {
// This test verifies the dependency injection pattern works
val customInstaller = MockCertificateInstaller()
val template = createMockTemplate(789, "custom-cert")
val handler = CertificateEnrollmentHandler(
scepClient = mockScepClient,
certificateInstaller = customInstaller,
)
// Act
handler.handleEnrollment(template)
// Assert: Custom installer was used
assertTrue(customInstaller.wasInstallCalled)
assertEquals("custom-cert", customInstaller.capturedAlias)
// Original installer was not used
assertFalse(mockInstaller.wasInstallCalled)
}
// ========== Helper Methods for Tests ==========
private fun createMockTemplate(
id: Int,
name: String,
url: String = "https://scep.example.com/scep",
challenge: String = "test-challenge",
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
id = id,
name = name,
certificateAuthorityId = 123,
certificateAuthorityName = "Test CA",
createdAt = "2024-01-01T00:00:00Z",
subjectName = "CN=$name,O=FleetDM",
certificateAuthorityType = "SCEP",
status = "active",
scepChallenge = challenge,
fleetChallenge = "fleet-secret",
keyLength = 2048,
signatureAlgorithm = "SHA256withRSA",
url = url,
)
}
@@ -69,7 +69,7 @@ class ScepClientImplTest {
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
id = 1,
name = "test-cert",
certificateAuthorityId = "ca-123",
certificateAuthorityId = 123,
certificateAuthorityName = "Test CA",
createdAt = "2024-01-01T00:00:00Z",
subjectName = subjectName,
@@ -64,7 +64,7 @@ class ScepIntegrationTest {
): GetCertificateTemplateResponse = GetCertificateTemplateResponse(
id = 1,
name = name,
certificateAuthorityId = "ca-123",
certificateAuthorityId = 123,
certificateAuthorityName = "Test CA",
createdAt = "2024-01-01T00:00:00Z",
subjectName = subject,
@@ -122,7 +122,7 @@ class ScepIntegrationTest {
val uniqueId = System.currentTimeMillis()
val template = createTemplate(
url = testTemplate.url ?: "https://scep.example.com/scep",
challenge = testTemplate.scepChallenge,
challenge = testTemplate.scepChallenge ?: "test-challenge",
name = "test-cert-$keySize-$uniqueId",
subject = "CN=IntegrationTestDevice-$keySize-$uniqueId,O=FleetDM,C=US",
keyLength = keySize,