Integrating scep client code (#36599)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user