@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/LatchwayReactNative.podspec +33 -0
  3. package/NOTICE +7 -0
  4. package/README.md +293 -3
  5. package/SECURITY.md +69 -0
  6. package/android/build.gradle.kts +62 -0
  7. package/android/consumer-rules.pro +2 -0
  8. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  9. package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  10. package/android/gradle.properties +4 -0
  11. package/android/gradlew +251 -0
  12. package/android/gradlew.bat +94 -0
  13. package/android/settings.gradle.kts +33 -0
  14. package/android/src/main/AndroidManifest.xml +3 -0
  15. package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
  16. package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
  17. package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
  18. package/babel.cjs +22 -0
  19. package/babel.d.cts +7 -0
  20. package/contract.lock +7 -0
  21. package/docs/architecture.md +145 -0
  22. package/docs/conformance.md +68 -0
  23. package/docs/langchain.md +135 -0
  24. package/docs/native-installation.md +172 -0
  25. package/docs/physical-device-evidence.md +559 -0
  26. package/docs/releasing.md +360 -0
  27. package/docs/security.md +55 -0
  28. package/ios/LatchwayNativeBridge.swift +1551 -0
  29. package/ios/RCTNativeLatchway.h +9 -0
  30. package/ios/RCTNativeLatchway.mm +175 -0
  31. package/lib/client.d.ts +29 -0
  32. package/lib/client.d.ts.map +1 -0
  33. package/lib/client.js +939 -0
  34. package/lib/client.js.map +1 -0
  35. package/lib/component-client.d.ts +15 -0
  36. package/lib/component-client.d.ts.map +1 -0
  37. package/lib/component-client.js +141 -0
  38. package/lib/component-client.js.map +1 -0
  39. package/lib/config.d.ts +25 -0
  40. package/lib/config.d.ts.map +1 -0
  41. package/lib/config.js +261 -0
  42. package/lib/config.js.map +1 -0
  43. package/lib/coordinator.d.ts +19 -0
  44. package/lib/coordinator.d.ts.map +1 -0
  45. package/lib/coordinator.js +167 -0
  46. package/lib/coordinator.js.map +1 -0
  47. package/lib/errors.d.ts +5 -0
  48. package/lib/errors.d.ts.map +1 -0
  49. package/lib/errors.js +201 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/index.d.ts +8 -0
  52. package/lib/index.d.ts.map +1 -0
  53. package/lib/index.js +13 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/native/NativeLatchway.d.ts +25 -0
  56. package/lib/native/NativeLatchway.d.ts.map +1 -0
  57. package/lib/native/NativeLatchway.js +3 -0
  58. package/lib/native/NativeLatchway.js.map +1 -0
  59. package/lib/native/bridge.d.ts +5 -0
  60. package/lib/native/bridge.d.ts.map +1 -0
  61. package/lib/native/bridge.js +17 -0
  62. package/lib/native/bridge.js.map +1 -0
  63. package/lib/native-output.d.ts +3 -0
  64. package/lib/native-output.d.ts.map +1 -0
  65. package/lib/native-output.js +43 -0
  66. package/lib/native-output.js.map +1 -0
  67. package/lib/polyfills.d.ts +8 -0
  68. package/lib/polyfills.d.ts.map +1 -0
  69. package/lib/polyfills.js +56 -0
  70. package/lib/polyfills.js.map +1 -0
  71. package/lib/request-id.d.ts +2 -0
  72. package/lib/request-id.d.ts.map +1 -0
  73. package/lib/request-id.js +5 -0
  74. package/lib/request-id.js.map +1 -0
  75. package/lib/runtime-symbols.d.ts +2 -0
  76. package/lib/runtime-symbols.d.ts.map +1 -0
  77. package/lib/runtime-symbols.js +9 -0
  78. package/lib/runtime-symbols.js.map +1 -0
  79. package/lib/testing.d.ts +7 -0
  80. package/lib/testing.d.ts.map +1 -0
  81. package/lib/testing.js +9 -0
  82. package/lib/testing.js.map +1 -0
  83. package/lib/types.d.ts +200 -0
  84. package/lib/types.d.ts.map +1 -0
  85. package/lib/types.js +2 -0
  86. package/lib/types.js.map +1 -0
  87. package/lib/version.d.ts +8 -0
  88. package/lib/version.d.ts.map +1 -0
  89. package/lib/version.js +8 -0
  90. package/lib/version.js.map +1 -0
  91. package/package.json +152 -6
  92. package/react-native.config.cjs +7 -0
  93. package/release-compatibility.json +64 -0
  94. package/src/client.ts +1022 -0
  95. package/src/component-client.ts +158 -0
  96. package/src/config.ts +368 -0
  97. package/src/coordinator.ts +195 -0
  98. package/src/errors.ts +225 -0
  99. package/src/index.ts +53 -0
  100. package/src/native/NativeLatchway.ts +75 -0
  101. package/src/native/bridge.ts +23 -0
  102. package/src/native-output.ts +43 -0
  103. package/src/polyfills.ts +50 -0
  104. package/src/request-id.ts +5 -0
  105. package/src/runtime-symbols.ts +9 -0
  106. package/src/testing.ts +11 -0
  107. package/src/types.ts +241 -0
  108. package/src/version.ts +7 -0
@@ -0,0 +1,559 @@
1
+ # Physical-device release evidence
2
+
3
+ The React Native release gate proves that the published JavaScript surface
4
+ actually reaches the locked native iOS and Android SDKs on production-eligible
5
+ devices. A simulator, emulator, debug build, testing process, debugger-attached
6
+ process, software-key fallback, sideloaded Android build, or testing attestation
7
+ environment can never produce passing release evidence.
8
+
9
+ The optional iOS development Firebase bootstrap is likewise excluded. Its
10
+ bridge exists only in `DEBUG`, its plist phase refuses Release/candidate builds,
11
+ and its one-read grant is launched outside the protected collector. Its local
12
+ runner does require a fresh sign-in, replacement App Attest session, Responses,
13
+ quota, exact trusted diagnostics, terminal revocation/sign-out, and a run-bound
14
+ app-container marker. Even so, a successful development-signed `dev.latchway`
15
+ run is source verification, not production App Attest, immutable-candidate, or
16
+ publication evidence.
17
+
18
+ This is an external release gate. Repository CI validates the collectors and
19
+ their failure cases, but it does not claim a physical-device success. Only the
20
+ protected, manually dispatched `Physical React Native evidence` workflow can
21
+ collect a candidate report, and the final cross-repository adapter requires all
22
+ four independently validated native and React Native reports.
23
+
24
+ Every authorization, lease, identity grant, candidate handoff, and retained
25
+ artifact is bound to one workflow run and attempt. Do not use **Re-run failed
26
+ jobs** to recover a partial physical proof: a successful authorization or
27
+ candidate job from an earlier attempt is intentionally unusable by a later
28
+ collector. Start a fresh complete workflow dispatch so all single-use grants
29
+ and attempt-bound evidence are reissued together.
30
+
31
+ The raw React Native device record is
32
+ `latchway.react-native-device-run.v2`. It contains only checks the opaque
33
+ production bridge can prove without exporting credentials. The protected
34
+ finalizer imports replay rejection, proof-tamper rejection, refresh-token
35
+ rotation, protocol-version rejection, and post-revocation enforcement from the
36
+ exact linked native report. A JavaScript record that supplies any of those
37
+ native-only proof IDs is rejected.
38
+
39
+ ## What the run proves
40
+
41
+ Each platform run uses the real example application and verifies:
42
+
43
+ - the React Native New Architecture bridge reached the locked native SDK;
44
+ - a production App Attest session with a Secure Enclave key on iOS, or a Play
45
+ Integrity session with hardware-backed Android Keystore key material;
46
+ - on iOS, an initial App Attest registration followed by session-only local
47
+ retirement and an App Attest assertion that reuses the same installation;
48
+ - a DPoP-authorized request through the opaque native dispatch boundary and the
49
+ concrete React Native `LatchwayError` mapping for authorization-first HTTP
50
+ 403 `component_feature_not_granted`;
51
+ - exact HTTP 401 replay/tamper rejection, redacted refresh-credential rotation
52
+ for one stable installation, HTTP 426 protocol-version-zero rejection, and
53
+ HTTP 403 post-revocation enforcement imported unchanged, field-for-field,
54
+ from the linked, release-eligible native report;
55
+ - a bounded, non-empty SSE stream and a quota response through the pinned
56
+ gateway image and configuration;
57
+ - one short-lived, P-256-signed deployment statement fetched from the same
58
+ gateway origin before and after each run, with the exact native/RN client
59
+ policy requiring request-hash-bound production trust;
60
+ - exact app/package version, build, team or cloud project, signing-certificate,
61
+ distribution/Play track, source, native SDK, core, and contract hashes;
62
+ - linkage to a separately passing native iOS or Android physical-device report;
63
+ - absence of identity/session/refresh tokens, DPoP proofs, raw attestation,
64
+ private keys, and provider credentials from every retained document.
65
+
66
+ The Android gate additionally requires the Play installer, a locked production
67
+ device with green Verified Boot, a Play-recognized app, and a licensed account.
68
+ Installing an APK with `adb` is deliberately insufficient. The iOS gate checks
69
+ a private snapshot of the exact signed `.app`, production App Attest
70
+ entitlement, application identifier, team, signing certificate, executable
71
+ hash, canonical per-file manifest hash, and deterministic whole-bundle tree
72
+ hash before inspection and again
73
+ immediately before installing that same snapshot.
74
+
75
+ ## Prepare immutable candidates
76
+
77
+ First produce and retain the native reports with the sibling SDK workflows:
78
+
79
+ - iOS: `latchway-ios-sdk/.github/workflows/physical-app-attest.yml`
80
+ - Android: `latchway-android/.github/workflows/physical-play-integrity.yml`
81
+
82
+ Stage each native evidence/profile pair on its matching protected runner. Pin
83
+ its evidence SHA-256 in the React Native environment. A React Native candidate
84
+ must embed all physical-run values listed in `example/.env.example`, with
85
+ `LATCHWAY_CONFORMANCE_AUTORUN=true`. Those values are release identifiers, not
86
+ credentials, but the build configuration itself remains a protected release
87
+ input. Produce the candidate with
88
+ `scripts/stage-physical-react-native-candidate.py ios|android`. The producer
89
+ requires clean exact React Native, JavaScript SDK, native SDK, and core
90
+ worktrees and checks their commits against `release-compatibility.json` and
91
+ `contract.lock`. It materializes the exact React Native and JavaScript commits
92
+ as fresh sibling worktrees, regenerates their dependencies and JavaScript build
93
+ output from protected locks, uses external Firebase configuration, performs a
94
+ Release build, inspects the result, and emits canonical source/candidate
95
+ manifests plus `SHA256SUMS`. The platform candidate necessarily embeds only its
96
+ non-secret Firebase client configuration. Provider credentials, provider
97
+ secrets, and signing passwords are never included anywhere in candidate
98
+ output. Its output directory must not already exist.
99
+
100
+ Run the producer from the clean React Native repository. Common protected
101
+ inputs are `LATCHWAY_SOURCE_COMMIT`, `LATCHWAY_CORE_COMMIT`,
102
+ `LATCHWAY_CORE_SOURCE_PATH`, `LATCHWAY_JAVASCRIPT_SDK_PATH`,
103
+ `LATCHWAY_CONTRACT_VERSION`,
104
+ `LATCHWAY_CONTRACT_BUNDLE_SHA256`, `LATCHWAY_RN_SDK_VERSION`,
105
+ `LATCHWAY_NATIVE_SDK_VERSION`, `LATCHWAY_NATIVE_EVIDENCE_PATH` and its
106
+ `LATCHWAY_NATIVE_EVIDENCE_SHA256`, all gateway coordinates listed in
107
+ `example/.env.example`, and a new `LATCHWAY_CANDIDATE_OUTPUT_DIR`. The producer
108
+ requires the repository-pinned Node 24.19.0 and pnpm 10.15.0
109
+ toolchain and records both versions in `source-inputs.json`. iOS also
110
+ requires `LATCHWAY_IOS_SDK_PATH`, `LATCHWAY_IOS_COMMIT`,
111
+ `LATCHWAY_BUNDLE_ID`, version/build/distribution pins,
112
+ `LATCHWAY_FIREBASE_IOS_CONFIG_PATH`, `LATCHWAY_IOS_PODFILE_LOCK_PATH`,
113
+ `LATCHWAY_TEAM_ID`, `LATCHWAY_IOS_APP_ID_PREFIX`,
114
+ `LATCHWAY_IOS_CODE_SIGN_IDENTITY`,
115
+ `LATCHWAY_IOS_PROVISIONING_PROFILE_UUID`,
116
+ `LATCHWAY_IOS_APPINTENTS_BUNDLE_ID`,
117
+ `LATCHWAY_IOS_APPINTENTS_PROVISIONING_PROFILE_UUID`,
118
+ `LATCHWAY_IOS_SHARED_KEYCHAIN_ACCESS_GROUP`, and the production App Attest and
119
+ `LATCHWAY_SIGNING_CERTIFICATE_SHA256` pins. Android requires
120
+ `LATCHWAY_ANDROID_SDK_PATH`, `LATCHWAY_ANDROID_COMMIT`,
121
+ `LATCHWAY_PACKAGE_NAME`, `LATCHWAY_APP_VERSION`, `LATCHWAY_VERSION_CODE`,
122
+ `LATCHWAY_PLAY_TRACK`, `LATCHWAY_REQUIRE_LICENSED`,
123
+ `LATCHWAY_CLOUD_PROJECT_NUMBER`, `LATCHWAY_FIREBASE_ANDROID_CONFIG_PATH`, and
124
+ the expected Play App Signing `LATCHWAY_SIGNING_CERTIFICATE_SHA256`. The
125
+ checkout and Gradle are strictly unsigned-only and reject every
126
+ keystore/password/alias/upload-certificate input. The repository producer emits
127
+ only a closed unsigned AAB/APK handoff and a canonical pre-sign AAB payload
128
+ manifest. A protected fresh signer job receives that data without a checkout or
129
+ Gradle. Before any step receives key material it separately downloads the AAB
130
+ verifier, checks its protected SHA-256, regenerates the canonical AAB payload
131
+ manifest and byte-compares it with the carried manifest. It also strictly parses
132
+ the unsigned APK, binds its package/version, embedded configuration and
133
+ JavaScript bundle, rejects every `META-INF` signature control and requires
134
+ `apksigner` to reject it as unsigned. The secret-bearing step invokes only
135
+ system signing tools; APK signing fixes minimum SDK 24 and disables v1/JAR
136
+ signatures. A second fresh job has no secret and no checkout; it accepts the
137
+ verifier source only when its SHA-256 matches the same protected pin. The
138
+ verifier independently parses local and central ZIP records,
139
+ rejects structural mismatches, unsafe modes, overlaps, trailing/polyglot bytes,
140
+ ambiguous names and extra signature metadata, fully reads every payload, pins
141
+ one common leaf signer, and requires exact continuity with the separately
142
+ carried pre-sign manifest. The exact independently verified AAB/APK bytes are
143
+ the only publishable signed output. The signer also retains the source-built
144
+ unsigned APK. The fresh verifier hash-binds it to the unsigned candidate
145
+ manifest, rejects `META-INF` signature controls in both APKs, requires the
146
+ signed APK's complete ZIP payload to be identical (only the v2/v3 APK signing
147
+ block may differ outside the ZIP payload), and independently rechecks package,
148
+ version, embedded Firebase and candidate-configuration hashes, JavaScript
149
+ bundle, and upload signer. This
150
+ prevents a correctly signed APK substituted inside the key-bearing boundary
151
+ from becoming a release output. Passwords and keystores never enter the
152
+ repository build or candidate output. Runtime identity grants are rejected if
153
+ present during candidate production.
154
+
155
+ Use `.github/workflows/react-native-android-candidate.yml` for the Android
156
+ release handoff. Its build job is the only job with a checkout and is isolated
157
+ in the credential-free `react-native-android-candidate-build` environment; it
158
+ emits only unsigned data. `sign-isolated` uses only
159
+ `react-native-android-upload-signing`, has no checkout or Gradle, and
160
+ `verify-signed` uses only the no-secret
161
+ `react-native-android-candidate-verification` environment, with neither signing
162
+ secrets nor a checkout. Each exact environment policy sentinel is the first
163
+ step, before checkout, artifact download, tool execution, secret access, or
164
+ job-token use. Configure the protected verifier
165
+ environment with the exact SHA-256 of
166
+ `scripts/VerifyReactNativeAabSignature.java`; changing the verifier therefore
167
+ requires an explicit protected-coordinate review. Also set the protected
168
+ `LATCHWAY_ANDROID_UPLOAD_SIGNATURE_ALGORITHM` coordinate to the upload key's
169
+ exact `SHA256withRSA` or `SHA256withECDSA` algorithm.
170
+
171
+ Before importing native-only proofs, the finalizer validates the native profile
172
+ and report against its platform-authoritative checked-in schema, requires
173
+ `release_eligible=true`, checks the platform and repository, and binds native
174
+ SDK, core, contract, gateway image/configuration/origin/deployment-statement
175
+ coordinates. The native
176
+ report's byte SHA-256 must match all three independent bindings: the value
177
+ embedded in the signed React Native candidate, the protected React Native
178
+ profile, and the externally signed one-job collector lease. Only the five exact
179
+ allowlisted native tests are copied. Missing, failed, renamed, extended,
180
+ cross-platform, rehashed, or coordinate-substituted reports fail closed.
181
+
182
+ Android native and React Native output evidence use the shared v1 contract. The
183
+ pinned iOS SDK uses the component-aware v2 contract; this repository carries an
184
+ exact reviewed snapshot at
185
+ `Conformance/linked-ios-physical-device-evidence.schema.json` together with its
186
+ validator at `scripts/linked-ios-device-evidence.py`. The finalizer test pins the
187
+ raw reviewed bytes (validator SHA-256
188
+ `8d12b2beb887cebb10f1fcc634cd9ebad839e3b40372a03f5f558ad5f41bc0d4`, schema
189
+ SHA-256 `b0f399ff16ff21e80ac1528af143e3834d0ef80e8a8dbeb9c7d4a2e354ead8c6`),
190
+ so an unreviewed snapshot change fails CI. This prevents genuine v2 iOS evidence
191
+ from being treated as v1 while preserving the v1 React Native output consumed
192
+ by the current cross-repository release adapter.
193
+
194
+ The linked iOS report must contain the exact 13-test component-observation v2
195
+ set. Widget, Share, and Action must each record a distinct successful delegated
196
+ request; sibling server credentials must be rejected; an Action attempt to read
197
+ a Widget or Share private key must record `SecItemCopyMatching` returning
198
+ `errSecMissingEntitlement` without key material; and two overlapping refreshes
199
+ must independently return the same rotated credentials and session. The fresh
200
+ GitHub-hosted signer rechecks those concrete fields before attesting the React
201
+ Native evidence bundle. The finalizer still imports only the five allowlisted
202
+ native security proofs into the outer report.
203
+
204
+ The React Native example does not reproduce or claim the linked SDK's
205
+ Widget/Share/Action operations: its App Intents target has no React Native
206
+ runtime or component-request bridge and fails closed when invoked. The React
207
+ Native iOS run proves the root App Attest path plus the exact, hash-bound native
208
+ Installation Family evidence.
209
+
210
+ For iOS, the producer archives a non-debug, production-signed New Architecture
211
+ `.app` from the exact candidate commit. It requires a protected `Podfile.lock`,
212
+ Firebase plist, signing identity, distinct root and App Intents bundle IDs and
213
+ provisioning-profile UUIDs, the protected shared Keychain access group,
214
+ certificate hash, Team ID, and App ID Prefix. App ID Prefix is independently
215
+ protected because it can differ from Team ID; it binds `application-identifier`
216
+ and both Keychain access groups. The signed root target must have exactly the
217
+ private app-ID group first and the shared component group second. The signed
218
+ App Intents extension must have only the shared group, so it cannot read root
219
+ default Keychain state. Each modern provisioning profile is verified with
220
+ OpenSSL CMS, then its UUID, devices, Team ID, App ID Prefix, bundle ID, and
221
+ App Attest/get-task-allow policy are checked. Every signed Keychain group must
222
+ be authorized by an exact profile entry or a well-formed terminal `*` prefix;
223
+ unrelated profile grants are allowed, while malformed wildcards and
224
+ unauthorized signed groups fail closed. Root signing and its profile must both
225
+ carry the exact App Attest `app-attest-opt-in=[CDhash]` value, and the profile
226
+ must authorize the production environment. The App Intents extension and its
227
+ profile must carry neither App Attest entitlement key.
228
+ `LATCHWAY_IOS_INSTALL_MODE` must be `install`:
229
+ the runner uninstalls any old application, verifies absence, and installs the
230
+ inspected candidate immediately before launch. The executable and
231
+ `main.jsbundle` SHA-256 pins must both match. The producer also emits a
232
+ canonical per-file `ios-app-files.sha256` manifest and the native-compatible
233
+ `latchway.ios-app-bundle-tree.v1` digest, which additionally binds directory
234
+ and file modes. Both hashes are protected lease coordinates and are copied to
235
+ the final profile/evidence. The collector copies the `.app` into a private
236
+ snapshot, verifies both hashes there, re-verifies both immediately before
237
+ handoff, and installs that exact snapshot. The bundle cannot contain its own
238
+ JavaScript hash, so that non-secret, lease-bound digest is supplied at launch
239
+ through the native child environment.
240
+
241
+ For Android, publish the same Release AAB through the pinned Play track,
242
+ install it from `com.android.vending` on the locked physical device, and keep a
243
+ licensed Play account on that device. This Play licensing account is only for
244
+ Play Integrity recognition; it is not the application's Firebase identity.
245
+ The serial is a secret. The package,
246
+ version, version code, canonical installed base/split APK-set manifest hash,
247
+ certificate hash, Play track, and cloud project number are protected
248
+ environment variables. Every split is signature/package/version-code checked;
249
+ the complete installed split manifest is captured before launch and again
250
+ after the observed run, and both the digest and byte-for-byte manifest must be
251
+ identical before the observed digest is written to the evidence profile;
252
+ an added, removed, or substituted split changes the protected set hash.
253
+
254
+ The protected GitHub environments are:
255
+
256
+ - `react-native-ios-production`, on a newly booted repository-scoped JIT
257
+ runner registered with `--ephemeral`, labelled `macOS`,
258
+ `latchway-physical-ios`, and `latchway-ephemeral-jit`, and named exactly
259
+ `latchway-rn-ios-<run-id>-<run-attempt>`;
260
+ - `react-native-android-production`, on the equivalent one-job runner labelled
261
+ `Linux`, `latchway-physical-android`, and `latchway-ephemeral-jit`, and named
262
+ exactly `latchway-rn-android-<run-id>-<run-attempt>`.
263
+
264
+ The application identity bootstrap is a Firebase custom token minted for the
265
+ protected run. It is not a Latchway session/admin grant and crosses only the
266
+ example-specific `LatchwayEvidence` bridge; it never crosses the public
267
+ `@latchway/react-native` SDK bridge. The token must be 32–65,536 bytes and exactly three
268
+ non-empty base64url JWT segments. The signed collector lease and issuer record,
269
+ not the Firebase JWT's provider-defined `aud` field, authoritatively bind its
270
+ SHA-256, repository, source commit, run/attempt, exact Latchway
271
+ `application_id`, exact iOS bundle ID or Android package name in
272
+ `package_or_bundle_identifier`, `identity_provider="firebase"`, issuance,
273
+ expiry, and collector audience. Both the source-free collector gate and the
274
+ fresh candidate-code-free attestation signer revalidate all three
275
+ identity/application fields.
276
+ The example exchanges it exactly once with
277
+ `signInWithCustomToken` to obtain a Firebase ID token; native Latchway code
278
+ continues to own App Attest/Play Integrity, DPoP, session, and refresh state.
279
+
280
+ Reusable runners, runners able to accept a second job, surviving workspaces,
281
+ and shared devices are ineligible. Each environment configures the public
282
+ collector trust root and SHA-256 plus its platform-specific one-use device
283
+ grant SHA-256. The grant is provisioned after the run ID and attempt exist,
284
+ records its issuance and expiry, remains valid for at most five minutes and no
285
+ later than the runner lease, is accepted once, and is bound
286
+ to repository, source commit, run/attempt, application, and
287
+ collector audience `latchway-physical-evidence/rn-ios-app-attest` or
288
+ `latchway-physical-evidence/rn-android-play-integrity`. No reusable identity
289
+ session, organization/admin token, PAT, registry/cloud credential, or OIDC
290
+ authority may be present on a collector. If platform application setup cannot
291
+ consume that one-use Firebase custom token, the physical gate cannot run.
292
+
293
+ Only the platform collection step receives the actual custom token. It first
294
+ hash-verifies the value against the signed lease coordinate and removes it from
295
+ the runner environment immediately after launch/staging. iOS inherits it
296
+ through `DEVICECTL_CHILD_*`; the app captures it before React Native starts,
297
+ unsets the process environment, and has one terminal in-memory read. This is a
298
+ bounded lifetime, not a claim that immutable Swift string bytes are
299
+ cryptographically zeroized; uninstall is authoritative. Android streams it on
300
+ stdin through a shell-only `android.permission.DUMP` content provider into one
301
+ process-memory slot—never an intent extra, argv value, log, or file—and clears
302
+ the mutable byte buffers. Fresh install/app-data clear forbids a persisted
303
+ Firebase `currentUser` from satisfying either platform run.
304
+
305
+ The variable names are `LATCHWAY_COLLECTOR_TRUST_ROOT_PEM`,
306
+ `LATCHWAY_COLLECTOR_TRUST_ROOT_SHA256`, `LATCHWAY_APPLICATION_ID`, and
307
+ respectively `LATCHWAY_IOS_BUNDLE_ID` plus
308
+ `LATCHWAY_IOS_DEVICE_GRANT_SHA256` and the producer-emitted
309
+ `LATCHWAY_IOS_APP_FILES_MANIFEST_SHA256` and
310
+ `LATCHWAY_IOS_APP_BUNDLE_TREE_SHA256`, or `LATCHWAY_ANDROID_PACKAGE_NAME` plus
311
+ `LATCHWAY_ANDROID_DEVICE_GRANT_SHA256`. The public trust root, expected hashes,
312
+ and non-secret application identifiers are repeated in
313
+ `physical-evidence-signing`; no private supervisor key, device selector,
314
+ application credential, or grant value is present there.
315
+
316
+ Create a third reviewed environment, `physical-evidence-signing`, in this
317
+ repository. It must contain no device, identity, application, native-evidence,
318
+ or runner credentials; the exact application identifiers above are coordinates,
319
+ not credentials. Require independent reviewers and restrict deployments to
320
+ `main`.
321
+
322
+ The GitHub-hosted `authorize-source` job and both final attestation jobs reuse
323
+ `physical-evidence-signing`; only the two collectors use their corresponding
324
+ `react-native-ios-production` or `react-native-android-production` environment.
325
+ Every job that names one of these protected environments has the exact
326
+ environment policy sentinel as its first step, before checkout, artifact
327
+ access, device work, credentials, job-token use, OIDC, or attestation.
328
+
329
+ Environment protection should require an independent reviewer and prevent
330
+ untrusted pull-request code from reaching the devices or application accounts.
331
+ Toolchain identities are compared byte-for-byte with the protected expected
332
+ Xcode, `adb`, and `apksigner` versions before collection.
333
+
334
+ ## Ephemeral runner identity and teardown
335
+
336
+ The GitHub-hosted `authorize-source` job checks out the React Native candidate
337
+ only as data, executes no repository code, records its exact commit and Git
338
+ tree for both platform audiences and this run/attempt, and creates a GitHub
339
+ Sigstore attestation. Each collector verifies that bundle with
340
+ `--deny-self-hosted-runners` before checking out or executing candidate code.
341
+
342
+ Every JIT image exposes root-owned, non-writable
343
+ `/etc/latchway/physical-collector/lease.json` and `lease.sig`, and the
344
+ root-owned client `/usr/local/libexec/latchway-physical-collector-finalize`.
345
+ The ECDSA/SHA-256 lease is signed outside the candidate VM and binds repository,
346
+ source commit and authorization hash, workflow run/attempt/job/audience,
347
+ runner name/image/boot identity, one-job/fresh/JIT flags, the exact signed app
348
+ or installed APK-set, the iOS per-file manifest and whole-tree digests,
349
+ JavaScript bundle and linked-native evidence hashes, and
350
+ the one-use grant hash/issuance/expiry. Its credential declaration must deny
351
+ long-lived, organization, administration, registry, and OIDC credentials.
352
+ The signed lease uses `latchway.physical-collector-lease.v2`, requires the exact
353
+ canonical grant key set, and asserts that the supervisor enforces the protected
354
+ grant digest once. The signed `latchway.physical-collector-teardown.v2` must
355
+ assert one consumption and gateway-receipt binding, and its
356
+ `observations.identity_grant_sha256` must equal that protected digest. Both the
357
+ collector and fresh signer enforce these fields for iOS and Android.
358
+
359
+ The finalizer is a client for an authenticated privileged supervisor, not a
360
+ general signing utility. The signing key and gateway observer capability stay
361
+ outside candidate control. The service accepts file paths rather than
362
+ caller-supplied digest claims, independently hashes and validates the source,
363
+ evidence, and wipe receipt, independently observes the device and the
364
+ gateway's server-side App Attest or Play Integrity run receipt, and permits one
365
+ invocation per lease. It deregisters the runner, prevents another job, and
366
+ schedules VM destruction within ten minutes. Candidate code can force a
367
+ failure, but it cannot obtain a signature over arbitrary hashes or a synthetic
368
+ device/provider verdict.
369
+
370
+ The supervisor's out-of-band watchdog must revoke the JIT registration,
371
+ invalidate the one-use grant, wipe/reset the attached device, and destroy the
372
+ VM after cancellation, timeout, runner crash, network loss, or a missing
373
+ finalizer receipt. This path cannot depend on candidate code or a final Actions
374
+ step executing.
375
+
376
+ Platform app-data wipe and supervisor finalization are separate unconditional
377
+ `if: always()` steps. iOS uninstalls the conformance app and confirms absence;
378
+ Android runs `pm clear` and confirms no process remains. Finalization still
379
+ runs if lease, source, toolchain, grant, collection, or wipe validation fails.
380
+ Only a signed teardown with `evidence_eligible=true`, independent
381
+ device/provider and gateway-receipt verification, successful wipe, JIT
382
+ deregistration, no further jobs, and a bounded destruction deadline can be
383
+ handed to the signer.
384
+
385
+ Both environments configure `LATCHWAY_GATEWAY_ORIGIN`, the deployment key ID,
386
+ the exact statement SHA-256, the local P-256 public-key path and SPKI SHA-256,
387
+ and `LATCHWAY_GATEWAY_MINIMUM_TRUST_LEVEL`. The gateway publishes canonical
388
+ `/.well-known/latchway/deployment-statement-v1.json` plus its detached DER
389
+ ECDSA/SHA-256 `.sig`. Statements last no more than 24 hours. Android policy
390
+ requires `PLAY_RECOGNIZED` and `LICENSED`; both platforms deny testing/debug
391
+ clients and require request-hash binding. The candidate embeds the origin,
392
+ environment, key ID, statement digest, and public-key digest.
393
+ The iOS client policy pins the core-normalized App Attest trust level exactly
394
+ to `app_verified`; the Android policy pins `device_verified` or
395
+ `strong_device_verified` according to its protected device requirement. A
396
+ provider's normalized trust level is never accepted as evidence for the other
397
+ provider. Both environments also pin `LATCHWAY_ERROR_MAPPING_FEATURE` to a
398
+ syntactically valid feature that is intentionally absent from the root
399
+ component's grant; the same non-secret value is embedded in the signed
400
+ candidate. It may also be absent from gateway configuration. The canonical
401
+ result remains HTTP 403 `component_feature_not_granted`, proving authorization
402
+ precedes feature lookup and does not disclose whether the feature exists.
403
+ This is the React Native wrapper's canonical mapping proof. The independently
404
+ versioned native iOS and Android reports retain their HTTP 404
405
+ `feature_not_found` mapping contract; the finalizer validates those reports
406
+ before importing only their five native-only security proofs.
407
+
408
+ ## Collect and validate
409
+
410
+ Dispatch `.github/workflows/physical-device-evidence.yml` with the full
411
+ 40-character React Native commit. The runners invoke:
412
+
413
+ ```sh
414
+ scripts/run-physical-react-native-ios.sh
415
+ scripts/run-physical-react-native-android.sh
416
+ ```
417
+
418
+ Each protected environment also pins `LATCHWAY_SOURCE_COMMIT` to that exact
419
+ 40-character candidate. The workflow refuses a dispatch input that differs
420
+ from the environment pin. The example retires its dedicated prior Latchway
421
+ installation before the measured calls, so a session created by an older app
422
+ build cannot be reused as current App Attest or Play Integrity proof.
423
+
424
+ The scripts refuse dirty source trees, symbolic-link inputs, mismatched hashes,
425
+ unsafe devices, untrusted builds, stale run IDs, and malformed records. The
426
+ example-native sinks rebuild v2 JSON from exact platform runtime allowlists
427
+ (eight tests on iOS and seven on Android), reject native-only proof fields and
428
+ IDs, and expose only a fixed protected file;
429
+ they do not accept an arbitrary output path. Successful
430
+ artifacts contain the profile, sanitized observation, schema-validated evidence,
431
+ JUnit, validation summary, device inventory, linked native report/profile,
432
+ signed statement, signature, public key, exact client policy, verification
433
+ result, and `SHA256SUMS`. A failed run never becomes a passing report.
434
+
435
+ Each protected JIT collector runs with only repository-scoped `actions: read`
436
+ and `contents: read`: candidate
437
+ checkout, native/device execution, and access to app/device credentials have no
438
+ OIDC, attestation, or artifact-metadata authority. It uploads a bounded one-day
439
+ `react-native-<platform>-physical-unsigned-<run>-<attempt>` handoff. A fresh
440
+ GitHub-hosted Ubuntu job behind `physical-evidence-signing` downloads that
441
+ handoff without checking out source and uses only fixed inline shell, `jq`, and
442
+ hash checks to enforce the exact file set, size limits, manifest, source
443
+ commit, run/attempt, platform, physical-device, production-provider,
444
+ passing-test, and redaction coordinates. Only that job receives OIDC and
445
+ attestation permissions. It emits the observer-compatible final artifacts
446
+ `react-native-ios-physical-<run>-<attempt>` and
447
+ `react-native-android-physical-<run>-<attempt>`, with the GitHub Sigstore bundle
448
+ at exactly `github-attestation.sigstore.json` beside the attested profile,
449
+ evidence, and `SHA256SUMS`.
450
+
451
+ The signer additionally verifies the GitHub-hosted source authorization,
452
+ trust-root signatures on the lease and teardown, exact run/grant/artifact
453
+ coordinates, device-wipe receipt, evidence-manifest binding, independent
454
+ supervisor verdict, and destruction deadline. It attests a
455
+ `collector-isolation-validation.json` subject and retains separate
456
+ `react-native-<platform>-collector-isolation-<run>-<attempt>` artifacts for 30
457
+ days. The observer-compatible physical artifact file sets remain unchanged.
458
+
459
+ Repository validation cannot create hardware, a JIT provisioning service, the
460
+ root supervisor, isolated signing key/observer capability, one-use grant
461
+ issuer, protected environments, or a post-job hypervisor destruction record.
462
+ Those remain external release prerequisites. Operators must independently
463
+ retain a destruction log bound to the same lease/run as each final artifact;
464
+ the signed teardown proves deregistration and scheduled destruction, not that
465
+ the hypervisor later completed it.
466
+
467
+ Repository-only validation is safe to run anywhere:
468
+
469
+ ```sh
470
+ python3 scripts/test-device-evidence.py
471
+ python3 scripts/test-physical-evidence-workflow.py
472
+ python3 scripts/test-finalize-react-native-device-run.py
473
+ python3 scripts/test-export-core-physical-evidence.py
474
+ python3 scripts/test-verify-gateway-deployment.py
475
+ bash -n scripts/gateway-deployment-evidence.sh
476
+ bash -n scripts/run-physical-react-native-ios.sh
477
+ bash -n scripts/run-physical-react-native-android.sh
478
+ ```
479
+
480
+ These tests use synthetic *rejection* fixtures and schema-valid offline
481
+ documents. They are not physical-device evidence.
482
+
483
+ ## Emit the core external domain document
484
+
485
+ After all four reports exist for one release, create a protected coordinates
486
+ file with exactly this shape:
487
+
488
+ ```json
489
+ {
490
+ "schema_version": 1,
491
+ "core_commit": "<40 lowercase hex>",
492
+ "core_release": "v<semver>",
493
+ "contract_version": "<semver>",
494
+ "bundle_sha256": "<64 lowercase hex>",
495
+ "oci_image_digest": "ghcr.io/latchway/latchway@sha256:<64 lowercase hex>",
496
+ "gateway_configuration_sha256": "<64 lowercase hex>",
497
+ "repositories": {
498
+ "core": { "commit": "<40 lowercase hex>", "tag": "v<semver>", "version": "<semver>" },
499
+ "javascript": { "commit": "<40 lowercase hex>", "tag": "v<semver>", "version": "<semver>" },
500
+ "ios": { "commit": "<40 lowercase hex>", "tag": "v<semver>", "version": "<semver>" },
501
+ "android": { "commit": "<40 lowercase hex>", "tag": "v<semver>", "version": "<semver>" },
502
+ "react_native": { "commit": "<40 lowercase hex>", "tag": "v<semver>", "version": "<semver>" }
503
+ }
504
+ }
505
+ ```
506
+
507
+ This file is prepared before publication: `tag` values are the intended
508
+ release names and need not exist yet. The adapter never resolves or fetches a
509
+ tag; it binds each report to the exact candidate commit and SDK version, while
510
+ requiring each future tag to equal `v<version>`.
511
+
512
+ Then run the deterministic offline adapter into a new, empty output directory:
513
+
514
+ ```sh
515
+ python3 scripts/export-core-physical-evidence.py \
516
+ --schema Conformance/physical-device-evidence.schema.json \
517
+ --coordinates /protected/release-coordinates.json \
518
+ --ios-profile /protected/ios/app-attest-profile.json \
519
+ --ios-evidence /protected/ios/app-attest-evidence.json \
520
+ --ios-attestation /protected/ios/github-attestation.sigstore.json \
521
+ --ios-manifest /protected/ios/SHA256SUMS \
522
+ --android-profile /protected/android/play-integrity-profile.json \
523
+ --android-evidence /protected/android/play-integrity-evidence.json \
524
+ --android-attestation /protected/android/github-attestation.sigstore.json \
525
+ --android-manifest /protected/android/SHA256SUMS \
526
+ --rn-ios-profile /protected/rn-ios/react-native-ios-profile.json \
527
+ --rn-ios-evidence /protected/rn-ios/react-native-ios-evidence.json \
528
+ --rn-ios-attestation /protected/rn-ios/github-attestation.sigstore.json \
529
+ --rn-ios-manifest /protected/rn-ios/SHA256SUMS \
530
+ --rn-android-profile /protected/rn-android/react-native-android-profile.json \
531
+ --rn-android-evidence /protected/rn-android/react-native-android-evidence.json \
532
+ --rn-android-attestation /protected/rn-android/github-attestation.sigstore.json \
533
+ --rn-android-manifest /protected/rn-android/SHA256SUMS \
534
+ --output-root /protected/core-physical-evidence
535
+ ```
536
+
537
+ Run this on a trusted release host with the GitHub CLI (`gh`) available. The
538
+ adapter verifies each profile and evidence file against its retained GitHub
539
+ Sigstore bundle, exact repository, protected workflow path, and candidate
540
+ source commit before parsing its claims. It then validates every report again,
541
+ requires the exact SDK version and shared gateway configuration/release
542
+ coordinates, requires all four reports to bind the same signed deployment
543
+ origin/key/statement, verifies both React Native-to-native evidence hashes, enforces a
544
+ seven-day collection/freshness window, copies all sixteen hashed source
545
+ artifacts. The gateway configuration hash remains mandatory in the protected coordinates
546
+ and every raw report, and is retained through the copied artifacts and their
547
+ hashes. It is intentionally not a top-level member of `physical_devices.json`,
548
+ whose envelope exactly matches the core consumer contract. Only then does the
549
+ adapter write that domain with these four claims:
550
+
551
+ - `app_attest_production_verified`
552
+ - `play_integrity_play_distributed_verified`
553
+ - `react_native_ios_verified`
554
+ - `react_native_android_verified`
555
+
556
+ All four claims are written together and only as `true`; there is no switch or
557
+ manual override that can manufacture a passing domain document. Preserve the
558
+ output as an immutable release artifact and run the core cross-repository
559
+ conformance validator against that directory.