react-native-instantpay-code-push 1.1.0
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/InstantpayCodePush.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +158 -0
- package/android/build.gradle +91 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/instantpaycodepush/BundleFileStorageService.kt +835 -0
- package/android/src/main/java/com/instantpaycodepush/BundleMetadata.kt +249 -0
- package/android/src/main/java/com/instantpaycodepush/CommonHelper.kt +39 -0
- package/android/src/main/java/com/instantpaycodepush/DecompressService.kt +85 -0
- package/android/src/main/java/com/instantpaycodepush/DecompressionStrategy.kt +24 -0
- package/android/src/main/java/com/instantpaycodepush/FileManagerService.kt +105 -0
- package/android/src/main/java/com/instantpaycodepush/HashUtils.kt +50 -0
- package/android/src/main/java/com/instantpaycodepush/InstantpayCodePushModule.kt +182 -0
- package/android/src/main/java/com/instantpaycodepush/InstantpayCodePushPackage.kt +33 -0
- package/android/src/main/java/com/instantpaycodepush/IpayCodePush.kt +101 -0
- package/android/src/main/java/com/instantpaycodepush/IpayCodePushException.kt +135 -0
- package/android/src/main/java/com/instantpaycodepush/IpayCodePushImpl.kt +329 -0
- package/android/src/main/java/com/instantpaycodepush/OkHttpDownloadService.kt +283 -0
- package/android/src/main/java/com/instantpaycodepush/ReactIntegrationManager.kt +141 -0
- package/android/src/main/java/com/instantpaycodepush/ReactIntegrationManagerBase.kt +35 -0
- package/android/src/main/java/com/instantpaycodepush/SignatureVerifier.kt +354 -0
- package/android/src/main/java/com/instantpaycodepush/VersionedPreferencesService.kt +70 -0
- package/android/src/main/java/com/instantpaycodepush/ZipDecompressionStrategy.kt +198 -0
- package/ios/InstantpayCodePush.h +5 -0
- package/ios/InstantpayCodePush.mm +21 -0
- package/lib/module/DefaultResolver.js +34 -0
- package/lib/module/DefaultResolver.js.map +1 -0
- package/lib/module/NativeInstantpayCodePush.js +5 -0
- package/lib/module/NativeInstantpayCodePush.js.map +1 -0
- package/lib/module/checkForUpdate.js +68 -0
- package/lib/module/checkForUpdate.js.map +1 -0
- package/lib/module/error.js +137 -0
- package/lib/module/error.js.map +1 -0
- package/lib/module/fetchUpdateInfo.js +36 -0
- package/lib/module/fetchUpdateInfo.js.map +1 -0
- package/lib/module/global.d.js +8 -0
- package/lib/module/global.d.js.map +1 -0
- package/lib/module/hooks/useEventCallback.js +13 -0
- package/lib/module/hooks/useEventCallback.js.map +1 -0
- package/lib/module/index.js +291 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native.js +233 -0
- package/lib/module/native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/store.js +53 -0
- package/lib/module/store.js.map +1 -0
- package/lib/module/types.js +62 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/wrap.js +171 -0
- package/lib/module/wrap.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/DefaultResolver.d.ts +10 -0
- package/lib/typescript/src/DefaultResolver.d.ts.map +1 -0
- package/lib/typescript/src/NativeInstantpayCodePush.d.ts +100 -0
- package/lib/typescript/src/NativeInstantpayCodePush.d.ts.map +1 -0
- package/lib/typescript/src/checkForUpdate.d.ts +29 -0
- package/lib/typescript/src/checkForUpdate.d.ts.map +1 -0
- package/lib/typescript/src/error.d.ts +124 -0
- package/lib/typescript/src/error.d.ts.map +1 -0
- package/lib/typescript/src/fetchUpdateInfo.d.ts +8 -0
- package/lib/typescript/src/fetchUpdateInfo.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useEventCallback.d.ts +5 -0
- package/lib/typescript/src/hooks/useEventCallback.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +203 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/native.d.ts +128 -0
- package/lib/typescript/src/native.d.ts.map +1 -0
- package/lib/typescript/src/store.d.ts +11 -0
- package/lib/typescript/src/store.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +174 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/wrap.d.ts +179 -0
- package/lib/typescript/src/wrap.d.ts.map +1 -0
- package/package.json +174 -0
- package/src/DefaultResolver.ts +36 -0
- package/src/NativeInstantpayCodePush.ts +111 -0
- package/src/checkForUpdate.ts +122 -0
- package/src/error.ts +159 -0
- package/src/fetchUpdateInfo.ts +47 -0
- package/src/global.d.ts +23 -0
- package/src/hooks/useEventCallback.ts +30 -0
- package/src/index.tsx +379 -0
- package/src/native.ts +280 -0
- package/src/store.ts +69 -0
- package/src/types.ts +227 -0
- package/src/wrap.tsx +384 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
package com.instantpaycodepush
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.facebook.react.ReactApplication
|
|
6
|
+
import com.facebook.react.ReactHost
|
|
7
|
+
import com.facebook.react.ReactInstanceEventListener
|
|
8
|
+
import com.facebook.react.bridge.JSBundleLoader
|
|
9
|
+
import com.facebook.react.bridge.ReactContext
|
|
10
|
+
import com.facebook.react.common.LifecycleState
|
|
11
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
12
|
+
import java.lang.reflect.Field
|
|
13
|
+
import kotlin.coroutines.resume
|
|
14
|
+
|
|
15
|
+
class ReactIntegrationManager(
|
|
16
|
+
context: Context,
|
|
17
|
+
) : ReactIntegrationManagerBase(context) {
|
|
18
|
+
|
|
19
|
+
companion object {
|
|
20
|
+
private const val CLASS_TAG = "*ReactIntegrationManager"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public fun setJSBundle(
|
|
24
|
+
application: ReactApplication,
|
|
25
|
+
bundleURL: String,
|
|
26
|
+
) {
|
|
27
|
+
try {
|
|
28
|
+
val reactHost = application.reactHost
|
|
29
|
+
check(reactHost != null)
|
|
30
|
+
|
|
31
|
+
// Try both Java and Kotlin field names for compatibility
|
|
32
|
+
val reactHostDelegateField =
|
|
33
|
+
try {
|
|
34
|
+
reactHost::class.java.getDeclaredField("mReactHostDelegate")
|
|
35
|
+
} catch (e: NoSuchFieldException) {
|
|
36
|
+
try {
|
|
37
|
+
reactHost::class.java.getDeclaredField("reactHostDelegate")
|
|
38
|
+
} catch (e2: NoSuchFieldException) {
|
|
39
|
+
throw RuntimeException("Neither mReactHostDelegate nor reactHostDelegate field found", e2)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
reactHostDelegateField.isAccessible = true
|
|
44
|
+
val reactHostDelegate =
|
|
45
|
+
reactHostDelegateField.get(
|
|
46
|
+
reactHost,
|
|
47
|
+
)
|
|
48
|
+
val jsBundleLoaderField = reactHostDelegate::class.java.getDeclaredField("jsBundleLoader")
|
|
49
|
+
jsBundleLoaderField.isAccessible = true
|
|
50
|
+
jsBundleLoaderField.set(reactHostDelegate, getJSBundlerLoader(bundleURL))
|
|
51
|
+
} catch (e: Exception) {
|
|
52
|
+
try {
|
|
53
|
+
// Fallback to old architecture if ReactHost is not available
|
|
54
|
+
@Suppress("DEPRECATION")
|
|
55
|
+
val instanceManager = application.reactNativeHost.reactInstanceManager
|
|
56
|
+
val bundleLoader: JSBundleLoader? = this.getJSBundlerLoader(bundleURL)
|
|
57
|
+
val bundleLoaderField: Field =
|
|
58
|
+
instanceManager::class.java.getDeclaredField("mBundleLoader")
|
|
59
|
+
bundleLoaderField.isAccessible = true
|
|
60
|
+
|
|
61
|
+
if (bundleLoader != null) {
|
|
62
|
+
bundleLoaderField.set(instanceManager, bundleLoader)
|
|
63
|
+
} else {
|
|
64
|
+
bundleLoaderField.set(instanceManager, null)
|
|
65
|
+
}
|
|
66
|
+
} catch (e: Exception) {
|
|
67
|
+
CommonHelper.logPrint(CLASS_TAG, "Failed to setJSBundle (fallback): ${e.message}")
|
|
68
|
+
}
|
|
69
|
+
CommonHelper.logPrint(CLASS_TAG, "Failed to setJSBundle: ${e.message}")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reload the React Native application, ensuring ReactContext is initialized first.
|
|
75
|
+
* Caller should run this on main thread.
|
|
76
|
+
*/
|
|
77
|
+
public suspend fun reload(application: ReactApplication) {
|
|
78
|
+
try {
|
|
79
|
+
val reactHost = application.reactHost
|
|
80
|
+
if (reactHost != null) {
|
|
81
|
+
// Ensure initialized; if not, start and wait
|
|
82
|
+
waitForReactContextInitialized(reactHost)
|
|
83
|
+
|
|
84
|
+
val activity = reactHost.currentReactContext?.currentActivity
|
|
85
|
+
if (reactHost.lifecycleState != LifecycleState.RESUMED && activity != null) {
|
|
86
|
+
reactHost.onHostResume(activity)
|
|
87
|
+
}
|
|
88
|
+
reactHost.reload("Requested by IpayCodePush")
|
|
89
|
+
} else {
|
|
90
|
+
// Fallback to old architecture if ReactHost is not available
|
|
91
|
+
@Suppress("DEPRECATION")
|
|
92
|
+
val reactNativeHost = application.reactNativeHost
|
|
93
|
+
try {
|
|
94
|
+
reactNativeHost.reactInstanceManager.recreateReactContextInBackground()
|
|
95
|
+
} catch (e: Exception) {
|
|
96
|
+
val currentActivity = reactNativeHost.reactInstanceManager.currentReactContext?.currentActivity
|
|
97
|
+
if (currentActivity == null) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
currentActivity.runOnUiThread {
|
|
102
|
+
currentActivity.recreate()
|
|
103
|
+
}
|
|
104
|
+
} catch (e: Exception) {
|
|
105
|
+
CommonHelper.logPrint(CLASS_TAG, "Failed to reload: ${e.message}")
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (e: Exception) {
|
|
109
|
+
CommonHelper.logPrint(CLASS_TAG, "Failed to reloads: ${e.message}")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Waits until ReactContext is initialized.
|
|
115
|
+
* @return true if ReactContext was already initialized; false if we waited for it.
|
|
116
|
+
*/
|
|
117
|
+
suspend fun waitForReactContextInitialized(reactHost: ReactHost): Boolean {
|
|
118
|
+
return try {
|
|
119
|
+
// If already initialized, return immediately
|
|
120
|
+
if (reactHost.currentReactContext != null) return true
|
|
121
|
+
|
|
122
|
+
// Wait for initialization; MainApplication handles starting the host
|
|
123
|
+
suspendCancellableCoroutine { continuation ->
|
|
124
|
+
val listener =
|
|
125
|
+
object : ReactInstanceEventListener {
|
|
126
|
+
override fun onReactContextInitialized(context: ReactContext) {
|
|
127
|
+
reactHost.removeReactInstanceEventListener(this)
|
|
128
|
+
if (continuation.isActive) continuation.resume(Unit)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
reactHost.addReactInstanceEventListener(listener)
|
|
133
|
+
continuation.invokeOnCancellation { reactHost.removeReactInstanceEventListener(listener) }
|
|
134
|
+
}
|
|
135
|
+
false
|
|
136
|
+
} catch (e: Exception) {
|
|
137
|
+
CommonHelper.logPrint(CLASS_TAG, "waitForReactContextInitialized failed: ${e.message}")
|
|
138
|
+
true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package com.instantpaycodepush
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import com.facebook.react.ReactApplication
|
|
6
|
+
import com.facebook.react.bridge.JSBundleLoader
|
|
7
|
+
|
|
8
|
+
open class ReactIntegrationManagerBase(
|
|
9
|
+
private val context: Context,
|
|
10
|
+
) {
|
|
11
|
+
|
|
12
|
+
fun getJSBundlerLoader(bundleFileUrl: String): JSBundleLoader? {
|
|
13
|
+
val bundleLoader: JSBundleLoader?
|
|
14
|
+
|
|
15
|
+
if (bundleFileUrl.lowercase().startsWith("assets://")) {
|
|
16
|
+
bundleLoader =
|
|
17
|
+
JSBundleLoader.createAssetLoader(
|
|
18
|
+
context,
|
|
19
|
+
bundleFileUrl,
|
|
20
|
+
false,
|
|
21
|
+
)
|
|
22
|
+
} else {
|
|
23
|
+
bundleLoader = JSBundleLoader.createFileLoader(bundleFileUrl)
|
|
24
|
+
}
|
|
25
|
+
return bundleLoader
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public fun getReactApplication(application: Application?): ReactApplication {
|
|
29
|
+
if (application is ReactApplication) {
|
|
30
|
+
return application
|
|
31
|
+
} else {
|
|
32
|
+
throw IllegalArgumentException("Application does not implement ReactApplication")
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
package com.instantpaycodepush
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Base64
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import java.io.File
|
|
7
|
+
import java.security.KeyFactory
|
|
8
|
+
import java.security.PublicKey
|
|
9
|
+
import java.security.Signature
|
|
10
|
+
import java.security.spec.X509EncodedKeySpec
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Prefix for signed file hash format.
|
|
14
|
+
*/
|
|
15
|
+
private const val SIGNED_HASH_PREFIX = "sig:"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Custom exceptions for signature verification errors.
|
|
19
|
+
*
|
|
20
|
+
* **IMPORTANT**: The error messages in these exceptions are used by the JavaScript layer
|
|
21
|
+
* (`packages/react-native/src/types.ts`) to detect signature verification failures.
|
|
22
|
+
* If you change these messages, update `isSignatureVerificationError()` in types.ts accordingly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
sealed class SignatureVerificationException(
|
|
26
|
+
message: String,
|
|
27
|
+
) : Exception(message) {
|
|
28
|
+
class PublicKeyNotConfigured :
|
|
29
|
+
SignatureVerificationException(
|
|
30
|
+
"Public key not configured for signature verification. " +
|
|
31
|
+
"Add 'ipay_code_push_public_key' to res/values/strings.xml",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
class InvalidPublicKeyFormat :
|
|
35
|
+
SignatureVerificationException(
|
|
36
|
+
"Public key format is invalid. Ensure the public key is in PEM format (BEGIN PUBLIC KEY)",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
class MissingFileHash :
|
|
40
|
+
SignatureVerificationException(
|
|
41
|
+
"File hash is missing or empty. Ensure the bundle update includes a valid file hash",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
class InvalidSignatureFormat :
|
|
45
|
+
SignatureVerificationException(
|
|
46
|
+
"Signature format is invalid or corrupted. The signature data is malformed or cannot be decoded",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
class SignatureVerificationFailed :
|
|
50
|
+
SignatureVerificationException(
|
|
51
|
+
"Bundle signature verification failed. The bundle may be corrupted or tampered with",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
class FileHashMismatch :
|
|
55
|
+
SignatureVerificationException(
|
|
56
|
+
"File hash verification failed. The bundle file hash does not match the expected value. File may be corrupted",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
class FileReadFailed :
|
|
60
|
+
SignatureVerificationException(
|
|
61
|
+
"Failed to read file for verification. Could not read file for hash verification",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
class UnsignedNotAllowed :
|
|
65
|
+
SignatureVerificationException(
|
|
66
|
+
"Unsigned bundle not allowed when signing is enabled. " +
|
|
67
|
+
"Public key is configured but bundle is not signed. Rejecting update",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
class SecurityFrameworkError(
|
|
71
|
+
cause: Throwable,
|
|
72
|
+
) : SignatureVerificationException(
|
|
73
|
+
"Security framework error during verification: ${cause.message}",
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Service for verifying bundle integrity through hash or RSA-SHA256 signature verification.
|
|
80
|
+
* Uses Java Signature API for cryptographic operations.
|
|
81
|
+
*
|
|
82
|
+
* fileHash format:
|
|
83
|
+
* - Signed: `sig:<base64_signature>` - Verify signature (implicitly verifies hash)
|
|
84
|
+
* - Unsigned: `<hex_hash>` - Verify SHA256 hash only
|
|
85
|
+
*
|
|
86
|
+
* Security rules:
|
|
87
|
+
* - null/empty fileHash → REJECT
|
|
88
|
+
* - sig:... + public key configured → verify signature → Install/REJECT
|
|
89
|
+
* - sig:... + public key NOT configured → REJECT (can't verify)
|
|
90
|
+
* - <hash> + public key configured → REJECT (unsigned not allowed)
|
|
91
|
+
* - <hash> + public key NOT configured → verify hash → Install/REJECT
|
|
92
|
+
*/
|
|
93
|
+
object SignatureVerifier {
|
|
94
|
+
private const val CLASS_TAG = "*SignatureVerifier"
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reads public key from Android string resources.
|
|
98
|
+
* @param context Application context
|
|
99
|
+
* @return Public key PEM string or null if not configured
|
|
100
|
+
*/
|
|
101
|
+
private fun getPublicKeyFromConfig(context: Context): String? {
|
|
102
|
+
val resourceId =
|
|
103
|
+
context.resources.getIdentifier(
|
|
104
|
+
"ipay_code_push_public_key",
|
|
105
|
+
"string",
|
|
106
|
+
context.packageName,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (resourceId == 0) {
|
|
110
|
+
CommonHelper.logPrint(CLASS_TAG, "ipay_code_push_public_key not found in strings.xml")
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
val publicKeyPEM = context.getString(resourceId)
|
|
115
|
+
if (publicKeyPEM.isEmpty()) {
|
|
116
|
+
CommonHelper.logPrint(CLASS_TAG, "ipay_code_push_public_key is empty")
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return publicKeyPEM
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Checks if signing is enabled (public key is configured).
|
|
125
|
+
* @param context Application context
|
|
126
|
+
* @return true if public key is configured
|
|
127
|
+
*/
|
|
128
|
+
fun isSigningEnabled(context: Context): Boolean = getPublicKeyFromConfig(context) != null
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Checks if fileHash is in signed format (starts with "sig:").
|
|
132
|
+
* @param fileHash The file hash string to check
|
|
133
|
+
* @return true if signed format
|
|
134
|
+
*/
|
|
135
|
+
fun isSignedFormat(fileHash: String?): Boolean = fileHash?.startsWith(SIGNED_HASH_PREFIX) == true
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extracts signature from signed format fileHash.
|
|
139
|
+
* @param fileHash The signed file hash (sig:<signature>)
|
|
140
|
+
* @return Base64-encoded signature or null if not signed format
|
|
141
|
+
*/
|
|
142
|
+
fun extractSignature(fileHash: String?): String? {
|
|
143
|
+
if (!isSignedFormat(fileHash)) return null
|
|
144
|
+
return fileHash?.removePrefix(SIGNED_HASH_PREFIX)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Verifies bundle integrity based on fileHash format.
|
|
149
|
+
* Determines verification mode by checking for "sig:" prefix.
|
|
150
|
+
*
|
|
151
|
+
* @param context Application context
|
|
152
|
+
* @param bundleFile The bundle file to verify
|
|
153
|
+
* @param fileHash Combined hash string (sig:<signature> or <hex_hash>)
|
|
154
|
+
* @throws SignatureVerificationException if verification fails
|
|
155
|
+
*/
|
|
156
|
+
fun verifyBundle(
|
|
157
|
+
context: Context,
|
|
158
|
+
bundleFile: File,
|
|
159
|
+
fileHash: String?,
|
|
160
|
+
) {
|
|
161
|
+
val signingEnabled = isSigningEnabled(context)
|
|
162
|
+
|
|
163
|
+
// Rule: null/empty fileHash → REJECT
|
|
164
|
+
if (fileHash.isNullOrEmpty()) {
|
|
165
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "fileHash is null or empty. Rejecting update.")
|
|
166
|
+
throw SignatureVerificationException.MissingFileHash()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isSignedFormat(fileHash)) {
|
|
170
|
+
// Signed format: sig:<signature>
|
|
171
|
+
val signature = extractSignature(fileHash)
|
|
172
|
+
if (signature.isNullOrEmpty()) {
|
|
173
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Failed to extract signature from fileHash")
|
|
174
|
+
throw SignatureVerificationException.InvalidSignatureFormat()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Rule: sig:... + public key NOT configured → REJECT
|
|
178
|
+
if (!signingEnabled) {
|
|
179
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Signed bundle but public key not configured. Cannot verify.")
|
|
180
|
+
throw SignatureVerificationException.PublicKeyNotConfigured()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Rule: sig:... + public key configured → verify signature
|
|
184
|
+
verifySignature(context, bundleFile, signature)
|
|
185
|
+
} else {
|
|
186
|
+
// Unsigned format: <hex_hash>
|
|
187
|
+
|
|
188
|
+
// Rule: <hash> + public key configured → REJECT
|
|
189
|
+
if (signingEnabled) {
|
|
190
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Unsigned bundle not allowed when signing is enabled. Rejecting.")
|
|
191
|
+
throw SignatureVerificationException.UnsignedNotAllowed()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Rule: <hash> + public key NOT configured → verify hash
|
|
195
|
+
verifyHash(bundleFile, fileHash)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Verifies SHA256 hash of a file.
|
|
201
|
+
* @param bundleFile The file to verify
|
|
202
|
+
* @param expectedHash Expected SHA256 hash (hex string)
|
|
203
|
+
* @throws SignatureVerificationException.FileHashMismatch if verification fails
|
|
204
|
+
*/
|
|
205
|
+
fun verifyHash(
|
|
206
|
+
bundleFile: File,
|
|
207
|
+
expectedHash: String,
|
|
208
|
+
) {
|
|
209
|
+
CommonHelper.logPrint(CLASS_TAG, "Verifying hash for file: ${bundleFile.name}")
|
|
210
|
+
|
|
211
|
+
if (!HashUtils.verifyHash(bundleFile, expectedHash)) {
|
|
212
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Hash mismatch!")
|
|
213
|
+
throw SignatureVerificationException.FileHashMismatch()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
CommonHelper.logPrint(CLASS_TAG, "✅ Hash verified successfully")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Verifies RSA-SHA256 signature of a file.
|
|
221
|
+
* Calculates the file hash internally and verifies the signature.
|
|
222
|
+
*
|
|
223
|
+
* @param context Application context
|
|
224
|
+
* @param bundleFile The file to verify
|
|
225
|
+
* @param signatureBase64 Base64-encoded RSA-SHA256 signature
|
|
226
|
+
* @throws SignatureVerificationException if verification fails
|
|
227
|
+
*/
|
|
228
|
+
fun verifySignature(
|
|
229
|
+
context: Context,
|
|
230
|
+
bundleFile: File,
|
|
231
|
+
signatureBase64: String,
|
|
232
|
+
) {
|
|
233
|
+
CommonHelper.logPrint(CLASS_TAG, "Verifying signature for file: ${bundleFile.name}")
|
|
234
|
+
|
|
235
|
+
// Get public key from config
|
|
236
|
+
val publicKeyPEM =
|
|
237
|
+
getPublicKeyFromConfig(context)
|
|
238
|
+
?: run {
|
|
239
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Cannot verify signature: public key not configured in strings.xml")
|
|
240
|
+
throw SignatureVerificationException.PublicKeyNotConfigured()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
// Convert PEM to PublicKey
|
|
245
|
+
val publicKey = createPublicKey(publicKeyPEM)
|
|
246
|
+
|
|
247
|
+
// Calculate file hash
|
|
248
|
+
val fileHashHex =
|
|
249
|
+
HashUtils.calculateSHA256(bundleFile)
|
|
250
|
+
?: run {
|
|
251
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Failed to calculate file hash")
|
|
252
|
+
throw SignatureVerificationException.FileReadFailed()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
CommonHelper.logPrint(CLASS_TAG, "Calculated file hash: $fileHashHex")
|
|
256
|
+
|
|
257
|
+
// Decode signature from base64
|
|
258
|
+
val signatureBytes =
|
|
259
|
+
try {
|
|
260
|
+
Base64.decode(signatureBase64, Base64.DEFAULT)
|
|
261
|
+
} catch (e: Exception) {
|
|
262
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Failed to decode signature from base64 $e")
|
|
263
|
+
throw SignatureVerificationException.InvalidSignatureFormat()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Convert hex fileHash to bytes
|
|
267
|
+
val fileHashBytes = hexToByteArray(fileHashHex)
|
|
268
|
+
|
|
269
|
+
// Verify signature using RSA-SHA256
|
|
270
|
+
val verifier = Signature.getInstance("SHA256withRSA")
|
|
271
|
+
verifier.initVerify(publicKey)
|
|
272
|
+
verifier.update(fileHashBytes)
|
|
273
|
+
val isValid = verifier.verify(signatureBytes)
|
|
274
|
+
|
|
275
|
+
if (isValid) {
|
|
276
|
+
CommonHelper.logPrint(CLASS_TAG, "✅ Signature verified successfully")
|
|
277
|
+
} else {
|
|
278
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "❌ Signature verification failed")
|
|
279
|
+
throw SignatureVerificationException.SignatureVerificationFailed()
|
|
280
|
+
}
|
|
281
|
+
} catch (e: SignatureVerificationException) {
|
|
282
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "SignatureVerificationException : Signature verification error $e")
|
|
283
|
+
throw e
|
|
284
|
+
} catch (e: Exception) {
|
|
285
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Signature verification error $e")
|
|
286
|
+
throw SignatureVerificationException.SecurityFrameworkError(e)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Converts PEM-formatted public key to PublicKey.
|
|
292
|
+
* @param publicKeyPEM Public key in PEM format
|
|
293
|
+
* @return PublicKey instance
|
|
294
|
+
* @throws SignatureVerificationException.InvalidPublicKeyFormat if conversion fails
|
|
295
|
+
*/
|
|
296
|
+
private fun createPublicKey(publicKeyPEM: String): PublicKey {
|
|
297
|
+
try {
|
|
298
|
+
// Remove PEM headers/footers and whitespace
|
|
299
|
+
val publicKeyBase64 =
|
|
300
|
+
publicKeyPEM
|
|
301
|
+
.replace("-----BEGIN PUBLIC KEY-----", "")
|
|
302
|
+
.replace("-----END PUBLIC KEY-----", "")
|
|
303
|
+
.replace("\\n", "")
|
|
304
|
+
.replace("\n", "")
|
|
305
|
+
.replace("\r", "")
|
|
306
|
+
.replace(" ", "")
|
|
307
|
+
.trim()
|
|
308
|
+
|
|
309
|
+
// Decode base64
|
|
310
|
+
val keyBytes = Base64.decode(publicKeyBase64, Base64.DEFAULT)
|
|
311
|
+
|
|
312
|
+
// Create PublicKey from X.509 format (SubjectPublicKeyInfo)
|
|
313
|
+
val spec = X509EncodedKeySpec(keyBytes)
|
|
314
|
+
val keyFactory = KeyFactory.getInstance("RSA")
|
|
315
|
+
val publicKey = keyFactory.generatePublic(spec)
|
|
316
|
+
|
|
317
|
+
CommonHelper.logPrint(CLASS_TAG, "Public key loaded successfully")
|
|
318
|
+
return publicKey
|
|
319
|
+
} catch (e: Exception) {
|
|
320
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG, CLASS_TAG, "Failed to create public key $e")
|
|
321
|
+
throw SignatureVerificationException.InvalidPublicKeyFormat()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Converts hex string to ByteArray.
|
|
327
|
+
* @param hexString Hex-encoded string
|
|
328
|
+
* @return ByteArray
|
|
329
|
+
* @throws SignatureVerificationException.SignatureVerificationFailed if conversion fails
|
|
330
|
+
*/
|
|
331
|
+
private fun hexToByteArray(hexString: String): ByteArray {
|
|
332
|
+
try {
|
|
333
|
+
val len = hexString.length
|
|
334
|
+
if (len % 2 != 0) {
|
|
335
|
+
throw SignatureVerificationException.InvalidSignatureFormat()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
val data = ByteArray(len / 2)
|
|
339
|
+
var i = 0
|
|
340
|
+
while (i < len) {
|
|
341
|
+
data[i / 2] =
|
|
342
|
+
(
|
|
343
|
+
(Character.digit(hexString[i], 16) shl 4) +
|
|
344
|
+
Character.digit(hexString[i + 1], 16)
|
|
345
|
+
).toByte()
|
|
346
|
+
i += 2
|
|
347
|
+
}
|
|
348
|
+
return data
|
|
349
|
+
} catch (e: Exception) {
|
|
350
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG, CLASS_TAG, "Failed to convert hex to byte array $e")
|
|
351
|
+
throw SignatureVerificationException.InvalidSignatureFormat()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package com.instantpaycodepush
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import java.io.File
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Interface for preference storage operations
|
|
9
|
+
*/
|
|
10
|
+
interface PreferencesService {
|
|
11
|
+
/**
|
|
12
|
+
* Gets a stored preference value
|
|
13
|
+
* @param key The key to retrieve
|
|
14
|
+
* @return The stored value or null if not found
|
|
15
|
+
*/
|
|
16
|
+
fun getItem(key: String): String?
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sets a preference value
|
|
20
|
+
* @param key The key to store under
|
|
21
|
+
* @param value The value to store (or null to remove)
|
|
22
|
+
*/
|
|
23
|
+
fun setItem(
|
|
24
|
+
key: String,
|
|
25
|
+
value: String?,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Implementation of PreferencesService using SharedPreferences
|
|
31
|
+
* Modified from original HotUpdaterPrefs to follow the service pattern
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
class VersionedPreferencesService(
|
|
35
|
+
private val context: Context,
|
|
36
|
+
private val isolationKey: String,
|
|
37
|
+
) : PreferencesService {
|
|
38
|
+
|
|
39
|
+
private val prefs: SharedPreferences
|
|
40
|
+
|
|
41
|
+
init {
|
|
42
|
+
|
|
43
|
+
val sharedPrefsDir = File(context.applicationInfo.dataDir, "shared_prefs")
|
|
44
|
+
if (sharedPrefsDir.exists() && sharedPrefsDir.isDirectory) {
|
|
45
|
+
sharedPrefsDir.listFiles()?.forEach { file ->
|
|
46
|
+
if (file.name.startsWith("IpayCodePushPrefs_") && file.name != "$isolationKey.xml") {
|
|
47
|
+
file.delete()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
prefs = context.getSharedPreferences(isolationKey, Context.MODE_PRIVATE)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override fun getItem(key: String): String? = prefs.getString(key, null)
|
|
56
|
+
|
|
57
|
+
override fun setItem(
|
|
58
|
+
key: String,
|
|
59
|
+
value: String?,
|
|
60
|
+
) {
|
|
61
|
+
prefs.edit().apply {
|
|
62
|
+
if (value == null) {
|
|
63
|
+
remove(key)
|
|
64
|
+
} else {
|
|
65
|
+
putString(key, value)
|
|
66
|
+
}
|
|
67
|
+
apply()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|