@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.
- package/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
- package/dist/scalebun.full.js +964 -533
- package/dist/scalebun.slim.js +962 -531
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +19 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +23 -57
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +19 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +175 -80
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +23 -57
- package/lib/typescript/core/config/schema.d.ts +2 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +21 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +190 -90
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +23 -69
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +35 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- 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(
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|