@latchway/react-native 0.0.0-bootstrap.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/LatchwayReactNative.podspec +33 -0
- package/NOTICE +7 -0
- package/README.md +293 -3
- package/SECURITY.md +69 -0
- package/android/build.gradle.kts +62 -0
- package/android/consumer-rules.pro +2 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/android/gradle.properties +4 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle.kts +33 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
- package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
- package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
- package/babel.cjs +22 -0
- package/babel.d.cts +7 -0
- package/contract.lock +7 -0
- package/docs/architecture.md +145 -0
- package/docs/conformance.md +68 -0
- package/docs/langchain.md +135 -0
- package/docs/native-installation.md +172 -0
- package/docs/physical-device-evidence.md +559 -0
- package/docs/releasing.md +360 -0
- package/docs/security.md +55 -0
- package/ios/LatchwayNativeBridge.swift +1551 -0
- package/ios/RCTNativeLatchway.h +9 -0
- package/ios/RCTNativeLatchway.mm +175 -0
- package/lib/client.d.ts +29 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +939 -0
- package/lib/client.js.map +1 -0
- package/lib/component-client.d.ts +15 -0
- package/lib/component-client.d.ts.map +1 -0
- package/lib/component-client.js +141 -0
- package/lib/component-client.js.map +1 -0
- package/lib/config.d.ts +25 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +261 -0
- package/lib/config.js.map +1 -0
- package/lib/coordinator.d.ts +19 -0
- package/lib/coordinator.d.ts.map +1 -0
- package/lib/coordinator.js +167 -0
- package/lib/coordinator.js.map +1 -0
- package/lib/errors.d.ts +5 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +201 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/lib/native/NativeLatchway.d.ts +25 -0
- package/lib/native/NativeLatchway.d.ts.map +1 -0
- package/lib/native/NativeLatchway.js +3 -0
- package/lib/native/NativeLatchway.js.map +1 -0
- package/lib/native/bridge.d.ts +5 -0
- package/lib/native/bridge.d.ts.map +1 -0
- package/lib/native/bridge.js +17 -0
- package/lib/native/bridge.js.map +1 -0
- package/lib/native-output.d.ts +3 -0
- package/lib/native-output.d.ts.map +1 -0
- package/lib/native-output.js +43 -0
- package/lib/native-output.js.map +1 -0
- package/lib/polyfills.d.ts +8 -0
- package/lib/polyfills.d.ts.map +1 -0
- package/lib/polyfills.js +56 -0
- package/lib/polyfills.js.map +1 -0
- package/lib/request-id.d.ts +2 -0
- package/lib/request-id.d.ts.map +1 -0
- package/lib/request-id.js +5 -0
- package/lib/request-id.js.map +1 -0
- package/lib/runtime-symbols.d.ts +2 -0
- package/lib/runtime-symbols.d.ts.map +1 -0
- package/lib/runtime-symbols.js +9 -0
- package/lib/runtime-symbols.js.map +1 -0
- package/lib/testing.d.ts +7 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +9 -0
- package/lib/testing.js.map +1 -0
- package/lib/types.d.ts +200 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +8 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +8 -0
- package/lib/version.js.map +1 -0
- package/package.json +152 -6
- package/react-native.config.cjs +7 -0
- package/release-compatibility.json +64 -0
- package/src/client.ts +1022 -0
- package/src/component-client.ts +158 -0
- package/src/config.ts +368 -0
- package/src/coordinator.ts +195 -0
- package/src/errors.ts +225 -0
- package/src/index.ts +53 -0
- package/src/native/NativeLatchway.ts +75 -0
- package/src/native/bridge.ts +23 -0
- package/src/native-output.ts +43 -0
- package/src/polyfills.ts +50 -0
- package/src/request-id.ts +5 -0
- package/src/runtime-symbols.ts +9 -0
- package/src/testing.ts +11 -0
- package/src/types.ts +241 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
package dev.latchway.reactnative
|
|
2
|
+
|
|
3
|
+
import android.net.Uri
|
|
4
|
+
import android.util.Base64
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.Promise
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.bridge.WritableMap
|
|
9
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
10
|
+
import dev.latchway.core.KeyPolicy
|
|
11
|
+
import dev.latchway.core.LATCHWAY_CONTRACT_VERSION
|
|
12
|
+
import dev.latchway.core.LATCHWAY_PROTOCOL_VERSION
|
|
13
|
+
import dev.latchway.core.LATCHWAY_SDK_VERSION
|
|
14
|
+
import dev.latchway.core.LatchwayClientPlatform
|
|
15
|
+
import dev.latchway.core.LatchwayErrorCode
|
|
16
|
+
import dev.latchway.core.LatchwayException
|
|
17
|
+
import dev.latchway.okhttp.LatchwayClient
|
|
18
|
+
import dev.latchway.okhttp.LatchwayConfiguration
|
|
19
|
+
import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_ID
|
|
20
|
+
import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION
|
|
21
|
+
import dev.latchway.playintegrity.PlayIntegrityAttestationProvider
|
|
22
|
+
import kotlinx.coroutines.CancellationException
|
|
23
|
+
import kotlinx.coroutines.CoroutineScope
|
|
24
|
+
import kotlinx.coroutines.Dispatchers
|
|
25
|
+
import kotlinx.coroutines.Job
|
|
26
|
+
import kotlinx.coroutines.SupervisorJob
|
|
27
|
+
import kotlinx.coroutines.cancel
|
|
28
|
+
import kotlinx.coroutines.currentCoroutineContext
|
|
29
|
+
import kotlinx.coroutines.launch
|
|
30
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
31
|
+
import kotlinx.coroutines.sync.Mutex
|
|
32
|
+
import kotlinx.coroutines.sync.withLock
|
|
33
|
+
import okhttp3.Call
|
|
34
|
+
import okhttp3.Callback
|
|
35
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
36
|
+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
37
|
+
import okhttp3.OkHttpClient
|
|
38
|
+
import okhttp3.Request
|
|
39
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
40
|
+
import okhttp3.Response
|
|
41
|
+
import org.json.JSONArray
|
|
42
|
+
import org.json.JSONObject
|
|
43
|
+
import java.io.Closeable
|
|
44
|
+
import java.io.IOException
|
|
45
|
+
import java.util.UUID
|
|
46
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
47
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
48
|
+
import kotlin.coroutines.resume
|
|
49
|
+
import kotlin.coroutines.resumeWithException
|
|
50
|
+
|
|
51
|
+
internal fun interface NativeClientFactory {
|
|
52
|
+
fun create(
|
|
53
|
+
configuration: NativeConfiguration,
|
|
54
|
+
keyPolicy: KeyPolicy,
|
|
55
|
+
cloudProjectNumber: Long,
|
|
56
|
+
tokenProvider: TransientIdentityTokenProvider,
|
|
57
|
+
reactContext: ReactApplicationContext,
|
|
58
|
+
): NativeClientOperations
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
internal interface NativeClientOperations : Closeable {
|
|
62
|
+
val applicationClient: OkHttpClient
|
|
63
|
+
|
|
64
|
+
suspend fun refresh()
|
|
65
|
+
suspend fun quota(feature: String): String
|
|
66
|
+
suspend fun diagnostics(): String
|
|
67
|
+
suspend fun revokeCurrentInstallation()
|
|
68
|
+
suspend fun revokeCurrentInstallationFamily()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
internal fun nativeApplicationClientBuilder(): OkHttpClient.Builder = OkHttpClient.Builder()
|
|
72
|
+
.followRedirects(false)
|
|
73
|
+
.followSslRedirects(false)
|
|
74
|
+
|
|
75
|
+
private class ProductionNativeClientOperations(
|
|
76
|
+
private val client: LatchwayClient,
|
|
77
|
+
) : NativeClientOperations {
|
|
78
|
+
override val applicationClient: OkHttpClient = client.buildOkHttpClient(
|
|
79
|
+
nativeApplicationClientBuilder(),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
override suspend fun refresh() {
|
|
83
|
+
client.refresh()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override suspend fun quota(feature: String): String {
|
|
87
|
+
val snapshot = client.quota(feature)
|
|
88
|
+
val limits = JSONArray()
|
|
89
|
+
snapshot.limits.forEach { limit ->
|
|
90
|
+
limits.put(JSONObject()
|
|
91
|
+
.put("metric", limit.metric)
|
|
92
|
+
.putNullable("maximum", limit.maximum)
|
|
93
|
+
.putNullable("used", limit.used)
|
|
94
|
+
.putNullable("reserved", limit.reserved)
|
|
95
|
+
.putNullable("remaining", limit.remaining)
|
|
96
|
+
.putNullable("resets_at", limit.resetsAt)
|
|
97
|
+
.put("hard", limit.hard))
|
|
98
|
+
}
|
|
99
|
+
return JSONObject()
|
|
100
|
+
.put("feature", snapshot.feature)
|
|
101
|
+
.put("observed_at", snapshot.observedAt)
|
|
102
|
+
.put("limits", limits)
|
|
103
|
+
.toString()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override suspend fun diagnostics(): String {
|
|
107
|
+
val diagnostics = client.diagnostics()
|
|
108
|
+
return JSONObject()
|
|
109
|
+
.put("contractVersion", diagnostics.contractVersion)
|
|
110
|
+
.put("protocolVersion", diagnostics.protocolVersion)
|
|
111
|
+
.put("keyStorage", diagnostics.key.backing.name.lowercase())
|
|
112
|
+
.put("attestation", JSONObject()
|
|
113
|
+
.put("support", "supported")
|
|
114
|
+
.put("provider", diagnostics.trustProvider)
|
|
115
|
+
.put("trustLevel", diagnostics.trustLevel))
|
|
116
|
+
.put("session", JSONObject()
|
|
117
|
+
.put("state", "active")
|
|
118
|
+
.put("expiresAt", diagnostics.sessionExpiresAt)
|
|
119
|
+
.put("refreshAvailable", diagnostics.refreshAvailable))
|
|
120
|
+
.put("installation", JSONObject()
|
|
121
|
+
.put("id", diagnostics.installationId)
|
|
122
|
+
.put("status", diagnostics.installationStatus))
|
|
123
|
+
.put("server", JSONObject()
|
|
124
|
+
.put("version", diagnostics.serverVersion)
|
|
125
|
+
.put("lastRequestID", diagnostics.requestId))
|
|
126
|
+
.toString()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
override suspend fun revokeCurrentInstallation() {
|
|
130
|
+
client.revokeCurrentInstallation()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override suspend fun revokeCurrentInstallationFamily() {
|
|
134
|
+
client.revokeCurrentInstallationFamily()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override fun close() {
|
|
138
|
+
applicationClient.dispatcher.cancelAll()
|
|
139
|
+
applicationClient.connectionPool.evictAll()
|
|
140
|
+
applicationClient.dispatcher.executorService.shutdown()
|
|
141
|
+
client.close()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private val PRODUCTION_NATIVE_CLIENT_FACTORY = NativeClientFactory {
|
|
146
|
+
configuration,
|
|
147
|
+
keyPolicy,
|
|
148
|
+
cloudProjectNumber,
|
|
149
|
+
tokenProvider,
|
|
150
|
+
reactContext,
|
|
151
|
+
->
|
|
152
|
+
val nativeConfiguration = LatchwayConfiguration(
|
|
153
|
+
baseUrl = configuration.baseURL.toHttpUrl(),
|
|
154
|
+
applicationId = configuration.applicationID,
|
|
155
|
+
environment = configuration.environment,
|
|
156
|
+
identityProvider = configuration.identityProvider,
|
|
157
|
+
clientPlatform = LatchwayClientPlatform.REACT_NATIVE_ANDROID,
|
|
158
|
+
sdkVersion = configuration.sdkVersion,
|
|
159
|
+
keyPolicy = keyPolicy,
|
|
160
|
+
allowInsecureLoopback = configuration.allowInsecureLoopback,
|
|
161
|
+
)
|
|
162
|
+
ProductionNativeClientOperations(
|
|
163
|
+
LatchwayClient(
|
|
164
|
+
configuration = nativeConfiguration,
|
|
165
|
+
identityTokenProvider = { tokenProvider.current() },
|
|
166
|
+
attestationProvider = PlayIntegrityAttestationProvider(
|
|
167
|
+
context = reactContext,
|
|
168
|
+
cloudProjectNumber = cloudProjectNumber,
|
|
169
|
+
),
|
|
170
|
+
context = reactContext,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@ReactModule(name = NativeLatchwayModule.NAME)
|
|
176
|
+
public class NativeLatchwayModule internal constructor(
|
|
177
|
+
reactContext: ReactApplicationContext,
|
|
178
|
+
private val clientFactory: NativeClientFactory,
|
|
179
|
+
private val userInfoFactory: () -> WritableMap,
|
|
180
|
+
) : NativeLatchwaySpec(reactContext) {
|
|
181
|
+
public constructor(reactContext: ReactApplicationContext) : this(
|
|
182
|
+
reactContext,
|
|
183
|
+
PRODUCTION_NATIVE_CLIENT_FACTORY,
|
|
184
|
+
{ Arguments.createMap() },
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
188
|
+
private val clients = ConcurrentHashMap<String, NativeClientContext>()
|
|
189
|
+
private val jobs = ConcurrentHashMap<String, Job>()
|
|
190
|
+
|
|
191
|
+
override fun getName(): String = NAME
|
|
192
|
+
|
|
193
|
+
override fun configure(clientID: String, configurationJSON: String, promise: Promise) {
|
|
194
|
+
launchPromise(clientID, "configure", promise) {
|
|
195
|
+
require(!clients.containsKey(clientID)) { "client identifier is already configured" }
|
|
196
|
+
val configuration = NativeConfiguration.parse(configurationJSON)
|
|
197
|
+
require(configuration.contractVersion == LATCHWAY_CONTRACT_VERSION &&
|
|
198
|
+
configuration.protocolVersion == LATCHWAY_PROTOCOL_VERSION &&
|
|
199
|
+
configuration.frameworkID == LATCHWAY_REACT_NATIVE_FRAMEWORK_ID &&
|
|
200
|
+
configuration.frameworkVersion == LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION
|
|
201
|
+
) { "contract version is incompatible" }
|
|
202
|
+
val projectNumber = configuration.playIntegrityCloudProjectNumber
|
|
203
|
+
?: throw IllegalArgumentException("Play Integrity cloud project number is required on Android")
|
|
204
|
+
val keyPolicy = when (configuration.keyPolicy) {
|
|
205
|
+
"hardware_backed_required" -> KeyPolicy(preferStrongBox = false, allowSoftwareBacked = false)
|
|
206
|
+
"strongbox_preferred" -> KeyPolicy(preferStrongBox = true, allowSoftwareBacked = false)
|
|
207
|
+
"software_allowed" -> KeyPolicy(preferStrongBox = true, allowSoftwareBacked = true)
|
|
208
|
+
else -> throw IllegalArgumentException("Android key policy is invalid")
|
|
209
|
+
}
|
|
210
|
+
val tokenProvider = TransientIdentityTokenProvider()
|
|
211
|
+
val client = clientFactory.create(
|
|
212
|
+
configuration = configuration,
|
|
213
|
+
keyPolicy = keyPolicy,
|
|
214
|
+
cloudProjectNumber = projectNumber,
|
|
215
|
+
tokenProvider = tokenProvider,
|
|
216
|
+
reactContext = reactApplicationContext,
|
|
217
|
+
)
|
|
218
|
+
val context = NativeClientContext(client, tokenProvider, configuration.baseURL.toHttpUrl())
|
|
219
|
+
check(clients.putIfAbsent(clientID, context) == null) { "client identifier is already configured" }
|
|
220
|
+
JSONObject()
|
|
221
|
+
.put("platform", "react_native_android")
|
|
222
|
+
.put("nativeSDKVersion", LATCHWAY_SDK_VERSION)
|
|
223
|
+
.put("contractVersion", LATCHWAY_CONTRACT_VERSION)
|
|
224
|
+
.put("protocolVersion", LATCHWAY_PROTOCOL_VERSION)
|
|
225
|
+
.toString()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
override fun configureComponent(
|
|
230
|
+
clientID: String,
|
|
231
|
+
configurationJSON: String,
|
|
232
|
+
componentJSON: String,
|
|
233
|
+
promise: Promise,
|
|
234
|
+
) {
|
|
235
|
+
launchPromise(clientID, "configure-component", promise) {
|
|
236
|
+
throw LatchwayException(
|
|
237
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
238
|
+
safeMessage = "React Native direct component attestation is not supported by this Android SDK",
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
override fun startRequest(
|
|
244
|
+
clientID: String,
|
|
245
|
+
operationID: String,
|
|
246
|
+
identityToken: String,
|
|
247
|
+
requestJSON: String,
|
|
248
|
+
promise: Promise,
|
|
249
|
+
) {
|
|
250
|
+
operate(clientID, operationID, promise) { context ->
|
|
251
|
+
context.startRequest(identityToken, requestJSON)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
override fun readResponseChunk(
|
|
256
|
+
clientID: String,
|
|
257
|
+
operationID: String,
|
|
258
|
+
responseID: String,
|
|
259
|
+
maximumBytes: Double,
|
|
260
|
+
promise: Promise,
|
|
261
|
+
) {
|
|
262
|
+
operate(clientID, operationID, promise) { context ->
|
|
263
|
+
context.readResponseChunk(responseID, maximumBytes)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
override fun closeResponse(clientID: String, responseID: String, promise: Promise) {
|
|
268
|
+
val context = clients[clientID]
|
|
269
|
+
if (context == null) {
|
|
270
|
+
promise.reject("invalid_configuration", "Latchway native client is not configured.")
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
context.closeResponse(responseID)
|
|
274
|
+
promise.resolve(null)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
override fun refresh(clientID: String, operationID: String, identityToken: String, promise: Promise) {
|
|
278
|
+
operate(clientID, operationID, promise) { context ->
|
|
279
|
+
context.withIdentityToken(identityToken) { client -> client.refresh() }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
override fun quota(
|
|
284
|
+
clientID: String,
|
|
285
|
+
operationID: String,
|
|
286
|
+
identityToken: String,
|
|
287
|
+
feature: String,
|
|
288
|
+
promise: Promise,
|
|
289
|
+
) {
|
|
290
|
+
operate(clientID, operationID, promise) { context ->
|
|
291
|
+
context.withIdentityToken(identityToken) { client -> client.quota(feature) }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
override fun diagnostics(clientID: String, operationID: String, identityToken: String, promise: Promise) {
|
|
296
|
+
operate(clientID, operationID, promise) { context ->
|
|
297
|
+
context.withIdentityToken(identityToken) { client -> client.diagnostics() }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
override fun establishDirectAttestation(
|
|
302
|
+
clientID: String,
|
|
303
|
+
operationID: String,
|
|
304
|
+
promise: Promise,
|
|
305
|
+
) {
|
|
306
|
+
operate(clientID, operationID, promise) {
|
|
307
|
+
// The v1 Android SDK has independently keyed delegated components,
|
|
308
|
+
// but no direct component-attestation endpoint. Never imitate the
|
|
309
|
+
// iOS App Attest protocol or accept evidence through JavaScript.
|
|
310
|
+
throw LatchwayException(
|
|
311
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
312
|
+
safeMessage = "Direct component attestation is not supported by this Android SDK",
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
override fun componentDiagnostics(
|
|
318
|
+
clientID: String,
|
|
319
|
+
operationID: String,
|
|
320
|
+
promise: Promise,
|
|
321
|
+
) {
|
|
322
|
+
operate(clientID, operationID, promise) {
|
|
323
|
+
throw LatchwayException(
|
|
324
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
325
|
+
safeMessage = "Direct-attestation component diagnostics are not supported by this Android SDK",
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
override fun prepareComponents(
|
|
331
|
+
clientID: String,
|
|
332
|
+
operationID: String,
|
|
333
|
+
identityToken: String,
|
|
334
|
+
componentsJSON: String,
|
|
335
|
+
promise: Promise,
|
|
336
|
+
) {
|
|
337
|
+
operate(clientID, operationID, promise) {
|
|
338
|
+
throw LatchwayException(
|
|
339
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
340
|
+
safeMessage = "Native iOS component provisioning is not supported by this Android SDK",
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
override fun replaceComponent(
|
|
346
|
+
clientID: String,
|
|
347
|
+
operationID: String,
|
|
348
|
+
identityToken: String,
|
|
349
|
+
componentJSON: String,
|
|
350
|
+
promise: Promise,
|
|
351
|
+
) {
|
|
352
|
+
operate(clientID, operationID, promise) {
|
|
353
|
+
throw LatchwayException(
|
|
354
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
355
|
+
safeMessage = "Native iOS component replacement is not supported by this Android SDK",
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
override fun rootComponentDiagnostics(
|
|
361
|
+
clientID: String,
|
|
362
|
+
operationID: String,
|
|
363
|
+
componentJSON: String,
|
|
364
|
+
promise: Promise,
|
|
365
|
+
) {
|
|
366
|
+
operate(clientID, operationID, promise) {
|
|
367
|
+
throw LatchwayException(
|
|
368
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
369
|
+
safeMessage = "Native iOS root component diagnostics are not supported by this Android SDK",
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
override fun revokeComponent(
|
|
375
|
+
clientID: String,
|
|
376
|
+
operationID: String,
|
|
377
|
+
identityToken: String,
|
|
378
|
+
componentJSON: String,
|
|
379
|
+
promise: Promise,
|
|
380
|
+
) {
|
|
381
|
+
operate(clientID, operationID, promise) {
|
|
382
|
+
throw LatchwayException(
|
|
383
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
384
|
+
safeMessage = "Native iOS component revocation is not supported by this Android SDK",
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
override fun revoke(clientID: String, operationID: String, identityToken: String, promise: Promise) {
|
|
390
|
+
operate(clientID, operationID, promise) { context ->
|
|
391
|
+
context.withIdentityToken(identityToken) { client -> client.revokeCurrentInstallation() }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
override fun revokeFamily(clientID: String, operationID: String, identityToken: String, promise: Promise) {
|
|
396
|
+
operate(clientID, operationID, promise) { context ->
|
|
397
|
+
context.withIdentityToken(identityToken) { client -> client.revokeCurrentInstallationFamily() }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
override fun revokeFamilyWithComponents(
|
|
402
|
+
clientID: String,
|
|
403
|
+
operationID: String,
|
|
404
|
+
identityToken: String,
|
|
405
|
+
componentsJSON: String,
|
|
406
|
+
promise: Promise,
|
|
407
|
+
) {
|
|
408
|
+
operate(clientID, operationID, promise) {
|
|
409
|
+
throw LatchwayException(
|
|
410
|
+
code = LatchwayErrorCode.ATTESTATION_UNSUPPORTED,
|
|
411
|
+
safeMessage = "Descriptor-bound iOS family retirement is not supported by this Android SDK",
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
override fun cancel(clientID: String, operationID: String) {
|
|
417
|
+
jobs[operationKey(clientID, operationID)]?.cancel()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
override fun dispose(clientID: String, promise: Promise) {
|
|
421
|
+
clients.remove(clientID)?.close()
|
|
422
|
+
val prefix = "$clientID|"
|
|
423
|
+
jobs.entries.filter { it.key.startsWith(prefix) }.forEach { it.value.cancel() }
|
|
424
|
+
promise.resolve(null)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
override fun invalidate() {
|
|
428
|
+
jobs.values.forEach { it.cancel() }
|
|
429
|
+
clients.values.forEach(NativeClientContext::close)
|
|
430
|
+
jobs.clear()
|
|
431
|
+
clients.clear()
|
|
432
|
+
scope.cancel()
|
|
433
|
+
super.invalidate()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private fun <T> operate(
|
|
437
|
+
clientID: String,
|
|
438
|
+
operationID: String,
|
|
439
|
+
promise: Promise,
|
|
440
|
+
action: suspend (NativeClientContext) -> T,
|
|
441
|
+
) {
|
|
442
|
+
val context = clients[clientID]
|
|
443
|
+
if (context == null) {
|
|
444
|
+
promise.reject("invalid_configuration", "Latchway native client is not configured.")
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
val key = operationKey(clientID, operationID)
|
|
448
|
+
val job = scope.launch(start = kotlinx.coroutines.CoroutineStart.LAZY) {
|
|
449
|
+
try {
|
|
450
|
+
promise.resolve(action(context))
|
|
451
|
+
} catch (failure: Throwable) {
|
|
452
|
+
promise.rejectSafe(failure, userInfoFactory)
|
|
453
|
+
} finally {
|
|
454
|
+
jobs.remove(key)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (jobs.putIfAbsent(key, job) != null) {
|
|
458
|
+
job.cancel()
|
|
459
|
+
promise.reject("request_invalid", "Latchway operation identifier is already active.")
|
|
460
|
+
} else {
|
|
461
|
+
job.start()
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private fun <T> launchPromise(
|
|
466
|
+
clientID: String,
|
|
467
|
+
operationID: String,
|
|
468
|
+
promise: Promise,
|
|
469
|
+
action: suspend () -> T,
|
|
470
|
+
) {
|
|
471
|
+
val key = operationKey(clientID, operationID)
|
|
472
|
+
val job = scope.launch(start = kotlinx.coroutines.CoroutineStart.LAZY) {
|
|
473
|
+
try { promise.resolve(action()) }
|
|
474
|
+
catch (failure: Throwable) { promise.rejectSafe(failure, userInfoFactory) }
|
|
475
|
+
finally { jobs.remove(key) }
|
|
476
|
+
}
|
|
477
|
+
if (jobs.putIfAbsent(key, job) != null) {
|
|
478
|
+
job.cancel()
|
|
479
|
+
promise.reject("request_invalid", "Latchway operation identifier is already active.")
|
|
480
|
+
} else {
|
|
481
|
+
job.start()
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
public companion object { public const val NAME: String = "NativeLatchway" }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
internal class NativeClientContext(
|
|
489
|
+
private val client: NativeClientOperations,
|
|
490
|
+
private val tokenProvider: TransientIdentityTokenProvider,
|
|
491
|
+
private val baseURL: okhttp3.HttpUrl,
|
|
492
|
+
) {
|
|
493
|
+
private val operationMutex = Mutex()
|
|
494
|
+
private val responses = ConcurrentHashMap<String, NativeResponse>()
|
|
495
|
+
private val applicationClient = client.applicationClient
|
|
496
|
+
|
|
497
|
+
suspend fun startRequest(identityToken: String, encoded: String): String =
|
|
498
|
+
withIdentityToken(identityToken) {
|
|
499
|
+
val input = NativeRequestInput.parse(encoded)
|
|
500
|
+
val url = try {
|
|
501
|
+
input.url.toHttpUrl()
|
|
502
|
+
} catch (failure: IllegalArgumentException) {
|
|
503
|
+
throw requestFailure("The request URL is invalid", failure)
|
|
504
|
+
}
|
|
505
|
+
validateNativeTarget(baseURL, url, input.method, input.feature)
|
|
506
|
+
val mediaType = input.headers.firstOrNull { it.first.equals("content-type", ignoreCase = true) }
|
|
507
|
+
?.second?.toMediaTypeOrNull()
|
|
508
|
+
val body = when {
|
|
509
|
+
input.body != null -> input.body.toRequestBody(mediaType)
|
|
510
|
+
input.method in METHODS_REQUIRING_BODY -> ByteArray(0).toRequestBody(mediaType)
|
|
511
|
+
else -> null
|
|
512
|
+
}
|
|
513
|
+
val request = try {
|
|
514
|
+
val builder = Request.Builder().url(url).method(input.method, body)
|
|
515
|
+
input.headers.forEach { (name, value) -> builder.header(name, value) }
|
|
516
|
+
builder.header("X-Latchway-Feature", input.feature)
|
|
517
|
+
builder.build()
|
|
518
|
+
} catch (failure: IllegalArgumentException) {
|
|
519
|
+
throw requestFailure("The native request is invalid", failure)
|
|
520
|
+
}
|
|
521
|
+
val response = try {
|
|
522
|
+
applicationClient.newCall(request).awaitResponse()
|
|
523
|
+
} catch (failure: IOException) {
|
|
524
|
+
throw LatchwayException(
|
|
525
|
+
code = LatchwayErrorCode.NETWORK_UNAVAILABLE,
|
|
526
|
+
retryable = true,
|
|
527
|
+
safeMessage = "The Latchway data-plane endpoint could not be reached",
|
|
528
|
+
cause = failure,
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
validateNativeTarget(baseURL, response.request.url, input.method, input.feature)
|
|
533
|
+
if (response.code !in 200..599) throw responseFailure("The native response status is invalid")
|
|
534
|
+
} catch (failure: Throwable) {
|
|
535
|
+
response.close()
|
|
536
|
+
throw failure
|
|
537
|
+
}
|
|
538
|
+
val responseID = "rsp_${UUID.randomUUID()}"
|
|
539
|
+
val handle = NativeResponse(response)
|
|
540
|
+
check(responses.putIfAbsent(responseID, handle) == null)
|
|
541
|
+
try {
|
|
542
|
+
responseMetadata(responseID, response)
|
|
543
|
+
} catch (failure: Throwable) {
|
|
544
|
+
responses.remove(responseID)?.close()
|
|
545
|
+
throw failure
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
suspend fun readResponseChunk(responseID: String, maximumBytes: Double): String {
|
|
550
|
+
if (!maximumBytes.isFinite() || maximumBytes % 1.0 != 0.0 || maximumBytes < 1.0 ||
|
|
551
|
+
maximumBytes > MAXIMUM_RESPONSE_CHUNK_BYTES.toDouble()
|
|
552
|
+
) {
|
|
553
|
+
throw requestFailure("The response chunk limit is invalid")
|
|
554
|
+
}
|
|
555
|
+
val handle = responses[responseID] ?: throw requestFailure("The native response handle is unavailable")
|
|
556
|
+
val bytes = handle.read(maximumBytes.toInt())
|
|
557
|
+
if (bytes == null) {
|
|
558
|
+
responses.remove(responseID, handle)
|
|
559
|
+
handle.close()
|
|
560
|
+
return JSONObject().put("done", true).toString()
|
|
561
|
+
}
|
|
562
|
+
return JSONObject()
|
|
563
|
+
.put("done", false)
|
|
564
|
+
.put("chunk", Base64.encodeToString(bytes, Base64.NO_WRAP))
|
|
565
|
+
.toString()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
fun closeResponse(responseID: String) {
|
|
569
|
+
responses.remove(responseID)?.close()
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
suspend fun <T> withIdentityToken(token: String, action: suspend (NativeClientOperations) -> T): T {
|
|
573
|
+
if (token.isEmpty() || token.toByteArray(Charsets.UTF_8).size > 65_536 || token.any(Char::isISOControl)) {
|
|
574
|
+
throw LatchwayException(
|
|
575
|
+
code = LatchwayErrorCode.REQUEST_INVALID,
|
|
576
|
+
safeMessage = "The identity token is invalid",
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
return operationMutex.withLock {
|
|
580
|
+
tokenProvider.set(token)
|
|
581
|
+
try { action(client) } finally { tokenProvider.clear() }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
fun close() {
|
|
586
|
+
responses.values.forEach(NativeResponse::close)
|
|
587
|
+
responses.clear()
|
|
588
|
+
client.close()
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private class NativeResponse(
|
|
593
|
+
private val response: Response,
|
|
594
|
+
) {
|
|
595
|
+
private val input = response.body.byteStream()
|
|
596
|
+
private val readMutex = Mutex()
|
|
597
|
+
private val closed = AtomicBoolean(false)
|
|
598
|
+
|
|
599
|
+
suspend fun read(maximumBytes: Int): ByteArray? = readMutex.withLock {
|
|
600
|
+
if (closed.get()) return@withLock null
|
|
601
|
+
val cancellation = currentCoroutineContext()[Job]?.invokeOnCompletion { failure ->
|
|
602
|
+
if (failure is CancellationException) close()
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
val buffer = ByteArray(maximumBytes)
|
|
606
|
+
var count: Int
|
|
607
|
+
do {
|
|
608
|
+
count = input.read(buffer)
|
|
609
|
+
} while (count == 0 && !closed.get())
|
|
610
|
+
if (count < 0) null else buffer.copyOf(count)
|
|
611
|
+
} catch (failure: IOException) {
|
|
612
|
+
throw LatchwayException(
|
|
613
|
+
code = LatchwayErrorCode.NETWORK_UNAVAILABLE,
|
|
614
|
+
retryable = true,
|
|
615
|
+
safeMessage = "The Latchway response stream failed",
|
|
616
|
+
cause = failure,
|
|
617
|
+
)
|
|
618
|
+
} finally {
|
|
619
|
+
cancellation?.dispose()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
fun close() {
|
|
624
|
+
if (closed.compareAndSet(false, true)) response.close()
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
internal class TransientIdentityTokenProvider {
|
|
629
|
+
@Volatile private var value: String? = null
|
|
630
|
+
fun set(token: String) { value = token }
|
|
631
|
+
fun clear() { value = null }
|
|
632
|
+
fun current(): String = value ?: throw IllegalStateException("identity token is unavailable")
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
internal data class NativeConfiguration(
|
|
636
|
+
val baseURL: String,
|
|
637
|
+
val applicationID: String,
|
|
638
|
+
val environment: String,
|
|
639
|
+
val identityProvider: String,
|
|
640
|
+
val sdkVersion: String,
|
|
641
|
+
val frameworkID: String,
|
|
642
|
+
val frameworkVersion: String,
|
|
643
|
+
val contractVersion: String,
|
|
644
|
+
val protocolVersion: Int,
|
|
645
|
+
val allowInsecureLoopback: Boolean,
|
|
646
|
+
val playIntegrityCloudProjectNumber: Long?,
|
|
647
|
+
val keyPolicy: String,
|
|
648
|
+
) {
|
|
649
|
+
companion object {
|
|
650
|
+
fun parse(encoded: String): NativeConfiguration {
|
|
651
|
+
require(encoded.toByteArray(Charsets.UTF_8).size <= 65_536) { "native configuration is too large" }
|
|
652
|
+
val value = JSONObject(encoded)
|
|
653
|
+
val android = value.getJSONObject("android")
|
|
654
|
+
val apple = value.getJSONObject("apple")
|
|
655
|
+
require(value.keys().asSequence().toSet() == NATIVE_CONFIGURATION_KEYS &&
|
|
656
|
+
android.keys().asSequence().toSet().let { keys ->
|
|
657
|
+
NATIVE_ANDROID_CONFIGURATION_REQUIRED_KEYS.all(keys::contains) &&
|
|
658
|
+
keys.all(NATIVE_ANDROID_CONFIGURATION_KEYS::contains)
|
|
659
|
+
} &&
|
|
660
|
+
apple.keys().asSequence().toSet().let { keys ->
|
|
661
|
+
NATIVE_APPLE_CONFIGURATION_REQUIRED_KEYS.all(keys::contains) &&
|
|
662
|
+
keys.all(NATIVE_APPLE_CONFIGURATION_KEYS::contains)
|
|
663
|
+
}
|
|
664
|
+
) { "native configuration has unexpected fields" }
|
|
665
|
+
return NativeConfiguration(
|
|
666
|
+
baseURL = value.getString("baseURL"),
|
|
667
|
+
applicationID = value.getString("applicationID"),
|
|
668
|
+
environment = value.getString("environment"),
|
|
669
|
+
identityProvider = value.getString("identityProvider"),
|
|
670
|
+
sdkVersion = value.getString("sdkVersion"),
|
|
671
|
+
frameworkID = value.getString("frameworkID"),
|
|
672
|
+
frameworkVersion = value.getString("frameworkVersion"),
|
|
673
|
+
contractVersion = value.getString("contractVersion"),
|
|
674
|
+
protocolVersion = value.getInt("protocolVersion"),
|
|
675
|
+
allowInsecureLoopback = value.optBoolean("allowInsecureLoopback", false),
|
|
676
|
+
playIntegrityCloudProjectNumber = android.optString("playIntegrityCloudProjectNumber")
|
|
677
|
+
.takeIf(String::isNotEmpty)?.toLongOrNull(),
|
|
678
|
+
keyPolicy = android.getString("keyPolicy"),
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private data class NativeRequestInput(
|
|
685
|
+
val url: String,
|
|
686
|
+
val method: String,
|
|
687
|
+
val feature: String,
|
|
688
|
+
val headers: List<Pair<String, String>>,
|
|
689
|
+
val body: ByteArray?,
|
|
690
|
+
) {
|
|
691
|
+
companion object {
|
|
692
|
+
fun parse(encoded: String): NativeRequestInput = try {
|
|
693
|
+
require(encoded.toByteArray(Charsets.UTF_8).size <= MAXIMUM_NATIVE_REQUEST_BYTES) {
|
|
694
|
+
"native request is too large"
|
|
695
|
+
}
|
|
696
|
+
val value = JSONObject(encoded)
|
|
697
|
+
require(value.keys().asSequence().toSet() == setOf("url", "method", "feature", "headers", "bodyBase64")) {
|
|
698
|
+
"native request has unexpected fields"
|
|
699
|
+
}
|
|
700
|
+
val method = value.getString("method")
|
|
701
|
+
require(METHOD_PATTERN.matches(method) && method !in FORBIDDEN_METHODS) { "request method is invalid" }
|
|
702
|
+
val feature = value.getString("feature")
|
|
703
|
+
require(FEATURE_PATTERN.matches(feature)) { "request feature is invalid" }
|
|
704
|
+
val encodedHeaders = value.getJSONArray("headers")
|
|
705
|
+
require(encodedHeaders.length() <= MAXIMUM_HEADERS) { "request has too many headers" }
|
|
706
|
+
var headerBytes = 0
|
|
707
|
+
val headers = buildList {
|
|
708
|
+
for (index in 0 until encodedHeaders.length()) {
|
|
709
|
+
val pair = encodedHeaders.getJSONArray(index)
|
|
710
|
+
require(pair.length() == 2) { "request header is invalid" }
|
|
711
|
+
val name = pair.getString(0).lowercase()
|
|
712
|
+
val headerValue = pair.getString(1)
|
|
713
|
+
require(HEADER_NAME_PATTERN.matches(name) && validHeaderValue(headerValue) &&
|
|
714
|
+
!isForbiddenCredentialName(name)
|
|
715
|
+
) { "request header is invalid" }
|
|
716
|
+
headerBytes += name.length + headerValue.length
|
|
717
|
+
require(headerBytes <= MAXIMUM_HEADER_BYTES) { "request headers are too large" }
|
|
718
|
+
add(name to headerValue)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
val body = value.optNullableString("bodyBase64")?.let { encodedBody ->
|
|
722
|
+
val decoded = Base64.decode(encodedBody, Base64.NO_WRAP)
|
|
723
|
+
require(decoded.size <= MAXIMUM_REQUEST_BODY_BYTES &&
|
|
724
|
+
Base64.encodeToString(decoded, Base64.NO_WRAP) == encodedBody
|
|
725
|
+
) { "request body is invalid" }
|
|
726
|
+
decoded
|
|
727
|
+
}
|
|
728
|
+
NativeRequestInput(
|
|
729
|
+
url = value.getString("url"),
|
|
730
|
+
method = method,
|
|
731
|
+
feature = feature,
|
|
732
|
+
headers = headers,
|
|
733
|
+
body = body,
|
|
734
|
+
)
|
|
735
|
+
} catch (failure: LatchwayException) {
|
|
736
|
+
throw failure
|
|
737
|
+
} catch (failure: Exception) {
|
|
738
|
+
throw requestFailure("The native request is invalid", failure)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation ->
|
|
744
|
+
continuation.invokeOnCancellation { cancel() }
|
|
745
|
+
enqueue(object : Callback {
|
|
746
|
+
override fun onFailure(call: Call, e: IOException) {
|
|
747
|
+
if (continuation.isActive) continuation.resumeWithException(e)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
override fun onResponse(call: Call, response: Response) {
|
|
751
|
+
if (continuation.isActive) {
|
|
752
|
+
continuation.resume(response)
|
|
753
|
+
} else {
|
|
754
|
+
response.close()
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private fun responseMetadata(responseID: String, response: Response): String {
|
|
761
|
+
val headers = JSONArray()
|
|
762
|
+
var count = 0
|
|
763
|
+
var size = 0
|
|
764
|
+
for ((name, value) in response.headers) {
|
|
765
|
+
val normalized = name.lowercase()
|
|
766
|
+
if (!safeResponseHeader(normalized)) continue
|
|
767
|
+
if (!validHeaderValue(value)) throw responseFailure("The response header is invalid")
|
|
768
|
+
count += 1
|
|
769
|
+
size += normalized.length + value.length
|
|
770
|
+
if (count > MAXIMUM_HEADERS || size > MAXIMUM_HEADER_BYTES) {
|
|
771
|
+
throw responseFailure("The response headers are too large")
|
|
772
|
+
}
|
|
773
|
+
headers.put(JSONArray().put(normalized).put(value))
|
|
774
|
+
}
|
|
775
|
+
return JSONObject()
|
|
776
|
+
.put("responseID", responseID)
|
|
777
|
+
.put("status", response.code)
|
|
778
|
+
.put("statusText", "")
|
|
779
|
+
.put("headers", headers)
|
|
780
|
+
.toString()
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
internal fun validateNativeTarget(
|
|
784
|
+
baseURL: okhttp3.HttpUrl,
|
|
785
|
+
target: okhttp3.HttpUrl,
|
|
786
|
+
method: String,
|
|
787
|
+
feature: String,
|
|
788
|
+
) {
|
|
789
|
+
if (target.scheme != baseURL.scheme || target.host != baseURL.host || target.port != baseURL.port ||
|
|
790
|
+
target.username.isNotEmpty() || target.password.isNotEmpty() || target.fragment != null
|
|
791
|
+
) {
|
|
792
|
+
throw requestFailure("The request destination is not an allowed Latchway data-plane URL")
|
|
793
|
+
}
|
|
794
|
+
val normalizedMethod = method.uppercase(java.util.Locale.US)
|
|
795
|
+
val structured = normalizedMethod == "POST" && target.encodedPath in ALLOWED_DATA_PLANE_PATHS
|
|
796
|
+
val opaquePrefix = "/proxy/$feature/"
|
|
797
|
+
val remaining = target.encodedPath.removePrefix(opaquePrefix)
|
|
798
|
+
val lowerRemaining = remaining.lowercase(java.util.Locale.US)
|
|
799
|
+
val opaque = normalizedMethod in OPAQUE_DATA_PLANE_METHODS && target.query == null &&
|
|
800
|
+
target.encodedPath.startsWith(opaquePrefix) && remaining.length in 1..2_048 &&
|
|
801
|
+
remaining.split('/').all { it.isNotEmpty() && it != "." && it != ".." } &&
|
|
802
|
+
"%2e" !in lowerRemaining && "%2f" !in lowerRemaining && "%5c" !in lowerRemaining &&
|
|
803
|
+
'\\' !in remaining && !remaining.startsWith("http:", ignoreCase = true) &&
|
|
804
|
+
!remaining.startsWith("https:", ignoreCase = true)
|
|
805
|
+
if (!structured && !opaque) {
|
|
806
|
+
throw requestFailure("The request method and path are not allowed by the Latchway client contract")
|
|
807
|
+
}
|
|
808
|
+
if (target.queryParameterNames.any { isForbiddenCredentialName(decodedCredentialName(it)) }) {
|
|
809
|
+
throw requestFailure("Upstream provider credentials must not be supplied in the request URL")
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private fun decodedCredentialName(value: String): String {
|
|
814
|
+
var decoded = value
|
|
815
|
+
repeat(4) {
|
|
816
|
+
val next = Uri.decode(decoded)
|
|
817
|
+
if (next == decoded) return decoded.lowercase()
|
|
818
|
+
decoded = next
|
|
819
|
+
}
|
|
820
|
+
if (PERCENT_ESCAPE_PATTERN.containsMatchIn(decoded)) return "credential-encoded-name"
|
|
821
|
+
return decoded.lowercase()
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private fun isForbiddenCredentialName(value: String): Boolean {
|
|
825
|
+
val normalized = value.lowercase()
|
|
826
|
+
if (normalized in FORBIDDEN_REQUEST_HEADERS || normalized in FORBIDDEN_CREDENTIAL_QUERY_NAMES) return true
|
|
827
|
+
val compact = normalized.filter(Char::isLetterOrDigit)
|
|
828
|
+
if (compact in setOf("key", "token", "secret", "bearer", "cookie", "password", "passwd")) return true
|
|
829
|
+
return FORBIDDEN_CREDENTIAL_NAME_FRAGMENTS.any(compact::contains)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private fun safeResponseHeader(name: String): Boolean =
|
|
833
|
+
name in SAFE_RESPONSE_HEADERS || name.startsWith("x-ratelimit-") || name.startsWith("ratelimit-")
|
|
834
|
+
|
|
835
|
+
private fun validHeaderValue(value: String): Boolean =
|
|
836
|
+
value.length <= MAXIMUM_HEADER_VALUE_BYTES && value.none { character ->
|
|
837
|
+
character.code in 0x00..0x08 || character.code in 0x0a..0x1f || character.code == 0x7f
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private fun requestFailure(message: String, cause: Throwable? = null): LatchwayException = LatchwayException(
|
|
841
|
+
code = LatchwayErrorCode.REQUEST_INVALID,
|
|
842
|
+
safeMessage = message,
|
|
843
|
+
cause = cause,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
private fun responseFailure(message: String): LatchwayException = LatchwayException(
|
|
847
|
+
code = LatchwayErrorCode.RESPONSE_INVALID,
|
|
848
|
+
safeMessage = message,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
private const val MAXIMUM_REQUEST_BODY_BYTES: Int = 8 * 1024 * 1024
|
|
852
|
+
private const val MAXIMUM_NATIVE_REQUEST_BYTES: Int = 12 * 1024 * 1024
|
|
853
|
+
private const val MAXIMUM_RESPONSE_CHUNK_BYTES: Int = 32 * 1024
|
|
854
|
+
private const val MAXIMUM_HEADERS: Int = 128
|
|
855
|
+
private const val MAXIMUM_HEADER_BYTES: Int = 128 * 1024
|
|
856
|
+
private const val MAXIMUM_HEADER_VALUE_BYTES: Int = 8 * 1024
|
|
857
|
+
|
|
858
|
+
private val NATIVE_CONFIGURATION_KEYS = setOf(
|
|
859
|
+
"baseURL", "applicationID", "environment", "identityProvider", "appVersion", "sdkVersion",
|
|
860
|
+
"frameworkID", "frameworkVersion", "contractVersion", "protocolVersion",
|
|
861
|
+
"allowInsecureLoopback", "apple", "android",
|
|
862
|
+
)
|
|
863
|
+
private val NATIVE_ANDROID_CONFIGURATION_REQUIRED_KEYS = setOf("keyPolicy")
|
|
864
|
+
private val NATIVE_ANDROID_CONFIGURATION_KEYS = NATIVE_ANDROID_CONFIGURATION_REQUIRED_KEYS +
|
|
865
|
+
setOf("playIntegrityCloudProjectNumber")
|
|
866
|
+
private val NATIVE_APPLE_CONFIGURATION_REQUIRED_KEYS = setOf("appAttestEnabled", "softwareKeyFallbackPolicy")
|
|
867
|
+
private val NATIVE_APPLE_CONFIGURATION_KEYS = NATIVE_APPLE_CONFIGURATION_REQUIRED_KEYS + setOf(
|
|
868
|
+
"storageNamespace", "rootKeychainAccessGroup", "legacySharedKeychainAccessGroups",
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
private val METHOD_PATTERN = Regex("^[A-Z][A-Z0-9!#$%&'*+.^_`|~-]{0,31}$")
|
|
872
|
+
private val FEATURE_PATTERN = Regex("^[a-z][a-z0-9_-]{0,62}$")
|
|
873
|
+
private val HEADER_NAME_PATTERN = Regex("^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
|
|
874
|
+
private val PERCENT_ESCAPE_PATTERN = Regex("%[0-9A-Fa-f]{2}")
|
|
875
|
+
private val METHODS_REQUIRING_BODY = setOf("POST", "PUT", "PATCH", "PROPPATCH", "REPORT")
|
|
876
|
+
private val FORBIDDEN_METHODS = setOf("CONNECT", "TRACE", "TRACK")
|
|
877
|
+
private val ALLOWED_DATA_PLANE_PATHS = setOf(
|
|
878
|
+
"/v1/responses",
|
|
879
|
+
"/v1/chat/completions",
|
|
880
|
+
"/v1/embeddings",
|
|
881
|
+
"/v1/messages",
|
|
882
|
+
)
|
|
883
|
+
private val OPAQUE_DATA_PLANE_METHODS = setOf("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
884
|
+
private val FORBIDDEN_REQUEST_HEADERS = setOf(
|
|
885
|
+
"authorization", "proxy-authorization", "api-key", "api_key", "apikey", "x-api-key",
|
|
886
|
+
"openai-api-key", "openai_api_key", "x-openai-api-key", "anthropic-api-key", "anthropic_api_key",
|
|
887
|
+
"x-goog-api-key", "x-goog_api_key", "access_token", "auth_token", "x-auth-token", "cookie", "connection",
|
|
888
|
+
"content-length", "expect", "host", "key", "proxy-connection", "te", "trailer", "transfer-encoding",
|
|
889
|
+
"token", "upgrade", "x-amz-credential", "x-amz-security-token", "x-amz-signature", "x-goog-credential",
|
|
890
|
+
"x-goog-signature", "dpop", "dpop-nonce", "x-latchway-feature", "x-latchway-framework",
|
|
891
|
+
"x-latchway-framework-version", "x-latchway-protocol-version", "x-latchway-request-id", "x-latchway-sdk",
|
|
892
|
+
"x-latchway-sdk-version",
|
|
893
|
+
)
|
|
894
|
+
private val FORBIDDEN_CREDENTIAL_QUERY_NAMES = FORBIDDEN_REQUEST_HEADERS + setOf(
|
|
895
|
+
"refresh_token", "identity_token", "private_key", "client_data_hash", "request_hash", "integrity_token",
|
|
896
|
+
)
|
|
897
|
+
private val FORBIDDEN_CREDENTIAL_NAME_FRAGMENTS = setOf(
|
|
898
|
+
"authorization", "dpop", "apikey", "accesstoken", "authtoken", "refreshtoken", "identitytoken",
|
|
899
|
+
"integritytoken", "sessiontoken", "privatekey", "clientsecret", "credential", "attestationevidence",
|
|
900
|
+
"clientdatahash", "requesthash", "xamzsignature", "xgoogsignature",
|
|
901
|
+
)
|
|
902
|
+
private val SAFE_RESPONSE_HEADERS = setOf(
|
|
903
|
+
"accept-ranges", "age", "cache-control", "content-encoding", "content-language", "content-length",
|
|
904
|
+
"content-range", "content-type", "date", "etag", "expires", "last-modified", "request-id", "retry-after",
|
|
905
|
+
"server-timing", "vary", "x-request-id", "x-latchway-request-id", "x-latchway-server-version",
|
|
906
|
+
"x-latchway-operation-id",
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
private fun JSONObject.optNullableString(name: String): String? =
|
|
910
|
+
if (isNull(name)) null else getString(name)
|
|
911
|
+
|
|
912
|
+
private fun JSONObject.putNullable(name: String, value: Any?): JSONObject =
|
|
913
|
+
put(name, value ?: JSONObject.NULL)
|
|
914
|
+
|
|
915
|
+
private fun operationKey(clientID: String, operationID: String): String = "$clientID|$operationID"
|
|
916
|
+
|
|
917
|
+
private fun Promise.rejectSafe(failure: Throwable, userInfoFactory: () -> WritableMap) {
|
|
918
|
+
val code: String
|
|
919
|
+
val message: String
|
|
920
|
+
val requestID: String?
|
|
921
|
+
val operationID: String?
|
|
922
|
+
val status: Int?
|
|
923
|
+
val retryable: Boolean
|
|
924
|
+
when (failure) {
|
|
925
|
+
is CancellationException -> {
|
|
926
|
+
code = "cancelled"; message = "The Latchway native operation was cancelled."
|
|
927
|
+
requestID = null; operationID = null; status = null; retryable = false
|
|
928
|
+
}
|
|
929
|
+
is LatchwayException -> {
|
|
930
|
+
code = failure.code.wireValue
|
|
931
|
+
message = safeNativeErrorMessage(code)
|
|
932
|
+
requestID = failure.requestId
|
|
933
|
+
operationID = failure.operationId
|
|
934
|
+
status = failure.httpStatus
|
|
935
|
+
retryable = failure.retryable
|
|
936
|
+
}
|
|
937
|
+
is IllegalArgumentException -> {
|
|
938
|
+
code = "invalid_configuration"; message = "Latchway native configuration is invalid."
|
|
939
|
+
requestID = null; operationID = null; status = null; retryable = false
|
|
940
|
+
}
|
|
941
|
+
else -> {
|
|
942
|
+
code = "internal_error"; message = "The Latchway native operation failed."
|
|
943
|
+
requestID = null; operationID = null; status = null; retryable = false
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
val userInfo = userInfoFactory().apply {
|
|
947
|
+
putString("code", code)
|
|
948
|
+
putString("documentationURL", "https://docs.latchway.dev/errors/${code.replace('_', '-')}")
|
|
949
|
+
requestID?.let { putString("requestID", it) }
|
|
950
|
+
operationID?.let { putString("operationID", it) }
|
|
951
|
+
status?.let { putInt("status", it) }
|
|
952
|
+
putBoolean("retryable", retryable)
|
|
953
|
+
}
|
|
954
|
+
reject(code, message, userInfo)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private fun safeNativeErrorMessage(code: String): String = when (code) {
|
|
958
|
+
"request_invalid" -> "The native Latchway request is invalid."
|
|
959
|
+
"configuration_invalid" -> "Latchway native configuration is invalid."
|
|
960
|
+
"network_unavailable" -> "The Latchway native transport is unavailable."
|
|
961
|
+
"response_invalid" -> "Latchway returned an invalid native response."
|
|
962
|
+
"operation_indeterminate" -> "The Latchway operation outcome must be reconciled."
|
|
963
|
+
else -> "The Latchway gateway rejected the request."
|
|
964
|
+
}
|