feat(logging): UID-keyed attestation dossier

Add a debug-only per-UID diagnostic plane gated on BuildConfig.DEBUG.
For apps in target.txt it records every keystore interaction and the
forged attestation it produces to teesim-uid-<uid>.log: decoded cert
chain (FORGE and PATCH paths), key params, keybox, and prop sources,
with the calling UID threaded through the C++ binder hook and Rust
certgen. Release builds stay silent (R8 strips the write plane and the
runtime gate short-circuits). Adds --clear-logs to package.sh.
This commit is contained in:
Enginex0
2026-06-04 15:53:06 +01:00
parent f826312fc4
commit e5483afc70
14 changed files with 392 additions and 44 deletions
+4 -1
View File
@@ -403,7 +403,10 @@ void inspectAndRewriteTransaction(binder_transaction_data *txn_data) {
uint64_t tx_id = ++g_transaction_id_counter; uint64_t tx_id = ++g_transaction_id_counter;
info.transaction_id = tx_id; info.transaction_id = tx_id;
LOGV("[Hook] Hijacking Transaction %" PRIu64 " (Code: %u)", tx_id, txn_data->code); // tx_id is the same counter handed to the Kotlin interceptor, and sender_euid is the
// calling app; together they correlate this native hijack with that UID's per-UID file.
LOGV("[Hook] Hijacking Transaction %" PRIu64 " (Code: %u, uid=%u)", tx_id, txn_data->code,
txn_data->sender_euid);
// Rewrite the destination to our Stub // Rewrite the destination to our Stub
txn_data->target.ptr = reinterpret_cast<uintptr_t>(g_stub_instance->getWeakRefs()); txn_data->target.ptr = reinterpret_cast<uintptr_t>(g_stub_instance->getWeakRefs());
@@ -74,24 +74,28 @@ object App {
} }
/** /**
* Release builds never emit diagnostics. Sweep any `.bin` dumps a prior debug install left in * Release builds never emit diagnostics. Sweep anything a prior debug install left behind so it
* the world-readable temp dir so they can't act as a detection artifact for apps that probe * cannot act as a detection artifact: the `.bin` dumps in the world-readable temp dir, and the
* /data/local/tmp. * per-UID diagnostic logs under the config dir.
*/ */
private fun purgeDebugDiagnostics() { private fun purgeDebugDiagnostics() {
if (SystemLogger.isDebugBuild) return if (SystemLogger.isDebugBuild) return
val stale = purgeStale(File("/data/local/tmp"), "/data/local/tmp") { name ->
File("/data/local/tmp").listFiles { _, name -> name.startsWith("teesim-") && name.endsWith(".bin")
name.startsWith("teesim-") && name.endsWith(".bin")
} ?: return
stale.forEach { runCatching { it.delete() } }
if (stale.isNotEmpty()) {
// warning() bypasses the rate limiter, so this once-per-boot audit
// line survives the noisy startup window.
SystemLogger.warning(
"Purged ${stale.size} stale debug diagnostic(s) from /data/local/tmp"
)
} }
purgeStale(File("${ConfigurationManager.CONFIG_PATH}/logs"), "per-UID log dir") { name ->
name.startsWith("teesim-uid-") && (name.endsWith(".log") || name.endsWith(".log.1"))
}
}
/** Deletes matching files in [dir], logging a single once-per-boot audit line if any existed. */
private fun purgeStale(dir: File, label: String, matches: (String) -> Boolean) {
val stale = dir.listFiles { _, name -> matches(name) } ?: return
if (stale.isEmpty()) return
stale.forEach { runCatching { it.delete() } }
// warning() bypasses the rate limiter, so this once-per-boot audit line survives the noisy
// startup window.
SystemLogger.warning("Purged ${stale.size} stale debug diagnostic(s) from $label")
} }
/** Initializes the necessary Android framework internals to satisfy KeyStore requirements. */ /** Initializes the necessary Android framework internals to satisfy KeyStore requirements. */
@@ -234,6 +234,103 @@ object AttestationPatcher {
} }
} }
/** Reverse map of attestation tag number to its symbolic name, e.g. 704 -> "ROOT_OF_TRUST". */
private val attestTagNames: Map<Int, String> by lazy {
AttestationConstants::class
.java
.fields
.filter { it.name.startsWith("TAG_") && it.type == Int::class.java }
.associate { (it.get(null) as Int) to it.name.removePrefix("TAG_") }
}
/**
* Renders the full key-attestation extension of [cert] as a single structured line for the
* diagnostic dossier, or null when the certificate carries no attestation extension. This is the
* ground-truth view of what we actually emitted, so any divergence from a genuine TEE surfaces
* directly as a differing field rather than having to be guessed.
*/
fun formatAttestationExtension(cert: X509Certificate): String? {
val rawExtension = cert.getExtensionValue(ATTESTATION_OID.id) ?: return null
return runCatching {
val keyDescriptionDer = ASN1OctetString.getInstance(rawExtension).octets
formatKeyDescription(ASN1Sequence.getInstance(keyDescriptionDer))
}
.getOrElse { "<unparseable attestation extension: ${it.message}>" }
}
/** Renders the identity fields of every certificate in a returned chain for the dossier. */
fun formatCertChain(chain: List<Certificate>): String =
chain
.mapIndexed { index, cert ->
val x509 = cert as? X509Certificate ?: return@mapIndexed "[$index] <non-X509>"
"[$index] subject=${x509.subjectX500Principal.name} " +
"issuer=${x509.issuerX500Principal.name} " +
"serial=${x509.serialNumber.toString(16)} " +
"notBefore=${x509.notBefore} notAfter=${x509.notAfter}"
}
.joinToString(separator = " ; ")
private fun formatKeyDescription(seq: ASN1Sequence): String {
val fields = seq.toArray()
return "attestVer=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_VERSION_INDEX])} " +
"attestSecLvl=${formatSecurityLevel(fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_SECURITY_LEVEL_INDEX])} " +
"kmVer=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_KEYMINT_VERSION_INDEX])} " +
"kmSecLvl=${formatSecurityLevel(fields[AttestationConstants.KEY_DESCRIPTION_KEYMINT_SECURITY_LEVEL_INDEX])} " +
"challenge=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_CHALLENGE_INDEX])} " +
"uniqueId=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_UNIQUE_ID_INDEX])} " +
"sw=${formatAuthorizationList(fields[AttestationConstants.KEY_DESCRIPTION_SOFTWARE_ENFORCED_INDEX])} " +
"tee=${formatAuthorizationList(fields[AttestationConstants.KEY_DESCRIPTION_TEE_ENFORCED_INDEX])}"
}
private fun formatSecurityLevel(obj: ASN1Encodable): String {
val level = (obj.toASN1Primitive() as? ASN1Enumerated)?.value?.toInt()
val name =
when (level) {
0 -> "Software"
1 -> "TEE"
2 -> "StrongBox"
else -> "?"
}
return "$level($name)"
}
private fun formatAuthorizationList(obj: ASN1Encodable): String {
val seq = obj.toASN1Primitive() as? ASN1Sequence ?: return formatAsn1Primitive(obj)
return seq
.map { element ->
val tagged = element as? ASN1TaggedObject ?: return@map formatAsn1Primitive(element)
val name = attestTagNames[tagged.tagNo] ?: "TAG"
val value =
if (tagged.tagNo == AttestationConstants.TAG_ROOT_OF_TRUST)
formatRootOfTrust(tagged.baseObject)
else formatAsn1Primitive(tagged.baseObject)
"${tagged.tagNo}($name)=$value"
}
.joinToString(prefix = "[", postfix = "]", separator = ", ")
}
/**
* Decodes the Root of Trust sub-sequence explicitly — it is the field a detector most often uses
* to unmask a simulated TEE (a random verifiedBootKey, an unexpected verifiedBootState, or a
* deviceLocked that disagrees with the bootloader all live here).
*/
private fun formatRootOfTrust(obj: ASN1Encodable): String {
val fields = (obj.toASN1Primitive() as? ASN1Sequence)?.toArray() ?: return formatAsn1Primitive(obj)
val state = fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_VERIFIED_BOOT_STATE_INDEX)
val stateName =
when ((state?.toASN1Primitive() as? ASN1Enumerated)?.value?.toInt()) {
0 -> "Verified"
1 -> "SelfSigned"
2 -> "Unverified"
3 -> "Failed"
else -> "?"
}
return "[bootKey=${formatAsn1Primitive(fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_VERIFIED_BOOT_KEY_INDEX))}, " +
"deviceLocked=${formatAsn1Primitive(fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_DEVICE_LOCKED_INDEX))}, " +
"verifiedBootState=${formatAsn1Primitive(state)}($stateName), " +
"bootHash=${formatAsn1Primitive(fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_VERIFIED_BOOT_HASH_INDEX))}]"
}
// Function to check if a given ASN1Sequence contains the Root of Trust tag. // Function to check if a given ASN1Sequence contains the Root of Trust tag.
private fun sequenceContainsRootOfTrust(seq: ASN1Encodable): Boolean { private fun sequenceContainsRootOfTrust(seq: ASN1Encodable): Boolean {
if (seq !is ASN1Sequence) return false if (seq !is ASN1Sequence) return false
@@ -224,7 +224,11 @@ abstract class BinderInterceptor : Binder() {
} }
} }
/** Helper function for consistent logging of intercepted transactions. */ /**
* Logs an intercepted transaction. For a targeted UID every transaction — whether we intercept
* or merely observe it — is recorded on that UID's own diagnostic plane, so its keystore
* timeline reads cleanly end to end. Untargeted UIDs get a single terse, rate-limited line.
*/
protected fun logTransaction( protected fun logTransaction(
txId: Long, txId: Long,
methodName: String, methodName: String,
@@ -232,15 +236,14 @@ abstract class BinderInterceptor : Binder() {
callingPid: Int, callingPid: Int,
skipPost: Boolean = false, skipPost: Boolean = false,
) { ) {
val isIntercepting = !skipPost && !ConfigurationManager.shouldSkipUid(callingUid) if (SystemLogger.isUidLogged(callingUid)) {
val action = if (isIntercepting) "Intercept" else "Observe" val action = if (skipPost) "observe" else "intercept"
val packages = ConfigurationManager.getPackagesForUid(callingUid).joinToString() SystemLogger.uidLog(callingUid, txId, "tx", "$methodName action=$action pid=$callingPid")
val message = return
"[TX_ID: $txId] $action $methodName for packages=[$packages] (uid=$callingUid, pid=$callingPid)" }
if (isIntercepting) { SystemLogger.verbose {
SystemLogger.debug(message) val packages = ConfigurationManager.getPackagesForUid(callingUid).joinToString()
} else { "[TX_ID: $txId] Observe $methodName for packages=[$packages] (uid=$callingUid, pid=$callingPid)"
SystemLogger.verbose(message)
} }
} }
@@ -18,6 +18,7 @@ import org.matrix.TEESimulator.attestation.KeyMintAttestation
import org.matrix.TEESimulator.config.ConfigurationManager import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.interception.keystore.shim.GeneratedKeyPersistence import org.matrix.TEESimulator.interception.keystore.shim.GeneratedKeyPersistence
import org.matrix.TEESimulator.interception.keystore.shim.KeyMintSecurityLevelInterceptor import org.matrix.TEESimulator.interception.keystore.shim.KeyMintSecurityLevelInterceptor
import org.matrix.TEESimulator.logging.AttestationDossier
import org.matrix.TEESimulator.logging.KeyMintParameterLogger import org.matrix.TEESimulator.logging.KeyMintParameterLogger
import org.matrix.TEESimulator.logging.SystemLogger import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.CertificateGenerator import org.matrix.TEESimulator.pki.CertificateGenerator
@@ -348,7 +349,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
SystemLogger.info("[TX_ID: $txId] Found generated response for ${descriptor.alias}:") SystemLogger.info("[TX_ID: $txId] Found generated response for ${descriptor.alias}:")
response.metadata?.authorizations?.forEach { response.metadata?.authorizations?.forEach {
KeyMintParameterLogger.logParameter(it.keyParameter) KeyMintParameterLogger.logParameter(callingUid, txId, it.keyParameter)
} }
return InterceptorUtils.createTypedObjectReply(response) return InterceptorUtils.createTypedObjectReply(response)
} else if (code == GRANT_TRANSACTION) { } else if (code == GRANT_TRANSACTION) {
@@ -653,6 +654,10 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
callingUid, callingUid,
) )
// PATCH decode point: the patched chain actually served back to the app on
// getKeyEntry — the ground truth a patch-mode detector reads.
AttestationDossier.log(callingUid, txId, "PATCH", finalChain.asList())
return InterceptorUtils.createTypedObjectReply(response) return InterceptorUtils.createTypedObjectReply(response)
} }
.onFailure { .onFailure {
@@ -34,6 +34,7 @@ import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.interception.keystore.InterceptorUtils import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
import org.matrix.TEESimulator.interception.keystore.KeyIdentifier import org.matrix.TEESimulator.interception.keystore.KeyIdentifier
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
import org.matrix.TEESimulator.logging.AttestationDossier
import org.matrix.TEESimulator.logging.SystemLogger import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.CertGenConfig import org.matrix.TEESimulator.pki.CertGenConfig
import org.matrix.TEESimulator.pki.CertificateGenerator import org.matrix.TEESimulator.pki.CertificateGenerator
@@ -274,6 +275,9 @@ class KeyMintSecurityLevelInterceptor(
metadata.authorizations = metadata.authorizations =
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid) InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
// PATCH decode point: the chain we rewrote onto a real TEE generateKey reply.
AttestationDossier.log(callingUid, txId, "PATCH", newChain.asList())
cleanupKeyData(keyId) cleanupKeyData(keyId)
patchedChains[keyId] = newChain patchedChains[keyId] = newChain
teeResponses[keyId] = teeResponses[keyId] =
@@ -556,14 +560,12 @@ class KeyMintSecurityLevelInterceptor(
it.tag == Tag.ATTESTATION_ID_SECOND_IMEI it.tag == Tag.ATTESTATION_ID_SECOND_IMEI
} }
// Debug-only probe trail: one greppable line per generateKey carrying the resolving // One `dispatch` record per generateKey on the targeted UID's diagnostic plane,
// package and the outcome. Release builds short-circuit before any string is built, // carrying the resolved outcome and the request shape that drove it. Scoped to
// so this is silent and artifact-free in production. // targeted UIDs, so the SKIP flood from every other app never reaches the plane;
// release builds short-circuit before any string is built.
fun logProbe(outcome: String) { fun logProbe(outcome: String) {
if (!SystemLogger.isDebugBuild) return if (!SystemLogger.isUidLogged(callingUid)) return
val pkg =
ConfigurationManager.getPackagesForUid(callingUid).firstOrNull()
?: "uid:$callingUid"
val tags = val tags =
buildList { buildList {
if (parsedParams.attestationChallenge != null) add("challenge") if (parsedParams.attestationChallenge != null) add("challenge")
@@ -579,10 +581,13 @@ class KeyMintSecurityLevelInterceptor(
if (params.any { it.tag == Tag.INCLUDE_UNIQUE_ID }) add("unique_id") if (params.any { it.tag == Tag.INCLUDE_UNIQUE_ID }) add("unique_id")
} }
.joinToString(",") .joinToString(",")
SystemLogger.debug( SystemLogger.uidLog(
"[probe] tx=$txId uid=$callingUid pkg=$pkg alias=${keyDescriptor.alias} " + callingUid,
txId,
"dispatch",
"outcome=$outcome alias=${keyDescriptor.alias} " +
"algo=${parsedParams.algorithm} sb=${securityLevel == SecurityLevel.STRONGBOX} " + "algo=${parsedParams.algorithm} sb=${securityLevel == SecurityLevel.STRONGBOX} " +
"tags=[$tags] -> $outcome" "tags=[$tags]",
) )
} }
@@ -711,6 +716,7 @@ class KeyMintSecurityLevelInterceptor(
forceGenerate -> { forceGenerate -> {
logProbe("FORGE") logProbe("FORGE")
doSoftwareKeyGen( doSoftwareKeyGen(
txId,
callingUid, callingUid,
keyDescriptor, keyDescriptor,
attestationKey, attestationKey,
@@ -737,6 +743,7 @@ class KeyMintSecurityLevelInterceptor(
} }
private fun doSoftwareKeyGen( private fun doSoftwareKeyGen(
txId: Long,
callingUid: Int, callingUid: Int,
keyDescriptor: KeyDescriptor, keyDescriptor: KeyDescriptor,
attestationKey: KeyDescriptor?, attestationKey: KeyDescriptor?,
@@ -851,9 +858,12 @@ class KeyMintSecurityLevelInterceptor(
return InterceptorUtils.createTypedObjectReply(metadata, diagnosticTag = "gen-mode-sym") return InterceptorUtils.createTypedObjectReply(metadata, diagnosticTag = "gen-mode-sym")
} }
var forgePath = "FORGE-bouncycastle"
val keyData = val keyData =
if (NativeCertGen.isAvailable && attestationKey == null) { if (NativeCertGen.isAvailable && attestationKey == null) {
generateAttestedKeyPairNative(callingUid, parsedParams) generateAttestedKeyPairNative(callingUid, parsedParams)?.also {
forgePath = "FORGE-rust"
}
?: CertificateGenerator.generateAttestedKeyPair( ?: CertificateGenerator.generateAttestedKeyPair(
callingUid, callingUid,
keyDescriptor.alias, keyDescriptor.alias,
@@ -871,6 +881,11 @@ class KeyMintSecurityLevelInterceptor(
) )
} ?: throw Exception("Both native and BouncyCastle cert gen failed.") } ?: throw Exception("Both native and BouncyCastle cert gen failed.")
// FORGE decode point: dump the dossier of the chain we just forged, tagged with the forger
// that produced it. uid + txId are both in scope here, so this is the cheapest place to
// capture ground truth for a generate-mode app.
AttestationDossier.log(callingUid, txId, forgePath, keyData.second)
val response = val response =
buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor) buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor)
generatedKeys[keyId] = generatedKeys[keyId] =
@@ -1015,6 +1030,8 @@ class KeyMintSecurityLevelInterceptor(
callerNonce = params.callerNonce == true, callerNonce = params.callerNonce == true,
unlockedDeviceRequired = params.unlockedDeviceRequired == true, unlockedDeviceRequired = params.unlockedDeviceRequired == true,
noAuthRequired = params.noAuthRequired != false, noAuthRequired = params.noAuthRequired != false,
uid = callingUid,
debugLogging = SystemLogger.isDebugBuild,
) )
val resultBytes = NativeCertGen.generateAttestedKeyPair(config) ?: return null val resultBytes = NativeCertGen.generateAttestedKeyPair(config) ?: return null
@@ -0,0 +1,33 @@
package org.matrix.TEESimulator.logging
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import org.matrix.TEESimulator.attestation.AttestationPatcher
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.util.AndroidDeviceUtils
/**
* Assembles the per-UID "attestation dossier": for a targeted app, the full decoded attestation we
* actually hand it, the identity of every certificate in the returned chain, and the source of each
* device value that fed that attestation. Emitting all three where a chain is produced turns "the
* app rejects us" into a field-by-field record that can be diffed against a genuine TEE.
*/
object AttestationDossier {
/**
* Records the dossier for [chain] under [uid], tagged with the [path] that produced it
* (`FORGE-rust`, `FORGE-bouncycastle`, or `PATCH`). No-op for untargeted UIDs and release
* builds; the expensive decoding is skipped entirely when the UID is out of scope.
*/
fun log(uid: Int, txId: Long, path: String, chain: List<Certificate>) {
if (!SystemLogger.isUidLogged(uid)) return
val leaf = chain.firstOrNull() as? X509Certificate
val extension =
leaf?.let { AttestationPatcher.formatAttestationExtension(it) }
?: "<no attestation extension>"
SystemLogger.uidLog(uid, txId, "attest", "path=$path depth=${chain.size} $extension")
SystemLogger.uidLog(uid, txId, "keybox", "file=${ConfigurationManager.getKeyboxFileForUid(uid)}")
SystemLogger.uidLog(uid, txId, "chain", AttestationPatcher.formatCertChain(chain))
SystemLogger.uidLog(uid, txId, "props", AndroidDeviceUtils.describeSources(uid))
}
}
@@ -69,12 +69,23 @@ object KeyMintParameterLogger {
.associate { field -> (field.get(null) as Int) to field.name } .associate { field -> (field.get(null) as Int) to field.name }
} }
/** /** Logs a single KeyParameter to the shared debug stream (used for un-scoped param dumps). */
* Logs a single KeyParameter in a formatted, readable way.
*
* @param param The KeyParameter to log.
*/
fun logParameter(param: KeyParameter) { fun logParameter(param: KeyParameter) {
SystemLogger.debug("KeyParam: ${describe(param)}")
}
/** Logs a single KeyParameter onto a targeted UID's diagnostic plane as a `param` record. */
fun logParameter(uid: Int, txId: Long, param: KeyParameter) {
SystemLogger.uidLog(uid, txId, "param", describe(param))
}
/**
* Formats a single KeyParameter into a readable `tag | Value` string. Shared by both
* [logParameter] overloads so the two logging planes render parameters identically.
*
* @param param The KeyParameter to format.
*/
private fun describe(param: KeyParameter): String {
val tagName = tagNames[param.tag] ?: "UNKNOWN_TAG" val tagName = tagNames[param.tag] ?: "UNKNOWN_TAG"
val value = param.value val value = param.value
val formattedValue: String = val formattedValue: String =
@@ -110,7 +121,7 @@ object KeyMintParameterLogger {
else -> "<raw>" else -> "<raw>"
} ?: "Unknown Value" } ?: "Unknown Value"
SystemLogger.debug("KeyParam: %-25s | Value: %s".format(tagName, formattedValue)) return "%-25s | Value: %s".format(tagName, formattedValue)
} }
private fun ByteArray.toReadableString(): String { private fun ByteArray.toReadableString(): String {
@@ -1,9 +1,17 @@
package org.matrix.TEESimulator.logging package org.matrix.TEESimulator.logging
import android.util.Log import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import org.matrix.TEESimulator.BuildConfig import org.matrix.TEESimulator.BuildConfig
import org.matrix.TEESimulator.config.ConfigurationManager
/** /**
* A centralized logging utility for the TEESimulator application. This object provides a consistent * A centralized logging utility for the TEESimulator application. This object provides a consistent
@@ -118,4 +126,97 @@ object SystemLogger {
if (!isDebugBuild) return if (!isDebugBuild) return
Log.w(TAG, message()) Log.w(TAG, message())
} }
// --- UID-keyed diagnostic plane (debug builds only) -------------------------------------
/**
* True when [uid] should receive deep, per-UID diagnostic logging: a debug build AND the UID is
* targeted in `target.txt`. This is the single scope gate for the diagnostic plane; it reuses
* the existing activation set, so no new configuration surface is introduced.
*/
fun isUidLogged(uid: Int): Boolean = isDebugBuild && !ConfigurationManager.shouldSkipUid(uid)
/** Resolves a UID to its primary package name for log labelling, falling back to `uid:N`. */
private fun label(uid: Int): String =
ConfigurationManager.getPackagesForUid(uid).firstOrNull() ?: "uid:$uid"
/**
* Emits one structured diagnostic record for a targeted [uid] as `[<pkg> tx=<txId>] <event>:
* <detail>`. The record is mirrored to logcat and appended to that UID's own file. In-scope
* records bypass the global rate limiter — a targeted app's traffic is already volume-bounded,
* and dropping a line mid-probe would corrupt the very trace we are trying to read. No-op for
* untargeted UIDs and in release builds.
*/
fun uidLog(uid: Int, txId: Long?, event: String, detail: String) {
if (!isUidLogged(uid)) return
val correlation = txId?.let { " tx=$it" } ?: ""
val line = "[${label(uid)}$correlation] $event: $detail"
Log.d(TAG, line)
runCatching { uidWriter(uid).append(line) }
}
/** Lazy [uidLog]: [detail] is only built for targeted UIDs in debug builds. */
inline fun uidLog(uid: Int, txId: Long?, event: String, detail: () -> String) {
if (!isUidLogged(uid)) return
uidLog(uid, txId, event, detail())
}
private val uidLogDir = File(ConfigurationManager.CONFIG_PATH, "logs")
private const val UID_LOG_MAX_BYTES = 4L * 1024 * 1024
private val uidWriters = ConcurrentHashMap<Int, UidLogFile>()
private fun uidWriter(uid: Int): UidLogFile = uidWriters.getOrPut(uid) { UidLogFile(uid, uidLogDir) }
/**
* An append-only diagnostic file for a single UID at `<logDir>/teesim-uid-<uid>.log`, rotated
* once to `.log.1` at [UID_LOG_MAX_BYTES]. Writes are synchronised because the keystore binder
* pool is multi-threaded, and every operation is wrapped so a logging fault can never propagate
* into the daemon. Created only on the debug-gated path, so release builds never touch this dir.
*/
private class UidLogFile(private val uid: Int, private val logDir: File) {
private val primary = File(logDir, "teesim-uid-$uid.log")
private val rotated = File(logDir, "teesim-uid-$uid.log.1")
private val clock = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
private var writer: BufferedWriter? = null
private var size = 0L
@Synchronized
fun append(message: String) {
runCatching {
val out = writer ?: open()
val line = "${clock.format(Date())} $message\n"
out.write(line)
out.flush()
size += line.length
if (size >= UID_LOG_MAX_BYTES) rotate()
}
}
private fun open(): BufferedWriter {
logDir.mkdirs()
val out = BufferedWriter(FileWriter(primary, /* append = */ true))
writer = out
size = primary.length()
val packages =
ConfigurationManager.getPackagesForUid(uid).joinToString().ifEmpty { "<unresolved>" }
val header = "${clock.format(Date())} === session uid=$uid packages=[$packages] ===\n"
out.write(header)
out.flush()
size += header.length
return out
}
private fun rotate() {
runCatching {
writer?.flush()
writer?.close()
}
writer = null
runCatching {
if (rotated.exists()) rotated.delete()
primary.renameTo(rotated)
}
size = 0L
}
}
} }
@@ -52,6 +52,10 @@ data class CertGenConfig(
val callerNonce: Boolean = false, val callerNonce: Boolean = false,
val unlockedDeviceRequired: Boolean = false, val unlockedDeviceRequired: Boolean = false,
val noAuthRequired: Boolean = true, val noAuthRequired: Boolean = true,
// Diagnostic plane: the calling app UID keys the native log lines, and debugLogging mirrors the
// APK debug variant so the native extension dump is silent in release.
val uid: Int,
val debugLogging: Boolean,
) )
object NativeCertGen { object NativeCertGen {
@@ -43,6 +43,7 @@ object AndroidDeviceUtils {
DeviceAttestationService.CachedAttestationData?.verifiedBootKey DeviceAttestationService.CachedAttestationData?.verifiedBootKey
}, },
expectedSize = 32, expectedSize = 32,
recordSource = { bootKeySource = it },
) )
} }
@@ -60,9 +61,16 @@ object AndroidDeviceUtils {
DeviceAttestationService.CachedAttestationData?.verifiedBootHash DeviceAttestationService.CachedAttestationData?.verifiedBootHash
}, },
expectedSize = 32, expectedSize = 32,
recordSource = { bootHashSource = it },
) )
} }
// Records which fallback tier supplied bootKey/bootHash so the diagnostic dossier can flag a
// random-fallback value — a real verifiedBootKey that resolves to random bytes is a textbook
// simulated-TEE tell. Populated by initializeBootProperty on first access.
@Volatile private var bootKeySource: String = "uninitialized"
@Volatile private var bootHashSource: String = "uninitialized"
/** /**
* Public function to explicitly trigger the initialization of the boot key and hash. Accessing * Public function to explicitly trigger the initialization of the boot key and hash. Accessing
* these properties here ensures they are set up before they might be needed elsewhere. * these properties here ensures they are set up before they might be needed elsewhere.
@@ -89,9 +97,11 @@ object AndroidDeviceUtils {
propertyName: String, propertyName: String,
attestationValueProvider: () -> ByteArray?, attestationValueProvider: () -> ByteArray?,
expectedSize: Int, expectedSize: Int,
recordSource: (String) -> Unit,
): ByteArray { ): ByteArray {
getProperty(propertyName, expectedSize)?.let { getProperty(propertyName, expectedSize)?.let {
SystemLogger.debug("Using $propertyName from system property: ${it.toHex()}") SystemLogger.debug("Using $propertyName from system property: ${it.toHex()}")
recordSource("system-prop")
persistToFile(propertyName, it) persistToFile(propertyName, it)
return it return it
} }
@@ -99,6 +109,7 @@ object AndroidDeviceUtils {
try { try {
attestationValueProvider()?.let { attestationValueProvider()?.let {
SystemLogger.debug("Using $propertyName from TEE attestation: ${it.toHex()}") SystemLogger.debug("Using $propertyName from TEE attestation: ${it.toHex()}")
recordSource("tee-attestation")
setProperty(propertyName, it) setProperty(propertyName, it)
persistToFile(propertyName, it) persistToFile(propertyName, it)
return it return it
@@ -109,12 +120,14 @@ object AndroidDeviceUtils {
readFromFile(propertyName, expectedSize)?.let { readFromFile(propertyName, expectedSize)?.let {
SystemLogger.debug("Using $propertyName from persistent file: ${it.toHex()}") SystemLogger.debug("Using $propertyName from persistent file: ${it.toHex()}")
recordSource("persistent-file")
setProperty(propertyName, it) setProperty(propertyName, it)
return it return it
} }
return generateRandomBytes(expectedSize).also { return generateRandomBytes(expectedSize).also {
SystemLogger.debug("Using randomly generated $propertyName: ${it.toHex()}") SystemLogger.debug("Using randomly generated $propertyName: ${it.toHex()}")
recordSource("random-fallback")
setProperty(propertyName, it) setProperty(propertyName, it)
persistToFile(propertyName, it) persistToFile(propertyName, it)
} }
@@ -230,6 +243,23 @@ object AndroidDeviceUtils {
return custom ?: getRealDevicePatchLevelInt("boot", isLong = true) return custom ?: getRealDevicePatchLevelInt("boot", isLong = true)
} }
/**
* Summarises, for a targeted [uid], the device values that feed attestation and where each came
* from. This is what exposes attested-versus-live mismatches: a random-fallback verifiedBootKey,
* a patch level overridden away from the live prop, or an OS version pulled from a stale cache.
*/
fun describeSources(uid: Int): String {
val customPatchLevel = ConfigurationManager.getPatchLevelForUid(uid) != null
val osVersionSource =
if (DeviceAttestationService.CachedAttestationData?.osVersion != null) "cache" else "map"
return "osVersion=$osVersion(src=$osVersionSource) " +
"osPatch=${getPatchLevel(uid)} vendorPatch=${getVendorPatchLevelLong(uid)} " +
"bootPatch=${getBootPatchLevelLong(uid)} customPatchLevel=$customPatchLevel " +
"bootKey=${bootKey.toHex()}(src=$bootKeySource) " +
"bootHash=${bootHash.toHex()}(src=$bootHashSource) " +
"teeCacheData=${DeviceAttestationService.CachedAttestationData != null}"
}
/** /**
* Retrieves the definitive device patch level integer for a given component. This function * Retrieves the definitive device patch level integer for a given component. This function
* encapsulates the entire fallback chain and guarantees a non-null return. * encapsulates the entire fallback chain and guarantees a non-null return.
+28 -1
View File
@@ -64,18 +64,41 @@ fn generate_attested_inner(env: &mut JNIEnv, config: &JObject) -> Result<jbyteAr
let cert_chain = if params.attestation_challenge.is_some() { let cert_chain = if params.attestation_challenge.is_some() {
let attest_ext = attestation::build_attestation_extension(&params)?; let attest_ext = attestation::build_attestation_extension(&params)?;
// Ground truth of what the Rust forger emitted, keyed to the app. Gated on the APK debug
// variant so release builds never dump the extension.
if params.debug_logging {
tracing::info!(
uid = params.uid,
ext_hex = %hex_encode(&attest_ext),
"produced attestation extension"
);
}
certbuilder::build_certificate_chain(&key_pair, Some(&attest_ext), &keybox, &params)? certbuilder::build_certificate_chain(&key_pair, Some(&attest_ext), &keybox, &params)?
} else { } else {
tracing::info!("no attestation challenge, generating self-signed cert (depth 1)"); tracing::info!(
uid = params.uid,
"no attestation challenge, generating self-signed cert (depth 1)"
);
certbuilder::build_self_signed_cert(&key_pair, &params)? certbuilder::build_self_signed_cert(&key_pair, &params)?
}; };
let blob = assemble_result(&key_pair.private_key_pkcs8, &cert_chain); let blob = assemble_result(&key_pair.private_key_pkcs8, &cert_chain);
tracing::info!(uid = params.uid, certs = cert_chain.len(), "assembled native cert result");
let out = env.byte_array_from_slice(&blob)?; let out = env.byte_array_from_slice(&blob)?;
Ok(out.into_raw()) Ok(out.into_raw())
} }
/// Lowercase hex of a byte slice for diagnostic dumps; the crate has no `hex` dependency.
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{:02x}", b);
}
out
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// JNI entry: initLogging // JNI entry: initLogging
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -205,6 +228,8 @@ fn extract_config(env: &mut JNIEnv, config: &JObject) -> Result<CertGenParams> {
let caller_nonce = get_boolean(env, config, "callerNonce")?; let caller_nonce = get_boolean(env, config, "callerNonce")?;
let unlocked_device_required = get_boolean(env, config, "unlockedDeviceRequired")?; let unlocked_device_required = get_boolean(env, config, "unlockedDeviceRequired")?;
let no_auth_required = get_boolean(env, config, "noAuthRequired")?; let no_auth_required = get_boolean(env, config, "noAuthRequired")?;
let uid = get_int(env, config, "uid")?;
let debug_logging = get_boolean(env, config, "debugLogging")?;
Ok(CertGenParams { Ok(CertGenParams {
algorithm: Algorithm::try_from(algorithm)?, algorithm: Algorithm::try_from(algorithm)?,
@@ -252,6 +277,8 @@ fn extract_config(env: &mut JNIEnv, config: &JObject) -> Result<CertGenParams> {
caller_nonce, caller_nonce,
unlocked_device_required, unlocked_device_required,
no_auth_required, no_auth_required,
uid,
debug_logging,
}) })
} }
+5
View File
@@ -93,6 +93,11 @@ pub struct CertGenParams {
pub caller_nonce: bool, pub caller_nonce: bool,
pub unlocked_device_required: bool, pub unlocked_device_required: bool,
pub no_auth_required: bool, pub no_auth_required: bool,
/// Calling app UID, used only to key diagnostic log lines to the requesting app.
pub uid: i32,
/// Mirrors the APK debug variant; gates the produced-extension dump so release stays quiet.
pub debug_logging: bool,
} }
pub struct GeneratedKeyPair { pub struct GeneratedKeyPair {
+8
View File
@@ -27,6 +27,7 @@ REBOOT=false
VERIFY=false VERIFY=false
BUILD_RUST=false BUILD_RUST=false
CLEAR_KEYS=false CLEAR_KEYS=false
CLEAR_LOGS=false
TRACE=false TRACE=false
ROOT_PROVIDER="ksu" ROOT_PROVIDER="ksu"
@@ -52,6 +53,7 @@ Deploy options:
--deploy Push ZIP to device and install --deploy Push ZIP to device and install
--reboot Reboot device after install --reboot Reboot device after install
--clear-keys Clear persistent_keys before deploy --clear-keys Clear persistent_keys before deploy
--clear-logs Clear per-UID diagnostic logs before deploy
--verify Run logcat verification after deploy --verify Run logcat verification after deploy
--root PROVIDER Root provider: ksu (default), magisk, apatch --root PROVIDER Root provider: ksu (default), magisk, apatch
@@ -73,6 +75,7 @@ while [[ $# -gt 0 ]]; do
--verify) VERIFY=true; shift ;; --verify) VERIFY=true; shift ;;
--rust) BUILD_RUST=true; shift ;; --rust) BUILD_RUST=true; shift ;;
--clear-keys) CLEAR_KEYS=true; shift ;; --clear-keys) CLEAR_KEYS=true; shift ;;
--clear-logs) CLEAR_LOGS=true; shift ;;
-v|--verbose) TRACE=true; shift ;; -v|--verbose) TRACE=true; shift ;;
--root) ROOT_PROVIDER="$2"; shift 2 ;; --root) ROOT_PROVIDER="$2"; shift 2 ;;
--help|-h) usage ;; --help|-h) usage ;;
@@ -153,6 +156,11 @@ deploy_zip() {
adb shell "rm -rf /data/adb/tricky_store/persistent_keys/*" 2>/dev/null || true adb shell "rm -rf /data/adb/tricky_store/persistent_keys/*" 2>/dev/null || true
fi fi
if [[ "$CLEAR_LOGS" == true ]]; then
bold "==> Clearing per-UID diagnostic logs"
adb shell "rm -f /data/adb/tricky_store/logs/teesim-uid-*" 2>/dev/null || true
fi
bold "==> Deploying $name" bold "==> Deploying $name"
adb push "$zip" /data/local/tmp/module.zip adb push "$zip" /data/local/tmp/module.zip
adb shell "su -c '$INSTALL_CMD /data/local/tmp/module.zip'" adb shell "su -c '$INSTALL_CMD /data/local/tmp/module.zip'"