diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt index 737baa8204..2cc2f77e07 100644 --- a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt @@ -4,12 +4,19 @@ import android.app.admin.DevicePolicyManager import android.content.Context import android.os.Bundle import android.util.Log +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.security.PrivateKey import java.security.cert.Certificate import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json /** * Orchestrates certificate enrollment operations by coordinating API calls, @@ -41,6 +48,18 @@ import kotlinx.coroutines.coroutineScope object CertificateOrchestrator { private const val TAG = "CertificateOrchestrator" + // DataStore key for storing installed certificates map as JSON + private val INSTALLED_CERTIFICATES_KEY = stringPreferencesKey("installed_certificates") + + // JSON serializer instance + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + // Mutex to protect concurrent access to certificate storage + private val certificateStorageMutex = Mutex() + /** * Reads certificate IDs from Android Managed Configuration. * @@ -55,6 +74,132 @@ object CertificateOrchestrator { return certRequestList?.map { bundle -> bundle.getInt("certificate_id") } } + /** + * Reads the installed certificates map from DataStore. + * + * @param context Android context + * @return Map of certificate ID to alias, or empty map if none stored + */ + internal suspend fun getInstalledCertificates(context: Context): Map { + certificateStorageMutex.withLock { + return try { + val prefs = context.prefDataStore.data.first() + val jsonString = prefs[INSTALLED_CERTIFICATES_KEY] + + if (jsonString == null) { + Log.d(TAG, "No installed certificates found in DataStore") + return emptyMap() + } + + val map = json.decodeFromString>(jsonString) + Log.d(TAG, "Loaded ${map.size} installed certificate(s) from DataStore") + map + } catch (e: Exception) { + Log.e(TAG, "Failed to read installed certificates from DataStore: ${e.message}", e) + emptyMap() + } + } + } + + /** + * Stores a certificate ID→alias mapping in DataStore after successful installation. + * This performs a read-modify-write operation to update the map. + * + * @param context Android context + * @param certificateId Certificate template ID + * @param alias Certificate alias used during installation + */ + internal suspend fun storeCertificateInstallation(context: Context, certificateId: Int, alias: String) { + certificateStorageMutex.withLock { + try { + context.prefDataStore.edit { preferences -> + // Read existing map + val existingJsonString = preferences[INSTALLED_CERTIFICATES_KEY] + val existingMap = if (existingJsonString != null) { + try { + json.decodeFromString>(existingJsonString) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse existing certificates JSON, starting fresh: ${e.message}") + emptyMap() + } + } else { + emptyMap() + } + + // Add new mapping + val updatedMap = existingMap.toMutableMap().apply { + put(certificateId, alias) + } + + // Serialize and store + val updatedJsonString = json.encodeToString(updatedMap) + preferences[INSTALLED_CERTIFICATES_KEY] = updatedJsonString + + Log.d(TAG, "Stored certificate mapping: $certificateId → $alias (total: ${updatedMap.size})") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to store certificate installation: ${e.message}", e) + // Non-fatal error - enrollment was successful, just tracking failed + } + } + } + + /** + * Retrieves the certificate alias for a given certificate ID from DataStore. + * + * @param context Android context + * @param certificateId Certificate template ID + * @return Certificate alias if previously installed, null otherwise + */ + internal suspend fun getCertificateAlias(context: Context, certificateId: Int): String? { + val installedCerts = getInstalledCertificates(context) + val alias = installedCerts[certificateId] + Log.d(TAG, "Certificate $certificateId alias lookup: ${alias ?: "not found"}") + return alias + } + + /** + * Checks if a certificate is installed in the Android keystore. + * + * @param context Android context + * @param alias Certificate alias + * @return True if certificate exists in keystore + */ + private fun isCertificateInstalled(context: Context, alias: String): Boolean = try { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val hasKeyPair = dpm.hasKeyPair(alias) + Log.d(TAG, "Certificate '$alias' installation check: $hasKeyPair") + hasKeyPair + } catch (e: Exception) { + Log.e(TAG, "Error checking if certificate '$alias' is installed: ${e.message}", e) + false + } + + /** + * Checks if a certificate ID has been successfully installed and still exists in keystore. + * This is a fast check that doesn't require fetching the template from the API. + * + * @param context Android context + * @param certificateId Certificate template ID + * @return True if certificate is tracked in DataStore AND exists in keystore + */ + internal suspend fun isCertificateIdInstalled(context: Context, certificateId: Int): Boolean { + // Check DataStore for this certificate ID + val storedAlias = getCertificateAlias(context, certificateId) + if (storedAlias == null) { + Log.d(TAG, "Certificate ID $certificateId not found in DataStore") + return false + } + + // Verify certificate still exists in keystore + val existsInKeystore = isCertificateInstalled(context, storedAlias) + if (!existsInKeystore) { + Log.w(TAG, "Certificate ID $certificateId tracked in DataStore but missing from keystore - will re-enroll") + } + + return existsInKeystore + } + /** * Enrolls a single certificate by fetching its template from the API, * performing SCEP enrollment, and installing it on the device. @@ -73,7 +218,14 @@ object CertificateOrchestrator { ): CertificateEnrollmentHandler.EnrollmentResult { Log.d(TAG, "Starting certificate enrollment for certificate ID: $certificateId") - // Step 1: Fetch certificate template from API + // Step 1: Check if certificate is already installed (BEFORE API call) + if (isCertificateIdInstalled(context, certificateId)) { + val alias = getCertificateAlias(context, certificateId)!! + Log.i(TAG, "Certificate ID $certificateId (alias: '$alias') is already installed, skipping enrollment") + return CertificateEnrollmentHandler.EnrollmentResult.Success(alias) + } + + // Step 2: Fetch certificate template from API (only if not already installed) val templateResult = ApiClient.getCertificateTemplate(certificateId) val template = templateResult.getOrElse { error -> Log.e(TAG, "Failed to fetch certificate template for ID $certificateId: ${error.message}", error) @@ -85,22 +237,25 @@ object CertificateOrchestrator { Log.d(TAG, "Successfully fetched certificate template: ${template.name}") - // Step 2: Create certificate installer (use provided or create default) + // Step 3: Create certificate installer (use provided or create default) val installer = certificateInstaller ?: AndroidCertificateInstaller(context) - // Step 3: Create enrollment handler + // Step 4: Create enrollment handler val handler = CertificateEnrollmentHandler( scepClient = scepClient, certificateInstaller = installer, ) - // Step 4: Perform enrollment + // Step 5: 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}") + + // Store certificate installation in DataStore + storeCertificateInstallation(context, certificateId, result.alias) } is CertificateEnrollmentHandler.EnrollmentResult.Failure -> { Log.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception) diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt index 15a9c04969..f2798bca9f 100644 --- a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt +++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt @@ -61,7 +61,7 @@ class MainActivity : ComponentActivity() { 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("host_uuids")) } + val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("host_uuid")) } val certRequestList by remember { mutableStateOf(appRestrictions.getParcelableArray("certificates", Bundle::class.java)?.toList()) } diff --git a/tools/snapshot/snapshot.go b/tools/snapshot/snapshot.go index c97ae0ea0e..cc9887f469 100644 --- a/tools/snapshot/snapshot.go +++ b/tools/snapshot/snapshot.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + "slices" + "time" "github.com/manifoldco/promptui" // Force promptui to use our newer x/sys package, @@ -17,10 +19,14 @@ import ( // Each snapshot folder contains a db.sql.gz file. type Snapshot struct { Name string - Date string + Date time.Time Path string // The directory containing the snapshot. } +func (s Snapshot) DateStr() string { + return s.Date.Format("Jan 02, 2006 03:04:05 PM") +} + // Which command to run. type Command int @@ -94,57 +100,50 @@ func restore(homedir string) error { // Walk the ~/.fleet/snapshots directory if it exists. dirEntries, err := os.ReadDir(snapshotsDir) var snapshots []Snapshot - var lastSnapshotName []byte + // var lastSnapshotName []byte for _, entry := range dirEntries { if entry.IsDir() { // Ensure there's a db backup file. - dbBackupFile := filepath.Join(snapshotsDir, entry.Name(), "db.sql.gz") - dbBackupFileInfo, err := os.Lstat(dbBackupFile) + subdirEntries, err := os.ReadDir(filepath.Join(snapshotsDir, entry.Name())) if err != nil { continue } - snapshot := Snapshot{ - Name: entry.Name(), - Date: dbBackupFileInfo.ModTime().Format("Jan 02, 2006 03:04:05 PM"), - Path: dbBackupFile, + for _, subentry := range subdirEntries { + dbBackupFile := filepath.Join(snapshotsDir, entry.Name(), subentry.Name()) + snapshotName := subentry.Name() + if snapshotName == "db.sql.gz" { + snapshotName = entry.Name() + } + dbBackupFileInfo, err := os.Lstat(dbBackupFile) + if err != nil { + continue + } + snapshot := Snapshot{ + Name: snapshotName, + Date: dbBackupFileInfo.ModTime(), + Path: dbBackupFile, + } + snapshots = append(snapshots, snapshot) } - snapshots = append(snapshots, snapshot) - } else if entry.Name() == "last_snapshot" { - // If the entry is the "last_snapshot" file, read its contents - lastSnapshotPath := filepath.Join(snapshotsDir, entry.Name()) - lastSnapshotName, err = os.ReadFile(lastSnapshotPath) - if err != nil { - fmt.Printf("Error reading last snapshot file (%s): %v\n", lastSnapshotPath, err) - return err - } - fmt.Println("Last snapshot: " + string(lastSnapshotName)) } } - // If lastSnapshotName is not empty, find its index in the snapshots list. - var lastSnapshotIndex int - if len(lastSnapshotName) > 0 { - for i, snapshot := range snapshots { - if snapshot.Name == string(lastSnapshotName) { - lastSnapshotIndex = i - break - } - } - } + slices.SortFunc(snapshots, func(a, b Snapshot) int { + return b.Date.Compare(a.Date) + }) // Set up and run the "Select snapshot" UI. templates := &promptui.SelectTemplates{ Label: " {{ .Name }}", - Active: "• {{ .Name }} ({{ .Date }})", - Inactive: " {{ .Name }} ({{ .Date }})", - Selected: " {{ .Name }} ({{ .Date }})", + Active: "• {{ .Name }} ({{ .DateStr }})", + Inactive: " {{ .Name }} ({{ .DateStr }})", + Selected: " {{ .Name }} ({{ .DateStr }})", } prompt := promptui.Select{ Label: "Select snapshot to restore", Items: snapshots, Templates: templates, Size: 10, - CursorPos: lastSnapshotIndex, } index, _, err := prompt.Run() if err != nil {