diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt index 7f438086..963cb911 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeAdapter.kt @@ -102,12 +102,21 @@ class HomeAdapter(listener: Listener) : IdBasedRecyclerViewAdapter() { val dateStr = publishTime?.let { io.github.vvb2060.keyattestation.attestation.AuthorizationList.formatDate(it) } ?: "" - + + val locales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales() + val context = if (!locales.isEmpty) { + val config = android.content.res.Configuration(app.resources.configuration) + config.setLocale(locales[0]) + app.createConfigurationContext(config) + } else { + app + } + val statusLine = when (source) { - RevocationList.DataSource.NETWORK_UPDATE -> app.getString(R.string.revocation_status_new_fetch) - RevocationList.DataSource.NETWORK_UP_TO_DATE -> app.getString(R.string.revocation_status_up_to_date) - RevocationList.DataSource.CACHE -> app.getString(R.string.revocation_status_offline_cached) - RevocationList.DataSource.BUNDLED -> app.getString(R.string.revocation_status_offline_bundled) + RevocationList.DataSource.NETWORK_UPDATE -> context.getString(R.string.revocation_status_new_fetch) + RevocationList.DataSource.NETWORK_UP_TO_DATE -> context.getString(R.string.revocation_status_up_to_date) + RevocationList.DataSource.CACHE -> context.getString(R.string.revocation_status_offline_cached) + RevocationList.DataSource.BUNDLED -> context.getString(R.string.revocation_status_offline_bundled) else -> "" } diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt index bdd8b70a..70d02683 100644 --- a/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt +++ b/app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.GetContent import androidx.appcompat.app.AlertDialog @@ -19,7 +20,9 @@ import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.github.vvb2060.keyattestation.AppApplication import io.github.vvb2060.keyattestation.BuildConfig import io.github.vvb2060.keyattestation.R @@ -28,11 +31,13 @@ import io.github.vvb2060.keyattestation.app.AppFragment import io.github.vvb2060.keyattestation.attestation.Attestation import io.github.vvb2060.keyattestation.databinding.HomeBinding import io.github.vvb2060.keyattestation.keystore.KeyStoreManager +import io.github.vvb2060.keyattestation.keystore.RkpRegistrationManager import io.github.vvb2060.keyattestation.lang.AttestationException import io.github.vvb2060.keyattestation.repository.AttestationData -import io.github.vvb2060.keyattestation.util.Status import io.github.vvb2060.keyattestation.util.ColorManager import io.github.vvb2060.keyattestation.util.LocaleManager +import io.github.vvb2060.keyattestation.util.Status +import kotlinx.coroutines.launch import rikka.html.text.HtmlCompat import rikka.html.text.toHtml import rikka.shizuku.Shizuku @@ -175,6 +180,13 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider { viewModel.preferShizuku && viewModel.canIncludeUniqueId menu.findItem(R.id.menu_rkp_test).isVisible = viewModel.preferShizuku && viewModel.canCheckRkp + + menu.findItem(R.id.menu_rkp_register)?.isVisible = + viewModel.preferShizuku && viewModel.canCheckRkp + + menu.findItem(R.id.menu_rkp_unregister)?.isVisible = + viewModel.preferShizuku && viewModel.canCheckRkp + menu.findItem(R.id.menu_use_sak)?.isVisible = viewModel.preferShizuku && viewModel.canSak @@ -243,6 +255,11 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider { viewModel.load() } + R.id.menu_rkp_dump -> { + handleDumpAction() + true + } + R.id.menu_use_sak -> { viewModel.preferSak = status viewModel.load() @@ -291,6 +308,14 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider { R.id.menu_rkp_test -> { viewModel.rkp() } + + R.id.menu_rkp_register -> { + handleRkpAction(RkpRegistrationManager.Action.REGISTER) + } + + R.id.menu_rkp_unregister -> { + handleRkpAction(RkpRegistrationManager.Action.UNREGISTER) + } R.id.menu_reset -> { viewModel.load(true) @@ -317,6 +342,61 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider { return true } + private fun handleRkpAction(action: RkpRegistrationManager.Action) { + val isRegister = action == RkpRegistrationManager.Action.REGISTER + val actionName = if (isRegister) "register" else "unregister" + val title = if (isRegister) "Register RKP Root" else "Unregister RKP Root" + + // 1. Verify Shizuku is active and our app has permission + if (Shizuku.pingBinder() && Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED) { + + // THE HAPPY PATH: Both Root (UID 0) and ADB (UID 2000) can execute pm clear! + MaterialAlertDialogBuilder(requireContext()) + .setTitle(title) + .setMessage("This will $actionName the RKP Root and clear all stored RKP keys.\n\nYour next attestation test will automatically fetch a fresh certificate chain. Proceed?") + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton("PROCEED") { _, _ -> + viewLifecycleOwner.lifecycleScope.launch { + val result = RkpRegistrationManager.performAction(action) + val msg = when (result) { + is RkpRegistrationManager.Result.Success -> result.message + is RkpRegistrationManager.Result.Error -> result.message + } + Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() + } + } + .show() + + } else { + // THE FALLBACK: Shizuku crashed or permission revoked + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Shizuku Unavailable") + .setMessage("Shizuku is not running or permission is denied. Please ensure Shizuku is active (via Root or Wireless Debugging).") + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + private fun handleDumpAction() { + viewLifecycleOwner.lifecycleScope.launch { + android.widget.Toast.makeText(requireContext(), "Dumping RKP Chains...", android.widget.Toast.LENGTH_SHORT).show() + + val result = io.github.vvb2060.keyattestation.keystore.RkpRegistrationManager.dumpCertChains() + + if (result is io.github.vvb2060.keyattestation.keystore.RkpRegistrationManager.Result.Success) { + val sendIntent = android.content.Intent().apply { + action = android.content.Intent.ACTION_SEND + putExtra(android.content.Intent.EXTRA_TEXT, result.message) + type = "text/plain" + } + val shareIntent = android.content.Intent.createChooser(sendIntent, "Share RKP Dump") + startActivity(shareIntent) + } else if (result is io.github.vvb2060.keyattestation.keystore.RkpRegistrationManager.Result.Error) { + android.widget.Toast.makeText(requireContext(), result.message, android.widget.Toast.LENGTH_LONG).show() + } + } + } + private fun showAboutDialog() { val context = requireContext() val text = StringBuilder() diff --git a/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RkpRegistrationManager.kt b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RkpRegistrationManager.kt new file mode 100644 index 00000000..90136f89 --- /dev/null +++ b/app/src/main/java/io/github/vvb2060/keyattestation/keystore/RkpRegistrationManager.kt @@ -0,0 +1,189 @@ +package io.github.vvb2060.keyattestation.keystore + +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import rikka.shizuku.Shizuku +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +object RkpRegistrationManager { + private const val TAG = "RkpRegistration" + private const val RKP_URL = "https://remoteprovisioning.googleapis.com/v1:signCertificates" + + enum class Action(val requestId: String) { + REGISTER("keymint_register_for_new_root"), + UNREGISTER("keymint_unregister") + } + + sealed class Result { + data class Success(val message: String) : Result() + data class Error(val message: String) : Result() + } + + suspend fun performAction(action: Action): Result = withContext(Dispatchers.IO) { + // Defense in Depth: Layer 1 + if (!Shizuku.pingBinder()) { + return@withContext Result.Error("Shizuku is not running or permission denied.") + } + + try { + // 1. Find the supported HAL + val hal = getSupportedHal() + ?: return@withContext Result.Error("No supported KeyMint HAL found.") + + // 2. Generate the CSR via Shizuku shell + val csrBase64 = getCsr(hal) + if (csrBase64.isNullOrBlank()) { + return@withContext Result.Error("Failed to generate CSR from device.") + } + + // 3. Decode base64 to raw bytes + val csrBytes = Base64.decode(csrBase64, Base64.NO_WRAP) + + // 4. Send the POST request + val result = sendRequest(csrBytes, action.requestId) + + // 5. Automatically clear the RKP keys on success so the next test fetches fresh certs + if (result is Result.Success) { + clearRkpKeys() + } + + return@withContext result + + } catch (e: Exception) { + Log.e(TAG, "RKP Action Failed", e) + return@withContext Result.Error("Unexpected error: ${e.message}") + } + } + + // Shizuku recently made newProcess private to encourage using UserService, + // but we cleanly bypass it with reflection for these simple shell commands. + private fun runShizukuCommand(vararg command: String): String { + val clazz = Class.forName("rikka.shizuku.Shizuku") + val method = clazz.getDeclaredMethod( + "newProcess", + Array::class.java, + Array::class.java, + String::class.java + ) + method.isAccessible = true // The magic line that bypasses the 'private' restriction + + val process = method.invoke(null, arrayOf(*command), null, null) as rikka.shizuku.ShizukuRemoteProcess + val output = BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() } + val exitCode = process.waitFor() + + // Stop the logcat from lying to us on silent shell failures + if (exitCode != 0) { + val errorOutput = BufferedReader(InputStreamReader(process.errorStream)).use { it.readText() } + Log.w(TAG, "Command '${command.joinToString(" ")}' failed with code $exitCode: $errorOutput") + } + + return output + } + + private fun getSupportedHal(): String? { + val output = runShizukuCommand("cmd", "remote_provisioning", "list") + return when { + output.contains("default") -> "default" + output.contains("strongbox") -> "strongbox" + else -> null + } + } + + private fun runCommandAndCaptureOutput(command: String): String { + return try { + // The Shizuku.newProcess API is hidden in recent versions, so we bypass it via reflection + val clazz = Class.forName("rikka.shizuku.Shizuku") + val method = clazz.getDeclaredMethod( + "newProcess", + Array::class.java, + Array::class.java, + String::class.java + ) + method.isAccessible = true + + // Execute the shell command and cast the result to a standard Java Process + val process = method.invoke(null, arrayOf("sh", "-c", command), null, null) as java.lang.Process + + // Capture the standard output + val reader = java.io.BufferedReader(java.io.InputStreamReader(process.inputStream)) + val output = reader.readText().trim() + process.waitFor() + output + } catch (e: Exception) { + "Error executing command: ${e.message}" + } + } + + + suspend fun dumpCertChains(): Result { + return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + if (!Shizuku.pingBinder()) { + return@withContext Result.Error("Shizuku is not running.") + } + + val defaultOutput = runCommandAndCaptureOutput("cmd remote_provisioning certify default") + val strongboxOutput = runCommandAndCaptureOutput("cmd remote_provisioning certify strongbox") + + val sb = java.lang.StringBuilder() + + sb.append("--- RKP DEFAULT HAL ---\n") + sb.append(defaultOutput.ifEmpty { "No Default HAL output or unsupported." }) + sb.append("\n\n") + + sb.append("--- RKP STRONGBOX HAL ---\n") + sb.append(strongboxOutput.ifEmpty { "No Strongbox HAL output or unsupported." }) + + // Return the massive string payload in a Success wrapper + Result.Success(sb.toString()) + } + } + + private fun getCsr(hal: String): String? { + val output = runShizukuCommand("cmd", "remote_provisioning", "csr", hal).trim() + return output.ifEmpty { null } + } + + private fun clearRkpKeys() { + try { + Log.i(TAG, "Attempting to clear stored RKP keys...") + // Try clearing both GMS and AOSP daemon packages. + // It will exit with code 1 on the one that doesn't exist, which runShizukuCommand will cleanly log. + runShizukuCommand("pm", "clear", "com.google.android.rkpdapp") + runShizukuCommand("pm", "clear", "com.android.rkpd") + Log.i(TAG, "RKP key clearing commands executed.") + } catch (e: Exception) { + Log.w(TAG, "Failed to execute pm clear commands for RKP keys.", e) + } + } + + private fun sendRequest(csrBytes: ByteArray, requestId: String): Result { + var connection: HttpURLConnection? = null + try { + val url = URL("$RKP_URL?request_id=$requestId") + connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + connection.setRequestProperty("Content-Type", "application/cbor") + connection.doOutput = true + + connection.outputStream.use { it.write(csrBytes) } + + val responseCode = connection.responseCode + return if (responseCode in 200..299) { + Result.Success("Success: $requestId") + } else if (responseCode == 400) { + Result.Success("Device already in requested state (HTTP 400).") + } else { + Result.Error("Server rejected request: HTTP $responseCode") + } + } finally { + connection?.disconnect() + } + } +} diff --git a/app/src/main/res/menu/home.xml b/app/src/main/res/menu/home.xml index 623d6f84..75d1ea47 100644 --- a/app/src/main/res/menu/home.xml +++ b/app/src/main/res/menu/home.xml @@ -91,6 +91,16 @@ android:showAsAction="never" android:title="@string/rkp_test" /> + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b2766b2..e2edfa22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,9 @@ Attest device MEID Include unique ID RKP test + Register RKP root + Unregister RKP root + Reset Load from file Save to file