@bota.dev/react-native-app-sdk 2.0.0-beta.10 → 2.0.0-beta.12
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 +18 -8
- 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,158 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import java.util.UUID
|
|
5
|
+
import java.util.concurrent.TimeUnit
|
|
6
|
+
import kotlin.coroutines.resume
|
|
7
|
+
import kotlin.coroutines.resumeWithException
|
|
8
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
9
|
+
import kotlinx.coroutines.NonCancellable
|
|
10
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
11
|
+
import kotlinx.coroutines.withContext
|
|
12
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
13
|
+
import okhttp3.Call
|
|
14
|
+
import okhttp3.Callback
|
|
15
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
16
|
+
import okhttp3.OkHttpClient
|
|
17
|
+
import okhttp3.Request
|
|
18
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
19
|
+
import okhttp3.Response
|
|
20
|
+
import org.json.JSONObject
|
|
21
|
+
|
|
22
|
+
internal class UploadOperation(val input: UploadInput) {
|
|
23
|
+
private var cancelled = false
|
|
24
|
+
private val cancellations = mutableSetOf<() -> Unit>()
|
|
25
|
+
var registrationId: String? = null
|
|
26
|
+
|
|
27
|
+
@Synchronized fun check() { if (cancelled) fail("CANCELLED") }
|
|
28
|
+
@Synchronized fun <T> active(block: () -> T): T { check(); return block() }
|
|
29
|
+
@Synchronized fun onCancel(callback: () -> Unit): () -> Unit {
|
|
30
|
+
check()
|
|
31
|
+
cancellations.add(callback)
|
|
32
|
+
return { synchronized(this) { cancellations.remove(callback) }; Unit }
|
|
33
|
+
}
|
|
34
|
+
fun cancel() {
|
|
35
|
+
val callbacks = synchronized(this) {
|
|
36
|
+
if (cancelled) return
|
|
37
|
+
cancelled = true
|
|
38
|
+
cancellations.toList().also { cancellations.clear() }
|
|
39
|
+
}
|
|
40
|
+
callbacks.forEach { it() }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
internal class CredentialBroker(private val emit: (String, String) -> Unit) {
|
|
45
|
+
private data class Pending(val operation: UploadOperation, val result: CompletableDeferred<String>)
|
|
46
|
+
private val pending = mutableMapOf<String, Pending>()
|
|
47
|
+
|
|
48
|
+
suspend fun request(operation: UploadOperation): String {
|
|
49
|
+
operation.check()
|
|
50
|
+
val requestId = UUID.randomUUID().toString()
|
|
51
|
+
val result = CompletableDeferred<String>()
|
|
52
|
+
val unregister = operation.onCancel { reject(requestId) }
|
|
53
|
+
try {
|
|
54
|
+
operation.active {
|
|
55
|
+
synchronized(this) { pending[requestId] = Pending(operation, result) }
|
|
56
|
+
emit(requestId, operation.input.operationId)
|
|
57
|
+
}
|
|
58
|
+
val token = withTimeoutOrNull(30_000) { result.await() } ?: fail("CREDENTIALS")
|
|
59
|
+
operation.check()
|
|
60
|
+
return token
|
|
61
|
+
} catch (error: Exception) {
|
|
62
|
+
operation.check()
|
|
63
|
+
throw error
|
|
64
|
+
} finally {
|
|
65
|
+
unregister()
|
|
66
|
+
synchronized(this) { pending.remove(requestId) }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun resolve(requestId: String, token: String) {
|
|
71
|
+
val request = synchronized(this) { pending.remove(requestId) } ?: return
|
|
72
|
+
try {
|
|
73
|
+
request.operation.active {
|
|
74
|
+
demand(token.length in 1..32768 && token.all { it.code in 33..126 }, "CREDENTIALS")
|
|
75
|
+
request.result.complete(token)
|
|
76
|
+
}
|
|
77
|
+
} catch (error: UploadFailure) { request.result.completeExceptionally(error) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fun reject(requestId: String) {
|
|
81
|
+
val request = synchronized(this) { pending.remove(requestId) } ?: return
|
|
82
|
+
request.result.completeExceptionally(UploadFailure("BOTA_UPLOAD_CREDENTIALS"))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
internal class UploadRequest(
|
|
87
|
+
val origin: String, val path: String, val method: String, val headers: Map<String, String>, val body: JSONObject?,
|
|
88
|
+
) {
|
|
89
|
+
override fun toString(): String = "UploadRequest(<redacted>)"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
internal interface UploadHttp {
|
|
93
|
+
suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
|
|
94
|
+
beforeDispatch: () -> Unit = {}): JSONObject
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Dedicated client: no app network recorder, auth interceptor, redirects, or automatic POST retry. */
|
|
98
|
+
internal class BackendUploadHttp(private val client: OkHttpClient = OkHttpClient.Builder()
|
|
99
|
+
.followRedirects(false).followSslRedirects(false).retryOnConnectionFailure(false)
|
|
100
|
+
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).callTimeout(45, TimeUnit.SECONDS).build()) : UploadHttp {
|
|
101
|
+
override suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
|
|
102
|
+
beforeDispatch: () -> Unit): JSONObject = withContext(if (retain) NonCancellable else kotlin.coroutines.EmptyCoroutineContext) {
|
|
103
|
+
try {
|
|
104
|
+
val nativeRequest = Request.Builder().url(request.origin + request.path).apply {
|
|
105
|
+
request.headers.forEach { (key, value) -> header(key, value) }
|
|
106
|
+
method(request.method, request.body?.toString()?.toRequestBody("application/json".toMediaType()))
|
|
107
|
+
}.build()
|
|
108
|
+
val call = client.newCall(nativeRequest)
|
|
109
|
+
suspendCancellableCoroutine { continuation ->
|
|
110
|
+
var unregister: (() -> Unit)? = null
|
|
111
|
+
continuation.invokeOnCancellation { call.cancel() }
|
|
112
|
+
try {
|
|
113
|
+
operation.active {
|
|
114
|
+
if (!retain) unregister = operation.onCancel { call.cancel() }
|
|
115
|
+
beforeDispatch()
|
|
116
|
+
call.enqueue(object : Callback {
|
|
117
|
+
override fun onFailure(call: Call, e: IOException) {
|
|
118
|
+
unregister?.invoke()
|
|
119
|
+
if (continuation.isActive) continuation.resumeWithException(UploadFailure("BOTA_UPLOAD_TRANSPORT"))
|
|
120
|
+
}
|
|
121
|
+
override fun onResponse(call: Call, response: Response) {
|
|
122
|
+
val value = runCatching {
|
|
123
|
+
response.use {
|
|
124
|
+
if (!response.isSuccessful && response.code != 401 && response.code != 403 && response.code != 409) fail("HTTP_${response.code}")
|
|
125
|
+
val body = response.body ?: fail("INVALID_DOCUMENT")
|
|
126
|
+
demand(body.contentLength() <= 65536, "INVALID_DOCUMENT")
|
|
127
|
+
// Read a bounded body here, before completing the retained request.
|
|
128
|
+
val source = body.source()
|
|
129
|
+
source.request(65537)
|
|
130
|
+
demand(source.buffer.size <= 65536, "INVALID_DOCUMENT")
|
|
131
|
+
val text = source.readUtf8()
|
|
132
|
+
if (response.code == 409 && runCatching {
|
|
133
|
+
JSONObject(text).optJSONObject("error")?.optString("code") == "encrypted_upload_v2_staging_missing"
|
|
134
|
+
}.getOrDefault(false)) fail("STAGING_MISSING")
|
|
135
|
+
if (!response.isSuccessful) fail("HTTP_${response.code}")
|
|
136
|
+
if (text.isEmpty()) JSONObject() else sanitized("INVALID_DOCUMENT") { JSONObject(text) }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
unregister?.invoke()
|
|
140
|
+
if (continuation.isActive) value.fold({ continuation.resume(it) }, {
|
|
141
|
+
continuation.resumeWithException(if (it is UploadFailure) it else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
} catch (error: Exception) {
|
|
147
|
+
unregister?.invoke()
|
|
148
|
+
if (continuation.isActive) continuation.resumeWithException(
|
|
149
|
+
if (error is UploadFailure) error else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (error: Exception) {
|
|
153
|
+
if (!retain) operation.check()
|
|
154
|
+
if (error is UploadFailure) throw error
|
|
155
|
+
fail("TRANSPORT")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.nio.channels.FileChannel
|
|
6
|
+
import java.nio.file.Files
|
|
7
|
+
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
|
8
|
+
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
|
|
9
|
+
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
|
|
10
|
+
import java.nio.file.StandardOpenOption.READ
|
|
11
|
+
import java.util.UUID
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
|
|
14
|
+
internal data class JournalEntry(val identitySha256: String, val pointer: UploadPointer?, val phase: String = "ready",
|
|
15
|
+
val importedPriorSha256: String? = null, val authorizationNonceSha256: String? = null,
|
|
16
|
+
val recoveryNonceBase64: String? = null) {
|
|
17
|
+
fun json(): JSONObject = (pointer?.json() ?: JSONObject()).put("version", 2)
|
|
18
|
+
.put("identitySha256", identitySha256).put("phase", phase).apply {
|
|
19
|
+
if (importedPriorSha256 != null) put("importedPriorSha256", importedPriorSha256)
|
|
20
|
+
if (authorizationNonceSha256 != null) put("authorizationNonceSha256", authorizationNonceSha256)
|
|
21
|
+
if (recoveryNonceBase64 != null) put("recoveryNonceBase64", recoveryNonceBase64)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
internal interface JournalDurability {
|
|
26
|
+
fun syncFile(stream: FileOutputStream) { stream.fd.sync() }
|
|
27
|
+
fun replace(source: File, target: File) { Files.move(source.toPath(), target.toPath(), ATOMIC_MOVE, REPLACE_EXISTING) }
|
|
28
|
+
fun syncDirectory(directory: File) { FileChannel.open(directory.toPath(), READ).use { it.force(true) } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
internal class UploadJournal(private val root: File, private val durability: JournalDurability = object : JournalDurability {}) {
|
|
32
|
+
private fun directory() {
|
|
33
|
+
val path = root.toPath()
|
|
34
|
+
if (!Files.exists(path, NOFOLLOW_LINKS)) {
|
|
35
|
+
demand(root.mkdir(), "JOURNAL")
|
|
36
|
+
durability.syncDirectory(root.parentFile!!)
|
|
37
|
+
}
|
|
38
|
+
demand(Files.isDirectory(path, NOFOLLOW_LINKS) && !Files.isSymbolicLink(path), "JOURNAL")
|
|
39
|
+
}
|
|
40
|
+
private fun file(key: String): File = File(root, "${hashString(key)}.json")
|
|
41
|
+
|
|
42
|
+
@Synchronized fun load(input: UploadInput): JournalEntry? = sanitized("JOURNAL") {
|
|
43
|
+
directory()
|
|
44
|
+
val file = file(input.journalKey)
|
|
45
|
+
if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return@sanitized null
|
|
46
|
+
demand(Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS) && file.length() <= 4096, "JOURNAL")
|
|
47
|
+
val json = JSONObject(file.readText(Charsets.UTF_8))
|
|
48
|
+
val allowed = setOf("version", "identitySha256", "phase", "recordingId", "sessionId", "ownerRevision", "importedPriorSha256", "authorizationNonceSha256", "recoveryNonceBase64")
|
|
49
|
+
demand(json.keys().asSequence().all { it in allowed }, "JOURNAL")
|
|
50
|
+
val version = json.integer("version", 1, 2)
|
|
51
|
+
val identity = hashString(json.string("identitySha256"))
|
|
52
|
+
demand(identity == (if (version == 1L) input.legacyIdentitySha256 else input.identitySha256), "IDENTITY_CONFLICT")
|
|
53
|
+
val phase = json.string("phase")
|
|
54
|
+
demand(phase in setOf("ready", "recording_create_unknown", "session_create_unknown"), "JOURNAL")
|
|
55
|
+
val pointer = if (json.has("recordingId")) UploadPointer.parse(json) else null
|
|
56
|
+
val imported = if (json.has("importedPriorSha256")) hashString(json.string("importedPriorSha256")) else null
|
|
57
|
+
val nonceHash = if (json.has("authorizationNonceSha256")) hashString(json.string("authorizationNonceSha256")) else null
|
|
58
|
+
demand(nonceHash == null || pointer?.sessionId != null, "JOURNAL")
|
|
59
|
+
val recoveryNonce = if (json.has("recoveryNonceBase64")) json.string("recoveryNonceBase64").also { opaque(it, 16) } else null
|
|
60
|
+
demand(recoveryNonce == null || (phase == "ready" && pointer?.sessionId != null), "JOURNAL")
|
|
61
|
+
demand(if (phase == "recording_create_unknown") pointer == null else pointer != null, "JOURNAL")
|
|
62
|
+
demand(phase != "session_create_unknown" || pointer?.sessionId == null, "JOURNAL")
|
|
63
|
+
if (input.prior != null && pointer != null) {
|
|
64
|
+
demand(input.prior.recordingId == pointer.recordingId &&
|
|
65
|
+
(input.prior.sessionId == null || input.prior.fingerprint() == imported ||
|
|
66
|
+
(input.prior.sessionId == pointer.sessionId && input.prior.ownerRevision == pointer.ownerRevision)),
|
|
67
|
+
"IDENTITY_CONFLICT")
|
|
68
|
+
}
|
|
69
|
+
JournalEntry(input.identitySha256, pointer, phase, imported, nonceHash, recoveryNonce).also {
|
|
70
|
+
if (version == 1L) save(input, it)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Synchronized fun save(input: UploadInput, entry: JournalEntry) = sanitized("JOURNAL") {
|
|
75
|
+
directory()
|
|
76
|
+
demand(entry.identitySha256 == input.identitySha256, "IDENTITY_CONFLICT")
|
|
77
|
+
val target = file(input.journalKey)
|
|
78
|
+
demand(!Files.isSymbolicLink(target.toPath()), "JOURNAL")
|
|
79
|
+
val temporary = File(root, ".${input.journalKey}.${UUID.randomUUID()}.tmp")
|
|
80
|
+
try {
|
|
81
|
+
demand(temporary.createNewFile(), "JOURNAL")
|
|
82
|
+
FileOutputStream(temporary).use { stream ->
|
|
83
|
+
stream.write(entry.json().toString().toByteArray(Charsets.UTF_8))
|
|
84
|
+
durability.syncFile(stream)
|
|
85
|
+
}
|
|
86
|
+
durability.replace(temporary, target)
|
|
87
|
+
durability.syncDirectory(root)
|
|
88
|
+
} finally { if (temporary.exists()) temporary.delete() }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Synchronized fun remove(input: UploadInput, expected: UploadPointer) = sanitized("JOURNAL") {
|
|
92
|
+
val current = load(input) ?: fail("IDENTITY_CONFLICT")
|
|
93
|
+
demand(current.phase == "ready" && current.pointer == expected, "IDENTITY_CONFLICT")
|
|
94
|
+
Files.delete(file(input.journalKey).toPath())
|
|
95
|
+
durability.syncDirectory(root)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Synchronized fun rollbackRejectedCreate(input: UploadInput, marker: JournalEntry, previous: JournalEntry?) = sanitized("JOURNAL") {
|
|
99
|
+
demand(load(input) == marker, "IDENTITY_CONFLICT")
|
|
100
|
+
when (marker.phase) {
|
|
101
|
+
"recording_create_unknown" -> demand(previous == null && marker.pointer == null, "IDENTITY_CONFLICT")
|
|
102
|
+
"session_create_unknown" -> demand(previous?.phase == "ready" && previous.copy(phase = marker.phase) == marker, "IDENTITY_CONFLICT")
|
|
103
|
+
else -> fail("IDENTITY_CONFLICT")
|
|
104
|
+
}
|
|
105
|
+
if (previous == null) {
|
|
106
|
+
Files.delete(file(input.journalKey).toPath())
|
|
107
|
+
durability.syncDirectory(root)
|
|
108
|
+
} else {
|
|
109
|
+
save(input, previous)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
package dev.bota.sdk.reactnative.upload
|
|
2
|
+
|
|
3
|
+
import java.net.URI
|
|
4
|
+
import java.security.MessageDigest
|
|
5
|
+
import java.time.Instant
|
|
6
|
+
import java.util.Base64
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
internal class UploadFailure(val code: String) : Exception(code)
|
|
11
|
+
internal fun fail(code: String): Nothing = throw UploadFailure("BOTA_UPLOAD_$code")
|
|
12
|
+
internal fun demand(value: Boolean, code: String = "INVALID_INPUT") { if (!value) fail(code) }
|
|
13
|
+
internal inline fun <T> sanitized(code: String, block: () -> T): T = try { block() }
|
|
14
|
+
catch (error: UploadFailure) { throw error }
|
|
15
|
+
catch (_: Exception) { fail(code) }
|
|
16
|
+
internal fun digest(value: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(value)
|
|
17
|
+
.joinToString("") { "%02x".format(it.toInt() and 255) }
|
|
18
|
+
internal fun hex(value: String): ByteArray {
|
|
19
|
+
demand(value.length % 2 == 0 && value.matches(Regex("[0-9a-fA-F]*")))
|
|
20
|
+
return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
21
|
+
}
|
|
22
|
+
internal fun encoded(value: ByteArray): String = Base64.getEncoder().encodeToString(value)
|
|
23
|
+
internal fun opaque(value: String, length: Int, hash: String? = null): ByteArray = sanitized("INVALID_DOCUMENT") {
|
|
24
|
+
val bytes = Base64.getDecoder().decode(value)
|
|
25
|
+
demand(bytes.size == length && encoded(bytes) == value && (hash == null || digest(bytes) == hash), "INVALID_DOCUMENT")
|
|
26
|
+
bytes
|
|
27
|
+
}
|
|
28
|
+
internal fun JSONObject.string(key: String): String = (opt(key) as? String)?.also {
|
|
29
|
+
demand(it.isNotEmpty() && it.length <= 8192)
|
|
30
|
+
} ?: fail("INVALID_INPUT")
|
|
31
|
+
internal fun JSONObject.integer(key: String, minimum: Long = 0, maximum: Long = 9007199254740991L): Long {
|
|
32
|
+
val raw = opt(key) as? Number ?: fail("INVALID_INPUT")
|
|
33
|
+
val text = raw.toString()
|
|
34
|
+
demand(text.matches(Regex("0|[1-9][0-9]*")))
|
|
35
|
+
val number = text.toLongOrNull() ?: fail("INVALID_INPUT")
|
|
36
|
+
demand(number in minimum..maximum)
|
|
37
|
+
return number
|
|
38
|
+
}
|
|
39
|
+
internal fun JSONObject.decimal(key: String, minimum: Long = 0): Long {
|
|
40
|
+
val value = string(key)
|
|
41
|
+
demand(value.matches(Regex("0|[1-9][0-9]*")))
|
|
42
|
+
return (value.toLongOrNull() ?: fail("INVALID_INPUT")).also { demand(it in minimum..9007199254740991L) }
|
|
43
|
+
}
|
|
44
|
+
internal fun uuid(value: String): String = value.also {
|
|
45
|
+
demand(it.matches(Regex("[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")))
|
|
46
|
+
}
|
|
47
|
+
internal fun hashString(value: String): String = value.also { demand(it.matches(Regex("[0-9a-f]{64}"))) }
|
|
48
|
+
internal fun JSONObject.optionalObject(key: String): JSONObject? {
|
|
49
|
+
if (!has(key) || isNull(key)) return null
|
|
50
|
+
return opt(key) as? JSONObject ?: fail("INVALID_INPUT")
|
|
51
|
+
}
|
|
52
|
+
internal fun timestamp(value: String): Long = sanitized("INVALID_DOCUMENT") { Instant.parse(value).toEpochMilli() }
|
|
53
|
+
|
|
54
|
+
internal data class UploadScope(
|
|
55
|
+
val apiOrigin: String, val accountId: String, val projectId: String, val organizationId: String,
|
|
56
|
+
val environment: String, val endUserId: String, val deviceId: String,
|
|
57
|
+
val bindingGeneration: Long, val serialNumber: String, val nativeDeviceId: String, val scopeKey: String,
|
|
58
|
+
val apiBasePath: String = "/v1",
|
|
59
|
+
) {
|
|
60
|
+
fun identity(includeTransport: Boolean = false): List<Any> = listOf(apiOrigin, accountId, projectId, organizationId, environment, endUserId,
|
|
61
|
+
deviceId, bindingGeneration, serialNumber, if (includeTransport) nativeDeviceId else "", scopeKey, apiBasePath)
|
|
62
|
+
|
|
63
|
+
companion object {
|
|
64
|
+
fun parse(json: JSONObject): UploadScope {
|
|
65
|
+
val origin = json.string("apiOrigin")
|
|
66
|
+
val url = URI(origin)
|
|
67
|
+
demand(url.scheme == "https" && !url.host.isNullOrEmpty() && url.rawUserInfo == null &&
|
|
68
|
+
url.rawQuery == null && url.rawFragment == null && (url.rawPath.isNullOrEmpty() || url.rawPath == "/"))
|
|
69
|
+
fun identifier(key: String) = json.string(key).also { demand(it.matches(Regex("[A-Za-z0-9_-]{1,256}"))) }
|
|
70
|
+
return UploadScope(origin.trimEnd('/'), identifier("accountId"), identifier("projectId"), json.optString("organizationId", "").also { demand(it.isEmpty() || it.matches(Regex("[A-Za-z0-9_-]{1,256}"))) },
|
|
71
|
+
identifier("environment"), identifier("endUserId"), identifier("deviceId"), json.integer("bindingGeneration"),
|
|
72
|
+
json.string("serialNumber"), json.string("nativeDeviceId"), json.string("scopeKey"),
|
|
73
|
+
json.optString("apiBasePath", "/v1").also { demand(it.matches(Regex("(/[A-Za-z0-9_-]+)+"))) })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
internal data class UploadRecording(
|
|
79
|
+
val uuid: String, val generation: Long, val ciphertextLength: Long, val ciphertextSha256: String,
|
|
80
|
+
val startedAtMs: Long, val durationMs: Long, val plaintextLength: Long, val storageFormat: Long,
|
|
81
|
+
) {
|
|
82
|
+
fun identity(): List<Any> = listOf(uuid, generation, ciphertextLength.toString(), ciphertextSha256,
|
|
83
|
+
startedAtMs.toString(), durationMs.toString(), plaintextLength.toString(), storageFormat)
|
|
84
|
+
|
|
85
|
+
companion object {
|
|
86
|
+
fun parse(json: JSONObject): UploadRecording = UploadRecording(uuid(json.string("uuid")),
|
|
87
|
+
json.integer("generation", 1, 4294967295), json.decimal("ciphertextLength", 1),
|
|
88
|
+
hashString(json.string("ciphertextSha256")), json.decimal("startedAtMs"), json.decimal("durationMs"),
|
|
89
|
+
json.decimal("plaintextLength"), json.integer("storageFormat", 3, 3)).also {
|
|
90
|
+
demand(it.startedAtMs + it.durationMs <= 8640000000000000L)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
internal data class UploadPointer(val recordingId: String, val sessionId: String? = null, val ownerRevision: Long? = null) {
|
|
96
|
+
fun fingerprint(): String = digest(JSONArray(listOf(recordingId, sessionId ?: JSONObject.NULL,
|
|
97
|
+
ownerRevision ?: JSONObject.NULL)).toString().toByteArray(Charsets.UTF_8))
|
|
98
|
+
fun json(): JSONObject = JSONObject().put("recordingId", recordingId).apply {
|
|
99
|
+
if (sessionId != null) put("sessionId", sessionId).put("ownerRevision", ownerRevision)
|
|
100
|
+
}
|
|
101
|
+
companion object {
|
|
102
|
+
fun parse(json: JSONObject): UploadPointer {
|
|
103
|
+
val id = json.string("recordingId")
|
|
104
|
+
demand(id.matches(Regex("rec_[A-Za-z0-9_-]+")))
|
|
105
|
+
val hasSession = json.has("sessionId") && !json.isNull("sessionId")
|
|
106
|
+
val hasRevision = json.has("ownerRevision") && !json.isNull("ownerRevision")
|
|
107
|
+
demand(hasSession == hasRevision)
|
|
108
|
+
return UploadPointer(id, if (hasSession) uuid(json.string("sessionId")) else null,
|
|
109
|
+
if (hasRevision) json.integer("ownerRevision", 1, Int.MAX_VALUE.toLong()) else null)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
internal data class UploadInput(
|
|
115
|
+
val operationId: String, val scope: UploadScope, val recording: UploadRecording,
|
|
116
|
+
val capability: ByteArray, val flags: Long, val journalKey: String, val identitySha256: String,
|
|
117
|
+
val prior: UploadPointer?, val checkpoint: JSONObject?,
|
|
118
|
+
) {
|
|
119
|
+
val legacyIdentitySha256: String get() = digest(JSONArray(scope.identity(includeTransport = true) +
|
|
120
|
+
recording.identity()).toString().toByteArray(Charsets.UTF_8))
|
|
121
|
+
|
|
122
|
+
fun validateCheckpoint(pointer: UploadPointer?) {
|
|
123
|
+
val cp = checkpoint ?: return
|
|
124
|
+
sanitized("IDENTITY_CONFLICT") {
|
|
125
|
+
demand(pointer?.sessionId != null && pointer.ownerRevision != null, "IDENTITY_CONFLICT")
|
|
126
|
+
val session = uuid(cp.string("uploadSessionId"))
|
|
127
|
+
cp.integer("version", 1, 1)
|
|
128
|
+
val revision = cp.integer("ownerRevision", 1, Int.MAX_VALUE.toLong())
|
|
129
|
+
demand(revision <= pointer!!.ownerRevision!! &&
|
|
130
|
+
((revision == pointer.ownerRevision) == (session == pointer.sessionId)), "IDENTITY_CONFLICT")
|
|
131
|
+
demand(cp.decimal("nextCiphertextOffset") <= recording.ciphertextLength, "IDENTITY_CONFLICT")
|
|
132
|
+
hashString(cp.string("prefixSha256"))
|
|
133
|
+
cp.integer("revision", 1, 4294967295)
|
|
134
|
+
val transport = cp.string("transportSessionId")
|
|
135
|
+
demand(transport.matches(Regex("[1-9][0-9]*")) && transport.toULongOrNull() != null)
|
|
136
|
+
cp.string("sinkRegistrationId")
|
|
137
|
+
cp.integer("windowPackets", 1, 65535)
|
|
138
|
+
cp.integer("dataPayloadBytes", 1, 65535)
|
|
139
|
+
if (cp.has("highestContiguousSequence") && !cp.isNull("highestContiguousSequence"))
|
|
140
|
+
cp.integer("highestContiguousSequence", 0, 4294967295)
|
|
141
|
+
// Legacy identity fields, if supplied during migration, cannot contradict this recording.
|
|
142
|
+
if (cp.has("recordingUuid")) demand(cp.string("recordingUuid") == recording.uuid, "IDENTITY_CONFLICT")
|
|
143
|
+
if (cp.has("recordingGeneration")) demand(cp.integer("recordingGeneration") == recording.generation, "IDENTITY_CONFLICT")
|
|
144
|
+
if (cp.has("ciphertextLength")) demand(cp.decimal("ciphertextLength") == recording.ciphertextLength, "IDENTITY_CONFLICT")
|
|
145
|
+
if (cp.has("ciphertextSha256")) demand(cp.string("ciphertextSha256") == recording.ciphertextSha256, "IDENTITY_CONFLICT")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
companion object {
|
|
150
|
+
fun parse(text: String): UploadInput = sanitized("INVALID_INPUT") {
|
|
151
|
+
demand(text.length <= 32768)
|
|
152
|
+
val json = JSONObject(text)
|
|
153
|
+
val operation = json.string("operationId").also { demand(it.length <= 256) }
|
|
154
|
+
val scope = UploadScope.parse(json.getJSONObject("scope"))
|
|
155
|
+
val recording = UploadRecording.parse(json.getJSONObject("recording"))
|
|
156
|
+
val capability = json.getJSONObject("capability")
|
|
157
|
+
val bytes = hex(capability.string("rawValueHex"))
|
|
158
|
+
demand(bytes.size == 24)
|
|
159
|
+
|
|
160
|
+
val keyFields = listOf(scope.scopeKey, scope.deviceId, scope.bindingGeneration, recording.uuid,
|
|
161
|
+
recording.generation, recording.ciphertextLength.toString(), recording.ciphertextSha256)
|
|
162
|
+
// Android org.json escapes slashes; the existing JS journal key uses JSON.stringify.
|
|
163
|
+
val keyJson = JSONArray(keyFields).toString().replace("\\/", "/")
|
|
164
|
+
val key = digest(keyJson.toByteArray(Charsets.UTF_8))
|
|
165
|
+
if (json.has("journalKey")) demand(hashString(json.string("journalKey")) == key, "IDENTITY_CONFLICT")
|
|
166
|
+
val identity = digest(JSONArray(scope.identity() + recording.identity()).toString().toByteArray(Charsets.UTF_8))
|
|
167
|
+
UploadInput(operation, scope, recording, bytes, capability.integer("flags", 0, 4294967295), key, identity,
|
|
168
|
+
json.optionalObject("priorJournalEntry")?.let(UploadPointer::parse), json.optionalObject("checkpoint"))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@bota.dev/react-native-app-sdk",
|
|
4
|
-
"packageVersion": "2.0.0-beta.
|
|
4
|
+
"packageVersion": "2.0.0-beta.12",
|
|
5
5
|
"reactNativeVersion": "0.86.3",
|
|
6
6
|
"codegenLibrary": "BotaDeviceSDKSpec",
|
|
7
7
|
"nativeModule": "BotaDeviceSDK",
|
|
@@ -4108,6 +4108,161 @@
|
|
|
4108
4108
|
]
|
|
4109
4109
|
},
|
|
4110
4110
|
"moduleName": "BotaDeviceSDK"
|
|
4111
|
+
},
|
|
4112
|
+
"NativeBotaUploadV2Backend": {
|
|
4113
|
+
"type": "NativeModule",
|
|
4114
|
+
"aliasMap": {},
|
|
4115
|
+
"enumMap": {},
|
|
4116
|
+
"spec": {
|
|
4117
|
+
"eventEmitters": [
|
|
4118
|
+
{
|
|
4119
|
+
"name": "onCredentialsRequested",
|
|
4120
|
+
"optional": false,
|
|
4121
|
+
"typeAnnotation": {
|
|
4122
|
+
"type": "EventEmitterTypeAnnotation",
|
|
4123
|
+
"typeAnnotation": {
|
|
4124
|
+
"type": "ObjectTypeAnnotation",
|
|
4125
|
+
"properties": [
|
|
4126
|
+
{
|
|
4127
|
+
"name": "requestId",
|
|
4128
|
+
"optional": false,
|
|
4129
|
+
"typeAnnotation": {
|
|
4130
|
+
"type": "StringTypeAnnotation"
|
|
4131
|
+
}
|
|
4132
|
+
},
|
|
4133
|
+
{
|
|
4134
|
+
"name": "operationId",
|
|
4135
|
+
"optional": false,
|
|
4136
|
+
"typeAnnotation": {
|
|
4137
|
+
"type": "StringTypeAnnotation"
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
]
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
],
|
|
4145
|
+
"methods": [
|
|
4146
|
+
{
|
|
4147
|
+
"name": "prepare",
|
|
4148
|
+
"optional": false,
|
|
4149
|
+
"typeAnnotation": {
|
|
4150
|
+
"type": "FunctionTypeAnnotation",
|
|
4151
|
+
"returnTypeAnnotation": {
|
|
4152
|
+
"type": "PromiseTypeAnnotation",
|
|
4153
|
+
"elementType": {
|
|
4154
|
+
"type": "StringTypeAnnotation"
|
|
4155
|
+
}
|
|
4156
|
+
},
|
|
4157
|
+
"params": [
|
|
4158
|
+
{
|
|
4159
|
+
"name": "inputJSON",
|
|
4160
|
+
"optional": false,
|
|
4161
|
+
"typeAnnotation": {
|
|
4162
|
+
"type": "StringTypeAnnotation"
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
]
|
|
4166
|
+
}
|
|
4167
|
+
},
|
|
4168
|
+
{
|
|
4169
|
+
"name": "cancel",
|
|
4170
|
+
"optional": false,
|
|
4171
|
+
"typeAnnotation": {
|
|
4172
|
+
"type": "FunctionTypeAnnotation",
|
|
4173
|
+
"returnTypeAnnotation": {
|
|
4174
|
+
"type": "PromiseTypeAnnotation",
|
|
4175
|
+
"elementType": {
|
|
4176
|
+
"type": "VoidTypeAnnotation"
|
|
4177
|
+
}
|
|
4178
|
+
},
|
|
4179
|
+
"params": [
|
|
4180
|
+
{
|
|
4181
|
+
"name": "operationId",
|
|
4182
|
+
"optional": false,
|
|
4183
|
+
"typeAnnotation": {
|
|
4184
|
+
"type": "StringTypeAnnotation"
|
|
4185
|
+
}
|
|
4186
|
+
}
|
|
4187
|
+
]
|
|
4188
|
+
}
|
|
4189
|
+
},
|
|
4190
|
+
{
|
|
4191
|
+
"name": "complete",
|
|
4192
|
+
"optional": false,
|
|
4193
|
+
"typeAnnotation": {
|
|
4194
|
+
"type": "FunctionTypeAnnotation",
|
|
4195
|
+
"returnTypeAnnotation": {
|
|
4196
|
+
"type": "PromiseTypeAnnotation",
|
|
4197
|
+
"elementType": {
|
|
4198
|
+
"type": "VoidTypeAnnotation"
|
|
4199
|
+
}
|
|
4200
|
+
},
|
|
4201
|
+
"params": [
|
|
4202
|
+
{
|
|
4203
|
+
"name": "operationId",
|
|
4204
|
+
"optional": false,
|
|
4205
|
+
"typeAnnotation": {
|
|
4206
|
+
"type": "StringTypeAnnotation"
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
]
|
|
4210
|
+
}
|
|
4211
|
+
},
|
|
4212
|
+
{
|
|
4213
|
+
"name": "resolveCredentials",
|
|
4214
|
+
"optional": false,
|
|
4215
|
+
"typeAnnotation": {
|
|
4216
|
+
"type": "FunctionTypeAnnotation",
|
|
4217
|
+
"returnTypeAnnotation": {
|
|
4218
|
+
"type": "PromiseTypeAnnotation",
|
|
4219
|
+
"elementType": {
|
|
4220
|
+
"type": "VoidTypeAnnotation"
|
|
4221
|
+
}
|
|
4222
|
+
},
|
|
4223
|
+
"params": [
|
|
4224
|
+
{
|
|
4225
|
+
"name": "requestId",
|
|
4226
|
+
"optional": false,
|
|
4227
|
+
"typeAnnotation": {
|
|
4228
|
+
"type": "StringTypeAnnotation"
|
|
4229
|
+
}
|
|
4230
|
+
},
|
|
4231
|
+
{
|
|
4232
|
+
"name": "token",
|
|
4233
|
+
"optional": false,
|
|
4234
|
+
"typeAnnotation": {
|
|
4235
|
+
"type": "StringTypeAnnotation"
|
|
4236
|
+
}
|
|
4237
|
+
}
|
|
4238
|
+
]
|
|
4239
|
+
}
|
|
4240
|
+
},
|
|
4241
|
+
{
|
|
4242
|
+
"name": "rejectCredentials",
|
|
4243
|
+
"optional": false,
|
|
4244
|
+
"typeAnnotation": {
|
|
4245
|
+
"type": "FunctionTypeAnnotation",
|
|
4246
|
+
"returnTypeAnnotation": {
|
|
4247
|
+
"type": "PromiseTypeAnnotation",
|
|
4248
|
+
"elementType": {
|
|
4249
|
+
"type": "VoidTypeAnnotation"
|
|
4250
|
+
}
|
|
4251
|
+
},
|
|
4252
|
+
"params": [
|
|
4253
|
+
{
|
|
4254
|
+
"name": "requestId",
|
|
4255
|
+
"optional": false,
|
|
4256
|
+
"typeAnnotation": {
|
|
4257
|
+
"type": "StringTypeAnnotation"
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
]
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
]
|
|
4264
|
+
},
|
|
4265
|
+
"moduleName": "BotaUploadV2Backend"
|
|
4111
4266
|
}
|
|
4112
4267
|
}
|
|
4113
4268
|
},
|
|
@@ -4117,13 +4272,17 @@
|
|
|
4117
4272
|
"path": "java/dev/bota/sdk/reactnative/NativeBotaDeviceSDKSpec.java",
|
|
4118
4273
|
"sha256": "3f1acef6907956745c7db721b5ec768a7d099da4a8e85bc50ae960451c261e2d"
|
|
4119
4274
|
},
|
|
4275
|
+
{
|
|
4276
|
+
"path": "java/dev/bota/sdk/reactnative/NativeBotaUploadV2BackendSpec.java",
|
|
4277
|
+
"sha256": "50844eb0e1ed98848de2819d2a88f00ccc4a550d4bcfdc5db21039a1eb17127f"
|
|
4278
|
+
},
|
|
4120
4279
|
{
|
|
4121
4280
|
"path": "jni/BotaDeviceSDKSpec-generated.cpp",
|
|
4122
|
-
"sha256": "
|
|
4281
|
+
"sha256": "f1a82f3cf03616e6f08655204af85317087cd2b0f633c052f2939eb49448c6b8"
|
|
4123
4282
|
},
|
|
4124
4283
|
{
|
|
4125
4284
|
"path": "jni/BotaDeviceSDKSpec.h",
|
|
4126
|
-
"sha256": "
|
|
4285
|
+
"sha256": "a4809b54c65c499c2ad727df1461d2044cfcb6de1eb3bbb05582e160426effce"
|
|
4127
4286
|
},
|
|
4128
4287
|
{
|
|
4129
4288
|
"path": "jni/CMakeLists.txt",
|
|
@@ -4131,23 +4290,23 @@
|
|
|
4131
4290
|
},
|
|
4132
4291
|
{
|
|
4133
4292
|
"path": "jni/react/renderer/components/BotaDeviceSDKSpec/BotaDeviceSDKSpecJSI.h",
|
|
4134
|
-
"sha256": "
|
|
4293
|
+
"sha256": "6c5309bc7b6b6b4b2e62e5bed85a7fab8a9da8c43af9e3904692e50756de9e0f"
|
|
4135
4294
|
}
|
|
4136
4295
|
],
|
|
4137
4296
|
"ios": [
|
|
4138
4297
|
{
|
|
4139
4298
|
"path": "BotaDeviceSDKSpec/BotaDeviceSDKSpec-generated.mm",
|
|
4140
|
-
"sha256": "
|
|
4299
|
+
"sha256": "8bee25e75f2e73f0b989b65e1c083fb851ba77ca3d4384aecaabb1bcb252fe7a"
|
|
4141
4300
|
},
|
|
4142
4301
|
{
|
|
4143
4302
|
"path": "BotaDeviceSDKSpec/BotaDeviceSDKSpec.h",
|
|
4144
|
-
"sha256": "
|
|
4303
|
+
"sha256": "6bef81e2a87bb83ddbc3021e69ddbd280dac23a5049af675b738925454b1c22c"
|
|
4145
4304
|
},
|
|
4146
4305
|
{
|
|
4147
4306
|
"path": "BotaDeviceSDKSpecJSI.h",
|
|
4148
|
-
"sha256": "
|
|
4307
|
+
"sha256": "6c5309bc7b6b6b4b2e62e5bed85a7fab8a9da8c43af9e3904692e50756de9e0f"
|
|
4149
4308
|
}
|
|
4150
4309
|
]
|
|
4151
4310
|
},
|
|
4152
|
-
"contractDigest": "
|
|
4311
|
+
"contractDigest": "0378337ad43dd8f3d04c6df7713d4c7805779f15befb830951437c6f60d057fe"
|
|
4153
4312
|
}
|