diff --git a/android/app/build.gradle b/android/app/build.gradle
index a54df5786..575e6c5cd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -106,6 +106,9 @@ android {
versionCode 91212350
versionName "14.36.0"
missingDimensionStrategy 'react-native-camera', 'mlkit'
+
+ // Bundle integrity hash - injected during release build
+ buildConfigField "String", "RN_BUNDLE_HASH", "\"${findProperty('RN_BUNDLE_HASH') ?: ''}\""
}
splits {
abi {
@@ -182,3 +185,29 @@ dependencies {
}
apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'
+
+// Task to calculate and inject React Native bundle hash for integrity verification
+android.applicationVariants.all { variant ->
+ if (!variant.buildType.isDebuggable()) {
+ def variantName = variant.name.capitalize()
+
+ // Hook into the bundle task to calculate hash after bundling
+ tasks.matching { it.name == "createBundle${variantName}JsAndAssets" }.configureEach { bundleTask ->
+ bundleTask.doLast {
+ def bundleFile = file("${buildDir}/generated/assets/createBundle${variantName}JsAndAssets/index.android.bundle")
+ if (bundleFile.exists()) {
+ def digest = java.security.MessageDigest.getInstance("SHA-256")
+ bundleFile.eachByte(8192) { bytes, len ->
+ digest.update(bytes, 0, len)
+ }
+ def hash = digest.digest().encodeHex().toString()
+ println "Bundle hash for ${variantName}: ${hash}"
+
+ // Write hash to a properties file for the build to pick up
+ def propsFile = file("${buildDir}/bundle-hash.properties")
+ propsFile.text = "RN_BUNDLE_HASH=${hash}"
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/bitpay/wallet/BundleIntegrityVerifier.kt b/android/app/src/main/java/com/bitpay/wallet/BundleIntegrityVerifier.kt
new file mode 100644
index 000000000..3f84a8a91
--- /dev/null
+++ b/android/app/src/main/java/com/bitpay/wallet/BundleIntegrityVerifier.kt
@@ -0,0 +1,79 @@
+package com.bitpay.wallet
+
+import android.app.AlertDialog
+import android.content.Context
+import android.util.Log
+import java.io.InputStream
+import java.security.MessageDigest
+
+/**
+ * Verifies the integrity of the React Native JavaScript bundle to detect tampering.
+ * Calculates SHA256 hash of the bundle and compares against expected value.
+ */
+object BundleIntegrityVerifier {
+ private const val TAG = "BundleIntegrity"
+ private const val BUNDLE_ASSET_NAME = "index.android.bundle"
+
+ /**
+ * Verifies bundle integrity and returns true if valid.
+ * In debug builds, verification is skipped.
+ * If expected hash is not configured, logs a warning but allows launch.
+ */
+ fun verify(context: Context): Boolean {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "Skipping bundle verification in debug build")
+ return true
+ }
+
+ val expectedHash = BuildConfig.RN_BUNDLE_HASH
+ if (expectedHash.isNullOrEmpty()) {
+ Log.w(TAG, "Bundle hash verification not configured. Set RN_BUNDLE_HASH in build.gradle.")
+ return true
+ }
+
+ return try {
+ val computedHash = calculateBundleHash(context)
+ val isValid = computedHash.equals(expectedHash, ignoreCase = true)
+
+ if (!isValid) {
+ Log.e(TAG, "Bundle integrity check failed. Expected: $expectedHash, Got: $computedHash")
+ } else {
+ Log.d(TAG, "Bundle integrity verified successfully")
+ }
+
+ isValid
+ } catch (e: Exception) {
+ Log.e(TAG, "Error verifying bundle integrity: ${e.message}")
+ false
+ }
+ }
+
+ /**
+ * Shows a security alert and terminates the app.
+ */
+ fun showTamperedAlert(context: Context) {
+ AlertDialog.Builder(context)
+ .setTitle("Security Warning")
+ .setMessage("This application has been modified and cannot run. Please reinstall from Google Play.")
+ .setCancelable(false)
+ .setPositiveButton("Close") { _, _ ->
+ android.os.Process.killProcess(android.os.Process.myPid())
+ }
+ .show()
+ }
+
+ private fun calculateBundleHash(context: Context): String {
+ val inputStream: InputStream = context.assets.open(BUNDLE_ASSET_NAME)
+ val digest = MessageDigest.getInstance("SHA-256")
+
+ inputStream.use { stream ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ while (stream.read(buffer).also { bytesRead = it } != -1) {
+ digest.update(buffer, 0, bytesRead)
+ }
+ }
+
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt b/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt
index 0b001500f..ab6793ee5 100644
--- a/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt
+++ b/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt
@@ -31,6 +31,13 @@ class MainActivity : ReactActivity() {
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
override fun onCreate(savedInstanceState: Bundle?) {
+ // Verify bundle integrity before loading React Native
+ if (!BundleIntegrityVerifier.verify(this)) {
+ super.onCreate(savedInstanceState)
+ BundleIntegrityVerifier.showTamperedAlert(this)
+ return
+ }
+
RNBootSplash.init(this, R.style.BootTheme)
supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()
super.onCreate(savedInstanceState)
diff --git a/ios/BitPayApp/AppDelegate.swift b/ios/BitPayApp/AppDelegate.swift
index 07193b9cd..0254a79a0 100644
--- a/ios/BitPayApp/AppDelegate.swift
+++ b/ios/BitPayApp/AppDelegate.swift
@@ -7,6 +7,65 @@ import RNBootSplash
import BrazeKit
import BrazeUI
import UserNotifications
+import CommonCrypto
+
+// MARK: - Bundle Integrity Verification
+
+/// Calculates SHA256 hash of the React Native bundle to detect tampering
+private func verifyBundleIntegrity() -> Bool {
+#if DEBUG
+ // Skip verification in debug mode (bundle is served from Metro)
+ return true
+#else
+ guard let bundleURL = Bundle.main.url(forResource: "main", withExtension: "jsbundle"),
+ let bundleData = try? Data(contentsOf: bundleURL) else {
+ return false
+ }
+
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
+ bundleData.withUnsafeBytes { buffer in
+ _ = CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &hash)
+ }
+
+ let computedHash = hash.map { String(format: "%02x", $0) }.joined()
+
+ // Expected hash is injected during build process via BUNDLE_HASH build setting
+ // If not set, verification is skipped (for backwards compatibility during rollout)
+ guard let expectedHash = Bundle.main.object(forInfoDictionaryKey: "RNBundleHash") as? String,
+ !expectedHash.isEmpty else {
+ // No hash configured yet - log warning but allow launch
+ NSLog("[Security] Bundle hash verification not configured. Set RNBundleHash in Info.plist.")
+ return true
+ }
+
+ let isValid = computedHash.lowercased() == expectedHash.lowercased()
+ if !isValid {
+ NSLog("[Security] Bundle integrity check failed. Expected: %@, Got: %@", expectedHash, computedHash)
+ }
+ return isValid
+#endif
+}
+
+/// Shows an alert when bundle tampering is detected and terminates the app
+private func showBundleTamperedAlert() {
+ DispatchQueue.main.async {
+ let alert = UIAlertController(
+ title: "Security Warning",
+ message: "This application has been modified and cannot run. Please reinstall from the App Store.",
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "Close", style: .destructive) { _ in
+ exit(0)
+ })
+
+ // Create a temporary window to show the alert
+ let window = UIWindow(frame: UIScreen.main.bounds)
+ window.rootViewController = UIViewController()
+ window.windowLevel = .alert + 1
+ window.makeKeyAndVisible()
+ window.rootViewController?.present(alert, animated: true)
+ }
+}
// MARK: - React Native Factory Delegate
@@ -52,6 +111,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BrazeInAppMessageUIDelega
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
AppDelegate.shared = self
+ // Verify React Native bundle integrity to prevent tampering
+ if !verifyBundleIntegrity() {
+ showBundleTamperedAlert()
+ return false
+ }
+
// 1. React Native setup using factory
let rnDelegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: rnDelegate)
diff --git a/ios/BitPayApp/Info.plist b/ios/BitPayApp/Info.plist
index 8d1c9e2a4..1b23df94a 100644
--- a/ios/BitPayApp/Info.plist
+++ b/ios/BitPayApp/Info.plist
@@ -80,6 +80,8 @@
Tracking is used to identify source of traffic, optimize campaigns, and improve user experiences in the app.
RCTNewArchEnabled
+ RNBundleHash
+ $(RN_BUNDLE_HASH)
UIAppFonts
Archivo-Black.ttf
diff --git a/ios/Podfile b/ios/Podfile
index 633a41c45..44e80ddc9 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -86,5 +86,41 @@ target 'BitPayApp' do
# necessary for Mac Catalyst builds
:mac_catalyst_enabled => false
)
+
+ # Add bundle integrity verification build phase
+ add_bundle_hash_build_phase(project)
+ end
+end
+
+# Adds a build phase to inject the React Native bundle hash for integrity verification
+def add_bundle_hash_build_phase(project)
+ target = project.targets.find { |t| t.name == 'BitPayApp' }
+ return unless target
+
+ phase_name = 'Inject Bundle Hash'
+ script_path = '"${SRCROOT}/scripts/inject-bundle-hash.sh"'
+
+ # Check if phase already exists
+ existing_phase = target.shell_script_build_phases.find { |p| p.name == phase_name }
+ if existing_phase
+ puts "Build phase '#{phase_name}' already exists"
+ return
end
+
+ # Find the "Bundle React Native code and images" phase to insert after it
+ rn_bundle_phase = target.shell_script_build_phases.find { |p| p.name&.include?('Bundle React Native') }
+
+ # Create new build phase
+ phase = target.new_shell_script_build_phase(phase_name)
+ phase.shell_script = script_path
+ phase.show_env_vars_in_log = '0'
+
+ # Move the phase to run after the RN bundle phase
+ if rn_bundle_phase
+ rn_index = target.build_phases.index(rn_bundle_phase)
+ target.build_phases.move(phase, rn_index + 1)
+ end
+
+ project.save
+ puts "Added '#{phase_name}' build phase to BitPayApp target"
end
diff --git a/ios/scripts/inject-bundle-hash.sh b/ios/scripts/inject-bundle-hash.sh
new file mode 100755
index 000000000..e4c9c80c5
--- /dev/null
+++ b/ios/scripts/inject-bundle-hash.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# inject-bundle-hash.sh
+# Calculates SHA256 hash of the React Native bundle and injects it into the build settings
+# This script should be added as a Build Phase in Xcode, running after "Bundle React Native code and images"
+
+set -e
+
+BUNDLE_PATH="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/main.jsbundle"
+
+if [ ! -f "$BUNDLE_PATH" ]; then
+ echo "warning: Bundle not found at $BUNDLE_PATH - skipping hash injection"
+ exit 0
+fi
+
+# Calculate SHA256 hash of the bundle
+BUNDLE_HASH=$(shasum -a 256 "$BUNDLE_PATH" | awk '{print $1}')
+
+echo "Bundle hash: $BUNDLE_HASH"
+
+# Update the Info.plist in the built product with the hash
+PLIST_PATH="${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}"
+
+if [ -f "$PLIST_PATH" ]; then
+ /usr/libexec/PlistBuddy -c "Set :RNBundleHash $BUNDLE_HASH" "$PLIST_PATH" 2>/dev/null || \
+ /usr/libexec/PlistBuddy -c "Add :RNBundleHash string $BUNDLE_HASH" "$PLIST_PATH"
+ echo "Injected bundle hash into $PLIST_PATH"
+else
+ echo "warning: Info.plist not found at $PLIST_PATH"
+fi