Skip to content
27 changes: 27 additions & 0 deletions android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import java.math.BigInteger
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
Expand All @@ -23,6 +28,13 @@ import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

/**
* Converts a java.util.Date to ISO8601 format string.
* Format: "yyyy-MM-dd'T'HH:mm:ss'Z'" (UTC timezone)
* Example: "2025-12-31T23:59:59Z"
*/
private fun Date.toISO8601String(): String = this.toInstant().toString() // Returns "2025-12-31T23:59:59Z"

val Context.prefDataStore: DataStore<Preferences> by preferencesDataStore(name = "pref_datastore")

/**
Expand All @@ -41,6 +53,9 @@ interface CertificateApiClient {
status: UpdateCertificateStatusStatus,
operationType: UpdateCertificateStatusOperation,
detail: String? = null,
notAfter: Date? = null,
notBefore: Date? = null,
serialNumber: BigInteger? = null,
): Result<Unit>
}

Expand Down Expand Up @@ -253,13 +268,19 @@ object ApiClient : CertificateApiClient {
status: UpdateCertificateStatusStatus,
operationType: UpdateCertificateStatusOperation,
detail: String?,
notAfter: Date?,
notBefore: Date?,
serialNumber: BigInteger?,
): Result<Unit> = makeRequest(
endpoint = "/api/fleetd/certificates/$certificateId/status",
method = "PUT",
body = UpdateCertificateStatusRequest(
status = status,
operationType = operationType,
detail = detail,
notAfter = notAfter?.toISO8601String(),
notBefore = notBefore?.toISO8601String(),
serialNumber = serialNumber?.toString(),
),
bodySerializer = UpdateCertificateStatusRequest.serializer(),
responseSerializer = UpdateCertificateStatusResponse.serializer(),
Expand Down Expand Up @@ -442,6 +463,12 @@ data class UpdateCertificateStatusRequest(
val operationType: UpdateCertificateStatusOperation,
@SerialName("detail")
val detail: String? = null,
@SerialName("not_valid_after")
val notAfter: String? = null,
@SerialName("not_valid_before")
val notBefore: String? = null,
@SerialName("serial")
val serialNumber: String? = null,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import com.fleetdm.agent.scep.ScepCsrException
import com.fleetdm.agent.scep.ScepEnrollmentException
import com.fleetdm.agent.scep.ScepKeyGenerationException
import com.fleetdm.agent.scep.ScepNetworkException
import java.math.BigInteger
import java.security.PrivateKey
import java.security.cert.Certificate
import java.util.Date

/**
* Handles certificate enrollment business logic without Android framework dependencies.
Expand All @@ -27,7 +29,7 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v
* Result of enrollment operation.
*/
sealed class EnrollmentResult {
data class Success(val alias: String) : EnrollmentResult()
data class Success(val alias: String, val notAfter: Date?, val notBefore: Date?, val serialNumber: BigInteger?) : EnrollmentResult()
data class Failure(val reason: String, val exception: Exception? = null, val isRetryable: Boolean = false) : EnrollmentResult()
data class PermanentlyFailed(val alias: String) : EnrollmentResult()
}
Expand All @@ -47,7 +49,12 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v
)

if (installed) {
EnrollmentResult.Success(config.name)
EnrollmentResult.Success(
alias = config.name,
notAfter = result.notAfter,
notBefore = result.notBefore,
serialNumber = result.serialNumber,
)
} else {
EnrollmentResult.Failure("Certificate installation failed")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.fleetdm.agent.scep.ScepClient
import com.fleetdm.agent.scep.ScepClientImpl
import java.math.BigInteger
import java.security.PrivateKey
import java.security.cert.Certificate
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -55,6 +61,24 @@ class CertificateOrchestrator(
explicitNulls = false
}

/**
* Converts a java.util.Date to ISO8601 format string.
* Format: "yyyy-MM-dd'T'HH:mm:ss'Z'" (UTC timezone)
* Example: "2025-12-31T23:59:59Z"
*/
private fun Date.toISO8601String(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
return dateFormat.format(this)
}

/**
* Parses an ISO8601 format string to java.util.Date.
* Format: "yyyy-MM-dd'T'HH:mm:ss'Z'" (UTC timezone)
* @throws : DateTimeParseException if the date string cannot be parsed
*/
private fun parseISO8601(dateString: String): Date = Date.from(Instant.parse(dateString))

// Mutex to protect concurrent access to certificate storage
private val certificateStorageMutex = Mutex()

Expand Down Expand Up @@ -322,7 +346,10 @@ class CertificateOrchestrator(
val results = mutableMapOf<Int, CleanupResult>()

// Step 1: Process certificates with operation="remove"
val certificatesToRemove = hostCertificates.filter { it.shouldRemove() }
// Note: UUID mismatches are now handled by enrollment (install-over), not cleanup
val certificatesToRemove = hostCertificates.filter {
it.shouldRemove()
}
Log.d(TAG, "Certificates marked for removal: ${certificatesToRemove.map { it.id }}")

for (hostCert in certificatesToRemove) {
Expand Down Expand Up @@ -496,13 +523,30 @@ class CertificateOrchestrator(
* @param alias Certificate alias
* @param isInstall True for install operation, false for remove operation
*/
internal suspend fun markCertificateUnreported(context: Context, certificateId: Int, alias: String, uuid: String, isInstall: Boolean) {
internal suspend fun markCertificateUnreported(
context: Context,
certificateId: Int,
alias: String,
uuid: String,
isInstall: Boolean,
notAfter: String? = null,
notBefore: String? = null,
serialNumber: String? = null,
) {
val status = if (isInstall) {
CertificateStatus.INSTALLED_UNREPORTED
} else {
CertificateStatus.REMOVED_UNREPORTED
}
val info = CertificateState(alias = alias, status = status, statusReportRetries = 0, uuid = uuid)
val info = CertificateState(
alias = alias,
status = status,
statusReportRetries = 0,
uuid = uuid,
notAfter = notAfter,
notBefore = notBefore,
serialNumber = serialNumber,
)
storeCertificateState(context, certificateId, info)
}

Expand Down Expand Up @@ -570,6 +614,9 @@ class CertificateOrchestrator(
certificateId = certId,
status = UpdateCertificateStatusStatus.VERIFIED,
operationType = operationType,
notAfter = state.notAfter?.let { parseISO8601(it) },
notBefore = state.notBefore?.let { parseISO8601(it) },
serialNumber = state.serialNumber?.let { BigInteger(it) },
)

if (result.isSuccess) {
Expand Down Expand Up @@ -623,10 +670,15 @@ class CertificateOrchestrator(
TAG,
"Certificate ID $certificateId (alias: '${storedState.alias}', uuid: $uuid) is already installed, skipping enrollment",
)
return CertificateEnrollmentHandler.EnrollmentResult.Success(storedState.alias)
return CertificateEnrollmentHandler.EnrollmentResult.Success(
alias = storedState.alias,
notAfter = null,
notBefore = null,
serialNumber = null,
)
}
if (existsInKeystore && storedState.uuid != uuid) {
Log.i(TAG, "Certificate ID $certificateId uuid changed (${storedState.uuid} -> $uuid), will reinstall")
Log.i(TAG, "Certificate ID $certificateId uuid changed (${storedState.uuid} -> $uuid), will install over existing")
}
}

Expand All @@ -653,7 +705,12 @@ class CertificateOrchestrator(
// The certificate template hasn't failed on the device, but isn't ready to be processed yet.
// Retry next time we fetch but don't mark as failed locally
Log.i(TAG, "Certificate template ${template.name} does not have status \"delivered\": status \"${template.status}\"")
return CertificateEnrollmentHandler.EnrollmentResult.Success(template.name)
return CertificateEnrollmentHandler.EnrollmentResult.Success(
alias = template.name,
notAfter = null,
notBefore = null,
serialNumber = null,
)
}

// Step 3: Create certificate installer (use provided or create default)
Expand All @@ -673,14 +730,31 @@ class CertificateOrchestrator(
is CertificateEnrollmentHandler.EnrollmentResult.Success -> {
Log.i(TAG, "Certificate enrollment successful for ID $certificateId with alias: ${result.alias}")

// Convert certificate metadata to ISO8601 for storage
val notAfterStr = result.notAfter?.toISO8601String()
val notBeforeStr = result.notBefore?.toISO8601String()
val serialNumberStr = result.serialNumber?.toString()

// First, mark as unreported (persisted before network call)
markCertificateUnreported(context, certificateId, template.name, uuid = uuid, isInstall = true)
markCertificateUnreported(
context,
certificateId,
template.name,
uuid = uuid,
isInstall = true,
notAfter = notAfterStr,
notBefore = notBeforeStr,
serialNumber = serialNumberStr,
)

// Attempt to report status
val reportResult = apiClient.updateCertificateStatus(
certificateId = certificateId,
status = UpdateCertificateStatusStatus.VERIFIED,
operationType = UpdateCertificateStatusOperation.INSTALL,
notAfter = result.notAfter,
notBefore = result.notBefore,
serialNumber = result.serialNumber,
)

if (reportResult.isSuccess) {
Expand Down Expand Up @@ -810,6 +884,12 @@ data class CertificateState(
val statusReportRetries: Int = 0,
@SerialName("uuid")
val uuid: String = "",
@SerialName("not_after")
val notAfter: String? = null,
@SerialName("not_before")
val notBefore: String? = null,
@SerialName("serial_number")
val serialNumber: String? = null,
) {
fun shouldRetry(): Boolean = status == CertificateStatus.RETRY && retries < (MAX_CERT_INSTALL_RETRIES)
fun shouldRetryStatusReport(): Boolean = statusReportRetries < MAX_STATUS_REPORT_RETRIES
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/java/com/fleetdm/agent/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ fun DebugCertificateList(certificates: CertificateStateMap) {
Text(text = "alias: ${value.alias}")
Text(text = "status: ${value.status}")
Text(text = "retries: ${value.retries}")
Text(text = "uuid: ${value.uuid}")
Text(text = "notBefore: ${value.notBefore}")
Text(text = "notAfter: ${value.notAfter}")
Text(text = "serial: ${value.serialNumber}")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,18 @@ class ScepClientImpl : ScepClient {
throw ScepCertificateException("No certificates returned from SCEP server")
}

val leafCertificate = (certificates.first() as java.security.cert.X509Certificate)
// Extract certificate metadata from the leaf certificate
val notAfter = leafCertificate.notAfter
val notBefore = leafCertificate.notBefore
val serialNumber = leafCertificate.serialNumber

ScepResult(
privateKey = keyPair.private,
certificateChain = certificates,
notAfter = notAfter,
notBefore = notBefore,
serialNumber = serialNumber,
)
}
response.isPending -> {
Expand Down
13 changes: 12 additions & 1 deletion android/app/src/main/java/com/fleetdm/agent/scep/ScepResult.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
package com.fleetdm.agent.scep

import java.math.BigInteger
import java.security.PrivateKey
import java.security.cert.Certificate
import java.util.Date

/**
* Result of a successful SCEP enrollment containing the private key and certificate chain.
*
* @property privateKey The generated private key
* @property certificateChain The certificate chain from the SCEP server (leaf certificate first)
* @property notAfter The expiration date (notAfter) of the leaf certificate
* @property notBefore The effective date (notBefore) of the leaf certificate
* @property serialNumber The serial number of the leaf certificate
*/
data class ScepResult(val privateKey: PrivateKey, val certificateChain: List<Certificate>)
data class ScepResult(
val privateKey: PrivateKey,
val certificateChain: List<Certificate>,
val notAfter: Date,
val notBefore: Date,
val serialNumber: BigInteger,
)
Loading
Loading