Merge PR #22: persist symmetric keys + byte-identical metadata

This commit is contained in:
Enginex0
2026-05-19 17:00:14 +01:00
5 changed files with 535 additions and 78 deletions
@@ -297,10 +297,15 @@ object ConfigurationManager {
) )
KeyBoxManager.invalidateCache(path) KeyBoxManager.invalidateCache(path)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
// Clear cached keys possibly containing old certificates // Drop only the patched cert chains so the next
// attestation request re-signs with the new keybox.
// Do NOT drop generatedKeys — that would destroy
// every alias/private key in memory and on disk,
// logging users out of any app that pinned a
// persisted keystore alias.
org.matrix.TEESimulator.interception.keystore.shim org.matrix.TEESimulator.interception.keystore.shim
.KeyMintSecurityLevelInterceptor .KeyMintSecurityLevelInterceptor
.clearAllGeneratedKeys("updating $file") .invalidatePatchedChains("updating $file")
} }
} }
} }
@@ -216,6 +216,28 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
} }
if (descriptor.alias == null) { if (descriptor.alias == null) {
if (descriptor.domain == Domain.KEY_ID) {
// The probe pipeline (and some AOSP callers) switch follow-up
// operations to KEY_ID semantics after generateKey returns a
// KEY_ID descriptor. Without this branch, our software keys
// are invisible to KEY_ID-based getKeyEntry calls and the
// request falls through to the real keystore2 daemon, which
// legitimately responds with KEY_NOT_FOUND. Duck Detector's
// TimingSideChannelProbe captures that exception during its
// warmup phase and surfaces it as
// "Captured private binder exception during timing skip".
// Resolving by KEY_ID and returning the cached response keeps
// the call on the happy path, eliminating the warmup signal.
val info = KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
)
if (info?.response != null) {
SystemLogger.info(
"[TX_ID: $txId] Found generated response via KEY_ID nspace=${descriptor.nspace}"
)
return InterceptorUtils.createTypedObjectReply(info.response)
}
}
return TransactionResult.ContinueAndSkipPost return TransactionResult.ContinueAndSkipPost
} }
val keyId = KeyIdentifier(callingUid, descriptor.alias) val keyId = KeyIdentifier(callingUid, descriptor.alias)
@@ -397,9 +419,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
) )
KeyMintSecurityLevelInterceptor.attestationKeys.add(keyId) KeyMintSecurityLevelInterceptor.attestationKeys.add(keyId)
// Snapshot metadata bytes for the same reason as the
// primary doSoftwareKeyGen path — loss-less restore
// after reboot.
val metadataBytesForPersist = response.metadata?.let { md ->
runCatching {
val parcel = android.os.Parcel.obtain()
try {
md.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
}
GeneratedKeyPersistence.save( GeneratedKeyPersistence.save(
keyId = keyId, keyId = keyId,
keyPair = keyData.first, keyPair = keyData.first,
secretKey = null,
nspace = newNspace, nspace = newNspace,
securityLevel = response.metadata.keySecurityLevel, securityLevel = response.metadata.keySecurityLevel,
certChain = keyData.second, certChain = keyData.second,
@@ -409,6 +446,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
purposes = parsedParameters.purpose, purposes = parsedParameters.purpose,
digests = parsedParameters.digest, digests = parsedParameters.digest,
isAttestationKey = true, isAttestationKey = true,
metadataBytes = metadataBytesForPersist,
) )
return InterceptorUtils.createTypedObjectReply(response) return InterceptorUtils.createTypedObjectReply(response)
@@ -29,13 +29,47 @@ data class PersistedKeyData(
val ecCurve: Int, val ecCurve: Int,
val purposes: List<Int>, val purposes: List<Int>,
val digests: List<Int>, val digests: List<Int>,
/** PKCS#8-encoded private key for asymmetric records, empty for symmetric. */
val privateKeyBytes: ByteArray, val privateKeyBytes: ByteArray,
val certChainBytes: List<ByteArray>, val certChainBytes: List<ByteArray>,
/**
* Byte-identical KeyMetadata parcel snapshot. Restoring authorizations
* directly from these bytes preserves tag count, order, and exact
* security-level annotations across reboots — the kind of structural
* details apps fingerprint to decide whether the alias is still
* "the same key".
*/
val metadataBytes: ByteArray,
/**
* Raw secret material for symmetric records (AES, HMAC, 3DES). Empty
* for asymmetric. Critical for AndroidX security crypto MasterKey
* (AES-GCM-256) — without this every reboot regenerates a fresh AES
* key and EncryptedSharedPreferences becomes undecryptable, which is
* what banking apps interpret as session expiry and force a relogin.
*/
val symmetricKeyBytes: ByteArray,
val symmetricAlgorithm: String,
) )
object GeneratedKeyPersistence { object GeneratedKeyPersistence {
private const val FORMAT_VERSION = 1 /**
* Single source of truth for the on-disk format. Bump this every time
* the layout changes; older numbers are silently skipped on read so
* stale dev artifacts and pre-fix upstream files can't be partially
* rehydrated into broken in-memory state.
*
* History:
* 1 — original upstream layout (no metadata snapshot, no symmetric
* block; restored keys lose authorization tags and AES master
* keys altogether — apps relying on persisted keystore state
* across reboots get logged out)
* 2 — transitional dev-only format that added metadata but still
* missed the symmetric block; never shipped
* 3 — current: byte-identical KeyMetadata snapshot + raw symmetric
* key material so AES/HMAC keys survive reboots
*/
private const val FORMAT_VERSION = 3
private val PERSISTENCE_DIR = File(CONFIG_PATH, "persistent_keys") private val PERSISTENCE_DIR = File(CONFIG_PATH, "persistent_keys")
// Per-filename locks to prevent concurrent writes to the same key file // Per-filename locks to prevent concurrent writes to the same key file
@@ -47,7 +81,8 @@ object GeneratedKeyPersistence {
fun save( fun save(
keyId: KeyIdentifier, keyId: KeyIdentifier,
keyPair: KeyPair, keyPair: KeyPair?,
secretKey: javax.crypto.SecretKey?,
nspace: Long, nspace: Long,
securityLevel: Int, securityLevel: Int,
certChain: List<Certificate>, certChain: List<Certificate>,
@@ -57,7 +92,11 @@ object GeneratedKeyPersistence {
purposes: List<Int>, purposes: List<Int>,
digests: List<Int>, digests: List<Int>,
isAttestationKey: Boolean, isAttestationKey: Boolean,
metadataBytes: ByteArray? = null,
) { ) {
require(keyPair != null || secretKey != null) {
"Either keyPair or secretKey must be provided"
}
val filename = keyFileName(keyId.uid, keyId.alias) val filename = keyFileName(keyId.uid, keyId.alias)
val lock = getLockForKey(filename) val lock = getLockForKey(filename)
SystemLogger.debug("[Persistence] Acquiring lock for $filename") SystemLogger.debug("[Persistence] Acquiring lock for $filename")
@@ -87,7 +126,8 @@ object GeneratedKeyPersistence {
out.writeInt(digests.size) out.writeInt(digests.size)
digests.forEach { out.writeInt(it) } digests.forEach { out.writeInt(it) }
val pkBytes = keyPair.private.encoded // Asymmetric key block (empty for symmetric-only).
val pkBytes = keyPair?.private?.encoded ?: ByteArray(0)
out.writeInt(pkBytes.size) out.writeInt(pkBytes.size)
out.write(pkBytes) out.write(pkBytes)
@@ -97,6 +137,23 @@ object GeneratedKeyPersistence {
out.writeInt(encoded.size) out.writeInt(encoded.size)
out.write(encoded) out.write(encoded)
} }
// Metadata snapshot (always present, may be empty
// if the live KeyMetadata could not be marshalled).
val mdBytes = metadataBytes ?: ByteArray(0)
out.writeInt(mdBytes.size)
if (mdBytes.isNotEmpty()) out.write(mdBytes)
// Symmetric key block (empty for asymmetric keys).
if (secretKey != null) {
val skBytes = secretKey.encoded
out.writeUTF(secretKey.algorithm)
out.writeInt(skBytes.size)
out.write(skBytes)
} else {
out.writeUTF("")
out.writeInt(0)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
tmpFile.delete() tmpFile.delete()
@@ -189,8 +246,17 @@ object GeneratedKeyPersistence {
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input -> DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
val version = input.readInt() val version = input.readInt()
if (version != FORMAT_VERSION) { if (version != FORMAT_VERSION) {
SystemLogger.warning( // Old upstream files (v1) and dev-only intermediate
"Skipping ${file.name}: unknown format version $version" // files (v2) are missing the metadata snapshot
// and/or symmetric key block — restoring them
// would put broken state in memory (apps relying
// on those records get logged out). Skip and let
// the next generateKey re-create cleanly with the
// new format. Affected apps re-login once after
// upgrade, then never again.
SystemLogger.info(
"Skipping ${file.name}: legacy format version $version. " +
"It will be replaced on next generateKey for this alias."
) )
return@runCatching return@runCatching
} }
@@ -212,7 +278,7 @@ object GeneratedKeyPersistence {
val pkLen = requireBounds(input.readInt(), 8192, "pkLen") val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
val pkBytes = ByteArray(pkLen) val pkBytes = ByteArray(pkLen)
input.readFully(pkBytes) if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount") val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map { val certChainBytes = (0 until certCount).map {
@@ -222,6 +288,17 @@ object GeneratedKeyPersistence {
certBytes certBytes
} }
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
val skAlgo = input.readUTF()
val skLen = requireBounds(input.readInt(), 8192, "skLen")
val skBytes = ByteArray(skLen).also {
if (skLen > 0) input.readFully(it)
}
if (storedSecLevel == securityLevel) { if (storedSecLevel == securityLevel) {
result.add( result.add(
PersistedKeyData( PersistedKeyData(
@@ -237,6 +314,9 @@ object GeneratedKeyPersistence {
digests = digests, digests = digests,
privateKeyBytes = pkBytes, privateKeyBytes = pkBytes,
certChainBytes = certChainBytes, certChainBytes = certChainBytes,
metadataBytes = metadataBytes,
symmetricKeyBytes = skBytes,
symmetricAlgorithm = skAlgo,
) )
) )
} }
@@ -292,7 +372,7 @@ object GeneratedKeyPersistence {
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input -> DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
val version = input.readInt() val version = input.readInt()
if (version != FORMAT_VERSION) { if (version != FORMAT_VERSION) {
SystemLogger.warning("rePersist: unknown format version $version for $keyId") SystemLogger.warning("rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)")
return return
} }
readPersistedKeyData(input) readPersistedKeyData(input)
@@ -303,10 +383,29 @@ object GeneratedKeyPersistence {
return return
} }
val keyPair = generatedKeyInfo.keyPair ?: return val keyPair = generatedKeyInfo.keyPair
val secretKey = generatedKeyInfo.secretKey
if (keyPair == null && secretKey == null) {
SystemLogger.warning("rePersist: no key material for $keyId")
return
}
// Serialize the live KeyMetadata (now contains the user-installed cert
// chain via updateSubcomponent) so the next boot restores byte-identical
// metadata. KeyMetadata is binder-free, so marshall() is safe here.
val metadataBytes = runCatching {
android.os.Parcel.obtain().let { parcel ->
try {
metadata.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
}.getOrNull()
save( save(
keyId = keyId, keyId = keyId,
keyPair = keyPair, keyPair = keyPair,
secretKey = secretKey,
nspace = generatedKeyInfo.nspace, nspace = generatedKeyInfo.nspace,
securityLevel = secLevel, securityLevel = secLevel,
certChain = newChain.toList(), certChain = newChain.toList(),
@@ -316,6 +415,7 @@ object GeneratedKeyPersistence {
purposes = persisted.purposes, purposes = persisted.purposes,
digests = persisted.digests, digests = persisted.digests,
isAttestationKey = persisted.isAttestationKey, isAttestationKey = persisted.isAttestationKey,
metadataBytes = metadataBytes,
) )
SystemLogger.debug("Re-persisted key $keyId with updated cert chain") SystemLogger.debug("Re-persisted key $keyId with updated cert chain")
} }
@@ -332,7 +432,8 @@ object GeneratedKeyPersistence {
return digest.joinToString("") { "%02x".format(it) } + ".bin" return digest.joinToString("") { "%02x".format(it) } + ".bin"
} }
// Reads all fields after version has already been consumed // Reads all fields after the version int has already been consumed
// and validated by the caller.
private fun readPersistedKeyData(input: DataInputStream): PersistedKeyData { private fun readPersistedKeyData(input: DataInputStream): PersistedKeyData {
val secLevel = input.readInt() val secLevel = input.readInt()
val uid = input.readInt() val uid = input.readInt()
@@ -351,7 +452,7 @@ object GeneratedKeyPersistence {
val pkLen = requireBounds(input.readInt(), 8192, "pkLen") val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
val pkBytes = ByteArray(pkLen) val pkBytes = ByteArray(pkLen)
input.readFully(pkBytes) if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount") val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map { val certChainBytes = (0 until certCount).map {
@@ -361,6 +462,17 @@ object GeneratedKeyPersistence {
certBytes certBytes
} }
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
val skAlgo = input.readUTF()
val skLen = requireBounds(input.readInt(), 8192, "skLen")
val skBytes = ByteArray(skLen).also {
if (skLen > 0) input.readFully(it)
}
return PersistedKeyData( return PersistedKeyData(
uid = uid, uid = uid,
alias = alias, alias = alias,
@@ -374,6 +486,9 @@ object GeneratedKeyPersistence {
digests = digests, digests = digests,
privateKeyBytes = pkBytes, privateKeyBytes = pkBytes,
certChainBytes = certChainBytes, certChainBytes = certChainBytes,
metadataBytes = metadataBytes,
symmetricKeyBytes = skBytes,
symmetricAlgorithm = skAlgo,
) )
} }
} }
@@ -571,6 +571,40 @@ class KeyMintSecurityLevelInterceptor(
generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams) generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams)
Keystore2Interceptor.forgetDeletedKey(keyId) Keystore2Interceptor.forgetDeletedKey(keyId)
// Persist symmetric keys too. Without this, AndroidX security
// crypto MasterKey (AES-GCM-256) is regenerated on every reboot
// and any EncryptedSharedPreferences becomes undecryptable —
// which apps that wrap their session token in
// EncryptedSharedPreferences interpret as session expiry.
// Snapshot the metadata bytes alongside the raw secret
// material so authorizations restore byte-identical.
val metadataBytesForSymmetric = runCatching {
val parcel = android.os.Parcel.obtain()
try {
metadata.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
persistExecutor.execute {
GeneratedKeyPersistence.save(
keyId = keyId,
keyPair = null,
secretKey = secretKey,
nspace = keyDescriptor.nspace,
securityLevel = securityLevel,
certChain = emptyList(),
algorithm = parsedParams.algorithm,
keySize = parsedParams.keySize,
ecCurve = parsedParams.ecCurve ?: 0,
purposes = parsedParams.purpose,
digests = parsedParams.digest,
isAttestationKey = false,
metadataBytes = metadataBytesForSymmetric,
)
}
if (securityLevel == SecurityLevel.STRONGBOX) { if (securityLevel == SecurityLevel.STRONGBOX) {
val delayMs = STRONGBOX_KEYGEN_LATENCY_FLOOR_MS - (System.nanoTime() - genStartNanos) / 1_000_000 val delayMs = STRONGBOX_KEYGEN_LATENCY_FLOOR_MS - (System.nanoTime() - genStartNanos) / 1_000_000
if (delayMs > 0) LockSupport.parkNanos(delayMs * 1_000_000) if (delayMs > 0) LockSupport.parkNanos(delayMs * 1_000_000)
@@ -609,10 +643,28 @@ class KeyMintSecurityLevelInterceptor(
} }
val certChainCopy = keyData.second.toList() val certChainCopy = keyData.second.toList()
// Snapshot the freshly built KeyMetadata bytes so loadPersistedKeys
// can restore byte-identical authorizations after reboot. Without
// this, the rebuild path drops every authorization tag that wasn't
// captured into PersistedKeyData primitive fields (origin, block
// mode, padding, expiry timestamps...), which broke session pinning
// for apps that fingerprint metadata across keystore calls.
val metadataBytesForPersist = response.metadata?.let { md ->
runCatching {
val parcel = android.os.Parcel.obtain()
try {
md.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
}
persistExecutor.execute { persistExecutor.execute {
GeneratedKeyPersistence.save( GeneratedKeyPersistence.save(
keyId = keyId, keyId = keyId,
keyPair = keyData.first, keyPair = keyData.first,
secretKey = null,
nspace = keyDescriptor.nspace, nspace = keyDescriptor.nspace,
securityLevel = securityLevel, securityLevel = securityLevel,
certChain = certChainCopy, certChain = certChainCopy,
@@ -622,6 +674,7 @@ class KeyMintSecurityLevelInterceptor(
purposes = parsedParams.purpose, purposes = parsedParams.purpose,
digests = parsedParams.digest, digests = parsedParams.digest,
isAttestationKey = isAttestKeyRequest, isAttestationKey = isAttestKeyRequest,
metadataBytes = metadataBytesForPersist,
) )
} }
@@ -842,6 +895,61 @@ class KeyMintSecurityLevelInterceptor(
return@runCatching return@runCatching
} }
// Symmetric (AES/HMAC/3DES) keys take a separate path:
// there is no PKCS8 private key, no certificate chain, just
// raw secret material plus the metadata snapshot.
val isSymmetric = record.symmetricKeyBytes.isNotEmpty()
if (isSymmetric) {
val secretKey = javax.crypto.spec.SecretKeySpec(
record.symmetricKeyBytes,
record.symmetricAlgorithm,
)
val response = if (record.metadataBytes.isNotEmpty()) {
runCatching {
val parcel = android.os.Parcel.obtain()
try {
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
parcel.setDataPosition(0)
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
} finally {
parcel.recycle()
}
}.getOrElse { e ->
SystemLogger.warning(
"Failed to restore symmetric metadata for ${record.alias}, falling back to primitive rebuild",
e,
)
rebuildSymmetricResponse(record)
}
} else {
// Pre-v3 file with symmetric key — should not happen
// because v3 always saves metadata, but be defensive:
// rebuild a minimal KeyMetadata from primitives so
// the secret material is still restored. Without
// this, dropping the record would silently log the
// user out the next time the alias is used.
SystemLogger.info(
"Symmetric record ${record.alias} missing metadata bytes, rebuilding from primitives"
)
rebuildSymmetricResponse(record)
}
generatedKeys[keyId] = GeneratedKeyInfo(
keyPair = null,
secretKey = secretKey,
nspace = record.nspace,
response = response,
keyParams = response.metadata?.let { md ->
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
},
)
SystemLogger.debug("Restored symmetric persisted key: $keyId (${record.symmetricAlgorithm}/${record.symmetricKeyBytes.size * 8}bit)")
return@runCatching
}
val algorithmName = when (record.algorithm) { val algorithmName = when (record.algorithm) {
Algorithm.EC -> "EC" Algorithm.EC -> "EC"
Algorithm.RSA -> "RSA" Algorithm.RSA -> "RSA"
@@ -867,56 +975,57 @@ class KeyMintSecurityLevelInterceptor(
blob = null blob = null
} }
val attestation = KeyMintAttestation( // Prefer the byte-identical metadata snapshot persisted by v3
keySize = record.keySize, // saves so apps that fingerprint the metadata (e.g. they
algorithm = record.algorithm, // pin algorithm/purpose/digest/origin/authorization order
ecCurve = record.ecCurve, // across reboots) keep their session valid. Fall back to
ecCurveName = "", // rebuilding
origin = null, // from primitive fields for v1-era files (which lose
blockMode = emptyList(), // authorization tags that weren't captured then).
padding = emptyList(), val response = if (record.metadataBytes.isNotEmpty()) {
purpose = record.purposes, runCatching {
digest = record.digests, val parcel = android.os.Parcel.obtain()
rsaPublicExponent = null, try {
certificateSerial = null, parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
certificateSubject = null, parcel.setDataPosition(0)
certificateNotBefore = null, val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
certificateNotAfter = null, // Make sure the descriptor's nspace matches the
attestationChallenge = null, // KEY_ID we will hand callers. updateSubcomponent
brand = null, // and getKeyEntry both index by nspace.
device = null, metadata.key = metadata.key ?: KeyDescriptor().apply {
product = null, domain = Domain.KEY_ID
serial = null, nspace = record.nspace
imei = null, alias = null
meid = null, blob = null
manufacturer = null, }
model = null, KeyEntryResponse().apply {
secondImei = null, this.metadata = metadata
activeDateTime = null, iSecurityLevel = original
originationExpireDateTime = null, }
usageExpireDateTime = null, } finally {
usageCountLimit = null, parcel.recycle()
callerNonce = null, }
nonce = null, }.getOrElse { e ->
unlockedDeviceRequired = null, SystemLogger.warning(
includeUniqueId = null, "Failed to restore metadata bytes for $record.alias, falling back to rebuild",
rollbackResistance = null, e,
earlyBootOnly = null, )
allowWhileOnBody = null, rebuildResponseFromRecord(record, certChain, descriptor)
trustedUserPresenceRequired = null, }
trustedConfirmationRequired = null, } else {
noAuthRequired = null, rebuildResponseFromRecord(record, certChain, descriptor)
maxUsesPerBoot = null, }
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
val response = buildKeyEntryResponse(record.uid, certChain, attestation, descriptor) val keyIdRestored = KeyIdentifier(record.uid, record.alias)
generatedKeys[keyId] = GeneratedKeyInfo(keyPair, null, record.nspace, response, attestation) generatedKeys[keyIdRestored] = GeneratedKeyInfo(keyPair, null, record.nspace, response, response.metadata?.let { md ->
if (record.isAttestationKey) attestationKeys.add(keyId) // Re-derive an attestation summary from authorizations so
// any code path that reads keyParams (e.g. logging) still
// works. This does not feed back into the metadata bytes.
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
})
if (record.isAttestationKey) attestationKeys.add(keyIdRestored)
SystemLogger.debug("Restored persisted key: $keyId") SystemLogger.debug("Restored persisted key: $keyIdRestored")
}.onFailure { }.onFailure {
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it) SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
} }
@@ -925,6 +1034,143 @@ class KeyMintSecurityLevelInterceptor(
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}") SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
} }
/**
* Fallback rebuild path used when no v3 metadata snapshot is available
* (key was saved by an older build, or the snapshot failed to deserialize).
* Rebuilds KeyEntryResponse from primitive fields. This loses any
* authorization tags that weren't captured at save time, which is why we
* prefer the byte-identical v3 snapshot whenever possible.
*/
private fun rebuildResponseFromRecord(
record: PersistedKeyData,
certChain: List<Certificate>,
descriptor: KeyDescriptor,
): KeyEntryResponse {
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
ecCurve = record.ecCurve,
ecCurveName = "",
origin = null,
blockMode = emptyList(),
padding = emptyList(),
purpose = record.purposes,
digest = record.digests,
rsaPublicExponent = null,
certificateSerial = null,
certificateSubject = null,
certificateNotBefore = null,
certificateNotAfter = null,
attestationChallenge = null,
brand = null,
device = null,
product = null,
serial = null,
imei = null,
meid = null,
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
earlyBootOnly = null,
allowWhileOnBody = null,
trustedUserPresenceRequired = null,
trustedConfirmationRequired = null,
noAuthRequired = null,
maxUsesPerBoot = null,
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
return buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
}
/**
* Defensive fallback for symmetric key records that somehow ended up
* without a metadata snapshot (e.g. a save where Parcel.marshall()
* threw and persisted an empty mdBytes, or a future format where the
* snapshot is lazily populated). Without this fallback, loadAll would
* skip the record and the secret material would be effectively lost,
* silently logging the user out the next time the alias is used.
*
* The rebuilt KeyMetadata is structurally minimal — only the primitive
* authorization tags we captured at save time. That's worse than a
* byte-identical snapshot for apps that fingerprint metadata, but it
* still keeps the AES key alive across reboots, which is the
* dominant correctness concern.
*/
private fun rebuildSymmetricResponse(record: PersistedKeyData): KeyEntryResponse {
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
ecCurve = record.ecCurve,
ecCurveName = "",
origin = null,
blockMode = emptyList(),
padding = emptyList(),
purpose = record.purposes,
digest = record.digests,
rsaPublicExponent = null,
certificateSerial = null,
certificateSubject = null,
certificateNotBefore = null,
certificateNotAfter = null,
attestationChallenge = null,
brand = null,
device = null,
product = null,
serial = null,
imei = null,
meid = null,
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
earlyBootOnly = null,
allowWhileOnBody = null,
trustedUserPresenceRequired = null,
trustedConfirmationRequired = null,
noAuthRequired = null,
maxUsesPerBoot = null,
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
val metadata = KeyMetadata().apply {
keySecurityLevel = securityLevel
key = KeyDescriptor().apply {
domain = Domain.KEY_ID
nspace = record.nspace
alias = null
blob = null
}
certificate = null
certificateChain = null
authorizations = attestation.toAuthorizations(record.uid, securityLevel)
modificationTimeMs = System.currentTimeMillis()
}
return KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
}
companion object { companion object {
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
@@ -1126,41 +1372,47 @@ private fun KeyMintAttestation.toAuthorizations(
authList.add(createAuth(Tag.BOOT_PATCHLEVEL, KeyParameterValue.integer(bootPatch))) authList.add(createAuth(Tag.BOOT_PATCHLEVEL, KeyParameterValue.integer(bootPatch)))
} }
fun createSwAuth(tag: Int, value: KeyParameterValue): Authorization { /**
* Keystore-enforced authorizations (CREATION_DATETIME, ACTIVE_DATETIME,
* USER_ID, etc.) are tagged by real KeyMint HAL with
* SecurityLevel.KEYSTORE (= 100, byte 0x64), not SOFTWARE (= 0, byte
* 0x00). The previous SOFTWARE value is exactly what Duck Detector's
* "TEE Simulator generate-mode fingerprint" probe scans for in the
* generateKey reply parcel. Aligning with real hardware here defeats
* that probe across every keystore-enforced tag, not just
* CREATION_DATETIME's byte-5 window — so probe variants that scan
* later offsets are also covered.
*/
fun createKeystoreAuth(tag: Int, value: KeyParameterValue): Authorization {
val param = KeyParameter().apply { val param = KeyParameter().apply {
this.tag = tag this.tag = tag
this.value = value this.value = value
} }
return Authorization().apply { return Authorization().apply {
this.keyParameter = param this.keyParameter = param
// Real KeyMint HAL marks keystore-enforced metadata (creation
// time, user id, etc.) with SecurityLevel.KEYSTORE (0x64), not
// SOFTWARE (0x00). Using SOFTWARE here is detectable by probes
// that scan the generateKey reply parcel for the 0x00 byte at
// the securityLevel slot of the last authorization entry.
this.securityLevel = SecurityLevel.KEYSTORE this.securityLevel = SecurityLevel.KEYSTORE
} }
} }
authList.add(createSwAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis()))) authList.add(createKeystoreAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
this.activeDateTime?.let { this.activeDateTime?.let {
authList.add(createSwAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time))) authList.add(createKeystoreAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
} }
this.originationExpireDateTime?.let { this.originationExpireDateTime?.let {
authList.add(createSwAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time))) authList.add(createKeystoreAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
} }
this.usageExpireDateTime?.let { this.usageExpireDateTime?.let {
authList.add(createSwAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time))) authList.add(createKeystoreAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
} }
this.usageCountLimit?.let { this.usageCountLimit?.let {
authList.add(createSwAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it))) authList.add(createKeystoreAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
} }
if (this.unlockedDeviceRequired == true) { if (this.unlockedDeviceRequired == true) {
authList.add(createSwAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true))) authList.add(createKeystoreAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
} }
authList.add(createSwAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000))) authList.add(createKeystoreAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000)))
return authList.toTypedArray() return authList.toTypedArray()
} }
@@ -218,19 +218,66 @@ class SoftwareOperation(
val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN" val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN"
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Initializing for purpose: $purposeName.") SystemLogger.debug("[SoftwareOp TX_ID: $txId] Initializing for purpose: $purposeName.")
if (purpose == null) {
// Defensive: if params somehow restored without a PURPOSE tag
// (corrupt v2 metadata, mismatched authorizations array on load,
// or future format drift) the original code crashed with NPE
// because Signer/Verifier/Cipher all dereference keyPair!!
// before checking purpose. Surface a clean keystore error
// instead so callers see a normal-looking operation failure
// they can recover from rather than the process appearing to
// silently corrupt their session.
SystemLogger.warning(
"[SoftwareOp TX_ID: $txId] Purpose missing on restored key " +
"(authorizations=${params.purpose}, keyPair=${if (keyPair != null) "present" else "null"}, " +
"secretKey=${if (secretKey != null) "present" else "null"}). " +
"Returning unsupportedPurpose."
)
throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"Restored key has no PURPOSE authorization",
)
}
primitive = primitive =
when (purpose) { when (purpose) {
KeyPurpose.SIGN -> Signer(keyPair!!, params) KeyPurpose.SIGN -> {
KeyPurpose.VERIFY -> Verifier(keyPair!!, params) val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] SIGN requested but keyPair is null",
)
Signer(kp, params)
}
KeyPurpose.VERIFY -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] VERIFY requested but keyPair is null",
)
Verifier(kp, params)
}
KeyPurpose.ENCRYPT -> { KeyPurpose.ENCRYPT -> {
val key: java.security.Key = secretKey ?: keyPair!!.public val key: java.security.Key = secretKey ?: keyPair?.public
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] ENCRYPT requires either secretKey or keyPair.public",
)
CipherPrimitive(key, params, Cipher.ENCRYPT_MODE) CipherPrimitive(key, params, Cipher.ENCRYPT_MODE)
} }
KeyPurpose.DECRYPT -> { KeyPurpose.DECRYPT -> {
val key: java.security.Key = secretKey ?: keyPair!!.private val key: java.security.Key = secretKey ?: keyPair?.private
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] DECRYPT requires either secretKey or keyPair.private",
)
CipherPrimitive(key, params, Cipher.DECRYPT_MODE) CipherPrimitive(key, params, Cipher.DECRYPT_MODE)
} }
KeyPurpose.AGREE_KEY -> KeyAgreementPrimitive(keyPair!!) KeyPurpose.AGREE_KEY -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] AGREE_KEY requested but keyPair is null",
)
KeyAgreementPrimitive(kp)
}
else -> else ->
throw ServiceSpecificException( throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose, KeystoreErrorCodes.unsupportedPurpose,