Merge PR #22: persist symmetric keys + byte-identical metadata
This commit is contained in:
@@ -297,10 +297,15 @@ object ConfigurationManager {
|
||||
)
|
||||
KeyBoxManager.invalidateCache(path)
|
||||
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
|
||||
.KeyMintSecurityLevelInterceptor
|
||||
.clearAllGeneratedKeys("updating $file")
|
||||
.invalidatePatchedChains("updating $file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -216,6 +216,28 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
||||
@@ -397,9 +419,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
)
|
||||
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(
|
||||
keyId = keyId,
|
||||
keyPair = keyData.first,
|
||||
secretKey = null,
|
||||
nspace = newNspace,
|
||||
securityLevel = response.metadata.keySecurityLevel,
|
||||
certChain = keyData.second,
|
||||
@@ -409,6 +446,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
purposes = parsedParameters.purpose,
|
||||
digests = parsedParameters.digest,
|
||||
isAttestationKey = true,
|
||||
metadataBytes = metadataBytesForPersist,
|
||||
)
|
||||
|
||||
return InterceptorUtils.createTypedObjectReply(response)
|
||||
|
||||
+125
-10
@@ -29,13 +29,47 @@ data class PersistedKeyData(
|
||||
val ecCurve: Int,
|
||||
val purposes: List<Int>,
|
||||
val digests: List<Int>,
|
||||
/** PKCS#8-encoded private key for asymmetric records, empty for symmetric. */
|
||||
val privateKeyBytes: 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 {
|
||||
|
||||
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")
|
||||
|
||||
// Per-filename locks to prevent concurrent writes to the same key file
|
||||
@@ -47,7 +81,8 @@ object GeneratedKeyPersistence {
|
||||
|
||||
fun save(
|
||||
keyId: KeyIdentifier,
|
||||
keyPair: KeyPair,
|
||||
keyPair: KeyPair?,
|
||||
secretKey: javax.crypto.SecretKey?,
|
||||
nspace: Long,
|
||||
securityLevel: Int,
|
||||
certChain: List<Certificate>,
|
||||
@@ -57,7 +92,11 @@ object GeneratedKeyPersistence {
|
||||
purposes: List<Int>,
|
||||
digests: List<Int>,
|
||||
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 lock = getLockForKey(filename)
|
||||
SystemLogger.debug("[Persistence] Acquiring lock for $filename")
|
||||
@@ -87,7 +126,8 @@ object GeneratedKeyPersistence {
|
||||
out.writeInt(digests.size)
|
||||
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.write(pkBytes)
|
||||
|
||||
@@ -97,6 +137,23 @@ object GeneratedKeyPersistence {
|
||||
out.writeInt(encoded.size)
|
||||
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) {
|
||||
tmpFile.delete()
|
||||
@@ -189,8 +246,17 @@ object GeneratedKeyPersistence {
|
||||
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != FORMAT_VERSION) {
|
||||
SystemLogger.warning(
|
||||
"Skipping ${file.name}: unknown format version $version"
|
||||
// Old upstream files (v1) and dev-only intermediate
|
||||
// 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
|
||||
}
|
||||
@@ -212,7 +278,7 @@ object GeneratedKeyPersistence {
|
||||
|
||||
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
|
||||
val pkBytes = ByteArray(pkLen)
|
||||
input.readFully(pkBytes)
|
||||
if (pkLen > 0) input.readFully(pkBytes)
|
||||
|
||||
val certCount = requireBounds(input.readInt(), 10, "certCount")
|
||||
val certChainBytes = (0 until certCount).map {
|
||||
@@ -222,6 +288,17 @@ object GeneratedKeyPersistence {
|
||||
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) {
|
||||
result.add(
|
||||
PersistedKeyData(
|
||||
@@ -237,6 +314,9 @@ object GeneratedKeyPersistence {
|
||||
digests = digests,
|
||||
privateKeyBytes = pkBytes,
|
||||
certChainBytes = certChainBytes,
|
||||
metadataBytes = metadataBytes,
|
||||
symmetricKeyBytes = skBytes,
|
||||
symmetricAlgorithm = skAlgo,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -292,7 +372,7 @@ object GeneratedKeyPersistence {
|
||||
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
|
||||
val version = input.readInt()
|
||||
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
|
||||
}
|
||||
readPersistedKeyData(input)
|
||||
@@ -303,10 +383,29 @@ object GeneratedKeyPersistence {
|
||||
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(
|
||||
keyId = keyId,
|
||||
keyPair = keyPair,
|
||||
secretKey = secretKey,
|
||||
nspace = generatedKeyInfo.nspace,
|
||||
securityLevel = secLevel,
|
||||
certChain = newChain.toList(),
|
||||
@@ -316,6 +415,7 @@ object GeneratedKeyPersistence {
|
||||
purposes = persisted.purposes,
|
||||
digests = persisted.digests,
|
||||
isAttestationKey = persisted.isAttestationKey,
|
||||
metadataBytes = metadataBytes,
|
||||
)
|
||||
SystemLogger.debug("Re-persisted key $keyId with updated cert chain")
|
||||
}
|
||||
@@ -332,7 +432,8 @@ object GeneratedKeyPersistence {
|
||||
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 {
|
||||
val secLevel = input.readInt()
|
||||
val uid = input.readInt()
|
||||
@@ -351,7 +452,7 @@ object GeneratedKeyPersistence {
|
||||
|
||||
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
|
||||
val pkBytes = ByteArray(pkLen)
|
||||
input.readFully(pkBytes)
|
||||
if (pkLen > 0) input.readFully(pkBytes)
|
||||
|
||||
val certCount = requireBounds(input.readInt(), 10, "certCount")
|
||||
val certChainBytes = (0 until certCount).map {
|
||||
@@ -361,6 +462,17 @@ object GeneratedKeyPersistence {
|
||||
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(
|
||||
uid = uid,
|
||||
alias = alias,
|
||||
@@ -374,6 +486,9 @@ object GeneratedKeyPersistence {
|
||||
digests = digests,
|
||||
privateKeyBytes = pkBytes,
|
||||
certChainBytes = certChainBytes,
|
||||
metadataBytes = metadataBytes,
|
||||
symmetricKeyBytes = skBytes,
|
||||
symmetricAlgorithm = skAlgo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+275
-23
@@ -571,6 +571,40 @@ class KeyMintSecurityLevelInterceptor(
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams)
|
||||
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) {
|
||||
val delayMs = STRONGBOX_KEYGEN_LATENCY_FLOOR_MS - (System.nanoTime() - genStartNanos) / 1_000_000
|
||||
if (delayMs > 0) LockSupport.parkNanos(delayMs * 1_000_000)
|
||||
@@ -609,10 +643,28 @@ class KeyMintSecurityLevelInterceptor(
|
||||
}
|
||||
|
||||
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 {
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = keyData.first,
|
||||
secretKey = null,
|
||||
nspace = keyDescriptor.nspace,
|
||||
securityLevel = securityLevel,
|
||||
certChain = certChainCopy,
|
||||
@@ -622,6 +674,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
purposes = parsedParams.purpose,
|
||||
digests = parsedParams.digest,
|
||||
isAttestationKey = isAttestKeyRequest,
|
||||
metadataBytes = metadataBytesForPersist,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -842,6 +895,61 @@ class KeyMintSecurityLevelInterceptor(
|
||||
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) {
|
||||
Algorithm.EC -> "EC"
|
||||
Algorithm.RSA -> "RSA"
|
||||
@@ -867,6 +975,77 @@ class KeyMintSecurityLevelInterceptor(
|
||||
blob = null
|
||||
}
|
||||
|
||||
// Prefer the byte-identical metadata snapshot persisted by v3
|
||||
// saves so apps that fingerprint the metadata (e.g. they
|
||||
// pin algorithm/purpose/digest/origin/authorization order
|
||||
// across reboots) keep their session valid. Fall back to
|
||||
// rebuilding
|
||||
// from primitive fields for v1-era files (which lose
|
||||
// authorization tags that weren't captured then).
|
||||
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)
|
||||
// Make sure the descriptor's nspace matches the
|
||||
// KEY_ID we will hand callers. updateSubcomponent
|
||||
// and getKeyEntry both index by nspace.
|
||||
metadata.key = metadata.key ?: KeyDescriptor().apply {
|
||||
domain = Domain.KEY_ID
|
||||
nspace = record.nspace
|
||||
alias = null
|
||||
blob = null
|
||||
}
|
||||
KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
SystemLogger.warning(
|
||||
"Failed to restore metadata bytes for $record.alias, falling back to rebuild",
|
||||
e,
|
||||
)
|
||||
rebuildResponseFromRecord(record, certChain, descriptor)
|
||||
}
|
||||
} else {
|
||||
rebuildResponseFromRecord(record, certChain, descriptor)
|
||||
}
|
||||
|
||||
val keyIdRestored = KeyIdentifier(record.uid, record.alias)
|
||||
generatedKeys[keyIdRestored] = GeneratedKeyInfo(keyPair, null, record.nspace, response, response.metadata?.let { md ->
|
||||
// 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: $keyIdRestored")
|
||||
}.onFailure {
|
||||
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -911,18 +1090,85 @@ class KeyMintSecurityLevelInterceptor(
|
||||
minMacLength = null,
|
||||
rsaOaepMgfDigest = emptyList(),
|
||||
)
|
||||
|
||||
val response = buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(keyPair, null, record.nspace, response, attestation)
|
||||
if (record.isAttestationKey) attestationKeys.add(keyId)
|
||||
|
||||
SystemLogger.debug("Restored persisted key: $keyId")
|
||||
}.onFailure {
|
||||
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
|
||||
}
|
||||
return buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
|
||||
}
|
||||
|
||||
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
|
||||
/**
|
||||
* 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 {
|
||||
@@ -1126,41 +1372,47 @@ private fun KeyMintAttestation.toAuthorizations(
|
||||
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 {
|
||||
this.tag = tag
|
||||
this.value = value
|
||||
}
|
||||
return Authorization().apply {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
authList.add(createSwAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
|
||||
authList.add(createKeystoreAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
authList.add(createSwAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
|
||||
authList.add(createKeystoreAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
+52
-5
@@ -218,19 +218,66 @@ class SoftwareOperation(
|
||||
val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN"
|
||||
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 =
|
||||
when (purpose) {
|
||||
KeyPurpose.SIGN -> Signer(keyPair!!, params)
|
||||
KeyPurpose.VERIFY -> Verifier(keyPair!!, params)
|
||||
KeyPurpose.SIGN -> {
|
||||
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 -> {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 ->
|
||||
throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
|
||||
Reference in New Issue
Block a user