@necessarysdk/react-native 0.0.0-stage → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1152 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +323 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +314 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +762 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +960 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +353 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +690 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +140 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +842 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +174 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
|
@@ -0,0 +1,353 @@
|
|
|
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
|
+
/// How one app image's comparison ended.
|
|
169
|
+
enum ImageOutcome: Equatable {
|
|
170
|
+
/// Compared: its __text bytes and how many differ, and the same for
|
|
171
|
+
/// the whole __TEXT segment (telemetry).
|
|
172
|
+
case compared(textBytes: Int, textDiffering: Int, segmentBytes: Int, segmentDiffering: Int)
|
|
173
|
+
/// Its file could not be read in full.
|
|
174
|
+
case unreadable(textBytes: Int, why: String)
|
|
175
|
+
/// Not compared: no usable __text, a __TEXT over the budget, no live
|
|
176
|
+
/// address. Its __text bytes when known, else 0.
|
|
177
|
+
case skipped(textBytes: Int, why: String)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// One image the loader mapped from the app's bundle.
|
|
181
|
+
struct AppImage: Equatable {
|
|
182
|
+
/// Its path in the bundle (bundleLabel).
|
|
183
|
+
let path: String
|
|
184
|
+
let isMainExecutable: Bool
|
|
185
|
+
let outcome: ImageOutcome
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// An image's path in the bundle, as the report names it: at most 128
|
|
189
|
+
/// characters, its end kept; "(bundle)" for the bundle itself.
|
|
190
|
+
static func bundleLabel(imagePath: String, bundlePath: String) -> String {
|
|
191
|
+
let inBundle = String(imagePath.dropFirst(bundlePath.count).drop(while: { $0 == "/" }))
|
|
192
|
+
return inBundle.isEmpty ? "(bundle)" : String(inBundle.suffix(128))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// The app bucket's figures from every bundle image's outcome (collector
|
|
196
|
+
/// 5): every image expected, and each compared, unreadable or skipped,
|
|
197
|
+
/// with its __text bytes; each one not compared named with why; complete
|
|
198
|
+
/// only when every image was compared - the images decide, since one
|
|
199
|
+
/// whose size could not be read adds no bytes. `checked` means the app
|
|
200
|
+
/// bucket was genuinely compared: not one image, nothing measured.
|
|
201
|
+
static func appCoverage(_ images: [AppImage], systemImagesUnreadable: Int) -> [String: Any] {
|
|
202
|
+
var compared = 0, unreadable = 0, skipped = 0
|
|
203
|
+
var expectedBytes = 0, comparedBytes = 0, unreadableBytes = 0, skippedBytes = 0
|
|
204
|
+
var differing = 0, imagesDiffering = 0, segmentCompared = 0, segmentDiffering = 0
|
|
205
|
+
var diffed: [String] = []
|
|
206
|
+
var notCompared: [String: String] = [:]
|
|
207
|
+
var mainCompared = false
|
|
208
|
+
|
|
209
|
+
func name(_ image: AppImage, _ why: String) {
|
|
210
|
+
if notCompared.count < maxNamedImages {
|
|
211
|
+
notCompared[image.path] = String(why.prefix(160))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for image in images {
|
|
216
|
+
switch image.outcome {
|
|
217
|
+
case let .compared(textBytes, textDiffering, segmentBytes, segmentDiff):
|
|
218
|
+
compared += 1
|
|
219
|
+
expectedBytes += textBytes
|
|
220
|
+
comparedBytes += textBytes
|
|
221
|
+
segmentCompared += segmentBytes
|
|
222
|
+
segmentDiffering += segmentDiff
|
|
223
|
+
if image.isMainExecutable { mainCompared = true }
|
|
224
|
+
if textDiffering > 0 {
|
|
225
|
+
differing += textDiffering
|
|
226
|
+
imagesDiffering += 1
|
|
227
|
+
diffed.append(image.path.split(separator: "/").last.map(String.init) ?? image.path)
|
|
228
|
+
}
|
|
229
|
+
case let .unreadable(textBytes, why):
|
|
230
|
+
unreadable += 1
|
|
231
|
+
expectedBytes += textBytes
|
|
232
|
+
unreadableBytes += textBytes
|
|
233
|
+
name(image, why)
|
|
234
|
+
case let .skipped(textBytes, why):
|
|
235
|
+
skipped += 1
|
|
236
|
+
expectedBytes += textBytes
|
|
237
|
+
skippedBytes += textBytes
|
|
238
|
+
name(image, why)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
var result: [String: Any] = [
|
|
243
|
+
"checked": compared > 0,
|
|
244
|
+
"app_compared_bytes": comparedBytes,
|
|
245
|
+
"app_diff_bytes": differing,
|
|
246
|
+
"app_libs_diff": imagesDiffering,
|
|
247
|
+
"app_images_compared": compared,
|
|
248
|
+
"system_images_unreadable": systemImagesUnreadable,
|
|
249
|
+
"app_images_expected": images.count,
|
|
250
|
+
"app_images_unreadable": unreadable,
|
|
251
|
+
"app_images_skipped": skipped,
|
|
252
|
+
"app_expected_bytes": expectedBytes,
|
|
253
|
+
"app_unreadable_bytes": unreadableBytes,
|
|
254
|
+
"app_skipped_bytes": skippedBytes,
|
|
255
|
+
"app_complete": !images.isEmpty && compared == images.count,
|
|
256
|
+
"main_executable_compared": mainCompared,
|
|
257
|
+
// Telemetry only. Never scored until it has a hardware baseline.
|
|
258
|
+
"app_segment_compared_bytes": segmentCompared,
|
|
259
|
+
"app_segment_diff_bytes": segmentDiffering,
|
|
260
|
+
"diffed_libs": diffed.joined(separator: ","),
|
|
261
|
+
]
|
|
262
|
+
if !notCompared.isEmpty {
|
|
263
|
+
result["app_images_not_compared"] = notCompared
|
|
264
|
+
}
|
|
265
|
+
return result
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MARK: - One location read per collection
|
|
269
|
+
|
|
270
|
+
/// One fix as the collector captured it from Core Location.
|
|
271
|
+
struct Fix: Equatable {
|
|
272
|
+
let latitude: Double
|
|
273
|
+
let longitude: Double
|
|
274
|
+
/// Negative when unknown, as Core Location reports it.
|
|
275
|
+
let horizontalAccuracy: Double
|
|
276
|
+
let timestamp: Date
|
|
277
|
+
/// From iOS 15, where the fix says; nil where it cannot.
|
|
278
|
+
let simulatedBySoftware: Bool?
|
|
279
|
+
let producedByAccessory: Bool?
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// One location read: the permission, the fix the phone already held -
|
|
283
|
+
/// none without the permission - and its age when read.
|
|
284
|
+
struct LocationRead: Equatable {
|
|
285
|
+
let permission: String
|
|
286
|
+
let fix: Fix?
|
|
287
|
+
let ageSeconds: Int
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
static func read(permission: String, fix: Fix?, now: Date) -> LocationRead {
|
|
291
|
+
let held = permission == "none" ? nil : fix
|
|
292
|
+
let age = held.map { max(0, Int(now.timeIntervalSince($0.timestamp))) } ?? 0
|
|
293
|
+
return LocationRead(permission: permission, fix: held, ageSeconds: age)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// The collection's one location read: made on first use and never again,
|
|
297
|
+
/// so the coordinates and the mock flag describe one fix. Each collection
|
|
298
|
+
/// makes its own.
|
|
299
|
+
final class LocationCapture {
|
|
300
|
+
private let read: () -> LocationRead
|
|
301
|
+
private var made: LocationRead?
|
|
302
|
+
|
|
303
|
+
init(_ read: @escaping () -> LocationRead) {
|
|
304
|
+
self.read = read
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
var value: LocationRead {
|
|
308
|
+
if let made = made { return made }
|
|
309
|
+
let fresh = self.read()
|
|
310
|
+
made = fresh
|
|
311
|
+
return fresh
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// The last known location: the coordinates of the read's fix.
|
|
316
|
+
static func location(_ read: LocationRead) -> [String: Any] {
|
|
317
|
+
var result: [String: Any] = ["status": "ok", "permission": read.permission]
|
|
318
|
+
guard let fix = read.fix else { return result }
|
|
319
|
+
result["latitude"] = fix.latitude
|
|
320
|
+
result["longitude"] = fix.longitude
|
|
321
|
+
if fix.horizontalAccuracy >= 0 { result["accuracy_m"] = fix.horizontalAccuracy }
|
|
322
|
+
result["age_seconds"] = read.ageSeconds
|
|
323
|
+
result["fix_time"] = Int(fix.timestamp.timeIntervalSince1970)
|
|
324
|
+
return result
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Whether the read's fix was simulated by software or produced by an
|
|
328
|
+
/// accessory: never its coordinates. Before iOS 15 the fix cannot say.
|
|
329
|
+
static func mockLocation(_ read: LocationRead) -> [String: Any] {
|
|
330
|
+
var result: [String: Any] = ["status": "ok", "permission": read.permission]
|
|
331
|
+
guard let fix = read.fix else { return result }
|
|
332
|
+
if let simulated = fix.simulatedBySoftware { result["mock"] = simulated }
|
|
333
|
+
if let accessory = fix.producedByAccessory { result["accessory"] = accessory }
|
|
334
|
+
result["age_seconds"] = read.ageSeconds
|
|
335
|
+
result["fix_time"] = Int(fix.timestamp.timeIntervalSince1970)
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Screen capture (collector 6): `recording` when a screen showing the
|
|
340
|
+
/// app's window could be asked. With none - the app in the background, or
|
|
341
|
+
/// before its first window - recording is not measured, never "no", and
|
|
342
|
+
/// `recording_status` says why: `unavailable`, as on Android with no window
|
|
343
|
+
/// to ask. The other screens attached are counted either way.
|
|
344
|
+
static func screenCapture(captured: Bool?, otherScreens: Int) -> [String: Any] {
|
|
345
|
+
var result: [String: Any] = ["status": "ok", "external_displays": max(0, otherScreens)]
|
|
346
|
+
if let captured = captured {
|
|
347
|
+
result["recording"] = captured
|
|
348
|
+
} else {
|
|
349
|
+
result["recording_status"] = "unavailable"
|
|
350
|
+
}
|
|
351
|
+
return result
|
|
352
|
+
}
|
|
353
|
+
}
|
package/lib/client.d.ts
ADDED
|
@@ -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
|
+
}
|