fix(keystore): gate synthetic grant to Android 16

KeyStoreManager.grantKeyAccess() became a public app API only in
Android 16 (API 36). Before that grant was a hidden API and SELinux
denied untrusted_app, so a real Android 15 device answers a private-
binder grant() with PERMISSION_DENIED.

The virtualized grant plane (5579b16) issued a synthetic grant on every
SDK, exposing a capability a real Android 15 app does not have. Gate
grant() and ungrant() on SDK_INT >= 36: pre-36 returns PERMISSION_DENIED
for synthetic keys (matching the real device, which Duck then marks
UNAVAILABLE rather than a tell); 36+ keeps the coherent virtualized
grant.

On-device 23106RN0DA (SDK 35): all four grant rows report UNAVAILABLE,
not RED; tamper score unaffected.

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 43e948efb3
commit bed32b7454
2 changed files with 34 additions and 15 deletions
@@ -62,6 +62,11 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
private const val RESPONSE_KEY_NOT_FOUND = 7 private const val RESPONSE_KEY_NOT_FOUND = 7
private const val RESPONSE_PERMISSION_DENIED = 6 private const val RESPONSE_PERMISSION_DENIED = 6
// KeyStoreManager.grantKeyAccess() became a public app API in Android 16 (API 36). Before that,
// grant was a hidden API and SELinux denied untrusted_app, so a synthetic-key grant must answer
// PERMISSION_DENIED pre-36 and a coherent virtualized grant on 36+.
private const val GRANT_PUBLIC_API_SDK = 36
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet() private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>() private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
@@ -191,25 +196,23 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
data.readTypedObject(KeyDescriptor.CREATOR) data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.ContinueAndSkipPost ?: return TransactionResult.ContinueAndSkipPost
// A Domain.GRANT read is served for ANY grantee uid — including isolated // Domain.GRANT read (Android 16+ KeyStoreManager grant). Served for ANY grantee uid —
// services (bindIsolatedService) that have no package mapping, so // including isolated services (bindIsolatedService) with no package mapping so resolve
// shouldSkipUid would otherwise drop them to the real keystore2. Resolve it // it before the package-scoped skip; caller-binding in resolveGrant() is the real access
// before the package-scoped skip: caller-binding in resolveGrant() is the // gate. On Android <= 15 no grants are ever issued (grant() denies), so softwareGrants is
// real access gate, mirroring keystore2 (a grant row is keyed on grantee+id, // empty and this falls through to the real keystore2.
// independent of the caller's policy).
if (code == GET_KEY_ENTRY_TRANSACTION && descriptor.domain == Domain.GRANT) { if (code == GET_KEY_ENTRY_TRANSACTION && descriptor.domain == Domain.GRANT) {
val grant = val grant =
KeyMintSecurityLevelInterceptor.resolveGrant(descriptor.nspace, callingUid) KeyMintSecurityLevelInterceptor.resolveGrant(descriptor.nspace, callingUid)
if (grant == null) { if (grant == null) {
// Ours but wrong caller -> KEY_NOT_FOUND (#57 probe 4, caller-binding); // Ours but wrong caller -> KEY_NOT_FOUND (caller-binding); not ours -> real keystore2.
// not ours -> fall through to the real keystore2.
return if ( return if (
KeyMintSecurityLevelInterceptor.softwareGrants.containsKey(descriptor.nspace) KeyMintSecurityLevelInterceptor.softwareGrants.containsKey(descriptor.nspace)
) )
InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND) InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
else TransactionResult.ContinueAndSkipPost else TransactionResult.ContinueAndSkipPost
} }
if ((grant.accessVector and 0x4) == 0) { // GET_INFO = 0x4 (#57 probe 3) if ((grant.accessVector and 0x4) == 0) { // GET_INFO = 0x4 (access-vector gate)
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED) return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
} }
val response = val response =
@@ -283,8 +286,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
return InterceptorUtils.createTypedObjectReply(teeResp) return InterceptorUtils.createTypedObjectReply(teeResp)
} }
} }
// Domain.GRANT is handled earlier (before the package-scoped skip), // Domain.GRANT is handled earlier (before the package-scoped skip); an alias-less
// so an alias-less read that reaches here is KEY_ID or unknown. // read reaching here is KEY_ID or unknown, so it falls through to the real keystore2.
return TransactionResult.ContinueAndSkipPost return TransactionResult.ContinueAndSkipPost
} }
val keyId = KeyIdentifier(callingUid, descriptor.alias) val keyId = KeyIdentifier(callingUid, descriptor.alias)
@@ -314,10 +317,20 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
?: return TransactionResult.ContinueAndSkipPost ?: return TransactionResult.ContinueAndSkipPost
val granteeUid = data.readInt() val granteeUid = data.readInt()
val accessVector = data.readInt() val accessVector = data.readInt()
// Only synthetic keys are ours; real keys fall through to the real keystore2, which
// applies the same SELinux gate the platform would.
val ownerKeyId = val ownerKeyId =
resolveOwnerKeyId(key, callingUid) resolveOwnerKeyId(key, callingUid)
?.takeIf { KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(it) } ?.takeIf { KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(it) }
?: return TransactionResult.ContinueAndSkipPost // real key -> real keystore2 ?: return TransactionResult.ContinueAndSkipPost
// Version-gated to mirror the real TEE 1:1. Pre-Android-16, grant was a hidden API and
// SELinux denied untrusted_app, so keystore2 returns PERMISSION_DENIED. Android 16
// (API 36) exposes KeyStoreManager.grantKeyAccess(), so an app grants its own key:
// issue a coherent, caller-bound, access-vector-carrying grant whose Domain.GRANT read
// returns the owner's chain.
if (Build.VERSION.SDK_INT < GRANT_PUBLIC_API_SDK) {
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
}
val grantId = val grantId =
KeyMintSecurityLevelInterceptor.issueGrant(ownerKeyId, granteeUid, accessVector) KeyMintSecurityLevelInterceptor.issueGrant(ownerKeyId, granteeUid, accessVector)
val reply = val reply =
@@ -339,6 +352,10 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
resolveOwnerKeyId(key, callingUid) resolveOwnerKeyId(key, callingUid)
?.takeIf { KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(it) } ?.takeIf { KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(it) }
?: return TransactionResult.ContinueAndSkipPost ?: return TransactionResult.ContinueAndSkipPost
// Same version gate as grant(): denied pre-36, revoke the virtualized grant on 36+.
if (Build.VERSION.SDK_INT < GRANT_PUBLIC_API_SDK) {
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
}
KeyMintSecurityLevelInterceptor.revokeGrant(ownerKeyId, granteeUid) KeyMintSecurityLevelInterceptor.revokeGrant(ownerKeyId, granteeUid)
return InterceptorUtils.createSuccessReply(writeResultCode = false) return InterceptorUtils.createSuccessReply(writeResultCode = false)
} else { } else {
@@ -1167,9 +1167,11 @@ class KeyMintSecurityLevelInterceptor(
private val interceptedOperations = ConcurrentHashMap<IBinder, OperationInterceptor>() private val interceptedOperations = ConcurrentHashMap<IBinder, OperationInterceptor>()
/** /**
* Grant plane (duck PR #38 / #57). A grant is caller-bound and carries an * Grant plane for the public `KeyStoreManager.grantKeyAccess()` API (Android 16, API 36+).
* access vector; resolving one yields the owner's own KeyEntryResponse so * On Android <= 15 grant was a hidden API denied to untrusted_app, so this state stays
* every access plane returns a coherent certificate chain. * empty there (the GRANT_TRANSACTION handler returns PERMISSION_DENIED for synthetic keys
* pre-36). 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( data class SoftwareGrant(
val ownerKeyId: KeyIdentifier, val ownerKeyId: KeyIdentifier,