Add dynamic dates and TEE-based patch defaults (#52)
Implements dynamic date keywords ('today') and templates ('YYYY-MM-DD') in the security_patch.txt configuration. This allows for auto-updating patch levels.
The `device_default` keyword is now significantly more accurate. It prioritizes reading real patch levels directly from a cached TEE attestation before falling back to system properties.
The README has been updated to document these new features.
This commit is contained in:
@@ -100,7 +100,11 @@ Dates should be provided in `YYYY-MM-DD` format (e.g., `2025-11-05`).
|
|||||||
|
|
||||||
#### Special Keywords
|
#### Special Keywords
|
||||||
|
|
||||||
In addition to date values, two special keywords provide advanced control:
|
In addition to static dates, several special keywords provide advanced, dynamic control:
|
||||||
|
|
||||||
|
* **`today`**: Dynamically uses the current date every time an attestation is generated. This ensures the device always appears up-to-date without needing manual edits.
|
||||||
|
|
||||||
|
* **Date Templates**: You can create semi-dynamic dates using `YYYY`, `MM`, and `DD` as placeholders for the current year, month, and day. For example, `YYYY-MM-05` will always resolve to the 5th of the current month and year.
|
||||||
|
|
||||||
* **`no`**: This keyword instructs the simulator to **completely omit** the corresponding patch level tag from the generated attestation.
|
* **`no`**: This keyword instructs the simulator to **completely omit** the corresponding patch level tag from the generated attestation.
|
||||||
|
|
||||||
@@ -113,10 +117,10 @@ This example demonstrates how to combine global settings, per-package overrides,
|
|||||||
```
|
```
|
||||||
# --- Global Configuration ---
|
# --- Global Configuration ---
|
||||||
# This is the default for all apps unless specified otherwise.
|
# This is the default for all apps unless specified otherwise.
|
||||||
# - Forge a recent system patch level.
|
# - Forge a recent system patch level, the 5th of the current month (a common patch date).
|
||||||
# - Use the device's real vendor patch level.
|
# - Use the device's real vendor patch level.
|
||||||
# - Do not report a boot patch level at all.
|
# - Do not report a boot patch level at all.
|
||||||
system=2025-11-05
|
system=YYYY-MM-05
|
||||||
vendor=device_default
|
vendor=device_default
|
||||||
boot=no
|
boot=no
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ object DeviceAttestationService {
|
|||||||
val attestVersion: Int?,
|
val attestVersion: Int?,
|
||||||
val keymasterVersion: Int?,
|
val keymasterVersion: Int?,
|
||||||
val osVersion: Int?,
|
val osVersion: Int?,
|
||||||
|
val osPatchLevel: Int?,
|
||||||
|
val vendorPatchLevel: Int?,
|
||||||
|
val bootPatchLevel: Int?,
|
||||||
)
|
)
|
||||||
|
|
||||||
// A unique alias for the key used to perform the TEE functionality check.
|
// A unique alias for the key used to perform the TEE functionality check.
|
||||||
@@ -178,6 +181,9 @@ object DeviceAttestationService {
|
|||||||
var verifiedBootKey: ByteArray? = null
|
var verifiedBootKey: ByteArray? = null
|
||||||
var verifiedBootHash: ByteArray? = null
|
var verifiedBootHash: ByteArray? = null
|
||||||
var osVersion: Int? = null
|
var osVersion: Int? = null
|
||||||
|
var osPatchLevel: Int? = null
|
||||||
|
var vendorPatchLevel: Int? = null
|
||||||
|
var bootPatchLevel: Int? = null
|
||||||
|
|
||||||
val softwareEnforced =
|
val softwareEnforced =
|
||||||
ASN1Sequence.getInstance(
|
ASN1Sequence.getInstance(
|
||||||
@@ -219,17 +225,35 @@ object DeviceAttestationService {
|
|||||||
.octets
|
.octets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AttestationConstants.TAG_OS_VERSION -> { // OS Version (TAG_OS_VERSION)
|
AttestationConstants.TAG_OS_VERSION -> {
|
||||||
osVersion =
|
osVersion =
|
||||||
ASN1Integer.getInstance(tagged.baseObject.toASN1Primitive())
|
ASN1Integer.getInstance(tagged.baseObject.toASN1Primitive())
|
||||||
.positiveValue
|
.positiveValue
|
||||||
.toInt()
|
.toInt()
|
||||||
}
|
}
|
||||||
|
AttestationConstants.TAG_OS_PATCHLEVEL -> {
|
||||||
|
osPatchLevel =
|
||||||
|
ASN1Integer.getInstance(tagged.baseObject.toASN1Primitive())
|
||||||
|
.positiveValue
|
||||||
|
.toInt()
|
||||||
|
}
|
||||||
|
AttestationConstants.TAG_VENDOR_PATCHLEVEL -> {
|
||||||
|
vendorPatchLevel =
|
||||||
|
ASN1Integer.getInstance(tagged.baseObject.toASN1Primitive())
|
||||||
|
.positiveValue
|
||||||
|
.toInt()
|
||||||
|
}
|
||||||
|
AttestationConstants.TAG_BOOT_PATCHLEVEL -> {
|
||||||
|
bootPatchLevel =
|
||||||
|
ASN1Integer.getInstance(tagged.baseObject.toASN1Primitive())
|
||||||
|
.positiveValue
|
||||||
|
.toInt()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemLogger.info(
|
SystemLogger.info(
|
||||||
"Successfully extracted attestation data: version=$attestVersion, osVersion=$osVersion, moduleHash=${moduleHash?.toHex()}, bootKey=${verifiedBootKey?.toHex()}, bootHash=${verifiedBootHash?.toHex()}"
|
"Successfully extracted attestation data: version=$attestVersion, osVersion=$osVersion, osPatch=$osPatchLevel, vendorPatch=$vendorPatchLevel, bootPatch=$bootPatchLevel, moduleHash=${moduleHash?.toHex()}, bootKey=${verifiedBootKey?.toHex()}, bootHash=${verifiedBootHash?.toHex()}"
|
||||||
)
|
)
|
||||||
return AttestationData(
|
return AttestationData(
|
||||||
moduleHash,
|
moduleHash,
|
||||||
@@ -238,6 +262,9 @@ object DeviceAttestationService {
|
|||||||
attestVersion,
|
attestVersion,
|
||||||
keymasterVersion,
|
keymasterVersion,
|
||||||
osVersion,
|
osVersion,
|
||||||
|
osPatchLevel,
|
||||||
|
vendorPatchLevel,
|
||||||
|
bootPatchLevel,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
SystemLogger.error("Failed to parse attestation data from certificate.", e)
|
SystemLogger.error("Failed to parse attestation data from certificate.", e)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.hardware.security.keymint.SecurityLevel
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemProperties
|
import android.os.SystemProperties
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.time.LocalDate
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
import org.bouncycastle.asn1.ASN1EncodableVector
|
import org.bouncycastle.asn1.ASN1EncodableVector
|
||||||
import org.bouncycastle.asn1.ASN1Integer
|
import org.bouncycastle.asn1.ASN1Integer
|
||||||
@@ -164,19 +165,56 @@ object AndroidDeviceUtils {
|
|||||||
|
|
||||||
fun getPatchLevel(uid: Int): Int {
|
fun getPatchLevel(uid: Int): Int {
|
||||||
val custom = getCustomPatchLevelFor(uid, "system", isLong = false)
|
val custom = getCustomPatchLevelFor(uid, "system", isLong = false)
|
||||||
// If custom is null, it means 'device_default' was used, so we fall back.
|
return custom ?: getRealDevicePatchLevelInt("system", isLong = false)
|
||||||
// Otherwise, we use the returned value, which is either the parsed date or DO_NOT_REPORT.
|
|
||||||
return custom ?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVendorPatchLevelLong(uid: Int): Int {
|
fun getVendorPatchLevelLong(uid: Int): Int {
|
||||||
val custom = getCustomPatchLevelFor(uid, "vendor", isLong = true)
|
val custom = getCustomPatchLevelFor(uid, "vendor", isLong = true)
|
||||||
return custom ?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = true)
|
return custom ?: getRealDevicePatchLevelInt("vendor", isLong = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBootPatchLevelLong(uid: Int): Int {
|
fun getBootPatchLevelLong(uid: Int): Int {
|
||||||
val custom = getCustomPatchLevelFor(uid, "boot", isLong = true)
|
val custom = getCustomPatchLevelFor(uid, "boot", isLong = true)
|
||||||
return custom ?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = true)
|
return custom ?: getRealDevicePatchLevelInt("boot", isLong = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the definitive device patch level integer for a given component. This function
|
||||||
|
* encapsulates the entire fallback chain and guarantees a non-null return.
|
||||||
|
*
|
||||||
|
* Fallback Priority:
|
||||||
|
* 1. Cached TEE attestation data.
|
||||||
|
* 2. Specific system property (e.g., ro.vendor.build.security_patch).
|
||||||
|
* 3. Default system patch level from Build.VERSION.SECURITY_PATCH.
|
||||||
|
*
|
||||||
|
* @param component The component ("system", "vendor", "boot").
|
||||||
|
* @param isLong Whether the final integer should be in YYYYMMDD format.
|
||||||
|
* @return The patch level as a guaranteed non-null Integer.
|
||||||
|
*/
|
||||||
|
private fun getRealDevicePatchLevelInt(component: String, isLong: Boolean): Int {
|
||||||
|
// Get value from cached TEE attestation data
|
||||||
|
DeviceAttestationService.CachedAttestationData?.let { data ->
|
||||||
|
val value =
|
||||||
|
when (component) {
|
||||||
|
"system" -> data.osPatchLevel
|
||||||
|
"vendor" -> data.vendorPatchLevel
|
||||||
|
"boot" -> data.bootPatchLevel
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (value != null) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only check the specific vendor property, as the boot one is non-existent.
|
||||||
|
if (component == "vendor") {
|
||||||
|
val propValue = SystemProperties.get("ro.vendor.build.security_patch", "")
|
||||||
|
if (!propValue.isNullOrBlank()) {
|
||||||
|
parsePatchLevelValue(propValue, isLong)?.let { parsedValue ->
|
||||||
|
return parsedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,16 +235,50 @@ object AndroidDeviceUtils {
|
|||||||
else -> config.all
|
else -> config.all
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
|
||||||
|
// First, resolve dynamic keywords and templates into a concrete date string.
|
||||||
|
val resolvedValue = resolveDateKeywords(value)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
// "device_default" indicates falling back to the system property.
|
// "device_default" indicates falling back to the system property.
|
||||||
value.equals("device_default", ignoreCase = true) -> null
|
resolvedValue.equals("device_default", ignoreCase = true) -> null
|
||||||
// "no" indicates this value should not be reported.
|
// "no" indicates this value should not be reported.
|
||||||
value.equals("no", ignoreCase = true) -> DO_NOT_REPORT
|
resolvedValue.equals("no", ignoreCase = true) -> DO_NOT_REPORT
|
||||||
// Otherwise, parse the date string.
|
// Otherwise, parse the resolved date string.
|
||||||
else -> parsePatchLevelValue(value, isLong)
|
else -> parsePatchLevelValue(resolvedValue, isLong)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves special date keywords and templates into a concrete "YYYY-MM-DD" date string.
|
||||||
|
*
|
||||||
|
* @param value The configuration value string (e.g., "today", "YYYY-MM-01").
|
||||||
|
* @return A concrete date string, or the original value if it's not a dynamic date keyword.
|
||||||
|
*/
|
||||||
|
private fun resolveDateKeywords(value: String): String {
|
||||||
|
// Handle the "today" keyword.
|
||||||
|
if (value.equals("today", ignoreCase = true)) {
|
||||||
|
return LocalDate.now().toString() // Returns "YYYY-MM-DD" format
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle date templates like "YYYY-MM-01" or "2025-MM-DD".
|
||||||
|
if (
|
||||||
|
value.contains("YYYY", ignoreCase = true) ||
|
||||||
|
value.contains("MM", ignoreCase = true) ||
|
||||||
|
value.contains("DD", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val now = LocalDate.now()
|
||||||
|
// Chain replacements for YYYY, MM, and DD placeholders.
|
||||||
|
return value
|
||||||
|
.replace("YYYY", now.year.toString(), ignoreCase = true)
|
||||||
|
.replace("MM", String.format("%02d", now.monthValue), ignoreCase = true)
|
||||||
|
.replace("DD", String.format("%02d", now.dayOfMonth), ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not a dynamic keyword or template, return the original value.
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses a patch level string (e.g., "2025-11-01") into an integer format. */
|
/** Parses a patch level string (e.g., "2025-11-01") into an integer format. */
|
||||||
private fun parsePatchLevelValue(value: String, isLong: Boolean): Int? {
|
private fun parsePatchLevelValue(value: String, isLong: Boolean): Int? {
|
||||||
val normalized = value.replace("-", "")
|
val normalized = value.replace("-", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user