mirror of
https://github.com/zhigang1992/react-native-code-push.git
synced 2026-06-14 09:59:12 +08:00
Implement code signing for client android SDK (#966)
* Add new optional way to create CodePush instance based on builder pattern Add constructor with additional option PublicKeyFilePath * Adapt changes from old code-signing branch Add `com.auth0:java-jwt:3.2.0` to deps Adapt changes from code-signing branch Fix errors appeared due to jwt library update. * Non-breaking change of CodePush constructor, downgrade jwt library Replace publicKey by publicKeyResourceDescriptor in CodePush constructor Downgrade jwt library to 2.2.2 due to issue with base64 decoding * Make code signing optional * Add small improvements Replace CodePushUnknownException catch with CodePushInvalidPublicKeyException in certain places Make mPublicKey static Add additional log for applying updates * Rename method verifyJWT with verifyAndDecodeJWT * Add minor fixes Add additional checking for potential problems with code-signing integration Fix Public Key parsing from strings.xml * Fix constructors * Fix constructors bug * Fix log messages
This commit is contained in:
committed by
Sergey Akhalkov
parent
4ab0e5e9fe
commit
5e332bbe83
@@ -3,6 +3,8 @@ package com.microsoft.codepush.react;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Resources;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
import com.facebook.react.ReactPackage;
|
||||
@@ -15,6 +17,7 @@ import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.NotActiveException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -36,11 +39,13 @@ public class CodePush implements ReactPackage {
|
||||
|
||||
// Config properties.
|
||||
private String mDeploymentKey;
|
||||
private String mServerUrl = "https://codepush.azurewebsites.net/";
|
||||
private static String mServerUrl = "https://codepush.azurewebsites.net/";
|
||||
|
||||
private Context mContext;
|
||||
private final boolean mIsDebugMode;
|
||||
|
||||
private static String mPublicKey;
|
||||
|
||||
private static ReactInstanceHolder mReactInstanceHolder;
|
||||
private static CodePush mCurrentInstance;
|
||||
|
||||
@@ -48,6 +53,10 @@ public class CodePush implements ReactPackage {
|
||||
this(deploymentKey, context, false);
|
||||
}
|
||||
|
||||
public static String getServiceUrl() {
|
||||
return mServerUrl;
|
||||
}
|
||||
|
||||
public CodePush(String deploymentKey, Context context, boolean isDebugMode) {
|
||||
mContext = context.getApplicationContext();
|
||||
|
||||
@@ -72,11 +81,45 @@ public class CodePush implements ReactPackage {
|
||||
initializeUpdateAfterRestart();
|
||||
}
|
||||
|
||||
public CodePush(String deploymentKey, Context context, boolean isDebugMode, String serverUrl) {
|
||||
public CodePush(String deploymentKey, Context context, boolean isDebugMode, @NonNull String serverUrl) {
|
||||
this(deploymentKey, context, isDebugMode);
|
||||
mServerUrl = serverUrl;
|
||||
}
|
||||
|
||||
public CodePush(String deploymentKey, Context context, boolean isDebugMode, int publicKeyResourceDescriptor) {
|
||||
this(deploymentKey, context, isDebugMode);
|
||||
|
||||
mPublicKey = getPublicKeyByResourceDescriptor(publicKeyResourceDescriptor);
|
||||
}
|
||||
|
||||
public CodePush(String deploymentKey, Context context, boolean isDebugMode, @NonNull String serverUrl, Integer publicKeyResourceDescriptor) {
|
||||
this(deploymentKey, context, isDebugMode);
|
||||
|
||||
if (publicKeyResourceDescriptor != null) {
|
||||
mPublicKey = getPublicKeyByResourceDescriptor(publicKeyResourceDescriptor);
|
||||
}
|
||||
|
||||
mServerUrl = serverUrl;
|
||||
}
|
||||
|
||||
private String getPublicKeyByResourceDescriptor(int publicKeyResourceDescriptor){
|
||||
String publicKey;
|
||||
try {
|
||||
publicKey = mContext.getString(publicKeyResourceDescriptor);
|
||||
} catch (Resources.NotFoundException e) {
|
||||
throw new CodePushInvalidPublicKeyException(
|
||||
"Unable to get public key, related resource descriptor " +
|
||||
publicKeyResourceDescriptor +
|
||||
" can not be found", e
|
||||
);
|
||||
}
|
||||
|
||||
if (publicKey.isEmpty()) {
|
||||
throw new CodePushInvalidPublicKeyException("Specified public key is empty");
|
||||
}
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
public void clearDebugCacheIfNeeded() {
|
||||
if (mIsDebugMode && mSettingsManager.isPendingUpdate(null)) {
|
||||
// This needs to be kept in sync with https://github.com/facebook/react-native/blob/master/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManager.java#L78
|
||||
@@ -99,6 +142,10 @@ public class CodePush implements ReactPackage {
|
||||
return mAssetsBundleFileName;
|
||||
}
|
||||
|
||||
public String getPublicKey() {
|
||||
return mPublicKey;
|
||||
}
|
||||
|
||||
long getBinaryResourcesModifiedTime() {
|
||||
try {
|
||||
String packageName = this.mContext.getPackageName();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.microsoft.codepush.react;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
public class CodePushBuilder {
|
||||
private String mDeploymentKey;
|
||||
private Context mContext;
|
||||
|
||||
private boolean mIsDebugMode;
|
||||
private String mServerUrl;
|
||||
private Integer mPublicKeyResourceDescriptor;
|
||||
|
||||
public CodePushBuilder(String deploymentKey, Context context) {
|
||||
this.mDeploymentKey = deploymentKey;
|
||||
this.mContext = context;
|
||||
this.mServerUrl = CodePush.getServiceUrl();
|
||||
}
|
||||
|
||||
public CodePushBuilder setIsDebugMode(boolean isDebugMode) {
|
||||
this.mIsDebugMode = isDebugMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CodePushBuilder setServerUrl(String serverUrl) {
|
||||
this.mServerUrl = serverUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CodePushBuilder setPublicKeyResourceDescriptor(int publicKeyResourceDescriptor) {
|
||||
this.mPublicKeyResourceDescriptor = publicKeyResourceDescriptor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CodePush build() {
|
||||
return new CodePush(this.mDeploymentKey, this.mContext, this.mIsDebugMode, this.mServerUrl, this.mPublicKeyResourceDescriptor);
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,5 @@ public class CodePushConstants {
|
||||
public static final String STATUS_FILE = "codepush.json";
|
||||
public static final String UNZIPPED_FOLDER_NAME = "unzipped";
|
||||
public static final String CODE_PUSH_APK_BUILD_TIME_KEY = "CODE_PUSH_APK_BUILD_TIME";
|
||||
public static final String BUNDLE_JWT_FILE = ".codepushrelease";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.microsoft.codepush.react;
|
||||
|
||||
class CodePushInvalidPublicKeyException extends RuntimeException {
|
||||
|
||||
public CodePushInvalidPublicKeyException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public CodePushInvalidPublicKeyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ public class CodePushNativeModule extends ReactContextBaseJavaModule {
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.emit(CodePushConstants.DOWNLOAD_PROGRESS_EVENT_NAME, latestDownloadProgress.createWritableMap());
|
||||
}
|
||||
});
|
||||
}, mCodePush.getPublicKey());
|
||||
|
||||
JSONObject newPackage = mUpdateManager.getPackage(CodePushUtils.tryGetString(updatePackage, CodePushConstants.PACKAGE_HASH_KEY));
|
||||
promise.resolve(CodePushUtils.convertJsonObjectToWritable(newPackage));
|
||||
|
||||
@@ -55,7 +55,7 @@ public class CodePushUpdateManager {
|
||||
return CodePushUtils.getJsonObjectFromFile(statusFilePath);
|
||||
} catch (IOException e) {
|
||||
// Should not happen.
|
||||
throw new CodePushUnknownException("Error getting current package info" , e);
|
||||
throw new CodePushUnknownException("Error getting current package info", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ public class CodePushUpdateManager {
|
||||
CodePushUtils.writeJsonToFile(packageInfo, getStatusFilePath());
|
||||
} catch (IOException e) {
|
||||
// Should not happen.
|
||||
throw new CodePushUnknownException("Error updating current package info" , e);
|
||||
throw new CodePushUnknownException("Error updating current package info", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,16 +116,16 @@ public class CodePushUpdateManager {
|
||||
if (packageHash == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return getPackage(packageHash);
|
||||
}
|
||||
|
||||
|
||||
public JSONObject getPreviousPackage() {
|
||||
String packageHash = getPreviousPackageHash();
|
||||
if (packageHash == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return getPackage(packageHash);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,8 @@ public class CodePushUpdateManager {
|
||||
}
|
||||
|
||||
public void downloadPackage(JSONObject updatePackage, String expectedBundleFileName,
|
||||
DownloadProgressCallback progressCallback) throws IOException {
|
||||
DownloadProgressCallback progressCallback,
|
||||
String stringPublicKey) throws IOException {
|
||||
String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null);
|
||||
String newUpdateFolderPath = getPackageFolderPath(newUpdateHash);
|
||||
String newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME);
|
||||
@@ -179,7 +180,7 @@ public class CodePushUpdateManager {
|
||||
while ((numBytesRead = bin.read(data, 0, CodePushConstants.DOWNLOAD_BUFFER_SIZE)) >= 0) {
|
||||
if (receivedBytes < 4) {
|
||||
for (int i = 0; i < numBytesRead; i++) {
|
||||
int headerOffset = (int)(receivedBytes) + i;
|
||||
int headerOffset = (int) (receivedBytes) + i;
|
||||
if (headerOffset >= 4) {
|
||||
break;
|
||||
}
|
||||
@@ -244,7 +245,39 @@ public class CodePushUpdateManager {
|
||||
}
|
||||
|
||||
if (isDiffUpdate) {
|
||||
CodePushUpdateUtils.verifyHashForDiffUpdate(newUpdateFolderPath, newUpdateHash);
|
||||
CodePushUtils.log("Applying diff update.");
|
||||
} else {
|
||||
CodePushUtils.log("Applying full update.");
|
||||
}
|
||||
|
||||
boolean isSignatureVerificationEnabled = (stringPublicKey != null);
|
||||
|
||||
String signaturePath = CodePushUpdateUtils.getSignatureFilePath(newUpdateFolderPath);
|
||||
boolean isSignatureAppearedInBundle = FileUtils.fileAtPathExists(signaturePath);
|
||||
|
||||
if (isSignatureVerificationEnabled) {
|
||||
if (isSignatureAppearedInBundle) {
|
||||
CodePushUpdateUtils.verifySignature(newUpdateFolderPath, stringPublicKey);
|
||||
} else {
|
||||
throw new CodePushInvalidUpdateException(
|
||||
"Error! Public key was provided but there is no JWT signature within app bundle to verify. " +
|
||||
"Possible reasons, why that might happen: \n" +
|
||||
"1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" +
|
||||
"2. You've been released CodePush bundle update without providing --privateKeyPath option."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isSignatureAppearedInBundle) {
|
||||
CodePushUtils.log(
|
||||
"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " +
|
||||
"Please ensure that public key is properly configured within your application."
|
||||
);
|
||||
CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash);
|
||||
} else {
|
||||
if (isDiffUpdate) {
|
||||
CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.microsoft.codepush.react;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Base64;
|
||||
|
||||
import com.auth0.jwt.JWTVerifier;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
@@ -13,13 +16,33 @@ import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
public class CodePushUpdateUtils {
|
||||
|
||||
public static final String NEW_LINE = System.getProperty("line.separator");
|
||||
|
||||
// Note: The hashing logic here must mirror the hashing logic in other native SDK's, as well as in the
|
||||
// CLI. Ensure that any changes here are propagated to these other locations.
|
||||
public static boolean isHashIgnored(String relativeFilePath) {
|
||||
final String __MACOSX = "__MACOSX/";
|
||||
final String DS_STORE = ".DS_Store";
|
||||
final String CODEPUSH_METADATA = ".codepushrelease";
|
||||
|
||||
return relativeFilePath.startsWith(__MACOSX)
|
||||
|| relativeFilePath.equals(DS_STORE)
|
||||
|| relativeFilePath.endsWith("/" + DS_STORE)
|
||||
|| relativeFilePath.equals(CODEPUSH_METADATA)
|
||||
|| relativeFilePath.endsWith("/" + CODEPUSH_METADATA);
|
||||
}
|
||||
|
||||
private static void addContentsOfFolderToManifest(String folderPath, String pathPrefix, ArrayList<String> manifest) {
|
||||
File folder = new File(folderPath);
|
||||
File[] folderFiles = folder.listFiles();
|
||||
@@ -28,9 +51,11 @@ public class CodePushUpdateUtils {
|
||||
String fullFilePath = file.getAbsolutePath();
|
||||
String relativePath = (pathPrefix.isEmpty() ? "" : (pathPrefix + "/")) + fileName;
|
||||
|
||||
if (fileName.equals(".DS_Store") || fileName.equals("__MACOSX")) {
|
||||
if (CodePushUpdateUtils.isHashIgnored(relativePath)) {
|
||||
continue;
|
||||
} else if (file.isDirectory()) {
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
addContentsOfFolderToManifest(fullFilePath, relativePath, manifest);
|
||||
} else {
|
||||
try {
|
||||
@@ -50,7 +75,7 @@ public class CodePushUpdateUtils {
|
||||
messageDigest = MessageDigest.getInstance("SHA-256");
|
||||
digestInputStream = new DigestInputStream(dataStream, messageDigest);
|
||||
byte[] byteBuffer = new byte[1024 * 8];
|
||||
while (digestInputStream.read(byteBuffer) != -1);
|
||||
while (digestInputStream.read(byteBuffer) != -1) ;
|
||||
} catch (NoSuchAlgorithmException | IOException e) {
|
||||
// Should not happen.
|
||||
throw new CodePushUnknownException("Unable to compute hash of update contents.", e);
|
||||
@@ -67,7 +92,7 @@ public class CodePushUpdateUtils {
|
||||
return String.format("%064x", new java.math.BigInteger(1, hash));
|
||||
}
|
||||
|
||||
public static void copyNecessaryFilesFromCurrentPackage(String diffManifestFilePath, String currentPackageFolderPath, String newPackageFolderPath) throws IOException{
|
||||
public static void copyNecessaryFilesFromCurrentPackage(String diffManifestFilePath, String currentPackageFolderPath, String newPackageFolderPath) throws IOException {
|
||||
FileUtils.copyDirectoryContents(currentPackageFolderPath, newPackageFolderPath);
|
||||
JSONObject diffManifest = CodePushUtils.getJsonObjectFromFile(diffManifestFilePath);
|
||||
try {
|
||||
@@ -122,9 +147,15 @@ public class CodePushUpdateUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static void verifyHashForDiffUpdate(String folderPath, String expectedHash) {
|
||||
// Hashing algorithm:
|
||||
// 1. Recursively generate a sorted array of format <relativeFilePath>: <sha256FileHash>
|
||||
// 2. JSON stringify the array
|
||||
// 3. SHA256-hash the result
|
||||
public static void verifyFolderHash(String folderPath, String expectedHash) {
|
||||
CodePushUtils.log("Verifying hash for folder path: " + folderPath);
|
||||
ArrayList<String> updateContentsManifest = new ArrayList<>();
|
||||
addContentsOfFolderToManifest(folderPath, "", updateContentsManifest);
|
||||
//sort manifest strings to make sure, that they are completely equal with manifest strings has been generated in cli!
|
||||
Collections.sort(updateContentsManifest);
|
||||
JSONArray updateContentsJSONArray = new JSONArray();
|
||||
for (String manifestEntry : updateContentsManifest) {
|
||||
@@ -133,9 +164,89 @@ public class CodePushUpdateUtils {
|
||||
|
||||
// The JSON serialization turns path separators into "\/", e.g. "CodePush\/assets\/image.png"
|
||||
String updateContentsManifestString = updateContentsJSONArray.toString().replace("\\/", "/");
|
||||
CodePushUtils.log("Manifest string: " + updateContentsManifestString);
|
||||
|
||||
String updateContentsManifestHash = computeHash(new ByteArrayInputStream(updateContentsManifestString.getBytes()));
|
||||
|
||||
CodePushUtils.log("Expected hash: " + expectedHash + ", actual hash: " + updateContentsManifestHash);
|
||||
if (!expectedHash.equals(updateContentsManifestHash)) {
|
||||
throw new CodePushInvalidUpdateException("The update contents failed the data integrity check.");
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> verifyAndDecodeJWT(String jwt, PublicKey publicKey) {
|
||||
try {
|
||||
final JWTVerifier verifier = new JWTVerifier(publicKey);
|
||||
final Map<String, Object> claims = verifier.verify(jwt);
|
||||
CodePushUtils.log("JWT verification succeeded:\n" + claims.toString());
|
||||
return claims;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static PublicKey parsePublicKey(String stringPublicKey) {
|
||||
try {
|
||||
//remove unnecessary "begin/end public key" entries from string
|
||||
stringPublicKey = stringPublicKey
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replace(NEW_LINE, "");
|
||||
byte[] byteKey = Base64.decode(stringPublicKey.getBytes(), Base64.DEFAULT);
|
||||
X509EncodedKeySpec X509Key = new X509EncodedKeySpec(byteKey);
|
||||
KeyFactory kf = KeyFactory.getInstance("RSA");
|
||||
|
||||
return kf.generatePublic(X509Key);
|
||||
} catch (Exception e) {
|
||||
CodePushUtils.log(e.getMessage());
|
||||
CodePushUtils.log(e.getStackTrace().toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getSignatureFilePath(String updateFolderPath){
|
||||
return CodePushUtils.appendPathComponent(
|
||||
CodePushUtils.appendPathComponent(updateFolderPath, CodePushConstants.CODE_PUSH_FOLDER_PREFIX),
|
||||
CodePushConstants.BUNDLE_JWT_FILE
|
||||
);
|
||||
}
|
||||
|
||||
public static String getSignature(String folderPath) {
|
||||
final String signatureFilePath = getSignatureFilePath(folderPath);
|
||||
|
||||
try {
|
||||
return FileUtils.readFileToString(signatureFilePath);
|
||||
} catch (IOException e) {
|
||||
CodePushUtils.log(e.getMessage());
|
||||
CodePushUtils.log(e.getStackTrace().toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void verifySignature(String folderPath, String stringPublicKey) throws CodePushInvalidUpdateException {
|
||||
CodePushUtils.log("Verifying signature for folder path: " + folderPath);
|
||||
|
||||
final PublicKey publicKey = parsePublicKey(stringPublicKey);
|
||||
if (publicKey == null) {
|
||||
throw new CodePushInvalidUpdateException("The update could not be verified because no public key was found.");
|
||||
}
|
||||
|
||||
final String signature = getSignature(folderPath);
|
||||
if (signature == null) {
|
||||
throw new CodePushInvalidUpdateException("The update could not be verified because no signature was found.");
|
||||
}
|
||||
|
||||
final Map<String, Object> claims = verifyAndDecodeJWT(signature, publicKey);
|
||||
if (claims == null) {
|
||||
throw new CodePushInvalidUpdateException("The update could not be verified because it was not signed by a trusted party.");
|
||||
}
|
||||
|
||||
final String contentHash = (String)claims.get("contentHash");
|
||||
if (contentHash == null) {
|
||||
throw new CodePushInvalidUpdateException("The update could not be verified because the signature did not specify a content hash.");
|
||||
}
|
||||
|
||||
CodePushUpdateUtils.verifyFolderHash(folderPath, contentHash);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user