diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000..aa724b7707 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..27cfdf17fd --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.fleetdm.agent" + compileSdk = 36 + + defaultConfig { + applicationId = "com.fleetdm.agent" + minSdk = 33 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/fleetdm/agent/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/com/fleetdm/agent/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..68660989e7 --- /dev/null +++ b/android/app/src/androidTest/java/com/fleetdm/agent/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.fleetdm.agent + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.fleetdm.agent", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8d247a30aa --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateService.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateService.kt new file mode 100644 index 0000000000..1e2980ec35 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateService.kt @@ -0,0 +1,156 @@ +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.security.PrivateKey +import java.security.cert.Certificate +import java.security.cert.X509Certificate + +/** + * Service to handle SCEP enrollment and silent certificate installation using DevicePolicyManager. + * Runs long-running tasks on the background IO thread via Coroutines. + */ +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) + + // Data structure assumed to be pushed by the MDM for SCEP + data class ScepConfig( + val url: String, + val challenge: String, + val alias: String, // Added alias for silent installation + val subject: String? = null + ) + + // Structure for SCEP result, holding the key and certificate(s) + data class ScepResult( + val privateKey: PrivateKey, + val certificateChain: Array + ) + + + 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 { + val scepConfig = parseScepConfig(certDataJson) + Log.d(TAG, "Parsed SCEP URL: ${scepConfig.url}") + + // Step 1: Execute the SCEP enrollment process + val result = scepEnrollment(scepConfig) + + if (result != null) { + Log.i(TAG, "SCEP enrollment succeeded. Performing silent installation.") + + // Step 2: Perform the silent installation using DPM + installCertificateSilently( + scepConfig.alias, + result.privateKey, + result.certificateChain + ) + } else { + Log.e(TAG, "SCEP enrollment failed or returned empty data.") + } + } catch (e: Exception) { + Log.e(TAG, "Certificate installation failed due to error: ${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 + } + + + /** + * Parses the JSON payload from the MDM into a structured configuration object. + */ + private fun parseScepConfig(jsonString: String): ScepConfig { + return try { + val json = JSONObject(jsonString) + ScepConfig( + url = json.getString("scep_url"), + challenge = json.getString("challenge"), + alias = json.getString("alias"), + subject = json.optString("subject", null) + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse SCEP configuration JSON: ${e.message}") + ScepConfig("", "", "default_alias", "") + } + } + + /** + * !!! SIMPLIFIED PLACEHOLDER: YOUR SCEP CLIENT IMPLEMENTATION GOES HERE !!! + * * In a real app, this function would handle: + * 1. KeyPair Generation (using KeyGenParameterSpec and KeyPairGenerator) + * 2. Certificate Signing Request (CSR) creation. + * 3. Network communication with the SCEP server to get the PKCS#7 response. + * 4. Parsing the response into a PrivateKey object and an Array of X509Certificate objects. + * * @return The resulting ScepResult object containing the key and chain, or null if enrollment fails. + */ + private fun scepEnrollment(config: ScepConfig): ScepResult? { + Log.w(TAG, "--- SCEP Enrollment Placeholder Running ---") + Log.w(TAG, "Your actual SCEP client library must replace this function.") + + // This MUST return a valid PrivateKey and Certificate array for installation to work. + // Returning null to simulate failure for now. + return null + } + + /** + * Performs a silent installation of the KeyPair using the delegated CERT_INSTALL scope. + * This method requires NO user interaction on modern managed devices (API 18+). + */ + private fun installCertificateSilently( + alias: String, + privateKey: PrivateKey, + certificateChain: Array + ) { + 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 + ) + + if (success) { + Log.i(TAG, "Certificate successfully installed silently with alias: $alias") + } else { + Log.e(TAG, "Silent certificate installation failed. Check MDM policy and delegation status.") + } + } + + 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt new file mode 100644 index 0000000000..3d2ea4862d --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt @@ -0,0 +1,47 @@ +package com.fleetdm.agent + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.fleetdm.agent.ui.theme.MyApplicationTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MyApplicationTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Greeting( + name = "Android", + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} + +@Composable +fun Greeting(name: String, modifier: Modifier = Modifier) { + Text( + text = "Hello $name!", + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + MyApplicationTheme { + Greeting("Android") + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/fleetdm/agent/RestrictionsReceiver.kt b/android/app/src/main/java/com/fleetdm/agent/RestrictionsReceiver.kt new file mode 100644 index 0000000000..c2c1227d56 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/RestrictionsReceiver.kt @@ -0,0 +1,39 @@ +package com.fleetdm.agent + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class RestrictionsReceiver : BroadcastReceiver() { + private val TAG = "CertCompanionRestrict" + private val CERT_DATA_KEY = "certificate_data" + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { + Log.i(TAG, "Application restrictions changed. Checking for new certificate data.") + + // Ensure context is not null before proceeding + context?.let { + // 1. Fetch the Managed Configuration (Application Restrictions) + val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager + val appRestrictions = restrictionsManager.applicationRestrictions + + val certData = appRestrictions.getString(CERT_DATA_KEY) + + if (!certData.isNullOrEmpty()) { + Log.d(TAG, "New certificate data found in restrictions.") + + // 2. Start the service to handle the installation asynchronously + val serviceIntent = Intent(it, CertificateService::class.java).apply { + putExtra("CERT_DATA", certData) + } + it.startService(serviceIntent) + + } else { + Log.d(TAG, "No relevant certificate data found for key '$CERT_DATA_KEY'.") + } + } + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/ui/theme/Color.kt b/android/app/src/main/java/com/fleetdm/agent/ui/theme/Color.kt new file mode 100644 index 0000000000..28edc6e450 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.fleetdm.agent.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/android/app/src/main/java/com/fleetdm/agent/ui/theme/Theme.kt b/android/app/src/main/java/com/fleetdm/agent/ui/theme/Theme.kt new file mode 100644 index 0000000000..44c74d3ed7 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.fleetdm.agent.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun MyApplicationTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/com/fleetdm/agent/ui/theme/Type.kt b/android/app/src/main/java/com/fleetdm/agent/ui/theme/Type.kt new file mode 100644 index 0000000000..547ad0796a --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.fleetdm.agent.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f8c6127d32 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..fb81021954 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Fleet Agent + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..e48770ab86 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +