From 0f61bb841ad7d1a1fc66feeff4659693928d1e72 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Sat, 30 May 2026 03:11:55 +0100 Subject: [PATCH] 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 --- .../keystore/Keystore2Interceptor.kt | 22 ++++ .../Keystore2MaintenanceInterceptor.kt | 108 ++++++++++++++++++ .../shim/KeyMintSecurityLevelInterceptor.kt | 30 +++++ .../maintenance/IKeystoreMaintenance.java | 22 ++++ 4 files changed, 182 insertions(+) create mode 100644 app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2MaintenanceInterceptor.kt create mode 100644 stub/src/main/java/android/security/maintenance/IKeystoreMaintenance.java diff --git a/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2Interceptor.kt b/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2Interceptor.kt index 94d0504..066c802 100644 --- a/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2Interceptor.kt +++ b/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2Interceptor.kt @@ -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 @@ -101,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) { diff --git a/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2MaintenanceInterceptor.kt b/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2MaintenanceInterceptor.kt new file mode 100644 index 0000000..9cb88a6 --- /dev/null +++ b/app/src/main/java/org/matrix/TEESimulator/interception/keystore/Keystore2MaintenanceInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/matrix/TEESimulator/interception/keystore/shim/KeyMintSecurityLevelInterceptor.kt b/app/src/main/java/org/matrix/TEESimulator/interception/keystore/shim/KeyMintSecurityLevelInterceptor.kt index bb74d6d..69955cf 100644 --- a/app/src/main/java/org/matrix/TEESimulator/interception/keystore/shim/KeyMintSecurityLevelInterceptor.kt +++ b/app/src/main/java/org/matrix/TEESimulator/interception/keystore/shim/KeyMintSecurityLevelInterceptor.kt @@ -1278,6 +1278,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) diff --git a/stub/src/main/java/android/security/maintenance/IKeystoreMaintenance.java b/stub/src/main/java/android/security/maintenance/IKeystoreMaintenance.java new file mode 100644 index 0000000..15cd114 --- /dev/null +++ b/stub/src/main/java/android/security/maintenance/IKeystoreMaintenance.java @@ -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}). + * + *

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!"); + } + } +}