@necessarysdk/react-native 0.1.5 → 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 +18 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +1 -1
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +24 -19
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +22 -7
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +21 -2
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +21 -16
- package/contract/report-1/android-8-ios-5/report.schema.json +1 -1
- package/doc/integration_guide.md +4 -4
- package/doc/measurements.md +5 -5
- package/ios/IntegrityProbeManager.swift +7 -8
- package/ios/ProbeReads.swift +17 -0
- package/lib/deviceTrust.js +19 -10
- package/lib/report.js +4 -10
- package/lib/util/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -0
- package/package.json +1 -1
- package/src/deviceTrust.ts +19 -10
- package/src/report.ts +4 -10
- package/src/util/answers.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
- `runtime_maps`, `instrumentation_threads` and iOS `dyld_images` leave the app's own folders, package name
|
|
6
|
+
and main executable out of what they read as names.
|
|
7
|
+
- Android: `root_shell` keeps the shell's error stream out of `su_path`, where a complaint that `which`
|
|
8
|
+
is missing would have read as `su` found.
|
|
9
|
+
- Android: a step-up signature that fails after the person approved, with any exception, answers the app;
|
|
10
|
+
a keystore's `ProviderException` there used to end the app. A location permission request is answered
|
|
11
|
+
when its activity goes, or the runtime does, with what the app holds then; it used to wait for ever.
|
|
12
|
+
- A step-up key the phone lost and a re-enrolment made again leaves approvals waiting until the backend
|
|
13
|
+
takes it, like a key replaced.
|
|
14
|
+
- A backend's answer that lacks a field the SDK needs is an `ApiException` (`invalid_backend_answer`): it
|
|
15
|
+
used to pass unseen, or to be a `TypeError` for a scan's challenge.
|
|
16
|
+
- The Python sample backend answers a malformed request: it used to drop the connection. Its other
|
|
17
|
+
requests answer as before.
|
|
18
|
+
- `doc/measurements.md`: `installer_package` is left out or null when no installer is named, as the
|
|
19
|
+
collectors send it; the report's format is the same for every SDK, as it says now.
|
|
20
|
+
|
|
3
21
|
## 0.1.5
|
|
4
22
|
|
|
5
23
|
The first release of Necessary for React Native: the client of the integration guide, for Android and
|
|
@@ -198,7 +198,7 @@ class InstallationKeyManager(private val context: Context) {
|
|
|
198
198
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
199
199
|
throw InstallationKeyFailure(
|
|
200
200
|
"UNSUPPORTED_ANDROID_VERSION",
|
|
201
|
-
"
|
|
201
|
+
"The installation key requires Android 6.0 (API 23) or newer.",
|
|
202
202
|
)
|
|
203
203
|
}
|
|
204
204
|
}
|
|
@@ -679,9 +679,11 @@ class IntegrityProbeManager(
|
|
|
679
679
|
)
|
|
680
680
|
|
|
681
681
|
// A lookup that could not start, or never finished, is not "no su": the
|
|
682
|
-
// probe fails, and the report says it measured nothing.
|
|
682
|
+
// probe fails, and the report says it measured nothing. What the shell
|
|
683
|
+
// says on its error stream is kept out: runCommand merges it, and a
|
|
684
|
+
// complaint that `which` is missing would read as a path to su.
|
|
683
685
|
private fun probeRootShell(): Map<String, Any> = ProbeReads.rootShell(
|
|
684
|
-
runCommand(listOf("/system/bin/sh", "-c", "command -v su || which su || true"))
|
|
686
|
+
runCommand(listOf("/system/bin/sh", "-c", "command -v su 2>/dev/null || which su 2>/dev/null || true"))
|
|
685
687
|
)
|
|
686
688
|
|
|
687
689
|
private fun probeSystemProperties(): Map<String, Any> {
|
|
@@ -745,18 +747,13 @@ class IntegrityProbeManager(
|
|
|
745
747
|
var mapsLines = 0
|
|
746
748
|
val file = File("/proc/self/maps")
|
|
747
749
|
if (!file.canRead()) return unavailable("/proc/self/maps is not readable")
|
|
750
|
+
val folders = ownFolders()
|
|
748
751
|
run {
|
|
749
|
-
file.forEachLine {
|
|
752
|
+
file.forEachLine { line ->
|
|
750
753
|
mapsLines++
|
|
751
|
-
val
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
if (line.contains(token)) {
|
|
755
|
-
found.add(token)
|
|
756
|
-
lineMatched = true
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
if (lineMatched) suspiciousLines++
|
|
754
|
+
val named = ProbeReads.namesIn(line, folders, context.packageName, suspiciousRuntimeTokens)
|
|
755
|
+
found.addAll(named)
|
|
756
|
+
if (named.isNotEmpty()) suspiciousLines++
|
|
760
757
|
}
|
|
761
758
|
}
|
|
762
759
|
if (mapsLines == 0) return unavailable("/proc/self/maps returned no lines")
|
|
@@ -853,7 +850,9 @@ class IntegrityProbeManager(
|
|
|
853
850
|
}
|
|
854
851
|
names.add(name)
|
|
855
852
|
}
|
|
856
|
-
return ProbeReads.threads(
|
|
853
|
+
return ProbeReads.threads(
|
|
854
|
+
names, unreadable, fridaThreads, glibThreads, suspiciousRuntimeTokens, context.packageName
|
|
855
|
+
)
|
|
857
856
|
}
|
|
858
857
|
|
|
859
858
|
/**
|
|
@@ -961,6 +960,17 @@ class IntegrityProbeManager(
|
|
|
961
960
|
)
|
|
962
961
|
}
|
|
963
962
|
|
|
963
|
+
/** The folders the app's own files live in: its install folder and its data folders. */
|
|
964
|
+
private fun ownFolders(): List<String> {
|
|
965
|
+
val info = context.applicationInfo
|
|
966
|
+
return listOfNotNull(
|
|
967
|
+
info.sourceDir?.let { File(it).parent }?.let { "$it/" },
|
|
968
|
+
info.dataDir?.let { "$it/" },
|
|
969
|
+
"/data/data/${context.packageName}/",
|
|
970
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) info.deviceProtectedDataDir?.let { "$it/" } else null,
|
|
971
|
+
).distinct()
|
|
972
|
+
}
|
|
973
|
+
|
|
964
974
|
/** The size of one /proc/self/maps range, "start-end" in hex; 0 if unreadable. */
|
|
965
975
|
private fun mappingSize(range: String): Long {
|
|
966
976
|
val dash = range.indexOf('-')
|
|
@@ -998,12 +1008,7 @@ class IntegrityProbeManager(
|
|
|
998
1008
|
val info = context.applicationInfo
|
|
999
1009
|
val apks = (listOf(info.sourceDir) + info.splitSourceDirs.orEmpty().toList())
|
|
1000
1010
|
.filterNotNull().distinct().toTypedArray()
|
|
1001
|
-
val dirs =
|
|
1002
|
-
info.sourceDir?.let { File(it).parent }?.let { "$it/" },
|
|
1003
|
-
info.dataDir?.let { "$it/" },
|
|
1004
|
-
"/data/data/${context.packageName}/",
|
|
1005
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) info.deviceProtectedDataDir?.let { "$it/" } else null,
|
|
1006
|
-
).distinct().toTypedArray()
|
|
1011
|
+
val dirs = ownFolders().toTypedArray()
|
|
1007
1012
|
val started = System.nanoTime()
|
|
1008
1013
|
val json = try {
|
|
1009
1014
|
nativeCodeIntegrity(apks, dirs)
|
|
@@ -23,6 +23,7 @@ import java.lang.ref.WeakReference
|
|
|
23
23
|
import java.security.SecureRandom
|
|
24
24
|
import java.util.concurrent.ExecutorService
|
|
25
25
|
import java.util.concurrent.Executors
|
|
26
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The SDK's native side on Android, one per React Native runtime and shared by its five modules:
|
|
@@ -48,9 +49,10 @@ internal class NecessaryNative(private val reactContext: ReactApplicationContext
|
|
|
48
49
|
private val stepUpKeys = StepUpKeyManager(context)
|
|
49
50
|
private val secureStore = SecureStore(context)
|
|
50
51
|
private val screenGuard = ScreenGuard()
|
|
51
|
-
// The app's own request for the location permission, answered when the person chooses
|
|
52
|
-
// collector itself never asks.
|
|
53
|
-
|
|
52
|
+
// The app's own request for the location permission, answered when the person chooses, or when
|
|
53
|
+
// the activity that asked goes; the collector itself never asks. Whichever comes first takes
|
|
54
|
+
// it: invalidate() runs off the main thread.
|
|
55
|
+
private val pendingLocation = AtomicReference<Promise?>(null)
|
|
54
56
|
// The activity the window state belongs to: a new one, while the screen is protected, is
|
|
55
57
|
// protected too.
|
|
56
58
|
private var attached: WeakReference<Activity>? = null
|
|
@@ -94,23 +96,30 @@ internal class NecessaryNative(private val reactContext: ReactApplicationContext
|
|
|
94
96
|
// app's own screen.
|
|
95
97
|
fun requestLocationPermission(promise: Promise) = mainHandler.post {
|
|
96
98
|
val activity = reactContext.currentActivity
|
|
97
|
-
if (
|
|
99
|
+
if (
|
|
100
|
+
locationState() == "precise" ||
|
|
101
|
+
activity !is PermissionAwareActivity ||
|
|
102
|
+
!pendingLocation.compareAndSet(null, promise)
|
|
103
|
+
) {
|
|
98
104
|
promise.resolve(locationState())
|
|
99
105
|
return@post
|
|
100
106
|
}
|
|
101
|
-
pendingLocation = promise
|
|
102
107
|
activity.requestPermissions(
|
|
103
108
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
|
|
104
109
|
LOCATION_REQUEST,
|
|
105
110
|
PermissionListener { requestCode, _, _ ->
|
|
106
111
|
if (requestCode != LOCATION_REQUEST) return@PermissionListener false
|
|
107
|
-
|
|
108
|
-
pendingLocation = null
|
|
112
|
+
answerLocationRequest()
|
|
109
113
|
true
|
|
110
114
|
},
|
|
111
115
|
)
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
/** The request still waiting, if one is, hears what the app holds now. */
|
|
119
|
+
private fun answerLocationRequest() {
|
|
120
|
+
pendingLocation.getAndSet(null)?.resolve(locationState())
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
private fun locationState(): String = when {
|
|
115
124
|
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
116
125
|
PackageManager.PERMISSION_GRANTED -> "precise"
|
|
@@ -241,10 +250,15 @@ internal class NecessaryNative(private val reactContext: ReactApplicationContext
|
|
|
241
250
|
|
|
242
251
|
override fun onHostPause() {}
|
|
243
252
|
|
|
253
|
+
// A location request ends with the activity that asked, recreated or not: React Native keeps
|
|
254
|
+
// the permission listener in that activity's delegate, and the next one has none, so the
|
|
255
|
+
// person's choice would never reach it.
|
|
256
|
+
//
|
|
244
257
|
// Gone for good: a step-up waiting for the confirm-credential result would never hear it, and
|
|
245
258
|
// would hold every later approval at STEPUP_BUSY. A recreated activity still receives the
|
|
246
259
|
// result, so a step-up waiting for it keeps waiting.
|
|
247
260
|
override fun onHostDestroy() {
|
|
261
|
+
answerLocationRequest()
|
|
248
262
|
val activity = attached?.get() ?: reactContext.currentActivity
|
|
249
263
|
if (activity?.isChangingConfigurations == true) return
|
|
250
264
|
stepUpKeys.cancelPendingConfirmation()
|
|
@@ -255,6 +269,7 @@ internal class NecessaryNative(private val reactContext: ReactApplicationContext
|
|
|
255
269
|
reactContext.removeActivityEventListener(this)
|
|
256
270
|
reactContext.removeLifecycleEventListener(this)
|
|
257
271
|
stepUpKeys.cancelPendingConfirmation()
|
|
272
|
+
answerLocationRequest()
|
|
258
273
|
worker.shutdown()
|
|
259
274
|
storageWorker.shutdown()
|
|
260
275
|
}
|
|
@@ -101,6 +101,20 @@ object ProbeReads {
|
|
|
101
101
|
return ok("tracer_pid" to pid, "seccomp" to seccomp, "no_new_privs" to noNewPrivs)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The listed names in one line of the memory map, in any case. The app's
|
|
106
|
+
* own folders and package name are taken out of the line first: Android
|
|
107
|
+
* names those folders after the package, with random text beside it, and
|
|
108
|
+
* neither names a tool. What lies inside those folders is still read, so a
|
|
109
|
+
* library the app was rebuilt with is found by its own name.
|
|
110
|
+
*/
|
|
111
|
+
fun namesIn(line: String, ownFolders: List<String>, packageName: String, tokens: List<String>): List<String> {
|
|
112
|
+
var text = line
|
|
113
|
+
for (folder in ownFolders) text = text.replace(folder, "/")
|
|
114
|
+
val lower = text.replace(packageName, "").lowercase(java.util.Locale.US)
|
|
115
|
+
return tokens.filter { lower.contains(it) }
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
/** The two developer settings, each read on its own: a read that threw is unread, not "off". */
|
|
105
119
|
fun developerSettings(adb: Result<Boolean>, developer: Result<Boolean>): Map<String, Any> {
|
|
106
120
|
if (adb.isFailure && developer.isFailure) {
|
|
@@ -166,7 +180,9 @@ object ProbeReads {
|
|
|
166
180
|
/**
|
|
167
181
|
* This process's thread names. A thread that ended between listing and
|
|
168
182
|
* reading is gone, not unread; one whose name could not be read is
|
|
169
|
-
* unread, since a renamed instrumentation thread could be that one.
|
|
183
|
+
* unread, since a renamed instrumentation thread could be that one. A
|
|
184
|
+
* thread named for the app itself - Android gives the main thread part of
|
|
185
|
+
* the package's name, 15 characters at most - names no tool.
|
|
170
186
|
*/
|
|
171
187
|
fun threads(
|
|
172
188
|
names: List<String>,
|
|
@@ -174,6 +190,7 @@ object ProbeReads {
|
|
|
174
190
|
fridaThreads: List<String>,
|
|
175
191
|
glibThreads: List<String>,
|
|
176
192
|
tokens: List<String>,
|
|
193
|
+
packageName: String = "",
|
|
177
194
|
): Map<String, Any> {
|
|
178
195
|
val named = names.filter { it.isNotEmpty() }
|
|
179
196
|
if (named.isEmpty()) return error("no thread of this process could be enumerated")
|
|
@@ -184,7 +201,9 @@ object ProbeReads {
|
|
|
184
201
|
val lower = name.lowercase(java.util.Locale.US)
|
|
185
202
|
for (t in fridaThreads) if (lower == t || lower.startsWith(t)) fridaHits.add(t)
|
|
186
203
|
for (t in glibThreads) if (lower == t) glibHits.add(t)
|
|
187
|
-
|
|
204
|
+
if (!packageName.contains(name)) {
|
|
205
|
+
for (t in tokens) if (lower.contains(t)) tokenHits.add(t)
|
|
206
|
+
}
|
|
188
207
|
}
|
|
189
208
|
val unread = if (unreadable > 0) {
|
|
190
209
|
mapOf("thread_names" to "$unreadable of ${named.size + unreadable} could not be read")
|
|
@@ -304,12 +304,16 @@ class StepUpKeyManager(private val context: Context) {
|
|
|
304
304
|
object : BiometricPrompt.AuthenticationCallback() {
|
|
305
305
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
306
306
|
val authorised = result.cryptoObject?.signature ?: signer
|
|
307
|
-
|
|
307
|
+
// Any failure here is told, a keystore's ProviderException
|
|
308
|
+
// among them: unanswered, this runs on the main thread, and
|
|
309
|
+
// an exception would end the app with the approval waiting.
|
|
310
|
+
val outcome: Result<String> = try {
|
|
308
311
|
authorised.update(payload)
|
|
309
|
-
|
|
310
|
-
} catch (error:
|
|
311
|
-
|
|
312
|
+
Result.success(base64Url(authorised.sign()))
|
|
313
|
+
} catch (error: Exception) {
|
|
314
|
+
Result.failure(signingFailed(error))
|
|
312
315
|
}
|
|
316
|
+
done(outcome)
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
@@ -443,23 +447,24 @@ class StepUpKeyManager(private val context: Context) {
|
|
|
443
447
|
payload: ByteArray,
|
|
444
448
|
done: (Result<String>) -> Unit,
|
|
445
449
|
) {
|
|
446
|
-
|
|
447
|
-
|
|
450
|
+
// Runs on the main thread after the person answered: every failure is
|
|
451
|
+
// told, as in authenticateOperation.
|
|
452
|
+
val outcome: Result<String> = try {
|
|
453
|
+
Result.success(signNow(entry, payload))
|
|
448
454
|
} catch (error: KeyPermanentlyInvalidatedException) {
|
|
449
|
-
|
|
455
|
+
Result.failure(discardDeadKey(error))
|
|
450
456
|
} catch (error: UserNotAuthenticatedException) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
error,
|
|
457
|
-
),
|
|
457
|
+
Result.failure(
|
|
458
|
+
InstallationKeyFailure(
|
|
459
|
+
"STEPUP_AUTH_FAILED",
|
|
460
|
+
"The keystore did not accept the authentication for the step-up key.",
|
|
461
|
+
error,
|
|
458
462
|
),
|
|
459
463
|
)
|
|
460
|
-
} catch (error:
|
|
461
|
-
|
|
464
|
+
} catch (error: Exception) {
|
|
465
|
+
Result.failure(signingFailed(error))
|
|
462
466
|
}
|
|
467
|
+
done(outcome)
|
|
463
468
|
}
|
|
464
469
|
|
|
465
470
|
/**
|
|
@@ -251,7 +251,7 @@
|
|
|
251
251
|
"description": "the app's version code"
|
|
252
252
|
},
|
|
253
253
|
"installer_package": {
|
|
254
|
-
"description": "the package that installed the app; null when none is named"
|
|
254
|
+
"description": "the package that installed the app; left out or null when none is named"
|
|
255
255
|
},
|
|
256
256
|
"source_dir": {
|
|
257
257
|
"description": "the base APK's path"
|
package/doc/integration_guide.md
CHANGED
|
@@ -117,8 +117,8 @@ so that you need not store it. The app answers with `installation_id`, `challeng
|
|
|
117
117
|
| `optional_probes` | the personal measurements you ask for, answered only where the app enables them (section 8) |
|
|
118
118
|
| `expires_at` | 60 seconds ahead |
|
|
119
119
|
|
|
120
|
-
Ask for every measurement you
|
|
121
|
-
tampered app cannot prepare its answers. **
|
|
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
122
|
hooks, gadgets and changes to the app's code in memory among them: on Android `runtime_maps`,
|
|
123
123
|
`frida_ports`, `instrumentation_threads`, `exec_mappings` and `code_integrity`; on iOS `dyld_images`,
|
|
124
124
|
`environment` and `code_integrity`. One left to chance is one a tampered app waits out, scanning again
|
|
@@ -629,8 +629,8 @@ normal looks like.
|
|
|
629
629
|
3. **The memory around it** (`exec_mappings`, on Android). Code loaded at run time needs executable
|
|
630
630
|
memory: memory both writable and executable (`wx_bytes`, `wx_size_classes`, `wx_largest_bytes`) and
|
|
631
631
|
executable regions with no file behind them (`anon_exec_unlabeled_bytes`). Some runtimes compile
|
|
632
|
-
code as they run
|
|
633
|
-
|
|
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
634
|
while the app runs, and maps it in blocks of one size (64 KiB on the phones measured; your own runs
|
|
635
635
|
show yours). Record the most of each your release maps, per OS version, processor and page size,
|
|
636
636
|
**over whole sessions through your app's main features, not at a cold start** — or a genuine
|
package/doc/measurements.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Measurements — what the SDKs measure on the phone
|
|
2
2
|
|
|
3
3
|
Every measurement a Necessary SDK can put in a signed report, field by field: what it is, how the
|
|
4
|
-
SDK measures it, and what a complete answer holds. Every SDK sends the same
|
|
5
|
-
your app is built with, and none sends a score or a verdict;
|
|
4
|
+
SDK measures it, and what a complete answer holds. Every SDK sends its report in the same format, whatever
|
|
5
|
+
framework your app is built with, and none sends a score or a verdict;
|
|
6
6
|
what a measurement means for a request is your backend's decision. The integration guide
|
|
7
7
|
(`integration_guide.md`, beside this file) is the protocol — how your backend asks for a scan and checks
|
|
8
8
|
the report — and its section 7 is about deciding with these measurements.
|
|
@@ -44,8 +44,8 @@ measurements — a probe — and each row of a table one of its fields.
|
|
|
44
44
|
Each probe says when it comes:
|
|
45
45
|
|
|
46
46
|
- **your backend asks for it**, in the challenge's `required_probes`. Ask every time for each
|
|
47
|
-
measurement you
|
|
48
|
-
- **every scan**, whether or not your backend asks.
|
|
47
|
+
measurement you need, and vary the others from scan to scan;
|
|
48
|
+
- **every scan**, whether or not your backend asks. If you need one, ask for it: until you do, its
|
|
49
49
|
absence or failure does not make a report incomplete;
|
|
50
50
|
- **a personal measurement**, only when your backend lists it in `optional_probes` and your app enables
|
|
51
51
|
it in its SDK (the SDK's README says how) — the location only with the person's permission as well.
|
|
@@ -93,7 +93,7 @@ APK that could not be hashed as `split_apks:<file name>`.
|
|
|
93
93
|
| `split_apks` | list | | the installed split APKs, apart from the base APK: each its file `name` and its `sha256`, sorted by name, at most 64; empty for an install without splits |
|
|
94
94
|
| `version_name` | text | | the app's version name |
|
|
95
95
|
| `version_code` | integer | | the app's version code |
|
|
96
|
-
| `installer_package` | text, or null | | the package that installed the app; null when none is named — installed with adb, or by an installer that names none |
|
|
96
|
+
| `installer_package` | text, or null | | the package that installed the app; left out or null when none is named — installed with adb, or by an installer that names none |
|
|
97
97
|
| `source_dir` | text | | the base APK's path on the phone |
|
|
98
98
|
|
|
99
99
|
### `debug_state`
|
|
@@ -881,20 +881,19 @@ final class IntegrityProbeManager {
|
|
|
881
881
|
var suspiciousCount = 0
|
|
882
882
|
var unnamed = 0
|
|
883
883
|
let imageCount = _dyld_image_count()
|
|
884
|
+
let bundlePath = Bundle.main.bundlePath
|
|
885
|
+
let mainName = Bundle.main.executableURL?.lastPathComponent
|
|
884
886
|
|
|
885
887
|
for index in 0..<imageCount {
|
|
886
888
|
guard let raw = _dyld_get_image_name(index) else {
|
|
887
889
|
unnamed += 1
|
|
888
890
|
continue
|
|
889
891
|
}
|
|
890
|
-
let
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
matched = true
|
|
896
|
-
}
|
|
897
|
-
if matched { suspiciousCount += 1 }
|
|
892
|
+
let named = ProbeReads.imageTokens(
|
|
893
|
+
path: String(cString: raw), bundlePath: bundlePath, mainExecutable: mainName,
|
|
894
|
+
tokens: IntegrityProbeManager.suspiciousImageTokens)
|
|
895
|
+
tokens.formUnion(named)
|
|
896
|
+
if !named.isEmpty { suspiciousCount += 1 }
|
|
898
897
|
}
|
|
899
898
|
|
|
900
899
|
var result: [String: Any] = [
|
package/ios/ProbeReads.swift
CHANGED
|
@@ -165,6 +165,23 @@ enum ProbeReads {
|
|
|
165
165
|
missing > 0 ? ["image_names": "\(missing) of \(total) could not be read"] : [:]
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/// The listed names in one loaded image's path, in any case. The app's own
|
|
169
|
+
/// bundle is read from the inside: its path and its main executable carry
|
|
170
|
+
/// the app's name, and neither names a tool. What the bundle holds beside
|
|
171
|
+
/// them is still read, so a library the app was rebuilt with is found by
|
|
172
|
+
/// its own name.
|
|
173
|
+
static func imageTokens(
|
|
174
|
+
path: String, bundlePath: String, mainExecutable: String?, tokens: [String]
|
|
175
|
+
) -> [String] {
|
|
176
|
+
var name = path
|
|
177
|
+
if path.hasPrefix(bundlePath) {
|
|
178
|
+
name = String(path.dropFirst(bundlePath.count).drop(while: { $0 == "/" }))
|
|
179
|
+
if name == mainExecutable { return [] }
|
|
180
|
+
}
|
|
181
|
+
let lower = name.lowercased()
|
|
182
|
+
return tokens.filter { lower.contains($0) }
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
/// How one app image's comparison ended.
|
|
169
186
|
enum ImageOutcome: Equatable {
|
|
170
187
|
/// Compared: its __text bytes and how many differ, and the same for
|
package/lib/deviceTrust.js
CHANGED
|
@@ -16,6 +16,7 @@ const report_1 = require("./report");
|
|
|
16
16
|
const requestProof_1 = require("./requestProof");
|
|
17
17
|
const stepUpKey_1 = require("./stepUpKey");
|
|
18
18
|
const transport_1 = require("./transport");
|
|
19
|
+
const answers_1 = require("./util/answers");
|
|
19
20
|
/**
|
|
20
21
|
* The personal measurements an app may enable (the public guide, section 8). Each is collected
|
|
21
22
|
* only when the backend asks for it as well -- the location only with the person's permission too.
|
|
@@ -276,15 +277,15 @@ class DeviceTrust {
|
|
|
276
277
|
this.current(generation);
|
|
277
278
|
try {
|
|
278
279
|
const challenge = await this.client.createInstallationChallenge(identity.installationId);
|
|
279
|
-
const payload = challenge
|
|
280
|
+
const payload = (0, answers_1.answerText)(challenge, 'payload');
|
|
280
281
|
const verified = await this.client.verifyInstallation({
|
|
281
282
|
installationId: identity.installationId,
|
|
282
|
-
challengeId: challenge
|
|
283
|
+
challengeId: (0, answers_1.answerText)(challenge, 'challenge_id'),
|
|
283
284
|
payload,
|
|
284
285
|
signature: await identity.signPayload(payload),
|
|
285
286
|
});
|
|
286
287
|
this.current(generation);
|
|
287
|
-
this.session = verified
|
|
288
|
+
this.session = (0, answers_1.answerText)(verified, 'device_token');
|
|
288
289
|
return this.session;
|
|
289
290
|
}
|
|
290
291
|
catch (error) {
|
|
@@ -566,21 +567,29 @@ class DeviceTrust {
|
|
|
566
567
|
const identity = await this.loadIdentity();
|
|
567
568
|
this.current(generation);
|
|
568
569
|
this.stepUpKeyNative.configuredMode = options.mode;
|
|
570
|
+
// From here the phone's key and the backend's differ until the backend takes the new one:
|
|
571
|
+
// approvals wait for that.
|
|
572
|
+
const holdApprovals = async () => {
|
|
573
|
+
await this.mutate(generation, () => this.store.setStepUpReenrolPending(true));
|
|
574
|
+
this.current(generation);
|
|
575
|
+
this.offeredStepUpKey = null;
|
|
576
|
+
this.stepUpUnavailable = DeviceTrust.reenrolPending();
|
|
577
|
+
};
|
|
569
578
|
const newKey = async (replace) => {
|
|
570
579
|
if (replace) {
|
|
571
|
-
|
|
572
|
-
// approvals wait for that.
|
|
573
|
-
await this.mutate(generation, () => this.store.setStepUpReenrolPending(true));
|
|
574
|
-
this.current(generation);
|
|
575
|
-
this.offeredStepUpKey = null;
|
|
576
|
-
this.stepUpUnavailable = DeviceTrust.reenrolPending();
|
|
580
|
+
await holdApprovals();
|
|
577
581
|
await this.mutate(generation, () => this.stepUpKeyNative.deleteKey());
|
|
578
582
|
}
|
|
579
|
-
|
|
583
|
+
const made = await this.mutate(generation, () => this.stepUpKeyNative.getOrCreateKey({
|
|
580
584
|
factor: options.factor,
|
|
581
585
|
mode: options.mode,
|
|
582
586
|
windowSeconds: options.windowSeconds,
|
|
583
587
|
}));
|
|
588
|
+
// None to replace, the phone having lost it: this one is as new.
|
|
589
|
+
if (made.created && !replace) {
|
|
590
|
+
await holdApprovals();
|
|
591
|
+
}
|
|
592
|
+
return made;
|
|
584
593
|
};
|
|
585
594
|
// A re-enrolment is the explicit recovery: a key the keystore cannot vouch for
|
|
586
595
|
// (STEPUP_KEY_UNAVAILABLE), or one that can never sign again, is replaced here -- and only here.
|
package/lib/report.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.SignedReport = exports.ScanChallenge = exports.shortenableLists = export
|
|
|
5
5
|
exports.fitReport = fitReport;
|
|
6
6
|
exports.collectReport = collectReport;
|
|
7
7
|
const errors_1 = require("./errors");
|
|
8
|
+
const answers_1 = require("./util/answers");
|
|
8
9
|
const bytes_1 = require("./util/bytes");
|
|
9
10
|
const native_1 = require("./util/native");
|
|
10
11
|
/** The most a backend takes: a report of 65,536 bytes of JSON (the measurement contract, section 1). */
|
|
@@ -67,17 +68,10 @@ class ScanChallenge {
|
|
|
67
68
|
}
|
|
68
69
|
static fromJson(value) {
|
|
69
70
|
const names = (list) => (Array.isArray(list) ? list.map((item) => String(item)) : []);
|
|
70
|
-
const text = (field) => {
|
|
71
|
-
const item = value[field];
|
|
72
|
-
if (typeof item !== 'string') {
|
|
73
|
-
throw new TypeError(`The scan challenge has no ${field}.`);
|
|
74
|
-
}
|
|
75
|
-
return item;
|
|
76
|
-
};
|
|
77
71
|
return new ScanChallenge({
|
|
78
|
-
challengeId:
|
|
79
|
-
nonce:
|
|
80
|
-
platform:
|
|
72
|
+
challengeId: (0, answers_1.answerText)(value, 'challenge_id'),
|
|
73
|
+
nonce: (0, answers_1.answerText)(value, 'nonce'),
|
|
74
|
+
platform: (0, answers_1.answerText)(value, 'platform'),
|
|
81
75
|
requiredProbes: names(value['required_probes']),
|
|
82
76
|
optionalProbes: names(value['optional_probes']),
|
|
83
77
|
answer: value,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Reading what your backend answered. */
|
|
2
|
+
/**
|
|
3
|
+
* The text [field] of an [answer] your backend gave. A field it left out is an [ApiException], as
|
|
4
|
+
* every failure of a call is: a cast would let it through unseen.
|
|
5
|
+
*/
|
|
6
|
+
export declare function answerText(answer: Record<string, unknown>, field: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Reading what your backend answered. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.answerText = answerText;
|
|
5
|
+
const errors_1 = require("../errors");
|
|
6
|
+
/**
|
|
7
|
+
* The text [field] of an [answer] your backend gave. A field it left out is an [ApiException], as
|
|
8
|
+
* every failure of a call is: a cast would let it through unseen.
|
|
9
|
+
*/
|
|
10
|
+
function answerText(answer, field) {
|
|
11
|
+
const value = answer[field];
|
|
12
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
throw new errors_1.ApiException(`The backend answered no ${field}.`, { code: 'invalid_backend_answer' });
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@necessarysdk/react-native",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Device integrity for React Native apps: a key the SDK never exports, signed measurements of the phone and the app, and request proofs. The app's backend decides.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
package/src/deviceTrust.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from './requestProof';
|
|
22
22
|
import { NativeStepUpKey, stepUpReenrolProof, StepUpKeyMetadata, StepUpOptions } from './stepUpKey';
|
|
23
23
|
import { HttpTransport, type DeviceTrustTransport, type JsonObject } from './transport';
|
|
24
|
+
import { answerText } from './util/answers';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* The personal measurements an app may enable (the public guide, section 8). Each is collected
|
|
@@ -376,15 +377,15 @@ export class DeviceTrust {
|
|
|
376
377
|
this.current(generation);
|
|
377
378
|
try {
|
|
378
379
|
const challenge = await this.client.createInstallationChallenge(identity.installationId);
|
|
379
|
-
const payload = challenge
|
|
380
|
+
const payload = answerText(challenge, 'payload');
|
|
380
381
|
const verified = await this.client.verifyInstallation({
|
|
381
382
|
installationId: identity.installationId,
|
|
382
|
-
challengeId: challenge
|
|
383
|
+
challengeId: answerText(challenge, 'challenge_id'),
|
|
383
384
|
payload,
|
|
384
385
|
signature: await identity.signPayload(payload),
|
|
385
386
|
});
|
|
386
387
|
this.current(generation);
|
|
387
|
-
this.session = verified
|
|
388
|
+
this.session = answerText(verified, 'device_token');
|
|
388
389
|
return this.session;
|
|
389
390
|
} catch (error) {
|
|
390
391
|
if (!(error instanceof ApiException) || retried) {
|
|
@@ -711,23 +712,31 @@ export class DeviceTrust {
|
|
|
711
712
|
const identity = await this.loadIdentity();
|
|
712
713
|
this.current(generation);
|
|
713
714
|
this.stepUpKeyNative.configuredMode = options.mode;
|
|
715
|
+
// From here the phone's key and the backend's differ until the backend takes the new one:
|
|
716
|
+
// approvals wait for that.
|
|
717
|
+
const holdApprovals = async (): Promise<void> => {
|
|
718
|
+
await this.mutate(generation, () => this.store.setStepUpReenrolPending(true));
|
|
719
|
+
this.current(generation);
|
|
720
|
+
this.offeredStepUpKey = null;
|
|
721
|
+
this.stepUpUnavailable = DeviceTrust.reenrolPending();
|
|
722
|
+
};
|
|
714
723
|
const newKey = async (replace: boolean): Promise<StepUpKeyMetadata> => {
|
|
715
724
|
if (replace) {
|
|
716
|
-
|
|
717
|
-
// approvals wait for that.
|
|
718
|
-
await this.mutate(generation, () => this.store.setStepUpReenrolPending(true));
|
|
719
|
-
this.current(generation);
|
|
720
|
-
this.offeredStepUpKey = null;
|
|
721
|
-
this.stepUpUnavailable = DeviceTrust.reenrolPending();
|
|
725
|
+
await holdApprovals();
|
|
722
726
|
await this.mutate(generation, () => this.stepUpKeyNative.deleteKey());
|
|
723
727
|
}
|
|
724
|
-
|
|
728
|
+
const made = await this.mutate(generation, () =>
|
|
725
729
|
this.stepUpKeyNative.getOrCreateKey({
|
|
726
730
|
factor: options.factor,
|
|
727
731
|
mode: options.mode,
|
|
728
732
|
windowSeconds: options.windowSeconds,
|
|
729
733
|
}),
|
|
730
734
|
);
|
|
735
|
+
// None to replace, the phone having lost it: this one is as new.
|
|
736
|
+
if (made.created && !replace) {
|
|
737
|
+
await holdApprovals();
|
|
738
|
+
}
|
|
739
|
+
return made;
|
|
731
740
|
};
|
|
732
741
|
|
|
733
742
|
// A re-enrolment is the explicit recovery: a key the keystore cannot vouch for
|
package/src/report.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { NativeIntegrityCollector } from './collector';
|
|
4
4
|
import { ApiException } from './errors';
|
|
5
|
+
import { answerText } from './util/answers';
|
|
5
6
|
import { base64UrlNoPadding, utf8Encode } from './util/bytes';
|
|
6
7
|
import { isObject } from './util/native';
|
|
7
8
|
import type { InstallationIdentity } from './installationKey';
|
|
@@ -87,17 +88,10 @@ export class ScanChallenge {
|
|
|
87
88
|
|
|
88
89
|
static fromJson(value: Record<string, unknown>): ScanChallenge {
|
|
89
90
|
const names = (list: unknown): string[] => (Array.isArray(list) ? list.map((item) => String(item)) : []);
|
|
90
|
-
const text = (field: string): string => {
|
|
91
|
-
const item = value[field];
|
|
92
|
-
if (typeof item !== 'string') {
|
|
93
|
-
throw new TypeError(`The scan challenge has no ${field}.`);
|
|
94
|
-
}
|
|
95
|
-
return item;
|
|
96
|
-
};
|
|
97
91
|
return new ScanChallenge({
|
|
98
|
-
challengeId:
|
|
99
|
-
nonce:
|
|
100
|
-
platform:
|
|
92
|
+
challengeId: answerText(value, 'challenge_id'),
|
|
93
|
+
nonce: answerText(value, 'nonce'),
|
|
94
|
+
platform: answerText(value, 'platform'),
|
|
101
95
|
requiredProbes: names(value['required_probes']),
|
|
102
96
|
optionalProbes: names(value['optional_probes']),
|
|
103
97
|
answer: value,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reading what your backend answered. */
|
|
2
|
+
|
|
3
|
+
import { ApiException } from '../errors';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The text [field] of an [answer] your backend gave. A field it left out is an [ApiException], as
|
|
7
|
+
* every failure of a call is: a cast would let it through unseen.
|
|
8
|
+
*/
|
|
9
|
+
export function answerText(answer: Record<string, unknown>, field: string): string {
|
|
10
|
+
const value = answer[field];
|
|
11
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
throw new ApiException(`The backend answered no ${field}.`, { code: 'invalid_backend_answer' });
|
|
15
|
+
}
|