@necessarysdk/react-native 0.0.0-stage → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +2 -0
  4. package/NecessaryReactNative.podspec +23 -0
  5. package/README.md +429 -2
  6. package/android/build.gradle +83 -0
  7. package/android/src/main/AndroidManifest.xml +41 -0
  8. package/android/src/main/cpp/CMakeLists.txt +7 -0
  9. package/android/src/main/cpp/code_integrity.cpp +484 -0
  10. package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
  11. package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
  12. package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
  13. package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
  14. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
  15. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
  16. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
  17. package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
  18. package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
  19. package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
  20. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
  21. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
  22. package/contract/report-1/android-8-ios-5/RULES.md +149 -0
  23. package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
  24. package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
  25. package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
  26. package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
  27. package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
  28. package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
  29. package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
  30. package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
  31. package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
  32. package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
  33. package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
  34. package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
  35. package/doc/integration_guide.md +771 -0
  36. package/doc/measurements.md +774 -0
  37. package/ios/InstallationKeyManager.swift +694 -0
  38. package/ios/IntegrityProbeManager.swift +959 -0
  39. package/ios/NecessaryModules.h +34 -0
  40. package/ios/NecessaryModules.mm +208 -0
  41. package/ios/NecessaryNative.swift +327 -0
  42. package/ios/PrivacyInfo.xcprivacy +14 -0
  43. package/ios/ProbeReads.swift +370 -0
  44. package/lib/client.d.ts +105 -0
  45. package/lib/client.js +176 -0
  46. package/lib/collector.d.ts +18 -0
  47. package/lib/collector.js +49 -0
  48. package/lib/deviceTrust.d.ts +253 -0
  49. package/lib/deviceTrust.js +699 -0
  50. package/lib/errors.d.ts +43 -0
  51. package/lib/errors.js +56 -0
  52. package/lib/index.d.ts +18 -0
  53. package/lib/index.js +34 -0
  54. package/lib/installationKey.d.ts +62 -0
  55. package/lib/installationKey.js +185 -0
  56. package/lib/installationStore.d.ts +49 -0
  57. package/lib/installationStore.js +127 -0
  58. package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
  59. package/lib/native/NativeNecessaryInstallationKey.js +4 -0
  60. package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
  61. package/lib/native/NativeNecessaryIntegrity.js +4 -0
  62. package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
  63. package/lib/native/NativeNecessarySecureScreen.js +4 -0
  64. package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
  65. package/lib/native/NativeNecessaryStepUpKey.js +4 -0
  66. package/lib/native/NativeNecessaryStorage.d.ts +14 -0
  67. package/lib/native/NativeNecessaryStorage.js +4 -0
  68. package/lib/protocol.d.ts +24 -0
  69. package/lib/protocol.js +50 -0
  70. package/lib/reinstallHint.d.ts +23 -0
  71. package/lib/reinstallHint.js +65 -0
  72. package/lib/report.d.ts +62 -0
  73. package/lib/report.js +134 -0
  74. package/lib/requestProof.d.ts +116 -0
  75. package/lib/requestProof.js +220 -0
  76. package/lib/secureScreen.d.ts +15 -0
  77. package/lib/secureScreen.js +32 -0
  78. package/lib/stepUpKey.d.ts +105 -0
  79. package/lib/stepUpKey.js +187 -0
  80. package/lib/transport.d.ts +39 -0
  81. package/lib/transport.js +117 -0
  82. package/lib/util/answers.d.ts +6 -0
  83. package/lib/util/answers.js +16 -0
  84. package/lib/util/bytes.d.ts +18 -0
  85. package/lib/util/bytes.js +207 -0
  86. package/lib/util/native.d.ts +20 -0
  87. package/lib/util/native.js +49 -0
  88. package/lib/util/release.d.ts +2 -0
  89. package/lib/util/release.js +7 -0
  90. package/lib/util/target.d.ts +18 -0
  91. package/lib/util/target.js +128 -0
  92. package/package.json +81 -4
  93. package/src/client.ts +221 -0
  94. package/src/collector.ts +64 -0
  95. package/src/deviceTrust.ts +851 -0
  96. package/src/errors.ts +71 -0
  97. package/src/index.ts +19 -0
  98. package/src/installationKey.ts +223 -0
  99. package/src/installationStore.ts +143 -0
  100. package/src/native/NativeNecessaryInstallationKey.ts +12 -0
  101. package/src/native/NativeNecessaryIntegrity.ts +15 -0
  102. package/src/native/NativeNecessarySecureScreen.ts +9 -0
  103. package/src/native/NativeNecessaryStepUpKey.ts +11 -0
  104. package/src/native/NativeNecessaryStorage.ts +16 -0
  105. package/src/protocol.ts +52 -0
  106. package/src/reinstallHint.ts +69 -0
  107. package/src/report.ts +168 -0
  108. package/src/requestProof.ts +295 -0
  109. package/src/secureScreen.ts +29 -0
  110. package/src/stepUpKey.ts +238 -0
  111. package/src/transport.ts +145 -0
  112. package/src/util/answers.ts +15 -0
  113. package/src/util/bytes.ts +194 -0
  114. package/src/util/native.ts +56 -0
  115. package/src/util/release.ts +6 -0
  116. package/src/util/target.ts +132 -0
  117. package/vectors/client_server.json +264 -0
@@ -0,0 +1,370 @@
1
+ import Foundation
2
+
3
+ /// What the iOS collector makes of what it read: pure functions with no
4
+ /// Apple-only framework in them, so they compile and can be tested on any
5
+ /// machine - the counterpart of the Android collector's reads. The
6
+ /// collector reads Mach-O headers, files and
7
+ /// Core Location; these decide what the reads mean.
8
+ enum ProbeReads {
9
+
10
+ // MARK: - App-code coverage
11
+
12
+ /// The largest __TEXT segment read from disk for one comparison.
13
+ static let maxTextSegment: UInt64 = 64 * 1024 * 1024
14
+ /// Images not compared that are named, at most (the server's list bound).
15
+ static let maxNamedImages = 64
16
+
17
+ /// Where an image's __text lies, as its load commands state it.
18
+ struct TextSpan: Equatable {
19
+ /// The whole __TEXT segment: its file offset and size, and where it
20
+ /// is mapped before the image's slide.
21
+ let segmentFileOffset: Int
22
+ let segmentSize: Int
23
+ let segmentVMAddress: UInt64
24
+ /// The __text section inside that segment: only this range is scored.
25
+ let textOffsetInSegment: Int
26
+ let textSize: Int
27
+ }
28
+
29
+ /// Why one app image's __text was not compared, and how many __text bytes
30
+ /// that leaves out: 0 when its size could not be read.
31
+ struct CoverageProblem: Error, Equatable {
32
+ let reason: String
33
+ let textSize: Int
34
+ }
35
+
36
+ /// The __text section of a __TEXT segment, or why it cannot be compared.
37
+ /// The section is checked before the segment's budget is applied, so an
38
+ /// image over the budget is skipped with its size rather than dropped
39
+ /// unseen; the header's unsigned values are checked before any becomes an
40
+ /// Int. `section` is nil when the segment names no __text.
41
+ static func textSpan(
42
+ segmentFileOffset fileOffset: UInt64,
43
+ segmentFileSize fileSize: UInt64,
44
+ segmentVMAddress: UInt64,
45
+ section: (offset: UInt64, size: UInt64)?
46
+ ) -> Result<TextSpan, CoverageProblem> {
47
+ guard let section = section else {
48
+ return .failure(CoverageProblem(reason: "no __text section in __TEXT", textSize: 0))
49
+ }
50
+ let size = section.size
51
+ guard size > 0 else {
52
+ return .failure(CoverageProblem(reason: "an empty __text section", textSize: 0))
53
+ }
54
+ guard section.offset >= fileOffset, size <= fileSize,
55
+ section.offset - fileOffset <= fileSize - size else {
56
+ return .failure(CoverageProblem(reason: "__text lies outside its __TEXT segment", textSize: 0))
57
+ }
58
+ guard fileSize <= maxTextSegment else {
59
+ return .failure(CoverageProblem(
60
+ reason: "__TEXT of \(fileSize) bytes is over the 64 MiB budget",
61
+ textSize: size <= 1 << 40 ? Int(size) : 0))
62
+ }
63
+ return .success(TextSpan(
64
+ segmentFileOffset: Int(clamping: fileOffset),
65
+ segmentSize: Int(fileSize),
66
+ segmentVMAddress: segmentVMAddress,
67
+ textOffsetInSegment: Int(section.offset - fileOffset),
68
+ textSize: Int(size)
69
+ ))
70
+ }
71
+
72
+ /// Whether an image's encryption keeps its __text from being compared, or
73
+ /// nil. The App Store encrypts an app's code on disk (LC_ENCRYPTION_INFO_64,
74
+ /// `cryptid` 1) and iOS decrypts it as it maps it, so a genuine image's file
75
+ /// and memory differ in every encrypted byte. An encrypted range that
76
+ /// overlaps __text makes the image one that cannot be compared: skipped,
77
+ /// with its __text bytes -- never compared, never clean. Offsets are the
78
+ /// file's, as `span`'s are; `cryptid` 0 is an image stored in the clear.
79
+ static func encryptionProblem(
80
+ cryptOffset: UInt32, cryptSize: UInt32, cryptID: UInt32, span: TextSpan
81
+ ) -> CoverageProblem? {
82
+ guard cryptID != 0, cryptSize > 0 else { return nil }
83
+ let textStart = UInt64(span.segmentFileOffset) + UInt64(span.textOffsetInSegment)
84
+ let textEnd = textStart + UInt64(span.textSize)
85
+ let cryptStart = UInt64(cryptOffset)
86
+ let cryptEnd = cryptStart + UInt64(cryptSize)
87
+ guard cryptStart < textEnd, textStart < cryptEnd else { return nil }
88
+ return CoverageProblem(
89
+ reason: "encrypted on disk (App Store): its code cannot be compared with its file",
90
+ textSize: span.textSize)
91
+ }
92
+
93
+ /// Why a read of `wanted` bytes is not the whole of them, or nil: a short
94
+ /// read is unreadable, never a comparison of part.
95
+ static func readProblem(got: Int, wanted: Int) -> String? {
96
+ return got == wanted ? nil : "unreadable: \(got) of \(wanted) bytes read"
97
+ }
98
+
99
+ /// Where the arm64 image starts in its file: 0 when the file is a 64-bit
100
+ /// Mach-O image; the arm64 slice's offset in a universal ("fat") file, the
101
+ /// form a build keeps its frameworks in when nothing thinned them (an ad
102
+ /// hoc or enterprise install, say); nil when it is neither. The image's own
103
+ /// offsets count from there, never from the file's start.
104
+ static func arm64Slice(_ head: [UInt8]) -> Int? {
105
+ func big(_ at: Int) -> Int? {
106
+ guard at + 4 <= head.count else { return nil }
107
+ return Int(head[at]) << 24 | Int(head[at + 1]) << 16 | Int(head[at + 2]) << 8 | Int(head[at + 3])
108
+ }
109
+ guard head.count >= 8 else { return nil }
110
+ let little = Int(head[0]) | Int(head[1]) << 8 | Int(head[2]) << 16 | Int(head[3]) << 24
111
+ if little == 0xfeed_facf { return 0 }
112
+ guard big(0) == 0xcafe_babe, let count = big(4), count < 64 else { return nil }
113
+ for index in 0..<count {
114
+ guard let cpu = big(8 + index * 20), let offset = big(8 + index * 20 + 8) else { return nil }
115
+ if cpu == 0x0100_000c { return offset } // CPU_TYPE_ARM64
116
+ }
117
+ return nil
118
+ }
119
+
120
+ /// The LC_UUID of a 64-bit Mach-O image, from its header and load commands
121
+ /// as read from its file, or nil when they hold none or cannot be walked.
122
+ static func machOUUID(_ bytes: [UInt8]) -> [UInt8]? {
123
+ func word(_ at: Int) -> Int {
124
+ Int(bytes[at]) | Int(bytes[at + 1]) << 8 | Int(bytes[at + 2]) << 16 | Int(bytes[at + 3]) << 24
125
+ }
126
+ guard bytes.count >= 32, word(0) == 0xfeed_facf else { return nil }
127
+ var offset = 32
128
+ for _ in 0..<word(16) {
129
+ guard offset + 8 <= bytes.count else { return nil }
130
+ let command = word(offset), size = word(offset + 4)
131
+ guard size >= 8, offset + size <= bytes.count else { return nil }
132
+ if command == 0x1b { // LC_UUID
133
+ return size >= 24 ? Array(bytes[(offset + 8)..<(offset + 24)]) : nil
134
+ }
135
+ offset += size
136
+ }
137
+ return nil
138
+ }
139
+
140
+ /// Whether the file on disk is the build that was loaded: its LC_UUID
141
+ /// against the loaded image's. An app updated while it ran -- TrollStore
142
+ /// installs over a running app, an in-place update, a development install --
143
+ /// runs the old code with the new file on disk, and the two then differ in
144
+ /// many bytes nobody changed: such an image is skipped with its __text
145
+ /// bytes, never compared. Nil when they match, or the loaded image has no
146
+ /// LC_UUID to tell by.
147
+ static func buildProblem(loaded: [UInt8]?, onDisk: [UInt8]?, textSize: Int) -> CoverageProblem? {
148
+ guard let loaded = loaded, loaded != onDisk else { return nil }
149
+ return CoverageProblem(
150
+ reason: "another build on disk than the one running (LC_UUID differs): the app was updated while it ran",
151
+ textSize: textSize)
152
+ }
153
+
154
+ /// An image dyld counts but gives no name or header for (it is being
155
+ /// unloaded as the list is walked): never dropped unsaid. It may be the
156
+ /// app's, so it is an app image that could not be read, with no bytes.
157
+ static func unlistedImage(index: Int) -> AppImage {
158
+ AppImage(path: "image \(index)", isMainExecutable: false,
159
+ outcome: .unreadable(textBytes: 0, why: "dyld gave no name or header for it"))
160
+ }
161
+
162
+ /// The images whose names dyld did not give, as `unread` says it: never a
163
+ /// shorter list read as all of them.
164
+ static func unnamedImages(_ missing: Int, of total: Int) -> [String: String] {
165
+ missing > 0 ? ["image_names": "\(missing) of \(total) could not be read"] : [:]
166
+ }
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
+
185
+ /// How one app image's comparison ended.
186
+ enum ImageOutcome: Equatable {
187
+ /// Compared: its __text bytes and how many differ, and the same for
188
+ /// the whole __TEXT segment (telemetry).
189
+ case compared(textBytes: Int, textDiffering: Int, segmentBytes: Int, segmentDiffering: Int)
190
+ /// Its file could not be read in full.
191
+ case unreadable(textBytes: Int, why: String)
192
+ /// Not compared: no usable __text, a __TEXT over the budget, no live
193
+ /// address. Its __text bytes when known, else 0.
194
+ case skipped(textBytes: Int, why: String)
195
+ }
196
+
197
+ /// One image the loader mapped from the app's bundle.
198
+ struct AppImage: Equatable {
199
+ /// Its path in the bundle (bundleLabel).
200
+ let path: String
201
+ let isMainExecutable: Bool
202
+ let outcome: ImageOutcome
203
+ }
204
+
205
+ /// An image's path in the bundle, as the report names it: at most 128
206
+ /// characters, its end kept; "(bundle)" for the bundle itself.
207
+ static func bundleLabel(imagePath: String, bundlePath: String) -> String {
208
+ let inBundle = String(imagePath.dropFirst(bundlePath.count).drop(while: { $0 == "/" }))
209
+ return inBundle.isEmpty ? "(bundle)" : String(inBundle.suffix(128))
210
+ }
211
+
212
+ /// The app bucket's figures from every bundle image's outcome (collector
213
+ /// 5): every image expected, and each compared, unreadable or skipped,
214
+ /// with its __text bytes; each one not compared named with why; complete
215
+ /// only when every image was compared - the images decide, since one
216
+ /// whose size could not be read adds no bytes. `checked` means the app
217
+ /// bucket was genuinely compared: not one image, nothing measured.
218
+ static func appCoverage(_ images: [AppImage], systemImagesUnreadable: Int) -> [String: Any] {
219
+ var compared = 0, unreadable = 0, skipped = 0
220
+ var expectedBytes = 0, comparedBytes = 0, unreadableBytes = 0, skippedBytes = 0
221
+ var differing = 0, imagesDiffering = 0, segmentCompared = 0, segmentDiffering = 0
222
+ var diffed: [String] = []
223
+ var notCompared: [String: String] = [:]
224
+ var mainCompared = false
225
+
226
+ func name(_ image: AppImage, _ why: String) {
227
+ if notCompared.count < maxNamedImages {
228
+ notCompared[image.path] = String(why.prefix(160))
229
+ }
230
+ }
231
+
232
+ for image in images {
233
+ switch image.outcome {
234
+ case let .compared(textBytes, textDiffering, segmentBytes, segmentDiff):
235
+ compared += 1
236
+ expectedBytes += textBytes
237
+ comparedBytes += textBytes
238
+ segmentCompared += segmentBytes
239
+ segmentDiffering += segmentDiff
240
+ if image.isMainExecutable { mainCompared = true }
241
+ if textDiffering > 0 {
242
+ differing += textDiffering
243
+ imagesDiffering += 1
244
+ diffed.append(image.path.split(separator: "/").last.map(String.init) ?? image.path)
245
+ }
246
+ case let .unreadable(textBytes, why):
247
+ unreadable += 1
248
+ expectedBytes += textBytes
249
+ unreadableBytes += textBytes
250
+ name(image, why)
251
+ case let .skipped(textBytes, why):
252
+ skipped += 1
253
+ expectedBytes += textBytes
254
+ skippedBytes += textBytes
255
+ name(image, why)
256
+ }
257
+ }
258
+
259
+ var result: [String: Any] = [
260
+ "checked": compared > 0,
261
+ "app_compared_bytes": comparedBytes,
262
+ "app_diff_bytes": differing,
263
+ "app_libs_diff": imagesDiffering,
264
+ "app_images_compared": compared,
265
+ "system_images_unreadable": systemImagesUnreadable,
266
+ "app_images_expected": images.count,
267
+ "app_images_unreadable": unreadable,
268
+ "app_images_skipped": skipped,
269
+ "app_expected_bytes": expectedBytes,
270
+ "app_unreadable_bytes": unreadableBytes,
271
+ "app_skipped_bytes": skippedBytes,
272
+ "app_complete": !images.isEmpty && compared == images.count,
273
+ "main_executable_compared": mainCompared,
274
+ // Telemetry only. Never scored until it has a hardware baseline.
275
+ "app_segment_compared_bytes": segmentCompared,
276
+ "app_segment_diff_bytes": segmentDiffering,
277
+ "diffed_libs": diffed.joined(separator: ","),
278
+ ]
279
+ if !notCompared.isEmpty {
280
+ result["app_images_not_compared"] = notCompared
281
+ }
282
+ return result
283
+ }
284
+
285
+ // MARK: - One location read per collection
286
+
287
+ /// One fix as the collector captured it from Core Location.
288
+ struct Fix: Equatable {
289
+ let latitude: Double
290
+ let longitude: Double
291
+ /// Negative when unknown, as Core Location reports it.
292
+ let horizontalAccuracy: Double
293
+ let timestamp: Date
294
+ /// From iOS 15, where the fix says; nil where it cannot.
295
+ let simulatedBySoftware: Bool?
296
+ let producedByAccessory: Bool?
297
+ }
298
+
299
+ /// One location read: the permission, the fix the phone already held -
300
+ /// none without the permission - and its age when read.
301
+ struct LocationRead: Equatable {
302
+ let permission: String
303
+ let fix: Fix?
304
+ let ageSeconds: Int
305
+ }
306
+
307
+ static func read(permission: String, fix: Fix?, now: Date) -> LocationRead {
308
+ let held = permission == "none" ? nil : fix
309
+ let age = held.map { max(0, Int(now.timeIntervalSince($0.timestamp))) } ?? 0
310
+ return LocationRead(permission: permission, fix: held, ageSeconds: age)
311
+ }
312
+
313
+ /// The collection's one location read: made on first use and never again,
314
+ /// so the coordinates and the mock flag describe one fix. Each collection
315
+ /// makes its own.
316
+ final class LocationCapture {
317
+ private let read: () -> LocationRead
318
+ private var made: LocationRead?
319
+
320
+ init(_ read: @escaping () -> LocationRead) {
321
+ self.read = read
322
+ }
323
+
324
+ var value: LocationRead {
325
+ if let made = made { return made }
326
+ let fresh = self.read()
327
+ made = fresh
328
+ return fresh
329
+ }
330
+ }
331
+
332
+ /// The last known location: the coordinates of the read's fix.
333
+ static func location(_ read: LocationRead) -> [String: Any] {
334
+ var result: [String: Any] = ["status": "ok", "permission": read.permission]
335
+ guard let fix = read.fix else { return result }
336
+ result["latitude"] = fix.latitude
337
+ result["longitude"] = fix.longitude
338
+ if fix.horizontalAccuracy >= 0 { result["accuracy_m"] = fix.horizontalAccuracy }
339
+ result["age_seconds"] = read.ageSeconds
340
+ result["fix_time"] = Int(fix.timestamp.timeIntervalSince1970)
341
+ return result
342
+ }
343
+
344
+ /// Whether the read's fix was simulated by software or produced by an
345
+ /// accessory: never its coordinates. Before iOS 15 the fix cannot say.
346
+ static func mockLocation(_ read: LocationRead) -> [String: Any] {
347
+ var result: [String: Any] = ["status": "ok", "permission": read.permission]
348
+ guard let fix = read.fix else { return result }
349
+ if let simulated = fix.simulatedBySoftware { result["mock"] = simulated }
350
+ if let accessory = fix.producedByAccessory { result["accessory"] = accessory }
351
+ result["age_seconds"] = read.ageSeconds
352
+ result["fix_time"] = Int(fix.timestamp.timeIntervalSince1970)
353
+ return result
354
+ }
355
+
356
+ /// Screen capture (collector 6): `recording` when a screen showing the
357
+ /// app's window could be asked. With none - the app in the background, or
358
+ /// before its first window - recording is not measured, never "no", and
359
+ /// `recording_status` says why: `unavailable`, as on Android with no window
360
+ /// to ask. The other screens attached are counted either way.
361
+ static func screenCapture(captured: Bool?, otherScreens: Int) -> [String: Any] {
362
+ var result: [String: Any] = ["status": "ok", "external_displays": max(0, otherScreens)]
363
+ if let captured = captured {
364
+ result["recording"] = captured
365
+ } else {
366
+ result["recording_status"] = "unavailable"
367
+ }
368
+ return result
369
+ }
370
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The public guide's five calls (section 4) over a [DeviceTrustTransport], the two scan calls each
3
+ * with its request proof (section 3), and the app PIN's two calls.
4
+ */
5
+ import type { InstallationIdentity } from './installationKey';
6
+ import type { ReinstallHint } from './reinstallHint';
7
+ import { ScanChallenge, type SignedReport } from './report';
8
+ import { RequestProof } from './requestProof';
9
+ import type { StepUpKeyMetadata } from './stepUpKey';
10
+ import type { DeviceTrustTransport, JsonObject } from './transport';
11
+ export declare class DeviceTrustClient {
12
+ /** The app PIN's default paths (the public guide, section 4). */
13
+ static readonly appPinPath = "/v1/account/app-pin";
14
+ static readonly appPinProofPath = "/v1/account/app-pin/proof";
15
+ /** The fields of the guide's registration, which [registerInstallation]'s `extra` cannot replace. */
16
+ static readonly registrationFields: ReadonlySet<string>;
17
+ readonly transport: DeviceTrustTransport;
18
+ private readonly platform;
19
+ /** [platform] replaces the phone's (`android` or `ios`) in tests. */
20
+ constructor(transport: DeviceTrustTransport, options?: {
21
+ platform?: () => string;
22
+ });
23
+ /**
24
+ * Sends [proof]'s request. [method], [path] and [encodedBody] replace what the proof was signed
25
+ * for -- only to test that a backend refuses them.
26
+ */
27
+ sendSigned(proof: RequestProof, overrides?: {
28
+ method?: string;
29
+ path?: string;
30
+ encodedBody?: string;
31
+ }): Promise<JsonObject>;
32
+ /**
33
+ * Signs and sends one request under [bearerToken], its [body] as JSON. [installationId] replaces
34
+ * the installation the proof names, only to test that a backend refuses it.
35
+ */
36
+ signAndSend(method: string, path: string, args: {
37
+ bearerToken: string;
38
+ identity: InstallationIdentity;
39
+ body?: Record<string, unknown> | null;
40
+ installationId?: string | null;
41
+ }): Promise<JsonObject>;
42
+ /**
43
+ * Registers the installation's key, and with [stepUpKey] the step-up key. The answer's
44
+ * `installation_id` is the installation your backend holds for this key, which may not be the one
45
+ * proposed. [extra] adds fields of your backend's own.
46
+ */
47
+ registerInstallation(identity: InstallationIdentity, hint: ReinstallHint | null, options?: {
48
+ stepUpKey?: StepUpKeyMetadata | null;
49
+ extra?: Record<string, unknown>;
50
+ }): Promise<JsonObject>;
51
+ /** Asks for a key challenge: `challenge_id` and the `payload` to sign. */
52
+ createInstallationChallenge(installationId: string): Promise<JsonObject>;
53
+ /** Answers a key challenge; the answer carries `device_token`, your backend's session for this installation. */
54
+ verifyInstallation(args: {
55
+ installationId: string;
56
+ challengeId: string;
57
+ payload: string;
58
+ signature: string;
59
+ }): Promise<JsonObject>;
60
+ /**
61
+ * Asks for a scan under [session]: the installation's session, from [verifyInstallation] -- or,
62
+ * for a scan that approves one [operation] ([describeOperation]), your account session for this
63
+ * installation.
64
+ */
65
+ integrityChallenge(args: {
66
+ session: string;
67
+ identity: InstallationIdentity;
68
+ operation?: Record<string, unknown> | null;
69
+ }): Promise<ScanChallenge>;
70
+ /**
71
+ * Sends [report] under the [session] that asked for its scan. The answer is your backend's own;
72
+ * for a scan that approves one request it carries `report_id`, sent with that request as
73
+ * `X-Integrity-Report`.
74
+ */
75
+ submitIntegrityReport(args: {
76
+ session: string;
77
+ identity: InstallationIdentity;
78
+ report: SignedReport;
79
+ }): Promise<JsonObject>;
80
+ /**
81
+ * Sets the account's app PIN under its account [session]. The backend requires the person's own
82
+ * re-authentication with it -- [reauthentication] carries it, the account's password for example:
83
+ * `{password: ...}`.
84
+ */
85
+ setAppPin(args: {
86
+ session: string;
87
+ identity: InstallationIdentity;
88
+ pin: string;
89
+ reauthentication?: Record<string, unknown>;
90
+ path?: string;
91
+ }): Promise<JsonObject>;
92
+ /**
93
+ * Has the backend check the app [pin] for the one signed [request] -- sent once, in a signed body,
94
+ * never in a header -- and answers that request unlocked by its proof, valid for that request
95
+ * alone.
96
+ */
97
+ proveAppPin(args: {
98
+ session: string;
99
+ identity: InstallationIdentity;
100
+ pin: string;
101
+ request: RequestProof;
102
+ path?: string;
103
+ }): Promise<RequestProof>;
104
+ close(): void;
105
+ }
package/lib/client.js ADDED
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * The public guide's five calls (section 4) over a [DeviceTrustTransport], the two scan calls each
4
+ * with its request proof (section 3), and the app PIN's two calls.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.DeviceTrustClient = void 0;
8
+ const errors_1 = require("./errors");
9
+ const native_1 = require("./util/native");
10
+ const report_1 = require("./report");
11
+ const requestProof_1 = require("./requestProof");
12
+ class DeviceTrustClient {
13
+ /** [platform] replaces the phone's (`android` or `ios`) in tests. */
14
+ constructor(transport, options = {}) {
15
+ this.transport = transport;
16
+ this.platform = options.platform ?? native_1.currentPlatform;
17
+ }
18
+ /**
19
+ * Sends [proof]'s request. [method], [path] and [encodedBody] replace what the proof was signed
20
+ * for -- only to test that a backend refuses them.
21
+ */
22
+ sendSigned(proof, overrides = {}) {
23
+ const sentMethod = (overrides.method ?? proof.method).toUpperCase();
24
+ return this.transport.send(sentMethod, overrides.path ?? proof.path, {
25
+ bearerToken: proof.bearerToken,
26
+ encodedBody: sentMethod === 'GET' ? null : overrides.encodedBody ?? proof.encodedBody,
27
+ headers: proof.headers,
28
+ });
29
+ }
30
+ /**
31
+ * Signs and sends one request under [bearerToken], its [body] as JSON. [installationId] replaces
32
+ * the installation the proof names, only to test that a backend refuses it.
33
+ */
34
+ async signAndSend(method, path, args) {
35
+ return this.sendSigned(await (0, requestProof_1.signRequest)({
36
+ identity: args.identity,
37
+ method,
38
+ path,
39
+ bearerToken: args.bearerToken,
40
+ encodedBody: JSON.stringify(args.body ?? {}),
41
+ installationId: args.installationId,
42
+ }));
43
+ }
44
+ /**
45
+ * Registers the installation's key, and with [stepUpKey] the step-up key. The answer's
46
+ * `installation_id` is the installation your backend holds for this key, which may not be the one
47
+ * proposed. [extra] adds fields of your backend's own.
48
+ */
49
+ registerInstallation(identity, hint, options = {}) {
50
+ const extra = options.extra ?? {};
51
+ const replaced = Object.keys(extra).filter((name) => DeviceTrustClient.registrationFields.has(name));
52
+ if (replaced.length > 0) {
53
+ throw new TypeError(`extra replaces the registration fields ${replaced.join(', ')}`);
54
+ }
55
+ const stepUpKey = options.stepUpKey ?? null;
56
+ const body = {
57
+ installation_id: identity.installationId,
58
+ platform: this.platform() === 'android' ? 'android' : 'ios',
59
+ public_key: identity.publicKeyJson,
60
+ reinstall_hint: hint === null ? null : hint.toJSON(),
61
+ // What the platform's keystore says about how the key is protected: a claim, not proof. Its
62
+ // value is comparative -- a non-exportable hardware key cannot move into software, so the
63
+ // same key later claiming a weaker level contradicts itself.
64
+ key_security: {
65
+ security_level: identity.securityLevel,
66
+ hardware_backed: identity.hardwareBacked,
67
+ provider: identity.provider,
68
+ },
69
+ };
70
+ // The step-up key, and how the phone says it is unlocked: also a claim, recorded and compared
71
+ // with what the backend requires.
72
+ if (stepUpKey !== null) {
73
+ body['stepup_public_key'] = stepUpKey.publicKey;
74
+ body['stepup_key_auth'] = stepUpKey.authJson;
75
+ }
76
+ Object.assign(body, extra);
77
+ return this.transport.send('POST', '/v1/installations/register', { encodedBody: JSON.stringify(body) });
78
+ }
79
+ /** Asks for a key challenge: `challenge_id` and the `payload` to sign. */
80
+ createInstallationChallenge(installationId) {
81
+ return this.transport.send('POST', '/v1/installations/challenge', {
82
+ encodedBody: JSON.stringify({ installation_id: installationId }),
83
+ });
84
+ }
85
+ /** Answers a key challenge; the answer carries `device_token`, your backend's session for this installation. */
86
+ verifyInstallation(args) {
87
+ return this.transport.send('POST', '/v1/installations/verify', {
88
+ encodedBody: JSON.stringify({
89
+ installation_id: args.installationId,
90
+ challenge_id: args.challengeId,
91
+ payload: args.payload,
92
+ signature: args.signature,
93
+ }),
94
+ });
95
+ }
96
+ /**
97
+ * Asks for a scan under [session]: the installation's session, from [verifyInstallation] -- or,
98
+ * for a scan that approves one [operation] ([describeOperation]), your account session for this
99
+ * installation.
100
+ */
101
+ async integrityChallenge(args) {
102
+ const body = {};
103
+ if (args.operation != null) {
104
+ body['operation'] = args.operation;
105
+ }
106
+ return report_1.ScanChallenge.fromJson(await this.signAndSend('POST', '/v1/integrity/challenge', {
107
+ bearerToken: args.session,
108
+ identity: args.identity,
109
+ body,
110
+ }));
111
+ }
112
+ /**
113
+ * Sends [report] under the [session] that asked for its scan. The answer is your backend's own;
114
+ * for a scan that approves one request it carries `report_id`, sent with that request as
115
+ * `X-Integrity-Report`.
116
+ */
117
+ submitIntegrityReport(args) {
118
+ return this.signAndSend('POST', '/v1/integrity/report', {
119
+ bearerToken: args.session,
120
+ identity: args.identity,
121
+ body: args.report.toJSON(),
122
+ });
123
+ }
124
+ /**
125
+ * Sets the account's app PIN under its account [session]. The backend requires the person's own
126
+ * re-authentication with it -- [reauthentication] carries it, the account's password for example:
127
+ * `{password: ...}`.
128
+ */
129
+ setAppPin(args) {
130
+ return this.signAndSend('POST', args.path ?? DeviceTrustClient.appPinPath, {
131
+ bearerToken: args.session,
132
+ identity: args.identity,
133
+ body: { ...(args.reauthentication ?? {}), pin: args.pin },
134
+ });
135
+ }
136
+ /**
137
+ * Has the backend check the app [pin] for the one signed [request] -- sent once, in a signed body,
138
+ * never in a header -- and answers that request unlocked by its proof, valid for that request
139
+ * alone.
140
+ */
141
+ async proveAppPin(args) {
142
+ const answer = await this.signAndSend('POST', args.path ?? DeviceTrustClient.appPinProofPath, {
143
+ bearerToken: args.session,
144
+ identity: args.identity,
145
+ body: (0, requestProof_1.appPinProofRequest)({
146
+ pin: args.pin,
147
+ method: args.request.method,
148
+ path: args.request.path,
149
+ encodedBody: args.request.encodedBody,
150
+ nonce: args.request.nonce,
151
+ }),
152
+ });
153
+ const proof = answer['app_pin_proof'];
154
+ if (typeof proof !== 'string' || proof.length === 0) {
155
+ throw new errors_1.ApiException('The backend answered no app PIN proof.', { code: 'app_pin_proof_missing' });
156
+ }
157
+ return args.request.withAppPinProof(proof);
158
+ }
159
+ close() {
160
+ this.transport.close();
161
+ }
162
+ }
163
+ exports.DeviceTrustClient = DeviceTrustClient;
164
+ /** The app PIN's default paths (the public guide, section 4). */
165
+ DeviceTrustClient.appPinPath = '/v1/account/app-pin';
166
+ DeviceTrustClient.appPinProofPath = '/v1/account/app-pin/proof';
167
+ /** The fields of the guide's registration, which [registerInstallation]'s `extra` cannot replace. */
168
+ DeviceTrustClient.registrationFields = new Set([
169
+ 'installation_id',
170
+ 'platform',
171
+ 'public_key',
172
+ 'reinstall_hint',
173
+ 'key_security',
174
+ 'stepup_public_key',
175
+ 'stepup_key_auth',
176
+ ]);
@@ -0,0 +1,18 @@
1
+ /** The native integrity collector: measures what a challenge asks for and the app enabled, and nothing else. */
2
+ export declare class NativeIntegrityCollector {
3
+ /**
4
+ * [enabledMeasurements]: the personal measurements this app's developers allow; with
5
+ * [optionalProbes], what the backend asks for, the collector measures only what is in both.
6
+ */
7
+ collect(args: {
8
+ requiredProbes: string[];
9
+ challengeNonce: string;
10
+ enabledMeasurements: string[];
11
+ optionalProbes?: string[];
12
+ }): Promise<Record<string, unknown>>;
13
+ /**
14
+ * Asks the person for the OS location permission and returns what the app now holds: precise,
15
+ * approximate or none. Only the app asks, from its own screen; a scan never prompts.
16
+ */
17
+ requestLocationPermission(): Promise<string>;
18
+ }