Compare commits

...
124 Commits
Author SHA1 Message Date
Enginex0 fb7f0ca098 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 b323f41b08 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 c46aaa34f8 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 ca86148633 Merge PR #22: persist symmetric keys + byte-identical metadata 2026-05-19 17:00:14 +01:00
Enginex0 0fbdf42e9d chore(release): bump update.json to v6.0.0-211 2026-05-19 17:00:04 +01:00
Enginex0 91ce9485fe 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 69fbdc112d 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 1ee66be05a 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 0b2c34ff8c fix(shim): restore nspace attest key lookup
Reverts 5f72acb. 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 2704eff797 fix(intercept): restore updateAad SSE injection
Reverts 22d1972. 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 22d1972bc7 fix(intercept): revert updateAad SSE injection
Reverts 59836e1. 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 5f72acb1e7 fix(shim): revert nspace attestation key lookup
Reverts d7dc5e0. 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 a7e7e45 baseline to investigate
from a clean state.
2026-05-19 14:29:29 +01:00
Enginex0 d7dc5e0b63 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 59836e143c 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 a7e7e454e7 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 bba4a9ebfa 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 58b98fd308 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 0617297b22 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 aef80c3105 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 032c87d50e 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 62d666fd63 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 39b3811dc3 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 1446090da9 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 52ff39d130 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 3dea767058 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 17b359e94d fix(interception): emit KEY_SIZE for EC keys
Revert 29b2a85. 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 57035b2c94 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 e8d12c4165 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 d048174402 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 21fb3ba879 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 ca56928add 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 521e28cece 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 372001e8de 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 6fa10d7111 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 7c58e2f039 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 6dc7755658 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 921edecb86 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 756aa2efb2 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 0ebfef55b6 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 94c7d00fb5 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 fe21106151 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 3d8d193a44 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 85eef8054d 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 051a003b33 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 29b2a85e9f 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 890f47009b 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 b85b3dea48 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 c8b3e9e528 Merge branch 'Enginex0:main' into fix/persistence-and-keystore-issues 2026-05-18 15:01:17 +02:00
Andrea-lyz a5375f7426 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 9e1b459b74 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 6476216aa3 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 bc7b11a380 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] 22cadc125e chore(release): bump update.json to v6.0.0-162 [skip ci] 2026-03-31 18:47:49 +00:00
Enginex0 5267c9dd00 ci(release): upload versioned assets only, auto-update zipUrl 2026-03-31 19:41:12 +01:00
github-actions[bot] dfc8aac920 chore(release): bump versionCode to 160 [skip ci] 2026-03-31 18:32:18 +00:00
github-actions[bot] bdb460411a chore(release): bump versionCode to 159 [skip ci] 2026-03-31 18:18:59 +00:00
Enginex0 ea792c7b78 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 18724cf40d docs(changelog): add AUTO mode banking app fix to v6.0.0 notes 2026-03-31 18:59:28 +01:00
Enginex0 1b7800345d 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 54c12a9fd5 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 1bc47840d5 chore(version): bump to v6.0.0 2026-03-26 12:28:23 +01:00
Enginex0 c8fadb07ae 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 c0b14eeeb1 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 47ab0225e1 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 ebb6336281 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 784373c8b5 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 2241bfb13d 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 954478b89b 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 191085087b 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 f870598e77 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 8fdc59a142 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 b6f9d7b486 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 99e6b0b5ea 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 397bb6338b 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 cb81116701 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 45477a7898 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 634a1293c1 docs(readme): credit MhmRdd for upstream AOSP compliance work 2026-03-19 07:37:53 +01:00
Enginex0 f115eda2dc chore(version): bump to v5.0 with changelog for AOSP compliance overhaul 2026-03-19 07:36:20 +01:00
Enginex0 217edf61fe docs: credit upstream PR #157 contributors 2026-03-19 07:33:57 +01:00
Enginex0 ee5bf2e1a7 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 2181157cb6 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 aa4917e623 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 f06cb30b40 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 03c71bd202 docs(release): add v4.8.1 changelog for StrongBox op rejection fix 2026-03-18 03:24:58 +01:00
Enginex0 7e2fc0b288 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 258a65ba59 docs(release): add v4.8 changelog for StrongBox hardening and LRU pruning 2026-03-17 19:56:45 +01:00
Enginex0 d2b8a92fbd chore(version): bump to v4.8 2026-03-17 19:48:48 +01:00
Enginex0 0723865eab 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 7cb44b9999 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 eddd9908af 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 36ccd22cdc 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 81e6fbf97e 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 7f63713f07 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 5df76eacd1 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 ca3978888e docs(release): bump to v4.7 with operation and attestation fixes changelog 2026-03-17 07:12:30 +01:00
Enginex0 023d7f929d 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 bd40f4b950 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 f8bfa0d
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 23696d2f61 ci(release): fetch full history for accurate commit count 2026-03-17 03:43:16 +01:00
Enginex0 dfacb34cf9 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-7e87766 to v4.6-117
format — commit count auto-increments, git hash dropped from filenames.
2026-03-17 03:35:32 +01:00
Enginex0 06d9db443c 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 7e87766493 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 f8bfa0dfd8 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 90ff59e0aa 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 8bdf0d59fa docs(release): bump to v4.5 with detection hardening changelog 2026-03-16 22:07:56 +01:00
Enginex0 6ab09f4889 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 f4559bcd19 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 3b5043a1bb docs(release): bump to v4.4 with AOSP conformance changelog 2026-03-16 13:26:34 +01:00
Enginex0 8001a8678a 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 (7804743), list_past_alias
enumeration filter inversion (2aac65c), and KeyMetadata alignment with
AOSP semantics — modificationTimeMs, Tag.ORIGIN, KeyDescriptor
normalization (86db5bf).

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 d21822eb9d docs(release): bump to v4.3 with changelog and update metadata 2026-03-11 13:12:03 +01:00
Enginex0 095a658996 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 70e8968e44 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 c122ded7bf 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 7b510a9915 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 8f63dda31b 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 9896df93de 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 0280bcf189 docs(readme): add build badge and building-from-source section 2026-03-11 00:28:28 +01:00
Enginex0 438a462bdf 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 32cfcb3 which wired the Rust crate into the pipeline.
2026-03-11 00:12:02 +01:00
Enginex0 40b08cd648 docs(release): bump to v4.2 with changelog and update metadata 2026-03-10 16:55:39 +01:00
Enginex0 c5ed627f68 chore(module): bump versionCode to 95 2026-03-10 16:37:58 +01:00
Enginex0 bee73eb39b 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 a0ee77202c 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 09d9896228 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 5a599025ad 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 f5c2bcc024 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
50 changed files with 3708 additions and 803 deletions
+109 -72
View File
@@ -3,16 +3,10 @@ name: Build
on:
push:
branches: [ "main" ]
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/**'
paths-ignore: [ '**.md' ]
pull_request:
branches: [ "main" ]
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/**'
paths-ignore: [ '**.md' ]
workflow_dispatch:
concurrency:
@@ -22,18 +16,9 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: read
outputs:
releaseName: ${{ steps.prepareArtifact.outputs.releaseName }}
debugName: ${{ steps.prepareArtifact.outputs.debugName }}
steps:
- name: Check out
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
submodules: "recursive"
fetch-depth: 0
@@ -45,6 +30,25 @@ jobs:
java-version: 21
cache: 'gradle'
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- name: Cache Rust artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin/cargo-ndk
native-certgen/target
key: rust-${{ runner.os }}-${{ hashFiles('native-certgen/Cargo.lock') }}
restore-keys: rust-${{ runner.os }}-
- name: Install cargo-ndk
run: command -v cargo-ndk || cargo install cargo-ndk
- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
@@ -60,73 +64,106 @@ jobs:
- name: Build with Gradle
run: |
chmod +x ./gradlew
./gradlew zipRelease zipDebug -Porg.gradle.parallel=true -Porg.gradle.vfs.watch=true -Dorg.gradle.jvmargs=-Xmx2048m
- name: Prepare artifact
if: success()
id: prepareArtifact
- name: Read version
id: ver
run: |
set -e
RELEASE_FILE=$(find out -name "*Release*.zip" | head -1)
DEBUG_FILE=$(find out -name "*Debug*.zip" | head -1)
ver=$(grep 'val verName' app/build.gradle.kts | sed 's/.*"\(.*\)".*/\1/')
count=$(git rev-list HEAD --count)
echo "version=${ver}-${count}" >> "$GITHUB_OUTPUT"
if [[ -z "$RELEASE_FILE" || -z "$DEBUG_FILE" ]]; then
echo "Error: Could not find release or debug files in out/"
echo "Contents of out/ directory:"
ls -la out/ || echo "out/ directory does not exist"
exit 1
fi
- name: List build artifacts
run: |
echo "Release: $(ls out/*Release*.zip | head -1) ($(du -h out/*Release*.zip | head -1 | cut -f1))"
echo "Debug: $(ls out/*Debug*.zip | head -1) ($(du -h out/*Debug*.zip | head -1 | cut -f1))"
# Extract names
RELEASE_NAME=$(basename "$RELEASE_FILE" .zip)
DEBUG_NAME=$(basename "$DEBUG_FILE" .zip)
echo "releaseName=$RELEASE_NAME" >> $GITHUB_OUTPUT
echo "debugName=$DEBUG_NAME" >> $GITHUB_OUTPUT
mkdir -p module-release module-debug
unzip -q "$RELEASE_FILE" -d module-release
unzip -q "$DEBUG_FILE" -d module-debug
echo " Release: $RELEASE_NAME"
echo " Debug: $DEBUG_NAME"
- name: Upload release
if: success()
id: release
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.prepareArtifact.outputs.releaseName }}
path: "./module-release/*"
name: TEESimulator-RS-release-zip
path: out/TEESimulator-RS-*-Release.zip
retention-days: 30
compression-level: 6
compression-level: 0
- name: Upload debug
if: success()
id: debug
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.prepareArtifact.outputs.debugName }}
path: "./module-debug/*"
name: TEESimulator-RS-debug-zip
path: out/TEESimulator-RS-*-Debug.zip
retention-days: 7
compression-level: 6
compression-level: 0
- name: Upload release mappings
if: success()
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
with:
name: release-mappings-${{ github.run_number }}
path: "./app/build/outputs/mapping/release"
name: release-mappings
path: app/build/outputs/mapping/release
retention-days: 30
compression-level: 9
- name: Summary
if: always()
release:
needs: build
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read version
id: ver
run: |
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "- **Gradle Tasks**: assembleRelease, assembleDebug" >> $GITHUB_STEP_SUMMARY
if [[ "${{ job.status }}" == "success" ]]; then
echo "- **Release Artifact**: ${{ steps.prepareArtifact.outputs.releaseName }}" >> $GITHUB_STEP_SUMMARY
echo "- **Debug Artifact**: ${{ steps.prepareArtifact.outputs.debugName }}" >> $GITHUB_STEP_SUMMARY
fi
ver=$(grep 'val verName' app/build.gradle.kts | sed 's/.*"\(.*\)".*/\1/')
count=$(git rev-list HEAD --count)
echo "version=${ver}-${count}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v4
with:
name: TEESimulator-RS-release-zip
path: zips
- uses: actions/download-artifact@v4
with:
name: TEESimulator-RS-debug-zip
path: zips
- name: Extract changelog
run: |
ver="${VER#v}"
awk "/^## TEESimulator-RS v${ver%%-*}/{flag=1; next} /^## TEESimulator-RS v/{if(flag) exit} flag" module/changelog.md > /tmp/notes.md
cat /tmp/notes.md
env:
VER: ${{ steps.ver.outputs.version }}
- name: Create release
run: |
gh release delete "$VER" --yes 2>/dev/null || true
RELEASE=$(ls zips/*Release*.zip | head -1)
DEBUG=$(ls zips/*Debug*.zip | head -1)
gh release create "$VER" \
--title "$VER" \
--latest \
--notes-file /tmp/notes.md \
"$RELEASE" \
"$DEBUG"
env:
VER: ${{ steps.ver.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Bump update.json
run: |
COUNT=$(git rev-list HEAD --count)
RELEASE_NAME=$(basename zips/*Release*.zip)
ZIP_URL="https://github.com/${{ github.repository }}/releases/download/${VER}/${RELEASE_NAME}"
jq ".versionCode = $COUNT | .zipUrl = \"$ZIP_URL\"" module/update.json > /tmp/update.json
mv /tmp/update.json module/update.json
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add module/update.json
git diff --cached --quiet || {
git commit -m "chore(release): bump update.json to $VER [skip ci]"
git push origin HEAD:main
}
env:
VER: ${{ steps.ver.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6
View File
@@ -1 +1,7 @@
out
.gradle
.kotlin
app/build
build
native-certgen/target
app/src/main/jniLibs
+74 -172
View File
@@ -1,237 +1,139 @@
<p align="center">
<h1 align="center">🔐 TEESimulator</h1>
<h1 align="center">TEESimulator-RS</h1>
<p align="center"><b>Full TEE Emulation for Rooted Android</b></p>
<p align="center">Hardware attestation. Software keys. Zero detection.</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v4.0-blue?style=for-the-badge" alt="v4.0">
<img src="https://img.shields.io/badge/Android-10%2B-green?style=for-the-badge&logo=android" alt="Android 10+">
<img src="https://img.shields.io/badge/Telegram-community-blue?style=for-the-badge&logo=telegram" alt="Telegram">
<a href="https://github.com/Enginex0/TEESimulator-RS/actions/workflows/build.yml"><img src="https://github.com/Enginex0/TEESimulator-RS/actions/workflows/build.yml/badge.svg" alt="Build"></a>
<img src="https://img.shields.io/badge/Android-10%2B-green?logo=android" alt="Android 10+">
<a href="https://t.me/superpowers9"><img src="https://img.shields.io/badge/Telegram-community-blue?logo=telegram" alt="Telegram"></a>
</p>
</p>
---
> [!NOTE]
> **This is a personal fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator)** with additional hardening, native Rust certificate generation, key persistence, and anti-detection features. For the upstream project, see the original repo.
> Fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator) with native Rust certificate generation, key persistence, and AOSP-compliant attestation behavior. For the upstream project, see the original repo.
---
## What It Does
## 🧬 What is TEESimulator?
TEESimulator intercepts Binder IPC at the `ioctl` level inside the `keystore2` process and generates entire certificate chains from scratch, signed by your keybox, with correct attestation extensions. Apps that verify hardware attestation see a legitimate device.
TEESimulator is a **complete software simulation** of Android's hardware-backed [Trusted Execution Environment](https://source.android.com/docs/security/features/trusty) for [Key Attestation](https://developer.android.com/privacy-and-security/security-key-attestation). Instead of patching certificates from the real TEE after the fact, TEESimulator intercepts Binder IPC at the `ioctl` level and generates entire certificate chains from scratch — signed by your keybox, with correct attestation extensions, indistinguishable from hardware-generated keys.
This is not TrickyStore. TEESimulator replaces TrickyStore and its forks entirely. It shares the same config paths for drop-in compatibility, but the internals are different: native Rust cert generation, binder-level interception via `lsplt`, per-UID rate limiting, key persistence, and AOSP-spec attestation behavior.
The result: **apps that verify hardware attestation see a legitimate, unmodified device** — even on rooted hardware with an unlocked bootloader.
> **This is not TrickyStore.** TEESimulator replaces TrickyStore and its forks entirely. It shares the same config paths for drop-in compatibility, but the architecture is fundamentally different: native Rust certificate generation, binder-level interception via `lsplt`, per-UID rate limiting, key persistence, and a multi-layer defense against detector apps.
---
## 🔥 Why TEESimulator?
🔐 **Native Cert Generation** — v4.0 generates X.509 certificate chains in Rust with `ring` and manual DER encoding. No BouncyCastle overhead, no Java crypto quirks, byte-perfect issuer chain linkage.
🎯 **Binder-Level Interception** — Hooks `ioctl()` on `libc.so` via `lsplt` inside the `keystore2` process. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions before the HAL ever sees them.
🛡️ **Detector Resistant** — Per-UID rate limiting blocks DuckDetector-style keygen flooding. Oversized challenges rejected with real KeyMint error codes. Chain consistency verified byte-for-byte.
💾 **Key Persistence** — Generated keys survive reboots. Apps that store attestation keys (banking, biometrics) don't break after a restart.
🔧 **Drop-In Replacement** — Same config paths as TrickyStore (`/data/adb/tricky_store/`). Swap the module ZIP, keep your keybox and target list.
---
## ✨ Features
**Core Attestation Engine**
- [x] **Full certificate chain generation** — leaf + intermediates + root, signed by your keybox
- [x] **Native Rust certgen**`libcertgen.so` built with `ring`, `rsa`, and manual DER assembly
- [x] **BouncyCastle fallback** — unsupported curves (P-224, P-521, Curve25519) fall back to Java
- [x] **ASN.1 attestation extensions** — OID 1.3.6.1.4.1.11129.2.1.17 with all AOSP-specified tags
- [x] **Multi-keybox support** — different keybox files per app group via `target.txt`
**Interception Layer**
- [x] **Binder ioctl hook**`lsplt` PLT hook on `libc.so` inside `keystore2` process
- [x] **generateKey / importKey / getKeyEntry** — all three transaction types intercepted
- [x] **256KB native payload cap** — oversized binder payloads bypass interception cleanly
- [x] **Challenge validation** — rejects >128-byte attestation challenges with `INVALID_INPUT_LENGTH`
**Hardening**
- [x] **Per-UID rate limiter** — 2 hardware keygens per 30s burst window, software fallback on overflow
- [x] **importKey eviction guard** — retained patch chains prevent generate-then-import cache attacks
- [x] **Key persistence** — file-backed storage with file-level locking, survives reboots and keybox rotations
- [x] **Global exception handler** — uncaught exceptions logged, daemon stays alive
**Configuration**
- [x] **Live config reload**`FileObserver` watches all config files, changes apply immediately
- [x] **Security patch spoofing** — per-package `system`, `vendor`, `boot` patch levels with dynamic templates
- [x] **Lifecycle scripts** — KSU Action button clears key cache, uninstall removes all traces
---
## 📋 Requirements
## Requirements
> [!IMPORTANT]
> TEESimulator requires root access and a valid `keybox.xml` for hardware-level attestation results. Without a keybox, the module generates software-level certificates that won't pass strict hardware attestation checks.
> A valid `keybox.xml` is required for hardware-level attestation. Without one, the module generates software-level certificates that won't pass strict hardware checks.
**You need:**
1. Android 10 or above
2. A supported root manager (KernelSU, Magisk, or APatch)
3. A hardware-backed `keybox.xml` placed at `/data/adb/tricky_store/keybox.xml`
1. Android 10+
2. Root manager: KernelSU, Magisk, or APatch
3. `keybox.xml` at `/data/adb/tricky_store/keybox.xml`
---
## Quick Start
## 📱 Compatibility
1. Download the latest ZIP from [Releases](https://github.com/Enginex0/TEESimulator-RS/releases)
2. Install via your root manager and reboot
3. Place your keybox at `/data/adb/tricky_store/keybox.xml`
4. Configure targets in `/data/adb/tricky_store/target.txt`
5. Verify with Play Integrity or Key Attestation Demo
### Root Managers
## Architecture
| Manager | Status | Notes |
|---|---|---|
| KernelSU | ✅ Tested | Full support including Action button and lifecycle scripts |
| Magisk | ✅ Supported | Standard module install |
| APatch | ✅ Supported | Standard module install |
**Native Cert Generation**`libcertgen.so` generates X.509 chains in Rust using `ring` and manual DER encoding. BouncyCastle fallback for unsupported curves (P-224, P-521, Curve25519).
### Tested Devices
**Binder Interception** — PLT hook on `ioctl()` in `libc.so` via `lsplt` inside `keystore2`. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions.
| Device | Android | TEE | Status |
|---|---|---|---|
| Redmi 14C (2409BRN2CA) | 14 (SDK 34) | Beanpod KeyMaster | ✅ Daily driver |
**AOSP Compliance** — Self-signed certs for non-attested keys (matching `ta/src/keys.rs`), correct AuthorizationList tag ordering, version-guarded extension fields, `authorize_create` enforcement.
> Tested against DuckDetector, Luna, Play Integrity, and Key Attestation Demo. If you test on a different device, [open an issue](https://github.com/Enginex0/TEESimulator/issues) with your results.
**Key Persistence** — Generated keys survive reboots. File-backed with file-level locking.
---
**Rate Limiting** — Per-UID hardware keygen cap (2/30s window, 2 concurrent). Overflow falls to software certs.
## 🚀 Quick Start
## Configuration
1. **Download** the latest release ZIP from [Releases](https://github.com/Enginex0/TEESimulator/releases)
2. **Install** via your root manager (KSU / Magisk / APatch) and reboot
3. **Place your keybox** at `/data/adb/tricky_store/keybox.xml`
4. **Configure targets** in `/data/adb/tricky_store/target.txt`
5. **Verify** — check Play Integrity or run Key Attestation Demo
All config files live at `/data/adb/tricky_store/` and are hot-reloaded via `FileObserver`.
TEESimulator replaces TrickyStore, TrickyStoreOSS, and their forks. Existing config files are compatible.
### target.txt
---
Controls which apps get intercepted and the simulation mode.
## ⚙️ Configuration
| Suffix | Mode |
|--------|------|
| `!` | Force software key generation |
| `?` | Force leaf certificate patching (real TEE key, patched cert) |
| *(none)* | Automatic selection |
All configuration files live at `/data/adb/tricky_store/` and are monitored by `FileObserver` — changes take effect immediately without rebooting.
### The `keybox.xml` Root of Trust
This file provides the master cryptographic identity. It contains a private key and a hardware-backed certificate chain from a real device. TEESimulator signs all generated certificates with this key, making them appear legitimate to verifiers.
```xml
<?xml version="1.0"?>
<AndroidAttestation>
<Keybox DeviceID="...">
<Key algorithm="ecdsa|rsa">
<PrivateKey format="pem">...</PrivateKey>
<CertificateChain>...</CertificateChain>
</Key>
</Keybox>
</AndroidAttestation>
```
### Target Packages (`target.txt`)
Controls which apps get intercepted and what simulation mode to use.
#### Mode Suffixes
* **`!` → Force Generation** — Creates a complete software-based virtual key. Full TEE simulation.
* **`?` → Force Leaf Hacking** — Real TEE key generated, but its attestation certificate is intercepted and patched.
* **No symbol → Automatic** — Module selects the best mode for your device.
#### Multi-Keybox
Specify different keybox files for different app groups. Apps listed after a `[filename.xml]` line use that keybox. Apps before any declaration use the default `keybox.xml`.
Multi-keybox support via `[filename.xml]` headers:
```
# Default keybox
com.google.android.gms!
io.github.vvb2060.keyattestation?
# Switch to a different keybox for the following apps
[aosp_keybox.xml]
com.google.android.gsf
# Another keybox
[demo_keybox.xml]
org.matrix.demo
```
### Security Patch Level (`security_patch.txt`)
### security_patch.txt
Configure the `osPatchLevel`, `vendorPatchLevel`, and `bootPatchLevel` reported in attestation certificates. This only affects attestation data — it does not change actual system properties.
#### Global and Per-Package
Settings at the top of the file are global defaults. Add `[package.name]` to override for specific apps.
#### Keys
Override patch levels reported in attestation certificates. Global defaults at top, per-package overrides with `[package.name]`.
| Key | Scope |
|---|---|
|-----|-------|
| `system` | OS patch level |
| `vendor` | Vendor patch level |
| `boot` | Boot/kernel patch level |
| `all` | Shorthand — sets all three at once |
| `all` | Sets all three |
#### Special Keywords
| Keyword | Effect |
|---|---|
| `today` | Current date, dynamically resolved on each attestation |
| `YYYY-MM-DD` templates | Semi-dynamic — `YYYY-MM-05` resolves to the 5th of the current month |
| `no` | Omit this patch level tag entirely from the attestation |
| `device_default` | Use the device's real hardware value |
| `prop` | Read from `ro.build.version.security_patch` (matches what detectors see via getprop) |
#### Example
Special values: `today`, `YYYY-MM-DD` templates, `no` (omit tag), `device_default`, `prop` (read from system property).
```
# Global — default for all apps
system=YYYY-MM-05
vendor=device_default
boot=no
# Override for GMS
[com.google.android.gms]
system=2024-10-01
# Custom config for a demo app
[org.matrix.demo]
all=2025-09-15
boot=device_default
system=2025-10-01
```
---
## Building from Source
## 💬 Community
Prerequisites: JDK 21, Android SDK/NDK 27, Rust stable with `aarch64-linux-android` target, `cargo-ndk`.
```bash
git clone --recursive https://github.com/Enginex0/TEESimulator-RS.git
cd TEESimulator-RS
./gradlew zipRelease zipDebug
```
Output ZIPs in `out/`. Gradle invokes `cargo ndk` automatically to cross-compile `libcertgen.so`.
Push to `main` or use **Actions > Build > Run workflow** to trigger CI.
## Compatibility
| Root Manager | Status |
|---|---|
| KernelSU | Tested (Action button + lifecycle scripts) |
| Magisk | Supported |
| APatch | Supported |
## Community
<p align="center">
<a href="https://t.me/superpowers9">
<img src="https://img.shields.io/badge/⚡_JOIN_THE_GRID-SuperPowers_Telegram-black?style=for-the-badge&logo=telegram&logoColor=cyan&labelColor=0d1117&color=00d4ff" alt="Telegram">
<img src="https://img.shields.io/badge/SuperPowers_Telegram-Join-blue?style=for-the-badge&logo=telegram" alt="Telegram">
</a>
</p>
---
## Credits
## 🙏 Credits
- [JingMatrix](https://github.com/JingMatrix/TEESimulator) — original TEESimulator and interception architecture
- [5ec1cff](https://github.com/5ec1cff/TrickyStore) — TrickyStore, the project that pioneered keystore interception
- [LSPlt](https://github.com/LSPosed/LSPlt) — PLT hook library
- [ring](https://github.com/briansmith/ring) — Rust cryptography library
- [MhmRdd](https://github.com/MhmRdd) — AOSP compliance work via upstream [PR #157](https://github.com/JingMatrix/TEESimulator/pull/157)
- [fatalcoder524](https://github.com/fatalcoder524) — contributor and collaborator
- [huguangares](https://github.com/huguangares) — collaborator and tester
- **[JingMatrix](https://github.com/JingMatrix/TEESimulator)** — original author of TEESimulator and the interception architecture
- **[5ec1cff](https://github.com/5ec1cff/TrickyStore)** — TrickyStore, the project that pioneered keystore interception on Android
- **[LSPlt](https://github.com/LSPosed/LSPlt)** — PLT hook library used for binder interception
- **[ring](https://github.com/briansmith/ring)** — Rust cryptography library powering native cert generation
## License
---
## 📄 License
This project is licensed under the [GNU General Public License v3.0](LICENSE).
---
<p align="center">
<b>🔐 Because the best attestation is the one the TEE never generated.</b>
</p>
[GNU General Public License v3.0](LICENSE)
+54 -17
View File
@@ -2,6 +2,7 @@ import com.android.build.api.artifact.SingleArtifact
import java.io.ByteArrayOutputStream
import javax.inject.Inject
import org.gradle.process.ExecOperations
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
@@ -29,7 +30,7 @@ val gitExecutor = objects.newInstance(GitExecutor::class.java)
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
val verName = "v4.0"
val verName = "v6.0.0"
android {
namespace = "org.matrix.TEESimulator"
@@ -65,6 +66,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
compileOnly(project(":stub"))
compileOnly(libs.annotation)
@@ -73,7 +80,7 @@ dependencies {
// --- Rust native cert gen build task ---
val buildRustCertgen by tasks.registering(Exec::class) {
group = "TEESimulator Native Build"
group = "TEESimulator-RS Native Build"
description = "Builds libcertgen.so via cargo-ndk for arm64-v8a."
workingDir = rootProject.projectDir.resolve("native-certgen")
@@ -91,6 +98,7 @@ val buildRustCertgen by tasks.registering(Exec::class) {
outputs.dir(rootProject.projectDir.resolve("app/src/main/jniLibs"))
environment("ANDROID_NDK_HOME", android.ndkDirectory.absolutePath)
environment("PATH", "${System.getProperty("user.home")}/.cargo/bin:${System.getenv("PATH") ?: ""}")
}
// AGP auto-detects jniLibs/ as an input to mergeJniLibFolders — wire the dependency
@@ -100,6 +108,34 @@ tasks.configureEach {
}
}
// Auto-rewrite module/update.json on every packaging build so versionCode and
// zipUrl track gitCommitCount automatically, matching module.prop.
val refreshUpdateJson by tasks.registering {
group = "TEESimulator-RS Module Packaging"
description = "Rewrite module/update.json to match current verName and gitCommitCount."
val updateJsonFile = rootProject.projectDir.resolve("module/update.json")
val capturedVerName = verName
val capturedCount = gitCommitCount
inputs.property("verName", capturedVerName)
inputs.property("gitCommitCount", capturedCount)
outputs.file(updateJsonFile)
doLast {
val fullVer = "$capturedVerName-$capturedCount"
updateJsonFile.writeText(
"""{
"version": "$fullVer",
"versionCode": $capturedCount,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/$fullVer/TEESimulator-RS-$fullVer-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
}
"""
)
}
}
androidComponents {
onVariants(selector().all()) { variant ->
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
@@ -108,22 +144,23 @@ androidComponents {
// --- Define output locations and file names ---
// Stage all files in a temporary directory inside 'build' before zipping
val tempModuleDir = project.layout.buildDirectory.dir("module/${variant.name}")
val zipFileName = "TEESimulator-$verName-$gitCommitCount-$gitCommitHash-$capitalized.zip"
val zipFileName = "TEESimulator-RS-$verName-$gitCommitCount-$capitalized.zip"
// Task 1: Prepare all module files in the temporary build directory.
// Using Sync ensures that stale files from previous runs are removed.
val prepareModuleFilesTask =
tasks.register<Sync>("prepareModuleFiles${capitalized}") {
group = "TEESimulator Module Packaging"
group = "TEESimulator-RS Module Packaging"
description = "Prepares all files for the ${variant.name} module zip."
if (isDebug) {
dependsOn("package${capitalized}")
} else {
dependsOn("minify${capitalized}WithR8")
dependsOn("strip${capitalized}DebugSymbols")
}
dependsOn("strip${capitalized}DebugSymbols")
dependsOn(buildRustCertgen)
dependsOn(refreshUpdateJson)
if (isDebug) {
from(variant.artifacts.get(SingleArtifact.APK)) {
@@ -140,12 +177,13 @@ androidComponents {
}
}
from(
project.layout.buildDirectory.dir(
"intermediates/stripped_native_libs/${variant.name}/strip${capitalized}DebugSymbols/out/lib"
)
) {
into("lib") // Place them in the 'lib' subfolder of the staging directory.
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")
}
@@ -161,8 +199,7 @@ androidComponents {
// Use expand() for simple key-value replacement.
expand(
"REPLACEMEVERCODE" to gitCommitCount.toString(),
"REPLACEMEVER" to
"$verName ($gitCommitCount-$gitCommitHash-${variant.name})",
"REPLACEMEVER" to "$verName-$gitCommitCount",
)
}
@@ -173,7 +210,7 @@ androidComponents {
// Task 2: Zip the prepared files from the temporary directory.
val zipTask =
tasks.register<Zip>("zip${capitalized}") {
group = "TEESimulator Module Packaging"
group = "TEESimulator-RS Module Packaging"
description = "Creates the flashable zip for the ${variant.name} module."
dependsOn(prepareModuleFilesTask)
@@ -186,7 +223,7 @@ androidComponents {
fun createInstallTasks(rootProvider: String, installCli: String) {
val pushTask =
tasks.register<Exec>("push${rootProvider}Module${capitalized}") {
group = "TEESimulator Module Installation"
group = "TEESimulator-RS Module Installation"
description =
"Pushes the ${variant.name} module to the device for $rootProvider."
dependsOn(zipTask)
@@ -200,7 +237,7 @@ androidComponents {
val installTask =
tasks.register<Exec>("install${rootProvider}${capitalized}") {
group = "TEESimulator Module Installation"
group = "TEESimulator-RS Module Installation"
description = "Installs the ${variant.name} module via $rootProvider."
dependsOn(pushTask)
commandLine(
@@ -213,7 +250,7 @@ androidComponents {
}
tasks.register<Exec>("install${rootProvider}AndReboot${capitalized}") {
group = "TEESimulator Module Installation"
group = "TEESimulator-RS Module Installation"
description = "Installs the ${variant.name} module via $rootProvider and reboots."
dependsOn(installTask)
commandLine("adb", "reboot")
+1
View File
@@ -5,6 +5,7 @@ set(CMAKE_CXX_STANDARD 23)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -DNDEBUG")
# LSPlt configuration
OPTION(LSPLT_BUILD_SHARED OFF)
+38 -36
View File
@@ -235,19 +235,21 @@ class BinderInterceptor : public BBinder {
struct RegistrationEntry {
wp<IBinder> target;
sp<IBinder> callback_interface;
std::vector<uint32_t> filtered_codes;
};
// Reader-Writer lock for the registry to allow concurrent reads (lookups)
mutable std::shared_mutex registry_mutex_;
std::map<wp<IBinder>, RegistrationEntry> registry_;
public:
BinderInterceptor() = default;
// Checks if a specific Binder instance is currently registered for interception
bool isBinderIntercepted(const wp<BBinder> &target) const {
bool shouldIntercept(const wp<BBinder> &target, uint32_t code) const {
std::shared_lock lock(registry_mutex_);
return registry_.find(target) != registry_.end();
auto it = registry_.find(target);
if (it == registry_.end()) return false;
const auto &codes = it->second.filtered_codes;
return codes.empty() || std::find(codes.begin(), codes.end(), code) != codes.end();
}
// Main entry point for processing the "Man-in-the-Middle" logic
@@ -348,14 +350,14 @@ static sp<BinderStub> g_stub_instance = nullptr;
namespace {
constexpr binder_size_t kMaxInterceptableDataSize = 256 * 1024;
void inspectAndRewriteTransaction(binder_transaction_data *txn_data) {
if (!txn_data || txn_data->target.ptr == 0)
return;
// Bypass interception for oversized payloads to prevent thread starvation from flood attacks
if (txn_data->data_size > kMaxInterceptableDataSize)
// AIDL methods use codes in [FIRST_CALL_TRANSACTION, LAST_CALL_TRANSACTION] (1..0x00ffffff).
// System transactions (PING, INTERFACE, DUMP, SHELL_COMMAND) use codes above that range.
// Skip those — intercepting a ping adds measurable latency that timing detectors flag.
if (txn_data->code > 0x00ffffffu && txn_data->code != intercept::kBackdoorCode)
return;
bool hijack = false;
@@ -387,7 +389,7 @@ void inspectAndRewriteTransaction(binder_transaction_data *txn_data) {
// This is safe because we are holding a strong reference.
wp<BBinder> wp_target = target_binder_ptr;
if (g_interceptor_instance->isBinderIntercepted(wp_target)) {
if (g_interceptor_instance->shouldIntercept(wp_target, txn_data->code)) {
info.transaction_code = txn_data->code;
info.target_binder = wp_target; // Assign the valid weak pointer
hijack = true;
@@ -426,44 +428,29 @@ void processBinderReadBuffer(const binder_write_read &bwr) {
uintptr_t ptr = bwr.read_buffer;
uintptr_t end = ptr + bwr.read_consumed;
LOGV("[Hook] Processing Read Buffer: Size=%llu, Consumed=%llu", bwr.read_size, bwr.read_consumed);
while (ptr < end) {
// Ensure we can read at least the command header
if (end - ptr < sizeof(uint32_t))
break;
uint32_t cmd = *reinterpret_cast<const uint32_t *>(ptr);
ptr += sizeof(uint32_t);
// Calculate payload size from the ioctl command code
size_t cmd_size = _IOC_SIZE(cmd);
// Log the command using our generated to-string function
LOGV("[Driver -> User] Command: %s (0x%x), DataSize: %zu", getBinderReturnCommandName(cmd), cmd, cmd_size);
// Safety check: ensure the command's data does not exceed the buffer
if (ptr + cmd_size > end) {
LOGE("[Hook] Buffer overflow detected while parsing command %s", getBinderReturnCommandName(cmd));
LOGE("[Hook] Buffer overrun parsing command 0x%x", cmd);
break;
}
// We are primarily interested in BR_TRANSACTION commands to intercept
if (cmd == BR_TRANSACTION || cmd == BR_TRANSACTION_SEC_CTX) {
binder_transaction_data *txn = nullptr;
if (__builtin_expect(cmd == BR_TRANSACTION || cmd == BR_TRANSACTION_SEC_CTX, 0)) {
binder_transaction_data *txn;
if (cmd == BR_TRANSACTION_SEC_CTX) {
// The data is wrapped in a secctx struct
auto *wrapper = reinterpret_cast<binder_transaction_data_secctx *>(ptr);
txn = &wrapper->transaction_data;
txn = &reinterpret_cast<binder_transaction_data_secctx *>(ptr)->transaction_data;
} else {
txn = reinterpret_cast<binder_transaction_data *>(ptr);
}
inspectAndRewriteTransaction(txn);
}
// Advance pointer to the next command
ptr += cmd_size;
}
}
@@ -483,13 +470,17 @@ int intercepted_ioctl(int fd, int request, ...) {
// 1. Call original kernel ioctl to let the driver do its work
int result = g_original_ioctl(fd, request, arg);
// 2. After the call returns, check if it was a BINDER_WRITE_READ and if it succeeded
if (result >= 0 && request == BINDER_WRITE_READ && arg != nullptr) {
const auto *bwr = static_cast<const binder_write_read *>(arg);
// We only care about data read FROM the driver (i.e., incoming commands)
if (bwr->read_consumed > 0) {
processBinderReadBuffer(*bwr);
// Fast reject: only enter the parser if the buffer could contain a BR_TRANSACTION.
// Pings, ref ops, and looper management never produce BR_TRANSACTION, so scanning
// their buffers is pure overhead (~2-5us per ioctl in debug builds).
if (bwr->read_consumed >= sizeof(uint32_t)) {
uint32_t first_cmd = *reinterpret_cast<const uint32_t *>(bwr->read_buffer);
if (first_cmd == BR_TRANSACTION || first_cmd == BR_TRANSACTION_SEC_CTX
|| bwr->read_consumed > sizeof(uint32_t) + _IOC_SIZE(first_cmd)) {
processBinderReadBuffer(*bwr);
}
}
}
@@ -532,18 +523,29 @@ status_t BinderInterceptor::handleRegister(const Parcel &data) {
if (data.readStrongBinder(&callback) != OK || !callback)
return BAD_VALUE;
// We can only intercept local Binders (BBinder), not remote proxies (BpBinder)
if (target->localBinder() == nullptr) {
LOGE("Cannot intercept remote binder proxies.");
return BAD_TYPE;
}
std::vector<uint32_t> codes;
int32_t code_count = 0;
if (data.dataAvail() >= sizeof(int32_t) && data.readInt32(&code_count) == OK && code_count > 0) {
codes.reserve(code_count);
for (int32_t i = 0; i < code_count; i++) {
uint32_t c = 0;
if (data.readUint32(&c) == OK) codes.push_back(c);
}
LOGI("Interceptor registered for binder %p with %zu filtered codes", target.get(), codes.size());
} else {
LOGI("Interceptor registered for binder %p (all codes)", target.get());
}
wp<IBinder> weak_target = target;
std::unique_lock lock(registry_mutex_);
registry_[weak_target] = {weak_target, callback};
registry_[weak_target] = {weak_target, callback, std::move(codes)};
LOGI("Interceptor registered for binder %p", target.get());
return OK;
}
+20 -1
View File
@@ -2,11 +2,13 @@
#include <unistd.h>
#include <sys/wait.h>
#include <sys/prctl.h>
#include <sys/resource.h>
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <time.h>
static volatile sig_atomic_t should_exit = 0;
@@ -27,7 +29,12 @@ int main(int argc, char *argv[]) {
const char *daemon_path = argv[1];
char **daemon_argv = &argv[1];
int backoff_ms = 500;
while (!should_exit) {
struct timespec child_start;
clock_gettime(CLOCK_MONOTONIC, &child_start);
pid_t pid = fork();
if (pid < 0) {
@@ -39,6 +46,7 @@ int main(int argc, char *argv[]) {
if (pid == 0) {
// Child: become the daemon
prctl(PR_SET_PDEATHSIG, SIGKILL); // Die if parent dies
setpriority(PRIO_PROCESS, 0, 10); // lower CPU priority than foreground
execv(daemon_path, daemon_argv);
perror("execv failed");
_exit(127);
@@ -50,7 +58,18 @@ int main(int argc, char *argv[]) {
if (should_exit) break;
// Instant restart - no delay
// Exponential backoff on rapid crashes, reset if child was stable
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long lived_ms = (now.tv_sec - child_start.tv_sec) * 1000 +
(now.tv_nsec - child_start.tv_nsec) / 1000000;
if (lived_ms > 30000) {
backoff_ms = 500;
} else {
usleep(backoff_ms * 1000);
if (backoff_ms < 30000) backoff_ms *= 2;
}
}
return 0;
@@ -8,7 +8,10 @@ import android.os.Build
import android.os.Looper
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.matrix.TEESimulator.config.BootStateManager
import org.matrix.TEESimulator.config.BulletinPoller
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.config.PatchLevelManager
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
@@ -23,8 +26,6 @@ import org.matrix.TEESimulator.util.AndroidDeviceUtils
object App {
// The delay in milliseconds before retrying to initialize the interceptor.
private const val RETRY_DELAY_MS = 1000L
// The sleep duration in milliseconds for the main service loop to keep the process alive.
private const val SERVICE_SLEEP_MS = 1000000L
/**
* The main entry point of the TEESimulator application.
@@ -35,14 +36,24 @@ object App {
fun main(args: Array<String>) {
SystemLogger.info("Welcome to TEESimulator!")
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
SystemLogger.error("Uncaught exception on ${thread.name}", throwable)
}
try {
// Initialize the Android framework environment
prepareEnvironment()
// Initialize and start the appropriate keystore interceptors.
initializeInterceptors()
// Spoof boot-state and patch-level props before any hook attaches,
// so keystore2's cached snapshot reflects the spoofed values.
BootStateManager.apply()
PatchLevelManager.initialize()
// Load the package configuration.
ConfigurationManager.initialize()
// Initialize and start the appropriate keystore interceptors.
initializeInterceptors()
// Set up the device's boot key and hash, which are crucial for attestation.
AndroidDeviceUtils.setupBootKeyAndHash()
@@ -54,6 +65,12 @@ object App {
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
try {
BulletinPoller.start()
} catch (e: Throwable) {
SystemLogger.error("Failed to start BulletinPoller", e)
}
// This starts the message queue processing. It blocks here indefinitely
// processing messages until Looper.myLooper().quit() is called.
Looper.loop()
@@ -2,8 +2,11 @@ package org.matrix.TEESimulator.attestation
import android.content.pm.PackageManager
import android.os.Build
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import org.bouncycastle.asn1.ASN1Boolean
import org.bouncycastle.asn1.ASN1Encodable
import org.bouncycastle.asn1.ASN1Enumerated
@@ -40,11 +43,12 @@ object AttestationBuilder {
securityLevel: Int,
): Extension {
val keyDescription = buildKeyDescription(params, uid, securityLevel)
var formattedString =
keyDescription.joinToString(separator = ", ") {
SystemLogger.verbose {
val formattedString = keyDescription.joinToString(separator = ", ") {
AttestationPatcher.formatAsn1Primitive(it)
}
SystemLogger.verbose("Forged attestation data: ${formattedString}")
"Forged attestation data: $formattedString"
}
return Extension(ATTESTATION_OID, false, DEROctetString(keyDescription.encoded))
}
@@ -127,33 +131,59 @@ object AttestationBuilder {
return properties
}
/** Constructs the main `KeyDescription` sequence, which is the core of the attestation. */
private fun buildKeyDescription(
params: KeyMintAttestation,
uid: Int,
securityLevel: Int,
): ASN1Sequence {
val creationTime = System.currentTimeMillis()
val teeEnforced = buildTeeEnforcedList(params, uid, securityLevel)
val softwareEnforced = buildSoftwareEnforcedList(uid, securityLevel)
val softwareEnforced = buildSoftwareEnforcedList(params, uid, securityLevel, creationTime)
val uniqueId =
if (params.includeUniqueId == true && params.attestationChallenge != null) {
computeUniqueId(creationTime, createApplicationId(uid).octets)
} else {
ByteArray(0)
}
val fields =
arrayOf(
ASN1Integer(
AndroidDeviceUtils.getAttestVersion(securityLevel).toLong()
), // attestationVersion
ASN1Enumerated(securityLevel), // attestationSecurityLevel
ASN1Integer(
AndroidDeviceUtils.getKeymasterVersion(securityLevel).toLong()
), // keymasterVersion
ASN1Enumerated(securityLevel), // keymasterSecurityLevel
DEROctetString(params.attestationChallenge ?: ByteArray(0)), // attestationChallenge
DEROctetString(ByteArray(0)), // uniqueId
ASN1Integer(AndroidDeviceUtils.getAttestVersion(securityLevel).toLong()),
ASN1Enumerated(securityLevel),
ASN1Integer(AndroidDeviceUtils.getKeymasterVersion(securityLevel).toLong()),
ASN1Enumerated(securityLevel),
DEROctetString(params.attestationChallenge ?: ByteArray(0)),
DEROctetString(uniqueId),
softwareEnforced,
teeEnforced,
)
return DERSequence(fields)
}
private fun computeUniqueId(creationTimeMs: Long, aaidDer: ByteArray): ByteArray {
val temporalCounter = creationTimeMs / 2592000000L
val message =
ByteBuffer.allocate(8 + aaidDer.size + 1)
.putLong(temporalCounter)
.put(aaidDer)
.put(0x00)
.array()
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(hbk, "HmacSHA256"))
return mac.doFinal(message).copyOf(16)
}
private val hbk: ByteArray by lazy {
val file = java.io.File(ConfigurationManager.CONFIG_PATH, "hbk")
if (file.exists() && file.length() == 32L) {
file.readBytes()
} else {
SystemLogger.warning("hbk not found, generating ephemeral HBK.")
ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
}
}
/** Builds the `TeeEnforced` authorization list. These are properties the TEE "guarantees". */
private fun buildTeeEnforcedList(
params: KeyMintAttestation,
@@ -182,23 +212,110 @@ object AttestationBuilder {
AttestationConstants.TAG_DIGEST,
DERSet(params.digest.map { ASN1Integer(it.toLong()) }.toTypedArray()),
),
)
if (params.ecCurve != null) {
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_EC_CURVE,
ASN1Integer(params.ecCurve.toLong()),
),
DERTaggedObject(true, AttestationConstants.TAG_NO_AUTH_REQUIRED, DERNull.INSTANCE),
)
)
}
if (params.blockMode.isNotEmpty()) {
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_BLOCK_MODE,
DERSet(params.blockMode.map { ASN1Integer(it.toLong()) }.toTypedArray()),
)
)
}
if (params.padding.isNotEmpty()) {
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_PADDING,
DERSet(params.padding.map { ASN1Integer(it.toLong()) }.toTypedArray()),
)
)
}
if (params.rsaPublicExponent != null) {
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_RSA_PUBLIC_EXPONENT,
ASN1Integer(params.rsaPublicExponent.toLong()),
)
)
}
val attestVersion = AndroidDeviceUtils.getAttestVersion(securityLevel)
if (params.rsaOaepMgfDigest.isNotEmpty() && attestVersion >= 100) {
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_RSA_OAEP_MGF_DIGEST,
DERSet(params.rsaOaepMgfDigest.map { ASN1Integer(it.toLong()) }.toTypedArray()),
)
)
}
if (params.rollbackResistance == true && attestVersion >= 3) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_ROLLBACK_RESISTANCE, DERNull.INSTANCE)
)
}
if (params.earlyBootOnly == true && attestVersion >= 4) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_EARLY_BOOT_ONLY, DERNull.INSTANCE)
)
}
if (params.noAuthRequired == true) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_NO_AUTH_REQUIRED, DERNull.INSTANCE)
)
}
if (params.allowWhileOnBody == true) {
list.add(
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)
)
}
if (params.trustedConfirmationRequired == true && attestVersion >= 3) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_TRUSTED_CONFIRMATION_REQUIRED, DERNull.INSTANCE)
)
}
list.addAll(
listOf(
DERTaggedObject(
true,
AttestationConstants.TAG_ORIGIN,
ASN1Integer(0L),
), // KeyOrigin.GENERATED
ASN1Integer((params.origin ?: 0).toLong()),
),
DERTaggedObject(
true,
AttestationConstants.TAG_ROOT_OF_TRUST,
buildRootOfTrust(null),
),
)
)
// Use the same logic as getSimulatedHardwareProperties to conditionally add patch levels.
val simulatedProperties = getSimulatedHardwareProperties(uid)
@@ -295,20 +412,32 @@ object AttestationBuilder {
* Builds the `SoftwareEnforced` authorization list. These are properties guaranteed by
* Keystore.
*/
private fun buildSoftwareEnforcedList(uid: Int, securityLevel: Int): DERSequence {
val list =
mutableListOf<ASN1Encodable>(
DERTaggedObject(
true,
AttestationConstants.TAG_CREATION_DATETIME,
ASN1Integer(System.currentTimeMillis()),
),
private fun buildSoftwareEnforcedList(
params: KeyMintAttestation,
uid: Int,
securityLevel: Int,
creationTimeMs: Long = System.currentTimeMillis(),
): DERSequence {
val list = mutableListOf<ASN1Encodable>()
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_CREATION_DATETIME,
ASN1Integer(creationTimeMs),
)
)
if (params.attestationChallenge != null) {
list.add(
DERTaggedObject(
true,
AttestationConstants.TAG_ATTESTATION_APPLICATION_ID,
createApplicationId(uid),
),
)
)
}
if (AndroidDeviceUtils.getAttestVersion(securityLevel) >= 400) {
list.add(
DERTaggedObject(
@@ -318,7 +447,39 @@ object AttestationBuilder {
)
)
}
return DERSequence(list.toTypedArray())
if (params.callerNonce == true) {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_CALLER_NONCE, DERNull.INSTANCE)
)
}
params.activeDateTime?.let {
list.add(
DERTaggedObject(true, AttestationConstants.TAG_ACTIVE_DATETIME, ASN1Integer(it.time))
)
}
params.originationExpireDateTime?.let {
list.add(
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))
)
}
params.usageCountLimit?.let {
list.add(
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)
)
}
return DERSequence(list.sortedBy { (it as DERTaggedObject).tagNo }.toTypedArray())
}
/**
@@ -346,6 +507,11 @@ object AttestationBuilder {
*/
@Throws(Throwable::class)
internal fun createApplicationId(uid: Int): DEROctetString {
val appUid = uid % 100000
if (appUid == 0 || appUid == 1000) {
return buildApplicationIdDer(listOf("AndroidSystem" to 1L), emptySet())
}
val pm =
ConfigurationManager.getPackageManager()
?: throw IllegalStateException("PackageManager not found!")
@@ -353,12 +519,11 @@ object AttestationBuilder {
pm.getPackagesForUid(uid) ?: throw IllegalStateException("No packages for UID $uid")
val sha256 = MessageDigest.getInstance("SHA-256")
val packageInfoList = mutableListOf<DERSequence>()
val packageInfoList = mutableListOf<Pair<String, Long>>()
val signatureDigests = mutableSetOf<Digest>()
// Process all packages associated with the UID in a single loop.
val userId = uid / 100000
packages.forEach { packageName ->
val userId = uid / 100000
val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(
@@ -371,34 +536,36 @@ object AttestationBuilder {
pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES, userId)
}
// Add package information (name and version code) to our list.
packageInfoList.add(
DERSequence(
arrayOf(
DEROctetString(packageInfo.packageName.toByteArray(StandardCharsets.UTF_8)),
ASN1Integer(packageInfo.longVersionCode),
)
)
)
packageInfoList.add(packageInfo.packageName to packageInfo.longVersionCode)
// Collect unique signature digests from the signing history.
packageInfo.signingInfo?.signingCertificateHistory?.forEach { signature ->
val digest = sha256.digest(signature.toByteArray())
signatureDigests.add(Digest(digest))
signatureDigests.add(Digest(sha256.digest(signature.toByteArray())))
}
}
// The application ID is a sequence of two sets:
// 1. A set of package information (name and version).
// 2. A set of SHA-256 digests of the signing certificates.
return buildApplicationIdDer(packageInfoList, signatureDigests)
}
private fun buildApplicationIdDer(
packages: List<Pair<String, Long>>,
digests: Set<Digest>,
): DEROctetString {
val packageInfoList =
packages.map { (name, version) ->
DERSequence(
arrayOf(
DEROctetString(name.toByteArray(StandardCharsets.UTF_8)),
ASN1Integer(version),
)
)
}
val applicationIdSequence =
DERSequence(
arrayOf(
DERSet(packageInfoList.toTypedArray()),
DERSet(signatureDigests.map { DEROctetString(it.digest) }.toTypedArray()),
DERSet(digests.map { DEROctetString(it.digest) }.toTypedArray()),
)
)
return DEROctetString(applicationIdSequence.encoded)
}
}
@@ -44,9 +44,11 @@ object AttestationConstants {
// --- Key Lifetime and Usage Control ---
const val TAG_ROLLBACK_RESISTANCE = 303
const val TAG_EARLY_BOOT_ONLY = 305
const val TAG_ACTIVE_DATETIME = 400
const val TAG_ORIGINATION_EXPIRE_DATETIME = 401
const val TAG_USAGE_EXPIRE_DATETIME = 402
const val TAG_MAX_BOOT_LEVEL = 403
const val TAG_MAX_USES_PER_BOOT = 404
const val TAG_USAGE_COUNT_LIMIT = 405
@@ -56,6 +58,10 @@ object AttestationConstants {
const val TAG_NO_AUTH_REQUIRED = 503
const val TAG_USER_AUTH_TYPE = 504
const val TAG_AUTH_TIMEOUT = 505
const val TAG_ALLOW_WHILE_ON_BODY = 506
const val TAG_TRUSTED_USER_PRESENCE_REQUIRED = 507
const val TAG_TRUSTED_CONFIRMATION_REQUIRED = 508
const val TAG_UNLOCKED_DEVICE_REQUIRED = 509
// --- Attestation and Application Info ---
const val TAG_APPLICATION_ID = 601
@@ -89,5 +95,5 @@ object AttestationConstants {
// --- Other Constants ---
// https://cs.android.com/android/platform/superproject/main/+/main:system/keymaster/km_openssl/attestation_record.cpp
const val CHALLENGE_LENGTH_LIMIT = 128 // kMaximumAttestationChallengeLength
const val CHALLENGE_LENGTH_LIMIT = 128
}
@@ -16,6 +16,7 @@ import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.KeyBox
import org.matrix.TEESimulator.pki.KeyBoxManager
import org.matrix.TEESimulator.util.toHex
import java.util.Date
/**
* Handles the modification (patching) of Android Key Attestation extensions within certificates.
@@ -36,7 +37,12 @@ object AttestationPatcher {
* @return A new, cryptographically valid, patched certificate chain. Returns the original chain
* on any failure.
*/
fun patchCertificateChain(originalChain: Array<Certificate>?, uid: Int): Array<Certificate> {
fun patchCertificateChain(
originalChain: Array<Certificate>?,
uid: Int,
notBefore: Date? = null,
notAfter: Date? = null,
): Array<Certificate> {
if (originalChain.isNullOrEmpty()) {
SystemLogger.error("Attempted to patch a null or empty certificate chain for UID $uid.")
return originalChain ?: emptyArray()
@@ -63,6 +69,8 @@ object AttestationPatcher {
keybox,
originalLeaf.sigAlgName,
uid,
notBefore,
notAfter,
)
// 4. Construct the NEW, VALID chain by prepending the patched leaf to the keybox's
@@ -111,17 +119,27 @@ object AttestationPatcher {
keybox: KeyBox,
sigAlgName: String,
uid: Int,
notBefore: Date? = null,
notAfter: Date? = null,
): Certificate {
// The issuer of our new leaf is the subject of the first certificate in our custom keybox
// chain.
val newIssuer = X509CertificateHolder(keybox.certificates[0].encoded).subject
val effectiveNotBefore = notBefore ?: originalLeafHolder.notBefore
val effectiveNotAfter = notAfter ?: originalLeafHolder.notAfter
if (notBefore != null || notAfter != null) {
SystemLogger.debug(
"Overriding cert dates: notBefore=${effectiveNotBefore} (was ${originalLeafHolder.notBefore}), notAfter=${effectiveNotAfter} (was ${originalLeafHolder.notAfter})"
)
}
val builder =
X509v3CertificateBuilder(
newIssuer,
originalLeafHolder.serialNumber,
originalLeafHolder.notBefore,
originalLeafHolder.notAfter,
effectiveNotBefore,
effectiveNotAfter,
originalLeafHolder.subject,
originalLeafHolder.subjectPublicKeyInfo,
)
@@ -146,7 +164,7 @@ object AttestationPatcher {
// Log the signature of the newly created certificate to observe its non-deterministic
// nature.
val signatureBytes = (newCertificate as X509Certificate).signature
SystemLogger.verbose("Signature of patched leaf cert: ${signatureBytes.toHex()}")
SystemLogger.verbose { "Signature of patched leaf cert: ${signatureBytes.toHex()}" }
return newCertificate
}
@@ -268,8 +286,10 @@ object AttestationPatcher {
private fun createPatchedAttestationExtension(parsed: ParsedAttestation, uid: Int): Extension {
val (allFields, teeEnforcedMap, originalRootOfTrust) = parsed
var formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
SystemLogger.verbose("Original attestation data: ${formattedString}")
SystemLogger.verbose {
val formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
"Original attestation data: $formattedString"
}
// Build the new Root of Trust and add/replace it in the map.
val newRootOfTrust = AttestationBuilder.buildRootOfTrust(originalRootOfTrust)
@@ -296,8 +316,10 @@ object AttestationPatcher {
allFields[AttestationConstants.KEY_DESCRIPTION_TEE_ENFORCED_INDEX] = sortedTeeEnforced
val patchedSequence = DERSequence(allFields)
formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
SystemLogger.verbose("Patched attestation data: ${formattedString}")
SystemLogger.verbose {
val formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
"Patched attestation data: $formattedString"
}
val patchedOctets = DEROctetString(patchedSequence)
return Extension(ATTESTATION_OID, false, patchedOctets)
@@ -148,11 +148,12 @@ object DeviceAttestationService {
// The extension's value is an ASN.1 sequence.
val keyDescriptionSeq = ASN1Sequence.getInstance(extension.extnValue.octets)
var formattedString =
keyDescriptionSeq.joinToString(separator = ", ") {
SystemLogger.verbose {
val formattedString = keyDescriptionSeq.joinToString(separator = ", ") {
AttestationPatcher.formatAsn1Primitive(it)
}
SystemLogger.verbose("Cached attestation data: ${formattedString}")
"Cached attestation data: $formattedString"
}
val fields = keyDescriptionSeq.toArray()
val attestVersion =
@@ -19,7 +19,7 @@ import org.matrix.TEESimulator.logging.KeyMintParameterLogger
data class KeyMintAttestation(
val keySize: Int,
val algorithm: Int,
val ecCurve: Int,
val ecCurve: Int?,
val ecCurveName: String,
val origin: Int?,
val blockMode: List<Int>,
@@ -41,19 +41,36 @@ data class KeyMintAttestation(
val manufacturer: ByteArray?,
val model: ByteArray?,
val secondImei: ByteArray?,
val activeDateTime: Date?,
val originationExpireDateTime: Date?,
val usageExpireDateTime: Date?,
val usageCountLimit: Int?,
val callerNonce: Boolean?,
val nonce: ByteArray?,
val unlockedDeviceRequired: Boolean?,
val includeUniqueId: Boolean?,
val rollbackResistance: Boolean?,
val earlyBootOnly: Boolean?,
val allowWhileOnBody: Boolean?,
val trustedUserPresenceRequired: Boolean?,
val trustedConfirmationRequired: Boolean?,
val noAuthRequired: Boolean?,
val maxUsesPerBoot: Int?,
val maxBootLevel: Int?,
val minMacLength: Int?,
val rsaOaepMgfDigest: List<Int>,
) {
/** Secondary constructor that populates the fields by parsing an array of `KeyParameter`. */
constructor(
params: Array<KeyParameter>
) : this(
// AOSP: [key_param(tag = KEY_SIZE, field = Integer)]
keySize = params.findInteger(Tag.KEY_SIZE) ?: 0,
keySize = params.findInteger(Tag.KEY_SIZE) ?: params.deriveKeySizeFromCurve(),
// AOSP: [key_param(tag = ALGORITHM, field = Algorithm)]
algorithm = params.findAlgorithm(Tag.ALGORITHM) ?: 0,
// AOSP: [key_param(tag = EC_CURVE, field = EcCurve)]
ecCurve = params.findEcCurve(Tag.EC_CURVE) ?: 0,
ecCurve = params.findEcCurve(Tag.EC_CURVE),
ecCurveName = params.deriveEcCurveName(),
// AOSP: [key_param(tag = ORIGIN, field = Origin)]
@@ -100,6 +117,24 @@ data class KeyMintAttestation(
manufacturer = params.findBlob(Tag.ATTESTATION_ID_MANUFACTURER),
model = params.findBlob(Tag.ATTESTATION_ID_MODEL),
secondImei = params.findBlob(Tag.ATTESTATION_ID_SECOND_IMEI),
activeDateTime = params.findDate(Tag.ACTIVE_DATETIME),
originationExpireDateTime = params.findDate(Tag.ORIGINATION_EXPIRE_DATETIME),
usageExpireDateTime = params.findDate(Tag.USAGE_EXPIRE_DATETIME),
usageCountLimit = params.findInteger(Tag.USAGE_COUNT_LIMIT),
callerNonce = params.findBoolean(Tag.CALLER_NONCE),
nonce = params.findBlob(Tag.NONCE),
unlockedDeviceRequired = params.findBoolean(Tag.UNLOCKED_DEVICE_REQUIRED),
includeUniqueId = params.findBoolean(Tag.INCLUDE_UNIQUE_ID),
rollbackResistance = params.findBoolean(Tag.ROLLBACK_RESISTANCE),
earlyBootOnly = params.findBoolean(Tag.EARLY_BOOT_ONLY),
allowWhileOnBody = params.findBoolean(Tag.ALLOW_WHILE_ON_BODY),
trustedUserPresenceRequired = params.findBoolean(Tag.TRUSTED_USER_PRESENCE_REQUIRED),
trustedConfirmationRequired = params.findBoolean(Tag.TRUSTED_CONFIRMATION_REQUIRED),
noAuthRequired = params.findBoolean(Tag.NO_AUTH_REQUIRED),
maxUsesPerBoot = params.findInteger(Tag.MAX_USES_PER_BOOT),
maxBootLevel = params.findInteger(Tag.MAX_BOOT_LEVEL),
minMacLength = params.findInteger(Tag.MIN_MAC_LENGTH),
rsaOaepMgfDigest = params.findAllDigests(Tag.RSA_OAEP_MGF_DIGEST),
) {
// Log all parsed parameters for debugging purposes.
params.forEach { KeyMintParameterLogger.logParameter(it) }
@@ -156,6 +191,21 @@ private fun Array<KeyParameter>.findAllKeyPurpose(tag: Int): List<Int> =
private fun Array<KeyParameter>.findAllDigests(tag: Int): List<Int> =
this.filter { it.tag == tag }.map { it.value.digest }
private fun Array<KeyParameter>.findBoolean(tag: Int): Boolean? =
if (this.any { it.tag == tag }) true else null
private fun Array<KeyParameter>.deriveKeySizeFromCurve(): Int {
val curveId = this.find { it.tag == Tag.EC_CURVE }?.value?.ecCurve ?: return 0
return when (curveId) {
EcCurve.P_224 -> 224
EcCurve.P_256 -> 256
EcCurve.P_384 -> 384
EcCurve.P_521 -> 521
EcCurve.CURVE_25519 -> 256
else -> 0
}
}
/**
* Derives the EC Curve name. Logic: Checks specific EC_CURVE tag first (field=EcCurve), falls back
* to KEY_SIZE (field=Integer).
@@ -0,0 +1,48 @@
package org.matrix.TEESimulator.config
import android.os.SystemProperties
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
object BootStateManager {
private val targets =
linkedMapOf(
"ro.boot.verifiedbootstate" to "green",
"ro.boot.flash.locked" to "1",
"ro.boot.veritymode" to "enforcing",
"ro.boot.vbmeta.device_state" to "locked",
)
private val fillIfAbsent =
linkedMapOf(
"ro.boot.vbmeta.invalidate_on_error" to "yes",
"ro.boot.vbmeta.avb_version" to "1.2",
"ro.boot.vbmeta.hash_alg" to "sha256",
"ro.boot.vbmeta.size" to "11904",
)
fun apply() {
for ((name, target) in targets) {
val current = SystemProperties.get(name, "")
if (current.isEmpty()) {
SystemLogger.debug("BootStateManager: $name absent on this device, skip")
continue
}
if (current == target) {
SystemLogger.debug("BootStateManager: $name already $target, skip")
continue
}
SystemLogger.info("BootStateManager: setting $name=$target (was: '$current')")
AndroidDeviceUtils.setProperty(name, target)
}
for ((name, value) in fillIfAbsent) {
val current = SystemProperties.get(name, "")
if (current.isNotEmpty()) {
SystemLogger.debug("BootStateManager: $name already '$current', skip")
continue
}
SystemLogger.info("BootStateManager: filling absent $name=$value")
AndroidDeviceUtils.setProperty(name, value)
}
}
}
@@ -0,0 +1,193 @@
package org.matrix.TEESimulator.config
import android.os.Handler
import android.os.HandlerThread
import java.io.File
import java.net.URL
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
import org.matrix.TEESimulator.BuildConfig
import org.matrix.TEESimulator.logging.SystemLogger
object BulletinPoller {
private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
private const val HISTORY_CAP = 10
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
private val DATE_REGEX = Regex("<td>(\\d{4}-\\d{2}-\\d{2})</td>")
private val PATCH_DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
private lateinit var handler: Handler
@Volatile private var bootstrapStep = 0
@Volatile private var steadyArmed = false
fun start() {
val thread = HandlerThread("BulletinPoller").apply { start() }
handler = Handler(thread.looper)
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
}
private fun pollOnce() {
try {
val result = fetchAndParse()
appendHistory(result)
scheduleNext(result.status == "success")
} catch (t: Throwable) {
SystemLogger.error("BulletinPoller: pollOnce failed", t)
scheduleNext(false)
}
}
private fun scheduleNext(success: Boolean) {
if (success || steadyArmed) {
steadyArmed = true
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
return
}
bootstrapStep++
if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
steadyArmed = true
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
} else {
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
}
}
private data class FetchResult(
val ts: Long,
val status: String,
val httpCode: Int?,
val parsedDate: String?,
val applied: Boolean,
val error: String?,
)
private fun fetchAndParse(): FetchResult {
val ts = System.currentTimeMillis()
var conn: HttpsURLConnection? = null
return try {
conn =
(URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty(
"User-Agent",
"TEESimulator/${BuildConfig.VERSION_NAME}",
)
requestMethod = "GET"
}
val code = conn.responseCode
if (code != 200) {
return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
}
val html = conn.inputStream.bufferedReader().use { it.readText() }
val date = DATE_REGEX.find(html)?.groupValues?.get(1)
if (date == null) {
return FetchResult(
ts,
"parse_error",
code,
null,
false,
"no <td>YYYY-MM-DD</td> match",
)
}
val current = currentPatch()
if (current == null || date <= current) {
return FetchResult(ts, "success", code, date, false, null)
}
if (PatchLevelManager.updateTo(date)) {
FetchResult(ts, "success", code, date, true, null)
} else {
FetchResult(
ts,
"validation_rejected",
code,
date,
false,
"PatchLevelManager.updateTo rejected $date",
)
}
} catch (e: Exception) {
FetchResult(ts, "network_error", null, null, false, e.toString())
} finally {
conn?.disconnect()
}
}
private fun currentPatch(): String? {
val f = File(PATCH_FILE)
if (!f.exists()) return null
val raw = try {
f.readLines()
.firstOrNull { it.startsWith("system=") }
?.substringAfter("system=")
?.trim()
?.takeIf { it != "prop" && it.isNotEmpty() }
} catch (_: Exception) {
null
}
if (raw == null) return null
if (PATCH_DATE_PATTERN.matches(raw)) return raw
SystemLogger.warning(
"BulletinPoller: ignoring malformed system='$raw' in $PATCH_FILE"
)
return null
}
private fun appendHistory(result: FetchResult) {
try {
val target = File(HISTORY_FILE)
val staging = File(HISTORY_STAGING)
val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
val history =
existing
?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
?: JSONArray()
val entry =
JSONObject().apply {
put("ts", result.ts)
put("status", result.status)
put("http_code", result.httpCode ?: JSONObject.NULL)
put("parsed_date", result.parsedDate ?: JSONObject.NULL)
put("applied", result.applied)
put("error", result.error ?: JSONObject.NULL)
}
history.put(entry)
while (history.length() > HISTORY_CAP) history.remove(0)
val latestKnown =
(0 until history.length())
.mapNotNull {
history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
d.isNotBlank()
}
}
.lastOrNull()
val root =
JSONObject().apply {
put("latest_known_date", latestKnown ?: JSONObject.NULL)
put("history", history)
}
staging.writeText(root.toString(2))
Files.move(
staging.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (e: Exception) {
SystemLogger.error("BulletinPoller: failed to persist history", e)
}
}
}
@@ -31,7 +31,6 @@ object ConfigurationManager {
// --- Configuration Paths ---
const val CONFIG_PATH = "/data/adb/tricky_store"
private const val TARGET_PACKAGES_FILE = "target.txt"
private const val TEE_STATUS_FILE = "tee_status.txt"
private const val PATCH_LEVEL_FILE = "security_patch.txt"
private const val DEFAULT_KEYBOX_FILE = "keybox.xml"
private val configRoot = File(CONFIG_PATH)
@@ -39,7 +38,6 @@ object ConfigurationManager {
// --- In-Memory Configuration State ---
@Volatile private var packageModes = mapOf<String, Mode>()
@Volatile private var packageKeyboxes = mapOf<String, String>()
@Volatile private var isTeeBroken: Boolean? = null
@Volatile private var globalCustomPatchLevel: CustomPatchLevel? = null
@Volatile private var packagePatchLevels = mapOf<String, CustomPatchLevel>()
@@ -68,8 +66,6 @@ object ConfigurationManager {
// Initial load of all configuration files.
loadTargetPackages(File(configRoot, TARGET_PACKAGES_FILE))
loadPatchLevelConfig(File(configRoot, PATCH_LEVEL_FILE))
storeTeeStatus() // Check and store the current TEE status.
// Start watching for any subsequent file changes.
ConfigObserver.startWatching()
SystemLogger.info("Configuration initialized and file observer started.")
@@ -87,33 +83,40 @@ object ConfigurationManager {
return packages.firstNotNullOfOrNull { pkg -> packageKeyboxes[pkg] } ?: DEFAULT_KEYBOX_FILE
}
/** Determines if the certificate for a given UID needs to be patched. */
fun shouldPatch(uid: Int): Boolean = getPackageModeForUid(uid) == Mode.PATCH
fun shouldPatch(uid: Int): Boolean {
val mode = getPackageModeForUid(uid)
return mode == Mode.PATCH || mode == Mode.AUTO
}
/** Determines if a new certificate needs to be generated for a given UID. */
fun shouldGenerate(uid: Int): Boolean = getPackageModeForUid(uid) == Mode.GENERATE
/** Determines if no operation is needed for a given UID. */
fun shouldSkipUid(uid: Int): Boolean = getPackageModeForUid(uid) == null
/** Resolves the operating mode for a given UID based on its packages and the TEE status. */
fun isAutoMode(uid: Int): Boolean {
for (pkg in getPackagesForUid(uid)) {
when (packageModes[pkg]) {
Mode.GENERATE, Mode.PATCH -> return false
Mode.AUTO -> return true
null -> continue
}
}
return false
}
private fun getPackageModeForUid(uid: Int): Mode? {
val packages = getPackagesForUid(uid)
if (packages.isEmpty()) return null
// Lazily load TEE status if it hasn't been checked yet.
if (isTeeBroken == null) loadTeeStatus()
// Find the first configured mode for any of the UID's packages.
for (pkg in packages) {
when (packageModes[pkg]) {
Mode.GENERATE -> return Mode.GENERATE
Mode.PATCH -> return Mode.PATCH
Mode.AUTO -> return if (isTeeBroken == true) Mode.GENERATE else Mode.PATCH
null -> continue // No config for this package, check the next one.
Mode.AUTO -> return if (DeviceAttestationService.isTeeFunctional) Mode.PATCH else Mode.GENERATE
null -> continue
}
}
return null // No configuration found for this UID.
return null
}
/**
@@ -171,7 +174,6 @@ object ConfigurationManager {
newModes[pkg] = Mode.PATCH
newKeyboxes[pkg] = currentKeybox
}
// No suffix means AUTO mode.
else -> {
newModes[trimmedLine] = Mode.AUTO
newKeyboxes[trimmedLine] = currentKeybox
@@ -280,29 +282,6 @@ object ConfigurationManager {
}
}
/** Checks the device's TEE status and writes the result to a file for persistence. */
private fun storeTeeStatus() {
val statusFile = File(configRoot, TEE_STATUS_FILE)
isTeeBroken = !DeviceAttestationService.isTeeFunctional
try {
statusFile.writeText("tee_broken=$isTeeBroken")
SystemLogger.info("TEE status stored: isTeeBroken=$isTeeBroken")
} catch (e: Exception) {
SystemLogger.error("Failed to write TEE status to file.", e)
}
}
/** Loads the TEE status from the file. */
private fun loadTeeStatus() {
val statusFile = File(configRoot, TEE_STATUS_FILE)
isTeeBroken =
if (statusFile.exists()) {
statusFile.readText().trim() == "tee_broken=true"
} else {
null // Status is unknown.
}
}
/**
* A FileObserver that monitors the configuration directory for changes and triggers reloads of
* the relevant settings.
@@ -327,10 +306,15 @@ object ConfigurationManager {
)
KeyBoxManager.invalidateCache(path)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
// Clear cached keys possibly containing old certificates
// Drop only the patched cert chains so the next
// attestation request re-signs with the new keybox.
// Do NOT drop generatedKeys — that would destroy
// every alias/private key in memory and on disk,
// logging users out of any app that pinned a
// persisted keystore alias.
org.matrix.TEESimulator.interception.keystore.shim
.KeyMintSecurityLevelInterceptor
.clearAllGeneratedKeys("updating $file")
.invalidatePatchedChains("updating $file")
}
}
}
@@ -360,7 +344,29 @@ object ConfigurationManager {
return iPackageManager
}
/** Retrieves the package names associated with a UID. */
fun checkSELinuxPermission(callingPid: Int, tclass: String, perm: String): Boolean {
return try {
val callerCtx =
java.io.File("/proc/$callingPid/attr/current").readText().trim('\u0000', ' ', '\n')
val selfCtx =
java.io.File("/proc/self/attr/current").readText().trim('\u0000', ' ', '\n')
android.os.SELinux.checkSELinuxAccess(callerCtx, selfCtx, tclass, perm)
} catch (_: Exception) {
false
}
}
fun hasPermissionForUid(uid: Int, permission: String): Boolean {
val userId = uid / 100000
return getPackagesForUid(uid).any { pkg ->
try {
getPackageManager()?.checkPermission(permission, pkg, userId) == 0
} catch (_: Exception) {
false
}
}
}
fun getPackagesForUid(uid: Int): Array<String> {
return uidToPackagesCache.getOrPut(uid) {
try {
@@ -0,0 +1,192 @@
package org.matrix.TEESimulator.config
import android.os.Build
import android.os.FileObserver
import android.os.SystemProperties
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.LocalDate
import org.json.JSONObject
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
object PatchLevelManager {
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
private const val STAGING_FILE = "/data/adb/tricky_store/security_patch.txt.next"
private const val PIF_DIR = "/data/adb/modules/playintegrityfix"
private const val FLOOR_YYYYMMDD = 20200101
private const val MAX_PAST_OFFSET = 10000
/**
* Pixel security bulletins publish monthly; pre-announced dates occasionally
* slip by 2-4 weeks. 60 days covers that window without admitting a
* far-future date from a hostile or mis-parsed bulletin response.
*/
private const val MAX_FUTURE_DAYS = 60L
private val PIF_FILENAMES =
setOf("pif.json", "pif.prop", "custom.pif.json", "custom.pif.prop")
private val DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
private val PROP_PATTERN = Regex("^SECURITY_PATCH=(.+)$", RegexOption.MULTILINE)
private val SECTION_HEADER = Regex("^\\[[a-zA-Z0-9_.-]+]$")
private val GLOBAL_KEYS = setOf("system", "boot", "vendor", "all")
private val PIF_SOURCES =
listOf(
"/data/adb/modules/playintegrityfix/pif.json",
"/data/adb/pif.json",
"/data/adb/modules/playintegrityfix/pif.prop",
"/data/adb/pif.prop",
"/data/adb/modules/playintegrityfix/custom.pif.json",
"/data/adb/modules/playintegrityfix/custom.pif.prop",
)
fun initialize() {
refreshFromSources()
startPifObserver()
}
private fun refreshFromSources() {
val date =
resolvePifPatch()
?: SystemProperties.get(
"ro.build.version.security_patch",
Build.VERSION.SECURITY_PATCH,
)
SystemLogger.info("PatchLevelManager: resolved patch date = $date")
applyToProps(date)
}
private fun startPifObserver() {
if (!File(PIF_DIR).exists()) {
SystemLogger.debug("PatchLevelManager: PIF dir absent, hot-reload disabled")
return
}
PifObserver.startWatching()
}
@Synchronized
private fun applyToProps(date: String) {
if (!DATE_PATTERN.matches(date)) {
SystemLogger.warning(
"PatchLevelManager: skip resetprop for invalid date: $date"
)
return
}
AndroidDeviceUtils.setProperty("ro.build.version.security_patch", date)
AndroidDeviceUtils.setProperty("ro.vendor.build.security_patch", date)
}
fun updateTo(date: String): Boolean {
if (!DATE_PATTERN.matches(date)) {
SystemLogger.warning("PatchLevelManager: invalid date format: $date")
return false
}
val dateInt = date.replace("-", "").toInt()
if (dateInt < FLOOR_YYYYMMDD) {
SystemLogger.warning("PatchLevelManager: $date below floor $FLOOR_YYYYMMDD")
return false
}
val now = LocalDate.now()
val today = now.year * 10000 + now.monthValue * 100 + now.dayOfMonth
if (today >= dateInt + MAX_PAST_OFFSET) {
SystemLogger.warning(
"PatchLevelManager: $date more than 1y older than today ($today)"
)
return false
}
val maxFuture =
now.plusDays(MAX_FUTURE_DAYS).let {
it.year * 10000 + it.monthValue * 100 + it.dayOfMonth
}
if (dateInt > maxFuture) {
SystemLogger.warning(
"PatchLevelManager: $date more than $MAX_FUTURE_DAYS days in future ($maxFuture)"
)
return false
}
try {
atomicWrite(date)
} catch (e: Exception) {
SystemLogger.error("PatchLevelManager: atomicWrite failed for $date", e)
return false
}
applyToProps(date)
SystemLogger.info("PatchLevelManager: applied patch date $date")
return true
}
private fun resolvePifPatch(): String? {
val source =
PIF_SOURCES.map(::File).lastOrNull { it.exists() && it.length() > 0 }
?: return null
return try {
val text = source.readText()
val parsed =
if (source.name.endsWith(".json")) {
JSONObject(text).optString("SECURITY_PATCH", "")
} else {
PROP_PATTERN.find(text)?.groupValues?.get(1)?.trim().orEmpty()
}
parsed.takeIf { it.isNotBlank() }
} catch (e: Exception) {
SystemLogger.warning(
"PatchLevelManager: failed to parse ${source.path}: ${e.message}"
)
null
}
}
private fun atomicWrite(date: String) {
val target = File(PATCH_FILE)
val staging = File(STAGING_FILE)
staging.writeText(mergedContents(target, date))
Files.move(
staging.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
}
private fun mergedContents(target: File, date: String): String {
val globalBlock = "system=$date\nboot=$date\nvendor=$date\n"
if (!target.exists()) return globalBlock
val tail = stripGlobalAssignments(target.readLines())
if (tail.isEmpty()) return globalBlock
return globalBlock + tail.joinToString("\n", prefix = "\n", postfix = "\n")
}
private fun stripGlobalAssignments(lines: List<String>): List<String> {
val kept = mutableListOf<String>()
var inGlobal = true
for (line in lines) {
val trimmed = line.trim()
if (SECTION_HEADER.matches(trimmed)) {
inGlobal = false
kept += line
continue
}
if (inGlobal && isGlobalKeyAssignment(trimmed)) continue
kept += line
}
return kept
}
private fun isGlobalKeyAssignment(trimmed: String): Boolean {
if (trimmed.isEmpty() || trimmed.startsWith("#") || '=' !in trimmed) return false
val key = trimmed.substringBefore('=').trim().lowercase()
return key in GLOBAL_KEYS
}
private object PifObserver :
FileObserver(File(PIF_DIR), CLOSE_WRITE or MOVED_TO or DELETE) {
override fun onEvent(event: Int, path: String?) {
if (path == null || path !in PIF_FILENAMES) return
SystemLogger.info("PatchLevelManager: PIF change ($path), refreshing")
refreshFromSources()
}
}
}
@@ -109,17 +109,17 @@ abstract class BinderInterceptor : Binder() {
* `handlePostTransact`).
*/
final override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
// The native hook prepends a transaction ID to the data parcel.
val txId = data.readLong()
val result =
val result = try {
when (code) {
// These codes are defined in the native layer to distinguish hook types.
PRE_TRANSACT_CODE -> handlePreTransact(txId, data)
POST_TRANSACT_CODE -> handlePostTransact(txId, data)
else -> return super.onTransact(code, data, reply, flags)
}
// The reply parcel is guaranteed to be non-null for our custom transactions.
} catch (e: Throwable) {
SystemLogger.error("[TX_ID: $txId] Interceptor exception, falling through to HAL", e)
TransactionResult.ContinueAndSkipPost
}
writeResultToReply(result, reply!!)
return true
}
@@ -293,15 +293,21 @@ abstract class BinderInterceptor : Binder() {
}
}
/** Uses the backdoor binder to register an interceptor for a specific target service. */
fun register(backdoor: IBinder, target: IBinder, interceptor: BinderInterceptor) {
fun register(
backdoor: IBinder,
target: IBinder,
interceptor: BinderInterceptor,
filteredCodes: IntArray = intArrayOf(),
) {
val data = Parcel.obtain()
val reply = Parcel.obtain()
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")
SystemLogger.info("Registered interceptor for target: $target (${filteredCodes.size} filtered codes)")
} catch (e: Exception) {
SystemLogger.error("Failed to register binder interceptor.", e)
} finally {
@@ -68,11 +68,12 @@ abstract class AbstractKeystoreInterceptor : BinderInterceptor() {
}
}
/** Registers this interceptor with the native hook layer and sets up a death recipient. */
protected open val interceptedCodes: IntArray = intArrayOf()
private fun setupInterceptor(service: IBinder, backdoor: IBinder) {
keystoreService = service
SystemLogger.info("Registering interceptor for service: $serviceName")
register(backdoor, service, this)
register(backdoor, service, this, interceptedCodes)
service.linkToDeath(createDeathRecipient(), 0)
onInterceptorReady(service, backdoor)
}
@@ -1,11 +1,16 @@
package org.matrix.TEESimulator.interception.keystore
import android.hardware.security.keymint.KeyParameter
import android.hardware.security.keymint.KeyParameterValue
import android.hardware.security.keymint.Tag
import android.os.Parcel
import android.os.Parcelable
import android.security.KeyStore
import android.security.keystore.KeystoreResponse
import android.system.keystore2.Authorization
import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
data class KeyIdentifier(val uid: Int, val alias: String)
@@ -14,11 +19,29 @@ object InterceptorUtils {
private const val EX_SERVICE_SPECIFIC = -8
private fun synthesizeSseMessage(errorCode: Int): String =
when (errorCode) {
2 -> "Error::Rc(SYSTEM_ERROR)"
4 -> "Error::Rc(PERMISSION_DENIED)"
6 -> "Error::Rc(VALUE_CORRUPTED)"
7 -> "Error::Rc(KEY_NOT_FOUND)"
10 -> "Error::Rc(BACKEND_BUSY)"
-3 -> "Error::Km(UNSUPPORTED_KEY_SIZE)"
-6 -> "Error::Km(INCOMPATIBLE_PURPOSE)"
-7 -> "Error::Km(INCOMPATIBLE_ALGORITHM)"
-29 -> "Error::Km(TOO_MANY_OPERATIONS)"
-49 -> "Error::Km(UNSUPPORTED_TAG)"
-75 -> "Error::Km(INVALID_INPUT_LENGTH)"
-76 -> "Error::Km(INVALID_TAG)"
else -> if (errorCode > 0) "Error::Rc($errorCode)" else "Error::Km($errorCode)"
}
fun createErrorReply(errorCode: Int): BinderInterceptor.TransactionResult.OverrideReply {
val parcel = Parcel.obtain().apply {
writeInt(EX_SERVICE_SPECIFIC)
writeString(synthesizeSseMessage(errorCode))
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
writeInt(errorCode)
writeString(null)
}
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
}
@@ -119,6 +142,70 @@ object InterceptorUtils {
/** Checks if a reply parcel contains an exception without consuming it. */
fun hasException(reply: Parcel): Boolean {
return runCatching { reply.readException() }.exceptionOrNull() != null
val exception = runCatching { reply.readException() }.exceptionOrNull()
if (exception != null) reply.setDataPosition(0)
return exception != null
}
fun createServiceSpecificErrorReply(
errorCode: Int
): BinderInterceptor.TransactionResult.OverrideReply = createErrorReply(errorCode)
fun normalizeServiceSpecificReply(reply: Parcel): Parcel? {
reply.setDataPosition(0)
if (reply.readInt() != EX_SERVICE_SPECIFIC) {
reply.setDataPosition(0)
return null
}
// Advance position past message and stack header to reach errorCode.
reply.readString()
reply.readInt()
val errorCode = reply.readInt()
reply.setDataPosition(0)
return Parcel.obtain().apply {
writeInt(EX_SERVICE_SPECIFIC)
writeString(synthesizeSseMessage(errorCode))
writeInt(0)
writeInt(errorCode)
}
}
fun patchAuthorizations(
authorizations: Array<Authorization>?,
callingUid: Int,
): Array<Authorization>? {
if (authorizations == null) return null
val osPatch = AndroidDeviceUtils.getPatchLevel(callingUid)
val vendorPatch = AndroidDeviceUtils.getVendorPatchLevelLong(callingUid)
val bootPatch = AndroidDeviceUtils.getBootPatchLevelLong(callingUid)
return authorizations
.map { auth ->
val replacement =
when (auth.keyParameter.tag) {
Tag.OS_PATCHLEVEL ->
if (osPatch != AndroidDeviceUtils.DO_NOT_REPORT) osPatch else null
Tag.VENDOR_PATCHLEVEL ->
if (vendorPatch != AndroidDeviceUtils.DO_NOT_REPORT) vendorPatch
else null
Tag.BOOT_PATCHLEVEL ->
if (bootPatch != AndroidDeviceUtils.DO_NOT_REPORT) bootPatch else null
else -> null
}
if (replacement != null) {
Authorization().apply {
keyParameter =
KeyParameter().apply {
tag = auth.keyParameter.tag
value = KeyParameterValue.integer(replacement)
}
securityLevel = auth.securityLevel
}
} else {
auth
}
}
.toTypedArray()
}
}
@@ -5,11 +5,13 @@ import android.hardware.security.keymint.SecurityLevel
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.system.keystore2.Domain
import android.system.keystore2.IKeystoreService
import android.system.keystore2.KeyDescriptor
import android.system.keystore2.KeyEntryResponse
import java.security.SecureRandom
import java.security.cert.Certificate
import java.util.concurrent.ConcurrentHashMap
import org.matrix.TEESimulator.attestation.AttestationPatcher
import org.matrix.TEESimulator.attestation.KeyMintAttestation
import org.matrix.TEESimulator.config.ConfigurationManager
@@ -44,6 +46,8 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
if (Build.VERSION.SDK_INT >= 34)
InterceptorUtils.getTransactCode(stubBinderClass, "listEntriesBatched")
else null
private val GET_NUMBER_OF_ENTRIES_TRANSACTION =
InterceptorUtils.getTransactCode(stubBinderClass, "getNumberOfEntries")
private val transactionNames: Map<Int, String> by lazy {
stubBinderClass.declaredFields
@@ -54,10 +58,32 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
.associate { field -> (field.get(null) as Int) to field.name.split("_")[1] }
}
private const val RESPONSE_KEY_NOT_FOUND = 7
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
fun forgetDeletedKey(keyId: KeyIdentifier) {
if (deletedSoftwareKeys.remove(keyId)) {
SystemLogger.debug("Cleared deletion marker for ${keyId.alias}")
}
}
override val serviceName = "android.system.keystore2.IKeystoreService/default"
override val processName = "keystore2"
override val injectionCommand = "exec ./inject `pidof keystore2` libTEESimulator.so entry"
override val interceptedCodes: IntArray by lazy {
listOfNotNull(
GET_KEY_ENTRY_TRANSACTION,
DELETE_KEY_TRANSACTION,
UPDATE_SUBCOMPONENT_TRANSACTION,
LIST_ENTRIES_TRANSACTION,
LIST_ENTRIES_BATCHED_TRANSACTION,
GET_NUMBER_OF_ENTRIES_TRANSACTION,
)
.toIntArray()
}
/**
* This method is called once the main service is hooked. It proceeds to find and hook the
* security level sub-services (e.g., TEE, StrongBox).
@@ -74,7 +100,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
SystemLogger.info("Found TEE SecurityLevel. Registering interceptor...")
val interceptor =
KeyMintSecurityLevelInterceptor(tee, SecurityLevel.TRUSTED_ENVIRONMENT)
register(backdoor, tee.asBinder(), interceptor)
register(
backdoor,
tee.asBinder(),
interceptor,
KeyMintSecurityLevelInterceptor.INTERCEPTED_CODES,
)
interceptor.loadPersistedKeys()
}
}
@@ -86,7 +117,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
SystemLogger.info("Found StrongBox SecurityLevel. Registering interceptor...")
val interceptor =
KeyMintSecurityLevelInterceptor(strongbox, SecurityLevel.STRONGBOX)
register(backdoor, strongbox.asBinder(), interceptor)
register(
backdoor,
strongbox.asBinder(),
interceptor,
KeyMintSecurityLevelInterceptor.INTERCEPTED_CODES,
)
interceptor.loadPersistedKeys()
}
}
@@ -102,7 +138,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
callingPid: Int,
data: Parcel,
): TransactionResult {
if (code == LIST_ENTRIES_TRANSACTION || code == LIST_ENTRIES_BATCHED_TRANSACTION) {
if (code == GET_NUMBER_OF_ENTRIES_TRANSACTION) {
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid, true)
return if (ConfigurationManager.shouldSkipUid(callingUid))
TransactionResult.ContinueAndSkipPost
else TransactionResult.Continue
} else if (code == LIST_ENTRIES_TRANSACTION || code == LIST_ENTRIES_BATCHED_TRANSACTION) {
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid, true)
val packages = ConfigurationManager.getPackagesForUid(callingUid).joinToString()
@@ -145,30 +186,79 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.ContinueAndSkipPost
if (descriptor.alias != null) {
SystemLogger.info("Handling ${transactionNames[code]!!} ${descriptor.alias}")
} else {
SystemLogger.info(
"Skip ${transactionNames[code]!!} for key [alias, blob, domain, nspace]: [${descriptor.alias}, ${descriptor.blob}, ${descriptor.domain}, ${descriptor.nspace}]"
)
return TransactionResult.ContinueAndSkipPost
}
val keyId = KeyIdentifier(callingUid, descriptor.alias)
if (code == DELETE_KEY_TRANSACTION) {
if (KeyMintSecurityLevelInterceptor.getGeneratedKeyResponse(keyId) != null) {
val keyId =
if (descriptor.alias != null) {
KeyIdentifier(callingUid, descriptor.alias)
} else if (descriptor.domain == Domain.KEY_ID) {
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
)?.let { info ->
KeyMintSecurityLevelInterceptor.generatedKeys.entries
.find { it.value.nspace == info.nspace && it.key.uid == callingUid }
?.key
}
} else null
if (keyId != null) {
val isSoftwareKey =
KeyMintSecurityLevelInterceptor.generatedKeys.containsKey(keyId)
KeyMintSecurityLevelInterceptor.cleanupKeyData(keyId)
SystemLogger.info(
"[TX_ID: $txId] Deleted cached keypair ${descriptor.alias}, replying with empty response."
)
return InterceptorUtils.createSuccessReply(writeResultCode = false)
if (isSoftwareKey) {
deletedSoftwareKeys.add(keyId)
SystemLogger.info(
"[TX_ID: $txId] Deleted cached keypair ${keyId.alias}, replying with empty response."
)
return InterceptorUtils.createSuccessReply(writeResultCode = false)
}
}
return TransactionResult.ContinueAndSkipPost
}
val response =
KeyMintSecurityLevelInterceptor.getGeneratedKeyResponse(keyId)
?: return TransactionResult.Continue
if (descriptor.alias == null) {
if (descriptor.domain == Domain.KEY_ID) {
// The probe pipeline (and some AOSP callers) switch follow-up
// operations to KEY_ID semantics after generateKey returns a
// KEY_ID descriptor. Without this branch, our software keys
// are invisible to KEY_ID-based getKeyEntry calls and the
// request falls through to the real keystore2 daemon, which
// legitimately responds with KEY_NOT_FOUND. Duck Detector's
// TimingSideChannelProbe captures that exception during its
// warmup phase and surfaces it as
// "Captured private binder exception during timing skip".
// Resolving by KEY_ID and returning the cached response keeps
// the call on the happy path, eliminating the warmup signal.
val info = KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
)
if (info?.response != null) {
SystemLogger.info(
"[TX_ID: $txId] Found generated response via KEY_ID nspace=${descriptor.nspace}"
)
return InterceptorUtils.createTypedObjectReply(info.response)
}
val teeResp = KeyMintSecurityLevelInterceptor.findTeeResponseByKeyId(
callingUid, descriptor.nspace
)
if (teeResp != null) {
SystemLogger.info(
"[TX_ID: $txId] Found TEE response via KEY_ID nspace=${descriptor.nspace}"
)
return InterceptorUtils.createTypedObjectReply(teeResp)
}
}
return TransactionResult.ContinueAndSkipPost
}
val keyId = KeyIdentifier(callingUid, descriptor.alias)
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}")
return InterceptorUtils.createErrorReply(RESPONSE_KEY_NOT_FOUND)
}
return TransactionResult.Continue
}
if (KeyMintSecurityLevelInterceptor.isAttestationKey(keyId))
SystemLogger.info("${descriptor.alias} was an attestation key")
@@ -203,10 +293,33 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
reply: Parcel?,
resultCode: Int,
): TransactionResult {
if (target != keystoreService || reply == null || InterceptorUtils.hasException(reply))
return TransactionResult.SkipTransaction
if (target != keystoreService || reply == null) return TransactionResult.SkipTransaction
if (InterceptorUtils.hasException(reply)) {
val normalized = InterceptorUtils.normalizeServiceSpecificReply(reply)
return if (normalized != null) TransactionResult.OverrideReply(normalized)
else TransactionResult.SkipTransaction
}
if (code == LIST_ENTRIES_TRANSACTION || code == LIST_ENTRIES_BATCHED_TRANSACTION) {
if (code == GET_NUMBER_OF_ENTRIES_TRANSACTION) {
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
return runCatching {
val hardwareCount = reply.readInt()
val softwareCount =
KeyMintSecurityLevelInterceptor.generatedKeys.keys.count {
it.uid == callingUid
}
val totalCount = hardwareCount + softwareCount
val parcel = Parcel.obtain().apply {
writeNoException()
writeInt(totalCount)
}
TransactionResult.OverrideReply(parcel)
}
.getOrElse {
SystemLogger.error("[TX_ID: $txId] Failed to modify getNumberOfEntries.", it)
TransactionResult.SkipTransaction
}
} else if (code == LIST_ENTRIES_TRANSACTION || code == LIST_ENTRIES_BATCHED_TRANSACTION) {
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
return runCatching {
@@ -241,23 +354,44 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
val response = reply.readTypedObject(KeyEntryResponse.CREATOR)!!
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
if (userUpdatedKeys.remove(keyId)) {
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: userUpdated=true, skipping patch" }
SystemLogger.debug("[TX_ID: $txId] Skipping cert patch for user-updated key $keyId.")
return TransactionResult.SkipTransaction
}
val authorizations = response.metadata.authorizations
val parsedParameters =
KeyMintAttestation(
authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray()
)
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: isImport=${parsedParameters.isImportKey()} origin=${parsedParameters.origin} inImportedKeys=${KeyMintSecurityLevelInterceptor.importedKeys.contains(keyId)} hasPatchedChain=${KeyMintSecurityLevelInterceptor.getPatchedChain(keyId) != null} isAttestKey=${parsedParameters.isAttestKey()}" }
if (parsedParameters.isImportKey()) {
val retainedChain = KeyMintSecurityLevelInterceptor.getPatchedChain(keyId)
if (retainedChain == null) {
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: imported, no retained chain, skip" }
SystemLogger.info("[TX_ID: $txId] Skip patching for imported key (no prior attestation).")
return TransactionResult.SkipTransaction
}
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: imported, SERVING RETAINED CHAIN (detection vector!)" }
SystemLogger.info("[TX_ID: $txId] Imported key overwrote attested alias, serving retained chain for $keyId")
CertificateHelper.updateCertificateChain(response.metadata, retainedChain).getOrThrow()
response.metadata.authorizations =
InterceptorUtils.patchAuthorizations(
response.metadata.authorizations,
callingUid,
)
return InterceptorUtils.createTypedObjectReply(response)
}
if (KeyMintSecurityLevelInterceptor.importedKeys.contains(keyId)) {
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: in importedKeys set, skip" }
SystemLogger.debug("[TX_ID: $txId] Skipping attest-key override for imported key $keyId")
return TransactionResult.SkipTransaction
}
if (parsedParameters.isAttestKey()) {
SystemLogger.warning(
"[TX_ID: $txId] Found hardware attest key ${keyId.alias} in the reply."
@@ -276,28 +410,52 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
keyData.second.toTypedArray(),
)
.getOrThrow()
response.metadata.authorizations =
InterceptorUtils.patchAuthorizations(
response.metadata.authorizations,
callingUid,
)
keyDescriptor.nspace = SecureRandom().nextLong()
val newNspace = SecureRandom().nextLong()
response.metadata.key?.let { it.nspace = newNspace }
KeyMintSecurityLevelInterceptor.generatedKeys[keyId] =
KeyMintSecurityLevelInterceptor.GeneratedKeyInfo(
keyData.first,
keyDescriptor.nspace,
null,
newNspace,
response,
parsedParameters,
)
KeyMintSecurityLevelInterceptor.attestationKeys.add(keyId)
// Snapshot metadata bytes for the same reason as the
// primary doSoftwareKeyGen path — loss-less restore
// after reboot.
val metadataBytesForPersist = response.metadata?.let { md ->
runCatching {
val parcel = android.os.Parcel.obtain()
try {
md.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
}
GeneratedKeyPersistence.save(
keyId = keyId,
keyPair = keyData.first,
nspace = keyDescriptor.nspace,
secretKey = null,
nspace = newNspace,
securityLevel = response.metadata.keySecurityLevel,
certChain = keyData.second,
algorithm = parsedParameters.algorithm,
keySize = parsedParameters.keySize,
ecCurve = parsedParameters.ecCurve,
ecCurve = parsedParameters.ecCurve ?: 0,
purposes = parsedParameters.purpose,
digests = parsedParameters.digest,
isAttestationKey = true,
metadataBytes = metadataBytesForPersist,
)
return InterceptorUtils.createTypedObjectReply(response)
@@ -326,10 +484,16 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
)
finalChain =
AttestationPatcher.patchCertificateChain(originalChain, callingUid)
KeyMintSecurityLevelInterceptor.patchedChains[keyId] = finalChain
}
CertificateHelper.updateCertificateChain(response.metadata, finalChain)
.getOrThrow()
response.metadata.authorizations =
InterceptorUtils.patchAuthorizations(
response.metadata.authorizations,
callingUid,
)
return InterceptorUtils.createTypedObjectReply(response)
}
@@ -347,9 +511,29 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
private fun handleUpdateSubcomponent(callingUid: Int, data: Parcel): TransactionResult {
data.enforceInterface(IKeystoreService.DESCRIPTOR)
val descriptor = data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.ContinueAndSkipPost
val generatedKeyInfo =
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(callingUid, descriptor?.nspace)
?: return TransactionResult.ContinueAndSkipPost
when (descriptor.domain) {
Domain.KEY_ID ->
KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
)
Domain.APP ->
descriptor.alias?.let {
KeyMintSecurityLevelInterceptor.generatedKeys[KeyIdentifier(callingUid, it)]
}
else -> null
}
if (generatedKeyInfo == null) {
descriptor.alias?.let {
val kid = KeyIdentifier(callingUid, it)
userUpdatedKeys.add(kid)
SystemLogger.trace { "[TRACE] updateSubcomponent $kid: not generated key, added to userUpdatedKeys" }
}
return TransactionResult.ContinueAndSkipPost
}
SystemLogger.info("Updating sub-component with key[${generatedKeyInfo.nspace}]")
val metadata = generatedKeyInfo.response.metadata
@@ -431,6 +431,24 @@ private data class LegacyKeygenParameters(
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
earlyBootOnly = null,
allowWhileOnBody = null,
trustedUserPresenceRequired = null,
trustedConfirmationRequired = null,
noAuthRequired = null,
maxUsesPerBoot = null,
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
}
@@ -129,7 +129,7 @@ object ListEntriesHandler {
startPastAlias: String?,
): List<KeyDescriptor> {
return KeyMintSecurityLevelInterceptor.generatedKeys.keys
.filter { it.uid == uid && (startPastAlias == null || it.alias < startPastAlias) }
.filter { it.uid == uid && (startPastAlias == null || it.alias > startPastAlias) }
.map { keyId ->
KeyDescriptor().apply {
this.domain = Domain.APP
@@ -0,0 +1,74 @@
package org.matrix.TEESimulator.interception.keystore.shim
import android.hardware.security.keymint.Algorithm
import android.hardware.security.keymint.KeyPurpose
import android.hardware.security.keymint.KeyParameter
import android.hardware.security.keymint.Tag
import org.matrix.TEESimulator.attestation.KeyMintAttestation
object AuthorizeCreate {
fun check(
keyParams: KeyMintAttestation?,
opParams: KeyMintAttestation,
rawOpParams: Array<KeyParameter>? = null,
): Int? {
if (keyParams == null) return null
val purpose = opParams.purpose.firstOrNull() ?: return null
// Algorithm-level rejection runs before purpose-list check (AOSP HAL behavior)
return checkAlgorithmPurpose(keyParams, purpose)
?: checkPurpose(keyParams, purpose)
?: 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) &&
(purpose == KeyPurpose.VERIFY || purpose == KeyPurpose.ENCRYPT)
) {
return KeystoreErrorCodes.unsupportedPurpose
}
if (algo == Algorithm.RSA && purpose == KeyPurpose.AGREE_KEY)
return KeystoreErrorCodes.unsupportedPurpose
return null
}
private fun checkPurpose(keyParams: KeyMintAttestation, purpose: Int): Int? {
if (purpose == KeyPurpose.WRAP_KEY)
return KeystoreErrorCodes.incompatiblePurpose
if (purpose !in keyParams.purpose)
return KeystoreErrorCodes.incompatiblePurpose
return null
}
private fun checkTemporalValidity(keyParams: KeyMintAttestation, purpose: Int): Int? {
val now = System.currentTimeMillis()
keyParams.activeDateTime?.let { activeDate ->
if (now < activeDate.time) return KeystoreErrorCodes.keyNotYetValid
}
keyParams.originationExpireDateTime?.let { expireDate ->
if (purpose == KeyPurpose.SIGN || purpose == KeyPurpose.ENCRYPT) {
if (now > expireDate.time) return KeystoreErrorCodes.keyExpired
}
}
keyParams.usageExpireDateTime?.let { expireDate ->
if (purpose == KeyPurpose.VERIFY || purpose == KeyPurpose.DECRYPT) {
if (now > expireDate.time) return KeystoreErrorCodes.keyExpired
}
}
return null
}
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)
return KeystoreErrorCodes.callerNonceProhibited
return null
}
}
@@ -29,13 +29,47 @@ data class PersistedKeyData(
val ecCurve: Int,
val purposes: List<Int>,
val digests: List<Int>,
/** PKCS#8-encoded private key for asymmetric records, empty for symmetric. */
val privateKeyBytes: ByteArray,
val certChainBytes: List<ByteArray>,
/**
* Byte-identical KeyMetadata parcel snapshot. Restoring authorizations
* directly from these bytes preserves tag count, order, and exact
* security-level annotations across reboots — the kind of structural
* details apps fingerprint to decide whether the alias is still
* "the same key".
*/
val metadataBytes: ByteArray,
/**
* Raw secret material for symmetric records (AES, HMAC, 3DES). Empty
* for asymmetric. Critical for AndroidX security crypto MasterKey
* (AES-GCM-256) — without this every reboot regenerates a fresh AES
* key and EncryptedSharedPreferences becomes undecryptable, which is
* what banking apps interpret as session expiry and force a relogin.
*/
val symmetricKeyBytes: ByteArray,
val symmetricAlgorithm: String,
)
object GeneratedKeyPersistence {
private const val FORMAT_VERSION = 1
/**
* Single source of truth for the on-disk format. Bump this every time
* the layout changes; older numbers are silently skipped on read so
* stale dev artifacts and pre-fix upstream files can't be partially
* rehydrated into broken in-memory state.
*
* History:
* 1 — original upstream layout (no metadata snapshot, no symmetric
* block; restored keys lose authorization tags and AES master
* keys altogether — apps relying on persisted keystore state
* across reboots get logged out)
* 2 — transitional dev-only format that added metadata but still
* missed the symmetric block; never shipped
* 3 — current: byte-identical KeyMetadata snapshot + raw symmetric
* key material so AES/HMAC keys survive reboots
*/
private const val FORMAT_VERSION = 3
private val PERSISTENCE_DIR = File(CONFIG_PATH, "persistent_keys")
// Per-filename locks to prevent concurrent writes to the same key file
@@ -47,7 +81,8 @@ object GeneratedKeyPersistence {
fun save(
keyId: KeyIdentifier,
keyPair: KeyPair,
keyPair: KeyPair?,
secretKey: javax.crypto.SecretKey?,
nspace: Long,
securityLevel: Int,
certChain: List<Certificate>,
@@ -57,7 +92,11 @@ object GeneratedKeyPersistence {
purposes: List<Int>,
digests: List<Int>,
isAttestationKey: Boolean,
metadataBytes: ByteArray? = null,
) {
require(keyPair != null || secretKey != null) {
"Either keyPair or secretKey must be provided"
}
val filename = keyFileName(keyId.uid, keyId.alias)
val lock = getLockForKey(filename)
SystemLogger.debug("[Persistence] Acquiring lock for $filename")
@@ -87,7 +126,8 @@ object GeneratedKeyPersistence {
out.writeInt(digests.size)
digests.forEach { out.writeInt(it) }
val pkBytes = keyPair.private.encoded
// Asymmetric key block (empty for symmetric-only).
val pkBytes = keyPair?.private?.encoded ?: ByteArray(0)
out.writeInt(pkBytes.size)
out.write(pkBytes)
@@ -97,6 +137,23 @@ object GeneratedKeyPersistence {
out.writeInt(encoded.size)
out.write(encoded)
}
// Metadata snapshot (always present, may be empty
// if the live KeyMetadata could not be marshalled).
val mdBytes = metadataBytes ?: ByteArray(0)
out.writeInt(mdBytes.size)
if (mdBytes.isNotEmpty()) out.write(mdBytes)
// Symmetric key block (empty for asymmetric keys).
if (secretKey != null) {
val skBytes = secretKey.encoded
out.writeUTF(secretKey.algorithm)
out.writeInt(skBytes.size)
out.write(skBytes)
} else {
out.writeUTF("")
out.writeInt(0)
}
}
} catch (e: Exception) {
tmpFile.delete()
@@ -129,6 +186,7 @@ object GeneratedKeyPersistence {
val file = File(PERSISTENCE_DIR, keyFileName(keyId.uid, keyId.alias))
if (file.exists()) {
if (file.delete()) {
fileLocks.remove(keyFileName(keyId.uid, keyId.alias))
SystemLogger.debug("Deleted persisted key: $keyId")
} else {
SystemLogger.warning("Failed to delete persisted key file: ${file.name}")
@@ -158,6 +216,7 @@ object GeneratedKeyPersistence {
if (file.delete()) count++
}
}
fileLocks.clear()
SystemLogger.info("Deleted $count persisted key files")
}.onFailure { e ->
SystemLogger.error("Failed to delete all persisted keys", e)
@@ -187,8 +246,17 @@ object GeneratedKeyPersistence {
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
val version = input.readInt()
if (version != FORMAT_VERSION) {
SystemLogger.warning(
"Skipping ${file.name}: unknown format version $version"
// Old upstream files (v1) and dev-only intermediate
// files (v2) are missing the metadata snapshot
// and/or symmetric key block — restoring them
// would put broken state in memory (apps relying
// on those records get logged out). Skip and let
// the next generateKey re-create cleanly with the
// new format. Affected apps re-login once after
// upgrade, then never again.
SystemLogger.info(
"Skipping ${file.name}: legacy format version $version. " +
"It will be replaced on next generateKey for this alias."
)
return@runCatching
}
@@ -210,7 +278,7 @@ object GeneratedKeyPersistence {
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
val pkBytes = ByteArray(pkLen)
input.readFully(pkBytes)
if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map {
@@ -220,6 +288,17 @@ object GeneratedKeyPersistence {
certBytes
}
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
val skAlgo = input.readUTF()
val skLen = requireBounds(input.readInt(), 8192, "skLen")
val skBytes = ByteArray(skLen).also {
if (skLen > 0) input.readFully(it)
}
if (storedSecLevel == securityLevel) {
result.add(
PersistedKeyData(
@@ -235,6 +314,9 @@ object GeneratedKeyPersistence {
digests = digests,
privateKeyBytes = pkBytes,
certChainBytes = certChainBytes,
metadataBytes = metadataBytes,
symmetricKeyBytes = skBytes,
symmetricAlgorithm = skAlgo,
)
)
}
@@ -290,7 +372,7 @@ object GeneratedKeyPersistence {
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
val version = input.readInt()
if (version != FORMAT_VERSION) {
SystemLogger.warning("rePersist: unknown format version $version for $keyId")
SystemLogger.warning("rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)")
return
}
readPersistedKeyData(input)
@@ -301,9 +383,29 @@ object GeneratedKeyPersistence {
return
}
val keyPair = generatedKeyInfo.keyPair
val secretKey = generatedKeyInfo.secretKey
if (keyPair == null && secretKey == null) {
SystemLogger.warning("rePersist: no key material for $keyId")
return
}
// Serialize the live KeyMetadata (now contains the user-installed cert
// chain via updateSubcomponent) so the next boot restores byte-identical
// metadata. KeyMetadata is binder-free, so marshall() is safe here.
val metadataBytes = runCatching {
android.os.Parcel.obtain().let { parcel ->
try {
metadata.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
}.getOrNull()
save(
keyId = keyId,
keyPair = generatedKeyInfo.keyPair,
keyPair = keyPair,
secretKey = secretKey,
nspace = generatedKeyInfo.nspace,
securityLevel = secLevel,
certChain = newChain.toList(),
@@ -313,6 +415,7 @@ object GeneratedKeyPersistence {
purposes = persisted.purposes,
digests = persisted.digests,
isAttestationKey = persisted.isAttestationKey,
metadataBytes = metadataBytes,
)
SystemLogger.debug("Re-persisted key $keyId with updated cert chain")
}
@@ -329,7 +432,8 @@ object GeneratedKeyPersistence {
return digest.joinToString("") { "%02x".format(it) } + ".bin"
}
// Reads all fields after version has already been consumed
// Reads all fields after the version int has already been consumed
// and validated by the caller.
private fun readPersistedKeyData(input: DataInputStream): PersistedKeyData {
val secLevel = input.readInt()
val uid = input.readInt()
@@ -348,7 +452,7 @@ object GeneratedKeyPersistence {
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
val pkBytes = ByteArray(pkLen)
input.readFully(pkBytes)
if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map {
@@ -358,6 +462,17 @@ object GeneratedKeyPersistence {
certBytes
}
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
val skAlgo = input.readUTF()
val skLen = requireBounds(input.readInt(), 8192, "skLen")
val skBytes = ByteArray(skLen).also {
if (skLen > 0) input.readFully(it)
}
return PersistedKeyData(
uid = uid,
alias = alias,
@@ -371,6 +486,9 @@ object GeneratedKeyPersistence {
digests = digests,
privateKeyBytes = pkBytes,
certChainBytes = certChainBytes,
metadataBytes = metadataBytes,
symmetricKeyBytes = skBytes,
symmetricAlgorithm = skAlgo,
)
}
}
@@ -13,6 +13,7 @@ import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
class OperationInterceptor(
private val original: IKeystoreOperation,
private val backdoor: IBinder,
private val isAead: Boolean,
) : BinderInterceptor() {
override fun onPreTransact(
@@ -27,6 +28,10 @@ class OperationInterceptor(
val methodName = transactionNames[code] ?: "unknown code=$code"
logTransaction(txId, methodName, callingUid, callingPid, true)
if (code == UPDATE_AAD_TRANSACTION && !isAead) {
return InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
}
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
KeyMintSecurityLevelInterceptor.removeOperationInterceptor(target, backdoor)
}
@@ -44,6 +49,9 @@ class OperationInterceptor(
private val ABORT_TRANSACTION =
InterceptorUtils.getTransactCode(IKeystoreOperation.Stub::class.java, "abort")
val INTERCEPTED_CODES =
intArrayOf(UPDATE_AAD_TRANSACTION, FINISH_TRANSACTION, ABORT_TRANSACTION)
private val transactionNames: Map<Int, String> by lazy {
IKeystoreOperation.Stub::class
.java
@@ -3,10 +3,15 @@ package org.matrix.TEESimulator.interception.keystore.shim
import android.hardware.security.keymint.Algorithm
import android.hardware.security.keymint.BlockMode
import android.hardware.security.keymint.Digest
import android.hardware.security.keymint.KeyParameter
import android.hardware.security.keymint.KeyParameterValue
import android.hardware.security.keymint.KeyPurpose
import android.hardware.security.keymint.PaddingMode
import android.os.RemoteException
import android.hardware.security.keymint.Tag
import android.os.ServiceSpecificException
import java.util.concurrent.locks.LockSupport
import android.system.keystore2.IKeystoreOperation
import android.system.keystore2.KeyParameters
import java.security.KeyPair
import java.security.Signature
import java.security.SignatureException
@@ -15,16 +20,16 @@ import org.matrix.TEESimulator.attestation.KeyMintAttestation
import org.matrix.TEESimulator.logging.KeyMintParameterLogger
import org.matrix.TEESimulator.logging.SystemLogger
// A sealed interface to represent the different cryptographic operations we can perform.
private sealed interface CryptoPrimitive {
fun updateAad(aadInput: ByteArray?) {
throw ServiceSpecificException(KeystoreErrorCodes.invalidTag)
}
fun update(data: ByteArray?): ByteArray?
fun finish(data: ByteArray?, signature: ByteArray?): ByteArray?
fun abort()
fun getBeginParameters(): Array<KeyParameter>? = null
}
// Helper object to map KeyMint constants to JCA algorithm strings.
private object JcaAlgorithmMapper {
fun mapSignatureAlgorithm(params: KeyMintAttestation): String {
val digest =
@@ -34,16 +39,18 @@ private object JcaAlgorithmMapper {
Digest.SHA_2_512 -> "SHA512"
else -> "NONE"
}
val keyAlgo =
when (params.algorithm) {
Algorithm.EC -> "ECDSA"
Algorithm.RSA -> "RSA"
else ->
throw IllegalArgumentException(
"Unsupported signature algorithm: ${params.algorithm}"
)
return when (params.algorithm) {
Algorithm.EC -> "${digest}withECDSA"
Algorithm.RSA -> {
val isPss = params.padding.firstOrNull() == PaddingMode.RSA_PSS
if (isPss) "${digest}withRSA/PSS" else "${digest}withRSA"
}
return "${digest}with${keyAlgo}"
else ->
throw ServiceSpecificException(
KeystoreErrorCodes.incompatibleAlgorithm,
"Unsupported signature algorithm: ${params.algorithm}",
)
}
}
fun mapCipherAlgorithm(params: KeyMintAttestation): String {
@@ -52,30 +59,32 @@ private object JcaAlgorithmMapper {
Algorithm.RSA -> "RSA"
Algorithm.AES -> "AES"
else ->
throw IllegalArgumentException(
"Unsupported cipher algorithm: ${params.algorithm}"
throw ServiceSpecificException(
KeystoreErrorCodes.incompatibleAlgorithm,
"Unsupported cipher algorithm: ${params.algorithm}",
)
}
val blockMode =
when (params.blockMode.firstOrNull()) {
BlockMode.ECB -> "ECB"
BlockMode.CBC -> "CBC"
BlockMode.CTR -> "CTR"
BlockMode.GCM -> "GCM"
else -> "ECB" // Default for RSA
else -> "ECB"
}
val padding =
when (params.padding.firstOrNull()) {
PaddingMode.NONE -> "NoPadding"
PaddingMode.PKCS7 -> "PKCS7Padding"
PaddingMode.RSA_PKCS1_1_5_ENCRYPT -> "PKCS1Padding"
PaddingMode.RSA_PKCS1_1_5_SIGN -> "PKCS1Padding"
PaddingMode.RSA_OAEP -> "OAEPPadding"
else -> "NoPadding" // Default for GCM
else -> "NoPadding"
}
return "$keyAlgo/$blockMode/$padding"
}
}
// Concrete implementation for Signing.
private class Signer(keyPair: KeyPair, params: KeyMintAttestation) : CryptoPrimitive {
private val signature: Signature =
Signature.getInstance(JcaAlgorithmMapper.mapSignatureAlgorithm(params)).apply {
@@ -95,7 +104,6 @@ private class Signer(keyPair: KeyPair, params: KeyMintAttestation) : CryptoPrimi
override fun abort() {}
}
// Concrete implementation for Verification.
private class Verifier(keyPair: KeyPair, params: KeyMintAttestation) : CryptoPrimitive {
private val signature: Signature =
Signature.getInstance(JcaAlgorithmMapper.mapSignatureAlgorithm(params)).apply {
@@ -109,107 +117,353 @@ 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 SignatureException("Signature to verify is null")
if (signature == null) {
throw ServiceSpecificException(KeystoreErrorCodes.verificationFailed, "Signature to verify is null")
}
if (!this.signature.verify(signature)) {
// Throwing an exception is how Keystore signals verification failure.
throw SignatureException("Signature verification failed")
throw ServiceSpecificException(KeystoreErrorCodes.verificationFailed, "Signature verification failed")
}
// A successful verification returns no data.
return null
}
override fun abort() {}
}
// Concrete implementation for Encryption/Decryption.
private class CipherPrimitive(
keyPair: KeyPair,
cryptoKey: java.security.Key,
params: KeyMintAttestation,
private val opMode: Int,
) : CryptoPrimitive {
private val isAead = params.blockMode.firstOrNull() == BlockMode.GCM
private val cipher: Cipher =
Cipher.getInstance(JcaAlgorithmMapper.mapCipherAlgorithm(params)).apply {
val key = if (opMode == Cipher.ENCRYPT_MODE) keyPair.public else keyPair.private
init(opMode, key)
val nonce = params.nonce
if (nonce != null && isAead) {
init(opMode, cryptoKey, javax.crypto.spec.GCMParameterSpec(128, nonce))
} else if (nonce != null) {
init(opMode, cryptoKey, javax.crypto.spec.IvParameterSpec(nonce))
} else {
init(opMode, cryptoKey)
}
}
override fun updateAad(aadInput: ByteArray?) {
if (!isAead) throw ServiceSpecificException(KeystoreErrorCodes.invalidTag)
if (aadInput != null) cipher.updateAAD(aadInput)
}
override fun update(data: ByteArray?): ByteArray? =
if (data != null) cipher.update(data) else null
override fun finish(data: ByteArray?, signature: ByteArray?): ByteArray? =
if (data != null) cipher.doFinal(data) else cipher.doFinal()
override fun getBeginParameters(): Array<KeyParameter>? {
val iv = cipher.iv ?: return null
return arrayOf(
KeyParameter().apply {
tag = Tag.NONCE
value = KeyParameterValue.blob(iv)
}
)
}
override fun abort() {}
}
/**
* A software-only implementation of a cryptographic operation. This class acts as a controller,
* delegating to a specific cryptographic primitive based on the operation's purpose.
*/
class SoftwareOperation(private val txId: Long, keyPair: KeyPair, params: KeyMintAttestation) {
// This now holds the specific strategy object (Signer, Verifier, etc.)
private class KeyAgreementPrimitive(keyPair: KeyPair) : CryptoPrimitive {
private val agreement: javax.crypto.KeyAgreement =
javax.crypto.KeyAgreement.getInstance("ECDH").apply { init(keyPair.private) }
override fun update(data: ByteArray?): ByteArray? = null
override fun finish(data: ByteArray?, signature: ByteArray?): ByteArray? {
if (data == null)
throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"Peer public key required for key agreement",
)
val peerKey =
java.security.KeyFactory.getInstance("EC")
.generatePublic(java.security.spec.X509EncodedKeySpec(data))
agreement.doPhase(peerKey, true)
return agreement.generateSecret()
}
override fun abort() {}
}
class SoftwareOperation(
private val txId: Long,
keyPair: KeyPair?,
secretKey: javax.crypto.SecretKey?,
params: KeyMintAttestation,
private val latencyFloorMs: Long = 0L,
) {
private val primitive: CryptoPrimitive
@Volatile var finalized = false
private set
var onFinishCallback: (() -> Unit)? = null
val beginParameters: KeyParameters?
get() {
val params = primitive.getBeginParameters() ?: return null
if (params.isEmpty()) return null
return KeyParameters().apply { keyParameter = params }
}
init {
// The "Strategy" pattern: choose the implementation based on the purpose.
// For simplicity, we only consider the first purpose listed.
val purpose = params.purpose.firstOrNull()
val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN"
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Initializing for purpose: $purposeName.")
if (purpose == null) {
// Defensive: if params somehow restored without a PURPOSE tag
// (corrupt v2 metadata, mismatched authorizations array on load,
// or future format drift) the original code crashed with NPE
// because Signer/Verifier/Cipher all dereference keyPair!!
// before checking purpose. Surface a clean keystore error
// instead so callers see a normal-looking operation failure
// they can recover from rather than the process appearing to
// silently corrupt their session.
SystemLogger.warning(
"[SoftwareOp TX_ID: $txId] Purpose missing on restored key " +
"(authorizations=${params.purpose}, keyPair=${if (keyPair != null) "present" else "null"}, " +
"secretKey=${if (secretKey != null) "present" else "null"}). " +
"Returning unsupportedPurpose."
)
throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"Restored key has no PURPOSE authorization",
)
}
primitive =
when (purpose) {
KeyPurpose.SIGN -> Signer(keyPair, params)
KeyPurpose.VERIFY -> Verifier(keyPair, params)
KeyPurpose.ENCRYPT -> CipherPrimitive(keyPair, params, Cipher.ENCRYPT_MODE)
KeyPurpose.DECRYPT -> CipherPrimitive(keyPair, params, Cipher.DECRYPT_MODE)
KeyPurpose.SIGN -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] SIGN requested but keyPair is null",
)
Signer(kp, params)
}
KeyPurpose.VERIFY -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] VERIFY requested but keyPair is null",
)
Verifier(kp, params)
}
KeyPurpose.ENCRYPT -> {
val key: java.security.Key = secretKey ?: keyPair?.public
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] ENCRYPT requires either secretKey or keyPair.public",
)
CipherPrimitive(key, params, Cipher.ENCRYPT_MODE)
}
KeyPurpose.DECRYPT -> {
val key: java.security.Key = secretKey ?: keyPair?.private
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] DECRYPT requires either secretKey or keyPair.private",
)
CipherPrimitive(key, params, Cipher.DECRYPT_MODE)
}
KeyPurpose.AGREE_KEY -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] AGREE_KEY requested but keyPair is null",
)
KeyAgreementPrimitive(kp)
}
else ->
throw UnsupportedOperationException("Unsupported operation purpose: $purpose")
throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"Unsupported operation purpose: $purpose",
)
}
}
private fun checkActive() {
if (finalized) {
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})")
throw ServiceSpecificException(KeystoreErrorCodes.tooMuchData)
}
}
fun updateAad(aadInput: ByteArray?) {
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)")
} catch (throwable: Throwable) {
val top = throwable.stackTrace.firstOrNull()?.toString() ?: "<no-frame>"
val code = (throwable as? ServiceSpecificException)?.errorCode
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() THREW class=${throwable::class.java.name} code=$code msg=${throwable.message} top=$top")
throw throwable
}
}
fun update(data: ByteArray?): ByteArray? {
SystemLogger.debug("[SoftwareOp TX_ID: $txId] update() inputSize=${data?.size ?: 0}")
checkActive()
checkInputLength(data)
try {
return primitive.update(data)
} catch (e: ServiceSpecificException) {
throw e
} catch (e: Exception) {
SystemLogger.error("[SoftwareOp TX_ID: $txId] Failed to update operation.", e)
throw e
throw mapToServiceSpecificException(e)
}
}
fun finish(data: ByteArray?, signature: ByteArray?): ByteArray? {
checkActive()
checkInputLength(data)
try {
val startNs = if (latencyFloorMs > 0) System.nanoTime() else 0L
val result = primitive.finish(data, signature)
if (latencyFloorMs > 0) {
val elapsedMs = (System.nanoTime() - startNs) / 1_000_000
val delayMs = latencyFloorMs - elapsedMs
if (delayMs > 0) LockSupport.parkNanos(delayMs * 1_000_000)
}
finalized = true
onFinishCallback?.invoke()
SystemLogger.info("[SoftwareOp TX_ID: $txId] Finished operation successfully.")
return result
} catch (e: ServiceSpecificException) {
throw e
} catch (e: Exception) {
SystemLogger.error("[SoftwareOp TX_ID: $txId] Failed to finish operation.", e)
// Re-throw the exception so the binder can report it to the client.
throw e
throw mapToServiceSpecificException(e)
}
}
fun abort() {
finalized = true
primitive.abort()
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)
else -> ServiceSpecificException(KeystoreErrorCodes.unknownError, e.message)
}
companion object {
private const val MAX_RECEIVE_DATA = 0x8000
}
}
internal object KeystoreErrorCodes {
val tooMuchData: Int by lazy {
resolveField("android.system.keystore2.ResponseCode", "TOO_MUCH_DATA", 21)
}
val invalidOperationHandle: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INVALID_OPERATION_HANDLE", -28)
}
val invalidTag: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INVALID_TAG", -76)
}
val verificationFailed: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "VERIFICATION_FAILED", -30)
}
val invalidArgument: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INVALID_ARGUMENT", -38)
}
val invalidInputLength: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INVALID_INPUT_LENGTH", -21)
}
val incompatibleKey: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INCOMPATIBLE_KEY", -31)
}
val incompatiblePurpose: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INCOMPATIBLE_PURPOSE", -13)
}
val unsupportedPurpose: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "UNSUPPORTED_PURPOSE", -14)
}
val incompatibleAlgorithm: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "INCOMPATIBLE_ALGORITHM", -18)
}
val keyNotYetValid: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "KEY_NOT_YET_VALID", -39)
}
val keyExpired: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "KEY_EXPIRED", -40)
}
val callerNonceProhibited: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "CALLER_NONCE_PROHIBITED", -55)
}
val unknownError: Int by lazy {
resolveField("android.hardware.security.keymint.ErrorCode", "UNKNOWN_ERROR", -1000)
}
fun resolveField(className: String, fieldName: String, fallback: Int): Int =
runCatching {
Class.forName(className).getField(fieldName).getInt(null)
}.getOrElse {
SystemLogger.debug("Resolved $className.$fieldName via fallback: $fallback")
fallback
}
}
/** The Binder interface for our [SoftwareOperation]. */
class SoftwareOperationBinder(private val operation: SoftwareOperation) :
IKeystoreOperation.Stub() {
@Throws(RemoteException::class)
@Synchronized
override fun updateAad(aadInput: ByteArray?) {
SystemLogger.info("[SoftwareOpBinder] updateAad() ENTRY callingUid=${android.os.Binder.getCallingUid()} size=${aadInput?.size ?: 0}")
try {
operation.updateAad(aadInput)
SystemLogger.info("[SoftwareOpBinder] updateAad() RETURNED_NORMALLY")
} catch (throwable: Throwable) {
val code = (throwable as? ServiceSpecificException)?.errorCode
SystemLogger.info("[SoftwareOpBinder] updateAad() PROPAGATING class=${throwable::class.java.name} code=$code msg=${throwable.message}")
throw throwable
}
}
@Synchronized
override fun update(input: ByteArray?): ByteArray? {
return operation.update(input)
}
@Throws(RemoteException::class)
@Synchronized
override fun finish(input: ByteArray?, signature: ByteArray?): ByteArray? {
return operation.finish(input, signature)
}
@Throws(RemoteException::class)
@Synchronized
override fun abort() {
operation.abort()
}
@@ -1,41 +1,86 @@
package org.matrix.TEESimulator.logging
import android.util.Log
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import org.matrix.TEESimulator.BuildConfig
/**
* A centralized logging utility for the TEESimulator application. This object provides a consistent
* logging tag and format for all application logs, making it easier to filter and debug in Logcat.
*
* Includes a rate limiter that caps logd syscalls during binder stress to prevent thread pool
* contention. The first [RATE_LIMIT_BURST] messages per [RATE_LIMIT_WINDOW_MS] window are logged
* normally; subsequent messages are suppressed and a summary is emitted when the window resets.
*/
object SystemLogger {
// The tag used for all log messages from this application.
private const val TAG = "TEESimulator"
@PublishedApi internal const val TAG = "TEESimulator"
private val isDebugBuild = BuildConfig.DEBUG
@PublishedApi internal val isDebugBuild = BuildConfig.DEBUG
// Rate limiter: allow BURST messages per WINDOW, then suppress until window resets.
private const val RATE_LIMIT_BURST = 15
private const val RATE_LIMIT_WINDOW_MS = 1000L
private val windowStart = AtomicLong(System.currentTimeMillis())
private val windowCount = AtomicInteger(0)
private val suppressedCount = AtomicInteger(0)
/**
* Returns true if this message should be emitted. Resets the window if expired
* and emits a suppression summary for the previous window.
*/
@PublishedApi internal fun acquireLogPermit(): Boolean {
val now = System.currentTimeMillis()
val start = windowStart.get()
if (now - start > RATE_LIMIT_WINDOW_MS) {
// Window expired: reset and emit suppression summary if needed.
if (windowStart.compareAndSet(start, now)) {
val suppressed = suppressedCount.getAndSet(0)
windowCount.set(1) // this call counts as #1 in the new window
if (suppressed > 0) {
Log.i(TAG, "[rate-limit] suppressed $suppressed log messages in previous window")
}
return true
}
}
val count = windowCount.incrementAndGet()
if (count <= RATE_LIMIT_BURST) return true
suppressedCount.incrementAndGet()
return false
}
/**
* Logs a debug message. Use this for fine-grained information that is useful for debugging.
*
* @param message The message to log.
*/
fun debug(message: String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.d(TAG, message)
}
/** Lazy debug: lambda only evaluates if message will be logged. */
inline fun debug(message: () -> String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.d(TAG, message())
}
/**
* Logs an informational message. Use this to report major application lifecycle events.
*
* @param message The message to log.
*/
fun info(message: String) {
if (!acquireLogPermit()) return
Log.i(TAG, message)
}
/** Lazy info: lambda only evaluates if message will be logged. */
inline fun info(message: () -> String) {
if (!acquireLogPermit()) return
Log.i(TAG, message())
}
/**
* Logs a warning message. Use this to report unexpected but non-fatal issues.
*
* @param message The message to log.
* @param throwable An optional exception to log with the message.
* Logs a warning message. Warnings are never rate-limited.
*/
fun warning(message: String, throwable: Throwable? = null) {
if (throwable != null) {
@@ -46,11 +91,7 @@ object SystemLogger {
}
/**
* Logs an error message. Use this to report fatal errors or exceptions that disrupt
* functionality.
*
* @param message The message to log.
* @param throwable An optional exception to log with the message.
* Logs an error message. Errors are never rate-limited.
*/
fun error(message: String, throwable: Throwable? = null) {
if (throwable != null) {
@@ -63,11 +104,22 @@ object SystemLogger {
/**
* Logs a verbose message. This level is for highly detailed logs that are generally not needed
* unless tracking a very specific issue.
*
* @param message The message to log.
*/
fun verbose(message: String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.v(TAG, message)
}
/** Lazy verbose: lambda only evaluates if message will be logged. */
inline fun verbose(message: () -> String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.v(TAG, message())
}
inline fun trace(message: () -> String) {
if (!isDebugBuild) return
Log.w(TAG, message())
}
}
@@ -8,7 +8,6 @@ import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import java.security.spec.ECGenParameterSpec
import java.security.spec.RSAKeyGenParameterSpec
import java.util.Date
@@ -36,6 +35,8 @@ import org.matrix.TEESimulator.logging.SystemLogger
*/
object CertificateGenerator {
private const val UNDEFINED_NOT_AFTER = 253402300799000L
/**
* Generates a software-based cryptographic key pair.
*
@@ -49,7 +50,10 @@ object CertificateGenerator {
Algorithm.EC -> "EC" to ECGenParameterSpec(params.ecCurveName)
Algorithm.RSA ->
"RSA" to
RSAKeyGenParameterSpec(params.keySize, params.rsaPublicExponent)
RSAKeyGenParameterSpec(
params.keySize,
params.rsaPublicExponent ?: RSAKeyGenParameterSpec.F4,
)
else ->
throw IllegalArgumentException(
"Unsupported algorithm: ${params.algorithm}"
@@ -88,11 +92,15 @@ object CertificateGenerator {
"Attestation challenge exceeds length limit (${challenge.size} > ${AttestationConstants.CHALLENGE_LENGTH_LIMIT})"
)
return runCatching {
return try {
// AOSP ta/src/keys.rs:451-478: no challenge + no attestKey = self-signed, depth 1
if (challenge == null && attestKeyAlias == null) {
SystemLogger.trace { "[certgen] no-challenge key: self-signed, depth=1, purposes=${params.purpose}" }
return listOf(buildSelfSignedCertificate(subjectKeyPair, params))
}
val keybox = getKeyboxForAlgorithm(uid, params.algorithm)
// Determine the signing key and issuer. If an attestKey is provided, use it.
// Otherwise, fall back to the root key from the keybox.
val (signingKey, issuer) =
if (attestKeyAlias != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
getAttestationKeyInfo(uid, attestKeyAlias)?.let { it.first to it.second }
@@ -101,20 +109,20 @@ object CertificateGenerator {
keybox.keyPair to getIssuerFromKeybox(keybox)
}
// Build the new leaf certificate with the simulated attestation.
val leafCert =
buildCertificate(subjectKeyPair, signingKey, issuer, params, uid, securityLevel)
// If not self-attesting, the chain is just the leaf. Otherwise, append the keybox
// chain.
if (attestKeyAlias != null) {
listOf(leafCert)
} else {
listOf(leafCert) + keybox.certificates
}
} catch (e: android.os.ServiceSpecificException) {
throw e
} catch (e: Exception) {
SystemLogger.error("Failed to generate certificate chain.", e)
null
}
.onFailure { SystemLogger.error("Failed to generate certificate chain.", it) }
.getOrNull()
}
/**
@@ -128,7 +136,7 @@ object CertificateGenerator {
params: KeyMintAttestation,
securityLevel: Int,
): Pair<KeyPair, List<Certificate>>? {
return runCatching {
return try {
SystemLogger.info(
"Generating new attested key pair for alias: '$alias' (UID: $uid)"
)
@@ -144,11 +152,12 @@ object CertificateGenerator {
"Successfully generated new certificate chain for alias: '$alias'."
)
Pair(newKeyPair, chain)
} catch (e: android.os.ServiceSpecificException) {
throw e
} catch (e: Exception) {
SystemLogger.error("Failed to generate attested key pair for alias '$alias'.", e)
null
}
.onFailure {
SystemLogger.error("Failed to generate attested key pair for alias '$alias'.", it)
}
.getOrNull()
}
fun getIssuerFromKeybox(keybox: KeyBox) =
@@ -163,7 +172,10 @@ object CertificateGenerator {
else -> throw IllegalArgumentException("Unsupported algorithm ID: $algorithm")
}
return KeyBoxManager.getAttestationKey(keyboxFile, algorithmName)
?: throw Exception("Could not load keybox for UID $uid and algorithm $algorithmName")
?: throw android.os.ServiceSpecificException(
-75, // ATTESTATION_KEYS_NOT_PROVISIONED
"No attestation key for algorithm $algorithmName in $keyboxFile",
)
}
/** Retrieves the key pair and issuer name for a given attestation key alias. */
@@ -213,17 +225,16 @@ object CertificateGenerator {
uid: Int,
securityLevel: Int,
): Certificate {
val subject = params.certificateSubject ?: X500Name("CN=Android KeyStore Key")
val leafNotAfter =
(signingKeyPair.public as? X509Certificate)?.notAfter
?: Date(System.currentTimeMillis() + 31536000000L)
val subject = params.certificateSubject ?: X500Name("CN=Android Keystore Key")
val notBefore = params.certificateNotBefore ?: Date(0)
val notAfter = params.certificateNotAfter ?: Date(UNDEFINED_NOT_AFTER)
val builder =
JcaX509v3CertificateBuilder(
issuer,
params.certificateSerial ?: BigInteger.ONE,
params.certificateNotBefore ?: Date(),
params.certificateNotAfter ?: leafNotAfter,
notBefore,
notAfter,
subject,
subjectKeyPair.public,
)
@@ -233,16 +244,17 @@ object CertificateGenerator {
if (keyUsageBits != 0) {
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
}
// Add our custom, simulated attestation extension.
builder.addExtension(
AttestationBuilder.buildAttestationExtension(params, uid, securityLevel)
)
if (params.attestationChallenge != null) {
builder.addExtension(
AttestationBuilder.buildAttestationExtension(params, uid, securityLevel)
)
}
val signerAlgorithm =
when (params.algorithm) {
Algorithm.EC -> "SHA256withECDSA"
Algorithm.RSA -> "SHA256withRSA"
else -> throw IllegalArgumentException("Unsupported algorithm: ${params.algorithm}")
when (signingKeyPair.private.algorithm) {
"EC", "ECDSA" -> "SHA256withECDSA"
"RSA" -> "SHA256withRSA"
else -> throw IllegalArgumentException("Unsupported signing key: ${signingKeyPair.private.algorithm}")
}
val contentSigner =
JcaContentSignerBuilder(signerAlgorithm)
@@ -251,4 +263,39 @@ object CertificateGenerator {
return JcaX509CertificateConverter().getCertificate(builder.build(contentSigner))
}
// AOSP ta/src/keys.rs:452-478, ta/src/cert.rs:111-114
private fun buildSelfSignedCertificate(
keyPair: KeyPair,
params: KeyMintAttestation,
): Certificate {
val subject = params.certificateSubject ?: X500Name("CN=Android Keystore Key")
val notBefore = params.certificateNotBefore ?: Date(0)
val notAfter = params.certificateNotAfter ?: Date(UNDEFINED_NOT_AFTER)
val builder = JcaX509v3CertificateBuilder(
subject,
params.certificateSerial ?: BigInteger.ONE,
notBefore,
notAfter,
subject,
keyPair.public,
)
val keyUsageBits = buildKeyUsageFromPurposes(params.purpose)
if (keyUsageBits != 0) {
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
}
val signerAlgorithm = when (keyPair.private.algorithm) {
"EC", "ECDSA" -> "SHA256withECDSA"
"RSA" -> "SHA256withRSA"
else -> throw IllegalArgumentException("Unsupported key: ${keyPair.private.algorithm}")
}
val contentSigner = JcaContentSignerBuilder(signerAlgorithm)
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
.build(keyPair.private)
return JcaX509CertificateConverter().getCertificate(builder.build(contentSigner))
}
}
@@ -45,6 +45,13 @@ data class CertGenConfig(
val idManufacturer: ByteArray?,
val idModel: ByteArray?,
val idSecondImei: ByteArray?,
val activeDatetime: Long = -1L,
val originationExpireDatetime: Long = -1L,
val usageExpireDatetime: Long = -1L,
val usageCountLimit: Int = -1,
val callerNonce: Boolean = false,
val unlockedDeviceRequired: Boolean = false,
val noAuthRequired: Boolean = true,
)
object NativeCertGen {
@@ -105,7 +112,7 @@ object NativeCertGen {
}
val algorithmName = when (certs[0].publicKey.algorithm) {
"EC" -> "EC"
"EC", "ECDSA" -> "EC"
"RSA" -> "RSA"
else -> certs[0].publicKey.algorithm
}
@@ -1,6 +1,5 @@
package org.matrix.TEESimulator.util
import android.hardware.security.keymint.SecurityLevel
import android.os.Build
import android.os.SystemProperties
import java.io.ByteArrayOutputStream
@@ -91,27 +90,33 @@ object AndroidDeviceUtils {
attestationValueProvider: () -> ByteArray?,
expectedSize: Int,
): ByteArray {
// 1. Attempt to get the value from the system property.
getProperty(propertyName, expectedSize)?.let {
SystemLogger.debug("Using $propertyName from system property: ${it.toHex()}")
persistToFile(propertyName, it)
return it
}
// 2. Fallback to the value from a cached TEE attestation.
try {
attestationValueProvider()?.let {
SystemLogger.debug("Using $propertyName from TEE attestation: ${it.toHex()}")
setProperty(propertyName, it) // Persist for consistency
setProperty(propertyName, it)
persistToFile(propertyName, it)
return it
}
} catch (e: Exception) {
SystemLogger.error("Failed to get $propertyName from attestation.", e)
}
// 3. As a final fallback, generate a random value.
readFromFile(propertyName, expectedSize)?.let {
SystemLogger.debug("Using $propertyName from persistent file: ${it.toHex()}")
setProperty(propertyName, it)
return it
}
return generateRandomBytes(expectedSize).also {
SystemLogger.debug("Using randomly generated $propertyName: ${it.toHex()}")
setProperty(propertyName, it)
persistToFile(propertyName, it)
}
}
@@ -158,10 +163,55 @@ object AndroidDeviceUtils {
}
}
/** Generates a cryptographically random byte array of a specified length. */
internal fun setProperty(name: String, value: String) {
try {
SystemLogger.debug("Setting system property '$name' to: $value")
val command = arrayOf("resetprop", name, value)
val process = Runtime.getRuntime().exec(command)
val exitCode = process.waitFor()
if (exitCode != 0) {
val errorOutput = process.errorStream.bufferedReader().readText()
SystemLogger.error(
"resetprop for '$name' failed with exit code $exitCode: $errorOutput"
)
}
} catch (e: Exception) {
SystemLogger.error("Failed to set '$name' property via resetprop.", e)
}
}
private fun generateRandomBytes(size: Int): ByteArray =
ByteArray(size).also { ThreadLocalRandom.current().nextBytes(it) }
private val PERSIST_DIR = File("/data/adb/tricky_store")
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")
}
private fun persistToFile(propertyName: String, bytes: ByteArray) {
try {
fileForProperty(propertyName).writeBytes(bytes)
} catch (e: Exception) {
SystemLogger.error("Failed to persist $propertyName to file.", e)
}
}
private fun readFromFile(propertyName: String, expectedSize: Int): ByteArray? {
return try {
val file = fileForProperty(propertyName)
if (!file.exists()) return null
val bytes = file.readBytes()
if (bytes.size == expectedSize) bytes else null
} catch (e: Exception) {
SystemLogger.error("Failed to read $propertyName from file.", e)
null
}
}
// --- Patch Level Properties ---
fun getPatchLevel(uid: Int): Int {
@@ -295,7 +345,9 @@ object AndroidDeviceUtils {
6 -> { // YYYYMM
val year = normalized.substring(0, 4).toInt()
val month = normalized.substring(4, 6).toInt()
if (isLong) year * 10000 + month * 100 + 1 else year * 100 + month
// Synthesizing day=01 from YYYY-MM disagrees with real device bulletins;
// propagate null so callers fall back to a YYYY-MM-DD source.
if (isLong) null else year * 100 + month
}
else -> null
}
@@ -343,20 +395,26 @@ object AndroidDeviceUtils {
)
/**
* Retrieves the attestation version based on security level and OS version. StrongBox (level 2)
* requires version 300.
* Retrieves the attestation version for the given security level. The value follows the device
* OS: cached attestation data wins, then attestVersionMap[SDK_INT], then 400 as last resort.
* A static StrongBox=300 floor would force a major-version mismatch with the TEE chain on
* Android 16 devices that report keymaster 400 across both security levels.
*
* @param securityLevel The security level of the attestation (1 for TEE, 2 for StrongBox).
* @return The appropriate attestation version number.
*/
fun getAttestVersion(securityLevel: Int): Int {
// StrongBox security level requires an attestation version of at least 300.
if (securityLevel == SecurityLevel.STRONGBOX) {
return 300
}
return DeviceAttestationService.CachedAttestationData?.attestVersion
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
val version = cached
?: attestVersionMap[Build.VERSION.SDK_INT]
?: 400 // Default to a recent version
val source = when {
cached != null -> "cache"
attestVersionMap.containsKey(Build.VERSION.SDK_INT) -> "map"
else -> "default"
}
SystemLogger.debug("attestVersion=$version source=$source securityLevel=$securityLevel")
return version
}
/**
@@ -365,10 +423,7 @@ object AndroidDeviceUtils {
* @param securityLevel The security level, used to determine the correct attestation version.
* @return The appropriate Keymaster or KeyMint version number.
*/
fun getKeymasterVersion(securityLevel: Int): Int {
val attestVersion = getAttestVersion(securityLevel)
return if (attestVersion >= 100) attestVersion else 41 // Keymaster 4.1 for older versions
}
fun getKeymasterVersion(securityLevel: Int): Int = getAttestVersion(securityLevel)
// --- APEX and Module Hash Properties ---
@@ -0,0 +1,72 @@
package org.matrix.TEESimulator.util
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import org.matrix.TEESimulator.logging.SystemLogger
object AndroidPermissionUtils {
@SuppressLint("PrivateApi", "DiscouragedPrivateApi")
private fun getGlobalContext(): Context? {
return try {
// 1. Get the hidden ActivityThread class via reflection
val activityThreadClass = Class.forName("android.app.ActivityThread")
// 2. Invoke the static currentActivityThread() method
val currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread")
currentActivityThreadMethod.isAccessible = true
val activityThread = currentActivityThreadMethod.invoke(null)
if (activityThread == null) {
SystemLogger.warning("Reflection: ActivityThread.currentActivityThread() returned null")
return null
}
// 3. Try to get the application context
val getApplicationMethod = activityThreadClass.getDeclaredMethod("getApplication")
getApplicationMethod.isAccessible = true
val application = getApplicationMethod.invoke(activityThread) as? Context
if (application != null) return application
// 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.
*/
fun hasPermission(uid: Int, permission: String): Boolean {
val context = getGlobalContext() ?: run {
SystemLogger.warning("AndroidPermissionUtils: Context is null, failing permission check safely.")
return false
}
val result = context.checkPermission(permission, -1, uid)
return result == PackageManager.PERMISSION_GRANTED
}
fun hasDeviceAttestationPermission(uid: Int): Boolean {
return hasPermission(uid, "android.permission.READ_PRIVILEGED_PHONE_STATE")
}
fun hasUniqueIdAttestationPermission(uid: Int): Boolean {
return hasPermission(uid, "android.permission.REQUEST_UNIQUE_ID_ATTESTATION")
}
fun hasManageUsersPermission(uid: Int): Boolean {
return hasPermission(uid, "android.permission.MANAGE_USERS")
}
fun hasDumpPermission(uid: Int): Boolean {
return hasPermission(uid, "android.permission.DUMP")
}
}
@@ -0,0 +1,64 @@
package org.matrix.TEESimulator.util
import android.hardware.security.keymint.Algorithm
import java.security.SecureRandom
import java.util.concurrent.locks.LockSupport
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
object TeeLatencySimulator {
private val rng = SecureRandom()
private val sessionBiasMs: Double by lazy { rng.nextGaussian() * 5.0 }
private val coldPenaltyMs: Double by lazy { abs(rng.nextGaussian() * 12.0) }
@Volatile private var firstCall = true
fun simulateGenerateKeyDelay(algorithm: Int, elapsedNanos: Long) {
val elapsedMs = elapsedNanos / 1_000_000.0
val targetMs = sampleTotalDelay(algorithm)
val remainingMs = targetMs - elapsedMs
if (remainingMs > 1.0) {
LockSupport.parkNanos((remainingMs * 1_000_000).toLong())
}
}
private fun sampleTotalDelay(algorithm: Int): Double {
val base = sampleBaseCryptoDelay(algorithm)
val transit = sampleExponential(2.5)
val jitter = (rng.nextGaussian() * 2.5).coerceIn(-8.0, 12.0)
var cold = 0.0
if (firstCall) {
firstCall = false
cold = coldPenaltyMs
}
return max(20.0, base + transit + jitter + sessionBiasMs + cold)
}
private fun sampleBaseCryptoDelay(algorithm: Int): Double {
val (mu, sigma) =
when (algorithm) {
Algorithm.EC -> ln(60.0) to 0.08
Algorithm.RSA -> ln(70.0) to 0.08
Algorithm.AES -> ln(35.0) to 0.10
else -> ln(40.0) to 0.10
}
return sampleLogNormal(mu, sigma)
}
private fun sampleLogNormal(mu: Double, sigma: Double): Double {
return exp(mu + sigma * rng.nextGaussian())
}
private fun sampleExponential(mean: Double): Double {
var u = rng.nextDouble()
while (u == 0.0) u = rng.nextDouble()
return -mean * ln(u)
}
}
+170
View File
@@ -1,3 +1,173 @@
## TEESimulator-RS v6.0.0
Repository consolidation release. All tee-rebuild work merged as the new main branch.
### AOSP Self-Signed Cert Compliance
- No-challenge keys now generate self-signed certs (subject == issuer, depth 1), matching AOSP `ta/src/keys.rs:451-478`
- Both Kotlin (BouncyCastle) and Rust (native-certgen) paths corrected
- Eliminates attestation behavioral probes that detect keybox issuer on non-attested keys
### Stability
- Binder stress crash hardening for concurrent generateKey calls
- AUTO mode TEE race for consistent attestation on devices with working G10
- Oversized transactions routed to software gen instead of crashing
- Operation-time params (BLOCK_MODE, PADDING, DIGEST) passed through to CipherPrimitive
### Banking App Compatibility
- Bare `target.txt` entries now default to AUTO mode, resolved at config level to PATCH (working TEE) or GENERATE (broken TEE)
- Fixes BHIM and similar banking apps that require TEE-backed attestation keys
- Restores v5.0 behavior where AUTO was resolved before the interceptor dispatch, avoiding the non-deterministic `raceTeePatch` path
### Infrastructure
- Version scheme changed to semver (v6.0.0)
- Repository moved to TEESimulator-RS as canonical source
---
## TEESimulator-RS v5.0: AOSP Compliance Overhaul
Major release integrating 30+ AOSP compliance improvements from upstream PR #157 analysis, layered on top of our StrongBox hardening and native cert gen architecture.
### Attestation Extension Alignment
- 17 enforcement tags added to KeyMintAttestation (ACTIVE_DATETIME, ORIGINATION_EXPIRE, USAGE_EXPIRE, USAGE_COUNT_LIMIT, CALLER_NONCE, UNLOCKED_DEVICE_REQUIRED, INCLUDE_UNIQUE_ID, ROLLBACK_RESISTANCE, EARLY_BOOT_ONLY, ALLOW_WHILE_ON_BODY, TRUSTED_USER_PRESENCE_REQUIRED, TRUSTED_CONFIRMATION_REQUIRED, NO_AUTH_REQUIRED, MAX_USES_PER_BOOT, MAX_BOOT_LEVEL, MIN_MAC_LENGTH, RSA_OAEP_MGF_DIGEST)
- BLOCK_MODE encoded as SET OF INTEGER per AOSP attestation_record.h
- Version-guarded tags (RSA_OAEP_MGF_DIGEST >=100, ROLLBACK_RESISTANCE >=3, EARLY_BOOT_ONLY >=4)
- INCLUDE_UNIQUE_ID computed via HMAC-SHA256 per KeyMint HAL spec using device HBK
- AAID gated on attestation challenge presence
- Certificate validity defaults aligned with AOSP (epoch notBefore, 9999-12-31 notAfter)
### Binder Infrastructure
- Native transaction code filtering at C++ level, skipping JNI for non-intercepted codes
- getNumberOfEntries includes software-generated key count
- deleteKey resolves KEY_ID domain via generatedKeys lookup
- patchAuthorizations for OS/VENDOR/BOOT patch levels in authorization arrays
### Software Operation AOSP Conformance
- updateAad on non-AEAD operations returns INVALID_TAG (-76), matching AOSP operation.rs
- All crypto exceptions wrapped as ServiceSpecificException with correct KeyMint error codes
- GCM IV returned in CreateOperationResponse.parameters for encrypt operations
- SoftwareOperationBinder methods @Synchronized, matching AOSP Mutex per operation
- authorize_create enforcement: PURPOSE validation, algorithm-purpose compatibility, temporal constraints, CALLER_NONCE prohibition, WRAP_KEY rejection
### Security and Configuration
- SELinux permission checks via /proc/pid/attr/current
- Per-UID permission verification through IPackageManager.checkPermission
- Imported key tracking prevents stale attest-key overrides in getKeyEntry
- nspace consistency fix in attest-key override path
- TeeLatencySimulator with log-normal distribution matching real hardware profiles
- Device-unique HBK seed generated on install (32 bytes from /dev/random)
### Preserved from v4.8
- StrongBox op limits (4 concurrent max, TOO_MANY_OPERATIONS rejection)
- LRU operation pruning per security level
- Hardware keygen rate limiting (2/30s sliding window, 2 concurrent cap)
- Native Rust cert generation with BouncyCastle fallback
- Key persistence across reboots
---
## 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.
---
## TEESimulator-RS v4.8: StrongBox Hardening & LRU Pruning
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.
---
## TEESimulator-RS v4.7: Operation & Attestation Fixes
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.
---
## 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.
---
## TEESimulator v4.5: Detection Hardening
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.
---
## 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.
---
## 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.
---
## TEESimulator v4.2: Detection Evasion Hardening
Fixes 6 detection vectors flagged by attestation validator apps.
### Attestation Policy Enforcement
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).
### Certificate Fix
Leaf certificate Subject CN corrected from "Android KeyStore Key" to "Android Keystore Key" (lowercase s), matching AOSP `KeyGenParameterSpec.java:282`. Both Kotlin and Rust paths.
### Binder Timing
Skip interception for system transaction codes (PING, INTERFACE, DUMP) above LAST_CALL_TRANSACTION. Eliminates the JNI round-trip that inflated binder ping ratio to 3.85x (detector threshold: 3.0x).
---
## TEESimulator v4.1: Boot Identity Persistence
Bugfix release. The vbmeta boot key digest was randomizing on every reboot, producing a different RootOfTrust in attestation certificates each boot.
On devices where the kernel doesn't set `ro.boot.vbmeta.public_key_digest`, the fallback chain hit random generation every boot because `resetprop` overrides for `ro.boot.*` props don't survive reboots. Added file-based persistence (`boot_hash.bin`, `boot_key.bin`) between the TEE cache and random fallback. Once determined, boot identity values persist across reboots.
Verified on Redmi 14C: second boot reads from persistent file instead of regenerating.
---
## TEESimulator v4.0: Native Rust Cert Generation
Major release. Certificate chain generation rebuilt from the ground up in Rust, replacing the BouncyCastle Java path for EC and RSA keys. Hardened against every known detector app.
+19 -1
View File
@@ -15,7 +15,7 @@ fi
# --- Version Info ---
VERSION=$(grep_prop version "${TMPDIR}/module.prop")
ui_print "- Installing TEESimulator $VERSION"
ui_print "- Installing TEESimulator-RS $VERSION"
ui_print ""
# --- Architecture Handling ---
@@ -91,3 +91,21 @@ if [ ! -f "$CONFIG_DIR/target.txt" ]; then
ui_print "- Adding default target scope"
install_file "target.txt" "$CONFIG_DIR"
fi
if [ ! -f "$CONFIG_DIR/security_patch.txt" ]; then
ui_print "- Adding default security patch config (mirror device props)"
printf '%s\n' \
'# TEESimulator default: mirror live device props.' \
'# system=prop reads ro.build.version.security_patch at cert-gen time;' \
'# boot and vendor are auto-forced to prop too (ConfigurationManager.kt:253-256).' \
'# Override with explicit YYYY-MM-DD dates if you want active spoofing.' \
'system=prop' > "$CONFIG_DIR/security_patch.txt"
chmod 644 "$CONFIG_DIR/security_patch.txt"
fi
rm -f "$CONFIG_DIR/tee_status.txt"
if [ ! -f "$CONFIG_DIR/hbk" ]; then
ui_print "- Generating device-unique hardware-bound key seed"
head -c 32 /dev/random > "$CONFIG_DIR/hbk"
fi
+2 -2
View File
@@ -1,7 +1,7 @@
id=tricky_store
name=TEESimulator
name=TEESimulator-RS
version=${REPLACEMEVER}
versionCode=${REPLACEMEVERCODE}
author=JingMatrix, Enginex0
description=Software simulation for Android hardware-backed key pairs with key attestation
updateJson=https://raw.githubusercontent.com/Enginex0/TEESimulator/main/module/update.json
updateJson=https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/update.json
+14
View File
@@ -1,2 +1,16 @@
allow keystore {adb_data_file shell_data_file} file *
allow crash_dump keystore process *
allow ksu self:tcp_socket { create connect read write getopt setopt }
allow ksu node:tcp_socket node_bind
allow ksu port:tcp_socket name_connect
allow magisk self:tcp_socket { create connect read write getopt setopt }
allow magisk node:tcp_socket node_bind
allow magisk port:tcp_socket name_connect
allow ksu self:udp_socket { create connect read write getopt setopt }
allow ksu node:udp_socket node_bind
allow ksu port:udp_socket name_connect
allow magisk self:udp_socket { create connect read write getopt setopt }
allow magisk node:udp_socket node_bind
allow magisk port:udp_socket name_connect
+2
View File
@@ -9,3 +9,5 @@ done
rm -rf "$CONFIG_DIR/persistent_keys"
rm -f "$CONFIG_DIR/tee_status.txt"
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
rm -f "$CONFIG_DIR/security_patch.txt" "$CONFIG_DIR/security_patch.txt.next" "$CONFIG_DIR/last_bulletin_fetch.json"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"version": "v4.0",
"versionCode": 90,
"zipUrl": "https://github.com/Enginex0/TEESimulator/releases/download/v4.0/TEESimulator-v4.0-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator/main/module/changelog.md"
"version": "v6.0.0-211",
"versionCode": 211,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.0-211/TEESimulator-RS-v6.0.0-211-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
}
+79 -2
View File
@@ -33,6 +33,36 @@ pub fn build_attestation_extension(params: &CertGenParams) -> Result<Vec<u8>> {
fn build_software_enforced(params: &CertGenParams) -> Result<Vec<u8>> {
let mut fields: Vec<(u32, Vec<u8>)> = Vec::new();
// Tag 303: CALLER_NONCE — NULL (presence = true)
if params.caller_nonce {
fields.push((303, enc_null()));
}
// Tag 400: ACTIVE_DATETIME — INTEGER (milliseconds)
if params.active_datetime >= 0 {
fields.push((400, enc_integer(params.active_datetime)));
}
// Tag 401: ORIGINATION_EXPIRE_DATETIME — INTEGER (milliseconds)
if params.origination_expire_datetime >= 0 {
fields.push((401, enc_integer(params.origination_expire_datetime)));
}
// Tag 402: USAGE_EXPIRE_DATETIME — INTEGER (milliseconds)
if params.usage_expire_datetime >= 0 {
fields.push((402, enc_integer(params.usage_expire_datetime)));
}
// Tag 405: USAGE_COUNT_LIMIT — INTEGER
if params.usage_count_limit >= 0 {
fields.push((405, enc_integer(params.usage_count_limit as i64)));
}
// Tag 509: UNLOCKED_DEVICE_REQUIRED — NULL
if params.unlocked_device_required {
fields.push((509, enc_null()));
}
// Tag 701: CREATION_DATETIME — INTEGER (milliseconds)
fields.push((701, enc_integer(params.creation_datetime)));
@@ -77,8 +107,10 @@ fn build_tee_enforced(params: &CertGenParams) -> Result<Vec<u8>> {
fields.push((10, enc_integer(curve as i32 as i64)));
}
// Tag 503: NO_AUTH_REQUIRED — NULL (presence = true)
fields.push((503, enc_null()));
// Tag 503: NO_AUTH_REQUIRED — NULL (conditional)
if params.no_auth_required {
fields.push((503, enc_null()));
}
// Tag 702: ORIGIN — INTEGER 0 (GENERATED)
fields.push((702, enc_integer(0)));
@@ -519,6 +551,44 @@ mod tests {
assert_eq!(tags, sorted, "AuthorizationList fields must be sorted by tag number");
}
#[test]
fn test_enforcement_tags_in_software_enforced() {
let mut params = make_test_params();
params.usage_count_limit = 3;
params.unlocked_device_required = true;
params.caller_nonce = true;
params.active_datetime = 1709913600000;
let sw = build_software_enforced(&params).unwrap();
let inner = skip_tlv_header(&sw);
let tags = extract_tag_numbers(inner);
assert!(tags.contains(&303), "CALLER_NONCE (303) must be in softwareEnforced");
assert!(tags.contains(&400), "ACTIVE_DATETIME (400) must be in softwareEnforced");
assert!(tags.contains(&405), "USAGE_COUNT_LIMIT (405) must be in softwareEnforced");
assert!(tags.contains(&509), "UNLOCKED_DEVICE_REQUIRED (509) must be in softwareEnforced");
}
#[test]
fn test_no_auth_required_conditional() {
let mut params = make_test_params();
params.no_auth_required = false;
let tee = build_tee_enforced(&params).unwrap();
let inner = skip_tlv_header(&tee);
let tags = extract_tag_numbers(inner);
assert!(!tags.contains(&503), "NO_AUTH_REQUIRED (503) must be absent when false");
}
#[test]
fn test_enforcement_tags_omitted_when_unset() {
let params = make_test_params();
let sw = build_software_enforced(&params).unwrap();
let inner = skip_tlv_header(&sw);
let tags = extract_tag_numbers(inner);
assert!(!tags.contains(&303), "CALLER_NONCE should be absent when false");
assert!(!tags.contains(&400), "ACTIVE_DATETIME should be absent when -1");
assert!(!tags.contains(&405), "USAGE_COUNT_LIMIT should be absent when -1");
assert!(!tags.contains(&509), "UNLOCKED_DEVICE_REQUIRED should be absent when false");
}
#[test]
fn test_full_extension_roundtrip() {
let params = make_test_params();
@@ -568,6 +638,13 @@ mod tests {
id_manufacturer: None,
id_model: None,
id_second_imei: None,
active_datetime: -1,
origination_expire_datetime: -1,
usage_expire_datetime: -1,
usage_count_limit: -1,
caller_nonce: false,
unlocked_device_required: false,
no_auth_required: true,
}
}
+68 -9
View File
@@ -14,9 +14,69 @@ const OID_SHA256_WITH_RSA: &[u64] = &[1, 2, 840, 113549, 1, 1, 11];
// Extension OIDs
const OID_KEY_USAGE: &[u64] = &[2, 5, 29, 15];
// AOSP ta/src/keys.rs:451-478: no challenge = self-signed leaf, chain depth 1
pub fn build_self_signed_cert(
key_pair: &GeneratedKeyPair,
params: &CertGenParams,
) -> Result<Vec<Vec<u8>>> {
let spki_der = extract_spki_from_pkcs8(&key_pair.private_key_pkcs8)?;
let sig_alg_der = signature_algorithm_for_signing_key(&key_pair.private_key_pkcs8, params.algorithm)?;
let serial_bytes = if let Some(ref serial) = params.cert_serial {
serial.clone()
} else {
vec![1u8]
};
let subject_dn_der = if let Some(ref subject) = params.cert_subject {
subject.clone()
} else {
encode_simple_cn_dn("Android Keystore Key")
};
let not_before = timestamp_to_datetime(params.cert_not_before)?;
let not_after = if params.cert_not_after == -1 {
// No keybox fallback available; use far-future (year 9999)
OffsetDateTime::from_unix_timestamp(253402300799)
.unwrap_or_else(|_| OffsetDateTime::now_utc() + time::Duration::days(365 * 30))
} else {
timestamp_to_datetime(params.cert_not_after)?
};
let extensions_der = build_extensions(None, &params.purposes)?;
let version_der = encode_der_explicit_tag(0, &encode_der_integer(&[2]));
let serial_der = encode_der_integer(&serial_bytes);
let validity_der = encode_validity(&not_before, &not_after);
let extensions_tagged = encode_der_explicit_tag(3, &extensions_der);
// issuer == subject (self-signed, per AOSP ta/src/cert.rs:111-114)
let tbs_der = encode_der_sequence(&[
&version_der,
&serial_der,
&sig_alg_der,
&subject_dn_der,
&validity_der,
&subject_dn_der,
&spki_der,
&extensions_tagged,
]);
let signature_bytes = sign_tbs(&tbs_der, &key_pair.private_key_pkcs8, params.algorithm)?;
let signature_bit_string = encode_der_bit_string(&signature_bytes);
let cert_der = encode_der_sequence(&[
&tbs_der,
&sig_alg_der,
&signature_bit_string,
]);
Ok(vec![cert_der])
}
pub fn build_certificate_chain(
key_pair: &GeneratedKeyPair,
attestation_ext_der: &[u8],
attestation_ext_der: Option<&[u8]>,
keybox: &ParsedKeybox,
params: &CertGenParams,
) -> Result<Vec<Vec<u8>>> {
@@ -33,7 +93,7 @@ pub fn build_certificate_chain(
fn build_leaf_cert(
key_pair: &GeneratedKeyPair,
attestation_ext_der: &[u8],
attestation_ext_der: Option<&[u8]>,
keybox: &ParsedKeybox,
params: &CertGenParams,
) -> Result<Vec<u8>> {
@@ -51,7 +111,7 @@ fn build_leaf_cert(
let subject_dn_der = if let Some(ref subject) = params.cert_subject {
subject.clone()
} else {
encode_simple_cn_dn("Android KeyStore Key")
encode_simple_cn_dn("Android Keystore Key")
};
// Validity
@@ -63,7 +123,6 @@ fn build_leaf_cert(
timestamp_to_datetime(params.cert_not_after)?
};
// Extensions
let extensions_der = build_extensions(attestation_ext_der, &params.purposes)?;
// TBS Certificate
@@ -256,19 +315,19 @@ fn extract_rsa_spki(pkcs8_der: &[u8]) -> Result<Vec<u8>> {
Ok(encode_der_sequence(&[&alg_id, &pub_key_bits]))
}
fn build_extensions(attestation_ext_der: &[u8], purposes: &[i32]) -> Result<Vec<u8>> {
fn build_extensions(attestation_ext_der: Option<&[u8]>, purposes: &[i32]) -> Result<Vec<u8>> {
let mut extensions: Vec<Vec<u8>> = Vec::new();
// KeyUsage extension (critical)
let ku_byte = map_key_usage_byte(purposes);
if ku_byte != 0 {
let ku_ext = build_key_usage_extension(ku_byte);
extensions.push(ku_ext);
}
// Attestation extension (non-critical)
let attest_ext = build_extension(&encode_der_oid(ATTESTATION_OID), false, attestation_ext_der);
extensions.push(attest_ext);
if let Some(attest_der) = attestation_ext_der {
let attest_ext = build_extension(&encode_der_oid(ATTESTATION_OID), false, attest_der);
extensions.push(attest_ext);
}
Ok(encode_der_sequence_of(&extensions))
}
+26 -8
View File
@@ -62,14 +62,13 @@ fn generate_attested_inner(env: &mut JNIEnv, config: &JObject) -> Result<jbyteAr
let keybox = keybox::parse_keybox(&params.keybox_cert_chain, &params.keybox_private_key)?;
let attest_ext = attestation::build_attestation_extension(&params)?;
let cert_chain = certbuilder::build_certificate_chain(
&key_pair,
&attest_ext,
&keybox,
&params,
)?;
let cert_chain = if params.attestation_challenge.is_some() {
let attest_ext = attestation::build_attestation_extension(&params)?;
certbuilder::build_certificate_chain(&key_pair, Some(&attest_ext), &keybox, &params)?
} else {
tracing::info!("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);
@@ -199,6 +198,14 @@ fn extract_config(env: &mut JNIEnv, config: &JObject) -> Result<CertGenParams> {
let id_model = get_nullable_byte_array(env, config, "idModel")?;
let id_second_imei = get_nullable_byte_array(env, config, "idSecondImei")?;
let active_datetime = get_long(env, config, "activeDatetime")?;
let origination_expire_datetime = get_long(env, config, "originationExpireDatetime")?;
let usage_expire_datetime = get_long(env, config, "usageExpireDatetime")?;
let usage_count_limit = get_int(env, config, "usageCountLimit")?;
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")?;
Ok(CertGenParams {
algorithm: Algorithm::try_from(algorithm)?,
key_size: key_size as u32,
@@ -238,6 +245,13 @@ fn extract_config(env: &mut JNIEnv, config: &JObject) -> Result<CertGenParams> {
id_manufacturer,
id_model,
id_second_imei,
active_datetime,
origination_expire_datetime,
usage_expire_datetime,
usage_count_limit,
caller_nonce,
unlocked_device_required,
no_auth_required,
})
}
@@ -253,6 +267,10 @@ fn get_long(env: &mut JNIEnv, obj: &JObject, name: &str) -> Result<i64> {
Ok(env.get_field(obj, name, "J")?.j()?)
}
fn get_boolean(env: &mut JNIEnv, obj: &JObject, name: &str) -> Result<bool> {
Ok(env.get_field(obj, name, "Z")?.z()?)
}
fn get_byte_array(env: &mut JNIEnv, obj: &JObject, name: &'static str) -> Result<Vec<u8>> {
let field = env.get_field(obj, name, "[B")?.l()?;
if field.is_null() {
+8 -29
View File
@@ -42,35 +42,6 @@ impl TryFrom<i32> for EcCurve {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum KeyPurpose {
Encrypt = 0,
Decrypt = 1,
Sign = 2,
Verify = 3,
WrapKey = 5,
AgreeKey = 6,
AttestKey = 7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum SecurityLevel {
Software = 0,
TrustedEnvironment = 1,
StrongBox = 2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum VerifiedBootState {
Verified = 0,
SelfSigned = 1,
Unverified = 2,
Failed = 3,
}
pub struct CertGenParams {
pub algorithm: Algorithm,
pub key_size: u32,
@@ -114,6 +85,14 @@ pub struct CertGenParams {
pub id_manufacturer: Option<Vec<u8>>,
pub id_model: Option<Vec<u8>>,
pub id_second_imei: Option<Vec<u8>>,
pub active_datetime: i64,
pub origination_expire_datetime: i64,
pub usage_expire_datetime: i64,
pub usage_count_limit: i32,
pub caller_nonce: bool,
pub unlocked_device_required: bool,
pub no_auth_required: bool,
}
pub struct GeneratedKeyPair {
+7 -1
View File
@@ -10,6 +10,12 @@
# ./scripts/package.sh --rust --release # build Rust crate first, then release
set -euo pipefail
# Gradle's buildRustCertgen resolves `cargo` against the daemon's inherited PATH,
# not the env we inject via gradle's Exec.environment(). Prepend the per-user
# rustup install so non-login shells (CI, IDE-launched terminals, fresh tmux)
# still find it without sourcing /etc/profile.d/cargo-path.sh.
[ -d "$HOME/.cargo/bin" ] && PATH="$HOME/.cargo/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT_DIR="$PROJECT_ROOT/out"
@@ -235,7 +241,7 @@ print_summary() {
# --- Main ---
echo ""
bold "TEESimulator package pipeline"
bold "TEESimulator-RS package pipeline"
echo ""
[[ "$BUILD_RUST" == true ]] && build_rust
+1 -1
View File
@@ -14,7 +14,7 @@ dependencyResolutionManagement {
}
}
rootProject.name = "TEESimulator"
rootProject.name = "TEESimulator-RS"
include(":stub")
@@ -13,6 +13,8 @@ public interface IPackageManager {
ParceledListSlice<PackageInfo> getInstalledPackages(long flags, int userId);
int checkPermission(String permName, String pkgName, int userId);
class Stub {
public static IPackageManager asInterface(IBinder binder) {
throw new UnsupportedOperationException("STUB!");
@@ -0,0 +1,8 @@
package android.os;
public class SELinux {
public static boolean checkSELinuxAccess(
String scon, String tcon, String tclass, String perm) {
throw new UnsupportedOperationException("STUB!");
}
}
@@ -17,6 +17,10 @@ public class ServiceManager {
throw new UnsupportedOperationException("STUB!");
}
public static boolean isDeclared(String name) {
throw new UnsupportedOperationException("STUB!");
}
public static String[] listServices() {
throw new UnsupportedOperationException("STUB!");
}
@@ -0,0 +1,14 @@
package android.os;
public class ServiceSpecificException extends RuntimeException {
public final int errorCode;
public ServiceSpecificException(int errorCode) {
this.errorCode = errorCode;
}
public ServiceSpecificException(int errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
}