Compare commits

..
300 Commits
Author SHA1 Message Date
Enginex0 6d241e56d6 chore(release): v6.0.1-307 2026-07-11 18:29:07 +01:00
Enginex0 1f96c8a13e fix(keymint): carry op OAEP MGF digest 2026-07-11 17:48:28 +01:00
Enginex0 000e926693 chore(release): v6.0.1-305 2026-07-11 17:38:22 +01:00
Enginex0 5b7373ad7a fix(attestation): log VINTF fallback source 2026-07-11 17:19:13 +01:00
Enginex0 eb94820192 fix(attestation): source module hash from framework 2026-07-11 17:10:46 +01:00
Enginex0 d7109421ef fix(keymint): execute HMAC operations 2026-07-11 16:52:24 +01:00
Enginex0 c9918952f4 fix(keystore): resolve grant-domain attest key 2026-07-11 16:39:05 +01:00
Enginex0 d2cb950cb6 fix(keymint): apply OAEP digest spec 2026-07-11 16:25:54 +01:00
Enginex0 2cf6526b5c fix(build): floor versionCode above shipped 298
The 2026-07-08 public-release history scrub (0f1143a) rewrote history
and dropped `git rev-list --count` below the build number already
shipped to testers (298), so post-scrub builds (291, 294) read as
downgrades. Add a floor offset so versionCode clears 298 and stays
monotonic across the rewrite: the current count maps to 300, and each
later commit still increments by one.
2026-07-11 13:50:21 +01:00
Enginex0 ab4f41eb2a fix(keymint): enforce operation authorizations
Add checkOperationAuthorizations to the AuthorizeCreate chain so the
interceptor rejects operations whose parameters are incompatible with
the key, matching real KeyMint HAL behavior:

- block mode, padding, digest, and RSA-OAEP MGF digest must each be a
  subset of the key's authorized set;
- AES-GCM rejects a requested MAC length below the key minimum;
- RSA-OAEP requires a digest.

Add the four backing KeyMint error codes (INCOMPATIBLE_BLOCK_MODE,
INCOMPATIBLE_PADDING_MODE, INCOMPATIBLE_DIGEST, INVALID_MAC_LENGTH) to
KeystoreErrorCodes, resolved at runtime with AOSP-correct fallbacks.

The check reads the raw request params (AuthorizeCreate.check is called
with parsedParams), so no op-param construction change is needed, and
execution is unaffected: our SoftwareOperation already runs GCM (128-bit
tag) and OAEP.
2026-07-11 13:23:29 +01:00
Enginex0 422f78ebcf fix(keystore): handle grant subcomponent updates
Extend our grant model to the updateSubcomponent path. A grantee holding
a grant with the UPDATE access-vector bit can now update the cert/chain
subcomponent of the owner's synthetic key; previously a Domain.GRANT
updateSubcomponent fell through to the real keystore2 and failed for
synthetic keys.

Reuse the existing grant machinery (resolveGrant / softwareGrants /
getGeneratedKeyResponse) already backing the getKeyEntry Domain.GRANT
read, gated on KEY_PERMISSION_UPDATE (0x80) instead of GET_INFO (0x4).
Extract a shared updateResponseSubcomponent() helper so the grant and
owner paths share one cert-swap plus re-persist body.
2026-07-11 13:06:22 +01:00
Enginex0 dc2d894647 feat(spoof): add boot_props_mode Oplus carve-out
Add a boot_props_mode control so global ro.boot.* property spoofing
can be tuned per device.

BootStateManager reads /data/adb/tricky_store/boot_props_mode
(auto/force/disable). In auto, Oplus-family devices
(OnePlus/OPPO/realme/Oplus) skip the global ro.boot.* spoof so vendor
TEE services such as ultrasonic fingerprint calibration keep working.
AndroidDeviceUtils routes the bootKey/bootHash resetprop writes
through a setBootProperty() gate honoring the same mode; the forged
value is still persisted and returned, so attestation is unaffected.

Tradeoff: in auto, skipping ro.boot.* leaves direct system-property
boot-state checks truthful on Oplus devices (compatibility over
stealth). Latent, not a reported issue.
2026-07-11 12:50:07 +01:00
Enginex0 a811919d0c fix(attestation): align KeyMint version to VINTF
getAttestVersion/getKeymasterVersion now read the device VINTF manifest
and derive attestationVersion=keymasterVersion=aidl*100 (KeyMint) or the
legacy HIDL Keymaster pair, so the forged attestation certificate's
version matches the device's declared IKeyMintDevice interface. This
removes the version MISMATCH that Duck Detector flags (attested 400 vs
declared 300). Falls back to the existing cache/SDK-map/400 chain when
VINTF is absent or unreadable.

Refs #40
2026-07-11 12:08:00 +01:00
Enginex0 0f1143a445 chore: prepare source tree for public release
- Freeze changelog.md and update.json at v6.0.1-282
- Add refactoring note to changelog top
- Untrack local planning files (CLAUDE.md, bucket/) via gitignore
2026-07-08 13:45:33 +01:00
Enginex0 4429f0ed60 docs(soter): initSigh resultCode is load-bearing
The probe's signSessionOk requires resultCode==0 (SoterCapabilityProbe
.kt:107), so writeInt(SOTER_OK) for initSigh is not optional. Cite the
probe line in the comment so a future cleanup does not drop it on the
false "detector ignores resultCode" belief, true only for the export
path.
2026-06-26 16:57:12 +01:00
Enginex0 5ddd8137df fix(soter): harden on-demand mount recovery
The supervisor mounted the forge on the happy path but could not
re-attempt: mount() returned silently on inject/handshake failure, and a
live-but-uninjected binding never died to trigger a rebind, stranding the
forge for the life of that soterserver process (audit F1).

Route every unmounted outcome through scheduleRetry(): inject failure,
post-inject handshake-null, and register failure now schedule a re-bind
instead of returning. Add onNullBinding (F2) and exponential backoff
capped at 30s, reset on a clean mount (F3). register() now returns
whether the transact succeeded so mount() retries on a false reply (F4);
existing keystore callers ignore the new return.

Audit remediation. compileDebugKotlin clean.
2026-06-26 16:57:12 +01:00
Enginex0 a4875dc200 feat(soter): wire supervisor into App startup
Start SoterProcessSupervisor from App.main() so the Layer-A forge
mounts on the on-demand soterserver process. prepareEnvironment() now
returns the system Context it previously discarded; main() hands it to
start() after keystore init and before Looper.loop(). start() runs on
its own HandlerThread and returns at once, so neither the blocking
keystore loop nor the message loop is affected.

The stub types ActivityThread.getSystemContext() as a bare ContextImpl,
so the Context cast warns CAST_NEVER_SUCCEEDS; the real ContextImpl does
extend Context, so it is runtime-safe and the warning is suppressed.

Checkpoint 10.W. compileDebugKotlin clean.
2026-06-26 16:13:03 +01:00
Enginex0 6b84c76f10 fix(module): detect diag.sh by file not exit code
The release build is meant to nuke any debug NDJSON directory on
install, but the sweep never ran. customize.sh keyed the debug vs
release decision on unzip's exit code:

  if unzip -qqjo "$ZIPFILE" "diag.sh" ...; then ...

Info-ZIP returns 11 when the entry is absent, but the busybox/toybox
unzip in the Magisk/KSU install environment exits 0, so on a release
ZIP (which correctly omits diag.sh) the branch was wrongly taken and
the rm -rf in else never fired.

Detect presence by the extracted file instead: run unzip, then test
[ -f "$MODPATH/diag.sh" ]. Robust to any unzip implementation.
2026-06-26 15:54:19 +01:00
Enginex0 43a2301982 feat(soter): sepolicy grants for ptrace injection
Injection into the soterserver app (platform_app domain, per recon)
needs ptrace under SELinux enforcing. Add the grant mirroring the
keystore one, in the base rule so it applies to both variants:

  allow crash_dump platform_app process *

The per-UID NDJSON write grant is debug-only: appended for debug
builds in build.gradle.kts's isDebug doLast, mirroring the existing
keystore media_rw_data_file grant. Keeping it out of the base rule
stops an external-storage write from leaking into release.

No soter_server SELinux type exists; platform_app is the soterserver
app domain. Runtime policy (KSU/magiskpolicy) grants this past the
compile-time neverallow; on-device avc verification is 10.V.

Checkpoint 10.C.
2026-06-26 15:54:19 +01:00
Enginex0 7de3ef1ba3 feat(soter): supervise on-demand injection
soterserver is Intent-bound and on-demand, so the one-shot pidof +
inject the always-alive keystore path uses never lands. Bind the
service to both poke its start and obtain the ISoterService binder
(the identity the native MITM registry keys on), inject
libTEESimulator.so, confirm the landing via the 0xdeadbeef handshake,
then register the forge.

Re-binds and re-injects on every respawn instead of exiting like the
keystore one-shot. Runs on its own HandlerThread so it never stalls
keystore init or the daemon looper; lifecycle logging is debug-gated.
Not yet wired into App.kt (that is 10.W).

Checkpoint 10.B.
2026-06-26 15:31:11 +01:00
Enginex0 9a8c06bca6 feat(soter): forge ISoterService Layer-A replies
Forge healthy com.tencent.soter.soterserver.ISoterService AIDL replies
from inside the injected soterserver process so the SOTER capability
probe reads available=true / damaged=false on a bootloader-unlocked
device whose SOTER TA can no longer use its factory ATTK.

Hardcodes the 13 obfuscation-stable transaction codes (R8 stripped the
Stub) and fills the 5 parcelable payloads with detector-valid values:
the export blob is a little-endian length-framed SOTER pubkey envelope
that the SDK's retrieveJsonFromExportedData parses to a non-null model.
Every request and forged reply is captured to per-UID NDJSON,
debug-gated.

Checkpoints 10.A (forge) and 10.M (reply marshalling).
2026-06-26 15:31:11 +01:00
Enginex0 4e61f1523d chore(release): sync update.json to v6.0.1-292 2026-06-26 11:40:57 +01:00
Enginex0 ab40677dac fix(attestation): per-security-level RSA/EC probe
c2552ba gated AUTO forge on isRsaAttestable, but the probe minted its
key without setIsStrongBoxBacked, so it measured only the TEE. A device
whose TEE provisions an RSA attestation key while its StrongBox cannot
(OnePlus PJZ110, Android 16) had StrongBox RSA requests PATCHed against
the real keystore, which has no StrongBox attestation key and returns
-74 (ATTESTATION_KEYS_NOT_PROVISIONED).

Probe each (algorithm, security-level) pair independently and have
dispatch consult the verdict matching the request's security level, for
both RSA and EC. StrongBox-incapable requests forge; capable ones keep
the genuine TEE chain via PATCH.

Refs #37
2026-06-26 11:38:39 +01:00
Enginex0 07dde7c756 fix(build): revert NDK to 27.3.13750724
NDK 29's Clang-21 libc++ makes libTEESimulator.so reference
__cxa_init_primary_exception, which the platform libc++ inside
keystore2 does not export. The injected lib resolves its C++ ABI
symbols against the target process at dlopen time, so injection
failed with "cannot locate symbol" on every retry. keystore2 ran
unhooked and every app saw the raw TEE chain (KeyAttestation showed
the real unlocked bootloader; per-UID NDJSON never created).

libc++ began emitting that symbol from std::exception_ptr
construction in Clang 19, so 27.3 (Clang 18) is the last toolchain
that builds a loadable lib. The exception_ptr machinery enters via
the AOSP/binder stub headers, not module code.

Verified on device: lib injects (3 maps in keystore2), KeyAttestation
generateKey -> PATCH with deviceLocked=true, verifiedBootState=Verified.
2026-06-25 17:14:15 +01:00
Enginex0 718c80a68b chore: bump NDK to 29.0.14206865
NDK 27.3.13750724's sysroot is corrupted (missing sys/cdefs.h and the
aarch64 asm headers), breaking the native-certgen build. NDK 29 is
installed and healthy, so move the toolchain to it.
2026-06-25 03:05:39 +01:00
Enginex0 9ffa00332b docs(readme): rewrite in plain language
Define load-bearing terms on first use, cut marketing phrasing, and
remove every em dash. Add an ASCII flow diagram to "How it works".
Trim credits to JingMatrix, ring, fatalcoder524, and huguangares.
Reword the tagline and update the build requirement to NDK 29.
2026-06-25 03:05:39 +01:00
Enginex0 5c36288461 refactor(certgen): drop dead native logging code
Remove orphaned native logging that nothing reached:

- The /sdcard/Download zip dump (NativeCertGen.dump and the dumpLogs
  JNI, dump_logs_inner, dump.rs, pub mod dump), superseded by the
  diag.sh export.
- The verbose-marker helpers (sysfs.rs, pub mod sysfs); the manual
  .verbose toggle still works via mod.rs::init's inline check.

Drop the now-unused direct deps zip and libc and the orphaned jstring
import. cargo ndk build is warning-clean.
2026-06-25 02:31:35 +01:00
Enginex0 d0139003a6 feat(logging): per-UID NDJSON on external storage
Move debug diagnostics off /data/local/tmp/teesim to
/data/media/0/TEESimulator (visible at /sdcard/TEESimulator), so users
can pull them without a root explorer. The logging code runs in the
keystore SELinux domain, so a debug-only media_rw_data_file grant plus
a debug-only diag.sh fragment gate the plane: diag.sh's presence is the
signal service.sh (setup) and action.sh (export) test. customize.sh
extracts diag.sh on debug installs or sweeps the dir on release, since
the release keystore domain cannot remove it itself.

Replace the per-call .bin parcel dumps (a fresh undecodable file per
generateKey) with one NDJSON record per event on the UID's own file,
carrying decoded fields plus the raw parcel as base64 for the offline
parsers. computeIfAbsent makes per-UID writer creation atomic.
2026-06-25 02:31:35 +01:00
Enginex0 28f48f2e01 fix(attestation): harden RSA capability probe
The RSA capability probe cached any first-call failure for the process
lifetime via by-lazy plus a catch-all false, so a transient keystore
hiccup could freeze the device into forging an attestation the real TEE
could serve, silently re-creating the issue #37 regression with no
self-heal.

Memoize only a definitive verdict: a successful probe, or a permanent
KeyStoreException per the framework's own isTransientFailure(). Transient
and non-keystore failures fail open, reporting the device attestable so
dispatch PATCHes the genuine chain, and re-probe on the next read. An
AtomicBoolean guard keeps at most one probe in flight with no lock held
across the keygen. Delete the probe key best-effort in finally.

Refs #37
2026-06-25 02:19:51 +01:00
Enginex0 c2552ba164 fix(dispatch): gate AUTO forge on RSA capability
v282 forged every AUTO attestation that carried a challenge, so a
strict app that validates attestation server-side, such as Kraken,
rejected the software-forged chain where it accepted a patched
real-TEE chain, breaking login. The trigger was algorithm-blind: the
AUTO capability probe only mints an EC key, so it could not tell an
EC-capable TEE from one that cannot provision RSA attestation keys.

Add an isRsaAttestable probe and forge AUTO attestation only for RSA
the real TEE cannot provision. EC and RSA-capable devices keep their
genuine TEE chain via PATCH, restoring the v280 behavior strict apps
depend on while preserving the RSA red fix on incapable devices.

Refs #37
2026-06-25 00:58:13 +01:00
Enginex0 e2dc7aa210 fix(keystore): vendor-gate real-op updateAad
OperationInterceptor rejected non-AEAD updateAad with INVALID_TAG
unconditionally, while SoftwareOperation's VendorQuirks gate returns
success on Samsung and Xiaomi-MTK. On those devices the real-key and
forged-key paths disagreed, and the genuine TEE accepts the call, so
the inconsistency fingerprinted the injection layer through Duck
Detector's operation error-path probe.

Apply the same gate to the real-op path: a void success reply where
nonAeadUpdateAadSucceeds(), else the INVALID_TAG reply. Promote
VendorQuirks to internal so both paths share one decision.

Refs #36
2026-06-25 00:58:00 +01:00
Enginex0 9193b79a6a chore(release): sync update.json to v6.0.1-282 2026-06-19 16:45:01 +01:00
Enginex0 b2d84b4661 fix(dispatch): forge AUTO attestation requests
Plain attestation (Use-attest-key OFF, challenge present) on an AUTO-mode target was routed to PATCH, deferring to the real TEE. The AUTO probe (checkTeeFunctionality) only proves the device can mint one EC key, so devices that cannot attest RSA or device-ID, or whose patched chain fails RSA verify, surfaced as KeyAttestation reds (ATTESTATION_KEYS_NOT_PROVISIONED/-49, BLOCK_TYPE_IS_NOT_01).

Forge these from the keybox instead, gated on isAutoMode + attestationChallenge, matching the attest-key-ON path that already yields a green Google-rooted chain. Non-attestation keys still pass through to real hardware, so KeyDetector hardware-backed checks are unaffected.

Verified offline against real FORGE captures with scripts/keyatt_conformance.py: uid10389/uid10154 chains are GREEN and the root SPKI byte-matches GOOGLE_ROOT_PUBLIC_KEY.
2026-06-19 14:22:56 +01:00
Enginex0 af3c27451d chore(release): sync update.json to v6.0.1-280 2026-06-19 01:46:06 +01:00
Enginex0 0586db18d9 fix(keystore): reorder auths off genmode sentinel
Duck's generate-mode parcel fingerprint reads the reply with a flat
12-byte stride and flags the sentinel tuple the device's native
ALGORITHM-first auth order lands on, at count 12 and 13. Real A16
hardware trips it too, so faithful mirroring stays flagged.

Add InterceptorUtils.normalizeAuthorizationLayout: marshal the auth
array, run Duck's exact predicate, and only when it would match,
reorder by a deterministic minimal move until it clears. Order
carries no keystore semantics and the cert chain is a separate
field, so count, values, security levels, and attestation are all
preserved. Applied on both the patch and forge reply paths; it keys
on the byte condition, never on any package.
2026-06-19 01:44:04 +01:00
Enginex0 ec5df08574 chore(release): sync update.json to v6.0.1-277
The packaging task rewrites update.json to gitCommitCount on every
build; this records the v6.0.1-277 artifacts and supersedes the manual
271 bump made before the build counter was understood.
2026-06-17 20:34:17 +01:00
Enginex0 d65526aaa5 chore(release): bump module to v6.0.1-271 2026-06-17 20:28:28 +01:00
Enginex0 ca226bd7de chore(pki): log attest-sign signer vs leaf algo
At the attest-key signing instant, log signer key algorithm, served
leaf algorithm, chain depth, and issuer (debug, targeted uid) so an EC
attest-key run pins the mismatched edge of the two-root chain.

Bucket a16-ec-attestkey-red, task T01.
2026-06-17 20:28:28 +01:00
Enginex0 e5d24c3907 fix(keystore): drop algo-split key on restore
A persisted record whose private-key algorithm disagrees with its
served leaf public key (EC private under an RSA leaf) makes every
signature fail as DATA_TOO_LARGE_FOR_MODULUS: the A16 EC two-root.
Require the two to match on restore and drop the record otherwise, so
the next generateKey rebirths a coherent key. Awaiting an EC device
capture to confirm the red originates from a restored record.

Bucket a16-ec-attestkey-red, task T01.
2026-06-17 20:28:28 +01:00
Enginex0 a54e8a5315 fix(attestation): cache AOSP attestVersion on A16
The A16 test device's KeyMint HAL reports attestVersion 100 (KeyMint
1.0); the lazy cache stored that and it shadowed the map's BAKLAVA->400
in getAttestVersion. fetchAttestationData now caches
AndroidDeviceUtils.aospAttestVersion (attestVersionMap[SDK_INT]),
falling back to the parsed device value only when the SDK is unmapped,
so the forge presents the AOSP-correct 400. attestVersionMap unchanged.

Bucket a16-ec-attestkey-red, task T02.
2026-06-17 20:28:27 +01:00
Enginex0 0b67700763 fix(keystore): evict stale cached key on regen
keystore2 replaces a key when generateKey reuses an alias. Mirror that:
drop any cached chain for the alias so a later getKeyEntry serves the
current key, not a stale FORGE from a prior generation (an
attest-key-mode leaf cached, then re-generated without an attest key).
2026-06-17 14:54:22 +01:00
Enginex0 fbee59688d feat(logging): log served and verified chains
Log each cert chain the module hands the app so an attestation
verification failure is provable from the per-UID log, not inferred.

- formatChainVerification verifies every edge of a produced chain and
  reports RSA signature-vs-modulus sizes (the DATA_TOO_LARGE condition).
- formatChainKeys and logServedChain record the chain served back on
  each getKeyEntry, keyed by alias, since the app reassembles its chain
  from the leaf alias plus the attest-key alias.

Debug-build only, gated by isUidLogged.
2026-06-17 14:54:22 +01:00
Enginex0 2e5155fb75 fix(keystore): serve getKeyEntry for skipped UIDs
Un-targeted privileged callers (e.g. KeyAttestation via Shizuku) have
their attest-key and device-id generateKey requests force-forged, but
getKeyEntry blanket-skipped those UIDs before the owned-key lookup, so
the framework's attestKeyAlias resolution in
AndroidKeyStoreKeyPairGeneratorSpi.initialize() returned KEY_NOT_FOUND
and surfaced as "Invalid attestKeyAlias".

Let getKeyEntry reach the owned-key lookup for skipped UIDs; a non-owned
key still skips post-processing so an un-targeted app's real key is
never patched.
2026-06-17 11:34:22 +01:00
Enginex0 ce22327147 refactor(keystore): strip unique-id at parse time
Decide the effective generateKey params once via .let when the caller lacks gen_unique_id / REQUEST_UNIQUE_ID_ATTESTATION, instead of mutating var params/parsedParams deep in handleGenerateKey and re-parsing KeyMintAttestation a second time. isAttestKeyRequest now derives from the final parsedParams, closing the staleness flagged in PR #27 review r3308356496.

Behavior is unchanged: no gate between the parse and the old strip site reads INCLUDE_UNIQUE_ID, and the && short-circuits so the permission lookups still run only when the tag is present.
2026-06-17 10:38:32 +01:00
Enginex0 f69fee21a8 chore(debug): co-locate per-UID logs with dumps
Per-UID dossier logs moved from /data/adb/tricky_store/logs to
/data/local/tmp/teesim, beside the .bin dumps, so the whole debug
trail comes off the device in one `adb pull /data/local/tmp/teesim/`
with no root and no /sdcard hop.

The release purge now sweeps .log/.log.1 from the diagnostic dir and
keeps legacy sweeps of both old locations (loose /data/local/tmp and
the module config dir) so upgrading to a release build leaves nothing
behind. Repoint package.sh --clear-logs to the new path.
2026-06-04 21:15:34 +01:00
Enginex0 9561f7d9c0 feat(logging): per-UID forge diagnostics
The attestation dossier only fired on a successfully produced chain, so
the StrongBox/BHIM failures left nothing on the per-UID plane and had to
be reconstructed from marshalled .bin dumps offline. Add three records,
all debug- and target-gated like the existing dossier:

- keybox-pick: which keybox signs the forge (requested algo, exact match
  vs EC fail-safe, signer subject) -- makes an EC-only-keybox RSA
  fallback visible instead of silent.
- forge-fail: emit the failure reason on the per-UID plane when a forge
  throws (e.g. ATTESTATION_KEYS_NOT_PROVISIONED), paired with dispatch.
- auth-shape: the emitted authorization list (count, ordered tags,
  per-auth securityLevel) -- the surface the duck generate-mode parcel
  fingerprint stride-walks, readable without offline decode.
2026-06-04 20:39:49 +01:00
Enginex0 79e4fe905e chore(debug): per-request gen-mode result dumps
The asymmetric and symmetric result dumps wrote a single fixed
filename (teesim-gen-mode-asym.bin / -sym.bin), so each forge
overwrote the previous one and only the final reply survived a
capture -- which is why a tester's zip showed one app's good chain
while the failing chain was already gone.

Tag both dumps with uid and tx, matching the request dumps, so every
forged chain is retained and correlatable with its request.
2026-06-04 20:00:35 +01:00
Enginex0 5bbb0bffe0 fix(pki): root RSA forge on EC-only keybox
The forge keybox selector matched the requested algorithm exactly and
threw -75 ATTESTATION_KEYS_NOT_PROVISIONED on a miss, while the patch
path already falls back to any usable key (EC preferred). An RSA
ATTEST_KEY request on an EC-only keybox therefore never rooted: the
caller's attest-key chain could not reach the Google root and verifiers
reported "unknown certificate".

Fall back to getAnyAttestationKey when no algorithm-matching keybox
exists. An EC attestation key validly ECDSA-signs an RSA-subject leaf,
so the EC keybox roots the RSA forge. No-op when the keybox is dual.
2026-06-04 20:00:08 +01:00
Enginex0 59a2357312 fix(keystore): repair attestation generation gaps
Two gaps in attestation generation surfaced by a tester's Key Attestation
app runs on build 259.

Device-ID attestation (IMEI/serial) via Shizuku arrives as a privileged
UID (shell/system) absent from target.txt, so it was skipped and the real
TEE rejected it with CANNOT_ATTEST_IDS (-66). Stop skipping requests that
carry device-ID tags, and force the forge path for them (the real TEE
cannot attest IDs, so there is no chain to patch). The permission gate
still rejects ordinary apps, mirroring a real device.

'Use attest key' produced WRONG_PUBLIC_KEY_TYPE: a reused persistent attest
key is designated by KEY_ID with a null alias, so the lookup missed and the
leaf was silently re-rooted under the keybox, double-rooting the chain the
caller assembles. Resolve the attest key by KEY_ID as well as alias, and
refuse to emit a leaf rather than fall back to the keybox when a designated
attest key cannot be resolved.
2026-06-04 19:07:42 +01:00
Enginex0 7f33bd737d chore(debug): move diagnostic dumps to subfolder
The debug-only .bin dumps wrote loose into /data/local/tmp, cluttering a
directory shared with every other tool. Route both writers through a
shared DIAGNOSTIC_DIR (/data/local/tmp/teesim) with mkdir-on-write, and
extend the release purge to sweep the new folder plus any loose leftovers
from older debug installs.
2026-06-04 19:05:26 +01:00
Enginex0 b90af0b716 fix(attestation): patch RSA leaf under EC keybox
An EC-only Google keybox could not re-root an RSA-keyed attestation: the keybox lookup asked for an RSA signing key, got null, and threw. patchCertificateChain caught the throw and returned the original chain untouched, leaking the device's real unlocked Root of Trust for RSA keys while EC keys patched correctly.

Fall back to any available keybox key (preferring EC) and sign the patched leaf with the keybox key's own algorithm rather than the original leaf's. A leaf's signature algorithm is independent of its subject key, so the RSA leaf re-signs validly under the EC keybox and the chain still roots to the Google keybox with the forged, locked RoT.

Add KeyBoxManager.getAnyAttestationKey; drop the now-dead sigAlgName param and normalizeSignatureAlgorithm helper.
2026-06-04 17:43:19 +01:00
Enginex0 e5483afc70 feat(logging): UID-keyed attestation dossier
Add a debug-only per-UID diagnostic plane gated on BuildConfig.DEBUG.
For apps in target.txt it records every keystore interaction and the
forged attestation it produces to teesim-uid-<uid>.log: decoded cert
chain (FORGE and PATCH paths), key params, keybox, and prop sources,
with the calling UID threaded through the C++ binder hook and Rust
certgen. Release builds stay silent (R8 strips the write plane and the
runtime gate short-circuits). Adds --clear-logs to package.sh.
2026-06-04 15:53:06 +01:00
Enginex0 f826312fc4 fix(pki): log keybox serial on every fetch
The serial log added previously lived in parseKeysFromXml, which
getAttestationKey runs only on a cache miss -- so it emitted at most
once per boot and scrolled off the buffer before it could be read.
Move it into getAttestationKey so the keybox attestation cert serials
are logged on every fetch, on the live native cert-gen path.

Verified on device: "Using RSA keybox keybox.xml; attestation cert
serials (hex): ..." now prints on each forge.
2026-06-04 13:58:43 +01:00
Enginex0 40519b94ea feat(keystore): vendor-gate non-AEAD updateAad
Mirror Duck Detector's OperationErrorPathProbe: real Samsung and
Xiaomi-MTK TEEs return success for updateAad on a non-AEAD operation,
while other vendors reject it with INVALID_TAG. The shim reads the same
Build identity the probe reads and answers accordingly, in both the
CryptoPrimitive default (sign/verify) and CipherPrimitive paths.

Forward hardening for the #28 detector: the prior unconditional
ServiceSpecificException throw already passes the probe on every vendor,
so this guards against stricter future probes rather than fixing a
current failure.
2026-06-04 13:14:43 +01:00
Enginex0 37e9d007de feat(pki): log keybox attestation cert serials
Emit each loaded keybox's certificate-chain serials (lowercase hex) at
parse time. A revoked or leaked keybox is then visible from logcat
alone, since Google's CRL and Duck Detector's "mass abuse" check both
match by certificate serial.

Diagnostic aid for the revoked-keybox danger in #28; the actual fix is
rotating to a non-revoked keybox.
2026-06-04 13:00:38 +01:00
Enginex0 5c300ff47b style: apply ktfmt formatting pass
Run the project ktfmt kotlinLangStyle formatter over app/ to bring the
tree into canonical form. Formatting only -- no logic change.

Verified semantic-neutral: ktfmt(working tree) is byte-identical to
ktfmt(committed HEAD) across all of app/src, so the prior uncommitted
WIP carried zero behavioral change.
2026-06-04 12:55:07 +01:00
Enginex0 5bd563d8db feat(keystore): probe trail for generateKey
Add a debug-only structured log line per generateKey, emitted at every
outcome (SKIP, the four REJECTs, FORWARD_HAL, FORGE, PATCH, PASSTHROUGH).
Each line carries the resolving package (via the cached
ConfigurationManager.getPackagesForUid), alias, algorithm, StrongBox
flag, the attestation tag set, and the verdict, so triaging "app X
broke" becomes a single logcat grep instead of decoding parcel dumps.

Gated on SystemLogger.isDebugBuild: release builds return before any
string is built, keeping the path silent and artifact-free. Logs to
logcat only, never to files.
2026-05-30 14:54:57 +01:00
Enginex0 254fb0f0a9 fix(keystore): forge device-property attestation
The canAttestDeviceIds gate (3575c74) probed the live TEE to decide
whether to honor device-property/ID attestation. That probe is gated on
isTeeFunctional, which is false on every dead-TEE device the module
serves, so GENERATE mode rejected all such requests with
CANNOT_ATTEST_IDS, including GMS Play Integrity's hardware path, which
broke BHIM and any UPI/Play-Integrity-gated app.

Remove the gate. Device-property attestation (BRAND/MODEL/...) now forges
unconditionally, as genuine devices universally attest it. Device-ID
attestation stays governed by the pre-existing caller-permission check,
the real KeyMint rule: privileged callers get it, ordinary apps do not.
Drop the now-unused DeviceAttestationService.canAttestDeviceIds probe.
2026-05-30 14:51:48 +01:00
Enginex0 8d195010fd chore(release): publish v6.0.1-251
Grant-plane coherence (Android 16 incl.), Google Wallet + fingerprint
compatibility (PR #26/#27), and removal of the in-module PIF/bulletin
resolvers. Frozen at gitCommitCount 251.
2026-05-30 13:45:55 +01:00
Enginex0 d6ddc925ce fix(action): sample getevent in 1s bursts
The piped getevent stream block-buffered on Magisk's BusyBox ash and
missed a single vol-key press before the timeout. Sample getevent in 1s
timeout bursts in a deadline loop instead.
2026-05-30 13:42:31 +01:00
Enginex0 a67efa4102 refactor(app): drop PIF resolvers + dump purge
Remove PatchLevelManager (auto-resolved the security-patch date from an
installed PlayIntegrityFix module into security_patch.txt, with a
FileObserver hot-reload) and BulletinPoller (scheduled bulletin refresh),
and their App.kt init/start calls.

Add purgeDebugDiagnostics(): release builds sweep stale teesim-*.bin
dumps from /data/local/tmp at boot so a prior debug install can't leave a
detection artifact. Stabilize the InterceptorUtils diagnostic dump path
to a single file instead of one per call.
2026-05-30 13:42:31 +01:00
Enginex0 00e8cc36fe chore(release): bump version to v6.0.1 2026-05-30 13:42:31 +01:00
Enginex0 ca857c30ac fix(keystore): grant plane serves patch-mode keys
Domain.GRANT readback only recognized synthetic keys (generatedKeys), so
patch-mode keys (real TEE key whose attestation we patch on read, cached
in teeResponses) fell through to the real keystore2 unpatched. Android 16
made KeyStoreManager.grantKeyAccess a public API, so the owner read
returned our patched chain while the grant read returned the raw real
chain -> duck SELF_/ISOLATED_CHAIN_SPLIT.

Gate grant()/ungrant()/resolveGrant on ownsKeyResponse() (synthetic OR
patch-mode) so every access plane serves the same cached KeyEntryResponse.
Pre-36 still answers PERMISSION_DENIED; no behavior change on Android 15.
2026-05-30 13:42:31 +01:00
Enginex0 a58c4798c3 fix(keystore): mirror TEE device-ID capability
generateKey synthesized device-property attestation unconditionally:
the BRAND/DEVICE/PRODUCT/MANUFACTURER/MODEL tags that
setDevicePropertiesAttestationIncluded emits. A forged key thus
succeeded where real silicon returns CANNOT_ATTEST_IDS. Hardware that
never provisioned device IDs cannot attest them, so forging them is an
over-capability tell: a genuine device of the same class fails the
identical request.

Add DeviceAttestationService.canAttestDeviceIds, a lazy probe that asks
the real TEE to attest device properties once and caches the verdict.
It is gated behind isTeeFunctional, so a silent or dead TEE
short-circuits to "cannot attest" without a second doomed probe.

handleGenerateKey now returns KEYMINT_CANNOT_ATTEST_IDS for any
device-ID or device-property attestation the real TEE cannot satisfy,
uniformly across AUTO, PATCH, and GENERATE. Basic attestation carries
none of these tags and is untouched.

Verified on 23106RN0DA: kknd under GENERATE now WARNs, matching a stock
locked-bootloader device. Principle: forge health, mirror capability.
2026-05-30 13:42:31 +01:00
Enginex0 8cebcf14a8 feat(keystore): mirror lifecycle via maintenance
Hook the keystore2 daemon's android.security.maintenance binder (hosted
by the same process, reached by the already-injected native hook) so
synthetic key state follows real key-lifecycle events:

- clearNamespace(APP) purges synthetic keys for the uid and their grants.
- deleteAllKeys() clears all synthetic keys and grants.
- migrateKeyNamespace() re-keys the synthetic entry, preserving material,
  chain, and grants.

Pure side-effect hook: every handled transaction mutates only our own
synthetic state, then returns ContinueAndSkipPost so the real keystore2
still performs the real operation. Unhandled codes pass through, so real
key lifecycle is never disturbed. Pre-empts delete-then-read and
clearNamespace coherence probes (Phase 9 Change 4).

Adds a minimal IKeystoreMaintenance compile stub for the descriptor;
transaction codes resolve reflectively on-device.

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 ab58b11e4d fix(keystore): evict stale chains on key mutation
Two synthetic-cache staleness gaps let getKeyEntry replay a pre-mutation
attestation:

- importKey now drops teeResponses and patchedChains for the alias, not
  only generatedKeys. A successful import replaces the real key, so the
  retained patched chain was a tell (duck STALE_GENERATED_AFTER_IMPORT).
- After updateSubcomponent re-keys a patched chain, getKeyEntry on a
  patch-mode key evicts the cached TEE response by KEY_ID or APP so the
  read falls through to the updated real keystore2
  (duck STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 bed32b7454 fix(keystore): gate synthetic grant to Android 16
KeyStoreManager.grantKeyAccess() became a public app API only in
Android 16 (API 36). Before that grant was a hidden API and SELinux
denied untrusted_app, so a real Android 15 device answers a private-
binder grant() with PERMISSION_DENIED.

The virtualized grant plane (5579b16) issued a synthetic grant on every
SDK, exposing a capability a real Android 15 app does not have. Gate
grant() and ungrant() on SDK_INT >= 36: pre-36 returns PERMISSION_DENIED
for synthetic keys (matching the real device, which Duck then marks
UNAVAILABLE rather than a tell); 36+ keeps the coherent virtualized
grant.

On-device 23106RN0DA (SDK 35): all four grant rows report UNAVAILABLE,
not RED; tamper score unaffected.

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 43e948efb3 fix(shim): match real gen-mode auth shape
Real keystore2 (captured on-device, MediaTek SDK 35) emits 11 EC
authorizations in the generateKey KeyMetadata: no VENDOR_PATCHLEVEL or
BOOT_PATCHLEVEL, and USER_ID tagged at SecurityLevel.SOFTWARE. The shim
emitted 13 with both patchlevels and USER_ID at KEYSTORE.

Duck-Detector's generate-mode parcel fingerprint stride-walks the reply
and keys on the 13-entry layout, so the two extra entries were the tell.
Drop both patchlevels from the authorization list (they remain in the
attestation extension via AttestationBuilder, so attestation content is
unchanged) and move USER_ID to SOFTWARE to mirror the captured device.

On-device 23106RN0DA: generate-mode fingerprint signal gone (0 local),
TEE tamper score 18 -> 8.

Refs Phase 7 .omc/plans/tee-fingerprint-phase-7-generate-mode-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0 cf4fb127f2 feat(keystore): virtualize grant plane
Duck-Detector's grant-domain probes generate an attested key, then
reach it through a second access plane -- IKeystoreService.grant() then
getKeyEntry(Domain.GRANT, grantId) -- and compare the certificate
chains. We synthesized the owner key but never virtualized the GRANT
plane, so grant reads fell through to the real keystore2, which has no
record of the synthetic key. That single fall-through produced six RED
rows.

Virtualize the plane so every access path returns the same synthesized
KeyEntryResponse:

- SoftwareGrant state model in the shim companion: issue/resolve/
  revoke/purge, caller-bound and access-vector-aware (Change 1).
- grant/ungrant/getKeyEntry(GRANT) handlers in Keystore2Interceptor.
  resolveGrant() enforces caller-binding (non-grantee -> KEY_NOT_FOUND,
  PR #57 probe 4) and the GET_INFO=0x4 access-vector gate (missing ->
  PERMISSION_DENIED, PR #57 probe 3); a valid read returns the owner's
  exact KeyEntryResponse for a coherent chain (Change 2).
- Purge grants on key teardown and clearAll, so grants die with the
  key and re-key orphans them -- matching real keystore2 (Change 3).

The Domain.GRANT read is resolved before the package-scoped
shouldSkipUid filter: isolated grantees (bindIsolatedService) have no
package mapping and would otherwise be dropped to the real keystore2,
leaving three grant rows Unavailable. Caller-binding in resolveGrant()
is the real access gate, mirroring keystore2's grantee+id row keying.

Verified on-device (generate mode, build #237): all four grant rows
clean, TEE tamper score 28 -> 18, zero adjacent regression.

Refs Phase 9 .omc/plans/tee-fingerprint-phase-9-grant-plane-coherence.md
2026-05-30 13:42:31 +01:00
Enginex0andGitHub 8dd644a2e5 Merge pull request #27 from Andrea-lyz/pr/fix-gpay-include-unique-id
fix(interception): strip INCLUDE_UNIQUE_ID instead of rejecting when permission missing
2026-05-30 11:25:44 +01:00
Enginex0andGitHub a0d7418504 Merge pull request #26 from Andrea-lyz/fix/createOperation-key-id-not-found
fix(intercept): use ContinueAndSkipPost for KEY_ID createOperation NOT FOUND
2026-05-30 11:25:40 +01:00
Andrea-lyz ebe2040b2b fix(interception): strip INCLUDE_UNIQUE_ID instead of rejecting on missing permission
The INCLUDE_UNIQUE_ID gate in handleGenerateKey (introduced as part of the
PR157 AOSP-compliance work) returns PERMISSION_DENIED when the caller
holds neither SELinux gen_unique_id nor REQUEST_UNIQUE_ID_ATTESTATION.

This breaks Google Wallet card binding on real devices: Wallet's
generateKey carries INCLUDE_UNIQUE_ID without holding the permission, so
its attestation key request is rejected and Wallet surfaces the failure
as "this phone does not meet the security requirements for Google
Wallet". Symptoms reported by users: clearing GMS data only helps for a
few seconds before the state regresses; no card can be added.

Naive removal of the gate is not safe: AttestationBuilder honours
`includeUniqueId == true` by computing an HMAC-SHA256 unique_id and
embedding it in the attestation extension. With the gate gone, GMS
attestation flows that include the tag end up with a unique_id in the
extension that Play Integrity flags as inconsistent for the caller,
turning all three integrity verdicts red.

The fix here splits the difference: when the permission check fails,
silently strip the INCLUDE_UNIQUE_ID tag from the KeyParameter array
(and re-parse `parsedParams`) instead of rejecting the request. The key
generates normally, AttestationBuilder takes the
`else { ByteArray(0) }` branch, and the resulting attestation simply
omits the unique_id field, matching pre-PR157 behaviour, where the
tag effectively had no effect.

Verified on a device that previously failed Wallet binding on the
PR157 baseline:
  - Play Integrity: BASIC + DEVICE + STRONG all pass.
  - Google Wallet: card binding completes successfully.
  - Calls that DO hold the permission are unaffected (still emit
    unique_id as before).

The rest of the PR157 compliance work (CALLER_NONCE handling,
AuthorizeCreate ordering, USAGE_COUNT_LIMIT counters, effectiveParams
merging) is preserved.
2026-05-27 06:07:18 +02:00
Andrea-lyz a731b33f09 fix(intercept): use ContinueAndSkipPost for KEY_ID createOperation NOT FOUND
When handleCreateOperation receives a Domain.KEY_ID request for a key
not in our generatedKeys cache, it correctly forwards to the real HAL.
However, it previously returned TransactionResult.Continue, which lets
the post-handler run. The post-handler unconditionally registers an
OperationInterceptor on the IKeystoreOperation binder returned by real
keystore2. This interceptor then interferes with the caller's
operation (intercepting finish/abort/updateAad calls).

On devices where vendor daemons (e.g. fingerprint calibration) use
Domain.KEY_ID for their hardware-backed keys, this causes operation
failures, the OperationInterceptor races with the immediate
finish/abort call and may reject updateAad with INVALID_TAG if the
operation is not GCM mode.

Fix: return ContinueAndSkipPost (matching the existing Domain.APP NOT
FOUND path) so the post-handler never runs for operations on keys we
don't own. When the key IS in generatedKeys, we never reach this
return, we proceed to create a SoftwareOperation directly in
pre-transact.

Symptom: OnePlus engineering mode ultrasonic fingerprint calibration
hash retrieval fails with module enabled, works with module disabled.

Signed-off-by: Andrea-lyz <Andrea-lyz@users.noreply.github.com>
2026-05-26 22:53:28 +02:00
Enginex0 67c283b75b chore(release): publish v6.0.0-235 2026-05-20 07:05:02 +01:00
Enginex0 40f652f725 chore: bump versionCode to 235 2026-05-20 06:54:35 +01:00
Enginex0 25fd28a32b chore: bump versionCode to 233 2026-05-20 06:52:11 +01:00
Enginex0 108e027a98 fix: reorder hal-enforced auths to evade duck detector
Duck-Detector's generate-mode parser walks the reply parcel at
12-byte strides and matches (secLevel=256, tag=1, unionTag=32) at
slot[count-1]. Those bytes are actually KEY_SIZE.value=256 followed
by the next Authorization's presence flag and size header, an
emergent fingerprint from misaligned parsing, not a fake value.

Reorder toAuthorizations so PURPOSE/ALGORITHM/KEY_SIZE come first,
mirroring AOSP keymint reference HAL output. KEY_SIZE moves from
auth#4 to auth#2, so its int payload no longer lands at byte 224.
Verified across 31 fresh duckdetector probes: zero matches (was
15/36 before).

Also add gen-mode wire-byte diagnostic to InterceptorUtils and the
generate-mode entry point, debug-gated, dumping request and reply
parcels to /data/local/tmp for offline decode.
2026-05-20 06:52:04 +01:00
Enginex0 be4c418893 feat(service): clear logd size props on boot
Spawn a backgrounded subshell from service.sh that polls
sys.boot_completed once per second and, once set, blanks the
persist.logd.size variants (root, crash, system, main).

Runs alongside the existing supervisor fork so daemon startup is
unaffected. Idempotent across reboots: even if a previous boot
already cleared the props, setting them to empty again is a no-op.
2026-05-20 04:13:57 +01:00
Enginex0 0829bc98ca fix: intercept createOperation under any caller UID
Mirror of the change applied to GENERATE_KEY in 76e0337. Drop the
outer shouldSkipUid gate so handleCreateOperation always runs.

handleCreateOperation already gates on the cache lookup
(KeyMintSecurityLevelInterceptor.kt:298-326): domain=APP looks up
KeyIdentifier(callingUid, alias) in generatedKeys and forwards to
HAL on miss; domain=KEY_ID looks up by nspace filtered by uid and
forwards on miss. The outer UID gate was redundant when the lookup
hits, and harmful when a key was generated under a non-target-list
UID (e.g. Shizuku-routed callers at shell 2000 / root 0 after
76e0337).

Without this change, a BYO key created under Shizuku-routed UID
that the app later attempts to use (signing operation under the
same Shizuku-routed UID) would be forwarded to real HAL, which has
no record of our software key, producing a silent operation
failure instead of the simulator handling the sign internally.

Surfaced by adversarial audit. CREATE_OPERATION no longer needs the
outer gate because handleCreateOperation's own cache-or-forward
logic is the correct gate.
2026-05-20 03:59:10 +01:00
Enginex0 49763cdca7 fix: harden software gen symmetric branch errors
doSoftwareKeyGen's symmetric branch (AES/HMAC/3DES) previously did
two things wrong:

1. It accepted attestationKey != null silently and then ignored the
   reference: the symmetric path never consults attestationKey, so a
   caller asking for BYO on a symmetric key would have received a key
   with no certificate chain and no signal that BYO was dropped.
   Reject early with KEYMINT_INVALID_ARGUMENT matching real KeyMint
   HAL behavior.

2. The unsupported-algorithm path (e.g. 3DES, which has no JCA
   mapping in this branch) threw SECURE_HW_COMMUNICATION_FAILED
   (-49), the same numeric code InterceptorUtils labels as
   Error::Km(UNSUPPORTED_TAG). That confuses diagnosis since -49 is
   exactly the symptom the BYO fix series was just chasing. Use
   KEYMINT_INVALID_ARGUMENT (-38) which is what real KeyMint returns
   for unsupported algorithms in this context.

Surfaced by adversarial audit of f384871's broadened forceGenerate
gate, which now routes any attestationKey != null to software
unconditionally.
2026-05-20 03:58:53 +01:00
Enginex0 937058a7ff fix: return full keybox chain when BYO attest key misses
CertificateGenerator.generateCertificateChain selected
keybox.keyPair as fallback signer when getAttestationKeyInfo returned
null, but the chain assembly at line 115 still keyed on
"attestKeyAlias != null" and returned only listOf(leafCert). The
caller received a depth-1 chain signed by the keybox root with no
parent attached, structurally invalid.

Track whether the BYO lookup actually returned a key. On hit return
the depth-1 chain (caller holds the rest). On miss include
keybox.certificates so the chain is rooted.

Surfaced by adversarial audit of f384871, which broadened the
software-dispatch gate to all attestationKey != null requests.
Without this companion fix the miss path produces a malformed chain
where the previous code would have forwarded to HAL.
2026-05-20 03:58:20 +01:00
Enginex0 76e033700c fix: intercept BYO request under any caller UID
Shizuku-routed key attestation calls reach keystore2 with callingUid
set to shell (2000) or root (0) instead of the originating app's uid.
target.txt has no entry for those uids so shouldSkipUid returned true
in onPreTransact, and handleGenerateKey was never entered. The
transaction reached the real KeyMint HAL, which on older Keymaster 4.x
HALs rejects Tag::ATTEST_KEY with -49 UNSUPPORTED_TAG.

Move the shouldSkipUid gate from onPreTransact into handleGenerateKey
itself, evaluated after attestationKey and isAttestKeyRequest are
parsed. Skip only when the request is neither BYO nor attest-key-
purpose. CREATE_OPERATION keeps its outer-level gate (not part of the
BYO flow).

After this change, Shizuku-routed BYO requests enter dispatch, hit
forceGenerate=true via the prior simplification, and route to
doSoftwareKeyGen. Non-BYO non-attest calls from shell/root uids
still fall through to HAL unchanged.

D3 (option 2B) from /home/rootdev/.claude/plans/breezy-seeking-wozniak.md.
2026-05-20 03:47:16 +01:00
Enginex0 f384871f5a refactor: simplify forceGenerate dispatch gate
Any attest-key request or BYO request goes software unconditionally.
Drops the alias-Elvis lookup and the nspace fallback added by the
17c6312 -> eea6001 -> be04f16 revert/restore churn, both of which
silently missed when callers (Shizuku, post-restart sessions) did
not share an in-memory KeyIdentifier(uid, alias) with the attest
key originally registered in attestationKeys.

The (shouldPatch && isAttestKeyRequest) clause is subsumed by the
broader isAttestKeyRequest clause.

D2 from /home/rootdev/.claude/plans/breezy-seeking-wozniak.md.
2026-05-20 03:46:47 +01:00
Enginex0 1cea3ab8f1 refactor: remove AUTO TEE race dispatch
The race added in b3aa795 forwarded BYO attest-key requests to real
HAL on cache miss, producing -49 UNSUPPORTED_TAG on devices whose
persistent attest key alias survived in keystore2 across daemon
restarts but never re-entered our in-memory attestationKeys set.

AUTO resolution now relies solely on
ConfigurationManager.getPackageModeForUid (config/ConfigurationManager.kt:115),
which uses DeviceAttestationService.isTeeFunctional
(attestation/DeviceAttestationService.kt:67), a Kotlin by-lazy probe
evaluated once per daemon session. Matches upstream JingMatrix and
the v5.0-138 baseline.

Drops the AtomicReference<Boolean?> identity-equality compiler
warnings the race relied on.
2026-05-20 03:46:19 +01:00
Enginex0 a6452c3a26 fix(action): stream getevent for vol on Magisk
Users reported vol+ confirmation not registering on Magisk. The
prior backgrounded `getevent -qlc 1` + `kill -0` poll captured
the first kernel event of any type, then restarted on miss. With
six events per keypress (EV_MSC scan, EV_KEY DOWN, EV_SYN, then
release variants) and a 1s poll cadence, the 10s budget exhausts
before a DOWN sample lands. The chainfire note that piped getevent
breaks BusyBox grep applies under Magisk's ash standalone mode.

Replace with a single streaming `getevent -lq` matched inline
against `KEY_VOLUMEUP DOWN` / `KEY_VOLUMEDOWN DOWN`, wrapped in
`/system/bin/timeout 10`. Full paths bypass BusyBox aliasing.
2026-05-20 02:44:43 +01:00
Enginex0 da9a99bfbf chore(release): publish v6.0.0-224
Bump OTA pointer to v6.0.0-224 and document the 59-commit delta
from v6.0.0-162. Highlights:

- Duck Detector TamperScore-4 cleared on Xiaomi A16
- Self-sufficient spoofing (PatchLevelManager, BulletinPoller,
  PIF FileObserver, vbmeta complement props)
- Persistent symmetric key storage (PR #22)
- Action button hardened: vol+ confirm + 22-language i18n
- Build: JVM 21, gradle auto-rewrites update.json
2026-05-19 19:41:31 +01:00
Enginex0 b9defa70ae feat(action): i18n the clear-keys confirmation
Add action_i18n.sh with 22 language arms (ar, az, bn, de, el, es-ES,
fa, fr, id, it, ja, ko, pl, pt-BR, ru, th, tl, tr, uk, vi, zh-CN,
zh-TW) plus an English default. Detect device locale via
persist.sys.locale with ro.product.locale and ro.system.locale
fallbacks; split Chinese on Hans/Hant.

action.sh sources the helper at top and substitutes every echoed
literal with $(_msg <key>). The confirm() flow stays unchanged.

Wire action_i18n.sh into customize.sh's per-file extraction list so
the helper lands alongside action.sh in /data/adb/modules/tricky_store
at install time.

Pattern mirrors tricky-addon-enhanced/install_i18n.sh.
2026-05-19 18:43:46 +01:00
Enginex0 60fee55a0f feat(action): require vol+ confirm to clear keys
Users reported accidentally triggering the module action button from
the root manager UI, which wiped /data/adb/tricky_store/persistent_keys
and forced every attestation-dependent app to re-enroll.

Gate the destructive operation behind an explicit Vol+ confirmation
with a 10-second timeout. Vol- or timeout aborts and preserves keys.
Pattern mirrors tricky-addon-enhanced/install_func.sh choose_automation
(getevent -qlc 1 polled per second), but defaults to cancel on timeout
because the destructive default of the previous script is the
behavior we are correcting.
2026-05-19 18:31:16 +01:00
Enginex0 182450d3b4 fix(intercept): cache non-attested keys for parity
After PR #22 and the AUTO-mode extension started caching attested
generateKey responses in teeResponses, KEY_ID getKeyEntry lookups for
attested keys returned from memory in ~1ms while non-attested keys
forwarded to real keystore2 took ~1.5ms.
TimingSideChannelProbe measured the 1.55x ratio against its 1.1x
threshold and flagged the asymmetry.

Forward non-attested generateKey to real keystore2 with post-hook
enabled (Continue instead of ContinueAndSkipPost), and extend the
GENERATE_KEY post-hook to cache no-chain responses into teeResponses.
The KEY_ID lookup added in the previous commit now resolves both
paths from memory at matched latency. Cert-chain patching is skipped
for the no-chain branch because there is no attestation extension to
rewrite.
2026-05-19 18:01:06 +01:00
Enginex0 6d11362034 feat(intercept): resolve KEY_ID via teeResponses
PR #22's KEY_ID lookup at Keystore2Interceptor.onPreTransact only
scanned generatedKeys, which is populated exclusively by
doSoftwareKeyGen (GENERATE mode and the attest-key override path).
For AUTO packages on TEE-good devices the real TEE handles
generateKey and the response lands in teeResponses via the existing
GENERATE_KEY_TRANSACTION post-hook, so getKeyEntry(KEY_ID) by
TimingSideChannelProbe missed and the call leaked SSE.

Add findTeeResponseByKeyId companion helper that mirrors
findGeneratedKeyByKeyId's shape but scans teeResponses keyed by
response.metadata.key.nspace. Wire it as a fallback after the
existing PR #22 lookup. Behavior unchanged for GENERATE packages.
2026-05-19 17:45:54 +01:00
Enginex0 20a58f8a58 fix(config): isAutoMode reads raw package mode
getPackageModeForUid collapses AUTO -> PATCH/GENERATE at the call site
based on DeviceAttestationService.isTeeFunctional, so isAutoMode never
observed Mode.AUTO and always returned false. That made the
isAuto-gated dispatch arms in KeyMintSecurityLevelInterceptor and the
entire raceTeePatch path unreachable.

Iterate packageModes directly with the same priority order as
getPackageModeForUid: first non-null mode wins. AUTO returns true,
PATCH and GENERATE return false. Activates raceTeePatch for AUTO
packages on the first generateKey per security level.
2026-05-19 17:45:37 +01:00
Enginex0 5a12970426 Merge PR #22: persist symmetric keys + byte-identical metadata 2026-05-19 17:00:14 +01:00
Enginex0 cfbba4cddb chore(release): bump update.json to v6.0.0-211 2026-05-19 17:00:04 +01:00
Enginex0 c4ea3e0bb2 fix(intercept): normalize passthrough SSE shape
Real keystore2 SSE replies passed through SkipTransaction kept
the daemon's anyhow chain in the parcel string. Run those replies
through the same synthesizer used for module-generated SSEs so
wire shape stays consistent regardless of source.
2026-05-19 17:00:04 +01:00
Enginex0 ea37792653 fix(util): drop StrongBox attest version hardcode
StrongBox was pinned to attest/keymaster v300 regardless of SDK,
which mismatches devices shipping KeyMint v400 on Android 16.
Fall through to the same SDK_INT->version map used by the TEE
path so StrongBox reports the device-correct tier.
2026-05-19 17:00:04 +01:00
Enginex0 80f65b02ac fix(intercept): synthesize canonical SSE messages
writeString(null) on service-specific exception replies left the
message word as 0xFFFFFFFF, which diverges from AOSP keystore2's
anyhow-formatted "Error::Rc(NAME)" / "Error::Km(NAME)" strings.
Map known ResponseCode/KeyMint codes to their canonical names so
the wire shape matches a stock TEE reply.
2026-05-19 17:00:04 +01:00
Enginex0 be04f16a50 fix(shim): restore nspace attest key lookup
Reverts eea6001. The nspace attestation key lookup was part of
the score-4 baseline and closes a real NPE on KEY_ID-domain
references where the alias field is null. Reverted in error
during the score-4-to-14 rollback; restoring to match the
working baseline.
2026-05-19 14:35:19 +01:00
Enginex0 9a7e4d7251 fix(intercept): restore updateAad SSE injection
Reverts 180dc03. The updateAad SSE injection landed earlier as
the F1 quirk fix and was part of the score-4 baseline on mt6768.
It was reverted in error during the score-4-to-14 rollback; the
actual regression driver was the uncommitted DELETE_KEY absorber
which has already been dropped. Restoring to match the working
baseline.
2026-05-19 14:35:12 +01:00
Enginex0 180dc039cd fix(intercept): revert updateAad SSE injection
Reverts 5d33701. Unconditionally injecting SSE(INVALID_TAG) on
non-AEAD updateAad matched the AOSP TA spec but diverged from
real-device behavior on mt6768, which returns silently. A
behavior-fingerprint detector on the Xiaomi probe flagged the
divergence and the Tamper score climbed from 4 to 14, with a
second detector raising key-tamper. Roll back to investigate a
device-conformant approach.
2026-05-19 14:29:39 +01:00
Enginex0 eea60018ca fix(shim): revert nspace attestation key lookup
Reverts 17c6312. The KEY_ID-domain alias-null branch targeted
duck-detector's timing-side-channel WARN, but the WARN persisted
in subsequent testing and the combined fix attempts pushed the
Tamper score from 4 to 14 with a new key-tamper detection on a
second detector. Roll back to the 2e55d56 baseline to investigate
from a clean state.
2026-05-19 14:29:29 +01:00
Enginex0 17c63120f5 fix(shim): resolve attest key by nspace
The probe in duck-detector's TimingSideChannelProbe chain calls
generateSigningKey with a KEY_ID-domain attestation key reference
where alias is null. KMSLI.kt:498 fed that null alias into the
non-null String param of KeyIdentifier, triggering an NPE that
the outer runCatching wraps into a ServiceSpecificException(-49).
Any SSE crossing the binder boundary carries the Parcel.read/
createException(OrNull) stack frames, which the detector's
TeeReportReducer at line 2635-2641 matches verbatim to emit
"Captured private binder exception during timing skip".

Branch on the alias before constructing KeyIdentifier: when
alias is present, retain the existing isAttestationKey lookup;
when null (Domain::KEY_ID), scan attestationKeys for a matching
uid + nspace via generatedKeys. Mirrors AOSP keystore2's own
dispatch in database.rs (Domain::APP by alias, Domain::KEY_ID
by key id).
2026-05-19 13:32:54 +01:00
Enginex0 5d33701601 feat(intercept): inject SSE on non-AEAD updateAad
Real MediaTek mt6768 KeyMint silently returns OK on non-AEAD
updateAad, contradicting AOSP's mandate at
system/keymint/ta/src/operation.rs:430-446 to throw InvalidTag
when aad_allowed is false. Duck-detector flags this divergence
as "updateAad mismatch" in OperationErrorPathProbe.

Wire UPDATE_AAD into OperationInterceptor and inject
ServiceSpecificException(INVALID_TAG) when create params
indicate non-AEAD. AEAD (BlockMode.GCM) passes through to real
KeyMint untouched so AES-GCM round-trips remain valid.
2026-05-19 13:05:36 +01:00
Enginex0 2e55d56426 feat(spoof): add TEE op latency floor
Attested keystore operations finishing faster than non-attested
ones on the same device is a timing inversion that detectors
score against TEE coherence. Floor TEE op latency at 4ms to
preserve the natural ordering.
2026-05-19 08:04:05 +01:00
Enginex0 c69e2d47b8 feat(spoof): fill absent vbmeta complement props
invalidate_on_error, avb_version, hash_alg, and size are sibling
props of vbmeta.device_state. When device_state is present but
its complements are missing, the partial set is itself a
detection signal. Fill with safe defaults if absent; never
overwrite when present.
2026-05-19 08:03:04 +01:00
Enginex0 3b1e908670 feat(spoof): skip absent boot-lock props
Creating a vendor-specific boot prop on a device that never had
one is itself a detection signal. Existence-guard the four
boot-lock targets so absent props stay absent.
2026-05-19 08:01:10 +01:00
Enginex0 259d27c3c7 feat(spoof): include vbmeta.device_state
Live install on the user's Pixel surfaced a fourth bootloader-lock
prop the source plan never enumerated. The Chunqiu-style detector
card listed three indicators on its bootloader-unlock row and
flagged red because ro.boot.vbmeta.device_state remained unlocked
even after ro.boot.verifiedbootstate, ro.boot.flash.locked, and
ro.boot.veritymode were all spoofed.

Add ro.boot.vbmeta.device_state=locked to BootStateManager.targets.
Verified post-reboot on device: all four props now report the
spoofed values.
2026-05-19 06:06:58 +01:00
Enginex0 d15abe3c62 fix(spoof): emit validation_rejected status
The source plan's bulletin-history schema declared a four-value
status enum: success, network_error, parse_error,
validation_rejected. The poller only ever wrote the first three;
when PatchLevelManager.updateTo silently rejected a date for bad
format, floor violation, past/future bounds, or atomicWrite IO
error, the history still recorded status=success and applied=true
because applied was set from isNewer before updateTo ran.

Make updateTo return Boolean. Wire the result through fetchAndParse
so a rejected apply lands as status=validation_rejected with
applied=false and an error string identifying the date that failed.
Closes the only spec gap from fancy-humming-firefly.md uncovered
during the source-plan cross-audit.
2026-05-19 05:55:18 +01:00
Enginex0 99957e18c4 fix(spoof): validate currentPatch against date regex
currentPatch returned the raw system= value unchanged. A
malformed value such as system=tomorrow flowed into the
lexicographic comparison `date > current`, where any well-formed
YYYY-MM-DD from the bulletin sorts before lowercase letters, so
the poller permanently judged its date "not newer" and never
applied an update. Validate the read value against the date
pattern; on mismatch, log a warning and treat as passive.
2026-05-19 05:36:36 +01:00
Enginex0 393ae6073f docs(spoof): explain MAX_FUTURE_DAYS rationale
The 60-day window covers Pixel monthly bulletin cadence plus
pre-announcement slip but rejects far-future hostile inputs. The
prior bare constant left readers wondering whether the value was
arbitrary; the KDoc closes that loop.
2026-05-19 05:35:40 +01:00
Enginex0 fbe819d6d8 fix(spoof): serialize concurrent applyToProps calls
applyToProps reaches Runtime.exec("resetprop", name, value) twice
per call, once for system and once for vendor. Boot-time
initialize, BulletinPoller's handler thread, and the PifObserver
inotify thread can all reach applyToProps independently. Two
concurrent invocations for different dates could interleave such
that system and vendor end up with mismatched values. Synchronize
on the singleton so each apply runs to completion before the
next begins.
2026-05-19 05:35:28 +01:00
Enginex0 ad1a9b8c32 fix(spoof): propagate read errors out of mergedContents
A read failure (partial-write race during user edit, SELinux
denial, or any other IOException) used to fall through the
runCatching and rewrite the file with only the global block,
silently destroying every existing [pkg] override. Drop the
runCatching so the failure bubbles to atomicWrite, where the M3
guard in updateTo logs and returns without applyToProps, leaving
both file and props untouched.
2026-05-19 05:35:13 +01:00
Enginex0 d146ad8234 fix(spoof): require = in global key-assignment check
isGlobalKeyAssignment treated any bare line whose first token
matched system/boot/vendor/all as a global assignment and
stripped it. A user line of literally "all" or "system" written
without a value (malformed config but reachable) thus got eaten
on the next atomicWrite. Require '=' in the trimmed line before
treating it as a key=value assignment.
2026-05-19 05:34:59 +01:00
Enginex0 e737453e02 fix(util): skip day synthesis for YYYY-MM input
parsePatchLevelValue silently synthesized day=01 when input was
6 chars (YYYY-MM) and caller wanted 8-digit YYYYMMDD. If
ro.vendor.build.security_patch ever returns YYYY-MM on a target
device (older Samsung firmware does), the synthesized day
disagrees with the real bulletin day -- a detection fingerprint.
Return null so getRealDevicePatchLevelInt falls through to
Build.VERSION.SECURITY_PATCH, which is always YYYY-MM-DD.
2026-05-19 05:17:40 +01:00
Enginex0 429e033b7f fix(interception): emit KEY_SIZE for EC keys
Revert 59dfb2e. AOSP 15 KeyMint reference TA at
system/keymint/common/src/tag/info.rs:61-89 lists both Tag::EcCurve
and Tag::KeySize in KEYMINT_ENFORCED_CHARACTERISTICS, and
check_ec_params at common/src/tag.rs:632 says "Key size is not
needed, but if present should match the curve" -- the TA passes
through whatever the caller supplies and keystore2 supplies both
for EC keys per KeyMintBenchmark.cpp:234,259. Omitting KEY_SIZE
made the simulator's characteristics list shorter than real
hardware, a detection fingerprint.
2026-05-19 05:15:51 +01:00
Enginex0 f706ffdf64 feat(spoof): hot-reload PIF via FileObserver
PatchLevelManager.initialize ran once at boot; PIF edits required
a reboot to take effect. Add a FileObserver on
/data/adb/modules/playintegrityfix for CLOSE_WRITE, MOVED_TO, and
DELETE on the four known PIF filenames, re-resolving and applying
the new date when any of them changes. Skip the watch when the
PIF dir is absent so the daemon does not start a stale inotify
node before the module is even installed.
2026-05-19 05:14:32 +01:00
Enginex0 bbeab27f11 fix(spoof): skip empty PIF source files
resolvePifPatch selected the last existing file regardless of
size. A 0-byte file picked up by lastOrNull caused JSONObject("")
to throw, the catch silently fell back to SystemProperties, and a
preceding non-empty PIF was ignored. Filter out zero-length files
so the lookup walks past them to the next valid candidate.
2026-05-19 05:10:59 +01:00
Enginex0 864c8841c1 fix(spoof): guard atomicWrite errors in updateTo
writeText and Files.move can throw IOException, SecurityException,
or AtomicMoveNotSupportedException. The exception previously
propagated through updateTo into BulletinPoller.fetchAndParse's
broad catch, which mislabelled it as "network_error" in the
history. Wrap atomicWrite, log the real failure, and return
before resetprop so the on-disk file and live props stay
consistent on failure.
2026-05-19 05:10:34 +01:00
Enginex0 4e55ba4e77 fix(spoof): preserve [pkg] sections in atomicWrite
atomicWrite previously overwrote the entire security_patch.txt
with only the three global lines, destroying the per-package
[pkg] overrides supported by ConfigurationManager. Read the
existing file, strip only global system/boot/vendor/all key
assignments, prepend the refreshed global block, and append
everything else (comments, blanks, all [pkg] sections) verbatim.
2026-05-19 05:09:39 +01:00
Enginex0 c511cc48e9 fix(spoof): respect system=prop passive default
PatchLevelManager.initialize previously called updateTo, which
overwrote security_patch.txt with explicit dates and destroyed
the Phase 1 default of system=prop. Split prop application into
a new private applyToProps so initialize only resetprops; never
writes the file. BulletinPoller now treats currentPatch() == null
(the signal for system=prop or missing/blank) as passive and
skips updateTo. The file becomes user-owned config; props track
PIF or the device default.
2026-05-19 05:06:59 +01:00
Enginex0 37179bbbfc fix(spoof): order spoofers before keystore hook
BootStateManager.apply and PatchLevelManager.initialize ran after
initializeInterceptors, so keystore2 cached ro.boot.* and
ro.build.version.security_patch from the un-spoofed values during
hook init. Move both before the interceptor so the hook sees the
spoofed snapshot. ConfigurationManager stays between them since
it only loads files and is independent of prop state.
2026-05-19 05:04:36 +01:00
Enginex0 a0e7fcf400 fix(spoof): isolate BulletinPoller.start failure
BulletinPoller.start ran inside App.main's outer try{...} catch
that rethrows, so any HandlerThread or Looper init failure killed
the daemon including keystore interception. Wrap the start call in
its own try so a poller failure logs and falls through, leaving
the rest of the pipeline alive.
2026-05-19 05:03:27 +01:00
Enginex0 c4e0a6ee48 fix(spoof): wrap pollOnce in umbrella try/catch
fetchAndParse and appendHistory each catch their own exceptions,
but scheduleNext can throw IllegalStateException if the Looper is
torn down or any helper raises an unanticipated error. Without an
outer catch, the reschedule chain broke and the poller stayed dead
until reboot. Wrap the entire body so a thrown exception still
attempts to schedule the next poll.
2026-05-19 05:03:19 +01:00
Enginex0 a79d7e3637 fix(spoof): allow UDP egress for DNS resolution
HttpsURLConnection resolves bulletin.source via getaddrinfo, which
uses UDP/53 first. Without UDP socket rules the resolver fails
before TCP even attempts, killing BulletinPoller silently on
enforcing SELinux kernels. Mirror the existing TCP rules onto UDP
for ksu and magisk.
2026-05-19 05:03:12 +01:00
Enginex0 8cb8616068 fix(spoof): bound future patch dates in updateTo
PatchLevelManager only rejected dates more than ~1 year in the
past. A MITM serving <td>2099-12-31</td> from a spoofed bulletin
response slipped through validation and got written to
security_patch.txt plus resetprop'd. Add a 60-day upper bound past
today using LocalDate.plusDays so month boundaries are handled
correctly. The existing past bound stays.
2026-05-19 05:01:51 +01:00
Enginex0 4b4b7ec626 chore(scripts): make package.sh find user-local cargo
Gradle's buildRustCertgen task uses commandLine("cargo", ...), which
ProcessBuilder resolves against the daemon's inherited PATH rather
than the env injected via Exec.environment(). Non-login shells (CI,
IDE-spawned terminals, fresh tmux panes) don't source the profile.d
hook that prepends ~/.cargo/bin, so the daemon dies with
"A problem occurred starting process 'command 'cargo''" even when
rustup is installed. Prepending ~/.cargo/bin at script entry makes
the script self-contained regardless of how the shell was launched.
2026-05-19 04:11:46 +01:00
Enginex0 439a9d8254 feat(spoof): periodic bulletin refresh via BulletinPoller
BulletinPoller fetches the Pixel security bulletin index page on
its own HandlerThread with 5s/30s/2m/10m/30m bootstrap backoff,
then 24h steady cadence. The first <td>YYYY-MM-DD</td> match is
the latest published patch; newer-than-current dates flow through
PatchLevelManager.updateTo for validation + atomic write + resetprop.

Persists the last 10 attempts to last_bulletin_fetch.json (atomic
rename) with status, http_code, parsed_date, applied, and error
fields so operators can audit history without logcat.

Sepolicy rule appends TCP-socket allow rules for both ksu and
magisk source domains so HttpsURLConnection survives SELinux
enforcement on either root provider. Uninstall.sh cleans the
three new artifacts.
2026-05-19 04:01:30 +01:00
Enginex0 128783dfd4 feat(spoof): PatchLevelManager with PIF resolution
PatchLevelManager resolves the active security patch from
PlayIntegrityFix via the same six-path override chain as
Tricky-Addon's get_extra.sh (pif.json/pif.prop/custom.pif.*,
later entries override earlier ones). Falls back to live
ro.build.version.security_patch when no PIF source is present.

updateTo() validates YYYY-MM-DD format, rejects dates below
2020-01-01 or more than one year older than today, then atomically
stages security_patch.txt with explicit system/boot/vendor dates
and resetprops ro.build.version.security_patch plus
ro.vendor.build.security_patch. Cert tags 706/718/719 then encode
consistent dates via AndroidDeviceUtils.parsePatchLevelValue
(YYYYMM for OS, YYYYMMDD for VENDOR/BOOT per AOSP Tag.aidl).

Wired from App.main after BootStateManager.apply().
2026-05-19 03:59:28 +01:00
Enginex0 da9ade723d feat(spoof): resetprop bootloader lock at boot
BootStateManager.apply() runs from App.main after ConfigurationManager
init and sets ro.boot.verifiedbootstate=green, ro.boot.flash.locked=1,
ro.boot.veritymode=enforcing via resetprop so the attestation
extension's hardcoded verifiedBootState=Verified agrees with what
detectors observe via getprop.

Adds an internal AndroidDeviceUtils.setProperty(name, value: String)
overload so the existing private ByteArray variant stays exclusive
to vbmeta digest persistence while config-package callers can set
plain string props without hex encoding.

Closes documented vulnerability D44 (countermeasure-matrix.md).
2026-05-19 03:57:23 +01:00
Enginex0 5a6d336fa8 feat(install): drop default security_patch.txt at install
Out-of-box install seeds /data/adb/tricky_store/security_patch.txt
with system=prop so TEESimulator passively mirrors live device props.
ConfigurationManager auto-forces boot=prop+vendor=prop when system=prop
(ConfigurationManager.kt:253-256), giving full coverage with one line.

Eliminates Chunqiu code 26 (Tampered Attestation Key) on out-of-box
installs without requiring the Tricky-Addon-Update-Target-List
companion module.
2026-05-19 03:53:53 +01:00
Enginex0 813ff814ea build(gradle): auto-rewrite update.json on packaging
Previously module/update.json had to be hand-bumped to keep
versionCode and zipUrl in lockstep with module.prop's expanded
$gitCommitCount. Wire a refreshUpdateJson task to the
prepareModuleFiles${variant} pipeline so every zipDebug/zipRelease
regenerates the file from current verName and gitCommitCount.
2026-05-19 03:25:58 +01:00
Enginex0 ee6770bcc7 build(gradle): expose cargo bin path to rust task
Gradle's exec environment does not inherit the user's interactive
shell PATH, so cargo-ndk could not find cargo even when it lived in
~/.cargo/bin. Prepend ~/.cargo/bin to PATH for buildRustCertgen so
the Rust toolchain resolves reliably from any shell.
2026-05-19 03:24:11 +01:00
Enginex0 bf29946fdc build(gradle): set kotlin jvmTarget to JVM_21
Java sourceCompatibility/targetCompatibility were already 21, but
the Kotlin compiler defaulted to JVM 17 bytecode, producing a
toolchain skew warning on every build. Align the Kotlin target to
match the Java target.
2026-05-19 03:23:55 +01:00
Enginex0 59dfb2eb85 fix(interception): omit KEY_SIZE for EC keys with ecCurve
AOSP keystore2 attestation lists KEY_SIZE only when there is no
authoritative key-shape tag. For EC keys the curve already pins the
key size, so emitting both KEY_SIZE and EC_CURVE is a forgery
fingerprint. Guard the createAuth call accordingly.
2026-05-19 03:23:10 +01:00
Enginex0 ee7e5ba698 fix(interception): drop delete marker on key regen
Regenerated keys were being filtered as deleted because the
deletion marker in Keystore2Interceptor.deletedSoftwareKeys
survived past the regen call. Clear the marker at both software
and TEE generation paths so the next getKeyEntry returns the
fresh key instead of NOT_FOUND.
2026-05-19 03:22:42 +01:00
Enginex0 04c310e8d3 wip(keystore): add F1 Phase A diagnostic logs in updateAad path
Instrumentation-only. Logs entry (primitive class, callingUid, input size)
and throwable propagation (class, SSE error code, message, stack top) in
both SoftwareOperation.updateAad and SoftwareOperationBinder.updateAad.
Intended to distinguish F1 hypotheses H1 (binder swallows SSE) vs H3
(AIDL signature drift) once duck's probe key is routed through our
simulator instead of the real TEE.
2026-05-19 00:27:38 +01:00
Yunzhe LiaoandGitHub f4d72a641e Merge branch 'Enginex0:main' into fix/persistence-and-keystore-issues 2026-05-18 15:01:17 +02:00
Andrea-lyz 5803039309 review: clean error codes, defensive symmetric fallback, v3 doc
Address Copilot/CodeRabbit review feedback on the persistence PR.

1. SoftwareOperation: replace requireNotNull(keyPair) in SIGN/VERIFY/AGREE_KEY
   branches with ServiceSpecificException(invalidArgument). The original
   requireNotNull throws IllegalArgumentException, which the binder layer
   wraps as KEYMINT_UNKNOWN_ERROR, defeating the goal of surfacing a
   clean keystore-style error. Aligns with how ENCRYPT/DECRYPT already
   handle missing key material in the same when block.

2. loadPersistedKeys: when a symmetric record has empty metadataBytes (e.g.
   a save where Parcel.marshall() was empty for any reason), rebuild a
   minimal KeyMetadata from PersistedKeyData primitive fields instead of
   skipping the record. Skipping silently dropped the AES key, which is
   the same 'logged out after reboot' behavior the PR is trying to fix.
   The rebuilt metadata is structurally minimal but preserves the secret
   material, which is the dominant correctness concern.

3. Comment fix: rebuildResponseFromRecord docs referred to 'v2 metadata
   snapshot', the format in this PR is v3.
2026-05-16 19:10:55 +02:00
Andrea-lyz 8b0acb649d fix: persist symmetric keys + byte-identical metadata; stop wiping keys on keybox edits
Five issues that together caused keystore-pinned apps to be silently
logged out across reboots and config changes. All flow from the same
root cause: GeneratedKeyPersistence loses information on save -> reload.

1. Symmetric keys (AES, HMAC, 3DES) were never persisted at all
   - GeneratedKeyPersistence.save only accepted KeyPair, ignoring SecretKey
   - AndroidX security MasterKey (AES-GCM-256) regenerated on every
     reboot, making EncryptedSharedPreferences undecryptable
   - Apps that wrap session tokens in EncryptedSharedPreferences
     interpret this as session expiry and force a relogin

2. Restored KeyMetadata authorizations differed from generation-time bytes
   - loadPersistedKeys rebuilt KeyMintAttestation with mostly null/empty
     fields, so toAuthorizations emitted a different tag set after
     reboot vs. at generateKey time
   - Apps that fingerprint metadata across keystore calls saw a
     "changed key"

3. certificate / certificateChain split could shift after restore
   - buildKeyEntryResponse called updateCertificateChain on the rebuilt
     metadata, which is allowed to repartition leaf vs. chain bytes
   - Apps with strict leaf fingerprint checks saw a "changed cert"

4. Touching ANY .xml under /data/adb/tricky_store wiped every cached key
   - ConfigObserver called clearAllGeneratedKeys() which also calls
     GeneratedKeyPersistence.deleteAll()
   - Editing keybox.xml (or any unrelated .xml) thus deleted every
     persisted key on disk
   - Even the keybox-cache argument does not justify wiping per-app keys:
     patched chains alone are stale, raw keypairs are not

5. SoftwareOperation NPE when restored keyParams missed PURPOSE tag
   - Init dereferenced keyPair!! before checking purpose, so a
     half-restored record crashed instead of producing a clean error

Single on-disk format (FORMAT_VERSION = 3) covers everything: PKCS8
private key bytes for asymmetric, raw secret bytes for symmetric, plus
the byte-identical KeyMetadata parcel snapshot so authorizations
restore exactly. Earlier dev-only formats are silently skipped by the
loader; the next generateKey for those aliases re-creates them in v3.

ConfigObserver now calls invalidatePatchedChains() instead of
clearAllGeneratedKeys() on .xml edits - only the chain cache is
stale, not the underlying keypairs.

Tested on OnePlus 13 (Android 16, KSU 3.2.4):
- Apps survive force-stop + cold reboot without losing keystore state
- Apps survive keybox.xml edits / replacements (touch, sed, cp -mv)
- Tamper score still 4 (CONSISTENT) on Duck Detector
- KeyAttestation chain output unchanged
2026-05-16 18:43:09 +02:00
Enginex0andGitHub 15a1c0ca40 Merge pull request #21 from Andrea-lyz/fix/duck-detector-generate-fingerprint
fix: defeat Duck Detector "generate-mode fingerprint" probe
2026-05-15 16:47:44 +01:00
Andrea-lyz 4ecbfc7259 fix: use SecurityLevel.KEYSTORE in createSwAuth to match real hardware
Duck Detector's 'TEE Simulator generate-mode fingerprint' probe scans the
generateKey reply parcel for a 16-byte marker where the securityLevel byte
is 0x00 (SOFTWARE). Real KeyMint HAL uses 0x64 (KEYSTORE=100) for
keystore-enforced metadata (creation time, user ID, etc.).

This single-line change aligns with real hardware behavior and defeats
the probe. Tested on OnePlus 13 (Android 16, KSU 3.2.4):
- Before: 'TEE Simulator generate-mode fingerprint: Matched' (score 50)
- After:  'No TEE Simulator generate-mode fingerprint observed' (score 4)

Reference: https://github.com/eltavine/Duck-Detector-Refactoring/commit/e368038
2026-05-15 16:23:05 +02:00
github-actions[bot] db882ec326 chore(release): bump update.json to v6.0.0-162 [skip ci] 2026-03-31 18:47:49 +00:00
Enginex0 37454e6262 ci(release): upload versioned assets only, auto-update zipUrl 2026-03-31 19:41:12 +01:00
github-actions[bot] feee04b95d chore(release): bump versionCode to 160 [skip ci] 2026-03-31 18:32:18 +00:00
github-actions[bot] 161be9fc36 chore(release): bump versionCode to 159 [skip ci] 2026-03-31 18:18:59 +00:00
Enginex0 23b5497f57 ci(release): auto-bump versionCode and add stable asset names
The release job now uploads assets with stable names
(TEESimulator-RS-Release.zip) alongside versioned ones, so the
/latest/download/ URL in update.json always resolves. versionCode
in update.json is bumped to the commit count automatically after
each release, committed with [skip ci] to prevent loops.
2026-03-31 19:12:46 +01:00
Enginex0 0081e93eb0 docs(changelog): add AUTO mode banking app fix to v6.0.0 notes 2026-03-31 18:59:28 +01:00
Enginex0 6ebc6d0bb1 fix(config): restore AUTO mode resolution for bare target entries
v6.0 changed bare target.txt entries from AUTO to GENERATE, breaking
apps like BHIM that need TEE-backed attestation keys. Restore AUTO as
default and resolve it at config level (PATCH if TEE works, GENERATE
if not) to bypass the non-deterministic raceTeePatch path.
2026-03-31 18:53:18 +01:00
Enginex0 0f749dded9 docs(readme): rewrite for TEESimulator-RS v6.0.0
Fix all links from old TEESimulator repo, strip emoji clutter,
add v6.0.0 changelog, update update.json to point at new repo.
2026-03-26 12:34:36 +01:00
Enginex0 23d40b8975 chore(version): bump to v6.0.0 2026-03-26 12:28:23 +01:00
Enginex0 80387b9516 fix(certgen): self-signed certs for no-challenge keys per AOSP spec
AOSP ta/src/keys.rs:451-478 requires self-signed leaf (depth 1) when
no attestation challenge is provided. Both Kotlin and Rust paths now
return subject==issuer, signed by generated key, no attestation
extension. Adds cert chain trace logging in debug builds.
2026-03-26 12:28:17 +01:00
Enginex0 7d470cf830 fix(operation): pass operation-time params through to CipherPrimitive
createOperation was building effectiveParams from key-generation params
but dropping operation-time fields (nonce, blockMode, padding,
minMacLength). This caused GCM decrypt to fail with
"IV must be specified in GCM mode" since the nonce from the begin call
never reached CipherPrimitive.

Also adds nonce field to KeyMintAttestation and handles GCM/CBC/CTR IV
initialization in CipherPrimitive.
2026-03-26 05:03:01 +01:00
Enginex0 76461ad39a fix(certgen): omit attestation extension when no challenge provided
AOSP KeyMint only includes the attestation extension (OID
1.3.6.1.4.1.11129.2.1.17) when ATTESTATION_CHALLENGE is present.
Without a challenge, generateKey produces a plain self-signed cert.
Our code unconditionally added the extension, which behavioral
probes detect by generating a key without a challenge and checking
for the OID.

Fixes both the Rust native-certgen and BouncyCastle paths.
Also skips AAID computation when no challenge is provided,
matching keystore2 security_level.rs:457 behavior.
2026-03-26 04:22:22 +01:00
Enginex0 08e8c769ab fix(interception): patch authorizations on import-overwrite path
The retained cert chain was applied to response metadata but the
authorizations array was left unpatched, allowing a detector to compare
metadata patch levels against cert attestation values and spot the
divergence. Refs upstream JingMatrix #164.
2026-03-26 02:49:01 +01:00
Enginex0 45d54f9369 feat(config): default bare target entries to GENERATE mode
PATCH and AUTO modes inherit the real TEE's attestation quirks (epoch 0
cert dates, version mismatch, missing USAGE_COUNT_LIMIT) which can't be
fixed in post-patch. GENERATE mode builds attestation from scratch with
full control over every field. Users who want real TEE key generation
can still use the ? suffix for explicit PATCH mode.
2026-03-26 02:32:14 +01:00
Enginex0 75acfb9235 perf(logging): add rate limiter and lazy formatting to SystemLogger
Under binder stress, debug builds hammered logd with 6-7 syscalls per
keygen, causing thread contention that spiked ping latency past G10b's
threshold. Rate-limit debug/info/verbose to 15 msgs per 1s window with
atomic CAS on window boundaries. Warnings and errors always pass.

Expensive verbose calls in AttestationBuilder, AttestationPatcher, and
DeviceAttestationService now use lazy lambdas so ASN.1 formatting only
runs when the message will actually be emitted.
2026-03-26 02:11:17 +01:00
Enginex0 1959f0a780 perf(interception): optimize ioctl hook hot path for ping latency
Strip LOGV from the buffer parse loop and add a fast pre-check that
peeks at the first binder command before entering the full parser.
Pings, ref ops, and looper management produce no BR_TRANSACTION, so
their buffers can be skipped entirely. Adds __builtin_expect hint
on the transaction branch for better pipeline prediction.

Drops G2 binder ping ratio from 3.95x to 1.17x in debug builds.
2026-03-26 01:59:32 +01:00
Enginex0 7a98b35666 fix(interception): route oversized transactions to software gen
The 256KB native size guard skipped interception entirely for oversized
transactions, causing them to reach the real TEE which returns different
attestation values. This inconsistency is exactly what G10 detects.

Oversized requests now flow through to the Kotlin layer where they hit
doSoftwareKeyGen via the forceGenerate flag. Software gen produces
consistent attestation without forwarding to the real TEE, preserving
the anti-amplification defense that the original guard intended.
2026-03-26 01:30:07 +01:00
Enginex0 0b8985d8bd fix(interception): resolve B3, C2, F1 and harden AUTO mode
B3: AttestationPatcher now accepts optional notBefore/notAfter overrides
so the PATCH path honors CERTIFICATE_NOT_BEFORE instead of inheriting
the real TEE's epoch 0.

C2: getKeymasterVersion delegates to getAttestVersion directly, ensuring
attestationVersion == keymasterVersion regardless of cache source.

F1: Remove incorrect EC+DECRYPT guard in AuthorizeCreate that returned
UNSUPPORTED_PURPOSE instead of INCOMPATIBLE_PURPOSE.

AUTO mode: Replace volatile teeFunctional boolean with AtomicReference
tri-state (null/true/false) so the first race winner locks the path for
all subsequent requests, preventing mixed attestation under concurrency.
2026-03-26 01:27:43 +01:00
Enginex0 b3aa7950c5 feat(interception): add AUTO mode TEE race for G10 attestation consistency
AUTO mode now races TEE hardware against software generation via
CompletableFuture. If TEE succeeds, the cert chain is patched and
cached in teeResponses before returning, making attestation
stress-resilient. If TEE fails, software fallback is used.

ConfigurationManager no longer resolves AUTO at config time; it
passes Mode.AUTO through to KeyMintSecurityLevelInterceptor for
runtime dispatch. shouldPatch() returns true for both PATCH and
AUTO modes. TEE status file persistence removed entirely.

Aligns handleGenerateKey with upstream PR #157 three-way dispatch:
forceGenerate, raceTeePatch, or hardware forwarding with post-patch.

Hardware keygen rate limiting removed (replaced by raceTeePatch for
AUTO, plain Continue for PATCH). Attest key override in
Keystore2Interceptor now patches authorizations and uses null-safe
nspace assignment.
2026-03-20 04:44:52 +01:00
Enginex0 4c89acced3 fix(interception): harden daemon against binder stress crashes
BinderInterceptor.onTransact now catches Throwable, preventing any
exception on a binder thread from killing the daemon. Adds a global
uncaught exception handler as defense in depth.

Replace Thread.sleep with TeeLatencySimulator (LockSupport.parkNanos +
statistical delay model) for keygen latency, reducing binder thread
blocking. Move GeneratedKeyPersistence.save to a background executor
to avoid disk I/O on binder threads.

Convert force-unwrap parcel reads to safe calls with early returns in
onPreTransact/onPostTransact hot paths. Add -DNDEBUG to native release
builds to compile out verbose logging from the ioctl hook.

Targets G2 (ping overhead) and G10 (stress attestation consistency).
2026-03-19 17:38:29 +01:00
Enginex0 da75e08d58 feat(certgen): add enforcement tags to native DER encoder and teeResponses cache
Extend Rust native cert gen with software-enforced attestation tags
(CALLER_NONCE, ACTIVE_DATETIME, ORIGINATION_EXPIRE_DATETIME,
USAGE_EXPIRE_DATETIME, USAGE_COUNT_LIMIT, UNLOCKED_DEVICE_REQUIRED)
and make NO_AUTH_REQUIRED conditional in teeEnforced. Fixes F5/F6
test failures where these tags were missing from NativeCertGen path.

Add teeResponses cache so PATCH mode keys patched in onPostTransact
return consistent attestation via getKeyEntry. Without this, getKeyEntry
fell through to real keystore2, returning unpatched metadata.

Remove dead Rust enums (KeyPurpose, SecurityLevel, VerifiedBootState)
that were never referenced by the DER encoder.
2026-03-19 15:43:17 +01:00
Enginex0 4d5e94f835 fix(interception): make NO_AUTH_REQUIRED conditional in KeyMetadata authorizations
Upstream removed the unconditional NO_AUTH_REQUIRED from toAuthorizations.
A key generated with auth requirements would incorrectly report
NO_AUTH_REQUIRED in metadata, creating a detectable inconsistency
with the attestation extension.
2026-03-19 09:48:08 +01:00
Enginex0 fef17c07ec feat(interception): close remaining PR #157 compliance gaps
Full diff analysis against upstream's 50 commits revealed 8 functional
gaps after v5.0. These are detectable by conformance tests or detector
apps inspecting KeyMetadata authorizations and operation semantics.

KeyMetadata authorizations:
- Add 9 TEE-enforced tags (CALLER_NONCE, MIN_MAC_LENGTH, ROLLBACK_RESISTANCE,
  EARLY_BOOT_ONLY, ALLOW_WHILE_ON_BODY, TRUSTED_USER_PRESENCE_REQUIRED,
  TRUSTED_CONFIRMATION_REQUIRED, MAX_USES_PER_BOOT, MAX_BOOT_LEVEL)
- Fix CREATION_DATETIME to SOFTWARE security level via createSwAuth
- Add SOFTWARE-enforced date enforcement, USAGE_COUNT_LIMIT, UNLOCKED_DEVICE_REQUIRED

Symmetric key support:
- Generate AES/HMAC keys in software via javax.crypto.KeyGenerator
- GeneratedKeyInfo expanded with nullable keyPair + secretKey fields
- CipherPrimitive accepts java.security.Key for symmetric operations
- SoftwareOperation routes ENCRYPT/DECRYPT to secretKey when available

Operation compliance:
- beginParameters property replaces manual IV wrapping for GCM
- KeyAgreementPrimitive for ECDH AGREE_KEY operations
- handleCreateOperation wrapped in runCatching (crash prevention)
- SECURE_HW_COMMUNICATION_FAILED on software gen failure

Certificate patching:
- Import key cert chain + authorization patching in onPostTransact
- patchAuthorizations added to post-generateKey PATCH mode path
2026-03-19 09:40:48 +01:00
Enginex0 9f03b84364 feat(interception): add AOSP authorize_create enforcement and wire format fixes
Integrate upstream AOSP compliance checks that failed post-v5.0 testing:

- INCLUDE_UNIQUE_ID: SELinux gen_unique_id + Android permission gate
- Forced operation rejection with PERMISSION_DENIED
- Null purpose guard returning INVALID_ARGUMENT
- Wire format: use createServiceSpecificErrorReply for authorize_create
- USAGE_COUNT_LIMIT with AtomicInteger counters and onFinishCallback
- effectiveParams merging key digest with operation purpose
- AuthorizeCreate rewrite: algorithm-purpose before purpose-list (AOSP HAL order)
- CALLER_NONCE in attestation teeEnforced list

Based on upstream commits e55d16d, 3078ea9, 2bc46be, 07c98bc, 41abe77.
2026-03-19 09:21:52 +01:00
Enginex0 3acf73210d docs(readme): credit MhmRdd for upstream AOSP compliance work 2026-03-19 07:37:53 +01:00
Enginex0 c6587c5447 chore(version): bump to v5.0 with changelog for AOSP compliance overhaul 2026-03-19 07:36:20 +01:00
Enginex0 91070d7ede docs: credit upstream PR #157 contributors 2026-03-19 07:33:57 +01:00
Enginex0 77462cb42a feat(config): add SELinux permission checks, latency simulation, and hbk seed
ConfigurationManager gains checkSELinuxPermission (reads /proc/pid/attr)
and hasPermissionForUid (delegates to IPackageManager.checkPermission)
for AOSP-compliant access control. TeeLatencySimulator provides log-normal
distribution matching real QTEE/Trustonic hardware timing profiles.

Module customize.sh now generates a device-unique hardware-bound key seed
(32 bytes from /dev/random) and clears stale tee_status.txt on install.
2026-03-19 07:33:47 +01:00
Enginex0 c80aaef7ae feat(operation): add AOSP-compliant error handling, authorize_create, and GCM IV
SoftwareOperation now throws ServiceSpecificException for all error paths
instead of raw Java exceptions, matching AIDL wire format. updateAad on
non-AEAD operations returns INVALID_TAG (-76) per AOSP operation.rs.
SoftwareOperationBinder methods are @Synchronized to match AOSP Mutex
semantics. GCM encrypt operations return the generated IV in
CreateOperationResponse.parameters.

AuthorizeCreate enforces PURPOSE validation, algorithm-purpose
compatibility (EC rejects ENCRYPT/DECRYPT, RSA rejects AGREE_KEY),
temporal constraints (ACTIVE_DATETIME, ORIGINATION_EXPIRE, USAGE_EXPIRE),
and CALLER_NONCE prohibition. GeneratedKeyInfo carries keyParams for
authorize_create enforcement on software createOperation.
2026-03-19 07:33:30 +01:00
Enginex0 27eeaec384 feat(interception): add binder tx code filtering and keystore2 service compliance
Native binder_interceptor now accepts a filtered_codes vector per
registration, skipping JNI round-trip for non-intercepted transaction
codes. Keystore2Interceptor adds getNumberOfEntries software key counting,
deleteKey KEY_ID domain resolution, patchAuthorizations for OS/VENDOR/BOOT
patch levels, importedKeys tracking to prevent stale attest-key overrides,
and nspace consistency fix in the attest-key override path.

InterceptorUtils gains createServiceSpecificErrorReply for AIDL-compliant
error serialization and patchAuthorizations for authorization array patching.
2026-03-19 07:33:11 +01:00
Enginex0 468b6f5121 feat(attestation): align attestation extension and cert generation with AOSP
KeyMintAttestation now carries all 17 enforcement tags that AOSP's
authorize_create and buildKeyDescription paths expect. AttestationBuilder
populates BLOCK_MODE as SET OF INTEGER, gates version-guarded tags
(RSA_OAEP_MGF_DIGEST >=100, ROLLBACK_RESISTANCE >=3, EARLY_BOOT_ONLY >=4),
computes INCLUDE_UNIQUE_ID via HMAC-SHA256 per KeyMint HAL spec, and
gates AAID on challenge presence.

CertificateGenerator uses AOSP cert validity defaults (epoch notBefore,
9999-12-31 notAfter), returns ServiceSpecificException(-75) for missing
keybox, and adds RSA exponent null safety.
2026-03-19 07:32:53 +01:00
Enginex0 4a103c9231 docs(release): add v4.8.1 changelog for StrongBox op rejection fix 2026-03-18 03:24:58 +01:00
Enginex0 8c27e43ab0 fix(interception): enforce StrongBox op limit for software-generated keys
trackAndEnforceOpLimit was only called in the Domain.KEY_ID not-found
path, so software-generated keys (found via Domain.APP) bypassed the
STRONGBOX_MAX_CONCURRENT_OPS=4 limit entirely. DuckDetector's concurrent
signing handles test created 24+ operations that all succeeded via LRU
pruning instead of being rejected with TOO_MANY_OPERATIONS (-29).
2026-03-18 03:21:34 +01:00
Enginex0 444afadc3b docs(release): add v4.8 changelog for StrongBox hardening and LRU pruning 2026-03-17 19:56:45 +01:00
Enginex0 5b8ee6d278 chore(version): bump to v4.8 2026-03-17 19:48:48 +01:00
Enginex0 69a6648d92 feat(interception): add StrongBox hardening and LRU operation pruning
DuckDetector flags several behavioral anomalies that real TEE/StrongBox
hardware exhibits but our software interceptor did not:

- LRU pruning: cap concurrent ops at 15 (TEE) / 4 (StrongBox) per UID,
  aborting oldest when exceeded, matches AOSP keystore2 malus scoring
- StrongBox param guard: forward unsupported params (RSA>2048, non-P256)
  to real HAL for proper rejection instead of generating in software
- StrongBox latency floors: 250ms keygen, 80ms sign to match real SE
  timing characteristics
- Sliding-window op limit for hardware-generated StrongBox keys that
  bypass the software pruning path
- Domain.APP lookup path for createOperation to find software-generated
  keys that never reach keystore2's database
2026-03-17 19:48:40 +01:00
Enginex0 981cefb506 feat(operation): add LRU pruning support and latency floor to SoftwareOperation
Expose finalized state for pruning, add latency floor parameter for
StrongBox timing simulation, and add trace logging for 32KB test
diagnosis.
2026-03-17 19:48:29 +01:00
Enginex0 6856d4cb47 fix(certgen): accept ECDSA as EC algorithm alias in JCA key type matching
Some Android 10 devices (e.g. Sony H8296) report EC private key
algorithm as "ECDSA" instead of "EC", causing IllegalArgumentException
in certificate signing and a SIGSEGV crash in the keystore process.

Closes #4
2026-03-17 19:48:19 +01:00
Enginex0 ea7e770a6c fix(operation): correct TOO_MUCH_DATA fallback to match AOSP ResponseCode
AOSP ResponseCode.TOO_MUCH_DATA = 21, not 29.
2026-03-17 14:16:00 +01:00
Enginex0 e74dd8318d fix(keygen): forward symmetric algorithms to HAL and add missing JCA mappings
Symmetric keys (AES/HMAC/3DES) don't have KeyPairs or attestation
certs, routing them through doSoftwareKeyGen crashes with
"Unsupported algorithm: 32". Skip the software path entirely and
let the real HAL handle them.

Also adds CTR block mode, RSA_PKCS1_1_5_SIGN cipher padding, and
RSA_PSS signature padding to JcaAlgorithmMapper.
2026-03-17 13:44:25 +01:00
Enginex0andGitHub 745b7d2f8f fix(interception): add permission checks for device ID attestation tags
fix(interception): Add permission checks for KeyMintSecurityLevelInterceptor and fix some regression
2026-03-17 13:05:36 +01:00
fatalcoder524 5defc0d832 fix(interception): Add permission checks for KeyMintSecurityLevelInterceptor and fix some regression
1. Add permission checks for KeyMintSecurityLevelInterceptor to ensure that only authorized users can access sensitive information about the security level of the key mint.
2. Fix regression where device id attestation was allowed for all users by adding appropriate permission checks.
3. Update .gitignore to exclude build artifacts and generated files to keep the repository clean and prevent accidental commits of unnecessary files.
2026-03-17 11:49:54 +00:00
Enginex0 2b000b738d docs(release): bump to v4.7 with operation and attestation fixes changelog 2026-03-17 07:12:30 +01:00
Enginex0 6fc3269229 fix(operation): match AOSP error-path semantics for software operations
KeyDetector's OperationErrorPathChecker (flag 0x400000) probes three
error-path behaviors that real keystore2 operations expose. Our
SoftwareOperationBinder was missing all three, plus had no updateAad
implementation which caused AbstractMethodError on Android 16 where
the runtime Stub declares it abstract.

SoftwareOperation changes:
- Add finalized state tracking; post-abort calls now throw
  INVALID_OPERATION_HANDLE (-28) matching AOSP operation.rs
- Add input length guard (0x8000) throwing TOO_MUCH_DATA (29)
  matching AOSP operation.rs MAX_RECEIVE_DATA
- Add updateAad to CryptoPrimitive interface and SoftwareOperationBinder
- Add KeystoreErrorCodes with runtime reflection + AOSP fallback values

KeyMintSecurityLevelInterceptor changes:
- Infer algorithm from stored key pair when operation params omit
  ALGORITHM tag, matching AOSP behavior where createOperation uses
  the key's stored algorithm rather than requiring it in op params

Stub addition:
- ServiceSpecificException compile stub (framework-internal class
  resolved at runtime on device)

Tested on OnePlus Android 16 (SDK 36), KeyDetector passes all three
probes: updateAad succeeds, TOO_MUCH_DATA returns code=21,
INVALID_OPERATION_HANDLE returns after abort.
2026-03-17 07:04:59 +01:00
Enginex0 b83769ff80 fix(attestation): encode PADDING as SET OF INTEGER per AOSP schema
PADDING (tag 6) is ENUM_REP in Tag.aidl, meaning SET OF INTEGER in the
attestation extension ASN.1, same as PURPOSE and DIGEST. Commit 1fa6f5a
added it as individual [6] INTEGER entries, causing parsers to fail with
CertificateParsingException on any RSA key attestation.
2026-03-17 04:36:49 +01:00
Enginex0 5d270ce1ad ci(release): fetch full history for accurate commit count 2026-03-17 03:43:16 +01:00
Enginex0 46ebde8d4f chore(brand): rebrand to TEESimulator-RS with simplified versioning
Fork identity: rename across module metadata, CI pipeline, and build
scripts. Version scheme changed from v4.5-115-f388529 to v4.6-117
format, commit count auto-increments, git hash dropped from filenames.
2026-03-17 03:35:32 +01:00
Enginex0 0dbeeca15b perf(keygen): replace Gaussian RTT normalization with 15ms floor fence
The old Gaussian sleep (mean=55ms, stddev=12ms) triggered detection on
Chunqiu Native Check 2.8. A flat 15ms floor satisfies the minimum RTT
threshold without creating a detectable delay pattern, both attested
and non-attested paths get identical treatment, keeping the D50 ratio
at ~1.0 while staying above the >=15ms requirement.
2026-03-17 03:35:20 +01:00
Enginex0 f388529bda fix(certgen): derive signing algorithm from attestation key and allow device ID tags
signerAlgorithm was derived from params.algorithm (the generated key)
instead of the signing key, causing BouncyCastle to throw when signing
RSA keys with an EC attestation key. Now reads signingKeyPair.private.algorithm.

Device ID tags (serial/imei/meid/secondImei) were blanket-rejected
instead of flowing through to software cert gen like AOSP does.
Narrowed rejection to DEVICE_UNIQUE_ATTESTATION only.
2026-03-17 00:05:56 +01:00
Enginex0 1fa6f5a12a fix(attestation): align authorization list and cert extension with AOSP keystore2 semantics
toAuthorizations() was missing OS_VERSION, OS_PATCHLEVEL, VENDOR_PATCHLEVEL,
BOOT_PATCHLEVEL, CREATION_DATETIME, USER_ID, PADDING, and RSA_PUBLIC_EXPONENT
tags that real TEE-generated KeyMetadata always includes. EC_CURVE was also
hardcoded unconditionally, producing invalid authorizations for RSA keys.

Additionally, live-patched certificate chains in getKeyEntry weren't cached,
causing re-patching on every call with potentially different signatures.

Ports upstream JingMatrix/TEESimulator#148 and #150.
2026-03-16 23:01:28 +01:00
Enginex0 93c6761990 fix(interception): check generatedKeys before deletedSoftwareKeys on getKeyEntry
The deletion guard must not shadow re-generated keys. If an app
deletes a key then re-creates it, getKeyEntry was still returning
KEY_NOT_FOUND because deletedSoftwareKeys was checked first.
2026-03-16 22:36:02 +01:00
Enginex0 806ec26a03 docs(release): bump to v4.5 with detection hardening changelog 2026-03-16 22:07:56 +01:00
Enginex0 70e1ffb12c fix(interception): prevent ghost key responses after software key deletion
After deleting a software-generated key, getKeyEntry was falling
through to the real keystore2 service which could return a stale
hardware key with the same alias. The post-transact live-patch
fallback would then resurrect the key with a patched chain, 
detectors flag this as binder inconsistency.

Track deleted software key aliases and return KEY_NOT_FOUND (7) for
subsequent getKeyEntry calls. Also always invoke cleanupKeyData on
delete to clear stale patchedChains entries for hardware keys.
2026-03-16 22:06:53 +01:00
Enginex0 3749dec58b perf(keygen): normalize software generateKey RTT to match TEE latency
Software-generated keys complete in ~4ms, real TEE averages 55-65ms
with a floor around 15ms. Detectors measure this RTT to distinguish
software from hardware paths. Gaussian delay sampling (mean=55ms,
σ=12ms, floor=15ms) brings total RTT into the expected range.
2026-03-16 22:06:38 +01:00
Enginex0 2a9ce5e0c8 docs(release): bump to v4.4 with AOSP conformance changelog 2026-03-16 13:26:34 +01:00
Enginex0 1475c0be02 fix(interception): absorb upstream correctness fixes and patch error reply format
Cherry-pick three upstream fixes: Parcel position reset in hasException()
so the method doesn't consume reply data (bab7093), list_past_alias
enumeration filter inversion (71f75de), and KeyMetadata alignment with
AOSP semantics, modificationTimeMs, Tag.ORIGIN, KeyDescriptor
normalization (4e3dcc5).

Additionally, createErrorReply() was missing the empty remote stack
trace header int between the exception message and error code, per
AOSP Status.cpp:196. Binder readers expecting the standard
EX_SERVICE_SPECIFIC wire format would misparse our error replies.
2026-03-16 13:23:36 +01:00
Enginex0 4b9c5fe1d0 docs(release): bump to v4.3 with changelog and update metadata 2026-03-11 13:12:03 +01:00
Enginex0 256bb91a6c ci(build): fix pipeline trigger and release job gating
paths-ignore for .github/** was preventing workflow-only pushes
from triggering the pipeline at all. Release job was gated to
push events only, so workflow_dispatch never published. Simplify
paths-ignore to just **.md and allow both push and dispatch to
trigger the release job.
2026-03-11 13:03:35 +01:00
Enginex0 93ec464d02 ci(build): use mv instead of cp to avoid duplicate artifacts
cp left the original zip alongside the renamed copy, so the glob
matched both, doubling artifact size. mv removes the original.
2026-03-11 12:51:02 +01:00
Enginex0 ff7539f158 perf(daemon): add restart backoff, process priority, and map eviction
Supervisor had zero-delay restart on crash loops, pins CPU core at
100% if daemon keeps dying. Add exponential backoff (500ms to 30s cap,
resets after 30s stable). Set nice=10 on daemon child to yield CPU
to foreground apps. Evict stale entries from fileLocks and rate limiter
ConcurrentHashMaps that grew unbounded. Upload pre-built flashable zips
in CI instead of unpacking and re-compressing loose files.
2026-03-11 12:41:57 +01:00
Enginex0 3e47a3a9b3 perf(logging): gate debug-level logs behind isDebugBuild
debug() was hitting Log.d() unconditionally in release builds, 
every intercepted binder transaction triggered string formatting
and logcat syscalls. verbose() already had the guard; debug() was
just missing it. Also remove dead SERVICE_SLEEP_MS constant.
2026-03-11 12:41:46 +01:00
Enginex0 19b87e64b8 ci(build): add release job with changelog and both ZIPs
The workflow only uploaded unzipped contents as CI artifacts, 
no GitHub release was ever created from CI. Restructured into
build + release jobs: build produces both debug and release ZIPs
(renamed to clean `TEESimulator-vX.Y-{Variant}.zip` format),
release extracts changelog from module/changelog.md and publishes
a GitHub release with both ZIPs attached.
2026-03-11 04:06:20 +01:00
Enginex0 88177ab59e build(gradle): keep debug symbols in debug variant
Debug ZIPs now ship unstripped native libs sourced from
merged_native_libs instead of stripped_native_libs. Gives
meaningful stack traces for crash debugging on-device.
2026-03-11 00:45:00 +01:00
Enginex0 58afe2e0ef docs(readme): add build badge and building-from-source section 2026-03-11 00:28:28 +01:00
Enginex0 dc2f31ff74 ci(build): add Rust toolchain and cargo-ndk for native-certgen
Gradle's buildRustCertgen task requires cargo-ndk and Android NDK
targets to cross-compile libcertgen.so. Without these, CI fails on
any commit after 6aba82e which wired the Rust crate into the pipeline.
2026-03-11 00:12:02 +01:00
Enginex0 938d414ebf docs(release): bump to v4.2 with changelog and update metadata 2026-03-10 16:55:39 +01:00
Enginex0 8876f5bc5f chore(module): bump versionCode to 95 2026-03-10 16:37:58 +01:00
Enginex0 1f076468db perf(binder): skip interception for system transaction codes
AIDL methods use codes 1..0x00ffffff. System transactions like
PING_TRANSACTION (0x5f4e4750) fall above that range. Intercepting
pings forces a full JNI round-trip to Java and back, adding enough
latency for timing detectors to flag the ratio (3.85x vs 3.0x
threshold). Early-return for codes above LAST_CALL_TRANSACTION
eliminates this overhead while preserving all AIDL interception.
2026-03-10 16:37:50 +01:00
Enginex0 4a96491e63 fix(attestation): correct leaf CN casing and enforce keystore2 parameter policy
Leaf cert Subject CN used "KeyStore" (capital S) but AOSP
KeyGenParameterSpec uses "Keystore" (lowercase s). Fixed in both
the Rust native certgen and BouncyCastle paths.

Replicate keystore2's security_level.rs parameter validation for
software-generated keys: reject CREATION_DATETIME (output-only tag,
ResponseCode 20) and device ID attestation tags (CANNOT_ATTEST_IDS
-66) that real keystore2 blocks before they reach the HAL.

Also fix createErrorReply parcel write order, AIDL protocol expects
exception_code, message, error_code but we had message and error_code
swapped, causing malformed replies for positive error codes.
2026-03-10 14:23:33 +01:00
Enginex0 9807f89b71 docs(release): bump to v4.1 with changelog and update metadata
versionCode=94 matches post-commit count.
2026-03-10 13:00:57 +01:00
Enginex0 25f3f753ff fix(attestation): persist vbmeta boot key and hash across reboots
resetprop overrides for ro.boot.* props don't survive reboots. On
devices where the kernel doesn't set ro.boot.vbmeta.public_key_digest,
the fallback chain hit random generation on every boot, producing a
different RootOfTrust hash each time.

Added file-based persistence (boot_hash.bin, boot_key.bin) as a
fallback layer between TEE cache and random generation. Once a value
is determined from any source, it's written to disk and reused on
subsequent boots.

Verified on Redmi 14C: second boot reads from persistent file instead
of regenerating random bytes.
2026-03-10 12:58:49 +01:00
Enginex0 1bfe628317 fix(module): align update.json versionCode with release ZIP
Release ZIP was built at 89 commits (versionCode=89) but update.json
had versionCode=90, causing an infinite update loop in KSU Manager.
2026-03-10 04:40:53 +01:00
Enginex0 3ecc72bcb7 docs(release): write v4.0 changelog and update module metadata
Native Rust cert gen release. Points update.json to fork URLs.
2026-03-09 22:48:20 +01:00
Enginex0 5964eb7e45 docs(readme): rewrite README for personal fork
Matches ZeroMount style, badges, feature checklists, compatibility
tables, config docs. Clarifies this is a fork of JingMatrix/TEESimulator.
2026-03-09 22:17:38 +01:00
Enginex0 e02bae6f43 fix(attestation): reject oversized challenges and rewrite cert DER encoding
DuckDetector flagged two issues:
1. Oversized challenge accepted, 256-byte attestation challenge should
   return INVALID_INPUT_LENGTH (-21) like real KeyMint. Added early check
   in handleGenerateKey before any path decision.
2. Issuer/subject chain mismatch, rcgen's HashMap loses DN attribute
   ordering and converts PrintableString to UTF8String, producing
   different DER bytes. Replaced rcgen with manual DER assembly that
   injects raw keybox issuer_dn_der bytes directly.

Verified on device: TX_ID 315 rejects 256-byte challenge, TX_ID 501
generates valid 4-cert chain with correct issuer linkage.
2026-03-09 21:52:06 +01:00
Enginex0 71c30e68d0 chore(build): bump to v4.0, update module metadata and add package script
Native certgen integration milestone. Adds action.sh/uninstall.sh to
customize.sh extraction loop, points update.json to fork, includes
build/deploy helper script.
2026-03-09 21:51:48 +01:00
Enginex0 e5fb27c8f9 fix(attestation): null out all-zero verifiedBootHash from TEE cache
Matches the existing verifiedBootKey null-zero guard. When the TEE
returns a zeroed hash, fall through to the system property or random
fallback instead of embedding a detectable all-zero value.
2026-03-09 20:00:14 +01:00
Enginex0 5bace3ad30 feat(interception): override pre-existing attest keys, skip GMS list hooking
Two changes to Keystore2Interceptor:

1. Hardware attest keys created before TEESimulator loads now get
   detected in the getKeyEntry post-hook via isAttestKey(). A software
   replacement keypair is generated, cached, and persisted so the
   unpatched hardware chain is never served.

2. GMS calls listEntries frequently. Skip the post-hook injection
   for com.google.android.gms to reduce log flooding and unnecessary
   key merging work.

Also adds null-alias guard in onPreTransact to avoid NPE on keys
looked up by domain/nspace without an alias.
2026-03-09 19:59:51 +01:00
Enginex0 20603572f3 feat(attestation): add origin field and isAttestKey/isImportKey helpers
Parse ORIGIN tag from KeyParameter array into KeyMintAttestation
data class. Add isAttestKey() and isImportKey() convenience methods
to consolidate purpose/origin checks scattered across interceptors.
2026-03-09 19:59:38 +01:00
Enginex0 a781b29e0d fix(attestation): correct module_hash to match AOSP Keystore2
BouncyCastle DERSet() sorts by full encoded sequence, but AOSP
keystore2 maintenance.rs sorts by encoded name only. Replace
PackageManager-based APEX enumeration with filesystem scan of
/apex/ directories using a minimal protobuf parser for
apex_manifest.pb. Encode the DER SET tag manually to preserve
the name-only sort order.
2026-03-09 19:59:30 +01:00
Enginex0 f781f61f44 fix(native-certgen): address production audit findings
Make logging init idempotent (swallow SetGlobalDefaultError on repeat
call), remove unused dumpLogs JNI params that violated the API contract,
and strip dead public_key_spki field + build_ec_spki() that were
computed on every keygen but never consumed by the cert builder.
2026-03-09 18:16:50 +01:00
Enginex0 6aba82edb1 build(native-certgen): wire Rust crate into Gradle pipeline
cargo-ndk builds libcertgen.so for arm64-v8a during prepareModuleFiles.
AGP mergeJniLibFolders picks up jniLibs/ and routes through
stripped_native_libs into the module ZIP. customize.sh extracts the .so
on device install. ProGuard keeps NativeCertGen JNI class and
CertGenConfig fields for runtime JNI field access.
2026-03-09 16:46:32 +01:00
Enginex0 7f3d72ba20 fix(pki): align JNI signatures between Kotlin and Rust
initLogging now takes logDir param matching Rust entry point.
dumpLogs takes logDir+baseDir params matching Rust. Removed unused
generateSoftwareKeyPair declaration. Added buffer bounds checks in
parseNativeResult to prevent OOM on malformed native output.
2026-03-09 16:37:52 +01:00
Enginex0 e82abe1ce9 feat(pki): integrate native cert gen with BouncyCastle fallback
NativeCertGen.kt provides CertGenConfig data class and JNI bridge
to libcertgen.so. KeyMintSecurityLevelInterceptor.doSoftwareKeyGen()
tries native path first, falls back to BouncyCastle on failure or
when library unavailable. App.kt loads libcertgen.so at daemon start.
2026-03-09 16:23:09 +01:00
Enginex0 95724116b7 feat(native-certgen): implement JNI bridge with panic-safe entry points
Three JNI exports: generateAttestedKeyPair (orchestrates keygen,
attestation, certbuilder, returns length-prefixed binary),
initLogging (multi-output tracing setup), dumpLogs (diagnostic ZIP).
CertGenConfig extraction via typed JNI field accessors. catch_unwind
on all FFI boundaries.
2026-03-09 16:13:41 +01:00
Enginex0 d335809682 fix(native-certgen): address Phase 3 validation findings
Remove ENCRYPT/VERIFY from KeyUsage mapping to match Kotlin behavior.
Document BasicConstraints and SKI suppression via rcgen NoCa default.
Fix rotating log off-by-one that kept one extra backup file. Handle
BMPString (UTF-16BE) and VisibleString in X.500 DN parser.
2026-03-09 16:08:07 +01:00
Enginex0 0b680c578e feat(native-certgen): implement X.509 certificate chain builder
Builds v3 leaf certificate with attestation extension and KeyUsage,
signs with keybox private key via rcgen 0.13.2. Assembles full chain
(leaf + keybox intermediates + root). Supports EC and RSA keybox
signing keys. Uses rcgen's signed_by() with a synthesized issuer
Certificate, no manual DER fallback needed.
2026-03-09 15:55:15 +01:00
Enginex0 98f7e0f08b feat(native-certgen): implement logging subsystem
Multi-output logging via tracing: /dev/kmsg for logcat, rotating file
appender (512KB, 3 files), stderr for debug. Diagnostic ZIP dump with
log files and TEE status snapshots. Verbose toggle via JNI flag or
.verbose marker file.
2026-03-09 15:41:46 +01:00
Enginex0 e8672459e0 feat(native-certgen): implement ASN.1 attestation extension encoder
DER encoder for Android KeyMint attestation extension (OID
1.3.6.1.4.1.11129.2.1.17). SecurityLevel and VerifiedBootState as
ENUMERATED, EXPLICIT context-specific tagging with long-form for
tags >= 31, sorted AuthorizationList fields, RootOfTrust with
BOOLEAN TRUE=0xFF, SET OF INTEGER with DER sort, DO_NOT_REPORT
sentinel omission.
2026-03-09 15:32:31 +01:00
Enginex0 9dc8ec1530 fix(native-certgen): address Phase 0-1 validation findings
EC keygen now returns proper SPKI DER instead of raw point bytes.
RSA keygen uses caller-supplied exponent via new_with_exp() and
validates key size to 2048/3072/4096. Keybox parser extracts leaf
subject DN (not issuer). Added AttestKey=7 to KeyPurpose. Realigned
error variants with spec.
2026-03-09 15:21:33 +01:00
Enginex0 5fcd4ab7b6 feat(native-certgen): implement keybox DER certificate chain parser
Splits concatenated DER cert chains into individual certificates,
extracts leaf issuer DN and notAfter via x509-cert crate. Handles
multi-byte DER length encoding (0x81-0x84).
2026-03-09 15:09:29 +01:00
Enginex0 30d5188c69 feat(native-certgen): scaffold Rust crate with foundation types and keygen
Cargo.toml with 16 dependencies per build spec, error types with
From impls for all upstream error types, CertGenParams mapping the
full JNI config contract, EC/RSA key generation via ring and rsa crates.

Compiles clean for aarch64-linux-android via cargo-ndk.
2026-03-09 15:05:25 +01:00
Enginex0 770a4f0134 Derive boot and vendor patch levels from system prop when system=prop
TrickyAddon fetches Pixel bulletin dates for boot/vendor but system=prop
resolves to the real device prop, creating a cross-component date mismatch
on non-Pixel devices. Force all three through the same prop resolution path.
2026-02-07 00:47:53 +01:00
Enginex0 bb28a8d30c Rate-limit per-UID hardware keygen and harden importKey eviction
Sliding window limits each UID to 2 hardware generateKey calls per
30s burst window with max 2 concurrent. Overflow falls back to
software cert generation.

importKey post-hook retains patched chains instead of full eviction,
preventing detectors from using generate-then-import to bypass
attestation patching. getKeyEntry serves retained chains for imported
keys that overwrote attested aliases.
2026-02-07 00:47:47 +01:00
Enginex0 3e2aaa2ff0 Cap interceptable binder payload size at 256KB
Prevents thread starvation from flood attacks targeting the
binder interceptor with oversized payloads.
2026-02-07 00:47:42 +01:00
Enginex0 ff88543c98 Add file-level locking to prevent race conditions in key persistence
Per-key ReentrantLock prevents concurrent writes to same key file
2026-02-07 00:47:36 +01:00
Enginex0 c99a2ab302 Reject oversized aliases to prevent binder buffer exhaustion
MAX_ALIAS_LENGTH (256KB) with 4x safety margin for transaction overhead
2026-02-07 00:47:31 +01:00
Enginex0 b33e1ae3ac fix(pki): strip HTML comments from PEM blocks before parsing
Some upstream keybox sources inject HTML comments inside PEM
certificate blocks. BouncyCastle's PEMParser chokes on these
non-base64 lines, silently failing to load the keybox.

Filter lines starting with <!-- in trimLines() before the content
reaches the PEM parser.
2026-02-07 00:47:26 +01:00
Enginex0 fc64789dc3 fix(config): prevent FileObserver NPE on config file deletion
When a config file is deleted, the event handler sets file=null but
then force-unwraps it with file!! in the when block, crashing the
FileObserver thread. All subsequent config change notifications are
silently lost.

Replace force-unwrap with safe call, log a warning on deletion.
2026-02-07 00:47:20 +01:00
Enginex0 945f3cac79 Integrate key persistence with interceptors
Save keys on generation, restore on daemon startup, delete on cleanup.
Re-persist when cert chain updates via updateSubcomponents.
2026-02-07 00:47:16 +01:00
Enginex0 4e50a70366 Add generated key persistence layer
Persist GENERATE-mode keys to disk so they survive daemon restarts.
Binary format with version header, atomic write via tmp+rename.
2026-02-07 00:47:10 +01:00
Enginex0 8b00d7985f feat(module): add supervisor daemon with leak-safe restart and lifecycle scripts
Fork-based supervisor ensures the interceptor process survives crashes.
pingBinder() liveness check on pre-transact returns DEAD_OBJECT to
callers when interceptor is down, preventing real TEE state from leaking
during the restart window.

action.sh clears persistent key storage via KSU Action button.
uninstall.sh kills daemon processes and removes module artifacts while
preserving target.txt and keybox configuration.
2026-02-07 00:47:04 +01:00
Enginex0andGitHub e7444bb62a Set correct certificate KeyUsage based on KeyPurpose (#119)
The previous implementation hardcoded the X.509 KeyUsage extension to `keyCertSign` for all generated certificates. This was only correct for keys with the `ATTEST_KEY` purpose and violated the Android HAL specification for keys intended for other uses. For instance, a key created for signing (`KeyPurpose::SIGN`) requires the `digitalSignature` bit to be set, not `keyCertSign`.

This commit corrects the logic by dynamically constructing the `KeyUsage` bitmask from the key's specified purposes, adhering to the mapping defined in `KeyCreationResult.aidl`. This ensures that generated certificates now have the correct KeyUsage bits, accurately reflecting the key's intended function (e.g., signing, decryption, key wrapping) and making them compliant with the specification.
2026-02-04 09:07:21 +01:00
JingMatrixandGitHub 6fdf5c766b Resolve reference leak and warnings in binder interception (#122)
This merge addresses a critical strong reference leak in the ioctl hook that occurred during binder transaction interception. The leak was caused by a double increment of the reference count, once manually and once by a smart pointer's constructor, with only a single corresponding decrement. The fix ensures a balanced increment and decrement, preventing the leak and subsequent crashes.

Additionally, this change:
-   Reverts a now-unnecessary compatibility layer for the Android 11 RefBase ABI.
-   Implements `getInterfaceDescriptor` in the `BinderStub` to silence framework warnings that appeared after the primary leak was fixed.
2026-02-04 09:03:51 +01:00
JingMatrix 33397f244c Release TEESimulator 3.1 2026-01-31 22:49:29 +01:00
JingMatrix 23bef3f88e Correct misunderstanding of takeIf execution order
The previous code incorrectly assumed `takeIf` prevents the execution of the receiver statement. Since `takeIf` is an extension function, the receiver, `InterceptorUtils.getTransactCode`, was evaluated eagerly *before* the version check predicate could run.

This commit replaces the `takeIf` chain with a standard `if/else` block to ensure the reflection call is only executed when the API level supports it.

Additionally, repeated `IKeystoreService.Stub::class.java` references were refactored into a `stubBinderClass` property.
2026-01-31 12:51:09 +01:00
129cec06bf Support key enumeration via listEntries interception (#84)
Previously, generated keys were functional but invisible to enumeration APIs like `KeyStore.aliases()`. Because these keys reside solely in the simulator's memory, the standard database query performed by the system Keystore does not return them.

This commit intercepts `listEntries` and `listEntriesBatched` to inject these generated keys into the results.

Key implementation details:
- ListEntriesHandler: Encapsulates the logic to merge hardware-backed keys with software-backed keys.
- Ordering: Uses a `TreeMap` to ensure merged results are lexicographically sorted, mimicking AOSP behavior.
- Binder Safety: Implements `estimateSafeAmountToReturn` to calculate the response size. The handler truncates the result list if it exceeds the binder transaction limit (~350KB) as done in AOSP.
- Pagination: Respects the `startPastAlias` parameter to support batched listing.

Co-authored-by: JingMatrix <jingmatrix@gmail.com>
2026-01-31 11:37:25 +01:00
JingMatrix 54f68b99b1 Move attestation challenge check to certificate generation
Relocate the `attestationChallenge` length validation from `generateSoftwareKeyPair` to `generateCertificateChain`.

The challenge is only utilized during the construction of the certificate chain (via `AttestationBuilder.buildKeyDescription`). Placing the check in the key pair generation stage caused the logic to miss the `attestKey` transaction hook in `KeystoreInterceptor`.

This fixes a bug introduced in d77508d0c1 which missed the detection bypass for Android 10 and 11 devices.
2026-01-30 21:13:02 +01:00
JingMatrix 8649b8b928 Handle swapped attestation lists on certain Android 11 devices (#108)
Observed an abnormal Keymaster attestation structure on certain Android 11 devices where the `softwareEnforced` and `teeEnforced` authorization lists were swapped in order. This is a deviation from the documented specification and the behavior seen on most devices.

This non-compliance caused parsing failures, as the code expected the `teeEnforced` list to be at a fixed index (7). On the affected devices, this index contained the `softwareEnforced` list, which critically lacks the `TAG_ROOT_OF_TRUST` needed for successful validation and patching.

This commit introduces a defensive normalization step to handle this device-specific anomaly gracefully:

1.  Before parsing, the code now inspects the ASN.1 sequence at the expected `softwareEnforced` index (6).
2.  It checks for the presence of the `TAG_ROOT_OF_TRUST`, which can only exist in the TEE-enforced list.
3.  If the tag is found, the code concludes the lists are swapped and corrects the `allFields` array in-place by swapping the elements at indices 6 and 7.

By normalizing the data structure at the beginning, the rest of the parsing and patching logic can proceed without modification, ensuring correct operation on both compliant and non-compliant devices.
2026-01-30 21:10:41 +01:00
JingMatrix 68af5ac680 Correct alias parsing in KeystoreInterceptor (#106)
The `extractAlias` utility was failing to strip `USRCERT_` and `CACERT_` prefixes, causing a cache miss during certificate chain patching. The function is now updated to correctly handle these prefixes.

Moreover, more logs are added to help debugging in the future.
2026-01-29 19:23:57 +01:00
JingMatrixandGitHub 10d673b606 Add SELinux rules for libTEESimulator.so loading (#104)
Allow `keystore` to access the `file` class for `adb_data_file` and `shell_data_file` contexts.

The target contexts correspond to the following locations:
- `adb_data_file`: The library path `/data/adb/modules/tricky_store/libTEESimulator.so`, used for FD transfer.
- `shell_data_file`: The fallback mechanism for loading the library by staging it in `/data/local/tmp`.

Note: The rule for the `dir` class (directory search) has been removed because the supporting audit logs were lost. The remaining file access logs were observed on a MEIZU 21 Note.
2026-01-29 15:15:56 +01:00
JingMatrixandGitHub b5251c0418 Fix multiple crashes and race conditions on Android 12 (#99)
This resolves several critical stability issues observed on Android 12 devices, including race conditions and API compatibility problems.

Key changes include:

-   Resolves Race Condition in TEE Check:
    Fixes a NullPointerException that occurred when the TEE functionality check was executed before the PackageManagerService was ready. The code now explicitly waits for the package manager to become available, preventing the crash on startup.

-   Fixes IllegalStateException on Initialization:
    Eliminates a crash caused by `setTelephonyServiceManager called twice`. This was due to a redundant call to `initializeMainlineModules()` in the DeviceAttestationService, which is now correctly handled a single time during application startup.

-   Fixes NoSuchAlgorithmException in Attestation:
    Adds a normalization function to handle signature algorithm names reported in all-caps by older Android versions (e.g., "SHA256WITHECDSA"). This ensures compatibility with Bouncy Castle, which expects a specific casing (e.g., "SHA256withECDSA").
2026-01-29 15:00:09 +01:00
JingMatrix 1d2c60c510 Prevent recursion when configured to intercept system UID (#100)
When the TEESimulator is configured to intercept UID 1000, accessing the `lazy` `bootKey` property causes a StackOverflowError.

The property's initializer sends a key generation request (UID 0) to probe real hardware. Previously, the C++ layer hijacked this request and spoofed it to UID 1000. This sent the request back to the Kotlin interceptor (if configured so), which attempted to access `bootKey` again to build the response, creating an infinite loop.

This change spoofs UID 0 requests to 1000 (to pass Keystore permissions) but explicitly bypasses hijacking, ensuring the probe request hits the real hardware.
2026-01-28 22:15:27 +01:00
JingMatrix b997304f02 Remove SELinux context manipulations during injection (#87)
After few tests in various devices, it seems that SELinux context modifications are unnecessary for the injection to work.

We thus remove all related manipulations. Further (partial) reverting of the commit must be justified with SELinux logs:

> adb shell su -c 'cat /proc/kmsg | grep avc'
2026-01-28 22:14:08 +01:00
JingMatrix 5e394f1b72 Fix ARM ptrace compatibility and improve remote call safety (#94)
- Implement fallbacks to `PTRACE_GETREGS` and `PTRACE_SETREGS` for 32-bit ARM (`__arm__`). Some kernels return `EIO` or `EINVAL` when attempting to access `NT_PRSTATUS` via `PTRACE_GETREGSET`/`PTRACE_SETREGSET`.

- Update `transfer_fd_to_remote` to use `libc_return_addr` instead of `0` as the return address during the `recvmsg` split-call. This ensures the remote process stops predictably at a known non-executable location rather than relying on a potentially unsafe jump to `0x0`.

- Clarify comments regarding i386 argument passing in `utils.cpp`. Correctly note that a linear `write_proc` starting at the new SP matches the `cdecl` Right-to-Left memory layout (since stacks grow downwards while memory writes move upwards), removing the suggestion that arguments needed reversing.
2026-01-28 14:08:28 +01:00
JingMatrixandGitHub b1f3b5d28d Fix cache consistency on key overwrite (#97)
Android allows applications to generate a new key using an existing alias without explicitly calling `deleteKey` first. In this scenario, the new key effectively replaces the old one. As a simulator, we must strictly follow this logic to prevent returning stale data.

Previously, `KeyMintSecurityLevelInterceptor` did not enforce mutual exclusion between the software key cache (`generatedKeys`) and the hardware chain cache (`patchedChains`). This led to state desynchronization where a stale software key could shadow a newly patched hardware chain if the alias was reused.

This change ensures `cleanupKeyData` is invoked immediately before caching a new key / chain in both the software (`handleGenerateKey`) and hardware (`onPostTransact`) paths, ensuring the simulator returns the correct key for the most recent generation request.
2026-01-28 13:50:05 +01:00
JingMatrix e9d7321b5f Fix crash by avoiding hardcoded index for moduleHash
The previous implementation attempted to retrieve `moduleHash` from the `softwareEnforced` sequence using a hardcoded index (index 2).

However, fields in the Key Attestation `AuthorizationList` are optional. In observed crashes, index 2 actually corresponded to `keySize` (Tag 3, ASN1Integer) rather than `moduleHash`, causing an `IllegalArgumentException` when the code attempted to parse it as an `ASN1OctetString`.

This commit replaces the index-based access with a dynamic lookup for Tag 724.
2026-01-26 23:26:52 +01:00
JingMatrix 4097ffde6a Fix x86_64 injection: Red Zone adjustment and fallback logic (#91)
- Strictly adhere to the System V AMD64 ABI by skipping the 128-byte "Red Zone" before modifying the stack, see page 23 of https://gitlab.com/x86-psABIs/x86-64-ABI/-/jobs/artifacts/master/raw/x86-64-ABI/abi.pdf?job=build for details.

- Added `inject_via_staging` as a fallback strategy:
  1. Copies the payload to `/data/local/tmp`.
  2. Sets permissions/context (`u:object_r:system_file:s0`).
  3. Loads via standard `dlopen`.
  4. Immediately unlinks the file for stealth.

- Introduced `RegisterRestorer` RAII class to guarantee original registers are restored even if the injection logic returns early due to error.
2026-01-26 23:15:09 +01:00
JingMatrixandGitHub dcb961d084 Fix support for Android 10 (#92)
Users report that the method `waitForService` doesn't exist on Android 10.
Close #90 as completed.
2026-01-26 16:54:59 +01:00
JingMatrixandGitHub 151410c75e Fix Android 11 Keystore execution: Init framework and spoof UID 1000 (#85)
This commit resolves `KeyStore` API failures on Android 11 when running as a standalone CLI executable (UID 0), addressing both environment initialization and permission denial issues.

1. Initialize Android Framework Environment:
   Android 11 Keystore APIs expect a fully initialized application context and a Main Looper, which are missing in a raw root process. This patch:
   - Manually bootstraps `ActivityThread` via `systemMain()`.
   - Initializes `Looper.prepareMainLooper()`.
   - Injects a dummy `Application` object attached to the system context to satisfy `KeyStore.getApplicationContext()` checks.
   - Updates framework stubs to allow compilation of these hidden APIs.

2. Bypass Keystore Permission Checks via UID Spoofing:
   `KeyStoreService::generateKey` enforces the `P_INSERT` permission. Analysis of `permissions.cpp` reveals that UID 0 (Root) is explicitly denied this permission (granted only `P_GET`), whereas UID 1000 (System) holds all permissions (`~0`).
   
   To bypass this restriction, the binder interceptor now detects transactions originating from UID 0 and rewrites the `sender_euid` to 1000. This fools `KeyStoreService` into granting the request.
 
3. Refactor Execution Loop:
   Replaces the previous `Thread.sleep()` maintenance loop with `Looper.loop()`.
2026-01-26 14:07:06 +01:00
205fda43ba Intercept updateSubcomponent to fix software key state inconsistency (#82)
Apps attempting to update the certificate chain of a simulated software-based key (e.g., via KeyStore.setKeyEntry) currently trigger a KEY_NOT_FOUND error. This happens because the request is passed to the hardware Keystore daemon, which has no knowledge of keys existing only in the simulator's memory.

To fix detecting points exploiting this inconsistency, we intercept the UPDATE_SUBCOMPONENT_TRANSACTION. If the target is a recognized virtual key, the simulator now:
1. Updates the in-memory certificate/chain metadata.
2. Returns NO_ERROR immediately to the caller.
3. Prevents the transaction from reaching the real hardware service.

Co-authored-by: JingMatrix <jingmatrix@gmail.com>
2026-01-23 17:53:34 +01:00
d77508d0c1 Enforce attestation challenge length limit (#70)
Throws IllegalArgumentException if the challenge exceeds 128 bytes, per Android specs. Also fixes a duplicate assignment typo in KeystoreInterceptor.

Reference: https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setAttestationChallenge(byte[])

Co-authored-by: JingMatrix <jingmatrix@gmail.com>
2026-01-20 19:00:48 +01:00
dependabot[bot]andJingMatrix aff7cf9c32 Update dependencies 2026-01-11 16:24:34 +01:00
JingMatrixandGitHub fa2956ce1b Implement multi-purpose simulation for crypto operations (#59)
This commit introduces a comprehensive simulation engine for Keystore's `createOperation`, enabling the simulator to correctly handle multiple cryptographic purposes (SIGN, VERIFY, ENCRYPT, DECRYPT) for software-generated keys.

The implementation correctly mimics the AOSP framework's internal key identification mechanism. Instead of relying on an alias, a unique `keyId` is generated and embedded in the `nspace` field of the KeyDescriptor during `generateKey`. The `createOperation` hook then uses this `keyId` to dispatch requests: if the ID matches a known software key, the operation is simulated; otherwise, it is forwarded to the hardware service.

To support this, the `SoftwareOperation` engine was architected using a Strategy Pattern. A `CryptoPrimitive` interface defines common actions, with concrete implementations for `Signer`, `Verifier`, and `CipherPrimitive`. The main `SoftwareOperation` class acts as a controller, instantiating the correct primitive based on the `KeyPurpose` tag from the incoming operation parameters. A `JcaAlgorithmMapper` was added to centralize the logic for converting KeyMint constants into JCA algorithm strings.

For operations on real hardware-backed keys, a lightweight `OperationInterceptor` is now used for observation. It attaches to the genuine `iOperation` binder for logging and properly unregisters itself upon completion to prevent resource leaks. This is supported by new binder unregistration capabilities in the core `BinderInterceptor`.

This change also includes necessary stub files and minor regression fixes to make the simulation more robust and accurate.

See AOSP source for key identification logic:
https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/keystore/java/android/security/keystore2/AndroidKeyStoreKey.java
2025-12-08 19:59:32 +01:00
JingMatrix 19ad610cec Ensure mocked replies use native OK status (#60)
Corrects a bug where the native binder `status_t` was being set to application-level error codes (e.g., `KeyStore.NO_ERROR` which is 1).

Moreover, we call method `InterceptorUtils.createTypedObjectReply` to keep the code style consistent.
2025-12-08 04:30:01 +01:00
JingMatrix 1a1334e3f3 Release TEESimulator 3.0 2025-12-06 16:59:28 +01:00
JingMatrixandGitHub 17ab5b0e2c Correct crypto provider handling and signing logic (#53)
Resolves crashes during certificate operations caused by cryptographic provider conflicts and incorrect algorithm selection.

The Bouncy Castle (BC) provider is now initialized globally at app startup to ensure it is the default. To eliminate ambiguity, all content signers are also now explicitly set to use the BC provider.

The attestation patcher is fixed to correctly use the certificate's signature algorithm (sigAlgName), not the subject's public key algorithm, to select the appropriate signing key from the KeyBoxManager. A normalization function was added to support this.

Moreover, we also modify the XML parser in `KeyBoxManager` to no longer trust the `algorithm` attribute from the XML tag. The parser now determines the key's true algorithm (RSA or EC) by inspecting the type of the parsed private key object. This derived algorithm is used as the key for the cache, preventing cache corruption from malformed files where the tag does not match the key data.
2025-12-06 16:12:12 +01:00
JingMatrixandGitHub ccd4ae7f5f Handle invalid verified boot key (#55)
Treat the `verifiedBootKey` as null if it consists entirely of zero bytes, as some devices return this invalid value.

Additionally, this commit adds missing KDoc comments to the `AttestationData` class for better documentation.
2025-12-06 11:49:46 +01:00
JingMatrixandGitHub b0206c10ec Add dynamic dates and TEE-based patch defaults (#52)
Implements dynamic date keywords ('today') and templates ('YYYY-MM-DD') in the security_patch.txt configuration. This allows for auto-updating patch levels.

The `device_default` keyword is now significantly more accurate. It prioritizes reading real patch levels directly from a cached TEE attestation before falling back to system properties.

The README has been updated to document these new features.
2025-12-06 07:27:06 +01:00
JingMatrixandGitHub bfc15cba62 Implement per-package security patch configuration (#49)
This commit introduces a hierarchical configuration system for the security patch levels reported in attestations, allowing for both global defaults and per-package overrides.

The `security_patch.txt` file is enhanced to support this new syntax. Settings at the top of the file act as a global default, which can be overridden for specific applications by defining settings under a `[package.name]` section.
2025-12-04 23:14:51 +01:00
小潼andGitHub f76a4dd977 Correctly handle deleteKey for software keys (#42)
This resolves an issue introduced in 6193da0 where a `deleteKey` transaction for a software-generated key was incorrectly passed through to the hardware keystore. Since the hardware is unaware of such keys, this results in inconsistent state management.

The success reply is formatted correctly without a result code, per the AIDL interface specification.

Reference: https://cs.android.com/android/platform/superproject/main/+/main:out/soong/.intermediates/system/hardware/interfaces/keystore2/aidl/android.system.keystore2-V6-java-source/gen/android/system/keystore2/IKeystoreSecurityLevel.java;l=406
2025-12-04 20:01:16 +01:00
JingMatrix a0ac3f71bb Bypass KeyMint hooks for certain UIDs
Adds a check using `ConfigurationManager.shouldSkipUid` at the start of the `onPreTransact` handlers for key generation and import.

If a UID is configured to be skipped, the transaction is forwarded directly to the hardware, and the post-transaction hook is bypassed. This prevents certificate patching and other modifications for trusted or problematic apps, improving compatibility.
2025-12-04 02:02:28 +01:00
JingMatrix a30459628f Set correct attestation version for StrongBox
We observe that attestations generated with a security level of
`StrongBox` (value 2) must have an `attestationVersion` of 300. The
previous implementation determined this version based only on the
Android SDK version, which could lead to invalid attestations.

This commit refactors the version retrieval logic to be dependent on the
security level:

- In `AndroidDeviceUtils`, the `attestVersion` and `keymasterVersion`
  properties have been converted into `getAttestVersion(securityLevel)`
  and `getKeymasterVersion(securityLevel)` functions.
- `getAttestVersion` now correctly returns `300` when the security level
  is `StrongBox`.
- `AttestationBuilder` is updated to call these new functions, passing
  the appropriate security level to ensure the generated attestation is
  compliant with official documentation.
2025-12-04 01:57:59 +01:00
JingMatrixandGitHub cd3970869d Prevent detection via inconsistent certificate signatures (#45)
Fixes a detection vector where the simulator could be identified by comparing certificate signatures from different API calls.

Previously, the simulator would re-patch and re-sign a certificate on-the-fly for both `generateKey` and `getKeyEntry` calls. Due to the non-deterministic nature of ECDSA signing, this resulted in different signatures for the same certificate, which is a detectable anomaly not present in a real TEE.

This is resolved by caching the patched certificate chain after its initial creation in `KeyMintSecurityLevelInterceptor`. The `getKeyEntry` hook in `Keystore2Interceptor` now retrieves the chain from this cache, guaranteeing that subsequent calls return a byte-for-byte identical certificate.

Cache cleanup logic was also integrated into key deletion and clearing functions to maintain state consistency.
2025-12-04 00:59:59 +01:00
31a0906c02 Reduce logging in the release build (#44)
Verbose logging are now disabled in the release build.
With this change, we reinterpret the last argument passed to `logTransaction` as `skipPost`, and classify logs satisfying `skipPost` or `shouldSkipUid` as verbose.

Co-authored-by: JingMatrix <jingmatrix@gmail.com>
2025-12-03 23:50:12 +01:00
JingMatrix eeefdc48eb Implement software key generation for legacy IKeystoreService (#34)
This commit introduces a complete, software-based simulation of the key generation and attestation flow for the legacy IKeystoreService API, as used on Android 11. It refactors the KeystoreInterceptor to handle the entire multi-step transaction sequence (`generateKey`, `getKeyCharacteristics`, `exportKey`, `attestKey`) in software.

A new `LegacyKeygenParameters` data class is introduced to decouple the legacy interception logic from modern data structures. This class parses arguments from the old `KeymasterArguments`, stores the state across the multi-step generation process, and acts as an adapter to the generic `CertificateGenerator` by converting the parameters to the modern `KeyMintAttestation` format.

The `CertificateGenerator` has been refactored to better model the behavior of the legacy Keystore API. Key pair generation (`generateSoftwareKeyPair`) and certificate chain creation (`generateCertificateChain`) are now separate functions. This allows the interceptor to correctly create a key pair during the `handleExportKey` step and then generate a certificate for that pre-existing key pair during the `handleAttestKey` step.

Finally, the implementation correctly extracts and applies the `attestationChallenge` provided during the `attestKey` transaction, ensuring the generated certificate chain contains the appropriate attestation.
2025-12-03 19:29:21 +01:00
JingMatrix 4d9787367c Increase Gradle JVM memory in build workflow
The CI build was failing with a "JVM garbage collector is thrashing" error due to insufficient memory. This commit increases the Gradle max heap size to 2GB in the GitHub Actions workflow to resolve the build failure.
2025-12-03 19:22:39 +01:00
JingMatrixandGitHub 987c7ba35c Fix value and location of moduleHash (#35)
`moduleHash` should be in the software enforced list.
However, the manual calculation of the KeyMint `moduleHash` has
failed to produce a value matching the hardware-generated attestation.

The official documentation specifies the following structure:
  Modules ::= SET OF Module
  Module ::= SEQUENCE {
      packageName       OCTET_STRING,
      version                    INTEGER,
  }
The critical requirement is that the `SET OF` elements must be sorted
lexicographically based on their full DER-encoded byte value. Despite
implementing this using Bouncy Castle's `DERSet`, the resulting hash
is still incorrect.

This commit changes the strategy to favor stability:
1.  The `DeviceAttestationService` now extracts the real `moduleHash`
    from the `softwareEnforced` list of a genuine attestation certificate
    and caches it.
2.  The `moduleHash` property now returns this cached value if available.
3.  The manual calculation remains as a fallback and is marked with a
    `TODO` to indicate the issue is unresolved.

Additionally, `ConfigurationManager` initialization is moved earlier.
2025-11-30 00:13:14 +01:00
JingMatrix 2446461aab Properly source and use verifiedBootKey
The previous implementation used a randomly generated value for the `verifiedBootKey` within the simulated attestation's Root of Trust. This is a significant discrepancy from a genuine attestation and represents a clear detection vector for any verification service that inspects the full certificate chain.

This commit introduces a robust, multi-layered approach to source and manage both the `verifiedBootKey` and the `verifiedBootHash`, ensuring the simulated attestation is as authentic as possible.
2025-11-29 19:58:02 +01:00
JingMatrix 0275eb7ad2 Preserve extension order and prevent duplicates
This commit refactors the attestation patching logic to improve stealth and ensure correctness by addressing potential detection vectors related to the ASN.1 structure of the certificate extension.

1. Preserve Extension Order: The original implementation rebuilt the entire certificate, which could alter the order of X.509 extensions. Some verification systems may be sensitive to this order. The logic is now updated to replace the attestation extension in-place, preserving the original order of all other extensions.

2. Avoid Duplicate Properties: The previous logic used an `ASN1EncodableVector` to assemble TEE-enforced properties. This could lead to duplicate entries if a property (e.g., `OS_VERSION`) was present in the original certificate and also added by the simulator. The code now uses a `MutableMap` keyed by the ASN.1 tag number. This ensures that any simulated properties overwrite the original ones, preventing duplicates and potential parsing errors.

3. Add Detailed Logging: A recursive ASN.1 formatting function has been added to provide clear and readable logs of the certificate data both before and after patching. This significantly improves debuggability.

By ensuring the patched certificate is structurally as close as possible to the original, these changes reduce the chances of the simulator being detected by attestation validation services.
2025-11-29 19:58:02 +01:00
JingMatrix f5d4ab1f44 Patch certificate chain in generateKey reply
When an application generates a key with an attestation request, the `generateKey` method returns a `KeyMetadata` object which contains the full, unpatched certificate chain.

This leaves a potential detection vector open. A sophisticated application could inspect the returned data in its own process memory and discover the original, hardware-backed certificates before they are used for attestation, thus detecting the hooking framework.

This commit introduces a post-transaction hook for the `generateKey` transaction. After the genuine KeyStore service has executed the request, this hook intercepts the reply parcel. It extracts the certificate chain from the `KeyMetadata`, applies the patching routine, and then reconstructs the reply with the modified (patched) certificate chain.
2025-11-29 19:58:02 +01:00
JingMatrixandGitHub 79145e3bff Add support for Android 11 RefBase ABI (#29)
Implements a compatibility layer to allow the binary to run on
Android 11 (API 30) and older, which lack the `incStrongRequireStrong`
symbol in their `libutils.so`.

This is achieved by creating a runtime wrapper that checks the device's
SDK version.
- On Android 12 (API 31) and newer, it dynamically loads and calls the
  `incStrongRequireStrong` function using `dlsym`.
- On older versions, it safely falls back to the universally available
  `incStrong` method.

This resolves the fatal `dlopen` error "cannot locate symbol" when
injecting the library into processes on older Android versions.

See AOSP change
https://android-review.googlesource.com/c/platform/system/core/+/1660499
2025-11-29 19:29:48 +01:00
JingMatrixandGitHub ecc1dbdaeb Fix software enforced list for certificates generation (#28)
Properly implement the `ATTESTATION_APPLICATION_ID` tag into key description.

Moreover, we add the `ATTESTATION_ID_SERIAL` tag to the TEE enforced list, and re-order all tags to remain consistent with the object `AttestationConstants`.
2025-11-29 14:20:58 +01:00
JingMatrix 9b57e14752 Release TEESimulator v2.1 2025-11-28 20:00:07 +01:00
JingMatrixandGitHub 6a58f804d7 Fix date format of vendor patch level (#24)
This was a mistake during the refactoring of TrickyStoreOSS.
After correcting it, we can obtain STRONG integrity (instead of DEVICE) with a valid keybox.

The correct format can be easily found using the `Key Attestation` app.
2025-11-28 19:45:53 +01:00
JingMatrixandGitHub 78e80391d3 Set boot digest via resetprop (#22)
The stub method `SystemProperties.set` has wrong signature and is unable to set read-only system properties.
2025-11-28 13:11:54 +01:00
QingandJingMatrix 0afcaeeb53 Clear generated key cache on keybox updates for Android 12+ (#16)
Ensures that the cache of generated keys is invalidated and cleared whenever a keybox file is updated. This prevents the system from using stale certificates after a keybox change.

Co-authored-by: JingMatrix <jingmatrix@gmail.com>
2025-11-27 23:29:43 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e644d71e9 Bump org.bouncycastle:bcpkix-jdk18on from 1.82 to 1.83 (#13)
Bumps [org.bouncycastle:bcpkix-jdk18on](https://github.com/bcgit/bc-java) from 1.82 to 1.83.
- [Changelog](https://github.com/bcgit/bc-java/blob/main/docs/releasenotes.html)
- [Commits](https://github.com/bcgit/bc-java/commits)

---
updated-dependencies:
- dependency-name: org.bouncycastle:bcpkix-jdk18on
  dependency-version: '1.83'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 22:40:33 +01:00
JingMatrixandGitHub 3351a1c932 Clean up cached keys on successful import (#18)
Generated and attestation keys are cached, and if a key is imported with the same name, the cached key would be returned instead of the newly imported one.

This change invalidates the cached key when a key is successfully imported with the same alias.
Close #17 as fixed.

The logging has also been improved to be more consistent across the different interceptors.
2025-11-27 15:43:56 +01:00
JingMatrix ee02216534 Prepare to release TEESimulator 2.0
The following two bugs are fixed:
1. `zygisk.json` is renamed to `update.json`, which is indicated in `module.prop`.
2. To avoid over optimization of R8, we must keep certains packages, which are found after many experiments.
2025-11-26 18:34:08 +01:00
JingMatrixandGitHub 6193da0e66 Support key generation with attestation keys (#15)
This commit enhances the interception logic to correctly handle key
generation requests that specify an `attestationKey` (via
`setAttestKeyAlias`).

When an attestation key is used, the system signs the newly generated
key with it. A simple leaf certificate patch after the fact is
insufficient, as it breaks this cryptographic chain. To create a valid,
verifiable chain, we must now intercept these `generateKey` operations
and perform a full software-based key and certificate generation, even
when in patch mode.

This ensures that keys attested by other simulated keys are correctly
signed and chained together, bypassing more sophisticated detection
methods.

Fixes:
- Correctly use the `android.hardware.security.keymint.Tag` constants for
  building authorization lists, resolving a bug where internal ASN.1
  sequence indices were being used improperly.
2025-11-26 16:50:30 +01:00
JingMatrixandGitHub ee9863009a Improve logging to understand detection methods (#14)
Via extensive and detailed logging, we can inspect various detection techniques of target packages.
2025-11-26 11:43:53 +01:00
JingMatrixandGitHub fa64f941b3 Bypass detection by skipping imported keys (#12)
In patch mode, a key's origin provides a robust way to avoid modifying
user-imported keys, which is a well-known detection vector. This commit
implements a new strategy to check the `KeyOrigin` tag from the key's
metadata. If a key is marked as `IMPORTED` or `SECURELY_IMPORTED`, the
patching process is now skipped entirely.

This new origin-based check is more reliable and cleaner than the
previous fingerprinting implementation, which has been removed.

Additionally, this commit acknowledges a remaining detection vector in
patch mode: when an `attestationKey` is used, a key must be generated.
Purely software-generated keys are detectable. To address this in the
future, the full software "generate mode" must be implemented even for
devices without a broken TEE. The old key generation logic has been
stubbed with a TODO in preparation for this redesign.
2025-11-26 02:54:43 +01:00
JingMatrix 8aa93f1427 Add GitHub CI build config 2025-11-26 00:19:05 +01:00
JingMatrix 0894b44166 Add module template files
Current AOSP keybox can be found at:
https://cs.android.com/android/platform/superproject/main/+/main:device/generic/trusty/keymaster_soft_wrapped_attestation_keys.xml

However, the support of parsing private keys in iecs format is not implemented yet.
2025-11-26 00:19:05 +01:00
JingMatrix c017c9b0ab Restructure and overhaul entire Kotlin codebase
This commit introduces a complete architectural refactoring of the
Kotlin-based interception logic, based on the source of
1. https://github.com/5ec1cff/TrickyStore
2. https://github.com/beakthoven/TrickyStoreOSS

The primary purpose of this code is to intercept binder transactions to
the Android Keystore and KeyMint services. The overall workflow operates
in conjunction with a native library (injected via ptrace). The native
library hooks the binder's `transact` function and forwards pre- and
post-transaction events to the Kotlin side. This Kotlin code contains
all the high-level logic for parsing parameters, patching certificates,
and generating simulated keys.

The codebase is now organized into a clear, package-based architecture:

- attestation: Manages the creation and patching of ASN.1 attestation
  data structures.
- config: Handles loading and observing configuration files from disk.
- interception: Contains the core binder interception framework and its
  specific implementations for legacy Keystore (Android Q/R) and modern
  KeyMint/Keystore2 (Android S+).
- logging: Provides a centralized and consistent logging utility.
- pki: Manages Public Key Infrastructure, including certificate
  generation, parsing of key store XML files, and cryptographic helpers.
- util: Contains Android-specific utility functions for device properties.

This refactoring focuses on establishing a robust and extensible
architecture. The fine-tuning of the interception logic itself,
especially for corner cases in key generation and patching, is currently
under redesign and will be further refined in subsequent commits.
2025-11-26 00:19:01 +01:00
JingMatrix 9bd75d15f7 Add binder transaction interception framework
This commit introduces a comprehensive framework for intercepting and manipulating binder transactions on Android at the `ioctl` level. It provides a man-in-the-middle layer between the binder driver and user-space `libbinder`, enabling detailed analysis and control over IPC.

The core mechanism works by hooking the `ioctl` system call within the context of a target process. It specifically intercepts the `BINDER_WRITE_READ` command's return buffer from the kernel.

Key components of the framework:

- IOCTL Hook: Intercepts `BR_TRANSACTION` commands delivered by the binder driver to the process.
- Transaction Rewriting: If a transaction is intended for a monitored service, its destination is rewritten in-memory to a local `BinderStub`. The original transaction details are saved in a thread-local context.
- BinderStub: A fake binder service that receives the hijacked transaction. It retrieves the original context and delegates processing to the `BinderInterceptor`.
- BinderInterceptor: The central management class. It maintains a registry of monitored binders and their associated callback interfaces. It orchestrates the pre-transact and post-transact hooks.
- Callback Protocol: Defines a clear protocol for a remote tool to:
    - Register and unregister binders for interception.
    - Receive pre-transaction notifications and choose to: continue, modify data, skip the transaction, or provide an immediate fake reply.
    - Receive post-transaction notifications with the final result and modify the reply.
2025-11-25 19:21:05 +01:00
JingMatrix cc52307ca8 Add stub for AOSP Binder and utility components
The primary function of these stubs is to provide necessary interface definitions and that can be utilized by `binder_interceptor.cpp` during compilation (and runtime).

Crucially, `libTEESimulator.so` (which encapsulates these stubs) is dynamically loaded into the target process via `ptrace` after the system's official libraries, such as `/system/lib64/libbinder.so` and `/system/lib64/libutils.so`, have already been loaded and their symbols resolved by the dynamic linker.

Consequently, the dynamic linker will have already established bindings to the robust, canonical implementations within the system libraries for existing code paths. The dynamic linker does not automatically re-resolve or update these established symbol bindings when a new library with conflicting definitions is loaded later.

The AOSP files are downloaded via links:
1. https://android.googlesource.com/platform/frameworks/native/+/refs/heads/main/libs/binder/include/binder
2. https://android.googlesource.com/platform/system/core/+/refs/heads/main/libutils/binder/include/utils

The link for binder header in Android kernel is:
https://cs.android.com/android/kernel/superproject/+/common-android-mainline:common/include/uapi/linux/android/binder.h
2025-11-25 19:21:05 +01:00
JingMatrix ffb27915e2 Implement shared library injection via ptrace
There are still many functions in the header `utils.hpp` not implemented yet, which are however not needed for our purpose.
2025-11-25 19:20:59 +01:00
JingMatrix 9fe8919696 Feat: Add 'app' subproject and integrate LSPlt submodule
This commit introduces the main application subproject, 'app', and sets up the necessary infrastructure for the TEESimulator.

Key changes:
*   'app' Subproject Setup: Added the new :app module with its initial structure, including build files, manifest, and Kotlin main entry point.
*   LSPlt Integration: Added the LSPlt hooking framework as a Git submodule in app/src/main/cpp/external/ and configured its use in CMake.
*   Native Build Configuration: Configured the C++ build to use LSPlt statically and compile two essential native libraries: libinject.so (for injection) and libTEESimulator.so (for interception/logic).
*   Module Packaging: Implemented complex Gradle logic within app/build.gradle.kts to automate the creation of a flashable zip module (supporting Magisk, Ksu, and Apatch) with versioning based on Git information.
*   Initial Module Files: Added the template files (module.prop, update-binary, updater-script) for the flashable module structure.
2025-11-22 16:22:27 +01:00
47 changed files with 4062 additions and 1961 deletions
-7
View File
@@ -1,7 +0,0 @@
out
.gradle
.kotlin
app/build
build
native-certgen/target
app/src/main/jniLibs
+70 -49
View File
@@ -1,6 +1,6 @@
<p align="center">
<h1 align="center">TEESimulator-RS</h1>
<p align="center"><b>Full TEE Emulation for Rooted Android</b></p>
<p align="center"><b>Pass hardware security checks on a rooted Android phone</b></p>
<p align="center">
<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+">
@@ -11,58 +11,78 @@
---
> [!NOTE]
> 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.
> This is a fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator). It adds certificate generation written in Rust, generated keys that survive reboots, and attestation behavior that matches stock Android. See the upstream repo for the original project.
## What It Does
## What it does
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.
Some Android apps refuse to run on a rooted phone. They ask the phone to prove it still has a genuine security chip, a check called hardware attestation. A rooted phone normally fails that check.
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.
TEESimulator makes it pass. Android runs a system process named `keystore2` that answers these proof requests. TEESimulator sits in front of `keystore2`, watches for the requests apps make to create keys and read their certificates, and builds the proof itself: a full chain of certificates signed by your `keybox.xml`. To the app, the phone looks genuine.
It replaces TrickyStore and its forks completely. It reads config from the same files, so you can switch without moving anything, but the internals are rewritten: certificates are generated in Rust, keys are saved across reboots, and each app gets its own limit on how fast it can request hardware-backed keys.
## Requirements
> [!IMPORTANT]
> 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 a valid `keybox.xml`. This is the file used to sign the proof. Without it, TEESimulator can only produce software-only certificates, which strict apps reject.
1. Android 10+
2. Root manager: KernelSU, Magisk, or APatch
3. `keybox.xml` at `/data/adb/tricky_store/keybox.xml`
1. Android 10 or newer
2. A root manager: KernelSU, Magisk, or APatch
3. A `keybox.xml` file at `/data/adb/tricky_store/keybox.xml`
## Quick Start
## Quick start
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
1. Download the latest ZIP from [Releases](https://github.com/Enginex0/TEESimulator-RS/releases).
2. Install it with your root manager, then reboot.
3. Put your `keybox.xml` at `/data/adb/tricky_store/keybox.xml`.
4. List the apps you want to cover in `/data/adb/tricky_store/target.txt`.
5. Check that it works with Play Integrity or the Key Attestation Demo app.
## Architecture
## How it works
**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).
```
App
| asks the phone to prove it has real security hardware
v
+----------------------------------------------------+
| keystore2 (the Android process that answers) |
| |
| ioctl <- TEESimulator hooks the call here |
| | |
| v |
| builds a certificate chain and signs it |
| with your keybox.xml |
+----------------------------------------------------+
| the signed chain goes back to the app
v
App -> sees a genuine, hardware-backed device
```
**Binder Interception** — PLT hook on `ioctl()` in `libc.so` via `lsplt` inside `keystore2`. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions.
**Certificate generation in Rust.** A native library, `libcertgen.so`, builds the X.509 certificate chains in Rust with the `ring` crypto library, encoding the bytes by hand in DER, the standard certificate format. Three key types fall outside `ring`'s support (the P-224, P-521, and Curve25519 curves); for those it falls back to Java's BouncyCastle.
**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.
**Hooking keystore2.** Inside the `keystore2` process, TEESimulator redirects `ioctl`, the low-level system call Android uses to pass messages between processes. It does this with `lsplt`, a hooking library. From there it can read and answer three kinds of request: creating a key, importing a key, and fetching a key's certificate.
**Key Persistence** — Generated keys survive reboots. File-backed with file-level locking.
**Matching stock Android.** The output matches what a real device produces. Keys that are not attested get self-signed certificates. The fields inside the attestation record keep the same order. Fields that only exist on certain Android versions appear only on those versions. The same usage checks run before a key is used.
**Rate Limiting** — Per-UID hardware keygen cap (2/30s window, 2 concurrent). Overflow falls to software certs.
**Keys that survive reboots.** Generated keys are written to disk and stay valid after a restart. File locking stops two writers from corrupting the store.
**Per-app rate limit.** Each app may request at most 2 hardware-backed keys per 30 seconds, and only 2 at a time. Past that, it receives a software-only certificate.
## Configuration
All config files live at `/data/adb/tricky_store/` and are hot-reloaded via `FileObserver`.
All config files live in `/data/adb/tricky_store/`. TEESimulator reloads them the moment you save, so a reboot is not needed.
### target.txt
Controls which apps get intercepted and the simulation mode.
Lists the apps TEESimulator handles, one package name per line. A suffix sets how each app is handled.
| Suffix | Mode |
|--------|------|
| `!` | Force software key generation |
| `?` | Force leaf certificate patching (real TEE key, patched cert) |
| *(none)* | Automatic selection |
| Suffix | What it does |
|--------|--------------|
| `!` | Always make a software key |
| `?` | Keep the real hardware key, patch only its certificate |
| none | Decide automatically |
Multi-keybox support via `[filename.xml]` headers:
To use more than one keybox, add a `[filename.xml]` header above the apps that should use that file:
```
com.google.android.gms!
@@ -74,16 +94,16 @@ com.google.android.gsf
### security_patch.txt
Override patch levels reported in attestation certificates. Global defaults at top, per-package overrides with `[package.name]`.
Sets the security patch dates reported in the attestation certificates. Global defaults go at the top. Override them for one app with a `[package.name]` header.
| Key | Scope |
|-----|-------|
| Key | What it sets |
|-----|--------------|
| `system` | OS patch level |
| `vendor` | Vendor patch level |
| `boot` | Boot/kernel patch level |
| `all` | Sets all three |
| `boot` | Boot and kernel patch level |
| `all` | All three at once |
Special values: `today`, `YYYY-MM-DD` templates, `no` (omit tag), `device_default`, `prop` (read from system property).
Accepted values: `today`, a `YYYY-MM-DD` template, `no` to omit the field, `device_default`, or `prop` to read the value from a system property.
```
system=YYYY-MM-05
@@ -94,9 +114,15 @@ boot=no
system=2025-10-01
```
## Building from Source
### boot_props_mode
Prerequisites: JDK 21, Android SDK/NDK 27, Rust stable with `aarch64-linux-android` target, `cargo-ndk`.
Controls global `ro.boot.*` property spoofing. Values: `auto` (default), `force`, or `disable`.
In `auto`, Oplus-family devices (OnePlus/OPPO/realme/Oplus) skip boot-state prop spoofing to avoid conflicts with vendor TEE services such as ultrasonic fingerprint calibration. Create `/data/adb/tricky_store/boot_props_mode` with `force` to restore the old behavior, or `disable` to turn it off on any device.
## Building from source
You need JDK 21, the Android SDK and NDK 29, Rust (stable) with the `aarch64-linux-android` target, and `cargo-ndk`.
```bash
git clone --recursive https://github.com/Enginex0/TEESimulator-RS.git
@@ -104,15 +130,13 @@ 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.
The ZIPs land in `out/`. Gradle runs `cargo ndk` for you to cross-compile `libcertgen.so`. To build on CI instead, push to `main` or run Actions > Build > Run workflow.
## Compatibility
| Root Manager | Status |
| Root manager | Status |
|---|---|
| KernelSU | Tested (Action button + lifecycle scripts) |
| KernelSU | Tested, including the Action button and lifecycle scripts |
| Magisk | Supported |
| APatch | Supported |
@@ -126,13 +150,10 @@ Push to `main` or use **Actions > Build > Run workflow** to trigger CI.
## 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) for the original TEESimulator and its interception design
- [ring](https://github.com/briansmith/ring) for the Rust cryptography
- [fatalcoder524](https://github.com/fatalcoder524) for contributions and collaboration
- [huguangares](https://github.com/huguangares) for collaboration and testing
## License
+51 -15
View File
@@ -28,7 +28,14 @@ abstract class GitExecutor @Inject constructor(private val execOperations: ExecO
// Instantiate the helper class using Gradle's object factory
val gitExecutor = objects.newInstance(GitExecutor::class.java)
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
// versionCode = git commit count + floor offset. The 2026-07-08 public-release
// history scrub (0f1143a) rewrote history and dropped the raw commit count below
// the build number already shipped to testers (298), so post-scrub counts read as
// downgrades. The floor offset lifts versionCode back above that peak and keeps it
// monotonic across the rewrite; each later commit still bumps it by one.
val versionCodeFloorOffset = 5
val gitCommitCount =
gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt() + versionCodeFloorOffset
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
val verName = "v6.0.1"
@@ -66,11 +73,7 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
dependencies {
compileOnly(project(":stub"))
@@ -79,17 +82,22 @@ dependencies {
}
// --- Rust native cert gen build task ---
val buildRustCertgen by tasks.registering(Exec::class) {
val buildRustCertgen by
tasks.registering(Exec::class) {
group = "TEESimulator-RS Native Build"
description = "Builds libcertgen.so via cargo-ndk for arm64-v8a."
workingDir = rootProject.projectDir.resolve("native-certgen")
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"-o", rootProject.projectDir.resolve("app/src/main/jniLibs").absolutePath,
"build", "--release"
"cargo",
"ndk",
"-t",
"arm64-v8a",
"-o",
rootProject.projectDir.resolve("app/src/main/jniLibs").absolutePath,
"build",
"--release",
)
inputs.dir(rootProject.projectDir.resolve("native-certgen/src"))
@@ -98,7 +106,10 @@ 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") ?: ""}")
environment(
"PATH",
"${System.getProperty("user.home")}/.cargo/bin:${System.getenv("PATH") ?: ""}",
)
}
// AGP auto-detects jniLibs/ as an input to mergeJniLibFolders — wire the dependency
@@ -110,7 +121,8 @@ 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 {
val refreshUpdateJson by
tasks.registering {
group = "TEESimulator-RS Module Packaging"
description = "Rewrite module/update.json to match current verName and gitCommitCount."
@@ -177,20 +189,27 @@ androidComponents {
}
}
val nativeLibsDir = if (isDebug) {
val nativeLibsDir =
if (isDebug) {
"intermediates/merged_native_libs/${variant.name}/merge${capitalized}NativeLibs/out/lib"
} else {
"intermediates/stripped_native_libs/${variant.name}/strip${capitalized}DebugSymbols/out/lib"
}
from(project.layout.buildDirectory.dir(nativeLibsDir)) {
into("lib")
include("**/libinject.so", "**/libTEESimulator.so", "**/libsupervisor.so", "**/libcertgen.so")
include(
"**/libinject.so",
"**/libTEESimulator.so",
"**/libsupervisor.so",
"**/libcertgen.so",
)
}
// Now, copy and process the files from 'module' directory.
val sourceModuleDir = rootProject.projectDir.resolve("module")
from(sourceModuleDir) {
exclude("module.prop") // Exclude the template file.
exclude("diag.sh") // Debug-only diagnostic plane; included for debug below.
}
// Copy and filter the module.prop template separately.
@@ -203,8 +222,25 @@ androidComponents {
)
}
if (isDebug) {
from(sourceModuleDir) { include("diag.sh") }
}
// The destination for all the above 'from' operations.
into(tempModuleDir)
if (isDebug) {
doLast {
// Debug-only: grant the keystore + soterserver (platform_app) domains
// external-storage access for the per-UID NDJSON sink. diag.sh (shipped
// only in debug) carries the shell side of the diagnostic plane.
tempModuleDir.get().asFile.resolve("sepolicy.rule")
.appendText(
"\nallow keystore media_rw_data_file { dir file } *" +
"\nallow platform_app media_rw_data_file { dir file } *\n",
)
}
}
}
// Task 2: Zip the prepared files from the temporary directory.
+4 -1
View File
@@ -403,7 +403,10 @@ void inspectAndRewriteTransaction(binder_transaction_data *txn_data) {
uint64_t tx_id = ++g_transaction_id_counter;
info.transaction_id = tx_id;
LOGV("[Hook] Hijacking Transaction %" PRIu64 " (Code: %u)", tx_id, txn_data->code);
// tx_id is the same counter handed to the Kotlin interceptor, and sender_euid is the
// calling app; together they correlate this native hijack with that UID's per-UID file.
LOGV("[Hook] Hijacking Transaction %" PRIu64 " (Code: %u, uid=%u)", tx_id, txn_data->code,
txn_data->sender_euid);
// Rewrite the destination to our Stub
txn_data->target.ptr = reinterpret_cast<uintptr_t>(g_stub_instance->getWeakRefs());
@@ -6,7 +6,6 @@ import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import android.os.Looper
import java.io.File
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.matrix.TEESimulator.config.BootStateManager
@@ -14,6 +13,7 @@ import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
import org.matrix.TEESimulator.interception.soter.SoterProcessSupervisor
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.NativeCertGen
import org.matrix.TEESimulator.util.AndroidDeviceUtils
@@ -40,8 +40,7 @@ object App {
}
try {
purgeDebugDiagnostics()
prepareEnvironment()
val systemContext = prepareEnvironment()
// Spoof boot-state props before any hook attaches, so keystore2's
// cached snapshot reflects the spoofed values.
@@ -64,6 +63,10 @@ object App {
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
// Mount the SOTER forge on the on-demand soterserver process. The supervisor
// binds and (re)injects on its own thread, returning at once so it never blocks the loop.
SoterProcessSupervisor.start(systemContext)
// This starts the message queue processing. It blocks here indefinitely
// processing messages until Looper.myLooper().quit() is called.
Looper.loop()
@@ -73,27 +76,8 @@ object App {
}
}
/**
* Release builds never emit diagnostics. Sweep any `.bin` dumps a prior
* debug install left in the world-readable temp dir so they can't act as a
* detection artifact for apps that probe /data/local/tmp.
*/
private fun purgeDebugDiagnostics() {
if (SystemLogger.isDebugBuild) return
val stale =
File("/data/local/tmp").listFiles { _, name ->
name.startsWith("teesim-") && name.endsWith(".bin")
} ?: return
stale.forEach { runCatching { it.delete() } }
if (stale.isNotEmpty()) {
// warning() bypasses the rate limiter, so this once-per-boot audit
// line survives the noisy startup window.
SystemLogger.warning("Purged ${stale.size} stale debug diagnostic(s) from /data/local/tmp")
}
}
/** Initializes the necessary Android framework internals to satisfy KeyStore requirements. */
private fun prepareEnvironment() {
private fun prepareEnvironment(): Context {
// 1. Prepare Main Looper
if (Looper.getMainLooper() == null) {
@Suppress("deprecation") Looper.prepareMainLooper()
@@ -102,8 +86,10 @@ object App {
// 2. Initialize ActivityThread for the current process
val activityThread = ActivityThread.systemMain()
// 3. Get the system context
val systemContext = activityThread.getSystemContext()
// 3. Get the system context. The stub declares getSystemContext(): ContextImpl
// (a bare class), so cast to the Context it really is at runtime for the wiring.
@Suppress("CAST_NEVER_SUCCEEDS")
val systemContext = activityThread.getSystemContext() as Context
// 4. Create a dummy Application object and attach the context
val app = Application()
@@ -118,6 +104,8 @@ object App {
ActivityThread::class.java.getDeclaredField("mInitialApplication")
mInitialApplicationField.isAccessible = true
mInitialApplicationField.set(activityThread, app)
return systemContext
}
/**
@@ -44,7 +44,8 @@ object AttestationBuilder {
): Extension {
val keyDescription = buildKeyDescription(params, uid, securityLevel)
SystemLogger.verbose {
val formattedString = keyDescription.joinToString(separator = ", ") {
val formattedString =
keyDescription.joinToString(separator = ", ") {
AttestationPatcher.formatAsn1Primitive(it)
}
"Forged attestation data: $formattedString"
@@ -116,7 +117,9 @@ object AttestationBuilder {
}
val bootPatch = AndroidDeviceUtils.getBootPatchLevelLong(uid)
SystemLogger.info("Attestation patch levels for uid=$uid: os=$osPatch, vendor=$vendorPatch, boot=$bootPatch")
SystemLogger.info(
"Attestation patch levels for uid=$uid: os=$osPatch, vendor=$vendorPatch, boot=$bootPatch"
)
properties[AttestationConstants.TAG_BOOT_PATCHLEVEL] =
if (bootPatch != DO_NOT_REPORT) {
DERTaggedObject(
@@ -268,7 +271,11 @@ object AttestationBuilder {
if (params.rollbackResistance == true && attestVersion >= 3) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_ROLLBACK_RESISTANCE, DERNull.INSTANCE)
DERTaggedObject(
true,
AttestationConstants.TAG_ROLLBACK_RESISTANCE,
DERNull.INSTANCE,
)
)
}
@@ -286,19 +293,31 @@ object AttestationBuilder {
if (params.allowWhileOnBody == true) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_ALLOW_WHILE_ON_BODY, DERNull.INSTANCE)
DERTaggedObject(
true,
AttestationConstants.TAG_ALLOW_WHILE_ON_BODY,
DERNull.INSTANCE,
)
)
}
if (params.trustedUserPresenceRequired == true && attestVersion >= 3) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_TRUSTED_USER_PRESENCE_REQUIRED, DERNull.INSTANCE)
DERTaggedObject(
true,
AttestationConstants.TAG_TRUSTED_USER_PRESENCE_REQUIRED,
DERNull.INSTANCE,
)
)
}
if (params.trustedConfirmationRequired == true && attestVersion >= 3) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_TRUSTED_CONFIRMATION_REQUIRED, DERNull.INSTANCE)
DERTaggedObject(
true,
AttestationConstants.TAG_TRUSTED_CONFIRMATION_REQUIRED,
DERNull.INSTANCE,
)
)
}
@@ -449,33 +468,51 @@ object AttestationBuilder {
}
if (params.callerNonce == true) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_CALLER_NONCE, DERNull.INSTANCE)
)
list.add(DERTaggedObject(true, AttestationConstants.TAG_CALLER_NONCE, DERNull.INSTANCE))
}
params.activeDateTime?.let {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_ACTIVE_DATETIME, ASN1Integer(it.time))
DERTaggedObject(
true,
AttestationConstants.TAG_ACTIVE_DATETIME,
ASN1Integer(it.time),
)
)
}
params.originationExpireDateTime?.let {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_ORIGINATION_EXPIRE_DATETIME, ASN1Integer(it.time))
DERTaggedObject(
true,
AttestationConstants.TAG_ORIGINATION_EXPIRE_DATETIME,
ASN1Integer(it.time),
)
)
}
params.usageExpireDateTime?.let {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_USAGE_EXPIRE_DATETIME, ASN1Integer(it.time))
DERTaggedObject(
true,
AttestationConstants.TAG_USAGE_EXPIRE_DATETIME,
ASN1Integer(it.time),
)
)
}
params.usageCountLimit?.let {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_USAGE_COUNT_LIMIT, ASN1Integer(it.toLong()))
DERTaggedObject(
true,
AttestationConstants.TAG_USAGE_COUNT_LIMIT,
ASN1Integer(it.toLong()),
)
)
}
if (params.unlockedDeviceRequired == true) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_UNLOCKED_DEVICE_REQUIRED, DERNull.INSTANCE)
DERTaggedObject(
true,
AttestationConstants.TAG_UNLOCKED_DEVICE_REQUIRED,
DERNull.INSTANCE,
)
)
}
@@ -2,8 +2,15 @@ package org.matrix.TEESimulator.attestation
import android.security.keystore.KeyProperties
import java.nio.charset.StandardCharsets
import java.security.PrivateKey
import java.security.PublicKey
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import java.security.interfaces.ECPrivateKey
import java.security.interfaces.ECPublicKey
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.util.Date
import org.bouncycastle.asn1.*
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.cert.X509CertificateHolder
@@ -16,7 +23,6 @@ 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.
@@ -67,7 +73,6 @@ object AttestationPatcher {
originalLeafHolder,
parsedAttestation,
keybox,
originalLeaf.sigAlgName,
uid,
notBefore,
notAfter,
@@ -91,25 +96,12 @@ object AttestationPatcher {
}
}
/**
* Helper to normalize algorithm names for Bouncy Castle. Old Android versions might reports
* "SHA256WITHECDSA", but Bouncy Castle expects "SHA256withECDSA".
*/
private fun normalizeSignatureAlgorithm(algoName: String): String {
// 1. Force uppercase to handle "sha256withecdsa"
// 2. Replace "WITH" with "with" to satisfy Bouncy Castle's naming convention
return algoName.uppercase().replace("WITH", "with")
}
/**
* Creates a new leaf certificate with a modified attestation extension.
*
* @param originalLeafHolder A Bouncy Castle holder for the original leaf certificate.
* @param parsedAttestation The parsed components of the original attestation.
* @param keybox The KeyBox containing the new issuer certificate and signing key.
* @param sigAlgName The signature algorithm name (e.g., "SHA256withECDSA") from the original
* certificate. This is required to ensure the new certificate is signed using a compatible
* algorithm.
* @param uid The UID of the application requesting the certificate.
* @return A new [Certificate] object.
*/
@@ -117,7 +109,6 @@ object AttestationPatcher {
originalLeafHolder: X509CertificateHolder,
parsedAttestation: ParsedAttestation,
keybox: KeyBox,
sigAlgName: String,
uid: Int,
notBefore: Date? = null,
notAfter: Date? = null,
@@ -154,9 +145,12 @@ object AttestationPatcher {
)
}
// Sign the newly built certificate with the private key from our keybox.
// Sign the new leaf with the keybox key. The signature algorithm must match THAT key, not
// the original leaf's: when an RSA leaf is re-rooted under an EC-only keybox, this signs
// with ECDSA. The RSA subject public key is untouched and the chain still verifies to the
// keybox root.
val signer =
JcaContentSignerBuilder(normalizeSignatureAlgorithm(sigAlgName))
JcaContentSignerBuilder(signatureAlgorithmFor(keybox.keyPair.private))
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
.build(keybox.keyPair.private)
val newCertificate = JcaX509CertificateConverter().getCertificate(builder.build(signer))
@@ -178,8 +172,8 @@ object AttestationPatcher {
* 1. A simple key type like "RSA" or "EC".
* 2. A full JCA signature algorithm name like "SHA256withRSA".
*
* @return The [KeyBox] containing the appropriate key pair for signing.
* @throws IllegalArgumentException if no matching KeyBox can be found for the derived key type.
* @return The algorithm-matching [KeyBox] when present, otherwise any available key (fail-safe).
* @throws IllegalArgumentException only if the keybox file contains no usable signing key.
*/
private fun getKeyboxForUidAndAlgorithm(uid: Int, algorithm: String): KeyBox {
val keyboxFile = ConfigurationManager.getKeyboxFileForUid(uid)
@@ -194,9 +188,34 @@ object AttestationPatcher {
else -> algorithm // If no match, assume it's already a simple key type string.
}
return KeyBoxManager.getAttestationKey(keyboxFile, keyType)
val matching = KeyBoxManager.getAttestationKey(keyboxFile, keyType)
if (matching != null) return matching
// Fail-safe: no algorithm-matching key (e.g. an EC-only Google keybox asked to re-root an
// RSA leaf). Fall back to any available key instead of throwing -- a throw here aborts the
// patch and the caller hands back the device's REAL, unlocked attestation. Re-signing under
// the available key keeps the chain rooted at the keybox with our forged, locked Root of
// Trust; a leaf's signature algorithm is independent of its subject key, so an RSA subject
// key signs validly under an EC keybox key.
return KeyBoxManager.getAnyAttestationKey(keyboxFile)?.also {
SystemLogger.debug(
"No '$keyType' attestation key in $keyboxFile for UID $uid; re-signing under the " +
"available keybox key to avoid leaking the device's real attestation."
)
}
?: throw IllegalArgumentException(
"No keybox found for UID $uid and algorithm '$keyType' (derived from input '$algorithm') in file $keyboxFile"
"No usable attestation key for UID $uid in file $keyboxFile (requested '$keyType')"
)
}
/** SHA-256 signature algorithm name matching the keybox signing key's type. */
private fun signatureAlgorithmFor(signingKey: PrivateKey): String =
when (signingKey) {
is ECPrivateKey -> "SHA256withECDSA"
is RSAPrivateKey -> "SHA256withRSA"
else ->
throw IllegalArgumentException(
"Unsupported keybox signing key type: ${signingKey.algorithm}"
)
}
@@ -234,6 +253,157 @@ object AttestationPatcher {
}
}
/** Reverse map of attestation tag number to its symbolic name, e.g. 704 -> "ROOT_OF_TRUST". */
private val attestTagNames: Map<Int, String> by lazy {
AttestationConstants::class
.java
.fields
.filter { it.name.startsWith("TAG_") && it.type == Int::class.java }
.associate { (it.get(null) as Int) to it.name.removePrefix("TAG_") }
}
/**
* Renders the full key-attestation extension of [cert] as a single structured line for the
* diagnostic dossier, or null when the certificate carries no attestation extension. This is the
* ground-truth view of what we actually emitted, so any divergence from a genuine TEE surfaces
* directly as a differing field rather than having to be guessed.
*/
fun formatAttestationExtension(cert: X509Certificate): String? {
val rawExtension = cert.getExtensionValue(ATTESTATION_OID.id) ?: return null
return runCatching {
val keyDescriptionDer = ASN1OctetString.getInstance(rawExtension).octets
formatKeyDescription(ASN1Sequence.getInstance(keyDescriptionDer))
}
.getOrElse { "<unparseable attestation extension: ${it.message}>" }
}
/** Renders the identity fields of every certificate in a returned chain for the dossier. */
fun formatCertChain(chain: List<Certificate>): String =
chain
.mapIndexed { index, cert ->
val x509 = cert as? X509Certificate ?: return@mapIndexed "[$index] <non-X509>"
"[$index] subject=${x509.subjectX500Principal.name} " +
"issuer=${x509.issuerX500Principal.name} " +
"serial=${x509.serialNumber.toString(16)} " +
"notBefore=${x509.notBefore} notAfter=${x509.notAfter}"
}
.joinToString(separator = " ; ")
/**
* Verifies every certificate in [chain] against its issuer and renders the outcome for the
* dossier. The forged chain is [leaf] + keybox certs, so edge 0<-1 proves the leaf was signed by
* the key matching the issuer cert and later edges test the keybox's own chain. For an RSA issuer
* it also reports signature-bytes vs modulus-bytes: a signature longer than the modulus is the
* exact DATA_TOO_LARGE_FOR_KEY_SIZE the app's verifier throws, so the offending edge is
* identifiable from the log alone.
*/
fun formatChainVerification(chain: List<Certificate>): String {
if (chain.size < 2) return "<single cert; nothing to chain-verify>"
return (0 until chain.size - 1).joinToString(separator = " ; ") { i ->
val child = chain[i] as? X509Certificate ?: return@joinToString "[$i]<non-X509>"
val parent =
chain[i + 1] as? X509Certificate ?: return@joinToString "[$i]<parent non-X509>"
val outcome =
runCatching {
child.verify(parent.publicKey)
"OK"
}
.getOrElse { "FAIL(${it.javaClass.simpleName}: ${it.message?.take(80)})" }
val rsaSizes =
(parent.publicKey as? RSAPublicKey)?.let {
val sigBytes = child.signature.size
val modBytes = (it.modulus.bitLength() + 7) / 8
" sig=${sigBytes}B mod=${modBytes}B" + if (sigBytes > modBytes) " OVERSIZE" else ""
} ?: ""
"[$i]${describeKey(child.publicKey)}<-[${i + 1}]${describeKey(parent.publicKey)}:" +
"$outcome$rsaSizes"
}
}
/**
* Per-cert key type/size, subject, issuer, and signature length, for reconstructing the chain a
* caller verifies. The signature length reveals the signer's key size, so a 4096-bit signature
* landing on a 2048-bit issuer (DATA_TOO_LARGE) is visible without the certificate bytes.
*/
fun formatChainKeys(chain: List<Certificate>): String =
chain
.mapIndexed { index, cert ->
val x509 = cert as? X509Certificate ?: return@mapIndexed "[$index]<non-X509>"
"[$index]${describeKey(x509.publicKey)} " +
"subj=${x509.subjectX500Principal.name} " +
"iss=${x509.issuerX500Principal.name} " +
"sigLen=${x509.signature.size}B"
}
.joinToString(separator = " ; ")
private fun describeKey(key: PublicKey): String =
when (key) {
is RSAPublicKey -> "RSA${key.modulus.bitLength()}"
is ECPublicKey -> "EC${key.params.curve.field.fieldSize}"
else -> key.algorithm
}
private fun formatKeyDescription(seq: ASN1Sequence): String {
val fields = seq.toArray()
return "attestVer=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_VERSION_INDEX])} " +
"attestSecLvl=${formatSecurityLevel(fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_SECURITY_LEVEL_INDEX])} " +
"kmVer=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_KEYMINT_VERSION_INDEX])} " +
"kmSecLvl=${formatSecurityLevel(fields[AttestationConstants.KEY_DESCRIPTION_KEYMINT_SECURITY_LEVEL_INDEX])} " +
"challenge=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_CHALLENGE_INDEX])} " +
"uniqueId=${formatAsn1Primitive(fields[AttestationConstants.KEY_DESCRIPTION_UNIQUE_ID_INDEX])} " +
"sw=${formatAuthorizationList(fields[AttestationConstants.KEY_DESCRIPTION_SOFTWARE_ENFORCED_INDEX])} " +
"tee=${formatAuthorizationList(fields[AttestationConstants.KEY_DESCRIPTION_TEE_ENFORCED_INDEX])}"
}
private fun formatSecurityLevel(obj: ASN1Encodable): String {
val level = (obj.toASN1Primitive() as? ASN1Enumerated)?.value?.toInt()
val name =
when (level) {
0 -> "Software"
1 -> "TEE"
2 -> "StrongBox"
else -> "?"
}
return "$level($name)"
}
private fun formatAuthorizationList(obj: ASN1Encodable): String {
val seq = obj.toASN1Primitive() as? ASN1Sequence ?: return formatAsn1Primitive(obj)
return seq
.map { element ->
val tagged = element as? ASN1TaggedObject ?: return@map formatAsn1Primitive(element)
val name = attestTagNames[tagged.tagNo] ?: "TAG"
val value =
if (tagged.tagNo == AttestationConstants.TAG_ROOT_OF_TRUST)
formatRootOfTrust(tagged.baseObject)
else formatAsn1Primitive(tagged.baseObject)
"${tagged.tagNo}($name)=$value"
}
.joinToString(prefix = "[", postfix = "]", separator = ", ")
}
/**
* Decodes the Root of Trust sub-sequence explicitly — it is the field a detector most often uses
* to unmask a simulated TEE (a random verifiedBootKey, an unexpected verifiedBootState, or a
* deviceLocked that disagrees with the bootloader all live here).
*/
private fun formatRootOfTrust(obj: ASN1Encodable): String {
val fields = (obj.toASN1Primitive() as? ASN1Sequence)?.toArray() ?: return formatAsn1Primitive(obj)
val state = fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_VERIFIED_BOOT_STATE_INDEX)
val stateName =
when ((state?.toASN1Primitive() as? ASN1Enumerated)?.value?.toInt()) {
0 -> "Verified"
1 -> "SelfSigned"
2 -> "Unverified"
3 -> "Failed"
else -> "?"
}
return "[bootKey=${formatAsn1Primitive(fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_VERIFIED_BOOT_KEY_INDEX))}, " +
"deviceLocked=${formatAsn1Primitive(fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_DEVICE_LOCKED_INDEX))}, " +
"verifiedBootState=${formatAsn1Primitive(state)}($stateName), " +
"bootHash=${formatAsn1Primitive(fields.getOrNull(AttestationConstants.ROOT_OF_TRUST_VERIFIED_BOOT_HASH_INDEX))}]"
}
// Function to check if a given ASN1Sequence contains the Root of Trust tag.
private fun sequenceContainsRootOfTrust(seq: ASN1Encodable): Boolean {
if (seq !is ASN1Sequence) return false
@@ -287,7 +457,8 @@ object AttestationPatcher {
val (allFields, teeEnforcedMap, originalRootOfTrust) = parsed
SystemLogger.verbose {
val formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
val formattedString =
allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
"Original attestation data: $formattedString"
}
@@ -317,7 +488,8 @@ object AttestationPatcher {
allFields[AttestationConstants.KEY_DESCRIPTION_TEE_ENFORCED_INDEX] = sortedTeeEnforced
val patchedSequence = DERSequence(allFields)
SystemLogger.verbose {
val formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
val formattedString =
patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
"Patched attestation data: $formattedString"
}
val patchedOctets = DEROctetString(patchedSequence)
@@ -1,7 +1,7 @@
package org.matrix.TEESimulator.attestation
import android.annotation.SuppressLint
import android.os.Build
import android.security.KeyStoreException
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyPairGenerator
@@ -9,6 +9,9 @@ import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.security.spec.ECGenParameterSpec
import java.security.spec.RSAKeyGenParameterSpec
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import org.bouncycastle.asn1.ASN1Integer
import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.ASN1OctetString
@@ -17,6 +20,7 @@ import org.bouncycastle.asn1.ASN1TaggedObject
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.cert.X509CertificateHolder
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
import org.matrix.TEESimulator.util.toHex
/**
@@ -61,22 +65,63 @@ object DeviceAttestationService {
// A unique alias for the key used to perform the TEE functionality check.
private const val TEE_CHECK_KEY_ALIAS = "TEESimulator_AttestationCheck"
// Alias for the device-ID attestation capability probe.
private const val DEVICE_ID_CHECK_KEY_ALIAS = "TEESimulator_DeviceIdCheck"
/**
* Lazily determines if the device's TEE is functional by attempting to generate an
* attestation-backed key pair. The result is cached.
*/
val isTeeFunctional: Boolean by lazy { checkTeeFunctionality() }
// Per (algorithm, security-level) attestation-capability verdicts, keyed by probe-key alias.
// A device may attest one algorithm or security level yet lack a provisioned attestation key
// for another (e.g. a TEE that attests RSA over a StrongBox that cannot), so each pair is
// probed and cached on its own.
private data class ProbeSpec(
val algorithm: String,
val strongBox: Boolean,
val keyAlias: String,
)
private val rsaTeeProbe =
ProbeSpec(KeyProperties.KEY_ALGORITHM_RSA, false, "TEESimulator_RsaAttestCheck")
private val rsaStrongBoxProbe =
ProbeSpec(KeyProperties.KEY_ALGORITHM_RSA, true, "TEESimulator_RsaAttestCheckSb")
private val ecTeeProbe =
ProbeSpec(KeyProperties.KEY_ALGORITHM_EC, false, "TEESimulator_EcAttestCheck")
private val ecStrongBoxProbe =
ProbeSpec(KeyProperties.KEY_ALGORITHM_EC, true, "TEESimulator_EcAttestCheckSb")
private val attestableVerdicts = ConcurrentHashMap<String, Boolean>()
private val attestProbesInFlight = ConcurrentHashMap<String, AtomicBoolean>()
/**
* Lazily mirrors whether the real TEE can attest device identifiers/properties (the tags added
* by `setDevicePropertiesAttestationIncluded`). Hardware that never provisioned device IDs
* returns CANNOT_ATTEST_IDS; the synthesizer consults this so it never forges a capability the
* real silicon lacks. Cached.
* Whether the real hardware can attest an RSA key at the requested security level. AUTO dispatch
* reads this to forge RSA attestation only where the hardware genuinely cannot serve it.
*
* Only a definitive verdict is cached: a successful probe, or a permanent keystore failure. A
* transient or unrecognized failure leaves the verdict unset and reports attestable, so dispatch
* PATCHes the genuine chain and re-probes next read — a one-off keystore hiccup can never freeze
* the device into forging an attestation it could serve.
*/
val canAttestDeviceIds: Boolean by lazy { checkDeviceIdAttestation() }
fun isRsaAttestable(strongBox: Boolean): Boolean =
isHardwareAttestable(if (strongBox) rsaStrongBoxProbe else rsaTeeProbe)
/** Whether the real hardware can attest an EC key at the requested security level. */
fun isEcAttestable(strongBox: Boolean): Boolean =
isHardwareAttestable(if (strongBox) ecStrongBoxProbe else ecTeeProbe)
private fun isHardwareAttestable(probe: ProbeSpec): Boolean {
attestableVerdicts[probe.keyAlias]?.let { return it }
val probeInFlight =
attestProbesInFlight.computeIfAbsent(probe.keyAlias) { AtomicBoolean(false) }
if (probeInFlight.compareAndSet(false, true)) {
try {
probeAttestability(probe)?.let { attestableVerdicts[probe.keyAlias] = it }
} finally {
probeInFlight.set(false)
}
}
return attestableVerdicts[probe.keyAlias] ?: true
}
/**
* Lazily fetches and parses attestation data from a genuinely generated certificate. The result
@@ -119,33 +164,80 @@ object DeviceAttestationService {
}
/**
* Probes whether the real TEE can satisfy device-ID/property attestation, mirroring its actual
* capability. Gated behind [isTeeFunctional] so a dead TEE never triggers a second doomed
* probe — it simply reports `false` (cannot attest), the faithful result for such hardware.
* Probes whether the real hardware can attest a key matching [probe] by generating one with an
* attestation challenge at the probe's algorithm and security level. Mirrors
* [checkTeeFunctionality]; the request runs as the module UID, so it is skipped by interception
* and reaches genuine hardware rather than the forge path.
*
* @return `true` if attestation succeeded, `false` only on a confirmed attestation-keys-
* unavailable failure, or `null` on a transient or unrecognized failure where the caller
* fails open and re-probes.
*/
private fun checkDeviceIdAttestation(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
if (!isTeeFunctional) return false
private fun probeAttestability(probe: ProbeSpec): Boolean? {
val label = "${probe.algorithm} attestation (strongBox=${probe.strongBox})"
SystemLogger.info("Performing $label capability check...")
return try {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val keyPairGenerator =
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
KeyPairGenerator.getInstance(probe.algorithm, "AndroidKeyStore")
val challenge = ByteArray(16).apply { SecureRandom().nextBytes(this) }
val spec =
KeyGenParameterSpec.Builder(DEVICE_ID_CHECK_KEY_ALIAS, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
val builder =
KeyGenParameterSpec.Builder(probe.keyAlias, KeyProperties.PURPOSE_SIGN)
.setDigests(KeyProperties.DIGEST_SHA256)
.setAttestationChallenge(challenge)
.setDevicePropertiesAttestationIncluded(true)
.build()
keyPairGenerator.initialize(spec)
.setIsStrongBoxBacked(probe.strongBox)
if (probe.algorithm == KeyProperties.KEY_ALGORITHM_RSA) {
builder
.setAlgorithmParameterSpec(RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4))
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
} else {
builder.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
}
keyPairGenerator.initialize(builder.build())
keyPairGenerator.generateKeyPair()
runCatching { keyStore.deleteEntry(DEVICE_ID_CHECK_KEY_ALIAS) }
SystemLogger.info("Device-ID attestation supported by TEE.")
SystemLogger.info("$label capability check successful.")
true
} catch (_: Exception) {
SystemLogger.info("Device-ID attestation not supported by TEE; mirroring as cannot-attest.")
} catch (e: Exception) {
if (isAttestationUnavailable(e)) {
SystemLogger.info("$label unsupported by hardware; AUTO will forge attestation.")
false
} else {
SystemLogger.warning(
"$label capability check failed transiently; treating as capable.",
e,
)
null
}
} finally {
deleteProbeKey(probe.keyAlias)
}
}
/**
* Whether [error] definitively means the hardware cannot attest the probed key: a permanent
* [KeyStoreException] from the keystore. Transient failures and non-keystore errors return
* `false`, so the caller fails open and re-probes rather than caching a guess. The probe runs a
* fixed, valid spec as root, so its only permanent keystore failure mode is missing attestation
* support; [KeyStoreException.isTransientFailure] draws the transient/permanent line.
*/
private fun isAttestationUnavailable(error: Throwable): Boolean {
var cause: Throwable? = error
while (cause != null) {
val keyStoreError = cause as? KeyStoreException
if (keyStoreError != null) return !keyStoreError.isTransientFailure
cause = cause.cause
}
return false
}
private fun deleteProbeKey(keyAlias: String) {
try {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(keyAlias)
} catch (e: Exception) {
SystemLogger.warning("Failed to delete attestation probe key.", e)
}
}
@@ -192,19 +284,23 @@ object DeviceAttestationService {
// The extension's value is an ASN.1 sequence.
val keyDescriptionSeq = ASN1Sequence.getInstance(extension.extnValue.octets)
SystemLogger.verbose {
val formattedString = keyDescriptionSeq.joinToString(separator = ", ") {
val formattedString =
keyDescriptionSeq.joinToString(separator = ", ") {
AttestationPatcher.formatAsn1Primitive(it)
}
"Cached attestation data: $formattedString"
}
val fields = keyDescriptionSeq.toArray()
val attestVersion =
val deviceAttestVersion =
ASN1Integer.getInstance(
fields[AttestationConstants.KEY_DESCRIPTION_ATTESTATION_VERSION_INDEX]
)
.positiveValue
.toInt()
// The device KeyMint HAL can report a version below its OS's AOSP value (100 on an A16
// where BAKLAVA mandates 400); cache the AOSP value so the forge matches an updated device.
val attestVersion = AndroidDeviceUtils.aospAttestVersion ?: deviceAttestVersion
val keymasterVersion =
ASN1Integer.getInstance(
fields[AttestationConstants.KEY_DESCRIPTION_KEYMINT_VERSION_INDEX]
@@ -298,7 +394,7 @@ object DeviceAttestationService {
}
SystemLogger.info(
"Successfully extracted attestation data: version=$attestVersion, osVersion=$osVersion, osPatch=$osPatchLevel, vendorPatch=$vendorPatchLevel, bootPatch=$bootPatchLevel, moduleHash=${moduleHash?.toHex()}, bootKey=${verifiedBootKey?.toHex()}, bootHash=${verifiedBootHash?.toHex()}"
"Successfully extracted attestation data: version=$deviceAttestVersion, osVersion=$osVersion, osPatch=$osPatchLevel, vendorPatch=$vendorPatchLevel, bootPatch=$bootPatchLevel, moduleHash=${moduleHash?.toHex()}, bootKey=${verifiedBootKey?.toHex()}, bootHash=${verifiedBootHash?.toHex()}"
)
return AttestationData(
moduleHash,
@@ -58,6 +58,7 @@ data class KeyMintAttestation(
val maxUsesPerBoot: Int?,
val maxBootLevel: Int?,
val minMacLength: Int?,
val macLength: Int? = null,
val rsaOaepMgfDigest: List<Int>,
) {
/** Secondary constructor that populates the fields by parsing an array of `KeyParameter`. */
@@ -134,6 +135,7 @@ data class KeyMintAttestation(
maxUsesPerBoot = params.findInteger(Tag.MAX_USES_PER_BOOT),
maxBootLevel = params.findInteger(Tag.MAX_BOOT_LEVEL),
minMacLength = params.findInteger(Tag.MIN_MAC_LENGTH),
macLength = params.findInteger(Tag.MAC_LENGTH),
rsaOaepMgfDigest = params.findAllDigests(Tag.RSA_OAEP_MGF_DIGEST),
) {
// Log all parsed parameters for debugging purposes.
@@ -142,7 +144,8 @@ data class KeyMintAttestation(
fun isAttestKey(): Boolean = purpose.size == 1 && purpose.contains(KeyPurpose.ATTEST_KEY)
fun isImportKey(): Boolean = origin == KeyOrigin.IMPORTED || origin == KeyOrigin.SECURELY_IMPORTED
fun isImportKey(): Boolean =
origin == KeyOrigin.IMPORTED || origin == KeyOrigin.SECURELY_IMPORTED
}
// --- Private helper extension functions for parsing KeyParameter arrays ---
@@ -1,10 +1,20 @@
package org.matrix.TEESimulator.config
import android.os.SystemProperties
import java.io.File
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
object BootStateManager {
private const val CONFIG_PATH = "/data/adb/tricky_store"
private const val BOOT_PROPS_MODE_FILE = "boot_props_mode"
private enum class BootPropsMode {
AUTO,
FORCE,
DISABLE,
}
private val targets =
linkedMapOf(
"ro.boot.verifiedbootstate" to "green",
@@ -22,6 +32,25 @@ object BootStateManager {
)
fun apply() {
val mode = readBootPropsMode()
when (mode) {
BootPropsMode.DISABLE -> {
SystemLogger.info("BootStateManager: disabled by $BOOT_PROPS_MODE_FILE")
return
}
BootPropsMode.AUTO -> {
if (isOplusFamilyDevice()) {
SystemLogger.warning(
"BootStateManager: skipping boot-state prop spoofing on Oplus-family device in auto mode"
)
return
}
}
BootPropsMode.FORCE -> {
SystemLogger.info("BootStateManager: force-enabled by $BOOT_PROPS_MODE_FILE")
}
}
for ((name, target) in targets) {
val current = SystemProperties.get(name, "")
if (current.isEmpty()) {
@@ -45,4 +74,50 @@ object BootStateManager {
AndroidDeviceUtils.setProperty(name, value)
}
}
fun shouldSpoofBootProps(): Boolean =
when (readBootPropsMode()) {
BootPropsMode.DISABLE -> false
BootPropsMode.AUTO -> !isOplusFamilyDevice()
BootPropsMode.FORCE -> true
}
private fun readBootPropsMode(): BootPropsMode {
val file = File(CONFIG_PATH, BOOT_PROPS_MODE_FILE)
if (!file.exists()) return BootPropsMode.AUTO
val raw =
runCatching { file.readText().trim().lowercase() }
.getOrElse {
SystemLogger.warning("BootStateManager: failed to read ${file.absolutePath}", it)
return BootPropsMode.AUTO
}
return when (raw) {
"1", "true", "on", "enable", "enabled", "force" -> BootPropsMode.FORCE
"0", "false", "off", "disable", "disabled", "none" -> BootPropsMode.DISABLE
else -> BootPropsMode.AUTO
}
}
private fun isOplusFamilyDevice(): Boolean {
val props =
listOf(
"ro.product.manufacturer",
"ro.product.brand",
"ro.product.vendor.manufacturer",
"ro.product.vendor.brand",
"ro.product.odm.manufacturer",
"ro.product.odm.brand",
"ro.boot.hardware.sku",
"ro.boot.project_name",
)
val joined =
props.joinToString(separator = " ") { name ->
SystemProperties.get(name, "")
}.lowercase()
return listOf("oneplus", "oplus", "oppo", "realme").any { joined.contains(it) }
}
}
@@ -96,7 +96,8 @@ object ConfigurationManager {
fun isAutoMode(uid: Int): Boolean {
for (pkg in getPackagesForUid(uid)) {
when (packageModes[pkg]) {
Mode.GENERATE, Mode.PATCH -> return false
Mode.GENERATE,
Mode.PATCH -> return false
Mode.AUTO -> return true
null -> continue
}
@@ -112,7 +113,9 @@ object ConfigurationManager {
when (packageModes[pkg]) {
Mode.GENERATE -> return Mode.GENERATE
Mode.PATCH -> return Mode.PATCH
Mode.AUTO -> return if (DeviceAttestationService.isTeeFunctional) Mode.PATCH else Mode.GENERATE
Mode.AUTO ->
return if (DeviceAttestationService.isTeeFunctional) Mode.PATCH
else Mode.GENERATE
null -> continue
}
}
@@ -260,7 +263,9 @@ object ConfigurationManager {
// resolves to the real device prop — force boot/vendor through the same path
// to prevent cross-component date mismatches on non-Pixel devices.
if (newGlobalLevel?.system.equals("prop", ignoreCase = true)) {
SystemLogger.info("system=prop: forcing boot/vendor to derive from device props (were: boot=${newGlobalLevel?.boot}, vendor=${newGlobalLevel?.vendor})")
SystemLogger.info(
"system=prop: forcing boot/vendor to derive from device props (were: boot=${newGlobalLevel?.boot}, vendor=${newGlobalLevel?.vendor})"
)
newGlobalLevel = newGlobalLevel?.copy(boot = "prop", vendor = "prop")
}
contextLines.remove("") // Remove global context to iterate over packages next
@@ -293,9 +298,11 @@ object ConfigurationManager {
val file = if (event != DELETE) File(configRoot, path) else null
when (path) {
TARGET_PACKAGES_FILE -> file?.let { loadTargetPackages(it) }
TARGET_PACKAGES_FILE ->
file?.let { loadTargetPackages(it) }
?: SystemLogger.warning("$TARGET_PACKAGES_FILE was deleted.")
PATCH_LEVEL_FILE -> file?.let { loadPatchLevelConfig(it) }
PATCH_LEVEL_FILE ->
file?.let { loadPatchLevelConfig(it) }
?: SystemLogger.warning("$PATCH_LEVEL_FILE was deleted.")
// Any change to an XML file is assumed to be a keybox.
// The cache in KeyBoxManager will handle reloading it on its next use.
@@ -110,14 +110,18 @@ abstract class BinderInterceptor : Binder() {
*/
final override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
val txId = data.readLong()
val result = try {
val result =
try {
when (code) {
PRE_TRANSACT_CODE -> handlePreTransact(txId, data)
POST_TRANSACT_CODE -> handlePostTransact(txId, data)
else -> return super.onTransact(code, data, reply, flags)
}
} catch (e: Throwable) {
SystemLogger.error("[TX_ID: $txId] Interceptor exception, falling through to HAL", e)
SystemLogger.error(
"[TX_ID: $txId] Interceptor exception, falling through to HAL",
e,
)
TransactionResult.ContinueAndSkipPost
}
writeResultToReply(result, reply!!)
@@ -220,7 +224,11 @@ abstract class BinderInterceptor : Binder() {
}
}
/** Helper function for consistent logging of intercepted transactions. */
/**
* Logs an intercepted transaction. For a targeted UID every transaction — whether we intercept
* or merely observe it — is recorded on that UID's own diagnostic plane, so its keystore
* timeline reads cleanly end to end. Untargeted UIDs get a single terse, rate-limited line.
*/
protected fun logTransaction(
txId: Long,
methodName: String,
@@ -228,15 +236,14 @@ abstract class BinderInterceptor : Binder() {
callingPid: Int,
skipPost: Boolean = false,
) {
val isIntercepting = !skipPost && !ConfigurationManager.shouldSkipUid(callingUid)
val action = if (isIntercepting) "Intercept" else "Observe"
if (SystemLogger.isUidLogged(callingUid)) {
val action = if (skipPost) "observe" else "intercept"
SystemLogger.uidLog(callingUid, txId, "tx", "$methodName action=$action pid=$callingPid")
return
}
SystemLogger.verbose {
val packages = ConfigurationManager.getPackagesForUid(callingUid).joinToString()
val message =
"[TX_ID: $txId] $action $methodName for packages=[$packages] (uid=$callingUid, pid=$callingPid)"
if (isIntercepting) {
SystemLogger.debug(message)
} else {
SystemLogger.verbose(message)
"[TX_ID: $txId] Observe $methodName for packages=[$packages] (uid=$callingUid, pid=$callingPid)"
}
}
@@ -298,18 +305,26 @@ abstract class BinderInterceptor : Binder() {
target: IBinder,
interceptor: BinderInterceptor,
filteredCodes: IntArray = intArrayOf(),
) {
): Boolean {
val data = Parcel.obtain()
val reply = Parcel.obtain()
try {
return try {
data.writeStrongBinder(target)
data.writeStrongBinder(interceptor)
data.writeInt(filteredCodes.size)
for (code in filteredCodes) data.writeInt(code)
backdoor.transact(REGISTER_INTERCEPTOR_CODE, data, reply, 0)
SystemLogger.info("Registered interceptor for target: $target (${filteredCodes.size} filtered codes)")
val ok = backdoor.transact(REGISTER_INTERCEPTOR_CODE, data, reply, 0)
if (ok) {
SystemLogger.info(
"Registered interceptor for target: $target (${filteredCodes.size} filtered codes)"
)
} else {
SystemLogger.error("Register transact returned false for target: $target")
}
ok
} catch (e: Exception) {
SystemLogger.error("Failed to register binder interceptor.", e)
false
} finally {
data.recycle()
reply.recycle()
@@ -8,6 +8,8 @@ import android.os.Parcelable
import android.security.KeyStore
import android.security.keystore.KeystoreResponse
import android.system.keystore2.Authorization
import java.nio.ByteBuffer
import java.nio.ByteOrder
import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
@@ -19,6 +21,11 @@ object InterceptorUtils {
private const val EX_SERVICE_SPECIFIC = -8
private const val FLAT_STRIDE_HEADER = 12
private const val MAX_AUTH_COUNT = 256
private const val SENTINEL_MODTIME = 4_294_967_297L
private const val HIGH_MODTIME = 4_999_999_999L
private fun synthesizeSseMessage(errorCode: Int): String =
when (errorCode) {
2 -> "Error::Rc(SYSTEM_ERROR)"
@@ -37,7 +44,8 @@ object InterceptorUtils {
}
fun createErrorReply(errorCode: Int): BinderInterceptor.TransactionResult.OverrideReply {
val parcel = Parcel.obtain().apply {
val parcel =
Parcel.obtain().apply {
writeInt(EX_SERVICE_SPECIFIC)
writeString(synthesizeSseMessage(errorCode))
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
@@ -112,24 +120,25 @@ object InterceptorUtils {
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
}
/** Correlates a captured reply parcel to the app that triggered it, for [createTypedObjectReply]. */
data class ReplyDiagnostic(val uid: Int, val txId: Long?, val event: String)
/** Creates an `OverrideReply` parcel containing a Parcelable object. */
fun <T : Parcelable?> createTypedObjectReply(
obj: T,
flags: Int = 0,
diagnosticTag: String? = null,
diagnostic: ReplyDiagnostic? = null,
): BinderInterceptor.TransactionResult.OverrideReply {
val parcel =
Parcel.obtain().apply {
writeNoException()
writeTypedObject(obj, flags)
}
if (diagnosticTag != null && SystemLogger.isDebugBuild) {
if (diagnostic != null && SystemLogger.isUidLogged(diagnostic.uid)) {
val savedPos = parcel.dataPosition()
val wire = parcel.marshall()
parcel.setDataPosition(savedPos)
val path = "/data/local/tmp/teesim-$diagnosticTag.bin"
runCatching { java.io.File(path).writeBytes(wire) }
SystemLogger.debug("[$diagnosticTag] reply len=${wire.size} path=$path")
SystemLogger.uidLogRaw(diagnostic.uid, diagnostic.txId, diagnostic.event, "len=${wire.size}", wire)
}
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
}
@@ -189,8 +198,8 @@ object InterceptorUtils {
val vendorPatch = AndroidDeviceUtils.getVendorPatchLevelLong(callingUid)
val bootPatch = AndroidDeviceUtils.getBootPatchLevelLong(callingUid)
return authorizations
.map { auth ->
val patched =
authorizations.map { auth ->
val replacement =
when (auth.keyParameter.tag) {
Tag.OS_PATCHLEVEL ->
@@ -216,5 +225,101 @@ object InterceptorUtils {
}
}
.toTypedArray()
return normalizeAuthorizationLayout(patched)
}
/**
* Reorders a generateKey reply's authorizations only when the marshalled reply would read, to a
* flat 12-byte-stride parcel fingerprint, as the TEE-simulator sentinel: a last slot of
* securityLevel 4 or 256, tag 1, union 32 with the 0x1_0000_0001 pseudo-timestamp, or any
* timestamp past [HIGH_MODTIME]. Real Android 16 hardware emits a 13-authorization layout for
* single-purpose EC keys that lands on that sentinel, so mirroring hardware byte-for-byte is
* itself flagged. Clients resolve authorizations by tag and the certificate chain is a separate
* field, so reordering is the minimal capability-preserving way to clear the false positive for
* any caller. Already-clean replies are returned unchanged.
*/
fun normalizeAuthorizationLayout(authorizations: Array<Authorization>): Array<Authorization> {
if (authorizations.size < 2) return authorizations
if (!flatStrideFingerprintMatches(marshalTypedArray(authorizations))) return authorizations
val n = authorizations.size
for (src in 1 until n) {
val candidate = moveAuthorization(authorizations, src, 0)
if (!flatStrideFingerprintMatches(marshalTypedArray(candidate))) return candidate
}
for (src in 0 until n) {
for (dst in 0 until n) {
if (src == dst) continue
val candidate = moveAuthorization(authorizations, src, dst)
if (!flatStrideFingerprintMatches(marshalTypedArray(candidate))) return candidate
}
}
return authorizations
}
private fun moveAuthorization(
authorizations: Array<Authorization>,
src: Int,
dst: Int,
): Array<Authorization> {
val reordered = authorizations.toMutableList()
reordered.add(dst, reordered.removeAt(src))
return reordered.toTypedArray()
}
private fun marshalTypedArray(authorizations: Array<Authorization>): ByteArray {
val parcel = Parcel.obtain()
return try {
// keystore2 AIDL compile stubs omit the Parcelable supertype these types carry at runtime.
parcel.writeTypedArray(authorizations.map { it as Parcelable }.toTypedArray(), 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
private fun flatStrideFingerprintMatches(marshalled: ByteArray): Boolean =
runCatching {
val parcel = ByteBuffer.wrap(marshalled).order(ByteOrder.LITTLE_ENDIAN)
val count = parcel.getInt(0)
if (count !in 1..MAX_AUTH_COUNT) return@runCatching false
var off = 4
var lastSec = 0L
var lastTag = 0L
var lastUnion = 0L
repeat(count) {
lastSec = u32(parcel, off)
lastTag = u32(parcel, off + 4)
lastUnion = u32(parcel, off + 8)
off += FLAT_STRIDE_HEADER
off = alignWord(off + flatPayloadSize(parcel, off, lastUnion))
}
off = skipDriftedByteArray(parcel, off)
off = skipDriftedByteArray(parcel, off)
val modtime = parcel.getLong(alignWord(off))
val unknownUnion = lastUnion !in 0..14
modtime > HIGH_MODTIME ||
(modtime == SENTINEL_MODTIME &&
(lastSec == 4L || lastSec == 256L) &&
lastTag == 1L &&
lastUnion == 32L &&
unknownUnion)
}.getOrDefault(false)
private fun flatPayloadSize(parcel: ByteBuffer, off: Int, union: Long): Int =
when {
union in 1..11 -> 4
union == 12L || union == 13L -> 8
union == 14L -> alignWord(off + 4 + parcel.getInt(off)) - off
else -> 0
}
private fun skipDriftedByteArray(parcel: ByteBuffer, off: Int): Int {
if (parcel.getInt(off) == 0) return off + 4
val lengthPos = off + 4
return alignWord(lengthPos + 4 + parcel.getInt(lengthPos))
}
private fun u32(parcel: ByteBuffer, off: Int): Long = parcel.getInt(off).toLong() and 0xFFFFFFFFL
private fun alignWord(off: Int): Int = (off + 3) and 3.inv()
}
@@ -18,6 +18,7 @@ import org.matrix.TEESimulator.attestation.KeyMintAttestation
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.interception.keystore.shim.GeneratedKeyPersistence
import org.matrix.TEESimulator.interception.keystore.shim.KeyMintSecurityLevelInterceptor
import org.matrix.TEESimulator.logging.AttestationDossier
import org.matrix.TEESimulator.logging.KeyMintParameterLogger
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.CertificateGenerator
@@ -63,6 +64,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
private const val RESPONSE_KEY_NOT_FOUND = 7
private const val RESPONSE_PERMISSION_DENIED = 6
private const val KEY_PERMISSION_GET_INFO = 0x4
private const val KEY_PERMISSION_UPDATE = 0x80
// KeyStoreManager.grantKeyAccess() became a public app API in Android 16 (API 36). Before that,
// grant was a hidden API and SELinux denied untrusted_app, so a synthetic-key grant must answer
@@ -120,7 +123,10 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
Keystore2MaintenanceInterceptor,
Keystore2MaintenanceInterceptor.interceptedCodes,
)
} ?: SystemLogger.warning("Maintenance binder not found; skipping lifecycle parity.")
}
?: SystemLogger.warning(
"Maintenance binder not found; skipping lifecycle parity."
)
}
.onFailure { SystemLogger.error("Failed to intercept maintenance binder.", it) }
}
@@ -219,22 +225,28 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
?: return TransactionResult.ContinueAndSkipPost
// Domain.GRANT read (Android 16+ KeyStoreManager grant). Served for ANY grantee uid —
// including isolated services (bindIsolatedService) with no package mapping — so resolve
// it before the package-scoped skip; caller-binding in resolveGrant() is the real access
// gate. On Android <= 15 no grants are ever issued (grant() denies), so softwareGrants is
// including isolated services (bindIsolatedService) with no package mapping — so
// resolve
// it before the package-scoped skip; caller-binding in resolveGrant() is the real
// access
// gate. On Android <= 15 no grants are ever issued (grant() denies), so softwareGrants
// is
// empty and this falls through to the real keystore2.
if (code == GET_KEY_ENTRY_TRANSACTION && descriptor.domain == Domain.GRANT) {
val grant =
KeyMintSecurityLevelInterceptor.resolveGrant(descriptor.nspace, callingUid)
if (grant == null) {
// Ours but wrong caller -> KEY_NOT_FOUND (caller-binding); not ours -> real keystore2.
// Ours but wrong caller -> KEY_NOT_FOUND (caller-binding); not ours -> real
// keystore2.
return if (
KeyMintSecurityLevelInterceptor.softwareGrants.containsKey(descriptor.nspace)
KeyMintSecurityLevelInterceptor.softwareGrants.containsKey(
descriptor.nspace
)
)
InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
else TransactionResult.ContinueAndSkipPost
}
if ((grant.accessVector and 0x4) == 0) { // GET_INFO = 0x4 (access-vector gate)
if ((grant.accessVector and KEY_PERMISSION_GET_INFO) == 0) {
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
}
val response =
@@ -244,7 +256,9 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
return InterceptorUtils.createTypedObjectReply(response)
}
if (ConfigurationManager.shouldSkipUid(callingUid))
// generateKey force-forges attest/device-id keys even for skipped UIDs; getKeyEntry
// must serve them back or the framework's attestKeyAlias lookup gets KEY_NOT_FOUND.
if (code != GET_KEY_ENTRY_TRANSACTION && ConfigurationManager.shouldSkipUid(callingUid))
return TransactionResult.ContinueAndSkipPost
if (code == DELETE_KEY_TRANSACTION) {
@@ -253,10 +267,14 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
KeyIdentifier(callingUid, descriptor.alias)
} else if (descriptor.domain == Domain.KEY_ID) {
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
)?.let { info ->
callingUid,
descriptor.nspace,
)
?.let { info ->
KeyMintSecurityLevelInterceptor.generatedKeys.entries
.find { it.value.nspace == info.nspace && it.key.uid == callingUid }
.find {
it.value.nspace == info.nspace && it.key.uid == callingUid
}
?.key
}
} else null
@@ -289,27 +307,34 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
// "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
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}"
)
logServedChain(callingUid, txId, "keyid:${descriptor.nspace}", info.response)
return InterceptorUtils.createTypedObjectReply(info.response)
}
val teeResp = KeyMintSecurityLevelInterceptor.findTeeResponseByKeyId(
callingUid, descriptor.nspace
val teeResp =
KeyMintSecurityLevelInterceptor.findTeeResponseByKeyId(
callingUid,
descriptor.nspace,
)
if (teeResp != null) {
SystemLogger.info(
"[TX_ID: $txId] Found TEE response via KEY_ID nspace=${descriptor.nspace}"
)
logServedChain(callingUid, txId, "keyid:${descriptor.nspace}", teeResp)
return InterceptorUtils.createTypedObjectReply(teeResp)
}
}
// Domain.GRANT is handled earlier (before the package-scoped skip); an alias-less
// read reaching here is KEY_ID or unknown, so it falls through to the real keystore2.
// read reaching here is KEY_ID or unknown, so it falls through to the real
// keystore2.
return TransactionResult.ContinueAndSkipPost
}
val keyId = KeyIdentifier(callingUid, descriptor.alias)
@@ -317,10 +342,16 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
val response = KeyMintSecurityLevelInterceptor.getGeneratedKeyResponse(keyId)
if (response == null) {
if (deletedSoftwareKeys.remove(keyId)) {
SystemLogger.info("[TX_ID: $txId] Returning KEY_NOT_FOUND for deleted key ${descriptor.alias}")
SystemLogger.info(
"[TX_ID: $txId] Returning KEY_NOT_FOUND for deleted key ${descriptor.alias}"
)
return InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
}
return TransactionResult.Continue
// Owned keys were served above; for a skipped UID a non-owned key must still skip
// post-processing so we never patch an un-targeted app's real key.
return if (ConfigurationManager.shouldSkipUid(callingUid))
TransactionResult.ContinueAndSkipPost
else TransactionResult.Continue
}
if (KeyMintSecurityLevelInterceptor.isAttestationKey(keyId))
@@ -328,8 +359,9 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
SystemLogger.info("[TX_ID: $txId] Found generated response for ${descriptor.alias}:")
response.metadata?.authorizations?.forEach {
KeyMintParameterLogger.logParameter(it.keyParameter)
KeyMintParameterLogger.logParameter(callingUid, txId, it.keyParameter)
}
logServedChain(callingUid, txId, descriptor.alias, response)
return InterceptorUtils.createTypedObjectReply(response)
} else if (code == GRANT_TRANSACTION) {
logTransaction(txId, transactionNames[code] ?: "grant", callingUid, callingPid)
@@ -339,14 +371,16 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
?: return TransactionResult.ContinueAndSkipPost
val granteeUid = data.readInt()
val accessVector = data.readInt()
// Synthetic (generatedKeys) AND patch-mode (teeResponses) keys are ours; both must grant
// coherently so the Domain.GRANT readback returns the same chain the owner read returns.
// Synthetic (generatedKeys) AND patch-mode (teeResponses) keys are ours; both must
// grant
// coherently so the Domain.GRANT readback returns the same chain the owner read
// returns.
// Real hardware keys fall through to the real keystore2, which applies the same SELinux
// gate the platform would.
val ownerKeyId =
resolveOwnerKeyId(key, callingUid)
?.takeIf { KeyMintSecurityLevelInterceptor.ownsKeyResponse(it) }
?: return TransactionResult.ContinueAndSkipPost
resolveOwnerKeyId(key, callingUid)?.takeIf {
KeyMintSecurityLevelInterceptor.ownsKeyResponse(it)
} ?: return TransactionResult.ContinueAndSkipPost
// Version-gated to mirror the real TEE 1:1. Pre-Android-16, grant was a hidden API and
// SELinux denied untrusted_app, so keystore2 returns PERMISSION_DENIED. Android 16
// (API 36) exposes KeyStoreManager.grantKeyAccess(), so an app grants its own key:
@@ -373,9 +407,9 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
?: return TransactionResult.ContinueAndSkipPost
val granteeUid = data.readInt()
val ownerKeyId =
resolveOwnerKeyId(key, callingUid)
?.takeIf { KeyMintSecurityLevelInterceptor.ownsKeyResponse(it) }
?: return TransactionResult.ContinueAndSkipPost
resolveOwnerKeyId(key, callingUid)?.takeIf {
KeyMintSecurityLevelInterceptor.ownsKeyResponse(it)
} ?: return TransactionResult.ContinueAndSkipPost
// Same version gate as grant(): denied pre-36, revoke the virtualized grant on 36+.
if (Build.VERSION.SDK_INT < GRANT_PUBLIC_API_SDK) {
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
@@ -423,7 +457,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
it.uid == callingUid
}
val totalCount = hardwareCount + softwareCount
val parcel = Parcel.obtain().apply {
val parcel =
Parcel.obtain().apply {
writeNoException()
writeInt(totalCount)
}
@@ -469,8 +504,12 @@ 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.")
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
}
@@ -480,18 +519,29 @@ 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()}" }
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).")
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()
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,
@@ -501,8 +551,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
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")
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
}
@@ -545,7 +599,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
// Snapshot metadata bytes for the same reason as the
// primary doSoftwareKeyGen path — loss-less restore
// after reboot.
val metadataBytesForPersist = response.metadata?.let { md ->
val metadataBytesForPersist =
response.metadata?.let { md ->
runCatching {
val parcel = android.os.Parcel.obtain()
try {
@@ -554,7 +609,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
} finally {
parcel.recycle()
}
}.getOrNull()
}
.getOrNull()
}
GeneratedKeyPersistence.save(
keyId = keyId,
@@ -609,6 +665,10 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
callingUid,
)
// PATCH decode point: the patched chain actually served back to the app on
// getKeyEntry — the ground truth a patch-mode detector reads.
AttestationDossier.log(callingUid, txId, "PATCH", finalChain.asList())
return InterceptorUtils.createTypedObjectReply(response)
}
.onFailure {
@@ -623,33 +683,90 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
/**
* Resolves the owner [KeyIdentifier] a grant/ungrant call targets. APP/alias keys map
* directly; KEY_ID keys are looked up by nspace (mirrors the deleteKey resolver). Returns
* null for anything not addressable, so callers fall through to the real keystore2.
* Resolves the owner [KeyIdentifier] a grant/ungrant call targets. APP/alias keys map directly;
* KEY_ID keys are looked up by nspace (mirrors the deleteKey resolver). Returns null for
* anything not addressable, so callers fall through to the real keystore2.
*/
private fun resolveOwnerKeyId(descriptor: KeyDescriptor, callingUid: Int): KeyIdentifier? =
when {
descriptor.alias != null -> KeyIdentifier(callingUid, descriptor.alias)
descriptor.domain == Domain.KEY_ID ->
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(callingUid, descriptor.nspace)
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid,
descriptor.nspace,
)
?.let { info ->
KeyMintSecurityLevelInterceptor.generatedKeys.entries
.firstOrNull { it.value.nspace == info.nspace && it.key.uid == callingUid }
.firstOrNull {
it.value.nspace == info.nspace && it.key.uid == callingUid
}
?.key
}
else -> null
}
/**
* Records the certificate chain actually served back to [uid] on a getKeyEntry, keyed by
* [alias]. The app reassembles its final chain from these served chains (the leaf alias plus
* the attest-key alias), so logging each one with key sizes and a per-edge verification makes a
* verification failure in the app's combined chain reproducible from the log, not inferred.
*/
private fun logServedChain(uid: Int, txId: Long, alias: String, response: KeyEntryResponse?) {
if (response == null || !SystemLogger.isUidLogged(uid)) return
val chain = CertificateHelper.getCertificateChain(response)?.asList() ?: return
SystemLogger.uidLog(uid, txId, "served", "alias=$alias depth=${chain.size}")
SystemLogger.uidLog(uid, txId, "served-keys", AttestationPatcher.formatChainKeys(chain))
SystemLogger.uidLog(uid, txId, "served-verify", AttestationPatcher.formatChainVerification(chain))
}
private fun handleUpdateSubcomponent(callingUid: Int, data: Parcel): TransactionResult {
data.enforceInterface(IKeystoreService.DESCRIPTOR)
val descriptor = data.readTypedObject(KeyDescriptor.CREATOR)
val descriptor =
data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.ContinueAndSkipPost
if (descriptor.domain == Domain.GRANT) {
val grant =
KeyMintSecurityLevelInterceptor.resolveGrant(descriptor.nspace, callingUid)
if (grant == null) {
return if (
KeyMintSecurityLevelInterceptor.softwareGrants.containsKey(descriptor.nspace)
)
InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
else TransactionResult.ContinueAndSkipPost
}
if ((grant.accessVector and KEY_PERMISSION_UPDATE) == 0) {
return InterceptorUtils.createErrorReply(RESPONSE_PERMISSION_DENIED)
}
val generatedKeyInfo =
KeyMintSecurityLevelInterceptor.generatedKeys[grant.ownerKeyId]
val response =
generatedKeyInfo?.response
?: KeyMintSecurityLevelInterceptor.getGeneratedKeyResponse(grant.ownerKeyId)
?: return InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
return updateResponseSubcomponent(
response = response,
publicCert = data.createByteArray(),
certificateChain = data.createByteArray(),
persist = {
if (generatedKeyInfo != null) {
GeneratedKeyPersistence.rePersistIfNeeded(
grant.ownerKeyId.uid,
generatedKeyInfo,
)
}
},
label = "grant[${descriptor.nspace}] -> ${grant.ownerKeyId}",
)
}
val generatedKeyInfo =
when (descriptor.domain) {
Domain.KEY_ID ->
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
callingUid,
descriptor.nspace,
)
Domain.APP ->
descriptor.alias?.let {
@@ -659,35 +776,58 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
if (generatedKeyInfo == null) {
// Patch-mode key (cached in teeResponses, not generatedKeys): the real keystore2 applies
// Patch-mode key (cached in teeResponses, not generatedKeys): the real keystore2
// applies
// the update, so drop our stale cached chain. Otherwise getKeyEntry replays the
// pre-update generated attestation (duck STALE_TEE_RESPONSE_AFTER_KEY_ID_UPDATE).
when (descriptor.domain) {
Domain.KEY_ID ->
KeyMintSecurityLevelInterceptor.evictTeeResponseByKeyId(callingUid, descriptor.nspace)
KeyMintSecurityLevelInterceptor.evictTeeResponseByKeyId(
callingUid,
descriptor.nspace,
)
Domain.APP ->
descriptor.alias?.let {
KeyMintSecurityLevelInterceptor.evictTeeResponse(KeyIdentifier(callingUid, it))
KeyMintSecurityLevelInterceptor.evictTeeResponse(
KeyIdentifier(callingUid, it)
)
}
else -> {}
}
descriptor.alias?.let {
val kid = KeyIdentifier(callingUid, it)
userUpdatedKeys.add(kid)
SystemLogger.trace { "[TRACE] updateSubcomponent $kid: not generated key, added to userUpdatedKeys" }
SystemLogger.trace {
"[TRACE] updateSubcomponent $kid: not generated key, added to userUpdatedKeys"
}
}
return TransactionResult.ContinueAndSkipPost
}
SystemLogger.info("Updating sub-component with key[${generatedKeyInfo.nspace}]")
val metadata = generatedKeyInfo.response.metadata
val publicCert = data.createByteArray()
val certificateChain = data.createByteArray()
return updateResponseSubcomponent(
response = generatedKeyInfo.response,
publicCert = data.createByteArray(),
certificateChain = data.createByteArray(),
persist = {
GeneratedKeyPersistence.rePersistIfNeeded(callingUid, generatedKeyInfo)
},
label = "key[${generatedKeyInfo.nspace}]",
)
}
private fun updateResponseSubcomponent(
response: KeyEntryResponse,
publicCert: ByteArray?,
certificateChain: ByteArray?,
persist: () -> Unit,
label: String,
): TransactionResult {
SystemLogger.info("Updating sub-component with $label")
val metadata = response.metadata
metadata.certificate = publicCert
metadata.certificateChain = certificateChain
GeneratedKeyPersistence.rePersistIfNeeded(callingUid, generatedKeyInfo)
persist()
SystemLogger.verbose(
"Key updated with sizes: [publicCert, certificateChain] = [${publicCert?.size}, ${certificateChain?.size}]"
@@ -7,18 +7,18 @@ import android.system.keystore2.Domain
import android.system.keystore2.KeyDescriptor
import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.interception.keystore.shim.KeyMintSecurityLevelInterceptor
import org.matrix.TEESimulator.logging.SystemLogger
/**
* Intercepts the keystore2 daemon's `android.security.maintenance` binder so our synthetic key
* state follows the same lifecycle events the platform applies to real keys.
*
* This is a pure side-effect hook: every handled transaction mutates only our own synthetic state
* and then returns [TransactionResult.ContinueAndSkipPost], so the real keystore2 still performs the
* real operation. We never fabricate a maintenance reply, so real key lifecycle is never disturbed.
* and then returns [TransactionResult.ContinueAndSkipPost], so the real keystore2 still performs
* the real operation. We never fabricate a maintenance reply, so real key lifecycle is never
* disturbed.
*
* Mounted via `register()` from [Keystore2Interceptor.onInterceptorReady]; the maintenance binder is
* hosted by the same keystore2 process, so the already-injected native hook reaches it too.
* Mounted via `register()` from [Keystore2Interceptor.onInterceptorReady]; the maintenance binder
* is hosted by the same keystore2 process, so the already-injected native hook reaches it too.
*/
object Keystore2MaintenanceInterceptor : BinderInterceptor() {
private val stubClass = IKeystoreMaintenance.Stub::class.java
@@ -93,13 +93,18 @@ object Keystore2MaintenanceInterceptor : BinderInterceptor() {
descriptor.alias != null -> KeyIdentifier(callingUid, descriptor.alias)
descriptor.domain == Domain.KEY_ID ->
KeyMintSecurityLevelInterceptor.generatedKeys.entries
.firstOrNull { it.key.uid == callingUid && it.value.nspace == descriptor.nspace }
.firstOrNull {
it.key.uid == callingUid && it.value.nspace == descriptor.nspace
}
?.key
else -> null
}
/** Destination must be an addressable Domain.APP alias for us to keep tracking the key. */
private fun resolveDestinationKeyId(descriptor: KeyDescriptor, callingUid: Int): KeyIdentifier? {
private fun resolveDestinationKeyId(
descriptor: KeyDescriptor,
callingUid: Int,
): KeyIdentifier? {
val alias = descriptor.alias ?: return null
if (descriptor.domain != Domain.APP) return null
val uid = if (descriptor.nspace > 0) descriptor.nspace.toInt() else callingUid
@@ -1,8 +1,10 @@
package org.matrix.TEESimulator.interception.keystore.shim
import android.hardware.security.keymint.Algorithm
import android.hardware.security.keymint.KeyPurpose
import android.hardware.security.keymint.BlockMode
import android.hardware.security.keymint.KeyParameter
import android.hardware.security.keymint.KeyPurpose
import android.hardware.security.keymint.PaddingMode
import android.hardware.security.keymint.Tag
import org.matrix.TEESimulator.attestation.KeyMintAttestation
@@ -18,13 +20,15 @@ object AuthorizeCreate {
// Algorithm-level rejection runs before purpose-list check (AOSP HAL behavior)
return checkAlgorithmPurpose(keyParams, purpose)
?: checkPurpose(keyParams, purpose)
?: checkOperationAuthorizations(keyParams, opParams)
?: checkTemporalValidity(keyParams, purpose)
?: checkCallerNonce(keyParams, purpose, rawOpParams)
}
private fun checkAlgorithmPurpose(keyParams: KeyMintAttestation, purpose: Int): Int? {
val algo = keyParams.algorithm
if ((algo == Algorithm.EC || algo == Algorithm.RSA) &&
if (
(algo == Algorithm.EC || algo == Algorithm.RSA) &&
(purpose == KeyPurpose.VERIFY || purpose == KeyPurpose.ENCRYPT)
) {
return KeystoreErrorCodes.unsupportedPurpose
@@ -35,10 +39,48 @@ object AuthorizeCreate {
}
private fun checkPurpose(keyParams: KeyMintAttestation, purpose: Int): Int? {
if (purpose == KeyPurpose.WRAP_KEY)
return KeystoreErrorCodes.incompatiblePurpose
if (purpose !in keyParams.purpose)
return KeystoreErrorCodes.incompatiblePurpose
if (purpose == KeyPurpose.WRAP_KEY) return KeystoreErrorCodes.incompatiblePurpose
if (purpose !in keyParams.purpose) return KeystoreErrorCodes.incompatiblePurpose
return null
}
private fun checkOperationAuthorizations(
keyParams: KeyMintAttestation,
opParams: KeyMintAttestation,
): Int? {
if (opParams.blockMode.any { it !in keyParams.blockMode }) {
return KeystoreErrorCodes.incompatibleBlockMode
}
if (opParams.padding.any { it !in keyParams.padding }) {
return KeystoreErrorCodes.incompatiblePaddingMode
}
if (opParams.digest.any { it !in keyParams.digest }) {
return KeystoreErrorCodes.incompatibleDigest
}
if (opParams.rsaOaepMgfDigest.any { it !in keyParams.rsaOaepMgfDigest }) {
return KeystoreErrorCodes.incompatibleDigest
}
if (keyParams.algorithm == Algorithm.AES && opParams.blockMode.contains(BlockMode.GCM)) {
val requestedMacLength = opParams.minMacLength
val keyMinMacLength = keyParams.minMacLength
if (
requestedMacLength != null &&
keyMinMacLength != null &&
requestedMacLength < keyMinMacLength
) {
return KeystoreErrorCodes.invalidMacLength
}
}
if (
keyParams.algorithm == Algorithm.RSA &&
opParams.padding.contains(PaddingMode.RSA_OAEP) &&
opParams.digest.isEmpty()
) {
return KeystoreErrorCodes.incompatibleDigest
}
return null
}
@@ -64,7 +106,11 @@ object AuthorizeCreate {
return null
}
private fun checkCallerNonce(keyParams: KeyMintAttestation, purpose: Int, rawOpParams: Array<KeyParameter>?): Int? {
private fun checkCallerNonce(
keyParams: KeyMintAttestation,
purpose: Int,
rawOpParams: Array<KeyParameter>?,
): Int? {
if (purpose != KeyPurpose.SIGN && purpose != KeyPurpose.ENCRYPT) return null
if (keyParams.callerNonce == true) return null
if (rawOpParams?.any { it.tag == Tag.NONCE } == true)
@@ -33,19 +33,17 @@ data class PersistedKeyData(
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".
* 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.
* 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,
@@ -54,20 +52,15 @@ data class PersistedKeyData(
object GeneratedKeyPersistence {
/**
* 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.
* 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
* 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")
@@ -109,7 +102,8 @@ object GeneratedKeyPersistence {
val tmpFile = File(PERSISTENCE_DIR, "$filename.tmp")
try {
DataOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { out ->
DataOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { out
->
out.writeInt(FORMAT_VERSION)
out.writeInt(securityLevel)
out.writeInt(keyId.uid)
@@ -160,10 +154,13 @@ object GeneratedKeyPersistence {
throw e
}
// Atomic rename — if this fails the tmp is left behind and cleaned on next deleteAll
// Atomic rename — if this fails the tmp is left behind and cleaned on next
// deleteAll
if (!tmpFile.renameTo(finalFile)) {
tmpFile.delete()
throw IllegalStateException("Failed to atomically rename $tmpFile -> $finalFile")
throw IllegalStateException(
"Failed to atomically rename $tmpFile -> $finalFile"
)
}
// Verify write succeeded - catches disk-full or filesystem errors
@@ -172,9 +169,8 @@ object GeneratedKeyPersistence {
}
SystemLogger.debug("Persisted key: $keyId")
}.onFailure { e ->
SystemLogger.error("Failed to persist key $keyId", e)
}
.onFailure { e -> SystemLogger.error("Failed to persist key $keyId", e) }
} finally {
lock.unlock()
SystemLogger.debug("[Persistence] Lock released for $filename")
@@ -194,9 +190,8 @@ object GeneratedKeyPersistence {
} else {
SystemLogger.debug("No persisted file to delete for: $keyId")
}
}.onFailure { e ->
SystemLogger.error("Failed to delete persisted key $keyId", e)
}
.onFailure { e -> SystemLogger.error("Failed to delete persisted key $keyId", e) }
}
fun deleteAll() {
@@ -218,9 +213,8 @@ object GeneratedKeyPersistence {
}
fileLocks.clear()
SystemLogger.info("Deleted $count persisted key files")
}.onFailure { e ->
SystemLogger.error("Failed to delete all persisted keys", e)
}
.onFailure { e -> SystemLogger.error("Failed to delete all persisted keys", e) }
}
fun loadAll(securityLevel: Int): List<PersistedKeyData> {
@@ -281,7 +275,8 @@ object GeneratedKeyPersistence {
if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map {
val certChainBytes =
(0 until certCount).map {
val certLen = requireBounds(input.readInt(), 65536, "certLen")
val certBytes = ByteArray(certLen)
input.readFully(certBytes)
@@ -289,15 +284,12 @@ object GeneratedKeyPersistence {
}
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
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)
}
val skBytes = ByteArray(skLen).also { if (skLen > 0) input.readFully(it) }
if (storedSecLevel == securityLevel) {
result.add(
@@ -321,7 +313,8 @@ object GeneratedKeyPersistence {
)
}
}
}.onFailure { e ->
}
.onFailure { e ->
SystemLogger.warning("Skipping corrupted persisted key file: ${file.name}", e)
}
}
@@ -345,11 +338,14 @@ object GeneratedKeyPersistence {
}
val secLevel = metadata.keySecurityLevel
val entry = KeyMintSecurityLevelInterceptor.generatedKeys.entries.find { (id, info) ->
val entry =
KeyMintSecurityLevelInterceptor.generatedKeys.entries.find { (id, info) ->
id.uid == callingUid && info.nspace == generatedKeyInfo.nspace
}
if (entry == null) {
SystemLogger.debug("rePersist: key not found in map for uid=$callingUid nspace=${generatedKeyInfo.nspace}")
SystemLogger.debug(
"rePersist: key not found in map for uid=$callingUid nspace=${generatedKeyInfo.nspace}"
)
return
}
@@ -368,16 +364,20 @@ object GeneratedKeyPersistence {
return
}
val persisted = runCatching {
val persisted =
runCatching {
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
val version = input.readInt()
if (version != FORMAT_VERSION) {
SystemLogger.warning("rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)")
SystemLogger.warning(
"rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)"
)
return
}
readPersistedKeyData(input)
}
}.getOrNull()
}
.getOrNull()
if (persisted == null) {
SystemLogger.warning("rePersist: failed to read existing data for $keyId")
return
@@ -392,7 +392,8 @@ object GeneratedKeyPersistence {
// 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 {
val metadataBytes =
runCatching {
android.os.Parcel.obtain().let { parcel ->
try {
metadata.writeToParcel(parcel, 0)
@@ -401,7 +402,8 @@ object GeneratedKeyPersistence {
parcel.recycle()
}
}
}.getOrNull()
}
.getOrNull()
save(
keyId = keyId,
keyPair = keyPair,
@@ -427,8 +429,8 @@ object GeneratedKeyPersistence {
}
private fun keyFileName(uid: Int, alias: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest("$uid:$alias".toByteArray(Charsets.UTF_8))
val digest =
MessageDigest.getInstance("SHA-256").digest("$uid:$alias".toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) } + ".bin"
}
@@ -455,7 +457,8 @@ object GeneratedKeyPersistence {
if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map {
val certChainBytes =
(0 until certCount).map {
val certLen = requireBounds(input.readInt(), 65536, "certLen")
val certBytes = ByteArray(certLen)
input.readFully(certBytes)
@@ -463,15 +466,11 @@ object GeneratedKeyPersistence {
}
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
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)
}
val skBytes = ByteArray(skLen).also { if (skLen > 0) input.readFully(it) }
return PersistedKeyData(
uid = uid,
@@ -28,8 +28,15 @@ class OperationInterceptor(
val methodName = transactionNames[code] ?: "unknown code=$code"
logTransaction(txId, methodName, callingUid, callingPid, true)
// Mirror SoftwareOperation's vendor gate: a real-key op must answer non-AEAD updateAad
// exactly as the forged-key path does. Samsung and Xiaomi-MTK TEEs accept it; rejecting
// here while the forged path accepts diverges the two and fingerprints the injection.
if (code == UPDATE_AAD_TRANSACTION && !isAead) {
return InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
return if (VendorQuirks.nonAeadUpdateAadSucceeds()) {
InterceptorUtils.createSuccessReply(writeResultCode = false)
} else {
InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
}
}
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
@@ -8,25 +8,61 @@ import android.hardware.security.keymint.KeyParameterValue
import android.hardware.security.keymint.KeyPurpose
import android.hardware.security.keymint.PaddingMode
import android.hardware.security.keymint.Tag
import android.os.Build
import android.os.ServiceSpecificException
import java.util.concurrent.locks.LockSupport
import android.os.SystemProperties
import android.system.keystore2.IKeystoreOperation
import android.system.keystore2.KeyParameters
import java.security.KeyPair
import java.security.Signature
import java.security.SignatureException
import java.util.concurrent.locks.LockSupport
import javax.crypto.Cipher
import org.matrix.TEESimulator.attestation.KeyMintAttestation
import org.matrix.TEESimulator.logging.KeyMintParameterLogger
import org.matrix.TEESimulator.logging.SystemLogger
/**
* Mirrors the per-vendor TEE quirk that Duck Detector's OperationErrorPathProbe checks: real
* Samsung and Xiaomi-MTK TrustZone return success for updateAad on a non-AEAD operation, while
* every other vendor rejects it with a service-specific INVALID_TAG. The module reads the same
* device-identity fields the probe reads, so a forged software operation answers exactly as that
* vendor's real TEE would.
*/
internal object VendorQuirks {
private val UPDATE_AAD_ALLOWS_SUCCESS = setOf("samsung")
private val XIAOMI_BRANDS = setOf("xiaomi", "redmi", "poco")
fun nonAeadUpdateAadSucceeds(): Boolean {
val manufacturer = Build.MANUFACTURER.lowercase()
val brand = Build.BRAND.lowercase()
if (manufacturer in UPDATE_AAD_ALLOWS_SUCCESS || brand in UPDATE_AAD_ALLOWS_SUCCESS) {
return true
}
if (manufacturer != "xiaomi" && brand !in XIAOMI_BRANDS) return false
return isMediaTek()
}
private fun isMediaTek(): Boolean {
val roHardware = SystemProperties.get("ro.hardware", "")
return roHardware.startsWith("mt") || Build.HARDWARE.startsWith("mt", ignoreCase = true)
}
}
private sealed interface CryptoPrimitive {
fun updateAad(aadInput: ByteArray?) {
// Real Samsung / Xiaomi-MTK TEEs accept updateAad on non-AEAD ops; others reject it.
if (!VendorQuirks.nonAeadUpdateAadSucceeds()) {
throw ServiceSpecificException(KeystoreErrorCodes.invalidTag)
}
}
fun update(data: ByteArray?): ByteArray?
fun finish(data: ByteArray?, signature: ByteArray?): ByteArray?
fun abort()
fun getBeginParameters(): Array<KeyParameter>? = null
}
@@ -83,6 +119,24 @@ private object JcaAlgorithmMapper {
}
return "$keyAlgo/$blockMode/$padding"
}
fun mapOaepDigest(digest: Int?): String =
when (digest) {
Digest.SHA1 -> "SHA-1"
Digest.SHA_2_224 -> "SHA-224"
Digest.SHA_2_256 -> "SHA-256"
Digest.SHA_2_384 -> "SHA-384"
Digest.SHA_2_512 -> "SHA-512"
else -> "SHA-256"
}
fun mapMacAlgorithm(params: KeyMintAttestation): String =
when (params.digest.firstOrNull()) {
Digest.SHA_2_256 -> "HmacSHA256"
Digest.SHA_2_384 -> "HmacSHA384"
Digest.SHA_2_512 -> "HmacSHA512"
else -> "HmacSHA256"
}
}
private class Signer(keyPair: KeyPair, params: KeyMintAttestation) : CryptoPrimitive {
@@ -118,10 +172,16 @@ private class Verifier(keyPair: KeyPair, params: KeyMintAttestation) : CryptoPri
override fun finish(data: ByteArray?, signature: ByteArray?): ByteArray? {
if (data != null) update(data)
if (signature == null) {
throw ServiceSpecificException(KeystoreErrorCodes.verificationFailed, "Signature to verify is null")
throw ServiceSpecificException(
KeystoreErrorCodes.verificationFailed,
"Signature to verify is null",
)
}
if (!this.signature.verify(signature)) {
throw ServiceSpecificException(KeystoreErrorCodes.verificationFailed, "Signature verification failed")
throw ServiceSpecificException(
KeystoreErrorCodes.verificationFailed,
"Signature verification failed",
)
}
return null
}
@@ -133,6 +193,7 @@ private class CipherPrimitive(
cryptoKey: java.security.Key,
params: KeyMintAttestation,
private val opMode: Int,
txId: Long,
) : CryptoPrimitive {
private val isAead = params.blockMode.firstOrNull() == BlockMode.GCM
private val cipher: Cipher =
@@ -142,13 +203,38 @@ private class CipherPrimitive(
init(opMode, cryptoKey, javax.crypto.spec.GCMParameterSpec(128, nonce))
} else if (nonce != null) {
init(opMode, cryptoKey, javax.crypto.spec.IvParameterSpec(nonce))
} else if (params.padding.firstOrNull() == PaddingMode.RSA_OAEP) {
val mainDigest = JcaAlgorithmMapper.mapOaepDigest(params.digest.firstOrNull())
val mgfDigest =
params.rsaOaepMgfDigest.firstOrNull()?.let {
JcaAlgorithmMapper.mapOaepDigest(it)
} ?: mainDigest
init(
opMode,
cryptoKey,
javax.crypto.spec.OAEPParameterSpec(
mainDigest,
"MGF1",
java.security.spec.MGF1ParameterSpec(mgfDigest),
javax.crypto.spec.PSource.PSpecified.DEFAULT,
),
)
SystemLogger.debug {
"[SoftwareOp TX_ID: $txId] oaep-op main=$mainDigest mgf=$mgfDigest " +
"mode=${if (opMode == Cipher.DECRYPT_MODE) "decrypt" else "encrypt"}"
}
} else {
init(opMode, cryptoKey)
}
}
override fun updateAad(aadInput: ByteArray?) {
if (!isAead) throw ServiceSpecificException(KeystoreErrorCodes.invalidTag)
if (!isAead) {
if (!VendorQuirks.nonAeadUpdateAadSucceeds()) {
throw ServiceSpecificException(KeystoreErrorCodes.invalidTag)
}
return
}
if (aadInput != null) cipher.updateAAD(aadInput)
}
@@ -193,6 +279,53 @@ private class KeyAgreementPrimitive(keyPair: KeyPair) : CryptoPrimitive {
override fun abort() {}
}
private class MacPrimitive(
secretKey: javax.crypto.SecretKey,
private val params: KeyMintAttestation,
private val txId: Long,
) : CryptoPrimitive {
private val mac: javax.crypto.Mac =
javax.crypto.Mac.getInstance(JcaAlgorithmMapper.mapMacAlgorithm(params)).apply {
init(secretKey)
}
override fun update(data: ByteArray?): ByteArray? {
if (data != null) mac.update(data)
return null
}
override fun finish(data: ByteArray?, signature: ByteArray?): ByteArray? {
if (data != null) mac.update(data)
val full = mac.doFinal()
// Tag.MAC_LENGTH is optional on the AndroidKeyStore Mac SPI; default to the
// full digest length so real Mac use keeps working when it is omitted.
val tagBytes = (params.macLength ?: (full.size * 8)) / 8
val tag = full.copyOf(tagBytes)
if (params.purpose.firstOrNull() == KeyPurpose.VERIFY) {
if (signature == null) {
throw ServiceSpecificException(
KeystoreErrorCodes.verificationFailed,
"MAC to verify is null",
)
}
if (!java.security.MessageDigest.isEqual(tag, signature)) {
throw ServiceSpecificException(
KeystoreErrorCodes.verificationFailed,
"MAC verification failed",
)
}
return null
}
SystemLogger.debug {
"[SoftwareOp TX_ID: $txId] hmac-op digest=${params.digest.firstOrNull()} " +
"macLen=${params.macLength} tag=${tag.size}B result=ok"
}
return tag
}
override fun abort() {}
}
class SoftwareOperation(
private val txId: Long,
keyPair: KeyPair?,
@@ -201,7 +334,8 @@ class SoftwareOperation(
private val latencyFloorMs: Long = 0L,
) {
private val primitive: CryptoPrimitive
@Volatile var finalized = false
@Volatile
var finalized = false
private set
var onFinishCallback: (() -> Unit)? = null
@@ -240,39 +374,64 @@ class SoftwareOperation(
}
primitive =
if (params.algorithm == Algorithm.HMAC) {
// An HMAC key is symmetric (secretKey set, keyPair null), so it must
// not fall through to the purpose-keyed Signer/Verifier paths, which
// require a keyPair. secretKey is populated at HMAC keygen and restore,
// so the throw is a defensive floor, not a live path.
MacPrimitive(
secretKey
?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] HMAC op but secretKey null",
),
params,
txId,
)
} else {
when (purpose) {
KeyPurpose.SIGN -> {
val kp = keyPair ?: throw ServiceSpecificException(
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(
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)
CipherPrimitive(key, params, Cipher.ENCRYPT_MODE, txId)
}
KeyPurpose.DECRYPT -> {
val key: java.security.Key = secretKey ?: keyPair?.private
val key: java.security.Key =
secretKey
?: keyPair?.private
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] DECRYPT requires either secretKey or keyPair.private",
)
CipherPrimitive(key, params, Cipher.DECRYPT_MODE)
CipherPrimitive(key, params, Cipher.DECRYPT_MODE, txId)
}
KeyPurpose.AGREE_KEY -> {
val kp = keyPair ?: throw ServiceSpecificException(
val kp =
keyPair
?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] AGREE_KEY requested but keyPair is null",
)
@@ -285,32 +444,43 @@ class SoftwareOperation(
)
}
}
}
private fun checkActive() {
if (finalized) {
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Rejected: operation already finalized (pruned or completed)")
SystemLogger.debug(
"[SoftwareOp TX_ID: $txId] Rejected: operation already finalized (pruned or completed)"
)
throw ServiceSpecificException(KeystoreErrorCodes.invalidOperationHandle)
}
}
private fun checkInputLength(data: ByteArray?) {
if (data != null && data.size > MAX_RECEIVE_DATA) {
SystemLogger.info("[SoftwareOp TX_ID: $txId] Input too large: ${data.size} > $MAX_RECEIVE_DATA, throwing TOO_MUCH_DATA(${KeystoreErrorCodes.tooMuchData})")
SystemLogger.info(
"[SoftwareOp TX_ID: $txId] Input too large: ${data.size} > $MAX_RECEIVE_DATA, throwing TOO_MUCH_DATA(${KeystoreErrorCodes.tooMuchData})"
)
throw ServiceSpecificException(KeystoreErrorCodes.tooMuchData)
}
}
fun updateAad(aadInput: ByteArray?) {
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() ENTRY inputSize=${aadInput?.size ?: 0} primitive=${primitive::class.simpleName}")
SystemLogger.info(
"[SoftwareOp TX_ID: $txId] updateAad() ENTRY inputSize=${aadInput?.size ?: 0} primitive=${primitive::class.simpleName}"
)
checkActive()
checkInputLength(aadInput)
try {
primitive.updateAad(aadInput)
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() RETURNED_NORMALLY (unexpected for non-AEAD)")
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")
SystemLogger.info(
"[SoftwareOp TX_ID: $txId] updateAad() THREW class=${throwable::class.java.name} code=$code msg=${throwable.message} top=$top"
)
throw throwable
}
}
@@ -358,11 +528,16 @@ class SoftwareOperation(
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Operation aborted.")
}
private fun mapToServiceSpecificException(e: Exception): ServiceSpecificException = when (e) {
is SignatureException -> ServiceSpecificException(KeystoreErrorCodes.verificationFailed, e.message)
is javax.crypto.BadPaddingException -> ServiceSpecificException(KeystoreErrorCodes.invalidArgument, e.message)
is javax.crypto.IllegalBlockSizeException -> ServiceSpecificException(KeystoreErrorCodes.invalidInputLength, e.message)
is java.security.InvalidKeyException -> ServiceSpecificException(KeystoreErrorCodes.incompatibleKey, e.message)
private fun mapToServiceSpecificException(e: Exception): ServiceSpecificException =
when (e) {
is SignatureException ->
ServiceSpecificException(KeystoreErrorCodes.verificationFailed, e.message)
is javax.crypto.BadPaddingException ->
ServiceSpecificException(KeystoreErrorCodes.invalidArgument, e.message)
is javax.crypto.IllegalBlockSizeException ->
ServiceSpecificException(KeystoreErrorCodes.invalidInputLength, e.message)
is java.security.InvalidKeyException ->
ServiceSpecificException(KeystoreErrorCodes.incompatibleKey, e.message)
else -> ServiceSpecificException(KeystoreErrorCodes.unknownError, e.message)
}
@@ -428,10 +603,25 @@ internal object KeystoreErrorCodes {
resolveField("android.hardware.security.keymint.ErrorCode", "UNKNOWN_ERROR", -1000)
}
val incompatibleBlockMode: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INCOMPATIBLE_BLOCK_MODE", -8)
}
val incompatiblePaddingMode: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INCOMPATIBLE_PADDING_MODE", -11)
}
val incompatibleDigest: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INCOMPATIBLE_DIGEST", -13)
}
val invalidMacLength: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INVALID_MAC_LENGTH", -57)
}
fun resolveField(className: String, fieldName: String, fallback: Int): Int =
runCatching {
Class.forName(className).getField(fieldName).getInt(null)
}.getOrElse {
runCatching { Class.forName(className).getField(fieldName).getInt(null) }
.getOrElse {
SystemLogger.debug("Resolved $className.$fieldName via fallback: $fallback")
fallback
}
@@ -442,13 +632,17 @@ class SoftwareOperationBinder(private val operation: SoftwareOperation) :
@Synchronized
override fun updateAad(aadInput: ByteArray?) {
SystemLogger.info("[SoftwareOpBinder] updateAad() ENTRY callingUid=${android.os.Binder.getCallingUid()} size=${aadInput?.size ?: 0}")
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}")
SystemLogger.info(
"[SoftwareOpBinder] updateAad() PROPAGATING class=${throwable::class.java.name} code=$code msg=${throwable.message}"
)
throw throwable
}
}
@@ -0,0 +1,170 @@
package org.matrix.TEESimulator.interception.soter
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import java.util.concurrent.Executor
import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.logging.SystemLogger
/**
* Keeps [SoterServiceInterceptor] mounted on the on-demand, restartable
* `com.tencent.soter.soterserver` process.
*
* `AbstractKeystoreInterceptor` injects `keystore2` exactly once: it is always alive and
* servicemanager-published, so the daemon gets its binder from `ServiceManager` and may
* `exitProcess` on failure. soterserver inverts both it is Intent-bound (NOT in
* `ServiceManager`) and may die and respawn. This supervisor therefore *binds* the SOTER
* service, which both triggers its on-demand start AND yields the `ISoterService` binder
* (the target the native MITM registry keys on); injects `libTEESimulator.so` on every
* (re)start; confirms the landing with the `0xdeadbeef` backdoor handshake; then registers
* the forge. It re-binds re-poking, re-injecting, re-registering whenever the process
* dies, never exiting.
*
* The bind recipe (action = the interface descriptor, package, `BIND_AUTO_CREATE`) and the
* rebind-on-death lifecycle mirror the SOTER SDK's own `SoterCoreTreble`, so the daemon
* connects exactly as a real client would. Everything runs on a dedicated [HandlerThread]
* so it never stalls keystore init or `Looper.loop()` in [org.matrix.TEESimulator.App].
*
* Observability (the checkpoint's mandatory gate): every lifecycle event bind, connect,
* inject ok/fail, handshake, respawn is logged via [SystemLogger], debug-gated. It never
* gates the forge.
*/
object SoterProcessSupervisor {
/** soterserver hosts the package's own process (recon 2026-06-26: process == package). */
private const val SOTER_PACKAGE = "com.tencent.soter.soterserver"
/** Reuses the daemon's native injector + `entry`, PID-resolved by the target package. */
private const val INJECTION_COMMAND =
"exec ./inject `pidof $SOTER_PACKAGE` libTEESimulator.so entry"
private const val REBIND_DELAY_MS = 1000L
private const val REBIND_MAX_MS = 30_000L
private val started = AtomicBoolean(false)
/** Re-bind backoff; doubles each failed (re)bind up to [REBIND_MAX_MS], resets on a clean mount. Handler-thread-confined. */
private var rebindDelay = REBIND_DELAY_MS
private lateinit var context: Context
private lateinit var handler: Handler
/** Delivers bind callbacks onto the supervisor thread so nothing touches the main looper. */
private val executor = Executor { command -> handler.post(command) }
/**
* Starts supervising on a dedicated thread and returns immediately. Idempotent. [context]
* must be able to bind services (the daemon's system context); supplied by the App wiring.
*/
fun start(context: Context) {
if (!started.compareAndSet(false, true)) return
this.context = context
handler = Handler(HandlerThread("soter-supervisor").apply { start() }.looper)
handler.post { bind() }
}
private val connection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
SystemLogger.debug("SOTER service connected; mounting forge")
service?.let(::mount)
}
override fun onServiceDisconnected(name: ComponentName?) {
SystemLogger.debug("SOTER service disconnected (process died); rebinding")
scheduleRetry()
}
override fun onBindingDied(name: ComponentName?) {
SystemLogger.debug("SOTER binding died; rebinding")
scheduleRetry()
}
override fun onNullBinding(name: ComponentName?) {
SystemLogger.debug("SOTER onBind returned null; rebinding")
scheduleRetry()
}
}
private fun bind() {
val intent = Intent(SoterServiceInterceptor.DESCRIPTOR).setPackage(SOTER_PACKAGE)
val bound =
runCatching {
context.bindService(intent, Context.BIND_AUTO_CREATE, executor, connection)
}
.getOrElse {
SystemLogger.debug { "SOTER bindService threw: $it" }
false
}
if (bound) {
SystemLogger.debug("SOTER bind requested (on-demand poke)")
} else {
SystemLogger.debug("SOTER bindService returned false; retrying")
scheduleRetry()
}
}
private fun rebind() {
runCatching { context.unbindService(connection) }
bind()
}
/**
* Re-attempts the bind after the current backoff, then widens it (capped at [REBIND_MAX_MS]).
* Every path that fails to leave the forge mounted routes here, so a live-but-uninjected
* binding is re-attempted instead of stranding the forge. A clean [mount] resets the backoff.
*/
private fun scheduleRetry() {
val delay = rebindDelay
rebindDelay = (rebindDelay * 2).coerceAtMost(REBIND_MAX_MS)
handler.postDelayed({ rebind() }, delay)
}
/** Confirms injection via the `0xdeadbeef` handshake, injecting first if absent, then registers. */
private fun mount(soterBinder: IBinder) {
var backdoor = BinderInterceptor.getBackdoor(soterBinder)
if (backdoor == null) {
SystemLogger.debug("SOTER backdoor absent; injecting libTEESimulator.so")
if (!injectLibrary()) {
SystemLogger.debug("SOTER injection failed; scheduling re-bind")
scheduleRetry()
return
}
backdoor = BinderInterceptor.getBackdoor(soterBinder)
}
if (backdoor == null) {
SystemLogger.debug("SOTER backdoor handshake failed after injection; scheduling re-bind")
scheduleRetry()
return
}
val registered =
BinderInterceptor.register(
backdoor,
soterBinder,
SoterServiceInterceptor,
SoterServiceInterceptor.interceptedCodes,
)
if (!registered) {
SystemLogger.debug("SOTER register failed; scheduling re-bind")
scheduleRetry()
return
}
rebindDelay = REBIND_DELAY_MS
SystemLogger.debug("SOTER forge mounted; handshake ok")
}
private fun injectLibrary(): Boolean =
runCatching {
Runtime.getRuntime().exec(arrayOf("/system/bin/sh", "-c", INJECTION_COMMAND)).waitFor() == 0
}
.getOrElse {
SystemLogger.debug { "SOTER inject exec failed: $it" }
false
}
}
@@ -0,0 +1,229 @@
package org.matrix.TEESimulator.interception.soter
import android.os.IBinder
import android.os.Parcel
import android.util.Base64
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyPairGenerator
import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.logging.SystemLogger
/**
* Forges healthy `com.tencent.soter.soterserver.ISoterService` (Layer A: AIDL over
* `/dev/binder`) replies from inside the injected soterserver app process, so the SOTER
* capability probe (春秋 / DuckDetector `SoterCapabilityProbe`) reads `available = true`
* / `damaged = false` on a bootloader-unlocked device whose SOTER TA can no longer use
* its factory ATTK. Replaces the external SoterFixer loop + the Hail freeze.
*
* Unconditional by design: the forge decision never consults `ConfigurationManager` /
* `target.txt` (Phase 10 spec §Decision, gate G). It is mounted by the SOTER process
* supervisor (10.B/10.W) against the ISoterService binder, so `onPreTransact` only sees
* transactions on that binder matching the raw transaction code is therefore enough.
*
* Diagnostics follow the module's standard three-layer capture (debug-gated, per-UID
* NDJSON via [SystemLogger]; see `logging/SystemLogger.kt`): a `tx` line for every
* transaction ([logTransaction]), the raw inbound request parcel, and the raw forged
* reply wire. Capture is scoped to targeted UIDs (`isUidLogged`) exactly like the
* keystore lane it does NOT make the forge conditional; the forge still fires for all.
*
* Transaction codes are HARDCODED 1..13 in AIDL declaration order, NOT resolved via
* [org.matrix.TEESimulator.interception.keystore.InterceptorUtils.getTransactCode]: the
* shipped soterserver build is R8/ProGuard obfuscated there is no `ISoterService$Stub`
* class and no `TRANSACTION_*` fields (recon 2026-06-26, `a$a.smali` packed-switch). The
* codes are fixed by Tencent's `ISoterService.aidl` and are obfuscation-independent.
*
* Scope boundary (10.A vs 10.M): the seven primitive-returning methods are fully forged
* here. The six parcelable-returning methods emit the correct AIDL envelope + the
* recon-verified `writeToParcel` field order; 10.M fills the payloads with
* detector-satisfying values a framed SOTER pubkey envelope the SDK's
* `retrieveJsonFromExportedData` parses to a non-null `SoterPubKeyModel`, a non-zero sign
* session, and a 256-byte signature.
*/
object SoterServiceInterceptor : BinderInterceptor() {
/** The surviving, obfuscation-stable interface identifier (used by the 10.B/10.W mount). */
const val DESCRIPTOR = "com.tencent.soter.soterserver.ISoterService"
// AIDL transaction codes = FIRST_CALL_TRANSACTION (1) + declaration index, verified
// against the obfuscated `a$a.smali` packed-switch (recon 2026-06-26). NOTE the 5/6
// order: removeAuthKey precedes getAuthKey in the real .aidl (the spec prose had it
// reversed). Comments record each method's return shape.
private const val TX_GENERATE_APP_SECURE_KEY = 1 // int
private const val TX_GET_APP_SECURE_KEY = 2 // SoterExportResult
private const val TX_HAS_ASK_ALREADY = 3 // boolean
private const val TX_GENERATE_AUTH_KEY = 4 // int
private const val TX_REMOVE_AUTH_KEY = 5 // int (NOT getAuthKey)
private const val TX_GET_AUTH_KEY = 6 // SoterExportResult (NOT removeAuthKey)
private const val TX_REMOVE_ALL_AUTH_KEY = 7 // int
private const val TX_HAS_AUTH_KEY = 8 // boolean
private const val TX_INIT_SIGH = 9 // SoterSessionResult (sic: Tencent's spelling)
private const val TX_FINISH_SIGN = 10 // SoterSignResult
private const val TX_GET_DEVICE_ID = 11 // SoterDeviceResult
private const val TX_GET_VERSION = 12 // int (real service returns 1)
private const val TX_GET_EXTRA_PARAM = 13 // SoterExtraParam
/** SOTER success result code (`SoterCoreResult` ERR_OK). */
private const val SOTER_OK = 0
/** finishSign signature length the probe expects. */
private const val SIGNATURE_LEN = 256
/** `cpu_id` placeholder in the export envelope; the local probe never reads its value
* (the backend pins the real per-`cpu_id` ATTK, which the forge cannot satisfy). */
private const val CPU_ID = "0000000000000000"
/** Code -> Tencent method name, for the `tx` diagnostic line. Names from the recon decompile. */
private val methodNames =
mapOf(
TX_GENERATE_APP_SECURE_KEY to "generateAppSecureKey",
TX_GET_APP_SECURE_KEY to "getAppSecureKey",
TX_HAS_ASK_ALREADY to "hasAskAlready",
TX_GENERATE_AUTH_KEY to "generateAuthKey",
TX_REMOVE_AUTH_KEY to "removeAuthKey",
TX_GET_AUTH_KEY to "getAuthKey",
TX_REMOVE_ALL_AUTH_KEY to "removeAllAuthKey",
TX_HAS_AUTH_KEY to "hasAuthKey",
TX_INIT_SIGH to "initSigh",
TX_FINISH_SIGN to "finishSign",
TX_GET_DEVICE_ID to "getDeviceId",
TX_GET_VERSION to "getVersion",
TX_GET_EXTRA_PARAM to "getExtraParam",
)
/** The codes this interceptor forges; consumed by the supervisor's registration (10.B/10.W). */
val interceptedCodes: IntArray = methodNames.keys.toIntArray()
/**
* Payload of [SoterExportResult.exportData] for getAppSecureKey (txn 2) and getAuthKey
* (txn 6). The detector's capability probe gates `damaged=false` on
* `SoterCore.getApp/AuthKeyModel() != null`, and the SDK's `retrieveJsonFromExportedData`
* (`SoterCoreBase`) returns a non-null `SoterPubKeyModel` only when this exact framing
* parses: `[4-byte LITTLE-ENDIAN json length][UTF-8 json][signature bytes]`. A
* non-empty-but-unframed blob throws inside the SDK and is read as `damaged` silently.
* The JSON parser swallows every exception, so only the framing is load-bearing; the
* `pub_key` is a genuine RSA-2048 SubjectPublicKeyInfo so a probe that base64/X.509-parses
* the field locally still succeeds. Lazily built keygen runs once, off the mount path.
*/
private val exportBlob: ByteArray by lazy { buildExportBlob() }
/** getDeviceId (txn 11) payload — well-formed, non-empty; the probe never parses it. */
private val deviceIdBlob = "TEESIM-SOTER-0001".toByteArray(Charsets.UTF_8)
/** finishSign (txn 10) signature payload — [SIGNATURE_LEN] bytes. */
private val signatureBlob = ByteArray(SIGNATURE_LEN)
private fun buildExportBlob(): ByteArray {
val pubKey =
runCatching {
val generator = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }
Base64.encodeToString(generator.generateKeyPair().public.encoded, Base64.NO_WRAP)
}
.getOrDefault("")
val json =
"""{"pub_key":"$pubKey","counter":0,"cpu_id":"$CPU_ID","uid":0}"""
.toByteArray(Charsets.UTF_8)
val lengthPrefix = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(json.size).array()
return lengthPrefix + json + signatureBlob
}
override fun onPreTransact(
txId: Long,
target: IBinder,
code: Int,
flags: Int,
callingUid: Int,
callingPid: Int,
data: Parcel,
): TransactionResult {
val method = methodNames[code]
if (method == null) {
// Not an ISoterService method we forge — record it as observed, then pass through.
logTransaction(txId, "code=$code", callingUid, callingPid, skipPost = true)
return TransactionResult.ContinueAndSkipPost
}
logTransaction(txId, method, callingUid, callingPid)
captureRequest(callingUid, txId, method, data)
return when (code) {
// Primitive returns — fully forged here.
TX_GENERATE_APP_SECURE_KEY,
TX_GENERATE_AUTH_KEY,
TX_REMOVE_AUTH_KEY,
TX_REMOVE_ALL_AUTH_KEY -> forgedReply(callingUid, txId, method) { writeInt(SOTER_OK) }
TX_GET_VERSION -> forgedReply(callingUid, txId, method) { writeInt(1) }
TX_HAS_ASK_ALREADY,
TX_HAS_AUTH_KEY -> forgedReply(callingUid, txId, method) { writeInt(1) } // boolean true
// Parcelable returns — correct envelope + recon field order, payloads filled (10.M).
TX_GET_APP_SECURE_KEY,
TX_GET_AUTH_KEY ->
forgedReply(callingUid, txId, method) {
writeInt(1) // non-null marker
writeInt(SOTER_OK) // resultCode
writeByteArray(exportBlob) // exportData — framed SOTER pubkey envelope
writeInt(exportBlob.size) // exportDataLength
}
TX_INIT_SIGH ->
forgedReply(callingUid, txId, method) {
writeInt(1)
writeLong(1L) // session — any non-zero satisfies the probe
writeInt(SOTER_OK) // resultCode — probe requires == 0 (SoterCapabilityProbe.kt:107)
}
TX_FINISH_SIGN ->
forgedReply(callingUid, txId, method) {
writeInt(1)
writeInt(SOTER_OK) // resultCode — finishSign throws on != 0
writeByteArray(signatureBlob) // exportData = signature
writeInt(signatureBlob.size) // exportDataLength
}
TX_GET_DEVICE_ID ->
forgedReply(callingUid, txId, method) {
writeInt(1)
writeInt(SOTER_OK) // resultCode
writeByteArray(deviceIdBlob) // exportData = device id
writeInt(deviceIdBlob.size) // exportDataLength
}
TX_GET_EXTRA_PARAM ->
forgedReply(callingUid, txId, method) {
writeInt(1)
writeValue("optical") // SoterExtraParam.result = fingerprint sensor type
}
// Unreachable: method != null means code is one of the 13 above.
else -> TransactionResult.ContinueAndSkipPost
}
}
/** Snapshots the inbound request parcel to the per-UID NDJSON plane (debug + targeted only). */
private fun captureRequest(uid: Int, txId: Long, method: String, data: Parcel) {
if (!SystemLogger.isUidLogged(uid)) return
runCatching { data.marshall() }
.onSuccess { raw ->
SystemLogger.uidLogRaw(uid, txId, "$method-request", "len=${raw.size}", raw)
}
}
/**
* Builds an AIDL reply (`writeNoException()` then [body]) and snapshots its wire bytes to the
* per-UID NDJSON plane before handing it to the native hook. Parcelable bodies write their own
* `writeInt(1)` non-null marker; the native hook recycles the parcel after use.
*/
private fun forgedReply(
uid: Int,
txId: Long,
method: String,
body: Parcel.() -> Unit,
): TransactionResult.OverrideReply {
val reply = Parcel.obtain()
reply.writeNoException()
reply.body()
if (SystemLogger.isUidLogged(uid)) {
runCatching { reply.marshall() }
.onSuccess { raw ->
SystemLogger.uidLogRaw(uid, txId, "$method-reply", "len=${raw.size}", raw)
}
}
return TransactionResult.OverrideReply(reply)
}
}
@@ -0,0 +1,74 @@
package org.matrix.TEESimulator.logging
import android.hardware.security.keymint.Tag
import android.system.keystore2.Authorization
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import org.matrix.TEESimulator.attestation.AttestationPatcher
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.util.AndroidDeviceUtils
/**
* Assembles the per-UID "attestation dossier": for a targeted app, the full decoded attestation we
* actually hand it, the identity of every certificate in the returned chain, and the source of each
* device value that fed that attestation. Emitting all three where a chain is produced turns "the
* app rejects us" into a field-by-field record that can be diffed against a genuine TEE.
*/
object AttestationDossier {
/**
* Records the dossier for [chain] under [uid], tagged with the [path] that produced it
* (`FORGE-rust`, `FORGE-bouncycastle`, or `PATCH`). No-op for untargeted UIDs and release
* builds; the expensive decoding is skipped entirely when the UID is out of scope.
*/
fun log(uid: Int, txId: Long, path: String, chain: List<Certificate>) {
if (!SystemLogger.isUidLogged(uid)) return
val leaf = chain.firstOrNull() as? X509Certificate
val extension =
leaf?.let { AttestationPatcher.formatAttestationExtension(it) }
?: "<no attestation extension>"
SystemLogger.uidLog(uid, txId, "attest", "path=$path depth=${chain.size} $extension")
SystemLogger.uidLog(uid, txId, "keybox", "file=${ConfigurationManager.getKeyboxFileForUid(uid)}")
SystemLogger.uidLog(uid, txId, "chain", AttestationPatcher.formatCertChain(chain))
SystemLogger.uidLog(uid, txId, "chain-verify", AttestationPatcher.formatChainVerification(chain))
SystemLogger.uidLog(uid, txId, "props", AndroidDeviceUtils.describeSources(uid))
}
/**
* Records the *shape* of the emitted authorization list count, ordered tags, and per-auth
* securityLevel. This is the exact surface the duck detector's generate-mode parcel fingerprint
* stride-walks, so logging it readably lets a "fingerprint" detection be compared against the
* known genuine-TEE shape without decoding the marshalled reply offline.
*/
fun logAuthShape(uid: Int, txId: Long, authorizations: Array<Authorization>?) {
if (!SystemLogger.isUidLogged(uid)) return
val auths = authorizations ?: return
val shape = auths.joinToString(",") { "${tagName(it.keyParameter.tag)}/${it.securityLevel}" }
SystemLogger.uidLog(uid, txId, "auth-shape", "n=${auths.size} [$shape]")
}
/** Names the authorization tags that occur in generate-mode replies; others render as numbers. */
private fun tagName(tag: Int): String =
when (tag) {
Tag.PURPOSE -> "PURPOSE"
Tag.ALGORITHM -> "ALGORITHM"
Tag.KEY_SIZE -> "KEY_SIZE"
Tag.DIGEST -> "DIGEST"
Tag.PADDING -> "PADDING"
Tag.EC_CURVE -> "EC_CURVE"
Tag.RSA_PUBLIC_EXPONENT -> "RSA_PUBLIC_EXPONENT"
Tag.NO_AUTH_REQUIRED -> "NO_AUTH_REQUIRED"
Tag.ORIGIN -> "ORIGIN"
Tag.OS_VERSION -> "OS_VERSION"
Tag.OS_PATCHLEVEL -> "OS_PATCHLEVEL"
Tag.VENDOR_PATCHLEVEL -> "VENDOR_PATCHLEVEL"
Tag.BOOT_PATCHLEVEL -> "BOOT_PATCHLEVEL"
Tag.CREATION_DATETIME -> "CREATION_DATETIME"
Tag.ROOT_OF_TRUST -> "ROOT_OF_TRUST"
Tag.USER_ID -> "USER_ID"
Tag.USAGE_COUNT_LIMIT -> "USAGE_COUNT_LIMIT"
Tag.UNLOCKED_DEVICE_REQUIRED -> "UNLOCKED_DEVICE_REQUIRED"
Tag.ACTIVE_DATETIME -> "ACTIVE_DATETIME"
else -> "tag${tag and 0x0FFFFFFF}"
}
}
@@ -69,12 +69,23 @@ object KeyMintParameterLogger {
.associate { field -> (field.get(null) as Int) to field.name }
}
/**
* Logs a single KeyParameter in a formatted, readable way.
*
* @param param The KeyParameter to log.
*/
/** Logs a single KeyParameter to the shared debug stream (used for un-scoped param dumps). */
fun logParameter(param: KeyParameter) {
SystemLogger.debug("KeyParam: ${describe(param)}")
}
/** Logs a single KeyParameter onto a targeted UID's diagnostic plane as a `param` record. */
fun logParameter(uid: Int, txId: Long, param: KeyParameter) {
SystemLogger.uidLog(uid, txId, "param", describe(param))
}
/**
* Formats a single KeyParameter into a readable `tag | Value` string. Shared by both
* [logParameter] overloads so the two logging planes render parameters identically.
*
* @param param The KeyParameter to format.
*/
private fun describe(param: KeyParameter): String {
val tagName = tagNames[param.tag] ?: "UNKNOWN_TAG"
val value = param.value
val formattedValue: String =
@@ -110,7 +121,7 @@ object KeyMintParameterLogger {
else -> "<raw>"
} ?: "Unknown Value"
SystemLogger.debug("KeyParam: %-25s | Value: %s".format(tagName, formattedValue))
return "%-25s | Value: %s".format(tagName, formattedValue)
}
private fun ByteArray.toReadableString(): String {
@@ -1,9 +1,19 @@
package org.matrix.TEESimulator.logging
import android.util.Base64
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import org.json.JSONObject
import org.matrix.TEESimulator.BuildConfig
import org.matrix.TEESimulator.config.ConfigurationManager
/**
* A centralized logging utility for the TEESimulator application. This object provides a consistent
@@ -26,10 +36,11 @@ object SystemLogger {
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.
* 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 {
@PublishedApi
internal fun acquireLogPermit(): Boolean {
val now = System.currentTimeMillis()
val start = windowStart.get()
if (now - start > RATE_LIMIT_WINDOW_MS) {
@@ -38,7 +49,10 @@ object SystemLogger {
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")
Log.i(
TAG,
"[rate-limit] suppressed $suppressed log messages in previous window",
)
}
return true
}
@@ -49,9 +63,7 @@ object SystemLogger {
return false
}
/**
* Logs a debug message. Use this for fine-grained information that is useful for debugging.
*/
/** Logs a debug message. Use this for fine-grained information that is useful for debugging. */
fun debug(message: String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
@@ -65,9 +77,7 @@ object SystemLogger {
Log.d(TAG, message())
}
/**
* Logs an informational message. Use this to report major application lifecycle events.
*/
/** Logs an informational message. Use this to report major application lifecycle events. */
fun info(message: String) {
if (!acquireLogPermit()) return
Log.i(TAG, message)
@@ -79,9 +89,7 @@ object SystemLogger {
Log.i(TAG, message())
}
/**
* Logs a warning message. Warnings are never rate-limited.
*/
/** Logs a warning message. Warnings are never rate-limited. */
fun warning(message: String, throwable: Throwable? = null) {
if (throwable != null) {
Log.w(TAG, message, throwable)
@@ -90,9 +98,7 @@ object SystemLogger {
}
}
/**
* Logs an error message. Errors are never rate-limited.
*/
/** Logs an error message. Errors are never rate-limited. */
fun error(message: String, throwable: Throwable? = null) {
if (throwable != null) {
Log.e(TAG, message, throwable)
@@ -122,4 +128,145 @@ object SystemLogger {
if (!isDebugBuild) return
Log.w(TAG, message())
}
// --- UID-keyed diagnostic plane (debug builds only) -------------------------------------
/**
* True when [uid] should receive deep, per-UID diagnostic logging: a debug build AND the UID is
* targeted in `target.txt`. This is the single scope gate for the diagnostic plane; it reuses
* the existing activation set, so no new configuration surface is introduced.
*/
fun isUidLogged(uid: Int): Boolean = isDebugBuild && !ConfigurationManager.shouldSkipUid(uid)
/** Resolves a UID to its primary package name for log labelling, falling back to `uid:N`. */
private fun label(uid: Int): String =
ConfigurationManager.getPackagesForUid(uid).firstOrNull() ?: "uid:$uid"
/**
* Emits one structured diagnostic record for a targeted [uid]. The human form
* `[<pkg> tx=<txId>] <event>: <detail>` goes to logcat; the file sink receives one NDJSON object
* per line under that UID's own file. In-scope records bypass the global rate limiter: a
* targeted app's traffic is already volume-bounded, and dropping a line mid-probe would corrupt
* the very trace we are trying to read. No-op for untargeted UIDs and in release builds.
*/
fun uidLog(uid: Int, txId: Long?, event: String, detail: String) {
if (!isUidLogged(uid)) return
val correlation = txId?.let { " tx=$it" } ?: ""
Log.d(TAG, "[${label(uid)}$correlation] $event: $detail")
runCatching { uidWriter(uid).append(jsonRecord(uid, txId, event, detail, null)) }
}
/** Lazy [uidLog]: [detail] is only built for targeted UIDs in debug builds. */
inline fun uidLog(uid: Int, txId: Long?, event: String, detail: () -> String) {
if (!isUidLogged(uid)) return
uidLog(uid, txId, event, detail())
}
/**
* [uidLog] plus the exact wire bytes that produced the event, base64 (NO_WRAP) in a `raw_b64`
* field. This is the structured replacement for the per-call `.bin` parcel dumps: one NDJSON
* line on the per-UID file instead of a fresh undecodable file per transaction, with the raw
* parcel still recoverable for offline parsers.
*/
fun uidLogRaw(uid: Int, txId: Long?, event: String, detail: String, raw: ByteArray) {
if (!isUidLogged(uid)) return
val correlation = txId?.let { " tx=$it" } ?: ""
Log.d(TAG, "[${label(uid)}$correlation] $event: $detail (raw ${raw.size}B)")
runCatching {
val encoded = Base64.encodeToString(raw, Base64.NO_WRAP)
uidWriter(uid).append(jsonRecord(uid, txId, event, detail, encoded))
}
}
/**
* External-storage root for every debug diagnostic. `/data/media/0/TEESimulator` is the
* in-namespace backing path the keystore domain can reach; a normal file manager sees the same
* files at `/sdcard/TEESimulator`. Release builds never write here and purge it on boot
* (App.purgeDebugDiagnostics). The domain reaches it via a debug-only media_rw_data_file
* sepolicy grant, and service.sh pre-creates the directory.
*/
const val DIAGNOSTIC_DIR = "/data/media/0/TEESimulator"
private val uidLogDir = File(DIAGNOSTIC_DIR)
private const val UID_LOG_MAX_BYTES = 4L * 1024 * 1024
private val uidWriters = ConcurrentHashMap<Int, UidLogFile>()
private val recordClock =
DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault())
private fun jsonRecord(
uid: Int,
txId: Long?,
event: String,
detail: String,
rawB64: String?,
): String =
JSONObject()
.apply {
put("ts", recordClock.format(Instant.now()))
put("uid", uid)
put("pkg", label(uid))
txId?.let { put("tx", it) }
put("event", event)
put("detail", detail)
rawB64?.let { put("raw_b64", it) }
}
.toString()
private fun uidWriter(uid: Int): UidLogFile =
uidWriters.computeIfAbsent(uid) { key ->
UidLogFile(key, uidLogDir).also { file ->
val packages =
ConfigurationManager.getPackagesForUid(key).joinToString().ifEmpty { "<unresolved>" }
runCatching {
file.append(jsonRecord(key, null, "session", "packages=[$packages]", null))
}
}
}
/**
* Append-only NDJSON sink for a single UID at `<logDir>/teesim-uid-<uid>.ndjson`, rotated once
* to `.ndjson.1` at [UID_LOG_MAX_BYTES]; one JSON object per line. Writes are synchronised
* because the keystore binder pool is multi-threaded, and every operation is wrapped so a
* logging fault can never propagate into the daemon. Created only on the debug-gated path.
*/
private class UidLogFile(uid: Int, private val logDir: File) {
private val primary = File(logDir, "teesim-uid-$uid.ndjson")
private val rotated = File(logDir, "teesim-uid-$uid.ndjson.1")
private var writer: BufferedWriter? = null
private var size = 0L
@Synchronized
fun append(jsonLine: String) {
runCatching {
val out = writer ?: open()
out.write(jsonLine)
out.write("\n")
out.flush()
size += jsonLine.length + 1
if (size >= UID_LOG_MAX_BYTES) rotate()
}
}
private fun open(): BufferedWriter {
logDir.mkdirs()
val out = BufferedWriter(FileWriter(primary, /* append = */ true))
writer = out
size = primary.length()
return out
}
private fun rotate() {
runCatching {
writer?.flush()
writer?.close()
}
writer = null
runCatching {
if (rotated.exists()) rotated.delete()
primary.renameTo(rotated)
}
size = 0L
}
}
}
@@ -95,19 +95,35 @@ 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}" }
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 wantsAttestKey =
attestKeyAlias != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val attestKeyInfo =
if (attestKeyAlias != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
getAttestationKeyInfo(uid, attestKeyAlias)
} else null
if (wantsAttestKey) getAttestationKeyInfo(uid, attestKeyAlias) else null
val (signingKey, issuer) = attestKeyInfo
?.let { it.first to it.second }
// When the caller designates an attest key, the leaf MUST be signed by it and returned
// alone (the caller appends the attest key's own chain). Re-rooting under the keybox
// here instead yields a self-rooted leaf that, concatenated with the attest key chain,
// double-roots and fails verification (WRONG_PUBLIC_KEY_TYPE). Refuse rather than emit
// a
// broken chain.
if (wantsAttestKey && attestKeyInfo == null) {
SystemLogger.error(
"Designated attest key '$attestKeyAlias' not found for uid $uid; refusing to " +
"emit a keybox-rooted leaf that would break the caller's chain."
)
return null
}
val (signingKey, issuer) =
attestKeyInfo?.let { it.first to it.second }
?: (keybox.keyPair to getIssuerFromKeybox(keybox))
val leafCert =
@@ -138,9 +154,7 @@ object CertificateGenerator {
securityLevel: Int,
): Pair<KeyPair, List<Certificate>>? {
return try {
SystemLogger.info(
"Generating new attested key pair for alias: '$alias' (UID: $uid)"
)
SystemLogger.info("Generating new attested key pair for alias: '$alias' (UID: $uid)")
val newKeyPair =
generateSoftwareKeyPair(params)
?: throw Exception("Failed to generate underlying software key pair.")
@@ -149,9 +163,7 @@ object CertificateGenerator {
generateCertificateChain(uid, newKeyPair, attestKeyAlias, params, securityLevel)
?: throw Exception("Failed to generate certificate chain for new key pair.")
SystemLogger.info(
"Successfully generated new certificate chain for alias: '$alias'."
)
SystemLogger.info("Successfully generated new certificate chain for alias: '$alias'.")
Pair(newKeyPair, chain)
} catch (e: android.os.ServiceSpecificException) {
throw e
@@ -172,11 +184,28 @@ object CertificateGenerator {
Algorithm.RSA -> "RSA"
else -> throw IllegalArgumentException("Unsupported algorithm ID: $algorithm")
}
return KeyBoxManager.getAttestationKey(keyboxFile, algorithmName)
// Prefer the algorithm-matching keybox, but fall back to any usable key (EC preferred) when
// none exists. An EC attestation key validly ECDSA-signs a leaf carrying an RSA subject key,
// so an EC-only keybox can still root an RSA forge. Without this fallback an RSA ATTEST_KEY
// request on an EC-only keybox throws -75 and the caller's chain never roots ("unknown
// certificate"). Mirrors the patch path's fail-safe
// (AttestationPatcher.getKeyboxForUidAndAlgorithm) and the RSA-leaf-under-EC-keybox handling
// in commit e6d5e4d.
val matched = KeyBoxManager.getAttestationKey(keyboxFile, algorithmName)
val keybox =
matched
?: KeyBoxManager.getAnyAttestationKey(keyboxFile)
?: throw android.os.ServiceSpecificException(
-75, // ATTESTATION_KEYS_NOT_PROVISIONED
"No attestation key for algorithm $algorithmName in $keyboxFile",
"No usable attestation key in $keyboxFile",
)
// Surface which keybox actually signs the forge, so an EC-only-keybox fallback (an RSA leaf
// rooted under the EC key) is visible on the per-UID plane instead of silent.
SystemLogger.uidLog(uid, null, "keybox-pick") {
"req=$algorithmName ${if (matched != null) "matched" else "fellback-to-any"} " +
"signer=${getIssuerFromKeybox(keybox)}"
}
return keybox
}
/** Retrieves the key pair and issuer name for a given attestation key alias. */
@@ -189,6 +218,15 @@ object CertificateGenerator {
val certChain = CertificateHelper.getCertificateChain(keyInfo.response)
if (!certChain.isNullOrEmpty()) {
val issuer = X509CertificateHolder(certChain[0].encoded).subject
// The leaf is signed by keyInfo.keyPair, but the caller verifies it against the
// public key of the chain getCertChain(attestKeyAlias) serves. A two-rooted EC chain
// (DATA_TOO_LARGE_FOR_MODULUS) is exactly those two disagreeing on algorithm; log
// both at the signing instant so an EC attest-key run pins the mismatched edge.
SystemLogger.uidLog(uid, null, "attest-sign") {
"alias=$attestKeyAlias signerKey=${keyInfo.keyPair?.public?.algorithm} " +
"servedLeafKey=${certChain[0].publicKey.algorithm} " +
"depth=${certChain.size} issuer=$issuer"
}
Pair(keyInfo.keyPair, issuer)
} else {
null
@@ -205,7 +243,9 @@ object CertificateGenerator {
private fun buildKeyUsageFromPurposes(purposes: List<Int>): Int {
var bits = 0
for (purpose in purposes) {
bits = bits or when (purpose) {
bits =
bits or
when (purpose) {
KeyPurpose.SIGN -> KeyUsage.digitalSignature
KeyPurpose.DECRYPT -> KeyUsage.dataEncipherment
KeyPurpose.WRAP_KEY -> KeyUsage.keyEncipherment
@@ -253,9 +293,13 @@ object CertificateGenerator {
val signerAlgorithm =
when (signingKeyPair.private.algorithm) {
"EC", "ECDSA" -> "SHA256withECDSA"
"EC",
"ECDSA" -> "SHA256withECDSA"
"RSA" -> "SHA256withRSA"
else -> throw IllegalArgumentException("Unsupported signing key: ${signingKeyPair.private.algorithm}")
else ->
throw IllegalArgumentException(
"Unsupported signing key: ${signingKeyPair.private.algorithm}"
)
}
val contentSigner =
JcaContentSignerBuilder(signerAlgorithm)
@@ -274,7 +318,8 @@ object CertificateGenerator {
val notBefore = params.certificateNotBefore ?: Date(0)
val notAfter = params.certificateNotAfter ?: Date(UNDEFINED_NOT_AFTER)
val builder = JcaX509v3CertificateBuilder(
val builder =
JcaX509v3CertificateBuilder(
subject,
params.certificateSerial ?: BigInteger.ONE,
notBefore,
@@ -288,12 +333,16 @@ object CertificateGenerator {
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
}
val signerAlgorithm = when (keyPair.private.algorithm) {
"EC", "ECDSA" -> "SHA256withECDSA"
val signerAlgorithm =
when (keyPair.private.algorithm) {
"EC",
"ECDSA" -> "SHA256withECDSA"
"RSA" -> "SHA256withRSA"
else -> throw IllegalArgumentException("Unsupported key: ${keyPair.private.algorithm}")
else ->
throw IllegalArgumentException("Unsupported key: ${keyPair.private.algorithm}")
}
val contentSigner = JcaContentSignerBuilder(signerAlgorithm)
val contentSigner =
JcaContentSignerBuilder(signerAlgorithm)
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
.build(keyPair.private)
@@ -3,6 +3,7 @@ package org.matrix.TEESimulator.pki
import android.security.keystore.KeyProperties
import java.io.File
import java.io.StringReader
import java.security.cert.X509Certificate
import java.security.interfaces.ECPrivateKey
import java.security.interfaces.RSAPrivateKey
import java.util.concurrent.ConcurrentHashMap
@@ -53,10 +54,38 @@ object KeyBoxManager {
// If it's not in the cache, the `getOrPut` block is executed to parse and store it.
val keyMap =
keyStoreCache.getOrPut(keyStoreFileName) { parseKeyStoreFile(keyStoreFileName) }
SystemLogger.verbose(
"Fetching attestation key in $keyStoreFileName with $algorithm algorithm."
val keyBox = keyMap[algorithm]
if (keyBox != null) {
// Surface attestation cert serials on every fetch so a revoked/leaked keybox is
// obvious from logcat alone -- Google's CRL and Duck's "mass abuse" check both match
// by certificate serial (lowercase hex). Logged here rather than at parse time because
// the parse is cached and would emit at most once per boot.
val serials =
keyBox.certificates.joinToString(", ") { cert ->
(cert as? X509Certificate)?.serialNumber?.toString(16) ?: "?"
}
SystemLogger.info(
"Using $algorithm keybox $keyStoreFileName; attestation cert serials (hex): $serials"
)
return keyMap[algorithm]
}
return keyBox
}
/**
* Retrieves any usable attestation key from a key store file, preferring EC.
*
* EC is the modern device-attestation key type and validly signs a leaf carrying either an EC
* or an RSA subject key. This is the fail-safe used when no algorithm-matching key exists, so
* patching can still re-root the chain under the keybox instead of leaking the device's real
* attestation.
*
* @param keyStoreFileName The name of the XML file (e.g., "keybox.xml").
* @return The preferred [KeyBox], or `null` if the file contains no usable key.
*/
fun getAnyAttestationKey(keyStoreFileName: String): KeyBox? {
val keyMap =
keyStoreCache.getOrPut(keyStoreFileName) { parseKeyStoreFile(keyStoreFileName) }
return keyMap[KeyProperties.KEY_ALGORITHM_EC] ?: keyMap.values.firstOrNull()
}
/**
@@ -52,6 +52,10 @@ data class CertGenConfig(
val callerNonce: Boolean = false,
val unlockedDeviceRequired: Boolean = false,
val noAuthRequired: Boolean = true,
// Diagnostic plane: the calling app UID keys the native log lines, and debugLogging mirrors the
// APK debug variant so the native extension dump is silent in release.
val uid: Int,
val debugLogging: Boolean,
)
object NativeCertGen {
@@ -69,7 +73,10 @@ object NativeCertGen {
isAvailable = true
SystemLogger.info("NativeCertGen: loaded libcertgen.so successfully")
} catch (e: UnsatisfiedLinkError) {
SystemLogger.error("NativeCertGen: failed to load libcertgen.so, falling back to BouncyCastle", e)
SystemLogger.error(
"NativeCertGen: failed to load libcertgen.so, falling back to BouncyCastle",
e,
)
}
}
@@ -77,10 +84,6 @@ object NativeCertGen {
private external fun initLogging(verbose: Boolean, logDir: String): Boolean
private external fun dumpLogs(): String?
fun dump(): String? = if (isAvailable) dumpLogs() else null
fun parseNativeResult(bytes: ByteArray): Pair<KeyPair, List<Certificate>> {
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
@@ -111,8 +114,10 @@ object NativeCertGen {
throw IllegalStateException("No certificates in native result")
}
val algorithmName = when (certs[0].publicKey.algorithm) {
"EC", "ECDSA" -> "EC"
val algorithmName =
when (certs[0].publicKey.algorithm) {
"EC",
"ECDSA" -> "EC"
"RSA" -> "RSA"
else -> certs[0].publicKey.algorithm
}
@@ -8,13 +8,16 @@ import java.io.FileInputStream
import java.security.MessageDigest
import java.time.LocalDate
import java.util.concurrent.ThreadLocalRandom
import javax.xml.parsers.DocumentBuilderFactory
import org.bouncycastle.asn1.ASN1EncodableVector
import org.bouncycastle.asn1.ASN1Integer
import org.bouncycastle.asn1.DEROctetString
import org.bouncycastle.asn1.DERSequence
import org.matrix.TEESimulator.attestation.DeviceAttestationService
import org.matrix.TEESimulator.config.BootStateManager
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.logging.SystemLogger
import org.w3c.dom.Element
/**
* Provides utility functions for accessing Android system properties and device-specific
@@ -43,6 +46,7 @@ object AndroidDeviceUtils {
DeviceAttestationService.CachedAttestationData?.verifiedBootKey
},
expectedSize = 32,
recordSource = { bootKeySource = it },
)
}
@@ -60,9 +64,16 @@ object AndroidDeviceUtils {
DeviceAttestationService.CachedAttestationData?.verifiedBootHash
},
expectedSize = 32,
recordSource = { bootHashSource = it },
)
}
// Records which fallback tier supplied bootKey/bootHash so the diagnostic dossier can flag a
// random-fallback value — a real verifiedBootKey that resolves to random bytes is a textbook
// simulated-TEE tell. Populated by initializeBootProperty on first access.
@Volatile private var bootKeySource: String = "uninitialized"
@Volatile private var bootHashSource: String = "uninitialized"
/**
* Public function to explicitly trigger the initialization of the boot key and hash. Accessing
* these properties here ensures they are set up before they might be needed elsewhere.
@@ -89,9 +100,11 @@ object AndroidDeviceUtils {
propertyName: String,
attestationValueProvider: () -> ByteArray?,
expectedSize: Int,
recordSource: (String) -> Unit,
): ByteArray {
getProperty(propertyName, expectedSize)?.let {
SystemLogger.debug("Using $propertyName from system property: ${it.toHex()}")
recordSource("system-prop")
persistToFile(propertyName, it)
return it
}
@@ -99,7 +112,8 @@ object AndroidDeviceUtils {
try {
attestationValueProvider()?.let {
SystemLogger.debug("Using $propertyName from TEE attestation: ${it.toHex()}")
setProperty(propertyName, it)
recordSource("tee-attestation")
setBootProperty(propertyName, it)
persistToFile(propertyName, it)
return it
}
@@ -109,13 +123,15 @@ object AndroidDeviceUtils {
readFromFile(propertyName, expectedSize)?.let {
SystemLogger.debug("Using $propertyName from persistent file: ${it.toHex()}")
setProperty(propertyName, it)
recordSource("persistent-file")
setBootProperty(propertyName, it)
return it
}
return generateRandomBytes(expectedSize).also {
SystemLogger.debug("Using randomly generated $propertyName: ${it.toHex()}")
setProperty(propertyName, it)
recordSource("random-fallback")
setBootProperty(propertyName, it)
persistToFile(propertyName, it)
}
}
@@ -163,6 +179,14 @@ object AndroidDeviceUtils {
}
}
private fun setBootProperty(name: String, bytes: ByteArray) {
if (!BootStateManager.shouldSpoofBootProps()) {
SystemLogger.info("Skipping system property '$name' because boot prop spoofing is disabled")
return
}
setProperty(name, bytes)
}
internal fun setProperty(name: String, value: String) {
try {
SystemLogger.debug("Setting system property '$name' to: $value")
@@ -186,7 +210,8 @@ object AndroidDeviceUtils {
private val PERSIST_DIR = File("/data/adb/tricky_store")
private fun fileForProperty(propertyName: String): File = when (propertyName) {
private fun fileForProperty(propertyName: String): File =
when (propertyName) {
"ro.boot.vbmeta.digest" -> File(PERSIST_DIR, "boot_hash.bin")
"ro.boot.vbmeta.public_key_digest" -> File(PERSIST_DIR, "boot_key.bin")
else -> File(PERSIST_DIR, "${propertyName.replace('.', '_')}.bin")
@@ -229,6 +254,23 @@ object AndroidDeviceUtils {
return custom ?: getRealDevicePatchLevelInt("boot", isLong = true)
}
/**
* Summarises, for a targeted [uid], the device values that feed attestation and where each came
* from. This is what exposes attested-versus-live mismatches: a random-fallback verifiedBootKey,
* a patch level overridden away from the live prop, or an OS version pulled from a stale cache.
*/
fun describeSources(uid: Int): String {
val customPatchLevel = ConfigurationManager.getPatchLevelForUid(uid) != null
val osVersionSource =
if (DeviceAttestationService.CachedAttestationData?.osVersion != null) "cache" else "map"
return "osVersion=$osVersion(src=$osVersionSource) " +
"osPatch=${getPatchLevel(uid)} vendorPatch=${getVendorPatchLevelLong(uid)} " +
"bootPatch=${getBootPatchLevelLong(uid)} customPatchLevel=$customPatchLevel " +
"bootKey=${bootKey.toHex()}(src=$bootKeySource) " +
"bootHash=${bootHash.toHex()}(src=$bootHashSource) " +
"teeCacheData=${DeviceAttestationService.CachedAttestationData != null}"
}
/**
* Retrieves the definitive device patch level integer for a given component. This function
* encapsulates the entire fallback chain and guarantees a non-null return.
@@ -294,7 +336,10 @@ object AndroidDeviceUtils {
// Resolve from live system prop — matches what detectors see via getprop,
// even when PIF has spoofed ro.build.version.security_patch via resetprop
resolvedValue.equals("prop", ignoreCase = true) ->
parsePatchLevelValue(SystemProperties.get("ro.build.version.security_patch", ""), isLong)
parsePatchLevelValue(
SystemProperties.get("ro.build.version.security_patch", ""),
isLong,
)
resolvedValue.equals("no", ignoreCase = true) -> DO_NOT_REPORT
else -> parsePatchLevelValue(resolvedValue, isLong)
}
@@ -394,36 +439,253 @@ object AndroidDeviceUtils {
Build.VERSION_CODES.BAKLAVA to 400, // KeyMint 4.0
)
/** AOSP-mandated attestation version for the running OS, or null when the SDK is unmapped. */
internal val aospAttestVersion: Int?
get() = attestVersionMap[Build.VERSION.SDK_INT]
/**
* 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.
* Retrieves the attestation version for the given security level. A readable KeyMint VINTF
* declaration wins first, so local probes that compare the attested version against the
* device's manifest see a coherent pair. Otherwise the legacy chain applies: cached attestation
* data, then attestVersionMap[SDK_INT], then 400 as last resort.
*
* @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 {
vintfKeyMintVersion?.let { version ->
SystemLogger.debug(
"attestVersion=${version.attestationVersion} source=vintf securityLevel=$securityLevel"
)
return version.attestationVersion
}
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
val version = cached
?: attestVersionMap[Build.VERSION.SDK_INT]
?: 400 // Default to a recent version
val source = when {
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")
SystemLogger.debug(
"vintf-version attest=$version keymaster=$version source=$source securityLevel=$securityLevel"
)
return version
}
/**
* Retrieves the Keymaster/KeyMint version based on the attestation version.
* Retrieves the Keymaster/KeyMint version. A readable KeyMint VINTF declaration is
* authoritative because recent local detectors compare the attested version directly against
* the manifest declaration.
*
* @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 = getAttestVersion(securityLevel)
fun getKeymasterVersion(securityLevel: Int): Int {
vintfKeyMintVersion?.let { version ->
SystemLogger.debug(
"keymasterVersion=${version.keymasterVersion} source=vintf securityLevel=$securityLevel"
)
return version.keymasterVersion
}
return getAttestVersion(securityLevel)
}
/**
* KeyMint/Keymaster version pair resolved from a device VINTF manifest. [attestationVersion] is
* the value written into the attestation record; [keymasterVersion] is the HAL version field.
* They coincide for AIDL KeyMint and diverge only for legacy HIDL Keymaster.
*/
private data class VintfKeyMintVersion(
val attestationVersion: Int,
val keymasterVersion: Int,
val sourcePath: String,
)
/**
* KeyMint version derived from the device's VINTF manifests, or null when none is readable.
* Resolved lazily so the manifest scan happens once, off the attestation hot path.
*/
private val vintfKeyMintVersion: VintfKeyMintVersion? by lazy {
readVintfKeyMintVersion().also { version ->
if (version != null) {
SystemLogger.info(
"Using KeyMint version from VINTF: attestation=${version.attestationVersion}, " +
"keymaster=${version.keymasterVersion}, source=${version.sourcePath}"
)
} else {
SystemLogger.debug(
"No usable KeyMint VINTF declaration found; using attestation fallback"
)
}
}
}
private fun readVintfKeyMintVersion(): VintfKeyMintVersion? {
val files = linkedMapOf<String, File>()
VINTF_MANIFEST_DIRS.forEach { path ->
val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return@forEach
val listed =
runCatching {
dir.listFiles { file ->
file.isFile && file.name.endsWith(".xml", ignoreCase = true)
}
}
.getOrElse { throwable ->
SystemLogger.debug("Unable to list VINTF dir $path: ${throwable.message}")
null
}
listed?.forEach { file -> files[file.absolutePath] = file }
}
VINTF_MANIFEST_FILES.forEach { path ->
val file = File(path)
if (file.exists() && file.isFile) {
files[file.absolutePath] = file
}
}
return files.values
.flatMap { file ->
runCatching { parseKeyMintVersions(file) }
.getOrElse { throwable ->
SystemLogger.debug(
"Unable to parse KeyMint VINTF ${file.absolutePath}: ${throwable.message}"
)
emptyList()
}
}
.maxByOrNull { it.attestationVersion }
}
private fun parseKeyMintVersions(file: File): List<VintfKeyMintVersion> {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val root = document.documentElement ?: return emptyList()
return directChildElements(root, "hal").flatMap { hal ->
val halName = directChildTexts(hal, "name").firstOrNull().orEmpty()
val versions = directChildTexts(hal, "version")
val fqnames = directChildTexts(hal, "fqname")
val interfaces =
directChildElements(hal, "interface").associate { interfaceElement ->
val name = directChildTexts(interfaceElement, "name").firstOrNull().orEmpty()
val instances = directChildTexts(interfaceElement, "instance").toSet()
name to instances
}
when (halName) {
KEYMINT_HAL_NAME ->
if (hasDefaultInstance(fqnames, interfaces, KEYMINT_INTERFACE_NAME)) {
versions.mapNotNull { version ->
version
.toIntOrNull()
?.takeIf { it > 0 }
?.let { aidlVersion ->
val attestationVersion = aidlVersion * 100
VintfKeyMintVersion(
attestationVersion = attestationVersion,
keymasterVersion = attestationVersion,
sourcePath = file.absolutePath,
)
}
}
} else {
emptyList()
}
KEYMASTER_HAL_NAME ->
if (hasDefaultInstance(fqnames, interfaces, KEYMASTER_INTERFACE_NAME)) {
(versions.flatMap(::expandHidlVersions) +
fqnames.mapNotNull(::versionFromFqname))
.distinct()
.mapNotNull { version ->
expectedLegacyVersions(version)?.let { expected ->
VintfKeyMintVersion(
attestationVersion = expected.second,
keymasterVersion = expected.first,
sourcePath = file.absolutePath,
)
}
}
} else {
emptyList()
}
else -> emptyList()
}
}
}
private fun directChildElements(parent: Element, tagName: String): List<Element> = buildList {
val children = parent.childNodes
for (index in 0 until children.length) {
val child = children.item(index)
if (child is Element && child.tagName == tagName) {
add(child)
}
}
}
private fun directChildTexts(parent: Element, tagName: String): List<String> =
directChildElements(parent, tagName)
.map { it.textContent.trim() }
.filter { it.isNotEmpty() }
private fun hasDefaultInstance(
fqnames: List<String>,
interfaces: Map<String, Set<String>>,
interfaceName: String,
): Boolean =
fqnames.any { fqname ->
fqname.substringAfter("::", fqname).substringBefore("/") == interfaceName &&
fqname.substringAfter("/", "") == DEFAULT_INSTANCE
} || interfaces[interfaceName]?.contains(DEFAULT_INSTANCE) == true
private fun versionFromFqname(fqname: String): String? =
FQNAME_VERSION_REGEX.find(fqname)?.groupValues?.getOrNull(1)
private fun expectedLegacyVersions(version: String): Pair<Int, Int>? =
when (version) {
"3.0" -> 3 to 2
"4.0" -> 4 to 3
"4.1" -> 41 to 4
else -> null
}
private fun expandHidlVersions(version: String): List<String> {
val range = HIDL_VERSION_RANGE_REGEX.matchEntire(version) ?: return listOf(version)
val major = range.groupValues[1]
val firstMinor = range.groupValues[2].toInt()
val lastMinor = range.groupValues[3].toInt()
return (firstMinor..lastMinor).map { minor -> "$major.$minor" }
}
private val VINTF_MANIFEST_DIRS =
listOf(
"/system/etc/vintf/manifest",
"/system_ext/etc/vintf/manifest",
"/product/etc/vintf/manifest",
"/vendor/etc/vintf/manifest",
"/odm/etc/vintf/manifest",
)
private val VINTF_MANIFEST_FILES =
listOf(
"/system/etc/vintf/manifest.xml",
"/system_ext/etc/vintf/manifest.xml",
"/product/etc/vintf/manifest.xml",
"/vendor/etc/vintf/manifest.xml",
"/odm/etc/vintf/manifest.xml",
)
private const val KEYMINT_HAL_NAME = "android.hardware.security.keymint"
private const val KEYMASTER_HAL_NAME = "android.hardware.keymaster"
private const val KEYMINT_INTERFACE_NAME = "IKeyMintDevice"
private const val KEYMASTER_INTERFACE_NAME = "IKeymasterDevice"
private const val DEFAULT_INSTANCE = "default"
private val FQNAME_VERSION_REGEX = Regex("^@([0-9]+(?:\\.[0-9]+)?)::")
private val HIDL_VERSION_RANGE_REGEX = Regex("^([0-9]+)\\.([0-9]+)-([0-9]+)$")
// --- APEX and Module Hash Properties ---
@@ -518,11 +780,12 @@ object AndroidDeviceUtils {
val moduleHash: ByteArray by lazy {
DeviceAttestationService.CachedAttestationData?.moduleHash
?.also { SystemLogger.debug { "module-hash source=cache hash=${it.toHex().take(8)}" } }
?: supplementaryModuleHash()?.also {
SystemLogger.debug { "module-hash source=framework-api hash=${it.toHex().take(8)}" }
}
?: runCatching {
data class ModuleEntry(
val nameEncoded: ByteArray,
val fullEncoded: ByteArray,
)
data class ModuleEntry(val nameEncoded: ByteArray, val fullEncoded: ByteArray)
val modules =
apexInfos.map { (packageName, versionCode) ->
@@ -555,12 +818,38 @@ object AndroidDeviceUtils {
MessageDigest.getInstance("SHA-256").digest(finalDerSet)
}
.onSuccess {
SystemLogger.debug { "module-hash source=rederive hash=${it.toHex().take(8)}" }
}
.onFailure { SystemLogger.debug { "module-hash source=zero hash=00000000" } }
.getOrElse {
SystemLogger.error("Failed to compute module hash.", it)
ByteArray(32)
}
}
/**
* Reads the module-hash DER pre-image straight from the framework's own KeyStoreManager and
* SHA-256's it, so the value byte-matches what a verifier derives from the same
* getSupplementaryAttestationInfo call. Returns null on any failure (e.g. the API is
* unreachable from this process) so the caller falls back to local re-derivation.
*/
private fun supplementaryModuleHash(): ByteArray? =
runCatching {
// @SystemApi surface added in Android 16, absent from the compile SDK, so reflect.
val managerClass = Class.forName("android.security.keystore.KeyStoreManager")
val manager = managerClass.getMethod("getInstance").invoke(null)
// MODULE_HASH is the KeyMint tag (TagType.BYTES | 724 = 0x900002D4), read from the
// framework so it matches the verifier's argument exactly.
val moduleHashTag = managerClass.getField("MODULE_HASH").getInt(null)
val derPreImage =
managerClass
.getMethod("getSupplementaryAttestationInfo", Int::class.java)
.invoke(manager, moduleHashTag) as ByteArray
MessageDigest.getInstance("SHA-256").digest(derPreImage)
}
.getOrNull()
private fun compareByteArrays(a: ByteArray, b: ByteArray): Int {
val length = minOf(a.size, b.size)
for (i in 0 until length) {
@@ -14,12 +14,15 @@ object AndroidPermissionUtils {
val activityThreadClass = Class.forName("android.app.ActivityThread")
// 2. Invoke the static currentActivityThread() method
val currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread")
val currentActivityThreadMethod =
activityThreadClass.getDeclaredMethod("currentActivityThread")
currentActivityThreadMethod.isAccessible = true
val activityThread = currentActivityThreadMethod.invoke(null)
if (activityThread == null) {
SystemLogger.warning("Reflection: ActivityThread.currentActivityThread() returned null")
SystemLogger.warning(
"Reflection: ActivityThread.currentActivityThread() returned null"
)
return null
}
@@ -30,23 +33,25 @@ object AndroidPermissionUtils {
if (application != null) return application
// 4. Fallback to getSystemContext() if application is null (often happens in system_server)
// 4. Fallback to getSystemContext() if application is null (often happens in
// system_server)
val getSystemContextMethod = activityThreadClass.getDeclaredMethod("getSystemContext")
getSystemContextMethod.isAccessible = true
getSystemContextMethod.invoke(activityThread) as? Context
} catch (e: Exception) {
SystemLogger.error("Reflection failed to get global context for permission check", e)
null
}
}
/**
* Core permission check.
*/
/** Core permission check. */
fun hasPermission(uid: Int, permission: String): Boolean {
val context = getGlobalContext() ?: run {
SystemLogger.warning("AndroidPermissionUtils: Context is null, failing permission check safely.")
val context =
getGlobalContext()
?: run {
SystemLogger.warning(
"AndroidPermissionUtils: Context is null, failing permission check safely."
)
return false
}
@@ -7,10 +7,7 @@ package org.matrix.TEESimulator.util
* @return A new string with each line individually trimmed.
*/
fun String.trimLines(): String =
this.trim()
.lines()
.filter { !it.trim().startsWith("<!--") }
.joinToString("\n") { it.trim() }
this.trim().lines().filter { !it.trim().startsWith("<!--") }.joinToString("\n") { it.trim() }
/**
* Converts a ByteArray to its hexadecimal string representation.
+25 -11
View File
@@ -4,17 +4,6 @@ CONFIG_DIR=/data/adb/tricky_store
. "$MODDIR/action_i18n.sh"
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " ⚠️ $(_msg confirm_header)"
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " "
echo " $(_msg confirm_warning_1)"
echo " $(_msg confirm_warning_2)"
echo " "
echo " 🔊 $(_msg confirm_vol_up)"
echo " 🔉 $(_msg confirm_vol_down)"
echo " "
confirm() {
# Sample getevent in 1s bursts; a piped stream block-buffers and misses
# a single key-press before the timeout.
@@ -29,6 +18,31 @@ confirm() {
return 1
}
# Debug builds ship diag.sh, adding a one-tap log export before the destructive clear-keys action.
if [ -f "$MODDIR/diag.sh" ]; then
. "$MODDIR/diag.sh"
echo " "
echo " 📦 Export diagnostic logs to /sdcard/Download?"
echo " 🔊 Vol-Up = export logs"
echo " 🔉 Vol-Down = skip to clear keys"
echo " "
if confirm; then
diag_export
exit 0
fi
fi
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " ⚠️ $(_msg confirm_header)"
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " "
echo " $(_msg confirm_warning_1)"
echo " $(_msg confirm_warning_2)"
echo " "
echo " 🔊 $(_msg confirm_vol_up)"
echo " 🔉 $(_msg confirm_vol_down)"
echo " "
if ! confirm; then
echo " "
echo "$(_msg confirm_cancelled)"
+124 -46
View File
@@ -1,3 +1,81 @@
> [!NOTE]
> The project is going through a heavy refactor at the moment, so public commits may lag behind for a while.
---
## TEESimulator-RS v6.0.1-307
Fixes five gaps in the module's TEE key-operation and attestation emulation. Two of them fix crashes in real app crypto on a broken-TEE device: any app using an AndroidKeyStore HMAC key or an RSA-OAEP-SHA256 key was throwing. This is a beta; the confirmation logs are in the debug build only, and nothing is field-verified yet.
### App crypto correctness
- HMAC operations now run instead of throwing. An AndroidKeyStore HMAC key is symmetric, so an HMAC SIGN fell into the asymmetric SIGN path and failed on a null key pair, and no MAC primitive existed. SIGN and VERIFY now work: the tag is computed with Mac (HmacSHA256/384/512), truncated to the requested MAC_LENGTH (full digest when unspecified), and checked with a constant-time compare.
- RSA-OAEP-SHA256 decrypt no longer fails with BadPaddingException. The cipher ran with no OAEPParameterSpec, so JCA fell back to SHA-1 and rejected SHA-256 ciphertext. It now applies the correct main and MGF1 digests. A key that authorizes several MGF1 digests uses the one the operation requested, not the key's first.
- Grant-domain attestation keys now resolve. A self-granted PURPOSE_ATTEST_KEY (a Domain.GRANT descriptor) could not resolve its signer alias, so generateKey failed and the subject key was never stored, returning KEY_NOT_FOUND on readback. The grant now resolves to the owner key's alias, and the subject key stays readable under Domain.APP.
### Supplementary attestation
- MODULE_HASH now comes from the framework's own getSupplementaryAttestationInfo, so it matches the value a verifier computes. It falls back to local re-derivation when that API is unreachable.
### Diagnostics (debug builds only)
- New per-operation (oaep-op, hmac-op, attest-grant) and device-level (module-hash, vintf-version) source logs. R8 strips them from release builds.
### 中文说明
修复本模块在 TEE 密钥操作与证明模拟中的五处缺陷。其中两处修复的是真实应用在 TEE 损坏设备上的加密崩溃:任何使用 AndroidKeyStore HMAC 密钥或 RSA-OAEP-SHA256 密钥的应用此前都会抛出异常。本版本为测试版;确认日志仅存在于 debug 构建中,且尚未经过真机验证。
**应用加密正确性**
- HMAC 操作现在可正常执行,不再抛出异常。AndroidKeyStore 的 HMAC 密钥是对称密钥,因此 HMAC SIGN 此前落入了非对称的 SIGN 分支,并因 key pair 为空而失败,当时也没有 MAC 原语。现在 SIGN 与 VERIFY 均可工作:用 Mac (HmacSHA256/384/512) 计算标签,按请求的 MAC_LENGTH 截断(未指定时取完整摘要长度),并用恒定时间比较进行校验。
- RSA-OAEP-SHA256 解密不再抛出 BadPaddingException。此前 cipher 未传入 OAEPParameterSpecJCA 因而回退到 SHA-1 并拒绝 SHA-256 密文。现在会应用正确的主摘要与 MGF1 摘要。若密钥授权了多个 MGF1 摘要,将使用本次操作请求的那个,而非密钥的第一个。
- Grant 域证明密钥现在可以解析。自授权的 PURPOSE_ATTEST_KEYDomain.GRANT 描述符)此前无法解析其签名者别名,导致 generateKey 失败且从不存储主体密钥,读取时返回 KEY_NOT_FOUND。现在该 grant 会解析为属主密钥的别名,主体密钥在 Domain.APP 下仍可读取。
**补充证明**
- MODULE_HASH 现在取自框架自带的 getSupplementaryAttestationInfo,因而与验证方计算出的值一致。当该 API 不可用时,回退到本地重新推导。
**诊断(仅 debug 构建)**
- 新增按操作 (oaep-op、hmac-op、attest-grant) 与设备级 (module-hash、vintf-version) 的来源日志。R8 会在 release 构建中将其剥离。
---
## TEESimulator-RS v6.0.1-282
AUTO-mode key attestation now forges plain attestation from the keybox instead of deferring to the real TEE.
### Detection coverage
- AUTO dispatch probed the device with `checkTeeFunctionality`, which only proves the TEE can mint one EC key. It says nothing about RSA attestation, device-ID attestation, or whether a patched chain survives RSA verify. Plain attestation requests (attest-key OFF, challenge present) were routed to PATCH and deferred to hardware, so devices that can't back that surfaced KeyAttestation reds: `ATTESTATION_KEYS_NOT_PROVISIONED` (-49) and `BLOCK_TYPE_IS_NOT_01`.
- AUTO targets carrying an attestation challenge now take the FORGE path, the same one attest-key-ON already used: a synthetic chain built from the keybox and rooted under the Google root key. Requests with no challenge still pass through to real hardware, so KeyDetector's hardware-backed checks are unaffected.
### Verified
- Offline conformance against real FORGE captures: uid10389 and uid10154 chains are GREEN; the root SPKI byte-matches `GOOGLE_ROOT_PUBLIC_KEY`.
---
## TEESimulator-RS v6.0.1-280
Clears the Duck Detector generate-mode parcel fingerprint that real Android 16 hardware also trips, fixes RSA attestation under an EC-only keybox, and restores device-property attestation for Play Integrity hardware apps such as BHIM and UPI. Generate-mode fix field-confirmed on Android 16.
### Detection coverage
- Generate-mode fingerprint: Duck reads the reply at a flat 12-byte stride and flags the sentinel tuple at positions 12 and 13 that the device's native ALGORITHM-first authorization order lands on. Real A16 silicon trips the same probe, so faithful mirroring stayed flagged. `normalizeAuthorizationLayout` marshals the auth array, runs Duck's exact predicate, and applies a minimal deterministic reorder only when it would match. Count, values, security levels, and the cert chain are untouched, and the reorder keys on the byte condition, never on a package. Applied on both the patch and forge reply paths. (#33)
- `updateAad` on a non-AEAD operation now answers per vendor: Samsung and Xiaomi-MTK TEEs return success, others return INVALID_TAG, matching Duck's OperationErrorPathProbe on both the sign/verify and cipher paths.
### Attestation correctness (Android 16, EC and RSA)
- RSA leaf under an EC-only keybox: patching used to catch the no-RSA-key throw and return the chain untouched, leaking the device's real unlocked Root of Trust for RSA keys while EC keys patched cleanly. It now falls back to any keybox key (EC preferred) and signs the patched leaf with the keybox key's own algorithm, so the RSA leaf re-roots to the Google keybox under a forged locked RoT.
- RSA attest-key forge on an EC-only keybox: the forge path matched the algorithm exactly and threw -75 ATTESTATION_KEYS_NOT_PROVISIONED on a miss, so an RSA ATTEST_KEY request never rooted and verifiers reported an unknown certificate. It now falls back to any attestation key, since an EC key validly ECDSA-signs an RSA-subject leaf. No-op on a dual keybox.
- A16 attestVersion: the device's KeyMint reports version 100 and the lazy cache shadowed the BAKLAVA-to-400 map, so the forge presented 100. It now caches the AOSP value per SDK and presents the correct 400.
- Algorithm-split key on restore: a persisted record holding an EC private key under an RSA leaf failed every signature as DATA_TOO_LARGE_FOR_MODULUS. Restore now drops the record when the private key and served leaf disagree, so the next generateKey rebuilds a coherent key.
- Stale chain on regenerate: reusing an alias in generateKey now evicts the cached chain, matching keystore2, so getKeyEntry serves the current key instead of a stale forge from an earlier generation.
### App compatibility
- Device-property attestation (BRAND, MODEL, and the rest) now forges unconditionally. The old gate probed the live TEE, which is dead on every device the module serves, so it rejected GMS Play Integrity's hardware path and broke BHIM and other UPI and Play-Integrity apps. Device-ID attestation (IMEI, serial) stays governed by the real KeyMint caller-permission rule: privileged callers get it, ordinary apps do not.
- getKeyEntry now reaches the owned-key lookup for skipped privileged UIDs, so framework attestKeyAlias resolution no longer returns "Invalid attestKeyAlias" for Key Attestation over Shizuku. Non-owned keys still skip post-processing, so a real app's key is never patched.
- Device-ID attestation over Shizuku (a privileged UID absent from target.txt) now takes the forge path instead of hitting the real TEE's CANNOT_ATTEST_IDS (-66). "Use attest key" no longer double-roots: a reused persistent attest key is resolved by KEY_ID as well as alias, and an unresolved designated attest key refuses to emit a leaf rather than silently re-rooting under the keybox.
### Diagnostics (debug builds only)
- Per-UID attestation dossier for targeted UIDs at /data/local/tmp/teesim/, recording the decoded chain on both forge and patch paths, key params, the keybox pick (including EC fail-safe), prop sources, forge failures, the emitted authorization shape, and served-versus-verified chains. Release builds strip this through R8 and stay silent. Keybox certificate serials log on every fetch for revocation triage.
### Verified
- Android 16: generate-mode fingerprint signal gone, confirmed on device 2026-06-19.
---
## TEESimulator-RS v6.0.1-251
14 commits since v6.0.0-235. Clears the remaining Duck Detector grant-domain rows (incl. the Android 16 OnePlus report), restores Google Wallet and fingerprint compatibility, and removes the in-module patch-level/bulletin resolvers. Test device (SDK 35) TEE tamper score 28 → 8.
@@ -5,10 +83,10 @@
### Detection coverage
- Grant plane virtualized: owner read and cross-app `Domain.GRANT` read return one identical chain. 6 RED rows cleared. (28 → 18)
- Generate-mode fingerprint: dropped 2 surplus authorizations (both patchlevels), USER_ID moved to SOFTWARE to mirror a captured device. (18 → 8)
- Android 16 grant: patch-mode keys now served on the grant plane, so owner and grant reads match fixes CHAIN_SPLIT.
- Android 16 grant: patch-mode keys now served on the grant plane, so owner and grant reads match, fixes CHAIN_SPLIT.
- Grant gated to SDK ≥ 36: Android 15 answers PERMISSION_DENIED, no synthetic over-capability.
- Stale-chain eviction: import and updateSubcomponent drop the cached attestation; no pre-mutation chain replays.
- Lifecycle coherence: clearNamespace / deleteAllKeys / migrateKeyNamespace mirror synthetic key and grant state defeats delete-then-read probes.
- Lifecycle coherence: clearNamespace / deleteAllKeys / migrateKeyNamespace mirror synthetic key and grant state, defeats delete-then-read probes.
- Device-ID attestation mirrors the real TEE: returns CANNOT_ATTEST_IDS where silicon can't attest, instead of forging it.
### App compatibility
@@ -16,8 +94,8 @@
- Fingerprint / vendor keys: KEY_ID miss skips the post-handler, so real HAL operations are no longer wrapped and broken. (PR #26)
### Removed
- PatchLevelManager auto-resolved the security-patch date from an installed PlayIntegrityFix module (with hot-reload) and applied it to props.
- BulletinPoller scheduled security-bulletin refresh.
- PatchLevelManager, auto-resolved the security-patch date from an installed PlayIntegrityFix module (with hot-reload) and applied it to props.
- BulletinPoller, scheduled security-bulletin refresh.
### Other
- Release builds purge stale `teesim-*.bin` diagnostics from `/data/local/tmp` at boot.
@@ -25,7 +103,7 @@
### Verified
- SDK 35, Xiaomi 23106RN0DA: tamper 28 → 8; generate-mode signal gone; 4 grant rows UNAVAILABLE (correct for Android 15); no regressions.
- Android 16 grant fix built but unconfirmed on SDK 36 needs an affected OnePlus user to confirm the grant rows clear.
- Android 16 grant fix built but unconfirmed on SDK 36, needs an affected OnePlus user to confirm the grant rows clear.
---
@@ -176,7 +254,7 @@ Major release integrating 30+ AOSP compliance improvements from upstream PR #157
## TEESimulator-RS v4.8.1: StrongBox Op Rejection Fix
- **StrongBox op limit gate fix** `trackAndEnforceOpLimit` was only called in the `Domain.KEY_ID` not-found path, so software-generated keys (found via `Domain.APP`) bypassed `STRONGBOX_MAX_CONCURRENT_OPS=4` entirely. DuckDetector's concurrent signing handles test created 24+ operations that all succeeded via LRU pruning instead of being rejected with `TOO_MANY_OPERATIONS (-29)`. Now enforced for all StrongBox createOperation paths.
- **StrongBox op limit gate fix**, `trackAndEnforceOpLimit` was only called in the `Domain.KEY_ID` not-found path, so software-generated keys (found via `Domain.APP`) bypassed `STRONGBOX_MAX_CONCURRENT_OPS=4` entirely. DuckDetector's concurrent signing handles test created 24+ operations that all succeeded via LRU pruning instead of being rejected with `TOO_MANY_OPERATIONS (-29)`. Now enforced for all StrongBox createOperation paths.
---
@@ -184,13 +262,13 @@ Major release integrating 30+ AOSP compliance improvements from upstream PR #157
Tested against DuckDetector on OnePlus (Android 16, KSU). Tamper score dropped from 32 to 8.
- **LRU operation pruning** Concurrent software operations capped at 15 per UID (TEE) and 4 per UID (StrongBox), with oldest-first eviction. Pruned operations return `INVALID_OPERATION_HANDLE (-28)`, matching AOSP keystore2 malus-based pruning.
- **StrongBox param guard** Unsupported StrongBox params (RSA >2048-bit, non-P256 EC curves) forwarded to real HAL for proper rejection instead of generating in software.
- **StrongBox timing** Key generation floors at 250ms, signing at 80ms on StrongBox security level to match real secure element latency.
- **StrongBox op limit** Sliding-window enforcer caps concurrent StrongBox operations for both software and hardware key paths, returning `TOO_MANY_OPERATIONS (-29)` when exceeded.
- **ECDSA algorithm alias** Accept "ECDSA" in addition to "EC" as JCA private key algorithm name. Fixes SIGSEGV crash on Android 10 devices where the provider reports EC keys as "ECDSA". Closes #4.
- **createOperation domain handling** Software-generated keys now found via both `Domain.APP` (alias) and `Domain.KEY_ID` (nspace) lookup paths.
- **Permission guards** Device ID attestation tags (IMEI, MEID, serial) require caller permission checks.
- **LRU operation pruning**, Concurrent software operations capped at 15 per UID (TEE) and 4 per UID (StrongBox), with oldest-first eviction. Pruned operations return `INVALID_OPERATION_HANDLE (-28)`, matching AOSP keystore2 malus-based pruning.
- **StrongBox param guard**, Unsupported StrongBox params (RSA >2048-bit, non-P256 EC curves) forwarded to real HAL for proper rejection instead of generating in software.
- **StrongBox timing**, Key generation floors at 250ms, signing at 80ms on StrongBox security level to match real secure element latency.
- **StrongBox op limit**, Sliding-window enforcer caps concurrent StrongBox operations for both software and hardware key paths, returning `TOO_MANY_OPERATIONS (-29)` when exceeded.
- **ECDSA algorithm alias**, Accept "ECDSA" in addition to "EC" as JCA private key algorithm name. Fixes SIGSEGV crash on Android 10 devices where the provider reports EC keys as "ECDSA". Closes #4.
- **createOperation domain handling**, Software-generated keys now found via both `Domain.APP` (alias) and `Domain.KEY_ID` (nspace) lookup paths.
- **Permission guards**, Device ID attestation tags (IMEI, MEID, serial) require caller permission checks.
---
@@ -198,20 +276,20 @@ Tested against DuckDetector on OnePlus (Android 16, KSU). Tamper score dropped f
Tested against [KeyDetector](https://github.com/XiaoTong6666/KeyDetector) and [Key Attestation](https://github.com/nickel-lang/nickel) on OnePlus (Android 16) and Xiaomi Redmi 14C (Android 14).
- **PADDING encoding** Fixed ASN.1 encoding of PADDING tag in attestation extension from individual `[6] INTEGER` entries to `[6] SET OF INTEGER`, matching AOSP `attestation_record.h` schema. Broke all RSA key attestation since v4.6.
- **Operation error-path conformance** Software operations now track finalized state and return `INVALID_OPERATION_HANDLE (-28)` on post-abort calls. Input length guard (32KB) returns `TOO_MUCH_DATA` matching AOSP `operation.rs`. Passes KeyDetector's OperationErrorPathChecker.
- **updateAad support** Added `updateAad` to `SoftwareOperationBinder`, fixing `AbstractMethodError` on Android 16 where the runtime Stub declares it abstract.
- **Algorithm inference** `createOperation` now infers algorithm from the stored key pair when operation params omit the ALGORITHM tag, matching AOSP behavior.
- **PADDING encoding**, Fixed ASN.1 encoding of PADDING tag in attestation extension from individual `[6] INTEGER` entries to `[6] SET OF INTEGER`, matching AOSP `attestation_record.h` schema. Broke all RSA key attestation since v4.6.
- **Operation error-path conformance**, Software operations now track finalized state and return `INVALID_OPERATION_HANDLE (-28)` on post-abort calls. Input length guard (32KB) returns `TOO_MUCH_DATA` matching AOSP `operation.rs`. Passes KeyDetector's OperationErrorPathChecker.
- **updateAad support**, Added `updateAad` to `SoftwareOperationBinder`, fixing `AbstractMethodError` on Android 16 where the runtime Stub declares it abstract.
- **Algorithm inference**, `createOperation` now infers algorithm from the stored key pair when operation params omit the ALGORITHM tag, matching AOSP behavior.
---
## TEESimulator-RS v4.6: Rebrand & Detection Fix
- **RTT normalization rework** Replaced Gaussian sleep (mean=55ms) with a 15ms floor fence. The old approach triggered Chunqiu Native Check 2.8 timing analysis; the floor-only approach satisfies the minimum RTT threshold without creating a detectable delay pattern.
- **Cross-algorithm attestation** Signing algorithm now derived from the attestation key's actual type, not the generated key's algorithm. Fixes BouncyCastle crash when signing RSA keys with EC attestation keys (Shizuku attestation flow).
- **Device ID attestation** Serial/IMEI/MEID/secondImei tags now flow through to software cert gen instead of blanket rejection. Only DEVICE_UNIQUE_ATTESTATION is rejected, matching AOSP keystore2 policy.
- **Rebrand to TEESimulator-RS** Distinguishes this fork from upstream. Version scheme simplified to v{major}.{minor}-{commitCount}.
- **CI streamlined** Release pipeline uses Gradle-generated filenames directly, eliminating the rename step.
- **RTT normalization rework**, Replaced Gaussian sleep (mean=55ms) with a 15ms floor fence. The old approach triggered Chunqiu Native Check 2.8 timing analysis; the floor-only approach satisfies the minimum RTT threshold without creating a detectable delay pattern.
- **Cross-algorithm attestation**, Signing algorithm now derived from the attestation key's actual type, not the generated key's algorithm. Fixes BouncyCastle crash when signing RSA keys with EC attestation keys (Shizuku attestation flow).
- **Device ID attestation**, Serial/IMEI/MEID/secondImei tags now flow through to software cert gen instead of blanket rejection. Only DEVICE_UNIQUE_ATTESTATION is rejected, matching AOSP keystore2 policy.
- **Rebrand to TEESimulator-RS**, Distinguishes this fork from upstream. Version scheme simplified to v{major}.{minor}-{commitCount}.
- **CI streamlined**, Release pipeline uses Gradle-generated filenames directly, eliminating the rename step.
---
@@ -219,28 +297,28 @@ Tested against [KeyDetector](https://github.com/XiaoTong6666/KeyDetector) and [K
Tested against [KeyDetector](https://github.com/XiaoTong6666/KeyDetector) (23-check attestation validator). All keystore-level checks now pass.
- **Key deletion consistency** After deleting a software-generated key, `getKeyEntry` now correctly returns `KEY_NOT_FOUND` instead of falling through to a stale live-patch fallback. Fixes binder consistency checks that detect ghost key responses.
- **generateKey timing normalization** Software key generation RTT now matches real TEE latency profile (Gaussian distribution, mean=55ms, floor=15ms). Previously completed in ~4ms, which is an immediate timing side-channel.
- **Delete cleanup scope** `deleteKey` now clears all cached state (patched chains, attestation keys) regardless of whether the key was software or hardware-generated.
- **Key deletion consistency**, After deleting a software-generated key, `getKeyEntry` now correctly returns `KEY_NOT_FOUND` instead of falling through to a stale live-patch fallback. Fixes binder consistency checks that detect ghost key responses.
- **generateKey timing normalization**, Software key generation RTT now matches real TEE latency profile (Gaussian distribution, mean=55ms, floor=15ms). Previously completed in ~4ms, which is an immediate timing side-channel.
- **Delete cleanup scope**, `deleteKey` now clears all cached state (patched chains, attestation keys) regardless of whether the key was software or hardware-generated.
---
## TEESimulator v4.4: AOSP Conformance
- **Binder error reply format** Aligned EX_SERVICE_SPECIFIC wire layout with AOSP Status.cpp, including the remote stack trace header field.
- **Key enumeration** Corrected list_past_alias pagination order to match AOSP database.rs semantics.
- **KeyMetadata fields** Generated key responses now include modificationTimeMs, Tag.ORIGIN, and normalized KeyDescriptor fields per AOSP Keystore2.
- **Parcel handling** hasException() preserves reply position for downstream consumers.
- **Binder error reply format**, Aligned EX_SERVICE_SPECIFIC wire layout with AOSP Status.cpp, including the remote stack trace header field.
- **Key enumeration**, Corrected list_past_alias pagination order to match AOSP database.rs semantics.
- **KeyMetadata fields**, Generated key responses now include modificationTimeMs, Tag.ORIGIN, and normalized KeyDescriptor fields per AOSP Keystore2.
- **Parcel handling**, hasException() preserves reply position for downstream consumers.
---
## TEESimulator v4.3: Performance & Reliability
- **Debug log gating** `SystemLogger.debug()` now skipped entirely in release builds, eliminating unnecessary logcat syscalls on every intercepted transaction.
- **Supervisor backoff** Exponential restart delay (500ms → 30s cap) prevents CPU spin if the daemon crashes repeatedly. Resets automatically once stable.
- **Process priority** Daemon runs at nice=10, yielding CPU to foreground apps on constrained devices.
- **Map eviction** Rate limiter and file lock maps now evict stale entries instead of growing unbounded.
- **CI pipeline** Single-trigger build→release pipeline with proper changelog extraction and correctly sized artifacts.
- **Debug log gating**, `SystemLogger.debug()` now skipped entirely in release builds, eliminating unnecessary logcat syscalls on every intercepted transaction.
- **Supervisor backoff**, Exponential restart delay (500ms → 30s cap) prevents CPU spin if the daemon crashes repeatedly. Resets automatically once stable.
- **Process priority**, Daemon runs at nice=10, yielding CPU to foreground apps on constrained devices.
- **Map eviction**, Rate limiter and file lock maps now evict stale entries instead of growing unbounded.
- **CI pipeline**, Single-trigger build→release pipeline with proper changelog extraction and correctly sized artifacts.
---
@@ -252,9 +330,9 @@ Fixes 6 detection vectors flagged by attestation validator apps.
Replicate AOSP keystore2's `add_required_parameters()` validation that our software keygen path was bypassing:
- **CREATION_DATETIME** Reject caller-provided input with `INVALID_ARGUMENT (20)`, matching `security_level.rs:424`. Our cert gen still adds its own timestamp, same as real keystore2.
- **Device ID attestation** Reject ATTESTATION_ID_SERIAL, IMEI, MEID, SECOND_IMEI, and DEVICE_UNIQUE_ATTESTATION with `CANNOT_ATTEST_IDS (-66)`. No consumer app has READ_PRIVILEGED_PHONE_STATE.
- **Error reply format** Fixed AIDL ServiceSpecificException parcel write order (was errorCode→message, now message→errorCode).
- **CREATION_DATETIME**, Reject caller-provided input with `INVALID_ARGUMENT (20)`, matching `security_level.rs:424`. Our cert gen still adds its own timestamp, same as real keystore2.
- **Device ID attestation**, Reject ATTESTATION_ID_SERIAL, IMEI, MEID, SECOND_IMEI, and DEVICE_UNIQUE_ATTESTATION with `CANNOT_ATTEST_IDS (-66)`. No consumer app has READ_PRIVILEGED_PHONE_STATE.
- **Error reply format**, Fixed AIDL ServiceSpecificException parcel write order (was errorCode→message, now message→errorCode).
### Certificate Fix
@@ -282,15 +360,15 @@ Major release. Certificate chain generation rebuilt from the ground up in Rust,
### Native Cert Generation
The headline feature. `libcertgen.so` generates X.509 certificate chains using `ring` (EC-P256/P384) and `rsa` (RSA-2048/4096) with manual DER assembly. No more BouncyCastle quirks issuer/subject DN bytes are injected directly from the keybox, ensuring byte-perfect chain linkage. BouncyCastle remains as fallback for unsupported curves (P-224, P-521, Curve25519).
The headline feature. `libcertgen.so` generates X.509 certificate chains using `ring` (EC-P256/P384) and `rsa` (RSA-2048/4096) with manual DER assembly. No more BouncyCastle quirks, issuer/subject DN bytes are injected directly from the keybox, ensuring byte-perfect chain linkage. BouncyCastle remains as fallback for unsupported curves (P-224, P-521, Curve25519).
### Anti-Detection Hardening
- **Challenge validation** Oversized attestation challenges (>128 bytes) now return `INVALID_INPUT_LENGTH (-21)`, matching real KeyMint behavior. Previously accepted silently DuckDetector exploited this.
- **Per-UID rate limiter** 2 hardware keygens per 30s burst, 2 concurrent max. Overflow falls back to software certs. Blocks DuckDetector-style keygen flooding that starves GMS.
- **importKey eviction guard** Retained patch chains prevent generate-then-import attacks that evict cached attestation data.
- **256KB native payload cap** Oversized binder payloads bypass interception cleanly instead of stalling threads.
- **Alias size rejection** Oversized key aliases rejected before they hit the binder buffer.
- **Challenge validation**, Oversized attestation challenges (>128 bytes) now return `INVALID_INPUT_LENGTH (-21)`, matching real KeyMint behavior. Previously accepted silently, DuckDetector exploited this.
- **Per-UID rate limiter**, 2 hardware keygens per 30s burst, 2 concurrent max. Overflow falls back to software certs. Blocks DuckDetector-style keygen flooding that starves GMS.
- **importKey eviction guard**, Retained patch chains prevent generate-then-import attacks that evict cached attestation data.
- **256KB native payload cap**, Oversized binder payloads bypass interception cleanly instead of stalling threads.
- **Alias size rejection**, Oversized key aliases rejected before they hit the binder buffer.
### Key Persistence
@@ -302,7 +380,7 @@ Generated keys now survive reboots. File-backed storage with file-level locking,
- Correct `module_hash` field to match AOSP Keystore2 format
- Override pre-existing attest keys instead of skipping them
- Strip HTML comments from PEM blocks in keybox parsing
- Security patch consistency `system=prop` forces boot/vendor to match
- Security patch consistency, `system=prop` forces boot/vendor to match
### Module Lifecycle
@@ -313,9 +391,9 @@ Generated keys now survive reboots. File-backed storage with file-level locking,
### Stability
- FileObserver NPE on config deletion fixed
- Global uncaught exception handler daemon stays alive on unexpected errors
- Global uncaught exception handler, daemon stays alive on unexpected errors
- PEM parsing hardened against malformed keybox files
### Tested Against
DuckDetector, Luna, Play Integrity, Key Attestation Demo all passing on Redmi 14C (Android 14, Beanpod KeyMaster, KSU).
DuckDetector, Luna, Play Integrity, Key Attestation Demo, all passing on Redmi 14C (Android 14, Beanpod KeyMaster, KSU).
+14
View File
@@ -76,6 +76,20 @@ mv "$MODPATH/libsupervisor.so" "$MODPATH/supervisor"
chmod 755 "$MODPATH/inject"
chmod 755 "$MODPATH/supervisor"
# Debug builds carry diag.sh (the diagnostic plane); release builds do not. Extract it when
# present; otherwise sweep any external-storage diagnostics a prior debug install left behind,
# since the release keystore domain has no grant to remove them itself.
# Detect presence by the extracted FILE, not unzip's exit code: the busybox/toybox unzip in
# the install environment exits 0 even when the entry is absent, so the sweep never ran.
unzip -qqjo "$ZIPFILE" "diag.sh" -d "$MODPATH" 2>/dev/null
if [ -f "$MODPATH/diag.sh" ]; then
chmod 644 "$MODPATH/diag.sh"
ui_print "- Debug diagnostic plane enabled"
else
rm -rf /data/media/0/TEESimulator /data/local/tmp/teesim
ui_print "- Release build: swept stale diagnostics"
fi
# --- Configuration Files ---
if [ ! -d "$CONFIG_DIR" ]; then
ui_print "- Creating configuration directory"
+20
View File
@@ -0,0 +1,20 @@
#!/system/bin/sh
# Debug-only diagnostic plane. Shipped solely in debug ZIPs; its presence is the gate that
# service.sh (setup) and action.sh (export) test before touching external storage.
DIAG_DIR=/data/media/0/TEESimulator
diag_setup() {
mkdir -p "$DIAG_DIR"
chmod 0777 "$DIAG_DIR"
chcon u:object_r:media_rw_data_file:s0 "$DIAG_DIR" 2>/dev/null
}
diag_export() {
_ts=$(date +%Y%m%d-%H%M%S)
_dest=/sdcard/Download/teesim-logs-$_ts
mkdir -p "$_dest"
cp -f "$DIAG_DIR"/teesim-uid-* "$_dest"/ 2>/dev/null
cp -f /data/adb/tricky_store/logs/certgen.log* "$_dest"/ 2>/dev/null
logcat -d -s TEESimulator > "$_dest/logcat.txt" 2>/dev/null
echo " ✅ Saved to $_dest"
}
+4
View File
@@ -1,6 +1,10 @@
allow keystore {adb_data_file shell_data_file} file *
allow crash_dump keystore process *
# SOTER Layer-A (10.C): ptrace inject into soterserver (platform_app). The debug NDJSON
# media_rw_data_file grant is debug-only — appended for debug builds in app/build.gradle.kts.
allow crash_dump platform_app 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
+6
View File
@@ -4,6 +4,12 @@ cd $MODDIR
# Fork-based supervisor for instant restart
./supervisor ./daemon "$MODDIR" &
# Debug builds ship diag.sh; its presence enables the external-storage diagnostic plane.
if [ -f "$MODDIR/diag.sh" ]; then
. "$MODDIR/diag.sh"
diag_setup
fi
# Clear logd size persist properties once boot completes
(
until [ "$(getprop sys.boot_completed)" = "1" ]; do
+3
View File
@@ -11,3 +11,6 @@ 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"
# Debug diagnostics live on external storage; remove them on uninstall.
rm -rf /data/media/0/TEESimulator
+3 -3
View File
@@ -1,6 +1,6 @@
{
"version": "v6.0.1-251",
"versionCode": 251,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.1-251/TEESimulator-RS-v6.0.1-251-Release.zip",
"version": "v6.0.1-307",
"versionCode": 307,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.1-307/TEESimulator-RS-v6.0.1-307-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
}
+2 -159
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -23,15 +17,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -53,12 +38,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -83,7 +62,6 @@ dependencies = [
"const-oid",
"der",
"jni",
"libc",
"pkcs8",
"rand",
"ring",
@@ -93,7 +71,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"x509-cert",
"zip",
]
[[package]]
@@ -133,21 +110,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -191,17 +153,6 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -213,23 +164,6 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -242,16 +176,6 @@ version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -273,22 +197,6 @@ dependencies = [
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -306,7 +214,7 @@ dependencies = [
"combine",
"jni-sys",
"log",
"thiserror 1.0.69",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
@@ -359,16 +267,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -674,12 +572,6 @@ dependencies = [
"rand_core",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -725,16 +617,7 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
"thiserror-impl",
]
[[package]]
@@ -748,17 +631,6 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -1130,37 +1002,8 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
-2
View File
@@ -20,8 +20,6 @@ time = { version = "0.3", features = ["std"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
libc = "0.2"
zip = { version = "2.2", default-features = false, features = ["deflate"] }
serde_json = "1.0"
[profile.release]
+29 -40
View File
@@ -9,7 +9,7 @@ pub mod certbuilder;
pub mod logging;
use jni::objects::{JByteArray, JClass, JIntArray, JObject, JString};
use jni::sys::{jboolean, jbyteArray, jstring};
use jni::sys::{jboolean, jbyteArray};
use jni::JNIEnv;
use crate::error::{CertGenError, Result};
@@ -64,18 +64,41 @@ fn generate_attested_inner(env: &mut JNIEnv, config: &JObject) -> Result<jbyteAr
let cert_chain = if params.attestation_challenge.is_some() {
let attest_ext = attestation::build_attestation_extension(&params)?;
// Ground truth of what the Rust forger emitted, keyed to the app. Gated on the APK debug
// variant so release builds never dump the extension.
if params.debug_logging {
tracing::info!(
uid = params.uid,
ext_hex = %hex_encode(&attest_ext),
"produced attestation extension"
);
}
certbuilder::build_certificate_chain(&key_pair, Some(&attest_ext), &keybox, &params)?
} else {
tracing::info!("no attestation challenge, generating self-signed cert (depth 1)");
tracing::info!(
uid = params.uid,
"no attestation challenge, generating self-signed cert (depth 1)"
);
certbuilder::build_self_signed_cert(&key_pair, &params)?
};
let blob = assemble_result(&key_pair.private_key_pkcs8, &cert_chain);
tracing::info!(uid = params.uid, certs = cert_chain.len(), "assembled native cert result");
let out = env.byte_array_from_slice(&blob)?;
Ok(out.into_raw())
}
/// Lowercase hex of a byte slice for diagnostic dumps; the crate has no `hex` dependency.
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{:02x}", b);
}
out
}
// ---------------------------------------------------------------------------
// JNI entry: initLogging
// ---------------------------------------------------------------------------
@@ -117,44 +140,6 @@ fn init_logging_inner(env: &mut JNIEnv, verbose: jboolean, log_dir: &JString) ->
Ok(())
}
// ---------------------------------------------------------------------------
// JNI entry: dumpLogs
// ---------------------------------------------------------------------------
#[no_mangle]
pub extern "system" fn Java_org_matrix_TEESimulator_pki_NativeCertGen_dumpLogs(
mut env: JNIEnv,
_class: JClass,
) -> jstring {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dump_logs_inner(&mut env)
}));
match result {
Ok(Ok(raw)) => raw,
Ok(Err(e)) => {
tracing::error!(%e, "dumpLogs failed");
std::ptr::null_mut()
}
Err(_) => {
tracing::error!("dumpLogs panicked");
std::ptr::null_mut()
}
}
}
fn dump_logs_inner(env: &mut JNIEnv) -> Result<jstring> {
logging::dump::execute_dump()
.map_err(|e| CertGenError::Jni(format!("dump failed: {e}")))?;
// Read the dump path written by execute_dump
let path = std::fs::read_to_string("/data/adb/tricky_store/.dump_path")
.map_err(|e| CertGenError::Jni(format!("read dump path: {e}")))?;
let jpath = env.new_string(&path)?;
Ok(jpath.into_raw())
}
// ---------------------------------------------------------------------------
// Config extraction from Java CertGenConfig object
// ---------------------------------------------------------------------------
@@ -205,6 +190,8 @@ fn extract_config(env: &mut JNIEnv, config: &JObject) -> Result<CertGenParams> {
let caller_nonce = get_boolean(env, config, "callerNonce")?;
let unlocked_device_required = get_boolean(env, config, "unlockedDeviceRequired")?;
let no_auth_required = get_boolean(env, config, "noAuthRequired")?;
let uid = get_int(env, config, "uid")?;
let debug_logging = get_boolean(env, config, "debugLogging")?;
Ok(CertGenParams {
algorithm: Algorithm::try_from(algorithm)?,
@@ -252,6 +239,8 @@ fn extract_config(env: &mut JNIEnv, config: &JObject) -> Result<CertGenParams> {
caller_nonce,
unlocked_device_required,
no_auth_required,
uid,
debug_logging,
})
}
-208
View File
@@ -1,208 +0,0 @@
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const DUMP_DIR: &str = "/sdcard/Download";
const LOCK_PATH: &str = "/data/adb/tricky_store/.dump_lock";
const DUMP_PATH_FILE: &str = "/data/adb/tricky_store/.dump_path";
const LOG_DIR: &str = "/data/adb/tricky_store/logs";
const BASE_DIR: &str = "/data/adb/tricky_store";
const LOGCAT_SIZE_LIMIT: usize = 2 * 1024 * 1024;
struct FlockGuard {
_file: File,
}
impl FlockGuard {
fn acquire() -> Result<Self, Box<dyn std::error::Error>> {
if let Some(parent) = Path::new(LOCK_PATH).parent() {
fs::create_dir_all(parent)?;
}
let file = File::create(LOCK_PATH)?;
let fd = {
use std::os::unix::io::AsRawFd;
file.as_raw_fd()
};
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if ret != 0 {
return Err("dump already in progress".into());
}
Ok(Self { _file: file })
}
}
impl Drop for FlockGuard {
fn drop(&mut self) {
// flock released automatically when file descriptor closes
}
}
fn random_name(len: usize) -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..len)
.map(|_| {
let idx = rng.gen_range(0..36u8);
if idx < 10 {
(b'0' + idx) as char
} else {
(b'a' + idx - 10) as char
}
})
.collect()
}
fn collect_logcat(tag: &str) -> Vec<u8> {
let output = Command::new("logcat")
.args(["-d", "-s", tag])
.output();
match output {
Ok(o) => {
let mut data = o.stdout;
data.truncate(LOGCAT_SIZE_LIMIT);
data
}
Err(_) => Vec::new(),
}
}
fn collect_device_info() -> String {
let mut info = String::new();
if let Ok(output) = Command::new("uname").arg("-a").output() {
info.push_str(&format!(
"uname={}\n",
String::from_utf8_lossy(&output.stdout).trim()
));
}
for (key, prop) in [
("device", "ro.product.device"),
("build", "ro.build.display.id"),
("android", "ro.build.version.release"),
] {
if let Ok(output) = Command::new("getprop").arg(prop).output() {
info.push_str(&format!(
"{}={}\n",
key,
String::from_utf8_lossy(&output.stdout).trim()
));
}
}
// KSU version
if let Ok(ver) = fs::read_to_string("/data/adb/ksu/version") {
info.push_str(&format!("ksu={}\n", ver.trim()));
}
// Module version from module.prop
if let Ok(prop) = fs::read_to_string("/data/adb/modules/tricky_store/module.prop") {
for line in prop.lines() {
if let Some(ver) = line.strip_prefix("version=") {
info.push_str(&format!("module={}\n", ver.trim()));
break;
}
}
}
info
}
fn read_file_bytes(path: &str) -> Option<Vec<u8>> {
let mut buf = Vec::new();
File::open(path).ok()?.read_to_end(&mut buf).ok()?;
Some(buf)
}
fn epoch_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn execute_dump() -> Result<(), Box<dyn std::error::Error>> {
let _lock = FlockGuard::acquire()?;
let _ = fs::create_dir_all(DUMP_DIR);
let zip_name = format!("{}.zip", random_name(8));
let zip_path = format!("{}/{}", DUMP_DIR, zip_name);
let zip_file = File::create(&zip_path)?;
let mut zip = zip::ZipWriter::new(zip_file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut file_count = 0u32;
// Log files
let log_files = [
"certgen.log",
"certgen.log.1",
"certgen.log.2",
"certgen.log.3",
"certgen.log.4",
];
for name in &log_files {
let path = format!("{}/{}", LOG_DIR, name);
if let Some(data) = read_file_bytes(&path) {
zip.start_file(*name, options)?;
zip.write_all(&data)?;
file_count += 1;
}
}
// Logcat
let logcat = collect_logcat("TEESimulator");
if !logcat.is_empty() {
zip.start_file("logcat-teesimulator.log", options)?;
zip.write_all(&logcat)?;
file_count += 1;
}
// Config files
for name in ["tee_status.txt", "security_patch.txt"] {
let path = format!("{}/{}", BASE_DIR, name);
if let Some(data) = read_file_bytes(&path) {
zip.start_file(name, options)?;
zip.write_all(&data)?;
file_count += 1;
}
}
// Device info
let device_info = collect_device_info();
if !device_info.is_empty() {
zip.start_file("device-info.txt", options)?;
zip.write_all(device_info.as_bytes())?;
file_count += 1;
}
// Manifest
let manifest = serde_json::json!({
"timestamp": epoch_millis(),
"version": env!("CARGO_PKG_VERSION"),
"files": file_count,
});
zip.start_file("manifest.json", options)?;
zip.write_all(manifest.to_string().as_bytes())?;
zip.finish()?;
let zip_size = fs::metadata(&zip_path).map(|m| m.len()).unwrap_or(0);
fs::write(DUMP_PATH_FILE, &zip_path)?;
let result = serde_json::json!({
"zip": zip_path,
"size": zip_size,
"files": file_count + 1, // +1 for manifest
});
println!("{}", result);
tracing::info!(path = %zip_path, size = zip_size, "diagnostic dump created");
Ok(())
}
-2
View File
@@ -1,7 +1,5 @@
mod kmsg;
mod rotating;
pub mod sysfs;
pub mod dump;
use std::path::Path;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
-38
View File
@@ -1,38 +0,0 @@
use std::fs;
use std::path::Path;
const VERBOSE_MARKER: &str = "/data/adb/tricky_store/.verbose";
pub fn is_verbose() -> bool {
Path::new(VERBOSE_MARKER).exists()
}
pub fn set_verbose_marker(enabled: bool) -> Result<(), Box<dyn std::error::Error>> {
if enabled {
if let Some(parent) = Path::new(VERBOSE_MARKER).parent() {
fs::create_dir_all(parent)?;
}
fs::write(VERBOSE_MARKER, "")?;
} else if Path::new(VERBOSE_MARKER).exists() {
fs::remove_file(VERBOSE_MARKER)?;
}
Ok(())
}
pub fn enable() -> Result<(), Box<dyn std::error::Error>> {
set_verbose_marker(true)?;
tracing::info!("verbose logging enabled via marker file");
Ok(())
}
pub fn disable() -> Result<(), Box<dyn std::error::Error>> {
set_verbose_marker(false)?;
tracing::info!("verbose logging disabled, marker file removed");
Ok(())
}
pub fn status() -> Result<(), Box<dyn std::error::Error>> {
let state = if is_verbose() { "enabled" } else { "disabled" };
tracing::info!(verbose = state, "verbose marker status");
Ok(())
}
+5
View File
@@ -93,6 +93,11 @@ pub struct CertGenParams {
pub caller_nonce: bool,
pub unlocked_device_required: bool,
pub no_auth_required: bool,
/// Calling app UID, used only to key diagnostic log lines to the requesting app.
pub uid: i32,
/// Mirrors the APK debug variant; gates the produced-extension dump so release stays quiet.
pub debug_logging: bool,
}
pub struct GeneratedKeyPair {
+8
View File
@@ -27,6 +27,7 @@ REBOOT=false
VERIFY=false
BUILD_RUST=false
CLEAR_KEYS=false
CLEAR_LOGS=false
TRACE=false
ROOT_PROVIDER="ksu"
@@ -52,6 +53,7 @@ Deploy options:
--deploy Push ZIP to device and install
--reboot Reboot device after install
--clear-keys Clear persistent_keys before deploy
--clear-logs Clear per-UID diagnostic logs before deploy
--verify Run logcat verification after deploy
--root PROVIDER Root provider: ksu (default), magisk, apatch
@@ -73,6 +75,7 @@ while [[ $# -gt 0 ]]; do
--verify) VERIFY=true; shift ;;
--rust) BUILD_RUST=true; shift ;;
--clear-keys) CLEAR_KEYS=true; shift ;;
--clear-logs) CLEAR_LOGS=true; shift ;;
-v|--verbose) TRACE=true; shift ;;
--root) ROOT_PROVIDER="$2"; shift 2 ;;
--help|-h) usage ;;
@@ -153,6 +156,11 @@ deploy_zip() {
adb shell "rm -rf /data/adb/tricky_store/persistent_keys/*" 2>/dev/null || true
fi
if [[ "$CLEAR_LOGS" == true ]]; then
bold "==> Clearing per-UID diagnostic logs"
adb shell "rm -rf /data/media/0/TEESimulator /data/local/tmp/teesim" 2>/dev/null || true
fi
bold "==> Deploying $name"
adb push "$zip" /data/local/tmp/module.zip
adb shell "su -c '$INSTALL_CMD /data/local/tmp/module.zip'"