@bota.dev/react-native-app-sdk 2.0.0-beta.10 → 2.0.0-beta.11
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/README.md +12 -1
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
- package/generated/codegen-contract.json +167 -8
- package/ios/BotaUploadV2Backend.mm +49 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
- package/ios/UploadBackend/UploadCoordinator.swift +84 -0
- package/ios/UploadBackend/UploadHTTP.swift +82 -0
- package/ios/UploadBackend/UploadJournal.swift +187 -0
- package/ios/UploadBackend/UploadModels.swift +273 -0
- package/ios/UploadBackend/UploadOperation.swift +391 -0
- package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
- package/lib/commonjs/index.js +5 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/module/encryptedUploadV2Backend.js +196 -0
- package/lib/module/encryptedUploadV2Backend.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +3 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/encryptedUploadV2Backend.ts +171 -0
- package/src/index.ts +6 -0
- package/src/sdkIdentity.ts +1 -1
- package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum UploadFailure: Error, LocalizedError, Equatable {
|
|
5
|
+
case cancelled, invalidInput, identityConflict, transport, journal, invalidDocument, pending, expired, state, stagingMissing
|
|
6
|
+
case http(Int)
|
|
7
|
+
|
|
8
|
+
var errorDescription: String? {
|
|
9
|
+
switch self {
|
|
10
|
+
case .cancelled: "BOTA_UPLOAD_CANCELLED"
|
|
11
|
+
case .invalidInput: "BOTA_UPLOAD_INVALID_INPUT"
|
|
12
|
+
case .identityConflict: "BOTA_UPLOAD_IDENTITY_CONFLICT"
|
|
13
|
+
case .transport: "BOTA_UPLOAD_TRANSPORT"
|
|
14
|
+
case .journal: "BOTA_UPLOAD_JOURNAL"
|
|
15
|
+
case .invalidDocument: "BOTA_UPLOAD_INVALID_DOCUMENT"
|
|
16
|
+
case .pending: "BOTA_UPLOAD_PENDING"
|
|
17
|
+
case .expired: "BOTA_UPLOAD_EXPIRED"
|
|
18
|
+
case .state: "BOTA_UPLOAD_STATE"
|
|
19
|
+
case .stagingMissing: "BOTA_UPLOAD_STAGING_MISSING"
|
|
20
|
+
case .http(let status): "BOTA_UPLOAD_HTTP_\(status)"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static func safe(_ error: Error) -> UploadFailure {
|
|
25
|
+
if let error = error as? UploadFailure { return error }
|
|
26
|
+
return error is CancellationError ? .cancelled : .transport
|
|
27
|
+
}
|
|
28
|
+
var transient: Bool {
|
|
29
|
+
switch self {
|
|
30
|
+
case .transport, .http(429), .http(500...599): true
|
|
31
|
+
default: false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
enum UploadValidation {
|
|
37
|
+
static func matches(_ text: String, _ pattern: String) -> Bool {
|
|
38
|
+
guard let range = text.range(of: pattern, options: .regularExpression) else { return false }
|
|
39
|
+
return range.lowerBound == text.startIndex && range.upperBound == text.endIndex
|
|
40
|
+
}
|
|
41
|
+
static func uuid(_ value: String) -> Bool {
|
|
42
|
+
matches(value, "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
|
|
43
|
+
}
|
|
44
|
+
static func hex(_ value: String, bytes: Int) -> Bool {
|
|
45
|
+
value.utf8.count == bytes * 2 && value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) }
|
|
46
|
+
}
|
|
47
|
+
static func bytes(_ hex: String) throws -> Data {
|
|
48
|
+
guard hex.count % 2 == 0, self.hex(hex, bytes: hex.count / 2) else { throw UploadFailure.invalidInput }
|
|
49
|
+
let chars = Array(hex.utf8)
|
|
50
|
+
return Data(stride(from: 0, to: chars.count, by: 2).map {
|
|
51
|
+
func nibble(_ c: UInt8) -> UInt8 { c <= 57 ? c - 48 : c - 87 }
|
|
52
|
+
return nibble(chars[$0]) * 16 + nibble(chars[$0 + 1])
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
static func hash(_ data: Data) -> String {
|
|
56
|
+
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
|
57
|
+
}
|
|
58
|
+
static func integer(_ text: String, maximum: UInt64 = 9_007_199_254_740_991) throws -> UInt64 {
|
|
59
|
+
guard matches(text, "^(0|[1-9][0-9]*)$"), let value = UInt64(text), value <= maximum else {
|
|
60
|
+
throw UploadFailure.invalidInput
|
|
61
|
+
}
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
static func document(_ base64: String?, hash: String? = nil, size: Int) throws -> Data {
|
|
65
|
+
guard let base64, let data = Data(base64Encoded: base64), data.count == size,
|
|
66
|
+
data.base64EncodedString() == base64,
|
|
67
|
+
hash == nil || self.hash(data) == hash else { throw UploadFailure.invalidDocument }
|
|
68
|
+
return data
|
|
69
|
+
}
|
|
70
|
+
static func date(_ value: String) throws -> Date {
|
|
71
|
+
let formatter = ISO8601DateFormatter()
|
|
72
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
73
|
+
if let date = formatter.date(from: value) { return date }
|
|
74
|
+
formatter.formatOptions = [.withInternetDateTime]
|
|
75
|
+
guard let date = formatter.date(from: value) else { throw UploadFailure.invalidDocument }
|
|
76
|
+
return date
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
struct UploadInput: Codable, Sendable {
|
|
81
|
+
struct Scope: Codable, Sendable {
|
|
82
|
+
var apiOrigin: String
|
|
83
|
+
var apiBasePath: String?
|
|
84
|
+
var accountId: String
|
|
85
|
+
var projectId: String
|
|
86
|
+
var organizationId: String
|
|
87
|
+
var environment: String
|
|
88
|
+
var endUserId: String
|
|
89
|
+
var deviceId: String
|
|
90
|
+
var bindingGeneration: UInt32
|
|
91
|
+
var serialNumber: String
|
|
92
|
+
var nativeDeviceId: String
|
|
93
|
+
var scopeKey: String
|
|
94
|
+
}
|
|
95
|
+
struct Recording: Codable, Sendable {
|
|
96
|
+
var uuid: String
|
|
97
|
+
var generation: UInt32
|
|
98
|
+
var ciphertextLength: String
|
|
99
|
+
var ciphertextSha256: String
|
|
100
|
+
var startedAtMs: String
|
|
101
|
+
var durationMs: String
|
|
102
|
+
var plaintextLength: String
|
|
103
|
+
var storageFormat: UInt8
|
|
104
|
+
}
|
|
105
|
+
struct Capability: Codable, Sendable { var rawValueHex: String; var flags: UInt32 }
|
|
106
|
+
struct Checkpoint: Codable, Sendable {
|
|
107
|
+
var version: UInt32
|
|
108
|
+
var uploadSessionId: String
|
|
109
|
+
var ownerRevision: UInt32
|
|
110
|
+
var revision: UInt32
|
|
111
|
+
var nextCiphertextOffset: String
|
|
112
|
+
var prefixSha256: String
|
|
113
|
+
var highestContiguousSequence: UInt32?
|
|
114
|
+
var transportSessionId: String
|
|
115
|
+
var sinkRegistrationId: String
|
|
116
|
+
var windowPackets: UInt16
|
|
117
|
+
var dataPayloadBytes: UInt16
|
|
118
|
+
}
|
|
119
|
+
var operationId: String
|
|
120
|
+
var scope: Scope
|
|
121
|
+
var recording: Recording
|
|
122
|
+
var capability: Capability
|
|
123
|
+
var checkpoint: Checkpoint?
|
|
124
|
+
var priorJournalEntry: UploadJournalEntry?
|
|
125
|
+
var journalKey: String
|
|
126
|
+
|
|
127
|
+
static func decode(_ data: Data) throws -> UploadInput {
|
|
128
|
+
do {
|
|
129
|
+
var object = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
|
|
130
|
+
if object["journalKey"] == nil,
|
|
131
|
+
let scope = object["scope"] as? [String: Any], let recording = object["recording"] as? [String: Any] {
|
|
132
|
+
let fields = [scope["scopeKey"], scope["deviceId"], scope["bindingGeneration"], recording["uuid"],
|
|
133
|
+
recording["generation"], recording["ciphertextLength"], recording["ciphertextSha256"]]
|
|
134
|
+
guard fields.allSatisfy({ $0 != nil }) else { throw UploadFailure.invalidInput }
|
|
135
|
+
object["journalKey"] = UploadValidation.hash(try JSONSerialization.data(
|
|
136
|
+
withJSONObject: fields.map { $0! }, options: [.withoutEscapingSlashes]))
|
|
137
|
+
}
|
|
138
|
+
let input = try JSONDecoder().decode(Self.self, from: JSONSerialization.data(withJSONObject: object))
|
|
139
|
+
try input.validate()
|
|
140
|
+
return input
|
|
141
|
+
} catch { throw (error as? UploadFailure) ?? .invalidInput }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func validate() throws {
|
|
145
|
+
guard !operationId.isEmpty, operationId.utf8.count <= 128,
|
|
146
|
+
let url = URLComponents(string: scope.apiOrigin), url.scheme == "https", url.host != nil,
|
|
147
|
+
url.user == nil, url.password == nil, url.query == nil, url.fragment == nil,
|
|
148
|
+
url.path.isEmpty, !scope.apiOrigin.hasSuffix("/"),
|
|
149
|
+
[scope.accountId, scope.projectId, scope.environment, scope.endUserId, scope.deviceId,
|
|
150
|
+
scope.serialNumber, scope.nativeDeviceId].allSatisfy({
|
|
151
|
+
!$0.isEmpty && $0 != "." && $0 != ".." && $0.utf8.count <= 256 &&
|
|
152
|
+
UploadValidation.matches($0, "^[A-Za-z0-9_.:@-]+$")
|
|
153
|
+
}), !scope.scopeKey.isEmpty, scope.scopeKey.utf8.count <= 4096,
|
|
154
|
+
UploadValidation.uuid(recording.uuid), recording.generation > 0,
|
|
155
|
+
recording.storageFormat == 3, UploadValidation.hex(recording.ciphertextSha256, bytes: 32),
|
|
156
|
+
UploadValidation.hex(capability.rawValueHex, bytes: 24),
|
|
157
|
+
UploadValidation.hex(journalKey, bytes: 32) else { throw UploadFailure.invalidInput }
|
|
158
|
+
guard scope.organizationId.isEmpty || UploadValidation.matches(scope.organizationId, "^[A-Za-z0-9_-]{1,256}$"),
|
|
159
|
+
UploadValidation.matches(scope.apiBasePath ?? "/v1", "^(/[A-Za-z0-9_-]+)+$") else { throw UploadFailure.invalidInput }
|
|
160
|
+
let length = try UploadValidation.integer(recording.ciphertextLength)
|
|
161
|
+
let start = try UploadValidation.integer(recording.startedAtMs, maximum: 253_402_300_799_999)
|
|
162
|
+
let duration = try UploadValidation.integer(recording.durationMs, maximum: 253_402_300_799_999)
|
|
163
|
+
_ = try UploadValidation.integer(recording.plaintextLength)
|
|
164
|
+
guard length > 0, duration <= 253_402_300_799_999 - start else { throw UploadFailure.invalidInput }
|
|
165
|
+
let key: [Any] = [scope.scopeKey, scope.deviceId, scope.bindingGeneration, recording.uuid,
|
|
166
|
+
recording.generation, recording.ciphertextLength, recording.ciphertextSha256]
|
|
167
|
+
guard UploadValidation.hash(try JSONSerialization.data(withJSONObject: key, options: [.withoutEscapingSlashes])) == journalKey else {
|
|
168
|
+
throw UploadFailure.identityConflict
|
|
169
|
+
}
|
|
170
|
+
try priorJournalEntry?.validate()
|
|
171
|
+
if let checkpoint {
|
|
172
|
+
guard checkpoint.version == 1, UploadValidation.uuid(checkpoint.uploadSessionId), checkpoint.ownerRevision > 0,
|
|
173
|
+
checkpoint.ownerRevision <= 2_147_483_647,
|
|
174
|
+
UploadValidation.hex(checkpoint.prefixSha256, bytes: 32),
|
|
175
|
+
!checkpoint.sinkRegistrationId.isEmpty, checkpoint.sinkRegistrationId.utf8.count <= 128,
|
|
176
|
+
checkpoint.windowPackets > 0, checkpoint.dataPayloadBytes > 0,
|
|
177
|
+
try UploadValidation.integer(checkpoint.nextCiphertextOffset, maximum: .max) <= length,
|
|
178
|
+
try UploadValidation.integer(checkpoint.transportSessionId, maximum: .max) > 0 else {
|
|
179
|
+
throw UploadFailure.identityConflict
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func identity(includeTransport: Bool = false) throws -> String {
|
|
185
|
+
struct Identity: Encodable { let scope: Scope; let recording: Recording }
|
|
186
|
+
let encoder = JSONEncoder()
|
|
187
|
+
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
|
188
|
+
var identityScope = scope
|
|
189
|
+
if !includeTransport { identityScope.nativeDeviceId = "" }
|
|
190
|
+
return UploadValidation.hash(try encoder.encode(Identity(scope: identityScope, recording: recording)))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func validateCheckpoint(_ entry: UploadJournalEntry?) throws {
|
|
194
|
+
guard let checkpoint else { return }
|
|
195
|
+
guard let id = entry?.sessionId, let revision = entry?.ownerRevision,
|
|
196
|
+
checkpoint.ownerRevision <= revision,
|
|
197
|
+
(checkpoint.ownerRevision == revision) == (checkpoint.uploadSessionId == id) else {
|
|
198
|
+
throw UploadFailure.identityConflict
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
struct UploadJournalEntry: Codable, Equatable, Sendable {
|
|
204
|
+
var recordingId: String
|
|
205
|
+
var sessionId: String?
|
|
206
|
+
var ownerRevision: UInt32?
|
|
207
|
+
|
|
208
|
+
func agrees(with entry: UploadJournalEntry) -> Bool {
|
|
209
|
+
recordingId == entry.recordingId && (sessionId == nil || sessionId == entry.sessionId) &&
|
|
210
|
+
(ownerRevision == nil || ownerRevision == entry.ownerRevision)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func identity() throws -> String {
|
|
214
|
+
let encoder = JSONEncoder()
|
|
215
|
+
encoder.outputFormatting = [.sortedKeys]
|
|
216
|
+
return UploadValidation.hash(try encoder.encode(self))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func validate() throws {
|
|
220
|
+
guard UploadValidation.matches(recordingId, "^rec_[A-Za-z0-9_-]+$"), recordingId.count <= 256 else {
|
|
221
|
+
throw UploadFailure.identityConflict
|
|
222
|
+
}
|
|
223
|
+
if let sessionId {
|
|
224
|
+
guard UploadValidation.uuid(sessionId), let ownerRevision, ownerRevision > 0,
|
|
225
|
+
ownerRevision <= 2_147_483_647 else { throw UploadFailure.identityConflict }
|
|
226
|
+
} else if ownerRevision != nil { throw UploadFailure.identityConflict }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
struct UploadSession: Decodable, Sendable {
|
|
231
|
+
let profile: String
|
|
232
|
+
let session_id: String
|
|
233
|
+
let owner_revision: UInt32
|
|
234
|
+
let policy: String
|
|
235
|
+
let authorization_base64: String
|
|
236
|
+
let authorization_sha256: String
|
|
237
|
+
let expires_at: String
|
|
238
|
+
let state: String?
|
|
239
|
+
let channel: String?
|
|
240
|
+
let ciphertext_length: UInt64?
|
|
241
|
+
let ciphertext_sha256: String?
|
|
242
|
+
let plaintext_length: UInt64?
|
|
243
|
+
let completion_receipt_base64: String?
|
|
244
|
+
let completion_receipt_sha256: String?
|
|
245
|
+
|
|
246
|
+
func validatePointer() throws {
|
|
247
|
+
guard profile == "encrypted_upload_v2", UploadValidation.uuid(session_id), owner_revision > 0,
|
|
248
|
+
owner_revision <= 2_147_483_647,
|
|
249
|
+
["legacy_allowed", "v2_preferred", "v2_required"].contains(policy) else { throw UploadFailure.invalidDocument }
|
|
250
|
+
}
|
|
251
|
+
func validateIdentity(_ entry: UploadJournalEntry, recording: UploadInput.Recording) throws {
|
|
252
|
+
try validatePointer()
|
|
253
|
+
guard session_id == entry.sessionId, owner_revision == entry.ownerRevision,
|
|
254
|
+
channel == "ble", ciphertext_length == UInt64(recording.ciphertextLength),
|
|
255
|
+
ciphertext_sha256 == recording.ciphertextSha256 else { throw UploadFailure.identityConflict }
|
|
256
|
+
}
|
|
257
|
+
func actions() throws -> (upload: Bool, manifest: Bool) {
|
|
258
|
+
switch state {
|
|
259
|
+
case "staging": (true, true)
|
|
260
|
+
case "staged": (false, true)
|
|
261
|
+
case "ready", "processing", "published": (false, false)
|
|
262
|
+
case "expired": throw UploadFailure.expired
|
|
263
|
+
default: throw UploadFailure.state
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
struct UploadEvidence: Sendable {
|
|
269
|
+
let ciphertextLength: UInt64
|
|
270
|
+
let ciphertextSHA256: Data
|
|
271
|
+
let manifestLength: UInt16
|
|
272
|
+
let manifestSHA256: Data
|
|
273
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
struct UploadPrepared: Sendable {
|
|
5
|
+
let session: UploadSession
|
|
6
|
+
let authorization: Data
|
|
7
|
+
let recordingId: String
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
struct UploadContext: Sendable {
|
|
11
|
+
let challenge: Data
|
|
12
|
+
let exchangeProof: @Sendable (Data) async throws -> Data
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@MainActor final class UploadOperation {
|
|
16
|
+
let input: UploadInput
|
|
17
|
+
private let journal: UploadJournal
|
|
18
|
+
private let http: any UploadHTTP
|
|
19
|
+
private let credentials: UploadCredentials
|
|
20
|
+
private let readNonce: @Sendable (String) async throws -> Data
|
|
21
|
+
private let wait: @Sendable (UInt64) async throws -> Void
|
|
22
|
+
private let now: @Sendable () -> Date
|
|
23
|
+
private var cancelled = false
|
|
24
|
+
private var tasks: [UUID: Task<UploadHTTPResponse, Error>] = [:]
|
|
25
|
+
private var entry: UploadJournalEntry?
|
|
26
|
+
private var session: UploadSession?
|
|
27
|
+
private var authorizationNonceSHA256: String?
|
|
28
|
+
private var recoveryNonceBase64: String?
|
|
29
|
+
private var uploadCiphertext = true
|
|
30
|
+
private var needsManifest = true
|
|
31
|
+
private var receipt: Data?
|
|
32
|
+
private var receiptDelivered = false
|
|
33
|
+
|
|
34
|
+
init(input: UploadInput, journal: UploadJournal, http: any UploadHTTP, credentials: UploadCredentials,
|
|
35
|
+
readNonce: @escaping @Sendable (String) async throws -> Data,
|
|
36
|
+
wait: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0 * 1_000_000) },
|
|
37
|
+
now: @escaping @Sendable () -> Date = { Date() }) {
|
|
38
|
+
self.input = input
|
|
39
|
+
self.journal = journal
|
|
40
|
+
self.http = http
|
|
41
|
+
self.credentials = credentials
|
|
42
|
+
self.readNonce = readNonce
|
|
43
|
+
self.wait = wait
|
|
44
|
+
self.now = now
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func cancel() {
|
|
48
|
+
cancelled = true
|
|
49
|
+
credentials.cancel(operationId: input.operationId)
|
|
50
|
+
tasks.values.forEach { $0.cancel() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func check() throws {
|
|
54
|
+
guard !cancelled, !Task.isCancelled else { throw UploadFailure.cancelled }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private var base: String { input.scope.apiBasePath ?? "/v1" }
|
|
58
|
+
private var contexts: String { "\(base)/devices/\(input.scope.deviceId)/encrypted-upload-v2/contexts" }
|
|
59
|
+
private func sessions(_ recordingId: String) -> String { "\(base)/recordings/\(recordingId)/encrypted-upload-v2/sessions" }
|
|
60
|
+
private func sessionPath() throws -> String {
|
|
61
|
+
guard let entry, let id = entry.sessionId else { throw UploadFailure.state }
|
|
62
|
+
return sessions(entry.recordingId) + "/" + id
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil,
|
|
66
|
+
drain: Bool = false, beforeDispatch: (() throws -> Void)? = nil,
|
|
67
|
+
onAuthorizationRejection: (() throws -> Void)? = nil) async throws -> Data {
|
|
68
|
+
try check()
|
|
69
|
+
let token = try await credentials.request(operationId: input.operationId)
|
|
70
|
+
try check()
|
|
71
|
+
guard let url = URL(string: input.scope.apiOrigin + path) else { throw UploadFailure.invalidInput }
|
|
72
|
+
var request = URLRequest(url: url)
|
|
73
|
+
request.httpMethod = method
|
|
74
|
+
request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
|
75
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
76
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
77
|
+
if !input.scope.organizationId.isEmpty {
|
|
78
|
+
request.setValue(input.scope.organizationId, forHTTPHeaderField: "X-Organization-Id")
|
|
79
|
+
}
|
|
80
|
+
if let body { request.httpBody = try JSONSerialization.data(withJSONObject: body) }
|
|
81
|
+
try beforeDispatch?()
|
|
82
|
+
let id = UUID()
|
|
83
|
+
let task = Task {
|
|
84
|
+
try self.check()
|
|
85
|
+
return try await http.send(request)
|
|
86
|
+
}
|
|
87
|
+
// Unstructured draining tasks are deliberately not cancelled after dispatch.
|
|
88
|
+
// The caller commits their response IDs before checking account cancellation.
|
|
89
|
+
if !drain { tasks[id] = task }
|
|
90
|
+
defer { tasks[id] = nil }
|
|
91
|
+
do {
|
|
92
|
+
let response = try await withTaskCancellationHandler { try await task.value } onCancel: {
|
|
93
|
+
if !drain { task.cancel() }
|
|
94
|
+
Task { @MainActor in self.cancel() }
|
|
95
|
+
}
|
|
96
|
+
if !drain { try check() }
|
|
97
|
+
if [401, 403].contains(response.status), let onAuthorizationRejection {
|
|
98
|
+
try check()
|
|
99
|
+
try onAuthorizationRejection()
|
|
100
|
+
}
|
|
101
|
+
if response.status == 409,
|
|
102
|
+
let object = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any],
|
|
103
|
+
let error = object["error"] as? [String: Any],
|
|
104
|
+
error["code"] as? String == "encrypted_upload_v2_staging_missing" { throw UploadFailure.stagingMissing }
|
|
105
|
+
guard (200...299).contains(response.status) else { throw UploadFailure.http(response.status) }
|
|
106
|
+
return response.body
|
|
107
|
+
} catch {
|
|
108
|
+
if !drain { try check() }
|
|
109
|
+
throw UploadFailure.safe(error)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func decode<T: Decodable>(_ data: Data, as: T.Type = T.self) throws -> T {
|
|
114
|
+
do { return try JSONDecoder().decode(T.self, from: data) }
|
|
115
|
+
catch { throw UploadFailure.invalidDocument }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private func retry<T>(_ operation: () async throws -> T) async throws -> T {
|
|
119
|
+
for attempt in 0..<3 {
|
|
120
|
+
try check()
|
|
121
|
+
do { let result = try await operation(); try check(); return result }
|
|
122
|
+
catch {
|
|
123
|
+
try check()
|
|
124
|
+
let error = UploadFailure.safe(error)
|
|
125
|
+
guard error.transient, attempt < 2 else { throw error }
|
|
126
|
+
try await wait(250)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw UploadFailure.pending
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func nonce() async throws -> String {
|
|
133
|
+
try check()
|
|
134
|
+
let data: Data
|
|
135
|
+
do { data = try await readNonce(input.operationId) }
|
|
136
|
+
catch { try check(); throw UploadFailure.safe(error) }
|
|
137
|
+
try check()
|
|
138
|
+
guard data.count == 16 else { throw UploadFailure.invalidDocument }
|
|
139
|
+
return data.base64EncodedString()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func prepare() async throws -> UploadPrepared {
|
|
143
|
+
try check()
|
|
144
|
+
try input.validate()
|
|
145
|
+
entry = try journal.load(input)
|
|
146
|
+
authorizationNonceSHA256 = try journal.authorizationNonceSHA256(input)
|
|
147
|
+
recoveryNonceBase64 = try journal.recoveryNonceBase64(input)
|
|
148
|
+
if entry == nil { entry = input.priorJournalEntry }
|
|
149
|
+
try input.validateCheckpoint(entry)
|
|
150
|
+
if let entry { try journal.save(input, entry: entry, recoveryNonceBase64: recoveryNonceBase64) }
|
|
151
|
+
if entry == nil {
|
|
152
|
+
let start = Double(try UploadValidation.integer(input.recording.startedAtMs))
|
|
153
|
+
let end = start + Double(try UploadValidation.integer(input.recording.durationMs))
|
|
154
|
+
let formatter = ISO8601DateFormatter()
|
|
155
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
156
|
+
let data = try await request(base + "/recordings", method: "POST", body: [
|
|
157
|
+
"device_id": input.scope.deviceId, "end_user_id": input.scope.endUserId,
|
|
158
|
+
"upload_method": "ble", "streaming": false,
|
|
159
|
+
"metadata": ["started_at": formatter.string(from: Date(timeIntervalSince1970: start / 1000)),
|
|
160
|
+
"ended_at": formatter.string(from: Date(timeIntervalSince1970: end / 1000))],
|
|
161
|
+
], drain: true, beforeDispatch: { try self.journal.markUnknown(self.input, entry: nil) },
|
|
162
|
+
onAuthorizationRejection: { try self.journal.rollbackRejectedCreate(self.input, entry: nil) })
|
|
163
|
+
struct Created: Decodable { let id: String }
|
|
164
|
+
let created = try decode(data, as: Created.self)
|
|
165
|
+
let committed = UploadJournalEntry(recordingId: created.id)
|
|
166
|
+
try committed.validate()
|
|
167
|
+
try journal.save(input, entry: committed)
|
|
168
|
+
entry = committed
|
|
169
|
+
}
|
|
170
|
+
try check()
|
|
171
|
+
guard let initial = entry else { throw UploadFailure.state }
|
|
172
|
+
let selected: UploadSession
|
|
173
|
+
if initial.sessionId != nil {
|
|
174
|
+
selected = try await restore()
|
|
175
|
+
let actions = try selected.actions()
|
|
176
|
+
uploadCiphertext = actions.upload
|
|
177
|
+
needsManifest = actions.manifest
|
|
178
|
+
} else {
|
|
179
|
+
let nonce = try await nonce()
|
|
180
|
+
let body: [String: Any] = [
|
|
181
|
+
"device_id": input.scope.deviceId, "end_user_id": input.scope.endUserId, "binding_generation": input.scope.bindingGeneration,
|
|
182
|
+
"recording_uuid": input.recording.uuid, "recording_generation": input.recording.generation,
|
|
183
|
+
"storage_format": "bota_enc_v2", "channel": "ble",
|
|
184
|
+
"capabilities_base64": try UploadValidation.bytes(input.capability.rawValueHex).base64EncodedString(),
|
|
185
|
+
"auth_nonce_base64": nonce, "ciphertext_length": try UploadValidation.integer(input.recording.ciphertextLength),
|
|
186
|
+
"ciphertext_sha256": input.recording.ciphertextSha256,
|
|
187
|
+
]
|
|
188
|
+
let data = try await request(sessions(initial.recordingId), method: "POST", body: body, drain: true,
|
|
189
|
+
beforeDispatch: { try self.journal.markUnknown(self.input, entry: initial) },
|
|
190
|
+
onAuthorizationRejection: { try self.journal.rollbackRejectedCreate(self.input, entry: initial) })
|
|
191
|
+
let pointer = try decode(data, as: Pointer.self)
|
|
192
|
+
let committed = try pointer.entry(recordingId: initial.recordingId)
|
|
193
|
+
let nonceHash = UploadValidation.hash(try UploadValidation.document(nonce, size: 16))
|
|
194
|
+
try journal.save(input, entry: committed, authorizationNonceSHA256: nonceHash)
|
|
195
|
+
authorizationNonceSHA256 = nonceHash
|
|
196
|
+
entry = committed
|
|
197
|
+
try check()
|
|
198
|
+
selected = try decode(data)
|
|
199
|
+
}
|
|
200
|
+
try check()
|
|
201
|
+
try selected.validatePointer()
|
|
202
|
+
_ = try UploadValidation.date(selected.expires_at)
|
|
203
|
+
guard UploadValidation.hex(selected.authorization_sha256, bytes: 32) else { throw UploadFailure.invalidDocument }
|
|
204
|
+
let authorization = try UploadValidation.document(selected.authorization_base64, hash: selected.authorization_sha256, size: 408)
|
|
205
|
+
session = selected
|
|
206
|
+
return UploadPrepared(session: selected, authorization: authorization, recordingId: initial.recordingId)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private struct Pointer: Decodable {
|
|
210
|
+
let profile: String
|
|
211
|
+
let session_id: String
|
|
212
|
+
let owner_revision: UInt32
|
|
213
|
+
func entry(recordingId: String) throws -> UploadJournalEntry {
|
|
214
|
+
guard profile == "encrypted_upload_v2" else { throw UploadFailure.invalidDocument }
|
|
215
|
+
let entry = UploadJournalEntry(recordingId: recordingId, sessionId: session_id, ownerRevision: owner_revision)
|
|
216
|
+
try entry.validate()
|
|
217
|
+
return entry
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private func restore() async throws -> UploadSession {
|
|
222
|
+
var visited = Set<String>()
|
|
223
|
+
for hop in 0...3 {
|
|
224
|
+
guard let parent = entry, let id = parent.sessionId, let revision = parent.ownerRevision else { throw UploadFailure.state }
|
|
225
|
+
visited.insert(id)
|
|
226
|
+
let status: UploadSession = try decode(await retry { try await request(try sessionPath()) })
|
|
227
|
+
try status.validateIdentity(parent, recording: input.recording)
|
|
228
|
+
let expiry = try UploadValidation.date(status.expires_at)
|
|
229
|
+
let preManifest = ["created", "staging", "staged"].contains(status.state ?? "")
|
|
230
|
+
let expired = status.state == "expired" || (preManifest && expiry <= now())
|
|
231
|
+
if recoveryNonceBase64 == nil && !expired && !(preManifest && authorizationNonceSHA256 != nil) { return status }
|
|
232
|
+
let nonce: String
|
|
233
|
+
if let pending = recoveryNonceBase64 { nonce = pending }
|
|
234
|
+
else { nonce = try await self.nonce() }
|
|
235
|
+
let nonceHash = UploadValidation.hash(try UploadValidation.document(nonce, size: 16))
|
|
236
|
+
let nonceChanged = authorizationNonceSHA256 != nil && nonceHash != authorizationNonceSHA256
|
|
237
|
+
if recoveryNonceBase64 == nil && !expired && !nonceChanged { return status }
|
|
238
|
+
guard input.capability.flags & 0x37f == 0x37f, hop < 3 else { throw UploadFailure.expired }
|
|
239
|
+
var body: [String: Any] = ["owner_revision": revision, "auth_nonce_base64": nonce,
|
|
240
|
+
"capabilities_base64": try UploadValidation.bytes(input.capability.rawValueHex).base64EncodedString()]
|
|
241
|
+
if !expired { body["reason"] = "nonce_changed" }
|
|
242
|
+
let path = sessions(parent.recordingId) + "/" + id + "/recover"
|
|
243
|
+
// The public challenge nonce lets a lost response retry the exact request.
|
|
244
|
+
try journal.save(input, entry: parent, recoveryNonceBase64: nonce)
|
|
245
|
+
recoveryNonceBase64 = nonce
|
|
246
|
+
try await retry {
|
|
247
|
+
let pointer: Pointer = try self.decode(await self.request(path, method: "POST", body: body, drain: true))
|
|
248
|
+
let successor = try pointer.entry(recordingId: parent.recordingId)
|
|
249
|
+
guard pointer.owner_revision > revision, !visited.contains(pointer.session_id) else { throw UploadFailure.identityConflict }
|
|
250
|
+
try self.journal.save(self.input, entry: successor, authorizationNonceSHA256: nonceHash)
|
|
251
|
+
self.entry = successor
|
|
252
|
+
self.authorizationNonceSHA256 = nonceHash
|
|
253
|
+
self.recoveryNonceBase64 = nil
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw UploadFailure.expired
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private func verify(_ evidence: UploadEvidence) throws {
|
|
260
|
+
try check()
|
|
261
|
+
guard session != nil, evidence.ciphertextLength == UInt64(input.recording.ciphertextLength),
|
|
262
|
+
evidence.ciphertextSHA256 == (try UploadValidation.bytes(input.recording.ciphertextSha256)) else {
|
|
263
|
+
throw UploadFailure.identityConflict
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func shouldUpload(_ evidence: UploadEvidence) throws -> Bool { try verify(evidence); return uploadCiphertext }
|
|
268
|
+
|
|
269
|
+
func reconcileStaging(_ manifest: Data, evidence: UploadEvidence) async throws -> Bool {
|
|
270
|
+
try verify(evidence)
|
|
271
|
+
guard uploadCiphertext else { return false }
|
|
272
|
+
do {
|
|
273
|
+
try await retry { try await submitManifest(manifest, evidence: evidence) }
|
|
274
|
+
return false
|
|
275
|
+
} catch UploadFailure.stagingMissing { return true }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
func stagingRequest(_ evidence: UploadEvidence) async throws -> URLRequest {
|
|
279
|
+
try verify(evidence)
|
|
280
|
+
guard uploadCiphertext, let session else { throw UploadFailure.state }
|
|
281
|
+
guard try UploadValidation.date(session.expires_at) > now() else { throw UploadFailure.expired }
|
|
282
|
+
struct Target: Decodable { let url: String; let method: String; let headers: [String: String] }
|
|
283
|
+
let target: Target = try decode(await request(try sessionPath() + "/staging-url", method: "POST",
|
|
284
|
+
body: ["owner_revision": session.owner_revision]))
|
|
285
|
+
try check()
|
|
286
|
+
guard try UploadValidation.date(session.expires_at) > now() else { throw UploadFailure.expired }
|
|
287
|
+
guard target.method == "PUT", let components = URLComponents(string: target.url), components.scheme == "https",
|
|
288
|
+
components.host != nil, components.user == nil, components.password == nil, components.fragment == nil,
|
|
289
|
+
let url = components.url else { throw UploadFailure.invalidDocument }
|
|
290
|
+
var request = URLRequest(url: url)
|
|
291
|
+
request.httpMethod = "PUT"
|
|
292
|
+
for (name, value) in target.headers {
|
|
293
|
+
guard UploadValidation.matches(name, "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"),
|
|
294
|
+
!value.contains("\r"), !value.contains("\n"), !value.contains("\0"),
|
|
295
|
+
!["authorization", "cookie", "proxy-authorization"].contains(name.lowercased()) else {
|
|
296
|
+
throw UploadFailure.invalidDocument
|
|
297
|
+
}
|
|
298
|
+
request.setValue(value, forHTTPHeaderField: name)
|
|
299
|
+
}
|
|
300
|
+
return request
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func submitManifest(_ manifest: Data, evidence: UploadEvidence) async throws {
|
|
304
|
+
try verify(evidence)
|
|
305
|
+
guard manifest.count == 580, evidence.manifestLength == 580, evidence.manifestSHA256.count == 32,
|
|
306
|
+
Data(SHA256.hash(data: manifest)) == evidence.manifestSHA256 else { throw UploadFailure.invalidDocument }
|
|
307
|
+
guard needsManifest, let session else { return }
|
|
308
|
+
_ = try await request(try sessionPath() + "/manifest", method: "POST", body: [
|
|
309
|
+
"owner_revision": session.owner_revision, "manifest_base64": manifest.base64EncodedString(),
|
|
310
|
+
"manifest_sha256": UploadValidation.hash(manifest),
|
|
311
|
+
])
|
|
312
|
+
uploadCiphertext = false
|
|
313
|
+
needsManifest = false
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
func finalize(_ evidence: UploadEvidence) async throws {
|
|
317
|
+
try verify(evidence)
|
|
318
|
+
guard let entry else { throw UploadFailure.state }
|
|
319
|
+
for _ in 0..<60 {
|
|
320
|
+
let status: UploadSession = try decode(await retry { try await request(try sessionPath()) })
|
|
321
|
+
try status.validateIdentity(entry, recording: input.recording)
|
|
322
|
+
_ = try status.actions()
|
|
323
|
+
if status.state == "published" {
|
|
324
|
+
guard status.plaintext_length == UInt64(input.recording.plaintextLength),
|
|
325
|
+
let hash = status.completion_receipt_sha256, UploadValidation.hex(hash, bytes: 32) else {
|
|
326
|
+
throw UploadFailure.invalidDocument
|
|
327
|
+
}
|
|
328
|
+
receipt = try UploadValidation.document(status.completion_receipt_base64, hash: hash, size: 336)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
try await wait(2000)
|
|
332
|
+
try check()
|
|
333
|
+
}
|
|
334
|
+
throw UploadFailure.pending
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
func completionReceipt(_ evidence: UploadEvidence) throws -> Data {
|
|
338
|
+
try verify(evidence)
|
|
339
|
+
guard let receipt else { throw UploadFailure.state }
|
|
340
|
+
receiptDelivered = true
|
|
341
|
+
return receipt
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
func complete() throws {
|
|
345
|
+
try check()
|
|
346
|
+
guard receiptDelivered, let entry else { throw UploadFailure.state }
|
|
347
|
+
try journal.remove(input, expected: entry)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private struct ContextStatus: Decodable, Sendable {
|
|
351
|
+
let context_id: String
|
|
352
|
+
let state: String
|
|
353
|
+
let challenge_base64: String
|
|
354
|
+
let result_base64: String?
|
|
355
|
+
let expires_at: String
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func uploadContext(_ nonce: Data) async throws -> UploadContext {
|
|
359
|
+
try check()
|
|
360
|
+
guard nonce.count == 16 else { throw UploadFailure.invalidDocument }
|
|
361
|
+
let initial: ContextStatus = try await retry {
|
|
362
|
+
try self.decode(await self.request(self.contexts, method: "POST", body: ["nonce_base64": nonce.base64EncodedString()]))
|
|
363
|
+
}
|
|
364
|
+
guard UploadValidation.uuid(initial.context_id), ["challenge", "pending", "complete"].contains(initial.state) else {
|
|
365
|
+
throw UploadFailure.invalidDocument
|
|
366
|
+
}
|
|
367
|
+
_ = try UploadValidation.date(initial.expires_at)
|
|
368
|
+
let challenge = try UploadValidation.document(initial.challenge_base64, size: 196)
|
|
369
|
+
return UploadContext(challenge: challenge, exchangeProof: { proof in try await self.exchange(proof, initial: initial) })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private func exchange(_ proof: Data, initial: ContextStatus) async throws -> Data {
|
|
373
|
+
try check()
|
|
374
|
+
guard (116...366).contains(proof.count) else { throw UploadFailure.invalidDocument }
|
|
375
|
+
let path = contexts + "/" + initial.context_id
|
|
376
|
+
var value: ContextStatus = try await retry {
|
|
377
|
+
try self.decode(await self.request(path + "/proof", method: "POST", body: ["proof_base64": proof.base64EncodedString()]))
|
|
378
|
+
}
|
|
379
|
+
for poll in 0...100 {
|
|
380
|
+
try check()
|
|
381
|
+
guard value.context_id == initial.context_id, value.challenge_base64 == initial.challenge_base64,
|
|
382
|
+
value.expires_at == initial.expires_at, ["pending", "complete"].contains(value.state),
|
|
383
|
+
value.state != "pending" || value.result_base64 == nil else { throw UploadFailure.invalidDocument }
|
|
384
|
+
if value.state == "complete" { return try UploadValidation.document(value.result_base64, size: 264) }
|
|
385
|
+
if poll == 100 { break }
|
|
386
|
+
try await wait(250)
|
|
387
|
+
value = try await retry { try self.decode(await self.request(path)) }
|
|
388
|
+
}
|
|
389
|
+
throw UploadFailure.pending
|
|
390
|
+
}
|
|
391
|
+
}
|