diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5a628e..9f8979e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to **DeviceIntelligence** are recorded here. Format follows
The wire format (`TelemetryReport` JSON, `Finding.kind` identifiers, detector IDs) carries an independent `schema_version` integer that is **only** bumped on breaking changes. Adding new finding kinds or new detectors is additive and does NOT bump `schema_version`. Backends pin against `schema_version` for correctness; library version pinning is for build-time API stability.
+## [Unreleased]
+
+### Changed
+
+- **`attestation.key` verdict: a non-Verified boot is now CRITICAL (was HIGH).** `deriveIntegrityVerdict` no longer treats SelfSigned (yellow) as a genuine OS — only a Google-Verified (green) boot qualifies for `MEETS_DEVICE_INTEGRITY`. SelfSigned (yellow), Unverified (orange), and Failed (red) all mean the boot chain was modified (custom AVB key / unlocked / failed verification), so the `tee_integrity_verdict` finding is now emitted at **CRITICAL** severity for all three. A yellow/relocked-with-own-key boot (custom kernel / KernelSU / custom ROM) no longer reaches `MEETS_DEVICE_INTEGRITY` and no longer downgrades to HIGH.
+
+### Added
+
+- **Hardware-attested "OS modified" proof on `tee_integrity_verdict`.** When the boot state is non-Verified, the finding's `message` now states *why* the OS is considered modified, and `details` gain two fields: `verified_boot_key_sha256` (the attested boot-signing key hash — Google's on stock, a custom value on a re-signed boot) and `os_modified_proof` (a human-readable, hardware-attested explanation). These come from the secure element's RootOfTrust and cannot be forged on-device.
+
+### Wire-format impact
+
+`schema_version` stays at `2` — the new `details` keys are additive and the severity field already existed. Backends that branch on `tee_integrity_verdict.severity` will now see `critical` where they previously saw `high` for non-Verified boots; treat that as a stronger signal, not a new shape.
+
## [2.0.0] — 2026-05-28
Stable major release. Removes the native analytics drain entirely: the SDK now performs zero network calls under any configuration, and the entire telemetry pipeline (collection → JSON → upload) lives in the consumer's process and on the consumer's backend.
diff --git a/README.md b/README.md
index c567818..dcdc5cd 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
DeviceIntelligence
- Open-source Android telemetry SDK for understanding the device ecosystem of your userbase.
- APK integrity · key attestation · bootloader integrity · runtime tampering · root indicators · emulator probe · cloner detection · runtime DEX-injection · 8-layer native anti-hooking stack.
- Not a RASP. Not a kill-switch. Just structured, deterministic JSON your backend can analyze.
+ Open-source Android device-integrity SDK.
+ Collects APK integrity, hardware key attestation, root / hook / emulator / cloner signals into one deterministic JSON your backend can act on.
+ Not a RASP — it observes and reports; your backend owns the policy.
@@ -15,40 +15,11 @@
-
-
-
-
-
-
-
- Same release APK, three devices. Left — clean Pixel 9 Pro. Middle — Pixel 6 Pro running KernelSU + LSPosed, Signals card lifts findings into product-shaped verdicts. Right — Findings card sorted worst-first, tap to expand the diagnostic details map.
-
-
---
-## Why DeviceIntelligence
-
-Most Android apps shipping device-tampering checks reach for one of:
-
-- **Google Play Integrity API** — black-box, requires Google Play Services, can't run on AOSP/FOSS devices, doesn't tell you *why* a device is suspect.
-- **A commercial RASP** — expensive, partly closed-source, opaque detection logic, vendor lock-in.
-- **A simple root checker** (RootBeer, SafetyNetHelper) — covers maybe 20% of the threat surface a real attacker uses.
-
-DeviceIntelligence sits in the gap. It's:
-
-- **Fully open-source** (Kotlin + native C++) — every detection rule is auditable. No closed binary blobs.
-- **Free-of-Google** — no Play Services dependency; works on AOSP, OpenGApps, GrapheneOS, CalyxOS, etc. Hardware key attestation still works on those builds because it's a Keymaster API call, not a Google API call.
-- **Detection-rich** — covers the techniques real attackers actually use: Frida agents, Xposed/LSPosed/EdXposed, Pine, SandHook, YAHFA, Cydia Substrate, Magisk, Zygisk, Riru, Taichi, app cloners, and runtime DEX injection (`InMemoryDexClassLoader`/`DexClassLoader` payloads). 8 layers of in-process anti-hooking with circular-bypass design.
-- **Backend-agnostic** — emits one deterministic JSON. Send it to your own backend, Datadog, BigQuery, anywhere. The SDK itself performs no network calls and ships no analytics — collection and policy stay entirely in your hands.
-
-**Use it when** you need device-tampering evidence richer than Play Integrity's pass/fail bit, want server-side control of the policy decision, can't or won't take the Play Services dependency, or want to audit the detection logic rather than trust a vendor's claims.
-
-**Don't use it when** you want a turnkey "block this user" decision baked into the SDK — that's not what this is. DeviceIntelligence reports facts; *your backend* decides what to do about them.
-
## Install
-Distributed via [JitPack](https://jitpack.io/#iamjosephmj/DeviceIntelligence).
+Distributed via [JitPack](https://jitpack.io/#iamjosephmj/DeviceIntelligence). Apply the Gradle plugin — it auto-wires the runtime AAR and bakes the build-time APK fingerprint.
**`settings.gradle.kts`**
@@ -80,27 +51,27 @@ plugins {
}
```
-Ships native binaries for **`arm64-v8a`**, **`x86_64`**, and **`armeabi-v7a`** (32-bit ARM). On 32-bit devices, all detectors run except `integrity.art`, which reports `INCONCLUSIVE` because the underlying `ArtMethod` field-offset table is currently 64-bit-only.
+`minSdk` 28. Ships native binaries for `arm64-v8a`, `x86_64`, and `armeabi-v7a`. `kotlinx-coroutines-android` is the only runtime dependency.
## Quick start
-Four entry points, pick the one that matches your use case.
+Four entry points — pick the one that fits your use case.
-**One-shot collect** — your app starts, you want one structured snapshot, you ship it to your backend.
+**One-shot collect** — one structured snapshot at startup.
```kotlin
lifecycleScope.launch {
- val report = DeviceIntelligence.collect(context) // TelemetryReport
- val json = DeviceIntelligence.collectJson(context) // canonical JSON
-
+ val report = DeviceIntelligence.collect(context) // TelemetryReport
+ val json = DeviceIntelligence.collectJson(context) // canonical JSON
val signals = report.toIntegritySignals()
+
if (IntegritySignal.HOOKING_FRAMEWORK_DETECTED in signals) {
- // Send to your backend, gate the action, raise a flag — your call.
+ // Ship to your backend, gate the action, raise a flag — your call.
}
}
```
-**Periodic observe** — long-running session, you want a fresh snapshot every N seconds (e.g. catch a Frida agent that attaches mid-flow).
+**Periodic observe** — a fresh snapshot every N seconds (e.g. catch a Frida agent that attaches mid-flow).
```kotlin
DeviceIntelligence.observe(context, interval = 2.seconds)
@@ -108,63 +79,48 @@ DeviceIntelligence.observe(context, interval = 2.seconds)
.launchIn(lifecycleScope)
```
-**Cumulative session observe** — same as `observe()` but accumulates findings across emissions. A transient hook that fires once and detaches stays visible with `stillActive=false`. Use this when your UI / backend correlation should never lose sight of a signal the moment it stops appearing.
+**Cumulative session observe** — like `observe()`, but accumulates findings across emissions. A transient hook that fires once and detaches stays visible with `stillActive = false`.
```kotlin
DeviceIntelligence.observeSession(context, interval = 2.seconds)
.onEach { session: SessionFindings ->
- render(session.findings) // List
- ship(session.toJson()) // canonical wire format
+ render(session.findings) // List
+ ship(session.toJson()) // canonical wire format
}
.launchIn(lifecycleScope)
```
-Each `TrackedFinding` carries `firstSeenAtEpochMs`, `lastSeenAtEpochMs`, `observationCount`, and `stillActive` on top of the underlying `Finding`.
+Each `TrackedFinding` adds `firstSeenAtEpochMs`, `lastSeenAtEpochMs`, `observationCount`, and `stillActive` on top of the underlying `Finding`.
-**Java / synchronous boundary** — for Java consumers, worker threads, JNI bridges.
+**Java / synchronous** — for Java consumers, worker threads, JNI bridges.
```java
TelemetryReport report = DeviceIntelligence.collectBlocking(context);
String json = DeviceIntelligence.collectJsonBlocking(context);
```
-`kotlinx-coroutines-android` is the only runtime dependency.
-
-## What it collects
-
-| Detector | id | What it observes |
-|----------------------|--------------------------|-------------------------------------------------------------------------------|
-| APK integrity | `integrity.apk` | APK bytes vs. the build-time fingerprint baked by the Gradle plugin |
-| Bootloader integrity | `integrity.bootloader` | TEE-spoofing / cached-chain detection on `attestation.key` |
-| ART integrity | `integrity.art` | In-process ART tampering across 5 vectors (Frida, Xposed, LSPosed, Pine, …) |
-| Key attestation | `attestation.key` | TEE / StrongBox attestation: Verified Boot, bootloader lock, OS patch level |
-| Runtime environment | `runtime.environment` | Debugger / ptrace / native integrity stack (text hash, GOT, injected libs) + runtime DEX-injection (InMemoryDexClassLoader / DexClassLoader payloads) + Frida 16+ Gum memfd-JIT attribution |
-| Root indicators | `runtime.root` | `su` binary, Magisk artifacts, `test-keys`, root-manager apps, Shamiko-bypass cross-checks (init mount-namespace + `@magisk_daemon` socket), MagiskTrustUserCerts TLS-trust-store MITM |
-| Emulator probe | `runtime.emulator` | CPU-instruction-level signals (arm64 MRS / x86_64 CPUID hypervisor bit) |
-| App cloner | `runtime.cloner` | Foreign APK mappings, mount-namespace inconsistencies, UID mismatches |
-
-Each detector emits granular `Finding`s; the `IntegritySignal` mapper collapses ~40 finding kinds into 11 product-shaped verdicts for UI / feature-flag code:
-
-| `IntegritySignal` | Meaning |
-|---------------------------------|------------------------------------------------------------------------------------------------------------------------|
-| `APK_TAMPERED` | APK on disk modified, repackaged, signer mismatch, or installer not allowlisted. |
-| `APK_FINGERPRINT_UNAVAILABLE` | The build-time fingerprint asset is missing/corrupt — couldn't make a verdict either way. |
-| `BOOTLOADER_INTEGRITY_FAILED` | Hardware key-attestation chain has anomalies, or device claims StrongBox but attests at a lower level. |
-| `TEE_ATTESTATION_DEGRADED` | Local advisory verdict on the attestation chain came back below `MEETS_STRONG_INTEGRITY`, OR the leaf cert's KeyDescription extension is in CBOR/EAT format (KeyMint 200+) and field-level parsing is deferred to backend re-verification. |
-| `HOOKING_FRAMEWORK_DETECTED` | Active code-level hooking — Frida (incl. Frida 16+ Gum memfd-JIT attribution), Xposed/LSPosed/EdXposed, Pine, SandHook, Substrate, ART-internals tampering, runtime DEX injection, RWX trampolines, `.text` drift, GOT rewrites. |
-| `INJECTED_NATIVE_CODE` | Unknown post-baseline `.so` or anonymous executable mapping; precondition for hooking but not yet proof of one. |
-| `ROOT_INDICATORS_PRESENT` | `su` binary, Magisk artifact, `test-keys` build, `which su` succeeds, root-manager app installed, Magisk visible in PID 1's mount namespace (Shamiko bypass), `@magisk_daemon` abstract socket bound, or a `tmpfs` over `/apex/com.android.conscrypt` (MagiskTrustUserCerts TLS-MITM enablement — treat as hard block). |
-| `EMULATOR_DETECTED` | CPU-instruction-level signals — arm64 MRS or x86_64 CPUID hypervisor bit. |
-| `APP_CLONED` | Foreign APK mappings, mount-namespace inconsistencies, UID mismatches. |
-| `DEBUGGER_ATTACHED` | JVM debugger or ptrace tracer attached. |
-| `DEBUG_FLAG_MISMATCH` | App's `FLAG_DEBUGGABLE` disagrees with `ro.debuggable`. |
-| `HARDWARE_ATTESTED_USERSPACE_TAMPERED` | **Strongest single signal.** Hardware attestation reports `verifiedBootState=Verified` AND a userspace hook finding fires in the same report. Either TEE compromise or post-attestation injection (Magisk + Shamiko, etc.). Backends should treat as highest-confidence compromise signal. |
+## Signals
+
+Detectors emit granular `Finding`s; the `IntegritySignal` mapper collapses them into product-shaped verdicts you branch on.
+
+| `IntegritySignal` | Meaning |
+|----------------------------------------|------------------------------------------------------------------------------------------|
+| `APK_TAMPERED` | APK modified, repackaged, signer mismatch, or installer not allowlisted. |
+| `APK_FINGERPRINT_UNAVAILABLE` | Build-time fingerprint asset missing/corrupt — no verdict either way. |
+| `BOOTLOADER_INTEGRITY_FAILED` | Attestation chain has anomalies, or device claims StrongBox but attests lower. |
+| `TEE_ATTESTATION_DEGRADED` | Attestation verdict below `MEETS_STRONG_INTEGRITY` (or a CBOR/EAT leaf needing re-verify).|
+| `HOOKING_FRAMEWORK_DETECTED` | Active code hooking — Frida, Xposed/LSPosed, Pine, SandHook, Substrate, DEX injection, `.text`/GOT tampering. |
+| `INJECTED_NATIVE_CODE` | Unknown post-baseline `.so` / anon-exec mapping (precondition for hooking). |
+| `ROOT_INDICATORS_PRESENT` | `su`, Magisk artifacts, `test-keys`, root-manager app, Shamiko bypass, or TLS-trust-store MITM. |
+| `EMULATOR_DETECTED` | CPU-level signals (arm64 MRS / x86_64 CPUID hypervisor bit). |
+| `APP_CLONED` | Foreign APK mappings, mount-namespace inconsistencies, UID mismatches. |
+| `DEBUGGER_ATTACHED` | JVM debugger or ptrace tracer attached. |
+| `DEBUG_FLAG_MISMATCH` | App's `FLAG_DEBUGGABLE` disagrees with `ro.debuggable`. |
+| `HARDWARE_ATTESTED_USERSPACE_TAMPERED` | **Strongest signal.** Verified boot **and** a userspace hook in the same report — treat as a hard block. |
```kotlin
val report = DeviceIntelligence.collect(context).toIntegritySignalReport()
when {
- // Hardware-attested AND userspace-tampered = the highest-confidence
- // signal the SDK can produce. Treat as a hard block.
IntegritySignal.HARDWARE_ATTESTED_USERSPACE_TAMPERED in report.signals -> hardBlock()
IntegritySignal.HOOKING_FRAMEWORK_DETECTED in report.signals -> denyPayment()
IntegritySignal.ROOT_INDICATORS_PRESENT in report.signals -> warnUser()
@@ -176,200 +132,41 @@ report.evidence[IntegritySignal.HOOKING_FRAMEWORK_DETECTED]?.forEach { finding -
}
```
-> **Not a RASP.** It does not block sessions, kill processes, or interrupt any flow. It only observes. Build enforcement on the JSON your backend ingests; keep the policy off-device.
-
-## Validated against
-
-DeviceIntelligence ships its own offensive verification harnesses — Frida scripts and a real LSPosed module that intentionally trip each detector. Detection isn't claimed; it's *verified* against the same tools an attacker would use, on real hardware (Pixel 6 Pro running KernelSU + LSPosed; secondary Pixel 9 Pro for clean baseline).
-
-**Cross-OEM stability.** Beyond the per-detector verification on Pixels, `collect()` / `observe()` / `observeSession()` have been validated for runtime stability across [Sauce Labs](https://saucelabs.com/)' real-device farm — every Android 11+ (API 30–36) device in the farm, spanning the major OEM forks (Samsung One UI, Xiaomi HyperOS / MIUI, Vivo OriginOS, Honor MagicOS, OPPO ColorOS, OnePlus OxygenOS, Motorola, plus AOSP-equivalent Pixels). "Stability" here means: the native lib loads, every detector runs to completion, the JSON parses, no crashes on any tested device. Attack-scenario coverage (LSPosed / Frida actually firing detections) is verified on the Pixel 6 Pro reference rig.
-
-**Cross-ABI stability.** All three native ABIs the AAR ships have been runtime-validated in the same Sauce Labs sweep:
-
-| ABI | Status |
-| -------------- | --------------------------------------------------------------|
-| `arm64-v8a` | full coverage — every detector works |
-| `x86_64` | full coverage — every detector works |
-| `armeabi-v7a` | runtime-stable since 0.8.0 (validated on Sauce Labs 32-bit ARM devices). Every detector works EXCEPT `integrity.art`, which reports `INCONCLUSIVE` because the underlying `ArtMethod` field-offset table is 64-bit-only. Characterising 32-bit ART struct layouts is tracked as a future minor-version research task. |
-
-| Surface | Validated with | Status |
-|----------------------------------|------------------------------------------------------------------------------------------------------|--------|
-| ART method-hook vectors A–F | `tools/red-team/frida-vector-{a,c,d,e,f}.js` — 5 independent JNI-level Frida scripts | shipped |
-| Frida-Java's `cls.method.implementation` | `tools/red-team/frida-vector-frida-java.js` | shipped |
-| LSPosed Java-side method hooks | `samples/lsposed-tester` — real LSPosed module installs hooks; StackGuard + StackWatchdog catch them | shipped |
-| Runtime DEX injection (CTF Flag 1) | LSPosed-driven `InMemoryDexClassLoader` + disk-backed `DexClassLoader` from `/data/local/tmp/` | shipped (0.6.0) |
-| Pre-baseline DEX injection (Zygisk timing) | `samples/lsposed-tester` `EarlyDexInjectionHook` — synchronous inject in `handleLoadPackage` | shipped via `unattributable_dex_at_baseline` (0.6.0) |
-| Newer hook frameworks (Dobby/Whale/YAHFA/FastHook/il2cpp-dumper) | `tools/red-team/maps-newer-frameworks.js` — Frida `prctl(PR_SET_VMA_ANON_NAME)` page renaming | shipped (0.9.0) |
-| Hardware attestation × userspace tampering correlation | composes existing detector findings — JVM unit tests + Pixel 6 Pro live data | shipped (1.0.0) |
-| Magisk + Shamiko hide-module bypass | `/proc/1/mountinfo` cross-check (init namespace can't be unshared per-process) + `@magisk_daemon` abstract Unix socket via `/proc/self/net/unix` — JVM unit tests with hand-crafted procfs fixtures. Finding kinds: `magisk_in_init_mountinfo`, `magisk_daemon_socket_present` | shipped (1.x) |
-| MagiskTrustUserCerts TLS-trust-store MITM | `tmpfs` bind-mount over `/apex/com.android.conscrypt` in `/proc/self/mountinfo` — JVM unit tests, CRITICAL severity (active TLS interception, not just root presence). Finding kind: `tls_trust_store_tampered` | shipped (1.x) |
-| Frida 16+ Gum memfd-backed JIT | `/memfd:jit-cache` + `rwxp` + region size >8 MB pattern in `/proc/self/maps` — JVM unit tests; fires alongside the generic `rwx_memory_mapping` for backend Frida-attribution pivot. Finding kind: `frida_memfd_jit_present` | shipped (1.x) |
-| EAT/CBOR attestation format detection | `KeyDescriptionParser` heuristic: when legacy ASN.1 parse fails AND the unwrapped extension starts with a CBOR map byte (`0xA0`–`0xBF`), emits `attestation_eat_format_detected` (LOW) so backends know parsed fields need server-side re-verification. Full CBOR-EAT field-level parsing tracked for a follow-up minor | shipped — format detection only (1.x) |
-| Real Zygisk module | TBD — see [`tools/red-team/CTF_ROADMAP.md`](tools/red-team/CTF_ROADMAP.md) | planned |
-| Samsung Knox warranty-bit parsing | Samsung Knox attestation extension OID prefix is detected on the leaf, but warranty-bit byte parsing requires on-device Samsung validation tracked for a follow-up minor | planned |
-
-Full step-by-step validation runbook for the Pixel 6 Pro: [`tools/red-team/FLAG1_RUNBOOK.md`](tools/red-team/FLAG1_RUNBOOK.md).
-
-## JSON contract
-
-`DeviceIntelligence.collectJson(context)` returns a single deterministic
-document. The shape is stable across releases that share the same
-`schema_version` (currently `2`). For every `Finding`, the fields
-`kind` / `severity` / `subject` / `message` are stable; `details` is
-opaque diagnostic data — useful for forensics, but its keys may change
-between releases without a `schema_version` bump, so don't key on them
-server-side.
-
-**`status` vs `findings`** answer different questions. `status`
-(`ok` / `inconclusive` / `error`) means "did the detector run?";
-`findings[]` means "what did it see?". A rooted device looks like
-`status: "ok"` plus a non-empty `findings[]`. Drive your "device looks
-tampered" decision off `summary.detectors_with_findings`, not `status`.
-
-**Per-collect vs cumulative session.** `collectJson()` and
-`TelemetryReport.toJson()` emit one snapshot of the moment the
-collect ran. `SessionFindings.toJson()` (from `observeSession`) emits
-a cumulative session view — same wire shape per finding plus
-`first_seen_at_epoch_ms` / `last_seen_at_epoch_ms` /
-`observation_count` / `still_active`, with a `latest_report_summary`
-correlation block. Both share `schema_version`; pick whichever
-matches your backend's correlation model.
-
-
-Full clean-device report (click to expand)
-
-Captured live from a clean Pixel 9 Pro. Locale, timezone, install
-timestamps, `vpn_active`, `boot_count`, and APK random suffixes were
-swapped for generic values; everything else (StrongBox-backed
-attestation, Tensor G4 SoC, Mali GPU, 120Hz panel, GMS signer SHA) is
-the unmodified real value. For tripped-detector examples, see
-[`docs/DETECTORS.md`](docs/DETECTORS.md).
-
-```json
+The underlying detectors (`integrity.apk`, `integrity.bootloader`, `integrity.art`, `attestation.key`, `runtime.environment`, `runtime.root`, `runtime.emulator`, `runtime.cloner`) and their finding kinds are documented in [`docs/DETECTORS.md`](docs/DETECTORS.md).
+
+> **Not a RASP.** It never blocks sessions, kills processes, or interrupts a flow. It only observes. Build enforcement on the JSON your backend ingests; keep the policy off-device.
+
+## Output
+
+`collectJson(context)` returns one deterministic document with a stable `schema_version` (currently `2`). The envelope:
+
+```jsonc
{
"schema_version": 2,
- "library_version": "1.0.0",
+ "library_version": "2.0.0",
"collected_at_epoch_ms": 1777400000000,
- "collection_duration_ms": 8325,
- "device": {
- "manufacturer": "Google",
- "model": "Pixel 9 Pro",
- "sdk_int": 36,
- "abi": "arm64-v8a",
- "fingerprint": "google/caiman/caiman:16/CP1A.260405.005/15001963:user/release-keys",
- "total_ram_mb": 15583,
- "cpu_cores": 8,
- "screen_density_dpi": 480,
- "screen_resolution": "1280x2856",
- "has_fingerprint_hw": true,
- "has_telephony_hw": true,
- "sensor_count": 41,
- "boot_count": 142,
- "vpn_active": false,
- "strongbox_available": true,
- "brand": "google",
- "board": "caiman",
- "hardware": "caiman",
- "product": "caiman",
- "device": "caiman",
- "bootloader_version": "ripcurrentpro-16.4-14791556",
- "radio_version": "g5400c-251201-260127-B-14784805,g5400c-251201-260127-B-14784805",
- "build_host": "67911e6f684b",
- "build_user": "android-build",
- "build_type": "user",
- "build_tags": "release-keys",
- "build_time_epoch_ms": 1773135125000,
- "supported_abis_all": ["arm64-v8a"],
- "soc_manufacturer": "Google",
- "soc_model": "Tensor G4",
- "gl_es_version": "3.2",
- "egl_implementation": "mali",
- "default_locale": "en-US",
- "system_locales": ["en-US"],
- "timezone_id": "America/Los_Angeles",
- "timezone_offset_minutes": -480,
- "auto_time_enabled": true,
- "auto_time_zone_enabled": true,
- "display_refresh_rate_hz": 120.0,
- "display_supported_refresh_rates_hz": [1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 24.0, 30.0, 40.0, 60.0, 120.0],
- "display_hdr_types": ["HDR10", "HLG", "HDR10_PLUS"],
- "device_secure": true,
- "biometrics_enrolled": true,
- "adb_enabled": false,
- "developer_options_enabled": false,
- "battery_present": true,
- "battery_technology": "Li-ion",
- "battery_health": "good",
- "battery_plug_type": "none",
- "thermal_status": "none",
- "boot_epoch_ms": 1776800000000,
- "play_services_availability": "success",
- "play_services_version_code": 261533035,
- "play_store_version_code": 85101930,
- "gms_signer_sha256": "5f2391277b1dbd489000467e4c2fa6af802430080457dce2f618992e9dfb5402"
- },
- "app": {
- "package_name": "io.ssemaj.sample",
- "apk_path": "/data/app/.../io.ssemaj.sample-.../base.apk",
- "installer_package": null,
- "signer_cert_sha256": ["a91535782adbd690b915679d456628153166d35527ea867ab830bccd730065a4"],
- "build_variant": "debug",
- "library_plugin_version": "1.0.0",
- "first_install_epoch_ms": 1775000000000,
- "last_update_epoch_ms": 1777300000000,
- "target_sdk_version": 36,
- "install_source": {
- "installing_package": null,
- "originating_package": null,
- "initiating_package": "com.android.shell"
- },
- "signer_cert_validity": [
- { "not_before_epoch_ms": 1771714645000, "not_after_epoch_ms": 2717794645000 }
- ],
- "attestation": {
- "chain_sha256": "dd12ccf2a857860f3712b45bcfebb7b917d4e0b9187cca0d0e50e9b119f5c9b8",
- "chain_length": 5,
- "attestation_security_level": "StrongBox",
- "keymaster_security_level": "StrongBox",
- "software_backed": false,
- "verified_boot_state": "Verified",
- "device_locked": true,
- "os_patch_level": 202604,
- "attested_package_name": "io.ssemaj.sample",
- "attested_signer_cert_sha256": ["a91535782adbd690b915679d456628153166d35527ea867ab830bccd730065a4"],
- "verdict_device_recognition": "MEETS_BASIC_INTEGRITY,MEETS_DEVICE_INTEGRITY,MEETS_STRONG_INTEGRITY",
- "verdict_app_recognition": "RECOGNIZED",
- "verdict_reason": null,
- "verdict_authoritative": false,
- "unavailable_reason": null
- }
- },
- "detectors": [
- { "id": "integrity.apk", "status": "ok", "duration_ms": 841, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "integrity.bootloader", "status": "ok", "duration_ms": 243, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "integrity.art", "status": "ok", "duration_ms": 4, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "attestation.key", "status": "ok", "duration_ms": 495, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "runtime.environment", "status": "ok", "duration_ms": 5525, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "runtime.root", "status": "ok", "duration_ms": 458, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "runtime.emulator", "status": "ok", "duration_ms": 0, "inconclusive_reason": null, "error_message": null, "findings": [] },
- { "id": "runtime.cloner", "status": "ok", "duration_ms": 0, "inconclusive_reason": null, "error_message": null, "findings": [] }
- ],
- "summary": {
- "total_findings": 0,
- "findings_by_severity": { "low": 0, "medium": 0, "high": 0, "critical": 0 },
- "findings_by_kind": {},
- "detectors_with_findings": [],
- "detectors_inconclusive": [],
- "detectors_errored": []
- }
+ "device": { /* model, abi, soc, strongbox_available, ... */ },
+ "app": { "package_name": "...", "signer_cert_sha256": ["..."], "attestation": { /* chain + verdict */ } },
+ "detectors": [ { "id": "integrity.apk", "status": "ok", "findings": [] }, /* ... */ ],
+ "summary": { "total_findings": 0, "findings_by_severity": {}, "detectors_with_findings": [] }
}
```
-
+- **`status` vs `findings`** answer different questions. `status` (`ok` / `inconclusive` / `error`) = "did the detector run?"; `findings[]` = "what did it see?". A rooted device is `status: "ok"` with a non-empty `findings[]` — drive decisions off `summary.detectors_with_findings`, not `status`.
+- For every `Finding`, `kind` / `severity` / `subject` / `message` are stable; `details` is opaque diagnostic data whose keys may change without a `schema_version` bump — don't key on them server-side.
+- `SessionFindings.toJson()` (from `observeSession`) adds `first_seen_at_epoch_ms` / `last_seen_at_epoch_ms` / `observation_count` / `still_active` per finding.
+
+A full clean-device report and tripped-detector examples are in [`docs/DETECTORS.md`](docs/DETECTORS.md).
+
+## Permissions
+
+| Permission | Required by | Default | Opt-out / opt-in |
+|------------------------|-----------------------------------------------------|---------|---------------------------------------|
+| `QUERY_ALL_PACKAGES` | `runtime.root` `root_manager_app_installed` channel | on | Strip via `tools:node="remove"` |
+| `ACCESS_NETWORK_STATE` | `DeviceContext.vpnActive` | off | `enableVpnDetection.set(true)` |
+| `USE_BIOMETRIC` | `DeviceContext.biometricsEnrolled` | off | `enableBiometricsDetection.set(true)` |
-A clean device emits empty `findings[]` everywhere and
-`summary.total_findings: 0`. You can alert on `total_findings > 0`
-server-side without parsing each detector individually.
+When opted out, `vpnActive` / `biometricsEnrolled` report `null` (not `false`).
## Try the sample
@@ -380,33 +177,16 @@ cd DeviceIntelligence
adb shell am start -n io.ssemaj.sample/.MainActivity
```
-## Permissions
-
-| Permission | Required by | Default | Opt-out / opt-in |
-|------------------------|-----------------------------------------------------|---------|----------------------------------------|
-| `QUERY_ALL_PACKAGES` | `runtime.root` `root_manager_app_installed` channel | on | Strip via `tools:node="remove"` |
-| `ACCESS_NETWORK_STATE` | `DeviceContext.vpnActive` | off | `enableVpnDetection.set(true)` |
-| `USE_BIOMETRIC` | `DeviceContext.biometricsEnrolled` | off | `enableBiometricsDetection.set(true)` |
-
-When you opt out of `vpnActive` / `biometricsEnrolled`, the field
-reports `null` (not `false`).
-
## Privacy & GDPR
-**Zero data collection.** The SDK does not make any network calls, does not contact any vendor cloud, and does not transmit any telemetry to anyone. The output of `DeviceIntelligence.collectJson(context)` stays in your app's process; what your app chooses to upload — and to where — is entirely up to you.
-
-**GDPR-friendly by design.** Because the SDK transmits no data, it is neither a data controller nor a data processor under GDPR. Your app remains the sole data controller for any device telemetry it chooses to forward to its own backend, and the consent / retention / DSAR posture is yours to define. The library itself reads no advertising ID (GAID), no `ANDROID_ID`, no IMEI/IMSI, no SIM serial, no account identifiers, and no contact / location / file-system content. Everything available in the `TelemetryReport` is documented end-to-end in the [JSON contract](#json-contract) section above so you can audit exactly what fields exist before deciding what to ship server-side.
+The SDK makes **zero network calls** and reads no GAID, `ANDROID_ID`, IMEI/IMSI, SIM serial, account, contact, or location data. The output of `collectJson(context)` stays in your process; what you upload — and where — is entirely your decision. Because the library transmits nothing, it is neither a data controller nor processor under GDPR; your app remains the sole controller for any telemetry it forwards. Every field is documented in the [output contract](#output) so you can audit exactly what exists before shipping it.
## Documentation
-- [**`docs/DETECTORS.md`**](docs/DETECTORS.md) — full per-detector reference (threat model, finding kinds, sample tripped JSON, costs, caveats)
-- [**`NATIVE_INTEGRITY_DESIGN.md`**](NATIVE_INTEGRITY_DESIGN.md) — design of the 8-layer (G0–G7) anti-hooking stack
-- [**`CHANGELOG.md`**](CHANGELOG.md) — version history from 0.5.2 → 1.0.0 with wire-format impact notes per release
-- [**`SECURITY.md`**](SECURITY.md) — vulnerability disclosure process, response SLOs, supported-versions policy
-- [**`tools/red-team/`**](tools/red-team/README.md) — Frida scripts that intentionally trip each `integrity.art` finding (Vectors A/C/D/E/F + Frida-Java)
-- [**`tools/red-team/CTF_ROADMAP.md`**](tools/red-team/CTF_ROADMAP.md) — capture-the-flag roadmap of every detection technique on the backlog (Flag 1 — DEX injection — captured 0.6.0; Flag 2 — newer hook frameworks — captured 0.9.0; Flag 5 — attestation × runtime correlation — captured 1.0.0)
-- [**`tools/red-team/FLAG1_RUNBOOK.md`**](tools/red-team/FLAG1_RUNBOOK.md) — Pixel 6 Pro on-device validation runbook for the Flag 1 DEX-injection detector
-- [**`samples/lsposed-tester/`**](samples/lsposed-tester/) — real LSPosed module that drives runtime DEX injection against the sample app, used to verify the detector against production attacker tooling rather than just Frida
+- [**`docs/DETECTORS.md`**](docs/DETECTORS.md) — per-detector reference: finding kinds, sample tripped JSON, costs, caveats.
+- [**`NATIVE_INTEGRITY_DESIGN.md`**](NATIVE_INTEGRITY_DESIGN.md) — design of the native anti-hooking stack.
+- [**`CHANGELOG.md`**](CHANGELOG.md) — version history with per-release wire-format impact.
+- [**`SECURITY.md`**](SECURITY.md) — vulnerability disclosure process and supported-versions policy.
## License
diff --git a/docs/DETECTORS.md b/docs/DETECTORS.md
index da7d889..edcde8a 100644
--- a/docs/DETECTORS.md
+++ b/docs/DETECTORS.md
@@ -606,7 +606,7 @@ Its output lives in **two places**, on purpose:
| Finding kind | Severity | Triggered when |
| --------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
-| `tee_integrity_verdict` | varies | The locally derived advisory verdict is degraded (severity > LOW). Severity tracks the verdict's own severity ladder, which combines hardware-backing, verified-boot state, bootloader-locked flag, OS patch age, and app-recognition cross-check. CRITICAL when the chain is software-backed OR app-recognition flagged a mismatch; HIGH when verified-boot is anything other than Verified; MEDIUM for patch-too-old / bootloader-unlocked; LOW (suppressed) otherwise. |
+| `tee_integrity_verdict` | varies | The locally derived advisory verdict is degraded (severity > LOW). Severity tracks the verdict's own severity ladder, which combines hardware-backing, verified-boot state, bootloader-locked flag, OS patch age, and app-recognition cross-check. **CRITICAL** when the chain is software-backed, app-recognition flagged a mismatch, **OR verified-boot is anything other than Verified** — i.e. SelfSigned (yellow), Unverified (orange), or Failed (red). A non-Verified boot means the boot chain was modified (custom AVB key / unlocked / failed verification) and the device is most probably rooted, so it must not pass device integrity. MEDIUM for patch-too-old / bootloader-unlocked on an otherwise genuine (Verified) boot; LOW (suppressed) otherwise. When the boot state is non-Verified the finding's `message` carries the hardware-attested `os_modified_proof` string, and `details` add `verified_boot_key_sha256` (the attested boot-signing key hash — Google's on stock, a custom value otherwise) and `os_modified_proof`. |
| `attestation_eat_format_detected` | low | The leaf cert's KeyDescription extension (OID `1.3.6.1.4.1.11129.2.1.17`) carries **CBOR-EAT** bytes instead of the legacy ASN.1 `KeyDescription` SEQUENCE. KeyMint 200+ on Android 14+ (RKP-provisioned keys) can emit attestation in this format. Library-side parsed fields will be null on those leaves; backends must re-parse the raw chain bytes (`app.attestation.chain_b64`) server-side for full field-level data. Full on-device CBOR/EAT decoding is tracked as a follow-up minor. Heuristic detection only — checks for a CBOR major-type-5 (map) byte (`0xA0`–`0xBF`) at the start of the unwrapped extension content. |
### Sample tripped JSON
@@ -619,16 +619,18 @@ Its output lives in **two places**, on purpose:
"findings": [
{
"kind": "tee_integrity_verdict",
- "severity": "high",
+ "severity": "critical",
"subject": "com.example.app",
- "message": "TEE evidence indicates degraded device or app integrity (advisory; verify chain server-side)",
+ "message": "OS MODIFIED (hardware-attested): bootloader is UNLOCKED (verifiedBootState=Unverified, deviceLocked=false) — boot is not verified and can be arbitrarily replaced => most probably rooted.",
"details": {
"device_recognition": "MEETS_BASIC_INTEGRITY",
"app_recognition": "RECOGNIZED",
"bootloader_locked": "false",
"verified_boot_state": "Unverified",
+ "verified_boot_key_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"verdict_authoritative": "false",
- "reason": "boot_unverified"
+ "reason": "boot_unverified",
+ "os_modified_proof": "OS MODIFIED (hardware-attested): bootloader is UNLOCKED (verifiedBootState=Unverified, deviceLocked=false) — boot is not verified and can be arbitrarily replaced => most probably rooted."
}
}
]