@latchway/react-native 0.0.0-bootstrap.0 → 1.0.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 +97 -0
- package/LatchwayReactNative.podspec +33 -0
- package/NOTICE +7 -0
- package/README.md +286 -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/contract.lock +7 -0
- package/docs/architecture.md +145 -0
- package/docs/conformance.md +68 -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/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/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 +130 -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/request-id.ts +5 -0
- package/src/testing.ts +11 -0
- package/src/types.ts +241 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
package dev.latchway.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Promise
|
|
4
|
+
import com.facebook.react.bridge.BridgeReactContext
|
|
5
|
+
import com.facebook.react.bridge.JavaOnlyMap
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.bridge.WritableMap
|
|
8
|
+
import dev.latchway.core.LATCHWAY_CONTRACT_VERSION
|
|
9
|
+
import dev.latchway.core.LATCHWAY_PROTOCOL_VERSION
|
|
10
|
+
import dev.latchway.core.LatchwayErrorCode
|
|
11
|
+
import dev.latchway.core.LatchwayException
|
|
12
|
+
import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_ID
|
|
13
|
+
import dev.latchway.okhttp.LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION
|
|
14
|
+
import okhttp3.Authenticator
|
|
15
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
16
|
+
import okhttp3.OkHttpClient
|
|
17
|
+
import okhttp3.Protocol
|
|
18
|
+
import okhttp3.Request
|
|
19
|
+
import okhttp3.Response
|
|
20
|
+
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
21
|
+
import okhttp3.mockwebserver.MockResponse
|
|
22
|
+
import okhttp3.mockwebserver.MockWebServer
|
|
23
|
+
import org.json.JSONArray
|
|
24
|
+
import org.json.JSONObject
|
|
25
|
+
import org.junit.After
|
|
26
|
+
import org.junit.Assert.assertEquals
|
|
27
|
+
import org.junit.Assert.assertFalse
|
|
28
|
+
import org.junit.Assert.assertNotEquals
|
|
29
|
+
import org.junit.Assert.assertNull
|
|
30
|
+
import org.junit.Assert.assertThrows
|
|
31
|
+
import org.junit.Assert.assertTrue
|
|
32
|
+
import org.junit.Test
|
|
33
|
+
import org.junit.runner.RunWith
|
|
34
|
+
import org.robolectric.RobolectricTestRunner
|
|
35
|
+
import org.robolectric.RuntimeEnvironment
|
|
36
|
+
import org.robolectric.annotation.Config
|
|
37
|
+
import okio.Buffer
|
|
38
|
+
import java.lang.reflect.Proxy
|
|
39
|
+
import java.nio.charset.StandardCharsets
|
|
40
|
+
import java.util.ArrayDeque
|
|
41
|
+
import java.util.Base64
|
|
42
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
43
|
+
import java.util.concurrent.CountDownLatch
|
|
44
|
+
import java.util.concurrent.TimeUnit
|
|
45
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
46
|
+
|
|
47
|
+
@RunWith(RobolectricTestRunner::class)
|
|
48
|
+
@Config(manifest = Config.NONE, sdk = [35])
|
|
49
|
+
public class NativeLatchwayModuleTest {
|
|
50
|
+
private val fixtures = mutableListOf<ModuleFixture>()
|
|
51
|
+
|
|
52
|
+
@After
|
|
53
|
+
public fun closeFixtures() {
|
|
54
|
+
fixtures.reversed().forEach(ModuleFixture::close)
|
|
55
|
+
fixtures.clear()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
public fun fwAuth101And102GeneratedSpecForwardsBootstrapAndDpopDispatchToNativeSdk() {
|
|
60
|
+
// FW-AUTH-101 / FW-AUTH-102: this is compiled bridge evidence. Pair it
|
|
61
|
+
// with the exact-AAR session/DPoP tests for the native cryptography.
|
|
62
|
+
val fake = FakeNativeClientOperations()
|
|
63
|
+
val fixture = configuredFixture(fake)
|
|
64
|
+
|
|
65
|
+
val response = fixture.startRequest(
|
|
66
|
+
identityToken = "external-identity-bootstrap",
|
|
67
|
+
requestJSON = nativeRequest(fixture.baseURL, "bootstrap through generated spec"),
|
|
68
|
+
)
|
|
69
|
+
val metadata = JSONObject(response.resolvedString())
|
|
70
|
+
|
|
71
|
+
assertEquals(200, metadata.getInt("status"))
|
|
72
|
+
assertEquals(1, fake.bootstrapCalls.get())
|
|
73
|
+
assertEquals(listOf("external-identity-bootstrap"), fake.networkIdentityTokens)
|
|
74
|
+
assertEquals(1, fake.protectedRequests.size)
|
|
75
|
+
val protected = fake.protectedRequests.single()
|
|
76
|
+
assertTrue(protected.header("Authorization")?.startsWith("DPoP ") == true)
|
|
77
|
+
assertEquals(2, protected.header("DPoP")?.count { it == '.' })
|
|
78
|
+
val protectedBody = Buffer()
|
|
79
|
+
requireNotNull(protected.body).writeTo(protectedBody)
|
|
80
|
+
assertEquals("bootstrap through generated spec", protectedBody.readUtf8())
|
|
81
|
+
assertTokenCleared(fake)
|
|
82
|
+
fixture.closeResponse(metadata.getString("responseID")).assertResolved()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
public fun fwAuth103AndBeh104OneGeneratedSpecCallKeepsSafeRetryAndFreshProofNative() {
|
|
87
|
+
// FW-AUTH-103 / FW-BEH-104: the compiled TurboModule boundary owns one
|
|
88
|
+
// operation while OkHttp performs both attempts. Pair with the exact
|
|
89
|
+
// Android AAR proof test to establish real DPoP generation.
|
|
90
|
+
MockWebServer().use { server ->
|
|
91
|
+
server.enqueue(MockResponse()
|
|
92
|
+
.setResponseCode(401)
|
|
93
|
+
.setHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\", dpop_nonce=\"nonce-2\"")
|
|
94
|
+
.setBody("{}"))
|
|
95
|
+
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
|
96
|
+
server.start()
|
|
97
|
+
|
|
98
|
+
val proofSequence = AtomicInteger()
|
|
99
|
+
val nativeRefreshes = AtomicInteger()
|
|
100
|
+
val identities = CopyOnWriteArrayList<String>()
|
|
101
|
+
val fake = FakeNativeClientOperations { operations ->
|
|
102
|
+
val authorization = "DPoP native-access-token"
|
|
103
|
+
nativeApplicationClientBuilder()
|
|
104
|
+
.addInterceptor { chain ->
|
|
105
|
+
identities += operations.currentIdentity()
|
|
106
|
+
val request = chain.request().newBuilder()
|
|
107
|
+
.header("Authorization", authorization)
|
|
108
|
+
.header("DPoP", semanticProof(proofSequence.incrementAndGet()))
|
|
109
|
+
.build()
|
|
110
|
+
chain.proceed(request)
|
|
111
|
+
}
|
|
112
|
+
.authenticator(Authenticator { _, response ->
|
|
113
|
+
identities += operations.currentIdentity()
|
|
114
|
+
nativeRefreshes.incrementAndGet()
|
|
115
|
+
response.request.newBuilder()
|
|
116
|
+
.header("Authorization", authorization)
|
|
117
|
+
.header("DPoP", semanticProof(proofSequence.incrementAndGet()))
|
|
118
|
+
.build()
|
|
119
|
+
})
|
|
120
|
+
.build()
|
|
121
|
+
}
|
|
122
|
+
val fixture = configuredFixture(fake, server.url("/").toString().removeSuffix("/"))
|
|
123
|
+
|
|
124
|
+
val response = fixture.startRequest(
|
|
125
|
+
identityToken = "external-identity-for-native-retry",
|
|
126
|
+
requestJSON = nativeRequest(fixture.baseURL, "one replayable body"),
|
|
127
|
+
)
|
|
128
|
+
val metadata = JSONObject(response.resolvedString())
|
|
129
|
+
val first = requireNotNull(server.takeRequest(5, TimeUnit.SECONDS))
|
|
130
|
+
val second = requireNotNull(server.takeRequest(5, TimeUnit.SECONDS))
|
|
131
|
+
|
|
132
|
+
assertEquals(200, metadata.getInt("status"))
|
|
133
|
+
assertEquals(1, nativeRefreshes.get())
|
|
134
|
+
assertEquals(2, server.requestCount)
|
|
135
|
+
assertEquals(first.body.readUtf8(), second.body.readUtf8())
|
|
136
|
+
assertEquals(first.headers["Authorization"], second.headers["Authorization"])
|
|
137
|
+
assertNotEquals(first.headers["DPoP"], second.headers["DPoP"])
|
|
138
|
+
assertEquals(
|
|
139
|
+
listOf("external-identity-for-native-retry", "external-identity-for-native-retry"),
|
|
140
|
+
identities,
|
|
141
|
+
)
|
|
142
|
+
assertTokenCleared(fake)
|
|
143
|
+
fixture.closeResponse(metadata.getString("responseID")).assertResolved()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@Test
|
|
148
|
+
public fun fwAuth104GeneratedSpecClearsRejectedIdentityBeforeReauthentication() {
|
|
149
|
+
// FW-AUTH-104
|
|
150
|
+
val fake = FakeNativeClientOperations()
|
|
151
|
+
fake.refreshFailures += LatchwayException(
|
|
152
|
+
code = LatchwayErrorCode.IDENTITY_REAUTHENTICATION_REQUIRED,
|
|
153
|
+
safeMessage = "synthetic native reauthentication request",
|
|
154
|
+
)
|
|
155
|
+
val fixture = configuredFixture(fake)
|
|
156
|
+
|
|
157
|
+
val rejected = fixture.refresh("external-identity-stale")
|
|
158
|
+
assertEquals("identity_reauthentication_required", rejected.rejection().code)
|
|
159
|
+
assertTokenCleared(fake)
|
|
160
|
+
|
|
161
|
+
fixture.refresh("external-identity-fresh").assertResolved()
|
|
162
|
+
assertEquals(
|
|
163
|
+
listOf("external-identity-stale", "external-identity-fresh"),
|
|
164
|
+
fake.refreshIdentityTokens,
|
|
165
|
+
)
|
|
166
|
+
assertTokenCleared(fake)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Test
|
|
170
|
+
public fun fwAuth105GeneratedSpecDelegatesFamilyRetirementAndSurfacesTerminalState() {
|
|
171
|
+
// FW-AUTH-105: terminal-state enforcement is implemented by the exact
|
|
172
|
+
// native SDK; this compiled test proves the generated bridge forwards it.
|
|
173
|
+
val fake = FakeNativeClientOperations()
|
|
174
|
+
val fixture = configuredFixture(fake)
|
|
175
|
+
|
|
176
|
+
fixture.revokeFamily("external-identity-family-owner").assertResolved()
|
|
177
|
+
assertEquals(1, fake.familyRevocations.get())
|
|
178
|
+
assertTokenCleared(fake)
|
|
179
|
+
|
|
180
|
+
val rejected = fixture.quota("external-identity-after-family-retirement")
|
|
181
|
+
assertEquals("installation_family_revoked", rejected.rejection().code)
|
|
182
|
+
assertTrue(fake.protectedRequests.isEmpty())
|
|
183
|
+
assertTokenCleared(fake)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@Test
|
|
187
|
+
public fun fwAuth106GeneratedSpecSurfacesNativeComponentRevocationWithoutDispatch() {
|
|
188
|
+
// FW-AUTH-106: the Android SDK owns component state. The React Native
|
|
189
|
+
// TurboModule must preserve its closed terminal failure unchanged.
|
|
190
|
+
val fake = FakeNativeClientOperations().apply { componentRevoked = true }
|
|
191
|
+
val fixture = configuredFixture(fake)
|
|
192
|
+
|
|
193
|
+
val rejected = fixture.quota("external-identity-component-owner")
|
|
194
|
+
|
|
195
|
+
assertEquals("component_revoked", rejected.rejection().code)
|
|
196
|
+
assertTrue(fake.protectedRequests.isEmpty())
|
|
197
|
+
assertTokenCleared(fake)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@Test
|
|
201
|
+
public fun fwSec103ProductionResponseTargetRevalidationRejectsCrossOriginRedirectResult() {
|
|
202
|
+
// FW-SEC-103
|
|
203
|
+
val fake = FakeNativeClientOperations { operations ->
|
|
204
|
+
nativeApplicationClientBuilder()
|
|
205
|
+
.addInterceptor { chain ->
|
|
206
|
+
operations.redirectResponseAttempts.incrementAndGet()
|
|
207
|
+
val redirected = chain.request().newBuilder()
|
|
208
|
+
.url("https://redirect-attacker.invalid/v1/responses")
|
|
209
|
+
.build()
|
|
210
|
+
Response.Builder()
|
|
211
|
+
.request(redirected)
|
|
212
|
+
.protocol(Protocol.HTTP_1_1)
|
|
213
|
+
.code(200)
|
|
214
|
+
.message("OK")
|
|
215
|
+
.body("{}".toResponseBody(JSON_MEDIA_TYPE))
|
|
216
|
+
.build()
|
|
217
|
+
}
|
|
218
|
+
.build()
|
|
219
|
+
}
|
|
220
|
+
val fixture = configuredFixture(fake)
|
|
221
|
+
val policyClient = nativeApplicationClientBuilder().build()
|
|
222
|
+
try {
|
|
223
|
+
assertFalse(policyClient.followRedirects)
|
|
224
|
+
assertFalse(policyClient.followSslRedirects)
|
|
225
|
+
} finally {
|
|
226
|
+
policyClient.closeCompletely()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
val rejected = fixture.startRequest(
|
|
230
|
+
identityToken = "external-identity-redirect-check",
|
|
231
|
+
requestJSON = nativeRequest(fixture.baseURL, "redirect must be revalidated"),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
assertEquals("request_invalid", rejected.rejection().code)
|
|
235
|
+
assertEquals(1, fake.redirectResponseAttempts.get())
|
|
236
|
+
assertTokenCleared(fake)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private fun configuredFixture(
|
|
240
|
+
fake: FakeNativeClientOperations,
|
|
241
|
+
baseURL: String = "https://gateway.example.test",
|
|
242
|
+
): ModuleFixture {
|
|
243
|
+
val reactContext: ReactApplicationContext = BridgeReactContext(RuntimeEnvironment.getApplication())
|
|
244
|
+
val factory = NativeClientFactory { configuration, _, _, tokenProvider, _ ->
|
|
245
|
+
fake.configuration = configuration
|
|
246
|
+
fake.tokenProvider = tokenProvider
|
|
247
|
+
fake
|
|
248
|
+
}
|
|
249
|
+
val module = NativeLatchwayModule(reactContext, factory) { JavaOnlyMap() }
|
|
250
|
+
val fixture = ModuleFixture(module, fake, baseURL)
|
|
251
|
+
fixtures += fixture
|
|
252
|
+
|
|
253
|
+
val configured = fixture.configure()
|
|
254
|
+
val compatibility = JSONObject(configured.resolvedString())
|
|
255
|
+
assertEquals("react_native_android", compatibility.getString("platform"))
|
|
256
|
+
assertEquals(LATCHWAY_CONTRACT_VERSION, compatibility.getString("contractVersion"))
|
|
257
|
+
assertEquals(LATCHWAY_PROTOCOL_VERSION, compatibility.getInt("protocolVersion"))
|
|
258
|
+
assertEquals(LATCHWAY_REACT_NATIVE_FRAMEWORK_ID, fake.configuration?.frameworkID)
|
|
259
|
+
assertEquals(LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION, fake.configuration?.frameworkVersion)
|
|
260
|
+
return fixture
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun assertTokenCleared(fake: FakeNativeClientOperations) {
|
|
264
|
+
assertThrows(IllegalStateException::class.java) { fake.currentIdentity() }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private class ModuleFixture(
|
|
269
|
+
private val module: NativeLatchwayModule,
|
|
270
|
+
private val operations: FakeNativeClientOperations,
|
|
271
|
+
val baseURL: String,
|
|
272
|
+
) {
|
|
273
|
+
private val operationSequence = AtomicInteger()
|
|
274
|
+
private var closed = false
|
|
275
|
+
|
|
276
|
+
fun configure(): RecordingPromise = RecordingPromise().also { promise ->
|
|
277
|
+
module.configure(CLIENT_ID, nativeConfiguration(baseURL), promise.value)
|
|
278
|
+
promise.await()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fun startRequest(identityToken: String, requestJSON: String): RecordingPromise =
|
|
282
|
+
RecordingPromise().also { promise ->
|
|
283
|
+
module.startRequest(
|
|
284
|
+
CLIENT_ID,
|
|
285
|
+
operationID("request"),
|
|
286
|
+
identityToken,
|
|
287
|
+
requestJSON,
|
|
288
|
+
promise.value,
|
|
289
|
+
)
|
|
290
|
+
promise.await()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fun refresh(identityToken: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
294
|
+
module.refresh(CLIENT_ID, operationID("refresh"), identityToken, promise.value)
|
|
295
|
+
promise.await()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fun revokeFamily(identityToken: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
299
|
+
module.revokeFamily(CLIENT_ID, operationID("family"), identityToken, promise.value)
|
|
300
|
+
promise.await()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fun quota(identityToken: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
304
|
+
module.quota(CLIENT_ID, operationID("quota"), identityToken, "assistant", promise.value)
|
|
305
|
+
promise.await()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fun closeResponse(responseID: String): RecordingPromise = RecordingPromise().also { promise ->
|
|
309
|
+
module.closeResponse(CLIENT_ID, responseID, promise.value)
|
|
310
|
+
promise.await()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fun close() {
|
|
314
|
+
if (closed) return
|
|
315
|
+
closed = true
|
|
316
|
+
val disposed = RecordingPromise()
|
|
317
|
+
module.dispose(CLIENT_ID, disposed.value)
|
|
318
|
+
disposed.await()
|
|
319
|
+
module.invalidate()
|
|
320
|
+
if (!operations.closed) operations.close()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private fun operationID(kind: String): String =
|
|
324
|
+
"rn-android-$kind-${operationSequence.incrementAndGet()}"
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private class FakeNativeClientOperations(
|
|
328
|
+
createApplicationClient: (FakeNativeClientOperations) -> OkHttpClient = { operations ->
|
|
329
|
+
nativeApplicationClientBuilder()
|
|
330
|
+
.addInterceptor { chain -> operations.defaultDispatch(chain.request()) }
|
|
331
|
+
.build()
|
|
332
|
+
},
|
|
333
|
+
) : NativeClientOperations {
|
|
334
|
+
lateinit var tokenProvider: TransientIdentityTokenProvider
|
|
335
|
+
var configuration: NativeConfiguration? = null
|
|
336
|
+
var familyRevoked: Boolean = false
|
|
337
|
+
var componentRevoked: Boolean = false
|
|
338
|
+
var closed: Boolean = false
|
|
339
|
+
val bootstrapCalls = AtomicInteger()
|
|
340
|
+
val familyRevocations = AtomicInteger()
|
|
341
|
+
val redirectResponseAttempts = AtomicInteger()
|
|
342
|
+
val networkIdentityTokens = CopyOnWriteArrayList<String>()
|
|
343
|
+
val refreshIdentityTokens = CopyOnWriteArrayList<String>()
|
|
344
|
+
val protectedRequests = CopyOnWriteArrayList<Request>()
|
|
345
|
+
val refreshFailures = ArrayDeque<Throwable>()
|
|
346
|
+
|
|
347
|
+
override val applicationClient: OkHttpClient by lazy { createApplicationClient(this) }
|
|
348
|
+
|
|
349
|
+
override suspend fun refresh() {
|
|
350
|
+
refreshIdentityTokens += currentIdentity()
|
|
351
|
+
refreshFailures.pollFirst()?.let { throw it }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
override suspend fun quota(feature: String): String {
|
|
355
|
+
if (familyRevoked) {
|
|
356
|
+
throw LatchwayException(
|
|
357
|
+
code = LatchwayErrorCode.INSTALLATION_FAMILY_REVOKED,
|
|
358
|
+
safeMessage = "synthetic native family terminal state",
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
if (componentRevoked) {
|
|
362
|
+
throw LatchwayException(
|
|
363
|
+
code = LatchwayErrorCode.COMPONENT_REVOKED,
|
|
364
|
+
safeMessage = "synthetic native component terminal state",
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
return JSONObject()
|
|
368
|
+
.put("feature", feature)
|
|
369
|
+
.put("observed_at", "2026-09-02T00:00:00Z")
|
|
370
|
+
.put("limits", JSONArray())
|
|
371
|
+
.toString()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
override suspend fun diagnostics(): String = JSONObject()
|
|
375
|
+
.put("contractVersion", LATCHWAY_CONTRACT_VERSION)
|
|
376
|
+
.put("protocolVersion", LATCHWAY_PROTOCOL_VERSION)
|
|
377
|
+
.toString()
|
|
378
|
+
|
|
379
|
+
override suspend fun revokeCurrentInstallation() = Unit
|
|
380
|
+
|
|
381
|
+
override suspend fun revokeCurrentInstallationFamily() {
|
|
382
|
+
currentIdentity()
|
|
383
|
+
familyRevocations.incrementAndGet()
|
|
384
|
+
familyRevoked = true
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
fun currentIdentity(): String = tokenProvider.current()
|
|
388
|
+
|
|
389
|
+
override fun close() {
|
|
390
|
+
if (closed) return
|
|
391
|
+
closed = true
|
|
392
|
+
applicationClient.closeCompletely()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private fun defaultDispatch(request: Request): Response {
|
|
396
|
+
if (familyRevoked) {
|
|
397
|
+
throw LatchwayException(
|
|
398
|
+
code = LatchwayErrorCode.INSTALLATION_FAMILY_REVOKED,
|
|
399
|
+
safeMessage = "synthetic native family terminal state",
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
if (componentRevoked) {
|
|
403
|
+
throw LatchwayException(
|
|
404
|
+
code = LatchwayErrorCode.COMPONENT_REVOKED,
|
|
405
|
+
safeMessage = "synthetic native component terminal state",
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
networkIdentityTokens += currentIdentity()
|
|
409
|
+
bootstrapCalls.compareAndSet(0, 1)
|
|
410
|
+
val protected = request.newBuilder()
|
|
411
|
+
.header("Authorization", "DPoP native-access-token")
|
|
412
|
+
.header("DPoP", semanticProof(protectedRequests.size + 1))
|
|
413
|
+
.build()
|
|
414
|
+
protectedRequests += protected
|
|
415
|
+
return Response.Builder()
|
|
416
|
+
.request(protected)
|
|
417
|
+
.protocol(Protocol.HTTP_1_1)
|
|
418
|
+
.code(200)
|
|
419
|
+
.message("OK")
|
|
420
|
+
.body("{}".toResponseBody(JSON_MEDIA_TYPE))
|
|
421
|
+
.build()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private class RecordingPromise {
|
|
426
|
+
private val terminal = CountDownLatch(1)
|
|
427
|
+
@Volatile private var resolved: Any? = UNSET
|
|
428
|
+
@Volatile private var rejected: NativeRejection? = null
|
|
429
|
+
|
|
430
|
+
val value: Promise = Proxy.newProxyInstance(
|
|
431
|
+
Promise::class.java.classLoader,
|
|
432
|
+
arrayOf(Promise::class.java),
|
|
433
|
+
) { _, method, arguments ->
|
|
434
|
+
when (method.name) {
|
|
435
|
+
"resolve" -> {
|
|
436
|
+
resolved = arguments?.firstOrNull()
|
|
437
|
+
terminal.countDown()
|
|
438
|
+
}
|
|
439
|
+
"reject" -> {
|
|
440
|
+
val values = arguments.orEmpty()
|
|
441
|
+
rejected = NativeRejection(
|
|
442
|
+
code = values.firstOrNull() as? String ?: "unspecified",
|
|
443
|
+
message = values.drop(1).filterIsInstance<String>().firstOrNull(),
|
|
444
|
+
userInfo = values.filterIsInstance<WritableMap>().firstOrNull(),
|
|
445
|
+
)
|
|
446
|
+
terminal.countDown()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
null
|
|
450
|
+
} as Promise
|
|
451
|
+
|
|
452
|
+
fun await(): RecordingPromise {
|
|
453
|
+
assertTrue("native promise did not settle", terminal.await(10, TimeUnit.SECONDS))
|
|
454
|
+
return this
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fun assertResolved() {
|
|
458
|
+
assertNull(rejected)
|
|
459
|
+
assertTrue(resolved !== UNSET)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fun resolvedString(): String {
|
|
463
|
+
assertResolved()
|
|
464
|
+
return resolved as String
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
fun rejection(): NativeRejection {
|
|
468
|
+
assertTrue(resolved === UNSET)
|
|
469
|
+
return requireNotNull(rejected)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private data class NativeRejection(
|
|
474
|
+
val code: String,
|
|
475
|
+
val message: String?,
|
|
476
|
+
val userInfo: WritableMap?,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
private fun nativeConfiguration(baseURL: String): String = JSONObject()
|
|
480
|
+
.put("baseURL", baseURL)
|
|
481
|
+
.put("applicationID", "app_react_native_android_test")
|
|
482
|
+
.put("environment", "development")
|
|
483
|
+
.put("identityProvider", "mock_oidc")
|
|
484
|
+
.put("appVersion", "1.0.0-test")
|
|
485
|
+
.put("sdkVersion", "1.0.0")
|
|
486
|
+
.put("frameworkID", LATCHWAY_REACT_NATIVE_FRAMEWORK_ID)
|
|
487
|
+
.put("frameworkVersion", LATCHWAY_REACT_NATIVE_FRAMEWORK_VERSION)
|
|
488
|
+
.put("contractVersion", LATCHWAY_CONTRACT_VERSION)
|
|
489
|
+
.put("protocolVersion", LATCHWAY_PROTOCOL_VERSION)
|
|
490
|
+
.put("allowInsecureLoopback", baseURL.startsWith("http://127.0.0.1:"))
|
|
491
|
+
.put("apple", JSONObject()
|
|
492
|
+
.put("appAttestEnabled", true)
|
|
493
|
+
.put("softwareKeyFallbackPolicy", "forbidden"))
|
|
494
|
+
.put("android", JSONObject()
|
|
495
|
+
.put("playIntegrityCloudProjectNumber", "123456789")
|
|
496
|
+
.put("keyPolicy", "software_allowed"))
|
|
497
|
+
.toString()
|
|
498
|
+
|
|
499
|
+
private fun nativeRequest(baseURL: String, body: String): String = JSONObject()
|
|
500
|
+
.put("url", "$baseURL/v1/responses")
|
|
501
|
+
.put("method", "POST")
|
|
502
|
+
.put("feature", "assistant")
|
|
503
|
+
.put("headers", JSONArray().put(JSONArray().put("content-type").put("application/json")))
|
|
504
|
+
.put(
|
|
505
|
+
"bodyBase64",
|
|
506
|
+
Base64.getEncoder().encodeToString(body.toByteArray(StandardCharsets.UTF_8)),
|
|
507
|
+
)
|
|
508
|
+
.toString()
|
|
509
|
+
|
|
510
|
+
private fun semanticProof(sequence: Int): String =
|
|
511
|
+
"eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiJqdGktJHNlcXVlbmNlIn0.signature-$sequence"
|
|
512
|
+
|
|
513
|
+
private fun OkHttpClient.closeCompletely() {
|
|
514
|
+
dispatcher.cancelAll()
|
|
515
|
+
connectionPool.evictAll()
|
|
516
|
+
dispatcher.executorService.shutdown()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
|
520
|
+
private const val CLIENT_ID = "rn-android-compiled-test-client"
|
|
521
|
+
private val UNSET = Any()
|
package/contract.lock
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
contract_version: 1.0.0
|
|
2
|
+
wire_protocol: 2
|
|
3
|
+
core_release: v1.0.0
|
|
4
|
+
core_commit: d260e3d7485e9e1487b5e03922b79c7089d94ce2
|
|
5
|
+
bundle_sha256: "4866aec1ff70e78d70f07847448161c2b59970fe102d95393b051444536d29a4"
|
|
6
|
+
minimum_server_version: 1.0.0
|
|
7
|
+
maximum_tested_server_version: 1.0.x
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# React Native SDK architecture
|
|
2
|
+
|
|
3
|
+
## Dependency and trust boundary
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
React Native application
|
|
7
|
+
└─ @latchway/react-native
|
|
8
|
+
├─ @latchway/client 1.0.0 (errors and shared transport concepts)
|
|
9
|
+
├─ Latchway/AppAttest 1.0.0 (iOS)
|
|
10
|
+
├─ Latchway/AppExtensions 1.0.0 (optional Debug extension target)
|
|
11
|
+
└─ dev.latchway:latchway-okhttp + latchway-play-integrity 1.0.0 (Android)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The core repository owns OpenAPI, error codes, attestation binding, DPoP behavior, and compatibility. This package owns the handwritten React Native API, fetch integration, TurboModule schema, cross-instance lease, abort propagation, stable error projection, and redacted diagnostics. Native SDKs exclusively own installation keys, secure session persistence, platform attestation, DPoP signing, and native single-flight.
|
|
15
|
+
|
|
16
|
+
The gateway, not the SDK, derives user, organization, plan, trust, routing,
|
|
17
|
+
pricing, and quota facts. The SDK never receives an upstream provider
|
|
18
|
+
credential or treats application-supplied values as trusted server facts.
|
|
19
|
+
|
|
20
|
+
## Contract ownership
|
|
21
|
+
|
|
22
|
+
The Latchway core repository exclusively owns the client OpenAPI, error-code
|
|
23
|
+
registry, protocol compatibility manifest, canonical attestation binding, DPoP
|
|
24
|
+
vectors, canonical request examples, and checksummed contract bundle. A
|
|
25
|
+
contract update must verify checksums, update `contract.lock`, regenerate only
|
|
26
|
+
internal types, rerun shared vectors, and pass conformance against the exact
|
|
27
|
+
core revision. Generated bridge and wire types do not become public API.
|
|
28
|
+
|
|
29
|
+
## Operation flow
|
|
30
|
+
|
|
31
|
+
1. JavaScript validates the origin, feature, configuration, request state, and decoded query names; provider-credential names fail before identity acquisition or dispatch.
|
|
32
|
+
2. The application identity callback returns an external identity JWT.
|
|
33
|
+
3. The TurboModule passes that JWT transiently to the native SDK while native session work runs.
|
|
34
|
+
4. Native repeats the exact origin and allowed-path checks, establishes or refreshes a device-bound session, signs a DPoP proof, attaches native-owned protocol headers, and dispatches through its private URLSession or OkHttp client.
|
|
35
|
+
5. Native refuses redirects, retains the credential-bearing request and response task, and returns only an opaque response identifier, status, and allowlisted safe headers.
|
|
36
|
+
6. A WHATWG `ReadableStream` pulls bounded base64 response chunks through the TurboModule. Pull demand supplies bridge backpressure; abort, reader cancellation, EOF, client disposal, and invalid metadata all finish or cancel the native handle. JavaScript never clones or replays an authenticated request. Android's locked authenticator and iOS's locked feature transport exclusively own the contract-safe, one-time pre-dispatch retry; iOS bounds rejection classification to 64 KiB before any response bytes become visible.
|
|
37
|
+
|
|
38
|
+
Authorization, DPoP, access tokens, refresh tokens, private keys, and attestation evidence never appear in a native return value. Response bodies are application data, not credential envelopes, and remain incrementally delivered rather than eagerly buffered.
|
|
39
|
+
|
|
40
|
+
The containing application's root client owns the public descriptor lifecycle:
|
|
41
|
+
prepare, replace, descriptor diagnostics, descriptor revoke, and whole-family
|
|
42
|
+
retirement. Descriptors are normalized and snapshotted before any identity
|
|
43
|
+
await; native results must match the same snapshot. Identity is acquired only
|
|
44
|
+
transiently for prepare, replace, revoke, and family retirement. Root-side
|
|
45
|
+
component diagnostics are identity-free. A 65,536-byte JavaScript serialization
|
|
46
|
+
limit keeps oversized descriptor sets from reaching the native bridge.
|
|
47
|
+
|
|
48
|
+
The component path uses a separate extension-process client, not the containing
|
|
49
|
+
app's root client or lease. JavaScript running inside a signed `.appex` can
|
|
50
|
+
supply one validated public descriptor; native configuration rejects a
|
|
51
|
+
containing-app process, selects `.reactNativeIOS`, and retains a
|
|
52
|
+
`LatchwayExtensionClient` with no direct App Attest provider. iOS extensions
|
|
53
|
+
cannot call `DCAppAttestService.generateKey`, and the containing application
|
|
54
|
+
must not attest on their behalf. The extension can use only independently
|
|
55
|
+
keyed, component-scoped delegated sessions. React Native v1 exposes no
|
|
56
|
+
JavaScript component request operation. A second bridge operation returns only
|
|
57
|
+
`LatchwayComponentDiagnostics`; direct-attestation trust-source decoders are
|
|
58
|
+
retained for protocol compatibility but are not reachable proof claims. The
|
|
59
|
+
identity callback is not invoked, and component credentials and DPoP material
|
|
60
|
+
never cross the TurboModule. Both platforms report `attestation_unsupported`
|
|
61
|
+
for the legacy direct operation.
|
|
62
|
+
|
|
63
|
+
The example App Intents extension has no React Native runtime or TurboModule
|
|
64
|
+
bridge. Its Debug target optionally links `Latchway/AppExtensions` and calls the
|
|
65
|
+
native extension client directly to prove an independently keyed delegated
|
|
66
|
+
session and one fully consumed bounded Responses request. The root publishes a
|
|
67
|
+
nonsecret exact-run shared-Keychain challenge immediately before waiting. The
|
|
68
|
+
intent captures it before client construction, rechecks it immediately before
|
|
69
|
+
echoing the run in a bounded receipt, and cannot publish for a superseded run.
|
|
70
|
+
The containing app accepts only its native-captured exact run and deletes both
|
|
71
|
+
challenge and receipt before descriptor-bound family retirement and sign-out;
|
|
72
|
+
abort also deletes both artifacts. This is local integration proof. In Release the
|
|
73
|
+
AppExtensions pod is absent, no executable Latchway client path is compiled,
|
|
74
|
+
and the intent fails closed. Both variants retain distinct root/extension
|
|
75
|
+
bundle identities and provisioning profiles, private-first/shared-second root
|
|
76
|
+
Keychain entitlements, and a shared-only extension entitlement. The root's
|
|
77
|
+
first/default group keeps its key, identity, and session state outside the
|
|
78
|
+
extension's reach.
|
|
79
|
+
|
|
80
|
+
The bridge intentionally implements a bounded fetch subset: method, headers,
|
|
81
|
+
an at-most-8-MiB buffered request body, cancellation, response metadata, and a
|
|
82
|
+
pull-driven response stream. Browser cookie/cache modes, service workers,
|
|
83
|
+
redirect following, streaming uploads, response trailers, and native response
|
|
84
|
+
URL metadata are outside this transport. Framework compatibility therefore
|
|
85
|
+
depends on a real custom-fetch seam and the framework's React Native support;
|
|
86
|
+
the presence of `fetchFor` alone is not a version-support claim.
|
|
87
|
+
|
|
88
|
+
`fetchFor(feature)` also maps the canonical safe
|
|
89
|
+
`X-Latchway-Request-ID` response header to the conventional `X-Request-ID`
|
|
90
|
+
alias without reading the body, so provider SDK failures retain server
|
|
91
|
+
correlation. The native runtime remains the framework identity reported to the
|
|
92
|
+
gateway (`react-native-fetch`); an underlying JavaScript library cannot spoof a
|
|
93
|
+
different SDK/framework pair through caller headers.
|
|
94
|
+
|
|
95
|
+
Feature binding is also protocol binding: a Responses feature cannot dispatch
|
|
96
|
+
Chat Completions, Embeddings, or Anthropic Messages. A consumer set spanning
|
|
97
|
+
those protocols constructs a separate `fetchFor` transport for each configured
|
|
98
|
+
feature instead of multiplexing incompatible endpoints through one identifier.
|
|
99
|
+
|
|
100
|
+
## Coordination
|
|
101
|
+
|
|
102
|
+
A module-global root lease map is keyed by native-module identity plus gateway/application/environment scope. A separate component lease map adds the component definition and never aliases the root map. Equivalent clients reuse one native client and configuration promise; conflicting security configuration for an active scope is rejected. Reference-counted disposal drops the native object only after the last JavaScript client leaves. The native iOS actor and Android coordinator/mutex prevent session establishment and refresh stampedes.
|
|
103
|
+
|
|
104
|
+
Native persistence namespaces include `react_native_ios` or `react_native_android`. The bridge configures the paired runtime identity, so challenge/grant platform and `X-Latchway-SDK: react-native` cannot disagree. Native compatibility JSON is checked against released contract 1.0.0 and current wire protocol 2 before any operation.
|
|
105
|
+
|
|
106
|
+
## TurboModule boundary
|
|
107
|
+
|
|
108
|
+
The handwritten spec carries root and component configuration as distinct
|
|
109
|
+
operations, a bounded request description, the root client's transient
|
|
110
|
+
application identity token, opaque response-handle start/read/close operations,
|
|
111
|
+
quota/diagnostic results, public component descriptors for lifecycle and
|
|
112
|
+
delegated-session compatibility, cancellation, and disposal. Root lifecycle
|
|
113
|
+
mutations carry identity transiently; root-side and extension-side component
|
|
114
|
+
diagnostics do not. The legacy direct-attestation operation remains
|
|
115
|
+
ABI-compatible but fails closed. Extension-process component operations have no
|
|
116
|
+
identity-token argument. The spec has no authorization-envelope operation and
|
|
117
|
+
does not return or accept provider attestation evidence, Play request hashes,
|
|
118
|
+
App Attest client-data hashes, session tokens, DPoP proofs, or key material.
|
|
119
|
+
Generated Objective-C++ and Java specs are disposable codegen output, not
|
|
120
|
+
public API.
|
|
121
|
+
|
|
122
|
+
## Native dependencies
|
|
123
|
+
|
|
124
|
+
Published package metadata pins release coordinates. CocoaPods consumes `Latchway/AppAttest` 1.0.0. Gradle consumes `dev.latchway:latchway-okhttp:1.0.0` and `dev.latchway:latchway-play-integrity:1.0.0`. Development may point `LATCHWAY_NATIVE_REPOSITORY` or `-PlatchwayNativeRepository` at a locally published Maven repository; local file links never enter npm metadata.
|
|
125
|
+
|
|
126
|
+
## Diagnostics and errors
|
|
127
|
+
|
|
128
|
+
Diagnostics contain version compatibility, platform, secure key-storage category, attestation support/provider, session state/expiration, installation ID/status, server version, and last request/error identifiers. Component diagnostics add only family/component IDs, public definition/access-group identifiers, key/session/grant availability, trust provenance/expiry, and a containing-app action flag. Native key IDs, JWK thumbprints, tokens, proofs, and evidence are excluded. Native errors are bounded, control-character stripped, secret-pattern redacted, and mapped to the shared `LatchwayError` taxonomy. A server-originated native error must carry the exact `https://docs.latchway.dev/errors/<hyphenated-code>` documentation URL; missing or mismatched links fail closed. `operation_indeterminate` alone carries a required canonical reconciliation ID through both native bridges; malformed, missing, contradictory, or otherwise attached operation metadata fails closed.
|
|
129
|
+
|
|
130
|
+
## Verification boundary
|
|
131
|
+
|
|
132
|
+
Unit and Node conformance tests own public request shaping, fail-closed
|
|
133
|
+
credential-output checks, response pull/backpressure, error projection,
|
|
134
|
+
cancellation, coordination, strict-CSP behavior, and
|
|
135
|
+
canonical vectors. Reproducible code generation proves the handwritten schema
|
|
136
|
+
remains valid. Native consumer builds prove released dependency resolution and
|
|
137
|
+
bridge compilation. Physical-device conformance proves real App Attest and Play
|
|
138
|
+
Integrity behavior, session rotation, quota, streaming, diagnostics, and
|
|
139
|
+
revocation against the exact core image.
|
|
140
|
+
|
|
141
|
+
## Non-goals
|
|
142
|
+
|
|
143
|
+
This package does not own server policy, provider routing, quota enforcement,
|
|
144
|
+
user-authentication UI, AI request modeling, upstream secrets, native
|
|
145
|
+
cryptography, native attestation verification, or an independent session store.
|