Compare commits

...
72 Commits
Author SHA1 Message Date
Enginex0 60b6ec64c2 feat(action): i18n the clear-keys confirmation
Add action_i18n.sh with 22 language arms (ar, az, bn, de, el, es-ES,
fa, fr, id, it, ja, ko, pl, pt-BR, ru, th, tl, tr, uk, vi, zh-CN,
zh-TW) plus an English default. Detect device locale via
persist.sys.locale with ro.product.locale and ro.system.locale
fallbacks; split Chinese on Hans/Hant.

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

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

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

Gate the destructive operation behind an explicit Vol+ confirmation
with a 10-second timeout. Vol- or timeout aborts and preserves keys.
Pattern mirrors tricky-addon-enhanced/install_func.sh choose_automation
(getevent -qlc 1 polled per second), but defaults to cancel on timeout
because the destructive default of the previous script is the
behavior we are correcting.
2026-05-19 18:31:16 +01:00
Enginex0 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
34 changed files with 2002 additions and 431 deletions
+18
View File
@@ -149,3 +149,21 @@ jobs:
env:
VER: ${{ steps.ver.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Bump update.json
run: |
COUNT=$(git rev-list HEAD --count)
RELEASE_NAME=$(basename zips/*Release*.zip)
ZIP_URL="https://github.com/${{ github.repository }}/releases/download/${VER}/${RELEASE_NAME}"
jq ".versionCode = $COUNT | .zipUrl = \"$ZIP_URL\"" module/update.json > /tmp/update.json
mv /tmp/update.json module/update.json
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add module/update.json
git diff --cached --quiet || {
git commit -m "chore(release): bump update.json to $VER [skip ci]"
git push origin HEAD:main
}
env:
VER: ${{ steps.ver.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+74 -194
View File
@@ -1,259 +1,139 @@
<p align="center">
<h1 align="center">🔐 TEESimulator</h1>
<h1 align="center">TEESimulator-RS</h1>
<p align="center"><b>Full TEE Emulation for Rooted Android</b></p>
<p align="center">Hardware attestation. Software keys. Zero detection.</p>
<p align="center">
<a href="https://github.com/Enginex0/TEESimulator/actions/workflows/build.yml"><img src="https://github.com/Enginex0/TEESimulator/actions/workflows/build.yml/badge.svg" alt="Build"></a>
<img src="https://img.shields.io/badge/version-v4.2-blue?style=for-the-badge" alt="v4.2">
<img src="https://img.shields.io/badge/Android-10%2B-green?style=for-the-badge&logo=android" alt="Android 10+">
<img src="https://img.shields.io/badge/Telegram-community-blue?style=for-the-badge&logo=telegram" alt="Telegram">
<a href="https://github.com/Enginex0/TEESimulator-RS/actions/workflows/build.yml"><img src="https://github.com/Enginex0/TEESimulator-RS/actions/workflows/build.yml/badge.svg" alt="Build"></a>
<img src="https://img.shields.io/badge/Android-10%2B-green?logo=android" alt="Android 10+">
<a href="https://t.me/superpowers9"><img src="https://img.shields.io/badge/Telegram-community-blue?logo=telegram" alt="Telegram"></a>
</p>
</p>
---
> [!NOTE]
> **This is a personal fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator)** with additional hardening, native Rust certificate generation, key persistence, and anti-detection features. For the upstream project, see the original repo.
> Fork of [JingMatrix/TEESimulator](https://github.com/JingMatrix/TEESimulator) with native Rust certificate generation, key persistence, and AOSP-compliant attestation behavior. For the upstream project, see the original repo.
---
## What It Does
## 🧬 What is TEESimulator?
TEESimulator intercepts Binder IPC at the `ioctl` level inside the `keystore2` process and generates entire certificate chains from scratch, signed by your keybox, with correct attestation extensions. Apps that verify hardware attestation see a legitimate device.
TEESimulator is a **complete software simulation** of Android's hardware-backed [Trusted Execution Environment](https://source.android.com/docs/security/features/trusty) for [Key Attestation](https://developer.android.com/privacy-and-security/security-key-attestation). Instead of patching certificates from the real TEE after the fact, TEESimulator intercepts Binder IPC at the `ioctl` level and generates entire certificate chains from scratch — signed by your keybox, with correct attestation extensions, indistinguishable from hardware-generated keys.
This is not TrickyStore. TEESimulator replaces TrickyStore and its forks entirely. It shares the same config paths for drop-in compatibility, but the internals are different: native Rust cert generation, binder-level interception via `lsplt`, per-UID rate limiting, key persistence, and AOSP-spec attestation behavior.
The result: **apps that verify hardware attestation see a legitimate, unmodified device** — even on rooted hardware with an unlocked bootloader.
> **This is not TrickyStore.** TEESimulator replaces TrickyStore and its forks entirely. It shares the same config paths for drop-in compatibility, but the architecture is fundamentally different: native Rust certificate generation, binder-level interception via `lsplt`, per-UID rate limiting, key persistence, and a multi-layer defense against detector apps.
---
## 🔥 Why TEESimulator?
🔐 **Native Cert Generation** — v4.0 generates X.509 certificate chains in Rust with `ring` and manual DER encoding. No BouncyCastle overhead, no Java crypto quirks, byte-perfect issuer chain linkage.
🎯 **Binder-Level Interception** — Hooks `ioctl()` on `libc.so` via `lsplt` inside the `keystore2` process. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions before the HAL ever sees them.
🛡️ **Detector Resistant** — Per-UID rate limiting blocks DuckDetector-style keygen flooding. Oversized challenges rejected with real KeyMint error codes. Chain consistency verified byte-for-byte.
💾 **Key Persistence** — Generated keys survive reboots. Apps that store attestation keys (banking, biometrics) don't break after a restart.
🔧 **Drop-In Replacement** — Same config paths as TrickyStore (`/data/adb/tricky_store/`). Swap the module ZIP, keep your keybox and target list.
---
## ✨ Features
**Core Attestation Engine**
- [x] **Full certificate chain generation** — leaf + intermediates + root, signed by your keybox
- [x] **Native Rust certgen**`libcertgen.so` built with `ring`, `rsa`, and manual DER assembly
- [x] **BouncyCastle fallback** — unsupported curves (P-224, P-521, Curve25519) fall back to Java
- [x] **ASN.1 attestation extensions** — OID 1.3.6.1.4.1.11129.2.1.17 with all AOSP-specified tags
- [x] **Multi-keybox support** — different keybox files per app group via `target.txt`
**Interception Layer**
- [x] **Binder ioctl hook**`lsplt` PLT hook on `libc.so` inside `keystore2` process
- [x] **generateKey / importKey / getKeyEntry** — all three transaction types intercepted
- [x] **256KB native payload cap** — oversized binder payloads bypass interception cleanly
- [x] **Challenge validation** — rejects >128-byte attestation challenges with `INVALID_INPUT_LENGTH`
**Hardening**
- [x] **Per-UID rate limiter** — 2 hardware keygens per 30s burst window, software fallback on overflow
- [x] **importKey eviction guard** — retained patch chains prevent generate-then-import cache attacks
- [x] **Key persistence** — file-backed storage with file-level locking, survives reboots and keybox rotations
- [x] **Global exception handler** — uncaught exceptions logged, daemon stays alive
**Configuration**
- [x] **Live config reload**`FileObserver` watches all config files, changes apply immediately
- [x] **Security patch spoofing** — per-package `system`, `vendor`, `boot` patch levels with dynamic templates
- [x] **Lifecycle scripts** — KSU Action button clears key cache, uninstall removes all traces
---
## 📋 Requirements
## Requirements
> [!IMPORTANT]
> TEESimulator requires root access and a valid `keybox.xml` for hardware-level attestation results. Without a keybox, the module generates software-level certificates that won't pass strict hardware attestation checks.
> A valid `keybox.xml` is required for hardware-level attestation. Without one, the module generates software-level certificates that won't pass strict hardware checks.
**You need:**
1. Android 10 or above
2. A supported root manager (KernelSU, Magisk, or APatch)
3. A hardware-backed `keybox.xml` placed at `/data/adb/tricky_store/keybox.xml`
1. Android 10+
2. Root manager: KernelSU, Magisk, or APatch
3. `keybox.xml` at `/data/adb/tricky_store/keybox.xml`
---
## Quick Start
## 📱 Compatibility
1. Download the latest ZIP from [Releases](https://github.com/Enginex0/TEESimulator-RS/releases)
2. Install via your root manager and reboot
3. Place your keybox at `/data/adb/tricky_store/keybox.xml`
4. Configure targets in `/data/adb/tricky_store/target.txt`
5. Verify with Play Integrity or Key Attestation Demo
### Root Managers
## Architecture
| Manager | Status | Notes |
|---|---|---|
| KernelSU | ✅ Tested | Full support including Action button and lifecycle scripts |
| Magisk | ✅ Supported | Standard module install |
| APatch | ✅ Supported | Standard module install |
**Native Cert Generation**`libcertgen.so` generates X.509 chains in Rust using `ring` and manual DER encoding. BouncyCastle fallback for unsupported curves (P-224, P-521, Curve25519).
### Tested Devices
**Binder Interception** — PLT hook on `ioctl()` in `libc.so` via `lsplt` inside `keystore2`. Intercepts `generateKey`, `importKey`, and `getKeyEntry` transactions.
| Device | Android | TEE | Status |
|---|---|---|---|
| Redmi 14C (2409BRN2CA) | 14 (SDK 34) | Beanpod KeyMaster | ✅ Daily driver |
**AOSP Compliance** — Self-signed certs for non-attested keys (matching `ta/src/keys.rs`), correct AuthorizationList tag ordering, version-guarded extension fields, `authorize_create` enforcement.
> Tested against DuckDetector, Luna, Play Integrity, and Key Attestation Demo. If you test on a different device, [open an issue](https://github.com/Enginex0/TEESimulator/issues) with your results.
**Key Persistence** — Generated keys survive reboots. File-backed with file-level locking.
---
**Rate Limiting** — Per-UID hardware keygen cap (2/30s window, 2 concurrent). Overflow falls to software certs.
## 🚀 Quick Start
## Configuration
1. **Download** the latest release ZIP from [Releases](https://github.com/Enginex0/TEESimulator/releases)
2. **Install** via your root manager (KSU / Magisk / APatch) and reboot
3. **Place your keybox** at `/data/adb/tricky_store/keybox.xml`
4. **Configure targets** in `/data/adb/tricky_store/target.txt`
5. **Verify** — check Play Integrity or run Key Attestation Demo
All config files live at `/data/adb/tricky_store/` and are hot-reloaded via `FileObserver`.
TEESimulator replaces TrickyStore, TrickyStoreOSS, and their forks. Existing config files are compatible.
### target.txt
---
Controls which apps get intercepted and the simulation mode.
## 🔨 Building from Source
| Suffix | Mode |
|--------|------|
| `!` | Force software key generation |
| `?` | Force leaf certificate patching (real TEE key, patched cert) |
| *(none)* | Automatic selection |
The CI workflow builds on every push to `main`. You can also build locally or trigger a build from your own fork.
**Prerequisites:** JDK 21, Android SDK/NDK 27, Rust stable with `aarch64-linux-android` target, `cargo-ndk`.
```bash
git clone https://github.com/Enginex0/TEESimulator.git
cd TEESimulator
./gradlew zipRelease zipDebug
```
Output ZIPs land in `out/`. The Gradle build automatically invokes `cargo ndk` to cross-compile `libcertgen.so` before packaging.
To rebuild from a fork, push to `main` or use **Actions → Build → Run workflow**. The workflow installs all toolchains (Java, Rust, cargo-ndk, ccache) and uploads Release + Debug ZIPs as artifacts.
---
## ⚙️ Configuration
All configuration files live at `/data/adb/tricky_store/` and are monitored by `FileObserver` — changes take effect immediately without rebooting.
### The `keybox.xml` Root of Trust
This file provides the master cryptographic identity. It contains a private key and a hardware-backed certificate chain from a real device. TEESimulator signs all generated certificates with this key, making them appear legitimate to verifiers.
```xml
<?xml version="1.0"?>
<AndroidAttestation>
<Keybox DeviceID="...">
<Key algorithm="ecdsa|rsa">
<PrivateKey format="pem">...</PrivateKey>
<CertificateChain>...</CertificateChain>
</Key>
</Keybox>
</AndroidAttestation>
```
### Target Packages (`target.txt`)
Controls which apps get intercepted and what simulation mode to use.
#### Mode Suffixes
* **`!` → Force Generation** — Creates a complete software-based virtual key. Full TEE simulation.
* **`?` → Force Leaf Hacking** — Real TEE key generated, but its attestation certificate is intercepted and patched.
* **No symbol → Automatic** — Module selects the best mode for your device.
#### Multi-Keybox
Specify different keybox files for different app groups. Apps listed after a `[filename.xml]` line use that keybox. Apps before any declaration use the default `keybox.xml`.
Multi-keybox support via `[filename.xml]` headers:
```
# Default keybox
com.google.android.gms!
io.github.vvb2060.keyattestation?
# Switch to a different keybox for the following apps
[aosp_keybox.xml]
com.google.android.gsf
# Another keybox
[demo_keybox.xml]
org.matrix.demo
```
### Security Patch Level (`security_patch.txt`)
### security_patch.txt
Configure the `osPatchLevel`, `vendorPatchLevel`, and `bootPatchLevel` reported in attestation certificates. This only affects attestation data — it does not change actual system properties.
#### Global and Per-Package
Settings at the top of the file are global defaults. Add `[package.name]` to override for specific apps.
#### Keys
Override patch levels reported in attestation certificates. Global defaults at top, per-package overrides with `[package.name]`.
| Key | Scope |
|---|---|
|-----|-------|
| `system` | OS patch level |
| `vendor` | Vendor patch level |
| `boot` | Boot/kernel patch level |
| `all` | Shorthand — sets all three at once |
| `all` | Sets all three |
#### Special Keywords
| Keyword | Effect |
|---|---|
| `today` | Current date, dynamically resolved on each attestation |
| `YYYY-MM-DD` templates | Semi-dynamic — `YYYY-MM-05` resolves to the 5th of the current month |
| `no` | Omit this patch level tag entirely from the attestation |
| `device_default` | Use the device's real hardware value |
| `prop` | Read from `ro.build.version.security_patch` (matches what detectors see via getprop) |
#### Example
Special values: `today`, `YYYY-MM-DD` templates, `no` (omit tag), `device_default`, `prop` (read from system property).
```
# Global — default for all apps
system=YYYY-MM-05
vendor=device_default
boot=no
# Override for GMS
[com.google.android.gms]
system=2024-10-01
# Custom config for a demo app
[org.matrix.demo]
all=2025-09-15
boot=device_default
system=2025-10-01
```
---
## Building from Source
## 💬 Community
Prerequisites: JDK 21, Android SDK/NDK 27, Rust stable with `aarch64-linux-android` target, `cargo-ndk`.
```bash
git clone --recursive https://github.com/Enginex0/TEESimulator-RS.git
cd TEESimulator-RS
./gradlew zipRelease zipDebug
```
Output ZIPs in `out/`. Gradle invokes `cargo ndk` automatically to cross-compile `libcertgen.so`.
Push to `main` or use **Actions > Build > Run workflow** to trigger CI.
## Compatibility
| Root Manager | Status |
|---|---|
| KernelSU | Tested (Action button + lifecycle scripts) |
| Magisk | Supported |
| APatch | Supported |
## Community
<p align="center">
<a href="https://t.me/superpowers9">
<img src="https://img.shields.io/badge/⚡_JOIN_THE_GRID-SuperPowers_Telegram-black?style=for-the-badge&logo=telegram&logoColor=cyan&labelColor=0d1117&color=00d4ff" alt="Telegram">
<img src="https://img.shields.io/badge/SuperPowers_Telegram-Join-blue?style=for-the-badge&logo=telegram" alt="Telegram">
</a>
</p>
---
## Credits
## 🙏 Credits
- [JingMatrix](https://github.com/JingMatrix/TEESimulator) — original TEESimulator and interception architecture
- [5ec1cff](https://github.com/5ec1cff/TrickyStore) — TrickyStore, the project that pioneered keystore interception
- [LSPlt](https://github.com/LSPosed/LSPlt) — PLT hook library
- [ring](https://github.com/briansmith/ring) — Rust cryptography library
- [MhmRdd](https://github.com/MhmRdd) — AOSP compliance work via upstream [PR #157](https://github.com/JingMatrix/TEESimulator/pull/157)
- [fatalcoder524](https://github.com/fatalcoder524) — contributor and collaborator
- [huguangares](https://github.com/huguangares) — collaborator and tester
- **[JingMatrix](https://github.com/JingMatrix/TEESimulator)** — original author of TEESimulator and the interception architecture
- **[5ec1cff](https://github.com/5ec1cff/TrickyStore)** — TrickyStore, the project that pioneered keystore interception on Android
- **[LSPlt](https://github.com/LSPosed/LSPlt)** — PLT hook library used for binder interception
- **[ring](https://github.com/briansmith/ring)** — Rust cryptography library powering native cert generation
- **[MhmRdd](https://github.com/MhmRdd)** — AOSP compliance improvements via upstream [PR #157](https://github.com/JingMatrix/TEESimulator/pull/157), including authorize_create enforcement, attestation extension alignment, and binder transaction filtering
- **[fatalcoder524](https://github.com/fatalcoder524)** — a real contributor and collaborator on this project
- **[huguangares](https://github.com/huguangares)** — collaborator and tester
## License
---
## 📄 License
This project is licensed under the [GNU General Public License v3.0](LICENSE).
---
<p align="center">
<b>🔐 Because the best attestation is the one the TEE never generated.</b>
</p>
[GNU General Public License v3.0](LICENSE)
+38 -1
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 = "v5.0"
val verName = "v6.0.0"
android {
namespace = "org.matrix.TEESimulator"
@@ -65,6 +66,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
compileOnly(project(":stub"))
compileOnly(libs.annotation)
@@ -91,6 +98,7 @@ val buildRustCertgen by tasks.registering(Exec::class) {
outputs.dir(rootProject.projectDir.resolve("app/src/main/jniLibs"))
environment("ANDROID_NDK_HOME", android.ndkDirectory.absolutePath)
environment("PATH", "${System.getProperty("user.home")}/.cargo/bin:${System.getenv("PATH") ?: ""}")
}
// AGP auto-detects jniLibs/ as an input to mergeJniLibFolders — wire the dependency
@@ -100,6 +108,34 @@ tasks.configureEach {
}
}
// Auto-rewrite module/update.json on every packaging build so versionCode and
// zipUrl track gitCommitCount automatically, matching module.prop.
val refreshUpdateJson by tasks.registering {
group = "TEESimulator-RS Module Packaging"
description = "Rewrite module/update.json to match current verName and gitCommitCount."
val updateJsonFile = rootProject.projectDir.resolve("module/update.json")
val capturedVerName = verName
val capturedCount = gitCommitCount
inputs.property("verName", capturedVerName)
inputs.property("gitCommitCount", capturedCount)
outputs.file(updateJsonFile)
doLast {
val fullVer = "$capturedVerName-$capturedCount"
updateJsonFile.writeText(
"""{
"version": "$fullVer",
"versionCode": $capturedCount,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/$fullVer/TEESimulator-RS-$fullVer-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
}
"""
)
}
}
androidComponents {
onVariants(selector().all()) { variant ->
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
@@ -124,6 +160,7 @@ androidComponents {
dependsOn("strip${capitalized}DebugSymbols")
}
dependsOn(buildRustCertgen)
dependsOn(refreshUpdateJson)
if (isDebug) {
from(variant.artifacts.get(SingleArtifact.APK)) {
+12 -29
View File
@@ -350,16 +350,10 @@ static sp<BinderStub> g_stub_instance = nullptr;
namespace {
constexpr binder_size_t kMaxInterceptableDataSize = 256 * 1024;
void inspectAndRewriteTransaction(binder_transaction_data *txn_data) {
if (!txn_data || txn_data->target.ptr == 0)
return;
// Bypass interception for oversized payloads to prevent thread starvation from flood attacks
if (txn_data->data_size > kMaxInterceptableDataSize)
return;
// AIDL methods use codes in [FIRST_CALL_TRANSACTION, LAST_CALL_TRANSACTION] (1..0x00ffffff).
// System transactions (PING, INTERFACE, DUMP, SHELL_COMMAND) use codes above that range.
// Skip those — intercepting a ping adds measurable latency that timing detectors flag.
@@ -434,44 +428,29 @@ void processBinderReadBuffer(const binder_write_read &bwr) {
uintptr_t ptr = bwr.read_buffer;
uintptr_t end = ptr + bwr.read_consumed;
LOGV("[Hook] Processing Read Buffer: Size=%llu, Consumed=%llu", bwr.read_size, bwr.read_consumed);
while (ptr < end) {
// Ensure we can read at least the command header
if (end - ptr < sizeof(uint32_t))
break;
uint32_t cmd = *reinterpret_cast<const uint32_t *>(ptr);
ptr += sizeof(uint32_t);
// Calculate payload size from the ioctl command code
size_t cmd_size = _IOC_SIZE(cmd);
// Log the command using our generated to-string function
LOGV("[Driver -> User] Command: %s (0x%x), DataSize: %zu", getBinderReturnCommandName(cmd), cmd, cmd_size);
// Safety check: ensure the command's data does not exceed the buffer
if (ptr + cmd_size > end) {
LOGE("[Hook] Buffer overflow detected while parsing command %s", getBinderReturnCommandName(cmd));
LOGE("[Hook] Buffer overrun parsing command 0x%x", cmd);
break;
}
// We are primarily interested in BR_TRANSACTION commands to intercept
if (cmd == BR_TRANSACTION || cmd == BR_TRANSACTION_SEC_CTX) {
binder_transaction_data *txn = nullptr;
if (__builtin_expect(cmd == BR_TRANSACTION || cmd == BR_TRANSACTION_SEC_CTX, 0)) {
binder_transaction_data *txn;
if (cmd == BR_TRANSACTION_SEC_CTX) {
// The data is wrapped in a secctx struct
auto *wrapper = reinterpret_cast<binder_transaction_data_secctx *>(ptr);
txn = &wrapper->transaction_data;
txn = &reinterpret_cast<binder_transaction_data_secctx *>(ptr)->transaction_data;
} else {
txn = reinterpret_cast<binder_transaction_data *>(ptr);
}
inspectAndRewriteTransaction(txn);
}
// Advance pointer to the next command
ptr += cmd_size;
}
}
@@ -491,15 +470,19 @@ 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) {
// 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);
}
}
}
return result;
}
@@ -8,7 +8,10 @@ import android.os.Build
import android.os.Looper
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.matrix.TEESimulator.config.BootStateManager
import org.matrix.TEESimulator.config.BulletinPoller
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.config.PatchLevelManager
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
import org.matrix.TEESimulator.interception.keystore.KeystoreInterceptor
@@ -39,11 +42,18 @@ object App {
try {
prepareEnvironment()
// Initialize and start the appropriate keystore interceptors.
initializeInterceptors()
// Spoof boot-state and patch-level props before any hook attaches,
// so keystore2's cached snapshot reflects the spoofed values.
BootStateManager.apply()
PatchLevelManager.initialize()
// Load the package configuration.
ConfigurationManager.initialize()
// Initialize and start the appropriate keystore interceptors.
initializeInterceptors()
// Set up the device's boot key and hash, which are crucial for attestation.
AndroidDeviceUtils.setupBootKeyAndHash()
@@ -55,6 +65,12 @@ object App {
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
try {
BulletinPoller.start()
} catch (e: Throwable) {
SystemLogger.error("Failed to start BulletinPoller", e)
}
// This starts the message queue processing. It blocks here indefinitely
// processing messages until Looper.myLooper().quit() is called.
Looper.loop()
@@ -43,11 +43,12 @@ object AttestationBuilder {
securityLevel: Int,
): Extension {
val keyDescription = buildKeyDescription(params, uid, securityLevel)
var formattedString =
keyDescription.joinToString(separator = ", ") {
SystemLogger.verbose {
val formattedString = keyDescription.joinToString(separator = ", ") {
AttestationPatcher.formatAsn1Primitive(it)
}
SystemLogger.verbose("Forged attestation data: ${formattedString}")
"Forged attestation data: $formattedString"
}
return Extension(ATTESTATION_OID, false, DEROctetString(keyDescription.encoded))
}
@@ -16,6 +16,7 @@ import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.KeyBox
import org.matrix.TEESimulator.pki.KeyBoxManager
import org.matrix.TEESimulator.util.toHex
import java.util.Date
/**
* Handles the modification (patching) of Android Key Attestation extensions within certificates.
@@ -36,7 +37,12 @@ object AttestationPatcher {
* @return A new, cryptographically valid, patched certificate chain. Returns the original chain
* on any failure.
*/
fun patchCertificateChain(originalChain: Array<Certificate>?, uid: Int): Array<Certificate> {
fun patchCertificateChain(
originalChain: Array<Certificate>?,
uid: Int,
notBefore: Date? = null,
notAfter: Date? = null,
): Array<Certificate> {
if (originalChain.isNullOrEmpty()) {
SystemLogger.error("Attempted to patch a null or empty certificate chain for UID $uid.")
return originalChain ?: emptyArray()
@@ -63,6 +69,8 @@ object AttestationPatcher {
keybox,
originalLeaf.sigAlgName,
uid,
notBefore,
notAfter,
)
// 4. Construct the NEW, VALID chain by prepending the patched leaf to the keybox's
@@ -111,17 +119,27 @@ object AttestationPatcher {
keybox: KeyBox,
sigAlgName: String,
uid: Int,
notBefore: Date? = null,
notAfter: Date? = null,
): Certificate {
// The issuer of our new leaf is the subject of the first certificate in our custom keybox
// chain.
val newIssuer = X509CertificateHolder(keybox.certificates[0].encoded).subject
val effectiveNotBefore = notBefore ?: originalLeafHolder.notBefore
val effectiveNotAfter = notAfter ?: originalLeafHolder.notAfter
if (notBefore != null || notAfter != null) {
SystemLogger.debug(
"Overriding cert dates: notBefore=${effectiveNotBefore} (was ${originalLeafHolder.notBefore}), notAfter=${effectiveNotAfter} (was ${originalLeafHolder.notAfter})"
)
}
val builder =
X509v3CertificateBuilder(
newIssuer,
originalLeafHolder.serialNumber,
originalLeafHolder.notBefore,
originalLeafHolder.notAfter,
effectiveNotBefore,
effectiveNotAfter,
originalLeafHolder.subject,
originalLeafHolder.subjectPublicKeyInfo,
)
@@ -146,7 +164,7 @@ object AttestationPatcher {
// Log the signature of the newly created certificate to observe its non-deterministic
// nature.
val signatureBytes = (newCertificate as X509Certificate).signature
SystemLogger.verbose("Signature of patched leaf cert: ${signatureBytes.toHex()}")
SystemLogger.verbose { "Signature of patched leaf cert: ${signatureBytes.toHex()}" }
return newCertificate
}
@@ -268,8 +286,10 @@ object AttestationPatcher {
private fun createPatchedAttestationExtension(parsed: ParsedAttestation, uid: Int): Extension {
val (allFields, teeEnforcedMap, originalRootOfTrust) = parsed
var formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
SystemLogger.verbose("Original attestation data: ${formattedString}")
SystemLogger.verbose {
val formattedString = allFields.joinToString(separator = ", ") { formatAsn1Primitive(it) }
"Original attestation data: $formattedString"
}
// Build the new Root of Trust and add/replace it in the map.
val newRootOfTrust = AttestationBuilder.buildRootOfTrust(originalRootOfTrust)
@@ -296,8 +316,10 @@ object AttestationPatcher {
allFields[AttestationConstants.KEY_DESCRIPTION_TEE_ENFORCED_INDEX] = sortedTeeEnforced
val patchedSequence = DERSequence(allFields)
formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
SystemLogger.verbose("Patched attestation data: ${formattedString}")
SystemLogger.verbose {
val formattedString = patchedSequence.joinToString(separator = ", ") { formatAsn1Primitive(it) }
"Patched attestation data: $formattedString"
}
val patchedOctets = DEROctetString(patchedSequence)
return Extension(ATTESTATION_OID, false, patchedOctets)
@@ -148,11 +148,12 @@ object DeviceAttestationService {
// The extension's value is an ASN.1 sequence.
val keyDescriptionSeq = ASN1Sequence.getInstance(extension.extnValue.octets)
var formattedString =
keyDescriptionSeq.joinToString(separator = ", ") {
SystemLogger.verbose {
val formattedString = keyDescriptionSeq.joinToString(separator = ", ") {
AttestationPatcher.formatAsn1Primitive(it)
}
SystemLogger.verbose("Cached attestation data: ${formattedString}")
"Cached attestation data: $formattedString"
}
val fields = keyDescriptionSeq.toArray()
val attestVersion =
@@ -46,6 +46,7 @@ data class KeyMintAttestation(
val usageExpireDateTime: Date?,
val usageCountLimit: Int?,
val callerNonce: Boolean?,
val nonce: ByteArray?,
val unlockedDeviceRequired: Boolean?,
val includeUniqueId: Boolean?,
val rollbackResistance: Boolean?,
@@ -121,6 +122,7 @@ data class KeyMintAttestation(
usageExpireDateTime = params.findDate(Tag.USAGE_EXPIRE_DATETIME),
usageCountLimit = params.findInteger(Tag.USAGE_COUNT_LIMIT),
callerNonce = params.findBoolean(Tag.CALLER_NONCE),
nonce = params.findBlob(Tag.NONCE),
unlockedDeviceRequired = params.findBoolean(Tag.UNLOCKED_DEVICE_REQUIRED),
includeUniqueId = params.findBoolean(Tag.INCLUDE_UNIQUE_ID),
rollbackResistance = params.findBoolean(Tag.ROLLBACK_RESISTANCE),
@@ -0,0 +1,48 @@
package org.matrix.TEESimulator.config
import android.os.SystemProperties
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
object BootStateManager {
private val targets =
linkedMapOf(
"ro.boot.verifiedbootstate" to "green",
"ro.boot.flash.locked" to "1",
"ro.boot.veritymode" to "enforcing",
"ro.boot.vbmeta.device_state" to "locked",
)
private val fillIfAbsent =
linkedMapOf(
"ro.boot.vbmeta.invalidate_on_error" to "yes",
"ro.boot.vbmeta.avb_version" to "1.2",
"ro.boot.vbmeta.hash_alg" to "sha256",
"ro.boot.vbmeta.size" to "11904",
)
fun apply() {
for ((name, target) in targets) {
val current = SystemProperties.get(name, "")
if (current.isEmpty()) {
SystemLogger.debug("BootStateManager: $name absent on this device, skip")
continue
}
if (current == target) {
SystemLogger.debug("BootStateManager: $name already $target, skip")
continue
}
SystemLogger.info("BootStateManager: setting $name=$target (was: '$current')")
AndroidDeviceUtils.setProperty(name, target)
}
for ((name, value) in fillIfAbsent) {
val current = SystemProperties.get(name, "")
if (current.isNotEmpty()) {
SystemLogger.debug("BootStateManager: $name already '$current', skip")
continue
}
SystemLogger.info("BootStateManager: filling absent $name=$value")
AndroidDeviceUtils.setProperty(name, value)
}
}
}
@@ -0,0 +1,193 @@
package org.matrix.TEESimulator.config
import android.os.Handler
import android.os.HandlerThread
import java.io.File
import java.net.URL
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
import org.matrix.TEESimulator.BuildConfig
import org.matrix.TEESimulator.logging.SystemLogger
object BulletinPoller {
private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
private const val HISTORY_CAP = 10
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
private val DATE_REGEX = Regex("<td>(\\d{4}-\\d{2}-\\d{2})</td>")
private val PATCH_DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
private lateinit var handler: Handler
@Volatile private var bootstrapStep = 0
@Volatile private var steadyArmed = false
fun start() {
val thread = HandlerThread("BulletinPoller").apply { start() }
handler = Handler(thread.looper)
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
}
private fun pollOnce() {
try {
val result = fetchAndParse()
appendHistory(result)
scheduleNext(result.status == "success")
} catch (t: Throwable) {
SystemLogger.error("BulletinPoller: pollOnce failed", t)
scheduleNext(false)
}
}
private fun scheduleNext(success: Boolean) {
if (success || steadyArmed) {
steadyArmed = true
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
return
}
bootstrapStep++
if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
steadyArmed = true
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
} else {
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
}
}
private data class FetchResult(
val ts: Long,
val status: String,
val httpCode: Int?,
val parsedDate: String?,
val applied: Boolean,
val error: String?,
)
private fun fetchAndParse(): FetchResult {
val ts = System.currentTimeMillis()
var conn: HttpsURLConnection? = null
return try {
conn =
(URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty(
"User-Agent",
"TEESimulator/${BuildConfig.VERSION_NAME}",
)
requestMethod = "GET"
}
val code = conn.responseCode
if (code != 200) {
return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
}
val html = conn.inputStream.bufferedReader().use { it.readText() }
val date = DATE_REGEX.find(html)?.groupValues?.get(1)
if (date == null) {
return FetchResult(
ts,
"parse_error",
code,
null,
false,
"no <td>YYYY-MM-DD</td> match",
)
}
val current = currentPatch()
if (current == null || date <= current) {
return FetchResult(ts, "success", code, date, false, null)
}
if (PatchLevelManager.updateTo(date)) {
FetchResult(ts, "success", code, date, true, null)
} else {
FetchResult(
ts,
"validation_rejected",
code,
date,
false,
"PatchLevelManager.updateTo rejected $date",
)
}
} catch (e: Exception) {
FetchResult(ts, "network_error", null, null, false, e.toString())
} finally {
conn?.disconnect()
}
}
private fun currentPatch(): String? {
val f = File(PATCH_FILE)
if (!f.exists()) return null
val raw = try {
f.readLines()
.firstOrNull { it.startsWith("system=") }
?.substringAfter("system=")
?.trim()
?.takeIf { it != "prop" && it.isNotEmpty() }
} catch (_: Exception) {
null
}
if (raw == null) return null
if (PATCH_DATE_PATTERN.matches(raw)) return raw
SystemLogger.warning(
"BulletinPoller: ignoring malformed system='$raw' in $PATCH_FILE"
)
return null
}
private fun appendHistory(result: FetchResult) {
try {
val target = File(HISTORY_FILE)
val staging = File(HISTORY_STAGING)
val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
val history =
existing
?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
?: JSONArray()
val entry =
JSONObject().apply {
put("ts", result.ts)
put("status", result.status)
put("http_code", result.httpCode ?: JSONObject.NULL)
put("parsed_date", result.parsedDate ?: JSONObject.NULL)
put("applied", result.applied)
put("error", result.error ?: JSONObject.NULL)
}
history.put(entry)
while (history.length() > HISTORY_CAP) history.remove(0)
val latestKnown =
(0 until history.length())
.mapNotNull {
history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
d.isNotBlank()
}
}
.lastOrNull()
val root =
JSONObject().apply {
put("latest_known_date", latestKnown ?: JSONObject.NULL)
put("history", history)
}
staging.writeText(root.toString(2))
Files.move(
staging.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (e: Exception) {
SystemLogger.error("BulletinPoller: failed to persist history", e)
}
}
}
@@ -7,6 +7,7 @@ import android.os.IBinder
import android.os.ServiceManager
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import org.matrix.TEESimulator.attestation.DeviceAttestationService
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.KeyBoxManager
@@ -92,7 +93,16 @@ object ConfigurationManager {
fun shouldSkipUid(uid: Int): Boolean = getPackageModeForUid(uid) == null
fun isAutoMode(uid: Int): Boolean = getPackageModeForUid(uid) == Mode.AUTO
fun isAutoMode(uid: Int): Boolean {
for (pkg in getPackagesForUid(uid)) {
when (packageModes[pkg]) {
Mode.GENERATE, Mode.PATCH -> return false
Mode.AUTO -> return true
null -> continue
}
}
return false
}
private fun getPackageModeForUid(uid: Int): Mode? {
val packages = getPackagesForUid(uid)
@@ -102,7 +112,7 @@ object ConfigurationManager {
when (packageModes[pkg]) {
Mode.GENERATE -> return Mode.GENERATE
Mode.PATCH -> return Mode.PATCH
Mode.AUTO -> return Mode.AUTO
Mode.AUTO -> return if (DeviceAttestationService.isTeeFunctional) Mode.PATCH else Mode.GENERATE
null -> continue
}
}
@@ -164,7 +174,6 @@ object ConfigurationManager {
newModes[pkg] = Mode.PATCH
newKeyboxes[pkg] = currentKeybox
}
// No suffix means AUTO mode.
else -> {
newModes[trimmedLine] = Mode.AUTO
newKeyboxes[trimmedLine] = currentKeybox
@@ -297,10 +306,15 @@ object ConfigurationManager {
)
KeyBoxManager.invalidateCache(path)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
// Clear cached keys possibly containing old certificates
// Drop only the patched cert chains so the next
// attestation request re-signs with the new keybox.
// Do NOT drop generatedKeys — that would destroy
// every alias/private key in memory and on disk,
// logging users out of any app that pinned a
// persisted keystore alias.
org.matrix.TEESimulator.interception.keystore.shim
.KeyMintSecurityLevelInterceptor
.clearAllGeneratedKeys("updating $file")
.invalidatePatchedChains("updating $file")
}
}
}
@@ -0,0 +1,192 @@
package org.matrix.TEESimulator.config
import android.os.Build
import android.os.FileObserver
import android.os.SystemProperties
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.LocalDate
import org.json.JSONObject
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
object PatchLevelManager {
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
private const val STAGING_FILE = "/data/adb/tricky_store/security_patch.txt.next"
private const val PIF_DIR = "/data/adb/modules/playintegrityfix"
private const val FLOOR_YYYYMMDD = 20200101
private const val MAX_PAST_OFFSET = 10000
/**
* Pixel security bulletins publish monthly; pre-announced dates occasionally
* slip by 2-4 weeks. 60 days covers that window without admitting a
* far-future date from a hostile or mis-parsed bulletin response.
*/
private const val MAX_FUTURE_DAYS = 60L
private val PIF_FILENAMES =
setOf("pif.json", "pif.prop", "custom.pif.json", "custom.pif.prop")
private val DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
private val PROP_PATTERN = Regex("^SECURITY_PATCH=(.+)$", RegexOption.MULTILINE)
private val SECTION_HEADER = Regex("^\\[[a-zA-Z0-9_.-]+]$")
private val GLOBAL_KEYS = setOf("system", "boot", "vendor", "all")
private val PIF_SOURCES =
listOf(
"/data/adb/modules/playintegrityfix/pif.json",
"/data/adb/pif.json",
"/data/adb/modules/playintegrityfix/pif.prop",
"/data/adb/pif.prop",
"/data/adb/modules/playintegrityfix/custom.pif.json",
"/data/adb/modules/playintegrityfix/custom.pif.prop",
)
fun initialize() {
refreshFromSources()
startPifObserver()
}
private fun refreshFromSources() {
val date =
resolvePifPatch()
?: SystemProperties.get(
"ro.build.version.security_patch",
Build.VERSION.SECURITY_PATCH,
)
SystemLogger.info("PatchLevelManager: resolved patch date = $date")
applyToProps(date)
}
private fun startPifObserver() {
if (!File(PIF_DIR).exists()) {
SystemLogger.debug("PatchLevelManager: PIF dir absent, hot-reload disabled")
return
}
PifObserver.startWatching()
}
@Synchronized
private fun applyToProps(date: String) {
if (!DATE_PATTERN.matches(date)) {
SystemLogger.warning(
"PatchLevelManager: skip resetprop for invalid date: $date"
)
return
}
AndroidDeviceUtils.setProperty("ro.build.version.security_patch", date)
AndroidDeviceUtils.setProperty("ro.vendor.build.security_patch", date)
}
fun updateTo(date: String): Boolean {
if (!DATE_PATTERN.matches(date)) {
SystemLogger.warning("PatchLevelManager: invalid date format: $date")
return false
}
val dateInt = date.replace("-", "").toInt()
if (dateInt < FLOOR_YYYYMMDD) {
SystemLogger.warning("PatchLevelManager: $date below floor $FLOOR_YYYYMMDD")
return false
}
val now = LocalDate.now()
val today = now.year * 10000 + now.monthValue * 100 + now.dayOfMonth
if (today >= dateInt + MAX_PAST_OFFSET) {
SystemLogger.warning(
"PatchLevelManager: $date more than 1y older than today ($today)"
)
return false
}
val maxFuture =
now.plusDays(MAX_FUTURE_DAYS).let {
it.year * 10000 + it.monthValue * 100 + it.dayOfMonth
}
if (dateInt > maxFuture) {
SystemLogger.warning(
"PatchLevelManager: $date more than $MAX_FUTURE_DAYS days in future ($maxFuture)"
)
return false
}
try {
atomicWrite(date)
} catch (e: Exception) {
SystemLogger.error("PatchLevelManager: atomicWrite failed for $date", e)
return false
}
applyToProps(date)
SystemLogger.info("PatchLevelManager: applied patch date $date")
return true
}
private fun resolvePifPatch(): String? {
val source =
PIF_SOURCES.map(::File).lastOrNull { it.exists() && it.length() > 0 }
?: return null
return try {
val text = source.readText()
val parsed =
if (source.name.endsWith(".json")) {
JSONObject(text).optString("SECURITY_PATCH", "")
} else {
PROP_PATTERN.find(text)?.groupValues?.get(1)?.trim().orEmpty()
}
parsed.takeIf { it.isNotBlank() }
} catch (e: Exception) {
SystemLogger.warning(
"PatchLevelManager: failed to parse ${source.path}: ${e.message}"
)
null
}
}
private fun atomicWrite(date: String) {
val target = File(PATCH_FILE)
val staging = File(STAGING_FILE)
staging.writeText(mergedContents(target, date))
Files.move(
staging.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
}
private fun mergedContents(target: File, date: String): String {
val globalBlock = "system=$date\nboot=$date\nvendor=$date\n"
if (!target.exists()) return globalBlock
val tail = stripGlobalAssignments(target.readLines())
if (tail.isEmpty()) return globalBlock
return globalBlock + tail.joinToString("\n", prefix = "\n", postfix = "\n")
}
private fun stripGlobalAssignments(lines: List<String>): List<String> {
val kept = mutableListOf<String>()
var inGlobal = true
for (line in lines) {
val trimmed = line.trim()
if (SECTION_HEADER.matches(trimmed)) {
inGlobal = false
kept += line
continue
}
if (inGlobal && isGlobalKeyAssignment(trimmed)) continue
kept += line
}
return kept
}
private fun isGlobalKeyAssignment(trimmed: String): Boolean {
if (trimmed.isEmpty() || trimmed.startsWith("#") || '=' !in trimmed) return false
val key = trimmed.substringBefore('=').trim().lowercase()
return key in GLOBAL_KEYS
}
private object PifObserver :
FileObserver(File(PIF_DIR), CLOSE_WRITE or MOVED_TO or DELETE) {
override fun onEvent(event: Int, path: String?) {
if (path == null || path !in PIF_FILENAMES) return
SystemLogger.info("PatchLevelManager: PIF change ($path), refreshing")
refreshFromSources()
}
}
}
@@ -19,10 +19,27 @@ object InterceptorUtils {
private const val EX_SERVICE_SPECIFIC = -8
private fun synthesizeSseMessage(errorCode: Int): String =
when (errorCode) {
2 -> "Error::Rc(SYSTEM_ERROR)"
4 -> "Error::Rc(PERMISSION_DENIED)"
6 -> "Error::Rc(VALUE_CORRUPTED)"
7 -> "Error::Rc(KEY_NOT_FOUND)"
10 -> "Error::Rc(BACKEND_BUSY)"
-3 -> "Error::Km(UNSUPPORTED_KEY_SIZE)"
-6 -> "Error::Km(INCOMPATIBLE_PURPOSE)"
-7 -> "Error::Km(INCOMPATIBLE_ALGORITHM)"
-29 -> "Error::Km(TOO_MANY_OPERATIONS)"
-49 -> "Error::Km(UNSUPPORTED_TAG)"
-75 -> "Error::Km(INVALID_INPUT_LENGTH)"
-76 -> "Error::Km(INVALID_TAG)"
else -> if (errorCode > 0) "Error::Rc($errorCode)" else "Error::Km($errorCode)"
}
fun createErrorReply(errorCode: Int): BinderInterceptor.TransactionResult.OverrideReply {
val parcel = Parcel.obtain().apply {
writeInt(EX_SERVICE_SPECIFIC)
writeString(null)
writeString(synthesizeSseMessage(errorCode))
writeInt(0) // empty remote stack trace header (AOSP Status.cpp:196)
writeInt(errorCode)
}
@@ -132,12 +149,25 @@ object InterceptorUtils {
fun createServiceSpecificErrorReply(
errorCode: Int
): BinderInterceptor.TransactionResult.OverrideReply {
val parcel =
Parcel.obtain().apply {
writeException(android.os.ServiceSpecificException(errorCode))
): 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)
}
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
}
fun patchAuthorizations(
@@ -62,6 +62,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
private val deletedSoftwareKeys: MutableSet<KeyIdentifier> = ConcurrentHashMap.newKeySet()
private val userUpdatedKeys = ConcurrentHashMap.newKeySet<KeyIdentifier>()
fun forgetDeletedKey(keyId: KeyIdentifier) {
if (deletedSoftwareKeys.remove(keyId)) {
SystemLogger.debug("Cleared deletion marker for ${keyId.alias}")
}
}
override val serviceName = "android.system.keystore2.IKeystoreService/default"
override val processName = "keystore2"
override val injectionCommand = "exec ./inject `pidof keystore2` libTEESimulator.so entry"
@@ -210,6 +216,37 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
if (descriptor.alias == null) {
if (descriptor.domain == Domain.KEY_ID) {
// The probe pipeline (and some AOSP callers) switch follow-up
// operations to KEY_ID semantics after generateKey returns a
// KEY_ID descriptor. Without this branch, our software keys
// are invisible to KEY_ID-based getKeyEntry calls and the
// request falls through to the real keystore2 daemon, which
// legitimately responds with KEY_NOT_FOUND. Duck Detector's
// TimingSideChannelProbe captures that exception during its
// warmup phase and surfaces it as
// "Captured private binder exception during timing skip".
// Resolving by KEY_ID and returning the cached response keeps
// the call on the happy path, eliminating the warmup signal.
val info = KeyMintSecurityLevelInterceptor.findGeneratedKeyByKeyId(
callingUid, descriptor.nspace
)
if (info?.response != null) {
SystemLogger.info(
"[TX_ID: $txId] Found generated response via KEY_ID nspace=${descriptor.nspace}"
)
return InterceptorUtils.createTypedObjectReply(info.response)
}
val teeResp = KeyMintSecurityLevelInterceptor.findTeeResponseByKeyId(
callingUid, descriptor.nspace
)
if (teeResp != null) {
SystemLogger.info(
"[TX_ID: $txId] Found TEE response via KEY_ID nspace=${descriptor.nspace}"
)
return InterceptorUtils.createTypedObjectReply(teeResp)
}
}
return TransactionResult.ContinueAndSkipPost
}
val keyId = KeyIdentifier(callingUid, descriptor.alias)
@@ -256,8 +293,12 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
reply: Parcel?,
resultCode: Int,
): TransactionResult {
if (target != keystoreService || reply == null || InterceptorUtils.hasException(reply))
return TransactionResult.SkipTransaction
if (target != keystoreService || reply == null) return TransactionResult.SkipTransaction
if (InterceptorUtils.hasException(reply)) {
val normalized = InterceptorUtils.normalizeServiceSpecificReply(reply)
return if (normalized != null) TransactionResult.OverrideReply(normalized)
else TransactionResult.SkipTransaction
}
if (code == GET_NUMBER_OF_ENTRIES_TRANSACTION) {
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
@@ -314,6 +355,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
if (userUpdatedKeys.remove(keyId)) {
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: userUpdated=true, skipping patch" }
SystemLogger.debug("[TX_ID: $txId] Skipping cert patch for user-updated key $keyId.")
return TransactionResult.SkipTransaction
}
@@ -324,18 +366,28 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray()
)
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: isImport=${parsedParameters.isImportKey()} origin=${parsedParameters.origin} inImportedKeys=${KeyMintSecurityLevelInterceptor.importedKeys.contains(keyId)} hasPatchedChain=${KeyMintSecurityLevelInterceptor.getPatchedChain(keyId) != null} isAttestKey=${parsedParameters.isAttestKey()}" }
if (parsedParameters.isImportKey()) {
val retainedChain = KeyMintSecurityLevelInterceptor.getPatchedChain(keyId)
if (retainedChain == null) {
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: imported, no retained chain, skip" }
SystemLogger.info("[TX_ID: $txId] Skip patching for imported key (no prior attestation).")
return TransactionResult.SkipTransaction
}
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: imported, SERVING RETAINED CHAIN (detection vector!)" }
SystemLogger.info("[TX_ID: $txId] Imported key overwrote attested alias, serving retained chain for $keyId")
CertificateHelper.updateCertificateChain(response.metadata, retainedChain).getOrThrow()
response.metadata.authorizations =
InterceptorUtils.patchAuthorizations(
response.metadata.authorizations,
callingUid,
)
return InterceptorUtils.createTypedObjectReply(response)
}
if (KeyMintSecurityLevelInterceptor.importedKeys.contains(keyId)) {
SystemLogger.trace { "[TRACE-$txId] getKeyEntry $keyId: in importedKeys set, skip" }
SystemLogger.debug("[TX_ID: $txId] Skipping attest-key override for imported key $keyId")
return TransactionResult.SkipTransaction
}
@@ -376,9 +428,24 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
)
KeyMintSecurityLevelInterceptor.attestationKeys.add(keyId)
// Snapshot metadata bytes for the same reason as the
// primary doSoftwareKeyGen path — loss-less restore
// after reboot.
val metadataBytesForPersist = response.metadata?.let { md ->
runCatching {
val parcel = android.os.Parcel.obtain()
try {
md.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
}
GeneratedKeyPersistence.save(
keyId = keyId,
keyPair = keyData.first,
secretKey = null,
nspace = newNspace,
securityLevel = response.metadata.keySecurityLevel,
certChain = keyData.second,
@@ -388,6 +455,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
purposes = parsedParameters.purpose,
digests = parsedParameters.digest,
isAttestationKey = true,
metadataBytes = metadataBytesForPersist,
)
return InterceptorUtils.createTypedObjectReply(response)
@@ -459,7 +527,11 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
}
if (generatedKeyInfo == null) {
descriptor.alias?.let { userUpdatedKeys.add(KeyIdentifier(callingUid, it)) }
descriptor.alias?.let {
val kid = KeyIdentifier(callingUid, it)
userUpdatedKeys.add(kid)
SystemLogger.trace { "[TRACE] updateSubcomponent $kid: not generated key, added to userUpdatedKeys" }
}
return TransactionResult.ContinueAndSkipPost
}
@@ -436,6 +436,7 @@ private data class LegacyKeygenParameters(
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
@@ -29,8 +29,6 @@ object AuthorizeCreate {
) {
return KeystoreErrorCodes.unsupportedPurpose
}
if (algo == Algorithm.EC && purpose == KeyPurpose.DECRYPT)
return KeystoreErrorCodes.unsupportedPurpose
if (algo == Algorithm.RSA && purpose == KeyPurpose.AGREE_KEY)
return KeystoreErrorCodes.unsupportedPurpose
return null
@@ -29,13 +29,47 @@ data class PersistedKeyData(
val ecCurve: Int,
val purposes: List<Int>,
val digests: List<Int>,
/** PKCS#8-encoded private key for asymmetric records, empty for symmetric. */
val privateKeyBytes: ByteArray,
val certChainBytes: List<ByteArray>,
/**
* Byte-identical KeyMetadata parcel snapshot. Restoring authorizations
* directly from these bytes preserves tag count, order, and exact
* security-level annotations across reboots — the kind of structural
* details apps fingerprint to decide whether the alias is still
* "the same key".
*/
val metadataBytes: ByteArray,
/**
* Raw secret material for symmetric records (AES, HMAC, 3DES). Empty
* for asymmetric. Critical for AndroidX security crypto MasterKey
* (AES-GCM-256) — without this every reboot regenerates a fresh AES
* key and EncryptedSharedPreferences becomes undecryptable, which is
* what banking apps interpret as session expiry and force a relogin.
*/
val symmetricKeyBytes: ByteArray,
val symmetricAlgorithm: String,
)
object GeneratedKeyPersistence {
private const val FORMAT_VERSION = 1
/**
* Single source of truth for the on-disk format. Bump this every time
* the layout changes; older numbers are silently skipped on read so
* stale dev artifacts and pre-fix upstream files can't be partially
* rehydrated into broken in-memory state.
*
* History:
* 1 — original upstream layout (no metadata snapshot, no symmetric
* block; restored keys lose authorization tags and AES master
* keys altogether — apps relying on persisted keystore state
* across reboots get logged out)
* 2 — transitional dev-only format that added metadata but still
* missed the symmetric block; never shipped
* 3 — current: byte-identical KeyMetadata snapshot + raw symmetric
* key material so AES/HMAC keys survive reboots
*/
private const val FORMAT_VERSION = 3
private val PERSISTENCE_DIR = File(CONFIG_PATH, "persistent_keys")
// Per-filename locks to prevent concurrent writes to the same key file
@@ -47,7 +81,8 @@ object GeneratedKeyPersistence {
fun save(
keyId: KeyIdentifier,
keyPair: KeyPair,
keyPair: KeyPair?,
secretKey: javax.crypto.SecretKey?,
nspace: Long,
securityLevel: Int,
certChain: List<Certificate>,
@@ -57,7 +92,11 @@ object GeneratedKeyPersistence {
purposes: List<Int>,
digests: List<Int>,
isAttestationKey: Boolean,
metadataBytes: ByteArray? = null,
) {
require(keyPair != null || secretKey != null) {
"Either keyPair or secretKey must be provided"
}
val filename = keyFileName(keyId.uid, keyId.alias)
val lock = getLockForKey(filename)
SystemLogger.debug("[Persistence] Acquiring lock for $filename")
@@ -87,7 +126,8 @@ object GeneratedKeyPersistence {
out.writeInt(digests.size)
digests.forEach { out.writeInt(it) }
val pkBytes = keyPair.private.encoded
// Asymmetric key block (empty for symmetric-only).
val pkBytes = keyPair?.private?.encoded ?: ByteArray(0)
out.writeInt(pkBytes.size)
out.write(pkBytes)
@@ -97,6 +137,23 @@ object GeneratedKeyPersistence {
out.writeInt(encoded.size)
out.write(encoded)
}
// Metadata snapshot (always present, may be empty
// if the live KeyMetadata could not be marshalled).
val mdBytes = metadataBytes ?: ByteArray(0)
out.writeInt(mdBytes.size)
if (mdBytes.isNotEmpty()) out.write(mdBytes)
// Symmetric key block (empty for asymmetric keys).
if (secretKey != null) {
val skBytes = secretKey.encoded
out.writeUTF(secretKey.algorithm)
out.writeInt(skBytes.size)
out.write(skBytes)
} else {
out.writeUTF("")
out.writeInt(0)
}
}
} catch (e: Exception) {
tmpFile.delete()
@@ -189,8 +246,17 @@ object GeneratedKeyPersistence {
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
val version = input.readInt()
if (version != FORMAT_VERSION) {
SystemLogger.warning(
"Skipping ${file.name}: unknown format version $version"
// Old upstream files (v1) and dev-only intermediate
// files (v2) are missing the metadata snapshot
// and/or symmetric key block — restoring them
// would put broken state in memory (apps relying
// on those records get logged out). Skip and let
// the next generateKey re-create cleanly with the
// new format. Affected apps re-login once after
// upgrade, then never again.
SystemLogger.info(
"Skipping ${file.name}: legacy format version $version. " +
"It will be replaced on next generateKey for this alias."
)
return@runCatching
}
@@ -212,7 +278,7 @@ object GeneratedKeyPersistence {
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
val pkBytes = ByteArray(pkLen)
input.readFully(pkBytes)
if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map {
@@ -222,6 +288,17 @@ object GeneratedKeyPersistence {
certBytes
}
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
val skAlgo = input.readUTF()
val skLen = requireBounds(input.readInt(), 8192, "skLen")
val skBytes = ByteArray(skLen).also {
if (skLen > 0) input.readFully(it)
}
if (storedSecLevel == securityLevel) {
result.add(
PersistedKeyData(
@@ -237,6 +314,9 @@ object GeneratedKeyPersistence {
digests = digests,
privateKeyBytes = pkBytes,
certChainBytes = certChainBytes,
metadataBytes = metadataBytes,
symmetricKeyBytes = skBytes,
symmetricAlgorithm = skAlgo,
)
)
}
@@ -292,7 +372,7 @@ object GeneratedKeyPersistence {
DataInputStream(BufferedInputStream(FileInputStream(existing))).use { input ->
val version = input.readInt()
if (version != FORMAT_VERSION) {
SystemLogger.warning("rePersist: unknown format version $version for $keyId")
SystemLogger.warning("rePersist: legacy format version $version for $keyId, will not re-persist (next generateKey replaces it)")
return
}
readPersistedKeyData(input)
@@ -303,10 +383,29 @@ object GeneratedKeyPersistence {
return
}
val keyPair = generatedKeyInfo.keyPair ?: return
val keyPair = generatedKeyInfo.keyPair
val secretKey = generatedKeyInfo.secretKey
if (keyPair == null && secretKey == null) {
SystemLogger.warning("rePersist: no key material for $keyId")
return
}
// Serialize the live KeyMetadata (now contains the user-installed cert
// chain via updateSubcomponent) so the next boot restores byte-identical
// metadata. KeyMetadata is binder-free, so marshall() is safe here.
val metadataBytes = runCatching {
android.os.Parcel.obtain().let { parcel ->
try {
metadata.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
}.getOrNull()
save(
keyId = keyId,
keyPair = keyPair,
secretKey = secretKey,
nspace = generatedKeyInfo.nspace,
securityLevel = secLevel,
certChain = newChain.toList(),
@@ -316,6 +415,7 @@ object GeneratedKeyPersistence {
purposes = persisted.purposes,
digests = persisted.digests,
isAttestationKey = persisted.isAttestationKey,
metadataBytes = metadataBytes,
)
SystemLogger.debug("Re-persisted key $keyId with updated cert chain")
}
@@ -332,7 +432,8 @@ object GeneratedKeyPersistence {
return digest.joinToString("") { "%02x".format(it) } + ".bin"
}
// Reads all fields after version has already been consumed
// Reads all fields after the version int has already been consumed
// and validated by the caller.
private fun readPersistedKeyData(input: DataInputStream): PersistedKeyData {
val secLevel = input.readInt()
val uid = input.readInt()
@@ -351,7 +452,7 @@ object GeneratedKeyPersistence {
val pkLen = requireBounds(input.readInt(), 8192, "pkLen")
val pkBytes = ByteArray(pkLen)
input.readFully(pkBytes)
if (pkLen > 0) input.readFully(pkBytes)
val certCount = requireBounds(input.readInt(), 10, "certCount")
val certChainBytes = (0 until certCount).map {
@@ -361,6 +462,17 @@ object GeneratedKeyPersistence {
certBytes
}
val metaLen = requireBounds(input.readInt(), 256 * 1024, "metaLen")
val metadataBytes = ByteArray(metaLen).also {
if (metaLen > 0) input.readFully(it)
}
val skAlgo = input.readUTF()
val skLen = requireBounds(input.readInt(), 8192, "skLen")
val skBytes = ByteArray(skLen).also {
if (skLen > 0) input.readFully(it)
}
return PersistedKeyData(
uid = uid,
alias = alias,
@@ -374,6 +486,9 @@ object GeneratedKeyPersistence {
digests = digests,
privateKeyBytes = pkBytes,
certChainBytes = certChainBytes,
metadataBytes = metadataBytes,
symmetricKeyBytes = skBytes,
symmetricAlgorithm = skAlgo,
)
}
}
@@ -20,11 +20,13 @@ import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedDeque
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.LockSupport
import org.matrix.TEESimulator.attestation.AttestationBuilder
import org.matrix.TEESimulator.attestation.AttestationConstants
@@ -34,6 +36,7 @@ import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.interception.core.BinderInterceptor
import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
import org.matrix.TEESimulator.interception.keystore.KeyIdentifier
import org.matrix.TEESimulator.interception.keystore.Keystore2Interceptor
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.pki.CertGenConfig
import org.matrix.TEESimulator.pki.CertificateGenerator
@@ -57,6 +60,10 @@ class KeyMintSecurityLevelInterceptor(
val keyParams: KeyMintAttestation? = null,
)
// null = undecided, true = TEE works (use PATCH), false = TEE broken (use GENERATE)
// Instance field so TRUSTED_ENVIRONMENT and STRONGBOX decide independently
val teePathDecision = AtomicReference<Boolean?>(null)
private val activeOps = ConcurrentHashMap<Int, ConcurrentLinkedDeque<SoftwareOperation>>()
private val recentOps = ConcurrentHashMap<Int, ConcurrentLinkedDeque<Long>>()
@@ -136,12 +143,14 @@ class KeyMintSecurityLevelInterceptor(
}
attestationKeys.remove(keyId)
importedKeys.add(keyId)
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: added to importedKeys, skipUid=${ConfigurationManager.shouldSkipUid(callingUid)}" }
if (!ConfigurationManager.shouldSkipUid(callingUid)) {
val metadata: KeyMetadata =
reply.readTypedObject(KeyMetadata.CREATOR)
?: return TransactionResult.SkipTransaction
val originalChain = CertificateHelper.getCertificateChain(metadata)
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: chainSize=${originalChain?.size ?: 0}" }
if (originalChain != null && originalChain.size > 1) {
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid)
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
@@ -152,6 +161,7 @@ class KeyMintSecurityLevelInterceptor(
this.metadata = metadata
iSecurityLevel = original
}
SystemLogger.trace { "[TRACE-$txId] post-importKey $keyId: PATCHED chain (chainSize=${newChain.size})" }
SystemLogger.debug("Cached patched certificate chain for imported key $keyId.")
return InterceptorUtils.createTypedObjectReply(metadata)
}
@@ -184,7 +194,8 @@ class KeyMintSecurityLevelInterceptor(
SystemLogger.info("Found new IKeystoreOperation. Registering interceptor...")
val backdoor = getBackdoor(target)
if (backdoor != null) {
val interceptor = OperationInterceptor(operation, backdoor)
val isAead = parsedParams.blockMode.firstOrNull() == BlockMode.GCM
val interceptor = OperationInterceptor(operation, backdoor, isAead)
register(backdoor, operationBinder, interceptor, OperationInterceptor.INTERCEPTED_CODES)
interceptedOperations[operationBinder] = interceptor
} else {
@@ -200,24 +211,39 @@ class KeyMintSecurityLevelInterceptor(
val metadata: KeyMetadata =
reply.readTypedObject(KeyMetadata.CREATOR)
?: return TransactionResult.SkipTransaction
val originalChain =
CertificateHelper.getCertificateChain(metadata)
?: return TransactionResult.SkipTransaction
if (originalChain.size > 1) {
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid)
// Cache the newly patched chain to ensure consistency across subsequent API calls.
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.SkipTransaction
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
val originalChain = CertificateHelper.getCertificateChain(metadata)
if (originalChain == null || originalChain.size <= 1) {
// Cache non-attested responses for KEY_ID getKeyEntry parity.
// Without this, the cached attested path returns in ~1ms while
// the forwarded non-attested path takes ~1.5ms, and
// TimingSideChannelProbe flags the 1.55x ratio.
cleanupKeyData(keyId)
teeResponses[keyId] = KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
return TransactionResult.SkipTransaction
}
data.readTypedObject(KeyDescriptor.CREATOR) // skip attestationKey
val keyParams = data.createTypedArray(KeyParameter.CREATOR)
val certNotBefore = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_BEFORE }?.value?.dateTime?.let { Date(it) }
val certNotAfter = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_AFTER }?.value?.dateTime?.let { Date(it) }
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid, certNotBefore, certNotAfter)
val key = metadata.key
?: return TransactionResult.SkipTransaction
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
metadata.authorizations =
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
// We must clean up cached generated keys before storing the patched chain
cleanupKeyData(keyId)
patchedChains[keyId] = newChain
teeResponses[keyId] = KeyEntryResponse().apply {
@@ -230,7 +256,6 @@ class KeyMintSecurityLevelInterceptor(
return InterceptorUtils.createTypedObjectReply(metadata)
}
}
return TransactionResult.SkipTransaction
}
@@ -348,10 +373,18 @@ class KeyMintSecurityLevelInterceptor(
keyParams.copy(
purpose = parsedParams.purpose,
digest = parsedParams.digest.ifEmpty { keyParams.digest },
blockMode = parsedParams.blockMode.ifEmpty { keyParams.blockMode },
padding = parsedParams.padding.ifEmpty { keyParams.padding },
nonce = parsedParams.nonce,
minMacLength = parsedParams.minMacLength ?: keyParams.minMacLength,
)
} else parsedParams
val opLatency = if (securityLevel == SecurityLevel.STRONGBOX) STRONGBOX_OP_LATENCY_FLOOR_MS else 0L
val opLatency = when (securityLevel) {
SecurityLevel.STRONGBOX -> STRONGBOX_OP_LATENCY_FLOOR_MS
SecurityLevel.TRUSTED_ENVIRONMENT -> TEE_OP_LATENCY_FLOOR_MS
else -> 0L
}
val softwareOperation = SoftwareOperation(txId, generatedKeyInfo.keyPair, generatedKeyInfo.secretKey, effectiveParams, opLatency)
if (keyParams?.usageCountLimit != null) {
@@ -391,10 +424,7 @@ class KeyMintSecurityLevelInterceptor(
}
private fun handleGenerateKey(txId: Long, callingUid: Int, callingPid: Int, data: Parcel): TransactionResult {
if (data.dataSize() > MAX_ALIAS_LENGTH) {
SystemLogger.warning("Skipping oversized transaction: ${data.dataSize()} bytes")
return TransactionResult.ContinueAndSkipPost
}
val oversized = data.dataSize() > MAX_ALIAS_LENGTH
return runCatching {
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
@@ -407,6 +437,11 @@ class KeyMintSecurityLevelInterceptor(
val params = data.createTypedArray(KeyParameter.CREATOR)!!
val parsedParams = KeyMintAttestation(params)
SystemLogger.trace { "[TRACE-$txId] generateKey alias=${keyDescriptor.alias} algo=${parsedParams.algorithm} challenge=${parsedParams.attestationChallenge?.size ?: "null"} serial=${parsedParams.serial != null} imei=${parsedParams.imei != null} noAuth=${parsedParams.noAuthRequired} purposes=${parsedParams.purpose}" }
if (SystemLogger.isDebugBuild) params.forEach { p ->
SystemLogger.trace { "[TRACE-$txId] tag=${p.tag} value=${p.value}" }
}
val challenge = parsedParams.attestationChallenge
if (challenge != null && challenge.size > AttestationConstants.CHALLENGE_LENGTH_LIMIT) {
SystemLogger.warning("[TX_ID: $txId] Rejecting oversized attestation challenge: ${challenge.size} bytes (max ${AttestationConstants.CHALLENGE_LENGTH_LIMIT})")
@@ -464,20 +499,27 @@ class KeyMintSecurityLevelInterceptor(
val isAttestKeyRequest = parsedParams.isAttestKey()
val forceGenerate =
oversized ||
ConfigurationManager.shouldGenerate(callingUid) ||
(ConfigurationManager.shouldPatch(callingUid) && isAttestKeyRequest) ||
(attestationKey != null &&
isAttestationKey(KeyIdentifier(callingUid, attestationKey.alias)))
(attestationKey.alias?.let { isAttestationKey(KeyIdentifier(callingUid, it)) }
?: attestationKeys.any { kid -> kid.uid == callingUid && generatedKeys[kid]?.nspace == attestationKey.nspace }))
val isAuto = ConfigurationManager.isAutoMode(callingUid)
if (isAuto) SystemLogger.debug("AUTO dispatch: teePathDecision=${teePathDecision.get()} for ${keyDescriptor.alias}")
SystemLogger.trace { "[TRACE-$txId] dispatch: forceGen=$forceGenerate isAuto=$isAuto teePath=${teePathDecision.get()} hasChallenge=${challenge != null} isSymmetric=$isSymmetric isAttestKey=$isAttestKeyRequest" }
when {
forceGenerate -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, parsedParams, keyId, isAttestKeyRequest)
isAuto && !teeFunctional -> raceTeePatch(callingUid, keyDescriptor, attestationKey, params, parsedParams, keyId, isAttestKeyRequest)
isAuto && teePathDecision.get() == null -> raceTeePatch(callingUid, keyDescriptor, attestationKey, params, parsedParams, keyId, isAttestKeyRequest)
isAuto && teePathDecision.get() == false -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, parsedParams, keyId, isAttestKeyRequest)
parsedParams.attestationChallenge != null -> TransactionResult.Continue
else -> {
cleanupKeyData(keyId)
TransactionResult.ContinueAndSkipPost
TransactionResult.Continue
}
}
}
@@ -535,6 +577,41 @@ class KeyMintSecurityLevelInterceptor(
iSecurityLevel = original
}
generatedKeys[keyId] = GeneratedKeyInfo(null, secretKey, keyDescriptor.nspace, response, parsedParams)
Keystore2Interceptor.forgetDeletedKey(keyId)
// Persist symmetric keys too. Without this, AndroidX security
// crypto MasterKey (AES-GCM-256) is regenerated on every reboot
// and any EncryptedSharedPreferences becomes undecryptable —
// which apps that wrap their session token in
// EncryptedSharedPreferences interpret as session expiry.
// Snapshot the metadata bytes alongside the raw secret
// material so authorizations restore byte-identical.
val metadataBytesForSymmetric = runCatching {
val parcel = android.os.Parcel.obtain()
try {
metadata.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
persistExecutor.execute {
GeneratedKeyPersistence.save(
keyId = keyId,
keyPair = null,
secretKey = secretKey,
nspace = keyDescriptor.nspace,
securityLevel = securityLevel,
certChain = emptyList(),
algorithm = parsedParams.algorithm,
keySize = parsedParams.keySize,
ecCurve = parsedParams.ecCurve ?: 0,
purposes = parsedParams.purpose,
digests = parsedParams.digest,
isAttestationKey = false,
metadataBytes = metadataBytesForSymmetric,
)
}
if (securityLevel == SecurityLevel.STRONGBOX) {
val delayMs = STRONGBOX_KEYGEN_LATENCY_FLOOR_MS - (System.nanoTime() - genStartNanos) / 1_000_000
@@ -559,13 +636,43 @@ class KeyMintSecurityLevelInterceptor(
val response = buildKeyEntryResponse(callingUid, keyData.second, parsedParams, keyDescriptor)
generatedKeys[keyId] = GeneratedKeyInfo(keyData.first, null, keyDescriptor.nspace, response, parsedParams)
Keystore2Interceptor.forgetDeletedKey(keyId)
if (isAttestKeyRequest) attestationKeys.add(keyId)
if (SystemLogger.isDebugBuild) {
val chain = keyData.second
val leaf = chain.firstOrNull() as? java.security.cert.X509Certificate
SystemLogger.trace {
"[certchain] ${keyDescriptor.alias}: depth=${chain.size} " +
"issuer=${leaf?.issuerX500Principal?.name} " +
"subject=${leaf?.subjectX500Principal?.name} " +
"hasAttest=${leaf?.getExtensionValue("1.3.6.1.4.1.11129.2.1.17") != null}"
}
}
val certChainCopy = keyData.second.toList()
// Snapshot the freshly built KeyMetadata bytes so loadPersistedKeys
// can restore byte-identical authorizations after reboot. Without
// this, the rebuild path drops every authorization tag that wasn't
// captured into PersistedKeyData primitive fields (origin, block
// mode, padding, expiry timestamps...), which broke session pinning
// for apps that fingerprint metadata across keystore calls.
val metadataBytesForPersist = response.metadata?.let { md ->
runCatching {
val parcel = android.os.Parcel.obtain()
try {
md.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}.getOrNull()
}
persistExecutor.execute {
GeneratedKeyPersistence.save(
keyId = keyId,
keyPair = keyData.first,
secretKey = null,
nspace = keyDescriptor.nspace,
securityLevel = securityLevel,
certChain = certChainCopy,
@@ -575,6 +682,7 @@ class KeyMintSecurityLevelInterceptor(
purposes = parsedParams.purpose,
digests = parsedParams.digest,
isAttestationKey = isAttestKeyRequest,
metadataBytes = metadataBytesForPersist,
)
}
@@ -633,12 +741,14 @@ class KeyMintSecurityLevelInterceptor(
return try {
val teeMetadata = threadA.join()
threadB.cancel(true)
teeFunctional = true
SystemLogger.info("AUTO: TEE succeeded for ${keyDescriptor.alias}, marked functional.")
teePathDecision.compareAndSet(null, true)
SystemLogger.info("AUTO: TEE succeeded, path locked to PATCH for ${keyDescriptor.alias}")
val originalChain = CertificateHelper.getCertificateChain(teeMetadata)
if (originalChain != null && originalChain.size > 1) {
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid)
val newChain = AttestationPatcher.patchCertificateChain(
originalChain, callingUid, parsedParams.certificateNotBefore, parsedParams.certificateNotAfter
)
CertificateHelper.updateCertificateChain(teeMetadata, newChain).getOrThrow()
teeMetadata.authorizations =
InterceptorUtils.patchAuthorizations(teeMetadata.authorizations, callingUid)
@@ -653,7 +763,13 @@ class KeyMintSecurityLevelInterceptor(
InterceptorUtils.createTypedObjectReply(teeMetadata)
} catch (_: Exception) {
SystemLogger.info("AUTO: TEE failed for ${keyDescriptor.alias}, using software result.")
if (teePathDecision.get() == true) {
threadB.cancel(true)
SystemLogger.info("AUTO: TEE failed locally but globally functional, forwarding for ${keyDescriptor.alias}")
return TransactionResult.Continue
}
teePathDecision.compareAndSet(null, false)
SystemLogger.info("AUTO: TEE failed, path locked to GENERATE for ${keyDescriptor.alias}")
try {
threadB.join()
} catch (e: Exception) {
@@ -687,7 +803,8 @@ class KeyMintSecurityLevelInterceptor(
val attestVersion = AndroidDeviceUtils.getAttestVersion(securityLevel)
val keymasterVersion = AndroidDeviceUtils.getKeymasterVersion(securityLevel)
val appId = AttestationBuilder.createApplicationId(callingUid)
val hasChallenge = params.attestationChallenge != null
val appId = if (hasChallenge) AttestationBuilder.createApplicationId(callingUid) else null
val config = CertGenConfig(
algorithm = params.algorithm,
@@ -713,7 +830,7 @@ class KeyMintSecurityLevelInterceptor(
bootKey = AndroidDeviceUtils.bootKey,
bootHash = AndroidDeviceUtils.bootHash,
creationDatetime = System.currentTimeMillis(),
attestationApplicationId = appId.octets,
attestationApplicationId = appId?.octets ?: ByteArray(0),
moduleHash = if (attestVersion >= 400) AndroidDeviceUtils.moduleHash else null,
idBrand = params.brand,
idDevice = params.device,
@@ -786,6 +903,61 @@ class KeyMintSecurityLevelInterceptor(
return@runCatching
}
// Symmetric (AES/HMAC/3DES) keys take a separate path:
// there is no PKCS8 private key, no certificate chain, just
// raw secret material plus the metadata snapshot.
val isSymmetric = record.symmetricKeyBytes.isNotEmpty()
if (isSymmetric) {
val secretKey = javax.crypto.spec.SecretKeySpec(
record.symmetricKeyBytes,
record.symmetricAlgorithm,
)
val response = if (record.metadataBytes.isNotEmpty()) {
runCatching {
val parcel = android.os.Parcel.obtain()
try {
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
parcel.setDataPosition(0)
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
} finally {
parcel.recycle()
}
}.getOrElse { e ->
SystemLogger.warning(
"Failed to restore symmetric metadata for ${record.alias}, falling back to primitive rebuild",
e,
)
rebuildSymmetricResponse(record)
}
} else {
// Pre-v3 file with symmetric key — should not happen
// because v3 always saves metadata, but be defensive:
// rebuild a minimal KeyMetadata from primitives so
// the secret material is still restored. Without
// this, dropping the record would silently log the
// user out the next time the alias is used.
SystemLogger.info(
"Symmetric record ${record.alias} missing metadata bytes, rebuilding from primitives"
)
rebuildSymmetricResponse(record)
}
generatedKeys[keyId] = GeneratedKeyInfo(
keyPair = null,
secretKey = secretKey,
nspace = record.nspace,
response = response,
keyParams = response.metadata?.let { md ->
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
},
)
SystemLogger.debug("Restored symmetric persisted key: $keyId (${record.symmetricAlgorithm}/${record.symmetricKeyBytes.size * 8}bit)")
return@runCatching
}
val algorithmName = when (record.algorithm) {
Algorithm.EC -> "EC"
Algorithm.RSA -> "RSA"
@@ -811,6 +983,77 @@ class KeyMintSecurityLevelInterceptor(
blob = null
}
// Prefer the byte-identical metadata snapshot persisted by v3
// saves so apps that fingerprint the metadata (e.g. they
// pin algorithm/purpose/digest/origin/authorization order
// across reboots) keep their session valid. Fall back to
// rebuilding
// from primitive fields for v1-era files (which lose
// authorization tags that weren't captured then).
val response = if (record.metadataBytes.isNotEmpty()) {
runCatching {
val parcel = android.os.Parcel.obtain()
try {
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
parcel.setDataPosition(0)
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
// Make sure the descriptor's nspace matches the
// KEY_ID we will hand callers. updateSubcomponent
// and getKeyEntry both index by nspace.
metadata.key = metadata.key ?: KeyDescriptor().apply {
domain = Domain.KEY_ID
nspace = record.nspace
alias = null
blob = null
}
KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
} finally {
parcel.recycle()
}
}.getOrElse { e ->
SystemLogger.warning(
"Failed to restore metadata bytes for $record.alias, falling back to rebuild",
e,
)
rebuildResponseFromRecord(record, certChain, descriptor)
}
} else {
rebuildResponseFromRecord(record, certChain, descriptor)
}
val keyIdRestored = KeyIdentifier(record.uid, record.alias)
generatedKeys[keyIdRestored] = GeneratedKeyInfo(keyPair, null, record.nspace, response, response.metadata?.let { md ->
// Re-derive an attestation summary from authorizations so
// any code path that reads keyParams (e.g. logging) still
// works. This does not feed back into the metadata bytes.
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
})
if (record.isAttestationKey) attestationKeys.add(keyIdRestored)
SystemLogger.debug("Restored persisted key: $keyIdRestored")
}.onFailure {
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
}
}
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
}
/**
* Fallback rebuild path used when no v3 metadata snapshot is available
* (key was saved by an older build, or the snapshot failed to deserialize).
* Rebuilds KeyEntryResponse from primitive fields. This loses any
* authorization tags that weren't captured at save time, which is why we
* prefer the byte-identical v3 snapshot whenever possible.
*/
private fun rebuildResponseFromRecord(
record: PersistedKeyData,
certChain: List<Certificate>,
descriptor: KeyDescriptor,
): KeyEntryResponse {
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
@@ -841,6 +1084,7 @@ class KeyMintSecurityLevelInterceptor(
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
@@ -854,23 +1098,89 @@ class KeyMintSecurityLevelInterceptor(
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
val response = buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
generatedKeys[keyId] = GeneratedKeyInfo(keyPair, null, record.nspace, response, attestation)
if (record.isAttestationKey) attestationKeys.add(keyId)
SystemLogger.debug("Restored persisted key: $keyId")
}.onFailure {
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
}
return buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
}
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
/**
* Defensive fallback for symmetric key records that somehow ended up
* without a metadata snapshot (e.g. a save where Parcel.marshall()
* threw and persisted an empty mdBytes, or a future format where the
* snapshot is lazily populated). Without this fallback, loadAll would
* skip the record and the secret material would be effectively lost,
* silently logging the user out the next time the alias is used.
*
* The rebuilt KeyMetadata is structurally minimal — only the primitive
* authorization tags we captured at save time. That's worse than a
* byte-identical snapshot for apps that fingerprint metadata, but it
* still keeps the AES key alive across reboots, which is the
* dominant correctness concern.
*/
private fun rebuildSymmetricResponse(record: PersistedKeyData): KeyEntryResponse {
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
ecCurve = record.ecCurve,
ecCurveName = "",
origin = null,
blockMode = emptyList(),
padding = emptyList(),
purpose = record.purposes,
digest = record.digests,
rsaPublicExponent = null,
certificateSerial = null,
certificateSubject = null,
certificateNotBefore = null,
certificateNotAfter = null,
attestationChallenge = null,
brand = null,
device = null,
product = null,
serial = null,
imei = null,
meid = null,
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
earlyBootOnly = null,
allowWhileOnBody = null,
trustedUserPresenceRequired = null,
trustedConfirmationRequired = null,
noAuthRequired = null,
maxUsesPerBoot = null,
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
val metadata = KeyMetadata().apply {
keySecurityLevel = securityLevel
key = KeyDescriptor().apply {
domain = Domain.KEY_ID
nspace = record.nspace
alias = null
blob = null
}
certificate = null
certificateChain = null
authorizations = attestation.toAuthorizations(record.uid, securityLevel)
modificationTimeMs = System.currentTimeMillis()
}
return KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
}
companion object {
private val secureRandom = SecureRandom()
@Volatile var teeFunctional = false
// Maximum alias length to prevent binder buffer exhaustion (Issue #109)
// Binder buffer is ~1MB; 256KB provides 4x safety margin for transaction overhead
@@ -883,6 +1193,7 @@ class KeyMintSecurityLevelInterceptor(
private const val TEE_LATENCY_FLOOR_MS = 15L
private const val STRONGBOX_KEYGEN_LATENCY_FLOOR_MS = 250L
private const val STRONGBOX_OP_LATENCY_FLOOR_MS = 80L
private const val TEE_OP_LATENCY_FLOOR_MS = 4L
private const val KEYMINT_TOO_MANY_OPERATIONS = -29
private const val KEYMINT_CANNOT_ATTEST_IDS = -66
private const val KEYMINT_UNKNOWN_ERROR = -1000
@@ -941,6 +1252,14 @@ class KeyMintSecurityLevelInterceptor(
?.value
}
fun findTeeResponseByKeyId(callingUid: Int, nspace: Long?): KeyEntryResponse? {
if (nspace == null || nspace == 0L) return null
return teeResponses.entries
.filter { (keyId, _) -> keyId.uid == callingUid }
.find { (_, response) -> response.metadata?.key?.nspace == nspace }
?.value
}
fun getPatchedChain(keyId: KeyIdentifier): Array<Certificate>? = patchedChains[keyId]
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
@@ -1069,36 +1388,47 @@ private fun KeyMintAttestation.toAuthorizations(
authList.add(createAuth(Tag.BOOT_PATCHLEVEL, KeyParameterValue.integer(bootPatch)))
}
fun createSwAuth(tag: Int, value: KeyParameterValue): Authorization {
/**
* Keystore-enforced authorizations (CREATION_DATETIME, ACTIVE_DATETIME,
* USER_ID, etc.) are tagged by real KeyMint HAL with
* SecurityLevel.KEYSTORE (= 100, byte 0x64), not SOFTWARE (= 0, byte
* 0x00). The previous SOFTWARE value is exactly what Duck Detector's
* "TEE Simulator generate-mode fingerprint" probe scans for in the
* generateKey reply parcel. Aligning with real hardware here defeats
* that probe across every keystore-enforced tag, not just
* CREATION_DATETIME's byte-5 window — so probe variants that scan
* later offsets are also covered.
*/
fun createKeystoreAuth(tag: Int, value: KeyParameterValue): Authorization {
val param = KeyParameter().apply {
this.tag = tag
this.value = value
}
return Authorization().apply {
this.keyParameter = param
this.securityLevel = SecurityLevel.SOFTWARE
this.securityLevel = SecurityLevel.KEYSTORE
}
}
authList.add(createSwAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
authList.add(createKeystoreAuth(Tag.CREATION_DATETIME, KeyParameterValue.dateTime(System.currentTimeMillis())))
this.activeDateTime?.let {
authList.add(createSwAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
authList.add(createKeystoreAuth(Tag.ACTIVE_DATETIME, KeyParameterValue.dateTime(it.time)))
}
this.originationExpireDateTime?.let {
authList.add(createSwAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
authList.add(createKeystoreAuth(Tag.ORIGINATION_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
}
this.usageExpireDateTime?.let {
authList.add(createSwAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
authList.add(createKeystoreAuth(Tag.USAGE_EXPIRE_DATETIME, KeyParameterValue.dateTime(it.time)))
}
this.usageCountLimit?.let {
authList.add(createSwAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
authList.add(createKeystoreAuth(Tag.USAGE_COUNT_LIMIT, KeyParameterValue.integer(it)))
}
if (this.unlockedDeviceRequired == true) {
authList.add(createSwAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
authList.add(createKeystoreAuth(Tag.UNLOCKED_DEVICE_REQUIRED, KeyParameterValue.boolValue(true)))
}
authList.add(createSwAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000)))
authList.add(createKeystoreAuth(Tag.USER_ID, KeyParameterValue.integer(callingUid / 100000)))
return authList.toTypedArray()
}
@@ -13,6 +13,7 @@ import org.matrix.TEESimulator.interception.keystore.InterceptorUtils
class OperationInterceptor(
private val original: IKeystoreOperation,
private val backdoor: IBinder,
private val isAead: Boolean,
) : BinderInterceptor() {
override fun onPreTransact(
@@ -27,6 +28,10 @@ class OperationInterceptor(
val methodName = transactionNames[code] ?: "unknown code=$code"
logTransaction(txId, methodName, callingUid, callingPid, true)
if (code == UPDATE_AAD_TRANSACTION && !isAead) {
return InterceptorUtils.createServiceSpecificErrorReply(KeystoreErrorCodes.invalidTag)
}
if (code == FINISH_TRANSACTION || code == ABORT_TRANSACTION) {
KeyMintSecurityLevelInterceptor.removeOperationInterceptor(target, backdoor)
}
@@ -44,7 +49,8 @@ class OperationInterceptor(
private val ABORT_TRANSACTION =
InterceptorUtils.getTransactCode(IKeystoreOperation.Stub::class.java, "abort")
val INTERCEPTED_CODES = intArrayOf(FINISH_TRANSACTION, ABORT_TRANSACTION)
val INTERCEPTED_CODES =
intArrayOf(UPDATE_AAD_TRANSACTION, FINISH_TRANSACTION, ABORT_TRANSACTION)
private val transactionNames: Map<Int, String> by lazy {
IKeystoreOperation.Stub::class
@@ -137,8 +137,15 @@ private class CipherPrimitive(
private val isAead = params.blockMode.firstOrNull() == BlockMode.GCM
private val cipher: Cipher =
Cipher.getInstance(JcaAlgorithmMapper.mapCipherAlgorithm(params)).apply {
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)
@@ -211,19 +218,66 @@ class SoftwareOperation(
val purposeName = KeyMintParameterLogger.purposeNames[purpose] ?: "UNKNOWN"
SystemLogger.debug("[SoftwareOp TX_ID: $txId] Initializing for purpose: $purposeName.")
if (purpose == null) {
// Defensive: if params somehow restored without a PURPOSE tag
// (corrupt v2 metadata, mismatched authorizations array on load,
// or future format drift) the original code crashed with NPE
// because Signer/Verifier/Cipher all dereference keyPair!!
// before checking purpose. Surface a clean keystore error
// instead so callers see a normal-looking operation failure
// they can recover from rather than the process appearing to
// silently corrupt their session.
SystemLogger.warning(
"[SoftwareOp TX_ID: $txId] Purpose missing on restored key " +
"(authorizations=${params.purpose}, keyPair=${if (keyPair != null) "present" else "null"}, " +
"secretKey=${if (secretKey != null) "present" else "null"}). " +
"Returning unsupportedPurpose."
)
throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"Restored key has no PURPOSE authorization",
)
}
primitive =
when (purpose) {
KeyPurpose.SIGN -> Signer(keyPair!!, params)
KeyPurpose.VERIFY -> Verifier(keyPair!!, params)
KeyPurpose.SIGN -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] SIGN requested but keyPair is null",
)
Signer(kp, params)
}
KeyPurpose.VERIFY -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] VERIFY requested but keyPair is null",
)
Verifier(kp, params)
}
KeyPurpose.ENCRYPT -> {
val key: java.security.Key = secretKey ?: keyPair!!.public
val key: java.security.Key = secretKey ?: keyPair?.public
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] ENCRYPT requires either secretKey or keyPair.public",
)
CipherPrimitive(key, params, Cipher.ENCRYPT_MODE)
}
KeyPurpose.DECRYPT -> {
val key: java.security.Key = secretKey ?: keyPair!!.private
val key: java.security.Key = secretKey ?: keyPair?.private
?: throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
"[SoftwareOp TX_ID: $txId] DECRYPT requires either secretKey or keyPair.private",
)
CipherPrimitive(key, params, Cipher.DECRYPT_MODE)
}
KeyPurpose.AGREE_KEY -> KeyAgreementPrimitive(keyPair!!)
KeyPurpose.AGREE_KEY -> {
val kp = keyPair ?: throw ServiceSpecificException(
KeystoreErrorCodes.invalidArgument,
"[SoftwareOp TX_ID: $txId] AGREE_KEY requested but keyPair is null",
)
KeyAgreementPrimitive(kp)
}
else ->
throw ServiceSpecificException(
KeystoreErrorCodes.unsupportedPurpose,
@@ -247,10 +301,18 @@ class SoftwareOperation(
}
fun updateAad(aadInput: ByteArray?) {
SystemLogger.debug("[SoftwareOp TX_ID: $txId] updateAad() inputSize=${aadInput?.size ?: 0}")
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() ENTRY inputSize=${aadInput?.size ?: 0} primitive=${primitive::class.simpleName}")
checkActive()
checkInputLength(aadInput)
try {
primitive.updateAad(aadInput)
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() RETURNED_NORMALLY (unexpected for non-AEAD)")
} catch (throwable: Throwable) {
val top = throwable.stackTrace.firstOrNull()?.toString() ?: "<no-frame>"
val code = (throwable as? ServiceSpecificException)?.errorCode
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() THREW class=${throwable::class.java.name} code=$code msg=${throwable.message} top=$top")
throw throwable
}
}
fun update(data: ByteArray?): ByteArray? {
@@ -380,7 +442,15 @@ class SoftwareOperationBinder(private val operation: SoftwareOperation) :
@Synchronized
override fun updateAad(aadInput: ByteArray?) {
SystemLogger.info("[SoftwareOpBinder] updateAad() ENTRY callingUid=${android.os.Binder.getCallingUid()} size=${aadInput?.size ?: 0}")
try {
operation.updateAad(aadInput)
SystemLogger.info("[SoftwareOpBinder] updateAad() RETURNED_NORMALLY")
} catch (throwable: Throwable) {
val code = (throwable as? ServiceSpecificException)?.errorCode
SystemLogger.info("[SoftwareOpBinder] updateAad() PROPAGATING class=${throwable::class.java.name} code=$code msg=${throwable.message}")
throw throwable
}
}
@Synchronized
@@ -1,42 +1,86 @@
package org.matrix.TEESimulator.logging
import android.util.Log
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import org.matrix.TEESimulator.BuildConfig
/**
* A centralized logging utility for the TEESimulator application. This object provides a consistent
* logging tag and format for all application logs, making it easier to filter and debug in Logcat.
*
* Includes a rate limiter that caps logd syscalls during binder stress to prevent thread pool
* contention. The first [RATE_LIMIT_BURST] messages per [RATE_LIMIT_WINDOW_MS] window are logged
* normally; subsequent messages are suppressed and a summary is emitted when the window resets.
*/
object SystemLogger {
// The tag used for all log messages from this application.
private const val TAG = "TEESimulator"
@PublishedApi internal const val TAG = "TEESimulator"
private val isDebugBuild = BuildConfig.DEBUG
@PublishedApi internal val isDebugBuild = BuildConfig.DEBUG
// Rate limiter: allow BURST messages per WINDOW, then suppress until window resets.
private const val RATE_LIMIT_BURST = 15
private const val RATE_LIMIT_WINDOW_MS = 1000L
private val windowStart = AtomicLong(System.currentTimeMillis())
private val windowCount = AtomicInteger(0)
private val suppressedCount = AtomicInteger(0)
/**
* Returns true if this message should be emitted. Resets the window if expired
* and emits a suppression summary for the previous window.
*/
@PublishedApi internal fun acquireLogPermit(): Boolean {
val now = System.currentTimeMillis()
val start = windowStart.get()
if (now - start > RATE_LIMIT_WINDOW_MS) {
// Window expired: reset and emit suppression summary if needed.
if (windowStart.compareAndSet(start, now)) {
val suppressed = suppressedCount.getAndSet(0)
windowCount.set(1) // this call counts as #1 in the new window
if (suppressed > 0) {
Log.i(TAG, "[rate-limit] suppressed $suppressed log messages in previous window")
}
return true
}
}
val count = windowCount.incrementAndGet()
if (count <= RATE_LIMIT_BURST) return true
suppressedCount.incrementAndGet()
return false
}
/**
* Logs a debug message. Use this for fine-grained information that is useful for debugging.
*
* @param message The message to log.
*/
fun debug(message: String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.d(TAG, message)
}
/** Lazy debug: lambda only evaluates if message will be logged. */
inline fun debug(message: () -> String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.d(TAG, message())
}
/**
* Logs an informational message. Use this to report major application lifecycle events.
*
* @param message The message to log.
*/
fun info(message: String) {
if (!acquireLogPermit()) return
Log.i(TAG, message)
}
/** Lazy info: lambda only evaluates if message will be logged. */
inline fun info(message: () -> String) {
if (!acquireLogPermit()) return
Log.i(TAG, message())
}
/**
* Logs a warning message. Use this to report unexpected but non-fatal issues.
*
* @param message The message to log.
* @param throwable An optional exception to log with the message.
* Logs a warning message. Warnings are never rate-limited.
*/
fun warning(message: String, throwable: Throwable? = null) {
if (throwable != null) {
@@ -47,11 +91,7 @@ object SystemLogger {
}
/**
* Logs an error message. Use this to report fatal errors or exceptions that disrupt
* functionality.
*
* @param message The message to log.
* @param throwable An optional exception to log with the message.
* Logs an error message. Errors are never rate-limited.
*/
fun error(message: String, throwable: Throwable? = null) {
if (throwable != null) {
@@ -64,11 +104,22 @@ object SystemLogger {
/**
* Logs a verbose message. This level is for highly detailed logs that are generally not needed
* unless tracking a very specific issue.
*
* @param message The message to log.
*/
fun verbose(message: String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.v(TAG, message)
}
/** Lazy verbose: lambda only evaluates if message will be logged. */
inline fun verbose(message: () -> String) {
if (!isDebugBuild) return
if (!acquireLogPermit()) return
Log.v(TAG, message())
}
inline fun trace(message: () -> String) {
if (!isDebugBuild) return
Log.w(TAG, message())
}
}
@@ -93,6 +93,12 @@ object CertificateGenerator {
)
return try {
// AOSP ta/src/keys.rs:451-478: no challenge + no attestKey = self-signed, depth 1
if (challenge == null && attestKeyAlias == null) {
SystemLogger.trace { "[certgen] no-challenge key: self-signed, depth=1, purposes=${params.purpose}" }
return listOf(buildSelfSignedCertificate(subjectKeyPair, params))
}
val keybox = getKeyboxForAlgorithm(uid, params.algorithm)
val (signingKey, issuer) =
@@ -238,10 +244,11 @@ object CertificateGenerator {
if (keyUsageBits != 0) {
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
}
// Add our custom, simulated attestation extension.
if (params.attestationChallenge != null) {
builder.addExtension(
AttestationBuilder.buildAttestationExtension(params, uid, securityLevel)
)
}
val signerAlgorithm =
when (signingKeyPair.private.algorithm) {
@@ -256,4 +263,39 @@ object CertificateGenerator {
return JcaX509CertificateConverter().getCertificate(builder.build(contentSigner))
}
// AOSP ta/src/keys.rs:452-478, ta/src/cert.rs:111-114
private fun buildSelfSignedCertificate(
keyPair: KeyPair,
params: KeyMintAttestation,
): Certificate {
val subject = params.certificateSubject ?: X500Name("CN=Android Keystore Key")
val notBefore = params.certificateNotBefore ?: Date(0)
val notAfter = params.certificateNotAfter ?: Date(UNDEFINED_NOT_AFTER)
val builder = JcaX509v3CertificateBuilder(
subject,
params.certificateSerial ?: BigInteger.ONE,
notBefore,
notAfter,
subject,
keyPair.public,
)
val keyUsageBits = buildKeyUsageFromPurposes(params.purpose)
if (keyUsageBits != 0) {
builder.addExtension(Extension.keyUsage, true, KeyUsage(keyUsageBits))
}
val signerAlgorithm = when (keyPair.private.algorithm) {
"EC", "ECDSA" -> "SHA256withECDSA"
"RSA" -> "SHA256withRSA"
else -> throw IllegalArgumentException("Unsupported key: ${keyPair.private.algorithm}")
}
val contentSigner = JcaContentSignerBuilder(signerAlgorithm)
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
.build(keyPair.private)
return JcaX509CertificateConverter().getCertificate(builder.build(contentSigner))
}
}
@@ -1,6 +1,5 @@
package org.matrix.TEESimulator.util
import android.hardware.security.keymint.SecurityLevel
import android.os.Build
import android.os.SystemProperties
import java.io.ByteArrayOutputStream
@@ -164,6 +163,24 @@ object AndroidDeviceUtils {
}
}
internal fun setProperty(name: String, value: String) {
try {
SystemLogger.debug("Setting system property '$name' to: $value")
val command = arrayOf("resetprop", name, value)
val process = Runtime.getRuntime().exec(command)
val exitCode = process.waitFor()
if (exitCode != 0) {
val errorOutput = process.errorStream.bufferedReader().readText()
SystemLogger.error(
"resetprop for '$name' failed with exit code $exitCode: $errorOutput"
)
}
} catch (e: Exception) {
SystemLogger.error("Failed to set '$name' property via resetprop.", e)
}
}
private fun generateRandomBytes(size: Int): ByteArray =
ByteArray(size).also { ThreadLocalRandom.current().nextBytes(it) }
@@ -328,7 +345,9 @@ object AndroidDeviceUtils {
6 -> { // YYYYMM
val year = normalized.substring(0, 4).toInt()
val month = normalized.substring(4, 6).toInt()
if (isLong) year * 10000 + month * 100 + 1 else year * 100 + month
// Synthesizing day=01 from YYYY-MM disagrees with real device bulletins;
// propagate null so callers fall back to a YYYY-MM-DD source.
if (isLong) null else year * 100 + month
}
else -> null
}
@@ -376,20 +395,26 @@ object AndroidDeviceUtils {
)
/**
* Retrieves the attestation version based on security level and OS version. StrongBox (level 2)
* requires version 300.
* Retrieves the attestation version for the given security level. The value follows the device
* OS: cached attestation data wins, then attestVersionMap[SDK_INT], then 400 as last resort.
* A static StrongBox=300 floor would force a major-version mismatch with the TEE chain on
* Android 16 devices that report keymaster 400 across both security levels.
*
* @param securityLevel The security level of the attestation (1 for TEE, 2 for StrongBox).
* @return The appropriate attestation version number.
*/
fun getAttestVersion(securityLevel: Int): Int {
// StrongBox security level requires an attestation version of at least 300.
if (securityLevel == SecurityLevel.STRONGBOX) {
return 300
}
return DeviceAttestationService.CachedAttestationData?.attestVersion
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
val version = cached
?: attestVersionMap[Build.VERSION.SDK_INT]
?: 400 // Default to a recent version
val source = when {
cached != null -> "cache"
attestVersionMap.containsKey(Build.VERSION.SDK_INT) -> "map"
else -> "default"
}
SystemLogger.debug("attestVersion=$version source=$source securityLevel=$securityLevel")
return version
}
/**
@@ -398,10 +423,7 @@ object AndroidDeviceUtils {
* @param securityLevel The security level, used to determine the correct attestation version.
* @return The appropriate Keymaster or KeyMint version number.
*/
fun getKeymasterVersion(securityLevel: Int): Int {
val attestVersion = getAttestVersion(securityLevel)
return if (attestVersion >= 100) attestVersion else 41 // Keymaster 4.1 for older versions
}
fun getKeymasterVersion(securityLevel: Int): Int = getAttestVersion(securityLevel)
// --- APEX and Module Hash Properties ---
+58 -2
View File
@@ -2,10 +2,66 @@
MODDIR=${0%/*}
CONFIG_DIR=/data/adb/tricky_store
. "$MODDIR/action_i18n.sh"
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " ⚠️ $(_msg confirm_header)"
echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " "
echo " $(_msg confirm_warning_1)"
echo " $(_msg confirm_warning_2)"
echo " "
echo " 🔊 $(_msg confirm_vol_up)"
echo " 🔉 $(_msg confirm_vol_down)"
echo " "
confirm() {
vol_tmp="${TMPDIR:-/data/local/tmp}/teesim_vol_key"
seconds=10
: > "$vol_tmp"
getevent -qlc 1 > "$vol_tmp" 2>/dev/null &
ge_pid=$!
while [ "$seconds" -gt 0 ]; do
sleep 1
if ! kill -0 "$ge_pid" 2>/dev/null; then
key=$(awk '/KEY_/{print $3}' "$vol_tmp" 2>/dev/null)
case "$key" in
KEY_VOLUMEUP)
rm -f "$vol_tmp"
return 0
;;
KEY_VOLUMEDOWN)
rm -f "$vol_tmp"
return 1
;;
esac
: > "$vol_tmp"
getevent -qlc 1 > "$vol_tmp" 2>/dev/null &
ge_pid=$!
fi
seconds=$((seconds - 1))
done
kill "$ge_pid" 2>/dev/null
wait "$ge_pid" 2>/dev/null
rm -f "$vol_tmp"
return 1
}
if ! confirm; then
echo " "
echo "$(_msg confirm_cancelled)"
exit 0
fi
if [ -d "$CONFIG_DIR/persistent_keys" ]; then
rm -rf "$CONFIG_DIR/persistent_keys"
mkdir -p "$CONFIG_DIR/persistent_keys"
echo "Persistent key storage cleared"
echo " "
echo "$(_msg confirm_cleared)"
else
echo "No persistent key storage found"
echo " "
echo " $(_msg confirm_not_found)"
fi
+255
View File
@@ -0,0 +1,255 @@
ACTION_LANG="en"
_detect_lang() {
local raw
raw=$(getprop persist.sys.locale 2>/dev/null)
[ -z "$raw" ] && raw=$(getprop ro.product.locale 2>/dev/null)
[ -z "$raw" ] && raw=$(getprop ro.system.locale 2>/dev/null)
local code=$(printf '%s' "$raw" | sed 's/_/-/g')
case "$code" in
zh-Hans*|zh-CN*) code="zh-CN" ;;
zh-Hant*|zh-TW*|zh-HK*) code="zh-TW" ;;
pt-BR*) code="pt-BR" ;;
pt*) code="pt-BR" ;;
es-ES*|es*) code="es-ES" ;;
*-*) code="${code%%-*}" ;;
esac
case "$code" in
ar|az|bn|de|el|es-ES|fa|fr|id|it|ja|ko|pl|pt-BR|ru|th|tl|tr|uk|vi|zh-CN|zh-TW) ACTION_LANG="$code" ;;
esac
}
_detect_lang
_msg() {
case "$ACTION_LANG" in
zh-CN) case "$1" in
confirm_header) echo "清除持久化密钥存储" ;;
confirm_warning_1) echo "这将删除所有缓存的证明密钥。" ;;
confirm_warning_2) echo "使用证明的应用将在下次使用时重新注册。" ;;
confirm_vol_up) echo "音量+ = 确认清除" ;;
confirm_vol_down) echo "音量- = 取消(10秒后默认)" ;;
confirm_cancelled) echo "已取消 - 密钥已保留" ;;
confirm_cleared) echo "持久化密钥存储已清除" ;;
confirm_not_found) echo "未找到持久化密钥存储" ;;
esac ;;
zh-TW) case "$1" in
confirm_header) echo "清除持久化金鑰儲存" ;;
confirm_warning_1) echo "這將刪除所有快取的證明金鑰。" ;;
confirm_warning_2) echo "使用證明的應用程式將在下次使用時重新註冊。" ;;
confirm_vol_up) echo "音量+ = 確認清除" ;;
confirm_vol_down) echo "音量- = 取消(10秒後預設)" ;;
confirm_cancelled) echo "已取消 - 金鑰已保留" ;;
confirm_cleared) echo "持久化金鑰儲存已清除" ;;
confirm_not_found) echo "未找到持久化金鑰儲存" ;;
esac ;;
ja) case "$1" in
confirm_header) echo "永続キーストレージを消去" ;;
confirm_warning_1) echo "キャッシュされた証明キーをすべて削除します。" ;;
confirm_warning_2) echo "証明を使用するアプリは次回使用時に再登録されます。" ;;
confirm_vol_up) echo "音量+ = 消去を確認" ;;
confirm_vol_down) echo "音量- = キャンセル(10秒後デフォルト)" ;;
confirm_cancelled) echo "キャンセルされました - キーは保持されます" ;;
confirm_cleared) echo "永続キーストレージを消去しました" ;;
confirm_not_found) echo "永続キーストレージが見つかりません" ;;
esac ;;
ko) case "$1" in
confirm_header) echo "영구 키 저장소 지우기" ;;
confirm_warning_1) echo "캐시된 모든 증명 키를 삭제합니다." ;;
confirm_warning_2) echo "증명을 사용하는 앱은 다음 사용 시 재등록됩니다." ;;
confirm_vol_up) echo "볼륨+ = 지우기 확인" ;;
confirm_vol_down) echo "볼륨- = 취소 (10초 후 기본값)" ;;
confirm_cancelled) echo "취소됨 - 키 유지됨" ;;
confirm_cleared) echo "영구 키 저장소가 지워졌습니다" ;;
confirm_not_found) echo "영구 키 저장소를 찾을 수 없습니다" ;;
esac ;;
ru) case "$1" in
confirm_header) echo "Очистить постоянное хранилище ключей" ;;
confirm_warning_1) echo "Это удалит все кэшированные ключи аттестации." ;;
confirm_warning_2) echo "Приложения, использующие аттестацию, перерегистрируются при следующем использовании." ;;
confirm_vol_up) echo "Громкость+ = Подтвердить очистку" ;;
confirm_vol_down) echo "Громкость- = Отмена (по умолчанию через 10с)" ;;
confirm_cancelled) echo "Отменено - ключи сохранены" ;;
confirm_cleared) echo "Постоянное хранилище ключей очищено" ;;
confirm_not_found) echo "Постоянное хранилище ключей не найдено" ;;
esac ;;
de) case "$1" in
confirm_header) echo "Persistenten Schlüsselspeicher löschen" ;;
confirm_warning_1) echo "Dies löscht alle zwischengespeicherten Attestierungsschlüssel." ;;
confirm_warning_2) echo "Apps mit Attestierung registrieren sich bei der nächsten Nutzung neu." ;;
confirm_vol_up) echo "Laut+ = Löschen bestätigen" ;;
confirm_vol_down) echo "Leise- = Abbrechen (Standard nach 10s)" ;;
confirm_cancelled) echo "Abgebrochen - Schlüssel beibehalten" ;;
confirm_cleared) echo "Persistenter Schlüsselspeicher gelöscht" ;;
confirm_not_found) echo "Kein persistenter Schlüsselspeicher gefunden" ;;
esac ;;
fr) case "$1" in
confirm_header) echo "Effacer le stockage de clés persistant" ;;
confirm_warning_1) echo "Ceci supprime toutes les clés d'attestation en cache." ;;
confirm_warning_2) echo "Les apps utilisant l'attestation se réinscriront à la prochaine utilisation." ;;
confirm_vol_up) echo "Vol+ = Confirmer l'effacement" ;;
confirm_vol_down) echo "Vol- = Annuler (par défaut après 10s)" ;;
confirm_cancelled) echo "Annulé - clés conservées" ;;
confirm_cleared) echo "Stockage de clés persistant effacé" ;;
confirm_not_found) echo "Aucun stockage de clés persistant trouvé" ;;
esac ;;
es-ES) case "$1" in
confirm_header) echo "Borrar almacenamiento persistente de claves" ;;
confirm_warning_1) echo "Esto elimina todas las claves de atestación en caché." ;;
confirm_warning_2) echo "Las apps que usan atestación se volverán a registrar en el próximo uso." ;;
confirm_vol_up) echo "Vol+ = Confirmar borrado" ;;
confirm_vol_down) echo "Vol- = Cancelar (predeterminado tras 10s)" ;;
confirm_cancelled) echo "Cancelado - claves conservadas" ;;
confirm_cleared) echo "Almacenamiento persistente de claves borrado" ;;
confirm_not_found) echo "No se encontró almacenamiento persistente de claves" ;;
esac ;;
pt-BR) case "$1" in
confirm_header) echo "Limpar armazenamento persistente de chaves" ;;
confirm_warning_1) echo "Isso exclui todas as chaves de atestação em cache." ;;
confirm_warning_2) echo "Apps que usam atestação serão re-registrados no próximo uso." ;;
confirm_vol_up) echo "Vol+ = Confirmar limpeza" ;;
confirm_vol_down) echo "Vol- = Cancelar (padrão após 10s)" ;;
confirm_cancelled) echo "Cancelado - chaves preservadas" ;;
confirm_cleared) echo "Armazenamento persistente de chaves limpo" ;;
confirm_not_found) echo "Nenhum armazenamento persistente de chaves encontrado" ;;
esac ;;
it) case "$1" in
confirm_header) echo "Cancella archivio chiavi persistente" ;;
confirm_warning_1) echo "Questo elimina tutte le chiavi di attestazione in cache." ;;
confirm_warning_2) echo "Le app che usano l'attestazione si re-registreranno al prossimo utilizzo." ;;
confirm_vol_up) echo "Vol+ = Conferma cancellazione" ;;
confirm_vol_down) echo "Vol- = Annulla (predefinito dopo 10s)" ;;
confirm_cancelled) echo "Annullato - chiavi conservate" ;;
confirm_cleared) echo "Archivio chiavi persistente cancellato" ;;
confirm_not_found) echo "Nessun archivio chiavi persistente trovato" ;;
esac ;;
tr) case "$1" in
confirm_header) echo "Kalıcı Anahtar Deposunu Temizle" ;;
confirm_warning_1) echo "Bu, önbelleğe alınmış tüm doğrulama anahtarlarını siler." ;;
confirm_warning_2) echo "Doğrulama kullanan uygulamalar bir sonraki kullanımda yeniden kaydolacak." ;;
confirm_vol_up) echo "Ses+ = Temizlemeyi onayla" ;;
confirm_vol_down) echo "Ses- = İptal (10sn sonra varsayılan)" ;;
confirm_cancelled) echo "İptal edildi - anahtarlar korundu" ;;
confirm_cleared) echo "Kalıcı anahtar deposu temizlendi" ;;
confirm_not_found) echo "Kalıcı anahtar deposu bulunamadı" ;;
esac ;;
id) case "$1" in
confirm_header) echo "Hapus Penyimpanan Kunci Persisten" ;;
confirm_warning_1) echo "Ini menghapus semua kunci atestasi yang di-cache." ;;
confirm_warning_2) echo "Aplikasi yang menggunakan atestasi akan mendaftar ulang saat digunakan." ;;
confirm_vol_up) echo "Vol+ = Konfirmasi hapus" ;;
confirm_vol_down) echo "Vol- = Batal (default setelah 10 detik)" ;;
confirm_cancelled) echo "Dibatalkan - kunci dipertahankan" ;;
confirm_cleared) echo "Penyimpanan kunci persisten dihapus" ;;
confirm_not_found) echo "Penyimpanan kunci persisten tidak ditemukan" ;;
esac ;;
vi) case "$1" in
confirm_header) echo "Xóa lưu trữ khóa cố định" ;;
confirm_warning_1) echo "Thao tác này xóa tất cả khóa chứng thực được lưu cache." ;;
confirm_warning_2) echo "Các ứng dụng dùng chứng thực sẽ đăng ký lại khi sử dụng tiếp theo." ;;
confirm_vol_up) echo "Vol+ = Xác nhận xóa" ;;
confirm_vol_down) echo "Vol- = Hủy (mặc định sau 10s)" ;;
confirm_cancelled) echo "Đã hủy - giữ nguyên khóa" ;;
confirm_cleared) echo "Đã xóa lưu trữ khóa cố định" ;;
confirm_not_found) echo "Không tìm thấy lưu trữ khóa cố định" ;;
esac ;;
ar) case "$1" in
confirm_header) echo "مسح تخزين المفاتيح الدائم" ;;
confirm_warning_1) echo "يؤدي هذا إلى حذف جميع مفاتيح التصديق المخزنة مؤقتاً." ;;
confirm_warning_2) echo "التطبيقات التي تستخدم التصديق ستعيد التسجيل في الاستخدام التالي." ;;
confirm_vol_up) echo "رفع الصوت = تأكيد المسح" ;;
confirm_vol_down) echo "خفض الصوت = إلغاء (افتراضي بعد 10 ثوانٍ)" ;;
confirm_cancelled) echo "تم الإلغاء - تم الاحتفاظ بالمفاتيح" ;;
confirm_cleared) echo "تم مسح تخزين المفاتيح الدائم" ;;
confirm_not_found) echo "لم يتم العثور على تخزين مفاتيح دائم" ;;
esac ;;
th) case "$1" in
confirm_header) echo "ล้างที่จัดเก็บคีย์ถาวร" ;;
confirm_warning_1) echo "การดำเนินการนี้จะลบคีย์การรับรองที่แคชไว้ทั้งหมด" ;;
confirm_warning_2) echo "แอปที่ใช้การรับรองจะลงทะเบียนใหม่ในการใช้งานครั้งถัดไป" ;;
confirm_vol_up) echo "เพิ่มเสียง = ยืนยันการล้าง" ;;
confirm_vol_down) echo "ลดเสียง = ยกเลิก (ค่าเริ่มต้นหลัง 10 วินาที)" ;;
confirm_cancelled) echo "ยกเลิกแล้ว - คีย์ยังคงอยู่" ;;
confirm_cleared) echo "ล้างที่จัดเก็บคีย์ถาวรแล้ว" ;;
confirm_not_found) echo "ไม่พบที่จัดเก็บคีย์ถาวร" ;;
esac ;;
uk) case "$1" in
confirm_header) echo "Очистити постійне сховище ключів" ;;
confirm_warning_1) echo "Це видаляє всі кешовані ключі атестації." ;;
confirm_warning_2) echo "Програми, що використовують атестацію, повторно зареєструються при наступному використанні." ;;
confirm_vol_up) echo "Гучність+ = Підтвердити очищення" ;;
confirm_vol_down) echo "Гучність- = Скасувати (за замовчуванням через 10с)" ;;
confirm_cancelled) echo "Скасовано - ключі збережено" ;;
confirm_cleared) echo "Постійне сховище ключів очищено" ;;
confirm_not_found) echo "Постійне сховище ключів не знайдено" ;;
esac ;;
pl) case "$1" in
confirm_header) echo "Wyczyść trwały magazyn kluczy" ;;
confirm_warning_1) echo "To usuwa wszystkie buforowane klucze atestacji." ;;
confirm_warning_2) echo "Aplikacje używające atestacji zarejestrują się ponownie przy następnym użyciu." ;;
confirm_vol_up) echo "Głośność+ = Potwierdź czyszczenie" ;;
confirm_vol_down) echo "Głośność- = Anuluj (domyślnie po 10s)" ;;
confirm_cancelled) echo "Anulowano - klucze zachowane" ;;
confirm_cleared) echo "Trwały magazyn kluczy wyczyszczony" ;;
confirm_not_found) echo "Nie znaleziono trwałego magazynu kluczy" ;;
esac ;;
az) case "$1" in
confirm_header) echo "Davamlı Açar Yaddaşını Təmizlə" ;;
confirm_warning_1) echo "Bu, keşlənmiş bütün təsdiqləmə açarlarını silir." ;;
confirm_warning_2) echo "Təsdiqləmədən istifadə edən tətbiqlər növbəti istifadədə yenidən qeydiyyatdan keçəcək." ;;
confirm_vol_up) echo "Səs+ = Təmizləməni təsdiqlə" ;;
confirm_vol_down) echo "Səs- = Ləğv et (10 saniyə sonra defolt)" ;;
confirm_cancelled) echo "Ləğv edildi - açarlar saxlanıldı" ;;
confirm_cleared) echo "Davamlı açar yaddaşı təmizləndi" ;;
confirm_not_found) echo "Davamlı açar yaddaşı tapılmadı" ;;
esac ;;
bn) case "$1" in
confirm_header) echo "স্থায়ী কী সংরক্ষণ পরিষ্কার করুন" ;;
confirm_warning_1) echo "এটি সমস্ত ক্যাশড অ্যাটেস্টেশন কী মুছে ফেলে।" ;;
confirm_warning_2) echo "অ্যাটেস্টেশন ব্যবহারকারী অ্যাপগুলি পরবর্তী ব্যবহারে পুনরায় নিবন্ধন করবে।" ;;
confirm_vol_up) echo "ভলিউম+ = পরিষ্কার নিশ্চিত করুন" ;;
confirm_vol_down) echo "ভলিউম- = বাতিল (১০ সেকেন্ডে ডিফল্ট)" ;;
confirm_cancelled) echo "বাতিল করা হয়েছে - কী সংরক্ষিত" ;;
confirm_cleared) echo "স্থায়ী কী সংরক্ষণ পরিষ্কার করা হয়েছে" ;;
confirm_not_found) echo "কোনো স্থায়ী কী সংরক্ষণ পাওয়া যায়নি" ;;
esac ;;
el) case "$1" in
confirm_header) echo "Εκκαθάριση Μόνιμου Αποθηκευτικού Χώρου Κλειδιών" ;;
confirm_warning_1) echo "Διαγράφει όλα τα προσωρινά αποθηκευμένα κλειδιά πιστοποίησης." ;;
confirm_warning_2) echo "Οι εφαρμογές που χρησιμοποιούν πιστοποίηση θα επανεγγραφούν στην επόμενη χρήση." ;;
confirm_vol_up) echo "Ένταση+ = Επιβεβαίωση εκκαθάρισης" ;;
confirm_vol_down) echo "Ένταση- = Ακύρωση (προεπιλογή μετά από 10 δευτ)" ;;
confirm_cancelled) echo "Ακυρώθηκε - τα κλειδιά διατηρήθηκαν" ;;
confirm_cleared) echo "Ο μόνιμος αποθηκευτικός χώρος κλειδιών εκκαθαρίστηκε" ;;
confirm_not_found) echo "Δεν βρέθηκε μόνιμος αποθηκευτικός χώρος κλειδιών" ;;
esac ;;
fa) case "$1" in
confirm_header) echo "پاک کردن ذخیره‌سازی دائمی کلید" ;;
confirm_warning_1) echo "این کار همه کلیدهای تأیید کش‌شده را حذف می‌کند." ;;
confirm_warning_2) echo "برنامه‌های استفاده‌کننده از تأیید در استفاده بعدی دوباره ثبت‌نام می‌کنند." ;;
confirm_vol_up) echo "صدا+ = تأیید پاک کردن" ;;
confirm_vol_down) echo "صدا- = لغو (پیش‌فرض پس از ۱۰ ثانیه)" ;;
confirm_cancelled) echo "لغو شد - کلیدها حفظ شدند" ;;
confirm_cleared) echo "ذخیره‌سازی دائمی کلید پاک شد" ;;
confirm_not_found) echo "ذخیره‌سازی دائمی کلید یافت نشد" ;;
esac ;;
tl) case "$1" in
confirm_header) echo "Burahin ang Persistent Key Storage" ;;
confirm_warning_1) echo "Buburahin nito ang lahat ng naka-cache na attestation keys." ;;
confirm_warning_2) echo "Magre-rehistro muli ang mga app na gumagamit ng attestation sa susunod na paggamit." ;;
confirm_vol_up) echo "Vol+ = Kumpirmahin ang pagbura" ;;
confirm_vol_down) echo "Vol- = Kanselahin (default pagkatapos ng 10s)" ;;
confirm_cancelled) echo "Nakansela - napanatili ang mga key" ;;
confirm_cleared) echo "Nabura ang persistent key storage" ;;
confirm_not_found) echo "Walang nahanap na persistent key storage" ;;
esac ;;
*) case "$1" in
confirm_header) echo "Clear Persistent Key Storage" ;;
confirm_warning_1) echo "This deletes all cached attestation keys." ;;
confirm_warning_2) echo "Apps using attestation will re-enroll on next use." ;;
confirm_vol_up) echo "Vol+ = Confirm clear" ;;
confirm_vol_down) echo "Vol- = Cancel (default after 10s)" ;;
confirm_cancelled) echo "Cancelled - keys preserved" ;;
confirm_cleared) echo "Persistent key storage cleared" ;;
confirm_not_found) echo "No persistent key storage found" ;;
esac ;;
esac
}
+26
View File
@@ -1,3 +1,29 @@
## TEESimulator-RS v6.0.0
Repository consolidation release. All tee-rebuild work merged as the new main branch.
### AOSP Self-Signed Cert Compliance
- No-challenge keys now generate self-signed certs (subject == issuer, depth 1), matching AOSP `ta/src/keys.rs:451-478`
- Both Kotlin (BouncyCastle) and Rust (native-certgen) paths corrected
- Eliminates attestation behavioral probes that detect keybox issuer on non-attested keys
### Stability
- Binder stress crash hardening for concurrent generateKey calls
- AUTO mode TEE race for consistent attestation on devices with working G10
- Oversized transactions routed to software gen instead of crashing
- Operation-time params (BLOCK_MODE, PADDING, DIGEST) passed through to CipherPrimitive
### Banking App Compatibility
- Bare `target.txt` entries now default to AUTO mode, resolved at config level to PATCH (working TEE) or GENERATE (broken TEE)
- Fixes BHIM and similar banking apps that require TEE-backed attestation keys
- Restores v5.0 behavior where AUTO was resolved before the interceptor dispatch, avoiding the non-deterministic `raceTeePatch` path
### Infrastructure
- Version scheme changed to semver (v6.0.0)
- Repository moved to TEESimulator-RS as canonical source
---
## TEESimulator-RS v5.0: AOSP Compliance Overhaul
Major release integrating 30+ AOSP compliance improvements from upstream PR #157 analysis, layered on top of our StrongBox hardening and native cert gen architecture.
+12 -1
View File
@@ -48,7 +48,7 @@ install_file() {
# --- Installation ---
ui_print "- Extracting module files"
for file in customize.sh module.prop service.sh sepolicy.rule daemon action.sh uninstall.sh; do
for file in customize.sh module.prop service.sh sepolicy.rule daemon action.sh action_i18n.sh uninstall.sh; do
install_file "$file" "$MODPATH"
done
@@ -92,6 +92,17 @@ if [ ! -f "$CONFIG_DIR/target.txt" ]; then
install_file "target.txt" "$CONFIG_DIR"
fi
if [ ! -f "$CONFIG_DIR/security_patch.txt" ]; then
ui_print "- Adding default security patch config (mirror device props)"
printf '%s\n' \
'# TEESimulator default: mirror live device props.' \
'# system=prop reads ro.build.version.security_patch at cert-gen time;' \
'# boot and vendor are auto-forced to prop too (ConfigurationManager.kt:253-256).' \
'# Override with explicit YYYY-MM-DD dates if you want active spoofing.' \
'system=prop' > "$CONFIG_DIR/security_patch.txt"
chmod 644 "$CONFIG_DIR/security_patch.txt"
fi
rm -f "$CONFIG_DIR/tee_status.txt"
if [ ! -f "$CONFIG_DIR/hbk" ]; then
+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
+1
View File
@@ -10,3 +10,4 @@ done
rm -rf "$CONFIG_DIR/persistent_keys"
rm -f "$CONFIG_DIR/tee_status.txt"
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
rm -f "$CONFIG_DIR/security_patch.txt" "$CONFIG_DIR/security_patch.txt.next" "$CONFIG_DIR/last_bulletin_fetch.json"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"version": "v4.5",
"versionCode": 111,
"zipUrl": "https://github.com/Enginex0/TEESimulator/releases/download/v4.5/TEESimulator-v4.5-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator/main/module/changelog.md"
"version": "v6.0.0-211",
"versionCode": 211,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.0-211/TEESimulator-RS-v6.0.0-211-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
}
+66 -7
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>> {
@@ -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);
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))
}
+6 -7
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 cert_chain = if params.attestation_challenge.is_some() {
let attest_ext = attestation::build_attestation_extension(&params)?;
let cert_chain = certbuilder::build_certificate_chain(
&key_pair,
&attest_ext,
&keybox,
&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);
+6
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"