feat(keystore): mirror lifecycle via maintenance

Hook the keystore2 daemon's android.security.maintenance binder (hosted
by the same process, reached by the already-injected native hook) so
synthetic key state follows real key-lifecycle events:

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

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

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

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
This commit is contained in:
Enginex0
2026-05-30 13:42:31 +01:00
parent 50f2e98375
commit 0f61bb841a
4 changed files with 182 additions and 0 deletions
@@ -5,6 +5,7 @@ import android.hardware.security.keymint.SecurityLevel
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.Parcel import android.os.Parcel
import android.os.ServiceManager
import android.system.keystore2.Domain import android.system.keystore2.Domain
import android.system.keystore2.IKeystoreService import android.system.keystore2.IKeystoreService
import android.system.keystore2.KeyDescriptor import android.system.keystore2.KeyDescriptor
@@ -101,6 +102,27 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
override fun onInterceptorReady(service: IBinder, backdoor: IBinder) { override fun onInterceptorReady(service: IBinder, backdoor: IBinder) {
val keystoreInterface = IKeystoreService.Stub.asInterface(service) val keystoreInterface = IKeystoreService.Stub.asInterface(service)
setupSecurityLevelInterceptors(keystoreInterface, backdoor) setupSecurityLevelInterceptors(keystoreInterface, backdoor)
setupMaintenanceInterceptor(backdoor)
}
/**
* Hooks the keystore2 daemon's `android.security.maintenance` binder, which is hosted by the
* same process, so synthetic key state follows real key-lifecycle events. Best-effort: if the
* service is absent the synthetic plane simply forgoes lifecycle parity.
*/
private fun setupMaintenanceInterceptor(backdoor: IBinder) {
runCatching {
ServiceManager.getService("android.security.maintenance")?.let { maintenance ->
SystemLogger.info("Found maintenance binder. Registering interceptor...")
register(
backdoor,
maintenance,
Keystore2MaintenanceInterceptor,
Keystore2MaintenanceInterceptor.interceptedCodes,
)
} ?: SystemLogger.warning("Maintenance binder not found; skipping lifecycle parity.")
}
.onFailure { SystemLogger.error("Failed to intercept maintenance binder.", it) }
} }
private fun setupSecurityLevelInterceptors(service: IKeystoreService, backdoor: IBinder) { private fun setupSecurityLevelInterceptors(service: IKeystoreService, backdoor: IBinder) {
@@ -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)
}
}
@@ -1278,6 +1278,36 @@ class KeyMintSecurityLevelInterceptor(
usageCounters.remove(keyId) usageCounters.remove(keyId)
} }
/** Clears every synthetic key owned by [uid] (maintenance.clearNamespace, Domain.APP). */
fun clearNamespaceKeys(uid: Int) {
val victims = generatedKeys.keys.filter { it.uid == uid }
if (victims.isEmpty()) return
victims.forEach { cleanupKeyData(it) } // also purges grants + persistence
SystemLogger.info(
"Cleared ${victims.size} synthetic keys for uid=$uid (maintenance.clearNamespace)"
)
}
/**
* Re-keys a synthetic entry from [srcId] to [dstId] for maintenance.migrateKeyNamespace,
* preserving the key material, certificate chain, and any grants (which reference the key,
* not the namespace). In-memory only: the stale persisted file is dropped and the migrated
* key is not re-persisted, matching the single-session boundary the grant plane already
* accepts (Phase 9 plan §9). No-op if [srcId] is not ours or [dstId] already exists.
*/
fun migrateGeneratedKey(srcId: KeyIdentifier, dstId: KeyIdentifier) {
if (srcId == dstId || generatedKeys.containsKey(dstId)) return
val info = generatedKeys.remove(srcId) ?: return
generatedKeys[dstId] = info
if (attestationKeys.remove(srcId)) attestationKeys.add(dstId)
if (importedKeys.remove(srcId)) importedKeys.add(dstId)
softwareGrants.entries
.filter { it.value.ownerKeyId == srcId }
.forEach { softwareGrants[it.key] = it.value.copy(ownerKeyId = dstId) }
GeneratedKeyPersistence.delete(srcId)
SystemLogger.info("Migrated synthetic key $srcId -> $dstId (maintenance.migrateKeyNamespace)")
}
fun removeOperationInterceptor(operationBinder: IBinder, backdoor: IBinder) { fun removeOperationInterceptor(operationBinder: IBinder, backdoor: IBinder) {
unregister(backdoor, operationBinder) unregister(backdoor, operationBinder)
@@ -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!");
}
}
}