Compare commits
70
Commits
v5.1.1-164
...
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 | ||
|
|
18724cf40d | ||
|
|
1b7800345d | ||
|
|
54c12a9fd5 | ||
|
|
1bc47840d5 | ||
|
|
c8fadb07ae | ||
|
|
c0b14eeeb1 | ||
|
|
47ab0225e1 | ||
|
|
ebb6336281 | ||
|
|
784373c8b5 | ||
|
|
2241bfb13d | ||
|
|
954478b89b | ||
|
|
191085087b | ||
|
|
f870598e77 |
@@ -149,3 +149,21 @@ jobs:
|
||||
env:
|
||||
VER: ${{ steps.ver.outputs.version }}
|
||||
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 }}
|
||||
|
||||
@@ -1,259 +1,139 @@
|
||||
<p align="center">
|
||||
<h1 align="center">🔐 TEESimulator</h1>
|
||||
<h1 align="center">TEESimulator-RS</h1>
|
||||
<p align="center"><b>Full TEE Emulation for Rooted Android</b></p>
|
||||
<p align="center">Hardware attestation. Software keys. Zero detection.</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Enginex0/TEESimulator/actions/workflows/build.yml"><img src="https://github.com/Enginex0/TEESimulator/actions/workflows/build.yml/badge.svg" alt="Build"></a>
|
||||
<img src="https://img.shields.io/badge/version-v4.2-blue?style=for-the-badge" alt="v4.2">
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-green?style=for-the-badge&logo=android" alt="Android 10+">
|
||||
<img src="https://img.shields.io/badge/Telegram-community-blue?style=for-the-badge&logo=telegram" alt="Telegram">
|
||||
<a href="https://github.com/Enginex0/TEESimulator-RS/actions/workflows/build.yml"><img src="https://github.com/Enginex0/TEESimulator-RS/actions/workflows/build.yml/badge.svg" alt="Build"></a>
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-green?logo=android" alt="Android 10+">
|
||||
<a href="https://t.me/superpowers9"><img src="https://img.shields.io/badge/Telegram-community-blue?logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **This is a personal fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator)** with additional hardening, native Rust certificate generation, key persistence, and anti-detection features. For the upstream project, see the original repo.
|
||||
> Fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator) with native Rust certificate generation, key persistence, and AOSP-compliant attestation behavior. For the upstream project, see the original repo.
|
||||
|
||||
---
|
||||
## What It Does
|
||||
|
||||
## 🧬 What is TEESimulator?
|
||||
TEESimulator intercepts Binder IPC at the `ioctl` level inside the `keystore2` process and generates entire certificate chains from scratch, signed by your keybox, with correct attestation extensions. Apps that verify hardware attestation see a legitimate device.
|
||||
|
||||
TEESimulator is a **complete software simulation** of Android's hardware-backed [Trusted Execution Environment](https://source.android.com/docs/security/features/trusty) for [Key Attestation](https://developer.android.com/privacy-and-security/security-key-attestation). Instead of patching certificates from the real TEE after the fact, TEESimulator intercepts Binder IPC at the `ioctl` level and generates entire certificate chains from scratch — signed by your keybox, with correct attestation extensions, indistinguishable from hardware-generated keys.
|
||||
This is not TrickyStore. TEESimulator replaces TrickyStore and its forks entirely. It shares the same config paths for drop-in compatibility, but the internals are different: native Rust cert generation, binder-level interception via `lsplt`, per-UID rate limiting, key persistence, and AOSP-spec attestation behavior.
|
||||
|
||||
The result: **apps that verify hardware attestation see a legitimate, unmodified device** — even on rooted hardware with an unlocked bootloader.
|
||||
|
||||
> **This is not TrickyStore.** TEESimulator replaces TrickyStore and its forks entirely. It shares the same config paths for drop-in compatibility, but the architecture is fundamentally different: native Rust certificate generation, binder-level interception via `lsplt`, per-UID rate limiting, key persistence, and a multi-layer defense against detector apps.
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Why TEESimulator?
|
||||
|
||||
🔐 **Native Cert Generation** — v4.0 generates X.509 certificate chains in Rust with `ring` and manual DER encoding. No BouncyCastle overhead, no Java crypto quirks, byte-perfect issuer chain linkage.
|
||||
|
||||
🎯 **Binder-Level Interception** — Hooks `ioctl()` on `libc.so` via `lsplt` inside the `keystore2` process. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions before the HAL ever sees them.
|
||||
|
||||
🛡️ **Detector Resistant** — Per-UID rate limiting blocks DuckDetector-style keygen flooding. Oversized challenges rejected with real KeyMint error codes. Chain consistency verified byte-for-byte.
|
||||
|
||||
💾 **Key Persistence** — Generated keys survive reboots. Apps that store attestation keys (banking, biometrics) don't break after a restart.
|
||||
|
||||
🔧 **Drop-In Replacement** — Same config paths as TrickyStore (`/data/adb/tricky_store/`). Swap the module ZIP, keep your keybox and target list.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
**Core Attestation Engine**
|
||||
- [x] **Full certificate chain generation** — leaf + intermediates + root, signed by your keybox
|
||||
- [x] **Native Rust certgen** — `libcertgen.so` built with `ring`, `rsa`, and manual DER assembly
|
||||
- [x] **BouncyCastle fallback** — unsupported curves (P-224, P-521, Curve25519) fall back to Java
|
||||
- [x] **ASN.1 attestation extensions** — OID 1.3.6.1.4.1.11129.2.1.17 with all AOSP-specified tags
|
||||
- [x] **Multi-keybox support** — different keybox files per app group via `target.txt`
|
||||
|
||||
**Interception Layer**
|
||||
- [x] **Binder ioctl hook** — `lsplt` PLT hook on `libc.so` inside `keystore2` process
|
||||
- [x] **generateKey / importKey / getKeyEntry** — all three transaction types intercepted
|
||||
- [x] **256KB native payload cap** — oversized binder payloads bypass interception cleanly
|
||||
- [x] **Challenge validation** — rejects >128-byte attestation challenges with `INVALID_INPUT_LENGTH`
|
||||
|
||||
**Hardening**
|
||||
- [x] **Per-UID rate limiter** — 2 hardware keygens per 30s burst window, software fallback on overflow
|
||||
- [x] **importKey eviction guard** — retained patch chains prevent generate-then-import cache attacks
|
||||
- [x] **Key persistence** — file-backed storage with file-level locking, survives reboots and keybox rotations
|
||||
- [x] **Global exception handler** — uncaught exceptions logged, daemon stays alive
|
||||
|
||||
**Configuration**
|
||||
- [x] **Live config reload** — `FileObserver` watches all config files, changes apply immediately
|
||||
- [x] **Security patch spoofing** — per-package `system`, `vendor`, `boot` patch levels with dynamic templates
|
||||
- [x] **Lifecycle scripts** — KSU Action button clears key cache, uninstall removes all traces
|
||||
|
||||
---
|
||||
|
||||
## 📋 Requirements
|
||||
## Requirements
|
||||
|
||||
> [!IMPORTANT]
|
||||
> TEESimulator requires root access and a valid `keybox.xml` for hardware-level attestation results. Without a keybox, the module generates software-level certificates that won't pass strict hardware attestation checks.
|
||||
> A valid `keybox.xml` is required for hardware-level attestation. Without one, the module generates software-level certificates that won't pass strict hardware checks.
|
||||
|
||||
**You need:**
|
||||
1. Android 10 or above
|
||||
2. A supported root manager (KernelSU, Magisk, or APatch)
|
||||
3. A hardware-backed `keybox.xml` placed at `/data/adb/tricky_store/keybox.xml`
|
||||
1. Android 10+
|
||||
2. Root manager: KernelSU, Magisk, or APatch
|
||||
3. `keybox.xml` at `/data/adb/tricky_store/keybox.xml`
|
||||
|
||||
---
|
||||
## Quick Start
|
||||
|
||||
## 📱 Compatibility
|
||||
1. Download the latest ZIP from [Releases](https://github.com/Enginex0/TEESimulator-RS/releases)
|
||||
2. Install via your root manager and reboot
|
||||
3. Place your keybox at `/data/adb/tricky_store/keybox.xml`
|
||||
4. Configure targets in `/data/adb/tricky_store/target.txt`
|
||||
5. Verify with Play Integrity or Key Attestation Demo
|
||||
|
||||
### Root Managers
|
||||
## Architecture
|
||||
|
||||
| Manager | Status | Notes |
|
||||
|---|---|---|
|
||||
| KernelSU | ✅ Tested | Full support including Action button and lifecycle scripts |
|
||||
| Magisk | ✅ Supported | Standard module install |
|
||||
| APatch | ✅ Supported | Standard module install |
|
||||
**Native Cert Generation** — `libcertgen.so` generates X.509 chains in Rust using `ring` and manual DER encoding. BouncyCastle fallback for unsupported curves (P-224, P-521, Curve25519).
|
||||
|
||||
### Tested Devices
|
||||
**Binder Interception** — PLT hook on `ioctl()` in `libc.so` via `lsplt` inside `keystore2`. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions.
|
||||
|
||||
| Device | Android | TEE | Status |
|
||||
|---|---|---|---|
|
||||
| Redmi 14C (2409BRN2CA) | 14 (SDK 34) | Beanpod KeyMaster | ✅ Daily driver |
|
||||
**AOSP Compliance** — Self-signed certs for non-attested keys (matching `ta/src/keys.rs`), correct AuthorizationList tag ordering, version-guarded extension fields, `authorize_create` enforcement.
|
||||
|
||||
> Tested against DuckDetector, Luna, Play Integrity, and Key Attestation Demo. If you test on a different device, [open an issue](https://github.com/Enginex0/TEESimulator/issues) with your results.
|
||||
**Key Persistence** — Generated keys survive reboots. File-backed with file-level locking.
|
||||
|
||||
---
|
||||
**Rate Limiting** — Per-UID hardware keygen cap (2/30s window, 2 concurrent). Overflow falls to software certs.
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Configuration
|
||||
|
||||
1. **Download** the latest release ZIP from [Releases](https://github.com/Enginex0/TEESimulator/releases)
|
||||
2. **Install** via your root manager (KSU / Magisk / APatch) and reboot
|
||||
3. **Place your keybox** at `/data/adb/tricky_store/keybox.xml`
|
||||
4. **Configure targets** in `/data/adb/tricky_store/target.txt`
|
||||
5. **Verify** — check Play Integrity or run Key Attestation Demo
|
||||
All config files live at `/data/adb/tricky_store/` and are hot-reloaded via `FileObserver`.
|
||||
|
||||
TEESimulator replaces TrickyStore, TrickyStoreOSS, and their forks. Existing config files are compatible.
|
||||
### target.txt
|
||||
|
||||
---
|
||||
Controls which apps get intercepted and the simulation mode.
|
||||
|
||||
## 🔨 Building from Source
|
||||
| Suffix | Mode |
|
||||
|--------|------|
|
||||
| `!` | Force software key generation |
|
||||
| `?` | Force leaf certificate patching (real TEE key, patched cert) |
|
||||
| *(none)* | Automatic selection |
|
||||
|
||||
The CI workflow builds on every push to `main`. You can also build locally or trigger a build from your own fork.
|
||||
|
||||
**Prerequisites:** JDK 21, Android SDK/NDK 27, Rust stable with `aarch64-linux-android` target, `cargo-ndk`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Enginex0/TEESimulator.git
|
||||
cd TEESimulator
|
||||
./gradlew zipRelease zipDebug
|
||||
```
|
||||
|
||||
Output ZIPs land in `out/`. The Gradle build automatically invokes `cargo ndk` to cross-compile `libcertgen.so` before packaging.
|
||||
|
||||
To rebuild from a fork, push to `main` or use **Actions → Build → Run workflow**. The workflow installs all toolchains (Java, Rust, cargo-ndk, ccache) and uploads Release + Debug ZIPs as artifacts.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
All configuration files live at `/data/adb/tricky_store/` and are monitored by `FileObserver` — changes take effect immediately without rebooting.
|
||||
|
||||
### The `keybox.xml` Root of Trust
|
||||
|
||||
This file provides the master cryptographic identity. It contains a private key and a hardware-backed certificate chain from a real device. TEESimulator signs all generated certificates with this key, making them appear legitimate to verifiers.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<AndroidAttestation>
|
||||
<Keybox DeviceID="...">
|
||||
<Key algorithm="ecdsa|rsa">
|
||||
<PrivateKey format="pem">...</PrivateKey>
|
||||
<CertificateChain>...</CertificateChain>
|
||||
</Key>
|
||||
</Keybox>
|
||||
</AndroidAttestation>
|
||||
```
|
||||
|
||||
### Target Packages (`target.txt`)
|
||||
|
||||
Controls which apps get intercepted and what simulation mode to use.
|
||||
|
||||
#### Mode Suffixes
|
||||
|
||||
* **`!` → Force Generation** — Creates a complete software-based virtual key. Full TEE simulation.
|
||||
* **`?` → Force Leaf Hacking** — Real TEE key generated, but its attestation certificate is intercepted and patched.
|
||||
* **No symbol → Automatic** — Module selects the best mode for your device.
|
||||
|
||||
#### Multi-Keybox
|
||||
|
||||
Specify different keybox files for different app groups. Apps listed after a `[filename.xml]` line use that keybox. Apps before any declaration use the default `keybox.xml`.
|
||||
Multi-keybox support via `[filename.xml]` headers:
|
||||
|
||||
```
|
||||
# Default keybox
|
||||
com.google.android.gms!
|
||||
io.github.vvb2060.keyattestation?
|
||||
|
||||
# Switch to a different keybox for the following apps
|
||||
[aosp_keybox.xml]
|
||||
com.google.android.gsf
|
||||
|
||||
# Another keybox
|
||||
[demo_keybox.xml]
|
||||
org.matrix.demo
|
||||
```
|
||||
|
||||
### Security Patch Level (`security_patch.txt`)
|
||||
### security_patch.txt
|
||||
|
||||
Configure the `osPatchLevel`, `vendorPatchLevel`, and `bootPatchLevel` reported in attestation certificates. This only affects attestation data — it does not change actual system properties.
|
||||
|
||||
#### Global and Per-Package
|
||||
|
||||
Settings at the top of the file are global defaults. Add `[package.name]` to override for specific apps.
|
||||
|
||||
#### Keys
|
||||
Override patch levels reported in attestation certificates. Global defaults at top, per-package overrides with `[package.name]`.
|
||||
|
||||
| Key | Scope |
|
||||
|---|---|
|
||||
|-----|-------|
|
||||
| `system` | OS patch level |
|
||||
| `vendor` | Vendor patch level |
|
||||
| `boot` | Boot/kernel patch level |
|
||||
| `all` | Shorthand — sets all three at once |
|
||||
| `all` | Sets all three |
|
||||
|
||||
#### Special Keywords
|
||||
|
||||
| Keyword | Effect |
|
||||
|---|---|
|
||||
| `today` | Current date, dynamically resolved on each attestation |
|
||||
| `YYYY-MM-DD` templates | Semi-dynamic — `YYYY-MM-05` resolves to the 5th of the current month |
|
||||
| `no` | Omit this patch level tag entirely from the attestation |
|
||||
| `device_default` | Use the device's real hardware value |
|
||||
| `prop` | Read from `ro.build.version.security_patch` (matches what detectors see via getprop) |
|
||||
|
||||
#### Example
|
||||
Special values: `today`, `YYYY-MM-DD` templates, `no` (omit tag), `device_default`, `prop` (read from system property).
|
||||
|
||||
```
|
||||
# Global — default for all apps
|
||||
system=YYYY-MM-05
|
||||
vendor=device_default
|
||||
boot=no
|
||||
|
||||
# Override for GMS
|
||||
[com.google.android.gms]
|
||||
system=2024-10-01
|
||||
|
||||
# Custom config for a demo app
|
||||
[org.matrix.demo]
|
||||
all=2025-09-15
|
||||
boot=device_default
|
||||
system=2025-10-01
|
||||
```
|
||||
|
||||
---
|
||||
## Building from Source
|
||||
|
||||
## 💬 Community
|
||||
Prerequisites: JDK 21, Android SDK/NDK 27, Rust stable with `aarch64-linux-android` target, `cargo-ndk`.
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/Enginex0/TEESimulator-RS.git
|
||||
cd TEESimulator-RS
|
||||
./gradlew zipRelease zipDebug
|
||||
```
|
||||
|
||||
Output ZIPs in `out/`. Gradle invokes `cargo ndk` automatically to cross-compile `libcertgen.so`.
|
||||
|
||||
Push to `main` or use **Actions > Build > Run workflow** to trigger CI.
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Root Manager | Status |
|
||||
|---|---|
|
||||
| KernelSU | Tested (Action button + lifecycle scripts) |
|
||||
| Magisk | Supported |
|
||||
| APatch | Supported |
|
||||
|
||||
## Community
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/superpowers9">
|
||||
<img src="https://img.shields.io/badge/⚡_JOIN_THE_GRID-SuperPowers_Telegram-black?style=for-the-badge&logo=telegram&logoColor=cyan&labelColor=0d1117&color=00d4ff" alt="Telegram">
|
||||
<img src="https://img.shields.io/badge/SuperPowers_Telegram-Join-blue?style=for-the-badge&logo=telegram" alt="Telegram">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
## Credits
|
||||
|
||||
## 🙏 Credits
|
||||
- [JingMatrix](https://github.com/JingMatrix/TEESimulator) — original TEESimulator and interception architecture
|
||||
- [5ec1cff](https://github.com/5ec1cff/TrickyStore) — TrickyStore, the project that pioneered keystore interception
|
||||
- [LSPlt](https://github.com/LSPosed/LSPlt) — PLT hook library
|
||||
- [ring](https://github.com/briansmith/ring) — Rust cryptography library
|
||||
- [MhmRdd](https://github.com/MhmRdd) — AOSP compliance work via upstream [PR #157](https://github.com/JingMatrix/TEESimulator/pull/157)
|
||||
- [fatalcoder524](https://github.com/fatalcoder524) — contributor and collaborator
|
||||
- [huguangares](https://github.com/huguangares) — collaborator and tester
|
||||
|
||||
- **[JingMatrix](https://github.com/JingMatrix/TEESimulator)** — original author of TEESimulator and the interception architecture
|
||||
- **[5ec1cff](https://github.com/5ec1cff/TrickyStore)** — TrickyStore, the project that pioneered keystore interception on Android
|
||||
- **[LSPlt](https://github.com/LSPosed/LSPlt)** — PLT hook library used for binder interception
|
||||
- **[ring](https://github.com/briansmith/ring)** — Rust cryptography library powering native cert generation
|
||||
- **[MhmRdd](https://github.com/MhmRdd)** — AOSP compliance improvements via upstream [PR #157](https://github.com/JingMatrix/TEESimulator/pull/157), including authorize_create enforcement, attestation extension alignment, and binder transaction filtering
|
||||
- **[fatalcoder524](https://github.com/fatalcoder524)** — a real contributor and collaborator on this project
|
||||
- **[huguangares](https://github.com/huguangares)** — collaborator and tester
|
||||
## License
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [GNU General Public License v3.0](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>🔐 Because the best attestation is the one the TEE never generated.</b>
|
||||
</p>
|
||||
[GNU General Public License v3.0](LICENSE)
|
||||
|
||||
+38
-1
@@ -2,6 +2,7 @@ import com.android.build.api.artifact.SingleArtifact
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -29,7 +30,7 @@ val gitExecutor = objects.newInstance(GitExecutor::class.java)
|
||||
|
||||
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
|
||||
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
|
||||
val verName = "v5.0"
|
||||
val verName = "v6.0.0"
|
||||
|
||||
android {
|
||||
namespace = "org.matrix.TEESimulator"
|
||||
@@ -65,6 +66,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":stub"))
|
||||
compileOnly(libs.annotation)
|
||||
@@ -91,6 +98,7 @@ val buildRustCertgen by tasks.registering(Exec::class) {
|
||||
outputs.dir(rootProject.projectDir.resolve("app/src/main/jniLibs"))
|
||||
|
||||
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
|
||||
@@ -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 {
|
||||
onVariants(selector().all()) { variant ->
|
||||
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
|
||||
@@ -124,6 +160,7 @@ androidComponents {
|
||||
dependsOn("strip${capitalized}DebugSymbols")
|
||||
}
|
||||
dependsOn(buildRustCertgen)
|
||||
dependsOn(refreshUpdateJson)
|
||||
|
||||
if (isDebug) {
|
||||
from(variant.artifacts.get(SingleArtifact.APK)) {
|
||||
|
||||
@@ -350,16 +350,10 @@ static sp<BinderStub> g_stub_instance = nullptr;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr binder_size_t kMaxInterceptableDataSize = 256 * 1024;
|
||||
|
||||
void inspectAndRewriteTransaction(binder_transaction_data *txn_data) {
|
||||
if (!txn_data || txn_data->target.ptr == 0)
|
||||
return;
|
||||
|
||||
// Bypass interception for oversized payloads to prevent thread starvation from flood attacks
|
||||
if (txn_data->data_size > kMaxInterceptableDataSize)
|
||||
return;
|
||||
|
||||
// AIDL methods use codes in [FIRST_CALL_TRANSACTION, LAST_CALL_TRANSACTION] (1..0x00ffffff).
|
||||
// System transactions (PING, INTERFACE, DUMP, SHELL_COMMAND) use codes above that range.
|
||||
// Skip those — intercepting a ping adds measurable latency that timing detectors flag.
|
||||
@@ -434,44 +428,29 @@ void processBinderReadBuffer(const binder_write_read &bwr) {
|
||||
uintptr_t ptr = bwr.read_buffer;
|
||||
uintptr_t end = ptr + bwr.read_consumed;
|
||||
|
||||
LOGV("[Hook] Processing Read Buffer: Size=%llu, Consumed=%llu", bwr.read_size, bwr.read_consumed);
|
||||
|
||||
while (ptr < end) {
|
||||
// Ensure we can read at least the command header
|
||||
if (end - ptr < sizeof(uint32_t))
|
||||
break;
|
||||
|
||||
uint32_t cmd = *reinterpret_cast<const uint32_t *>(ptr);
|
||||
ptr += sizeof(uint32_t);
|
||||
|
||||
// Calculate payload size from the ioctl command code
|
||||
size_t cmd_size = _IOC_SIZE(cmd);
|
||||
|
||||
// Log the command using our generated to-string function
|
||||
LOGV("[Driver -> User] Command: %s (0x%x), DataSize: %zu", getBinderReturnCommandName(cmd), cmd, cmd_size);
|
||||
|
||||
// Safety check: ensure the command's data does not exceed the buffer
|
||||
if (ptr + cmd_size > end) {
|
||||
LOGE("[Hook] Buffer overflow detected while parsing command %s", getBinderReturnCommandName(cmd));
|
||||
LOGE("[Hook] Buffer overrun parsing command 0x%x", cmd);
|
||||
break;
|
||||
}
|
||||
|
||||
// We are primarily interested in BR_TRANSACTION commands to intercept
|
||||
if (cmd == BR_TRANSACTION || cmd == BR_TRANSACTION_SEC_CTX) {
|
||||
binder_transaction_data *txn = nullptr;
|
||||
|
||||
if (__builtin_expect(cmd == BR_TRANSACTION || cmd == BR_TRANSACTION_SEC_CTX, 0)) {
|
||||
binder_transaction_data *txn;
|
||||
if (cmd == BR_TRANSACTION_SEC_CTX) {
|
||||
// The data is wrapped in a secctx struct
|
||||
auto *wrapper = reinterpret_cast<binder_transaction_data_secctx *>(ptr);
|
||||
txn = &wrapper->transaction_data;
|
||||
txn = &reinterpret_cast<binder_transaction_data_secctx *>(ptr)->transaction_data;
|
||||
} else {
|
||||
txn = reinterpret_cast<binder_transaction_data *>(ptr);
|
||||
}
|
||||
|
||||
inspectAndRewriteTransaction(txn);
|
||||
}
|
||||
|
||||
// Advance pointer to the next command
|
||||
ptr += cmd_size;
|
||||
}
|
||||
}
|
||||
@@ -491,13 +470,17 @@ int intercepted_ioctl(int fd, int request, ...) {
|
||||
// 1. Call original kernel ioctl to let the driver do its work
|
||||
int result = g_original_ioctl(fd, request, arg);
|
||||
|
||||
// 2. After the call returns, check if it was a BINDER_WRITE_READ and if it succeeded
|
||||
if (result >= 0 && request == BINDER_WRITE_READ && arg != nullptr) {
|
||||
const auto *bwr = static_cast<const binder_write_read *>(arg);
|
||||
|
||||
// We only care about data read FROM the driver (i.e., incoming commands)
|
||||
if (bwr->read_consumed > 0) {
|
||||
processBinderReadBuffer(*bwr);
|
||||
// Fast reject: only enter the parser if the buffer could contain a BR_TRANSACTION.
|
||||
// Pings, ref ops, and looper management never produce BR_TRANSACTION, so scanning
|
||||
// their buffers is pure overhead (~2-5us per ioctl in debug builds).
|
||||
if (bwr->read_consumed >= sizeof(uint32_t)) {
|
||||
uint32_t first_cmd = *reinterpret_cast<const uint32_t *>(bwr->read_buffer);
|
||||
if (first_cmd == BR_TRANSACTION || first_cmd == BR_TRANSACTION_SEC_CTX
|
||||
|| bwr->read_consumed > sizeof(uint32_t) + _IOC_SIZE(first_cmd)) {
|
||||
processBinderReadBuffer(*bwr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import android.os.Build
|
||||
import android.os.Looper
|
||||
import java.security.Security
|
||||
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.PatchLevelManager
|
||||
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
|
||||
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
||||
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
|
||||
@@ -39,11 +42,18 @@ object App {
|
||||
|
||||
try {
|
||||
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.
|
||||
ConfigurationManager.initialize()
|
||||
|
||||
// Initialize and start the appropriate keystore interceptors.
|
||||
initializeInterceptors()
|
||||
|
||||
// Set up the device's boot key and hash, which are crucial for attestation.
|
||||
AndroidDeviceUtils.setupBootKeyAndHash()
|
||||
|
||||
@@ -55,6 +65,12 @@ object App {
|
||||
|
||||
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
|
||||
// processing messages until Looper.myLooper().quit() is called.
|
||||
Looper.loop()
|
||||
|
||||
@@ -43,11 +43,12 @@ object AttestationBuilder {
|
||||
securityLevel: Int,
|
||||
): Extension {
|
||||
val keyDescription = buildKeyDescription(params, uid, securityLevel)
|
||||
var formattedString =
|
||||
keyDescription.joinToString(separator = ", ") {
|
||||
SystemLogger.verbose {
|
||||
val formattedString = keyDescription.joinToString(separator = ", ") {
|
||||
AttestationPatcher.formatAsn1Primitive(it)
|
||||
}
|
||||
SystemLogger.verbose("Forged attestation data: ${formattedString}")
|
||||
"Forged attestation data: $formattedString"
|
||||
}
|
||||
return Extension(ATTESTATION_OID, false, DEROctetString(keyDescription.encoded))
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.matrix.TEESimulator.logging.SystemLogger
|
||||
import org.matrix.TEESimulator.pki.KeyBox
|
||||
import org.matrix.TEESimulator.pki.KeyBoxManager
|
||||
import org.matrix.TEESimulator.util.toHex
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Handles the modification (patching) of Android Key Attestation extensions within certificates.
|
||||
@@ -36,7 +37,12 @@ object AttestationPatcher {
|
||||
* @return A new, cryptographically valid, patched certificate chain. Returns the original chain
|
||||
* on any failure.
|
||||
*/
|
||||
fun patchCertificateChain(originalChain: Array<Certificate>?, uid: Int): Array<Certificate> {
|
||||
fun patchCertificateChain(
|
||||
originalChain: Array<Certificate>?,
|
||||
uid: Int,
|
||||
notBefore: Date? = null,
|
||||
notAfter: Date? = null,
|
||||
): Array<Certificate> {
|
||||
if (originalChain.isNullOrEmpty()) {
|
||||
SystemLogger.error("Attempted to patch a null or empty certificate chain for UID $uid.")
|
||||
return originalChain ?: emptyArray()
|
||||
@@ -63,6 +69,8 @@ object AttestationPatcher {
|
||||
keybox,
|
||||
originalLeaf.sigAlgName,
|
||||
uid,
|
||||
notBefore,
|
||||
notAfter,
|
||||
)
|
||||
|
||||
// 4. Construct the NEW, VALID chain by prepending the patched leaf to the keybox's
|
||||
@@ -111,17 +119,27 @@ object AttestationPatcher {
|
||||
keybox: KeyBox,
|
||||
sigAlgName: String,
|
||||
uid: Int,
|
||||
notBefore: Date? = null,
|
||||
notAfter: Date? = null,
|
||||
): Certificate {
|
||||
// The issuer of our new leaf is the subject of the first certificate in our custom keybox
|
||||
// chain.
|
||||
val newIssuer = X509CertificateHolder(keybox.certificates[0].encoded).subject
|
||||
|
||||
val effectiveNotBefore = notBefore ?: originalLeafHolder.notBefore
|
||||
val effectiveNotAfter = notAfter ?: originalLeafHolder.notAfter
|
||||
if (notBefore != null || notAfter != null) {
|
||||
SystemLogger.debug(
|
||||
"Overriding cert dates: notBefore=${effectiveNotBefore} (was ${originalLeafHolder.notBefore}), notAfter=${effectiveNotAfter} (was ${originalLeafHolder.notAfter})"
|
||||
)
|
||||
}
|
||||
|
||||
val builder =
|
||||
X509v3CertificateBuilder(
|
||||
newIssuer,
|
||||
originalLeafHolder.serialNumber,
|
||||
originalLeafHolder.notBefore,
|
||||
originalLeafHolder.notAfter,
|
||||
effectiveNotBefore,
|
||||
effectiveNotAfter,
|
||||
originalLeafHolder.subject,
|
||||
originalLeafHolder.subjectPublicKeyInfo,
|
||||
)
|
||||
@@ -146,7 +164,7 @@ object AttestationPatcher {
|
||||
// Log the signature of the newly created certificate to observe its non-deterministic
|
||||
// nature.
|
||||
val signatureBytes = (newCertificate as X509Certificate).signature
|
||||
SystemLogger.verbose("Signature of patched leaf cert: ${signatureBytes.toHex()}")
|
||||
SystemLogger.verbose { "Signature of patched leaf cert: ${signatureBytes.toHex()}" }
|
||||
|
||||
return newCertificate
|
||||
}
|
||||
@@ -268,8 +286,10 @@ object AttestationPatcher {
|
||||
private fun createPatchedAttestationExtension(parsed: ParsedAttestation, uid: Int): Extension {
|
||||
val (allFields, teeEnforcedMap, originalRootOfTrust) = parsed
|
||||
|
||||
var formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
|
||||
SystemLogger.verbose("Original attestation data: ${formattedString}")
|
||||
SystemLogger.verbose {
|
||||
val formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
|
||||
"Original attestation data: $formattedString"
|
||||
}
|
||||
|
||||
// Build the new Root of Trust and add/replace it in the map.
|
||||
val newRootOfTrust = AttestationBuilder.buildRootOfTrust(originalRootOfTrust)
|
||||
@@ -296,8 +316,10 @@ object AttestationPatcher {
|
||||
|
||||
allFields[AttestationConstants.KEY_DESCRIPTION_TEE_ENFORCED_INDEX] = sortedTeeEnforced
|
||||
val patchedSequence = DERSequence(allFields)
|
||||
formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
|
||||
SystemLogger.verbose("Patched attestation data: ${formattedString}")
|
||||
SystemLogger.verbose {
|
||||
val formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
|
||||
"Patched attestation data: $formattedString"
|
||||
}
|
||||
val patchedOctets = DEROctetString(patchedSequence)
|
||||
|
||||
return Extension(ATTESTATION_OID, false, patchedOctets)
|
||||
|
||||
@@ -148,11 +148,12 @@ object DeviceAttestationService {
|
||||
|
||||
// The extension's value is an ASN.1 sequence.
|
||||
val keyDescriptionSeq = ASN1Sequence.getInstance(extension.extnValue.octets)
|
||||
var formattedString =
|
||||
keyDescriptionSeq.joinToString(separator = ", ") {
|
||||
SystemLogger.verbose {
|
||||
val formattedString = keyDescriptionSeq.joinToString(separator = ", ") {
|
||||
AttestationPatcher.formatAsn1Primitive(it)
|
||||
}
|
||||
SystemLogger.verbose("Cached attestation data: ${formattedString}")
|
||||
"Cached attestation data: $formattedString"
|
||||
}
|
||||
val fields = keyDescriptionSeq.toArray()
|
||||
|
||||
val attestVersion =
|
||||
|
||||
@@ -46,6 +46,7 @@ data class KeyMintAttestation(
|
||||
val usageExpireDateTime: Date?,
|
||||
val usageCountLimit: Int?,
|
||||
val callerNonce: Boolean?,
|
||||
val nonce: ByteArray?,
|
||||
val unlockedDeviceRequired: Boolean?,
|
||||
val includeUniqueId: Boolean?,
|
||||
val rollbackResistance: Boolean?,
|
||||
@@ -121,6 +122,7 @@ data class KeyMintAttestation(
|
||||
usageExpireDateTime = params.findDate(Tag.USAGE_EXPIRE_DATETIME),
|
||||
usageCountLimit = params.findInteger(Tag.USAGE_COUNT_LIMIT),
|
||||
callerNonce = params.findBoolean(Tag.CALLER_NONCE),
|
||||
nonce = params.findBlob(Tag.NONCE),
|
||||
unlockedDeviceRequired = params.findBoolean(Tag.UNLOCKED_DEVICE_REQUIRED),
|
||||
includeUniqueId = params.findBoolean(Tag.INCLUDE_UNIQUE_ID),
|
||||
rollbackResistance = params.findBoolean(Tag.ROLLBACK_RESISTANCE),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.os.IBinder
|
||||
import android.os.ServiceManager
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import org.matrix.TEESimulator.attestation.DeviceAttestationService
|
||||
import org.matrix.TEESimulator.logging.SystemLogger
|
||||
import org.matrix.TEESimulator.pki.KeyBoxManager
|
||||
|
||||
@@ -92,7 +93,16 @@ object ConfigurationManager {
|
||||
|
||||
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? {
|
||||
val packages = getPackagesForUid(uid)
|
||||
@@ -102,7 +112,7 @@ object ConfigurationManager {
|
||||
when (packageModes[pkg]) {
|
||||
Mode.GENERATE -> return Mode.GENERATE
|
||||
Mode.PATCH -> return Mode.PATCH
|
||||
Mode.AUTO -> return Mode.AUTO
|
||||
Mode.AUTO -> return if (DeviceAttestationService.isTeeFunctional) Mode.PATCH else Mode.GENERATE
|
||||
null -> continue
|
||||
}
|
||||
}
|
||||
@@ -164,7 +174,6 @@ object ConfigurationManager {
|
||||
newModes[pkg] = Mode.PATCH
|
||||
newKeyboxes[pkg] = currentKeybox
|
||||
}
|
||||
// No suffix means AUTO mode.
|
||||
else -> {
|
||||
newModes[trimmedLine] = Mode.AUTO
|
||||
newKeyboxes[trimmedLine] = currentKeybox
|
||||
@@ -297,10 +306,15 @@ object ConfigurationManager {
|
||||
)
|
||||
KeyBoxManager.invalidateCache(path)
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
|
||||
// Clear cached keys possibly containing old certificates
|
||||
// Drop only the patched cert chains so the next
|
||||
// attestation request re-signs with the new keybox.
|
||||
// Do NOT drop generatedKeys — that would destroy
|
||||
// every alias/private key in memory and on disk,
|
||||
// logging users out of any app that pinned a
|
||||
// persisted keystore alias.
|
||||
org.matrix.TEESimulator.interception.keystore.shim
|
||||
.KeyMintSecurityLevelInterceptor
|
||||
.clearAllGeneratedKeys("updating $file")
|
||||
.invalidatePatchedChains("updating $file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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 {
|
||||
val parcel = Parcel.obtain().apply {
|
||||
writeInt(EX_SERVICE_SPECIFIC)
|
||||
writeString(null)
|
||||
writeString(synthesizeSseMessage(errorCode))
|
||||
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
|
||||
writeInt(errorCode)
|
||||
}
|
||||
@@ -132,12 +149,25 @@ object InterceptorUtils {
|
||||
|
||||
fun createServiceSpecificErrorReply(
|
||||
errorCode: Int
|
||||
): BinderInterceptor.TransactionResult.OverrideReply {
|
||||
val parcel =
|
||||
Parcel.obtain().apply {
|
||||
writeException(android.os.ServiceSpecificException(errorCode))
|
||||
}
|
||||
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
|
||||
): BinderInterceptor.TransactionResult.OverrideReply = createErrorReply(errorCode)
|
||||
|
||||
fun normalizeServiceSpecificReply(reply: Parcel): Parcel? {
|
||||
reply.setDataPosition(0)
|
||||
if (reply.readInt() != EX_SERVICE_SPECIFIC) {
|
||||
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(
|
||||
|
||||
+75
-3
@@ -62,6 +62,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
|
||||
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 processName = "keystore2"
|
||||
override val injectionCommand = "exec ./inject `pidof keystore2` libTEESimulator.so entry"
|
||||
@@ -210,6 +216,37 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
}
|
||||
|
||||
if (descriptor.alias == null) {
|
||||
if (descriptor.domain == Domain.KEY_ID) {
|
||||
// The probe pipeline (and some AOSP callers) switch follow-up
|
||||
// operations to KEY_ID semantics after generateKey returns a
|
||||
// KEY_ID descriptor. Without this branch, our software keys
|
||||
// are invisible to KEY_ID-based getKeyEntry calls and the
|
||||
// request falls through to the real keystore2 daemon, which
|
||||
// legitimately responds with KEY_NOT_FOUND. Duck Detector's
|
||||
// TimingSideChannelProbe captures that exception during its
|
||||
// warmup phase and surfaces it as
|
||||
// "Captured private binder exception during timing skip".
|
||||
// Resolving by KEY_ID and returning the cached response keeps
|
||||
// the call on the happy path, eliminating the warmup signal.
|
||||
val info = KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
|
||||
callingUid, descriptor.nspace
|
||||
)
|
||||
if (info?.response != null) {
|
||||
SystemLogger.info(
|
||||
"[TX_ID: $txId] Found generated response via KEY_ID nspace=${descriptor.nspace}"
|
||||
)
|
||||
return InterceptorUtils.createTypedObjectReply(info.response)
|
||||
}
|
||||
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
|
||||
}
|
||||
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
||||
@@ -256,8 +293,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
reply: Parcel?,
|
||||
resultCode: Int,
|
||||
): TransactionResult {
|
||||
if (target != keystoreService || reply == null || InterceptorUtils.hasException(reply))
|
||||
return TransactionResult.SkipTransaction
|
||||
if (target != keystoreService || reply == null) 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) {
|
||||
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
|
||||
@@ -314,6 +355,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
|
||||
if (userUpdatedKeys.remove(keyId)) {
|
||||
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: userUpdated=true, skipping patch" }
|
||||
SystemLogger.debug("[TX_ID: $txId] Skipping cert patch for user-updated key $keyId.")
|
||||
return TransactionResult.SkipTransaction
|
||||
}
|
||||
@@ -324,18 +366,28 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray()
|
||||
)
|
||||
|
||||
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: isImport=${parsedParameters.isImportKey()} origin=${parsedParameters.origin} inImportedKeys=${KeyMintSecurityLevelInterceptor.importedKeys.contains(keyId)} hasPatchedChain=${KeyMintSecurityLevelInterceptor.getPatchedChain(keyId) != null} isAttestKey=${parsedParameters.isAttestKey()}" }
|
||||
|
||||
if (parsedParameters.isImportKey()) {
|
||||
val retainedChain = KeyMintSecurityLevelInterceptor.getPatchedChain(keyId)
|
||||
if (retainedChain == null) {
|
||||
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: imported, no retained chain, skip" }
|
||||
SystemLogger.info("[TX_ID: $txId] Skip patching for imported key (no prior attestation).")
|
||||
return TransactionResult.SkipTransaction
|
||||
}
|
||||
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: imported, SERVING RETAINED CHAIN (detection vector!)" }
|
||||
SystemLogger.info("[TX_ID: $txId] Imported key overwrote attested alias, serving retained chain for $keyId")
|
||||
CertificateHelper.updateCertificateChain(response.metadata, retainedChain).getOrThrow()
|
||||
response.metadata.authorizations =
|
||||
InterceptorUtils.patchAuthorizations(
|
||||
response.metadata.authorizations,
|
||||
callingUid,
|
||||
)
|
||||
return InterceptorUtils.createTypedObjectReply(response)
|
||||
}
|
||||
|
||||
if (KeyMintSecurityLevelInterceptor.importedKeys.contains(keyId)) {
|
||||
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: in importedKeys set, skip" }
|
||||
SystemLogger.debug("[TX_ID: $txId] Skipping attest-key override for imported key $keyId")
|
||||
return TransactionResult.SkipTransaction
|
||||
}
|
||||
@@ -376,9 +428,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
)
|
||||
KeyMintSecurityLevelInterceptor.attestationKeys.add(keyId)
|
||||
|
||||
// Snapshot metadata bytes for the same reason as the
|
||||
// primary doSoftwareKeyGen path — loss-less restore
|
||||
// after reboot.
|
||||
val metadataBytesForPersist = response.metadata?.let { md ->
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
md.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = keyData.first,
|
||||
secretKey = null,
|
||||
nspace = newNspace,
|
||||
securityLevel = response.metadata.keySecurityLevel,
|
||||
certChain = keyData.second,
|
||||
@@ -388,6 +455,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
purposes = parsedParameters.purpose,
|
||||
digests = parsedParameters.digest,
|
||||
isAttestationKey = true,
|
||||
metadataBytes = metadataBytesForPersist,
|
||||
)
|
||||
|
||||
return InterceptorUtils.createTypedObjectReply(response)
|
||||
@@ -459,7 +527,11 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
||||
}
|
||||
|
||||
if (generatedKeyInfo == null) {
|
||||
descriptor.alias?.let { userUpdatedKeys.add(KeyIdentifier(callingUid, it)) }
|
||||
descriptor.alias?.let {
|
||||
val kid = KeyIdentifier(callingUid, it)
|
||||
userUpdatedKeys.add(kid)
|
||||
SystemLogger.trace { "[TRACE] updateSubcomponent $kid: not generated key, added to userUpdatedKeys" }
|
||||
}
|
||||
return TransactionResult.ContinueAndSkipPost
|
||||
}
|
||||
|
||||
|
||||
@@ -436,6 +436,7 @@ private data class LegacyKeygenParameters(
|
||||
usageExpireDateTime = null,
|
||||
usageCountLimit = null,
|
||||
callerNonce = null,
|
||||
nonce = null,
|
||||
unlockedDeviceRequired = null,
|
||||
includeUniqueId = null,
|
||||
rollbackResistance = null,
|
||||
|
||||
-2
@@ -29,8 +29,6 @@ object AuthorizeCreate {
|
||||
) {
|
||||
return KeystoreErrorCodes.unsupportedPurpose
|
||||
}
|
||||
if (algo == Algorithm.EC && purpose == KeyPurpose.DECRYPT)
|
||||
return KeystoreErrorCodes.unsupportedPurpose
|
||||
if (algo == Algorithm.RSA && purpose == KeyPurpose.AGREE_KEY)
|
||||
return KeystoreErrorCodes.unsupportedPurpose
|
||||
return null
|
||||
|
||||
+125
-10
@@ -29,13 +29,47 @@ data class PersistedKeyData(
|
||||
val ecCurve: Int,
|
||||
val purposes: List<Int>,
|
||||
val digests: List<Int>,
|
||||
/** PKCS#8-encoded private key for asymmetric records, empty for symmetric. */
|
||||
val privateKeyBytes: ByteArray,
|
||||
val certChainBytes: List<ByteArray>,
|
||||
/**
|
||||
* Byte-identical KeyMetadata parcel snapshot. Restoring authorizations
|
||||
* directly from these bytes preserves tag count, order, and exact
|
||||
* security-level annotations across reboots — the kind of structural
|
||||
* details apps fingerprint to decide whether the alias is still
|
||||
* "the same key".
|
||||
*/
|
||||
val metadataBytes: ByteArray,
|
||||
/**
|
||||
* Raw secret material for symmetric records (AES, HMAC, 3DES). Empty
|
||||
* for asymmetric. Critical for AndroidX security crypto MasterKey
|
||||
* (AES-GCM-256) — without this every reboot regenerates a fresh AES
|
||||
* key and EncryptedSharedPreferences becomes undecryptable, which is
|
||||
* what banking apps interpret as session expiry and force a relogin.
|
||||
*/
|
||||
val symmetricKeyBytes: ByteArray,
|
||||
val symmetricAlgorithm: String,
|
||||
)
|
||||
|
||||
object GeneratedKeyPersistence {
|
||||
|
||||
private const val FORMAT_VERSION = 1
|
||||
/**
|
||||
* Single source of truth for the on-disk format. Bump this every time
|
||||
* the layout changes; older numbers are silently skipped on read so
|
||||
* stale dev artifacts and pre-fix upstream files can't be partially
|
||||
* rehydrated into broken in-memory state.
|
||||
*
|
||||
* History:
|
||||
* 1 — original upstream layout (no metadata snapshot, no symmetric
|
||||
* block; restored keys lose authorization tags and AES master
|
||||
* keys altogether — apps relying on persisted keystore state
|
||||
* across reboots get logged out)
|
||||
* 2 — transitional dev-only format that added metadata but still
|
||||
* missed the symmetric block; never shipped
|
||||
* 3 — current: byte-identical KeyMetadata snapshot + raw symmetric
|
||||
* key material so AES/HMAC keys survive reboots
|
||||
*/
|
||||
private const val FORMAT_VERSION = 3
|
||||
private val PERSISTENCE_DIR = File(CONFIG_PATH, "persistent_keys")
|
||||
|
||||
// Per-filename locks to prevent concurrent writes to the same key file
|
||||
@@ -47,7 +81,8 @@ object GeneratedKeyPersistence {
|
||||
|
||||
fun save(
|
||||
keyId: KeyIdentifier,
|
||||
keyPair: KeyPair,
|
||||
keyPair: KeyPair?,
|
||||
secretKey: javax.crypto.SecretKey?,
|
||||
nspace: Long,
|
||||
securityLevel: Int,
|
||||
certChain: List<Certificate>,
|
||||
@@ -57,7 +92,11 @@ object GeneratedKeyPersistence {
|
||||
purposes: List<Int>,
|
||||
digests: List<Int>,
|
||||
isAttestationKey: Boolean,
|
||||
metadataBytes: ByteArray? = null,
|
||||
) {
|
||||
require(keyPair != null || secretKey != null) {
|
||||
"Either keyPair or secretKey must be provided"
|
||||
}
|
||||
val filename = keyFileName(keyId.uid, keyId.alias)
|
||||
val lock = getLockForKey(filename)
|
||||
SystemLogger.debug("[Persistence] Acquiring lock for $filename")
|
||||
@@ -87,7 +126,8 @@ object GeneratedKeyPersistence {
|
||||
out.writeInt(digests.size)
|
||||
digests.forEach { out.writeInt(it) }
|
||||
|
||||
val pkBytes = keyPair.private.encoded
|
||||
// Asymmetric key block (empty for symmetric-only).
|
||||
val pkBytes = keyPair?.private?.encoded ?: ByteArray(0)
|
||||
out.writeInt(pkBytes.size)
|
||||
out.write(pkBytes)
|
||||
|
||||
@@ -97,6 +137,23 @@ object GeneratedKeyPersistence {
|
||||
out.writeInt(encoded.size)
|
||||
out.write(encoded)
|
||||
}
|
||||
|
||||
// Metadata snapshot (always present, may be empty
|
||||
// if the live KeyMetadata could not be marshalled).
|
||||
val mdBytes = metadataBytes ?: ByteArray(0)
|
||||
out.writeInt(mdBytes.size)
|
||||
if (mdBytes.isNotEmpty()) out.write(mdBytes)
|
||||
|
||||
// Symmetric key block (empty for asymmetric keys).
|
||||
if (secretKey != null) {
|
||||
val skBytes = secretKey.encoded
|
||||
out.writeUTF(secretKey.algorithm)
|
||||
out.writeInt(skBytes.size)
|
||||
out.write(skBytes)
|
||||
} else {
|
||||
out.writeUTF("")
|
||||
out.writeInt(0)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tmpFile.delete()
|
||||
@@ -189,8 +246,17 @@ object GeneratedKeyPersistence {
|
||||
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != FORMAT_VERSION) {
|
||||
SystemLogger.warning(
|
||||
"Skipping ${file.name}: unknown format version $version"
|
||||
// Old upstream files (v1) and dev-only intermediate
|
||||
// files (v2) are missing the metadata snapshot
|
||||
// and/or symmetric key block — restoring them
|
||||
// would put broken state in memory (apps relying
|
||||
// on those records get logged out). Skip and let
|
||||
// the next generateKey re-create cleanly with the
|
||||
// new format. Affected apps re-login once after
|
||||
// upgrade, then never again.
|
||||
SystemLogger.info(
|
||||
"Skipping ${file.name}: legacy format version $version. " +
|
||||
"It will be replaced on next generateKey for this alias."
|
||||
)
|
||||
return@runCatching
|
||||
}
|
||||
@@ -212,7 +278,7 @@ object GeneratedKeyPersistence {
|
||||
|
||||
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
|
||||
val pkBytes = ByteArray(pkLen)
|
||||
input.readFully(pkBytes)
|
||||
if (pkLen > 0) input.readFully(pkBytes)
|
||||
|
||||
val certCount = requireBounds(input.readInt(), 10, "certCount")
|
||||
val certChainBytes = (0 until certCount).map {
|
||||
@@ -222,6 +288,17 @@ object GeneratedKeyPersistence {
|
||||
certBytes
|
||||
}
|
||||
|
||||
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
|
||||
val metadataBytes = ByteArray(metaLen).also {
|
||||
if (metaLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
val skAlgo = input.readUTF()
|
||||
val skLen = requireBounds(input.readInt(), 8192, "skLen")
|
||||
val skBytes = ByteArray(skLen).also {
|
||||
if (skLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
if (storedSecLevel == securityLevel) {
|
||||
result.add(
|
||||
PersistedKeyData(
|
||||
@@ -237,6 +314,9 @@ object GeneratedKeyPersistence {
|
||||
digests = digests,
|
||||
privateKeyBytes = pkBytes,
|
||||
certChainBytes = certChainBytes,
|
||||
metadataBytes = metadataBytes,
|
||||
symmetricKeyBytes = skBytes,
|
||||
symmetricAlgorithm = skAlgo,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -292,7 +372,7 @@ object GeneratedKeyPersistence {
|
||||
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != FORMAT_VERSION) {
|
||||
SystemLogger.warning("rePersist: unknown format version $version for $keyId")
|
||||
SystemLogger.warning("rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)")
|
||||
return
|
||||
}
|
||||
readPersistedKeyData(input)
|
||||
@@ -303,10 +383,29 @@ object GeneratedKeyPersistence {
|
||||
return
|
||||
}
|
||||
|
||||
val keyPair = generatedKeyInfo.keyPair ?: return
|
||||
val keyPair = generatedKeyInfo.keyPair
|
||||
val secretKey = generatedKeyInfo.secretKey
|
||||
if (keyPair == null && secretKey == null) {
|
||||
SystemLogger.warning("rePersist: no key material for $keyId")
|
||||
return
|
||||
}
|
||||
// Serialize the live KeyMetadata (now contains the user-installed cert
|
||||
// chain via updateSubcomponent) so the next boot restores byte-identical
|
||||
// metadata. KeyMetadata is binder-free, so marshall() is safe here.
|
||||
val metadataBytes = runCatching {
|
||||
android.os.Parcel.obtain().let { parcel ->
|
||||
try {
|
||||
metadata.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
save(
|
||||
keyId = keyId,
|
||||
keyPair = keyPair,
|
||||
secretKey = secretKey,
|
||||
nspace = generatedKeyInfo.nspace,
|
||||
securityLevel = secLevel,
|
||||
certChain = newChain.toList(),
|
||||
@@ -316,6 +415,7 @@ object GeneratedKeyPersistence {
|
||||
purposes = persisted.purposes,
|
||||
digests = persisted.digests,
|
||||
isAttestationKey = persisted.isAttestationKey,
|
||||
metadataBytes = metadataBytes,
|
||||
)
|
||||
SystemLogger.debug("Re-persisted key $keyId with updated cert chain")
|
||||
}
|
||||
@@ -332,7 +432,8 @@ object GeneratedKeyPersistence {
|
||||
return digest.joinToString("") { "%02x".format(it) } + ".bin"
|
||||
}
|
||||
|
||||
// Reads all fields after version has already been consumed
|
||||
// Reads all fields after the version int has already been consumed
|
||||
// and validated by the caller.
|
||||
private fun readPersistedKeyData(input: DataInputStream): PersistedKeyData {
|
||||
val secLevel = input.readInt()
|
||||
val uid = input.readInt()
|
||||
@@ -351,7 +452,7 @@ object GeneratedKeyPersistence {
|
||||
|
||||
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
|
||||
val pkBytes = ByteArray(pkLen)
|
||||
input.readFully(pkBytes)
|
||||
if (pkLen > 0) input.readFully(pkBytes)
|
||||
|
||||
val certCount = requireBounds(input.readInt(), 10, "certCount")
|
||||
val certChainBytes = (0 until certCount).map {
|
||||
@@ -361,6 +462,17 @@ object GeneratedKeyPersistence {
|
||||
certBytes
|
||||
}
|
||||
|
||||
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
|
||||
val metadataBytes = ByteArray(metaLen).also {
|
||||
if (metaLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
val skAlgo = input.readUTF()
|
||||
val skLen = requireBounds(input.readInt(), 8192, "skLen")
|
||||
val skBytes = ByteArray(skLen).also {
|
||||
if (skLen > 0) input.readFully(it)
|
||||
}
|
||||
|
||||
return PersistedKeyData(
|
||||
uid = uid,
|
||||
alias = alias,
|
||||
@@ -374,6 +486,9 @@ object GeneratedKeyPersistence {
|
||||
digests = digests,
|
||||
privateKeyBytes = pkBytes,
|
||||
certChainBytes = certChainBytes,
|
||||
metadataBytes = metadataBytes,
|
||||
symmetricKeyBytes = skBytes,
|
||||
symmetricAlgorithm = skAlgo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+425
-95
@@ -20,11 +20,13 @@ import java.security.SecureRandom
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.locks.LockSupport
|
||||
import org.matrix.TEESimulator.attestation.AttestationBuilder
|
||||
import org.matrix.TEESimulator.attestation.AttestationConstants
|
||||
@@ -34,6 +36,7 @@ import org.matrix.TEESimulator.config.ConfigurationManager
|
||||
import org.matrix.TEESimulator.interception.core.BinderInterceptor
|
||||
import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
|
||||
import org.matrix.TEESimulator.interception.keystore.KeyIdentifier
|
||||
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
|
||||
import org.matrix.TEESimulator.logging.SystemLogger
|
||||
import org.matrix.TEESimulator.pki.CertGenConfig
|
||||
import org.matrix.TEESimulator.pki.CertificateGenerator
|
||||
@@ -57,6 +60,10 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val keyParams: KeyMintAttestation? = null,
|
||||
)
|
||||
|
||||
// null = undecided, true = TEE works (use PATCH), false = TEE broken (use GENERATE)
|
||||
// Instance field so TRUSTED_ENVIRONMENT and STRONGBOX decide independently
|
||||
val teePathDecision = AtomicReference<Boolean?>(null)
|
||||
|
||||
private val activeOps = ConcurrentHashMap<Int, ConcurrentLinkedDeque<SoftwareOperation>>()
|
||||
private val recentOps = ConcurrentHashMap<Int, ConcurrentLinkedDeque<Long>>()
|
||||
|
||||
@@ -136,12 +143,14 @@ class KeyMintSecurityLevelInterceptor(
|
||||
}
|
||||
attestationKeys.remove(keyId)
|
||||
importedKeys.add(keyId)
|
||||
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: added to importedKeys, skipUid=${ConfigurationManager.shouldSkipUid(callingUid)}" }
|
||||
|
||||
if (!ConfigurationManager.shouldSkipUid(callingUid)) {
|
||||
val metadata: KeyMetadata =
|
||||
reply.readTypedObject(KeyMetadata.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val originalChain = CertificateHelper.getCertificateChain(metadata)
|
||||
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: chainSize=${originalChain?.size ?: 0}" }
|
||||
if (originalChain != null && originalChain.size > 1) {
|
||||
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid)
|
||||
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
|
||||
@@ -152,6 +161,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: PATCHED chain (chainSize=${newChain.size})" }
|
||||
SystemLogger.debug("Cached patched certificate chain for imported key $keyId.")
|
||||
return InterceptorUtils.createTypedObjectReply(metadata)
|
||||
}
|
||||
@@ -184,7 +194,8 @@ class KeyMintSecurityLevelInterceptor(
|
||||
SystemLogger.info("Found new IKeystoreOperation. Registering interceptor...")
|
||||
val backdoor = getBackdoor(target)
|
||||
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)
|
||||
interceptedOperations[operationBinder] = interceptor
|
||||
} else {
|
||||
@@ -200,36 +211,50 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val metadata: KeyMetadata =
|
||||
reply.readTypedObject(KeyMetadata.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val originalChain =
|
||||
CertificateHelper.getCertificateChain(metadata)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
if (originalChain.size > 1) {
|
||||
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid)
|
||||
|
||||
// Cache the newly patched chain to ensure consistency across subsequent API calls.
|
||||
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val key = metadata.key
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
|
||||
metadata.authorizations =
|
||||
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
|
||||
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||
?: return TransactionResult.SkipTransaction
|
||||
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
|
||||
|
||||
// We must clean up cached generated keys before storing the patched chain
|
||||
val originalChain = CertificateHelper.getCertificateChain(metadata)
|
||||
if (originalChain == null || originalChain.size <= 1) {
|
||||
// Cache non-attested responses for KEY_ID getKeyEntry parity.
|
||||
// Without this, the cached attested path returns in ~1ms while
|
||||
// the forwarded non-attested path takes ~1.5ms, and
|
||||
// TimingSideChannelProbe flags the 1.55x ratio.
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -348,10 +373,18 @@ class KeyMintSecurityLevelInterceptor(
|
||||
keyParams.copy(
|
||||
purpose = parsedParams.purpose,
|
||||
digest = parsedParams.digest.ifEmpty { keyParams.digest },
|
||||
blockMode = parsedParams.blockMode.ifEmpty { keyParams.blockMode },
|
||||
padding = parsedParams.padding.ifEmpty { keyParams.padding },
|
||||
nonce = parsedParams.nonce,
|
||||
minMacLength = parsedParams.minMacLength ?: keyParams.minMacLength,
|
||||
)
|
||||
} 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)
|
||||
|
||||
if (keyParams?.usageCountLimit != null) {
|
||||
@@ -391,10 +424,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
}
|
||||
|
||||
private fun handleGenerateKey(txId: Long, callingUid: Int, callingPid: Int, data: Parcel): TransactionResult {
|
||||
if (data.dataSize() > MAX_ALIAS_LENGTH) {
|
||||
SystemLogger.warning("Skipping oversized transaction: ${data.dataSize()} bytes")
|
||||
return TransactionResult.ContinueAndSkipPost
|
||||
}
|
||||
val oversized = data.dataSize() > MAX_ALIAS_LENGTH
|
||||
|
||||
return runCatching {
|
||||
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||
@@ -407,6 +437,11 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val params = data.createTypedArray(KeyParameter.CREATOR)!!
|
||||
val parsedParams = KeyMintAttestation(params)
|
||||
|
||||
SystemLogger.trace { "[TRACE-$txId] generateKey alias=${keyDescriptor.alias} algo=${parsedParams.algorithm} challenge=${parsedParams.attestationChallenge?.size ?: "null"} serial=${parsedParams.serial != null} imei=${parsedParams.imei != null} noAuth=${parsedParams.noAuthRequired} purposes=${parsedParams.purpose}" }
|
||||
if (SystemLogger.isDebugBuild) params.forEach { p ->
|
||||
SystemLogger.trace { "[TRACE-$txId] tag=${p.tag} value=${p.value}" }
|
||||
}
|
||||
|
||||
val challenge = parsedParams.attestationChallenge
|
||||
if (challenge != null && challenge.size > AttestationConstants.CHALLENGE_LENGTH_LIMIT) {
|
||||
SystemLogger.warning("[TX_ID: $txId] Rejecting oversized attestation challenge: ${challenge.size} bytes (max ${AttestationConstants.CHALLENGE_LENGTH_LIMIT})")
|
||||
@@ -464,20 +499,27 @@ class KeyMintSecurityLevelInterceptor(
|
||||
val isAttestKeyRequest = parsedParams.isAttestKey()
|
||||
|
||||
val forceGenerate =
|
||||
ConfigurationManager.shouldGenerate(callingUid) ||
|
||||
oversized ||
|
||||
ConfigurationManager.shouldGenerate(callingUid) ||
|
||||
(ConfigurationManager.shouldPatch(callingUid) && isAttestKeyRequest) ||
|
||||
(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)
|
||||
|
||||
if (isAuto) SystemLogger.debug("AUTO dispatch: teePathDecision=${teePathDecision.get()} for ${keyDescriptor.alias}")
|
||||
|
||||
SystemLogger.trace { "[TRACE-$txId] dispatch: forceGen=$forceGenerate isAuto=$isAuto teePath=${teePathDecision.get()} hasChallenge=${challenge != null} isSymmetric=$isSymmetric isAttestKey=$isAttestKeyRequest" }
|
||||
|
||||
when {
|
||||
forceGenerate -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, parsedParams, keyId, isAttestKeyRequest)
|
||||
isAuto && !teeFunctional -> raceTeePatch(callingUid, keyDescriptor, attestationKey, params, parsedParams, keyId, isAttestKeyRequest)
|
||||
isAuto && teePathDecision.get() == null -> raceTeePatch(callingUid, keyDescriptor, attestationKey, params, parsedParams, keyId, isAttestKeyRequest)
|
||||
isAuto && teePathDecision.get() == false -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, parsedParams, keyId, isAttestKeyRequest)
|
||||
parsedParams.attestationChallenge != null -> TransactionResult.Continue
|
||||
else -> {
|
||||
cleanupKeyData(keyId)
|
||||
TransactionResult.ContinueAndSkipPost
|
||||
TransactionResult.Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,6 +577,41 @@ class KeyMintSecurityLevelInterceptor(
|
||||
iSecurityLevel = original
|
||||
}
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams)
|
||||
Keystore2Interceptor.forgetDeletedKey(keyId)
|
||||
|
||||
// Persist symmetric keys too. Without this, AndroidX security
|
||||
// crypto MasterKey (AES-GCM-256) is regenerated on every reboot
|
||||
// and any EncryptedSharedPreferences becomes undecryptable —
|
||||
// which apps that wrap their session token in
|
||||
// EncryptedSharedPreferences interpret as session expiry.
|
||||
// Snapshot the metadata bytes alongside the raw secret
|
||||
// material so authorizations restore byte-identical.
|
||||
val metadataBytesForSymmetric = runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
metadata.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrNull()
|
||||
persistExecutor.execute {
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = null,
|
||||
secretKey = secretKey,
|
||||
nspace = keyDescriptor.nspace,
|
||||
securityLevel = securityLevel,
|
||||
certChain = emptyList(),
|
||||
algorithm = parsedParams.algorithm,
|
||||
keySize = parsedParams.keySize,
|
||||
ecCurve = parsedParams.ecCurve ?: 0,
|
||||
purposes = parsedParams.purpose,
|
||||
digests = parsedParams.digest,
|
||||
isAttestationKey = false,
|
||||
metadataBytes = metadataBytesForSymmetric,
|
||||
)
|
||||
}
|
||||
|
||||
if (securityLevel == SecurityLevel.STRONGBOX) {
|
||||
val delayMs = STRONGBOX_KEYGEN_LATENCY_FLOOR_MS - (System.nanoTime() - genStartNanos) / 1_000_000
|
||||
@@ -559,13 +636,43 @@ class KeyMintSecurityLevelInterceptor(
|
||||
|
||||
val response = buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor)
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(keyData.first, null, keyDescriptor.nspace, response, parsedParams)
|
||||
Keystore2Interceptor.forgetDeletedKey(keyId)
|
||||
if (isAttestKeyRequest) attestationKeys.add(keyId)
|
||||
|
||||
if (SystemLogger.isDebugBuild) {
|
||||
val chain = keyData.second
|
||||
val leaf = chain.firstOrNull() as? java.security.cert.X509Certificate
|
||||
SystemLogger.trace {
|
||||
"[certchain] ${keyDescriptor.alias}: depth=${chain.size} " +
|
||||
"issuer=${leaf?.issuerX500Principal?.name} " +
|
||||
"subject=${leaf?.subjectX500Principal?.name} " +
|
||||
"hasAttest=${leaf?.getExtensionValue("1.3.6.1.4.1.11129.2.1.17") != null}"
|
||||
}
|
||||
}
|
||||
|
||||
val certChainCopy = keyData.second.toList()
|
||||
// Snapshot the freshly built KeyMetadata bytes so loadPersistedKeys
|
||||
// can restore byte-identical authorizations after reboot. Without
|
||||
// this, the rebuild path drops every authorization tag that wasn't
|
||||
// captured into PersistedKeyData primitive fields (origin, block
|
||||
// mode, padding, expiry timestamps...), which broke session pinning
|
||||
// for apps that fingerprint metadata across keystore calls.
|
||||
val metadataBytesForPersist = response.metadata?.let { md ->
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
md.writeToParcel(parcel, 0)
|
||||
parcel.marshall()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
persistExecutor.execute {
|
||||
GeneratedKeyPersistence.save(
|
||||
keyId = keyId,
|
||||
keyPair = keyData.first,
|
||||
secretKey = null,
|
||||
nspace = keyDescriptor.nspace,
|
||||
securityLevel = securityLevel,
|
||||
certChain = certChainCopy,
|
||||
@@ -575,6 +682,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
purposes = parsedParams.purpose,
|
||||
digests = parsedParams.digest,
|
||||
isAttestationKey = isAttestKeyRequest,
|
||||
metadataBytes = metadataBytesForPersist,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -633,12 +741,14 @@ class KeyMintSecurityLevelInterceptor(
|
||||
return try {
|
||||
val teeMetadata = threadA.join()
|
||||
threadB.cancel(true)
|
||||
teeFunctional = true
|
||||
SystemLogger.info("AUTO: TEE succeeded for ${keyDescriptor.alias}, marked functional.")
|
||||
teePathDecision.compareAndSet(null, true)
|
||||
SystemLogger.info("AUTO: TEE succeeded, path locked to PATCH for ${keyDescriptor.alias}")
|
||||
|
||||
val originalChain = CertificateHelper.getCertificateChain(teeMetadata)
|
||||
if (originalChain != null && originalChain.size > 1) {
|
||||
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid)
|
||||
val newChain = AttestationPatcher.patchCertificateChain(
|
||||
originalChain, callingUid, parsedParams.certificateNotBefore, parsedParams.certificateNotAfter
|
||||
)
|
||||
CertificateHelper.updateCertificateChain(teeMetadata, newChain).getOrThrow()
|
||||
teeMetadata.authorizations =
|
||||
InterceptorUtils.patchAuthorizations(teeMetadata.authorizations, callingUid)
|
||||
@@ -653,7 +763,13 @@ class KeyMintSecurityLevelInterceptor(
|
||||
|
||||
InterceptorUtils.createTypedObjectReply(teeMetadata)
|
||||
} catch (_: Exception) {
|
||||
SystemLogger.info("AUTO: TEE failed for ${keyDescriptor.alias}, using software result.")
|
||||
if (teePathDecision.get() == true) {
|
||||
threadB.cancel(true)
|
||||
SystemLogger.info("AUTO: TEE failed locally but globally functional, forwarding for ${keyDescriptor.alias}")
|
||||
return TransactionResult.Continue
|
||||
}
|
||||
teePathDecision.compareAndSet(null, false)
|
||||
SystemLogger.info("AUTO: TEE failed, path locked to GENERATE for ${keyDescriptor.alias}")
|
||||
try {
|
||||
threadB.join()
|
||||
} catch (e: Exception) {
|
||||
@@ -687,7 +803,8 @@ class KeyMintSecurityLevelInterceptor(
|
||||
|
||||
val attestVersion = AndroidDeviceUtils.getAttestVersion(securityLevel)
|
||||
val keymasterVersion = AndroidDeviceUtils.getKeymasterVersion(securityLevel)
|
||||
val appId = AttestationBuilder.createApplicationId(callingUid)
|
||||
val hasChallenge = params.attestationChallenge != null
|
||||
val appId = if (hasChallenge) AttestationBuilder.createApplicationId(callingUid) else null
|
||||
|
||||
val config = CertGenConfig(
|
||||
algorithm = params.algorithm,
|
||||
@@ -713,7 +830,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
bootKey = AndroidDeviceUtils.bootKey,
|
||||
bootHash = AndroidDeviceUtils.bootHash,
|
||||
creationDatetime = System.currentTimeMillis(),
|
||||
attestationApplicationId = appId.octets,
|
||||
attestationApplicationId = appId?.octets ?: ByteArray(0),
|
||||
moduleHash = if (attestVersion >= 400) AndroidDeviceUtils.moduleHash else null,
|
||||
idBrand = params.brand,
|
||||
idDevice = params.device,
|
||||
@@ -786,6 +903,61 @@ class KeyMintSecurityLevelInterceptor(
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
// Symmetric (AES/HMAC/3DES) keys take a separate path:
|
||||
// there is no PKCS8 private key, no certificate chain, just
|
||||
// raw secret material plus the metadata snapshot.
|
||||
val isSymmetric = record.symmetricKeyBytes.isNotEmpty()
|
||||
if (isSymmetric) {
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(
|
||||
record.symmetricKeyBytes,
|
||||
record.symmetricAlgorithm,
|
||||
)
|
||||
val response = if (record.metadataBytes.isNotEmpty()) {
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
|
||||
KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
SystemLogger.warning(
|
||||
"Failed to restore symmetric metadata for ${record.alias}, falling back to primitive rebuild",
|
||||
e,
|
||||
)
|
||||
rebuildSymmetricResponse(record)
|
||||
}
|
||||
} else {
|
||||
// Pre-v3 file with symmetric key — should not happen
|
||||
// because v3 always saves metadata, but be defensive:
|
||||
// rebuild a minimal KeyMetadata from primitives so
|
||||
// the secret material is still restored. Without
|
||||
// this, dropping the record would silently log the
|
||||
// user out the next time the alias is used.
|
||||
SystemLogger.info(
|
||||
"Symmetric record ${record.alias} missing metadata bytes, rebuilding from primitives"
|
||||
)
|
||||
rebuildSymmetricResponse(record)
|
||||
}
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(
|
||||
keyPair = null,
|
||||
secretKey = secretKey,
|
||||
nspace = record.nspace,
|
||||
response = response,
|
||||
keyParams = response.metadata?.let { md ->
|
||||
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
|
||||
},
|
||||
)
|
||||
SystemLogger.debug("Restored symmetric persisted key: $keyId (${record.symmetricAlgorithm}/${record.symmetricKeyBytes.size * 8}bit)")
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
val algorithmName = when (record.algorithm) {
|
||||
Algorithm.EC -> "EC"
|
||||
Algorithm.RSA -> "RSA"
|
||||
@@ -811,55 +983,57 @@ class KeyMintSecurityLevelInterceptor(
|
||||
blob = null
|
||||
}
|
||||
|
||||
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,
|
||||
unlockedDeviceRequired = null,
|
||||
includeUniqueId = null,
|
||||
rollbackResistance = null,
|
||||
earlyBootOnly = null,
|
||||
allowWhileOnBody = null,
|
||||
trustedUserPresenceRequired = null,
|
||||
trustedConfirmationRequired = null,
|
||||
noAuthRequired = null,
|
||||
maxUsesPerBoot = null,
|
||||
maxBootLevel = null,
|
||||
minMacLength = null,
|
||||
rsaOaepMgfDigest = emptyList(),
|
||||
)
|
||||
// Prefer the byte-identical metadata snapshot persisted by v3
|
||||
// saves so apps that fingerprint the metadata (e.g. they
|
||||
// pin algorithm/purpose/digest/origin/authorization order
|
||||
// across reboots) keep their session valid. Fall back to
|
||||
// rebuilding
|
||||
// from primitive fields for v1-era files (which lose
|
||||
// authorization tags that weren't captured then).
|
||||
val response = if (record.metadataBytes.isNotEmpty()) {
|
||||
runCatching {
|
||||
val parcel = android.os.Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
|
||||
// Make sure the descriptor's nspace matches the
|
||||
// KEY_ID we will hand callers. updateSubcomponent
|
||||
// and getKeyEntry both index by nspace.
|
||||
metadata.key = metadata.key ?: KeyDescriptor().apply {
|
||||
domain = Domain.KEY_ID
|
||||
nspace = record.nspace
|
||||
alias = null
|
||||
blob = null
|
||||
}
|
||||
KeyEntryResponse().apply {
|
||||
this.metadata = metadata
|
||||
iSecurityLevel = original
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
SystemLogger.warning(
|
||||
"Failed to restore metadata bytes for $record.alias, falling back to rebuild",
|
||||
e,
|
||||
)
|
||||
rebuildResponseFromRecord(record, certChain, descriptor)
|
||||
}
|
||||
} else {
|
||||
rebuildResponseFromRecord(record, certChain, descriptor)
|
||||
}
|
||||
|
||||
val response = buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
|
||||
generatedKeys[keyId] = GeneratedKeyInfo(keyPair, null, record.nspace, response, attestation)
|
||||
if (record.isAttestationKey) attestationKeys.add(keyId)
|
||||
val keyIdRestored = KeyIdentifier(record.uid, record.alias)
|
||||
generatedKeys[keyIdRestored] = GeneratedKeyInfo(keyPair, null, record.nspace, response, response.metadata?.let { md ->
|
||||
// Re-derive an attestation summary from authorizations so
|
||||
// any code path that reads keyParams (e.g. logging) still
|
||||
// works. This does not feed back into the metadata bytes.
|
||||
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
|
||||
})
|
||||
if (record.isAttestationKey) attestationKeys.add(keyIdRestored)
|
||||
|
||||
SystemLogger.debug("Restored persisted key: $keyId")
|
||||
SystemLogger.debug("Restored persisted key: $keyIdRestored")
|
||||
}.onFailure {
|
||||
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
|
||||
}
|
||||
@@ -868,9 +1042,145 @@ class KeyMintSecurityLevelInterceptor(
|
||||
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 {
|
||||
private val secureRandom = SecureRandom()
|
||||
@Volatile var teeFunctional = false
|
||||
|
||||
// Maximum alias length to prevent binder buffer exhaustion (Issue #109)
|
||||
// Binder buffer is ~1MB; 256KB provides 4x safety margin for transaction overhead
|
||||
@@ -883,6 +1193,7 @@ class KeyMintSecurityLevelInterceptor(
|
||||
private const val TEE_LATENCY_FLOOR_MS = 15L
|
||||
private const val STRONGBOX_KEYGEN_LATENCY_FLOOR_MS = 250L
|
||||
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_CANNOT_ATTEST_IDS = -66
|
||||
private const val KEYMINT_UNKNOWN_ERROR = -1000
|
||||
@@ -941,6 +1252,14 @@ class KeyMintSecurityLevelInterceptor(
|
||||
?.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 isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
||||
@@ -1069,36 +1388,47 @@ private fun KeyMintAttestation.toAuthorizations(
|
||||
authList.add(createAuth(Tag.BOOT_PATCHLEVEL, KeyParameterValue.integer(bootPatch)))
|
||||
}
|
||||
|
||||
fun createSwAuth(tag: Int, value: KeyParameterValue): Authorization {
|
||||
/**
|
||||
* Keystore-enforced authorizations (CREATION_DATETIME, ACTIVE_DATETIME,
|
||||
* USER_ID, etc.) are tagged by real KeyMint HAL with
|
||||
* SecurityLevel.KEYSTORE (= 100, byte 0x64), not SOFTWARE (= 0, byte
|
||||
* 0x00). The previous SOFTWARE value is exactly what Duck Detector's
|
||||
* "TEE Simulator generate-mode fingerprint" probe scans for in the
|
||||
* generateKey reply parcel. Aligning with real hardware here defeats
|
||||
* that probe across every keystore-enforced tag, not just
|
||||
* CREATION_DATETIME's byte-5 window — so probe variants that scan
|
||||
* later offsets are also covered.
|
||||
*/
|
||||
fun createKeystoreAuth(tag: Int, value: KeyParameterValue): Authorization {
|
||||
val param = KeyParameter().apply {
|
||||
this.tag = tag
|
||||
this.value = value
|
||||
}
|
||||
return Authorization().apply {
|
||||
this.keyParameter = param
|
||||
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 {
|
||||
authList.add(createSwAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
authList.add(createKeystoreAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
}
|
||||
this.originationExpireDateTime?.let {
|
||||
authList.add(createSwAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
authList.add(createKeystoreAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
}
|
||||
this.usageExpireDateTime?.let {
|
||||
authList.add(createSwAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
authList.add(createKeystoreAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
|
||||
}
|
||||
this.usageCountLimit?.let {
|
||||
authList.add(createSwAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
|
||||
authList.add(createKeystoreAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
|
||||
}
|
||||
if (this.unlockedDeviceRequired == true) {
|
||||
authList.add(createSwAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
|
||||
authList.add(createKeystoreAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
|
||||
}
|
||||
|
||||
authList.add(createSwAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000)))
|
||||
authList.add(createKeystoreAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000)))
|
||||
|
||||
return authList.toTypedArray()
|
||||
}
|
||||
|
||||
+7
-1
@@ -13,6 +13,7 @@ import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
|
||||
class OperationInterceptor(
|
||||
private val original: IKeystoreOperation,
|
||||
private val backdoor: IBinder,
|
||||
private val isAead: Boolean,
|
||||
) : BinderInterceptor() {
|
||||
|
||||
override fun onPreTransact(
|
||||
@@ -27,6 +28,10 @@ class OperationInterceptor(
|
||||
val methodName = transactionNames[code] ?: "unknown code=$code"
|
||||
logTransaction(txId, methodName, callingUid, callingPid, true)
|
||||
|
||||
if (code == UPDATE_AAD_TRANSACTION && !isAead) {
|
||||
return InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
|
||||
}
|
||||
|
||||
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
|
||||
KeyMintSecurityLevelInterceptor.removeOperationInterceptor(target, backdoor)
|
||||
}
|
||||
@@ -44,7 +49,8 @@ class OperationInterceptor(
|
||||
private val ABORT_TRANSACTION =
|
||||
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 {
|
||||
IKeystoreOperation.Stub::class
|
||||
|
||||
+79
-9
@@ -137,7 +137,14 @@ private class CipherPrimitive(
|
||||
private val isAead = params.blockMode.firstOrNull() == BlockMode.GCM
|
||||
private val cipher: Cipher =
|
||||
Cipher.getInstance(JcaAlgorithmMapper.mapCipherAlgorithm(params)).apply {
|
||||
init(opMode, cryptoKey)
|
||||
val nonce = params.nonce
|
||||
if (nonce != null && isAead) {
|
||||
init(opMode, cryptoKey, javax.crypto.spec.GCMParameterSpec(128, nonce))
|
||||
} else if (nonce != null) {
|
||||
init(opMode, cryptoKey, javax.crypto.spec.IvParameterSpec(nonce))
|
||||
} else {
|
||||
init(opMode, cryptoKey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateAad(aadInput: ByteArray?) {
|
||||
@@ -211,19 +218,66 @@ class SoftwareOperation(
|
||||
val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN"
|
||||
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Initializing for purpose: $purposeName.")
|
||||
|
||||
if (purpose == null) {
|
||||
// Defensive: if params somehow restored without a PURPOSE tag
|
||||
// (corrupt v2 metadata, mismatched authorizations array on load,
|
||||
// or future format drift) the original code crashed with NPE
|
||||
// because Signer/Verifier/Cipher all dereference keyPair!!
|
||||
// before checking purpose. Surface a clean keystore error
|
||||
// instead so callers see a normal-looking operation failure
|
||||
// they can recover from rather than the process appearing to
|
||||
// silently corrupt their session.
|
||||
SystemLogger.warning(
|
||||
"[SoftwareOp TX_ID: $txId] Purpose missing on restored key " +
|
||||
"(authorizations=${params.purpose}, keyPair=${if (keyPair != null) "present" else "null"}, " +
|
||||
"secretKey=${if (secretKey != null) "present" else "null"}). " +
|
||||
"Returning unsupportedPurpose."
|
||||
)
|
||||
throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
"Restored key has no PURPOSE authorization",
|
||||
)
|
||||
}
|
||||
|
||||
primitive =
|
||||
when (purpose) {
|
||||
KeyPurpose.SIGN -> Signer(keyPair!!, params)
|
||||
KeyPurpose.VERIFY -> Verifier(keyPair!!, params)
|
||||
KeyPurpose.SIGN -> {
|
||||
val kp = keyPair ?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.invalidArgument,
|
||||
"[SoftwareOp TX_ID: $txId] SIGN requested but keyPair is null",
|
||||
)
|
||||
Signer(kp, params)
|
||||
}
|
||||
KeyPurpose.VERIFY -> {
|
||||
val kp = keyPair ?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.invalidArgument,
|
||||
"[SoftwareOp TX_ID: $txId] VERIFY requested but keyPair is null",
|
||||
)
|
||||
Verifier(kp, params)
|
||||
}
|
||||
KeyPurpose.ENCRYPT -> {
|
||||
val key: java.security.Key = secretKey ?: keyPair!!.public
|
||||
val key: java.security.Key = secretKey ?: keyPair?.public
|
||||
?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
"[SoftwareOp TX_ID: $txId] ENCRYPT requires either secretKey or keyPair.public",
|
||||
)
|
||||
CipherPrimitive(key, params, Cipher.ENCRYPT_MODE)
|
||||
}
|
||||
KeyPurpose.DECRYPT -> {
|
||||
val key: java.security.Key = secretKey ?: keyPair!!.private
|
||||
val key: java.security.Key = secretKey ?: keyPair?.private
|
||||
?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
"[SoftwareOp TX_ID: $txId] DECRYPT requires either secretKey or keyPair.private",
|
||||
)
|
||||
CipherPrimitive(key, params, Cipher.DECRYPT_MODE)
|
||||
}
|
||||
KeyPurpose.AGREE_KEY -> KeyAgreementPrimitive(keyPair!!)
|
||||
KeyPurpose.AGREE_KEY -> {
|
||||
val kp = keyPair ?: throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.invalidArgument,
|
||||
"[SoftwareOp TX_ID: $txId] AGREE_KEY requested but keyPair is null",
|
||||
)
|
||||
KeyAgreementPrimitive(kp)
|
||||
}
|
||||
else ->
|
||||
throw ServiceSpecificException(
|
||||
KeystoreErrorCodes.unsupportedPurpose,
|
||||
@@ -247,10 +301,18 @@ class SoftwareOperation(
|
||||
}
|
||||
|
||||
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()
|
||||
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? {
|
||||
@@ -380,7 +442,15 @@ class SoftwareOperationBinder(private val operation: SoftwareOperation) :
|
||||
|
||||
@Synchronized
|
||||
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
|
||||
|
||||
@@ -1,42 +1,86 @@
|
||||
package org.matrix.TEESimulator.logging
|
||||
|
||||
import android.util.Log
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import org.matrix.TEESimulator.BuildConfig
|
||||
|
||||
/**
|
||||
* A centralized logging utility for the TEESimulator application. This object provides a consistent
|
||||
* logging tag and format for all application logs, making it easier to filter and debug in Logcat.
|
||||
*
|
||||
* Includes a rate limiter that caps logd syscalls during binder stress to prevent thread pool
|
||||
* contention. The first [RATE_LIMIT_BURST] messages per [RATE_LIMIT_WINDOW_MS] window are logged
|
||||
* normally; subsequent messages are suppressed and a summary is emitted when the window resets.
|
||||
*/
|
||||
object SystemLogger {
|
||||
// The tag used for all log messages from this application.
|
||||
private const val TAG = "TEESimulator"
|
||||
@PublishedApi internal const val TAG = "TEESimulator"
|
||||
|
||||
private val isDebugBuild = BuildConfig.DEBUG
|
||||
@PublishedApi internal val isDebugBuild = BuildConfig.DEBUG
|
||||
|
||||
// Rate limiter: allow BURST messages per WINDOW, then suppress until window resets.
|
||||
private const val RATE_LIMIT_BURST = 15
|
||||
private const val RATE_LIMIT_WINDOW_MS = 1000L
|
||||
private val windowStart = AtomicLong(System.currentTimeMillis())
|
||||
private val windowCount = AtomicInteger(0)
|
||||
private val suppressedCount = AtomicInteger(0)
|
||||
|
||||
/**
|
||||
* Returns true if this message should be emitted. Resets the window if expired
|
||||
* and emits a suppression summary for the previous window.
|
||||
*/
|
||||
@PublishedApi internal fun acquireLogPermit(): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val start = windowStart.get()
|
||||
if (now - start > RATE_LIMIT_WINDOW_MS) {
|
||||
// Window expired: reset and emit suppression summary if needed.
|
||||
if (windowStart.compareAndSet(start, now)) {
|
||||
val suppressed = suppressedCount.getAndSet(0)
|
||||
windowCount.set(1) // this call counts as #1 in the new window
|
||||
if (suppressed > 0) {
|
||||
Log.i(TAG, "[rate-limit] suppressed $suppressed log messages in previous window")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
val count = windowCount.incrementAndGet()
|
||||
if (count <= RATE_LIMIT_BURST) return true
|
||||
suppressedCount.incrementAndGet()
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a debug message. Use this for fine-grained information that is useful for debugging.
|
||||
*
|
||||
* @param message The message to log.
|
||||
*/
|
||||
fun debug(message: String) {
|
||||
if (!isDebugBuild) return
|
||||
if (!acquireLogPermit()) return
|
||||
Log.d(TAG, message)
|
||||
}
|
||||
|
||||
/** Lazy debug: lambda only evaluates if message will be logged. */
|
||||
inline fun debug(message: () -> String) {
|
||||
if (!isDebugBuild) return
|
||||
if (!acquireLogPermit()) return
|
||||
Log.d(TAG, message())
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an informational message. Use this to report major application lifecycle events.
|
||||
*
|
||||
* @param message The message to log.
|
||||
*/
|
||||
fun info(message: String) {
|
||||
if (!acquireLogPermit()) return
|
||||
Log.i(TAG, message)
|
||||
}
|
||||
|
||||
/** Lazy info: lambda only evaluates if message will be logged. */
|
||||
inline fun info(message: () -> String) {
|
||||
if (!acquireLogPermit()) return
|
||||
Log.i(TAG, message())
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message. Use this to report unexpected but non-fatal issues.
|
||||
*
|
||||
* @param message The message to log.
|
||||
* @param throwable An optional exception to log with the message.
|
||||
* Logs a warning message. Warnings are never rate-limited.
|
||||
*/
|
||||
fun warning(message: String, throwable: Throwable? = null) {
|
||||
if (throwable != null) {
|
||||
@@ -47,11 +91,7 @@ object SystemLogger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message. Use this to report fatal errors or exceptions that disrupt
|
||||
* functionality.
|
||||
*
|
||||
* @param message The message to log.
|
||||
* @param throwable An optional exception to log with the message.
|
||||
* Logs an error message. Errors are never rate-limited.
|
||||
*/
|
||||
fun error(message: String, throwable: Throwable? = null) {
|
||||
if (throwable != null) {
|
||||
@@ -64,11 +104,22 @@ object SystemLogger {
|
||||
/**
|
||||
* Logs a verbose message. This level is for highly detailed logs that are generally not needed
|
||||
* unless tracking a very specific issue.
|
||||
*
|
||||
* @param message The message to log.
|
||||
*/
|
||||
fun verbose(message: String) {
|
||||
if (!isDebugBuild) return
|
||||
if (!acquireLogPermit()) return
|
||||
Log.v(TAG, message)
|
||||
}
|
||||
|
||||
/** Lazy verbose: lambda only evaluates if message will be logged. */
|
||||
inline fun verbose(message: () -> String) {
|
||||
if (!isDebugBuild) return
|
||||
if (!acquireLogPermit()) return
|
||||
Log.v(TAG, message())
|
||||
}
|
||||
|
||||
inline fun trace(message: () -> String) {
|
||||
if (!isDebugBuild) return
|
||||
Log.w(TAG, message())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,12 @@ object CertificateGenerator {
|
||||
)
|
||||
|
||||
return try {
|
||||
// AOSP ta/src/keys.rs:451-478: no challenge + no attestKey = self-signed, depth 1
|
||||
if (challenge == null && attestKeyAlias == null) {
|
||||
SystemLogger.trace { "[certgen] no-challenge key: self-signed, depth=1, purposes=${params.purpose}" }
|
||||
return listOf(buildSelfSignedCertificate(subjectKeyPair, params))
|
||||
}
|
||||
|
||||
val keybox = getKeyboxForAlgorithm(uid, params.algorithm)
|
||||
|
||||
val (signingKey, issuer) =
|
||||
@@ -238,10 +244,11 @@ object CertificateGenerator {
|
||||
if (keyUsageBits != 0) {
|
||||
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
|
||||
}
|
||||
// Add our custom, simulated attestation extension.
|
||||
builder.addExtension(
|
||||
AttestationBuilder.buildAttestationExtension(params, uid, securityLevel)
|
||||
)
|
||||
if (params.attestationChallenge != null) {
|
||||
builder.addExtension(
|
||||
AttestationBuilder.buildAttestationExtension(params, uid, securityLevel)
|
||||
)
|
||||
}
|
||||
|
||||
val signerAlgorithm =
|
||||
when (signingKeyPair.private.algorithm) {
|
||||
@@ -256,4 +263,39 @@ object CertificateGenerator {
|
||||
|
||||
return JcaX509CertificateConverter().getCertificate(builder.build(contentSigner))
|
||||
}
|
||||
|
||||
// AOSP ta/src/keys.rs:452-478, ta/src/cert.rs:111-114
|
||||
private fun buildSelfSignedCertificate(
|
||||
keyPair: KeyPair,
|
||||
params: KeyMintAttestation,
|
||||
): Certificate {
|
||||
val subject = params.certificateSubject ?: X500Name("CN=Android Keystore Key")
|
||||
val notBefore = params.certificateNotBefore ?: Date(0)
|
||||
val notAfter = params.certificateNotAfter ?: Date(UNDEFINED_NOT_AFTER)
|
||||
|
||||
val builder = JcaX509v3CertificateBuilder(
|
||||
subject,
|
||||
params.certificateSerial ?: BigInteger.ONE,
|
||||
notBefore,
|
||||
notAfter,
|
||||
subject,
|
||||
keyPair.public,
|
||||
)
|
||||
|
||||
val keyUsageBits = buildKeyUsageFromPurposes(params.purpose)
|
||||
if (keyUsageBits != 0) {
|
||||
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
|
||||
}
|
||||
|
||||
val signerAlgorithm = when (keyPair.private.algorithm) {
|
||||
"EC", "ECDSA" -> "SHA256withECDSA"
|
||||
"RSA" -> "SHA256withRSA"
|
||||
else -> throw IllegalArgumentException("Unsupported key: ${keyPair.private.algorithm}")
|
||||
}
|
||||
val contentSigner = JcaContentSignerBuilder(signerAlgorithm)
|
||||
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
|
||||
.build(keyPair.private)
|
||||
|
||||
return JcaX509CertificateConverter().getCertificate(builder.build(contentSigner))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.matrix.TEESimulator.util
|
||||
|
||||
import android.hardware.security.keymint.SecurityLevel
|
||||
import android.os.Build
|
||||
import android.os.SystemProperties
|
||||
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 =
|
||||
ByteArray(size).also { ThreadLocalRandom.current().nextBytes(it) }
|
||||
|
||||
@@ -328,7 +345,9 @@ object AndroidDeviceUtils {
|
||||
6 -> { // YYYYMM
|
||||
val year = normalized.substring(0, 4).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
|
||||
}
|
||||
@@ -376,20 +395,26 @@ object AndroidDeviceUtils {
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves the attestation version based on security level and OS version. StrongBox (level 2)
|
||||
* requires version 300.
|
||||
* Retrieves the attestation version for the given security level. The value follows the device
|
||||
* 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).
|
||||
* @return The appropriate attestation version number.
|
||||
*/
|
||||
fun getAttestVersion(securityLevel: Int): Int {
|
||||
// StrongBox security level requires an attestation version of at least 300.
|
||||
if (securityLevel == SecurityLevel.STRONGBOX) {
|
||||
return 300
|
||||
}
|
||||
return DeviceAttestationService.CachedAttestationData?.attestVersion
|
||||
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
|
||||
val version = cached
|
||||
?: attestVersionMap[Build.VERSION.SDK_INT]
|
||||
?: 400 // Default to a recent version
|
||||
val source = when {
|
||||
cached != null -> "cache"
|
||||
attestVersionMap.containsKey(Build.VERSION.SDK_INT) -> "map"
|
||||
else -> "default"
|
||||
}
|
||||
SystemLogger.debug("attestVersion=$version source=$source securityLevel=$securityLevel")
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,10 +423,7 @@ object AndroidDeviceUtils {
|
||||
* @param securityLevel The security level, used to determine the correct attestation version.
|
||||
* @return The appropriate Keymaster or KeyMint version number.
|
||||
*/
|
||||
fun getKeymasterVersion(securityLevel: Int): Int {
|
||||
val attestVersion = getAttestVersion(securityLevel)
|
||||
return if (attestVersion >= 100) attestVersion else 41 // Keymaster 4.1 for older versions
|
||||
}
|
||||
fun getKeymasterVersion(securityLevel: Int): Int = getAttestVersion(securityLevel)
|
||||
|
||||
// --- APEX and Module Hash Properties ---
|
||||
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
## TEESimulator-RS v6.0.0
|
||||
|
||||
Repository consolidation release. All tee-rebuild work merged as the new main branch.
|
||||
|
||||
### AOSP Self-Signed Cert Compliance
|
||||
- No-challenge keys now generate self-signed certs (subject == issuer, depth 1), matching AOSP `ta/src/keys.rs:451-478`
|
||||
- Both Kotlin (BouncyCastle) and Rust (native-certgen) paths corrected
|
||||
- Eliminates attestation behavioral probes that detect keybox issuer on non-attested keys
|
||||
|
||||
### Stability
|
||||
- Binder stress crash hardening for concurrent generateKey calls
|
||||
- AUTO mode TEE race for consistent attestation on devices with working G10
|
||||
- Oversized transactions routed to software gen instead of crashing
|
||||
- Operation-time params (BLOCK_MODE, PADDING, DIGEST) passed through to CipherPrimitive
|
||||
|
||||
### Banking App Compatibility
|
||||
- Bare `target.txt` entries now default to AUTO mode, resolved at config level to PATCH (working TEE) or GENERATE (broken TEE)
|
||||
- Fixes BHIM and similar banking apps that require TEE-backed attestation keys
|
||||
- Restores v5.0 behavior where AUTO was resolved before the interceptor dispatch, avoiding the non-deterministic `raceTeePatch` path
|
||||
|
||||
### Infrastructure
|
||||
- Version scheme changed to semver (v6.0.0)
|
||||
- Repository moved to TEESimulator-RS as canonical source
|
||||
|
||||
---
|
||||
|
||||
## TEESimulator-RS v5.0: AOSP Compliance Overhaul
|
||||
|
||||
Major release integrating 30+ AOSP compliance improvements from upstream PR #157 analysis, layered on top of our StrongBox hardening and native cert gen architecture.
|
||||
|
||||
@@ -92,6 +92,17 @@ if [ ! -f "$CONFIG_DIR/target.txt" ]; then
|
||||
install_file "target.txt" "$CONFIG_DIR"
|
||||
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"
|
||||
|
||||
if [ ! -f "$CONFIG_DIR/hbk" ]; then
|
||||
|
||||
@@ -1,2 +1,16 @@
|
||||
allow keystore {adb_data_file shell_data_file} file *
|
||||
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 -f "$CONFIG_DIR/tee_status.txt"
|
||||
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": "v4.5",
|
||||
"versionCode": 111,
|
||||
"zipUrl": "https://github.com/Enginex0/TEESimulator/releases/download/v4.5/TEESimulator-v4.5-Release.zip",
|
||||
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator/main/module/changelog.md"
|
||||
"version": "v6.0.0-211",
|
||||
"versionCode": 211,
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,69 @@ const OID_SHA256_WITH_RSA: &[u64] = &[1, 2, 840, 113549, 1, 1, 11];
|
||||
// Extension OIDs
|
||||
const OID_KEY_USAGE: &[u64] = &[2, 5, 29, 15];
|
||||
|
||||
// AOSP ta/src/keys.rs:451-478: no challenge = self-signed leaf, chain depth 1
|
||||
pub fn build_self_signed_cert(
|
||||
key_pair: &GeneratedKeyPair,
|
||||
params: &CertGenParams,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
let spki_der = extract_spki_from_pkcs8(&key_pair.private_key_pkcs8)?;
|
||||
let sig_alg_der = signature_algorithm_for_signing_key(&key_pair.private_key_pkcs8, params.algorithm)?;
|
||||
|
||||
let serial_bytes = if let Some(ref serial) = params.cert_serial {
|
||||
serial.clone()
|
||||
} else {
|
||||
vec![1u8]
|
||||
};
|
||||
|
||||
let subject_dn_der = if let Some(ref subject) = params.cert_subject {
|
||||
subject.clone()
|
||||
} else {
|
||||
encode_simple_cn_dn("Android Keystore Key")
|
||||
};
|
||||
|
||||
let not_before = timestamp_to_datetime(params.cert_not_before)?;
|
||||
let not_after = if params.cert_not_after == -1 {
|
||||
// No keybox fallback available; use far-future (year 9999)
|
||||
OffsetDateTime::from_unix_timestamp(253402300799)
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc() + time::Duration::days(365 * 30))
|
||||
} else {
|
||||
timestamp_to_datetime(params.cert_not_after)?
|
||||
};
|
||||
|
||||
let extensions_der = build_extensions(None, ¶ms.purposes)?;
|
||||
|
||||
let version_der = encode_der_explicit_tag(0, &encode_der_integer(&[2]));
|
||||
let serial_der = encode_der_integer(&serial_bytes);
|
||||
let validity_der = encode_validity(¬_before, ¬_after);
|
||||
let extensions_tagged = encode_der_explicit_tag(3, &extensions_der);
|
||||
|
||||
// issuer == subject (self-signed, per AOSP ta/src/cert.rs:111-114)
|
||||
let tbs_der = encode_der_sequence(&[
|
||||
&version_der,
|
||||
&serial_der,
|
||||
&sig_alg_der,
|
||||
&subject_dn_der,
|
||||
&validity_der,
|
||||
&subject_dn_der,
|
||||
&spki_der,
|
||||
&extensions_tagged,
|
||||
]);
|
||||
|
||||
let signature_bytes = sign_tbs(&tbs_der, &key_pair.private_key_pkcs8, params.algorithm)?;
|
||||
let signature_bit_string = encode_der_bit_string(&signature_bytes);
|
||||
|
||||
let cert_der = encode_der_sequence(&[
|
||||
&tbs_der,
|
||||
&sig_alg_der,
|
||||
&signature_bit_string,
|
||||
]);
|
||||
|
||||
Ok(vec![cert_der])
|
||||
}
|
||||
|
||||
pub fn build_certificate_chain(
|
||||
key_pair: &GeneratedKeyPair,
|
||||
attestation_ext_der: &[u8],
|
||||
attestation_ext_der: Option<&[u8]>,
|
||||
keybox: &ParsedKeybox,
|
||||
params: &CertGenParams,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
@@ -33,7 +93,7 @@ pub fn build_certificate_chain(
|
||||
|
||||
fn build_leaf_cert(
|
||||
key_pair: &GeneratedKeyPair,
|
||||
attestation_ext_der: &[u8],
|
||||
attestation_ext_der: Option<&[u8]>,
|
||||
keybox: &ParsedKeybox,
|
||||
params: &CertGenParams,
|
||||
) -> Result<Vec<u8>> {
|
||||
@@ -63,7 +123,6 @@ fn build_leaf_cert(
|
||||
timestamp_to_datetime(params.cert_not_after)?
|
||||
};
|
||||
|
||||
// Extensions
|
||||
let extensions_der = build_extensions(attestation_ext_der, ¶ms.purposes)?;
|
||||
|
||||
// TBS Certificate
|
||||
@@ -256,19 +315,19 @@ fn extract_rsa_spki(pkcs8_der: &[u8]) -> Result<Vec<u8>> {
|
||||
Ok(encode_der_sequence(&[&alg_id, &pub_key_bits]))
|
||||
}
|
||||
|
||||
fn build_extensions(attestation_ext_der: &[u8], purposes: &[i32]) -> Result<Vec<u8>> {
|
||||
fn build_extensions(attestation_ext_der: Option<&[u8]>, purposes: &[i32]) -> Result<Vec<u8>> {
|
||||
let mut extensions: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
// KeyUsage extension (critical)
|
||||
let ku_byte = map_key_usage_byte(purposes);
|
||||
if ku_byte != 0 {
|
||||
let ku_ext = build_key_usage_extension(ku_byte);
|
||||
extensions.push(ku_ext);
|
||||
}
|
||||
|
||||
// Attestation extension (non-critical)
|
||||
let attest_ext = build_extension(&encode_der_oid(ATTESTATION_OID), false, attestation_ext_der);
|
||||
extensions.push(attest_ext);
|
||||
if let Some(attest_der) = attestation_ext_der {
|
||||
let attest_ext = build_extension(&encode_der_oid(ATTESTATION_OID), false, attest_der);
|
||||
extensions.push(attest_ext);
|
||||
}
|
||||
|
||||
Ok(encode_der_sequence_of(&extensions))
|
||||
}
|
||||
|
||||
@@ -62,14 +62,13 @@ fn generate_attested_inner(env: &mut JNIEnv, config: &JObject) -> Result<jbyteAr
|
||||
|
||||
let keybox = keybox::parse_keybox(¶ms.keybox_cert_chain, ¶ms.keybox_private_key)?;
|
||||
|
||||
let attest_ext = attestation::build_attestation_extension(¶ms)?;
|
||||
|
||||
let cert_chain = certbuilder::build_certificate_chain(
|
||||
&key_pair,
|
||||
&attest_ext,
|
||||
&keybox,
|
||||
¶ms,
|
||||
)?;
|
||||
let cert_chain = if params.attestation_challenge.is_some() {
|
||||
let attest_ext = attestation::build_attestation_extension(¶ms)?;
|
||||
certbuilder::build_certificate_chain(&key_pair, Some(&attest_ext), &keybox, ¶ms)?
|
||||
} else {
|
||||
tracing::info!("no attestation challenge, generating self-signed cert (depth 1)");
|
||||
certbuilder::build_self_signed_cert(&key_pair, ¶ms)?
|
||||
};
|
||||
|
||||
let blob = assemble_result(&key_pair.private_key_pkcs8, &cert_chain);
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
# ./scripts/package.sh --rust --release # build Rust crate first, then release
|
||||
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)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
OUT_DIR="$PROJECT_ROOT/out"
|
||||
|
||||
Reference in New Issue
Block a user