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:
+93
-2
@@ -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)
|
||||
|
||||
+47
@@ -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.")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user