@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.
- package/CHANGELOG.md +40 -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 +1157 -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 +338 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -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 +767 -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 +959 -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 +370 -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 +699 -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 +134 -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/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -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 +851 -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 +168 -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/answers.ts +15 -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
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
# Necessary — what the app sends, and how your backend checks it
|
|
2
|
+
|
|
3
|
+
The Necessary plugins are open source. They give your app
|
|
4
|
+
a key they never export (section 1 says where the phone keeps it), measurements of the phone and of the
|
|
5
|
+
app running on it, and — if
|
|
6
|
+
you turn them on — a second key that the person must unlock, and a PIN your backend checks, to approve
|
|
7
|
+
a request. **They never decide
|
|
8
|
+
anything: your backend does.** This guide is the protocol the plugins follow — what they
|
|
9
|
+
send, how your backend checks that a report was signed by the key an installation registered and proved
|
|
10
|
+
it holds, and how to weigh what it says with your own business logic. It describes no particular server.
|
|
11
|
+
A valid signature proves possession of that key and that the bytes you verify are the ones it signed; on
|
|
12
|
+
its own it does not prove which phone or which app produced them, or that the plugin ran unmodified
|
|
13
|
+
(section 9).
|
|
14
|
+
|
|
15
|
+
There are two ways to connect, and the choice is yours. **By default the plugin calls the API in
|
|
16
|
+
section 4**, under a base URL your app sets, and your backend implements it. Or your app calls the
|
|
17
|
+
plugin's functions and carries the messages over its own API. The checks are the same either way.
|
|
18
|
+
Binary values travel as base64url without padding, hashes are lower-case hex SHA-256, times are UTC.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. The installation key
|
|
23
|
+
|
|
24
|
+
On first use the plugin creates an **installation key**: ECDSA P-256 (ES256), never exportable by the
|
|
25
|
+
app. On Android it is generated inside Android Keystore — in StrongBox where the phone has one, otherwise
|
|
26
|
+
wherever that phone's Keystore keeps keys, its secure hardware or, on some phones, software. On iOS the
|
|
27
|
+
plugin asks the Secure Enclave first; where it refuses — the Simulator, or a configuration that declines
|
|
28
|
+
the key — the plugin falls back to a Keychain key marked not extractable: a software key, which a
|
|
29
|
+
compromised phone can copy. `key_security` (below) is what the phone says about where its key is. Your
|
|
30
|
+
backend only ever sees the public key.
|
|
31
|
+
|
|
32
|
+
Every signature works the same way: the phone base64url-decodes the payload it is given, signs those
|
|
33
|
+
bytes with ECDSA over SHA-256, and returns the signature in ASN.1 DER, base64url. **Verify over the bytes
|
|
34
|
+
that were signed — never over JSON you encoded again yourself.**
|
|
35
|
+
|
|
36
|
+
### Registration (the app to your backend)
|
|
37
|
+
|
|
38
|
+
| Field | Meaning |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `installation_id` | a UUID the app proposes |
|
|
41
|
+
| `platform` | `android` or `ios` |
|
|
42
|
+
| `public_key` | a JWK: `kty` `EC`, `crv` `P-256`, `alg` `ES256`, `x`, `y` |
|
|
43
|
+
| `reinstall_hint` | `kind` (`android_id_sha256` or `idfv_sha256`) and `value`, a SHA-256 of the platform's per-app device identifier; or `null` |
|
|
44
|
+
| `key_security` | `security_level`, `hardware_backed`, `provider`: what the phone says about its own key |
|
|
45
|
+
|
|
46
|
+
Your backend:
|
|
47
|
+
|
|
48
|
+
1. Accepts only an EC P-256 key whose `x` and `y` are 32 bytes each and whose point is on the curve.
|
|
49
|
+
2. Computes the **thumbprint**: the lower-case hex SHA-256 of `{"crv":"P-256","kty":"EC","x":"…","y":"…"}`
|
|
50
|
+
— members in that order, no spaces, `x` and `y` as 32-byte big-endian values in base64url without
|
|
51
|
+
padding. **The thumbprint is the installation's identity**, not the proposed `installation_id`: a
|
|
52
|
+
registration whose thumbprint you already hold is the same installation (an app that lost its stored
|
|
53
|
+
ID), so answer with the one you have. A proposed `installation_id` you already hold under another key
|
|
54
|
+
is not that installation: refuse it. The plugin never sends one — when its key is gone it makes a new
|
|
55
|
+
ID as well.
|
|
56
|
+
3. Keys the reinstall hint with a secret of its own (HMAC-SHA256) before storing it. **The hint is what
|
|
57
|
+
the app says**: a modified app can send another phone's hint, or a new one every time, and keying it
|
|
58
|
+
does not make its origin genuine. It links reinstalls on one phone provisionally; it never
|
|
59
|
+
authenticates anyone. On Android it changes when the app's signing key changes.
|
|
60
|
+
4. Records `key_security` but never relies on it: the phone cannot prove it.
|
|
61
|
+
5. Trusts nothing about the installation until it has proved that it holds the key.
|
|
62
|
+
|
|
63
|
+
### Installations and phones
|
|
64
|
+
|
|
65
|
+
An installation is one key — its thumbprint, exact within this protocol. What becomes of the key when
|
|
66
|
+
the app is removed depends on the platform:
|
|
67
|
+
|
|
68
|
+
- **Android** deletes the key with the app. A reinstall makes a new key, so a new installation, while the
|
|
69
|
+
reinstall hint stays the same until the phone is reset or the app's signing key changes.
|
|
70
|
+
- **iOS** keeps the key, and the installation ID the plugin stores beside it, in the Keychain — and
|
|
71
|
+
Apple does not promise whether Keychain items outlive the app. A reinstall may come back with the same
|
|
72
|
+
key, so the same installation (step 2 above); with the key but without the app's other data; or with
|
|
73
|
+
neither, so a new key. The hint stays the same until every app from your company has been removed.
|
|
74
|
+
Handle all three: the same thumbprint is the same installation, whatever else the app forgot, and a
|
|
75
|
+
session your app kept in the Keychain may come back with it.
|
|
76
|
+
|
|
77
|
+
So **a phone, as far as the hint can tell, is every installation that shares a reinstall hint**: count
|
|
78
|
+
accounts, reinstalls and limits per phone (section 7) — knowing that a fraudster who sends fresh hints
|
|
79
|
+
looks like many phones, and one who copies a hint looks like someone else's.
|
|
80
|
+
|
|
81
|
+
- **Key identities are exact; matches by hint are provisional.**
|
|
82
|
+
- **A match by hint alone never carries a block, a ban or trust from one installation to another.** Act
|
|
83
|
+
on it only with other evidence — the same account signing in on both, the same network, the same
|
|
84
|
+
payees — and even then it links two installations; it does not prove one phone.
|
|
85
|
+
- An account signing in on an installation whose key it has used before strengthens a link; it still
|
|
86
|
+
does not make the hint a hardware identity.
|
|
87
|
+
|
|
88
|
+
### Proof of possession
|
|
89
|
+
|
|
90
|
+
Your backend sends a challenge: a `challenge_id` (a UUID) and a `payload` — base64url bytes of your
|
|
91
|
+
choosing. We suggest JSON naming the `challenge_id`, the `installation_id`, a `purpose`, a `nonce` of 32
|
|
92
|
+
random bytes and an `expires_at` two minutes ahead, either stored or carrying an HMAC under a key of yours
|
|
93
|
+
so that you need not store it. The app answers with `installation_id`, `challenge_id`, `payload`
|
|
94
|
+
(unchanged) and `signature`. Your backend:
|
|
95
|
+
|
|
96
|
+
1. Checks that the payload is one it issued (stored, or its HMAC verifies), for this `challenge_id`,
|
|
97
|
+
this `installation_id` and this purpose.
|
|
98
|
+
2. Checks that it has not expired.
|
|
99
|
+
3. Verifies the signature over the decoded payload bytes with the installation's registered key.
|
|
100
|
+
4. Consumes the challenge atomically — inserting its ID under a unique key is the single-use check, so a
|
|
101
|
+
second answer fails however the two requests interleave. Consume only after the signature verifies, so
|
|
102
|
+
nobody can use up another phone's challenges.
|
|
103
|
+
5. Issues its own short-lived session for this installation; ten minutes is a sound choice.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 2. The scan
|
|
108
|
+
|
|
109
|
+
### The challenge (your backend to the app)
|
|
110
|
+
|
|
111
|
+
| Field | Meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `challenge_id` | a UUID |
|
|
114
|
+
| `nonce` | 32 random bytes; store its SHA-256, not the nonce |
|
|
115
|
+
| `platform` | the installation's platform, as registered |
|
|
116
|
+
| `required_probes` | the measurements this scan must answer (section 6) |
|
|
117
|
+
| `optional_probes` | the personal measurements you ask for, answered only where the app enables them (section 8) |
|
|
118
|
+
| `expires_at` | 60 seconds ahead |
|
|
119
|
+
|
|
120
|
+
Ask for every measurement you need every time, and vary the others from scan to scan, so that a
|
|
121
|
+
tampered app cannot prepare its answers. **Use every measurement that would catch tampering** — the
|
|
122
|
+
hooks, gadgets and changes to the app's code in memory among them: on Android `runtime_maps`,
|
|
123
|
+
`frida_ports`, `instrumentation_threads`, `exec_mappings` and `code_integrity`; on iOS `dyld_images`,
|
|
124
|
+
`environment` and `code_integrity`. One left to chance is one a tampered app waits out, scanning again
|
|
125
|
+
until it is not asked for. Ask for iOS `code_integrity` too, although every report carries it: only a
|
|
126
|
+
report that lacks a measurement you asked for is refused. Issue a challenge only to a session that has
|
|
127
|
+
proved its key, and limit how often one installation may ask.
|
|
128
|
+
|
|
129
|
+
### The report (the app to your backend)
|
|
130
|
+
|
|
131
|
+
The app sends `report_payload` — the base64url of these UTF-8 JSON bytes — and `report_signature`:
|
|
132
|
+
|
|
133
|
+
| Field | Meaning |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `challenge_id`, `challenge_nonce` | the challenge it answers |
|
|
136
|
+
| `installation_id` | the installation whose key signs it |
|
|
137
|
+
| `platform` | `android` or `ios` |
|
|
138
|
+
| `collector_version` | the plugin's measurement version, an integer |
|
|
139
|
+
| `collected_at` | the phone's clock, in Unix seconds |
|
|
140
|
+
| `probe_results` | one object per measurement (section 6) |
|
|
141
|
+
| `enabled_measurements` | the personal measurements this app's developers enabled (section 8) |
|
|
142
|
+
| `version` | the report format: `1` |
|
|
143
|
+
|
|
144
|
+
Your backend, in this order, refusing at the first failure:
|
|
145
|
+
|
|
146
|
+
1. Decodes the payload (a report is far below 64 KiB; refuse larger): a JSON object with `version` 1.
|
|
147
|
+
2. Its `installation_id` is the session's installation.
|
|
148
|
+
3. Finds the challenge by `challenge_id`, locking it: it belongs to this installation, and `platform`
|
|
149
|
+
matches it.
|
|
150
|
+
4. The challenge is unused and has not expired.
|
|
151
|
+
5. The SHA-256 of the decoded `challenge_nonce` equals the stored hash.
|
|
152
|
+
6. `collected_at` is within 90 seconds of your clock.
|
|
153
|
+
7. `collector_version` is one you support — refuse plugins too old to measure what you need.
|
|
154
|
+
8. Every probe in `required_probes` is in `probe_results`. **A report that lacks a measurement you asked
|
|
155
|
+
for is refused, never read.**
|
|
156
|
+
9. The signature verifies over the decoded payload bytes with the installation's key.
|
|
157
|
+
10. Marks the challenge used, in the same transaction.
|
|
158
|
+
|
|
159
|
+
Only then read the measurements — and keep the signed bytes, or their SHA-256, with what you decided.
|
|
160
|
+
Read them first by the contract's rules (`RULES.md`, sections 2 and 7): a report whose figures contradict each
|
|
161
|
+
other is not a measurement, clean or otherwise; one with a requested measurement failed, unread or
|
|
162
|
+
without its fields is incomplete — ask again rather than read it as clean; only then do your own rules
|
|
163
|
+
weigh what it found. Decide deliberately what an incomplete report may still do.
|
|
164
|
+
|
|
165
|
+
### A scan for one action
|
|
166
|
+
|
|
167
|
+
When the app asks for the challenge, it may name the one request the scan is to approve — a payment, a
|
|
168
|
+
change of payee — as `operation`: that request's `method`, `path`, `query` (as sent, empty when none)
|
|
169
|
+
and `body_sha256` (the SHA-256 of its exact body).
|
|
170
|
+
|
|
171
|
+
- **Such a scan is the account's, not the installation's.** The app asks for it under your **account
|
|
172
|
+
session** — the one that will send the request — never under the installation session of section 1;
|
|
173
|
+
refuse an operation named under that one. Your account session must itself belong to this
|
|
174
|
+
installation: issue it only to an installation that has proved its key, carry the installation in it,
|
|
175
|
+
and require its request proofs (section 3).
|
|
176
|
+
- Read the account and the session from your own verified token, never from anything the app says, and
|
|
177
|
+
store a SHA-256 of the description, joined to them, with the challenge.
|
|
178
|
+
- Your answer to the report carries `report_id`, inside its `integrity` object, which names the stored
|
|
179
|
+
report; the app sends it back with the request, in the header `X-Integrity-Report`.
|
|
180
|
+
- When the request arrives, accept it only if that report answered a challenge naming exactly this
|
|
181
|
+
request — same method, path, query and body — under the same account session, within a couple of
|
|
182
|
+
minutes, and your decision on that report allowed the request; and mark the report used in the same
|
|
183
|
+
transaction, so that it approves one request, once.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 3. Binding each request to the phone (recommended)
|
|
188
|
+
|
|
189
|
+
A copied session token is useless without the installation key when every sensitive request carries a
|
|
190
|
+
proof signed by that key. The plugin adds two headers to the request, beside `Authorization: Bearer`:
|
|
191
|
+
`X-Access-Proof`, the base64url of these JSON bytes, and `X-Access-Signature`.
|
|
192
|
+
|
|
193
|
+
| Field | Meaning |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `access_token_sha256` | SHA-256 of the bearer token, as sent |
|
|
196
|
+
| `body_sha256` | SHA-256 of the exact body bytes sent (of nothing, for a GET) |
|
|
197
|
+
| `installation_id` | the installation |
|
|
198
|
+
| `method` | upper case |
|
|
199
|
+
| `path` | the path, without the query |
|
|
200
|
+
| `query` | the query string exactly as sent, without `?`; empty when there is none |
|
|
201
|
+
| `nonce` | 32 random bytes |
|
|
202
|
+
| `timestamp` | Unix seconds |
|
|
203
|
+
| `version` | `2` |
|
|
204
|
+
|
|
205
|
+
Your backend checks every field against the request it actually received — the installation (from the
|
|
206
|
+
session), method, path, query, body hash and token hash — then that the timestamp is within 120 seconds,
|
|
207
|
+
then the signature; and only after the signature verifies does it store the nonce's hash under a unique
|
|
208
|
+
key, so that a repeated request is refused as a replay. Keep nonces for at least ten minutes, longer than
|
|
209
|
+
the clock window on either side.
|
|
210
|
+
|
|
211
|
+
### Step-up: the person approves one request (optional)
|
|
212
|
+
|
|
213
|
+
For a request where you want the person and not only the phone — a payment, a new payee, a changed
|
|
214
|
+
password — the plugin can hold a second key, the **step-up key**: ECDSA P-256 like the installation key,
|
|
215
|
+
never exportable, and usable only after the person unlocks it. A step-up proof shows that someone able to
|
|
216
|
+
unlock this phone approved this one request; it does not show who. The app turns step-up on and chooses
|
|
217
|
+
how the key is unlocked; your backend decides which requests need it.
|
|
218
|
+
|
|
219
|
+
| How it is unlocked | |
|
|
220
|
+
|---|---|
|
|
221
|
+
| factor `passcode` | the phone's passcode: its PIN, pattern or password |
|
|
222
|
+
| factor `biometric` | a biometric enrolled now, such as a fingerprint or a face |
|
|
223
|
+
| mode `per_use` | the person unlocks the key for every signature |
|
|
224
|
+
| mode `windowed` | one unlock lets the key sign for `window_seconds` — Android only. Android 9 and 10 cannot ask for the passcode at every signature, so there a passcode key is windowed, and says so |
|
|
225
|
+
|
|
226
|
+
Removing the screen lock — or, for a biometric key, enrolling another biometric — makes the key
|
|
227
|
+
unusable. The app then makes a new one, which your backend takes only by re-enrolment (below).
|
|
228
|
+
|
|
229
|
+
**At registration**, with step-up on, the registration (section 1) also carries:
|
|
230
|
+
|
|
231
|
+
| Field | Meaning |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `stepup_public_key` | the step-up key's JWK, as `public_key` |
|
|
234
|
+
| `stepup_key_auth` | `factor`, `mode` and `window_seconds` (0 for `per_use`): what the phone says about how the key is unlocked |
|
|
235
|
+
|
|
236
|
+
Your backend:
|
|
237
|
+
|
|
238
|
+
1. Takes a step-up key only with an installation it does not yet hold. A step-up key arriving with a
|
|
239
|
+
later registration of a known installation is not taken: otherwise whoever holds the installation
|
|
240
|
+
key could put a key of their own in its place.
|
|
241
|
+
2. Refuses a step-up key whose thumbprint is the installation key's: it would add nothing.
|
|
242
|
+
3. Records `stepup_key_auth` and, like `key_security`, never treats it as proof. Compare it with what
|
|
243
|
+
you require, and decide whether to accept a weaker key — a windowed one where you want every signature
|
|
244
|
+
unlocked; refusing it shuts out the owners of Android 9 and 10 phones.
|
|
245
|
+
|
|
246
|
+
**The proof.** A request that needs step-up carries, beside its request proof, `X-Step-Up-Proof` — the
|
|
247
|
+
base64url of these JSON bytes — and `X-Step-Up-Signature`, the step-up key's signature over them:
|
|
248
|
+
|
|
249
|
+
| Field | Meaning |
|
|
250
|
+
|---|---|
|
|
251
|
+
| `version` | `2` |
|
|
252
|
+
| `purpose` | `stepup` |
|
|
253
|
+
| `installation_id` | the installation |
|
|
254
|
+
| `access_token_sha256` | SHA-256 of the bearer token, as sent |
|
|
255
|
+
| `method`, `path`, `query`, `body_sha256` | the request, as its request proof states them |
|
|
256
|
+
| `factor` | the step-up key's factor |
|
|
257
|
+
| `nonce` | the request proof's nonce — the same value |
|
|
258
|
+
| `timestamp` | Unix seconds |
|
|
259
|
+
|
|
260
|
+
The plugin has the step-up key sign first — the phone asks the person — and the request proof after, so
|
|
261
|
+
the time the person takes does not count against the request proof's clock window.
|
|
262
|
+
|
|
263
|
+
Your backend, for a request that needs step-up, after the request proof's checks:
|
|
264
|
+
|
|
265
|
+
1. Refuses the request without both headers, telling the app that step-up is needed.
|
|
266
|
+
2. Verifies the signature over the decoded bytes with the step-up key it holds for this installation.
|
|
267
|
+
3. Checks `version` 2, `purpose` `stepup`, the installation, and every request field against the request
|
|
268
|
+
it received — the token hash, method, path, query and body hash — and that `nonce` is this request's
|
|
269
|
+
request-proof nonce. That nonce is stored once, so one approval approves one request, once.
|
|
270
|
+
4. Checks that the timestamp is within 120 seconds of its clock.
|
|
271
|
+
5. Checks the factor: both the proof's and the one recorded with the key are the factor you require.
|
|
272
|
+
|
|
273
|
+
Which requests need step-up, and which factor, is yours to decide. Limit failed step-ups per
|
|
274
|
+
installation and per account.
|
|
275
|
+
|
|
276
|
+
**Re-enrolment.** An unusable key, or one with a factor you no longer accept, is replaced only with the
|
|
277
|
+
person's own re-authentication — your account's password, for example — and a proof from the new key.
|
|
278
|
+
The request, with its request proof, carries the new `stepup_public_key` and `stepup_key_auth`, and the
|
|
279
|
+
new key's signature over the base64url of this JSON:
|
|
280
|
+
|
|
281
|
+
```text
|
|
282
|
+
{"version":1,"purpose":"stepup_reenrol","installation_id":"…","stepup_key_thumbprint":"…",
|
|
283
|
+
"nonce":"…","timestamp":…}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`stepup_key_thumbprint` is the new key's thumbprint (section 1); `nonce` is the request proof's. Your
|
|
287
|
+
backend checks the re-authentication, that the new key is not the installation key, the signature with
|
|
288
|
+
the new key, each field and the timestamp. Then the new key replaces the old one for that account alone:
|
|
289
|
+
a second account on the same phone, which needs only the installation key, must not gain step-up for the
|
|
290
|
+
first.
|
|
291
|
+
|
|
292
|
+
### The app PIN: the person's PIN for one request (optional)
|
|
293
|
+
|
|
294
|
+
A PIN of the person's account, which your backend checks: something the person knows, where the
|
|
295
|
+
step-up key is something only someone able to unlock the phone can use. It works on any phone, one
|
|
296
|
+
without a screen lock included. The app turns it on; your backend decides which requests need the
|
|
297
|
+
step-up key, the PIN, or both. The plugin never stores the PIN, and sends it only inside a signed
|
|
298
|
+
request body — never in a header, which proxies and monitoring tools record.
|
|
299
|
+
|
|
300
|
+
**Setting it.** Under your account session, with a request proof, the app sends the PIN with the
|
|
301
|
+
person's own re-authentication — your account's password, for example (section 4). Your backend:
|
|
302
|
+
|
|
303
|
+
1. Requires that re-authentication, and counts a wrong one as it counts a failed sign-in.
|
|
304
|
+
2. Takes only digits, of the length you chose.
|
|
305
|
+
3. Stores only a slow verifier — bcrypt, scrypt or PBKDF2 with a high work factor — over an HMAC of the
|
|
306
|
+
account and the PIN, keyed by a secret your database never holds, so that a copy of the database
|
|
307
|
+
checks nothing. Never log the PIN.
|
|
308
|
+
4. Clears the count of wrong PINs and any lock: a new PIN is how a person who forgot or locked theirs
|
|
309
|
+
gets back in. The PIN it replaces stops counting at once: checks of it still running no longer hold
|
|
310
|
+
the new PIN's attempts, and the proofs made under it no longer serve.
|
|
311
|
+
|
|
312
|
+
**Unlocking one request.** The app signs the request that needs the PIN first, which fixes its request
|
|
313
|
+
proof's nonce. Then, under the same account session and with a request proof of its own, it sends the
|
|
314
|
+
PIN with a description of that request — its `method`, `path`, `query`, `body_sha256` and that
|
|
315
|
+
`nonce` (section 4). Your backend:
|
|
316
|
+
|
|
317
|
+
1. Refuses an incomplete description before it checks the PIN.
|
|
318
|
+
2. Claims one attempt before comparing, so that PINs sent at the same moment cannot pass your limit, and
|
|
319
|
+
counts a wrong PIN; the last wrong one you allow locks the PIN until a new one is set. While the PIN
|
|
320
|
+
is being checked for another request, it answers 429 with a short wait and counts nothing.
|
|
321
|
+
3. Settles each check in one step, against the PIN it compared — note which PIN that was (when it was
|
|
322
|
+
set, or a number that grows with each PIN) when you claim the attempt. A check that finishes after a
|
|
323
|
+
new PIN was set unlocks nothing and changes nothing — not the new PIN's count, not its lock — and is
|
|
324
|
+
answered as a wrong PIN; one that could not run gives its attempt back.
|
|
325
|
+
4. Answers a proof it makes itself — an HMAC, under a key of its own, over the account, the
|
|
326
|
+
installation, the session, the request described, the PIN it checked and an expiry about two minutes
|
|
327
|
+
ahead.
|
|
328
|
+
|
|
329
|
+
The request then carries that proof in `X-App-PIN-Proof`. Your backend checks that it made the proof,
|
|
330
|
+
that it has not expired, that the PIN it names is still the account's PIN, that the account,
|
|
331
|
+
installation and session are this request's, and that the method, path, query, body hash and nonce are
|
|
332
|
+
this request's. That nonce is stored once with the request
|
|
333
|
+
proof, so one PIN entry unlocks one request, once.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 4. The API — the plugin's default
|
|
338
|
+
|
|
339
|
+
Unless your app carries the messages itself, the plugin makes these five calls under the HTTPS base URL
|
|
340
|
+
your app sets — and, with the app PIN on, two more, and with step-up on, one for re-enrolment, at paths
|
|
341
|
+
your app can change. Your backend implements them by sections 1 to 3.
|
|
342
|
+
|
|
343
|
+
| Call | Method and path | Authorization |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| Register the installation | `POST /v1/installations/register` | none |
|
|
346
|
+
| Ask for a key challenge | `POST /v1/installations/challenge` | none |
|
|
347
|
+
| Answer it | `POST /v1/installations/verify` | none |
|
|
348
|
+
| Ask for a scan | `POST /v1/integrity/challenge` | the session from *Answer it* — or, for a scan that approves one request, your account session for this installation (section 2) — with a request proof (section 3) |
|
|
349
|
+
| Send the report | `POST /v1/integrity/report` | the session that asked for the scan, with a request proof |
|
|
350
|
+
| Set the app PIN | `POST /v1/account/app-pin` | your account session, with a request proof |
|
|
351
|
+
| Prove the app PIN for one request | `POST /v1/account/app-pin/proof` | your account session, with a request proof |
|
|
352
|
+
| Re-enrol the step-up key | `POST /v1/installations/stepup-key` | your account session, with a request proof |
|
|
353
|
+
|
|
354
|
+
Requests and answers, in JSON. An answer may carry more fields; the plugin passes them to your app.
|
|
355
|
+
|
|
356
|
+
```text
|
|
357
|
+
POST /v1/installations/register
|
|
358
|
+
{"installation_id": "0f1d…", "platform": "android",
|
|
359
|
+
"public_key": {"kty": "EC", "crv": "P-256", "alg": "ES256", "x": "…", "y": "…"},
|
|
360
|
+
"reinstall_hint": {"kind": "android_id_sha256", "value": "9c1e…"},
|
|
361
|
+
"key_security": {"security_level": "strongbox", "hardware_backed": true,
|
|
362
|
+
"provider": "AndroidKeyStore"}}
|
|
363
|
+
→ {"installation_id": "0f1d…"} the installation you hold for this key
|
|
364
|
+
|
|
365
|
+
POST /v1/installations/challenge
|
|
366
|
+
{"installation_id": "0f1d…"}
|
|
367
|
+
→ {"challenge_id": "5b2a…", "payload": "eyJ…", "expires_at": "2026-10-20T12:02:00Z"}
|
|
368
|
+
|
|
369
|
+
POST /v1/installations/verify
|
|
370
|
+
{"installation_id": "0f1d…", "challenge_id": "5b2a…", "payload": "eyJ…", "signature": "MEUCIQ…"}
|
|
371
|
+
→ {"device_token": "…"} your session for this installation
|
|
372
|
+
|
|
373
|
+
POST /v1/integrity/challenge
|
|
374
|
+
{} or, under the account session, for a scan that approves one request (section 2),
|
|
375
|
+
{"operation": {"method": "POST", "path": "/v1/account/transfer", "query": "",
|
|
376
|
+
"body_sha256": "98ab…"}}
|
|
377
|
+
→ {"challenge_id": "7c5e…", "nonce": "q3Vn…", "platform": "android",
|
|
378
|
+
"required_probes": ["app_identity", "debug_state", …], "optional_probes": [],
|
|
379
|
+
"expires_at": "2026-10-20T12:01:00Z"}
|
|
380
|
+
|
|
381
|
+
POST /v1/integrity/report
|
|
382
|
+
{"report_payload": "eyJ…", "report_signature": "MEUCIQ…"}
|
|
383
|
+
→ {"integrity": {"report_id": "3e9d…", …}} the stored report; the plugin hands the
|
|
384
|
+
whole answer to your app, and for a scan
|
|
385
|
+
that approves one request sends report_id
|
|
386
|
+
with that request as X-Integrity-Report
|
|
387
|
+
|
|
388
|
+
POST /v1/account/app-pin with the app PIN on (section 3)
|
|
389
|
+
{"password": "…", "pin": "246810"} your own re-authentication, and the PIN
|
|
390
|
+
→ {"app_pin_set": true, …}
|
|
391
|
+
|
|
392
|
+
POST /v1/account/app-pin/proof
|
|
393
|
+
{"pin": "246810", "request": {"method": "POST", "path": "/v1/account/transfer", "query": "",
|
|
394
|
+
"body_sha256": "98ab…", "nonce": "yMnK…"}}
|
|
395
|
+
→ {"app_pin_proof": "eyJ…", "expires_at": "2026-10-20T12:02:00Z"} sent with that request as
|
|
396
|
+
X-App-PIN-Proof
|
|
397
|
+
|
|
398
|
+
POST /v1/installations/stepup-key with step-up on: a new key (section 3)
|
|
399
|
+
{"password": "…", "stepup_public_key": {"kty": "EC", "crv": "P-256", …},
|
|
400
|
+
"stepup_key_auth": {"factor": "passcode", "mode": "per_use", "window_seconds": 0},
|
|
401
|
+
"stepup_key_proof": "eyJ…", "stepup_key_signature": "MEUCIQ…"}
|
|
402
|
+
→ {"stepup_key_registered": true, …} the new key's proof is signed over the
|
|
403
|
+
request proof's nonce
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- `security_level` is `strongbox`, `trusted_execution_environment`, `secure_hardware`, `software` or
|
|
407
|
+
`unknown` on Android, and `secure_enclave` or `software` on iOS; `provider` is `AndroidKeyStore`,
|
|
408
|
+
`SecureEnclave` or `Keychain`.
|
|
409
|
+
- Prefer key challenges that store nothing — a payload carrying your HMAC — so that asking for one
|
|
410
|
+
cannot fill a table, and limit registrations and challenges per network address.
|
|
411
|
+
- The rest of a report's answer is yours, in the same `integrity` object: tell the app what to do next,
|
|
412
|
+
never the score or the reasons (section 7).
|
|
413
|
+
- With step-up on (section 3), the registration also carries `stepup_public_key` and `stepup_key_auth`,
|
|
414
|
+
and the requests your backend chooses carry the person's approval. Take a step-up key only when you
|
|
415
|
+
first register an installation; a key the phone made later — after its screen lock was removed and
|
|
416
|
+
set again, say — you take by re-enrolment alone. Say so in the answer: `stepup_key_registered`,
|
|
417
|
+
whether you hold a step-up key for this installation, and, when the app offered one for an
|
|
418
|
+
installation you already held, `stepup_key_matches`, whether it is that one. Where you hold another
|
|
419
|
+
or none, the plugin asks the app to re-enrol rather than sign approvals with a key you do not hold.
|
|
420
|
+
|
|
421
|
+
### Errors
|
|
422
|
+
|
|
423
|
+
Refuse with an HTTP status and `{"error": {"code": "…", "message": "…", "details": {…}}}`. The plugin
|
|
424
|
+
acts on the codes below; any other refusal it hands to your app as it came.
|
|
425
|
+
|
|
426
|
+
| Status | `code` | When | The plugin |
|
|
427
|
+
|---|---|---|---|
|
|
428
|
+
| 401 | `challenge_expired`, `challenge_used` | a key challenge expired, or was answered already | asks for a new one, once |
|
|
429
|
+
| 401 | `integrity_challenge_expired`, `integrity_challenge_replay` | the same, for a scan | asks for a new scan, once |
|
|
430
|
+
| 401 | `expired_token` | the session from *Answer it* has expired | proves the key again, then retries |
|
|
431
|
+
| 404 | `installation_not_found` | you do not know this installation | registers again with the same key, then retries |
|
|
432
|
+
| 429 | any, with `details.retry_after_seconds` | too many requests | waits, if that is ten seconds or less, then retries once; a longer wait goes to your app |
|
|
433
|
+
| 403 | `installation_inactive`, `device_inactive` | you have switched this installation or this phone off | stops, and tells your app |
|
|
434
|
+
| 400, 401 | `invalid_installation_signature`, `integrity_report_stale`, `integrity_probe_missing` (with `details.missing_probes`) | a signature that does not verify, a phone clock far off, a measurement left out | stops, and tells your app |
|
|
435
|
+
| 400 | `invalid_operation` | an `operation` you cannot read, or one named under the installation session rather than an account session | stops, and tells your app |
|
|
436
|
+
| 403 | `app_pin_not_set` | the account has no app PIN | tells your app, which asks the person to set one |
|
|
437
|
+
| 403 | `app_pin_invalid` | a wrong app PIN, which counts — or one checked against a PIN replaced meanwhile, which does not | tells your app, which asks for the PIN again |
|
|
438
|
+
| 403 | `app_pin_locked` | too many wrong app PINs | tells your app: a new PIN, set with the person's re-authentication, unlocks it |
|
|
439
|
+
|
|
440
|
+
### Carrying the messages yourself
|
|
441
|
+
|
|
442
|
+
Each call is built from plugin functions your app can call directly: load or create the key (its public
|
|
443
|
+
JWK and thumbprint), sign a payload, collect and sign a report for a challenge, sign a request (the
|
|
444
|
+
two headers of section 3) and, with step-up on, have the person approve one (two more headers) or sign a
|
|
445
|
+
new step-up key's re-enrolment proof. Send their results over your own API, and make the same checks.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## 5. Reference code
|
|
450
|
+
|
|
451
|
+
Python with `cryptography`, checked against the plugins' shared test vectors:
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
import base64
|
|
455
|
+
import hashlib
|
|
456
|
+
import json
|
|
457
|
+
|
|
458
|
+
from cryptography.hazmat.primitives import hashes
|
|
459
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def b64url_decode(text):
|
|
463
|
+
return base64.b64decode(text + "=" * (-len(text) % 4), altchars=b"-_", validate=True)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def b64url_encode(raw):
|
|
467
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def public_key(jwk):
|
|
471
|
+
"""The installation's key, from the JWK the app registered."""
|
|
472
|
+
if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
|
|
473
|
+
raise ValueError("only EC P-256 keys are accepted")
|
|
474
|
+
x, y = b64url_decode(jwk["x"]), b64url_decode(jwk["y"])
|
|
475
|
+
if len(x) != 32 or len(y) != 32:
|
|
476
|
+
raise ValueError("P-256 coordinates are 32 bytes each")
|
|
477
|
+
# Raises ValueError for a point that is not on the curve.
|
|
478
|
+
return ec.EllipticCurvePublicNumbers(
|
|
479
|
+
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1()).public_key()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def thumbprint(jwk):
|
|
483
|
+
"""The installation's identity: lower-case hex SHA-256 of the canonical key."""
|
|
484
|
+
x, y = b64url_decode(jwk["x"]), b64url_decode(jwk["y"])
|
|
485
|
+
canonical = json.dumps({"crv": "P-256", "kty": "EC",
|
|
486
|
+
"x": b64url_encode(x), "y": b64url_encode(y)},
|
|
487
|
+
sort_keys=True, separators=(",", ":"))
|
|
488
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def signed_bytes(jwk, payload_b64, signature_b64):
|
|
492
|
+
"""The bytes the phone signed, or InvalidSignature. Parse these bytes -
|
|
493
|
+
never JSON you encoded again yourself."""
|
|
494
|
+
payload = b64url_decode(payload_b64)
|
|
495
|
+
public_key(jwk).verify(b64url_decode(signature_b64), payload, ec.ECDSA(hashes.SHA256()))
|
|
496
|
+
return payload
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## 6. What the app measures
|
|
502
|
+
|
|
503
|
+
**The full contract is published with this guide**, in `contract/report-1/android-8-ios-5/`: the
|
|
504
|
+
report's schema (`report.schema.json`: every measurement's fields with their types, units and bounds),
|
|
505
|
+
the rules a schema cannot state (`RULES.md`: when a measurement is complete, how its figures add up,
|
|
506
|
+
what contradicts itself, how versions change), and sample reports judged each way (`samples/`). The
|
|
507
|
+
folder's name is two versions: `report-1` is the report's format, version 1, and `android-8-ios-5` the
|
|
508
|
+
lowest versions of the plugins' measuring code — their collectors — that the contract accepts: 8 on
|
|
509
|
+
Android and 5 on iOS (`collector_version`), not Android 8 or iOS 5, the operating systems. Every
|
|
510
|
+
measurement and field is described in `measurements.md`, beside this guide; the tables below summarise
|
|
511
|
+
them.
|
|
512
|
+
|
|
513
|
+
Each entry in `probe_results` has a `status`: `ok`, `error` (with `error`, and on Android `error_type`),
|
|
514
|
+
`unsupported` (with `reason`), or `not_enabled` for a personal measurement this app does not collect.
|
|
515
|
+
**A measurement counts only when its status is `ok` and its fields are there** — a status on its own is a
|
|
516
|
+
claim. A field a plugin version does not know is absent; read absent as not measured. Besides what you
|
|
517
|
+
ask for, every report carries `device` and `network`, and on iOS `code_integrity`.
|
|
518
|
+
|
|
519
|
+
**An `ok` probe may carry `unread`**: the parts it could not read, each with why —
|
|
520
|
+
`{"adb_enabled": "SecurityException", "/sbin/su": "SecurityException"}`. An unread part is not
|
|
521
|
+
measured: never read it as a clean "no", and take a probe you required that carries one as not
|
|
522
|
+
measured. A probe that could read nothing answers `error`. From plugin versions Android 8 and iOS 5
|
|
523
|
+
(`collector_version`) no failed read is reported as a clean value; an older Android plugin could report
|
|
524
|
+
one, and an older iOS plugin a partial comparison of the app's code as complete — refuse those versions,
|
|
525
|
+
or keep their scans apart as older evidence.
|
|
526
|
+
|
|
527
|
+
### Android
|
|
528
|
+
|
|
529
|
+
| Probe | What it tells you | Fields |
|
|
530
|
+
|---|---|---|
|
|
531
|
+
| `app_identity` | the installed app: package, the base APK's hash, the signing certificates, flags, install source; from version 8 the split APKs apart | `package_name`, `apk_sha256` (the base APK alone, never its splits), `cert_sha256` (list), `debuggable`, `allow_backup`, `version_name`, `version_code`, `installer_package`, `source_dir`, `current_signers`, `signing_lineage`, `split_apks` (each `name` and `sha256`, sorted by name; empty without splits) |
|
|
532
|
+
| `debug_state` | a Java debugger attached or awaited | `debugger_connected`, `waiting_for_debugger` |
|
|
533
|
+
| `tracer` | another process tracing the app | `tracer_pid`, `seccomp`, `no_new_privs` |
|
|
534
|
+
| `root_files` | known root, su and framework paths that exist; a test-keys build | `found_paths`, `test_keys`, `build_tags` |
|
|
535
|
+
| `root_shell` | whether `su` resolves from the app's shell | `su_found`, `su_path` |
|
|
536
|
+
| `system_properties` | build and boot-security properties | `properties`: `ro.secure`, `ro.debuggable`, `ro.build.type`, `ro.build.tags`, `ro.boot.verifiedbootstate`, `ro.boot.flash.locked`, `ro.boot.vbmeta.device_state`, `ro.boot.veritymode` |
|
|
537
|
+
| `selinux` | the SELinux mode, read two ways | `mode` (`enforcing`, `permissive`, `disabled`, `unknown`), `getenforce`, `enforce_value` |
|
|
538
|
+
| `mounts` | system partitions mounted writable | `protected_rw_mounts` |
|
|
539
|
+
| `runtime_maps` | instrumentation and hooking frameworks named in the app's memory map | `suspicious_tokens`, `suspicious_line_count`, `maps_lines` |
|
|
540
|
+
| `frida_ports` | Frida's default local ports accepting connections | `open_ports` |
|
|
541
|
+
| `instrumentation_threads` | threads an instrumentation runtime creates, even renamed | `frida_threads`, `glib_threads`, `token_threads`, `thread_count` |
|
|
542
|
+
| `exec_mappings` | memory both writable and executable, executable memory from deleted files, unlabelled executable regions | `wx_mappings`, `wx_bytes`, `wx_size_classes`, `wx_smallest_bytes`, `wx_largest_bytes`, `deleted_exec_mappings`, `deleted_exec_own_code`, `deleted_exec_jit`, `anon_exec_labeled`, `anon_exec_unlabeled`, `anon_exec_unlabeled_bytes`, `maps_lines`, `samples` |
|
|
543
|
+
| `code_integrity` | the app's native code and system libraries in memory compared with their files — an inline hook changes bytes | `checked`, `diff_bytes`, and for each region — the core system libraries, the extended ones, the app's own — its bytes compared, differing, expected, skipped and unreadable, and whether it is complete (`core_compared_bytes` to `app_complete`); `ext_libs_diff`, `app_libs_diff`, `diffed_libs`, `protect_restore_failures`, `elapsed_ms` |
|
|
544
|
+
| `developer_settings` | developer options and USB debugging | `developer_options_enabled`, `adb_enabled` |
|
|
545
|
+
| `emulator` | build values typical of an emulator | `suspected`, `reasons`, `model`, `manufacturer`, `product`, `hardware` |
|
|
546
|
+
| `device` | the OS version, the security patch level, model, build, the app's ABI, memory page size | `release`, `sdk_int`, `security_patch`, `manufacturer`, `brand`, `model`, `device`, `build_id`, `fingerprint`, `abi`, `page_size` |
|
|
547
|
+
| `network` | a VPN up on any network; an HTTP proxy set | `vpn_active`, `http_proxy_configured`, `transports`, `interfaces` (names only) |
|
|
548
|
+
|
|
549
|
+
### iOS
|
|
550
|
+
|
|
551
|
+
| Probe | What it tells you | Fields |
|
|
552
|
+
|---|---|---|
|
|
553
|
+
| `app_identity` | the installed app and its executable's hash | `bundle_id`, `executable_sha256`, `version_name`, `version_code`, `executable_bytes` |
|
|
554
|
+
| `code_signing` | the executable's own code signature | `signing_identifier`, `team_identifier`, `get_task_allow`, `signed`, `signature_absent`, `signature_parse_error`, `code_directory_flags`, `entitlement_count` |
|
|
555
|
+
| `debugger` | a debugger tracing the process | `traced` |
|
|
556
|
+
| `jailbreak_files` | known jailbreak paths that exist | `found_paths`, `checked_count` |
|
|
557
|
+
| `sandbox` | whether the app can write outside its sandbox | `write_outside_sandbox_succeeded`, `probe_path` |
|
|
558
|
+
| `dyld_images` | instrumentation and jailbreak names among the loaded libraries | `suspicious_tokens`, `suspicious_image_count`, `image_count` |
|
|
559
|
+
| `environment` | libraries injected through the environment | `dyld_insert_libraries` |
|
|
560
|
+
| `simulator` | the simulator, and the hardware model | `is_simulator`, `model` |
|
|
561
|
+
| `code_integrity` | the app's code in memory compared with its file, image by image | `checked`, `app_compared_bytes`, `app_diff_bytes`, `app_libs_diff`, `diffed_libs`; from version 5 the coverage: `app_images_expected` (the app's images loaded), `app_images_compared`, `app_images_unreadable`, `app_images_skipped`, `app_expected_bytes`, `app_unreadable_bytes`, `app_skipped_bytes`, `app_complete`, `main_executable_compared`, `app_images_not_compared` (each image not compared, by its path in the app, with why); `system_images_unreadable` |
|
|
562
|
+
| `device` | the OS version and build, the model, the memory page size | `os_version`, `os_build`, `model_identifier`, `page_size` |
|
|
563
|
+
| `network` | a VPN, inferred from the interfaces the proxy settings are scoped to — a heuristic, so the names come with it; an HTTP proxy | `vpn_active`, `http_proxy_configured`, `scoped_interfaces` |
|
|
564
|
+
|
|
565
|
+
- **`apk_sha256` is the base APK's alone.** Installs of one release with different feature, ABI or
|
|
566
|
+
density splits share it; the splits are in `split_apks`. Pin and compare builds by the base hash, and
|
|
567
|
+
read the splits as what this install added.
|
|
568
|
+
- **On iOS only the app's own code can be compared.** The system libraries live in a shared cache that
|
|
569
|
+
no app can read: `system_images_unreadable` counts them, and their figures stay 0 — not measured, never
|
|
570
|
+
clean. The app's coverage adds up — expected = compared + unreadable + skipped, for images and for
|
|
571
|
+
bytes — and `app_complete` is true only when every image was compared, the main executable among them.
|
|
572
|
+
A comparison short of complete measured part of the app's code; figures that do not add up are a
|
|
573
|
+
report to distrust.
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## 7. Deciding: the measurements, and fraud on clean phones
|
|
578
|
+
|
|
579
|
+
The plugin tells you about the phone. Most fraud does not need a tampered one: it uses genuine,
|
|
580
|
+
unmodified phones — bought by the dozen, borrowed from a victim, handed to a mule — and those pass every
|
|
581
|
+
measurement. What catches it is what only your backend can see: the same phones and accounts over time,
|
|
582
|
+
what your own server observed, where the requests come from, and what happened afterwards.
|
|
583
|
+
|
|
584
|
+
### 7.1 The measurements are evidence
|
|
585
|
+
|
|
586
|
+
- **The phone never sends a score or a verdict. You decide, on your server.**
|
|
587
|
+
- **Weigh each measurement by how rare it is on genuine phones, and by what is at stake.** Developer
|
|
588
|
+
options, USB debugging and a VPN are common on honest phones; an instrumentation framework in memory, a
|
|
589
|
+
debugger attached, code changed in memory, or an app that is not your own signed build are not.
|
|
590
|
+
- **Compare the app with your release** — its files, its code and its size, as section 7.2 describes. A
|
|
591
|
+
modified app is the most direct attack on everything else here: it holds a genuine key.
|
|
592
|
+
- **Compare a phone with itself.** A sudden change between one phone's reports — another signing
|
|
593
|
+
certificate, an older OS than before — matters more than any single value.
|
|
594
|
+
- **A missing or failed measurement is not proof of an attack.** Phones differ, people refuse
|
|
595
|
+
permissions, and OS versions hide things: ask again rather than punish the person. (A measurement you
|
|
596
|
+
required that is absent from the report is different: step 8 of section 2 refuses that report.)
|
|
597
|
+
|
|
598
|
+
### 7.2 Your app as you built it: its files, its code, its size
|
|
599
|
+
|
|
600
|
+
An app taken apart and rebuilt — with a hook, a cheat or an injected library — changes in size and in
|
|
601
|
+
content from the app you released, and the plugin measures it three ways, each against what it should
|
|
602
|
+
be. Nothing on the phone can prove that the app is yours (section 9); what you can do is know exactly
|
|
603
|
+
what each release looks like, and refuse what does not look like it.
|
|
604
|
+
|
|
605
|
+
**Write down each release as your customers install it.** At every release, run the build on your
|
|
606
|
+
reference phones — each OS version, processor and memory page size you support, through its main
|
|
607
|
+
features — and record what the plugin reports. Record from the installed build, not from what you
|
|
608
|
+
uploaded: a store may re-sign or slim the app for each kind of phone, so keep one record per kind. And
|
|
609
|
+
record only from your own runs, never from the field, where the first modified copy would set what
|
|
610
|
+
normal looks like.
|
|
611
|
+
|
|
612
|
+
1. **The installed files** (`app_identity`). A single changed byte changes a hash.
|
|
613
|
+
- Android: the base APK's SHA-256 (`apk_sha256`); your signing certificate (`cert_sha256`,
|
|
614
|
+
`current_signers`) — a rebuilt app must be signed again, with a key that is not yours; each split
|
|
615
|
+
APK you ship and its hash (`split_apks`); and, where you distribute through a store, that store as
|
|
616
|
+
`installer_package`.
|
|
617
|
+
- iOS: the main executable's SHA-256 and its **size in bytes** (`executable_sha256`,
|
|
618
|
+
`executable_bytes`) — an executable with code added is larger; and your team identifier
|
|
619
|
+
(`team_identifier`, in `code_signing`).
|
|
620
|
+
|
|
621
|
+
A report whose files match no release of yours is not your app as you built it.
|
|
622
|
+
2. **The code in memory** (`code_integrity`). The plugin compares the app's code as it runs with the
|
|
623
|
+
code in its installed files: every byte that differs (`app_diff_bytes`, and on Android the system
|
|
624
|
+
libraries' too) is code changed while running, which should never happen. Its **size** tells you
|
|
625
|
+
what was loaded: on iOS `app_images_expected` counts the app's own images in memory and
|
|
626
|
+
`app_expected_bytes` their code — an injected library is one image more and its code more than your
|
|
627
|
+
release ever loads; on Android `app_expected_bytes` is the app's own native code in memory. Compare
|
|
628
|
+
both with your release's record. A comparison short of complete measured only part of the code.
|
|
629
|
+
3. **The memory around it** (`exec_mappings`, on Android). Code loaded at run time needs executable
|
|
630
|
+
memory: memory both writable and executable (`wx_bytes`, `wx_size_classes`, `wx_largest_bytes`) and
|
|
631
|
+
executable regions with no file behind them (`anon_exec_unlabeled_bytes`). Some runtimes compile
|
|
632
|
+
code as they run — each method the first time it is called — and map some of each. Such a runtime
|
|
633
|
+
shows itself by how it grows: it maps more as more of the app is used, keeps it
|
|
634
|
+
while the app runs, and maps it in blocks of one size (64 KiB on the phones measured; your own runs
|
|
635
|
+
show yours). Record the most of each your release maps, per OS version, processor and page size,
|
|
636
|
+
**over whole sessions through your app's main features, not at a cold start** — or a genuine
|
|
637
|
+
customer who signs in and approves a request maps more than your record. **More than your app ever
|
|
638
|
+
maps, or a size that is not a whole number of its runtime's blocks, is code in it that you did not
|
|
639
|
+
ship.** That tells a runtime's own code from a tool's; it does not prove a hook absent — one can
|
|
640
|
+
allocate in the same blocks — so the code comparison above is the stronger evidence.
|
|
641
|
+
|
|
642
|
+
Compare a phone with itself too: the same installation reporting other hashes without an update, or an
|
|
643
|
+
older build than before, is an app that changed under it.
|
|
644
|
+
|
|
645
|
+
### 7.3 Clean phones, fraudulent people
|
|
646
|
+
|
|
647
|
+
Keep three kinds of evidence that the phone does not get to decide.
|
|
648
|
+
|
|
649
|
+
1. **Who uses which phone, over time.** Link every account to every installation it signs in from, and
|
|
650
|
+
through the reinstall hint to the phone it suggests (section 1), and **never delete a link** — mark it
|
|
651
|
+
ended, so the history stays. From the links:
|
|
652
|
+
- accounts per phone — one phone, many accounts: farms, bonus abuse, mule herding;
|
|
653
|
+
- phones per account — one account, many phones: shared, sold or taken-over accounts;
|
|
654
|
+
- installations per phone — reinstalling again and again to look new (a fraudster who also sends a
|
|
655
|
+
fresh hint each time looks like new phones instead, which the addresses and speed below still show);
|
|
656
|
+
- how long you have known the phone, and the account — a new phone on an old account is the shape of
|
|
657
|
+
a takeover.
|
|
658
|
+
2. **What your own server saw.** Wrong passwords and failed second factors, counted per account, per
|
|
659
|
+
phone and per network address; request proofs signed by the wrong phone (a copied token); requests
|
|
660
|
+
sent twice; scans refused. These come from your logs, not from the phone.
|
|
661
|
+
3. **Where the requests come from, and how fast.** The network address of each request: data-centre and
|
|
662
|
+
VPN exits, many accounts behind one address, an address far from where the account lives. And speed:
|
|
663
|
+
registrations, sign-ins, scans and requests per phone, per account and per address in a minute, an
|
|
664
|
+
hour and a day, and the time from sign-up to the first payout. People are slow and irregular; scripts
|
|
665
|
+
are fast and regular.
|
|
666
|
+
|
|
667
|
+
**Measure what is normal for your genuine customers** — accounts per phone, sign-ins per hour, the time
|
|
668
|
+
to a first payout — **and set each limit just beyond it.** Numbers borrowed from elsewhere either let
|
|
669
|
+
fraud through or stop your customers.
|
|
670
|
+
|
|
671
|
+
How the common patterns show up:
|
|
672
|
+
|
|
673
|
+
| Pattern | How it shows up | What helps |
|
|
674
|
+
|---|---|---|
|
|
675
|
+
| A farm of real phones | many accounts per phone over time; reinstalls between sign-ups; phones arriving in batches — same model and build, minutes apart, one network address; accounts acting on a schedule | limits per phone, counted by reinstall hint, and per network address, since a hint can be made up; first payouts held; risk shared along links made by keys and accounts |
|
|
676
|
+
| Takeover, on the fraudster's own clean phone | an old account on a phone you have never seen; wrong passwords or a reset just before; a new payee and a large amount soon after | a new phone starts limited until a phone the account already trusts approves it, or a waiting period passes |
|
|
677
|
+
| A scam on the victim's own phone | the account's own, known phone; an unusual payee or amount; the screen shared or mirrored, a remote-control app, an Accessibility service not from a store — where you collect them | pause the payment and confirm another way; delay new payees |
|
|
678
|
+
| Mule accounts | new accounts receiving from many senders and paying out fast; several on one phone; phones shared with accounts later confirmed as fraud | limits on what new accounts send; outcomes fed back along links |
|
|
679
|
+
| Bonus and promotion abuse | many new accounts per phone or per address, each claiming once; a reinstall between them | one bonus per phone, by reinstall hint, not per account — and per address, since a hint can be made up |
|
|
680
|
+
| Credential stuffing through real phones | many accounts tried from few phones or addresses; wrong passwords spread across accounts | failures counted per phone and per address, not only per account |
|
|
681
|
+
| Automation on real phones | perfectly regular timing; steps faster than a person can read; the same steps in the same order across many accounts | speed and regularity per phone and per address; a second factor that needs the person |
|
|
682
|
+
| Shared or sold accounts | one account on many phones, in different places, at the same time | phones per account; each new phone approved from a trusted one |
|
|
683
|
+
|
|
684
|
+
### 7.4 What to do about it
|
|
685
|
+
|
|
686
|
+
- **Bind a sensitive action to a fresh scan** made for it (section 2, *A scan for one action*) and to its
|
|
687
|
+
request proof (section 3).
|
|
688
|
+
- **A new phone on an old account starts limited.** It can read, but not move money or change who
|
|
689
|
+
receives it, until a phone the account already trusts approves it — show a code on the new phone and
|
|
690
|
+
have the person type it on the trusted one — or until a waiting period has passed.
|
|
691
|
+
- **Cool off whatever is new**: new accounts, new payees and new phones get lower limits at first.
|
|
692
|
+
- **Spread risk along links you can stand behind.** When an account is confirmed as fraud, mark every
|
|
693
|
+
installation it used, and look again at every other account on those installations. A phone joined to
|
|
694
|
+
them by reinstall hint alone is a lead to review, never a ban to copy (section 1).
|
|
695
|
+
- **Feed outcomes back.** Chargebacks, disputes, refunds, complaints and confirmed fraud belong beside
|
|
696
|
+
the phones and accounts they involved: fraud found later is your best teacher.
|
|
697
|
+
- **Answer in proportion**: allow, ask for a second factor, hold for review, or block — and keep blocking
|
|
698
|
+
for strong evidence on actions that matter.
|
|
699
|
+
- **Tell the phone only what to do next** — scan again, confirm with a second factor, contact support —
|
|
700
|
+
never the score or which signal mattered. That tells an attacker what to hide.
|
|
701
|
+
|
|
702
|
+
### 7.5 An example: a payout request
|
|
703
|
+
|
|
704
|
+
1. **Is it signed by the phone that holds this session?** The request proof (section 3) verifies, or the
|
|
705
|
+
request is refused.
|
|
706
|
+
2. **Is there a fresh scan for exactly this payout?** If not, ask for one (section 2).
|
|
707
|
+
3. **Did the scan find strong evidence of tampering?** Hold the payout for review, or refuse it.
|
|
708
|
+
4. **What do the phone and the account say over time?** A phone first seen today, a password that failed
|
|
709
|
+
this morning, a payee added an hour ago, an amount far above this account's usual, a VPN exit, other
|
|
710
|
+
accounts on this phone that took money from strangers this week — each raises what you ask for: a
|
|
711
|
+
second factor, approval from a trusted phone, a review.
|
|
712
|
+
5. **Record the decision with its evidence** — the report's SHA-256, the links and counts you read, what
|
|
713
|
+
you asked for — so that a chargeback next month can be traced back to it.
|
|
714
|
+
|
|
715
|
+
### 7.6 Observe before you enforce
|
|
716
|
+
|
|
717
|
+
Run every new rule silently first: record what it would have done, measure how many genuine customers it
|
|
718
|
+
would have stopped, and only then turn it on, with a review queue for what it holds. Check your rules
|
|
719
|
+
against your outcomes regularly — fraud adapts to them.
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## 8. Personal measurements and privacy
|
|
724
|
+
|
|
725
|
+
These identify a person's phone, network or habits, so they need two keys: your backend lists them in
|
|
726
|
+
`optional_probes`, **and** the app's developers enable them in the plugin. Anything else answers
|
|
727
|
+
`not_enabled`. The location needs the person's permission as well, which the plugin asks for only when
|
|
728
|
+
your app asks.
|
|
729
|
+
|
|
730
|
+
| Probe | What it tells you | Fields | Platforms |
|
|
731
|
+
|---|---|---|---|
|
|
732
|
+
| `local_addresses` | the phone's own IP addresses, link-local ones left out | `addresses` (`interface`, `address`) | both |
|
|
733
|
+
| `accessibility` | the Accessibility services that are on, and the keyboard in use | `services`, `keyboard`, `settings_only`, `truncated` | Android |
|
|
734
|
+
| `notification_access` | the apps allowed to read notifications, one-time codes among them | `listeners`, `truncated` | Android |
|
|
735
|
+
| `other_access` | device-administrator apps, and known remote-control apps installed | `device_admins`, `remote_access_apps`, `truncated` | Android |
|
|
736
|
+
| `screen_capture` | the screen being recorded, mirrored or cast | `recording`, `external_displays`, `recording_status` | both |
|
|
737
|
+
| `location` | the last location the phone already knows — never a fresh fix, never a prompt | `permission` (`precise`, `approximate`, `none`), `latitude`, `longitude`, `accuracy_m`, `age_seconds`, `fix_time`; Android also `provider` | both |
|
|
738
|
+
| `mock_location` | whether that location was faked | `permission`, `mock`, `age_seconds`, `fix_time`; iOS also `accessory` | both |
|
|
739
|
+
|
|
740
|
+
- A list of apps holds at most 32, each with `package` and `service`, and with `system` and `installer`
|
|
741
|
+
where Android lets the app see them; `truncated` is true when the phone had more.
|
|
742
|
+
- `recording` exists on Android from Android 15, and on iOS while the app has a window in the
|
|
743
|
+
foreground; when it is missing, `recording_status` says why (`unsupported`, `no_permission`,
|
|
744
|
+
`unavailable` — from iOS only `unavailable`).
|
|
745
|
+
- iOS shows an app no other app: `accessibility`, `notification_access` and `other_access` answer
|
|
746
|
+
`unsupported` there.
|
|
747
|
+
- From plugin versions Android 8 and iOS 5 both location answers describe one fix, read once per scan,
|
|
748
|
+
and carry its time, `fix_time` (Unix seconds): two different times are two observations, not one.
|
|
749
|
+
Asking for `mock_location` alone never sends coordinates. On Android a location source the plugin
|
|
750
|
+
could not read is in `unread`: then no coordinates do not mean that the phone knows no location.
|
|
751
|
+
- On iOS, `mock` is Apple's own flag. On an iPhone with iOS 15.8, a location faked with Apple's
|
|
752
|
+
developer tools was flagged on the first scan; third-party spoofing apps have not been tested, and Apple
|
|
753
|
+
does not promise to flag them. `accessory` marks a location from an external GPS accessory.
|
|
754
|
+
- **Drop any personal measurement you did not ask for** before you store or read a report — after the
|
|
755
|
+
signature has been checked over the bytes as sent.
|
|
756
|
+
- **Treat `probe_results` as personal data.** They identify a phone — its model, OS build, installed
|
|
757
|
+
tools, install source. Keep them only as long as you need them, and never store a name, e-mail address
|
|
758
|
+
or phone number beside them.
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## 9. What no app can prove
|
|
763
|
+
|
|
764
|
+
- A fully compromised phone can report anything, and can use a genuine key to sign what it likes: the
|
|
765
|
+
key proves that a report came from that installation, not that the phone is honest — nor that the app
|
|
766
|
+
is your own unmodified build, or that the plugin measured what it reports. A modified app that holds a
|
|
767
|
+
registered key signs whatever it is given.
|
|
768
|
+
- The plugin cannot prove to your backend that its key is hardware-backed; `key_security` is the phone's
|
|
769
|
+
own word.
|
|
770
|
+
- Measurements raise the cost of fraud, and together with your own business logic they make it
|
|
771
|
+
expensive. On their own they are never proof.
|