Integrating scep client code (#36599)

This commit is contained in:
Dante Catalfamo
2025-12-04 10:19:52 -05:00
committed by GitHub
parent 092b55a760
commit 6628b63dec
19 changed files with 530 additions and 367 deletions
+3
View File
@@ -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:
-5
View File
@@ -55,11 +55,6 @@
android:value="" />
</service>
<!-- Service Declaration -->
<service
android:name=".CertificateService"
android:exported="false" />
<!-- Managed configuration schema -->
<meta-data android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
@@ -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<ConfigCheckWorker>(
15, // 15 is the minimum
TimeUnit.MINUTES,
).build()
private fun schedulePeriodicCertificateEnrollment() {
val workRequest = PeriodicWorkRequestBuilder<CertificateEnrollmentWorker>(
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")
}
}
@@ -22,7 +22,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
private val Context.credentialStore: DataStore<Preferences> by preferencesDataStore(name = "api_credentials")
val Context.prefDataStore: DataStore<Preferences> 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<GetCertificateTemplateResponse> {
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"
}
}
@@ -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<CertificateEnrollmentWorker>()
.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")
}
}
}
@@ -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)
@@ -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)
}
}
}
@@ -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<Int>? {
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<Int>,
scepClient: ScepClient = ScepClientImpl(),
): Map<Int, CertificateEnrollmentHandler.EnrollmentResult> = 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<Certificate>): 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
}
}
}
@@ -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<Certificate>): 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()
}
}
@@ -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()
}
}
@@ -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<CertificateInfo> 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())
@@ -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
}
@@ -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 {
@@ -2,19 +2,19 @@
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
<restriction
android:key="enrollSecret"
android:key="enroll_secret"
android:title="@string/enroll_secret_title"
android:restrictionType="string"
android:description="@string/enroll_secret_description" />
<restriction
android:key="serverURL"
android:key="server_url"
android:title="@string/server_url_title"
android:restrictionType="string"
android:description="@string/server_url_description" />
<restriction
android:key="hostUUID"
android:key="host_uuid"
android:title="@string/host_uuid_title"
android:restrictionType="string"
android:description="@string/host_uuid_description" />
@@ -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,
)
}
@@ -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<ConfigCheckWorker>(context)
.build()
// Execute the worker
val result = runBlocking {
worker.doWork()
}
assertEquals(ListenableWorker.Result.success(), result)
}
}
@@ -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 {
@@ -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.
}
@@ -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