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:
Ruslan Bikkinin
2017-09-13 10:08:17 +01:00
committed by Sergey Akhalkov
parent 4ab0e5e9fe
commit 5e332bbe83
8 changed files with 260 additions and 16 deletions

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View File

@@ -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));

View File

@@ -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);

View File

@@ -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);
}
}