@necessarysdk/react-native 0.0.0-stage → 0.1.5
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/CHANGELOG.md +22 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1152 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +323 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +314 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +762 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +960 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +353 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +690 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +140 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +842 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +174 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
package/README.md
CHANGED
|
@@ -1,3 +1,430 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Necessary for React Native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Device integrity for React Native apps on Android and iOS. The SDK gives your app a key it never
|
|
4
|
+
exports — made in the phone's keystore, in secure hardware where the phone has it and in software where
|
|
5
|
+
it does not — signed measurements of the phone and of the app running on it, and a proof on each request
|
|
6
|
+
your app sends — and, if you turn them on, a second key the person must unlock to approve a request, and
|
|
7
|
+
a PIN your backend checks. **It never scores and never decides: your backend does.**
|
|
8
|
+
|
|
9
|
+
Android 7.0 (API 24) and later; iOS 15 and later; React Native 0.76 or later with the New Architecture
|
|
10
|
+
(the default since 0.76, the only one since 0.82). It is built and tested with React Native 0.88, which
|
|
11
|
+
the example app runs.
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
- [The integration guide](https://github.com/merajhasan88/necessary_reactnative/blob/main/doc/integration_guide.md)
|
|
16
|
+
(`doc/integration_guide.md`) — the protocol: what the SDK sends, and how your backend checks it.
|
|
17
|
+
- [The measurements](https://github.com/merajhasan88/necessary_reactnative/blob/main/doc/measurements.md)
|
|
18
|
+
(`doc/measurements.md`) — everything the SDK measures on the phone, field by field: what each
|
|
19
|
+
measurement is, how it is taken, and what a complete answer holds.
|
|
20
|
+
- [The measurement contract](https://github.com/merajhasan88/necessary_reactnative/tree/main/contract/report-1/android-8-ios-5)
|
|
21
|
+
(`contract/report-1/android-8-ios-5/`) — the report's schema, its rules and sample reports. The
|
|
22
|
+
folder's name is two versions: `report-1` is the report's format, and `android-8-ios-5` the lowest
|
|
23
|
+
versions of the measuring code, the collector, that the contract accepts — 8 on Android and 5 on iOS
|
|
24
|
+
(`collector_version`; this SDK's are 10 and 8). They are not the Android or iOS versions the SDK runs
|
|
25
|
+
on, which are above.
|
|
26
|
+
- [The example app](https://github.com/merajhasan88/necessary_reactnative/tree/main/example)
|
|
27
|
+
(`example/`) puts every piece below in a working app, against the
|
|
28
|
+
[sample backends](https://github.com/merajhasan88/necessary_reactnative/tree/main/backend)
|
|
29
|
+
(in the repository, not the package). [Try the example app](#try-the-example-app), below, installs it
|
|
30
|
+
and tests it step by step, measurement by measurement.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install @necessarysdk/react-native
|
|
36
|
+
cd ios && pod install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The SDK's native modules link themselves (autolinking); rebuild the app after installing. The SDK
|
|
40
|
+
declares what its own features need. Your app adds only what you choose to use:
|
|
41
|
+
|
|
42
|
+
- **Location measurements**: on Android `ACCESS_COARSE_LOCATION` (and `ACCESS_FINE_LOCATION` for a
|
|
43
|
+
precise location) in your manifest; on iOS `NSLocationWhenInUseUsageDescription` in `Info.plist`.
|
|
44
|
+
The SDK never asks by itself: call `trust.requestLocationPermission()` from your own screen.
|
|
45
|
+
- **Biometric step-up on iOS**: `NSFaceIDUsageDescription` in `Info.plist`.
|
|
46
|
+
|
|
47
|
+
## Start
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { DeviceTrust, PersonalMeasurement } from '@necessarysdk/react-native';
|
|
51
|
+
|
|
52
|
+
const trust = new DeviceTrust({
|
|
53
|
+
baseUrl: 'https://api.example.com',
|
|
54
|
+
// The personal measurements your app allows; your backend asks for what it needs of them.
|
|
55
|
+
personalMeasurements: [PersonalMeasurement.screenCapture],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await trust.start(); // loads or makes the key, registers it, proves it holds it
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`start()` makes the guide's first three calls under `baseUrl`: register the installation, ask for a key
|
|
62
|
+
challenge, answer it. Your backend answers each as the guide says. A new key is a new installation:
|
|
63
|
+
`trust.identity?.createdThisLaunch` tells your app to forget what it kept for the old one.
|
|
64
|
+
|
|
65
|
+
## Scan
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const scan = await trust.scan();
|
|
69
|
+
const answer = scan.answer; // your backend's answer, whole
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The SDK measures what your backend's challenge asks for — and, of the personal measurements it asks
|
|
73
|
+
for, the ones your app enabled — signs the report with the installation key and sends it. It acts on the
|
|
74
|
+
guide's protocol errors once (a challenge expired, the installation session expired, the backend lost
|
|
75
|
+
the installation, a short wait); any other refusal reaches your app as an `ApiException`.
|
|
76
|
+
|
|
77
|
+
## Personal measurements
|
|
78
|
+
|
|
79
|
+
Seven measurements identify a person's phone, network or habits. Each is collected only when your app
|
|
80
|
+
enables it **and** your backend's challenge asks for it (`optional_probes`); otherwise the report says
|
|
81
|
+
`not_enabled`. None is on unless your app turns it on:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
trust.personalMeasurements = new Set([
|
|
85
|
+
PersonalMeasurement.screenCapture, // the screen recorded, mirrored or cast
|
|
86
|
+
PersonalMeasurement.location, // the last location the phone already knows
|
|
87
|
+
]);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Set them in the constructor, or change them whenever the person does — in a privacy setting: the next
|
|
91
|
+
scan follows, and the installation and its step-up key stay as they are.
|
|
92
|
+
|
|
93
|
+
| `PersonalMeasurement` | What it reports | Platforms |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `localAddresses` | the phone's own IP addresses, link-local ones left out | both |
|
|
96
|
+
| `accessibility` | the Accessibility services that are on, and the keyboard in use | Android |
|
|
97
|
+
| `notificationAccess` | the apps allowed to read notifications | Android |
|
|
98
|
+
| `otherAccess` | device-administrator apps, and known remote-control apps installed | Android |
|
|
99
|
+
| `screenCapture` | the screen being recorded, mirrored or cast | both |
|
|
100
|
+
| `location` | the last location the phone already knows — never a fresh fix, never a prompt | both |
|
|
101
|
+
| `mockLocation` | whether that location was faked | both |
|
|
102
|
+
|
|
103
|
+
The location needs the person's permission as well: declare it (Install, above) and ask from your own
|
|
104
|
+
screen with `await trust.requestLocationPermission()`, which answers `precise`, `approximate` or
|
|
105
|
+
`none`. On iOS the three Android-only measurements answer `unsupported`.
|
|
106
|
+
|
|
107
|
+
## Prove your own requests
|
|
108
|
+
|
|
109
|
+
Every request your app sends to your own API can carry a proof that this installation's key signed it,
|
|
110
|
+
so a copied session token is useless on another phone:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const body = JSON.stringify({ amount: 25, to: 'payee-7' });
|
|
114
|
+
const proof = await trust.signRequest({
|
|
115
|
+
method: 'POST',
|
|
116
|
+
path: '/v1/transfer',
|
|
117
|
+
bearer: accountSession, // your backend's session for this installation
|
|
118
|
+
body, // sent exactly as signed
|
|
119
|
+
});
|
|
120
|
+
await fetch('https://api.example.com/v1/transfer', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
Authorization: `Bearer ${accountSession}`,
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
...proof.headers, // X-Access-Proof, X-Access-Signature
|
|
126
|
+
},
|
|
127
|
+
body,
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Each proof serves its request once. `trust.send(proof)` sends it through the SDK's transport instead.
|
|
132
|
+
|
|
133
|
+
## A scan for one action
|
|
134
|
+
|
|
135
|
+
For a request that matters — a payment, a new payee — have the scan name it, under the account session
|
|
136
|
+
that will send it, and send the scan's report with the request:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { describeOperation } from '@necessarysdk/react-native';
|
|
140
|
+
|
|
141
|
+
const operation = describeOperation('POST', '/v1/transfer', body);
|
|
142
|
+
const scan = await trust.scan({ session: accountSession, operation });
|
|
143
|
+
const proof = await trust.signRequest({
|
|
144
|
+
method: 'POST',
|
|
145
|
+
path: '/v1/transfer',
|
|
146
|
+
bearer: accountSession,
|
|
147
|
+
body,
|
|
148
|
+
reportId: scan.reportId, // X-Integrity-Report
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Step-up: the person approves one request
|
|
153
|
+
|
|
154
|
+
Turn it on, and choose how the key is unlocked:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { DeviceTrust, StepUpOptions } from '@necessarysdk/react-native';
|
|
158
|
+
|
|
159
|
+
const trust = new DeviceTrust({
|
|
160
|
+
baseUrl: 'https://api.example.com',
|
|
161
|
+
stepUp: new StepUpOptions({ factor: 'passcode', mode: 'per_use' }),
|
|
162
|
+
});
|
|
163
|
+
await trust.start(); // registers the step-up key with the installation
|
|
164
|
+
|
|
165
|
+
if (trust.stepUpUnavailable !== null) {
|
|
166
|
+
// This phone cannot make the key -- no screen lock, say. Step-up is off here.
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const proof = await trust.signRequest({
|
|
170
|
+
method: 'POST',
|
|
171
|
+
path: '/v1/transfer',
|
|
172
|
+
bearer: accountSession,
|
|
173
|
+
body,
|
|
174
|
+
stepUp: true,
|
|
175
|
+
reason: 'Approve the transfer of 25', // what the phone shows the person
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| Option | Values |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `factor` | `passcode` (the phone's PIN, pattern or password) or `biometric` (a fingerprint or face enrolled now) |
|
|
182
|
+
| `mode` | `per_use` (unlocked for every signature) or `windowed` with `windowSeconds` (one unlock for a while; Android only) |
|
|
183
|
+
|
|
184
|
+
Android 9 and 10 cannot ask for the passcode at every signature: there a passcode key is windowed, and
|
|
185
|
+
says so to your backend. iOS makes per-use keys only. An app whose backend sets the policy assigns
|
|
186
|
+
`trust.stepUp` before `start()`.
|
|
187
|
+
|
|
188
|
+
Removing the screen lock, or enrolling another biometric for a biometric key, makes the key unusable:
|
|
189
|
+
an approval then fails with `NativeKeyException` `STEPUP_KEY_INVALIDATED`. On Android the SDK deletes
|
|
190
|
+
the key only when Android itself says so; when the keystore cannot establish whether the key is usable,
|
|
191
|
+
or cannot be reached at all — a failure it may recover from — the approval fails with
|
|
192
|
+
`STEPUP_KEY_UNAVAILABLE` and the key is kept: try again later. Android 9 does not say that a key the
|
|
193
|
+
screen lock killed is dead, so there such a key answers `STEPUP_KEY_UNAVAILABLE`. The person re-enrols a
|
|
194
|
+
new key with their own re-authentication, which also replaces a key that stays unavailable:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
await trust.reenrolStepUp({
|
|
198
|
+
session: accountSession,
|
|
199
|
+
reauthentication: { password }, // as your backend requires
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A new key is made, the person unlocks it to sign its proof, and your backend takes it for that account
|
|
204
|
+
alone; approvals use it from then on. The call goes to `/v1/installations/stepup-key`, or to your API's
|
|
205
|
+
own path: `new StepUpOptions({ reenrolPath: ... })`. Until your backend has taken the new key — the
|
|
206
|
+
person cancelled the prompt, the password was wrong, the answer was lost — approvals fail with
|
|
207
|
+
`STEPUP_REENROL_PENDING`, across restarts too: call `reenrolStepUp` again. The same holds for a key the
|
|
208
|
+
phone made after the installation was registered — the screen lock was off at the first `start()` and
|
|
209
|
+
set later, say — since your backend takes a step-up key only at an installation's first registration:
|
|
210
|
+
`start()` reports `stepUpUnavailable` `STEPUP_REENROL_PENDING`, and re-enrolment registers the key.
|
|
211
|
+
|
|
212
|
+
## The app PIN: the person's PIN for one request
|
|
213
|
+
|
|
214
|
+
Turn it on; the two paths default to the guide's and can be your API's own:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { AppPinOptions, DeviceTrust } from '@necessarysdk/react-native';
|
|
218
|
+
|
|
219
|
+
const trust = new DeviceTrust({
|
|
220
|
+
baseUrl: 'https://api.example.com',
|
|
221
|
+
appPin: new AppPinOptions(), // path: '/v1/account/app-pin', proofPath: '/v1/account/app-pin/proof'
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Setting it: with the person's own re-authentication, as your backend requires.
|
|
225
|
+
await trust.setAppPin({ session: accountSession, pin, reauthentication: { password } });
|
|
226
|
+
|
|
227
|
+
// Using it: your backend checks the PIN for this one request, and the request carries its proof.
|
|
228
|
+
const proof = await trust.signRequest({
|
|
229
|
+
method: 'POST',
|
|
230
|
+
path: '/v1/transfer',
|
|
231
|
+
bearer: accountSession,
|
|
232
|
+
body,
|
|
233
|
+
appPin: pin,
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The PIN travels once, in a signed body, never in a header, and the SDK never stores it. Protect the
|
|
238
|
+
screen while it is typed:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { SecureScreen } from '@necessarysdk/react-native';
|
|
242
|
+
|
|
243
|
+
await SecureScreen.protect(true); // Android: no screenshots or recording; iOS: covered while captured
|
|
244
|
+
// ... the PIN is typed ...
|
|
245
|
+
await SecureScreen.protect(false);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Calls nest — each `protect(true)` needs its `protect(false)` — and when the last one ends the window gets
|
|
249
|
+
back what it had: a window your app keeps secure itself stays secure. On iOS the cover is over your app's
|
|
250
|
+
window, not the system keyboard. Opening Control Center to start a recording closes the keyboard, so
|
|
251
|
+
protect whenever the field has focus, as the example does.
|
|
252
|
+
|
|
253
|
+
Your backend's refusals reach your app as `ApiException` with the guide's codes: `app_pin_not_set`,
|
|
254
|
+
`app_pin_invalid` (it counts), `app_pin_locked` (a new PIN, set with the re-authentication, unlocks it).
|
|
255
|
+
Step-up and the app PIN combine: your backend decides which requests need which.
|
|
256
|
+
|
|
257
|
+
## What happens when
|
|
258
|
+
|
|
259
|
+
| When | What the SDK does |
|
|
260
|
+
|---|---|
|
|
261
|
+
| The key is lost — an Android reinstall, a cleared keystore | `start()` makes a new key and a new installation ID; `trust.identity?.createdThisLaunch` is true, so forget what you kept for the old installation. Your backend links the two only provisionally, by the reinstall hint |
|
|
262
|
+
| Android's keystore cannot be reached | its lookups answer "no such key" then too, so the SDK makes a key only where the keystore, answering, holds none: `start()` fails with `NativeKeyException` `KEY_UNAVAILABLE`, sends nothing and keeps the installation as it is — call it again later; if it persists, `reset()` starts a new installation. Signing answers the same, and a step-up key `STEPUP_KEY_UNAVAILABLE` |
|
|
263
|
+
| iOS keeps the key across a reinstall | with its ID, the same installation comes back; without it, the SDK proposes a new ID, your backend answers with the one it holds for the key, and the SDK keeps that one |
|
|
264
|
+
| The app's process restarts | nothing is kept in memory: `start()` again finds the same key and installation, and proves it for a new installation session |
|
|
265
|
+
| The activity, a scene or a window is replaced | screen protection follows: Android sets it on each new window; on iOS the cover is placed again, on the window the person sees in each scene, whenever a window becomes key, visible or hidden or a scene comes to the foreground — and protection asked for before any window exists covers the first one. A step-up prompt the activity's end cancelled answers `STEPUP_CANCELLED`: ask again |
|
|
266
|
+
| Two calls at once | `start()`, `prove()`, `loadIdentity()` and the same `scan()` run once, and the second caller gets the first's result; scans for different operations, or asked after the personal measurements changed, run apart |
|
|
267
|
+
| A step-up prompt is open | on iOS the SDK's other calls — a scan, a request's signature — wait until the person answers it; Android runs them meanwhile |
|
|
268
|
+
| `reset()` or `close()` while calls run | the calls end with `InterruptedCallException` and keep nothing — no session, no installation ID, no report sent, no request proof or app PIN answer handed out; a reset first waits for every write and key change still running, so none outlives it; after a reset, new calls wait for the deletion and never join one made before. A proof handed out before the reset, or a request already sent, is beyond it: reset is local |
|
|
269
|
+
| A key cannot be deleted | `reset()` fails with that `NativeKeyException` (`KEY_DELETE_FAILED`): call it again. If it is the installation key, the installation stays as it was, its ID with it; if it is the step-up key, the installation itself is already gone. None to delete, or no step-up on the phone, is no failure |
|
|
270
|
+
| A native call fails on Android | the `NativeKeyException`'s message says what failed, then everything beneath it — `[cause: …]`, each failure's kind and words down to the keystore's own answer, and from Android 13 the keystore's numeric code wherever Android passes it on — so a key that reads as unavailable or dead says what the keystore answered. A probe's `error` and each `unread` reason carry the same, within the contract's lengths |
|
|
271
|
+
| A call abandoned, or its answer lost | a scan is simply asked for again — a challenge serves once; the SDK never sends a signed request twice: sign it again, and let your backend decide whether the first one reached it |
|
|
272
|
+
| A report over 65,536 bytes | it is never sent: the app lists the contract lets a collector shorten are shortened, and say `truncated`; anything else answers an `ApiException` `report_too_large` |
|
|
273
|
+
| The stored installation ID cannot be read | `start()` fails with `NativeKeyException` `STORAGE_UNREADABLE` and makes nothing new over it: call it again; `reset()` starts a new installation |
|
|
274
|
+
|
|
275
|
+
## Your own transport
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const trust = DeviceTrust.over(new MyTransport()); // implements DeviceTrustTransport
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
A transport sends each message the SDK builds and answers your backend's JSON object, or throws an
|
|
282
|
+
`ApiException` with its `code`, `message` and `details`. To carry the messages over an API of your own
|
|
283
|
+
shape instead, use the pieces directly — `trust.client`, `collectReport`, `signRequest` — and make the
|
|
284
|
+
guide's checks in your backend.
|
|
285
|
+
|
|
286
|
+
## What the SDK never does
|
|
287
|
+
|
|
288
|
+
It never scores, decides, or tells your app a score; never stores your account session; never sends the
|
|
289
|
+
PIN in a header; and uses no platform attestation (Play Integrity, SafetyNet, App Attest, DeviceCheck).
|
|
290
|
+
It never exports a private key, but it cannot prove to your backend where a key is kept: the
|
|
291
|
+
registration's `key_security` is the phone's own word, and where the secure hardware declines — the iOS
|
|
292
|
+
Simulator, some configurations — the key is a software key, which a compromised phone can copy. A fully
|
|
293
|
+
compromised phone can falsify what it measures and can use a genuine key to sign what it likes: the
|
|
294
|
+
measurements raise the cost of fraud, and your backend weighs them with its own business logic.
|
|
295
|
+
|
|
296
|
+
## Try the example app
|
|
297
|
+
|
|
298
|
+
The example app (`example/`) puts every piece above in a working app, against a sample backend.
|
|
299
|
+
Its own README carries the same steps.
|
|
300
|
+
|
|
301
|
+
### Install it
|
|
302
|
+
|
|
303
|
+
The sample backends live in the repository, not in the package, so start from a clone.
|
|
304
|
+
|
|
305
|
+
1. Clone the repository:
|
|
306
|
+
|
|
307
|
+
```sh
|
|
308
|
+
git clone https://github.com/merajhasan88/necessary_reactnative.git
|
|
309
|
+
cd necessary_reactnative
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
2. Start a sample backend on your computer. It answers on `127.0.0.1` only. In Python 3.10 or later:
|
|
313
|
+
|
|
314
|
+
```sh
|
|
315
|
+
pip install --require-hashes -r backend/python/requirements.txt
|
|
316
|
+
python3 backend/python/server.py --port 8787 --schema contract/report-1/android-8-ios-5/report.schema.json
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The TypeScript and C# samples run the same way; their commands are in
|
|
320
|
+
[`backend/README.md`](https://github.com/merajhasan88/necessary_reactnative/blob/main/backend/README.md).
|
|
321
|
+
3. Install the example's packages (Node.js 22 or later), from `example/`:
|
|
322
|
+
|
|
323
|
+
```sh
|
|
324
|
+
cd example
|
|
325
|
+
npm ci
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
4. Name the backend in `example/src/backends.json`, then run the app:
|
|
329
|
+
- **On an Android emulator**, a debug build reaches your computer at `10.0.2.2`:
|
|
330
|
+
`{"Sample": "http://10.0.2.2:8787"}`, then `npx react-native run-android`.
|
|
331
|
+
- **On an Android phone** connected by USB with USB debugging on, forward the port first:
|
|
332
|
+
`adb reverse tcp:8787 tcp:8787`, name `{"Sample": "http://127.0.0.1:8787"}`, then
|
|
333
|
+
`npx react-native run-android`.
|
|
334
|
+
- **A release build**, on Android or iOS, talks only HTTPS: the SDK refuses an `http://` address in
|
|
335
|
+
a release build. Put the sample behind an HTTPS proxy on a name the phone can resolve, with a
|
|
336
|
+
certificate the phone trusts, and name that address:
|
|
337
|
+
`{"Sample": "https://backend.example.com"}`, then
|
|
338
|
+
`npx react-native run-android --mode release`.
|
|
339
|
+
- **On an iPhone**, run `pod install` in `example/ios`, open `example/ios/NecessaryExample.xcworkspace`
|
|
340
|
+
in Xcode on a Mac, choose your team under Signing & Capabilities, and run with the iPhone
|
|
341
|
+
connected. An iPhone reaches your computer over your network, so name the address of the HTTPS
|
|
342
|
+
proxy.
|
|
343
|
+
|
|
344
|
+
To offer several backends, name each: `{"Python": "https://…", "TypeScript": "https://…"}`. A debug
|
|
345
|
+
build measures itself as one — `debuggable` in `app_identity` — so judge the measurements on a release
|
|
346
|
+
build.
|
|
347
|
+
|
|
348
|
+
### Test it on a phone, step by step
|
|
349
|
+
|
|
350
|
+
With several backends, tap one at the top and run the steps once for each. Every result line carries a
|
|
351
|
+
✓ when the backend did what it should, a red ! when it did not.
|
|
352
|
+
|
|
353
|
+
1. **Installation → Start.** Expect: `Installation …, proved; step-up on.` On a phone without a screen
|
|
354
|
+
lock, step-up is off: `step-up off here (…)`, and step 7 cannot pass.
|
|
355
|
+
2. **Measurements:** switch on **All of them**, tap **Ask for the location permission** and allow it.
|
|
356
|
+
Once is enough; the switches reset when the app restarts.
|
|
357
|
+
3. **Scan.** Expect: `The backend answered next: allow, report …` — the sample backends answer
|
|
358
|
+
`scan_again` when a measurement could not be read, and `review` when its figures contradict each
|
|
359
|
+
other. Under **Hooks, gadgets and memory changes** every row reads `measured`, none `not asked`. Open
|
|
360
|
+
any row for the fields the phone sent.
|
|
361
|
+
4. **Account → Sign in.** Expect: `An account session for this installation.`
|
|
362
|
+
5. **Requests → Protected request.** Expect: ✓ `{"done":"/v1/demo/echo"}`.
|
|
363
|
+
6. **Sensitive, without approval.** Expect: ✓ `Refused, as it should be: 403 needs_approval`.
|
|
364
|
+
7. **Sensitive, with step-up**, then unlock with the phone's passcode. Expect: ✓
|
|
365
|
+
`{"done":"/v1/demo/sensitive"}`.
|
|
366
|
+
8. Type 6 digits in **App PIN**, then **Set the app PIN**. Expect: ✓ `Set, with the account password.`
|
|
367
|
+
9. **Sensitive, with the app PIN.** Expect: ✓ `{"done":"/v1/demo/sensitive"}`.
|
|
368
|
+
10. **Requests a backend must refuse:** tap each of the four. Expect a ✓ on each: `Refused, as it should
|
|
369
|
+
be: 401 proof_reused` for the request sent twice, `401 proof_not_accepted` for the other three.
|
|
370
|
+
|
|
371
|
+
If the phone's screen lock is removed and set again, the step-up key dies: step 7 then shows `The phone
|
|
372
|
+
said STEPUP_KEY_INVALIDATED` (on Android 9, `STEPUP_KEY_UNAVAILABLE`). Tap **Re-enrol the step-up key**
|
|
373
|
+
and unlock with the passcode — expect ✓ `{"stepup_key_registered":true}` — and step 7 passes again. The
|
|
374
|
+
new key is that account's alone. If the lock was off at step 1 and set afterwards, **Start again** says
|
|
375
|
+
`step-up off here (STEPUP_REENROL_PENDING)`: the backend took the installation without a step-up key, so
|
|
376
|
+
re-enrol the same way after step 4.
|
|
377
|
+
|
|
378
|
+
The app PIN belongs to the account, not to the phone. Phones signed in with the same handle share it: a
|
|
379
|
+
PIN set on one unlocks requests on the other, and before any is set, step 9 answers `app_pin_not_set`.
|
|
380
|
+
Sign in with another handle for an account of its own.
|
|
381
|
+
|
|
382
|
+
### A session copied to another phone
|
|
383
|
+
|
|
384
|
+
A session token copied off one phone must be useless on another. You need two phones running the
|
|
385
|
+
example against the same backend.
|
|
386
|
+
|
|
387
|
+
1. **Phone A:** steps 1 and 4 above, then **Requests a backend must refuse → Share this phone's
|
|
388
|
+
session**, and send the text to yourself.
|
|
389
|
+
2. Get the text to phone B, for example in a note or a message to yourself. It holds phone A's session
|
|
390
|
+
and installation ID; the sample's sessions last ten minutes.
|
|
391
|
+
3. **Phone B:** step 1 above, on the same backend.
|
|
392
|
+
4. **Phone B:** paste the text into **A session from another phone**, then tap **Use it here**. Expect: ✓
|
|
393
|
+
`Refused, as it should be: 401 invalid_installation_signature`. Phone B's key cannot sign for phone A's
|
|
394
|
+
installation, even when the request names it.
|
|
395
|
+
|
|
396
|
+
If step 4 reads `The session is no longer valid: sign in again`, phone A's session has expired: sign in
|
|
397
|
+
again on phone A and share it again.
|
|
398
|
+
|
|
399
|
+
### Measurement by measurement
|
|
400
|
+
|
|
401
|
+
What each measurement is and every field it carries:
|
|
402
|
+
[`doc/measurements.md`](https://github.com/merajhasan88/necessary_reactnative/blob/main/doc/measurements.md).
|
|
403
|
+
On a phone of your own you can watch these change, one at a time: make the change, tap **Scan**, open the
|
|
404
|
+
measurement's row, and change it back afterwards.
|
|
405
|
+
|
|
406
|
+
| Measurement | Change | The row then shows |
|
|
407
|
+
|---|---|---|
|
|
408
|
+
| `developer_settings` (Android) | Settings → About phone → tap Build number seven times; then Developer options → USB debugging | `developer_options_enabled`, then `adb_enabled`, true |
|
|
409
|
+
| `network` | turn on a VPN app; or set a manual proxy on your Wi-Fi network | `vpn_active` true, the tunnel's interface in `interfaces` (Android) or `scoped_interfaces` (iOS); `http_proxy_configured` true |
|
|
410
|
+
| `local_addresses` | switch between Wi-Fi and mobile data | the phone's own addresses, per interface |
|
|
411
|
+
| `screen_capture` | start the screen recorder (Android 15 and later; on iOS from Control Center); connect an external display | `recording` true; `external_displays` 1 |
|
|
412
|
+
| `location` | tap **Ask for the location permission** and allow it | the `permission`, the coordinates and their `age_seconds` |
|
|
413
|
+
| `mock_location` | Android: Developer options → Select mock location app, and run a mock-location app; iOS: simulate a location from Xcode while the app runs from it | `mock` true |
|
|
414
|
+
| `accessibility` (Android) | Settings → Accessibility → turn on a service such as Select to Speak; or choose another keyboard | the service in `services`; the keyboard in `keyboard` |
|
|
415
|
+
| `notification_access` (Android) | Settings → Notification access (under Special app access) → allow an app | the app in `listeners` |
|
|
416
|
+
| `other_access` (Android) | install AnyDesk, TeamViewer QuickSupport, RustDesk or AirDroid; or make an app a device administrator | the app in `remote_access_apps`; in `device_admins` |
|
|
417
|
+
| `app_identity` | install a build of the example made with other code | other hashes; a debug build reads `debuggable` true |
|
|
418
|
+
| `debug_state` (Android) | in Android Studio, Run → Attach Debugger to Android Process, on a debug build | `debugger_connected` true |
|
|
419
|
+
| `code_signing` (iOS) | run a development build from Xcode | `get_task_allow` true |
|
|
420
|
+
|
|
421
|
+
The rest — root and jailbreak traces, Frida and other hooking tools, code changed in memory, an emulator
|
|
422
|
+
or the Simulator — needs a device you are allowed to change: never a phone you depend on. On an Android
|
|
423
|
+
emulator `emulator.suspected` is true, and a `userdebug` image shows a debuggable, test-keys build in
|
|
424
|
+
`system_properties` and `root_files`; on the iOS Simulator `simulator.is_simulator` is true. Frida on a
|
|
425
|
+
test device shows in `runtime_maps`, `frida_ports`, `instrumentation_threads`, `exec_mappings` and
|
|
426
|
+
`code_integrity` on Android, and in `dyld_images` and `code_integrity` on iOS.
|
|
427
|
+
|
|
428
|
+
## Licence
|
|
429
|
+
|
|
430
|
+
Copyright 2026 Meraj Hasan. Licensed under the Apache License, Version 2.0 (`LICENSE`, `NOTICE`).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The SDK's native side on Android, built inside the app that links it: the app's root project
|
|
2
|
+
// provides the Android and React Native Gradle plugins, and the plugin of the native modules'
|
|
3
|
+
// language, and the versions below where it sets none.
|
|
4
|
+
|
|
5
|
+
def extOrDefault(String name, Object fallback) {
|
|
6
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : fallback
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
apply plugin: "com.android.library"
|
|
10
|
+
apply plugin: "kotlin-android"
|
|
11
|
+
apply plugin: "com.facebook.react"
|
|
12
|
+
|
|
13
|
+
android {
|
|
14
|
+
namespace "com.necessarysdk.reactnative"
|
|
15
|
+
compileSdkVersion extOrDefault("compileSdkVersion", 36)
|
|
16
|
+
if (rootProject.ext.has("ndkVersion")) {
|
|
17
|
+
ndkVersion rootProject.ext.get("ndkVersion")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
defaultConfig {
|
|
21
|
+
minSdkVersion Math.max(24, extOrDefault("minSdkVersion", 24) as int)
|
|
22
|
+
targetSdkVersion extOrDefault("targetSdkVersion", 36)
|
|
23
|
+
externalNativeBuild {
|
|
24
|
+
cmake {
|
|
25
|
+
cppFlags "-std=c++17"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
externalNativeBuild {
|
|
31
|
+
cmake {
|
|
32
|
+
path "src/main/cpp/CMakeLists.txt"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
compileOptions {
|
|
37
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
38
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
buildTypes {
|
|
42
|
+
release {
|
|
43
|
+
minifyEnabled false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
testOptions {
|
|
48
|
+
unitTests {
|
|
49
|
+
includeAndroidResources = true
|
|
50
|
+
all {
|
|
51
|
+
outputs.upToDateWhen { false }
|
|
52
|
+
testLogging {
|
|
53
|
+
events "passed", "skipped", "failed", "standardOut", "standardError"
|
|
54
|
+
showStandardStreams = true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
kotlin {
|
|
62
|
+
compilerOptions {
|
|
63
|
+
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
repositories {
|
|
68
|
+
google()
|
|
69
|
+
mavenCentral()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dependencies {
|
|
73
|
+
implementation "com.facebook.react:react-android"
|
|
74
|
+
// The collectors' failure handling, the step-up key's verdicts and the key lookups, tested on
|
|
75
|
+
// the JVM.
|
|
76
|
+
testImplementation "junit:junit:4.13.2"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
react {
|
|
80
|
+
jsRootDir = file("../src/")
|
|
81
|
+
libraryName = "NecessarySpec"
|
|
82
|
+
codegenJavaPackageName = "com.necessarysdk.reactnative"
|
|
83
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
<!-- What the SDK's own features need; all normal permissions, granted at
|
|
3
|
+
install. The location permissions are the app's to declare and ask for:
|
|
4
|
+
the SDK measures the location only where the app has them. -->
|
|
5
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
6
|
+
<!-- The network probe asks the connectivity service whether a VPN or a proxy
|
|
7
|
+
is in use. -->
|
|
8
|
+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
9
|
+
<!-- Step-up prompts (BiometricPrompt, device credential or biometric). -->
|
|
10
|
+
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
|
11
|
+
<!-- screen_capture on Android 15 and later: whether this app's window is being
|
|
12
|
+
recorded. -->
|
|
13
|
+
<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />
|
|
14
|
+
<!-- Screen protection: other apps' windows are not drawn over the protected
|
|
15
|
+
window (Android 12 and later). -->
|
|
16
|
+
<uses-permission android:name="android.permission.HIDE_OVERLAY_WINDOWS" />
|
|
17
|
+
<queries>
|
|
18
|
+
<!-- The apps that can hold Accessibility, a keyboard, notification access
|
|
19
|
+
or device administration, so the accessibility, notification_access
|
|
20
|
+
and other_access measurements can read who installed each. -->
|
|
21
|
+
<intent>
|
|
22
|
+
<action android:name="android.accessibilityservice.AccessibilityService"/>
|
|
23
|
+
</intent>
|
|
24
|
+
<intent>
|
|
25
|
+
<action android:name="android.view.InputMethod"/>
|
|
26
|
+
</intent>
|
|
27
|
+
<intent>
|
|
28
|
+
<action android:name="android.service.notification.NotificationListenerService"/>
|
|
29
|
+
</intent>
|
|
30
|
+
<intent>
|
|
31
|
+
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED"/>
|
|
32
|
+
</intent>
|
|
33
|
+
<!-- Remote-control apps scammers have victims install; the same list as
|
|
34
|
+
IntegrityProbeManager.remoteAccessPackages. -->
|
|
35
|
+
<package android:name="com.anydesk.anydeskandroid"/>
|
|
36
|
+
<package android:name="com.teamviewer.quicksupport.market"/>
|
|
37
|
+
<!-- RustDesk, by its own package name. -->
|
|
38
|
+
<package android:name="com.carriez.flutter_hbb"/>
|
|
39
|
+
<package android:name="com.sand.airdroid"/>
|
|
40
|
+
</queries>
|
|
41
|
+
</manifest>
|