react-native-device-integrity 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -11
- package/android/src/main/java/com/deviceintegrity/DeviceIntegrityModule.kt +68 -2
- package/android/src/main/java/com/deviceintegrity/IntegrityChecks.kt +5 -1
- package/android/src/main/java/com/deviceintegrity/TamperChecks.kt +158 -0
- package/ios/DIIntegrityChecks.h +7 -1
- package/ios/DIIntegrityChecks.m +9 -1
- package/ios/DITamperChecks.h +27 -0
- package/ios/DITamperChecks.m +304 -0
- package/ios/DeviceIntegrity.mm +56 -4
- package/jest/index.d.ts +39 -0
- package/jest/index.js +100 -0
- package/lib/module/NativeDeviceIntegrity.js.map +1 -1
- package/lib/module/checkIntegrity.js +2 -0
- package/lib/module/checkIntegrity.js.map +1 -1
- package/lib/module/checkIntegrity.native.js +83 -6
- package/lib/module/checkIntegrity.native.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/resolveStatus.js +50 -5
- package/lib/module/resolveStatus.js.map +1 -1
- package/lib/module/types.js +4 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/useDeviceIntegrity.js +21 -7
- package/lib/module/useDeviceIntegrity.js.map +1 -1
- package/lib/typescript/src/NativeDeviceIntegrity.d.ts +2 -2
- package/lib/typescript/src/NativeDeviceIntegrity.d.ts.map +1 -1
- package/lib/typescript/src/checkIntegrity.d.ts.map +1 -1
- package/lib/typescript/src/checkIntegrity.native.d.ts +6 -0
- package/lib/typescript/src/checkIntegrity.native.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/resolveStatus.d.ts +1 -0
- package/lib/typescript/src/resolveStatus.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +49 -2
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/useDeviceIntegrity.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/NativeDeviceIntegrity.ts +5 -2
- package/src/checkIntegrity.native.ts +112 -15
- package/src/checkIntegrity.ts +2 -0
- package/src/index.tsx +5 -0
- package/src/resolveStatus.ts +69 -12
- package/src/types.ts +76 -2
- package/src/useDeviceIntegrity.ts +22 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# react-native-device-integrity
|
|
2
2
|
|
|
3
|
-
Jailbreak, root, hooking, debugger and
|
|
3
|
+
Jailbreak, root, hooking, debugger, emulator and opt-in tamper detection for React Native and Expo.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/react-native-device-integrity)
|
|
6
6
|
[](https://www.npmjs.com/package/react-native-device-integrity)
|
|
@@ -10,10 +10,34 @@ Jailbreak, root, hooking, debugger and emulator detection for React Native and E
|
|
|
10
10
|
|
|
11
11
|
## What it does / doesn't
|
|
12
12
|
|
|
13
|
-
**Does:** report client-side signals for jailbreak/root, hooking frameworks, attached debuggers,
|
|
13
|
+
**Does:** report client-side signals for jailbreak/root, hooking frameworks, attached debuggers, emulator/simulator environments, and (opt-in) app tampering indicators.
|
|
14
14
|
|
|
15
15
|
**Doesn't:** send telemetry, make network calls, or request permissions. Client-side only — **not** a replacement for commercial RASP/anti-tamper products or server-side attestation (App Attest / Play Integrity).
|
|
16
16
|
|
|
17
|
+
## How it compares
|
|
18
|
+
|
|
19
|
+
A snapshot of popular options, as of September 2026. Check each project for current details.
|
|
20
|
+
|
|
21
|
+
| | **react-native-device-integrity** | [jail-monkey](https://github.com/GantMan/jail-monkey) | [freeRASP](https://github.com/talsec/Free-RASP-ReactNative) | [expo-device](https://docs.expo.dev/versions/latest/sdk/device/) | [react-native-device-info](https://github.com/react-native-device-info/react-native-device-info) |
|
|
22
|
+
| --- | --- | --- | --- | --- | --- |
|
|
23
|
+
| Focus | Device integrity signals | Jailbreak/root + device flags | Full RASP / app shielding | Device info | Device info |
|
|
24
|
+
| Jailbreak / root | ✅ | ✅ | ✅ | Experimental (`isRootedExperimentalAsync`) | — |
|
|
25
|
+
| Hooking frameworks (Frida, Xposed, Substrate) | ✅ iOS + Android | Android: suspicious apps | ✅ | — | — |
|
|
26
|
+
| Debugger attached | ✅ | Debug-build check | ✅ | — | — |
|
|
27
|
+
| Emulator / simulator | ✅ (reported separately) | — | ✅ | `isDevice` | `isEmulator()` |
|
|
28
|
+
| App tampering, unofficial store, screenshots, malware… | Signature, installer (Android); encryption, Team ID (iOS) — opt-in | — | ✅ | — | — |
|
|
29
|
+
| Result model | `clean` / `compromised` / `unknown` + signal ids + reason | Booleans | Per-threat callbacks | Boolean / throws | Boolean |
|
|
30
|
+
| Failed checks | Reported as `unknown`, never `clean` | Not documented | Not documented | Throws | Not documented |
|
|
31
|
+
| Network / telemetry | **None** | None | Security telemetry to Talsec (part of the free plan) | None | None |
|
|
32
|
+
| Licence / cost | MIT, fully open source | MIT | MIT wrapper + proprietary SDK; free up to 100k devices | MIT | MIT |
|
|
33
|
+
| Architecture | New Architecture (TurboModule) | New + old architecture | Not documented | Expo module | Not documented |
|
|
34
|
+
| Expo config plugin | ✅ | — | ✅ | Built in (Expo) | — |
|
|
35
|
+
|
|
36
|
+
**Pick this library** if you want a small, fully open-source, zero-network check with typed, fail-closed results that you can audit end to end.
|
|
37
|
+
**Pick freeRASP** if you need broad RASP coverage (tampering, store, screen capture, malware) and managed reporting, and can accept telemetry and its fair-usage terms.
|
|
38
|
+
**Pick jail-monkey** if you also need its extra device flags (mock location, ADB, developer settings) or old-architecture support.
|
|
39
|
+
None of these replace server-side attestation (App Attest / Play Integrity) for high-value actions.
|
|
40
|
+
|
|
17
41
|
## Requirements
|
|
18
42
|
|
|
19
43
|
- **New Architecture (TurboModule) required** — no old-architecture bridge fallback.
|
|
@@ -80,13 +104,25 @@ Android needs no extra setup — the library manifest `<queries>` list merges au
|
|
|
80
104
|
import {
|
|
81
105
|
checkIntegrity,
|
|
82
106
|
useDeviceIntegrity,
|
|
107
|
+
SIGNAL_IDS,
|
|
83
108
|
} from 'react-native-device-integrity';
|
|
84
109
|
|
|
85
110
|
const result = await checkIntegrity({
|
|
86
111
|
treatEmulatorAsCompromised: false,
|
|
87
112
|
timeoutMs: 10_000,
|
|
113
|
+
ignore: ['debugger_attached'],
|
|
114
|
+
policy: { compromisingCategories: ['jailbreak', 'root', 'hooking', 'tamper'] },
|
|
115
|
+
android: {
|
|
116
|
+
expectedSigningCertificates: ['…sha256 hex…'],
|
|
117
|
+
allowedInstallers: ['com.android.vending'],
|
|
118
|
+
},
|
|
119
|
+
ios: {
|
|
120
|
+
expectedTeamIds: ['ABCDE12345'],
|
|
121
|
+
requireEncryptedBinary: true,
|
|
122
|
+
},
|
|
88
123
|
});
|
|
89
124
|
// result.status: 'clean' | 'compromised' | 'unknown'
|
|
125
|
+
// result.ignored, result.durationMs
|
|
90
126
|
```
|
|
91
127
|
|
|
92
128
|
```tsx
|
|
@@ -121,15 +157,25 @@ Never rejects. Failures map to `status: 'unknown'` with a `reason`.
|
|
|
121
157
|
|
|
122
158
|
| Option | Default | Description |
|
|
123
159
|
| --- | --- | --- |
|
|
124
|
-
| `treatEmulatorAsCompromised` | `false` |
|
|
160
|
+
| `treatEmulatorAsCompromised` | `false` | Shorthand that unions `'emulator'` into the compromising category set. When `false`, emulator/simulator signals are reported but do not compromise (unless listed in `policy`). |
|
|
125
161
|
| `timeoutMs` | `10000` | Resolve as `'unknown'` with reason `'timeout'` if native does not answer in time. |
|
|
162
|
+
| `ignore` | `undefined` | `SignalId[]` — matching signals are removed from `signals`, moved to `result.ignored`, and never affect status. |
|
|
163
|
+
| `policy.compromisingCategories` | every category except `'emulator'` | Categories that make status `'compromised'`. When both `policy` and `treatEmulatorAsCompromised` are set, the sets are **unioned**. An empty list means no signal can make the status `'compromised'`; use it only deliberately. |
|
|
164
|
+
| `android.expectedSigningCertificates` | unset | SHA-256 digests of expected signing certificates (hex; colons/whitespace optional). Check runs only when non-empty. |
|
|
165
|
+
| `android.allowedInstallers` | unset | Allowed installer package names. Check runs only when non-empty. A null/unknown installer (e.g. adb/sideload) counts as untrusted. |
|
|
166
|
+
| `ios.expectedTeamIds` | unset | Expected Apple Team IDs. Check runs only when non-empty. |
|
|
167
|
+
| `ios.requireEncryptedBinary` | unset | When `true`, require the main executable to be encrypted. |
|
|
168
|
+
|
|
169
|
+
Tamper checks run **only** when their option is provided and non-empty (or `true`). Not configured ⇒ the check does not run and does **not** make the report incomplete.
|
|
126
170
|
|
|
127
171
|
#### `IntegrityResult`
|
|
128
172
|
|
|
129
173
|
| Field | Type | Description |
|
|
130
174
|
| --- | --- | --- |
|
|
131
175
|
| `status` | `'clean' \| 'compromised' \| 'unknown'` | Resolved status (see [Result semantics](#result-semantics)). |
|
|
132
|
-
| `signals` | `Signal[]` | Detected signals (may be empty). |
|
|
176
|
+
| `signals` | `Signal[]` | Detected signals after `ignore` filtering (may be empty). |
|
|
177
|
+
| `ignored` | `Signal[]` | Signals removed by `ignore` (always present; possibly empty). |
|
|
178
|
+
| `durationMs` | `number` | Wall time of the check measured in JS (`performance.now()` when available, else `Date.now()`). `0` on unsupported platforms. |
|
|
133
179
|
| `platform` | `Platform.OS` | Platform the check ran on. |
|
|
134
180
|
| `reason?` | `UnknownReason` | Present when `status` is `'unknown'`. |
|
|
135
181
|
| `error?` | `string` | Optional message (e.g. native error text). |
|
|
@@ -138,15 +184,19 @@ Never rejects. Failures map to `status: 'unknown'` with a `reason`.
|
|
|
138
184
|
|
|
139
185
|
| Field | Type |
|
|
140
186
|
| --- | --- |
|
|
141
|
-
| `id` | `string` (
|
|
187
|
+
| `id` | `SignalId \| (string & {})` (autocomplete for known ids; unknown future ids accepted) |
|
|
142
188
|
| `category` | `SignalCategory` |
|
|
143
189
|
| `description` | `string` (generic; never includes matched paths/packages) |
|
|
144
190
|
|
|
191
|
+
#### `SIGNAL_IDS` / `SignalId`
|
|
192
|
+
|
|
193
|
+
`SIGNAL_IDS` is a readonly tuple of every known signal id. `SignalId` is `(typeof SIGNAL_IDS)[number]`.
|
|
194
|
+
|
|
145
195
|
#### `SignalCategory`
|
|
146
196
|
|
|
147
197
|
`'jailbreak'` | `'root'` | `'hooking'` | `'debugger'` | `'emulator'` | `'tamper'` | `'environment'`
|
|
148
198
|
|
|
149
|
-
|
|
199
|
+
A native category the JS layer doesn't recognise is reported as `environment`, never dropped. `tamper` is used by the opt-in tamper checks below.
|
|
150
200
|
|
|
151
201
|
#### `UnknownReason`
|
|
152
202
|
|
|
@@ -156,7 +206,7 @@ Never rejects. Failures map to `status: 'unknown'` with a `reason`.
|
|
|
156
206
|
| `native_module_unavailable` | TurboModule is missing / null (wrong arch, Expo Go, failed link). |
|
|
157
207
|
| `native_error` | Native call threw/rejected, or the native report was malformed. |
|
|
158
208
|
| `timeout` | Native did not answer within `timeoutMs`. |
|
|
159
|
-
| `incomplete` | Native finished with `completed: false` (a check threw; compromise evidence still wins if any non-
|
|
209
|
+
| `incomplete` | Native finished with `completed: false` (a check threw; compromise evidence still wins if any non-ignored compromising signal is present). |
|
|
160
210
|
| `not_implemented` | Native report has `completed: false` and `reason: 'not_implemented'` (stub / unfinished native path). |
|
|
161
211
|
|
|
162
212
|
### `useDeviceIntegrity(options?)`
|
|
@@ -175,6 +225,7 @@ Behaviour:
|
|
|
175
225
|
|
|
176
226
|
- Runs on mount.
|
|
177
227
|
- Re-checks when the app returns to the foreground (`background`/`inactive` → `active`).
|
|
228
|
+
- Re-checks when options change **by value** (stable key) — inline object literals with the same contents do not loop.
|
|
178
229
|
- Latest-request-wins (stale responses are ignored).
|
|
179
230
|
- Keeps the previous `result` while refreshing (`loading` is `true`, `result` is not cleared).
|
|
180
231
|
|
|
@@ -182,10 +233,12 @@ Behaviour:
|
|
|
182
233
|
|
|
183
234
|
Status is resolved from the native report + options:
|
|
184
235
|
|
|
185
|
-
1.
|
|
186
|
-
2.
|
|
187
|
-
3.
|
|
188
|
-
4.
|
|
236
|
+
1. Apply `ignore` — matching signals move to `ignored` and never affect status.
|
|
237
|
+
2. Build the compromising category set: `policy.compromisingCategories` if provided, otherwise every category except `'emulator'`. Union `'emulator'` when `treatEmulatorAsCompromised` is `true`.
|
|
238
|
+
3. **Compromise evidence wins** — any non-ignored signal whose category is in that set → `'compromised'`, even if the run was incomplete.
|
|
239
|
+
4. Non-compromising, non-ignored signals (e.g. emulator by default) stay in `signals` but do not change status.
|
|
240
|
+
5. If the run did not complete (`completed: false`) and there is no compromising evidence → `'unknown'`.
|
|
241
|
+
6. Otherwise → `'clean'`.
|
|
189
242
|
|
|
190
243
|
**`unknown` is never `clean`.** Incomplete, timed-out, missing-module, and unsupported-platform outcomes must not be treated as a safe device.
|
|
191
244
|
|
|
@@ -210,6 +263,51 @@ function IntegrityGate({ children }: { children: React.ReactNode }) {
|
|
|
210
263
|
}
|
|
211
264
|
```
|
|
212
265
|
|
|
266
|
+
## Testing with Jest
|
|
267
|
+
|
|
268
|
+
Ship a CommonJS mock that never loads native code:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
jest.mock(
|
|
272
|
+
'react-native-device-integrity',
|
|
273
|
+
() => require('react-native-device-integrity/jest')
|
|
274
|
+
);
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Helpers:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import {
|
|
281
|
+
checkIntegrity,
|
|
282
|
+
useDeviceIntegrity,
|
|
283
|
+
setMockIntegrityResult,
|
|
284
|
+
resetMockIntegrity,
|
|
285
|
+
SIGNAL_IDS,
|
|
286
|
+
} from 'react-native-device-integrity/jest';
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
resetMockIntegrity();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('blocks compromised devices', async () => {
|
|
293
|
+
setMockIntegrityResult({
|
|
294
|
+
status: 'compromised',
|
|
295
|
+
signals: [
|
|
296
|
+
{
|
|
297
|
+
id: 'jailbreak_files',
|
|
298
|
+
category: 'jailbreak',
|
|
299
|
+
description: 'Unexpected system paths',
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const result = await checkIntegrity();
|
|
305
|
+
expect(result.status).toBe('compromised');
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`setMockIntegrityResult(partial)` merges onto a default clean result (`status: 'clean'`, empty `signals` / `ignored`, `platform: 'ios'`, `durationMs: 0`). `useDeviceIntegrity` returns `{ status, signals, result, loading: false, refresh }` from the current mock result.
|
|
310
|
+
|
|
213
311
|
## Signal reference
|
|
214
312
|
|
|
215
313
|
Descriptions are generic on purpose (no matched paths or package names).
|
|
@@ -226,6 +324,8 @@ Descriptions are generic on purpose (no matched paths or package names).
|
|
|
226
324
|
| `hooking_libraries` | `hooking` | Suspicious dynamic libraries are loaded. |
|
|
227
325
|
| `hooking_dyld_insert` | `hooking` | `DYLD_INSERT_LIBRARIES` is set in the process environment. |
|
|
228
326
|
| `debugger_attached` | `debugger` | A debugger is attached to the process. |
|
|
327
|
+
| `tamper_binary_decrypted` | `tamper` | Main executable encryption (`LC_ENCRYPTION_INFO`) reports decrypted / missing (opt-in). |
|
|
328
|
+
| `tamper_team_id_mismatch` | `tamper` | App Team ID is not in `expectedTeamIds` (opt-in). |
|
|
229
329
|
|
|
230
330
|
### Android
|
|
231
331
|
|
|
@@ -241,6 +341,33 @@ Descriptions are generic on purpose (no matched paths or package names).
|
|
|
241
341
|
| `hooking_frida` | `hooking` | Instrumentation framework artifacts were detected. |
|
|
242
342
|
| `hooking_xposed` | `hooking` | Code-injection framework artifacts were detected. |
|
|
243
343
|
| `debugger_attached` | `debugger` | A debugger is attached to the process. |
|
|
344
|
+
| `tamper_signature_mismatch` | `tamper` | None of the app's signing certificates match `expectedSigningCertificates` (opt-in). |
|
|
345
|
+
| `tamper_untrusted_installer` | `tamper` | Installer package is not in `allowedInstallers` (opt-in; null/unknown counts as untrusted). |
|
|
346
|
+
|
|
347
|
+
### Tamper checks (opt-in)
|
|
348
|
+
|
|
349
|
+
These checks run only when you pass the corresponding options. They do not run by default and do not make an otherwise complete report incomplete.
|
|
350
|
+
|
|
351
|
+
**Android — signing certificates**
|
|
352
|
+
|
|
353
|
+
- Get the SHA-256 of the signing certificate with `keytool -printcert -jarfile app.apk`, or from Play Console → App signing → “App signing key certificate” SHA-256.
|
|
354
|
+
- For Play Store builds, use the **Play App Signing** key digest (not necessarily the upload key).
|
|
355
|
+
- Digests are hex, case-insensitive; colons and whitespace are optional (normalized before compare).
|
|
356
|
+
|
|
357
|
+
**Android — allowed installers**
|
|
358
|
+
|
|
359
|
+
- Typical Play Store value: `com.android.vending`.
|
|
360
|
+
- A null/unknown installer (adb / sideload) is treated as untrusted.
|
|
361
|
+
- **Only enable in release builds** — debug installs via adb will always fail this check.
|
|
362
|
+
|
|
363
|
+
**iOS — Team ID**
|
|
364
|
+
|
|
365
|
+
- Use the Team ID from your [Apple Developer](https://developer.apple.com/account) account (the keychain access-group prefix).
|
|
366
|
+
|
|
367
|
+
**iOS — encrypted binary**
|
|
368
|
+
|
|
369
|
+
- `requireEncryptedBinary: true` is appropriate for **App Store / TestFlight** builds only.
|
|
370
|
+
- **Never** enable for debug, development, or ad-hoc builds (those binaries are typically unencrypted).
|
|
244
371
|
|
|
245
372
|
### Notes
|
|
246
373
|
|
|
@@ -3,6 +3,9 @@ package com.deviceintegrity
|
|
|
3
3
|
import com.facebook.react.bridge.Arguments
|
|
4
4
|
import com.facebook.react.bridge.Promise
|
|
5
5
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.bridge.ReadableArray
|
|
7
|
+
import com.facebook.react.bridge.ReadableMap
|
|
8
|
+
import com.facebook.react.bridge.ReadableType
|
|
6
9
|
import com.facebook.react.bridge.WritableArray
|
|
7
10
|
import com.facebook.react.bridge.WritableMap
|
|
8
11
|
import java.util.concurrent.Executors
|
|
@@ -10,10 +13,11 @@ import java.util.concurrent.Executors
|
|
|
10
13
|
class DeviceIntegrityModule(reactContext: ReactApplicationContext) :
|
|
11
14
|
NativeDeviceIntegritySpec(reactContext) {
|
|
12
15
|
|
|
13
|
-
override fun checkIntegrity(promise: Promise) {
|
|
16
|
+
override fun checkIntegrity(options: ReadableMap, promise: Promise) {
|
|
14
17
|
executor.execute {
|
|
15
18
|
try {
|
|
16
|
-
val
|
|
19
|
+
val androidOptions = parseAndroidOptions(options)
|
|
20
|
+
val report = IntegrityChecks(reactApplicationContext).run(androidOptions)
|
|
17
21
|
promise.resolve(toWritableMap(report))
|
|
18
22
|
} catch (t: Throwable) {
|
|
19
23
|
promise.reject("E_INTEGRITY", t.message ?: "Unexpected integrity check failure", t)
|
|
@@ -28,6 +32,68 @@ class DeviceIntegrityModule(reactContext: ReactApplicationContext) :
|
|
|
28
32
|
Thread(runnable, "DeviceIntegrityChecks").apply { isDaemon = true }
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Defensive parse of `options.android`. Missing keys, nulls, and wrong types
|
|
37
|
+
* are treated as not configured (empty lists).
|
|
38
|
+
*/
|
|
39
|
+
internal fun parseAndroidOptions(options: ReadableMap?): AndroidTamperOptions {
|
|
40
|
+
if (options == null || !options.hasKey("android") || options.isNull("android")) {
|
|
41
|
+
return AndroidTamperOptions()
|
|
42
|
+
}
|
|
43
|
+
if (options.getType("android") != ReadableType.Map) {
|
|
44
|
+
return AndroidTamperOptions()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
val android =
|
|
48
|
+
try {
|
|
49
|
+
options.getMap("android")
|
|
50
|
+
} catch (_: Throwable) {
|
|
51
|
+
null
|
|
52
|
+
} ?: return AndroidTamperOptions()
|
|
53
|
+
|
|
54
|
+
return AndroidTamperOptions(
|
|
55
|
+
expectedSigningCertificates =
|
|
56
|
+
readStringList(android, "expectedSigningCertificates"),
|
|
57
|
+
allowedInstallers = readStringList(android, "allowedInstallers"),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private fun readStringList(map: ReadableMap, key: String): List<String> {
|
|
62
|
+
if (!map.hasKey(key) || map.isNull(key)) {
|
|
63
|
+
return emptyList()
|
|
64
|
+
}
|
|
65
|
+
if (map.getType(key) != ReadableType.Array) {
|
|
66
|
+
return emptyList()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
val array: ReadableArray =
|
|
70
|
+
try {
|
|
71
|
+
map.getArray(key)
|
|
72
|
+
} catch (_: Throwable) {
|
|
73
|
+
null
|
|
74
|
+
} ?: return emptyList()
|
|
75
|
+
|
|
76
|
+
val result = ArrayList<String>(array.size())
|
|
77
|
+
for (i in 0 until array.size()) {
|
|
78
|
+
if (array.isNull(i)) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (array.getType(i) != ReadableType.String) {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
val value =
|
|
85
|
+
try {
|
|
86
|
+
array.getString(i)
|
|
87
|
+
} catch (_: Throwable) {
|
|
88
|
+
null
|
|
89
|
+
}
|
|
90
|
+
if (value != null) {
|
|
91
|
+
result.add(value)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
31
97
|
private fun toWritableMap(report: IntegrityChecks.Report): WritableMap {
|
|
32
98
|
val map = Arguments.createMap()
|
|
33
99
|
map.putBoolean("completed", report.completed)
|
|
@@ -26,7 +26,7 @@ internal class IntegrityChecks(private val context: Context) {
|
|
|
26
26
|
val signals: List<Signal>,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
-
fun run(): Report {
|
|
29
|
+
fun run(androidOptions: AndroidTamperOptions = AndroidTamperOptions()): Report {
|
|
30
30
|
val signals = mutableListOf<Signal>()
|
|
31
31
|
val seen = mutableSetOf<String>()
|
|
32
32
|
var completed = true
|
|
@@ -56,6 +56,10 @@ internal class IntegrityChecks(private val context: Context) {
|
|
|
56
56
|
runCheck { checkHookingXposed() }
|
|
57
57
|
runCheck { checkDebuggerAttached() }
|
|
58
58
|
|
|
59
|
+
val tamper = TamperChecks(context, androidOptions)
|
|
60
|
+
runCheck { tamper.checkSignatureMismatch() }
|
|
61
|
+
runCheck { tamper.checkUntrustedInstaller() }
|
|
62
|
+
|
|
59
63
|
return Report(
|
|
60
64
|
completed = completed,
|
|
61
65
|
reason = if (completed) null else "incomplete",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package com.deviceintegrity
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.content.pm.Signature
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import java.security.MessageDigest
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Android tamper signals (signing certificate + install source).
|
|
11
|
+
*
|
|
12
|
+
* Independent implementation informed by OWASP MASTG
|
|
13
|
+
* (MASTG-TEST-0038-style signature verification / install source checks);
|
|
14
|
+
* no code copied.
|
|
15
|
+
*/
|
|
16
|
+
internal data class AndroidTamperOptions(
|
|
17
|
+
val expectedSigningCertificates: List<String> = emptyList(),
|
|
18
|
+
val allowedInstallers: List<String> = emptyList(),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
internal class TamperChecks(
|
|
22
|
+
private val context: Context,
|
|
23
|
+
private val options: AndroidTamperOptions,
|
|
24
|
+
) {
|
|
25
|
+
|
|
26
|
+
fun checkSignatureMismatch(): IntegrityChecks.Signal? {
|
|
27
|
+
if (options.expectedSigningCertificates.isEmpty()) {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
val expected =
|
|
32
|
+
options.expectedSigningCertificates
|
|
33
|
+
.map { normalizeCertDigest(it) }
|
|
34
|
+
.filter { it.isNotEmpty() }
|
|
35
|
+
.toSet()
|
|
36
|
+
// Configured but no usable digest (e.g. blank values): the check cannot run.
|
|
37
|
+
// Throwing marks the report incomplete instead of silently passing.
|
|
38
|
+
if (expected.isEmpty()) {
|
|
39
|
+
throw IllegalArgumentException("expectedSigningCertificates contains no usable digest")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
val current = currentSigningDigests()
|
|
43
|
+
if (current.any { digest -> digest in expected }) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return IntegrityChecks.Signal(
|
|
48
|
+
id = "tamper_signature_mismatch",
|
|
49
|
+
category = "tamper",
|
|
50
|
+
description = "App signing certificate does not match the expected certificate",
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fun checkUntrustedInstaller(): IntegrityChecks.Signal? {
|
|
55
|
+
if (options.allowedInstallers.isEmpty()) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
val allowed =
|
|
60
|
+
options.allowedInstallers
|
|
61
|
+
.filter { it.isNotEmpty() }
|
|
62
|
+
.toSet()
|
|
63
|
+
// Configured but only blank values: the check cannot run, so report incomplete.
|
|
64
|
+
if (allowed.isEmpty()) {
|
|
65
|
+
throw IllegalArgumentException("allowedInstallers contains no usable package name")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
val installer = installingPackageName()
|
|
69
|
+
|
|
70
|
+
if (!installer.isNullOrEmpty() && installer in allowed) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return IntegrityChecks.Signal(
|
|
75
|
+
id = "tamper_untrusted_installer",
|
|
76
|
+
category = "tamper",
|
|
77
|
+
description = "The app was installed from an untrusted source",
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun installingPackageName(): String? {
|
|
82
|
+
val pm = context.packageManager
|
|
83
|
+
val packageName = context.packageName
|
|
84
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
85
|
+
pm.getInstallSourceInfo(packageName).installingPackageName
|
|
86
|
+
} else {
|
|
87
|
+
@Suppress("DEPRECATION")
|
|
88
|
+
pm.getInstallerPackageName(packageName)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private fun currentSigningDigests(): Set<String> {
|
|
93
|
+
val pm = context.packageManager
|
|
94
|
+
val packageName = context.packageName
|
|
95
|
+
|
|
96
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
97
|
+
val packageInfo =
|
|
98
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
99
|
+
pm.getPackageInfo(
|
|
100
|
+
packageName,
|
|
101
|
+
PackageManager.PackageInfoFlags.of(
|
|
102
|
+
PackageManager.GET_SIGNING_CERTIFICATES.toLong(),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
} else {
|
|
106
|
+
@Suppress("DEPRECATION")
|
|
107
|
+
pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
val signingInfo =
|
|
111
|
+
packageInfo.signingInfo
|
|
112
|
+
?: throw IllegalStateException("signingInfo unavailable")
|
|
113
|
+
|
|
114
|
+
val signatures: Array<Signature> =
|
|
115
|
+
if (signingInfo.hasMultipleSigners()) {
|
|
116
|
+
signingInfo.apkContentsSigners
|
|
117
|
+
?: throw IllegalStateException("apkContentsSigners unavailable")
|
|
118
|
+
} else {
|
|
119
|
+
val history =
|
|
120
|
+
signingInfo.signingCertificateHistory
|
|
121
|
+
?: throw IllegalStateException("signingCertificateHistory unavailable")
|
|
122
|
+
if (history.isEmpty()) {
|
|
123
|
+
throw IllegalStateException("signingCertificateHistory empty")
|
|
124
|
+
}
|
|
125
|
+
// Last entry is the current signing key (supports key rotation).
|
|
126
|
+
arrayOf(history.last())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return signatures.map { sha256Hex(it.toByteArray()) }.toSet()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Suppress("DEPRECATION")
|
|
133
|
+
val packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
|
|
134
|
+
@Suppress("DEPRECATION")
|
|
135
|
+
val signatures =
|
|
136
|
+
packageInfo.signatures
|
|
137
|
+
?: throw IllegalStateException("signatures unavailable")
|
|
138
|
+
if (signatures.isEmpty()) {
|
|
139
|
+
throw IllegalStateException("signatures empty")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return signatures.map { sha256Hex(it.toByteArray()) }.toSet()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
companion object {
|
|
146
|
+
internal fun normalizeCertDigest(value: String): String =
|
|
147
|
+
value.replace(":", "").replace(Regex("\\s+"), "").lowercase()
|
|
148
|
+
|
|
149
|
+
private fun sha256Hex(bytes: ByteArray): String {
|
|
150
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
|
151
|
+
return buildString(digest.size * 2) {
|
|
152
|
+
for (b in digest) {
|
|
153
|
+
append("%02x".format(b))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/ios/DIIntegrityChecks.h
CHANGED
|
@@ -5,10 +5,16 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
5
5
|
/**
|
|
6
6
|
* Internal integrity checks. Runs off the main thread (except URL-scheme
|
|
7
7
|
* queries) and delivers a NativeIntegrityReport-shaped dictionary.
|
|
8
|
+
*
|
|
9
|
+
* @param iosConfig Optional parsed `ios` options. Keys:
|
|
10
|
+
* - expectedTeamIds: NSArray of NSString
|
|
11
|
+
* - requireEncryptedBinary: @YES
|
|
12
|
+
* Nil/empty ⇒ no tamper checks run (and the report is not incomplete).
|
|
8
13
|
*/
|
|
9
14
|
@interface DIIntegrityChecks : NSObject
|
|
10
15
|
|
|
11
|
-
+ (void)
|
|
16
|
+
+ (void)runWithIOSConfig:(nullable NSDictionary *)iosConfig
|
|
17
|
+
completion:(void (^)(NSDictionary *report))completion;
|
|
12
18
|
|
|
13
19
|
@end
|
|
14
20
|
|
package/ios/DIIntegrityChecks.m
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Check set informed by IOSSecuritySuite and the OWASP MASTG; independent implementation.
|
|
2
2
|
|
|
3
3
|
#import "DIIntegrityChecks.h"
|
|
4
|
+
#import "DITamperChecks.h"
|
|
4
5
|
|
|
5
6
|
#import <UIKit/UIKit.h>
|
|
6
7
|
#import <mach-o/dyld.h>
|
|
@@ -54,7 +55,8 @@
|
|
|
54
55
|
return [report copy];
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
+ (void)
|
|
58
|
+
+ (void)runWithIOSConfig:(NSDictionary *)iosConfig
|
|
59
|
+
completion:(void (^)(NSDictionary *report))completion
|
|
58
60
|
{
|
|
59
61
|
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
60
62
|
NSMutableArray<NSDictionary *> *signals = [NSMutableArray array];
|
|
@@ -95,6 +97,12 @@
|
|
|
95
97
|
completed = NO;
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
// --- tamper checks (optional; skipped on Simulator by design) ---
|
|
101
|
+
[DITamperChecks runWithConfig:iosConfig
|
|
102
|
+
signals:signals
|
|
103
|
+
seenIds:seenIds
|
|
104
|
+
completed:&completed];
|
|
105
|
+
|
|
98
106
|
#if TARGET_OS_SIMULATOR
|
|
99
107
|
// Simulator: host Mac filesystem and Xcode-injected DYLD_INSERT_LIBRARIES
|
|
100
108
|
// make jailbreak / dyld-insert checks meaningless — skip by design.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Independent implementation informed by OWASP MASTG (MASVS-RESILIENCE-2
|
|
7
|
+
* app integrity); no code copied.
|
|
8
|
+
*
|
|
9
|
+
* Optional tamper checks driven by the `ios` options object. A check runs
|
|
10
|
+
* only when its option is configured; unconfigured checks are skipped and
|
|
11
|
+
* do not mark the report incomplete. On the Simulator, configured checks
|
|
12
|
+
* are skipped by design (not incomplete).
|
|
13
|
+
*
|
|
14
|
+
* Config keys (all optional):
|
|
15
|
+
* - expectedTeamIds: NSArray of NSString (non-empty ⇒ run team-id check)
|
|
16
|
+
* - requireEncryptedBinary: @YES ⇒ run binary-encryption check
|
|
17
|
+
*/
|
|
18
|
+
@interface DITamperChecks : NSObject
|
|
19
|
+
|
|
20
|
+
+ (void)runWithConfig:(nullable NSDictionary *)config
|
|
21
|
+
signals:(NSMutableArray<NSDictionary *> *)signals
|
|
22
|
+
seenIds:(NSMutableSet<NSString *> *)seenIds
|
|
23
|
+
completed:(BOOL *)completed;
|
|
24
|
+
|
|
25
|
+
@end
|
|
26
|
+
|
|
27
|
+
NS_ASSUME_NONNULL_END
|