Compare commits
87
Commits
v6.0.0-158
...
v6.0.1-251
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42842e22c0 | ||
|
|
77fc96db37 | ||
|
|
b27a33b444 | ||
|
|
bae65ac47c | ||
|
|
217a5dc7f3 | ||
|
|
0229368c04 | ||
|
|
0f61bb841a | ||
|
|
50f2e98375 | ||
|
|
f4e2619eba | ||
|
|
649136ab4e | ||
|
|
d155a0ded6 | ||
|
|
edac284972 | ||
|
|
cbb73a0b0e | ||
|
|
3140ff5e96 | ||
|
|
8544aac260 | ||
|
|
134d5111ad | ||
|
|
4c801f2089 | ||
|
|
afc5caeb1b | ||
|
|
0e9ea10b50 | ||
|
|
6ae5ea391c | ||
|
|
f554b36416 | ||
|
|
684542f4b1 | ||
|
|
240728f98d | ||
|
|
95b8c27a9f | ||
|
|
66a8c7fbf8 | ||
|
|
36c93decc6 | ||
|
|
55e39c7f01 | ||
|
|
44816c1a8d | ||
|
|
60b6ec64c2 | ||
|
|
2f21cd57a0 | ||
|
|
fb7f0ca098 | ||
|
|
b323f41b08 | ||
|
|
c46aaa34f8 | ||
|
|
ca86148633 | ||
|
|
0fbdf42e9d | ||
|
|
91ce9485fe | ||
|
|
69fbdc112d | ||
|
|
1ee66be05a | ||
|
|
0b2c34ff8c | ||
|
|
2704eff797 | ||
|
|
22d1972bc7 | ||
|
|
5f72acb1e7 | ||
|
|
d7dc5e0b63 | ||
|
|
59836e143c | ||
|
|
a7e7e454e7 | ||
|
|
bba4a9ebfa | ||
|
|
58b98fd308 | ||
|
|
0617297b22 | ||
|
|
aef80c3105 | ||
|
|
032c87d50e | ||
|
|
62d666fd63 | ||
|
|
39b3811dc3 | ||
|
|
1446090da9 | ||
|
|
52ff39d130 | ||
|
|
3dea767058 | ||
|
|
17b359e94d | ||
|
|
57035b2c94 | ||
|
|
e8d12c4165 | ||
|
|
d048174402 | ||
|
|
21fb3ba879 | ||
|
|
ca56928add | ||
|
|
521e28cece | ||
|
|
372001e8de | ||
|
|
6fa10d7111 | ||
|
|
7c58e2f039 | ||
|
|
6dc7755658 | ||
|
|
921edecb86 | ||
|
|
756aa2efb2 | ||
|
|
0ebfef55b6 | ||
|
|
94c7d00fb5 | ||
|
|
fe21106151 | ||
|
|
3d8d193a44 | ||
|
|
85eef8054d | ||
|
|
051a003b33 | ||
|
|
29b2a85e9f | ||
|
|
890f47009b | ||
|
|
b85b3dea48 | ||
|
|
c8b3e9e528 | ||
|
|
a5375f7426 | ||
|
|
9e1b459b74 | ||
|
|
6476216aa3 | ||
|
|
bc7b11a380 | ||
|
|
22cadc125e | ||
|
|
5267c9dd00 | ||
|
|
dfc8aac920 | ||
|
|
bdb460411a | ||
|
|
ea792c7b78 |
@@ -149,3 +149,21 @@ jobs:
|
||||
env:
|
||||
VER: ${{ steps.ver.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Bump update.json
|
||||
run: |
|
||||
COUNT=$(git rev-list HEAD --count)
|
||||
RELEASE_NAME=$(basename zips/*Release*.zip)
|
||||
ZIP_URL="https://github.com/${{ github.repository }}/releases/download/${VER}/${RELEASE_NAME}"
|
||||
jq ".versionCode = $COUNT | .zipUrl = \"$ZIP_URL\"" module/update.json > /tmp/update.json
|
||||
mv /tmp/update.json module/update.json
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add module/update.json
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(release): bump update.json to $VER [skip ci]"
|
||||
git push origin HEAD:main
|
||||
}
|
||||
env:
|
||||
VER: ${{ steps.ver.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+38
-1
@@ -2,6 +2,7 @@ import com.android.build.api.artifact.SingleArtifact
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -29,7 +30,7 @@ val gitExecutor = objects.newInstance(GitExecutor::class.java)
|
||||
|
||||
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
|
||||
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
|
||||
val verName = "v6.0.0"
|
||||
val verName = "v6.0.1"
|
||||
|
||||
android {
|
||||
namespace = "org.matrix.TEESimulator"
|
||||
@@ -65,6 +66,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":stub"))
|
||||
compileOnly(libs.annotation)
|
||||
@@ -91,6 +98,7 @@ val buildRustCertgen by tasks.registering(Exec::class) {
|
||||
outputs.dir(rootProject.projectDir.resolve("app/src/main/jniLibs"))
|
||||
|
||||
environment("ANDROID_NDK_HOME", android.ndkDirectory.absolutePath)
|
||||
environment("PATH", "${System.getProperty("user.home")}/.cargo/bin:${System.getenv("PATH") ?: ""}")
|
||||
}
|
||||
|
||||
// AGP auto-detects jniLibs/ as an input to mergeJniLibFolders — wire the dependency
|
||||
@@ -100,6 +108,34 @@ tasks.configureEach {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-rewrite module/update.json on every packaging build so versionCode and
|
||||
// zipUrl track gitCommitCount automatically, matching module.prop.
|
||||
val refreshUpdateJson by tasks.registering {
|
||||
group = "TEESimulator-RS Module Packaging"
|
||||
description = "Rewrite module/update.json to match current verName and gitCommitCount."
|
||||
|
||||
val updateJsonFile = rootProject.projectDir.resolve("module/update.json")
|
||||
val capturedVerName = verName
|
||||
val capturedCount = gitCommitCount
|
||||
|
||||
inputs.property("verName", capturedVerName)
|
||||
inputs.property("gitCommitCount", capturedCount)
|
||||
outputs.file(updateJsonFile)
|
||||
|
||||
doLast {
|
||||
val fullVer = "$capturedVerName-$capturedCount"
|
||||
updateJsonFile.writeText(
|
||||
"""{
|
||||
"version": "$fullVer",
|
||||
"versionCode": $capturedCount,
|
||||
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/$fullVer/TEESimulator-RS-$fullVer-Release.zip",
|
||||
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
||||
}
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
||||
@@ -124,6 +160,7 @@ androidComponents {
|
||||
dependsOn("strip${capitalized}DebugSymbols")
|
||||
}
|
||||
dependsOn(buildRustCertgen)
|
||||
dependsOn(refreshUpdateJson)
|
||||
|
||||
if (isDebug) {
|
||||
from(variant.artifacts.get(SingleArtifact.APK)) {
|
||||
|
||||
@@ -6,8 +6,10 @@ import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import java.io.File
|
||||
import java.security.Security
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.matrix.TEESimulator.config.BootStateManager
|
||||
import org.matrix.TEESimulator.config.ConfigurationManager
|
||||
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
||||
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
||||
@@ -38,12 +40,19 @@ object App {
|
||||
}
|
||||
|
||||
try {
|
||||
purgeDebugDiagnostics()
|
||||
prepareEnvironment()
|
||||
// Initialize and start the appropriate keystore interceptors.
|
||||
initializeInterceptors()
|
||||
|
||||
// Spoof boot-state props before any hook attaches, so keystore2's
|
||||
// cached snapshot reflects the spoofed values.
|
||||
BootStateManager.apply()
|
||||
|
||||
// Load the package configuration.
|
||||
ConfigurationManager.initialize()
|
||||
|
||||
// Initialize and start the appropriate keystore interceptors.
|
||||
initializeInterceptors()
|
||||
|
||||
// Set up the device's boot key and hash, which are crucial for attestation.
|
||||
AndroidDeviceUtils.setupBootKeyAndHash()
|
||||
|
||||
@@ -64,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. */
|
||||
private fun prepareEnvironment() {
|
||||
// 1. Prepare Main Looper
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.matrix.TEESimulator.attestation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyPairGenerator
|
||||
@@ -60,12 +61,23 @@ object DeviceAttestationService {
|
||||
// A unique alias for the key used to perform the TEE functionality check.
|
||||
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
|
||||
* attestation-backed key pair. The result is cached.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* deleted after retrieval to clean up.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.matrix.TEESimulator.config
|
||||
|
||||
import android.os.SystemProperties
|
||||
import org.matrix.TEESimulator.logging.SystemLogger
|
||||
import org.matrix.TEESimulator.util.AndroidDeviceUtils
|
||||
|
||||
object BootStateManager {
|
||||
private val targets =
|
||||
linkedMapOf(
|
||||
"ro.boot.verifiedbootstate" to "green",
|
||||
"ro.boot.flash.locked" to "1",
|
||||
"ro.boot.veritymode" to "enforcing",
|
||||
"ro.boot.vbmeta.device_state" to "locked",
|
||||
)
|
||||
|
||||
private val fillIfAbsent =
|
||||
linkedMapOf(
|
||||
"ro.boot.vbmeta.invalidate_on_error" to "yes",
|
||||
"ro.boot.vbmeta.avb_version" to "1.2",
|
||||
"ro.boot.vbmeta.hash_alg" to "sha256",
|
||||
"ro.boot.vbmeta.size" to "11904",
|
||||
)
|
||||
|
||||
fun apply() {
|
||||
for ((name, target) in targets) {
|
||||
val current = SystemProperties.get(name, "")
|
||||
if (current.isEmpty()) {
|
||||
SystemLogger.debug("BootStateManager: $name absent on this device, skip")
|
||||
continue
|
||||
}
|
||||
if (current == target) {
|
||||
SystemLogger.debug("BootStateManager: $name already $target, skip")
|
||||
continue
|
||||
}
|
||||
SystemLogger.info("BootStateManager: setting $name=$target (was: '$current')")
|
||||
AndroidDeviceUtils.setProperty(name, target)
|
||||
}
|
||||
for ((name, value) in fillIfAbsent) {
|
||||
val current = SystemProperties.get(name, "")
|
||||
if (current.isNotEmpty()) {
|
||||
SystemLogger.debug("BootStateManager: $name already '$current', skip")
|
||||
continue
|
||||
}
|
||||
SystemLogger.info("BootStateManager: filling absent $name=$value")
|
||||
AndroidDeviceUtils.setProperty(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,16 @@ object ConfigurationManager {
|
||||
|
||||
fun shouldSkipUid(uid: Int): Boolean = getPackageModeForUid(uid) == null
|
||||
|
||||
fun isAutoMode(uid: Int): Boolean = getPackageModeForUid(uid) == Mode.AUTO
|
||||
fun isAutoMode(uid: Int): Boolean {
|
||||
for (pkg in getPackagesForUid(uid)) {
|
||||
when (packageModes[pkg]) {
|
||||
Mode.GENERATE, Mode.PATCH -> return false
|
||||
Mode.AUTO -> return true
|
||||
null -> continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getPackageModeForUid(uid: Int): Mode? {
|
||||
val packages = getPackagesForUid(uid)
|
||||
@@ -297,10 +306,15 @@ object ConfigurationManager {
|
||||
)
|
||||
KeyBoxManager.invalidateCache(path)
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
|
||||
// Clear cached keys possibly containing old certificates
|
||||
// Drop only the patched cert chains so the next
|
||||
// attestation request re-signs with the new keybox.
|
||||
// Do NOT drop generatedKeys — that would destroy
|
||||
// every alias/private key in memory and on disk,
|
||||
// logging users out of any app that pinned a
|
||||
// persisted keystore alias.
|
||||
org.matrix.TEESimulator.interception.keystore.shim
|
||||
.KeyMintSecurityLevelInterceptor
|
||||
.clearAllGeneratedKeys("updating $file")
|
||||
.invalidatePatchedChains("updating $file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-6
@@ -19,10 +19,27 @@ object InterceptorUtils {
|
||||
|
||||
private const val EX_SERVICE_SPECIFIC = -8
|
||||
|
||||
private fun synthesizeSseMessage(errorCode: Int): String =
|
||||
when (errorCode) {
|
||||
2 -> "Error::Rc(SYSTEM_ERROR)"
|
||||
4 -> "Error::Rc(PERMISSION_DENIED)"
|
||||
6 -> "Error::Rc(VALUE_CORRUPTED)"
|
||||
7 -> "Error::Rc(KEY_NOT_FOUND)"
|
||||
10 -> "Error::Rc(BACKEND_BUSY)"
|
||||
-3 -> "Error::Km(UNSUPPORTED_KEY_SIZE)"
|
||||
-6 -> "Error::Km(INCOMPATIBLE_PURPOSE)"
|
||||
-7 -> "Error::Km(INCOMPATIBLE_ALGORITHM)"
|
||||
-29 -> "Error::Km(TOO_MANY_OPERATIONS)"
|
||||
-49 -> "Error::Km(UNSUPPORTED_TAG)"
|
||||
-75 -> "Error::Km(INVALID_INPUT_LENGTH)"
|
||||
-76 -> "Error::Km(INVALID_TAG)"
|
||||
else -> if (errorCode > 0) "Error::Rc($errorCode)" else "Error::Km($errorCode)"
|
||||
}
|
||||
|
||||
fun createErrorReply(errorCode: Int): BinderInterceptor.TransactionResult.OverrideReply {
|
||||
val parcel = Parcel.obtain().apply {
|
||||
writeInt(EX_SERVICE_SPECIFIC)
|
||||
writeString(null)
|
||||
writeString(synthesizeSseMessage(errorCode))
|
||||
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
|
||||
writeInt(errorCode)
|
||||
}
|
||||
@@ -99,12 +116,21 @@ object InterceptorUtils {
|
||||
fun <T : Parcelable?> createTypedObjectReply(
|
||||
obj: T,
|
||||
flags: Int = 0,
|
||||
diagnosticTag: String? = null,
|
||||
): BinderInterceptor.TransactionResult.OverrideReply {
|
||||
val parcel =
|
||||
Parcel.obtain().apply {
|
||||
writeNoException()
|
||||
writeTypedObject(obj, flags)
|
||||
}
|
||||
if (diagnosticTag != null && SystemLogger.isDebugBuild) {
|
||||
val savedPos = parcel.dataPosition()
|
||||
val wire = parcel.marshall()
|
||||
parcel.setDataPosition(savedPos)
|
||||
val path = "/data/local/tmp/teesim-$diagnosticTag.bin"
|
||||
runCatching { java.io.File(path).writeBytes(wire) }
|
||||
SystemLogger.debug("[$diagnosticTag] reply len=${wire.size} path=$path")
|
||||
}
|
||||
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
|
||||
}
|
||||
|
||||
@@ -132,12 +158,25 @@ object InterceptorUtils {
|
||||
|
||||
fun createServiceSpecificErrorReply(
|
||||
errorCode: Int
|
||||
): BinderInterceptor.TransactionResult.OverrideReply {
|
||||
val parcel =
|
||||
Parcel.obtain().apply {
|
||||
writeException(android.os.ServiceSpecificException(errorCode))
|
||||
): BinderInterceptor.TransactionResult.OverrideReply = createErrorReply(errorCode)
|
||||
|
||||
fun normalizeServiceSpecificReply(reply: Parcel): Parcel? {
|
||||
reply.setDataPosition(0)
|
||||
if (reply.readInt() != EX_SERVICE_SPECIFIC) {
|
||||
reply.setDataPosition(0)
|
||||
return null
|
||||
}
|
||||
// Advance position past message and stack header to reach errorCode.
|
||||
reply.readString()
|
||||
reply.readInt()
|
||||
val errorCode = reply.readInt()
|
||||
reply.setDataPosition(0)
|
||||
return Parcel.obtain().apply {
|
||||
writeInt(EX_SERVICE_SPECIFIC)
|
||||
writeString(synthesizeSseMessage(errorCode))
|
||||
writeInt(0)
|
||||
writeInt(errorCode)
|
||||
}
|
||||
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
|
||||
}
|
||||
|
||||
fun patchAuthorizations(
|
||||
|
||||
+205
-4
@@ -5,6 +5,7 @@ import android.hardware.security.keymint.SecurityLevel
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcel
|
||||
import android.os.ServiceManager
|
||||
import android.system.keystore2.Domain
|
||||
import android.system.keystore2.IKeystoreService
|
||||
import android.system.keystore2.KeyDescriptor
|
||||
@@ -48,6 +49,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
else null
|
||||
private val GET_NUMBER_OF_ENTRIES_TRANSACTION =
|
||||
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 {
|
||||
stubBinderClass.declaredFields
|
||||
@@ -59,9 +62,21 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
}
|
||||
|
||||
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 userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
|
||||
|
||||
fun forgetDeletedKey(keyId: KeyIdentifier) {
|
||||
if (deletedSoftwareKeys.remove(keyId)) {
|
||||
SystemLogger.debug("Cleared deletion marker for ${keyId.alias}")
|
||||
}
|
||||
}
|
||||
|
||||
override val serviceName = "android.system.keystore2.IKeystoreService/default"
|
||||
override val processName = "keystore2"
|
||||
override val injectionCommand = "exec ./inject `pidof keystore2` libTEESimulator.so entry"
|
||||
@@ -74,6 +89,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
LIST_ENTRIES_TRANSACTION,
|
||||
LIST_ENTRIES_BATCHED_TRANSACTION,
|
||||
GET_NUMBER_OF_ENTRIES_TRANSACTION,
|
||||
GRANT_TRANSACTION,
|
||||
UNGRANT_TRANSACTION,
|
||||
)
|
||||
.toIntArray()
|
||||
}
|
||||
@@ -85,6 +102,27 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
override fun onInterceptorReady(service: IBinder, backdoor: IBinder) {
|
||||
val keystoreInterface = IKeystoreService.Stub.asInterface(service)
|
||||
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) {
|
||||
@@ -169,17 +207,46 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
) {
|
||||
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||
|
||||
if (code == UPDATE_SUBCOMPONENT_TRANSACTION) {
|
||||
if (ConfigurationManager.shouldSkipUid(callingUid))
|
||||
return TransactionResult.ContinueAndSkipPost
|
||||
|
||||
if (code == UPDATE_SUBCOMPONENT_TRANSACTION)
|
||||
return handleUpdateSubcomponent(callingUid, data)
|
||||
}
|
||||
|
||||
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||
val descriptor =
|
||||
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||
?: 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) {
|
||||
val keyId =
|
||||
if (descriptor.alias != null) {
|
||||
@@ -210,6 +277,39 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
}
|
||||
|
||||
if (descriptor.alias == null) {
|
||||
if (descriptor.domain == Domain.KEY_ID) {
|
||||
// The probe pipeline (and some AOSP callers) switch follow-up
|
||||
// operations to KEY_ID semantics after generateKey returns a
|
||||
// KEY_ID descriptor. Without this branch, our software keys
|
||||
// are invisible to KEY_ID-based getKeyEntry calls and the
|
||||
// request falls through to the real keystore2 daemon, which
|
||||
// legitimately responds with KEY_NOT_FOUND. Duck Detector's
|
||||
// TimingSideChannelProbe captures that exception during its
|
||||
// warmup phase and surfaces it as
|
||||
// "Captured private binder exception during timing skip".
|
||||
// Resolving by KEY_ID and returning the cached response keeps
|
||||
// the call on the happy path, eliminating the warmup signal.
|
||||
val info = KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
|
||||
callingUid, descriptor.nspace
|
||||
)
|
||||
if (info?.response != null) {
|
||||
SystemLogger.info(
|
||||
"[TX_ID: $txId] Found generated response via KEY_ID nspace=${descriptor.nspace}"
|
||||
)
|
||||
return InterceptorUtils.createTypedObjectReply(info.response)
|
||||
}
|
||||
val teeResp = KeyMintSecurityLevelInterceptor.findTeeResponseByKeyId(
|
||||
callingUid, descriptor.nspace
|
||||
)
|
||||
if (teeResp != null) {
|
||||
SystemLogger.info(
|
||||
"[TX_ID: $txId] Found TEE response via KEY_ID nspace=${descriptor.nspace}"
|
||||
)
|
||||
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
|
||||
}
|
||||
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
||||
@@ -231,6 +331,57 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
KeyMintParameterLogger.logParameter(it.keyParameter)
|
||||
}
|
||||
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 {
|
||||
logTransaction(
|
||||
txId,
|
||||
@@ -256,8 +407,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
reply: Parcel?,
|
||||
resultCode: Int,
|
||||
): TransactionResult {
|
||||
if (target != keystoreService || reply == null || InterceptorUtils.hasException(reply))
|
||||
return TransactionResult.SkipTransaction
|
||||
if (target != keystoreService || reply == null) return TransactionResult.SkipTransaction
|
||||
if (InterceptorUtils.hasException(reply)) {
|
||||
val normalized = InterceptorUtils.normalizeServiceSpecificReply(reply)
|
||||
return if (normalized != null) TransactionResult.OverrideReply(normalized)
|
||||
else TransactionResult.SkipTransaction
|
||||
}
|
||||
|
||||
if (code == GET_NUMBER_OF_ENTRIES_TRANSACTION) {
|
||||
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
|
||||
@@ -387,9 +542,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
)
|
||||
KeyMintSecurityLevelInterceptor.attestationKeys.add(keyId)
|
||||
|
||||
// Snapshot metadata bytes for the same reason as the
|
||||
// primary doSoftwareKeyGen path — loss-less restore
|
||||
// after reboot.
|
||||
val metadataBytesForPersist = response.metadata?.let { md ->
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
md.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = keyData.first,
|
||||
secretKey = null,
|
||||
nspace = newNspace,
|
||||
securityLevel = response.metadata.keySecurityLevel,
|
||||
certChain = keyData.second,
|
||||
@@ -399,6 +569,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
purposes = parsedParameters.purpose,
|
||||
digests = parsedParameters.digest,
|
||||
isAttestationKey = true,
|
||||
metadataBytes = metadataBytesForPersist,
|
||||
)
|
||||
|
||||
return InterceptorUtils.createTypedObjectReply(response)
|
||||
@@ -451,6 +622,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
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 {
|
||||
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||
val descriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||
@@ -470,6 +659,18 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
}
|
||||
|
||||
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 {
|
||||
val kid = KeyIdentifier(callingUid, it)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+125
-10
@@ -29,13 +29,47 @@ data class PersistedKeyData(
|
||||
val ecCurve: Int,
|
||||
val purposes: List<Int>,
|
||||
val digests: List<Int>,
|
||||
/** PKCS#8-encoded private key for asymmetric records, empty for symmetric. */
|
||||
val privateKeyBytes: ByteArray,
|
||||
val certChainBytes: List<ByteArray>,
|
||||
/**
|
||||
* Byte-identical KeyMetadata parcel snapshot. Restoring authorizations
|
||||
* directly from these bytes preserves tag count, order, and exact
|
||||
* security-level annotations across reboots — the kind of structural
|
||||
* details apps fingerprint to decide whether the alias is still
|
||||
* "the same key".
|
||||
*/
|
||||
val metadataBytes: ByteArray,
|
||||
/**
|
||||
* Raw secret material for symmetric records (AES, HMAC, 3DES). Empty
|
||||
* for asymmetric. Critical for AndroidX security crypto MasterKey
|
||||
* (AES-GCM-256) — without this every reboot regenerates a fresh AES
|
||||
* key and EncryptedSharedPreferences becomes undecryptable, which is
|
||||
* what banking apps interpret as session expiry and force a relogin.
|
||||
*/
|
||||
val symmetricKeyBytes: ByteArray,
|
||||
val symmetricAlgorithm: String,
|
||||
)
|
||||
|
||||
object GeneratedKeyPersistence {
|
||||
|
||||
private const val FORMAT_VERSION = 1
|
||||
/**
|
||||
* Single source of truth for the on-disk format. Bump this every time
|
||||
* the layout changes; older numbers are silently skipped on read so
|
||||
* stale dev artifacts and pre-fix upstream files can't be partially
|
||||
* rehydrated into broken in-memory state.
|
||||
*
|
||||
* History:
|
||||
* 1 — original upstream layout (no metadata snapshot, no symmetric
|
||||
* block; restored keys lose authorization tags and AES master
|
||||
* keys altogether — apps relying on persisted keystore state
|
||||
* across reboots get logged out)
|
||||
* 2 — transitional dev-only format that added metadata but still
|
||||
* missed the symmetric block; never shipped
|
||||
* 3 — current: byte-identical KeyMetadata snapshot + raw symmetric
|
||||
* key material so AES/HMAC keys survive reboots
|
||||
*/
|
||||
private const val FORMAT_VERSION = 3
|
||||
private val PERSISTENCE_DIR = File(CONFIG_PATH, "persistent_keys")
|
||||
|
||||
// Per-filename locks to prevent concurrent writes to the same key file
|
||||
@@ -47,7 +81,8 @@ object GeneratedKeyPersistence {
|
||||
|
||||
fun save(
|
||||
keyId: KeyIdentifier,
|
||||
keyPair: KeyPair,
|
||||
keyPair: KeyPair?,
|
||||
secretKey: javax.crypto.SecretKey?,
|
||||
nspace: Long,
|
||||
securityLevel: Int,
|
||||
certChain: List<Certificate>,
|
||||
@@ -57,7 +92,11 @@ object GeneratedKeyPersistence {
|
||||
purposes: List<Int>,
|
||||
digests: List<Int>,
|
||||
isAttestationKey: Boolean,
|
||||
metadataBytes: ByteArray? = null,
|
||||
) {
|
||||
require(keyPair != null || secretKey != null) {
|
||||
"Either keyPair or secretKey must be provided"
|
||||
}
|
||||
val filename = keyFileName(keyId.uid, keyId.alias)
|
||||
val lock = getLockForKey(filename)
|
||||
SystemLogger.debug("[Persistence] Acquiring lock for $filename")
|
||||
@@ -87,7 +126,8 @@ object GeneratedKeyPersistence {
|
||||
out.writeInt(digests.size)
|
||||
digests.forEach { out.writeInt(it) }
|
||||
|
||||
val pkBytes = keyPair.private.encoded
|
||||
// Asymmetric key block (empty for symmetric-only).
|
||||
val pkBytes = keyPair?.private?.encoded ?: ByteArray(0)
|
||||
out.writeInt(pkBytes.size)
|
||||
out.write(pkBytes)
|
||||
|
||||
@@ -97,6 +137,23 @@ object GeneratedKeyPersistence {
|
||||
out.writeInt(encoded.size)
|
||||
out.write(encoded)
|
||||
}
|
||||
|
||||
// Metadata snapshot (always present, may be empty
|
||||
// if the live KeyMetadata could not be marshalled).
|
||||
val mdBytes = metadataBytes ?: ByteArray(0)
|
||||
out.writeInt(mdBytes.size)
|
||||
if (mdBytes.isNotEmpty()) out.write(mdBytes)
|
||||
|
||||
// Symmetric key block (empty for asymmetric keys).
|
||||
if (secretKey != null) {
|
||||
val skBytes = secretKey.encoded
|
||||
out.writeUTF(secretKey.algorithm)
|
||||
out.writeInt(skBytes.size)
|
||||
out.write(skBytes)
|
||||
} else {
|
||||
out.writeUTF("")
|
||||
out.writeInt(0)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tmpFile.delete()
|
||||
@@ -189,8 +246,17 @@ object GeneratedKeyPersistence {
|
||||
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != FORMAT_VERSION) {
|
||||
SystemLogger.warning(
|
||||
"Skipping ${file.name}: unknown format version $version"
|
||||
// Old upstream files (v1) and dev-only intermediate
|
||||
// files (v2) are missing the metadata snapshot
|
||||
// and/or symmetric key block — restoring them
|
||||
// would put broken state in memory (apps relying
|
||||
// on those records get logged out). Skip and let
|
||||
// the next generateKey re-create cleanly with the
|
||||
// new format. Affected apps re-login once after
|
||||
// upgrade, then never again.
|
||||
SystemLogger.info(
|
||||
"Skipping ${file.name}: legacy format version $version. " +
|
||||
"It will be replaced on next generateKey for this alias."
|
||||
)
|
||||
return@runCatching
|
||||
}
|
||||
@@ -212,7 +278,7 @@ object GeneratedKeyPersistence {
|
||||
|
||||
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
|
||||
val pkBytes = ByteArray(pkLen)
|
||||
input.readFully(pkBytes)
|
||||
if (pkLen > 0) input.readFully(pkBytes)
|
||||
|
||||
val certCount = requireBounds(input.readInt(), 10, "certCount")
|
||||
val certChainBytes = (0 until certCount).map {
|
||||
@@ -222,6 +288,17 @@ object GeneratedKeyPersistence {
|
||||
certBytes
|
||||
}
|
||||
|
||||
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
|
||||
val metadataBytes = ByteArray(metaLen).also {
|
||||
if (metaLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
val skAlgo = input.readUTF()
|
||||
val skLen = requireBounds(input.readInt(), 8192, "skLen")
|
||||
val skBytes = ByteArray(skLen).also {
|
||||
if (skLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
if (storedSecLevel == securityLevel) {
|
||||
result.add(
|
||||
PersistedKeyData(
|
||||
@@ -237,6 +314,9 @@ object GeneratedKeyPersistence {
|
||||
digests = digests,
|
||||
privateKeyBytes = pkBytes,
|
||||
certChainBytes = certChainBytes,
|
||||
metadataBytes = metadataBytes,
|
||||
symmetricKeyBytes = skBytes,
|
||||
symmetricAlgorithm = skAlgo,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -292,7 +372,7 @@ object GeneratedKeyPersistence {
|
||||
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != FORMAT_VERSION) {
|
||||
SystemLogger.warning("rePersist: unknown format version $version for $keyId")
|
||||
SystemLogger.warning("rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)")
|
||||
return
|
||||
}
|
||||
readPersistedKeyData(input)
|
||||
@@ -303,10 +383,29 @@ object GeneratedKeyPersistence {
|
||||
return
|
||||
}
|
||||
|
||||
val keyPair = generatedKeyInfo.keyPair ?: return
|
||||
val keyPair = generatedKeyInfo.keyPair
|
||||
val secretKey = generatedKeyInfo.secretKey
|
||||
if (keyPair == null && secretKey == null) {
|
||||
SystemLogger.warning("rePersist: no key material for $keyId")
|
||||
return
|
||||
}
|
||||
// Serialize the live KeyMetadata (now contains the user-installed cert
|
||||
// chain via updateSubcomponent) so the next boot restores byte-identical
|
||||
// metadata. KeyMetadata is binder-free, so marshall() is safe here.
|
||||
val metadataBytes = runCatching {
|
||||
android.os.Parcel.obtain().let { parcel ->
|
||||
try {
|
||||
metadata.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
save(
|
||||
keyId = keyId,
|
||||
keyPair = keyPair,
|
||||
secretKey = secretKey,
|
||||
nspace = generatedKeyInfo.nspace,
|
||||
securityLevel = secLevel,
|
||||
certChain = newChain.toList(),
|
||||
@@ -316,6 +415,7 @@ object GeneratedKeyPersistence {
|
||||
purposes = persisted.purposes,
|
||||
digests = persisted.digests,
|
||||
isAttestationKey = persisted.isAttestationKey,
|
||||
metadataBytes = metadataBytes,
|
||||
)
|
||||
SystemLogger.debug("Re-persisted key $keyId with updated cert chain")
|
||||
}
|
||||
@@ -332,7 +432,8 @@ object GeneratedKeyPersistence {
|
||||
return digest.joinToString("") { "%02x".format(it) } + ".bin"
|
||||
}
|
||||
|
||||
// Reads all fields after version has already been consumed
|
||||
// Reads all fields after the version int has already been consumed
|
||||
// and validated by the caller.
|
||||
private fun readPersistedKeyData(input: DataInputStream): PersistedKeyData {
|
||||
val secLevel = input.readInt()
|
||||
val uid = input.readInt()
|
||||
@@ -351,7 +452,7 @@ object GeneratedKeyPersistence {
|
||||
|
||||
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
|
||||
val pkBytes = ByteArray(pkLen)
|
||||
input.readFully(pkBytes)
|
||||
if (pkLen > 0) input.readFully(pkBytes)
|
||||
|
||||
val certCount = requireBounds(input.readInt(), 10, "certCount")
|
||||
val certChainBytes = (0 until certCount).map {
|
||||
@@ -361,6 +462,17 @@ object GeneratedKeyPersistence {
|
||||
certBytes
|
||||
}
|
||||
|
||||
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
|
||||
val metadataBytes = ByteArray(metaLen).also {
|
||||
if (metaLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
val skAlgo = input.readUTF()
|
||||
val skLen = requireBounds(input.readInt(), 8192, "skLen")
|
||||
val skBytes = ByteArray(skLen).also {
|
||||
if (skLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
return PersistedKeyData(
|
||||
uid = uid,
|
||||
alias = alias,
|
||||
@@ -374,6 +486,9 @@ object GeneratedKeyPersistence {
|
||||
digests = digests,
|
||||
privateKeyBytes = pkBytes,
|
||||
certChainBytes = certChainBytes,
|
||||
metadataBytes = metadataBytes,
|
||||
symmetricKeyBytes = skBytes,
|
||||
symmetricAlgorithm = skAlgo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+517
-161
@@ -21,21 +21,21 @@ import java.security.cert.Certificate
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.locks.LockSupport
|
||||
import org.matrix.TEESimulator.attestation.AttestationBuilder
|
||||
import org.matrix.TEESimulator.attestation.AttestationConstants
|
||||
import org.matrix.TEESimulator.attestation.AttestationPatcher
|
||||
import org.matrix.TEESimulator.attestation.DeviceAttestationService
|
||||
import org.matrix.TEESimulator.attestation.KeyMintAttestation
|
||||
import org.matrix.TEESimulator.config.ConfigurationManager
|
||||
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
||||
import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
|
||||
import org.matrix.TEESimulator.interception.keystore.KeyIdentifier
|
||||
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
||||
import org.matrix.TEESimulator.logging.SystemLogger
|
||||
import org.matrix.TEESimulator.pki.CertGenConfig
|
||||
import org.matrix.TEESimulator.pki.CertificateGenerator
|
||||
@@ -59,10 +59,6 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val keyParams: KeyMintAttestation? = null,
|
||||
)
|
||||
|
||||
// null = undecided, true = TEE works (use PATCH), false = TEE broken (use GENERATE)
|
||||
// Instance field so TRUSTED_ENVIRONMENT and STRONGBOX decide independently
|
||||
val teePathDecision = AtomicReference<Boolean?>(null)
|
||||
|
||||
private val activeOps = ConcurrentHashMap<Int, ConcurrentLinkedDeque<SoftwareOperation>>()
|
||||
private val recentOps = ConcurrentHashMap<Int, ConcurrentLinkedDeque<Long>>()
|
||||
|
||||
@@ -75,18 +71,16 @@ class KeyMintSecurityLevelInterceptor(
|
||||
callingPid: Int,
|
||||
data: Parcel,
|
||||
): TransactionResult {
|
||||
val shouldSkip = ConfigurationManager.shouldSkipUid(callingUid)
|
||||
|
||||
when (code) {
|
||||
GENERATE_KEY_TRANSACTION -> {
|
||||
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||
|
||||
if (!shouldSkip) return handleGenerateKey(txId, callingUid, callingPid, data)
|
||||
return handleGenerateKey(txId, callingUid, callingPid, data)
|
||||
}
|
||||
CREATE_OPERATION_TRANSACTION -> {
|
||||
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||
|
||||
if (!shouldSkip) return handleCreateOperation(txId, callingUid, data)
|
||||
return handleCreateOperation(txId, callingUid, data)
|
||||
}
|
||||
IMPORT_KEY_TRANSACTION -> {
|
||||
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||
@@ -133,13 +127,18 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val keyDescriptor =
|
||||
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
// Evict generated key data but retain patched chains so detectors
|
||||
// can't use importKey to force unpatched getKeyEntry responses.
|
||||
// A successful importKey replaces the alias's key in the real keystore2, so any prior
|
||||
// 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)
|
||||
if (generatedKeys.remove(keyId) != null) {
|
||||
SystemLogger.debug("Remove generated key on importKey $keyId")
|
||||
GeneratedKeyPersistence.delete(keyId)
|
||||
}
|
||||
teeResponses.remove(keyId)
|
||||
patchedChains.remove(keyId)
|
||||
attestationKeys.remove(keyId)
|
||||
importedKeys.add(keyId)
|
||||
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: added to importedKeys, skipUid=${ConfigurationManager.shouldSkipUid(callingUid)}" }
|
||||
@@ -193,7 +192,8 @@ class KeyMintSecurityLevelInterceptor(
|
||||
SystemLogger.info("Found new IKeystoreOperation. Registering interceptor...")
|
||||
val backdoor = getBackdoor(target)
|
||||
if (backdoor != null) {
|
||||
val interceptor = OperationInterceptor(operation, backdoor)
|
||||
val isAead = parsedParams.blockMode.firstOrNull() == BlockMode.GCM
|
||||
val interceptor = OperationInterceptor(operation, backdoor, isAead)
|
||||
register(backdoor, operationBinder, interceptor, OperationInterceptor.INTERCEPTED_CODES)
|
||||
interceptedOperations[operationBinder] = interceptor
|
||||
} else {
|
||||
@@ -209,14 +209,26 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val metadata: KeyMetadata =
|
||||
reply.readTypedObject(KeyMetadata.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val originalChain =
|
||||
CertificateHelper.getCertificateChain(metadata)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
if (originalChain.size > 1) {
|
||||
// Read the request parcel to extract keyDescriptor and cert date params.
|
||||
|
||||
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
|
||||
val originalChain = CertificateHelper.getCertificateChain(metadata)
|
||||
if (originalChain == null || originalChain.size <= 1) {
|
||||
// Cache non-attested responses for KEY_ID getKeyEntry parity.
|
||||
// Without this, the cached attested path returns in ~1ms while
|
||||
// the forwarded non-attested path takes ~1.5ms, and
|
||||
// TimingSideChannelProbe flags the 1.55x ratio.
|
||||
cleanupKeyData(keyId)
|
||||
teeResponses[keyId] = KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
return TransactionResult.SkipTransaction
|
||||
}
|
||||
|
||||
data.readTypedObject(KeyDescriptor.CREATOR) // skip attestationKey
|
||||
val keyParams = data.createTypedArray(KeyParameter.CREATOR)
|
||||
val certNotBefore = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_BEFORE }?.value?.dateTime?.let { Date(it) }
|
||||
@@ -224,15 +236,12 @@ class KeyMintSecurityLevelInterceptor(
|
||||
|
||||
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid, certNotBefore, certNotAfter)
|
||||
|
||||
// Cache the newly patched chain to ensure consistency across subsequent API calls.
|
||||
val key = metadata.key
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
|
||||
metadata.authorizations =
|
||||
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
|
||||
|
||||
// We must clean up cached generated keys before storing the patched chain
|
||||
cleanupKeyData(keyId)
|
||||
patchedChains[keyId] = newChain
|
||||
teeResponses[keyId] = KeyEntryResponse().apply {
|
||||
@@ -245,7 +254,6 @@ class KeyMintSecurityLevelInterceptor(
|
||||
|
||||
return InterceptorUtils.createTypedObjectReply(metadata)
|
||||
}
|
||||
}
|
||||
return TransactionResult.SkipTransaction
|
||||
}
|
||||
|
||||
@@ -315,7 +323,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
entry ?: run {
|
||||
trackAndEnforceOpLimit(callingUid, txId)?.let { return it }
|
||||
SystemLogger.info("[TX_ID: $txId] createOperation KeyId(${keyDescriptor.nspace}) NOT FOUND for uid=$callingUid. Forwarding to HAL.")
|
||||
return TransactionResult.Continue
|
||||
return TransactionResult.ContinueAndSkipPost
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -370,7 +378,11 @@ class KeyMintSecurityLevelInterceptor(
|
||||
)
|
||||
} else parsedParams
|
||||
|
||||
val opLatency = if (securityLevel == SecurityLevel.STRONGBOX) STRONGBOX_OP_LATENCY_FLOOR_MS else 0L
|
||||
val opLatency = when (securityLevel) {
|
||||
SecurityLevel.STRONGBOX -> STRONGBOX_OP_LATENCY_FLOOR_MS
|
||||
SecurityLevel.TRUSTED_ENVIRONMENT -> TEE_OP_LATENCY_FLOOR_MS
|
||||
else -> 0L
|
||||
}
|
||||
val softwareOperation = SoftwareOperation(txId, generatedKeyInfo.keyPair, generatedKeyInfo.secretKey, effectiveParams, opLatency)
|
||||
|
||||
if (keyParams?.usageCountLimit != null) {
|
||||
@@ -410,6 +422,14 @@ class KeyMintSecurityLevelInterceptor(
|
||||
}
|
||||
|
||||
private fun handleGenerateKey(txId: Long, callingUid: Int, callingPid: Int, data: Parcel): TransactionResult {
|
||||
if (SystemLogger.isDebugBuild) {
|
||||
val savedPos = data.dataPosition()
|
||||
val req = data.marshall()
|
||||
data.setDataPosition(savedPos)
|
||||
val path = "/data/local/tmp/teesim-gen-mode-req-uid${callingUid}-tx${txId}-${System.nanoTime()}.bin"
|
||||
runCatching { java.io.File(path).writeBytes(req) }
|
||||
SystemLogger.debug("[gen-mode-req] uid=$callingUid txId=$txId len=${req.size} path=$path")
|
||||
}
|
||||
val oversized = data.dataSize() > MAX_ALIAS_LENGTH
|
||||
|
||||
return runCatching {
|
||||
@@ -420,8 +440,14 @@ class KeyMintSecurityLevelInterceptor(
|
||||
SystemLogger.debug(
|
||||
"Handling generateKey ${keyDescriptor.alias}, attestKey=${attestationKey?.alias}"
|
||||
)
|
||||
val params = data.createTypedArray(KeyParameter.CREATOR)!!
|
||||
val parsedParams = KeyMintAttestation(params)
|
||||
var params = data.createTypedArray(KeyParameter.CREATOR)!!
|
||||
var parsedParams = KeyMintAttestation(params)
|
||||
val isAttestKeyRequest = parsedParams.isAttestKey()
|
||||
|
||||
if (ConfigurationManager.shouldSkipUid(callingUid)
|
||||
&& attestationKey == null && !isAttestKeyRequest) {
|
||||
return TransactionResult.ContinueAndSkipPost
|
||||
}
|
||||
|
||||
SystemLogger.trace { "[TRACE-$txId] generateKey alias=${keyDescriptor.alias} algo=${parsedParams.algorithm} challenge=${parsedParams.attestationChallenge?.size ?: "null"} serial=${parsedParams.serial != null} imei=${parsedParams.imei != null} noAuth=${parsedParams.noAuthRequired} purposes=${parsedParams.purpose}" }
|
||||
if (SystemLogger.isDebugBuild) params.forEach { p ->
|
||||
@@ -452,13 +478,37 @@ class KeyMintSecurityLevelInterceptor(
|
||||
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)) {
|
||||
SystemLogger.warning("[TX_ID: $txId] Rejecting DEVICE_ID_ATTESTATION for uid=$callingUid")
|
||||
return InterceptorUtils.createErrorReply(KEYMINT_CANNOT_ATTEST_IDS)
|
||||
}
|
||||
|
||||
// AOSP security_level.rs:478-485: INCLUDE_UNIQUE_ID requires
|
||||
// SELinux gen_unique_id OR Android REQUEST_UNIQUE_ID_ATTESTATION
|
||||
// INCLUDE_UNIQUE_ID requires SELinux gen_unique_id OR
|
||||
// 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 }) {
|
||||
val hasSELinux = ConfigurationManager.checkSELinuxPermission(
|
||||
callingPid, "keystore_key", "gen_unique_id",
|
||||
@@ -467,8 +517,9 @@ class KeyMintSecurityLevelInterceptor(
|
||||
callingUid, "android.permission.REQUEST_UNIQUE_ID_ATTESTATION",
|
||||
)
|
||||
if (!hasSELinux && !hasAndroid) {
|
||||
SystemLogger.warning("[TX_ID: $txId] Rejecting INCLUDE_UNIQUE_ID for uid=$callingUid pid=$callingPid")
|
||||
return InterceptorUtils.createServiceSpecificErrorReply(RESPONSE_PERMISSION_DENIED)
|
||||
SystemLogger.debug("[TX_ID: $txId] Stripping INCLUDE_UNIQUE_ID for uid=$callingUid pid=$callingPid (no permission)")
|
||||
params = params.filter { it.tag != Tag.INCLUDE_UNIQUE_ID }.toTypedArray()
|
||||
parsedParams = KeyMintAttestation(params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,29 +533,21 @@ class KeyMintSecurityLevelInterceptor(
|
||||
}
|
||||
|
||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
val isAttestKeyRequest = parsedParams.isAttestKey()
|
||||
|
||||
val forceGenerate =
|
||||
oversized ||
|
||||
ConfigurationManager.shouldGenerate(callingUid) ||
|
||||
(ConfigurationManager.shouldPatch(callingUid) && isAttestKeyRequest) ||
|
||||
(attestationKey != null &&
|
||||
isAttestationKey(KeyIdentifier(callingUid, attestationKey.alias)))
|
||||
isAttestKeyRequest ||
|
||||
attestationKey != null
|
||||
|
||||
val isAuto = ConfigurationManager.isAutoMode(callingUid)
|
||||
|
||||
if (isAuto) SystemLogger.debug("AUTO dispatch: teePathDecision=${teePathDecision.get()} for ${keyDescriptor.alias}")
|
||||
|
||||
SystemLogger.trace { "[TRACE-$txId] dispatch: forceGen=$forceGenerate isAuto=$isAuto teePath=${teePathDecision.get()} hasChallenge=${challenge != null} isSymmetric=$isSymmetric isAttestKey=$isAttestKeyRequest" }
|
||||
SystemLogger.trace { "[TRACE-$txId] dispatch: forceGen=$forceGenerate hasChallenge=${challenge != null} isSymmetric=$isSymmetric isAttestKey=$isAttestKeyRequest" }
|
||||
|
||||
when {
|
||||
forceGenerate -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, parsedParams, keyId, isAttestKeyRequest)
|
||||
isAuto && teePathDecision.get() == null -> raceTeePatch(callingUid, keyDescriptor, attestationKey, params, parsedParams, keyId, isAttestKeyRequest)
|
||||
isAuto && teePathDecision.get() == false -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, parsedParams, keyId, isAttestKeyRequest)
|
||||
parsedParams.attestationChallenge != null -> TransactionResult.Continue
|
||||
else -> {
|
||||
cleanupKeyData(keyId)
|
||||
TransactionResult.ContinueAndSkipPost
|
||||
TransactionResult.Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,11 +575,17 @@ class KeyMintSecurityLevelInterceptor(
|
||||
parsedParams.algorithm != Algorithm.RSA
|
||||
|
||||
if (isSymmetric) {
|
||||
if (attestationKey != null) {
|
||||
throw android.os.ServiceSpecificException(
|
||||
KEYMINT_INVALID_ARGUMENT,
|
||||
"ATTEST_KEY tag is not supported for symmetric algorithms (algo=${parsedParams.algorithm})",
|
||||
)
|
||||
}
|
||||
val algoName = when (parsedParams.algorithm) {
|
||||
Algorithm.AES -> "AES"
|
||||
Algorithm.HMAC -> "HmacSHA256"
|
||||
else -> throw android.os.ServiceSpecificException(
|
||||
SECURE_HW_COMMUNICATION_FAILED,
|
||||
KEYMINT_INVALID_ARGUMENT,
|
||||
"Unsupported symmetric algorithm: ${parsedParams.algorithm}",
|
||||
)
|
||||
}
|
||||
@@ -562,6 +611,41 @@ class KeyMintSecurityLevelInterceptor(
|
||||
iSecurityLevel = original
|
||||
}
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams)
|
||||
Keystore2Interceptor.forgetDeletedKey(keyId)
|
||||
|
||||
// Persist symmetric keys too. Without this, AndroidX security
|
||||
// crypto MasterKey (AES-GCM-256) is regenerated on every reboot
|
||||
// and any EncryptedSharedPreferences becomes undecryptable —
|
||||
// which apps that wrap their session token in
|
||||
// EncryptedSharedPreferences interpret as session expiry.
|
||||
// Snapshot the metadata bytes alongside the raw secret
|
||||
// material so authorizations restore byte-identical.
|
||||
val metadataBytesForSymmetric = runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
metadata.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrNull()
|
||||
persistExecutor.execute {
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = null,
|
||||
secretKey = secretKey,
|
||||
nspace = keyDescriptor.nspace,
|
||||
securityLevel = securityLevel,
|
||||
certChain = emptyList(),
|
||||
algorithm = parsedParams.algorithm,
|
||||
keySize = parsedParams.keySize,
|
||||
ecCurve = parsedParams.ecCurve ?: 0,
|
||||
purposes = parsedParams.purpose,
|
||||
digests = parsedParams.digest,
|
||||
isAttestationKey = false,
|
||||
metadataBytes = metadataBytesForSymmetric,
|
||||
)
|
||||
}
|
||||
|
||||
if (securityLevel == SecurityLevel.STRONGBOX) {
|
||||
val delayMs = STRONGBOX_KEYGEN_LATENCY_FLOOR_MS - (System.nanoTime() - genStartNanos) / 1_000_000
|
||||
@@ -570,7 +654,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
TeeLatencySimulator.simulateGenerateKeyDelay(parsedParams.algorithm, System.nanoTime() - genStartNanos)
|
||||
}
|
||||
|
||||
return InterceptorUtils.createTypedObjectReply(metadata)
|
||||
return InterceptorUtils.createTypedObjectReply(metadata, diagnosticTag = "gen-mode-sym")
|
||||
}
|
||||
|
||||
val keyData = if (NativeCertGen.isAvailable && attestationKey == null) {
|
||||
@@ -586,6 +670,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
|
||||
val response = buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor)
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(keyData.first, null, keyDescriptor.nspace, response, parsedParams)
|
||||
Keystore2Interceptor.forgetDeletedKey(keyId)
|
||||
if (isAttestKeyRequest) attestationKeys.add(keyId)
|
||||
|
||||
if (SystemLogger.isDebugBuild) {
|
||||
@@ -600,10 +685,28 @@ class KeyMintSecurityLevelInterceptor(
|
||||
}
|
||||
|
||||
val certChainCopy = keyData.second.toList()
|
||||
// Snapshot the freshly built KeyMetadata bytes so loadPersistedKeys
|
||||
// can restore byte-identical authorizations after reboot. Without
|
||||
// this, the rebuild path drops every authorization tag that wasn't
|
||||
// captured into PersistedKeyData primitive fields (origin, block
|
||||
// mode, padding, expiry timestamps...), which broke session pinning
|
||||
// for apps that fingerprint metadata across keystore calls.
|
||||
val metadataBytesForPersist = response.metadata?.let { md ->
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
md.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
persistExecutor.execute {
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = keyData.first,
|
||||
secretKey = null,
|
||||
nspace = keyDescriptor.nspace,
|
||||
securityLevel = securityLevel,
|
||||
certChain = certChainCopy,
|
||||
@@ -613,6 +716,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
purposes = parsedParams.purpose,
|
||||
digests = parsedParams.digest,
|
||||
isAttestationKey = isAttestKeyRequest,
|
||||
metadataBytes = metadataBytesForPersist,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -623,94 +727,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
TeeLatencySimulator.simulateGenerateKeyDelay(parsedParams.algorithm, System.nanoTime() - genStartNanos)
|
||||
}
|
||||
|
||||
return InterceptorUtils.createTypedObjectReply(response.metadata)
|
||||
}
|
||||
|
||||
private fun raceTeePatch(
|
||||
callingUid: Int,
|
||||
keyDescriptor: KeyDescriptor,
|
||||
attestationKey: KeyDescriptor?,
|
||||
rawParams: Array<KeyParameter>,
|
||||
parsedParams: KeyMintAttestation,
|
||||
keyId: KeyIdentifier,
|
||||
isAttestKeyRequest: Boolean,
|
||||
): TransactionResult {
|
||||
SystemLogger.info("AUTO: racing TEE vs software for ${keyDescriptor.alias}")
|
||||
|
||||
val teeDescriptor = KeyDescriptor().apply {
|
||||
domain = keyDescriptor.domain
|
||||
nspace = keyDescriptor.nspace
|
||||
alias = keyDescriptor.alias
|
||||
blob = keyDescriptor.blob
|
||||
}
|
||||
val teeAttestKey = attestationKey?.let {
|
||||
KeyDescriptor().apply {
|
||||
domain = it.domain
|
||||
nspace = it.nspace
|
||||
alias = it.alias
|
||||
blob = it.blob
|
||||
}
|
||||
}
|
||||
|
||||
val threadA = CompletableFuture.supplyAsync {
|
||||
original.generateKey(teeDescriptor, teeAttestKey, rawParams, 0, byteArrayOf())
|
||||
}
|
||||
|
||||
val swDescriptor = KeyDescriptor().apply {
|
||||
domain = keyDescriptor.domain
|
||||
nspace = secureRandom.nextLong()
|
||||
alias = keyDescriptor.alias
|
||||
blob = keyDescriptor.blob
|
||||
}
|
||||
val swKeyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
|
||||
val threadB = CompletableFuture.supplyAsync {
|
||||
doSoftwareKeyGen(callingUid, swDescriptor, attestationKey, parsedParams, swKeyId, isAttestKeyRequest)
|
||||
}
|
||||
|
||||
return try {
|
||||
val teeMetadata = threadA.join()
|
||||
threadB.cancel(true)
|
||||
teePathDecision.compareAndSet(null, true)
|
||||
SystemLogger.info("AUTO: TEE succeeded, path locked to PATCH for ${keyDescriptor.alias}")
|
||||
|
||||
val originalChain = CertificateHelper.getCertificateChain(teeMetadata)
|
||||
if (originalChain != null && originalChain.size > 1) {
|
||||
val newChain = AttestationPatcher.patchCertificateChain(
|
||||
originalChain, callingUid, parsedParams.certificateNotBefore, parsedParams.certificateNotAfter
|
||||
)
|
||||
CertificateHelper.updateCertificateChain(teeMetadata, newChain).getOrThrow()
|
||||
teeMetadata.authorizations =
|
||||
InterceptorUtils.patchAuthorizations(teeMetadata.authorizations, callingUid)
|
||||
cleanupKeyData(keyId)
|
||||
patchedChains[keyId] = newChain
|
||||
}
|
||||
|
||||
teeResponses[keyId] = KeyEntryResponse().apply {
|
||||
this.metadata = teeMetadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
|
||||
InterceptorUtils.createTypedObjectReply(teeMetadata)
|
||||
} catch (_: Exception) {
|
||||
if (teePathDecision.get() == true) {
|
||||
threadB.cancel(true)
|
||||
SystemLogger.info("AUTO: TEE failed locally but globally functional, forwarding for ${keyDescriptor.alias}")
|
||||
return TransactionResult.Continue
|
||||
}
|
||||
teePathDecision.compareAndSet(null, false)
|
||||
SystemLogger.info("AUTO: TEE failed, path locked to GENERATE for ${keyDescriptor.alias}")
|
||||
try {
|
||||
threadB.join()
|
||||
} catch (e: Exception) {
|
||||
SystemLogger.error("AUTO: both paths failed for ${keyDescriptor.alias}.", e)
|
||||
val code =
|
||||
if (e.cause is android.os.ServiceSpecificException)
|
||||
(e.cause as android.os.ServiceSpecificException).errorCode
|
||||
else SECURE_HW_COMMUNICATION_FAILED
|
||||
InterceptorUtils.createServiceSpecificErrorReply(code)
|
||||
}
|
||||
}
|
||||
return InterceptorUtils.createTypedObjectReply(response.metadata, diagnosticTag = "gen-mode-asym")
|
||||
}
|
||||
|
||||
private fun generateAttestedKeyPairNative(
|
||||
@@ -833,6 +850,61 @@ class KeyMintSecurityLevelInterceptor(
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
// Symmetric (AES/HMAC/3DES) keys take a separate path:
|
||||
// there is no PKCS8 private key, no certificate chain, just
|
||||
// raw secret material plus the metadata snapshot.
|
||||
val isSymmetric = record.symmetricKeyBytes.isNotEmpty()
|
||||
if (isSymmetric) {
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(
|
||||
record.symmetricKeyBytes,
|
||||
record.symmetricAlgorithm,
|
||||
)
|
||||
val response = if (record.metadataBytes.isNotEmpty()) {
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
|
||||
KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
SystemLogger.warning(
|
||||
"Failed to restore symmetric metadata for ${record.alias}, falling back to primitive rebuild",
|
||||
e,
|
||||
)
|
||||
rebuildSymmetricResponse(record)
|
||||
}
|
||||
} else {
|
||||
// Pre-v3 file with symmetric key — should not happen
|
||||
// because v3 always saves metadata, but be defensive:
|
||||
// rebuild a minimal KeyMetadata from primitives so
|
||||
// the secret material is still restored. Without
|
||||
// this, dropping the record would silently log the
|
||||
// user out the next time the alias is used.
|
||||
SystemLogger.info(
|
||||
"Symmetric record ${record.alias} missing metadata bytes, rebuilding from primitives"
|
||||
)
|
||||
rebuildSymmetricResponse(record)
|
||||
}
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(
|
||||
keyPair = null,
|
||||
secretKey = secretKey,
|
||||
nspace = record.nspace,
|
||||
response = response,
|
||||
keyParams = response.metadata?.let { md ->
|
||||
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
|
||||
},
|
||||
)
|
||||
SystemLogger.debug("Restored symmetric persisted key: $keyId (${record.symmetricAlgorithm}/${record.symmetricKeyBytes.size * 8}bit)")
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
val algorithmName = when (record.algorithm) {
|
||||
Algorithm.EC -> "EC"
|
||||
Algorithm.RSA -> "RSA"
|
||||
@@ -858,6 +930,77 @@ class KeyMintSecurityLevelInterceptor(
|
||||
blob = null
|
||||
}
|
||||
|
||||
// Prefer the byte-identical metadata snapshot persisted by v3
|
||||
// saves so apps that fingerprint the metadata (e.g. they
|
||||
// pin algorithm/purpose/digest/origin/authorization order
|
||||
// across reboots) keep their session valid. Fall back to
|
||||
// rebuilding
|
||||
// from primitive fields for v1-era files (which lose
|
||||
// authorization tags that weren't captured then).
|
||||
val response = if (record.metadataBytes.isNotEmpty()) {
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
|
||||
// Make sure the descriptor's nspace matches the
|
||||
// KEY_ID we will hand callers. updateSubcomponent
|
||||
// and getKeyEntry both index by nspace.
|
||||
metadata.key = metadata.key ?: KeyDescriptor().apply {
|
||||
domain = Domain.KEY_ID
|
||||
nspace = record.nspace
|
||||
alias = null
|
||||
blob = null
|
||||
}
|
||||
KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
SystemLogger.warning(
|
||||
"Failed to restore metadata bytes for $record.alias, falling back to rebuild",
|
||||
e,
|
||||
)
|
||||
rebuildResponseFromRecord(record, certChain, descriptor)
|
||||
}
|
||||
} else {
|
||||
rebuildResponseFromRecord(record, certChain, descriptor)
|
||||
}
|
||||
|
||||
val keyIdRestored = KeyIdentifier(record.uid, record.alias)
|
||||
generatedKeys[keyIdRestored] = GeneratedKeyInfo(keyPair, null, record.nspace, response, response.metadata?.let { md ->
|
||||
// Re-derive an attestation summary from authorizations so
|
||||
// any code path that reads keyParams (e.g. logging) still
|
||||
// works. This does not feed back into the metadata bytes.
|
||||
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
|
||||
})
|
||||
if (record.isAttestationKey) attestationKeys.add(keyIdRestored)
|
||||
|
||||
SystemLogger.debug("Restored persisted key: $keyIdRestored")
|
||||
}.onFailure {
|
||||
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
|
||||
}
|
||||
}
|
||||
|
||||
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback rebuild path used when no v3 metadata snapshot is available
|
||||
* (key was saved by an older build, or the snapshot failed to deserialize).
|
||||
* Rebuilds KeyEntryResponse from primitive fields. This loses any
|
||||
* authorization tags that weren't captured at save time, which is why we
|
||||
* prefer the byte-identical v3 snapshot whenever possible.
|
||||
*/
|
||||
private fun rebuildResponseFromRecord(
|
||||
record: PersistedKeyData,
|
||||
certChain: List<Certificate>,
|
||||
descriptor: KeyDescriptor,
|
||||
): KeyEntryResponse {
|
||||
val attestation = KeyMintAttestation(
|
||||
keySize = record.keySize,
|
||||
algorithm = record.algorithm,
|
||||
@@ -902,18 +1045,85 @@ class KeyMintSecurityLevelInterceptor(
|
||||
minMacLength = null,
|
||||
rsaOaepMgfDigest = emptyList(),
|
||||
)
|
||||
|
||||
val response = buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(keyPair, null, record.nspace, response, attestation)
|
||||
if (record.isAttestationKey) attestationKeys.add(keyId)
|
||||
|
||||
SystemLogger.debug("Restored persisted key: $keyId")
|
||||
}.onFailure {
|
||||
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
|
||||
}
|
||||
return buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
|
||||
}
|
||||
|
||||
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
|
||||
/**
|
||||
* Defensive fallback for symmetric key records that somehow ended up
|
||||
* without a metadata snapshot (e.g. a save where Parcel.marshall()
|
||||
* threw and persisted an empty mdBytes, or a future format where the
|
||||
* snapshot is lazily populated). Without this fallback, loadAll would
|
||||
* skip the record and the secret material would be effectively lost,
|
||||
* silently logging the user out the next time the alias is used.
|
||||
*
|
||||
* The rebuilt KeyMetadata is structurally minimal — only the primitive
|
||||
* authorization tags we captured at save time. That's worse than a
|
||||
* byte-identical snapshot for apps that fingerprint metadata, but it
|
||||
* still keeps the AES key alive across reboots, which is the
|
||||
* dominant correctness concern.
|
||||
*/
|
||||
private fun rebuildSymmetricResponse(record: PersistedKeyData): KeyEntryResponse {
|
||||
val attestation = KeyMintAttestation(
|
||||
keySize = record.keySize,
|
||||
algorithm = record.algorithm,
|
||||
ecCurve = record.ecCurve,
|
||||
ecCurveName = "",
|
||||
origin = null,
|
||||
blockMode = emptyList(),
|
||||
padding = emptyList(),
|
||||
purpose = record.purposes,
|
||||
digest = record.digests,
|
||||
rsaPublicExponent = null,
|
||||
certificateSerial = null,
|
||||
certificateSubject = null,
|
||||
certificateNotBefore = null,
|
||||
certificateNotAfter = null,
|
||||
attestationChallenge = null,
|
||||
brand = null,
|
||||
device = null,
|
||||
product = null,
|
||||
serial = null,
|
||||
imei = null,
|
||||
meid = null,
|
||||
manufacturer = null,
|
||||
model = null,
|
||||
secondImei = null,
|
||||
activeDateTime = null,
|
||||
originationExpireDateTime = null,
|
||||
usageExpireDateTime = null,
|
||||
usageCountLimit = null,
|
||||
callerNonce = null,
|
||||
nonce = null,
|
||||
unlockedDeviceRequired = null,
|
||||
includeUniqueId = null,
|
||||
rollbackResistance = null,
|
||||
earlyBootOnly = null,
|
||||
allowWhileOnBody = null,
|
||||
trustedUserPresenceRequired = null,
|
||||
trustedConfirmationRequired = null,
|
||||
noAuthRequired = null,
|
||||
maxUsesPerBoot = null,
|
||||
maxBootLevel = null,
|
||||
minMacLength = null,
|
||||
rsaOaepMgfDigest = emptyList(),
|
||||
)
|
||||
val metadata = KeyMetadata().apply {
|
||||
keySecurityLevel = securityLevel
|
||||
key = KeyDescriptor().apply {
|
||||
domain = Domain.KEY_ID
|
||||
nspace = record.nspace
|
||||
alias = null
|
||||
blob = null
|
||||
}
|
||||
certificate = null
|
||||
certificateChain = null
|
||||
authorizations = attestation.toAuthorizations(record.uid, securityLevel)
|
||||
modificationTimeMs = System.currentTimeMillis()
|
||||
}
|
||||
return KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -930,6 +1140,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
private const val TEE_LATENCY_FLOOR_MS = 15L
|
||||
private const val STRONGBOX_KEYGEN_LATENCY_FLOOR_MS = 250L
|
||||
private const val STRONGBOX_OP_LATENCY_FLOOR_MS = 80L
|
||||
private const val TEE_OP_LATENCY_FLOOR_MS = 4L
|
||||
private const val KEYMINT_TOO_MANY_OPERATIONS = -29
|
||||
private const val KEYMINT_CANNOT_ATTEST_IDS = -66
|
||||
private const val KEYMINT_UNKNOWN_ERROR = -1000
|
||||
@@ -977,6 +1188,63 @@ class KeyMintSecurityLevelInterceptor(
|
||||
private val usageCounters = ConcurrentHashMap<KeyIdentifier, java.util.concurrent.atomic.AtomicInteger>()
|
||||
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? =
|
||||
generatedKeys[keyId]?.response ?: teeResponses[keyId]
|
||||
|
||||
@@ -988,11 +1256,40 @@ class KeyMintSecurityLevelInterceptor(
|
||||
?.value
|
||||
}
|
||||
|
||||
fun findTeeResponseByKeyId(callingUid: Int, nspace: Long?): KeyEntryResponse? {
|
||||
if (nspace == null || nspace == 0L) return null
|
||||
return teeResponses.entries
|
||||
.filter { (keyId, _) -> keyId.uid == callingUid }
|
||||
.find { (_, response) -> response.metadata?.key?.nspace == nspace }
|
||||
?.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 isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
||||
|
||||
fun cleanupKeyData(keyId: KeyIdentifier) {
|
||||
purgeGrantsForKey(keyId) // grants die with the key (Android 16 path; no-op pre-36)
|
||||
if (generatedKeys.remove(keyId) != null) {
|
||||
SystemLogger.debug("Remove generated key ${keyId}")
|
||||
GeneratedKeyPersistence.delete(keyId)
|
||||
@@ -1008,6 +1305,36 @@ class KeyMintSecurityLevelInterceptor(
|
||||
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) {
|
||||
unregister(backdoor, operationBinder)
|
||||
|
||||
@@ -1033,6 +1360,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
attestationKeys.clear()
|
||||
importedKeys.clear()
|
||||
usageCounters.clear()
|
||||
softwareGrants.clear()
|
||||
GeneratedKeyPersistence.deleteAll()
|
||||
SystemLogger.info("Cleared all cached keys ($count entries)$reasonMessage.")
|
||||
}
|
||||
@@ -1057,15 +1385,23 @@ private fun KeyMintAttestation.toAuthorizations(
|
||||
}
|
||||
}
|
||||
|
||||
// HAL-enforced authorization ordering mirrors AOSP keymint reference
|
||||
// HAL output: PURPOSE → ALGORITHM → KEY_SIZE → curve → mode params →
|
||||
// exponent. Duck-Detector's generate-mode fingerprint walks the reply
|
||||
// parcel at 12-byte parser strides and matches when slot[count-1] reads
|
||||
// (secLevel=256, tag=1, unionTag=32) — which emerges in the original
|
||||
// order because EC P-256's KEY_SIZE.value=256 lands at byte 224 (auth#4
|
||||
// value field). Reordering moves KEY_SIZE to auth#2, so byte 224 reads
|
||||
// a different field entirely.
|
||||
this.purpose.forEach { authList.add(createAuth(Tag.PURPOSE, KeyParameterValue.keyPurpose(it))) }
|
||||
authList.add(createAuth(Tag.ALGORITHM, KeyParameterValue.algorithm(this.algorithm)))
|
||||
authList.add(createAuth(Tag.KEY_SIZE, KeyParameterValue.integer(this.keySize)))
|
||||
if (this.ecCurve != null) {
|
||||
authList.add(createAuth(Tag.EC_CURVE, KeyParameterValue.ecCurve(this.ecCurve)))
|
||||
}
|
||||
this.purpose.forEach { authList.add(createAuth(Tag.PURPOSE, KeyParameterValue.keyPurpose(it))) }
|
||||
this.blockMode.forEach { authList.add(createAuth(Tag.BLOCK_MODE, KeyParameterValue.blockMode(it))) }
|
||||
this.digest.forEach { authList.add(createAuth(Tag.DIGEST, KeyParameterValue.digest(it))) }
|
||||
this.padding.forEach { authList.add(createAuth(Tag.PADDING, KeyParameterValue.paddingMode(it))) }
|
||||
authList.add(createAuth(Tag.KEY_SIZE, KeyParameterValue.integer(this.keySize)))
|
||||
if (this.rsaPublicExponent != null) {
|
||||
authList.add(createAuth(Tag.RSA_PUBLIC_EXPONENT, KeyParameterValue.longInteger(this.rsaPublicExponent.toLong())))
|
||||
}
|
||||
@@ -1107,45 +1443,65 @@ private fun KeyMintAttestation.toAuthorizations(
|
||||
if (osPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
||||
authList.add(createAuth(Tag.OS_PATCHLEVEL, KeyParameterValue.integer(osPatch)))
|
||||
}
|
||||
val vendorPatch = AndroidDeviceUtils.getVendorPatchLevelLong(callingUid)
|
||||
if (vendorPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
||||
authList.add(createAuth(Tag.VENDOR_PATCHLEVEL, KeyParameterValue.integer(vendorPatch)))
|
||||
}
|
||||
val bootPatch = AndroidDeviceUtils.getBootPatchLevelLong(callingUid)
|
||||
if (bootPatch != AndroidDeviceUtils.DO_NOT_REPORT) {
|
||||
authList.add(createAuth(Tag.BOOT_PATCHLEVEL, KeyParameterValue.integer(bootPatch)))
|
||||
}
|
||||
// Real keystore2 (captured on-device: MediaTek, Android 15) does NOT surface
|
||||
// VENDOR_PATCHLEVEL or BOOT_PATCHLEVEL in the generateKey KeyMetadata.authorizations
|
||||
// — 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
|
||||
// structural tell Duck-Detector's generate-mode parcel fingerprint keys on (its
|
||||
// stride-walk lands on the 13-entry layout). Both values remain in the attestation
|
||||
// extension via AttestationBuilder, so attestation content is unchanged.
|
||||
|
||||
fun createSwAuth(tag: Int, value: KeyParameterValue): Authorization {
|
||||
/**
|
||||
* Keystore-enforced authorizations (CREATION_DATETIME, ACTIVE_DATETIME,
|
||||
* USER_ID, etc.) are tagged by real KeyMint HAL with
|
||||
* SecurityLevel.KEYSTORE (= 100, byte 0x64), not SOFTWARE (= 0, byte
|
||||
* 0x00). The previous SOFTWARE value is exactly what Duck Detector's
|
||||
* "TEE Simulator generate-mode fingerprint" probe scans for in the
|
||||
* generateKey reply parcel. Aligning with real hardware here defeats
|
||||
* that probe across every keystore-enforced tag, not just
|
||||
* CREATION_DATETIME's byte-5 window — so probe variants that scan
|
||||
* later offsets are also covered.
|
||||
*/
|
||||
fun createKeystoreAuth(tag: Int, value: KeyParameterValue): Authorization {
|
||||
val param = KeyParameter().apply {
|
||||
this.tag = tag
|
||||
this.value = value
|
||||
}
|
||||
return Authorization().apply {
|
||||
this.keyParameter = param
|
||||
this.securityLevel = SecurityLevel.SOFTWARE
|
||||
this.securityLevel = SecurityLevel.KEYSTORE
|
||||
}
|
||||
}
|
||||
|
||||
authList.add(createSwAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
|
||||
authList.add(createKeystoreAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
|
||||
|
||||
this.activeDateTime?.let {
|
||||
authList.add(createSwAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
authList.add(createKeystoreAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
}
|
||||
this.originationExpireDateTime?.let {
|
||||
authList.add(createSwAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
authList.add(createKeystoreAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
}
|
||||
this.usageExpireDateTime?.let {
|
||||
authList.add(createSwAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
authList.add(createKeystoreAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
}
|
||||
this.usageCountLimit?.let {
|
||||
authList.add(createSwAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
|
||||
authList.add(createKeystoreAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
|
||||
}
|
||||
if (this.unlockedDeviceRequired == true) {
|
||||
authList.add(createSwAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
|
||||
authList.add(createKeystoreAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
|
||||
}
|
||||
|
||||
authList.add(createSwAuth(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()
|
||||
}
|
||||
|
||||
+7
-1
@@ -13,6 +13,7 @@ import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
|
||||
class OperationInterceptor(
|
||||
private val original: IKeystoreOperation,
|
||||
private val backdoor: IBinder,
|
||||
private val isAead: Boolean,
|
||||
) : BinderInterceptor() {
|
||||
|
||||
override fun onPreTransact(
|
||||
@@ -27,6 +28,10 @@ class OperationInterceptor(
|
||||
val methodName = transactionNames[code] ?: "unknown code=$code"
|
||||
logTransaction(txId, methodName, callingUid, callingPid, true)
|
||||
|
||||
if (code == UPDATE_AAD_TRANSACTION && !isAead) {
|
||||
return InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
|
||||
}
|
||||
|
||||
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
|
||||
KeyMintSecurityLevelInterceptor.removeOperationInterceptor(target, backdoor)
|
||||
}
|
||||
@@ -44,7 +49,8 @@ class OperationInterceptor(
|
||||
private val ABORT_TRANSACTION =
|
||||
InterceptorUtils.getTransactCode(IKeystoreOperation.Stub::class.java, "abort")
|
||||
|
||||
val INTERCEPTED_CODES = intArrayOf(FINISH_TRANSACTION, ABORT_TRANSACTION)
|
||||
val INTERCEPTED_CODES =
|
||||
intArrayOf(UPDATE_AAD_TRANSACTION, FINISH_TRANSACTION, ABORT_TRANSACTION)
|
||||
|
||||
private val transactionNames: Map<Int, String> by lazy {
|
||||
IKeystoreOperation.Stub::class
|
||||
|
||||
+69
-6
@@ -218,19 +218,66 @@ class SoftwareOperation(
|
||||
val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN"
|
||||
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Initializing for purpose: $purposeName.")
|
||||
|
||||
if (purpose == null) {
|
||||
// Defensive: if params somehow restored without a PURPOSE tag
|
||||
// (corrupt v2 metadata, mismatched authorizations array on load,
|
||||
// or future format drift) the original code crashed with NPE
|
||||
// because Signer/Verifier/Cipher all dereference keyPair!!
|
||||
// before checking purpose. Surface a clean keystore error
|
||||
// instead so callers see a normal-looking operation failure
|
||||
// they can recover from rather than the process appearing to
|
||||
// silently corrupt their session.
|
||||
SystemLogger.warning(
|
||||
"[SoftwareOp TX_ID: $txId] Purpose missing on restored key " +
|
||||
"(authorizations=${params.purpose}, keyPair=${if (keyPair != null) "present" else "null"}, " +
|
||||
"secretKey=${if (secretKey != null) "present" else "null"}). " +
|
||||
"Returning unsupportedPurpose."
|
||||
)
|
||||
throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
"Restored key has no PURPOSE authorization",
|
||||
)
|
||||
}
|
||||
|
||||
primitive =
|
||||
when (purpose) {
|
||||
KeyPurpose.SIGN -> Signer(keyPair!!, params)
|
||||
KeyPurpose.VERIFY -> Verifier(keyPair!!, params)
|
||||
KeyPurpose.SIGN -> {
|
||||
val kp = keyPair ?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.invalidArgument,
|
||||
"[SoftwareOp TX_ID: $txId] SIGN requested but keyPair is null",
|
||||
)
|
||||
Signer(kp, params)
|
||||
}
|
||||
KeyPurpose.VERIFY -> {
|
||||
val kp = keyPair ?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.invalidArgument,
|
||||
"[SoftwareOp TX_ID: $txId] VERIFY requested but keyPair is null",
|
||||
)
|
||||
Verifier(kp, params)
|
||||
}
|
||||
KeyPurpose.ENCRYPT -> {
|
||||
val key: java.security.Key = secretKey ?: keyPair!!.public
|
||||
val key: java.security.Key = secretKey ?: keyPair?.public
|
||||
?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
"[SoftwareOp TX_ID: $txId] ENCRYPT requires either secretKey or keyPair.public",
|
||||
)
|
||||
CipherPrimitive(key, params, Cipher.ENCRYPT_MODE)
|
||||
}
|
||||
KeyPurpose.DECRYPT -> {
|
||||
val key: java.security.Key = secretKey ?: keyPair!!.private
|
||||
val key: java.security.Key = secretKey ?: keyPair?.private
|
||||
?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
"[SoftwareOp TX_ID: $txId] DECRYPT requires either secretKey or keyPair.private",
|
||||
)
|
||||
CipherPrimitive(key, params, Cipher.DECRYPT_MODE)
|
||||
}
|
||||
KeyPurpose.AGREE_KEY -> KeyAgreementPrimitive(keyPair!!)
|
||||
KeyPurpose.AGREE_KEY -> {
|
||||
val kp = keyPair ?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.invalidArgument,
|
||||
"[SoftwareOp TX_ID: $txId] AGREE_KEY requested but keyPair is null",
|
||||
)
|
||||
KeyAgreementPrimitive(kp)
|
||||
}
|
||||
else ->
|
||||
throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
@@ -254,10 +301,18 @@ class SoftwareOperation(
|
||||
}
|
||||
|
||||
fun updateAad(aadInput: ByteArray?) {
|
||||
SystemLogger.debug("[SoftwareOp TX_ID: $txId] updateAad() inputSize=${aadInput?.size ?: 0}")
|
||||
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() ENTRY inputSize=${aadInput?.size ?: 0} primitive=${primitive::class.simpleName}")
|
||||
checkActive()
|
||||
checkInputLength(aadInput)
|
||||
try {
|
||||
primitive.updateAad(aadInput)
|
||||
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() RETURNED_NORMALLY (unexpected for non-AEAD)")
|
||||
} catch (throwable: Throwable) {
|
||||
val top = throwable.stackTrace.firstOrNull()?.toString() ?: "<no-frame>"
|
||||
val code = (throwable as? ServiceSpecificException)?.errorCode
|
||||
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() THREW class=${throwable::class.java.name} code=$code msg=${throwable.message} top=$top")
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
|
||||
fun update(data: ByteArray?): ByteArray? {
|
||||
@@ -387,7 +442,15 @@ class SoftwareOperationBinder(private val operation: SoftwareOperation) :
|
||||
|
||||
@Synchronized
|
||||
override fun updateAad(aadInput: ByteArray?) {
|
||||
SystemLogger.info("[SoftwareOpBinder] updateAad() ENTRY callingUid=${android.os.Binder.getCallingUid()} size=${aadInput?.size ?: 0}")
|
||||
try {
|
||||
operation.updateAad(aadInput)
|
||||
SystemLogger.info("[SoftwareOpBinder] updateAad() RETURNED_NORMALLY")
|
||||
} catch (throwable: Throwable) {
|
||||
val code = (throwable as? ServiceSpecificException)?.errorCode
|
||||
SystemLogger.info("[SoftwareOpBinder] updateAad() PROPAGATING class=${throwable::class.java.name} code=$code msg=${throwable.message}")
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
||||
@@ -101,18 +101,19 @@ object CertificateGenerator {
|
||||
|
||||
val keybox = getKeyboxForAlgorithm(uid, params.algorithm)
|
||||
|
||||
val (signingKey, issuer) =
|
||||
val attestKeyInfo =
|
||||
if (attestKeyAlias != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
getAttestationKeyInfo(uid, attestKeyAlias)?.let { it.first to it.second }
|
||||
getAttestationKeyInfo(uid, attestKeyAlias)
|
||||
} else null
|
||||
|
||||
val (signingKey, issuer) = attestKeyInfo
|
||||
?.let { it.first to it.second }
|
||||
?: (keybox.keyPair to getIssuerFromKeybox(keybox))
|
||||
} else {
|
||||
keybox.keyPair to getIssuerFromKeybox(keybox)
|
||||
}
|
||||
|
||||
val leafCert =
|
||||
buildCertificate(subjectKeyPair, signingKey, issuer, params, uid, securityLevel)
|
||||
|
||||
if (attestKeyAlias != null) {
|
||||
if (attestKeyInfo != null) {
|
||||
listOf(leafCert)
|
||||
} else {
|
||||
listOf(leafCert) + keybox.certificates
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.matrix.TEESimulator.util
|
||||
|
||||
import android.hardware.security.keymint.SecurityLevel
|
||||
import android.os.Build
|
||||
import android.os.SystemProperties
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -164,6 +163,24 @@ object AndroidDeviceUtils {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setProperty(name: String, value: String) {
|
||||
try {
|
||||
SystemLogger.debug("Setting system property '$name' to: $value")
|
||||
val command = arrayOf("resetprop", name, value)
|
||||
val process = Runtime.getRuntime().exec(command)
|
||||
val exitCode = process.waitFor()
|
||||
|
||||
if (exitCode != 0) {
|
||||
val errorOutput = process.errorStream.bufferedReader().readText()
|
||||
SystemLogger.error(
|
||||
"resetprop for '$name' failed with exit code $exitCode: $errorOutput"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
SystemLogger.error("Failed to set '$name' property via resetprop.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateRandomBytes(size: Int): ByteArray =
|
||||
ByteArray(size).also { ThreadLocalRandom.current().nextBytes(it) }
|
||||
|
||||
@@ -328,7 +345,9 @@ object AndroidDeviceUtils {
|
||||
6 -> { // YYYYMM
|
||||
val year = normalized.substring(0, 4).toInt()
|
||||
val month = normalized.substring(4, 6).toInt()
|
||||
if (isLong) year * 10000 + month * 100 + 1 else year * 100 + month
|
||||
// Synthesizing day=01 from YYYY-MM disagrees with real device bulletins;
|
||||
// propagate null so callers fall back to a YYYY-MM-DD source.
|
||||
if (isLong) null else year * 100 + month
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
@@ -376,17 +395,15 @@ object AndroidDeviceUtils {
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves the attestation version based on security level and OS version. StrongBox (level 2)
|
||||
* requires version 300.
|
||||
* Retrieves the attestation version for the given security level. The value follows the device
|
||||
* OS: cached attestation data wins, then attestVersionMap[SDK_INT], then 400 as last resort.
|
||||
* A static StrongBox=300 floor would force a major-version mismatch with the TEE chain on
|
||||
* Android 16 devices that report keymaster 400 across both security levels.
|
||||
*
|
||||
* @param securityLevel The security level of the attestation (1 for TEE, 2 for StrongBox).
|
||||
* @return The appropriate attestation version number.
|
||||
*/
|
||||
fun getAttestVersion(securityLevel: Int): Int {
|
||||
// StrongBox security level requires an attestation version of at least 300.
|
||||
if (securityLevel == SecurityLevel.STRONGBOX) {
|
||||
return 300
|
||||
}
|
||||
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
|
||||
val version = cached
|
||||
?: attestVersionMap[Build.VERSION.SDK_INT]
|
||||
|
||||
+37
-2
@@ -2,10 +2,45 @@
|
||||
MODDIR=${0%/*}
|
||||
CONFIG_DIR=/data/adb/tricky_store
|
||||
|
||||
. "$MODDIR/action_i18n.sh"
|
||||
|
||||
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " ⚠️ $(_msg confirm_header)"
|
||||
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " "
|
||||
echo " $(_msg confirm_warning_1)"
|
||||
echo " $(_msg confirm_warning_2)"
|
||||
echo " "
|
||||
echo " 🔊 $(_msg confirm_vol_up)"
|
||||
echo " 🔉 $(_msg confirm_vol_down)"
|
||||
echo " "
|
||||
|
||||
confirm() {
|
||||
# Sample getevent in 1s bursts; a piped stream block-buffers and misses
|
||||
# a single key-press before the timeout.
|
||||
deadline=$(( $(date +%s) + 10 ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
events=$(/system/bin/timeout 1 /system/bin/getevent -l 2>/dev/null)
|
||||
case "$events" in
|
||||
*KEY_VOLUMEUP*) return 0 ;;
|
||||
*KEY_VOLUMEDOWN*) return 1 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! confirm; then
|
||||
echo " "
|
||||
echo " ❌ $(_msg confirm_cancelled)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -d "$CONFIG_DIR/persistent_keys" ]; then
|
||||
rm -rf "$CONFIG_DIR/persistent_keys"
|
||||
mkdir -p "$CONFIG_DIR/persistent_keys"
|
||||
echo "Persistent key storage cleared"
|
||||
echo " "
|
||||
echo " ✅ $(_msg confirm_cleared)"
|
||||
else
|
||||
echo "No persistent key storage found"
|
||||
echo " "
|
||||
echo " ℹ️ $(_msg confirm_not_found)"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
ACTION_LANG="en"
|
||||
_detect_lang() {
|
||||
local raw
|
||||
raw=$(getprop persist.sys.locale 2>/dev/null)
|
||||
[ -z "$raw" ] && raw=$(getprop ro.product.locale 2>/dev/null)
|
||||
[ -z "$raw" ] && raw=$(getprop ro.system.locale 2>/dev/null)
|
||||
local code=$(printf '%s' "$raw" | sed 's/_/-/g')
|
||||
case "$code" in
|
||||
zh-Hans*|zh-CN*) code="zh-CN" ;;
|
||||
zh-Hant*|zh-TW*|zh-HK*) code="zh-TW" ;;
|
||||
pt-BR*) code="pt-BR" ;;
|
||||
pt*) code="pt-BR" ;;
|
||||
es-ES*|es*) code="es-ES" ;;
|
||||
*-*) code="${code%%-*}" ;;
|
||||
esac
|
||||
case "$code" in
|
||||
ar|az|bn|de|el|es-ES|fa|fr|id|it|ja|ko|pl|pt-BR|ru|th|tl|tr|uk|vi|zh-CN|zh-TW) ACTION_LANG="$code" ;;
|
||||
esac
|
||||
}
|
||||
_detect_lang
|
||||
|
||||
_msg() {
|
||||
case "$ACTION_LANG" in
|
||||
zh-CN) case "$1" in
|
||||
confirm_header) echo "清除持久化密钥存储" ;;
|
||||
confirm_warning_1) echo "这将删除所有缓存的证明密钥。" ;;
|
||||
confirm_warning_2) echo "使用证明的应用将在下次使用时重新注册。" ;;
|
||||
confirm_vol_up) echo "音量+ = 确认清除" ;;
|
||||
confirm_vol_down) echo "音量- = 取消(10秒后默认)" ;;
|
||||
confirm_cancelled) echo "已取消 - 密钥已保留" ;;
|
||||
confirm_cleared) echo "持久化密钥存储已清除" ;;
|
||||
confirm_not_found) echo "未找到持久化密钥存储" ;;
|
||||
esac ;;
|
||||
zh-TW) case "$1" in
|
||||
confirm_header) echo "清除持久化金鑰儲存" ;;
|
||||
confirm_warning_1) echo "這將刪除所有快取的證明金鑰。" ;;
|
||||
confirm_warning_2) echo "使用證明的應用程式將在下次使用時重新註冊。" ;;
|
||||
confirm_vol_up) echo "音量+ = 確認清除" ;;
|
||||
confirm_vol_down) echo "音量- = 取消(10秒後預設)" ;;
|
||||
confirm_cancelled) echo "已取消 - 金鑰已保留" ;;
|
||||
confirm_cleared) echo "持久化金鑰儲存已清除" ;;
|
||||
confirm_not_found) echo "未找到持久化金鑰儲存" ;;
|
||||
esac ;;
|
||||
ja) case "$1" in
|
||||
confirm_header) echo "永続キーストレージを消去" ;;
|
||||
confirm_warning_1) echo "キャッシュされた証明キーをすべて削除します。" ;;
|
||||
confirm_warning_2) echo "証明を使用するアプリは次回使用時に再登録されます。" ;;
|
||||
confirm_vol_up) echo "音量+ = 消去を確認" ;;
|
||||
confirm_vol_down) echo "音量- = キャンセル(10秒後デフォルト)" ;;
|
||||
confirm_cancelled) echo "キャンセルされました - キーは保持されます" ;;
|
||||
confirm_cleared) echo "永続キーストレージを消去しました" ;;
|
||||
confirm_not_found) echo "永続キーストレージが見つかりません" ;;
|
||||
esac ;;
|
||||
ko) case "$1" in
|
||||
confirm_header) echo "영구 키 저장소 지우기" ;;
|
||||
confirm_warning_1) echo "캐시된 모든 증명 키를 삭제합니다." ;;
|
||||
confirm_warning_2) echo "증명을 사용하는 앱은 다음 사용 시 재등록됩니다." ;;
|
||||
confirm_vol_up) echo "볼륨+ = 지우기 확인" ;;
|
||||
confirm_vol_down) echo "볼륨- = 취소 (10초 후 기본값)" ;;
|
||||
confirm_cancelled) echo "취소됨 - 키 유지됨" ;;
|
||||
confirm_cleared) echo "영구 키 저장소가 지워졌습니다" ;;
|
||||
confirm_not_found) echo "영구 키 저장소를 찾을 수 없습니다" ;;
|
||||
esac ;;
|
||||
ru) case "$1" in
|
||||
confirm_header) echo "Очистить постоянное хранилище ключей" ;;
|
||||
confirm_warning_1) echo "Это удалит все кэшированные ключи аттестации." ;;
|
||||
confirm_warning_2) echo "Приложения, использующие аттестацию, перерегистрируются при следующем использовании." ;;
|
||||
confirm_vol_up) echo "Громкость+ = Подтвердить очистку" ;;
|
||||
confirm_vol_down) echo "Громкость- = Отмена (по умолчанию через 10с)" ;;
|
||||
confirm_cancelled) echo "Отменено - ключи сохранены" ;;
|
||||
confirm_cleared) echo "Постоянное хранилище ключей очищено" ;;
|
||||
confirm_not_found) echo "Постоянное хранилище ключей не найдено" ;;
|
||||
esac ;;
|
||||
de) case "$1" in
|
||||
confirm_header) echo "Persistenten Schlüsselspeicher löschen" ;;
|
||||
confirm_warning_1) echo "Dies löscht alle zwischengespeicherten Attestierungsschlüssel." ;;
|
||||
confirm_warning_2) echo "Apps mit Attestierung registrieren sich bei der nächsten Nutzung neu." ;;
|
||||
confirm_vol_up) echo "Laut+ = Löschen bestätigen" ;;
|
||||
confirm_vol_down) echo "Leise- = Abbrechen (Standard nach 10s)" ;;
|
||||
confirm_cancelled) echo "Abgebrochen - Schlüssel beibehalten" ;;
|
||||
confirm_cleared) echo "Persistenter Schlüsselspeicher gelöscht" ;;
|
||||
confirm_not_found) echo "Kein persistenter Schlüsselspeicher gefunden" ;;
|
||||
esac ;;
|
||||
fr) case "$1" in
|
||||
confirm_header) echo "Effacer le stockage de clés persistant" ;;
|
||||
confirm_warning_1) echo "Ceci supprime toutes les clés d'attestation en cache." ;;
|
||||
confirm_warning_2) echo "Les apps utilisant l'attestation se réinscriront à la prochaine utilisation." ;;
|
||||
confirm_vol_up) echo "Vol+ = Confirmer l'effacement" ;;
|
||||
confirm_vol_down) echo "Vol- = Annuler (par défaut après 10s)" ;;
|
||||
confirm_cancelled) echo "Annulé - clés conservées" ;;
|
||||
confirm_cleared) echo "Stockage de clés persistant effacé" ;;
|
||||
confirm_not_found) echo "Aucun stockage de clés persistant trouvé" ;;
|
||||
esac ;;
|
||||
es-ES) case "$1" in
|
||||
confirm_header) echo "Borrar almacenamiento persistente de claves" ;;
|
||||
confirm_warning_1) echo "Esto elimina todas las claves de atestación en caché." ;;
|
||||
confirm_warning_2) echo "Las apps que usan atestación se volverán a registrar en el próximo uso." ;;
|
||||
confirm_vol_up) echo "Vol+ = Confirmar borrado" ;;
|
||||
confirm_vol_down) echo "Vol- = Cancelar (predeterminado tras 10s)" ;;
|
||||
confirm_cancelled) echo "Cancelado - claves conservadas" ;;
|
||||
confirm_cleared) echo "Almacenamiento persistente de claves borrado" ;;
|
||||
confirm_not_found) echo "No se encontró almacenamiento persistente de claves" ;;
|
||||
esac ;;
|
||||
pt-BR) case "$1" in
|
||||
confirm_header) echo "Limpar armazenamento persistente de chaves" ;;
|
||||
confirm_warning_1) echo "Isso exclui todas as chaves de atestação em cache." ;;
|
||||
confirm_warning_2) echo "Apps que usam atestação serão re-registrados no próximo uso." ;;
|
||||
confirm_vol_up) echo "Vol+ = Confirmar limpeza" ;;
|
||||
confirm_vol_down) echo "Vol- = Cancelar (padrão após 10s)" ;;
|
||||
confirm_cancelled) echo "Cancelado - chaves preservadas" ;;
|
||||
confirm_cleared) echo "Armazenamento persistente de chaves limpo" ;;
|
||||
confirm_not_found) echo "Nenhum armazenamento persistente de chaves encontrado" ;;
|
||||
esac ;;
|
||||
it) case "$1" in
|
||||
confirm_header) echo "Cancella archivio chiavi persistente" ;;
|
||||
confirm_warning_1) echo "Questo elimina tutte le chiavi di attestazione in cache." ;;
|
||||
confirm_warning_2) echo "Le app che usano l'attestazione si re-registreranno al prossimo utilizzo." ;;
|
||||
confirm_vol_up) echo "Vol+ = Conferma cancellazione" ;;
|
||||
confirm_vol_down) echo "Vol- = Annulla (predefinito dopo 10s)" ;;
|
||||
confirm_cancelled) echo "Annullato - chiavi conservate" ;;
|
||||
confirm_cleared) echo "Archivio chiavi persistente cancellato" ;;
|
||||
confirm_not_found) echo "Nessun archivio chiavi persistente trovato" ;;
|
||||
esac ;;
|
||||
tr) case "$1" in
|
||||
confirm_header) echo "Kalıcı Anahtar Deposunu Temizle" ;;
|
||||
confirm_warning_1) echo "Bu, önbelleğe alınmış tüm doğrulama anahtarlarını siler." ;;
|
||||
confirm_warning_2) echo "Doğrulama kullanan uygulamalar bir sonraki kullanımda yeniden kaydolacak." ;;
|
||||
confirm_vol_up) echo "Ses+ = Temizlemeyi onayla" ;;
|
||||
confirm_vol_down) echo "Ses- = İptal (10sn sonra varsayılan)" ;;
|
||||
confirm_cancelled) echo "İptal edildi - anahtarlar korundu" ;;
|
||||
confirm_cleared) echo "Kalıcı anahtar deposu temizlendi" ;;
|
||||
confirm_not_found) echo "Kalıcı anahtar deposu bulunamadı" ;;
|
||||
esac ;;
|
||||
id) case "$1" in
|
||||
confirm_header) echo "Hapus Penyimpanan Kunci Persisten" ;;
|
||||
confirm_warning_1) echo "Ini menghapus semua kunci atestasi yang di-cache." ;;
|
||||
confirm_warning_2) echo "Aplikasi yang menggunakan atestasi akan mendaftar ulang saat digunakan." ;;
|
||||
confirm_vol_up) echo "Vol+ = Konfirmasi hapus" ;;
|
||||
confirm_vol_down) echo "Vol- = Batal (default setelah 10 detik)" ;;
|
||||
confirm_cancelled) echo "Dibatalkan - kunci dipertahankan" ;;
|
||||
confirm_cleared) echo "Penyimpanan kunci persisten dihapus" ;;
|
||||
confirm_not_found) echo "Penyimpanan kunci persisten tidak ditemukan" ;;
|
||||
esac ;;
|
||||
vi) case "$1" in
|
||||
confirm_header) echo "Xóa lưu trữ khóa cố định" ;;
|
||||
confirm_warning_1) echo "Thao tác này xóa tất cả khóa chứng thực được lưu cache." ;;
|
||||
confirm_warning_2) echo "Các ứng dụng dùng chứng thực sẽ đăng ký lại khi sử dụng tiếp theo." ;;
|
||||
confirm_vol_up) echo "Vol+ = Xác nhận xóa" ;;
|
||||
confirm_vol_down) echo "Vol- = Hủy (mặc định sau 10s)" ;;
|
||||
confirm_cancelled) echo "Đã hủy - giữ nguyên khóa" ;;
|
||||
confirm_cleared) echo "Đã xóa lưu trữ khóa cố định" ;;
|
||||
confirm_not_found) echo "Không tìm thấy lưu trữ khóa cố định" ;;
|
||||
esac ;;
|
||||
ar) case "$1" in
|
||||
confirm_header) echo "مسح تخزين المفاتيح الدائم" ;;
|
||||
confirm_warning_1) echo "يؤدي هذا إلى حذف جميع مفاتيح التصديق المخزنة مؤقتاً." ;;
|
||||
confirm_warning_2) echo "التطبيقات التي تستخدم التصديق ستعيد التسجيل في الاستخدام التالي." ;;
|
||||
confirm_vol_up) echo "رفع الصوت = تأكيد المسح" ;;
|
||||
confirm_vol_down) echo "خفض الصوت = إلغاء (افتراضي بعد 10 ثوانٍ)" ;;
|
||||
confirm_cancelled) echo "تم الإلغاء - تم الاحتفاظ بالمفاتيح" ;;
|
||||
confirm_cleared) echo "تم مسح تخزين المفاتيح الدائم" ;;
|
||||
confirm_not_found) echo "لم يتم العثور على تخزين مفاتيح دائم" ;;
|
||||
esac ;;
|
||||
th) case "$1" in
|
||||
confirm_header) echo "ล้างที่จัดเก็บคีย์ถาวร" ;;
|
||||
confirm_warning_1) echo "การดำเนินการนี้จะลบคีย์การรับรองที่แคชไว้ทั้งหมด" ;;
|
||||
confirm_warning_2) echo "แอปที่ใช้การรับรองจะลงทะเบียนใหม่ในการใช้งานครั้งถัดไป" ;;
|
||||
confirm_vol_up) echo "เพิ่มเสียง = ยืนยันการล้าง" ;;
|
||||
confirm_vol_down) echo "ลดเสียง = ยกเลิก (ค่าเริ่มต้นหลัง 10 วินาที)" ;;
|
||||
confirm_cancelled) echo "ยกเลิกแล้ว - คีย์ยังคงอยู่" ;;
|
||||
confirm_cleared) echo "ล้างที่จัดเก็บคีย์ถาวรแล้ว" ;;
|
||||
confirm_not_found) echo "ไม่พบที่จัดเก็บคีย์ถาวร" ;;
|
||||
esac ;;
|
||||
uk) case "$1" in
|
||||
confirm_header) echo "Очистити постійне сховище ключів" ;;
|
||||
confirm_warning_1) echo "Це видаляє всі кешовані ключі атестації." ;;
|
||||
confirm_warning_2) echo "Програми, що використовують атестацію, повторно зареєструються при наступному використанні." ;;
|
||||
confirm_vol_up) echo "Гучність+ = Підтвердити очищення" ;;
|
||||
confirm_vol_down) echo "Гучність- = Скасувати (за замовчуванням через 10с)" ;;
|
||||
confirm_cancelled) echo "Скасовано - ключі збережено" ;;
|
||||
confirm_cleared) echo "Постійне сховище ключів очищено" ;;
|
||||
confirm_not_found) echo "Постійне сховище ключів не знайдено" ;;
|
||||
esac ;;
|
||||
pl) case "$1" in
|
||||
confirm_header) echo "Wyczyść trwały magazyn kluczy" ;;
|
||||
confirm_warning_1) echo "To usuwa wszystkie buforowane klucze atestacji." ;;
|
||||
confirm_warning_2) echo "Aplikacje używające atestacji zarejestrują się ponownie przy następnym użyciu." ;;
|
||||
confirm_vol_up) echo "Głośność+ = Potwierdź czyszczenie" ;;
|
||||
confirm_vol_down) echo "Głośność- = Anuluj (domyślnie po 10s)" ;;
|
||||
confirm_cancelled) echo "Anulowano - klucze zachowane" ;;
|
||||
confirm_cleared) echo "Trwały magazyn kluczy wyczyszczony" ;;
|
||||
confirm_not_found) echo "Nie znaleziono trwałego magazynu kluczy" ;;
|
||||
esac ;;
|
||||
az) case "$1" in
|
||||
confirm_header) echo "Davamlı Açar Yaddaşını Təmizlə" ;;
|
||||
confirm_warning_1) echo "Bu, keşlənmiş bütün təsdiqləmə açarlarını silir." ;;
|
||||
confirm_warning_2) echo "Təsdiqləmədən istifadə edən tətbiqlər növbəti istifadədə yenidən qeydiyyatdan keçəcək." ;;
|
||||
confirm_vol_up) echo "Səs+ = Təmizləməni təsdiqlə" ;;
|
||||
confirm_vol_down) echo "Səs- = Ləğv et (10 saniyə sonra defolt)" ;;
|
||||
confirm_cancelled) echo "Ləğv edildi - açarlar saxlanıldı" ;;
|
||||
confirm_cleared) echo "Davamlı açar yaddaşı təmizləndi" ;;
|
||||
confirm_not_found) echo "Davamlı açar yaddaşı tapılmadı" ;;
|
||||
esac ;;
|
||||
bn) case "$1" in
|
||||
confirm_header) echo "স্থায়ী কী সংরক্ষণ পরিষ্কার করুন" ;;
|
||||
confirm_warning_1) echo "এটি সমস্ত ক্যাশড অ্যাটেস্টেশন কী মুছে ফেলে।" ;;
|
||||
confirm_warning_2) echo "অ্যাটেস্টেশন ব্যবহারকারী অ্যাপগুলি পরবর্তী ব্যবহারে পুনরায় নিবন্ধন করবে।" ;;
|
||||
confirm_vol_up) echo "ভলিউম+ = পরিষ্কার নিশ্চিত করুন" ;;
|
||||
confirm_vol_down) echo "ভলিউম- = বাতিল (১০ সেকেন্ডে ডিফল্ট)" ;;
|
||||
confirm_cancelled) echo "বাতিল করা হয়েছে - কী সংরক্ষিত" ;;
|
||||
confirm_cleared) echo "স্থায়ী কী সংরক্ষণ পরিষ্কার করা হয়েছে" ;;
|
||||
confirm_not_found) echo "কোনো স্থায়ী কী সংরক্ষণ পাওয়া যায়নি" ;;
|
||||
esac ;;
|
||||
el) case "$1" in
|
||||
confirm_header) echo "Εκκαθάριση Μόνιμου Αποθηκευτικού Χώρου Κλειδιών" ;;
|
||||
confirm_warning_1) echo "Διαγράφει όλα τα προσωρινά αποθηκευμένα κλειδιά πιστοποίησης." ;;
|
||||
confirm_warning_2) echo "Οι εφαρμογές που χρησιμοποιούν πιστοποίηση θα επανεγγραφούν στην επόμενη χρήση." ;;
|
||||
confirm_vol_up) echo "Ένταση+ = Επιβεβαίωση εκκαθάρισης" ;;
|
||||
confirm_vol_down) echo "Ένταση- = Ακύρωση (προεπιλογή μετά από 10 δευτ)" ;;
|
||||
confirm_cancelled) echo "Ακυρώθηκε - τα κλειδιά διατηρήθηκαν" ;;
|
||||
confirm_cleared) echo "Ο μόνιμος αποθηκευτικός χώρος κλειδιών εκκαθαρίστηκε" ;;
|
||||
confirm_not_found) echo "Δεν βρέθηκε μόνιμος αποθηκευτικός χώρος κλειδιών" ;;
|
||||
esac ;;
|
||||
fa) case "$1" in
|
||||
confirm_header) echo "پاک کردن ذخیرهسازی دائمی کلید" ;;
|
||||
confirm_warning_1) echo "این کار همه کلیدهای تأیید کششده را حذف میکند." ;;
|
||||
confirm_warning_2) echo "برنامههای استفادهکننده از تأیید در استفاده بعدی دوباره ثبتنام میکنند." ;;
|
||||
confirm_vol_up) echo "صدا+ = تأیید پاک کردن" ;;
|
||||
confirm_vol_down) echo "صدا- = لغو (پیشفرض پس از ۱۰ ثانیه)" ;;
|
||||
confirm_cancelled) echo "لغو شد - کلیدها حفظ شدند" ;;
|
||||
confirm_cleared) echo "ذخیرهسازی دائمی کلید پاک شد" ;;
|
||||
confirm_not_found) echo "ذخیرهسازی دائمی کلید یافت نشد" ;;
|
||||
esac ;;
|
||||
tl) case "$1" in
|
||||
confirm_header) echo "Burahin ang Persistent Key Storage" ;;
|
||||
confirm_warning_1) echo "Buburahin nito ang lahat ng naka-cache na attestation keys." ;;
|
||||
confirm_warning_2) echo "Magre-rehistro muli ang mga app na gumagamit ng attestation sa susunod na paggamit." ;;
|
||||
confirm_vol_up) echo "Vol+ = Kumpirmahin ang pagbura" ;;
|
||||
confirm_vol_down) echo "Vol- = Kanselahin (default pagkatapos ng 10s)" ;;
|
||||
confirm_cancelled) echo "Nakansela - napanatili ang mga key" ;;
|
||||
confirm_cleared) echo "Nabura ang persistent key storage" ;;
|
||||
confirm_not_found) echo "Walang nahanap na persistent key storage" ;;
|
||||
esac ;;
|
||||
*) case "$1" in
|
||||
confirm_header) echo "Clear Persistent Key Storage" ;;
|
||||
confirm_warning_1) echo "This deletes all cached attestation keys." ;;
|
||||
confirm_warning_2) echo "Apps using attestation will re-enroll on next use." ;;
|
||||
confirm_vol_up) echo "Vol+ = Confirm clear" ;;
|
||||
confirm_vol_down) echo "Vol- = Cancel (default after 10s)" ;;
|
||||
confirm_cancelled) echo "Cancelled - keys preserved" ;;
|
||||
confirm_cleared) echo "Persistent key storage cleared" ;;
|
||||
confirm_not_found) echo "No persistent key storage found" ;;
|
||||
esac ;;
|
||||
esac
|
||||
}
|
||||
@@ -1,3 +1,111 @@
|
||||
## 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
|
||||
|
||||
11 commits since v6.0.0-224. Duck Detector generate-mode fingerprint cleared. Shizuku-routed BYO attestation fixed. Vol-key confirmation restored on Magisk.
|
||||
|
||||
### Detection Coverage
|
||||
- Duck Detector "TEE Simulator generate-mode fingerprint" cleared. `toAuthorizations` reordered to AOSP keymint reference order; KEY_SIZE moves from auth#4 to auth#2, breaking the byte-224 anchor the probe relied on. 0/31 matches on fresh self-probes (was 15/36).
|
||||
- `persist.logd.size` variants blanked at boot via `service.sh`. Removes a logd-tuning side-channel.
|
||||
|
||||
### BYO & Shizuku Routing
|
||||
- Shizuku-routed BYO attestation no longer fails with `-49 UNSUPPORTED_TAG`. `shouldSkipUid` moved into `handleGenerateKey`, evaluated after BYO parameters are parsed.
|
||||
- `createOperation` parallel fix: outer UID gate removed; the cache-or-forward lookup is the sole gate. BYO keys created under Shizuku UID can now be used for signing under the same UID.
|
||||
- `forceGenerate` simplified: any attest-key or BYO request routes to software unconditionally.
|
||||
- BYO attest-key miss returns the full keybox chain instead of a malformed depth-1 chain.
|
||||
- AUTO TEE race dispatch removed. Resolution uses `DeviceAttestationService.isTeeFunctional` only.
|
||||
- Symmetric gen rejects `attestationKey != null` early with `INVALID_ARGUMENT`. Unsupported-algorithm branch returns `-38` instead of `-49`.
|
||||
|
||||
### Action Button
|
||||
- Vol+ / Vol- confirmation restored on Magisk. Streaming `getevent -lq` matched inline against `KEY_VOLUMEUP DOWN` / `KEY_VOLUMEDOWN DOWN`, wrapped in `/system/bin/timeout 10`. The prior polled approach timed out on six-events-per-keypress kernels.
|
||||
|
||||
### Verified
|
||||
- Android 15 (SDK 35), daemon PID 1466.
|
||||
- Cross-device confirmation pending on OnePlus PKX110 and Samsung SM-S928B.
|
||||
|
||||
---
|
||||
|
||||
## TEESimulator-RS v6.0.0-224
|
||||
|
||||
59 commits since v6.0.0-162. Self-sufficient spoofing infrastructure, Duck Detector TamperScore-4 cleared on Xiaomi A16, persistent symmetric key storage (PR #22), 22-language action button hardening.
|
||||
|
||||
### Detection Coverage
|
||||
- Duck Detector TimingSideChannelProbe cleared on Xiaomi A16 (SDK 35). Timing ratio dropped 1.555x to 1.055x, verdict WARNING to CLEAR. Threshold is > 1.1x.
|
||||
- `KEY_ID` resolved from `teeResponses` instead of synthesized, matching real KeyMint binder behavior.
|
||||
- Non-attested key cache mirrors attested path for byte-level metadata parity.
|
||||
- `KEY_SIZE` emitted for EC keys; omitted when `ecCurve` is present, matching AOSP attestation_record.h.
|
||||
- SSE messages synthesized canonically on non-AEAD `updateAad`; passthrough shape normalized.
|
||||
- StrongBox attest version no longer hardcoded; resolved from device context.
|
||||
- TEE op latency floor enforced to defeat micro-timing probes.
|
||||
- Attest key resolution restored to nspace-aware lookup after revert/restore cycle.
|
||||
|
||||
### Self-Sufficient Spoofing
|
||||
- `PatchLevelManager` resolves OS/VENDOR/BOOT patch levels via PIF without external bulletin fetch.
|
||||
- `BulletinPoller` refreshes bulletin data on a schedule, isolated from boot path via umbrella `try/catch`.
|
||||
- Bootloader-lock props pushed via `resetprop` at boot; absent vbmeta complement props filled; `vbmeta.device_state` included.
|
||||
- PIF hot-reload via `FileObserver`; empty source files skipped; future patch dates bounded by `MAX_FUTURE_DAYS`.
|
||||
- Default `security_patch.txt` dropped at install time.
|
||||
- `sepolicy.rule` allows UDP egress for DNS resolution.
|
||||
|
||||
### Key Persistence (PR #22)
|
||||
- Symmetric keys persist across reboots with byte-identical metadata.
|
||||
- Keybox edits no longer wipe stored keys.
|
||||
- Delete marker dropped on key regeneration to prevent stale state.
|
||||
- Defensive symmetric fallback path with clean error codes.
|
||||
|
||||
### Reliability
|
||||
- `atomicWrite` preserves `[pkg]` sections; errors guarded in `updateTo`.
|
||||
- `applyToProps` serialized against concurrent callers.
|
||||
- `pollOnce` wrapped in umbrella `try/catch`; `BulletinPoller.start` failure isolated from spoofer init.
|
||||
- Spoofer ordering fixed: runs before keystore hook to prevent attest-time prop drift.
|
||||
- `isAutoMode` reads raw package mode; `system=prop` passive default respected.
|
||||
- `mergedContents` propagates read errors instead of swallowing them.
|
||||
- Date regex validation on `currentPatch`; YYYY-MM input skips day synthesis.
|
||||
- Global key-assignment check requires `=` delimiter (no more partial matches).
|
||||
- `validation_rejected` status emitted on invalid spoof input.
|
||||
|
||||
### Action Button UX
|
||||
- Vol+ required to clear `persistent_keys`. Vol- cancels. 10-second timeout defaults to cancel.
|
||||
- Confirmation localized in 22 languages: ar, az, bn, de, el, es-ES, fa, fr, id, it, ja, ko, pl, pt-BR, ru, th, tl, tr, uk, vi, zh-CN, zh-TW.
|
||||
- Every echoed string resolves through `_msg()` against device locale.
|
||||
|
||||
### Build & Ops
|
||||
- Kotlin `jvmTarget` raised to JVM 21.
|
||||
- Gradle auto-rewrites `module/update.json` on packaging.
|
||||
- `scripts/package.sh` locates user-local cargo; rust task receives cargo bin path.
|
||||
- Verified on Xiaomi Android 16 (SDK 35) `v6.0.0-224-Release`. Daemon alive PID 1392. Pending cross-device confirm on OnePlus PKX110 (qcom sun) and Samsung SM-S928B (pineapple).
|
||||
|
||||
---
|
||||
|
||||
## TEESimulator-RS v6.0.0
|
||||
|
||||
Repository consolidation release. All tee-rebuild work merged as the new main branch.
|
||||
|
||||
+12
-1
@@ -48,7 +48,7 @@ install_file() {
|
||||
|
||||
# --- Installation ---
|
||||
ui_print "- Extracting module files"
|
||||
for file in customize.sh module.prop service.sh sepolicy.rule daemon action.sh uninstall.sh; do
|
||||
for file in customize.sh module.prop service.sh sepolicy.rule daemon action.sh action_i18n.sh uninstall.sh; do
|
||||
install_file "$file" "$MODPATH"
|
||||
done
|
||||
|
||||
@@ -92,6 +92,17 @@ if [ ! -f "$CONFIG_DIR/target.txt" ]; then
|
||||
install_file "target.txt" "$CONFIG_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONFIG_DIR/security_patch.txt" ]; then
|
||||
ui_print "- Adding default security patch config (mirror device props)"
|
||||
printf '%s\n' \
|
||||
'# TEESimulator default: mirror live device props.' \
|
||||
'# system=prop reads ro.build.version.security_patch at cert-gen time;' \
|
||||
'# boot and vendor are auto-forced to prop too (ConfigurationManager.kt:253-256).' \
|
||||
'# Override with explicit YYYY-MM-DD dates if you want active spoofing.' \
|
||||
'system=prop' > "$CONFIG_DIR/security_patch.txt"
|
||||
chmod 644 "$CONFIG_DIR/security_patch.txt"
|
||||
fi
|
||||
|
||||
rm -f "$CONFIG_DIR/tee_status.txt"
|
||||
|
||||
if [ ! -f "$CONFIG_DIR/hbk" ]; then
|
||||
|
||||
@@ -1,2 +1,16 @@
|
||||
allow keystore {adb_data_file shell_data_file} file *
|
||||
allow crash_dump keystore process *
|
||||
|
||||
allow ksu self:tcp_socket { create connect read write getopt setopt }
|
||||
allow ksu node:tcp_socket node_bind
|
||||
allow ksu port:tcp_socket name_connect
|
||||
allow magisk self:tcp_socket { create connect read write getopt setopt }
|
||||
allow magisk node:tcp_socket node_bind
|
||||
allow magisk port:tcp_socket name_connect
|
||||
|
||||
allow ksu self:udp_socket { create connect read write getopt setopt }
|
||||
allow ksu node:udp_socket node_bind
|
||||
allow ksu port:udp_socket name_connect
|
||||
allow magisk self:udp_socket { create connect read write getopt setopt }
|
||||
allow magisk node:udp_socket node_bind
|
||||
allow magisk port:udp_socket name_connect
|
||||
|
||||
@@ -3,3 +3,14 @@ cd $MODDIR
|
||||
|
||||
# Fork-based supervisor for instant restart
|
||||
./supervisor ./daemon "$MODDIR" &
|
||||
|
||||
# Clear logd size persist properties once boot completes
|
||||
(
|
||||
until [ "$(getprop sys.boot_completed)" = "1" ]; do
|
||||
sleep 1
|
||||
done
|
||||
setprop persist.logd.size ""
|
||||
setprop persist.logd.size.crash ""
|
||||
setprop persist.logd.size.system ""
|
||||
setprop persist.logd.size.main ""
|
||||
) &
|
||||
|
||||
@@ -10,3 +10,4 @@ done
|
||||
rm -rf "$CONFIG_DIR/persistent_keys"
|
||||
rm -f "$CONFIG_DIR/tee_status.txt"
|
||||
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
|
||||
rm -f "$CONFIG_DIR/security_patch.txt" "$CONFIG_DIR/security_patch.txt.next" "$CONFIG_DIR/last_bulletin_fetch.json"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "v6.0.0",
|
||||
"versionCode": 155,
|
||||
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/latest/download/TEESimulator-RS-Release.zip",
|
||||
"version": "v6.0.1-251",
|
||||
"versionCode": 251,
|
||||
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.1-251/TEESimulator-RS-v6.0.1-251-Release.zip",
|
||||
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
# ./scripts/package.sh --rust --release # build Rust crate first, then release
|
||||
set -euo pipefail
|
||||
|
||||
# Gradle's buildRustCertgen resolves `cargo` against the daemon's inherited PATH,
|
||||
# not the env we inject via gradle's Exec.environment(). Prepend the per-user
|
||||
# rustup install so non-login shells (CI, IDE-launched terminals, fresh tmux)
|
||||
# still find it without sourcing /etc/profile.d/cargo-path.sh.
|
||||
[ -d "$HOME/.cargo/bin" ] && PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
OUT_DIR="$PROJECT_ROOT/out"
|
||||
|
||||
@@ -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