Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> ""
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>::class.java,
Array<String>::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<String>::class.java,
Array<String>::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()
}
}
}
15 changes: 15 additions & 0 deletions app/src/main/res/menu/home.xml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@
android:showAsAction="never"
android:title="@string/rkp_test" />

<item
android:id="@+id/menu_rkp_register"
android:title="@string/rkp_register"
android:showAsAction="never" />

<item
android:id="@+id/menu_rkp_unregister"
android:title="@string/rkp_unregister"
android:showAsAction="never" />

<item
android:id="@+id/menu_reset"
android:showAsAction="never"
Expand All @@ -110,5 +120,10 @@
android:id="@+id/menu_about"
android:showAsAction="never"
android:title="@string/about" />

<item
android:id="@+id/menu_rkp_dump"
android:title="Dump RKP Chains"
android:showAsAction="never" />

</menu>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
<string name="id_type_meid">Attest device MEID</string>
<string name="include_unique_id">Include unique ID</string>
<string name="rkp_test">RKP test</string>
<string name="rkp_register">Register RKP root</string>
<string name="rkp_unregister">Unregister RKP root</string>

<string name="reset">Reset</string>
<string name="load_certs">Load from file</string>
<string name="save_certs">Save to file</string>
Expand Down
Loading