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