Compare commits
15
Commits
v6.0.0-235
...
v6.0.1-251
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42842e22c0 | ||
|
|
77fc96db37 | ||
|
|
b27a33b444 | ||
|
|
bae65ac47c | ||
|
|
217a5dc7f3 | ||
|
|
0229368c04 | ||
|
|
0f61bb841a | ||
|
|
50f2e98375 | ||
|
|
f4e2619eba | ||
|
|
649136ab4e | ||
|
|
d155a0ded6 | ||
|
|
edac284972 | ||
|
|
cbb73a0b0e | ||
|
|
3140ff5e96 | ||
|
|
8544aac260 |
@@ -30,7 +30,7 @@ val gitExecutor = objects.newInstance(GitExecutor::class.java)
|
|||||||
|
|
||||||
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
|
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
|
||||||
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
|
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
|
||||||
val verName = "v6.0.0"
|
val verName = "v6.0.1"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.matrix.TEESimulator"
|
namespace = "org.matrix.TEESimulator"
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import android.content.Context
|
|||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import java.io.File
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
import org.matrix.TEESimulator.config.BootStateManager
|
import org.matrix.TEESimulator.config.BootStateManager
|
||||||
import org.matrix.TEESimulator.config.BulletinPoller
|
|
||||||
import org.matrix.TEESimulator.config.ConfigurationManager
|
import org.matrix.TEESimulator.config.ConfigurationManager
|
||||||
import org.matrix.TEESimulator.config.PatchLevelManager
|
|
||||||
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
||||||
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
||||||
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
|
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
|
||||||
@@ -41,12 +40,12 @@ object App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
purgeDebugDiagnostics()
|
||||||
prepareEnvironment()
|
prepareEnvironment()
|
||||||
|
|
||||||
// Spoof boot-state and patch-level props before any hook attaches,
|
// Spoof boot-state props before any hook attaches, so keystore2's
|
||||||
// so keystore2's cached snapshot reflects the spoofed values.
|
// cached snapshot reflects the spoofed values.
|
||||||
BootStateManager.apply()
|
BootStateManager.apply()
|
||||||
PatchLevelManager.initialize()
|
|
||||||
|
|
||||||
// Load the package configuration.
|
// Load the package configuration.
|
||||||
ConfigurationManager.initialize()
|
ConfigurationManager.initialize()
|
||||||
@@ -65,12 +64,6 @@ object App {
|
|||||||
|
|
||||||
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
|
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
|
||||||
|
|
||||||
try {
|
|
||||||
BulletinPoller.start()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
SystemLogger.error("Failed to start BulletinPoller", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This starts the message queue processing. It blocks here indefinitely
|
// This starts the message queue processing. It blocks here indefinitely
|
||||||
// processing messages until Looper.myLooper().quit() is called.
|
// processing messages until Looper.myLooper().quit() is called.
|
||||||
Looper.loop()
|
Looper.loop()
|
||||||
@@ -80,6 +73,25 @@ object App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release builds never emit diagnostics. Sweep any `.bin` dumps a prior
|
||||||
|
* debug install left in the world-readable temp dir so they can't act as a
|
||||||
|
* detection artifact for apps that probe /data/local/tmp.
|
||||||
|
*/
|
||||||
|
private fun purgeDebugDiagnostics() {
|
||||||
|
if (SystemLogger.isDebugBuild) return
|
||||||
|
val stale =
|
||||||
|
File("/data/local/tmp").listFiles { _, name ->
|
||||||
|
name.startsWith("teesim-") && name.endsWith(".bin")
|
||||||
|
} ?: return
|
||||||
|
stale.forEach { runCatching { it.delete() } }
|
||||||
|
if (stale.isNotEmpty()) {
|
||||||
|
// warning() bypasses the rate limiter, so this once-per-boot audit
|
||||||
|
// line survives the noisy startup window.
|
||||||
|
SystemLogger.warning("Purged ${stale.size} stale debug diagnostic(s) from /data/local/tmp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Initializes the necessary Android framework internals to satisfy KeyStore requirements. */
|
/** Initializes the necessary Android framework internals to satisfy KeyStore requirements. */
|
||||||
private fun prepareEnvironment() {
|
private fun prepareEnvironment() {
|
||||||
// 1. Prepare Main Looper
|
// 1. Prepare Main Looper
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.matrix.TEESimulator.attestation
|
package org.matrix.TEESimulator.attestation
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Build
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import java.security.KeyPairGenerator
|
import java.security.KeyPairGenerator
|
||||||
@@ -60,12 +61,23 @@ object DeviceAttestationService {
|
|||||||
// A unique alias for the key used to perform the TEE functionality check.
|
// A unique alias for the key used to perform the TEE functionality check.
|
||||||
private const val TEE_CHECK_KEY_ALIAS = "TEESimulator_AttestationCheck"
|
private const val TEE_CHECK_KEY_ALIAS = "TEESimulator_AttestationCheck"
|
||||||
|
|
||||||
|
// Alias for the device-ID attestation capability probe.
|
||||||
|
private const val DEVICE_ID_CHECK_KEY_ALIAS = "TEESimulator_DeviceIdCheck"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily determines if the device's TEE is functional by attempting to generate an
|
* Lazily determines if the device's TEE is functional by attempting to generate an
|
||||||
* attestation-backed key pair. The result is cached.
|
* attestation-backed key pair. The result is cached.
|
||||||
*/
|
*/
|
||||||
val isTeeFunctional: Boolean by lazy { checkTeeFunctionality() }
|
val isTeeFunctional: Boolean by lazy { checkTeeFunctionality() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily mirrors whether the real TEE can attest device identifiers/properties (the tags added
|
||||||
|
* by `setDevicePropertiesAttestationIncluded`). Hardware that never provisioned device IDs
|
||||||
|
* returns CANNOT_ATTEST_IDS; the synthesizer consults this so it never forges a capability the
|
||||||
|
* real silicon lacks. Cached.
|
||||||
|
*/
|
||||||
|
val canAttestDeviceIds: Boolean by lazy { checkDeviceIdAttestation() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily fetches and parses attestation data from a genuinely generated certificate. The result
|
* Lazily fetches and parses attestation data from a genuinely generated certificate. The result
|
||||||
* is cached. Returns null if the TEE is not functional or parsing fails.
|
* is cached. Returns null if the TEE is not functional or parsing fails.
|
||||||
@@ -106,6 +118,37 @@ object DeviceAttestationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probes whether the real TEE can satisfy device-ID/property attestation, mirroring its actual
|
||||||
|
* capability. Gated behind [isTeeFunctional] so a dead TEE never triggers a second doomed
|
||||||
|
* probe — it simply reports `false` (cannot attest), the faithful result for such hardware.
|
||||||
|
*/
|
||||||
|
private fun checkDeviceIdAttestation(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
|
||||||
|
if (!isTeeFunctional) return false
|
||||||
|
return try {
|
||||||
|
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
val keyPairGenerator =
|
||||||
|
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
|
||||||
|
val challenge = ByteArray(16).apply { SecureRandom().nextBytes(this) }
|
||||||
|
val spec =
|
||||||
|
KeyGenParameterSpec.Builder(DEVICE_ID_CHECK_KEY_ALIAS, KeyProperties.PURPOSE_SIGN)
|
||||||
|
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||||
|
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||||
|
.setAttestationChallenge(challenge)
|
||||||
|
.setDevicePropertiesAttestationIncluded(true)
|
||||||
|
.build()
|
||||||
|
keyPairGenerator.initialize(spec)
|
||||||
|
keyPairGenerator.generateKeyPair()
|
||||||
|
runCatching { keyStore.deleteEntry(DEVICE_ID_CHECK_KEY_ALIAS) }
|
||||||
|
SystemLogger.info("Device-ID attestation supported by TEE.")
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
SystemLogger.info("Device-ID attestation not supported by TEE; mirroring as cannot-attest.")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the attestation certificate generated during the TEE check. The key entry is
|
* Retrieves the attestation certificate generated during the TEE check. The key entry is
|
||||||
* deleted after retrieval to clean up.
|
* deleted after retrieval to clean up.
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
package org.matrix.TEESimulator.config
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.HandlerThread
|
|
||||||
import java.io.File
|
|
||||||
import java.net.URL
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.StandardCopyOption
|
|
||||||
import javax.net.ssl.HttpsURLConnection
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.matrix.TEESimulator.BuildConfig
|
|
||||||
import org.matrix.TEESimulator.logging.SystemLogger
|
|
||||||
|
|
||||||
object BulletinPoller {
|
|
||||||
private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
|
|
||||||
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
|
|
||||||
private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
|
|
||||||
private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
|
|
||||||
private const val HISTORY_CAP = 10
|
|
||||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
|
||||||
private const val READ_TIMEOUT_MS = 15_000
|
|
||||||
private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
|
|
||||||
|
|
||||||
private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
|
|
||||||
private val DATE_REGEX = Regex("<td>(\\d{4}-\\d{2}-\\d{2})</td>")
|
|
||||||
private val PATCH_DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
|
||||||
|
|
||||||
private lateinit var handler: Handler
|
|
||||||
@Volatile private var bootstrapStep = 0
|
|
||||||
@Volatile private var steadyArmed = false
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
val thread = HandlerThread("BulletinPoller").apply { start() }
|
|
||||||
handler = Handler(thread.looper)
|
|
||||||
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pollOnce() {
|
|
||||||
try {
|
|
||||||
val result = fetchAndParse()
|
|
||||||
appendHistory(result)
|
|
||||||
scheduleNext(result.status == "success")
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
SystemLogger.error("BulletinPoller: pollOnce failed", t)
|
|
||||||
scheduleNext(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleNext(success: Boolean) {
|
|
||||||
if (success || steadyArmed) {
|
|
||||||
steadyArmed = true
|
|
||||||
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bootstrapStep++
|
|
||||||
if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
|
|
||||||
steadyArmed = true
|
|
||||||
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
|
|
||||||
} else {
|
|
||||||
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class FetchResult(
|
|
||||||
val ts: Long,
|
|
||||||
val status: String,
|
|
||||||
val httpCode: Int?,
|
|
||||||
val parsedDate: String?,
|
|
||||||
val applied: Boolean,
|
|
||||||
val error: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun fetchAndParse(): FetchResult {
|
|
||||||
val ts = System.currentTimeMillis()
|
|
||||||
var conn: HttpsURLConnection? = null
|
|
||||||
return try {
|
|
||||||
conn =
|
|
||||||
(URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
|
|
||||||
connectTimeout = CONNECT_TIMEOUT_MS
|
|
||||||
readTimeout = READ_TIMEOUT_MS
|
|
||||||
setRequestProperty(
|
|
||||||
"User-Agent",
|
|
||||||
"TEESimulator/${BuildConfig.VERSION_NAME}",
|
|
||||||
)
|
|
||||||
requestMethod = "GET"
|
|
||||||
}
|
|
||||||
val code = conn.responseCode
|
|
||||||
if (code != 200) {
|
|
||||||
return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
|
|
||||||
}
|
|
||||||
val html = conn.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
val date = DATE_REGEX.find(html)?.groupValues?.get(1)
|
|
||||||
if (date == null) {
|
|
||||||
return FetchResult(
|
|
||||||
ts,
|
|
||||||
"parse_error",
|
|
||||||
code,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
"no <td>YYYY-MM-DD</td> match",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val current = currentPatch()
|
|
||||||
if (current == null || date <= current) {
|
|
||||||
return FetchResult(ts, "success", code, date, false, null)
|
|
||||||
}
|
|
||||||
if (PatchLevelManager.updateTo(date)) {
|
|
||||||
FetchResult(ts, "success", code, date, true, null)
|
|
||||||
} else {
|
|
||||||
FetchResult(
|
|
||||||
ts,
|
|
||||||
"validation_rejected",
|
|
||||||
code,
|
|
||||||
date,
|
|
||||||
false,
|
|
||||||
"PatchLevelManager.updateTo rejected $date",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
FetchResult(ts, "network_error", null, null, false, e.toString())
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentPatch(): String? {
|
|
||||||
val f = File(PATCH_FILE)
|
|
||||||
if (!f.exists()) return null
|
|
||||||
val raw = try {
|
|
||||||
f.readLines()
|
|
||||||
.firstOrNull { it.startsWith("system=") }
|
|
||||||
?.substringAfter("system=")
|
|
||||||
?.trim()
|
|
||||||
?.takeIf { it != "prop" && it.isNotEmpty() }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (raw == null) return null
|
|
||||||
if (PATCH_DATE_PATTERN.matches(raw)) return raw
|
|
||||||
SystemLogger.warning(
|
|
||||||
"BulletinPoller: ignoring malformed system='$raw' in $PATCH_FILE"
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun appendHistory(result: FetchResult) {
|
|
||||||
try {
|
|
||||||
val target = File(HISTORY_FILE)
|
|
||||||
val staging = File(HISTORY_STAGING)
|
|
||||||
val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
|
|
||||||
val history =
|
|
||||||
existing
|
|
||||||
?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
|
|
||||||
?: JSONArray()
|
|
||||||
val entry =
|
|
||||||
JSONObject().apply {
|
|
||||||
put("ts", result.ts)
|
|
||||||
put("status", result.status)
|
|
||||||
put("http_code", result.httpCode ?: JSONObject.NULL)
|
|
||||||
put("parsed_date", result.parsedDate ?: JSONObject.NULL)
|
|
||||||
put("applied", result.applied)
|
|
||||||
put("error", result.error ?: JSONObject.NULL)
|
|
||||||
}
|
|
||||||
history.put(entry)
|
|
||||||
while (history.length() > HISTORY_CAP) history.remove(0)
|
|
||||||
|
|
||||||
val latestKnown =
|
|
||||||
(0 until history.length())
|
|
||||||
.mapNotNull {
|
|
||||||
history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
|
|
||||||
d.isNotBlank()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.lastOrNull()
|
|
||||||
|
|
||||||
val root =
|
|
||||||
JSONObject().apply {
|
|
||||||
put("latest_known_date", latestKnown ?: JSONObject.NULL)
|
|
||||||
put("history", history)
|
|
||||||
}
|
|
||||||
staging.writeText(root.toString(2))
|
|
||||||
Files.move(
|
|
||||||
staging.toPath(),
|
|
||||||
target.toPath(),
|
|
||||||
StandardCopyOption.ATOMIC_MOVE,
|
|
||||||
StandardCopyOption.REPLACE_EXISTING,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
SystemLogger.error("BulletinPoller: failed to persist history", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package org.matrix.TEESimulator.config
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.FileObserver
|
|
||||||
import android.os.SystemProperties
|
|
||||||
import java.io.File
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.StandardCopyOption
|
|
||||||
import java.time.LocalDate
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.matrix.TEESimulator.logging.SystemLogger
|
|
||||||
import org.matrix.TEESimulator.util.AndroidDeviceUtils
|
|
||||||
|
|
||||||
object PatchLevelManager {
|
|
||||||
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
|
|
||||||
private const val STAGING_FILE = "/data/adb/tricky_store/security_patch.txt.next"
|
|
||||||
private const val PIF_DIR = "/data/adb/modules/playintegrityfix"
|
|
||||||
private const val FLOOR_YYYYMMDD = 20200101
|
|
||||||
private const val MAX_PAST_OFFSET = 10000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pixel security bulletins publish monthly; pre-announced dates occasionally
|
|
||||||
* slip by 2-4 weeks. 60 days covers that window without admitting a
|
|
||||||
* far-future date from a hostile or mis-parsed bulletin response.
|
|
||||||
*/
|
|
||||||
private const val MAX_FUTURE_DAYS = 60L
|
|
||||||
|
|
||||||
private val PIF_FILENAMES =
|
|
||||||
setOf("pif.json", "pif.prop", "custom.pif.json", "custom.pif.prop")
|
|
||||||
|
|
||||||
private val DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
|
||||||
private val PROP_PATTERN = Regex("^SECURITY_PATCH=(.+)$", RegexOption.MULTILINE)
|
|
||||||
private val SECTION_HEADER = Regex("^\\[[a-zA-Z0-9_.-]+]$")
|
|
||||||
private val GLOBAL_KEYS = setOf("system", "boot", "vendor", "all")
|
|
||||||
|
|
||||||
private val PIF_SOURCES =
|
|
||||||
listOf(
|
|
||||||
"/data/adb/modules/playintegrityfix/pif.json",
|
|
||||||
"/data/adb/pif.json",
|
|
||||||
"/data/adb/modules/playintegrityfix/pif.prop",
|
|
||||||
"/data/adb/pif.prop",
|
|
||||||
"/data/adb/modules/playintegrityfix/custom.pif.json",
|
|
||||||
"/data/adb/modules/playintegrityfix/custom.pif.prop",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
refreshFromSources()
|
|
||||||
startPifObserver()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshFromSources() {
|
|
||||||
val date =
|
|
||||||
resolvePifPatch()
|
|
||||||
?: SystemProperties.get(
|
|
||||||
"ro.build.version.security_patch",
|
|
||||||
Build.VERSION.SECURITY_PATCH,
|
|
||||||
)
|
|
||||||
SystemLogger.info("PatchLevelManager: resolved patch date = $date")
|
|
||||||
applyToProps(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPifObserver() {
|
|
||||||
if (!File(PIF_DIR).exists()) {
|
|
||||||
SystemLogger.debug("PatchLevelManager: PIF dir absent, hot-reload disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
PifObserver.startWatching()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun applyToProps(date: String) {
|
|
||||||
if (!DATE_PATTERN.matches(date)) {
|
|
||||||
SystemLogger.warning(
|
|
||||||
"PatchLevelManager: skip resetprop for invalid date: $date"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
AndroidDeviceUtils.setProperty("ro.build.version.security_patch", date)
|
|
||||||
AndroidDeviceUtils.setProperty("ro.vendor.build.security_patch", date)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateTo(date: String): Boolean {
|
|
||||||
if (!DATE_PATTERN.matches(date)) {
|
|
||||||
SystemLogger.warning("PatchLevelManager: invalid date format: $date")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val dateInt = date.replace("-", "").toInt()
|
|
||||||
if (dateInt < FLOOR_YYYYMMDD) {
|
|
||||||
SystemLogger.warning("PatchLevelManager: $date below floor $FLOOR_YYYYMMDD")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val now = LocalDate.now()
|
|
||||||
val today = now.year * 10000 + now.monthValue * 100 + now.dayOfMonth
|
|
||||||
if (today >= dateInt + MAX_PAST_OFFSET) {
|
|
||||||
SystemLogger.warning(
|
|
||||||
"PatchLevelManager: $date more than 1y older than today ($today)"
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val maxFuture =
|
|
||||||
now.plusDays(MAX_FUTURE_DAYS).let {
|
|
||||||
it.year * 10000 + it.monthValue * 100 + it.dayOfMonth
|
|
||||||
}
|
|
||||||
if (dateInt > maxFuture) {
|
|
||||||
SystemLogger.warning(
|
|
||||||
"PatchLevelManager: $date more than $MAX_FUTURE_DAYS days in future ($maxFuture)"
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
atomicWrite(date)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
SystemLogger.error("PatchLevelManager: atomicWrite failed for $date", e)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
applyToProps(date)
|
|
||||||
SystemLogger.info("PatchLevelManager: applied patch date $date")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolvePifPatch(): String? {
|
|
||||||
val source =
|
|
||||||
PIF_SOURCES.map(::File).lastOrNull { it.exists() && it.length() > 0 }
|
|
||||||
?: return null
|
|
||||||
return try {
|
|
||||||
val text = source.readText()
|
|
||||||
val parsed =
|
|
||||||
if (source.name.endsWith(".json")) {
|
|
||||||
JSONObject(text).optString("SECURITY_PATCH", "")
|
|
||||||
} else {
|
|
||||||
PROP_PATTERN.find(text)?.groupValues?.get(1)?.trim().orEmpty()
|
|
||||||
}
|
|
||||||
parsed.takeIf { it.isNotBlank() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
SystemLogger.warning(
|
|
||||||
"PatchLevelManager: failed to parse ${source.path}: ${e.message}"
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun atomicWrite(date: String) {
|
|
||||||
val target = File(PATCH_FILE)
|
|
||||||
val staging = File(STAGING_FILE)
|
|
||||||
staging.writeText(mergedContents(target, date))
|
|
||||||
Files.move(
|
|
||||||
staging.toPath(),
|
|
||||||
target.toPath(),
|
|
||||||
StandardCopyOption.ATOMIC_MOVE,
|
|
||||||
StandardCopyOption.REPLACE_EXISTING,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergedContents(target: File, date: String): String {
|
|
||||||
val globalBlock = "system=$date\nboot=$date\nvendor=$date\n"
|
|
||||||
if (!target.exists()) return globalBlock
|
|
||||||
val tail = stripGlobalAssignments(target.readLines())
|
|
||||||
if (tail.isEmpty()) return globalBlock
|
|
||||||
return globalBlock + tail.joinToString("\n", prefix = "\n", postfix = "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stripGlobalAssignments(lines: List<String>): List<String> {
|
|
||||||
val kept = mutableListOf<String>()
|
|
||||||
var inGlobal = true
|
|
||||||
for (line in lines) {
|
|
||||||
val trimmed = line.trim()
|
|
||||||
if (SECTION_HEADER.matches(trimmed)) {
|
|
||||||
inGlobal = false
|
|
||||||
kept += line
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (inGlobal && isGlobalKeyAssignment(trimmed)) continue
|
|
||||||
kept += line
|
|
||||||
}
|
|
||||||
return kept
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isGlobalKeyAssignment(trimmed: String): Boolean {
|
|
||||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || '=' !in trimmed) return false
|
|
||||||
val key = trimmed.substringBefore('=').trim().lowercase()
|
|
||||||
return key in GLOBAL_KEYS
|
|
||||||
}
|
|
||||||
|
|
||||||
private object PifObserver :
|
|
||||||
FileObserver(File(PIF_DIR), CLOSE_WRITE or MOVED_TO or DELETE) {
|
|
||||||
override fun onEvent(event: Int, path: String?) {
|
|
||||||
if (path == null || path !in PIF_FILENAMES) return
|
|
||||||
SystemLogger.info("PatchLevelManager: PIF change ($path), refreshing")
|
|
||||||
refreshFromSources()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -127,7 +127,7 @@ object InterceptorUtils {
|
|||||||
val savedPos = parcel.dataPosition()
|
val savedPos = parcel.dataPosition()
|
||||||
val wire = parcel.marshall()
|
val wire = parcel.marshall()
|
||||||
parcel.setDataPosition(savedPos)
|
parcel.setDataPosition(savedPos)
|
||||||
val path = "/data/local/tmp/teesim-$diagnosticTag-${System.nanoTime()}.bin"
|
val path = "/data/local/tmp/teesim-$diagnosticTag.bin"
|
||||||
runCatching { java.io.File(path).writeBytes(wire) }
|
runCatching { java.io.File(path).writeBytes(wire) }
|
||||||
SystemLogger.debug("[$diagnosticTag] reply len=${wire.size} path=$path")
|
SystemLogger.debug("[$diagnosticTag] reply len=${wire.size} path=$path")
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-4
@@ -5,6 +5,7 @@ import android.hardware.security.keymint.SecurityLevel
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
|
import android.os.ServiceManager
|
||||||
import android.system.keystore2.Domain
|
import android.system.keystore2.Domain
|
||||||
import android.system.keystore2.IKeystoreService
|
import android.system.keystore2.IKeystoreService
|
||||||
import android.system.keystore2.KeyDescriptor
|
import android.system.keystore2.KeyDescriptor
|
||||||
@@ -48,6 +49,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
else null
|
else null
|
||||||
private val GET_NUMBER_OF_ENTRIES_TRANSACTION =
|
private val GET_NUMBER_OF_ENTRIES_TRANSACTION =
|
||||||
InterceptorUtils.getTransactCode(stubBinderClass, "getNumberOfEntries")
|
InterceptorUtils.getTransactCode(stubBinderClass, "getNumberOfEntries")
|
||||||
|
private val GRANT_TRANSACTION = InterceptorUtils.getTransactCode(stubBinderClass, "grant")
|
||||||
|
private val UNGRANT_TRANSACTION = InterceptorUtils.getTransactCode(stubBinderClass, "ungrant")
|
||||||
|
|
||||||
private val transactionNames: Map<Int, String> by lazy {
|
private val transactionNames: Map<Int, String> by lazy {
|
||||||
stubBinderClass.declaredFields
|
stubBinderClass.declaredFields
|
||||||
@@ -59,6 +62,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val RESPONSE_KEY_NOT_FOUND = 7
|
private const val RESPONSE_KEY_NOT_FOUND = 7
|
||||||
|
private const val RESPONSE_PERMISSION_DENIED = 6
|
||||||
|
|
||||||
|
// KeyStoreManager.grantKeyAccess() became a public app API in Android 16 (API 36). Before that,
|
||||||
|
// grant was a hidden API and SELinux denied untrusted_app, so a synthetic-key grant must answer
|
||||||
|
// PERMISSION_DENIED pre-36 and a coherent virtualized grant on 36+.
|
||||||
|
private const val GRANT_PUBLIC_API_SDK = 36
|
||||||
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
|
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
|
||||||
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
|
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
|
||||||
|
|
||||||
@@ -80,6 +89,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
LIST_ENTRIES_TRANSACTION,
|
LIST_ENTRIES_TRANSACTION,
|
||||||
LIST_ENTRIES_BATCHED_TRANSACTION,
|
LIST_ENTRIES_BATCHED_TRANSACTION,
|
||||||
GET_NUMBER_OF_ENTRIES_TRANSACTION,
|
GET_NUMBER_OF_ENTRIES_TRANSACTION,
|
||||||
|
GRANT_TRANSACTION,
|
||||||
|
UNGRANT_TRANSACTION,
|
||||||
)
|
)
|
||||||
.toIntArray()
|
.toIntArray()
|
||||||
}
|
}
|
||||||
@@ -91,6 +102,27 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
override fun onInterceptorReady(service: IBinder, backdoor: IBinder) {
|
override fun onInterceptorReady(service: IBinder, backdoor: IBinder) {
|
||||||
val keystoreInterface = IKeystoreService.Stub.asInterface(service)
|
val keystoreInterface = IKeystoreService.Stub.asInterface(service)
|
||||||
setupSecurityLevelInterceptors(keystoreInterface, backdoor)
|
setupSecurityLevelInterceptors(keystoreInterface, backdoor)
|
||||||
|
setupMaintenanceInterceptor(backdoor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks the keystore2 daemon's `android.security.maintenance` binder, which is hosted by the
|
||||||
|
* same process, so synthetic key state follows real key-lifecycle events. Best-effort: if the
|
||||||
|
* service is absent the synthetic plane simply forgoes lifecycle parity.
|
||||||
|
*/
|
||||||
|
private fun setupMaintenanceInterceptor(backdoor: IBinder) {
|
||||||
|
runCatching {
|
||||||
|
ServiceManager.getService("android.security.maintenance")?.let { maintenance ->
|
||||||
|
SystemLogger.info("Found maintenance binder. Registering interceptor...")
|
||||||
|
register(
|
||||||
|
backdoor,
|
||||||
|
maintenance,
|
||||||
|
Keystore2MaintenanceInterceptor,
|
||||||
|
Keystore2MaintenanceInterceptor.interceptedCodes,
|
||||||
|
)
|
||||||
|
} ?: SystemLogger.warning("Maintenance binder not found; skipping lifecycle parity.")
|
||||||
|
}
|
||||||
|
.onFailure { SystemLogger.error("Failed to intercept maintenance binder.", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSecurityLevelInterceptors(service: IKeystoreService, backdoor: IBinder) {
|
private fun setupSecurityLevelInterceptors(service: IKeystoreService, backdoor: IBinder) {
|
||||||
@@ -175,17 +207,46 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
) {
|
) {
|
||||||
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||||
|
|
||||||
if (ConfigurationManager.shouldSkipUid(callingUid))
|
if (code == UPDATE_SUBCOMPONENT_TRANSACTION) {
|
||||||
return TransactionResult.ContinueAndSkipPost
|
if (ConfigurationManager.shouldSkipUid(callingUid))
|
||||||
|
return TransactionResult.ContinueAndSkipPost
|
||||||
if (code == UPDATE_SUBCOMPONENT_TRANSACTION)
|
|
||||||
return handleUpdateSubcomponent(callingUid, data)
|
return handleUpdateSubcomponent(callingUid, data)
|
||||||
|
}
|
||||||
|
|
||||||
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||||
val descriptor =
|
val descriptor =
|
||||||
data.readTypedObject(KeyDescriptor.CREATOR)
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
?: return TransactionResult.ContinueAndSkipPost
|
?: return TransactionResult.ContinueAndSkipPost
|
||||||
|
|
||||||
|
// Domain.GRANT read (Android 16+ KeyStoreManager grant). Served for ANY grantee uid —
|
||||||
|
// including isolated services (bindIsolatedService) with no package mapping — so resolve
|
||||||
|
// it before the package-scoped skip; caller-binding in resolveGrant() is the real access
|
||||||
|
// gate. On Android <= 15 no grants are ever issued (grant() denies), so softwareGrants is
|
||||||
|
// empty and this falls through to the real keystore2.
|
||||||
|
if (code == GET_KEY_ENTRY_TRANSACTION && descriptor.domain == Domain.GRANT) {
|
||||||
|
val grant =
|
||||||
|
KeyMintSecurityLevelInterceptor.resolveGrant(descriptor.nspace, callingUid)
|
||||||
|
if (grant == null) {
|
||||||
|
// Ours but wrong caller -> KEY_NOT_FOUND (caller-binding); not ours -> real keystore2.
|
||||||
|
return if (
|
||||||
|
KeyMintSecurityLevelInterceptor.softwareGrants.containsKey(descriptor.nspace)
|
||||||
|
)
|
||||||
|
InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
|
||||||
|
else TransactionResult.ContinueAndSkipPost
|
||||||
|
}
|
||||||
|
if ((grant.accessVector and 0x4) == 0) { // GET_INFO = 0x4 (access-vector gate)
|
||||||
|
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
|
||||||
|
}
|
||||||
|
val response =
|
||||||
|
KeyMintSecurityLevelInterceptor.getGeneratedKeyResponse(grant.ownerKeyId)
|
||||||
|
?: return InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
|
||||||
|
// Same object the owner read returns -> coherent chain across planes.
|
||||||
|
return InterceptorUtils.createTypedObjectReply(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConfigurationManager.shouldSkipUid(callingUid))
|
||||||
|
return TransactionResult.ContinueAndSkipPost
|
||||||
|
|
||||||
if (code == DELETE_KEY_TRANSACTION) {
|
if (code == DELETE_KEY_TRANSACTION) {
|
||||||
val keyId =
|
val keyId =
|
||||||
if (descriptor.alias != null) {
|
if (descriptor.alias != null) {
|
||||||
@@ -247,6 +308,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
return InterceptorUtils.createTypedObjectReply(teeResp)
|
return InterceptorUtils.createTypedObjectReply(teeResp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Domain.GRANT is handled earlier (before the package-scoped skip); an alias-less
|
||||||
|
// read reaching here is KEY_ID or unknown, so it falls through to the real keystore2.
|
||||||
return TransactionResult.ContinueAndSkipPost
|
return TransactionResult.ContinueAndSkipPost
|
||||||
}
|
}
|
||||||
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
||||||
@@ -268,6 +331,57 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
KeyMintParameterLogger.logParameter(it.keyParameter)
|
KeyMintParameterLogger.logParameter(it.keyParameter)
|
||||||
}
|
}
|
||||||
return InterceptorUtils.createTypedObjectReply(response)
|
return InterceptorUtils.createTypedObjectReply(response)
|
||||||
|
} else if (code == GRANT_TRANSACTION) {
|
||||||
|
logTransaction(txId, transactionNames[code] ?: "grant", callingUid, callingPid)
|
||||||
|
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||||
|
val key =
|
||||||
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
|
?: return TransactionResult.ContinueAndSkipPost
|
||||||
|
val granteeUid = data.readInt()
|
||||||
|
val accessVector = data.readInt()
|
||||||
|
// Synthetic (generatedKeys) AND patch-mode (teeResponses) keys are ours; both must grant
|
||||||
|
// coherently so the Domain.GRANT readback returns the same chain the owner read returns.
|
||||||
|
// Real hardware keys fall through to the real keystore2, which applies the same SELinux
|
||||||
|
// gate the platform would.
|
||||||
|
val ownerKeyId =
|
||||||
|
resolveOwnerKeyId(key, callingUid)
|
||||||
|
?.takeIf { KeyMintSecurityLevelInterceptor.ownsKeyResponse(it) }
|
||||||
|
?: return TransactionResult.ContinueAndSkipPost
|
||||||
|
// Version-gated to mirror the real TEE 1:1. Pre-Android-16, grant was a hidden API and
|
||||||
|
// SELinux denied untrusted_app, so keystore2 returns PERMISSION_DENIED. Android 16
|
||||||
|
// (API 36) exposes KeyStoreManager.grantKeyAccess(), so an app grants its own key:
|
||||||
|
// issue a coherent, caller-bound, access-vector-carrying grant whose Domain.GRANT read
|
||||||
|
// returns the owner's chain.
|
||||||
|
if (Build.VERSION.SDK_INT < GRANT_PUBLIC_API_SDK) {
|
||||||
|
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
|
||||||
|
}
|
||||||
|
val grantId =
|
||||||
|
KeyMintSecurityLevelInterceptor.issueGrant(ownerKeyId, granteeUid, accessVector)
|
||||||
|
val reply =
|
||||||
|
KeyDescriptor().apply {
|
||||||
|
domain = Domain.GRANT
|
||||||
|
nspace = grantId
|
||||||
|
alias = null
|
||||||
|
blob = null
|
||||||
|
}
|
||||||
|
return InterceptorUtils.createTypedObjectReply(reply)
|
||||||
|
} else if (code == UNGRANT_TRANSACTION) {
|
||||||
|
logTransaction(txId, transactionNames[code] ?: "ungrant", callingUid, callingPid)
|
||||||
|
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||||
|
val key =
|
||||||
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
|
?: return TransactionResult.ContinueAndSkipPost
|
||||||
|
val granteeUid = data.readInt()
|
||||||
|
val ownerKeyId =
|
||||||
|
resolveOwnerKeyId(key, callingUid)
|
||||||
|
?.takeIf { KeyMintSecurityLevelInterceptor.ownsKeyResponse(it) }
|
||||||
|
?: return TransactionResult.ContinueAndSkipPost
|
||||||
|
// Same version gate as grant(): denied pre-36, revoke the virtualized grant on 36+.
|
||||||
|
if (Build.VERSION.SDK_INT < GRANT_PUBLIC_API_SDK) {
|
||||||
|
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
|
||||||
|
}
|
||||||
|
KeyMintSecurityLevelInterceptor.revokeGrant(ownerKeyId, granteeUid)
|
||||||
|
return InterceptorUtils.createSuccessReply(writeResultCode = false)
|
||||||
} else {
|
} else {
|
||||||
logTransaction(
|
logTransaction(
|
||||||
txId,
|
txId,
|
||||||
@@ -508,6 +622,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
return TransactionResult.SkipTransaction
|
return TransactionResult.SkipTransaction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the owner [KeyIdentifier] a grant/ungrant call targets. APP/alias keys map
|
||||||
|
* directly; KEY_ID keys are looked up by nspace (mirrors the deleteKey resolver). Returns
|
||||||
|
* null for anything not addressable, so callers fall through to the real keystore2.
|
||||||
|
*/
|
||||||
|
private fun resolveOwnerKeyId(descriptor: KeyDescriptor, callingUid: Int): KeyIdentifier? =
|
||||||
|
when {
|
||||||
|
descriptor.alias != null -> KeyIdentifier(callingUid, descriptor.alias)
|
||||||
|
descriptor.domain == Domain.KEY_ID ->
|
||||||
|
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(callingUid, descriptor.nspace)
|
||||||
|
?.let { info ->
|
||||||
|
KeyMintSecurityLevelInterceptor.generatedKeys.entries
|
||||||
|
.firstOrNull { it.value.nspace == info.nspace && it.key.uid == callingUid }
|
||||||
|
?.key
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleUpdateSubcomponent(callingUid: Int, data: Parcel): TransactionResult {
|
private fun handleUpdateSubcomponent(callingUid: Int, data: Parcel): TransactionResult {
|
||||||
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||||
val descriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
val descriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
@@ -527,6 +659,18 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (generatedKeyInfo == null) {
|
if (generatedKeyInfo == null) {
|
||||||
|
// Patch-mode key (cached in teeResponses, not generatedKeys): the real keystore2 applies
|
||||||
|
// the update, so drop our stale cached chain. Otherwise getKeyEntry replays the
|
||||||
|
// pre-update generated attestation (duck STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).
|
||||||
|
when (descriptor.domain) {
|
||||||
|
Domain.KEY_ID ->
|
||||||
|
KeyMintSecurityLevelInterceptor.evictTeeResponseByKeyId(callingUid, descriptor.nspace)
|
||||||
|
Domain.APP ->
|
||||||
|
descriptor.alias?.let {
|
||||||
|
KeyMintSecurityLevelInterceptor.evictTeeResponse(KeyIdentifier(callingUid, it))
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
descriptor.alias?.let {
|
descriptor.alias?.let {
|
||||||
val kid = KeyIdentifier(callingUid, it)
|
val kid = KeyIdentifier(callingUid, it)
|
||||||
userUpdatedKeys.add(kid)
|
userUpdatedKeys.add(kid)
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package org.matrix.TEESimulator.interception.keystore
|
||||||
|
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.security.maintenance.IKeystoreMaintenance
|
||||||
|
import android.system.keystore2.Domain
|
||||||
|
import android.system.keystore2.KeyDescriptor
|
||||||
|
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
||||||
|
import org.matrix.TEESimulator.interception.keystore.shim.KeyMintSecurityLevelInterceptor
|
||||||
|
import org.matrix.TEESimulator.logging.SystemLogger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepts the keystore2 daemon's `android.security.maintenance` binder so our synthetic key
|
||||||
|
* state follows the same lifecycle events the platform applies to real keys.
|
||||||
|
*
|
||||||
|
* This is a pure side-effect hook: every handled transaction mutates only our own synthetic state
|
||||||
|
* and then returns [TransactionResult.ContinueAndSkipPost], so the real keystore2 still performs the
|
||||||
|
* real operation. We never fabricate a maintenance reply, so real key lifecycle is never disturbed.
|
||||||
|
*
|
||||||
|
* Mounted via `register()` from [Keystore2Interceptor.onInterceptorReady]; the maintenance binder is
|
||||||
|
* hosted by the same keystore2 process, so the already-injected native hook reaches it too.
|
||||||
|
*/
|
||||||
|
object Keystore2MaintenanceInterceptor : BinderInterceptor() {
|
||||||
|
private val stubClass = IKeystoreMaintenance.Stub::class.java
|
||||||
|
|
||||||
|
private val CLEAR_NAMESPACE_TRANSACTION =
|
||||||
|
InterceptorUtils.getTransactCode(stubClass, "clearNamespace")
|
||||||
|
private val DELETE_ALL_KEYS_TRANSACTION =
|
||||||
|
InterceptorUtils.getTransactCode(stubClass, "deleteAllKeys")
|
||||||
|
private val MIGRATE_KEY_NAMESPACE_TRANSACTION =
|
||||||
|
InterceptorUtils.getTransactCode(stubClass, "migrateKeyNamespace")
|
||||||
|
|
||||||
|
/** Only the lifecycle transactions we mirror; unresolved codes (-1) are dropped. */
|
||||||
|
val interceptedCodes: IntArray by lazy {
|
||||||
|
listOf(
|
||||||
|
CLEAR_NAMESPACE_TRANSACTION,
|
||||||
|
DELETE_ALL_KEYS_TRANSACTION,
|
||||||
|
MIGRATE_KEY_NAMESPACE_TRANSACTION,
|
||||||
|
)
|
||||||
|
.filter { it != -1 }
|
||||||
|
.toIntArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreTransact(
|
||||||
|
txId: Long,
|
||||||
|
target: IBinder,
|
||||||
|
code: Int,
|
||||||
|
flags: Int,
|
||||||
|
callingUid: Int,
|
||||||
|
callingPid: Int,
|
||||||
|
data: Parcel,
|
||||||
|
): TransactionResult {
|
||||||
|
when (code) {
|
||||||
|
CLEAR_NAMESPACE_TRANSACTION -> handleClearNamespace(data)
|
||||||
|
DELETE_ALL_KEYS_TRANSACTION ->
|
||||||
|
KeyMintSecurityLevelInterceptor.clearAllGeneratedKeys("maintenance.deleteAllKeys")
|
||||||
|
MIGRATE_KEY_NAMESPACE_TRANSACTION -> handleMigrateKeyNamespace(data, callingUid)
|
||||||
|
}
|
||||||
|
// Always let the real keystore2 perform the real lifecycle operation.
|
||||||
|
return TransactionResult.ContinueAndSkipPost
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleClearNamespace(data: Parcel) {
|
||||||
|
data.enforceInterface(IKeystoreMaintenance.DESCRIPTOR)
|
||||||
|
val domain = data.readInt()
|
||||||
|
val nspace = data.readLong()
|
||||||
|
// Only Domain.APP namespaces map to our per-uid synthetic keys; nspace is the app uid.
|
||||||
|
if (domain == Domain.APP) {
|
||||||
|
KeyMintSecurityLevelInterceptor.clearNamespaceKeys(nspace.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMigrateKeyNamespace(data: Parcel, callingUid: Int) {
|
||||||
|
data.enforceInterface(IKeystoreMaintenance.DESCRIPTOR)
|
||||||
|
val source = data.readTypedObject(KeyDescriptor.CREATOR) ?: return
|
||||||
|
val destination = data.readTypedObject(KeyDescriptor.CREATOR) ?: return
|
||||||
|
val srcId = resolveSyntheticKeyId(source, callingUid) ?: return
|
||||||
|
if (!KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(srcId)) return // not ours
|
||||||
|
|
||||||
|
val dstId = resolveDestinationKeyId(destination, callingUid)
|
||||||
|
if (dstId == null) {
|
||||||
|
// Migrated out of our trackable (Domain.APP/alias) space -> drop our shadow so reads
|
||||||
|
// fall through to the real keystore2, which now owns it at the new namespace.
|
||||||
|
KeyMintSecurityLevelInterceptor.cleanupKeyData(srcId)
|
||||||
|
} else {
|
||||||
|
KeyMintSecurityLevelInterceptor.migrateGeneratedKey(srcId, dstId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a synthetic owner key from a source descriptor (Domain.APP alias or KEY_ID). */
|
||||||
|
private fun resolveSyntheticKeyId(descriptor: KeyDescriptor, callingUid: Int): KeyIdentifier? =
|
||||||
|
when {
|
||||||
|
descriptor.alias != null -> KeyIdentifier(callingUid, descriptor.alias)
|
||||||
|
descriptor.domain == Domain.KEY_ID ->
|
||||||
|
KeyMintSecurityLevelInterceptor.generatedKeys.entries
|
||||||
|
.firstOrNull { it.key.uid == callingUid && it.value.nspace == descriptor.nspace }
|
||||||
|
?.key
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destination must be an addressable Domain.APP alias for us to keep tracking the key. */
|
||||||
|
private fun resolveDestinationKeyId(descriptor: KeyDescriptor, callingUid: Int): KeyIdentifier? {
|
||||||
|
val alias = descriptor.alias ?: return null
|
||||||
|
if (descriptor.domain != Domain.APP) return null
|
||||||
|
val uid = if (descriptor.nspace > 0) descriptor.nspace.toInt() else callingUid
|
||||||
|
return KeyIdentifier(uid, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
-18
@@ -29,6 +29,7 @@ import java.util.concurrent.locks.LockSupport
|
|||||||
import org.matrix.TEESimulator.attestation.AttestationBuilder
|
import org.matrix.TEESimulator.attestation.AttestationBuilder
|
||||||
import org.matrix.TEESimulator.attestation.AttestationConstants
|
import org.matrix.TEESimulator.attestation.AttestationConstants
|
||||||
import org.matrix.TEESimulator.attestation.AttestationPatcher
|
import org.matrix.TEESimulator.attestation.AttestationPatcher
|
||||||
|
import org.matrix.TEESimulator.attestation.DeviceAttestationService
|
||||||
import org.matrix.TEESimulator.attestation.KeyMintAttestation
|
import org.matrix.TEESimulator.attestation.KeyMintAttestation
|
||||||
import org.matrix.TEESimulator.config.ConfigurationManager
|
import org.matrix.TEESimulator.config.ConfigurationManager
|
||||||
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
||||||
@@ -126,13 +127,18 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
val keyDescriptor =
|
val keyDescriptor =
|
||||||
data.readTypedObject(KeyDescriptor.CREATOR)
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
?: return TransactionResult.SkipTransaction
|
?: return TransactionResult.SkipTransaction
|
||||||
// Evict generated key data but retain patched chains so detectors
|
// A successful importKey replaces the alias's key in the real keystore2, so any prior
|
||||||
// can't use importKey to force unpatched getKeyEntry responses.
|
// generate/patch cache for this alias is stale. Drop it: a non-attested import then
|
||||||
|
// falls through to the real keystore2 (origin=IMPORTED, imported leaf), and the
|
||||||
|
// attested-import branch below re-caches the fresh patched chain. Without this,
|
||||||
|
// getKeyEntry replays the prior generated attestation (duck STALE_GENERATED_AFTER_IMPORT).
|
||||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||||
if (generatedKeys.remove(keyId) != null) {
|
if (generatedKeys.remove(keyId) != null) {
|
||||||
SystemLogger.debug("Remove generated key on importKey $keyId")
|
SystemLogger.debug("Remove generated key on importKey $keyId")
|
||||||
GeneratedKeyPersistence.delete(keyId)
|
GeneratedKeyPersistence.delete(keyId)
|
||||||
}
|
}
|
||||||
|
teeResponses.remove(keyId)
|
||||||
|
patchedChains.remove(keyId)
|
||||||
attestationKeys.remove(keyId)
|
attestationKeys.remove(keyId)
|
||||||
importedKeys.add(keyId)
|
importedKeys.add(keyId)
|
||||||
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: added to importedKeys, skipUid=${ConfigurationManager.shouldSkipUid(callingUid)}" }
|
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: added to importedKeys, skipUid=${ConfigurationManager.shouldSkipUid(callingUid)}" }
|
||||||
@@ -317,7 +323,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
entry ?: run {
|
entry ?: run {
|
||||||
trackAndEnforceOpLimit(callingUid, txId)?.let { return it }
|
trackAndEnforceOpLimit(callingUid, txId)?.let { return it }
|
||||||
SystemLogger.info("[TX_ID: $txId] createOperation KeyId(${keyDescriptor.nspace}) NOT FOUND for uid=$callingUid. Forwarding to HAL.")
|
SystemLogger.info("[TX_ID: $txId] createOperation KeyId(${keyDescriptor.nspace}) NOT FOUND for uid=$callingUid. Forwarding to HAL.")
|
||||||
return TransactionResult.Continue
|
return TransactionResult.ContinueAndSkipPost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
@@ -434,8 +440,8 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
SystemLogger.debug(
|
SystemLogger.debug(
|
||||||
"Handling generateKey ${keyDescriptor.alias}, attestKey=${attestationKey?.alias}"
|
"Handling generateKey ${keyDescriptor.alias}, attestKey=${attestationKey?.alias}"
|
||||||
)
|
)
|
||||||
val params = data.createTypedArray(KeyParameter.CREATOR)!!
|
var params = data.createTypedArray(KeyParameter.CREATOR)!!
|
||||||
val parsedParams = KeyMintAttestation(params)
|
var parsedParams = KeyMintAttestation(params)
|
||||||
val isAttestKeyRequest = parsedParams.isAttestKey()
|
val isAttestKeyRequest = parsedParams.isAttestKey()
|
||||||
|
|
||||||
if (ConfigurationManager.shouldSkipUid(callingUid)
|
if (ConfigurationManager.shouldSkipUid(callingUid)
|
||||||
@@ -472,13 +478,37 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
it.tag == Tag.ATTESTATION_ID_SECOND_IMEI
|
it.tag == Tag.ATTESTATION_ID_SECOND_IMEI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val hasDevicePropertyAttestation = parsedParams.brand != null ||
|
||||||
|
parsedParams.device != null ||
|
||||||
|
parsedParams.product != null ||
|
||||||
|
parsedParams.manufacturer != null ||
|
||||||
|
parsedParams.model != null
|
||||||
|
|
||||||
|
// Mirror the real TEE's capability: hardware that never provisioned device IDs
|
||||||
|
// returns CANNOT_ATTEST_IDS. Synthesizing device-ID/property attestation a chip of
|
||||||
|
// this class cannot produce is an over-capability tell — a genuine device fails the
|
||||||
|
// same request. Forge health, mirror capability.
|
||||||
|
if ((hasDeviceIdAttestation || hasDevicePropertyAttestation) &&
|
||||||
|
!DeviceAttestationService.canAttestDeviceIds) {
|
||||||
|
SystemLogger.info("[TX_ID: $txId] Real TEE cannot attest device IDs; returning CANNOT_ATTEST_IDS for uid=$callingUid (mirroring hardware)")
|
||||||
|
return InterceptorUtils.createErrorReply(KEYMINT_CANNOT_ATTEST_IDS)
|
||||||
|
}
|
||||||
|
|
||||||
if(hasDeviceIdAttestation && !AndroidPermissionUtils.hasDeviceAttestationPermission(callingUid)) {
|
if(hasDeviceIdAttestation && !AndroidPermissionUtils.hasDeviceAttestationPermission(callingUid)) {
|
||||||
SystemLogger.warning("[TX_ID: $txId] Rejecting DEVICE_ID_ATTESTATION for uid=$callingUid")
|
SystemLogger.warning("[TX_ID: $txId] Rejecting DEVICE_ID_ATTESTATION for uid=$callingUid")
|
||||||
return InterceptorUtils.createErrorReply(KEYMINT_CANNOT_ATTEST_IDS)
|
return InterceptorUtils.createErrorReply(KEYMINT_CANNOT_ATTEST_IDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AOSP security_level.rs:478-485: INCLUDE_UNIQUE_ID requires
|
// INCLUDE_UNIQUE_ID requires SELinux gen_unique_id OR
|
||||||
// SELinux gen_unique_id OR Android REQUEST_UNIQUE_ID_ATTESTATION
|
// android.permission.REQUEST_UNIQUE_ID_ATTESTATION (AOSP
|
||||||
|
// security_level.rs:478-485). AOSP returns PERMISSION_DENIED
|
||||||
|
// when neither is held — but doing so breaks Google Wallet
|
||||||
|
// card binding (Wallet's generateKey carries the tag without
|
||||||
|
// holding the permission, and Play Integrity also fails when
|
||||||
|
// unique_id ends up in the attestation). Silently strip the
|
||||||
|
// tag so the key generates normally and the resulting
|
||||||
|
// attestation simply omits the unique_id field. This mirrors
|
||||||
|
// the pre-PR157 behavior where the tag had no effect.
|
||||||
if (params.any { it.tag == Tag.INCLUDE_UNIQUE_ID }) {
|
if (params.any { it.tag == Tag.INCLUDE_UNIQUE_ID }) {
|
||||||
val hasSELinux = ConfigurationManager.checkSELinuxPermission(
|
val hasSELinux = ConfigurationManager.checkSELinuxPermission(
|
||||||
callingPid, "keystore_key", "gen_unique_id",
|
callingPid, "keystore_key", "gen_unique_id",
|
||||||
@@ -487,8 +517,9 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
callingUid, "android.permission.REQUEST_UNIQUE_ID_ATTESTATION",
|
callingUid, "android.permission.REQUEST_UNIQUE_ID_ATTESTATION",
|
||||||
)
|
)
|
||||||
if (!hasSELinux && !hasAndroid) {
|
if (!hasSELinux && !hasAndroid) {
|
||||||
SystemLogger.warning("[TX_ID: $txId] Rejecting INCLUDE_UNIQUE_ID for uid=$callingUid pid=$callingPid")
|
SystemLogger.debug("[TX_ID: $txId] Stripping INCLUDE_UNIQUE_ID for uid=$callingUid pid=$callingPid (no permission)")
|
||||||
return InterceptorUtils.createServiceSpecificErrorReply(RESPONSE_PERMISSION_DENIED)
|
params = params.filter { it.tag != Tag.INCLUDE_UNIQUE_ID }.toTypedArray()
|
||||||
|
parsedParams = KeyMintAttestation(params)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,6 +1188,63 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
private val usageCounters = ConcurrentHashMap<KeyIdentifier, java.util.concurrent.atomic.AtomicInteger>()
|
private val usageCounters = ConcurrentHashMap<KeyIdentifier, java.util.concurrent.atomic.AtomicInteger>()
|
||||||
private val interceptedOperations = ConcurrentHashMap<IBinder, OperationInterceptor>()
|
private val interceptedOperations = ConcurrentHashMap<IBinder, OperationInterceptor>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant plane for the public `KeyStoreManager.grantKeyAccess()` API (Android 16, API 36+).
|
||||||
|
* On Android <= 15 grant was a hidden API denied to untrusted_app, so this state stays
|
||||||
|
* empty there (the GRANT_TRANSACTION handler returns PERMISSION_DENIED for synthetic keys
|
||||||
|
* pre-36). A grant is caller-bound and carries an access vector; resolving one yields the
|
||||||
|
* owner's own KeyEntryResponse so every access plane returns a coherent certificate chain.
|
||||||
|
*/
|
||||||
|
data class SoftwareGrant(
|
||||||
|
val ownerKeyId: KeyIdentifier,
|
||||||
|
val granteeUid: Int,
|
||||||
|
val accessVector: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
val softwareGrants = ConcurrentHashMap<Long, SoftwareGrant>() // grantId -> grant
|
||||||
|
|
||||||
|
/** Mint or reuse a grant id (random, non-zero, non -1 Long). Re-grant reuses the id. */
|
||||||
|
fun issueGrant(ownerKeyId: KeyIdentifier, granteeUid: Int, accessVector: Int): Long {
|
||||||
|
softwareGrants.entries
|
||||||
|
.firstOrNull { it.value.ownerKeyId == ownerKeyId && it.value.granteeUid == granteeUid }
|
||||||
|
?.let { existing ->
|
||||||
|
softwareGrants[existing.key] = existing.value.copy(accessVector = accessVector)
|
||||||
|
return existing.key
|
||||||
|
}
|
||||||
|
var id = secureRandom.nextLong()
|
||||||
|
while (id == 0L || id == -1L || softwareGrants.containsKey(id)) id = secureRandom.nextLong()
|
||||||
|
softwareGrants[id] = SoftwareGrant(ownerKeyId, granteeUid, accessVector)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller-bound resolve: only the designated grantee, only while the key exists. */
|
||||||
|
fun resolveGrant(grantId: Long, callerUid: Int): SoftwareGrant? =
|
||||||
|
softwareGrants[grantId]?.takeIf {
|
||||||
|
it.granteeUid == callerUid && ownsKeyResponse(it.ownerKeyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this interceptor holds a coherent [KeyEntryResponse] for [keyId] — synthetic
|
||||||
|
* (`generatedKeys`) OR patch-mode (`teeResponses`, a real TEE key whose attestation we
|
||||||
|
* patched). The grant plane must virtualize both: gating on `generatedKeys` alone left
|
||||||
|
* patch-mode keys' `Domain.GRANT` readback falling through to the real keystore2 unpatched,
|
||||||
|
* splitting the grant chain against the owner's patched read (duck SELF_/ISOLATED_CHAIN_SPLIT,
|
||||||
|
* surfaced once Android 16 made KeyStoreManager.grantKeyAccess a public API).
|
||||||
|
*/
|
||||||
|
fun ownsKeyResponse(keyId: KeyIdentifier): Boolean = getGeneratedKeyResponse(keyId) != null
|
||||||
|
|
||||||
|
fun revokeGrant(ownerKeyId: KeyIdentifier, granteeUid: Int) {
|
||||||
|
softwareGrants.entries
|
||||||
|
.filter { it.value.ownerKeyId == ownerKeyId && it.value.granteeUid == granteeUid }
|
||||||
|
.forEach { softwareGrants.remove(it.key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purgeGrantsForKey(ownerKeyId: KeyIdentifier) {
|
||||||
|
softwareGrants.entries
|
||||||
|
.filter { it.value.ownerKeyId == ownerKeyId }
|
||||||
|
.forEach { softwareGrants.remove(it.key) }
|
||||||
|
}
|
||||||
|
|
||||||
fun getGeneratedKeyResponse(keyId: KeyIdentifier): KeyEntryResponse? =
|
fun getGeneratedKeyResponse(keyId: KeyIdentifier): KeyEntryResponse? =
|
||||||
generatedKeys[keyId]?.response ?: teeResponses[keyId]
|
generatedKeys[keyId]?.response ?: teeResponses[keyId]
|
||||||
|
|
||||||
@@ -1176,11 +1264,32 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
?.value
|
?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the cached TEE/patched response (and patched chain) addressed by KEY_ID so a
|
||||||
|
* post-mutation getKeyEntry falls through to the now-updated real keystore2 key. Used
|
||||||
|
* after updateSubcomponent re-keys a patched chain (duck
|
||||||
|
* STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).
|
||||||
|
*/
|
||||||
|
fun evictTeeResponseByKeyId(callingUid: Int, nspace: Long?) {
|
||||||
|
if (nspace == null || nspace == 0L) return
|
||||||
|
teeResponses.entries
|
||||||
|
.filter { (keyId, _) -> keyId.uid == callingUid }
|
||||||
|
.find { (_, response) -> response.metadata?.key?.nspace == nspace }
|
||||||
|
?.let { evictTeeResponse(it.key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alias-addressed counterpart of [evictTeeResponseByKeyId]. */
|
||||||
|
fun evictTeeResponse(keyId: KeyIdentifier) {
|
||||||
|
teeResponses.remove(keyId)
|
||||||
|
patchedChains.remove(keyId)
|
||||||
|
}
|
||||||
|
|
||||||
fun getPatchedChain(keyId: KeyIdentifier): Array<Certificate>? = patchedChains[keyId]
|
fun getPatchedChain(keyId: KeyIdentifier): Array<Certificate>? = patchedChains[keyId]
|
||||||
|
|
||||||
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
||||||
|
|
||||||
fun cleanupKeyData(keyId: KeyIdentifier) {
|
fun cleanupKeyData(keyId: KeyIdentifier) {
|
||||||
|
purgeGrantsForKey(keyId) // grants die with the key (Android 16 path; no-op pre-36)
|
||||||
if (generatedKeys.remove(keyId) != null) {
|
if (generatedKeys.remove(keyId) != null) {
|
||||||
SystemLogger.debug("Remove generated key ${keyId}")
|
SystemLogger.debug("Remove generated key ${keyId}")
|
||||||
GeneratedKeyPersistence.delete(keyId)
|
GeneratedKeyPersistence.delete(keyId)
|
||||||
@@ -1196,6 +1305,36 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
usageCounters.remove(keyId)
|
usageCounters.remove(keyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clears every synthetic key owned by [uid] (maintenance.clearNamespace, Domain.APP). */
|
||||||
|
fun clearNamespaceKeys(uid: Int) {
|
||||||
|
val victims = generatedKeys.keys.filter { it.uid == uid }
|
||||||
|
if (victims.isEmpty()) return
|
||||||
|
victims.forEach { cleanupKeyData(it) } // also purges grants + persistence
|
||||||
|
SystemLogger.info(
|
||||||
|
"Cleared ${victims.size} synthetic keys for uid=$uid (maintenance.clearNamespace)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-keys a synthetic entry from [srcId] to [dstId] for maintenance.migrateKeyNamespace,
|
||||||
|
* preserving the key material, certificate chain, and any grants (which reference the key,
|
||||||
|
* not the namespace). In-memory only: the stale persisted file is dropped and the migrated
|
||||||
|
* key is not re-persisted, matching the single-session boundary the grant plane already
|
||||||
|
* accepts (Phase 9 plan §9). No-op if [srcId] is not ours or [dstId] already exists.
|
||||||
|
*/
|
||||||
|
fun migrateGeneratedKey(srcId: KeyIdentifier, dstId: KeyIdentifier) {
|
||||||
|
if (srcId == dstId || generatedKeys.containsKey(dstId)) return
|
||||||
|
val info = generatedKeys.remove(srcId) ?: return
|
||||||
|
generatedKeys[dstId] = info
|
||||||
|
if (attestationKeys.remove(srcId)) attestationKeys.add(dstId)
|
||||||
|
if (importedKeys.remove(srcId)) importedKeys.add(dstId)
|
||||||
|
softwareGrants.entries
|
||||||
|
.filter { it.value.ownerKeyId == srcId }
|
||||||
|
.forEach { softwareGrants[it.key] = it.value.copy(ownerKeyId = dstId) }
|
||||||
|
GeneratedKeyPersistence.delete(srcId)
|
||||||
|
SystemLogger.info("Migrated synthetic key $srcId -> $dstId (maintenance.migrateKeyNamespace)")
|
||||||
|
}
|
||||||
|
|
||||||
fun removeOperationInterceptor(operationBinder: IBinder, backdoor: IBinder) {
|
fun removeOperationInterceptor(operationBinder: IBinder, backdoor: IBinder) {
|
||||||
unregister(backdoor, operationBinder)
|
unregister(backdoor, operationBinder)
|
||||||
|
|
||||||
@@ -1221,6 +1360,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
attestationKeys.clear()
|
attestationKeys.clear()
|
||||||
importedKeys.clear()
|
importedKeys.clear()
|
||||||
usageCounters.clear()
|
usageCounters.clear()
|
||||||
|
softwareGrants.clear()
|
||||||
GeneratedKeyPersistence.deleteAll()
|
GeneratedKeyPersistence.deleteAll()
|
||||||
SystemLogger.info("Cleared all cached keys ($count entries)$reasonMessage.")
|
SystemLogger.info("Cleared all cached keys ($count entries)$reasonMessage.")
|
||||||
}
|
}
|
||||||
@@ -1303,14 +1443,13 @@ private fun KeyMintAttestation.toAuthorizations(
|
|||||||
if (osPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
if (osPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
||||||
authList.add(createAuth(Tag.OS_PATCHLEVEL, KeyParameterValue.integer(osPatch)))
|
authList.add(createAuth(Tag.OS_PATCHLEVEL, KeyParameterValue.integer(osPatch)))
|
||||||
}
|
}
|
||||||
val vendorPatch = AndroidDeviceUtils.getVendorPatchLevelLong(callingUid)
|
// Real keystore2 (captured on-device: MediaTek, Android 15) does NOT surface
|
||||||
if (vendorPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
// VENDOR_PATCHLEVEL or BOOT_PATCHLEVEL in the generateKey KeyMetadata.authorizations
|
||||||
authList.add(createAuth(Tag.VENDOR_PATCHLEVEL, KeyParameterValue.integer(vendorPatch)))
|
// — they exist only in the attestation extension. Emitting them yielded a
|
||||||
}
|
// 13-authorization EC reply where the genuine HAL emits 11, which is precisely the
|
||||||
val bootPatch = AndroidDeviceUtils.getBootPatchLevelLong(callingUid)
|
// structural tell Duck-Detector's generate-mode parcel fingerprint keys on (its
|
||||||
if (bootPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
// stride-walk lands on the 13-entry layout). Both values remain in the attestation
|
||||||
authList.add(createAuth(Tag.BOOT_PATCHLEVEL, KeyParameterValue.integer(bootPatch)))
|
// extension via AttestationBuilder, so attestation content is unchanged.
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keystore-enforced authorizations (CREATION_DATETIME, ACTIVE_DATETIME,
|
* Keystore-enforced authorizations (CREATION_DATETIME, ACTIVE_DATETIME,
|
||||||
@@ -1352,7 +1491,17 @@ private fun KeyMintAttestation.toAuthorizations(
|
|||||||
authList.add(createKeystoreAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
|
authList.add(createKeystoreAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
authList.add(createKeystoreAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000)))
|
// Captured real keystore2 tags USER_ID at SecurityLevel.SOFTWARE (0), even though
|
||||||
|
// CREATION_DATETIME above is KEYSTORE (100). Mirror that split exactly.
|
||||||
|
authList.add(
|
||||||
|
Authorization().apply {
|
||||||
|
this.keyParameter = KeyParameter().apply {
|
||||||
|
this.tag = Tag.USER_ID
|
||||||
|
this.value = KeyParameterValue.integer(callingUid / 100000)
|
||||||
|
}
|
||||||
|
this.securityLevel = SecurityLevel.SOFTWARE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return authList.toTypedArray()
|
return authList.toTypedArray()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-17
@@ -16,23 +16,16 @@ echo " 🔉 $(_msg confirm_vol_down)"
|
|||||||
echo " "
|
echo " "
|
||||||
|
|
||||||
confirm() {
|
confirm() {
|
||||||
vol_tmp="${TMPDIR:-/data/local/tmp}/teesim_vol_key"
|
# Sample getevent in 1s bursts; a piped stream block-buffers and misses
|
||||||
: > "$vol_tmp"
|
# a single key-press before the timeout.
|
||||||
|
deadline=$(( $(date +%s) + 10 ))
|
||||||
# Stream getevent and match VOLUME DOWN inline. Single-event sampling
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
# (`getevent -c 1`) races with EV_SYN/EV_MSC noise on Magisk's BusyBox ash.
|
events=$(/system/bin/timeout 1 /system/bin/getevent -l 2>/dev/null)
|
||||||
/system/bin/timeout 10 /system/bin/sh -c '
|
case "$events" in
|
||||||
/system/bin/getevent -lq 2>/dev/null | while IFS= read -r line; do
|
*KEY_VOLUMEUP*) return 0 ;;
|
||||||
case "$line" in
|
*KEY_VOLUMEDOWN*) return 1 ;;
|
||||||
*KEY_VOLUMEUP*DOWN*) echo UP > "$1"; exit 0 ;;
|
esac
|
||||||
*KEY_VOLUMEDOWN*DOWN*) echo DOWN > "$1"; exit 0 ;;
|
done
|
||||||
esac
|
|
||||||
done
|
|
||||||
' _ "$vol_tmp"
|
|
||||||
|
|
||||||
key=$(cat "$vol_tmp" 2>/dev/null)
|
|
||||||
rm -f "$vol_tmp"
|
|
||||||
[ "$key" = "UP" ] && return 0
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,34 @@
|
|||||||
|
## TEESimulator-RS v6.0.1-251
|
||||||
|
|
||||||
|
14 commits since v6.0.0-235. Clears the remaining Duck Detector grant-domain rows (incl. the Android 16 OnePlus report), restores Google Wallet and fingerprint compatibility, and removes the in-module patch-level/bulletin resolvers. Test device (SDK 35) TEE tamper score 28 → 8.
|
||||||
|
|
||||||
|
### Detection coverage
|
||||||
|
- Grant plane virtualized: owner read and cross-app `Domain.GRANT` read return one identical chain. 6 RED rows cleared. (28 → 18)
|
||||||
|
- Generate-mode fingerprint: dropped 2 surplus authorizations (both patchlevels), USER_ID moved to SOFTWARE to mirror a captured device. (18 → 8)
|
||||||
|
- Android 16 grant: patch-mode keys now served on the grant plane, so owner and grant reads match — fixes CHAIN_SPLIT.
|
||||||
|
- Grant gated to SDK ≥ 36: Android 15 answers PERMISSION_DENIED, no synthetic over-capability.
|
||||||
|
- Stale-chain eviction: import and updateSubcomponent drop the cached attestation; no pre-mutation chain replays.
|
||||||
|
- Lifecycle coherence: clearNamespace / deleteAllKeys / migrateKeyNamespace mirror synthetic key and grant state — defeats delete-then-read probes.
|
||||||
|
- Device-ID attestation mirrors the real TEE: returns CANNOT_ATTEST_IDS where silicon can't attest, instead of forging it.
|
||||||
|
|
||||||
|
### App compatibility
|
||||||
|
- Google Wallet: INCLUDE_UNIQUE_ID stripped (not rejected) when the caller lacks the permission; card binding works. (PR #27)
|
||||||
|
- Fingerprint / vendor keys: KEY_ID miss skips the post-handler, so real HAL operations are no longer wrapped and broken. (PR #26)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- PatchLevelManager — auto-resolved the security-patch date from an installed PlayIntegrityFix module (with hot-reload) and applied it to props.
|
||||||
|
- BulletinPoller — scheduled security-bulletin refresh.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- Release builds purge stale `teesim-*.bin` diagnostics from `/data/local/tmp` at boot.
|
||||||
|
- Vol-key confirmation rewritten to 1s `getevent` bursts (piped stream missed single presses on Magisk).
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- SDK 35, Xiaomi 23106RN0DA: tamper 28 → 8; generate-mode signal gone; 4 grant rows UNAVAILABLE (correct for Android 15); no regressions.
|
||||||
|
- Android 16 grant fix built but unconfirmed on SDK 36 — needs an affected OnePlus user to confirm the grant rows clear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TEESimulator-RS v6.0.0-235
|
## TEESimulator-RS v6.0.0-235
|
||||||
|
|
||||||
11 commits since v6.0.0-224. Duck Detector generate-mode fingerprint cleared. Shizuku-routed BYO attestation fixed. Vol-key confirmation restored on Magisk.
|
11 commits since v6.0.0-224. Duck Detector generate-mode fingerprint cleared. Shizuku-routed BYO attestation fixed. Vol-key confirmation restored on Magisk.
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "v6.0.0-235",
|
"version": "v6.0.1-251",
|
||||||
"versionCode": 235,
|
"versionCode": 251,
|
||||||
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.0-235/TEESimulator-RS-v6.0.0-235-Release.zip",
|
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.1-251/TEESimulator-RS-v6.0.1-251-Release.zip",
|
||||||
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package android.security.maintenance;
|
||||||
|
|
||||||
|
import android.os.IBinder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time stub for the hidden keystore2 maintenance binder
|
||||||
|
* ({@code android.security.maintenance.IKeystoreMaintenance}).
|
||||||
|
*
|
||||||
|
* <p>This module is a {@code compileOnly} dependency, so the real framework class
|
||||||
|
* (which carries the actual {@code TRANSACTION_*} codes) is loaded at runtime. We
|
||||||
|
* only need the {@link #DESCRIPTOR} token to parse the transaction parcel and the
|
||||||
|
* inner {@code Stub} class so {@code getTransactCode} can reflect the real codes.
|
||||||
|
*/
|
||||||
|
public interface IKeystoreMaintenance {
|
||||||
|
String DESCRIPTOR = "android.security.maintenance.IKeystoreMaintenance";
|
||||||
|
|
||||||
|
class Stub {
|
||||||
|
public static IKeystoreMaintenance asInterface(IBinder b) {
|
||||||
|
throw new UnsupportedOperationException("STUB!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user