Compare commits
18
Commits
v6.0.0-235
...
v6.0.1-282
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40754fd8b | ||
|
|
0f32194a89 | ||
|
|
64723b2564 | ||
|
|
42842e22c0 | ||
|
|
77fc96db37 | ||
|
|
b27a33b444 | ||
|
|
bae65ac47c | ||
|
|
217a5dc7f3 | ||
|
|
0229368c04 | ||
|
|
0f61bb841a | ||
|
|
50f2e98375 | ||
|
|
f4e2619eba | ||
|
|
649136ab4e | ||
|
|
d155a0ded6 | ||
|
|
edac284972 | ||
|
|
cbb73a0b0e | ||
|
|
3140ff5e96 | ||
|
|
8544aac260 |
@@ -5,3 +5,6 @@ app/build
|
|||||||
build
|
build
|
||||||
native-certgen/target
|
native-certgen/target
|
||||||
app/src/main/jniLibs
|
app/src/main/jniLibs
|
||||||
|
|
||||||
|
# Internal planning/strategy plane, never publish
|
||||||
|
.omc/
|
||||||
|
|||||||
@@ -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,75 @@
|
|||||||
|
## TEESimulator-RS v6.0.1-282
|
||||||
|
|
||||||
|
AUTO-mode key attestation now forges plain attestation from the keybox instead of deferring to the real TEE.
|
||||||
|
|
||||||
|
### Detection coverage
|
||||||
|
- AUTO dispatch probed the device with `checkTeeFunctionality`, which only proves the TEE can mint one EC key. It says nothing about RSA attestation, device-ID attestation, or whether a patched chain survives RSA verify. Plain attestation requests (attest-key OFF, challenge present) were routed to PATCH and deferred to hardware, so devices that can't back that surfaced KeyAttestation reds: `ATTESTATION_KEYS_NOT_PROVISIONED` (-49) and `BLOCK_TYPE_IS_NOT_01`.
|
||||||
|
- AUTO targets carrying an attestation challenge now take the FORGE path, the same one attest-key-ON already used: a synthetic chain built from the keybox and rooted under the Google root key. Requests with no challenge still pass through to real hardware, so KeyDetector's hardware-backed checks are unaffected.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Offline conformance against real FORGE captures: uid10389 and uid10154 chains are GREEN; the root SPKI byte-matches `GOOGLE_ROOT_PUBLIC_KEY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TEESimulator-RS v6.0.1-280
|
||||||
|
|
||||||
|
Clears the Duck Detector generate-mode parcel fingerprint that real Android 16 hardware also trips, fixes RSA attestation under an EC-only keybox, and restores device-property attestation for Play Integrity hardware apps such as BHIM and UPI. Generate-mode fix field-confirmed on Android 16.
|
||||||
|
|
||||||
|
### Detection coverage
|
||||||
|
- Generate-mode fingerprint: Duck reads the reply at a flat 12-byte stride and flags the sentinel tuple at positions 12 and 13 that the device's native ALGORITHM-first authorization order lands on. Real A16 silicon trips the same probe, so faithful mirroring stayed flagged. `normalizeAuthorizationLayout` marshals the auth array, runs Duck's exact predicate, and applies a minimal deterministic reorder only when it would match. Count, values, security levels, and the cert chain are untouched, and the reorder keys on the byte condition, never on a package. Applied on both the patch and forge reply paths. (#33)
|
||||||
|
- `updateAad` on a non-AEAD operation now answers per vendor: Samsung and Xiaomi-MTK TEEs return success, others return INVALID_TAG, matching Duck's OperationErrorPathProbe on both the sign/verify and cipher paths.
|
||||||
|
|
||||||
|
### Attestation correctness (Android 16, EC and RSA)
|
||||||
|
- RSA leaf under an EC-only keybox: patching used to catch the no-RSA-key throw and return the chain untouched, leaking the device's real unlocked Root of Trust for RSA keys while EC keys patched cleanly. It now falls back to any keybox key (EC preferred) and signs the patched leaf with the keybox key's own algorithm, so the RSA leaf re-roots to the Google keybox under a forged locked RoT.
|
||||||
|
- RSA attest-key forge on an EC-only keybox: the forge path matched the algorithm exactly and threw -75 ATTESTATION_KEYS_NOT_PROVISIONED on a miss, so an RSA ATTEST_KEY request never rooted and verifiers reported an unknown certificate. It now falls back to any attestation key, since an EC key validly ECDSA-signs an RSA-subject leaf. No-op on a dual keybox.
|
||||||
|
- A16 attestVersion: the device's KeyMint reports version 100 and the lazy cache shadowed the BAKLAVA-to-400 map, so the forge presented 100. It now caches the AOSP value per SDK and presents the correct 400.
|
||||||
|
- Algorithm-split key on restore: a persisted record holding an EC private key under an RSA leaf failed every signature as DATA_TOO_LARGE_FOR_MODULUS. Restore now drops the record when the private key and served leaf disagree, so the next generateKey rebuilds a coherent key.
|
||||||
|
- Stale chain on regenerate: reusing an alias in generateKey now evicts the cached chain, matching keystore2, so getKeyEntry serves the current key instead of a stale forge from an earlier generation.
|
||||||
|
|
||||||
|
### App compatibility
|
||||||
|
- Device-property attestation (BRAND, MODEL, and the rest) now forges unconditionally. The old gate probed the live TEE, which is dead on every device the module serves, so it rejected GMS Play Integrity's hardware path and broke BHIM and other UPI and Play-Integrity apps. Device-ID attestation (IMEI, serial) stays governed by the real KeyMint caller-permission rule: privileged callers get it, ordinary apps do not.
|
||||||
|
- getKeyEntry now reaches the owned-key lookup for skipped privileged UIDs, so framework attestKeyAlias resolution no longer returns "Invalid attestKeyAlias" for Key Attestation over Shizuku. Non-owned keys still skip post-processing, so a real app's key is never patched.
|
||||||
|
- Device-ID attestation over Shizuku (a privileged UID absent from target.txt) now takes the forge path instead of hitting the real TEE's CANNOT_ATTEST_IDS (-66). "Use attest key" no longer double-roots: a reused persistent attest key is resolved by KEY_ID as well as alias, and an unresolved designated attest key refuses to emit a leaf rather than silently re-rooting under the keybox.
|
||||||
|
|
||||||
|
### Diagnostics (debug builds only)
|
||||||
|
- Per-UID attestation dossier for targeted UIDs at /data/local/tmp/teesim/, recording the decoded chain on both forge and patch paths, key params, the keybox pick (including EC fail-safe), prop sources, forge failures, the emitted authorization shape, and served-versus-verified chains. Release builds strip this through R8 and stay silent. Keybox certificate serials log on every fetch for revocation triage.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Android 16: generate-mode fingerprint signal gone, confirmed on device 2026-06-19.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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-282",
|
||||||
"versionCode": 235,
|
"versionCode": 282,
|
||||||
"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-282/TEESimulator-RS-v6.0.1-282-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