Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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) }
}
}
7 changes: 7 additions & 0 deletions android/app/src/main/java/com/bitpay/wallet/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
65 changes: 65 additions & 0 deletions ios/BitPayApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions ios/BitPayApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
<string>Tracking is used to identify source of traffic, optimize campaigns, and improve user experiences in the app.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RNBundleHash</key>
<string>$(RN_BUNDLE_HASH)</string>
<key>UIAppFonts</key>
<array>
<string>Archivo-Black.ttf</string>
Expand Down
36 changes: 36 additions & 0 deletions ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions ios/scripts/inject-bundle-hash.sh
Original file line number Diff line number Diff line change
@@ -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