@necessarysdk/react-native 0.0.0-stage → 0.1.6

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 (117) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +2 -0
  4. package/NecessaryReactNative.podspec +23 -0
  5. package/README.md +429 -2
  6. package/android/build.gradle +83 -0
  7. package/android/src/main/AndroidManifest.xml +41 -0
  8. package/android/src/main/cpp/CMakeLists.txt +7 -0
  9. package/android/src/main/cpp/code_integrity.cpp +484 -0
  10. package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
  11. package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
  12. package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
  13. package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
  14. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
  15. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
  16. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
  17. package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
  18. package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
  19. package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
  20. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
  21. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
  22. package/contract/report-1/android-8-ios-5/RULES.md +149 -0
  23. package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
  24. package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
  25. package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
  26. package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
  27. package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
  28. package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
  29. package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
  30. package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
  31. package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
  32. package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
  33. package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
  34. package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
  35. package/doc/integration_guide.md +771 -0
  36. package/doc/measurements.md +774 -0
  37. package/ios/InstallationKeyManager.swift +694 -0
  38. package/ios/IntegrityProbeManager.swift +959 -0
  39. package/ios/NecessaryModules.h +34 -0
  40. package/ios/NecessaryModules.mm +208 -0
  41. package/ios/NecessaryNative.swift +327 -0
  42. package/ios/PrivacyInfo.xcprivacy +14 -0
  43. package/ios/ProbeReads.swift +370 -0
  44. package/lib/client.d.ts +105 -0
  45. package/lib/client.js +176 -0
  46. package/lib/collector.d.ts +18 -0
  47. package/lib/collector.js +49 -0
  48. package/lib/deviceTrust.d.ts +253 -0
  49. package/lib/deviceTrust.js +699 -0
  50. package/lib/errors.d.ts +43 -0
  51. package/lib/errors.js +56 -0
  52. package/lib/index.d.ts +18 -0
  53. package/lib/index.js +34 -0
  54. package/lib/installationKey.d.ts +62 -0
  55. package/lib/installationKey.js +185 -0
  56. package/lib/installationStore.d.ts +49 -0
  57. package/lib/installationStore.js +127 -0
  58. package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
  59. package/lib/native/NativeNecessaryInstallationKey.js +4 -0
  60. package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
  61. package/lib/native/NativeNecessaryIntegrity.js +4 -0
  62. package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
  63. package/lib/native/NativeNecessarySecureScreen.js +4 -0
  64. package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
  65. package/lib/native/NativeNecessaryStepUpKey.js +4 -0
  66. package/lib/native/NativeNecessaryStorage.d.ts +14 -0
  67. package/lib/native/NativeNecessaryStorage.js +4 -0
  68. package/lib/protocol.d.ts +24 -0
  69. package/lib/protocol.js +50 -0
  70. package/lib/reinstallHint.d.ts +23 -0
  71. package/lib/reinstallHint.js +65 -0
  72. package/lib/report.d.ts +62 -0
  73. package/lib/report.js +134 -0
  74. package/lib/requestProof.d.ts +116 -0
  75. package/lib/requestProof.js +220 -0
  76. package/lib/secureScreen.d.ts +15 -0
  77. package/lib/secureScreen.js +32 -0
  78. package/lib/stepUpKey.d.ts +105 -0
  79. package/lib/stepUpKey.js +187 -0
  80. package/lib/transport.d.ts +39 -0
  81. package/lib/transport.js +117 -0
  82. package/lib/util/answers.d.ts +6 -0
  83. package/lib/util/answers.js +16 -0
  84. package/lib/util/bytes.d.ts +18 -0
  85. package/lib/util/bytes.js +207 -0
  86. package/lib/util/native.d.ts +20 -0
  87. package/lib/util/native.js +49 -0
  88. package/lib/util/release.d.ts +2 -0
  89. package/lib/util/release.js +7 -0
  90. package/lib/util/target.d.ts +18 -0
  91. package/lib/util/target.js +128 -0
  92. package/package.json +81 -4
  93. package/src/client.ts +221 -0
  94. package/src/collector.ts +64 -0
  95. package/src/deviceTrust.ts +851 -0
  96. package/src/errors.ts +71 -0
  97. package/src/index.ts +19 -0
  98. package/src/installationKey.ts +223 -0
  99. package/src/installationStore.ts +143 -0
  100. package/src/native/NativeNecessaryInstallationKey.ts +12 -0
  101. package/src/native/NativeNecessaryIntegrity.ts +15 -0
  102. package/src/native/NativeNecessarySecureScreen.ts +9 -0
  103. package/src/native/NativeNecessaryStepUpKey.ts +11 -0
  104. package/src/native/NativeNecessaryStorage.ts +16 -0
  105. package/src/protocol.ts +52 -0
  106. package/src/reinstallHint.ts +69 -0
  107. package/src/report.ts +168 -0
  108. package/src/requestProof.ts +295 -0
  109. package/src/secureScreen.ts +29 -0
  110. package/src/stepUpKey.ts +238 -0
  111. package/src/transport.ts +145 -0
  112. package/src/util/answers.ts +15 -0
  113. package/src/util/bytes.ts +194 -0
  114. package/src/util/native.ts +56 -0
  115. package/src/util/release.ts +6 -0
  116. package/src/util/target.ts +132 -0
  117. package/vectors/client_server.json +264 -0
@@ -0,0 +1,484 @@
1
+ // Native code-integrity probe.
2
+ //
3
+ // Runs IN the app's own process and reads the app's own already-mapped,
4
+ // readable r-x pages by direct pointer. That is the whole reason it is native:
5
+ // an earlier attempt in managed code opened /proc/self/mem, which SELinux denies to
6
+ // untrusted_app on Android 9/10; reading one's own mapped pages needs no such
7
+ // access and no ptrace.
8
+ //
9
+ // For each target library it compares EVERY executable mapping against the same
10
+ // bytes on disk. Iterating every VMA (not just the first) is essential: an
11
+ // inline hooker flips individual code pages writable to patch them, which
12
+ // splits a library's single r-x mapping into several, and the patched page is
13
+ // usually not the first. The dynamic linker does not modify .text at runtime
14
+ // (relocations land in the GOT/data segments), so on a clean device memory and
15
+ // disk are byte-identical; a hook overwrites a prologue and shows up here as a
16
+ // difference, whatever the hooking framework is called.
17
+ //
18
+ // Targets are grouped into three buckets so the server can weight them
19
+ // separately:
20
+ // core - libc, libart. Validated at zero on clean hardware; scored today.
21
+ // ext - other high-value system hook targets, TLS included (cert-pinning
22
+ // bypass patches libssl/libcrypto). Scored once baselined on device.
23
+ // app - the app's own native code (the React Native runtime, its JavaScript
24
+ // engine and the app's own libraries). Catches in-memory patching
25
+ // of the app itself. Scored once baselined.
26
+ //
27
+ // Two rules from the release review of 2026-10-31:
28
+ // - One inventory. /proc/self/maps is read ONCE, whole, and every bucket is
29
+ // taken from that snapshot. It used to be reopened per target name, and a
30
+ // failed reopen counted as "not loaded": a bucket could read complete
31
+ // while a target went unmeasured. A snapshot that cannot be read answers
32
+ // checked false with a reason -- no bucket complete, no byte count made up.
33
+ // Complete means: every target mapping in that snapshot compared.
34
+ // - One comparison at a time, process-wide. A comparison makes execute-only
35
+ // pages readable, reads them and restores them; two at once could read a
36
+ // page the other had just made unreadable again. Every caller -- any
37
+ // module instance, any runtime, any thread -- takes the same lock for the
38
+ // whole snapshot/unlock/read/restore. (It does not make a library that
39
+ // the app itself unloads meanwhile safe to read.)
40
+ //
41
+ // And from the pass over the review's other items, the same day:
42
+ // - The app region is the app's own: the APKs it was installed from (base
43
+ // and splits, by exact path, as Android names them) and the libraries the
44
+ // SDK names. It used to be every mapping ending in ".apk" -- another
45
+ // package's APK-mapped code (WebView, a Play services module) counted as
46
+ // the app's. Whatever executable file of the app's own is mapped but not
47
+ // in the region -- a library another library extracted, the app's own
48
+ // code the platform compiled -- is counted and named (app_outside_count,
49
+ // app_outside_files), so the report says what the region leaves out.
50
+
51
+ #include <jni.h>
52
+ #include <fcntl.h>
53
+ #include <unistd.h>
54
+ #include <string.h>
55
+ #include <stdio.h>
56
+ #include <stdlib.h>
57
+ #include <sys/mman.h>
58
+ #include <algorithm>
59
+ #include <atomic>
60
+ #include <mutex>
61
+ #include <set>
62
+ #include <string>
63
+ #include <vector>
64
+
65
+ namespace {
66
+
67
+ // Bytes compared per bucket at most. Deliberately far above any real target
68
+ // set, so every target mapping is measured IN FULL: the old 4 MiB cap per
69
+ // suffix left the rest of large mappings (libart, and every library mapped
70
+ // straight from the APK, share the ".apk" suffix) unmeasured while reporting clean
71
+ // before. Anything the budget still cuts off is reported
72
+ // as skipped_bytes, never silently dropped.
73
+ constexpr size_t kBucketBudget = 256u << 20;
74
+ constexpr size_t kChunk = 256u << 10; // compared in 256 KiB chunks
75
+
76
+ const char *kCore[] = {"/libc.so", "/libart.so", nullptr};
77
+ const char *kExt[] = {
78
+ "/libc++.so", "/libssl.so", "/libcrypto.so",
79
+ "/libandroid_runtime.so", "/libbinder.so", nullptr,
80
+ };
81
+ // The app's own native code. A normal React Native release APK does NOT extract
82
+ // its native libraries: they are mapped straight out of the (uncompressed) zip,
83
+ // so /proc/self/maps shows them backed by ".../base.apk" rather than
84
+ // ".../lib/arm64/libreactnative.so". The app's own APKs (base and splits) are
85
+ // therefore targets by their exact paths, which the app passes in; these
86
+ // names -- the React Native runtime, its JavaScript engine (Hermes, under
87
+ // either of its names, with its tooling), JSI, fbjni and the app's own native
88
+ // modules -- still cover builds that do extract.
89
+ const char *kAppNamed[] = {
90
+ "/libreactnative.so", "/libhermesvm.so", "/libhermes.so",
91
+ "/libhermestooling.so", "/libjsi.so", "/libfbjni.so", "/libappmodules.so",
92
+ nullptr,
93
+ };
94
+
95
+ // What the app owns: the APKs it was installed from, by exact path, and the
96
+ // directories its own files live in (its install directory, its data
97
+ // directories), each ending in '/'.
98
+ struct AppOwn {
99
+ std::vector<std::string> apks;
100
+ std::vector<std::string> dirs;
101
+ };
102
+
103
+ // One target: a path suffix ("/libc.so") or an exact path (one of the app's
104
+ // APKs), and the name a difference is reported under.
105
+ struct Target {
106
+ std::string match;
107
+ bool exact;
108
+ std::string name;
109
+ };
110
+
111
+ std::vector<Target> named_targets(const char **suffixes) {
112
+ std::vector<Target> targets;
113
+ for (int i = 0; suffixes[i] != nullptr; i++) {
114
+ targets.push_back({suffixes[i], false, suffixes[i] + 1}); // name drops the leading '/'
115
+ }
116
+ return targets;
117
+ }
118
+
119
+ std::string base_name(const std::string &path) {
120
+ size_t slash = path.rfind('/');
121
+ return slash == std::string::npos ? path : path.substr(slash + 1);
122
+ }
123
+
124
+ std::vector<Target> app_targets(const AppOwn &own) {
125
+ std::vector<Target> targets = named_targets(kAppNamed);
126
+ for (const std::string &apk : own.apks) {
127
+ if (!apk.empty()) targets.push_back({apk, true, base_name(apk)});
128
+ }
129
+ return targets;
130
+ }
131
+
132
+ // What one bucket measured. expected = compared + skipped + unreadable, so a
133
+ // bucket that measured less than its targets says so instead of reading clean.
134
+ struct Coverage {
135
+ long expected = 0; // bytes in the executable target mappings found
136
+ long compared = 0; // bytes actually compared against disk
137
+ long skipped = 0; // not compared: the bucket budget ran out
138
+ long unreadable = 0; // not compared: unlockable, unopenable or short read
139
+ long diff = 0; // compared bytes that differ from disk
140
+ int found = 0; // target libraries mapped at all
141
+ int withDiff = 0; // target libraries with at least one differing byte
142
+ };
143
+
144
+ // One executable mapping of the snapshot.
145
+ struct Mapping {
146
+ unsigned long start;
147
+ unsigned long end;
148
+ unsigned long offset;
149
+ char perms[5];
150
+ std::string path;
151
+ };
152
+
153
+ // Every comparison, from any thread of the process, one at a time.
154
+ std::mutex g_inspection;
155
+
156
+ // The memory map (/proc/self/maps; another path only in the host test), read
157
+ // whole into `text`. False when it cannot be read, or a read fails part-way.
158
+ bool read_maps(std::string *text, const char *path = "/proc/self/maps") {
159
+ int fd = open(path, O_RDONLY | O_CLOEXEC);
160
+ if (fd < 0) return false;
161
+ char chunk[16384];
162
+ bool ok = true;
163
+ for (;;) {
164
+ ssize_t got = read(fd, chunk, sizeof(chunk));
165
+ if (got < 0) { ok = false; break; }
166
+ if (got == 0) break;
167
+ text->append(chunk, (size_t) got);
168
+ }
169
+ close(fd);
170
+ return ok;
171
+ }
172
+
173
+ // The executable, file-backed mappings of one snapshot.
174
+ std::vector<Mapping> executable_mappings(const std::string &text) {
175
+ std::vector<Mapping> found;
176
+ size_t at = 0;
177
+ while (at < text.size()) {
178
+ size_t next = text.find('\n', at);
179
+ if (next == std::string::npos) next = text.size();
180
+ std::string line = text.substr(at, next - at);
181
+ at = next + 1;
182
+ Mapping m{};
183
+ int consumed = 0;
184
+ if (sscanf(line.c_str(), "%lx-%lx %4s %lx %*x:%*x %*d %n",
185
+ &m.start, &m.end, m.perms, &m.offset, &consumed) < 4) continue;
186
+ if (consumed <= 0 || m.perms[2] != 'x' || m.end <= m.start) continue;
187
+ m.path = line.substr((size_t) consumed);
188
+ if (m.path.empty()) continue;
189
+ found.push_back(m);
190
+ }
191
+ return found;
192
+ }
193
+
194
+ struct Counters {
195
+ int xomUnlocked = 0; // execute-only mappings made readable for the copy
196
+ int xomUnreadable = 0; // execute-only mappings that could not be
197
+ int restoreFailures = 0; // protections that could not be put back
198
+ };
199
+
200
+ int protection_of(const char *perms) {
201
+ int prot = PROT_NONE;
202
+ if (perms[0] == 'r') prot |= PROT_READ;
203
+ if (perms[1] == 'w') prot |= PROT_WRITE;
204
+ if (perms[2] == 'x') prot |= PROT_EXEC;
205
+ return prot;
206
+ }
207
+
208
+ // Puts a mapping's EXACT original protection back on every exit path. The old
209
+ // loop restored plain PROT_EXEC by hand at each early exit, and changed
210
+ // mappings it then skipped as non-targets.
211
+ class ProtectionGuard {
212
+ public:
213
+ explicit ProtectionGuard(int *failures) : failures_(failures) {}
214
+ ProtectionGuard(const ProtectionGuard &) = delete;
215
+ ProtectionGuard &operator=(const ProtectionGuard &) = delete;
216
+ void arm(void *start, size_t len, int original) {
217
+ start_ = start; len_ = len; original_ = original; armed_ = true;
218
+ }
219
+ ~ProtectionGuard() {
220
+ if (armed_ && mprotect(start_, len_, original_) != 0) (*failures_)++;
221
+ }
222
+ private:
223
+ int *failures_;
224
+ void *start_ = nullptr;
225
+ size_t len_ = 0;
226
+ int original_ = PROT_NONE;
227
+ bool armed_ = false;
228
+ };
229
+
230
+ bool ends_with(const char *text, const char *suffix) {
231
+ size_t len = strlen(text), suffix_len = strlen(suffix);
232
+ return len >= suffix_len && strcmp(text + len - suffix_len, suffix) == 0;
233
+ }
234
+
235
+ // Compare `limit` bytes of one mapping with the file bytes at `offset`.
236
+ // Returns the bytes compared; adds differing bytes to *diff.
237
+ size_t compare_mapping(const unsigned char *mem, int fd, unsigned long offset,
238
+ size_t limit, unsigned char *buf, long *diff) {
239
+ size_t done = 0;
240
+ while (done < limit) {
241
+ size_t want = limit - done < kChunk ? limit - done : kChunk;
242
+ ssize_t got = pread(fd, buf, want, (off_t) (offset + done));
243
+ if (got <= 0) break;
244
+ for (ssize_t i = 0; i < got; i++) if (mem[done + i] != buf[i]) (*diff)++;
245
+ done += (size_t) got;
246
+ if ((size_t) got < want) break; // short read: the rest is unreadable
247
+ }
248
+ return done;
249
+ }
250
+
251
+ bool selects(const Target &t, const std::string &path) {
252
+ return t.exact ? path == t.match : ends_with(path.c_str(), t.match.c_str());
253
+ }
254
+
255
+ // Compare every executable mapping of the snapshot the target selects
256
+ // against disk, adding what was measured to `cov` and marking each mapping
257
+ // selected. Returns whether any mapping matched.
258
+ bool compare_one(const Target &target, const std::vector<Mapping> &mappings,
259
+ Coverage *cov, Counters *counters, unsigned char *buf,
260
+ std::vector<bool> *selected) {
261
+ bool any = false;
262
+ long diffBefore = cov->diff;
263
+ for (size_t index = 0; index < mappings.size(); index++) {
264
+ const Mapping &m = mappings[index];
265
+ // Select the target BEFORE touching any protection: a mapping that is
266
+ // not measured is never modified.
267
+ if (!selects(target, m.path)) continue;
268
+ if ((*selected)[index]) continue; // one mapping, one bucket
269
+ (*selected)[index] = true;
270
+ any = true;
271
+ size_t span = m.end - m.start;
272
+ cov->expected += (long) span;
273
+ size_t used = (size_t) (cov->compared + cov->unreadable);
274
+ size_t room = used < kBucketBudget ? kBucketBudget - used : 0;
275
+ size_t limit = span < room ? span : room;
276
+ cov->skipped += (long) (span - limit);
277
+ if (limit == 0) continue;
278
+
279
+ // Android 10+ maps system libraries EXECUTE-ONLY (--xp); dereferencing
280
+ // such a page segfaults. Add PROT_READ for the copy only; the guard
281
+ // restores the original protection whatever happens next. If adding it
282
+ // is refused, the bytes are unreadable -- counted, never clean.
283
+ ProtectionGuard guard(&counters->restoreFailures);
284
+ if (m.perms[0] != 'r') {
285
+ int original = protection_of(m.perms);
286
+ if (mprotect((void *) m.start, span, original | PROT_READ) != 0) {
287
+ counters->xomUnreadable++;
288
+ cov->unreadable += (long) limit;
289
+ continue;
290
+ }
291
+ guard.arm((void *) m.start, span, original);
292
+ counters->xomUnlocked++;
293
+ }
294
+ int fd = open(m.path.c_str(), O_RDONLY | O_CLOEXEC);
295
+ if (fd < 0) {
296
+ cov->unreadable += (long) limit;
297
+ continue;
298
+ }
299
+ size_t done = compare_mapping((const unsigned char *) m.start, fd, m.offset,
300
+ limit, buf, &cov->diff);
301
+ close(fd);
302
+ cov->compared += (long) done;
303
+ cov->unreadable += (long) (limit - done);
304
+ }
305
+ if (any) {
306
+ cov->found++;
307
+ if (cov->diff > diffBefore) cov->withDiff++;
308
+ }
309
+ return any;
310
+ }
311
+
312
+ // Sum a bucket; append the name of any target that differs to `names`.
313
+ void compare_bucket(const std::vector<Target> &targets, const std::vector<Mapping> &mappings,
314
+ Coverage *cov, Counters *counters, unsigned char *buf,
315
+ std::vector<bool> *selected, std::string *names) {
316
+ for (const Target &target : targets) {
317
+ long before = cov->diff;
318
+ if (!compare_one(target, mappings, cov, counters, buf, selected)) continue;
319
+ if (cov->diff > before && names->size() + target.name.size() + 1 < 480) {
320
+ if (!names->empty()) names->push_back(',');
321
+ names->append(target.name);
322
+ }
323
+ }
324
+ }
325
+
326
+ // The app's own executable files mapped but in no bucket: counted, and named
327
+ // (sorted, at most kOutsideNames), so the app region never reads as all of the
328
+ // app's code.
329
+ constexpr size_t kOutsideNames = 32;
330
+
331
+ std::set<std::string> outside_files(const std::vector<Mapping> &mappings,
332
+ const std::vector<bool> &selected, const AppOwn &own) {
333
+ std::set<std::string> files;
334
+ for (size_t index = 0; index < mappings.size(); index++) {
335
+ if (selected[index]) continue;
336
+ const std::string &path = mappings[index].path;
337
+ for (const std::string &dir : own.dirs) {
338
+ if (!dir.empty() && path.compare(0, dir.size(), dir) == 0) {
339
+ files.insert(path);
340
+ break;
341
+ }
342
+ }
343
+ }
344
+ return files;
345
+ }
346
+
347
+ void append_json_string(std::string *out, const std::string &value) {
348
+ out->push_back('"');
349
+ for (unsigned char c : value) {
350
+ if (c == '"' || c == '\\') {
351
+ out->push_back('\\');
352
+ out->push_back((char) c);
353
+ } else if (c < 0x20) {
354
+ char escaped[8];
355
+ snprintf(escaped, sizeof(escaped), "\\u%04x", c);
356
+ out->append(escaped);
357
+ } else {
358
+ out->push_back((char) c);
359
+ }
360
+ }
361
+ out->push_back('"');
362
+ }
363
+
364
+ bool complete(const Coverage &cov) {
365
+ return cov.expected > 0 && cov.compared == cov.expected;
366
+ }
367
+
368
+ } // namespace
369
+
370
+ #ifdef NECESSARY_HOST_TEST
371
+ // The host test's witness that comparisons never overlap.
372
+ std::atomic<int> g_in_flight{0};
373
+ std::atomic<int> g_max_in_flight{0};
374
+ #endif
375
+
376
+ namespace {
377
+
378
+ // The whole comparison, one at a time in the process: the snapshot, every
379
+ // unlock, read and restore. `maps_path` is /proc/self/maps but in the host test.
380
+ std::string inspect(const char *maps_path, const AppOwn &own) {
381
+ std::lock_guard<std::mutex> one_at_a_time(g_inspection);
382
+ #ifdef NECESSARY_HOST_TEST
383
+ struct InFlight {
384
+ InFlight() {
385
+ int now = ++g_in_flight;
386
+ int seen = g_max_in_flight.load();
387
+ while (now > seen && !g_max_in_flight.compare_exchange_weak(seen, now)) {}
388
+ }
389
+ ~InFlight() { --g_in_flight; }
390
+ } in_flight;
391
+ #endif
392
+ Coverage core, ext, app;
393
+ Counters counters;
394
+ std::string names;
395
+ const char *reason = "";
396
+ std::string snapshot;
397
+ std::vector<Mapping> mappings;
398
+ std::set<std::string> outside;
399
+ if (!read_maps(&snapshot, maps_path)) {
400
+ reason = "maps_unreadable";
401
+ } else if (snapshot.empty()) {
402
+ reason = "maps_empty";
403
+ } else {
404
+ mappings = executable_mappings(snapshot);
405
+ std::vector<bool> selected(mappings.size(), false);
406
+ unsigned char *buf = (unsigned char *) malloc(kChunk);
407
+ if (!buf) return std::string();
408
+ compare_bucket(named_targets(kCore), mappings, &core, &counters, buf, &selected, &names);
409
+ compare_bucket(named_targets(kExt), mappings, &ext, &counters, buf, &selected, &names);
410
+ compare_bucket(app_targets(own), mappings, &app, &counters, buf, &selected, &names);
411
+ free(buf);
412
+ outside = outside_files(mappings, selected, own);
413
+ if (core.found + ext.found + app.found == 0) reason = "no_target_mapped";
414
+ }
415
+
416
+ bool checked = (core.found + ext.found + app.found) > 0;
417
+ char json[2048];
418
+ snprintf(json, sizeof(json),
419
+ "{\"checked\":%s,\"reason\":\"%s\","
420
+ "\"diff_bytes\":%ld," // core diff
421
+ "\"core_compared_bytes\":%ld,\"core_diff_bytes\":%ld,"
422
+ "\"core_expected_bytes\":%ld,\"core_skipped_bytes\":%ld,"
423
+ "\"core_unreadable_bytes\":%ld,\"core_complete\":%s,"
424
+ "\"ext_compared_bytes\":%ld,\"ext_diff_bytes\":%ld,\"ext_libs_diff\":%d,"
425
+ "\"ext_expected_bytes\":%ld,\"ext_skipped_bytes\":%ld,"
426
+ "\"ext_unreadable_bytes\":%ld,\"ext_complete\":%s,"
427
+ "\"app_compared_bytes\":%ld,\"app_diff_bytes\":%ld,\"app_libs_diff\":%d,"
428
+ "\"app_expected_bytes\":%ld,\"app_skipped_bytes\":%ld,"
429
+ "\"app_unreadable_bytes\":%ld,\"app_complete\":%s,"
430
+ "\"xom_regions_unlocked\":%d,\"xom_regions_unreadable\":%d,"
431
+ "\"protect_restore_failures\":%d,\"app_outside_count\":%d,",
432
+ checked ? "true" : "false", checked ? "" : reason,
433
+ core.diff,
434
+ core.compared, core.diff,
435
+ core.expected, core.skipped, core.unreadable, complete(core) ? "true" : "false",
436
+ ext.compared, ext.diff, ext.withDiff,
437
+ ext.expected, ext.skipped, ext.unreadable, complete(ext) ? "true" : "false",
438
+ app.compared, app.diff, app.withDiff,
439
+ app.expected, app.skipped, app.unreadable, complete(app) ? "true" : "false",
440
+ counters.xomUnlocked, counters.xomUnreadable,
441
+ counters.restoreFailures, (int) outside.size());
442
+ std::string out(json);
443
+ out.append("\"app_outside_files\":[");
444
+ std::vector<std::string> listed;
445
+ for (const std::string &path : outside) listed.push_back(base_name(path));
446
+ std::sort(listed.begin(), listed.end());
447
+ listed.erase(std::unique(listed.begin(), listed.end()), listed.end());
448
+ for (size_t i = 0; i < listed.size() && i < kOutsideNames; i++) {
449
+ if (i) out.push_back(',');
450
+ append_json_string(&out, listed[i].substr(0, 128));
451
+ }
452
+ out.append("],\"diffed_libs\":");
453
+ append_json_string(&out, names);
454
+ out.push_back('}');
455
+ return out;
456
+ }
457
+
458
+ std::vector<std::string> strings_of(JNIEnv *env, jobjectArray array) {
459
+ std::vector<std::string> values;
460
+ if (array == nullptr) return values;
461
+ jsize length = env->GetArrayLength(array);
462
+ for (jsize i = 0; i < length; i++) {
463
+ jstring item = (jstring) env->GetObjectArrayElement(array, i);
464
+ if (item == nullptr) continue;
465
+ const char *chars = env->GetStringUTFChars(item, nullptr);
466
+ if (chars != nullptr) {
467
+ values.emplace_back(chars);
468
+ env->ReleaseStringUTFChars(item, chars);
469
+ }
470
+ env->DeleteLocalRef(item);
471
+ }
472
+ return values;
473
+ }
474
+
475
+ } // namespace
476
+
477
+ extern "C" JNIEXPORT jstring JNICALL
478
+ Java_com_necessarysdk_reactnative_IntegrityProbeManager_nativeCodeIntegrity(
479
+ JNIEnv *env, jobject /* this */, jobjectArray app_apks, jobjectArray app_dirs) {
480
+ AppOwn own{strings_of(env, app_apks), strings_of(env, app_dirs)};
481
+ std::string json = inspect("/proc/self/maps", own);
482
+ if (json.empty()) return nullptr;
483
+ return env->NewStringUTF(json.c_str());
484
+ }
@@ -0,0 +1,49 @@
1
+ package com.necessarysdk.reactnative
2
+
3
+ /**
4
+ * What failed, and everything beneath it: a failure and each of its causes,
5
+ * outermost first, as "Class: message < Class: message". Every failure the
6
+ * SDK reports carries it, so a key that reads as unavailable or dead says
7
+ * what the keystore itself answered -- on Android 9 an
8
+ * UnrecoverableKeyException's message is always "Failed to obtain information
9
+ * about key", and only the KeyStoreException beneath it says which failure it
10
+ * was (the release review of 2026-10-31, item 4: "preserve the original cause
11
+ * chain and available error codes").
12
+ *
13
+ * Class names and messages only: never a stack trace, a key or a value of the
14
+ * app's. A pure function, so a JVM test holds it.
15
+ */
16
+ internal object Causes {
17
+ /** How many failures a chain names: deeper ones are the same failure rewrapped. */
18
+ const val MAX_LINKS = 8
19
+
20
+ /**
21
+ * [error] and its causes. [detail] adds what a failure's message leaves
22
+ * out -- AndroidKeyStore's numeric error code -- or null. Empty for no error.
23
+ */
24
+ fun chain(error: Throwable?, detail: (Throwable) -> String? = { null }): String {
25
+ val links = mutableListOf<String>()
26
+ val seen = mutableSetOf<Throwable>()
27
+ var current = error
28
+ while (current != null && links.size < MAX_LINKS && seen.add(current)) {
29
+ val message = current.message?.trim().orEmpty()
30
+ val extra = detail(current)
31
+ links.add(
32
+ current.javaClass.simpleName +
33
+ (if (message.isEmpty()) "" else ": $message") +
34
+ (if (extra.isNullOrEmpty()) "" else " ($extra)"),
35
+ )
36
+ current = current.cause
37
+ }
38
+ if (current != null && current !in seen) {
39
+ links.add("…")
40
+ }
41
+ return links.joinToString(" < ")
42
+ }
43
+
44
+ /** [message], then what failed beneath it when anything did. */
45
+ fun withCauses(message: String, cause: Throwable?, detail: (Throwable) -> String? = { null }): String {
46
+ val beneath = chain(cause, detail)
47
+ return if (beneath.isEmpty()) message else "$message [cause: $beneath]"
48
+ }
49
+ }