fix(keystore): evict stale chains on key mutation

Two synthetic-cache staleness gaps let getKeyEntry replay a pre-mutation
attestation:

- importKey now drops teeResponses and patchedChains for the alias, not
  only generatedKeys. A successful import replaces the real key, so the
  retained patched chain was a tell (duck STALE_GENERATED_AFTER_IMPORT).
- After updateSubcomponent re-keys a patched chain, getKeyEntry on a
  patch-mode key evicts the cached TEE response by KEY_ID or APP so the
  read falls through to the updated real keystore2
  (duck STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).

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 f4e2619eba
commit 50f2e98375
2 changed files with 40 additions and 3 deletions
@@ -635,6 +635,18 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
if (generatedKeyInfo == null) {
// Patch-mode key (cached in teeResponses, not generatedKeys): the real keystore2 applies
// the update, so drop our stale cached chain. Otherwise getKeyEntry replays the
// pre-update generated attestation (duck STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).
when (descriptor.domain) {
Domain.KEY_ID ->
KeyMintSecurityLevelInterceptor.evictTeeResponseByKeyId(callingUid, descriptor.nspace)
Domain.APP ->
descriptor.alias?.let {
KeyMintSecurityLevelInterceptor.evictTeeResponse(KeyIdentifier(callingUid, it))
}
else -> {}
}
descriptor.alias?.let {
val kid = KeyIdentifier(callingUid, it)
userUpdatedKeys.add(kid)
@@ -126,13 +126,18 @@ class KeyMintSecurityLevelInterceptor(
val keyDescriptor =
data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.SkipTransaction
// Evict generated key data but retain patched chains so detectors
// can't use importKey to force unpatched getKeyEntry responses.
// A successful importKey replaces the alias's key in the real keystore2, so any prior
// generate/patch cache for this alias is stale. Drop it: a non-attested import then
// falls through to the real keystore2 (origin=IMPORTED, imported leaf), and the
// attested-import branch below re-caches the fresh patched chain. Without this,
// getKeyEntry replays the prior generated attestation (duck STALE_GENERATED_AFTER_IMPORT).
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
if (generatedKeys.remove(keyId) != null) {
SystemLogger.debug("Remove generated key on importKey $keyId")
GeneratedKeyPersistence.delete(keyId)
}
teeResponses.remove(keyId)
patchedChains.remove(keyId)
attestationKeys.remove(keyId)
importedKeys.add(keyId)
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: added to importedKeys, skipUid=${ConfigurationManager.shouldSkipUid(callingUid)}" }
@@ -1232,12 +1237,32 @@ class KeyMintSecurityLevelInterceptor(
?.value
}
/**
* Drops the cached TEE/patched response (and patched chain) addressed by KEY_ID so a
* post-mutation getKeyEntry falls through to the now-updated real keystore2 key. Used
* after updateSubcomponent re-keys a patched chain (duck
* STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).
*/
fun evictTeeResponseByKeyId(callingUid: Int, nspace: Long?) {
if (nspace == null || nspace == 0L) return
teeResponses.entries
.filter { (keyId, _) -> keyId.uid == callingUid }
.find { (_, response) -> response.metadata?.key?.nspace == nspace }
?.let { evictTeeResponse(it.key) }
}
/** Alias-addressed counterpart of [evictTeeResponseByKeyId]. */
fun evictTeeResponse(keyId: KeyIdentifier) {
teeResponses.remove(keyId)
patchedChains.remove(keyId)
}
fun getPatchedChain(keyId: KeyIdentifier): Array<Certificate>? = patchedChains[keyId]
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
fun cleanupKeyData(keyId: KeyIdentifier) {
purgeGrantsForKey(keyId) // grants die with the key (re-key orphans them too)
purgeGrantsForKey(keyId) // grants die with the key (Android 16 path; no-op pre-36)
if (generatedKeys.remove(keyId) != null) {
SystemLogger.debug("Remove generated key ${keyId}")
GeneratedKeyPersistence.delete(keyId)