pulse-updates 1.3.4 → 1.3.5
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/src/main/java/app/pulse/updates/PulseController.kt +58 -0
- package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +6 -3
- package/ios/PulseUpdates/PulseController.swift +44 -0
- package/ios/PulseUpdates/PulseTypes.swift +17 -0
- package/ios/PulseUpdates/PulseUpdates.swift +5 -4
- package/package.json +1 -1
|
@@ -98,6 +98,8 @@ class PulseController private constructor() {
|
|
|
98
98
|
private var directory: File? = null
|
|
99
99
|
private var embeddedManifest: EmbeddedManifest? = null
|
|
100
100
|
private val executor = Executors.newSingleThreadExecutor()
|
|
101
|
+
@Volatile
|
|
102
|
+
private var nativeSignatureTrustRoot: PulseSignatureTrustRoot? = null
|
|
101
103
|
|
|
102
104
|
// Stable, anonymous per-install device id (persisted in the pulse dir). Lazily generated.
|
|
103
105
|
// Used to (a) bucket staged rollouts server-side via the Pulse-Device-Id header and
|
|
@@ -209,6 +211,48 @@ class PulseController private constructor() {
|
|
|
209
211
|
|
|
210
212
|
// MARK: - Public API
|
|
211
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Load only the signing trust root needed by JS config/decision verification.
|
|
216
|
+
*
|
|
217
|
+
* Debug React Native hosts intentionally bypass getBundleFile() and obtain JS
|
|
218
|
+
* from Metro. That means the normal launch initializer has not run when JS asks
|
|
219
|
+
* the native module to verify a signed config response. Keep this path deliberately
|
|
220
|
+
* smaller than initializeWithoutStarting(): it must not open the update database,
|
|
221
|
+
* seed/select a bundle, mark a launch, or start update/error-recovery machinery.
|
|
222
|
+
*/
|
|
223
|
+
@Synchronized
|
|
224
|
+
internal fun initializeSignatureTrustRoot(context: Context): PulseSignatureTrustRoot? {
|
|
225
|
+
config?.let { configured ->
|
|
226
|
+
// A partially supplied override is a configuration error. Do not quietly
|
|
227
|
+
// combine or replace one of its halves with values from another source.
|
|
228
|
+
if (configured.signingKeyId != null || configured.signingPublicKey != null) {
|
|
229
|
+
return PulseSignatureTrustRoot.from(
|
|
230
|
+
configured.signingKeyId,
|
|
231
|
+
configured.signingPublicKey
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
nativeSignatureTrustRoot?.let { return it }
|
|
236
|
+
|
|
237
|
+
val applicationContext = context.applicationContext
|
|
238
|
+
val loaded = try {
|
|
239
|
+
val appInfo = applicationContext.packageManager.getApplicationInfo(
|
|
240
|
+
applicationContext.packageName,
|
|
241
|
+
android.content.pm.PackageManager.GET_META_DATA
|
|
242
|
+
)
|
|
243
|
+
PulseSignatureTrustRoot.from(
|
|
244
|
+
appInfo.metaData?.getString("PulseUpdatesSigningKeyId"),
|
|
245
|
+
appInfo.metaData?.getString("PulseUpdatesSigningPublicKey")
|
|
246
|
+
)
|
|
247
|
+
} catch (error: Exception) {
|
|
248
|
+
pulseLogWarn(TAG, "Failed to load native signing trust root: ${error.message}")
|
|
249
|
+
null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
nativeSignatureTrustRoot = loaded
|
|
253
|
+
return loaded
|
|
254
|
+
}
|
|
255
|
+
|
|
212
256
|
/**
|
|
213
257
|
* Initialize context and directory without full config
|
|
214
258
|
* Called by getBundleFile before JS starts
|
|
@@ -1052,6 +1096,20 @@ data class PulseUpdatesConfig(
|
|
|
1052
1096
|
}
|
|
1053
1097
|
}
|
|
1054
1098
|
|
|
1099
|
+
/** Native-pinned Ed25519 verifier identity, intentionally separate from update launch config. */
|
|
1100
|
+
internal data class PulseSignatureTrustRoot(
|
|
1101
|
+
val keyId: String,
|
|
1102
|
+
val publicKey: String
|
|
1103
|
+
) {
|
|
1104
|
+
companion object {
|
|
1105
|
+
fun from(keyId: String?, publicKey: String?): PulseSignatureTrustRoot? {
|
|
1106
|
+
val normalizedKeyId = keyId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
|
1107
|
+
val normalizedPublicKey = publicKey?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
|
1108
|
+
return PulseSignatureTrustRoot(normalizedKeyId, normalizedPublicKey)
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1055
1113
|
// MARK: - Results
|
|
1056
1114
|
|
|
1057
1115
|
data class CheckResult(
|
|
@@ -75,13 +75,16 @@ class PulseUpdatesModule(private val reactContext: ReactApplicationContext) :
|
|
|
75
75
|
|
|
76
76
|
@ReactMethod
|
|
77
77
|
fun verifySignatureAsync(canonicalPayload: String, alg: String, keyId: String, signature: String, promise: Promise) {
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// Metro/debug hosts do not enter getBundleFile(), so load the native-pinned
|
|
79
|
+
// trust root without starting OTA launch selection before verifying config.
|
|
80
|
+
val trustRoot = PulseController.getInstance()
|
|
81
|
+
.initializeSignatureTrustRoot(reactContext.applicationContext)
|
|
82
|
+
if (alg.lowercase() != "ed25519" || trustRoot?.keyId != keyId) {
|
|
80
83
|
promise.resolve(false)
|
|
81
84
|
return
|
|
82
85
|
}
|
|
83
86
|
try {
|
|
84
|
-
val publicKeyBytes = android.util.Base64.decode(
|
|
87
|
+
val publicKeyBytes = android.util.Base64.decode(trustRoot.publicKey, android.util.Base64.DEFAULT)
|
|
85
88
|
val signatureBytes = android.util.Base64.decode(signature, android.util.Base64.DEFAULT)
|
|
86
89
|
val spec = net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable.getByName("Ed25519")
|
|
87
90
|
val publicKey = net.i2p.crypto.eddsa.EdDSAPublicKey(
|
|
@@ -146,6 +146,11 @@ public final class PulseController {
|
|
|
146
146
|
// Only valid for the immediate check->fetch sequence, cleared on use or new check
|
|
147
147
|
private var lastCheckManifest: PulseManifestModel?
|
|
148
148
|
|
|
149
|
+
// JS config verification can run in a DEBUG/Metro host before bundle selection.
|
|
150
|
+
// Cache its native-pinned trust root independently from the launch lifecycle.
|
|
151
|
+
private let signatureTrustRootLock = NSLock()
|
|
152
|
+
private var nativeSignatureTrustRoot: PulseSignatureTrustRoot?
|
|
153
|
+
|
|
149
154
|
// Flag to prevent concurrent fetch operations (with lock for thread safety)
|
|
150
155
|
private var isFetching = false
|
|
151
156
|
private var pendingFetchCallbacks: [(Result<PulseFetchResult, Error>) -> Void] = []
|
|
@@ -222,6 +227,45 @@ public final class PulseController {
|
|
|
222
227
|
|
|
223
228
|
// MARK: - Public API
|
|
224
229
|
|
|
230
|
+
/// Load only the signing trust root needed by JS config/decision verification.
|
|
231
|
+
///
|
|
232
|
+
/// DEBUG builds intentionally return nil from `getBundleURL()` so React Native
|
|
233
|
+
/// stays attached to Metro. This method therefore must not start Pulse, open its
|
|
234
|
+
/// database, seed/select a bundle, or install launch/error-recovery bookkeeping.
|
|
235
|
+
func initializeSignatureTrustRoot() -> PulseSignatureTrustRoot? {
|
|
236
|
+
if let configuredConfig = config,
|
|
237
|
+
(configuredConfig.signingKeyId != nil || configuredConfig.signingPublicKey != nil) {
|
|
238
|
+
// A partially supplied override is a configuration error. Do not quietly
|
|
239
|
+
// combine or replace one of its halves with values from another source.
|
|
240
|
+
return PulseSignatureTrustRoot(
|
|
241
|
+
keyId: configuredConfig.signingKeyId,
|
|
242
|
+
publicKey: configuredConfig.signingPublicKey
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
signatureTrustRootLock.lock()
|
|
247
|
+
defer { signatureTrustRootLock.unlock() }
|
|
248
|
+
|
|
249
|
+
if let configuredConfig = config,
|
|
250
|
+
(configuredConfig.signingKeyId != nil || configuredConfig.signingPublicKey != nil) {
|
|
251
|
+
return PulseSignatureTrustRoot(
|
|
252
|
+
keyId: configuredConfig.signingKeyId,
|
|
253
|
+
publicKey: configuredConfig.signingPublicKey
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
if let cached = nativeSignatureTrustRoot {
|
|
257
|
+
return cached
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let info = Bundle.main.infoDictionary
|
|
261
|
+
let loaded = PulseSignatureTrustRoot(
|
|
262
|
+
keyId: info?["PulseUpdatesSigningKeyId"] as? String,
|
|
263
|
+
publicKey: info?["PulseUpdatesSigningPublicKey"] as? String
|
|
264
|
+
)
|
|
265
|
+
nativeSignatureTrustRoot = loaded
|
|
266
|
+
return loaded
|
|
267
|
+
}
|
|
268
|
+
|
|
225
269
|
/// Configure the controller (call before start)
|
|
226
270
|
public func configure(_ config: PulseUpdatesConfig) {
|
|
227
271
|
self.config = config
|
|
@@ -56,6 +56,23 @@ public struct PulseUpdatesConfig {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/// Native-pinned Ed25519 verifier identity, separate from update launch state.
|
|
60
|
+
struct PulseSignatureTrustRoot {
|
|
61
|
+
let keyId: String
|
|
62
|
+
let publicKey: String
|
|
63
|
+
|
|
64
|
+
init?(keyId: String?, publicKey: String?) {
|
|
65
|
+
guard let keyId = keyId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
66
|
+
!keyId.isEmpty,
|
|
67
|
+
let publicKey = publicKey?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
68
|
+
!publicKey.isEmpty else {
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
self.keyId = keyId
|
|
72
|
+
self.publicKey = publicKey
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
59
76
|
// MARK: - Manifest Models
|
|
60
77
|
|
|
61
78
|
public struct PulseManifestModel: Codable {
|
|
@@ -69,11 +69,12 @@ public class PulseUpdates: RCTEventEmitter {
|
|
|
69
69
|
signature: String,
|
|
70
70
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
71
71
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
72
|
+
// DEBUG/Metro bypasses getBundleURL(), so obtain only the native-pinned
|
|
73
|
+
// trust root here without entering update database or bundle selection.
|
|
72
74
|
guard alg.lowercased() == "ed25519",
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
let
|
|
76
|
-
let keyData = Data(base64Encoded: keyBase64),
|
|
75
|
+
let trustRoot = PulseController.shared.initializeSignatureTrustRoot(),
|
|
76
|
+
trustRoot.keyId == keyId,
|
|
77
|
+
let keyData = Data(base64Encoded: trustRoot.publicKey),
|
|
77
78
|
let signatureData = Data(base64Encoded: signature),
|
|
78
79
|
let payloadData = canonicalPayload.data(using: .utf8),
|
|
79
80
|
let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: keyData) else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-updates",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "Pulse app-experience SDK for React Native: updates, config, experiments, events, decisions and first-party links",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|