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.
Files changed (44) hide show
  1. package/README.md +138 -11
  2. package/android/src/main/java/com/deviceintegrity/DeviceIntegrityModule.kt +68 -2
  3. package/android/src/main/java/com/deviceintegrity/IntegrityChecks.kt +5 -1
  4. package/android/src/main/java/com/deviceintegrity/TamperChecks.kt +158 -0
  5. package/ios/DIIntegrityChecks.h +7 -1
  6. package/ios/DIIntegrityChecks.m +9 -1
  7. package/ios/DITamperChecks.h +27 -0
  8. package/ios/DITamperChecks.m +304 -0
  9. package/ios/DeviceIntegrity.mm +56 -4
  10. package/jest/index.d.ts +39 -0
  11. package/jest/index.js +100 -0
  12. package/lib/module/NativeDeviceIntegrity.js.map +1 -1
  13. package/lib/module/checkIntegrity.js +2 -0
  14. package/lib/module/checkIntegrity.js.map +1 -1
  15. package/lib/module/checkIntegrity.native.js +83 -6
  16. package/lib/module/checkIntegrity.native.js.map +1 -1
  17. package/lib/module/index.js +1 -0
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/resolveStatus.js +50 -5
  20. package/lib/module/resolveStatus.js.map +1 -1
  21. package/lib/module/types.js +4 -1
  22. package/lib/module/types.js.map +1 -1
  23. package/lib/module/useDeviceIntegrity.js +21 -7
  24. package/lib/module/useDeviceIntegrity.js.map +1 -1
  25. package/lib/typescript/src/NativeDeviceIntegrity.d.ts +2 -2
  26. package/lib/typescript/src/NativeDeviceIntegrity.d.ts.map +1 -1
  27. package/lib/typescript/src/checkIntegrity.d.ts.map +1 -1
  28. package/lib/typescript/src/checkIntegrity.native.d.ts +6 -0
  29. package/lib/typescript/src/checkIntegrity.native.d.ts.map +1 -1
  30. package/lib/typescript/src/index.d.ts +2 -1
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/lib/typescript/src/resolveStatus.d.ts +1 -0
  33. package/lib/typescript/src/resolveStatus.d.ts.map +1 -1
  34. package/lib/typescript/src/types.d.ts +49 -2
  35. package/lib/typescript/src/types.d.ts.map +1 -1
  36. package/lib/typescript/src/useDeviceIntegrity.d.ts.map +1 -1
  37. package/package.json +8 -2
  38. package/src/NativeDeviceIntegrity.ts +5 -2
  39. package/src/checkIntegrity.native.ts +112 -15
  40. package/src/checkIntegrity.ts +2 -0
  41. package/src/index.tsx +5 -0
  42. package/src/resolveStatus.ts +69 -12
  43. package/src/types.ts +76 -2
  44. 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 emulator detection for React Native and Expo.
3
+ Jailbreak, root, hooking, debugger, emulator and opt-in tamper detection for React Native and Expo.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/react-native-device-integrity.svg)](https://www.npmjs.com/package/react-native-device-integrity)
6
6
  [![npm license](https://img.shields.io/npm/l/react-native-device-integrity.svg)](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, and emulator/simulator environments.
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` | When `true`, emulator/simulator signals make `status` `'compromised'`. When `false`, they are reported but do not compromise. |
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` (stable public API) |
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
- `tamper` is reserved for future checks; no current signal uses it. A native category the JS layer doesn't recognise is reported as `environment`, never dropped.
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-emulator signal is present). |
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. **Compromise evidence wins** — any non-emulator signal → `'compromised'`, even if the run was incomplete.
186
- 2. Emulator/simulator signals compromise **only** when `treatEmulatorAsCompromised` is `true`.
187
- 3. If the run did not complete (`completed: false`) and there is no compromising evidence → `'unknown'`.
188
- 4. Otherwise → `'clean'`.
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 report = IntegrityChecks(reactApplicationContext).run()
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
+ }
@@ -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)runWithCompletion:(void (^)(NSDictionary *report))completion;
16
+ + (void)runWithIOSConfig:(nullable NSDictionary *)iosConfig
17
+ completion:(void (^)(NSDictionary *report))completion;
12
18
 
13
19
  @end
14
20
 
@@ -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)runWithCompletion:(void (^)(NSDictionary *report))completion
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