pulse-updates 1.3.3 → 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.
@@ -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
- val config = PulseController.getInstance().config
79
- if (alg.lowercase() != "ed25519" || config?.signingKeyId != keyId || config.signingPublicKey == null) {
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(config.signingPublicKey, android.util.Base64.DEFAULT)
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 config = PulseController.shared.config,
74
- config.signingKeyId == keyId,
75
- let keyBase64 = config.signingPublicKey,
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",
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",
@@ -495,11 +495,18 @@ function parseArgs() {
495
495
  /**
496
496
  * Find Info.plist and return its path and content
497
497
  */
498
+ export function selectIOSInfoPlist(candidates) {
499
+ return candidates.find(({ content }) => /<key>PulseUpdatesURL<\/key>/.test(content)) ||
500
+ candidates[0] ||
501
+ null;
502
+ }
503
+
498
504
  function findInfoPlist() {
499
505
  const possiblePaths = [
500
506
  'ios/App/Info.plist',
501
507
  'ios/*/Info.plist',
502
508
  ];
509
+ const candidates = [];
503
510
 
504
511
  for (const pattern of possiblePaths) {
505
512
  if (pattern.includes('*')) {
@@ -512,25 +519,25 @@ function findInfoPlist() {
512
519
  const plistPath = path.join(baseDir, dir, 'Info.plist');
513
520
  if (fs.existsSync(plistPath)) {
514
521
  try {
515
- return {
522
+ candidates.push({
516
523
  path: plistPath,
517
524
  content: fs.readFileSync(plistPath, 'utf8'),
518
- };
525
+ });
519
526
  } catch {}
520
527
  }
521
528
  }
522
529
  }
523
530
  } else if (fs.existsSync(pattern)) {
524
531
  try {
525
- return {
532
+ candidates.push({
526
533
  path: pattern,
527
534
  content: fs.readFileSync(pattern, 'utf8'),
528
- };
535
+ });
529
536
  } catch {}
530
537
  }
531
538
  }
532
539
 
533
- return null;
540
+ return selectIOSInfoPlist(candidates);
534
541
  }
535
542
 
536
543
  /**