Compare commits
57
Commits
v6.0.0-158
...
v6.0.0-222
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb7f0ca098 | ||
|
|
b323f41b08 | ||
|
|
c46aaa34f8 | ||
|
|
ca86148633 | ||
|
|
0fbdf42e9d | ||
|
|
91ce9485fe | ||
|
|
69fbdc112d | ||
|
|
1ee66be05a | ||
|
|
0b2c34ff8c | ||
|
|
2704eff797 | ||
|
|
22d1972bc7 | ||
|
|
5f72acb1e7 | ||
|
|
d7dc5e0b63 | ||
|
|
59836e143c | ||
|
|
a7e7e454e7 | ||
|
|
bba4a9ebfa | ||
|
|
58b98fd308 | ||
|
|
0617297b22 | ||
|
|
aef80c3105 | ||
|
|
032c87d50e | ||
|
|
62d666fd63 | ||
|
|
39b3811dc3 | ||
|
|
1446090da9 | ||
|
|
52ff39d130 | ||
|
|
3dea767058 | ||
|
|
17b359e94d | ||
|
|
57035b2c94 | ||
|
|
e8d12c4165 | ||
|
|
d048174402 | ||
|
|
21fb3ba879 | ||
|
|
ca56928add | ||
|
|
521e28cece | ||
|
|
372001e8de | ||
|
|
6fa10d7111 | ||
|
|
7c58e2f039 | ||
|
|
6dc7755658 | ||
|
|
921edecb86 | ||
|
|
756aa2efb2 | ||
|
|
0ebfef55b6 | ||
|
|
94c7d00fb5 | ||
|
|
fe21106151 | ||
|
|
3d8d193a44 | ||
|
|
85eef8054d | ||
|
|
051a003b33 | ||
|
|
29b2a85e9f | ||
|
|
890f47009b | ||
|
|
b85b3dea48 | ||
|
|
c8b3e9e528 | ||
|
|
a5375f7426 | ||
|
|
9e1b459b74 | ||
|
|
6476216aa3 | ||
|
|
bc7b11a380 | ||
|
|
22cadc125e | ||
|
|
5267c9dd00 | ||
|
|
dfc8aac920 | ||
|
|
bdb460411a | ||
|
|
ea792c7b78 |
@@ -149,3 +149,21 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VER: ${{ steps.ver.outputs.version }}
|
VER: ${{ steps.ver.outputs.version }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Bump update.json
|
||||||
|
run: |
|
||||||
|
COUNT=$(git rev-list HEAD --count)
|
||||||
|
RELEASE_NAME=$(basename zips/*Release*.zip)
|
||||||
|
ZIP_URL="https://github.com/${{ github.repository }}/releases/download/${VER}/${RELEASE_NAME}"
|
||||||
|
jq ".versionCode = $COUNT | .zipUrl = \"$ZIP_URL\"" module/update.json > /tmp/update.json
|
||||||
|
mv /tmp/update.json module/update.json
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add module/update.json
|
||||||
|
git diff --cached --quiet || {
|
||||||
|
git commit -m "chore(release): bump update.json to $VER [skip ci]"
|
||||||
|
git push origin HEAD:main
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
VER: ${{ steps.ver.outputs.version }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import com.android.build.api.artifact.SingleArtifact
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.gradle.process.ExecOperations
|
import org.gradle.process.ExecOperations
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
@@ -65,6 +66,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(project(":stub"))
|
compileOnly(project(":stub"))
|
||||||
compileOnly(libs.annotation)
|
compileOnly(libs.annotation)
|
||||||
@@ -91,6 +98,7 @@ val buildRustCertgen by tasks.registering(Exec::class) {
|
|||||||
outputs.dir(rootProject.projectDir.resolve("app/src/main/jniLibs"))
|
outputs.dir(rootProject.projectDir.resolve("app/src/main/jniLibs"))
|
||||||
|
|
||||||
environment("ANDROID_NDK_HOME", android.ndkDirectory.absolutePath)
|
environment("ANDROID_NDK_HOME", android.ndkDirectory.absolutePath)
|
||||||
|
environment("PATH", "${System.getProperty("user.home")}/.cargo/bin:${System.getenv("PATH") ?: ""}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AGP auto-detects jniLibs/ as an input to mergeJniLibFolders — wire the dependency
|
// AGP auto-detects jniLibs/ as an input to mergeJniLibFolders — wire the dependency
|
||||||
@@ -100,6 +108,34 @@ tasks.configureEach {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-rewrite module/update.json on every packaging build so versionCode and
|
||||||
|
// zipUrl track gitCommitCount automatically, matching module.prop.
|
||||||
|
val refreshUpdateJson by tasks.registering {
|
||||||
|
group = "TEESimulator-RS Module Packaging"
|
||||||
|
description = "Rewrite module/update.json to match current verName and gitCommitCount."
|
||||||
|
|
||||||
|
val updateJsonFile = rootProject.projectDir.resolve("module/update.json")
|
||||||
|
val capturedVerName = verName
|
||||||
|
val capturedCount = gitCommitCount
|
||||||
|
|
||||||
|
inputs.property("verName", capturedVerName)
|
||||||
|
inputs.property("gitCommitCount", capturedCount)
|
||||||
|
outputs.file(updateJsonFile)
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
val fullVer = "$capturedVerName-$capturedCount"
|
||||||
|
updateJsonFile.writeText(
|
||||||
|
"""{
|
||||||
|
"version": "$fullVer",
|
||||||
|
"versionCode": $capturedCount,
|
||||||
|
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/$fullVer/TEESimulator-RS-$fullVer-Release.zip",
|
||||||
|
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
androidComponents {
|
androidComponents {
|
||||||
onVariants(selector().all()) { variant ->
|
onVariants(selector().all()) { variant ->
|
||||||
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
||||||
@@ -124,6 +160,7 @@ androidComponents {
|
|||||||
dependsOn("strip${capitalized}DebugSymbols")
|
dependsOn("strip${capitalized}DebugSymbols")
|
||||||
}
|
}
|
||||||
dependsOn(buildRustCertgen)
|
dependsOn(buildRustCertgen)
|
||||||
|
dependsOn(refreshUpdateJson)
|
||||||
|
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
from(variant.artifacts.get(SingleArtifact.APK)) {
|
from(variant.artifacts.get(SingleArtifact.APK)) {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import android.os.Build
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.matrix.TEESimulator.config.BootStateManager
|
||||||
|
import org.matrix.TEESimulator.config.BulletinPoller
|
||||||
import org.matrix.TEESimulator.config.ConfigurationManager
|
import org.matrix.TEESimulator.config.ConfigurationManager
|
||||||
|
import org.matrix.TEESimulator.config.PatchLevelManager
|
||||||
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
||||||
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
||||||
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
|
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
|
||||||
@@ -39,11 +42,18 @@ object App {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
prepareEnvironment()
|
prepareEnvironment()
|
||||||
// Initialize and start the appropriate keystore interceptors.
|
|
||||||
initializeInterceptors()
|
// Spoof boot-state and patch-level props before any hook attaches,
|
||||||
|
// so keystore2's cached snapshot reflects the spoofed values.
|
||||||
|
BootStateManager.apply()
|
||||||
|
PatchLevelManager.initialize()
|
||||||
|
|
||||||
// Load the package configuration.
|
// Load the package configuration.
|
||||||
ConfigurationManager.initialize()
|
ConfigurationManager.initialize()
|
||||||
|
|
||||||
|
// Initialize and start the appropriate keystore interceptors.
|
||||||
|
initializeInterceptors()
|
||||||
|
|
||||||
// Set up the device's boot key and hash, which are crucial for attestation.
|
// Set up the device's boot key and hash, which are crucial for attestation.
|
||||||
AndroidDeviceUtils.setupBootKeyAndHash()
|
AndroidDeviceUtils.setupBootKeyAndHash()
|
||||||
|
|
||||||
@@ -55,6 +65,12 @@ object App {
|
|||||||
|
|
||||||
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
|
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
|
||||||
|
|
||||||
|
try {
|
||||||
|
BulletinPoller.start()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
SystemLogger.error("Failed to start BulletinPoller", e)
|
||||||
|
}
|
||||||
|
|
||||||
// This starts the message queue processing. It blocks here indefinitely
|
// This starts the message queue processing. It blocks here indefinitely
|
||||||
// processing messages until Looper.myLooper().quit() is called.
|
// processing messages until Looper.myLooper().quit() is called.
|
||||||
Looper.loop()
|
Looper.loop()
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package org.matrix.TEESimulator.config
|
||||||
|
|
||||||
|
import android.os.SystemProperties
|
||||||
|
import org.matrix.TEESimulator.logging.SystemLogger
|
||||||
|
import org.matrix.TEESimulator.util.AndroidDeviceUtils
|
||||||
|
|
||||||
|
object BootStateManager {
|
||||||
|
private val targets =
|
||||||
|
linkedMapOf(
|
||||||
|
"ro.boot.verifiedbootstate" to "green",
|
||||||
|
"ro.boot.flash.locked" to "1",
|
||||||
|
"ro.boot.veritymode" to "enforcing",
|
||||||
|
"ro.boot.vbmeta.device_state" to "locked",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val fillIfAbsent =
|
||||||
|
linkedMapOf(
|
||||||
|
"ro.boot.vbmeta.invalidate_on_error" to "yes",
|
||||||
|
"ro.boot.vbmeta.avb_version" to "1.2",
|
||||||
|
"ro.boot.vbmeta.hash_alg" to "sha256",
|
||||||
|
"ro.boot.vbmeta.size" to "11904",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun apply() {
|
||||||
|
for ((name, target) in targets) {
|
||||||
|
val current = SystemProperties.get(name, "")
|
||||||
|
if (current.isEmpty()) {
|
||||||
|
SystemLogger.debug("BootStateManager: $name absent on this device, skip")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (current == target) {
|
||||||
|
SystemLogger.debug("BootStateManager: $name already $target, skip")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
SystemLogger.info("BootStateManager: setting $name=$target (was: '$current')")
|
||||||
|
AndroidDeviceUtils.setProperty(name, target)
|
||||||
|
}
|
||||||
|
for ((name, value) in fillIfAbsent) {
|
||||||
|
val current = SystemProperties.get(name, "")
|
||||||
|
if (current.isNotEmpty()) {
|
||||||
|
SystemLogger.debug("BootStateManager: $name already '$current', skip")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
SystemLogger.info("BootStateManager: filling absent $name=$value")
|
||||||
|
AndroidDeviceUtils.setProperty(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package org.matrix.TEESimulator.config
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.matrix.TEESimulator.BuildConfig
|
||||||
|
import org.matrix.TEESimulator.logging.SystemLogger
|
||||||
|
|
||||||
|
object BulletinPoller {
|
||||||
|
private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
|
||||||
|
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
|
||||||
|
private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
|
||||||
|
private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
|
||||||
|
private const val HISTORY_CAP = 10
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
private const val READ_TIMEOUT_MS = 15_000
|
||||||
|
private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
|
||||||
|
private val DATE_REGEX = Regex("<td>(\\d{4}-\\d{2}-\\d{2})</td>")
|
||||||
|
private val PATCH_DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||||
|
|
||||||
|
private lateinit var handler: Handler
|
||||||
|
@Volatile private var bootstrapStep = 0
|
||||||
|
@Volatile private var steadyArmed = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val thread = HandlerThread("BulletinPoller").apply { start() }
|
||||||
|
handler = Handler(thread.looper)
|
||||||
|
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollOnce() {
|
||||||
|
try {
|
||||||
|
val result = fetchAndParse()
|
||||||
|
appendHistory(result)
|
||||||
|
scheduleNext(result.status == "success")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
SystemLogger.error("BulletinPoller: pollOnce failed", t)
|
||||||
|
scheduleNext(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleNext(success: Boolean) {
|
||||||
|
if (success || steadyArmed) {
|
||||||
|
steadyArmed = true
|
||||||
|
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bootstrapStep++
|
||||||
|
if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
|
||||||
|
steadyArmed = true
|
||||||
|
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
|
||||||
|
} else {
|
||||||
|
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FetchResult(
|
||||||
|
val ts: Long,
|
||||||
|
val status: String,
|
||||||
|
val httpCode: Int?,
|
||||||
|
val parsedDate: String?,
|
||||||
|
val applied: Boolean,
|
||||||
|
val error: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fetchAndParse(): FetchResult {
|
||||||
|
val ts = System.currentTimeMillis()
|
||||||
|
var conn: HttpsURLConnection? = null
|
||||||
|
return try {
|
||||||
|
conn =
|
||||||
|
(URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
|
||||||
|
connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
|
readTimeout = READ_TIMEOUT_MS
|
||||||
|
setRequestProperty(
|
||||||
|
"User-Agent",
|
||||||
|
"TEESimulator/${BuildConfig.VERSION_NAME}",
|
||||||
|
)
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code != 200) {
|
||||||
|
return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
|
||||||
|
}
|
||||||
|
val html = conn.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
val date = DATE_REGEX.find(html)?.groupValues?.get(1)
|
||||||
|
if (date == null) {
|
||||||
|
return FetchResult(
|
||||||
|
ts,
|
||||||
|
"parse_error",
|
||||||
|
code,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"no <td>YYYY-MM-DD</td> match",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val current = currentPatch()
|
||||||
|
if (current == null || date <= current) {
|
||||||
|
return FetchResult(ts, "success", code, date, false, null)
|
||||||
|
}
|
||||||
|
if (PatchLevelManager.updateTo(date)) {
|
||||||
|
FetchResult(ts, "success", code, date, true, null)
|
||||||
|
} else {
|
||||||
|
FetchResult(
|
||||||
|
ts,
|
||||||
|
"validation_rejected",
|
||||||
|
code,
|
||||||
|
date,
|
||||||
|
false,
|
||||||
|
"PatchLevelManager.updateTo rejected $date",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
FetchResult(ts, "network_error", null, null, false, e.toString())
|
||||||
|
} finally {
|
||||||
|
conn?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentPatch(): String? {
|
||||||
|
val f = File(PATCH_FILE)
|
||||||
|
if (!f.exists()) return null
|
||||||
|
val raw = try {
|
||||||
|
f.readLines()
|
||||||
|
.firstOrNull { it.startsWith("system=") }
|
||||||
|
?.substringAfter("system=")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it != "prop" && it.isNotEmpty() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (raw == null) return null
|
||||||
|
if (PATCH_DATE_PATTERN.matches(raw)) return raw
|
||||||
|
SystemLogger.warning(
|
||||||
|
"BulletinPoller: ignoring malformed system='$raw' in $PATCH_FILE"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendHistory(result: FetchResult) {
|
||||||
|
try {
|
||||||
|
val target = File(HISTORY_FILE)
|
||||||
|
val staging = File(HISTORY_STAGING)
|
||||||
|
val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
|
||||||
|
val history =
|
||||||
|
existing
|
||||||
|
?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
|
||||||
|
?: JSONArray()
|
||||||
|
val entry =
|
||||||
|
JSONObject().apply {
|
||||||
|
put("ts", result.ts)
|
||||||
|
put("status", result.status)
|
||||||
|
put("http_code", result.httpCode ?: JSONObject.NULL)
|
||||||
|
put("parsed_date", result.parsedDate ?: JSONObject.NULL)
|
||||||
|
put("applied", result.applied)
|
||||||
|
put("error", result.error ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
history.put(entry)
|
||||||
|
while (history.length() > HISTORY_CAP) history.remove(0)
|
||||||
|
|
||||||
|
val latestKnown =
|
||||||
|
(0 until history.length())
|
||||||
|
.mapNotNull {
|
||||||
|
history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
|
||||||
|
d.isNotBlank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lastOrNull()
|
||||||
|
|
||||||
|
val root =
|
||||||
|
JSONObject().apply {
|
||||||
|
put("latest_known_date", latestKnown ?: JSONObject.NULL)
|
||||||
|
put("history", history)
|
||||||
|
}
|
||||||
|
staging.writeText(root.toString(2))
|
||||||
|
Files.move(
|
||||||
|
staging.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SystemLogger.error("BulletinPoller: failed to persist history", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,16 @@ object ConfigurationManager {
|
|||||||
|
|
||||||
fun shouldSkipUid(uid: Int): Boolean = getPackageModeForUid(uid) == null
|
fun shouldSkipUid(uid: Int): Boolean = getPackageModeForUid(uid) == null
|
||||||
|
|
||||||
fun isAutoMode(uid: Int): Boolean = getPackageModeForUid(uid) == Mode.AUTO
|
fun isAutoMode(uid: Int): Boolean {
|
||||||
|
for (pkg in getPackagesForUid(uid)) {
|
||||||
|
when (packageModes[pkg]) {
|
||||||
|
Mode.GENERATE, Mode.PATCH -> return false
|
||||||
|
Mode.AUTO -> return true
|
||||||
|
null -> continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private fun getPackageModeForUid(uid: Int): Mode? {
|
private fun getPackageModeForUid(uid: Int): Mode? {
|
||||||
val packages = getPackagesForUid(uid)
|
val packages = getPackagesForUid(uid)
|
||||||
@@ -297,10 +306,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package org.matrix.TEESimulator.config
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.FileObserver
|
||||||
|
import android.os.SystemProperties
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.time.LocalDate
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.matrix.TEESimulator.logging.SystemLogger
|
||||||
|
import org.matrix.TEESimulator.util.AndroidDeviceUtils
|
||||||
|
|
||||||
|
object PatchLevelManager {
|
||||||
|
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
|
||||||
|
private const val STAGING_FILE = "/data/adb/tricky_store/security_patch.txt.next"
|
||||||
|
private const val PIF_DIR = "/data/adb/modules/playintegrityfix"
|
||||||
|
private const val FLOOR_YYYYMMDD = 20200101
|
||||||
|
private const val MAX_PAST_OFFSET = 10000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel security bulletins publish monthly; pre-announced dates occasionally
|
||||||
|
* slip by 2-4 weeks. 60 days covers that window without admitting a
|
||||||
|
* far-future date from a hostile or mis-parsed bulletin response.
|
||||||
|
*/
|
||||||
|
private const val MAX_FUTURE_DAYS = 60L
|
||||||
|
|
||||||
|
private val PIF_FILENAMES =
|
||||||
|
setOf("pif.json", "pif.prop", "custom.pif.json", "custom.pif.prop")
|
||||||
|
|
||||||
|
private val DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||||
|
private val PROP_PATTERN = Regex("^SECURITY_PATCH=(.+)$", RegexOption.MULTILINE)
|
||||||
|
private val SECTION_HEADER = Regex("^\\[[a-zA-Z0-9_.-]+]$")
|
||||||
|
private val GLOBAL_KEYS = setOf("system", "boot", "vendor", "all")
|
||||||
|
|
||||||
|
private val PIF_SOURCES =
|
||||||
|
listOf(
|
||||||
|
"/data/adb/modules/playintegrityfix/pif.json",
|
||||||
|
"/data/adb/pif.json",
|
||||||
|
"/data/adb/modules/playintegrityfix/pif.prop",
|
||||||
|
"/data/adb/pif.prop",
|
||||||
|
"/data/adb/modules/playintegrityfix/custom.pif.json",
|
||||||
|
"/data/adb/modules/playintegrityfix/custom.pif.prop",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
refreshFromSources()
|
||||||
|
startPifObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshFromSources() {
|
||||||
|
val date =
|
||||||
|
resolvePifPatch()
|
||||||
|
?: SystemProperties.get(
|
||||||
|
"ro.build.version.security_patch",
|
||||||
|
Build.VERSION.SECURITY_PATCH,
|
||||||
|
)
|
||||||
|
SystemLogger.info("PatchLevelManager: resolved patch date = $date")
|
||||||
|
applyToProps(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPifObserver() {
|
||||||
|
if (!File(PIF_DIR).exists()) {
|
||||||
|
SystemLogger.debug("PatchLevelManager: PIF dir absent, hot-reload disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PifObserver.startWatching()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun applyToProps(date: String) {
|
||||||
|
if (!DATE_PATTERN.matches(date)) {
|
||||||
|
SystemLogger.warning(
|
||||||
|
"PatchLevelManager: skip resetprop for invalid date: $date"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AndroidDeviceUtils.setProperty("ro.build.version.security_patch", date)
|
||||||
|
AndroidDeviceUtils.setProperty("ro.vendor.build.security_patch", date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTo(date: String): Boolean {
|
||||||
|
if (!DATE_PATTERN.matches(date)) {
|
||||||
|
SystemLogger.warning("PatchLevelManager: invalid date format: $date")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val dateInt = date.replace("-", "").toInt()
|
||||||
|
if (dateInt < FLOOR_YYYYMMDD) {
|
||||||
|
SystemLogger.warning("PatchLevelManager: $date below floor $FLOOR_YYYYMMDD")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val now = LocalDate.now()
|
||||||
|
val today = now.year * 10000 + now.monthValue * 100 + now.dayOfMonth
|
||||||
|
if (today >= dateInt + MAX_PAST_OFFSET) {
|
||||||
|
SystemLogger.warning(
|
||||||
|
"PatchLevelManager: $date more than 1y older than today ($today)"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val maxFuture =
|
||||||
|
now.plusDays(MAX_FUTURE_DAYS).let {
|
||||||
|
it.year * 10000 + it.monthValue * 100 + it.dayOfMonth
|
||||||
|
}
|
||||||
|
if (dateInt > maxFuture) {
|
||||||
|
SystemLogger.warning(
|
||||||
|
"PatchLevelManager: $date more than $MAX_FUTURE_DAYS days in future ($maxFuture)"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
atomicWrite(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SystemLogger.error("PatchLevelManager: atomicWrite failed for $date", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
applyToProps(date)
|
||||||
|
SystemLogger.info("PatchLevelManager: applied patch date $date")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePifPatch(): String? {
|
||||||
|
val source =
|
||||||
|
PIF_SOURCES.map(::File).lastOrNull { it.exists() && it.length() > 0 }
|
||||||
|
?: return null
|
||||||
|
return try {
|
||||||
|
val text = source.readText()
|
||||||
|
val parsed =
|
||||||
|
if (source.name.endsWith(".json")) {
|
||||||
|
JSONObject(text).optString("SECURITY_PATCH", "")
|
||||||
|
} else {
|
||||||
|
PROP_PATTERN.find(text)?.groupValues?.get(1)?.trim().orEmpty()
|
||||||
|
}
|
||||||
|
parsed.takeIf { it.isNotBlank() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SystemLogger.warning(
|
||||||
|
"PatchLevelManager: failed to parse ${source.path}: ${e.message}"
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun atomicWrite(date: String) {
|
||||||
|
val target = File(PATCH_FILE)
|
||||||
|
val staging = File(STAGING_FILE)
|
||||||
|
staging.writeText(mergedContents(target, date))
|
||||||
|
Files.move(
|
||||||
|
staging.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergedContents(target: File, date: String): String {
|
||||||
|
val globalBlock = "system=$date\nboot=$date\nvendor=$date\n"
|
||||||
|
if (!target.exists()) return globalBlock
|
||||||
|
val tail = stripGlobalAssignments(target.readLines())
|
||||||
|
if (tail.isEmpty()) return globalBlock
|
||||||
|
return globalBlock + tail.joinToString("\n", prefix = "\n", postfix = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripGlobalAssignments(lines: List<String>): List<String> {
|
||||||
|
val kept = mutableListOf<String>()
|
||||||
|
var inGlobal = true
|
||||||
|
for (line in lines) {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (SECTION_HEADER.matches(trimmed)) {
|
||||||
|
inGlobal = false
|
||||||
|
kept += line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inGlobal && isGlobalKeyAssignment(trimmed)) continue
|
||||||
|
kept += line
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isGlobalKeyAssignment(trimmed: String): Boolean {
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#") || '=' !in trimmed) return false
|
||||||
|
val key = trimmed.substringBefore('=').trim().lowercase()
|
||||||
|
return key in GLOBAL_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
private object PifObserver :
|
||||||
|
FileObserver(File(PIF_DIR), CLOSE_WRITE or MOVED_TO or DELETE) {
|
||||||
|
override fun onEvent(event: Int, path: String?) {
|
||||||
|
if (path == null || path !in PIF_FILENAMES) return
|
||||||
|
SystemLogger.info("PatchLevelManager: PIF change ($path), refreshing")
|
||||||
|
refreshFromSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-7
@@ -19,10 +19,27 @@ object InterceptorUtils {
|
|||||||
|
|
||||||
private const val EX_SERVICE_SPECIFIC = -8
|
private const val EX_SERVICE_SPECIFIC = -8
|
||||||
|
|
||||||
|
private fun synthesizeSseMessage(errorCode: Int): String =
|
||||||
|
when (errorCode) {
|
||||||
|
2 -> "Error::Rc(SYSTEM_ERROR)"
|
||||||
|
4 -> "Error::Rc(PERMISSION_DENIED)"
|
||||||
|
6 -> "Error::Rc(VALUE_CORRUPTED)"
|
||||||
|
7 -> "Error::Rc(KEY_NOT_FOUND)"
|
||||||
|
10 -> "Error::Rc(BACKEND_BUSY)"
|
||||||
|
-3 -> "Error::Km(UNSUPPORTED_KEY_SIZE)"
|
||||||
|
-6 -> "Error::Km(INCOMPATIBLE_PURPOSE)"
|
||||||
|
-7 -> "Error::Km(INCOMPATIBLE_ALGORITHM)"
|
||||||
|
-29 -> "Error::Km(TOO_MANY_OPERATIONS)"
|
||||||
|
-49 -> "Error::Km(UNSUPPORTED_TAG)"
|
||||||
|
-75 -> "Error::Km(INVALID_INPUT_LENGTH)"
|
||||||
|
-76 -> "Error::Km(INVALID_TAG)"
|
||||||
|
else -> if (errorCode > 0) "Error::Rc($errorCode)" else "Error::Km($errorCode)"
|
||||||
|
}
|
||||||
|
|
||||||
fun createErrorReply(errorCode: Int): BinderInterceptor.TransactionResult.OverrideReply {
|
fun createErrorReply(errorCode: Int): BinderInterceptor.TransactionResult.OverrideReply {
|
||||||
val parcel = Parcel.obtain().apply {
|
val parcel = Parcel.obtain().apply {
|
||||||
writeInt(EX_SERVICE_SPECIFIC)
|
writeInt(EX_SERVICE_SPECIFIC)
|
||||||
writeString(null)
|
writeString(synthesizeSseMessage(errorCode))
|
||||||
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
|
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
|
||||||
writeInt(errorCode)
|
writeInt(errorCode)
|
||||||
}
|
}
|
||||||
@@ -132,12 +149,25 @@ object InterceptorUtils {
|
|||||||
|
|
||||||
fun createServiceSpecificErrorReply(
|
fun createServiceSpecificErrorReply(
|
||||||
errorCode: Int
|
errorCode: Int
|
||||||
): BinderInterceptor.TransactionResult.OverrideReply {
|
): BinderInterceptor.TransactionResult.OverrideReply = createErrorReply(errorCode)
|
||||||
val parcel =
|
|
||||||
Parcel.obtain().apply {
|
fun normalizeServiceSpecificReply(reply: Parcel): Parcel? {
|
||||||
writeException(android.os.ServiceSpecificException(errorCode))
|
reply.setDataPosition(0)
|
||||||
}
|
if (reply.readInt() != EX_SERVICE_SPECIFIC) {
|
||||||
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
|
reply.setDataPosition(0)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Advance position past message and stack header to reach errorCode.
|
||||||
|
reply.readString()
|
||||||
|
reply.readInt()
|
||||||
|
val errorCode = reply.readInt()
|
||||||
|
reply.setDataPosition(0)
|
||||||
|
return Parcel.obtain().apply {
|
||||||
|
writeInt(EX_SERVICE_SPECIFIC)
|
||||||
|
writeString(synthesizeSseMessage(errorCode))
|
||||||
|
writeInt(0)
|
||||||
|
writeInt(errorCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun patchAuthorizations(
|
fun patchAuthorizations(
|
||||||
|
|||||||
+59
-2
@@ -62,6 +62,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
|
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
|
||||||
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
|
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
|
||||||
|
|
||||||
|
fun forgetDeletedKey(keyId: KeyIdentifier) {
|
||||||
|
if (deletedSoftwareKeys.remove(keyId)) {
|
||||||
|
SystemLogger.debug("Cleared deletion marker for ${keyId.alias}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override val serviceName = "android.system.keystore2.IKeystoreService/default"
|
override val serviceName = "android.system.keystore2.IKeystoreService/default"
|
||||||
override val processName = "keystore2"
|
override val processName = "keystore2"
|
||||||
override val injectionCommand = "exec ./inject `pidof keystore2` libTEESimulator.so entry"
|
override val injectionCommand = "exec ./inject `pidof keystore2` libTEESimulator.so entry"
|
||||||
@@ -210,6 +216,37 @@ 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)
|
||||||
|
}
|
||||||
|
val teeResp = KeyMintSecurityLevelInterceptor.findTeeResponseByKeyId(
|
||||||
|
callingUid, descriptor.nspace
|
||||||
|
)
|
||||||
|
if (teeResp != null) {
|
||||||
|
SystemLogger.info(
|
||||||
|
"[TX_ID: $txId] Found TEE response via KEY_ID nspace=${descriptor.nspace}"
|
||||||
|
)
|
||||||
|
return InterceptorUtils.createTypedObjectReply(teeResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
return TransactionResult.ContinueAndSkipPost
|
return TransactionResult.ContinueAndSkipPost
|
||||||
}
|
}
|
||||||
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
||||||
@@ -256,8 +293,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
reply: Parcel?,
|
reply: Parcel?,
|
||||||
resultCode: Int,
|
resultCode: Int,
|
||||||
): TransactionResult {
|
): TransactionResult {
|
||||||
if (target != keystoreService || reply == null || InterceptorUtils.hasException(reply))
|
if (target != keystoreService || reply == null) return TransactionResult.SkipTransaction
|
||||||
return TransactionResult.SkipTransaction
|
if (InterceptorUtils.hasException(reply)) {
|
||||||
|
val normalized = InterceptorUtils.normalizeServiceSpecificReply(reply)
|
||||||
|
return if (normalized != null) TransactionResult.OverrideReply(normalized)
|
||||||
|
else TransactionResult.SkipTransaction
|
||||||
|
}
|
||||||
|
|
||||||
if (code == GET_NUMBER_OF_ENTRIES_TRANSACTION) {
|
if (code == GET_NUMBER_OF_ENTRIES_TRANSACTION) {
|
||||||
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
|
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
|
||||||
@@ -387,9 +428,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,
|
||||||
@@ -399,6 +455,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)
|
||||||
|
|||||||
+125
-10
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+372
-89
@@ -36,6 +36,7 @@ import org.matrix.TEESimulator.config.ConfigurationManager
|
|||||||
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
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.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
|
||||||
@@ -193,7 +194,8 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
SystemLogger.info("Found new IKeystoreOperation. Registering interceptor...")
|
SystemLogger.info("Found new IKeystoreOperation. Registering interceptor...")
|
||||||
val backdoor = getBackdoor(target)
|
val backdoor = getBackdoor(target)
|
||||||
if (backdoor != null) {
|
if (backdoor != null) {
|
||||||
val interceptor = OperationInterceptor(operation, backdoor)
|
val isAead = parsedParams.blockMode.firstOrNull() == BlockMode.GCM
|
||||||
|
val interceptor = OperationInterceptor(operation, backdoor, isAead)
|
||||||
register(backdoor, operationBinder, interceptor, OperationInterceptor.INTERCEPTED_CODES)
|
register(backdoor, operationBinder, interceptor, OperationInterceptor.INTERCEPTED_CODES)
|
||||||
interceptedOperations[operationBinder] = interceptor
|
interceptedOperations[operationBinder] = interceptor
|
||||||
} else {
|
} else {
|
||||||
@@ -209,42 +211,50 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
val metadata: KeyMetadata =
|
val metadata: KeyMetadata =
|
||||||
reply.readTypedObject(KeyMetadata.CREATOR)
|
reply.readTypedObject(KeyMetadata.CREATOR)
|
||||||
?: return TransactionResult.SkipTransaction
|
?: return TransactionResult.SkipTransaction
|
||||||
val originalChain =
|
|
||||||
CertificateHelper.getCertificateChain(metadata)
|
|
||||||
?: return TransactionResult.SkipTransaction
|
|
||||||
if (originalChain.size > 1) {
|
|
||||||
// Read the request parcel to extract keyDescriptor and cert date params.
|
|
||||||
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
|
||||||
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
|
||||||
?: return TransactionResult.SkipTransaction
|
|
||||||
data.readTypedObject(KeyDescriptor.CREATOR) // skip attestationKey
|
|
||||||
val keyParams = data.createTypedArray(KeyParameter.CREATOR)
|
|
||||||
val certNotBefore = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_BEFORE }?.value?.dateTime?.let { Date(it) }
|
|
||||||
val certNotAfter = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_AFTER }?.value?.dateTime?.let { Date(it) }
|
|
||||||
|
|
||||||
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid, certNotBefore, certNotAfter)
|
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||||
|
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
|
?: return TransactionResult.SkipTransaction
|
||||||
|
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||||
|
|
||||||
// Cache the newly patched chain to ensure consistency across subsequent API calls.
|
val originalChain = CertificateHelper.getCertificateChain(metadata)
|
||||||
val key = metadata.key
|
if (originalChain == null || originalChain.size <= 1) {
|
||||||
?: return TransactionResult.SkipTransaction
|
// Cache non-attested responses for KEY_ID getKeyEntry parity.
|
||||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
// Without this, the cached attested path returns in ~1ms while
|
||||||
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
|
// the forwarded non-attested path takes ~1.5ms, and
|
||||||
metadata.authorizations =
|
// TimingSideChannelProbe flags the 1.55x ratio.
|
||||||
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
|
|
||||||
|
|
||||||
// We must clean up cached generated keys before storing the patched chain
|
|
||||||
cleanupKeyData(keyId)
|
cleanupKeyData(keyId)
|
||||||
patchedChains[keyId] = newChain
|
|
||||||
teeResponses[keyId] = KeyEntryResponse().apply {
|
teeResponses[keyId] = KeyEntryResponse().apply {
|
||||||
this.metadata = metadata
|
this.metadata = metadata
|
||||||
iSecurityLevel = original
|
iSecurityLevel = original
|
||||||
}
|
}
|
||||||
SystemLogger.debug(
|
return TransactionResult.SkipTransaction
|
||||||
"Cached patched certificate chain for $keyId. (${key.alias} [${key.domain}, ${key.nspace}])"
|
|
||||||
)
|
|
||||||
|
|
||||||
return InterceptorUtils.createTypedObjectReply(metadata)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.readTypedObject(KeyDescriptor.CREATOR) // skip attestationKey
|
||||||
|
val keyParams = data.createTypedArray(KeyParameter.CREATOR)
|
||||||
|
val certNotBefore = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_BEFORE }?.value?.dateTime?.let { Date(it) }
|
||||||
|
val certNotAfter = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_AFTER }?.value?.dateTime?.let { Date(it) }
|
||||||
|
|
||||||
|
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid, certNotBefore, certNotAfter)
|
||||||
|
|
||||||
|
val key = metadata.key
|
||||||
|
?: return TransactionResult.SkipTransaction
|
||||||
|
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
|
||||||
|
metadata.authorizations =
|
||||||
|
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
|
||||||
|
|
||||||
|
cleanupKeyData(keyId)
|
||||||
|
patchedChains[keyId] = newChain
|
||||||
|
teeResponses[keyId] = KeyEntryResponse().apply {
|
||||||
|
this.metadata = metadata
|
||||||
|
iSecurityLevel = original
|
||||||
|
}
|
||||||
|
SystemLogger.debug(
|
||||||
|
"Cached patched certificate chain for $keyId. (${key.alias} [${key.domain}, ${key.nspace}])"
|
||||||
|
)
|
||||||
|
|
||||||
|
return InterceptorUtils.createTypedObjectReply(metadata)
|
||||||
}
|
}
|
||||||
return TransactionResult.SkipTransaction
|
return TransactionResult.SkipTransaction
|
||||||
}
|
}
|
||||||
@@ -370,7 +380,11 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
)
|
)
|
||||||
} else parsedParams
|
} else parsedParams
|
||||||
|
|
||||||
val opLatency = if (securityLevel == SecurityLevel.STRONGBOX) STRONGBOX_OP_LATENCY_FLOOR_MS else 0L
|
val opLatency = when (securityLevel) {
|
||||||
|
SecurityLevel.STRONGBOX -> STRONGBOX_OP_LATENCY_FLOOR_MS
|
||||||
|
SecurityLevel.TRUSTED_ENVIRONMENT -> TEE_OP_LATENCY_FLOOR_MS
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
val softwareOperation = SoftwareOperation(txId, generatedKeyInfo.keyPair, generatedKeyInfo.secretKey, effectiveParams, opLatency)
|
val softwareOperation = SoftwareOperation(txId, generatedKeyInfo.keyPair, generatedKeyInfo.secretKey, effectiveParams, opLatency)
|
||||||
|
|
||||||
if (keyParams?.usageCountLimit != null) {
|
if (keyParams?.usageCountLimit != null) {
|
||||||
@@ -489,7 +503,8 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
ConfigurationManager.shouldGenerate(callingUid) ||
|
ConfigurationManager.shouldGenerate(callingUid) ||
|
||||||
(ConfigurationManager.shouldPatch(callingUid) && isAttestKeyRequest) ||
|
(ConfigurationManager.shouldPatch(callingUid) && isAttestKeyRequest) ||
|
||||||
(attestationKey != null &&
|
(attestationKey != null &&
|
||||||
isAttestationKey(KeyIdentifier(callingUid, attestationKey.alias)))
|
(attestationKey.alias?.let { isAttestationKey(KeyIdentifier(callingUid, it)) }
|
||||||
|
?: attestationKeys.any { kid -> kid.uid == callingUid && generatedKeys[kid]?.nspace == attestationKey.nspace }))
|
||||||
|
|
||||||
val isAuto = ConfigurationManager.isAutoMode(callingUid)
|
val isAuto = ConfigurationManager.isAutoMode(callingUid)
|
||||||
|
|
||||||
@@ -504,7 +519,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
parsedParams.attestationChallenge != null -> TransactionResult.Continue
|
parsedParams.attestationChallenge != null -> TransactionResult.Continue
|
||||||
else -> {
|
else -> {
|
||||||
cleanupKeyData(keyId)
|
cleanupKeyData(keyId)
|
||||||
TransactionResult.ContinueAndSkipPost
|
TransactionResult.Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,6 +577,41 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
iSecurityLevel = original
|
iSecurityLevel = original
|
||||||
}
|
}
|
||||||
generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams)
|
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) {
|
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
|
||||||
@@ -586,6 +636,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
|
|
||||||
val response = buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor)
|
val response = buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor)
|
||||||
generatedKeys[keyId] = GeneratedKeyInfo(keyData.first, null, keyDescriptor.nspace, response, parsedParams)
|
generatedKeys[keyId] = GeneratedKeyInfo(keyData.first, null, keyDescriptor.nspace, response, parsedParams)
|
||||||
|
Keystore2Interceptor.forgetDeletedKey(keyId)
|
||||||
if (isAttestKeyRequest) attestationKeys.add(keyId)
|
if (isAttestKeyRequest) attestationKeys.add(keyId)
|
||||||
|
|
||||||
if (SystemLogger.isDebugBuild) {
|
if (SystemLogger.isDebugBuild) {
|
||||||
@@ -600,10 +651,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,
|
||||||
@@ -613,6 +682,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
purposes = parsedParams.purpose,
|
purposes = parsedParams.purpose,
|
||||||
digests = parsedParams.digest,
|
digests = parsedParams.digest,
|
||||||
isAttestationKey = isAttestKeyRequest,
|
isAttestationKey = isAttestKeyRequest,
|
||||||
|
metadataBytes = metadataBytesForPersist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,6 +903,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"
|
||||||
@@ -858,56 +983,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)
|
||||||
}
|
}
|
||||||
@@ -916,6 +1042,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()
|
||||||
|
|
||||||
@@ -930,6 +1193,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
private const val TEE_LATENCY_FLOOR_MS = 15L
|
private const val TEE_LATENCY_FLOOR_MS = 15L
|
||||||
private const val STRONGBOX_KEYGEN_LATENCY_FLOOR_MS = 250L
|
private const val STRONGBOX_KEYGEN_LATENCY_FLOOR_MS = 250L
|
||||||
private const val STRONGBOX_OP_LATENCY_FLOOR_MS = 80L
|
private const val STRONGBOX_OP_LATENCY_FLOOR_MS = 80L
|
||||||
|
private const val TEE_OP_LATENCY_FLOOR_MS = 4L
|
||||||
private const val KEYMINT_TOO_MANY_OPERATIONS = -29
|
private const val KEYMINT_TOO_MANY_OPERATIONS = -29
|
||||||
private const val KEYMINT_CANNOT_ATTEST_IDS = -66
|
private const val KEYMINT_CANNOT_ATTEST_IDS = -66
|
||||||
private const val KEYMINT_UNKNOWN_ERROR = -1000
|
private const val KEYMINT_UNKNOWN_ERROR = -1000
|
||||||
@@ -988,6 +1252,14 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
?.value
|
?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun findTeeResponseByKeyId(callingUid: Int, nspace: Long?): KeyEntryResponse? {
|
||||||
|
if (nspace == null || nspace == 0L) return null
|
||||||
|
return teeResponses.entries
|
||||||
|
.filter { (keyId, _) -> keyId.uid == callingUid }
|
||||||
|
.find { (_, response) -> response.metadata?.key?.nspace == nspace }
|
||||||
|
?.value
|
||||||
|
}
|
||||||
|
|
||||||
fun getPatchedChain(keyId: KeyIdentifier): Array<Certificate>? = patchedChains[keyId]
|
fun getPatchedChain(keyId: KeyIdentifier): Array<Certificate>? = patchedChains[keyId]
|
||||||
|
|
||||||
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
||||||
@@ -1116,36 +1388,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
|
||||||
this.securityLevel = SecurityLevel.SOFTWARE
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -13,6 +13,7 @@ import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
|
|||||||
class OperationInterceptor(
|
class OperationInterceptor(
|
||||||
private val original: IKeystoreOperation,
|
private val original: IKeystoreOperation,
|
||||||
private val backdoor: IBinder,
|
private val backdoor: IBinder,
|
||||||
|
private val isAead: Boolean,
|
||||||
) : BinderInterceptor() {
|
) : BinderInterceptor() {
|
||||||
|
|
||||||
override fun onPreTransact(
|
override fun onPreTransact(
|
||||||
@@ -27,6 +28,10 @@ class OperationInterceptor(
|
|||||||
val methodName = transactionNames[code] ?: "unknown code=$code"
|
val methodName = transactionNames[code] ?: "unknown code=$code"
|
||||||
logTransaction(txId, methodName, callingUid, callingPid, true)
|
logTransaction(txId, methodName, callingUid, callingPid, true)
|
||||||
|
|
||||||
|
if (code == UPDATE_AAD_TRANSACTION && !isAead) {
|
||||||
|
return InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
|
||||||
|
}
|
||||||
|
|
||||||
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
|
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
|
||||||
KeyMintSecurityLevelInterceptor.removeOperationInterceptor(target, backdoor)
|
KeyMintSecurityLevelInterceptor.removeOperationInterceptor(target, backdoor)
|
||||||
}
|
}
|
||||||
@@ -44,7 +49,8 @@ class OperationInterceptor(
|
|||||||
private val ABORT_TRANSACTION =
|
private val ABORT_TRANSACTION =
|
||||||
InterceptorUtils.getTransactCode(IKeystoreOperation.Stub::class.java, "abort")
|
InterceptorUtils.getTransactCode(IKeystoreOperation.Stub::class.java, "abort")
|
||||||
|
|
||||||
val INTERCEPTED_CODES = intArrayOf(FINISH_TRANSACTION, ABORT_TRANSACTION)
|
val INTERCEPTED_CODES =
|
||||||
|
intArrayOf(UPDATE_AAD_TRANSACTION, FINISH_TRANSACTION, ABORT_TRANSACTION)
|
||||||
|
|
||||||
private val transactionNames: Map<Int, String> by lazy {
|
private val transactionNames: Map<Int, String> by lazy {
|
||||||
IKeystoreOperation.Stub::class
|
IKeystoreOperation.Stub::class
|
||||||
|
|||||||
+71
-8
@@ -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,
|
||||||
@@ -254,10 +301,18 @@ class SoftwareOperation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateAad(aadInput: ByteArray?) {
|
fun updateAad(aadInput: ByteArray?) {
|
||||||
SystemLogger.debug("[SoftwareOp TX_ID: $txId] updateAad() inputSize=${aadInput?.size ?: 0}")
|
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() ENTRY inputSize=${aadInput?.size ?: 0} primitive=${primitive::class.simpleName}")
|
||||||
checkActive()
|
checkActive()
|
||||||
checkInputLength(aadInput)
|
checkInputLength(aadInput)
|
||||||
primitive.updateAad(aadInput)
|
try {
|
||||||
|
primitive.updateAad(aadInput)
|
||||||
|
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() RETURNED_NORMALLY (unexpected for non-AEAD)")
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
val top = throwable.stackTrace.firstOrNull()?.toString() ?: "<no-frame>"
|
||||||
|
val code = (throwable as? ServiceSpecificException)?.errorCode
|
||||||
|
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() THREW class=${throwable::class.java.name} code=$code msg=${throwable.message} top=$top")
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(data: ByteArray?): ByteArray? {
|
fun update(data: ByteArray?): ByteArray? {
|
||||||
@@ -387,7 +442,15 @@ class SoftwareOperationBinder(private val operation: SoftwareOperation) :
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun updateAad(aadInput: ByteArray?) {
|
override fun updateAad(aadInput: ByteArray?) {
|
||||||
operation.updateAad(aadInput)
|
SystemLogger.info("[SoftwareOpBinder] updateAad() ENTRY callingUid=${android.os.Binder.getCallingUid()} size=${aadInput?.size ?: 0}")
|
||||||
|
try {
|
||||||
|
operation.updateAad(aadInput)
|
||||||
|
SystemLogger.info("[SoftwareOpBinder] updateAad() RETURNED_NORMALLY")
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
val code = (throwable as? ServiceSpecificException)?.errorCode
|
||||||
|
SystemLogger.info("[SoftwareOpBinder] updateAad() PROPAGATING class=${throwable::class.java.name} code=$code msg=${throwable.message}")
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.matrix.TEESimulator.util
|
package org.matrix.TEESimulator.util
|
||||||
|
|
||||||
import android.hardware.security.keymint.SecurityLevel
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemProperties
|
import android.os.SystemProperties
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -164,6 +163,24 @@ object AndroidDeviceUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun setProperty(name: String, value: String) {
|
||||||
|
try {
|
||||||
|
SystemLogger.debug("Setting system property '$name' to: $value")
|
||||||
|
val command = arrayOf("resetprop", name, value)
|
||||||
|
val process = Runtime.getRuntime().exec(command)
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
|
||||||
|
if (exitCode != 0) {
|
||||||
|
val errorOutput = process.errorStream.bufferedReader().readText()
|
||||||
|
SystemLogger.error(
|
||||||
|
"resetprop for '$name' failed with exit code $exitCode: $errorOutput"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SystemLogger.error("Failed to set '$name' property via resetprop.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateRandomBytes(size: Int): ByteArray =
|
private fun generateRandomBytes(size: Int): ByteArray =
|
||||||
ByteArray(size).also { ThreadLocalRandom.current().nextBytes(it) }
|
ByteArray(size).also { ThreadLocalRandom.current().nextBytes(it) }
|
||||||
|
|
||||||
@@ -328,7 +345,9 @@ object AndroidDeviceUtils {
|
|||||||
6 -> { // YYYYMM
|
6 -> { // YYYYMM
|
||||||
val year = normalized.substring(0, 4).toInt()
|
val year = normalized.substring(0, 4).toInt()
|
||||||
val month = normalized.substring(4, 6).toInt()
|
val month = normalized.substring(4, 6).toInt()
|
||||||
if (isLong) year * 10000 + month * 100 + 1 else year * 100 + month
|
// Synthesizing day=01 from YYYY-MM disagrees with real device bulletins;
|
||||||
|
// propagate null so callers fall back to a YYYY-MM-DD source.
|
||||||
|
if (isLong) null else year * 100 + month
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -376,17 +395,15 @@ object AndroidDeviceUtils {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the attestation version based on security level and OS version. StrongBox (level 2)
|
* Retrieves the attestation version for the given security level. The value follows the device
|
||||||
* requires version 300.
|
* OS: cached attestation data wins, then attestVersionMap[SDK_INT], then 400 as last resort.
|
||||||
|
* A static StrongBox=300 floor would force a major-version mismatch with the TEE chain on
|
||||||
|
* Android 16 devices that report keymaster 400 across both security levels.
|
||||||
*
|
*
|
||||||
* @param securityLevel The security level of the attestation (1 for TEE, 2 for StrongBox).
|
* @param securityLevel The security level of the attestation (1 for TEE, 2 for StrongBox).
|
||||||
* @return The appropriate attestation version number.
|
* @return The appropriate attestation version number.
|
||||||
*/
|
*/
|
||||||
fun getAttestVersion(securityLevel: Int): Int {
|
fun getAttestVersion(securityLevel: Int): Int {
|
||||||
// StrongBox security level requires an attestation version of at least 300.
|
|
||||||
if (securityLevel == SecurityLevel.STRONGBOX) {
|
|
||||||
return 300
|
|
||||||
}
|
|
||||||
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
|
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
|
||||||
val version = cached
|
val version = cached
|
||||||
?: attestVersionMap[Build.VERSION.SDK_INT]
|
?: attestVersionMap[Build.VERSION.SDK_INT]
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ if [ ! -f "$CONFIG_DIR/target.txt" ]; then
|
|||||||
install_file "target.txt" "$CONFIG_DIR"
|
install_file "target.txt" "$CONFIG_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_DIR/security_patch.txt" ]; then
|
||||||
|
ui_print "- Adding default security patch config (mirror device props)"
|
||||||
|
printf '%s\n' \
|
||||||
|
'# TEESimulator default: mirror live device props.' \
|
||||||
|
'# system=prop reads ro.build.version.security_patch at cert-gen time;' \
|
||||||
|
'# boot and vendor are auto-forced to prop too (ConfigurationManager.kt:253-256).' \
|
||||||
|
'# Override with explicit YYYY-MM-DD dates if you want active spoofing.' \
|
||||||
|
'system=prop' > "$CONFIG_DIR/security_patch.txt"
|
||||||
|
chmod 644 "$CONFIG_DIR/security_patch.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
rm -f "$CONFIG_DIR/tee_status.txt"
|
rm -f "$CONFIG_DIR/tee_status.txt"
|
||||||
|
|
||||||
if [ ! -f "$CONFIG_DIR/hbk" ]; then
|
if [ ! -f "$CONFIG_DIR/hbk" ]; then
|
||||||
|
|||||||
@@ -1,2 +1,16 @@
|
|||||||
allow keystore {adb_data_file shell_data_file} file *
|
allow keystore {adb_data_file shell_data_file} file *
|
||||||
allow crash_dump keystore process *
|
allow crash_dump keystore process *
|
||||||
|
|
||||||
|
allow ksu self:tcp_socket { create connect read write getopt setopt }
|
||||||
|
allow ksu node:tcp_socket node_bind
|
||||||
|
allow ksu port:tcp_socket name_connect
|
||||||
|
allow magisk self:tcp_socket { create connect read write getopt setopt }
|
||||||
|
allow magisk node:tcp_socket node_bind
|
||||||
|
allow magisk port:tcp_socket name_connect
|
||||||
|
|
||||||
|
allow ksu self:udp_socket { create connect read write getopt setopt }
|
||||||
|
allow ksu node:udp_socket node_bind
|
||||||
|
allow ksu port:udp_socket name_connect
|
||||||
|
allow magisk self:udp_socket { create connect read write getopt setopt }
|
||||||
|
allow magisk node:udp_socket node_bind
|
||||||
|
allow magisk port:udp_socket name_connect
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ done
|
|||||||
rm -rf "$CONFIG_DIR/persistent_keys"
|
rm -rf "$CONFIG_DIR/persistent_keys"
|
||||||
rm -f "$CONFIG_DIR/tee_status.txt"
|
rm -f "$CONFIG_DIR/tee_status.txt"
|
||||||
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
|
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
|
||||||
|
rm -f "$CONFIG_DIR/security_patch.txt" "$CONFIG_DIR/security_patch.txt.next" "$CONFIG_DIR/last_bulletin_fetch.json"
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "v6.0.0",
|
"version": "v6.0.0-211",
|
||||||
"versionCode": 155,
|
"versionCode": 211,
|
||||||
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/latest/download/TEESimulator-RS-Release.zip",
|
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.0-211/TEESimulator-RS-v6.0.0-211-Release.zip",
|
||||||
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
# ./scripts/package.sh --rust --release # build Rust crate first, then release
|
# ./scripts/package.sh --rust --release # build Rust crate first, then release
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Gradle's buildRustCertgen resolves `cargo` against the daemon's inherited PATH,
|
||||||
|
# not the env we inject via gradle's Exec.environment(). Prepend the per-user
|
||||||
|
# rustup install so non-login shells (CI, IDE-launched terminals, fresh tmux)
|
||||||
|
# still find it without sourcing /etc/profile.d/cargo-path.sh.
|
||||||
|
[ -d "$HOME/.cargo/bin" ] && PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
OUT_DIR="$PROJECT_ROOT/out"
|
OUT_DIR="$PROJECT_ROOT/out"
|
||||||
|
|||||||
Reference in New Issue
Block a user