From 6628b63dec3e5a9d4ff07a280678dbe68d595dd0 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:19:52 -0500 Subject: [PATCH] Integrating scep client code (#36599) --- android/app/detekt.yml | 3 + android/app/src/main/AndroidManifest.xml | 5 - .../com/fleetdm/agent/AgentApplication.kt | 33 ++-- .../main/java/com/fleetdm/agent/ApiClient.kt | 88 ++++++++- .../java/com/fleetdm/agent/BootReceiver.kt | 38 ++-- .../agent/CertificateEnrollmentHandler.kt | 28 +-- .../agent/CertificateEnrollmentWorker.kt | 96 ++++++++++ .../fleetdm/agent/CertificateOrchestrator.kt | 168 ++++++++++++++++++ .../com/fleetdm/agent/CertificateService.kt | 103 ----------- .../com/fleetdm/agent/ConfigCheckWorker.kt | 29 --- .../java/com/fleetdm/agent/MainActivity.kt | 12 +- .../java/com/fleetdm/agent/scep/ScepClient.kt | 4 +- .../com/fleetdm/agent/scep/ScepClientImpl.kt | 10 +- .../app/src/main/res/xml/app_restrictions.xml | 6 +- .../agent/CertificateEnrollmentHandlerTest.kt | 133 +++++--------- .../fleetdm/agent/ConfigCheckWorkerTest.kt | 36 ---- .../com/fleetdm/agent/scep/MockScepClient.kt | 7 +- .../fleetdm/agent/scep/ScepClientImplTest.kt | 47 +++-- .../fleetdm/agent/scep/ScepIntegrationTest.kt | 51 ++++-- 19 files changed, 530 insertions(+), 367 deletions(-) create mode 100644 android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt create mode 100644 android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt delete mode 100644 android/app/src/main/java/com/fleetdm/agent/CertificateService.kt delete mode 100644 android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt delete mode 100644 android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt diff --git a/android/app/detekt.yml b/android/app/detekt.yml index 5abbd3c11d..a19ae50336 100644 --- a/android/app/detekt.yml +++ b/android/app/detekt.yml @@ -12,6 +12,9 @@ style: active: false # Color hex values are standard in Compose MaxLineLength: active: false # Handled by ktlint + ReturnCount: + active: true + max: 5 complexity: CognitiveComplexMethod: diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fc1cd01601..2dbd16ac2e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -55,11 +55,6 @@ android:value="" /> - - - diff --git a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt index 379c4a861e..a85060a459 100644 --- a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt +++ b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt @@ -5,7 +5,9 @@ import android.content.Context import android.content.RestrictionsManager import android.os.Build import android.util.Log +import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit @@ -21,7 +23,6 @@ import kotlinx.coroutines.launch class AgentApplication : Application() { companion object { private const val TAG = "fleet-app" - private const val CONFIG_CHECK_WORK_NAME = "config_check_periodic" } private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -31,7 +32,7 @@ class AgentApplication : Application() { Log.i(TAG, "Fleet agent process started") ApiClient.initialize(this) refreshEnrollmentCredentials() - schedulePeriodicConfigCheck() + schedulePeriodicCertificateEnrollment() } private fun refreshEnrollmentCredentials() { @@ -41,9 +42,9 @@ class AgentApplication : Application() { as? RestrictionsManager val appRestrictions = restrictionsManager?.applicationRestrictions ?: return@launch - val enrollSecret = appRestrictions.getString("enrollSecret") - val hostUUID = appRestrictions.getString("hostUUID") - val serverURL = appRestrictions.getString("serverURL") + val enrollSecret = appRestrictions.getString("enroll_secret") + val hostUUID = appRestrictions.getString("host_uuid") + val serverURL = appRestrictions.getString("server_url") if (enrollSecret != null && hostUUID != null && serverURL != null) { Log.d(TAG, "Refreshing enrollment credentials from MDM config") @@ -71,21 +72,23 @@ class AgentApplication : Application() { } } - private fun schedulePeriodicConfigCheck() { - val workRequest = - PeriodicWorkRequestBuilder( - 15, // 15 is the minimum - TimeUnit.MINUTES, - ).build() + private fun schedulePeriodicCertificateEnrollment() { + val workRequest = PeriodicWorkRequestBuilder( + 15, // 15 minutes is the minimum + TimeUnit.MINUTES, + ).setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ).build() - WorkManager - .getInstance(this) + WorkManager.getInstance(this) .enqueueUniquePeriodicWork( - CONFIG_CHECK_WORK_NAME, + CertificateEnrollmentWorker.WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest, ) - Log.i(TAG, "Scheduled periodic config check every 15 minutes") + Log.i(TAG, "Scheduled periodic certificate enrollment every 15 minutes") } } diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt index 0416fa9fb9..89dae43b07 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt @@ -22,7 +22,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -private val Context.credentialStore: DataStore by preferencesDataStore(name = "api_credentials") +val Context.prefDataStore: DataStore by preferencesDataStore(name = "pref_datastore") object ApiClient { private val json = Json { ignoreUnknownKeys = true } @@ -39,7 +39,7 @@ object ApiClient { fun initialize(context: Context) { Log.d("fleet-apiClient", "initializing api client") if (!::dataStore.isInitialized) { - dataStore = context.applicationContext.credentialStore + dataStore = context.applicationContext.prefDataStore } } @@ -200,6 +200,39 @@ object ApiClient { } } + suspend fun getCertificateTemplate(certificateId: Int): Result { + val nodeKeyResult = getNodeKeyOrEnroll() + val orbitNodeKey = nodeKeyResult.getOrElse { error -> + return Result.failure(error) + } + + val credentials = getEnrollmentCredentials() ?: return Result.failure(Exception("enroll credentials not set")) + + return makeRequest( + endpoint = "/api/fleetd/orbit/certificates/$certificateId", + method = "POST", + body = GetCertificateTemplateRequest(orbitNodeKey = orbitNodeKey), + bodySerializer = GetCertificateTemplateRequest.serializer(), + responseSerializer = GetCertificateTemplateResponse.serializer(), + ).fold( + onSuccess = { res -> + Log.i("ApiClient", "successfully retrieved certificate template ${res.id}: ${res.name}") + Result.success( + res.apply { + setUrl( + serverUrl = credentials.baseUrl, + hostUUID = credentials.hardwareUUID, + ) + }, + ) + }, + onFailure = { throwable -> + Log.e("ApiClient", "failed to get certificate template $certificateId") + Result.failure(throwable) + }, + ) + } + private suspend fun getEnrollmentCredentials(): EnrollmentCredentials? { val prefs = dataStore.data.first() val enrollSecret = prefs[ENROLL_SECRET] @@ -348,3 +381,54 @@ data class OrbitUpdateChannels( @SerialName("desktop") val desktop: String = "", ) + +@Serializable +private data class GetCertificateTemplateRequest( + @SerialName("orbit_node_key") + val orbitNodeKey: String, +) + +@Serializable +data class GetCertificateTemplateResponse( + @SerialName("id") + val id: Int, + + @SerialName("name") + val name: String, + + @SerialName("certificate_authority_id") + val certificateAuthorityId: String, + + @SerialName("certificate_authority_name") + val certificateAuthorityName: String, + + @SerialName("created_at") + val createdAt: String, + + @SerialName("subject_name") + val subjectName: String, + + @SerialName("certificate_authority_type") + val certificateAuthorityType: String, + + @SerialName("status") + val status: String, + + @SerialName("scep_challenge") + val scepChallenge: String, + + @SerialName("fleet_challenge") + val fleetChallenge: String?, + + @SerialName("key_length") + val keyLength: Int = 2048, + + @SerialName("signature_algorithm") + val signatureAlgorithm: String = "SHA256withRSA", + + var url: String?, +) { + fun setUrl(serverUrl: String, hostUUID: String) { + url = "$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,$fleetChallenge" + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/BootReceiver.kt b/android/app/src/main/java/com/fleetdm/agent/BootReceiver.kt index d542c2f4d5..f4f6527bd5 100644 --- a/android/app/src/main/java/com/fleetdm/agent/BootReceiver.kt +++ b/android/app/src/main/java/com/fleetdm/agent/BootReceiver.kt @@ -4,6 +4,11 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager class BootReceiver : BroadcastReceiver() { companion object { @@ -12,27 +17,26 @@ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { - Log.i(TAG, "Device boot completed. Initializing Fleet Agent.") + Log.i(TAG, "Device boot completed. Triggering certificate enrollment.") context?.let { - // Check for any pending certificate operations or managed configurations - // that may need to be processed after boot - val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager - val appRestrictions = restrictionsManager.applicationRestrictions + // Trigger immediate certificate enrollment on boot + val workRequest = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() - val certData = appRestrictions.getString("certificate_data") + WorkManager.getInstance(it) + .enqueueUniqueWork( + "${CertificateEnrollmentWorker.WORK_NAME}_boot", + ExistingWorkPolicy.REPLACE, // Run fresh enrollment on boot + workRequest, + ) - if (!certData.isNullOrEmpty()) { - Log.d(TAG, "Found certificate data after boot. Processing installation.") - - // Start the service to handle the installation - val serviceIntent = Intent(it, CertificateService::class.java).apply { - putExtra("CERT_DATA", certData) - } - it.startService(serviceIntent) - } else { - Log.d(TAG, "No pending certificate operations after boot.") - } + Log.d(TAG, "Scheduled certificate enrollment after boot") } } } diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt index bca843b1bc..ef1c713fe8 100644 --- a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt @@ -34,11 +34,8 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v /** * Main enrollment flow: parse config, enroll via SCEP, install certificate. */ - suspend fun handleEnrollment(certDataJson: String): EnrollmentResult { + suspend fun handleEnrollment(config: GetCertificateTemplateResponse): EnrollmentResult { return try { - // Step 1: Parse configuration - val config = parseScepConfig(certDataJson) - // Step 2: Perform SCEP enrollment val result = performEnrollment(config) ?: return EnrollmentResult.Failure( reason = "SCEP enrollment failed or returned null", @@ -47,13 +44,13 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v // Step 3: Install certificate val installed = certificateInstaller.installCertificate( - config.alias, + config.name, result.privateKey, result.certificateChain.toTypedArray(), ) if (installed) { - EnrollmentResult.Success(config.alias) + EnrollmentResult.Success(config.name) } else { EnrollmentResult.Failure("Certificate installation failed") } @@ -64,28 +61,11 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v } } - /** - * Parses JSON configuration into ScepConfig object. - */ - fun parseScepConfig(jsonString: String): ScepConfig = try { - val json = JSONObject(jsonString) - ScepConfig( - url = json.getString("scep_url"), - challenge = json.getString("challenge"), - alias = json.getString("alias"), - subject = json.getString("subject"), - keyLength = json.optInt("key_length", 2048), - signatureAlgorithm = json.optString("signature_algorithm", "SHA256withRSA"), - ) - } catch (e: Exception) { - throw IllegalArgumentException("Invalid SCEP configuration: ${e.message}", e) - } - /** * Performs SCEP enrollment, returning result or null on failure. */ @Suppress("SwallowedException") - suspend fun performEnrollment(config: ScepConfig): ScepResult? = try { + suspend fun performEnrollment(config: GetCertificateTemplateResponse): ScepResult? = try { scepClient.enroll(config) } catch (e: ScepEnrollmentException) { // Enrollment failure is expected in some scenarios (pending approval, invalid challenge) diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt new file mode 100644 index 0000000000..c751d1bf27 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt @@ -0,0 +1,96 @@ +package com.fleetdm.agent + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +/** + * WorkManager worker that handles certificate enrollment operations in the background. + * + * This worker: + * - Gets all certificate IDs from managed configuration + * - Calls CertificateOrchestrator to enroll all certificates in parallel + * - Returns appropriate Result based on enrollment outcomes + * - Supports automatic retry for transient failures + */ +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)") + + // 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() + } + + val certificateIds = CertificateOrchestrator.getCertificateIDs(applicationContext) + + if (certificateIds.isNullOrEmpty()) { + Log.d(TAG, "No certificates to enroll") + return Result.success() + } + + 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 + } + is CertificateEnrollmentHandler.EnrollmentResult.Failure -> { + Log.e(TAG, "Certificate $certificateId enrollment failed: ${result.reason}", result.exception) + if (shouldRetry(result.reason)) { + 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") + } + Result.failure() + } + else -> { + Log.i(TAG, "All ${results.size} certificate(s) enrolled successfully") + Result.success() + } + } + } + + companion object { + const val WORK_NAME = "certificate_enrollment" + private const val TAG = "CertEnrollmentWorker" + 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) + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt new file mode 100644 index 0000000000..737baa8204 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt @@ -0,0 +1,168 @@ +package com.fleetdm.agent + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.os.Bundle +import android.util.Log +import com.fleetdm.agent.scep.ScepClient +import com.fleetdm.agent.scep.ScepClientImpl +import java.security.PrivateKey +import java.security.cert.Certificate +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** + * 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 + * 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() + * ) + * ``` + */ +object CertificateOrchestrator { + private const val TAG = "CertificateOrchestrator" + + /** + * Reads certificate IDs from Android Managed Configuration. + * + * @param context Android context + * @return List of certificate IDs to enroll, or null if none configured + */ + fun getCertificateIDs(context: Context): List? { + 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") } + } + + /** + * Enrolls a single certificate by fetching its template from the API, + * performing SCEP enrollment, and installing it on the device. + * + * @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 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(), + certificateInstaller: CertificateEnrollmentHandler.CertificateInstaller? = null, + ): CertificateEnrollmentHandler.EnrollmentResult { + Log.d(TAG, "Starting certificate enrollment for certificate ID: $certificateId") + + // Step 1: Fetch certificate template from API + val templateResult = ApiClient.getCertificateTemplate(certificateId) + val template = 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, + ) + } + + Log.d(TAG, "Successfully fetched certificate template: ${template.name}") + + // Step 2: Create certificate installer (use provided or create default) + val installer = certificateInstaller ?: AndroidCertificateInstaller(context) + + // Step 3: Create enrollment handler + val handler = CertificateEnrollmentHandler( + scepClient = scepClient, + certificateInstaller = installer, + ) + + // Step 4: Perform enrollment + Log.d(TAG, "Starting SCEP enrollment for certificate: ${template.name}") + val result = handler.handleEnrollment(template) + + when (result) { + is CertificateEnrollmentHandler.EnrollmentResult.Success -> { + Log.i(TAG, "Certificate enrollment successful for ID $certificateId with alias: ${result.alias}") + } + is CertificateEnrollmentHandler.EnrollmentResult.Failure -> { + Log.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception) + } + } + + return result + } + + /** + * 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) + * @return Map of certificate ID to enrollment result + */ + suspend fun enrollCertificates( + context: Context, + certificateIds: List, + scepClient: ScepClient = ScepClientImpl(), + ): Map = coroutineScope { + Log.d(TAG, "Starting batch certificate enrollment for ${certificateIds.size} certificates") + + certificateIds.associateWith { certificateId -> + async { + enrollCertificate(context, certificateId, scepClient) + } + }.mapValues { it.value.await() } + } + + /** + * Android-specific certificate installer using DevicePolicyManager. + * + * This implementation uses the delegated certificate installation API + * which allows a non-DPC app to install certificates when properly + * delegated by the Device Policy Controller. + */ + class AndroidCertificateInstaller(private val context: Context) : CertificateEnrollmentHandler.CertificateInstaller { + private val TAG = "AndroidCertInstaller" + + override fun installCertificate(alias: String, privateKey: PrivateKey, certificateChain: Array): Boolean { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + + // The admin component is null because the caller is a DELEGATED application, + // not the Device Policy Controller itself. The DPM recognizes the delegation + // via the calling package's UID and the granted CERT_INSTALL scope. + val success = dpm.installKeyPair( + null, + privateKey, + certificateChain, + alias, + true, // requestAccess: allows user confirmation if needed + ) + + if (success) { + Log.i(TAG, "Certificate successfully installed with alias: $alias") + } else { + Log.e(TAG, "Certificate installation failed. Check MDM policy and delegation status.") + } + + return success + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateService.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateService.kt deleted file mode 100644 index 2782a73c80..0000000000 --- a/android/app/src/main/java/com/fleetdm/agent/CertificateService.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.fleetdm.agent - -import android.app.Service -import android.app.admin.DevicePolicyManager -import android.content.Context -import android.content.Intent -import android.os.IBinder -import android.util.Log -import com.fleetdm.agent.scep.ScepClientImpl -import java.security.PrivateKey -import java.security.cert.Certificate -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -/** - * Service to handle SCEP enrollment and silent certificate installation using DevicePolicyManager. - * Runs long-running tasks on the background IO thread via Coroutines. - * - * This is a thin wrapper around CertificateEnrollmentHandler that provides Android-specific - * lifecycle management and certificate installation. - */ -class CertificateService : Service() { - private val TAG = "CertCompanionService" - - // Use a supervisor job for the service's lifecycle - private val serviceJob = Job() - private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) - - // Enrollment handler with Android-specific certificate installer - private val enrollmentHandler = CertificateEnrollmentHandler( - scepClient = ScepClientImpl(), - certificateInstaller = AndroidCertificateInstaller(), - ) - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val certDataJson = intent?.getStringExtra("CERT_DATA") - - if (certDataJson != null) { - // Launch the SCEP process in a coroutine on the IO dispatcher - serviceScope.launch { - try { - when (val result = enrollmentHandler.handleEnrollment(certDataJson)) { - is CertificateEnrollmentHandler.EnrollmentResult.Success -> { - Log.i(TAG, "Certificate successfully enrolled and installed with alias: ${result.alias}") - } - is CertificateEnrollmentHandler.EnrollmentResult.Failure -> { - Log.e(TAG, "Certificate enrollment failed: ${result.reason}", result.exception) - } - } - } catch (e: Exception) { - Log.e(TAG, "Unexpected error during certificate enrollment: ${e.message}", e) - } finally { - // Stop the service when work is done, regardless of success/failure - stopSelf(startId) - } - } - } else { - Log.w(TAG, "Service started without 'CERT_DATA' extra.") - stopSelf(startId) - } - return START_NOT_STICKY - } - - /** - * Android-specific certificate installer using DevicePolicyManager. - */ - inner class AndroidCertificateInstaller : CertificateEnrollmentHandler.CertificateInstaller { - override fun installCertificate(alias: String, privateKey: PrivateKey, certificateChain: Array): Boolean { - val dpm = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - - // The admin component is null because the caller is a DELEGATED application, - // not the Device Policy Controller itself. The DPM recognizes the delegation - // via the calling package's UID and the granted CERT_INSTALL scope. - val success = dpm.installKeyPair( - null, - privateKey, - certificateChain, - alias, - true, // requestAccess: allows user confirmation if needed - ) - - if (success) { - Log.i(TAG, "Certificate successfully installed with alias: $alias") - } else { - Log.e(TAG, "Certificate installation failed. Check MDM policy and delegation status.") - } - - return success - } - } - - override fun onBind(intent: Intent?): IBinder? { - return null // Not a bound service - } - - override fun onDestroy() { - super.onDestroy() - // Cancel the coroutine scope when the service is destroyed to prevent leaks - serviceJob.cancel() - } -} diff --git a/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt b/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt deleted file mode 100644 index 0596090c66..0000000000 --- a/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.fleetdm.agent - -import android.content.Context -import android.util.Log -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters - -/** - * WorkManager worker that periodically checks managed configurations. - */ -class ConfigCheckWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - - companion object { - private const val TAG = "fleet-worker" - } - - override suspend fun doWork(): Result { - Log.i(TAG, "Periodic config check triggered") - - val configResult = ApiClient.getOrbitConfig() - configResult.onSuccess { config -> - Log.d(TAG, "Successfully fetched orbit config") - }.onFailure { error -> - Log.e(TAG, "Failed to fetch orbit config: ${error.message}", error) - } - - return Result.success() - } -} diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt index b36e8db7b9..15a9c04969 100644 --- a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt +++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt @@ -55,13 +55,13 @@ class MainActivity : ComponentActivity() { val dpm = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager setContent { - val enrollSecret by remember { mutableStateOf(appRestrictions.getString("enrollSecret")) } + val enrollSecret by remember { mutableStateOf(appRestrictions.getString("enroll_secret")) } val delegatedScopes by remember { mutableStateOf(dpm.getDelegatedScopes(null, packageName).toList()) } val delegatedCertScope by remember { mutableStateOf(delegatedScopes.contains(DevicePolicyManager.DELEGATION_CERT_INSTALL)) } val androidID by remember { mutableStateOf(Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)) } - val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("hostUUID")) } + val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("host_uuids")) } val certRequestList by remember { mutableStateOf(appRestrictions.getParcelableArray("certificates", Bundle::class.java)?.toList()) } @@ -83,7 +83,7 @@ class MainActivity : ComponentActivity() { mutableStateOf(grantedPermissions.toList()) } val fleetBaseUrl by remember { - mutableStateOf(appRestrictions.getString("serverURL")) + mutableStateOf(appRestrictions.getString("server_url")) } var installedCertificates: List by remember { mutableStateOf(listOf()) } val apiKey by ApiClient.apiKeyFlow.collectAsState(initial = null) @@ -104,12 +104,12 @@ class MainActivity : ComponentActivity() { KeyValue("packageName", packageName) KeyValue("versionName", packageManager.getPackageInfo(packageName, 0).versionName) KeyValue("longVersionCode", packageManager.getPackageInfo(packageName, 0).longVersionCode.toString()) - KeyValue("enrollSecret", enrollSecret) + KeyValue("enroll_secret", enrollSecret) KeyValue("delegatedScopes", delegatedScopes.toString()) KeyValue("delegated cert scope", delegatedCertScope.toString()) KeyValue("android id", androidID) - KeyValue("hostUUID (MC)", enrollmentSpecificID) - KeyValue("serverURL (MC)", fleetBaseUrl) + KeyValue("host_uuid (MC)", enrollmentSpecificID) + KeyValue("server_url (MC)", fleetBaseUrl) KeyValue("orbit_node_key (datastore)", apiKey) KeyValue("base_url (datastore)", baseUrl) KeyValue("certificate_ids", certIds.toString()) diff --git a/android/app/src/main/java/com/fleetdm/agent/scep/ScepClient.kt b/android/app/src/main/java/com/fleetdm/agent/scep/ScepClient.kt index 6bc6849920..f9ec799451 100644 --- a/android/app/src/main/java/com/fleetdm/agent/scep/ScepClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/scep/ScepClient.kt @@ -1,5 +1,7 @@ package com.fleetdm.agent.scep +import com.fleetdm.agent.GetCertificateTemplateResponse + /** * Interface for SCEP (Simple Certificate Enrollment Protocol) client operations. * @@ -17,5 +19,5 @@ interface ScepClient { * @return ScepResult containing the private key and certificate chain * @throws ScepException if enrollment fails */ - suspend fun enroll(config: ScepConfig): ScepResult + suspend fun enroll(config: GetCertificateTemplateResponse): ScepResult } diff --git a/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt b/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt index e261f5e960..905d74df93 100644 --- a/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt +++ b/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt @@ -1,6 +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 import org.bouncycastle.asn1.x500.X500Name @@ -44,7 +44,7 @@ class ScepClientImpl : ScepClient { } } - override suspend fun enroll(config: ScepConfig): ScepResult = withContext(Dispatchers.IO) { + override suspend fun enroll(config: GetCertificateTemplateResponse): ScepResult = withContext(Dispatchers.IO) { try { // Log calls removed to avoid test failures on JVM (use logcat in Android Studio) @@ -53,9 +53,9 @@ class ScepClientImpl : ScepClient { // Step 2: Parse subject name val entity = try { - X500Name(config.subject) + X500Name(config.subjectName) } catch (e: Exception) { - throw ScepCsrException("Invalid X.500 subject name: ${config.subject}", e) + throw ScepCsrException("Invalid X.500 subject name: ${config.subjectName}", e) } // Step 3: Create self-signed certificate for signing the PKCS7 envelope @@ -81,7 +81,7 @@ class ScepClientImpl : ScepClient { val client = Client(server, verifier) // Step 5: Build Certificate Signing Request (CSR) - val csr = buildCsr(entity, keyPair, config.challenge, config.signatureAlgorithm) + val csr = buildCsr(entity, keyPair, config.scepChallenge, config.signatureAlgorithm) // Step 6: Send enrollment request val response = try { diff --git a/android/app/src/main/res/xml/app_restrictions.xml b/android/app/src/main/res/xml/app_restrictions.xml index fb0f9a87c9..86f9acb1e9 100644 --- a/android/app/src/main/res/xml/app_restrictions.xml +++ b/android/app/src/main/res/xml/app_restrictions.xml @@ -2,19 +2,19 @@ diff --git a/android/app/src/test/java/com/fleetdm/agent/CertificateEnrollmentHandlerTest.kt b/android/app/src/test/java/com/fleetdm/agent/CertificateEnrollmentHandlerTest.kt index 329420fce2..d43d60eb5c 100644 --- a/android/app/src/test/java/com/fleetdm/agent/CertificateEnrollmentHandlerTest.kt +++ b/android/app/src/test/java/com/fleetdm/agent/CertificateEnrollmentHandlerTest.kt @@ -1,7 +1,6 @@ package com.fleetdm.agent import com.fleetdm.agent.scep.MockScepClient -import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -68,17 +67,17 @@ class CertificateEnrollmentHandlerTest { } @Test - fun `handler enrolls with valid CERT_DATA`() = runTest { - val certData = createValidCertDataJson() + fun `handler enrolls with valid certificate template`() = runTest { + val template = createValidCertificateTemplate() - val result = handler.handleEnrollment(certData.toString()) + val result = handler.handleEnrollment(template) // Verify SCEP client was called with correct config assertNotNull(mockScepClient.capturedConfig) assertEquals("https://scep.example.com/cgi-bin/pkiclient.exe", mockScepClient.capturedConfig?.url) - assertEquals("secret123", mockScepClient.capturedConfig?.challenge) - assertEquals("device-cert", mockScepClient.capturedConfig?.alias) - assertEquals("CN=Device123,O=FleetDM", mockScepClient.capturedConfig?.subject) + assertEquals("secret123", mockScepClient.capturedConfig?.scepChallenge) + assertEquals("device-cert", mockScepClient.capturedConfig?.name) + assertEquals("CN=Device123,O=FleetDM", mockScepClient.capturedConfig?.subjectName) // Verify success assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Success) @@ -86,9 +85,9 @@ class CertificateEnrollmentHandlerTest { @Test fun `handler installs certificate after successful enrollment`() = runTest { - val certData = createValidCertDataJson() + val template = createValidCertificateTemplate() - val result = handler.handleEnrollment(certData.toString()) + val result = handler.handleEnrollment(template) // Verify certificate installer was called assertTrue(mockInstaller.wasInstallCalled) @@ -105,9 +104,9 @@ class CertificateEnrollmentHandlerTest { fun `handler handles enrollment failure gracefully`() = runTest { mockScepClient.shouldThrowEnrollmentException = true - val certData = createValidCertDataJson() + val template = createValidCertificateTemplate() - val result = handler.handleEnrollment(certData.toString()) + val result = handler.handleEnrollment(template) // Verify certificate installer was NOT called since enrollment failed assertFalse(mockInstaller.wasInstallCalled) @@ -120,9 +119,9 @@ class CertificateEnrollmentHandlerTest { fun `handler handles network exception gracefully`() = runTest { mockScepClient.shouldThrowNetworkException = true - val certData = createValidCertDataJson() + val template = createValidCertificateTemplate() - val result = handler.handleEnrollment(certData.toString()) + val result = handler.handleEnrollment(template) // Verify certificate installer was NOT called assertFalse(mockInstaller.wasInstallCalled) @@ -135,9 +134,9 @@ class CertificateEnrollmentHandlerTest { fun `handler handles installation failure`() = runTest { mockInstaller.shouldSucceed = false - val certData = createValidCertDataJson() + val template = createValidCertificateTemplate() - val result = handler.handleEnrollment(certData.toString()) + val result = handler.handleEnrollment(template) // Verify enrollment succeeded but installation failed assertTrue(mockInstaller.wasInstallCalled) @@ -145,91 +144,53 @@ class CertificateEnrollmentHandlerTest { } @Test - fun `handler parses custom key length and signature algorithm`() = runTest { - val certData = JSONObject().apply { - put("scep_url", "https://scep.example.com/cgi-bin/pkiclient.exe") - put("challenge", "secret123") - put("alias", "device-cert") - put("subject", "CN=Device123,O=FleetDM") - put("key_length", 4096) - put("signature_algorithm", "SHA512withRSA") - } + fun `handler uses custom key length and signature algorithm`() = runTest { + val template = createValidCertificateTemplate( + keyLength = 4096, + signatureAlgorithm = "SHA512withRSA", + ) - handler.handleEnrollment(certData.toString()) + handler.handleEnrollment(template) - // Verify config was parsed correctly + // Verify config was used correctly assertEquals(4096, mockScepClient.capturedConfig?.keyLength) assertEquals("SHA512withRSA", mockScepClient.capturedConfig?.signatureAlgorithm) } @Test - fun `handler uses default values when optional parameters missing`() = runTest { - val certData = JSONObject().apply { - put("scep_url", "https://scep.example.com/cgi-bin/pkiclient.exe") - put("challenge", "secret123") - put("alias", "device-cert") - put("subject", "CN=Device123,O=FleetDM") - // key_length and signature_algorithm not provided - } + fun `handler uses default values for optional parameters`() = runTest { + val template = createValidCertificateTemplate() - handler.handleEnrollment(certData.toString()) + handler.handleEnrollment(template) // Verify defaults were used assertEquals(2048, mockScepClient.capturedConfig?.keyLength) assertEquals("SHA256withRSA", mockScepClient.capturedConfig?.signatureAlgorithm) } - @Test - fun `handler rejects invalid JSON`() = runTest { - val invalidJson = "not valid json" - - val result = handler.handleEnrollment(invalidJson) - - // Verify failure result - assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure) - val failure = result as CertificateEnrollmentHandler.EnrollmentResult.Failure - assertTrue(failure.reason.contains("Invalid configuration")) - } - - @Test - fun `handler rejects URL without scheme`() = runTest { - val certData = JSONObject().apply { - put("scep_url", "scep.example.com/path") - put("challenge", "secret123") - put("alias", "device-cert") - put("subject", "CN=Device123,O=FleetDM") - } - - val result = handler.handleEnrollment(certData.toString()) - - assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure) - val failure = result as CertificateEnrollmentHandler.EnrollmentResult.Failure - assertTrue(failure.reason.contains("Invalid configuration")) - } - - @Test - fun `handler rejects key length below 2048`() = runTest { - val certData = JSONObject().apply { - put("scep_url", "https://scep.example.com/path") - put("challenge", "secret123") - put("alias", "device-cert") - put("subject", "CN=Device123,O=FleetDM") - put("key_length", 1024) - } - - val result = handler.handleEnrollment(certData.toString()) - - assertTrue(result is CertificateEnrollmentHandler.EnrollmentResult.Failure) - val failure = result as CertificateEnrollmentHandler.EnrollmentResult.Failure - assertTrue(failure.reason.contains("Invalid configuration")) - } - // Helper functions - private fun createValidCertDataJson(): JSONObject = JSONObject().apply { - put("scep_url", "https://scep.example.com/cgi-bin/pkiclient.exe") - put("challenge", "secret123") - put("alias", "device-cert") - put("subject", "CN=Device123,O=FleetDM") - } + 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 = "ca-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, + ) } diff --git a/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt b/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt deleted file mode 100644 index 0443a6bb93..0000000000 --- a/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.fleetdm.agent - -import android.content.Context -import androidx.work.ListenableWorker -import androidx.work.testing.TestListenableWorkerBuilder -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import kotlinx.coroutines.runBlocking - -@RunWith(RobolectricTestRunner::class) -class ConfigCheckWorkerTest { - private lateinit var context: Context - - @Before - fun setUp() { - context = RuntimeEnvironment.getApplication() - ApiClient.initialize(context) - } - - @Test - fun testDoWork() { - val worker = - TestListenableWorkerBuilder(context) - .build() - - // Execute the worker - val result = runBlocking { - worker.doWork() - } - assertEquals(ListenableWorker.Result.success(), result) - } -} diff --git a/android/app/src/test/java/com/fleetdm/agent/scep/MockScepClient.kt b/android/app/src/test/java/com/fleetdm/agent/scep/MockScepClient.kt index 56f2ab86f3..fafb20994e 100644 --- a/android/app/src/test/java/com/fleetdm/agent/scep/MockScepClient.kt +++ b/android/app/src/test/java/com/fleetdm/agent/scep/MockScepClient.kt @@ -1,5 +1,6 @@ package com.fleetdm.agent.scep +import com.fleetdm.agent.GetCertificateTemplateResponse import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder @@ -24,7 +25,7 @@ class MockScepClient : ScepClient { var shouldThrowNetworkException = false var shouldThrowCertificateException = false var enrollmentDelay = 0L - var capturedConfig: ScepConfig? = null + var capturedConfig: GetCertificateTemplateResponse? = null init { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { @@ -32,7 +33,7 @@ class MockScepClient : ScepClient { } } - override suspend fun enroll(config: ScepConfig): ScepResult { + override suspend fun enroll(config: GetCertificateTemplateResponse): ScepResult { capturedConfig = config if (enrollmentDelay > 0) { @@ -47,7 +48,7 @@ class MockScepClient : ScepClient { } // Generate a real key pair and certificate for testing - return generateMockResult(config.subject) + return generateMockResult(config.subjectName) } private fun generateMockResult(subject: String): ScepResult { diff --git a/android/app/src/test/java/com/fleetdm/agent/scep/ScepClientImplTest.kt b/android/app/src/test/java/com/fleetdm/agent/scep/ScepClientImplTest.kt index 58069206f5..8d582f0f4a 100644 --- a/android/app/src/test/java/com/fleetdm/agent/scep/ScepClientImplTest.kt +++ b/android/app/src/test/java/com/fleetdm/agent/scep/ScepClientImplTest.kt @@ -1,5 +1,6 @@ package com.fleetdm.agent.scep +import com.fleetdm.agent.GetCertificateTemplateResponse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assert.fail @@ -24,15 +25,10 @@ class ScepClientImplTest { @Test fun `enroll with malformed URL throws ScepNetworkException`() = runTest { - val config = ScepConfig( - url = "http://[invalid", - challenge = "secret", - alias = "cert", - subject = "CN=Test", - ) + val template = createCertificateTemplate(url = "http://[invalid") try { - scepClient.enroll(config) + scepClient.enroll(template) fail("Expected ScepNetworkException to be thrown") } catch (e: ScepNetworkException) { assertTrue(e.message?.contains("Invalid SCEP URL") == true) @@ -41,15 +37,10 @@ class ScepClientImplTest { @Test fun `enroll with invalid subject throws ScepCsrException`() = runTest { - val config = ScepConfig( - url = "https://scep.example.com/cgi-bin/pkiclient.exe", - challenge = "secret", - alias = "cert", - subject = "invalid-subject-format", - ) + val template = createCertificateTemplate(subjectName = "invalid-subject-format") try { - scepClient.enroll(config) + scepClient.enroll(template) fail("Expected ScepCsrException to be thrown") } catch (e: ScepCsrException) { assertTrue(e.message?.contains("Invalid X.500 subject name") == true) @@ -58,21 +49,39 @@ class ScepClientImplTest { @Test fun `enroll with unreachable server throws ScepNetworkException`() = runTest { - val config = ScepConfig( + val template = createCertificateTemplate( url = "https://invalid-scep-server-that-does-not-exist.example.com/scep", - challenge = "secret", - alias = "cert", - subject = "CN=Test,O=Example", ) try { - scepClient.enroll(config) + scepClient.enroll(template) 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 = "ca-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. } diff --git a/android/app/src/test/java/com/fleetdm/agent/scep/ScepIntegrationTest.kt b/android/app/src/test/java/com/fleetdm/agent/scep/ScepIntegrationTest.kt index dd75b8cdb5..fd2a8edfe3 100644 --- a/android/app/src/test/java/com/fleetdm/agent/scep/ScepIntegrationTest.kt +++ b/android/app/src/test/java/com/fleetdm/agent/scep/ScepIntegrationTest.kt @@ -1,5 +1,6 @@ package com.fleetdm.agent.scep +import com.fleetdm.agent.GetCertificateTemplateResponse import com.fleetdm.agent.IntegrationTest import com.fleetdm.agent.IntegrationTestRule import org.junit.Assert.assertEquals @@ -34,7 +35,7 @@ class ScepIntegrationTest { val integrationTestRule = IntegrationTestRule() private lateinit var scepClient: ScepClientImpl - private lateinit var testConfig: ScepConfig + private lateinit var testTemplate: GetCertificateTemplateResponse @Before fun setup() { @@ -46,19 +47,41 @@ class ScepIntegrationTest { // Generate unique subject DN to avoid duplicates on SCEP server val uniqueId = System.currentTimeMillis() - testConfig = ScepConfig( + testTemplate = createTemplate( url = scepUrl, challenge = challenge, - alias = "integration-test-cert-$uniqueId", + name = "integration-test-cert-$uniqueId", subject = "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 = "ca-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(testConfig) + val result = scepClient.enroll(testTemplate) // Verify result structure assertNotNull("Private key should not be null", result.privateKey) @@ -79,10 +102,10 @@ class ScepIntegrationTest { @IntegrationTest @Test fun `enrollment with invalid challenge fails`() = runTest { - val invalidConfig = testConfig.copy(challenge = "invalid-challenge-that-should-fail") + val invalidTemplate = testTemplate.copy(scepChallenge = "invalid-challenge-that-should-fail") try { - scepClient.enroll(invalidConfig) + scepClient.enroll(invalidTemplate) fail("Expected ScepEnrollmentException for invalid challenge") } catch (e: ScepEnrollmentException) { // Expected - enrollment should fail with invalid challenge @@ -97,13 +120,15 @@ class ScepIntegrationTest { keySizes.forEach { keySize -> val uniqueId = System.currentTimeMillis() - val config = testConfig.copy( - keyLength = keySize, - alias = "test-cert-$keySize-$uniqueId", + val template = createTemplate( + url = testTemplate.url ?: "https://scep.example.com/scep", + challenge = testTemplate.scepChallenge, + name = "test-cert-$keySize-$uniqueId", subject = "CN=IntegrationTestDevice-$keySize-$uniqueId,O=FleetDM,C=US", + keyLength = keySize, ) - val result = scepClient.enroll(config) + val result = scepClient.enroll(template) assertNotNull(result.privateKey) println("Successfully enrolled with key size: $keySize") @@ -115,7 +140,7 @@ class ScepIntegrationTest { fun `enrollment performance test`() = runTest { val startTime = System.currentTimeMillis() - val result = scepClient.enroll(testConfig) + val result = scepClient.enroll(testTemplate) val duration = System.currentTimeMillis() - startTime @@ -128,14 +153,14 @@ class ScepIntegrationTest { @Test fun `enrollment with unreachable server fails quickly`() = runTest { - val unreachableConfig = testConfig.copy( + val unreachableTemplate = testTemplate.copy( url = "https://unreachable-scep-server.invalid/scep", ) val startTime = System.currentTimeMillis() try { - scepClient.enroll(unreachableConfig) + scepClient.enroll(unreachableTemplate) fail("Expected ScepNetworkException") } catch (e: ScepNetworkException) { val duration = System.currentTimeMillis() - startTime