diff --git a/android/app/build.gradle b/android/app/build.gradle index a59954d..7c12558 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,4 +22,7 @@ android { dependencies { compile "com.facebook.react:react-native:+" + //todo as required minimal sdk version will be more then 23, upgrade this to latest version + //see https://github.com/auth0/java-jwt/issues/131 + compile 'com.auth0:java-jwt:2.2.2' } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java index e32aeae..586f155 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java @@ -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(); diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushBuilder.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushBuilder.java new file mode 100644 index 0000000..ee1eb4a --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushBuilder.java @@ -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); + } +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java index 8bf937b..50be2c0 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java @@ -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"; } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushInvalidPublicKeyException.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushInvalidPublicKeyException.java new file mode 100755 index 0000000..29a739b --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushInvalidPublicKeyException.java @@ -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); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java index 636063a..9a20ee7 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java @@ -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)); diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 383b52e..3c2a743 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -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); diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java index 224fc82..734b1f8 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java @@ -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 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 : + // 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 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 verifyAndDecodeJWT(String jwt, PublicKey publicKey) { + try { + final JWTVerifier verifier = new JWTVerifier(publicKey); + final Map 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 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); + } + } \ No newline at end of file