feat(keystore): virtualize grant plane

Duck-Detector's grant-domain probes generate an attested key, then
reach it through a second access plane -- IKeystoreService.grant() then
getKeyEntry(Domain.GRANT, grantId) -- and compare the certificate
chains. We synthesized the owner key but never virtualized the GRANT
plane, so grant reads fell through to the real keystore2, which has no
record of the synthetic key. That single fall-through produced six RED
rows.

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

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

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

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

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
This commit is contained in:
Enginex0
2026-05-30 13:42:31 +01:00
parent edac284972
commit d155a0ded6
2 changed files with 142 additions and 4 deletions
@@ -48,6 +48,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,6 +61,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
private const val RESPONSE_KEY_NOT_FOUND = 7
private const val RESPONSE_PERMISSION_DENIED = 6
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
@@ -80,6 +83,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
LIST_ENTRIES_TRANSACTION,
LIST_ENTRIES_BATCHED_TRANSACTION,
GET_NUMBER_OF_ENTRIES_TRANSACTION,
GRANT_TRANSACTION,
UNGRANT_TRANSACTION,
)
.toIntArray()
}
@@ -175,17 +180,48 @@ 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
// A Domain.GRANT read is served for ANY grantee uid — including isolated
// services (bindIsolatedService) that have no package mapping, so
// shouldSkipUid would otherwise drop them to the real keystore2. Resolve it
// before the package-scoped skip: caller-binding in resolveGrant() is the
// real access gate, mirroring keystore2 (a grant row is keyed on grantee+id,
// independent of the caller's policy).
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 (#57 probe 4, caller-binding);
// not ours -> fall through to the 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 (#57 probe 3)
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) {
@@ -247,6 +283,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
return InterceptorUtils.createTypedObjectReply(teeResp)
}
}
// Domain.GRANT is handled earlier (before the package-scoped skip),
// so an alias-less read that reaches here is KEY_ID or unknown.
return TransactionResult.ContinueAndSkipPost
}
val keyId = KeyIdentifier(callingUid, descriptor.alias)
@@ -268,6 +306,41 @@ 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()
val ownerKeyId =
resolveOwnerKeyId(key, callingUid)
?.takeIf { KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(it) }
?: return TransactionResult.ContinueAndSkipPost // real key -> real keystore2
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.generatedKeys.containsKey(it) }
?: return TransactionResult.ContinueAndSkipPost
KeyMintSecurityLevelInterceptor.revokeGrant(ownerKeyId, granteeUid)
return InterceptorUtils.createSuccessReply(writeResultCode = false)
} else {
logTransaction(
txId,
@@ -508,6 +581,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)
@@ -1166,6 +1166,51 @@ class KeyMintSecurityLevelInterceptor(
private val usageCounters = ConcurrentHashMap<KeyIdentifier, java.util.concurrent.atomic.AtomicInteger>()
private val interceptedOperations = ConcurrentHashMap<IBinder, OperationInterceptor>()
/**
* Grant plane (duck PR #38 / #57). 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 && generatedKeys.containsKey(it.ownerKeyId)
}
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]
@@ -1190,6 +1235,7 @@ class KeyMintSecurityLevelInterceptor(
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
fun cleanupKeyData(keyId: KeyIdentifier) {
purgeGrantsForKey(keyId) // grants die with the key (re-key orphans them too)
if (generatedKeys.remove(keyId) != null) {
SystemLogger.debug("Remove generated key ${keyId}")
GeneratedKeyPersistence.delete(keyId)
@@ -1230,6 +1276,7 @@ class KeyMintSecurityLevelInterceptor(
attestationKeys.clear()
importedKeys.clear()
usageCounters.clear()
softwareGrants.clear()
GeneratedKeyPersistence.deleteAll()
SystemLogger.info("Cleared all cached keys ($count entries)$reasonMessage.")
}