@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.
Files changed (41) hide show
  1. package/README.md +12 -1
  2. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  7. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  8. package/generated/codegen-contract.json +167 -8
  9. package/ios/BotaUploadV2Backend.mm +49 -0
  10. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  11. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  12. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  13. package/ios/UploadBackend/UploadJournal.swift +187 -0
  14. package/ios/UploadBackend/UploadModels.swift +273 -0
  15. package/ios/UploadBackend/UploadOperation.swift +391 -0
  16. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  17. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  18. package/lib/commonjs/index.js +5 -0
  19. package/lib/commonjs/index.js.map +1 -1
  20. package/lib/commonjs/sdkIdentity.js +1 -1
  21. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  22. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  23. package/lib/module/encryptedUploadV2Backend.js +196 -0
  24. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  25. package/lib/module/index.js +3 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/sdkIdentity.js +1 -1
  28. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  29. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  30. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  31. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  32. package/lib/typescript/index.d.ts +3 -0
  33. package/lib/typescript/index.d.ts.map +1 -1
  34. package/lib/typescript/sdkIdentity.d.ts +1 -1
  35. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  36. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  37. package/package.json +1 -1
  38. package/src/encryptedUploadV2Backend.ts +171 -0
  39. package/src/index.ts +6 -0
  40. package/src/sdkIdentity.ts +1 -1
  41. 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
+ }