@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 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
- "This prototype requires Android 6.0 (API 23) or newer.",
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 { original ->
752
+ file.forEachLine { line ->
750
753
  mapsLines++
751
- val line = original.lowercase(Locale.US)
752
- var lineMatched = false
753
- for (token in suspiciousRuntimeTokens) {
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(names, unreadable, fridaThreads, glibThreads, suspiciousRuntimeTokens)
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 = listOfNotNull(
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; the
52
- // collector itself never asks.
53
- private var pendingLocation: Promise? = null
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 (locationState() == "precise" || pendingLocation != null || activity !is PermissionAwareActivity) {
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
- pendingLocation?.resolve(locationState())
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
- for (t in tokens) if (lower.contains(t)) tokenHits.add(t)
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
- try {
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
- done(Result.success(base64Url(authorised.sign())))
310
- } catch (error: GeneralSecurityException) {
311
- done(Result.failure(signingFailed(error)))
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
- try {
447
- done(Result.success(signNow(entry, payload)))
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
- done(Result.failure(discardDeadKey(error)))
455
+ Result.failure(discardDeadKey(error))
450
456
  } catch (error: UserNotAuthenticatedException) {
451
- done(
452
- Result.failure(
453
- InstallationKeyFailure(
454
- "STEPUP_AUTH_FAILED",
455
- "The keystore did not accept the authentication for the step-up key.",
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: GeneralSecurityException) {
461
- done(Result.failure(signingFailed(error)))
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"
@@ -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 rely on every time, and vary the others from scan to scan, so that a
121
- tampered app cannot prepare its answers. **Rely on every measurement that would catch tampering** — the
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 and map some of each — one that compiles each method the first time it runs,
633
- say. Such a runtime shows itself by how it grows: it maps more as more of the app is used, keeps it
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
@@ -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 report, whatever framework
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 rely on, and vary the others from scan to scan;
48
- - **every scan**, whether or not your backend asks. Rely on one only by asking for it: until you do, its
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 name = String(cString: raw).lowercased()
891
- var matched = false
892
- for token in IntegrityProbeManager.suspiciousImageTokens
893
- where name.contains(token) {
894
- tokens.insert(token)
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] = [
@@ -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
@@ -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['payload'];
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['challenge_id'],
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['device_token'];
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
- // From here the phone's key and the backend's differ until the backend takes the new one:
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
- return this.mutate(generation, () => this.stepUpKeyNative.getOrCreateKey({
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: text('challenge_id'),
79
- nonce: text('nonce'),
80
- platform: text('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.5",
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",
@@ -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['payload'] as string;
380
+ const payload = answerText(challenge, 'payload');
380
381
  const verified = await this.client.verifyInstallation({
381
382
  installationId: identity.installationId,
382
- challengeId: challenge['challenge_id'] as string,
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['device_token'] as string;
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
- // From here the phone's key and the backend's differ until the backend takes the new one:
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
- return this.mutate(generation, () =>
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: text('challenge_id'),
99
- nonce: text('nonce'),
100
- platform: text('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
+ }