From 5c9d90da2bcc032ac067e187c192ca039b9f0392 Mon Sep 17 00:00:00 2001 From: johnathan White Date: Tue, 16 Dec 2025 20:29:40 -0500 Subject: [PATCH] added bundle integrity check --- android/app/build.gradle | 29 +++++++ .../bitpay/wallet/BundleIntegrityVerifier.kt | 79 +++++++++++++++++++ .../java/com/bitpay/wallet/MainActivity.kt | 7 ++ ios/BitPayApp/AppDelegate.swift | 65 +++++++++++++++ ios/BitPayApp/Info.plist | 2 + ios/Podfile | 36 +++++++++ ios/scripts/inject-bundle-hash.sh | 29 +++++++ 7 files changed, 247 insertions(+) create mode 100644 android/app/src/main/java/com/bitpay/wallet/BundleIntegrityVerifier.kt create mode 100755 ios/scripts/inject-bundle-hash.sh diff --git a/android/app/build.gradle b/android/app/build.gradle index a54df57866..575e6c5cd9 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 0000000000..3f84a8a911 --- /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 0b001500ff..ab6793ee5d 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 07193b9cdd..0254a79a08 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 8d1c9e2a4f..1b23df94ac 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 633a41c45e..44e80ddc95 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 0000000000..e4c9c80c51 --- /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