Compare commits

...
18 Commits
Author SHA1 Message Date
Enginex0 b40754fd8b chore(release): publish v6.0.1-282 2026-06-19 16:50:16 +01:00
Enginex0 0f32194a89 chore: untrack .omc planning files 2026-06-19 04:00:24 +01:00
Enginex0 64723b2564 chore(release): publish v6.0.1-280 2026-06-19 03:41:09 +01:00
Enginex0 42842e22c0 chore(release): publish v6.0.1-251
Grant-plane coherence (Android 16 incl.), Google Wallet + fingerprint
compatibility (PR #26/#27), and removal of the in-module PIF/bulletin
resolvers. Frozen at gitCommitCount 251.
2026-05-30 13:45:55 +01:00
Enginex0 77fc96db37 fix(action): sample getevent in 1s bursts
The piped getevent stream block-buffered on Magisk's BusyBox ash and
missed a single vol-key press before the timeout. Sample getevent in 1s
timeout bursts in a deadline loop instead.
2026-05-30 13:42:31 +01:00
Enginex0 b27a33b444 refactor(app): drop PIF resolvers + dump purge
Remove PatchLevelManager (auto-resolved the security-patch date from an
installed PlayIntegrityFix module into security_patch.txt, with a
FileObserver hot-reload) and BulletinPoller (scheduled bulletin refresh),
and their App.kt init/start calls.

Add purgeDebugDiagnostics(): release builds sweep stale teesim-*.bin
dumps from /data/local/tmp at boot so a prior debug install can't leave a
detection artifact. Stabilize the InterceptorUtils diagnostic dump path
to a single file instead of one per call.
2026-05-30 13:42:31 +01:00
Enginex0 bae65ac47c chore(release): bump version to v6.0.1 2026-05-30 13:42:31 +01:00
Enginex0 217a5dc7f3 fix(keystore): grant plane serves patch-mode keys
Domain.GRANT readback only recognized synthetic keys (generatedKeys), so
patch-mode keys (real TEE key whose attestation we patch on read, cached
in teeResponses) fell through to the real keystore2 unpatched. Android 16
made KeyStoreManager.grantKeyAccess a public API, so the owner read
returned our patched chain while the grant read returned the raw real
chain -> duck SELF_/ISOLATED_CHAIN_SPLIT.

Gate grant()/ungrant()/resolveGrant on ownsKeyResponse() (synthetic OR
patch-mode) so every access plane serves the same cached KeyEntryResponse.
Pre-36 still answers PERMISSION_DENIED; no behavior change on Android 15.
2026-05-30 13:42:31 +01:00
Enginex0 0229368c04 fix(keystore): mirror TEE device-ID capability
generateKey synthesized device-property attestation unconditionally:
the BRAND/DEVICE/PRODUCT/MANUFACTURER/MODEL tags that
setDevicePropertiesAttestationIncluded emits. A forged key thus
succeeded where real silicon returns CANNOT_ATTEST_IDS. Hardware that
never provisioned device IDs cannot attest them, so forging them is an
over-capability tell: a genuine device of the same class fails the
identical request.

Add DeviceAttestationService.canAttestDeviceIds, a lazy probe that asks
the real TEE to attest device properties once and caches the verdict.
It is gated behind isTeeFunctional, so a silent or dead TEE
short-circuits to "cannot attest" without a second doomed probe.

handleGenerateKey now returns KEYMINT_CANNOT_ATTEST_IDS for any
device-ID or device-property attestation the real TEE cannot satisfy,
uniformly across AUTO, PATCH, and GENERATE. Basic attestation carries
none of these tags and is untouched.

Verified on 23106RN0DA: kknd under GENERATE now WARNs, matching a stock
locked-bootloader device. Principle: forge health, mirror capability.
2026-05-30 13:42:31 +01:00
Enginex0 0f61bb841a feat(keystore): mirror lifecycle via maintenance
Hook the keystore2 daemon's android.security.maintenance binder (hosted
by the same process, reached by the already-injected native hook) so
synthetic key state follows real key-lifecycle events:

- clearNamespace(APP) purges synthetic keys for the uid and their grants.
- deleteAllKeys() clears all synthetic keys and grants.
- migrateKeyNamespace() re-keys the synthetic entry, preserving material,
  chain, and grants.

Pure side-effect hook: every handled transaction mutates only our own
synthetic state, then returns ContinueAndSkipPost so the real keystore2
still performs the real operation. Unhandled codes pass through, so real
key lifecycle is never disturbed. Pre-empts delete-then-read and
clearNamespace coherence probes (Phase 9 Change 4).

Adds a minimal IKeystoreMaintenance compile stub for the descriptor;
transaction codes resolve reflectively on-device.

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 50f2e98375 fix(keystore): evict stale chains on key mutation
Two synthetic-cache staleness gaps let getKeyEntry replay a pre-mutation
attestation:

- importKey now drops teeResponses and patchedChains for the alias, not
  only generatedKeys. A successful import replaces the real key, so the
  retained patched chain was a tell (duck STALE_GENERATED_AFTER_IMPORT).
- After updateSubcomponent re-keys a patched chain, getKeyEntry on a
  patch-mode key evicts the cached TEE response by KEY_ID or APP so the
  read falls through to the updated real keystore2
  (duck STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 f4e2619eba fix(keystore): gate synthetic grant to Android 16
KeyStoreManager.grantKeyAccess() became a public app API only in
Android 16 (API 36). Before that grant was a hidden API and SELinux
denied untrusted_app, so a real Android 15 device answers a private-
binder grant() with PERMISSION_DENIED.

The virtualized grant plane (8be9077) issued a synthetic grant on every
SDK, exposing a capability a real Android 15 app does not have. Gate
grant() and ungrant() on SDK_INT >= 36: pre-36 returns PERMISSION_DENIED
for synthetic keys (matching the real device, which Duck then marks
UNAVAILABLE rather than a tell); 36+ keeps the coherent virtualized
grant.

On-device 23106RN0DA (SDK 35): all four grant rows report UNAVAILABLE,
not RED; tamper score unaffected.

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 649136ab4e fix(shim): match real gen-mode auth shape
Real keystore2 (captured on-device, MediaTek SDK 35) emits 11 EC
authorizations in the generateKey KeyMetadata: no VENDOR_PATCHLEVEL or
BOOT_PATCHLEVEL, and USER_ID tagged at SecurityLevel.SOFTWARE. The shim
emitted 13 with both patchlevels and USER_ID at KEYSTORE.

Duck-Detector's generate-mode parcel fingerprint stride-walks the reply
and keys on the 13-entry layout, so the two extra entries were the tell.
Drop both patchlevels from the authorization list (they remain in the
attestation extension via AttestationBuilder, so attestation content is
unchanged) and move USER_ID to SOFTWARE to mirror the captured device.

On-device 23106RN0DA: generate-mode fingerprint signal gone (0 local),
TEE tamper score 18 -> 8.

Refs Phase 7 .omc/plans/tee-fingerprint-phase-7-generate-mode-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 d155a0ded6 feat(keystore): virtualize grant plane
Duck-Detector's grant-domain probes generate an attested key, then
reach it through a second access plane -- IKeystoreService.grant() then
getKeyEntry(Domain.GRANT, grantId) -- and compare the certificate
chains. We synthesized the owner key but never virtualized the GRANT
plane, so grant reads fell through to the real keystore2, which has no
record of the synthetic key. That single fall-through produced six RED
rows.

Virtualize the plane so every access path returns the same synthesized
KeyEntryResponse:

- SoftwareGrant state model in the shim companion: issue/resolve/
  revoke/purge, caller-bound and access-vector-aware (Change 1).
- grant/ungrant/getKeyEntry(GRANT) handlers in Keystore2Interceptor.
  resolveGrant() enforces caller-binding (non-grantee -> KEY_NOT_FOUND,
  PR #57 probe 4) and the GET_INFO=0x4 access-vector gate (missing ->
  PERMISSION_DENIED, PR #57 probe 3); a valid read returns the owner's
  exact KeyEntryResponse for a coherent chain (Change 2).
- Purge grants on key teardown and clearAll, so grants die with the
  key and re-key orphans them -- matching real keystore2 (Change 3).

The Domain.GRANT read is resolved before the package-scoped
shouldSkipUid filter: isolated grantees (bindIsolatedService) have no
package mapping and would otherwise be dropped to the real keystore2,
leaving three grant rows Unavailable. Caller-binding in resolveGrant()
is the real access gate, mirroring keystore2's grantee+id row keying.

Verified on-device (generate mode, build #237): all four grant rows
clean, TEE tamper score 28 -> 18, zero adjacent regression.

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0andGitHub edac284972 Merge pull request #27 from Andrea-lyz/pr/fix-gpay-include-unique-id
fix(interception): strip INCLUDE_UNIQUE_ID instead of rejecting when permission missing
2026-05-30 11:25:44 +01:00
Enginex0andGitHub cbb73a0b0e Merge pull request #26 from Andrea-lyz/fix/createOperation-key-id-not-found
fix(intercept): use ContinueAndSkipPost for KEY_ID createOperation NOT FOUND
2026-05-30 11:25:40 +01:00
Andrea-lyz 3140ff5e96 fix(interception): strip INCLUDE_UNIQUE_ID instead of rejecting on missing permission
The INCLUDE_UNIQUE_ID gate in handleGenerateKey (introduced as part of the
PR157 AOSP-compliance work) returns PERMISSION_DENIED when the caller
holds neither SELinux gen_unique_id nor REQUEST_UNIQUE_ID_ATTESTATION.

This breaks Google Wallet card binding on real devices: Wallet's
generateKey carries INCLUDE_UNIQUE_ID without holding the permission, so
its attestation key request is rejected and Wallet surfaces the failure
as "this phone does not meet the security requirements for Google
Wallet". Symptoms reported by users: clearing GMS data only helps for a
few seconds before the state regresses; no card can be added.

Naive removal of the gate is not safe: AttestationBuilder honours
`includeUniqueId == true` by computing an HMAC-SHA256 unique_id and
embedding it in the attestation extension. With the gate gone, GMS
attestation flows that include the tag end up with a unique_id in the
extension that Play Integrity flags as inconsistent for the caller,
turning all three integrity verdicts red.

The fix here splits the difference: when the permission check fails,
silently strip the INCLUDE_UNIQUE_ID tag from the KeyParameter array
(and re-parse `parsedParams`) instead of rejecting the request. The key
generates normally, AttestationBuilder takes the
`else { ByteArray(0) }` branch, and the resulting attestation simply
omits the unique_id field — matching pre-PR157 behaviour, where the
tag effectively had no effect.

Verified on a device that previously failed Wallet binding on the
PR157 baseline:
  - Play Integrity: BASIC + DEVICE + STRONG all pass.
  - Google Wallet: card binding completes successfully.
  - Calls that DO hold the permission are unaffected (still emit
    unique_id as before).

The rest of the PR157 compliance work (CALLER_NONCE handling,
AuthorizeCreate ordering, USAGE_COUNT_LIMIT counters, effectiveParams
merging) is preserved.
2026-05-27 06:07:18 +02:00
Andrea-lyz 8544aac260 fix(intercept): use ContinueAndSkipPost for KEY_ID createOperation NOT FOUND
When handleCreateOperation receives a Domain.KEY_ID request for a key
not in our generatedKeys cache, it correctly forwards to the real HAL.
However, it previously returned TransactionResult.Continue, which lets
the post-handler run. The post-handler unconditionally registers an
OperationInterceptor on the IKeystoreOperation binder returned by real
keystore2. This interceptor then interferes with the caller's
operation (intercepting finish/abort/updateAad calls).

On devices where vendor daemons (e.g. fingerprint calibration) use
Domain.KEY_ID for their hardware-backed keys, this causes operation
failures — the OperationInterceptor races with the immediate
finish/abort call and may reject updateAad with INVALID_TAG if the
operation is not GCM mode.

Fix: return ContinueAndSkipPost (matching the existing Domain.APP NOT
FOUND path) so the post-handler never runs for operations on keys we
don't own. When the key IS in generatedKeys, we never reach this
return — we proceed to create a SoftwareOperation directly in
pre-transact.

Symptom: OnePlus engineering mode ultrasonic fingerprint calibration
hash retrieval fails with module enabled, works with module disabled.

Signed-off-by: Andrea-lyz <Andrea-lyz@users.noreply.github.com>
2026-05-26 22:53:28 +02:00
14 changed files with 601 additions and 440 deletions
+3
View File
@@ -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/
+1 -1
View File
@@ -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")
} }
@@ -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)
@@ -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)
}
}
@@ -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
View File
@@ -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
} }
+72
View File
@@ -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
View File
@@ -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!");
}
}
}