Compare commits

...
72 Commits
Author SHA1 Message Date
Enginex0 134d5111ad chore(release): publish v6.0.0-235 2026-05-20 07:05:02 +01:00
Enginex0 4c801f2089 chore: bump versionCode to 235 2026-05-20 06:54:35 +01:00
Enginex0 afc5caeb1b chore: bump versionCode to 233 2026-05-20 06:52:11 +01:00
Enginex0 0e9ea10b50 fix: reorder hal-enforced auths to evade duck detector
Duck-Detector's generate-mode parser walks the reply parcel at
12-byte strides and matches (secLevel=256, tag=1, unionTag=32) at
slot[count-1]. Those bytes are actually KEY_SIZE.value=256 followed
by the next Authorization's presence flag and size header — an
emergent fingerprint from misaligned parsing, not a fake value.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Duck Detector TamperScore-4 cleared on Xiaomi A16
- Self-sufficient spoofing (PatchLevelManager, BulletinPoller,
  PIF FileObserver, vbmeta complement props)
- Persistent symmetric key storage (PR #22)
- Action button hardened: vol+ confirm + 22-language i18n
- Build: JVM 21, gradle auto-rewrites update.json
2026-05-19 19:41:31 +01:00
Enginex0 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
24 changed files with 1696 additions and 256 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 }}
+37
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)
@@ -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)) {
@@ -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()
@@ -0,0 +1,48 @@
package org.matrix.TEESimulator.config
import android.os.SystemProperties
import org.matrix.TEESimulator.logging.SystemLogger
import org.matrix.TEESimulator.util.AndroidDeviceUtils
object BootStateManager {
private val targets =
linkedMapOf(
"ro.boot.verifiedbootstate" to "green",
"ro.boot.flash.locked" to "1",
"ro.boot.veritymode" to "enforcing",
"ro.boot.vbmeta.device_state" to "locked",
)
private val fillIfAbsent =
linkedMapOf(
"ro.boot.vbmeta.invalidate_on_error" to "yes",
"ro.boot.vbmeta.avb_version" to "1.2",
"ro.boot.vbmeta.hash_alg" to "sha256",
"ro.boot.vbmeta.size" to "11904",
)
fun apply() {
for ((name, target) in targets) {
val current = SystemProperties.get(name, "")
if (current.isEmpty()) {
SystemLogger.debug("BootStateManager: $name absent on this device, skip")
continue
}
if (current == target) {
SystemLogger.debug("BootStateManager: $name already $target, skip")
continue
}
SystemLogger.info("BootStateManager: setting $name=$target (was: '$current')")
AndroidDeviceUtils.setProperty(name, target)
}
for ((name, value) in fillIfAbsent) {
val current = SystemProperties.get(name, "")
if (current.isNotEmpty()) {
SystemLogger.debug("BootStateManager: $name already '$current', skip")
continue
}
SystemLogger.info("BootStateManager: filling absent $name=$value")
AndroidDeviceUtils.setProperty(name, value)
}
}
}
@@ -0,0 +1,193 @@
package org.matrix.TEESimulator.config
import android.os.Handler
import android.os.HandlerThread
import java.io.File
import java.net.URL
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
import org.matrix.TEESimulator.BuildConfig
import org.matrix.TEESimulator.logging.SystemLogger
object BulletinPoller {
private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
private const val HISTORY_CAP = 10
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
private val DATE_REGEX = Regex("<td>(\\d{4}-\\d{2}-\\d{2})</td>")
private val PATCH_DATE_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}$")
private lateinit var handler: Handler
@Volatile private var bootstrapStep = 0
@Volatile private var steadyArmed = false
fun start() {
val thread = HandlerThread("BulletinPoller").apply { start() }
handler = Handler(thread.looper)
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
}
private fun pollOnce() {
try {
val result = fetchAndParse()
appendHistory(result)
scheduleNext(result.status == "success")
} catch (t: Throwable) {
SystemLogger.error("BulletinPoller: pollOnce failed", t)
scheduleNext(false)
}
}
private fun scheduleNext(success: Boolean) {
if (success || steadyArmed) {
steadyArmed = true
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
return
}
bootstrapStep++
if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
steadyArmed = true
handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
} else {
handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
}
}
private data class FetchResult(
val ts: Long,
val status: String,
val httpCode: Int?,
val parsedDate: String?,
val applied: Boolean,
val error: String?,
)
private fun fetchAndParse(): FetchResult {
val ts = System.currentTimeMillis()
var conn: HttpsURLConnection? = null
return try {
conn =
(URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty(
"User-Agent",
"TEESimulator/${BuildConfig.VERSION_NAME}",
)
requestMethod = "GET"
}
val code = conn.responseCode
if (code != 200) {
return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
}
val html = conn.inputStream.bufferedReader().use { it.readText() }
val date = DATE_REGEX.find(html)?.groupValues?.get(1)
if (date == null) {
return FetchResult(
ts,
"parse_error",
code,
null,
false,
"no <td>YYYY-MM-DD</td> match",
)
}
val current = currentPatch()
if (current == null || date <= current) {
return FetchResult(ts, "success", code, date, false, null)
}
if (PatchLevelManager.updateTo(date)) {
FetchResult(ts, "success", code, date, true, null)
} else {
FetchResult(
ts,
"validation_rejected",
code,
date,
false,
"PatchLevelManager.updateTo rejected $date",
)
}
} catch (e: Exception) {
FetchResult(ts, "network_error", null, null, false, e.toString())
} finally {
conn?.disconnect()
}
}
private fun currentPatch(): String? {
val f = File(PATCH_FILE)
if (!f.exists()) return null
val raw = try {
f.readLines()
.firstOrNull { it.startsWith("system=") }
?.substringAfter("system=")
?.trim()
?.takeIf { it != "prop" && it.isNotEmpty() }
} catch (_: Exception) {
null
}
if (raw == null) return null
if (PATCH_DATE_PATTERN.matches(raw)) return raw
SystemLogger.warning(
"BulletinPoller: ignoring malformed system='$raw' in $PATCH_FILE"
)
return null
}
private fun appendHistory(result: FetchResult) {
try {
val target = File(HISTORY_FILE)
val staging = File(HISTORY_STAGING)
val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
val history =
existing
?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
?: JSONArray()
val entry =
JSONObject().apply {
put("ts", result.ts)
put("status", result.status)
put("http_code", result.httpCode ?: JSONObject.NULL)
put("parsed_date", result.parsedDate ?: JSONObject.NULL)
put("applied", result.applied)
put("error", result.error ?: JSONObject.NULL)
}
history.put(entry)
while (history.length() > HISTORY_CAP) history.remove(0)
val latestKnown =
(0 until history.length())
.mapNotNull {
history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
d.isNotBlank()
}
}
.lastOrNull()
val root =
JSONObject().apply {
put("latest_known_date", latestKnown ?: JSONObject.NULL)
put("history", history)
}
staging.writeText(root.toString(2))
Files.move(
staging.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (e: Exception) {
SystemLogger.error("BulletinPoller: failed to persist history", e)
}
}
}
@@ -93,7 +93,16 @@ object ConfigurationManager {
fun shouldSkipUid(uid: Int): Boolean = getPackageModeForUid(uid) == null
fun 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)
@@ -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)
}
@@ -99,12 +116,21 @@ object InterceptorUtils {
fun <T : Parcelable?> createTypedObjectReply(
obj: T,
flags: Int = 0,
diagnosticTag: String? = null,
): BinderInterceptor.TransactionResult.OverrideReply {
val parcel =
Parcel.obtain().apply {
writeNoException()
writeTypedObject(obj, flags)
}
if (diagnosticTag != null && SystemLogger.isDebugBuild) {
val savedPos = parcel.dataPosition()
val wire = parcel.marshall()
parcel.setDataPosition(savedPos)
val path = "/data/local/tmp/teesim-$diagnosticTag-${System.nanoTime()}.bin"
runCatching { java.io.File(path).writeBytes(wire) }
SystemLogger.debug("[$diagnosticTag] reply len=${wire.size} path=$path")
}
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
}
@@ -132,12 +158,25 @@ object InterceptorUtils {
fun createServiceSpecificErrorReply(
errorCode: Int
): BinderInterceptor.TransactionResult.OverrideReply {
val parcel =
Parcel.obtain().apply {
writeException(android.os.ServiceSpecificException(errorCode))
}
return BinderInterceptor.TransactionResult.OverrideReply(parcel)
): BinderInterceptor.TransactionResult.OverrideReply = createErrorReply(errorCode)
fun normalizeServiceSpecificReply(reply: Parcel): Parcel? {
reply.setDataPosition(0)
if (reply.readInt() != EX_SERVICE_SPECIFIC) {
reply.setDataPosition(0)
return null
}
// Advance position past message and stack header to reach errorCode.
reply.readString()
reply.readInt()
val errorCode = reply.readInt()
reply.setDataPosition(0)
return Parcel.obtain().apply {
writeInt(EX_SERVICE_SPECIFIC)
writeString(synthesizeSseMessage(errorCode))
writeInt(0)
writeInt(errorCode)
}
}
fun patchAuthorizations(
@@ -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)
@@ -387,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,
@@ -399,6 +455,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
purposes = parsedParameters.purpose,
digests = parsedParameters.digest,
isAttestationKey = true,
metadataBytes = metadataBytesForPersist,
)
return InterceptorUtils.createTypedObjectReply(response)
@@ -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,
)
}
}
@@ -21,12 +21,10 @@ 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
@@ -36,6 +34,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
@@ -59,10 +58,6 @@ 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>>()
@@ -75,18 +70,16 @@ class KeyMintSecurityLevelInterceptor(
callingPid: Int,
data: Parcel,
): TransactionResult {
val shouldSkip = ConfigurationManager.shouldSkipUid(callingUid)
when (code) {
GENERATE_KEY_TRANSACTION -> {
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
if (!shouldSkip) return handleGenerateKey(txId, callingUid, callingPid, data)
return handleGenerateKey(txId, callingUid, callingPid, data)
}
CREATE_OPERATION_TRANSACTION -> {
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
if (!shouldSkip) return handleCreateOperation(txId, callingUid, data)
return handleCreateOperation(txId, callingUid, data)
}
IMPORT_KEY_TRANSACTION -> {
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
@@ -193,7 +186,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 {
@@ -209,42 +203,50 @@ class KeyMintSecurityLevelInterceptor(
val metadata: KeyMetadata =
reply.readTypedObject(KeyMetadata.CREATOR)
?: return TransactionResult.SkipTransaction
val originalChain =
CertificateHelper.getCertificateChain(metadata)
?: return TransactionResult.SkipTransaction
if (originalChain.size > 1) {
// Read the request parcel to extract keyDescriptor and cert date params.
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.SkipTransaction
data.readTypedObject(KeyDescriptor.CREATOR) // skip attestationKey
val keyParams = data.createTypedArray(KeyParameter.CREATOR)
val certNotBefore = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_BEFORE }?.value?.dateTime?.let { Date(it) }
val certNotAfter = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_AFTER }?.value?.dateTime?.let { Date(it) }
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid, certNotBefore, certNotAfter)
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)
?: return TransactionResult.SkipTransaction
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
// Cache the newly patched chain to ensure consistency across subsequent API calls.
val 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
val originalChain = CertificateHelper.getCertificateChain(metadata)
if (originalChain == null || originalChain.size <= 1) {
// Cache non-attested responses for KEY_ID getKeyEntry parity.
// Without this, the cached attested path returns in ~1ms while
// the forwarded non-attested path takes ~1.5ms, and
// TimingSideChannelProbe flags the 1.55x ratio.
cleanupKeyData(keyId)
patchedChains[keyId] = newChain
teeResponses[keyId] = KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
SystemLogger.debug(
"Cached patched certificate chain for $keyId. (${key.alias} [${key.domain}, ${key.nspace}])"
)
return InterceptorUtils.createTypedObjectReply(metadata)
return TransactionResult.SkipTransaction
}
data.readTypedObject(KeyDescriptor.CREATOR) // skip attestationKey
val keyParams = data.createTypedArray(KeyParameter.CREATOR)
val certNotBefore = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_BEFORE }?.value?.dateTime?.let { Date(it) }
val certNotAfter = keyParams?.find { it.tag == Tag.CERTIFICATE_NOT_AFTER }?.value?.dateTime?.let { Date(it) }
val newChain = AttestationPatcher.patchCertificateChain(originalChain, callingUid, certNotBefore, certNotAfter)
val key = metadata.key
?: return TransactionResult.SkipTransaction
CertificateHelper.updateCertificateChain(metadata, newChain).getOrThrow()
metadata.authorizations =
InterceptorUtils.patchAuthorizations(metadata.authorizations, callingUid)
cleanupKeyData(keyId)
patchedChains[keyId] = newChain
teeResponses[keyId] = KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
SystemLogger.debug(
"Cached patched certificate chain for $keyId. (${key.alias} [${key.domain}, ${key.nspace}])"
)
return InterceptorUtils.createTypedObjectReply(metadata)
}
return TransactionResult.SkipTransaction
}
@@ -370,7 +372,11 @@ class KeyMintSecurityLevelInterceptor(
)
} 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) {
@@ -410,6 +416,14 @@ class KeyMintSecurityLevelInterceptor(
}
private fun handleGenerateKey(txId: Long, callingUid: Int, callingPid: Int, data: Parcel): TransactionResult {
if (SystemLogger.isDebugBuild) {
val savedPos = data.dataPosition()
val req = data.marshall()
data.setDataPosition(savedPos)
val path = "/data/local/tmp/teesim-gen-mode-req-uid${callingUid}-tx${txId}-${System.nanoTime()}.bin"
runCatching { java.io.File(path).writeBytes(req) }
SystemLogger.debug("[gen-mode-req] uid=$callingUid txId=$txId len=${req.size} path=$path")
}
val oversized = data.dataSize() > MAX_ALIAS_LENGTH
return runCatching {
@@ -422,6 +436,12 @@ class KeyMintSecurityLevelInterceptor(
)
val params = data.createTypedArray(KeyParameter.CREATOR)!!
val parsedParams = KeyMintAttestation(params)
val isAttestKeyRequest = parsedParams.isAttestKey()
if (ConfigurationManager.shouldSkipUid(callingUid)
&& attestationKey == null && !isAttestKeyRequest) {
return TransactionResult.ContinueAndSkipPost
}
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 ->
@@ -482,29 +502,21 @@ class KeyMintSecurityLevelInterceptor(
}
val keyId = KeyIdentifier(callingUid, keyDescriptor.alias)
val isAttestKeyRequest = parsedParams.isAttestKey()
val forceGenerate =
oversized ||
ConfigurationManager.shouldGenerate(callingUid) ||
(ConfigurationManager.shouldPatch(callingUid) && isAttestKeyRequest) ||
(attestationKey != null &&
isAttestationKey(KeyIdentifier(callingUid, attestationKey.alias)))
isAttestKeyRequest ||
attestationKey != null
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" }
SystemLogger.trace { "[TRACE-$txId] dispatch: forceGen=$forceGenerate hasChallenge=${challenge != null} isSymmetric=$isSymmetric isAttestKey=$isAttestKeyRequest" }
when {
forceGenerate -> doSoftwareKeyGen(callingUid, keyDescriptor, attestationKey, 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
}
}
}
@@ -532,11 +544,17 @@ class KeyMintSecurityLevelInterceptor(
parsedParams.algorithm != Algorithm.RSA
if (isSymmetric) {
if (attestationKey != null) {
throw android.os.ServiceSpecificException(
KEYMINT_INVALID_ARGUMENT,
"ATTEST_KEY tag is not supported for symmetric algorithms (algo=${parsedParams.algorithm})",
)
}
val algoName = when (parsedParams.algorithm) {
Algorithm.AES -> "AES"
Algorithm.HMAC -> "HmacSHA256"
else -> throw android.os.ServiceSpecificException(
SECURE_HW_COMMUNICATION_FAILED,
KEYMINT_INVALID_ARGUMENT,
"Unsupported symmetric algorithm: ${parsedParams.algorithm}",
)
}
@@ -562,6 +580,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
@@ -570,7 +623,7 @@ class KeyMintSecurityLevelInterceptor(
TeeLatencySimulator.simulateGenerateKeyDelay(parsedParams.algorithm, System.nanoTime() - genStartNanos)
}
return InterceptorUtils.createTypedObjectReply(metadata)
return InterceptorUtils.createTypedObjectReply(metadata, diagnosticTag = "gen-mode-sym")
}
val keyData = if (NativeCertGen.isAvailable && attestationKey == null) {
@@ -586,6 +639,7 @@ 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) {
@@ -600,10 +654,28 @@ class KeyMintSecurityLevelInterceptor(
}
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,
@@ -613,6 +685,7 @@ class KeyMintSecurityLevelInterceptor(
purposes = parsedParams.purpose,
digests = parsedParams.digest,
isAttestationKey = isAttestKeyRequest,
metadataBytes = metadataBytesForPersist,
)
}
@@ -623,94 +696,7 @@ class KeyMintSecurityLevelInterceptor(
TeeLatencySimulator.simulateGenerateKeyDelay(parsedParams.algorithm, System.nanoTime() - genStartNanos)
}
return InterceptorUtils.createTypedObjectReply(response.metadata)
}
private fun raceTeePatch(
callingUid: Int,
keyDescriptor: KeyDescriptor,
attestationKey: KeyDescriptor?,
rawParams: Array<KeyParameter>,
parsedParams: KeyMintAttestation,
keyId: KeyIdentifier,
isAttestKeyRequest: Boolean,
): TransactionResult {
SystemLogger.info("AUTO: racing TEE vs software for ${keyDescriptor.alias}")
val teeDescriptor = KeyDescriptor().apply {
domain = keyDescriptor.domain
nspace = keyDescriptor.nspace
alias = keyDescriptor.alias
blob = keyDescriptor.blob
}
val teeAttestKey = attestationKey?.let {
KeyDescriptor().apply {
domain = it.domain
nspace = it.nspace
alias = it.alias
blob = it.blob
}
}
val threadA = CompletableFuture.supplyAsync {
original.generateKey(teeDescriptor, teeAttestKey, rawParams, 0, byteArrayOf())
}
val swDescriptor = KeyDescriptor().apply {
domain = keyDescriptor.domain
nspace = secureRandom.nextLong()
alias = keyDescriptor.alias
blob = keyDescriptor.blob
}
val swKeyId = KeyIdentifier(callingUid, keyDescriptor.alias)
val threadB = CompletableFuture.supplyAsync {
doSoftwareKeyGen(callingUid, swDescriptor, attestationKey, parsedParams, swKeyId, isAttestKeyRequest)
}
return try {
val teeMetadata = threadA.join()
threadB.cancel(true)
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, parsedParams.certificateNotBefore, parsedParams.certificateNotAfter
)
CertificateHelper.updateCertificateChain(teeMetadata, newChain).getOrThrow()
teeMetadata.authorizations =
InterceptorUtils.patchAuthorizations(teeMetadata.authorizations, callingUid)
cleanupKeyData(keyId)
patchedChains[keyId] = newChain
}
teeResponses[keyId] = KeyEntryResponse().apply {
this.metadata = teeMetadata
iSecurityLevel = original
}
InterceptorUtils.createTypedObjectReply(teeMetadata)
} catch (_: Exception) {
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) {
SystemLogger.error("AUTO: both paths failed for ${keyDescriptor.alias}.", e)
val code =
if (e.cause is android.os.ServiceSpecificException)
(e.cause as android.os.ServiceSpecificException).errorCode
else SECURE_HW_COMMUNICATION_FAILED
InterceptorUtils.createServiceSpecificErrorReply(code)
}
}
return InterceptorUtils.createTypedObjectReply(response.metadata, diagnosticTag = "gen-mode-asym")
}
private fun generateAttestedKeyPairNative(
@@ -833,6 +819,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"
@@ -858,56 +899,57 @@ class KeyMintSecurityLevelInterceptor(
blob = null
}
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
ecCurve = record.ecCurve,
ecCurveName = "",
origin = null,
blockMode = emptyList(),
padding = emptyList(),
purpose = record.purposes,
digest = record.digests,
rsaPublicExponent = null,
certificateSerial = null,
certificateSubject = null,
certificateNotBefore = null,
certificateNotAfter = null,
attestationChallenge = null,
brand = null,
device = null,
product = null,
serial = null,
imei = null,
meid = null,
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
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(),
)
// Prefer the byte-identical metadata snapshot persisted by v3
// saves so apps that fingerprint the metadata (e.g. they
// pin algorithm/purpose/digest/origin/authorization order
// across reboots) keep their session valid. Fall back to
// rebuilding
// from primitive fields for v1-era files (which lose
// authorization tags that weren't captured then).
val response = if (record.metadataBytes.isNotEmpty()) {
runCatching {
val parcel = android.os.Parcel.obtain()
try {
parcel.unmarshall(record.metadataBytes, 0, record.metadataBytes.size)
parcel.setDataPosition(0)
val metadata = KeyMetadata.CREATOR.createFromParcel(parcel)
// Make sure the descriptor's nspace matches the
// KEY_ID we will hand callers. updateSubcomponent
// and getKeyEntry both index by nspace.
metadata.key = metadata.key ?: KeyDescriptor().apply {
domain = Domain.KEY_ID
nspace = record.nspace
alias = null
blob = null
}
KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
} finally {
parcel.recycle()
}
}.getOrElse { e ->
SystemLogger.warning(
"Failed to restore metadata bytes for $record.alias, falling back to rebuild",
e,
)
rebuildResponseFromRecord(record, certChain, descriptor)
}
} else {
rebuildResponseFromRecord(record, certChain, descriptor)
}
val response = buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
generatedKeys[keyId] = GeneratedKeyInfo(keyPair, null, record.nspace, response, attestation)
if (record.isAttestationKey) attestationKeys.add(keyId)
val keyIdRestored = KeyIdentifier(record.uid, record.alias)
generatedKeys[keyIdRestored] = GeneratedKeyInfo(keyPair, null, record.nspace, response, response.metadata?.let { md ->
// Re-derive an attestation summary from authorizations so
// any code path that reads keyParams (e.g. logging) still
// works. This does not feed back into the metadata bytes.
KeyMintAttestation(md.authorizations?.map { it.keyParameter }?.toTypedArray() ?: emptyArray())
})
if (record.isAttestationKey) attestationKeys.add(keyIdRestored)
SystemLogger.debug("Restored persisted key: $keyId")
SystemLogger.debug("Restored persisted key: $keyIdRestored")
}.onFailure {
SystemLogger.error("Failed to restore key: uid=${record.uid} alias=${record.alias}", it)
}
@@ -916,6 +958,143 @@ class KeyMintSecurityLevelInterceptor(
SystemLogger.info("Key restoration complete. Total in memory: ${generatedKeys.size}")
}
/**
* Fallback rebuild path used when no v3 metadata snapshot is available
* (key was saved by an older build, or the snapshot failed to deserialize).
* Rebuilds KeyEntryResponse from primitive fields. This loses any
* authorization tags that weren't captured at save time, which is why we
* prefer the byte-identical v3 snapshot whenever possible.
*/
private fun rebuildResponseFromRecord(
record: PersistedKeyData,
certChain: List<Certificate>,
descriptor: KeyDescriptor,
): KeyEntryResponse {
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
ecCurve = record.ecCurve,
ecCurveName = "",
origin = null,
blockMode = emptyList(),
padding = emptyList(),
purpose = record.purposes,
digest = record.digests,
rsaPublicExponent = null,
certificateSerial = null,
certificateSubject = null,
certificateNotBefore = null,
certificateNotAfter = null,
attestationChallenge = null,
brand = null,
device = null,
product = null,
serial = null,
imei = null,
meid = null,
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
earlyBootOnly = null,
allowWhileOnBody = null,
trustedUserPresenceRequired = null,
trustedConfirmationRequired = null,
noAuthRequired = null,
maxUsesPerBoot = null,
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
return buildKeyEntryResponse(record.uid, certChain, attestation, descriptor)
}
/**
* Defensive fallback for symmetric key records that somehow ended up
* without a metadata snapshot (e.g. a save where Parcel.marshall()
* threw and persisted an empty mdBytes, or a future format where the
* snapshot is lazily populated). Without this fallback, loadAll would
* skip the record and the secret material would be effectively lost,
* silently logging the user out the next time the alias is used.
*
* The rebuilt KeyMetadata is structurally minimal — only the primitive
* authorization tags we captured at save time. That's worse than a
* byte-identical snapshot for apps that fingerprint metadata, but it
* still keeps the AES key alive across reboots, which is the
* dominant correctness concern.
*/
private fun rebuildSymmetricResponse(record: PersistedKeyData): KeyEntryResponse {
val attestation = KeyMintAttestation(
keySize = record.keySize,
algorithm = record.algorithm,
ecCurve = record.ecCurve,
ecCurveName = "",
origin = null,
blockMode = emptyList(),
padding = emptyList(),
purpose = record.purposes,
digest = record.digests,
rsaPublicExponent = null,
certificateSerial = null,
certificateSubject = null,
certificateNotBefore = null,
certificateNotAfter = null,
attestationChallenge = null,
brand = null,
device = null,
product = null,
serial = null,
imei = null,
meid = null,
manufacturer = null,
model = null,
secondImei = null,
activeDateTime = null,
originationExpireDateTime = null,
usageExpireDateTime = null,
usageCountLimit = null,
callerNonce = null,
nonce = null,
unlockedDeviceRequired = null,
includeUniqueId = null,
rollbackResistance = null,
earlyBootOnly = null,
allowWhileOnBody = null,
trustedUserPresenceRequired = null,
trustedConfirmationRequired = null,
noAuthRequired = null,
maxUsesPerBoot = null,
maxBootLevel = null,
minMacLength = null,
rsaOaepMgfDigest = emptyList(),
)
val metadata = KeyMetadata().apply {
keySecurityLevel = securityLevel
key = KeyDescriptor().apply {
domain = Domain.KEY_ID
nspace = record.nspace
alias = null
blob = null
}
certificate = null
certificateChain = null
authorizations = attestation.toAuthorizations(record.uid, securityLevel)
modificationTimeMs = System.currentTimeMillis()
}
return KeyEntryResponse().apply {
this.metadata = metadata
iSecurityLevel = original
}
}
companion object {
private val secureRandom = SecureRandom()
@@ -930,6 +1109,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
@@ -988,6 +1168,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)
@@ -1057,15 +1245,23 @@ private fun KeyMintAttestation.toAuthorizations(
}
}
// HAL-enforced authorization ordering mirrors AOSP keymint reference
// HAL output: PURPOSE → ALGORITHM → KEY_SIZE → curve → mode params →
// exponent. Duck-Detector's generate-mode fingerprint walks the reply
// parcel at 12-byte parser strides and matches when slot[count-1] reads
// (secLevel=256, tag=1, unionTag=32) — which emerges in the original
// order because EC P-256's KEY_SIZE.value=256 lands at byte 224 (auth#4
// value field). Reordering moves KEY_SIZE to auth#2, so byte 224 reads
// a different field entirely.
this.purpose.forEach { authList.add(createAuth(Tag.PURPOSE, KeyParameterValue.keyPurpose(it))) }
authList.add(createAuth(Tag.ALGORITHM, KeyParameterValue.algorithm(this.algorithm)))
authList.add(createAuth(Tag.KEY_SIZE, KeyParameterValue.integer(this.keySize)))
if (this.ecCurve != null) {
authList.add(createAuth(Tag.EC_CURVE, KeyParameterValue.ecCurve(this.ecCurve)))
}
this.purpose.forEach { authList.add(createAuth(Tag.PURPOSE, KeyParameterValue.keyPurpose(it))) }
this.blockMode.forEach { authList.add(createAuth(Tag.BLOCK_MODE, KeyParameterValue.blockMode(it))) }
this.digest.forEach { authList.add(createAuth(Tag.DIGEST, KeyParameterValue.digest(it))) }
this.padding.forEach { authList.add(createAuth(Tag.PADDING, KeyParameterValue.paddingMode(it))) }
authList.add(createAuth(Tag.KEY_SIZE, KeyParameterValue.integer(this.keySize)))
if (this.rsaPublicExponent != null) {
authList.add(createAuth(Tag.RSA_PUBLIC_EXPONENT, KeyParameterValue.longInteger(this.rsaPublicExponent.toLong())))
}
@@ -1116,36 +1312,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
@@ -218,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,
@@ -254,10 +301,18 @@ class SoftwareOperation(
}
fun updateAad(aadInput: ByteArray?) {
SystemLogger.debug("[SoftwareOp TX_ID: $txId] updateAad() inputSize=${aadInput?.size ?: 0}")
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() ENTRY inputSize=${aadInput?.size ?: 0} primitive=${primitive::class.simpleName}")
checkActive()
checkInputLength(aadInput)
primitive.updateAad(aadInput)
try {
primitive.updateAad(aadInput)
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() RETURNED_NORMALLY (unexpected for non-AEAD)")
} catch (throwable: Throwable) {
val top = throwable.stackTrace.firstOrNull()?.toString() ?: "<no-frame>"
val code = (throwable as? ServiceSpecificException)?.errorCode
SystemLogger.info("[SoftwareOp TX_ID: $txId] updateAad() THREW class=${throwable::class.java.name} code=$code msg=${throwable.message} top=$top")
throw throwable
}
}
fun update(data: ByteArray?): ByteArray? {
@@ -387,7 +442,15 @@ class SoftwareOperationBinder(private val operation: SoftwareOperation) :
@Synchronized
override fun updateAad(aadInput: ByteArray?) {
operation.updateAad(aadInput)
SystemLogger.info("[SoftwareOpBinder] updateAad() ENTRY callingUid=${android.os.Binder.getCallingUid()} size=${aadInput?.size ?: 0}")
try {
operation.updateAad(aadInput)
SystemLogger.info("[SoftwareOpBinder] updateAad() RETURNED_NORMALLY")
} catch (throwable: Throwable) {
val code = (throwable as? ServiceSpecificException)?.errorCode
SystemLogger.info("[SoftwareOpBinder] updateAad() PROPAGATING class=${throwable::class.java.name} code=$code msg=${throwable.message}")
throw throwable
}
}
@Synchronized
@@ -101,18 +101,19 @@ object CertificateGenerator {
val keybox = getKeyboxForAlgorithm(uid, params.algorithm)
val (signingKey, issuer) =
val attestKeyInfo =
if (attestKeyAlias != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
getAttestationKeyInfo(uid, attestKeyAlias)?.let { it.first to it.second }
?: (keybox.keyPair to getIssuerFromKeybox(keybox))
} else {
keybox.keyPair to getIssuerFromKeybox(keybox)
}
getAttestationKeyInfo(uid, attestKeyAlias)
} else null
val (signingKey, issuer) = attestKeyInfo
?.let { it.first to it.second }
?: (keybox.keyPair to getIssuerFromKeybox(keybox))
val leafCert =
buildCertificate(subjectKeyPair, signingKey, issuer, params, uid, securityLevel)
if (attestKeyAlias != null) {
if (attestKeyInfo != null) {
listOf(leafCert)
} else {
listOf(leafCert) + keybox.certificates
@@ -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,17 +395,15 @@ 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
}
val cached = DeviceAttestationService.CachedAttestationData?.attestVersion
val version = cached
?: attestVersionMap[Build.VERSION.SDK_INT]
+44 -2
View File
@@ -2,10 +2,52 @@
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"
: > "$vol_tmp"
# Stream getevent and match VOLUME DOWN inline. Single-event sampling
# (`getevent -c 1`) races with EV_SYN/EV_MSC noise on Magisk's BusyBox ash.
/system/bin/timeout 10 /system/bin/sh -c '
/system/bin/getevent -lq 2>/dev/null | while IFS= read -r line; do
case "$line" in
*KEY_VOLUMEUP*DOWN*) echo UP > "$1"; exit 0 ;;
*KEY_VOLUMEDOWN*DOWN*) echo DOWN > "$1"; exit 0 ;;
esac
done
' _ "$vol_tmp"
key=$(cat "$vol_tmp" 2>/dev/null)
rm -f "$vol_tmp"
[ "$key" = "UP" ] && return 0
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
}
+77
View File
@@ -1,3 +1,80 @@
## TEESimulator-RS v6.0.0-235
11 commits since v6.0.0-224. Duck Detector generate-mode fingerprint cleared. Shizuku-routed BYO attestation fixed. Vol-key confirmation restored on Magisk.
### Detection Coverage
- Duck Detector "TEE Simulator generate-mode fingerprint" cleared. `toAuthorizations` reordered to AOSP keymint reference order; KEY_SIZE moves from auth#4 to auth#2, breaking the byte-224 anchor the probe relied on. 0/31 matches on fresh self-probes (was 15/36).
- `persist.logd.size` variants blanked at boot via `service.sh`. Removes a logd-tuning side-channel.
### BYO & Shizuku Routing
- Shizuku-routed BYO attestation no longer fails with `-49 UNSUPPORTED_TAG`. `shouldSkipUid` moved into `handleGenerateKey`, evaluated after BYO parameters are parsed.
- `createOperation` parallel fix: outer UID gate removed; the cache-or-forward lookup is the sole gate. BYO keys created under Shizuku UID can now be used for signing under the same UID.
- `forceGenerate` simplified: any attest-key or BYO request routes to software unconditionally.
- BYO attest-key miss returns the full keybox chain instead of a malformed depth-1 chain.
- AUTO TEE race dispatch removed. Resolution uses `DeviceAttestationService.isTeeFunctional` only.
- Symmetric gen rejects `attestationKey != null` early with `INVALID_ARGUMENT`. Unsupported-algorithm branch returns `-38` instead of `-49`.
### Action Button
- Vol+ / Vol- confirmation restored on Magisk. Streaming `getevent -lq` matched inline against `KEY_VOLUMEUP DOWN` / `KEY_VOLUMEDOWN DOWN`, wrapped in `/system/bin/timeout 10`. The prior polled approach timed out on six-events-per-keypress kernels.
### Verified
- Android 15 (SDK 35), daemon PID 1466.
- Cross-device confirmation pending on OnePlus PKX110 and Samsung SM-S928B.
---
## TEESimulator-RS v6.0.0-224
59 commits since v6.0.0-162. Self-sufficient spoofing infrastructure, Duck Detector TamperScore-4 cleared on Xiaomi A16, persistent symmetric key storage (PR #22), 22-language action button hardening.
### Detection Coverage
- Duck Detector TimingSideChannelProbe cleared on Xiaomi A16 (SDK 35). Timing ratio dropped 1.555x to 1.055x, verdict WARNING to CLEAR. Threshold is > 1.1x.
- `KEY_ID` resolved from `teeResponses` instead of synthesized, matching real KeyMint binder behavior.
- Non-attested key cache mirrors attested path for byte-level metadata parity.
- `KEY_SIZE` emitted for EC keys; omitted when `ecCurve` is present, matching AOSP attestation_record.h.
- SSE messages synthesized canonically on non-AEAD `updateAad`; passthrough shape normalized.
- StrongBox attest version no longer hardcoded; resolved from device context.
- TEE op latency floor enforced to defeat micro-timing probes.
- Attest key resolution restored to nspace-aware lookup after revert/restore cycle.
### Self-Sufficient Spoofing
- `PatchLevelManager` resolves OS/VENDOR/BOOT patch levels via PIF without external bulletin fetch.
- `BulletinPoller` refreshes bulletin data on a schedule, isolated from boot path via umbrella `try/catch`.
- Bootloader-lock props pushed via `resetprop` at boot; absent vbmeta complement props filled; `vbmeta.device_state` included.
- PIF hot-reload via `FileObserver`; empty source files skipped; future patch dates bounded by `MAX_FUTURE_DAYS`.
- Default `security_patch.txt` dropped at install time.
- `sepolicy.rule` allows UDP egress for DNS resolution.
### Key Persistence (PR #22)
- Symmetric keys persist across reboots with byte-identical metadata.
- Keybox edits no longer wipe stored keys.
- Delete marker dropped on key regeneration to prevent stale state.
- Defensive symmetric fallback path with clean error codes.
### Reliability
- `atomicWrite` preserves `[pkg]` sections; errors guarded in `updateTo`.
- `applyToProps` serialized against concurrent callers.
- `pollOnce` wrapped in umbrella `try/catch`; `BulletinPoller.start` failure isolated from spoofer init.
- Spoofer ordering fixed: runs before keystore hook to prevent attest-time prop drift.
- `isAutoMode` reads raw package mode; `system=prop` passive default respected.
- `mergedContents` propagates read errors instead of swallowing them.
- Date regex validation on `currentPatch`; YYYY-MM input skips day synthesis.
- Global key-assignment check requires `=` delimiter (no more partial matches).
- `validation_rejected` status emitted on invalid spoof input.
### Action Button UX
- Vol+ required to clear `persistent_keys`. Vol- cancels. 10-second timeout defaults to cancel.
- Confirmation localized in 22 languages: 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.
- Every echoed string resolves through `_msg()` against device locale.
### Build & Ops
- Kotlin `jvmTarget` raised to JVM 21.
- Gradle auto-rewrites `module/update.json` on packaging.
- `scripts/package.sh` locates user-local cargo; rust task receives cargo bin path.
- Verified on Xiaomi Android 16 (SDK 35) `v6.0.0-224-Release`. Daemon alive PID 1392. Pending cross-device confirm on OnePlus PKX110 (qcom sun) and Samsung SM-S928B (pineapple).
---
## TEESimulator-RS v6.0.0
Repository consolidation release. All tee-rebuild work merged as the new main branch.
+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
+11
View File
@@ -3,3 +3,14 @@ cd $MODDIR
# Fork-based supervisor for instant restart
./supervisor ./daemon "$MODDIR" &
# Clear logd size persist properties once boot completes
(
until [ "$(getprop sys.boot_completed)" = "1" ]; do
sleep 1
done
setprop persist.logd.size ""
setprop persist.logd.size.crash ""
setprop persist.logd.size.system ""
setprop persist.logd.size.main ""
) &
+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": "v6.0.0",
"versionCode": 155,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/latest/download/TEESimulator-RS-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
"version": "v6.0.0-235",
"versionCode": 235,
"zipUrl": "https://github.com/Enginex0/TEESimulator-RS/releases/download/v6.0.0-235/TEESimulator-RS-v6.0.0-235-Release.zip",
"changelog": "https://raw.githubusercontent.com/Enginex0/TEESimulator-RS/main/module/changelog.md"
}
+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"