@scalebun/react-native 1.13.0 → 2.0.1

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 (115) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
  8. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  9. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
  10. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  11. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
  12. package/dist/scalebun.full.js +964 -533
  13. package/dist/scalebun.slim.js +962 -531
  14. package/ios/Ota/OtaProtocol.swift +242 -0
  15. package/ios/Ota/OtaSlotManager.swift +26 -0
  16. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  17. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  18. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  19. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  20. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  21. package/ios/ReplaySdk.swift +65 -2
  22. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  23. package/lib/commonjs/core/config/schema.js +19 -12
  24. package/lib/commonjs/core/constants/version.js +1 -1
  25. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  26. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  27. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  28. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  29. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  30. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  31. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  32. package/lib/commonjs/features/journey/uiState.js +81 -1
  33. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  34. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  35. package/lib/commonjs/features/network/thirdParty.js +90 -0
  36. package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
  37. package/lib/commonjs/features/session/SessionManager.js +63 -0
  38. package/lib/commonjs/public/ScaleBunFacade.js +23 -57
  39. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  40. package/lib/module/core/config/schema.js +19 -12
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  43. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  44. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  45. package/lib/module/features/journey/interactionProtocol.js +128 -8
  46. package/lib/module/features/journey/scrollContext.js +135 -0
  47. package/lib/module/features/journey/targetGeometry.js +155 -0
  48. package/lib/module/features/journey/touchTarget.js +151 -24
  49. package/lib/module/features/journey/uiState.js +78 -1
  50. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  51. package/lib/module/features/network/NetworkFeature.js +38 -4
  52. package/lib/module/features/network/thirdParty.js +83 -0
  53. package/lib/module/features/ota/OtaOrchestrator.js +175 -80
  54. package/lib/module/features/session/SessionManager.js +63 -0
  55. package/lib/module/public/ScaleBunFacade.js +23 -57
  56. package/lib/typescript/core/config/schema.d.ts +2 -3
  57. package/lib/typescript/core/constants/version.d.ts +1 -1
  58. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  59. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  60. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  61. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  62. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  63. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  64. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  65. package/lib/typescript/features/network/index.d.ts +18 -0
  66. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  67. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
  68. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  69. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  70. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  71. package/lib/typescript/public/types.d.ts +10 -0
  72. package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
  73. package/package.json +3 -13
  74. package/scalebun-react-native.podspec +4 -0
  75. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  76. package/src/core/config/schema.ts +21 -9
  77. package/src/core/constants/version.ts +1 -1
  78. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  79. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  80. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  81. package/src/features/journey/interactionProtocol.ts +188 -10
  82. package/src/features/journey/scrollContext.ts +137 -0
  83. package/src/features/journey/targetGeometry.ts +176 -0
  84. package/src/features/journey/touchTarget.ts +237 -26
  85. package/src/features/journey/uiState.ts +80 -1
  86. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  87. package/src/features/network/NetworkFeature.ts +41 -4
  88. package/src/features/network/index.ts +18 -0
  89. package/src/features/network/thirdParty.ts +92 -0
  90. package/src/features/ota/OtaOrchestrator.ts +190 -90
  91. package/src/features/ota/OtaTypes.ts +23 -1
  92. package/src/features/session/SessionManager.ts +113 -0
  93. package/src/public/ScaleBunFacade.ts +23 -69
  94. package/src/public/types.ts +10 -0
  95. package/src/specs/NativeScaleBunOta.ts +35 -20
  96. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  97. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  98. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  99. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  100. package/lib/commonjs/features/ota/signature.js +0 -175
  101. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  102. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  103. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  104. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  105. package/lib/module/features/ota/signature.js +0 -169
  106. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  107. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  108. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  109. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  110. package/lib/typescript/features/ota/signature.d.ts +0 -81
  111. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  112. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  113. package/src/features/ota/crypto/loadSha512.ts +0 -35
  114. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  115. package/src/features/ota/signature.ts +0 -206
@@ -0,0 +1,242 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * SB-OTA-RSA-SHA256-V1 binary codec — the iOS mirror of the TypeScript reference
5
+ * (`packages/ota-protocol/src/codec.ts`), the Android `OtaProtocol.kt`, and the
6
+ * JVM parity harness (`tools/rsa-ota-parity/RsaOtaParity.java`). Pure Foundation,
7
+ * no RN/CryptoKit dependency, so it is unit-testable and cannot be neutered by
8
+ * the JS bundle it protects. See docs/ota/SB-OTA-RSA-SHA256-V1.md.
9
+ *
10
+ * ⚪ Not compiled in this workspace (no macOS toolchain); the algorithm + wire
11
+ * format are gated by the JVM parity harness against the shared fixtures, and an
12
+ * XCTest (ScaleBunOtaVerifierTests) is provided ready-to-run on a simulator.
13
+ */
14
+
15
+ enum OtaFailureCode: String {
16
+ case INVALID_ENVELOPE
17
+ case INVALID_BASE64
18
+ case UNSUPPORTED_PROTOCOL
19
+ case UNSUPPORTED_SCHEMA
20
+ case UNSUPPORTED_SIGNATURE_ALGORITHM
21
+ case UNSUPPORTED_HASH_ALGORITHM
22
+ case UNKNOWN_KEY_ID
23
+ case INVALID_PUBLIC_KEY
24
+ case INVALID_SIGNATURE
25
+ case INVALID_SIGNED_PAYLOAD
26
+ case PROJECT_MISMATCH
27
+ case APPLICATION_MISMATCH
28
+ case PLATFORM_MISMATCH
29
+ case APP_VERSION_MISMATCH
30
+ case RUNTIME_VERSION_MISMATCH
31
+ case RELEASE_DOWNGRADE_REJECTED
32
+ case RELEASE_EXPIRED
33
+ case DOWNLOAD_FAILED
34
+ case DOWNLOAD_TOO_LARGE
35
+ case ARTIFACT_SIZE_MISMATCH
36
+ case ARTIFACT_HASH_MISMATCH
37
+ case PATCH_BASE_MISMATCH
38
+ case PATCH_RESULT_MISMATCH
39
+ case UNSAFE_ARCHIVE
40
+ case STAGING_FAILED
41
+ case ACTIVATION_FAILED
42
+ case INTERNAL_VERIFICATION_ERROR
43
+ }
44
+
45
+ struct OtaVerifyError: Error {
46
+ let code: OtaFailureCode
47
+ let message: String
48
+ }
49
+
50
+ struct SignedRelease {
51
+ let signatureAlgorithm: String
52
+ let hashAlgorithm: String
53
+ let keyId: String
54
+ let projectId: String
55
+ let applicationId: String
56
+ let releaseId: String
57
+ let bundleId: String
58
+ let platform: String
59
+ let artifactType: String
60
+ let targetAppVersion: String
61
+ let targetRuntimeVersion: String
62
+ let minimumSdkVersion: String
63
+ let releaseSequence: Int64
64
+ let issuedAtEpochSeconds: Int64
65
+ let expiresAtEpochSeconds: Int64
66
+ let artifactSize: Int64
67
+ let artifactSha256: String
68
+ let baseBundleId: String
69
+ let baseArtifactSha256: String
70
+ let resultArtifactSha256: String
71
+ }
72
+
73
+ enum OtaProtocol {
74
+ static let magic = "SBOTA001"
75
+ static let schemaVersion = 1
76
+ static let signatureAlgorithm = "SB-OTA-RSA-SHA256-V1"
77
+ static let hashAlgorithm = "SHA-256"
78
+ static let maxSignedPayloadBytes = 8192
79
+ static let maxSignatureBytes = 1024
80
+ static let maxStringFieldBytes = 256
81
+ static let sha256Len = 32
82
+ static let maxSafeUint64: Int64 = 9_007_199_254_740_991 // 2^53 - 1
83
+
84
+ private static let magicBytes = Array(magic.utf8)
85
+
86
+ /// Strict canonical standard-Base64 decode with a hard size cap.
87
+ static func decodeBase64Strict(_ value: String, _ maxBytes: Int, _ what: String) throws -> [UInt8] {
88
+ if value.isEmpty { throw OtaVerifyError(code: .INVALID_BASE64, message: "\(what): empty") }
89
+ if value.count > (maxBytes * 4) / 3 + 4 {
90
+ throw OtaVerifyError(code: .INVALID_BASE64, message: "\(what): exceeds size limit")
91
+ }
92
+ if value.count % 4 != 0 || value.range(of: "^[A-Za-z0-9+/]*={0,2}$", options: .regularExpression) == nil {
93
+ throw OtaVerifyError(code: .INVALID_BASE64, message: "\(what): not canonical base64")
94
+ }
95
+ guard let decoded = Data(base64Encoded: value) else {
96
+ throw OtaVerifyError(code: .INVALID_BASE64, message: "\(what): invalid base64")
97
+ }
98
+ if decoded.base64EncodedString() != value {
99
+ throw OtaVerifyError(code: .INVALID_BASE64, message: "\(what): non-canonical base64")
100
+ }
101
+ if decoded.count > maxBytes {
102
+ throw OtaVerifyError(code: .INVALID_BASE64, message: "\(what): decoded exceeds limit")
103
+ }
104
+ return [UInt8](decoded)
105
+ }
106
+
107
+ /// Parse ONLY enough to obtain the (still-unauthenticated) keyId (docs §5).
108
+ static func peekKeyId(_ bytes: [UInt8]) throws -> String {
109
+ if bytes.count > maxSignedPayloadBytes {
110
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "payload too large")
111
+ }
112
+ var r = Reader(bytes)
113
+ if try r.fixed(magicBytes.count) != magicBytes {
114
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "bad magic")
115
+ }
116
+ if try r.u16() != schemaVersion { throw OtaVerifyError(code: .UNSUPPORTED_SCHEMA, message: "schema") }
117
+ _ = try r.str("signatureAlgorithm")
118
+ _ = try r.str("hashAlgorithm")
119
+ let keyId = try r.str("keyId")
120
+ if keyId.isEmpty { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "empty keyId") }
121
+ return keyId
122
+ }
123
+
124
+ /// Fully parse + validate an authenticated payload.
125
+ static func parse(_ bytes: [UInt8]) throws -> SignedRelease {
126
+ if bytes.count > maxSignedPayloadBytes {
127
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "payload too large")
128
+ }
129
+ var r = Reader(bytes)
130
+ if try r.fixed(magicBytes.count) != magicBytes {
131
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "bad magic")
132
+ }
133
+ if try r.u16() != schemaVersion { throw OtaVerifyError(code: .UNSUPPORTED_SCHEMA, message: "schema") }
134
+ let sigAlg = try r.str("signatureAlgorithm")
135
+ if sigAlg != signatureAlgorithm { throw OtaVerifyError(code: .UNSUPPORTED_SIGNATURE_ALGORITHM, message: sigAlg) }
136
+ let hashAlg = try r.str("hashAlgorithm")
137
+ if hashAlg != hashAlgorithm { throw OtaVerifyError(code: .UNSUPPORTED_HASH_ALGORITHM, message: hashAlg) }
138
+
139
+ let keyId = try r.str("keyId")
140
+ let projectId = try r.str("projectId")
141
+ let applicationId = try r.str("applicationId")
142
+ let releaseId = try r.str("releaseId")
143
+ let bundleId = try r.str("bundleId")
144
+ for v in [keyId, projectId, applicationId, releaseId, bundleId] where v.isEmpty {
145
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "empty id")
146
+ }
147
+ let platform = try r.str("platform")
148
+ if platform != "android" && platform != "ios" {
149
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "platform")
150
+ }
151
+ let artifactType = try r.str("artifactType")
152
+ if artifactType != "full" && artifactType != "patch" {
153
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "artifactType")
154
+ }
155
+ let targetAppVersion = try r.str("targetAppVersion")
156
+ let targetRuntimeVersion = try r.str("targetRuntimeVersion")
157
+ let minimumSdkVersion = try r.str("minimumSdkVersion")
158
+ let releaseSequence = try r.u64()
159
+ let issuedAt = try r.u64()
160
+ let expiresAt = try r.u64()
161
+ let artifactSize = try r.u64()
162
+ if issuedAt == 0 { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "issuedAt zero") }
163
+ if artifactSize == 0 { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "artifactSize zero") }
164
+ if expiresAt != 0 && expiresAt < issuedAt {
165
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "expiresAt < issuedAt")
166
+ }
167
+ let artifactSha = try r.fixed(sha256Len)
168
+ let baseBundleId = try r.str("baseBundleId")
169
+ let baseSha = try r.lenBytes(sha256Len, "baseArtifactSha256")
170
+ let resultSha = try r.fixed(sha256Len)
171
+ if artifactType == "full" {
172
+ if !baseBundleId.isEmpty || !baseSha.isEmpty {
173
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "full has base fields")
174
+ }
175
+ } else {
176
+ if baseBundleId.isEmpty || baseSha.count != sha256Len {
177
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "patch missing base")
178
+ }
179
+ }
180
+ if r.remaining() != 0 { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "trailing bytes") }
181
+
182
+ return SignedRelease(
183
+ signatureAlgorithm: sigAlg, hashAlgorithm: hashAlg, keyId: keyId, projectId: projectId,
184
+ applicationId: applicationId, releaseId: releaseId, bundleId: bundleId, platform: platform,
185
+ artifactType: artifactType, targetAppVersion: targetAppVersion,
186
+ targetRuntimeVersion: targetRuntimeVersion, minimumSdkVersion: minimumSdkVersion,
187
+ releaseSequence: releaseSequence, issuedAtEpochSeconds: issuedAt, expiresAtEpochSeconds: expiresAt,
188
+ artifactSize: artifactSize, artifactSha256: toHex(artifactSha), baseBundleId: baseBundleId,
189
+ baseArtifactSha256: baseSha.count == sha256Len ? toHex(baseSha) : "", resultArtifactSha256: toHex(resultSha)
190
+ )
191
+ }
192
+
193
+ static func toHex(_ bytes: [UInt8]) -> String {
194
+ bytes.map { String(format: "%02x", $0) }.joined()
195
+ }
196
+
197
+ private struct Reader {
198
+ let b: [UInt8]
199
+ var pos = 0
200
+ init(_ b: [UInt8]) { self.b = b }
201
+
202
+ mutating func need(_ n: Int) throws {
203
+ if n < 0 || pos + n > b.count { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "truncated") }
204
+ }
205
+ mutating func fixed(_ n: Int) throws -> [UInt8] {
206
+ try need(n); let o = Array(b[pos..<pos + n]); pos += n; return o
207
+ }
208
+ mutating func u16() throws -> Int {
209
+ let x = try fixed(2); return (Int(x[0]) << 8) | Int(x[1])
210
+ }
211
+ mutating func u32() throws -> Int64 {
212
+ let x = try fixed(4)
213
+ return (Int64(x[0]) << 24) | (Int64(x[1]) << 16) | (Int64(x[2]) << 8) | Int64(x[3])
214
+ }
215
+ mutating func u64() throws -> Int64 {
216
+ let x = try fixed(8)
217
+ var v: Int64 = 0
218
+ for i in 0..<8 {
219
+ // guard against overflow into the sign bit before shifting.
220
+ if v > (Int64.max >> 8) { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "uint64 overflow") }
221
+ v = (v << 8) | Int64(x[i])
222
+ }
223
+ if v < 0 || v > maxSafeUint64 {
224
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "uint64 out of safe range")
225
+ }
226
+ return v
227
+ }
228
+ mutating func lenBytes(_ max: Int, _ field: String) throws -> [UInt8] {
229
+ let n = try u32()
230
+ if n > Int64(max) { throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "\(field) length \(n) > \(max)") }
231
+ return try fixed(Int(n))
232
+ }
233
+ mutating func str(_ field: String = "string") throws -> String {
234
+ let raw = try lenBytes(maxStringFieldBytes, field)
235
+ guard let s = String(bytes: raw, encoding: .utf8) else {
236
+ throw OtaVerifyError(code: .INVALID_SIGNED_PAYLOAD, message: "\(field): invalid UTF-8")
237
+ }
238
+ return s
239
+ }
240
+ func remaining() -> Int { b.count - pos }
241
+ }
242
+ }
@@ -141,6 +141,32 @@ class OtaSlotManager {
141
141
  try? FileManager.default.removeItem(at: bootMarkerFile)
142
142
  NSLog("[ScaleBunOta] Boot marker cleared — bundle marked healthy")
143
143
  }
144
+ // A bundle is only trusted for anti-downgrade once it has run healthily.
145
+ confirmCurrentSequence()
146
+ }
147
+
148
+ private var sequenceFile: URL { otaRoot.appendingPathComponent("highest_sequence") }
149
+
150
+ /// Highest CONFIRMED release sequence — the anti-downgrade floor (docs §15).
151
+ /// Raised only at markHealthy, so a failed/rolled-back higher sequence never
152
+ /// blocks a legitimate re-install.
153
+ func highestConfirmedSequence() -> Int64 {
154
+ guard let s = try? String(contentsOf: sequenceFile, encoding: .utf8),
155
+ let v = Int64(s.trimmingCharacters(in: .whitespacesAndNewlines)) else { return 0 }
156
+ return v
157
+ }
158
+
159
+ private func confirmCurrentSequence() {
160
+ guard let meta = readMeta(slot: currentSlot) else { return }
161
+ let seq: Int64
162
+ if let n = meta["releaseSequence"] as? Int64 { seq = n }
163
+ else if let n = meta["releaseSequence"] as? Int { seq = Int64(n) }
164
+ else if let n = meta["releaseSequence"] as? NSNumber { seq = n.int64Value }
165
+ else { return }
166
+ if seq > highestConfirmedSequence() {
167
+ try? FileManager.default.createDirectory(at: otaRoot, withIntermediateDirectories: true)
168
+ try? String(seq).write(to: sequenceFile, atomically: true, encoding: .utf8)
169
+ }
144
170
  }
145
171
 
146
172
  /// Revert to previous slot.
@@ -18,6 +18,7 @@
18
18
 
19
19
  RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(getActiveBundle)
20
20
  RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(getSlotState)
21
+ RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(getTrustedKeyIds)
21
22
 
22
23
  RCT_EXTERN_METHOD(stageBundle:(NSString *)sourcePath
23
24
  sha256:(NSString *)sha256
@@ -40,9 +41,11 @@ RCT_EXTERN_METHOD(revertToPrevious:(RCTPromiseResolveBlock)resolve
40
41
 
41
42
  RCT_EXTERN_METHOD(restartApp)
42
43
 
43
- RCT_EXTERN_METHOD(verifyEd25519:(NSString *)messageHex
44
- signatureHex:(NSString *)signatureHex
45
- publicKeyHex:(NSString *)publicKeyHex
44
+ RCT_EXTERN_METHOD(verifyRelease:(NSString *)envelopeJson
45
+ resolve:(RCTPromiseResolveBlock)resolve
46
+ reject:(RCTPromiseRejectBlock)reject)
47
+
48
+ RCT_EXTERN_METHOD(verifyAndStageRelease:(NSString *)envelopeJson
46
49
  resolve:(RCTPromiseResolveBlock)resolve
47
50
  reject:(RCTPromiseRejectBlock)reject)
48
51
 
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+ import Security
3
+ import CommonCrypto
4
+
5
+ /**
6
+ * Native trusted-key registry (docs §9) for iOS. Trusted RSA public keys are
7
+ * EMBEDDED in the app bundle (resource `ScaleBunOtaKeys.json`), never fetched
8
+ * over the OTA channel:
9
+ *
10
+ * ```json
11
+ * { "projectId": "proj_abc",
12
+ * "keys": [ { "keyId": "production-2026-01", "publicKey": "-----BEGIN PUBLIC KEY----- …" } ] }
13
+ * ```
14
+ *
15
+ * `SecKeyCreateWithData` expects the PKCS#1 `RSAPublicKey` body, NOT the X.509
16
+ * SPKI wrapper (PROMPT §11.1). We therefore unwrap the SPKI to its inner PKCS#1
17
+ * DER with a minimal, bounded ASN.1 reader — no OpenSSL/SwiftyRSA. Keys are
18
+ * validated as RSA ≥ 2048 bits via `SecKeyCopyAttributes`. The fingerprint is
19
+ * SHA-256 over the full SPKI DER, matching the backend + Android.
20
+ */
21
+ struct TrustedKey {
22
+ let keyId: String
23
+ let secKey: SecKey
24
+ let fingerprint: String
25
+ }
26
+
27
+ final class ScaleBunOtaKeyRegistry {
28
+ let projectId: String
29
+ private let byKeyId: [String: TrustedKey]
30
+
31
+ private init(projectId: String, byKeyId: [String: TrustedKey]) {
32
+ self.projectId = projectId
33
+ self.byKeyId = byKeyId
34
+ }
35
+
36
+ func get(_ keyId: String) -> TrustedKey? { byKeyId[keyId] }
37
+ var isEmpty: Bool { byKeyId.isEmpty }
38
+ func keyIds() -> [String] { Array(byKeyId.keys) }
39
+
40
+ static let defaultResource = "ScaleBunOtaKeys"
41
+
42
+ /// Load from the app bundle. Empty registry if the resource is absent →
43
+ /// verification fails closed (UNKNOWN_KEY_ID), never open.
44
+ static func fromBundle(_ bundle: Bundle = .main, resource: String = defaultResource) -> ScaleBunOtaKeyRegistry {
45
+ guard let url = bundle.url(forResource: resource, withExtension: "json"),
46
+ let data = try? Data(contentsOf: url),
47
+ let json = String(data: data, encoding: .utf8) else {
48
+ return ScaleBunOtaKeyRegistry(projectId: "", byKeyId: [:])
49
+ }
50
+ return (try? fromJson(json)) ?? ScaleBunOtaKeyRegistry(projectId: "", byKeyId: [:])
51
+ }
52
+
53
+ static func fromJson(_ json: String) throws -> ScaleBunOtaKeyRegistry {
54
+ guard let data = json.data(using: .utf8),
55
+ let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
56
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "trusted-key config is not an object")
57
+ }
58
+ let projectId = (root["projectId"] as? String) ?? ""
59
+ guard let arr = root["keys"] as? [[String: Any]] else {
60
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "trusted-key config has no `keys` array")
61
+ }
62
+ var map: [String: TrustedKey] = [:]
63
+ for entry in arr {
64
+ let keyId = ((entry["keyId"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
65
+ let pem = ((entry["publicKey"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
66
+ if keyId.isEmpty || pem.isEmpty {
67
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "key entry missing keyId/publicKey")
68
+ }
69
+ if map[keyId] != nil {
70
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "duplicate keyId \(keyId)")
71
+ }
72
+ map[keyId] = try parseKey(keyId: keyId, pem: pem)
73
+ }
74
+ return ScaleBunOtaKeyRegistry(projectId: projectId, byKeyId: map)
75
+ }
76
+
77
+ private static func parseKey(keyId: String, pem: String) throws -> TrustedKey {
78
+ let b64 = pem
79
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
80
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
81
+ .replacingOccurrences(of: "\n", with: "")
82
+ .replacingOccurrences(of: "\r", with: "")
83
+ .replacingOccurrences(of: " ", with: "")
84
+ guard let spkiDer = Data(base64Encoded: b64) else {
85
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "key \(keyId): bad base64")
86
+ }
87
+ let pkcs1 = try Self.spkiToPkcs1([UInt8](spkiDer), keyId: keyId)
88
+
89
+ let attrs: [String: Any] = [
90
+ kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
91
+ kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
92
+ ]
93
+ var error: Unmanaged<CFError>?
94
+ guard let secKey = SecKeyCreateWithData(Data(pkcs1) as CFData, attrs as CFDictionary, &error) else {
95
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "key \(keyId): SecKeyCreateWithData failed")
96
+ }
97
+ // Validate RSA size via SecKeyCopyAttributes.
98
+ guard let ka = SecKeyCopyAttributes(secKey) as? [String: Any],
99
+ let bits = ka[kSecAttrKeySizeInBits as String] as? Int, bits >= 2048 else {
100
+ throw OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "key \(keyId): RSA modulus < 2048 bits")
101
+ }
102
+
103
+ var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
104
+ [UInt8](spkiDer).withUnsafeBytes { _ = CC_SHA256($0.baseAddress, CC_LONG(spkiDer.count), &digest) }
105
+ return TrustedKey(keyId: keyId, secKey: secKey, fingerprint: OtaProtocol.toHex(digest))
106
+ }
107
+
108
+ // MARK: - Minimal bounded ASN.1: SPKI → inner PKCS#1 RSAPublicKey DER
109
+
110
+ /// SPKI = SEQ { AlgorithmIdentifier SEQ, BIT STRING { 0x00, RSAPublicKey } }.
111
+ /// Returns the RSAPublicKey PKCS#1 DER (what SecKeyCreateWithData wants).
112
+ private static func spkiToPkcs1(_ der: [UInt8], keyId: String) throws -> [UInt8] {
113
+ func fail(_ m: String) -> OtaVerifyError { OtaVerifyError(code: .INVALID_PUBLIC_KEY, message: "key \(keyId): \(m)") }
114
+ var i = 0
115
+ func readLen() throws -> Int {
116
+ guard i < der.count else { throw fail("truncated length") }
117
+ let first = Int(der[i]); i += 1
118
+ if first < 0x80 { return first }
119
+ let n = first & 0x7f
120
+ if n == 0 || n > 4 { throw fail("bad length form") }
121
+ guard i + n <= der.count else { throw fail("truncated long length") }
122
+ var len = 0
123
+ for _ in 0..<n { len = (len << 8) | Int(der[i]); i += 1 }
124
+ return len
125
+ }
126
+ func expectTag(_ tag: UInt8) throws {
127
+ guard i < der.count else { throw fail("truncated tag") }
128
+ guard der[i] == tag else { throw fail("unexpected tag") }
129
+ i += 1
130
+ }
131
+ // outer SEQUENCE
132
+ try expectTag(0x30); _ = try readLen()
133
+ // AlgorithmIdentifier SEQUENCE — skip its whole body
134
+ try expectTag(0x30)
135
+ let algLen = try readLen()
136
+ guard i + algLen <= der.count else { throw fail("truncated alg id") }
137
+ i += algLen
138
+ // BIT STRING
139
+ try expectTag(0x03)
140
+ let bitLen = try readLen()
141
+ guard bitLen >= 1, i + bitLen <= der.count else { throw fail("truncated bit string") }
142
+ guard der[i] == 0x00 else { throw fail("nonzero unused bits") }
143
+ let start = i + 1
144
+ let end = i + bitLen
145
+ return Array(der[start..<end])
146
+ }
147
+ }