Skip to content

Commit a23223a

Browse files
andrewbibiloniRachel Prince
andauthored
FAD Add release codeHash verification for APKs (#2910)
* Add logic to calculate code hash from zip file * Finish codehash check implementation * Add isInstalledRelease unit tests * google java format * Merge and fix tests, add copyright to releaseIdStorage * add copyright * java format * add final copyright * Add annotations to calculateAPkInternalCodehash * Add comments, refactor updateApk to take in latestRelease, change logic for cachedCodeHashes to only calcuate ziphash once, add unit test * Fix tests affected by merge * Update ziphash comment, change codehash equality method name Co-authored-by: Rachel Prince <[email protected]>
1 parent d701761 commit a23223a

File tree

8 files changed

+286
-35
lines changed

8 files changed

+286
-35
lines changed

firebase-app-distribution/firebase-app-distribution.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ dependencies {
4848
testImplementation 'junit:junit:4.13.2'
4949
testImplementation "org.robolectric:robolectric:$robolectricVersion"
5050
testImplementation "com.google.truth:truth:$googleTruthVersion"
51-
testImplementation 'org.mockito:mockito-core:2.25.0'
52-
androidTestImplementation "org.mockito:mockito-android:2.25.0"
51+
testImplementation 'org.mockito:mockito-inline:3.4.0'
52+
androidTestImplementation "org.mockito:mockito-android:3.4.0"
5353
testImplementation 'androidx.test:core:1.2.0'
5454

5555
implementation 'com.google.android.gms:play-services-tasks:17.0.0'

firebase-app-distribution/src/main/java/com/google/firebase/appdistribution/CheckForUpdateClient.java

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package com.google.firebase.appdistribution;
1616

17+
import static com.google.firebase.appdistribution.internal.ReleaseIdentificationUtils.calculateApkInternalCodeHash;
18+
1719
import android.content.Context;
1820
import android.content.pm.PackageInfo;
1921
import android.content.pm.PackageManager;
@@ -27,6 +29,10 @@
2729
import com.google.firebase.appdistribution.internal.ReleaseIdentificationUtils;
2830
import com.google.firebase.installations.FirebaseInstallationsApi;
2931
import com.google.firebase.installations.InstallationTokenResult;
32+
import java.io.File;
33+
import java.util.Locale;
34+
import java.util.concurrent.ConcurrentHashMap;
35+
import java.util.concurrent.ConcurrentMap;
3036
import java.util.concurrent.Executor;
3137
import java.util.concurrent.Executors;
3238

@@ -36,6 +42,8 @@ class CheckForUpdateClient {
3642
private final FirebaseApp firebaseApp;
3743
private final FirebaseAppDistributionTesterApiClient firebaseAppDistributionTesterApiClient;
3844
private final FirebaseInstallationsApi firebaseInstallationsApi;
45+
private static final ConcurrentMap<String, String> cachedCodeHashes = new ConcurrentHashMap<>();
46+
private final ReleaseIdentifierStorage releaseIdentifierStorage;
3947

4048
Task<AppDistributionReleaseInternal> cachedCheckForUpdate = null;
4149
private final Executor checkForUpdateExecutor;
@@ -49,6 +57,8 @@ class CheckForUpdateClient {
4957
this.firebaseInstallationsApi = firebaseInstallationsApi;
5058
// TODO: verify if this is best way to use executorservice here
5159
this.checkForUpdateExecutor = Executors.newFixedThreadPool(UPDATE_THREAD_POOL_SIZE);
60+
this.releaseIdentifierStorage =
61+
new ReleaseIdentifierStorage(firebaseApp.getApplicationContext());
5262
}
5363

5464
CheckForUpdateClient(
@@ -61,6 +71,8 @@ class CheckForUpdateClient {
6171
this.firebaseInstallationsApi = firebaseInstallationsApi;
6272
// TODO: verify if this is best way to use executorservice here
6373
this.checkForUpdateExecutor = executor;
74+
this.releaseIdentifierStorage =
75+
new ReleaseIdentifierStorage(firebaseApp.getApplicationContext());
6476
}
6577

6678
@NonNull
@@ -135,11 +147,10 @@ private boolean isNewerBuildVersion(AppDistributionReleaseInternal latestRelease
135147
> getInstalledAppVersionCode(firebaseApp.getApplicationContext());
136148
}
137149

138-
private boolean isInstalledRelease(AppDistributionReleaseInternal latestRelease) {
150+
@VisibleForTesting
151+
boolean isInstalledRelease(AppDistributionReleaseInternal latestRelease) {
139152
if (latestRelease.getBinaryType().equals(BinaryType.APK)) {
140-
// TODO(rachelprince): APK codehash verification. For now assume
141-
// the release is identical unless the build version is different
142-
return true;
153+
return hasSameCodeHashAsInstallledRelease(latestRelease);
143154
}
144155

145156
if (latestRelease.getIasArtifactId() == null) {
@@ -165,4 +176,39 @@ private long getInstalledAppVersionCode(Context context) throws FirebaseAppDistr
165176
}
166177
return PackageInfoCompat.getLongVersionCode(pInfo);
167178
}
179+
180+
@VisibleForTesting
181+
String extractApkCodeHash(PackageInfo packageInfo) {
182+
File sourceFile = new File(packageInfo.applicationInfo.sourceDir);
183+
184+
String key =
185+
String.format(
186+
Locale.ENGLISH, "%s.%d", sourceFile.getAbsolutePath(), sourceFile.lastModified());
187+
if (!cachedCodeHashes.containsKey(key)) {
188+
cachedCodeHashes.put(key, calculateApkInternalCodeHash(sourceFile));
189+
}
190+
return releaseIdentifierStorage.getExternalCodeHash(cachedCodeHashes.get(key));
191+
}
192+
193+
private boolean hasSameCodeHashAsInstallledRelease(AppDistributionReleaseInternal latestRelease) {
194+
try {
195+
Context context = firebaseApp.getApplicationContext();
196+
PackageInfo metadataPackageInfo =
197+
context
198+
.getPackageManager()
199+
.getPackageInfo(context.getPackageName(), PackageManager.GET_META_DATA);
200+
String externalCodeHash = extractApkCodeHash(metadataPackageInfo);
201+
// Will trigger during the first install of the app since no zipHash to externalCodeHash
202+
// mapping will have been set in ReleaseIdentifierStorage yet
203+
if (externalCodeHash == null) {
204+
return false;
205+
}
206+
207+
// If the codeHash for the retrieved latestRelease is equal to the stored codeHash
208+
// of the installed release, then they are the same release.
209+
return externalCodeHash.equals(latestRelease.getCodeHash());
210+
} catch (PackageManager.NameNotFoundException e) {
211+
return false;
212+
}
213+
}
168214
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2021 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.appdistribution;
16+
17+
import android.content.Context;
18+
import android.content.SharedPreferences;
19+
import com.google.firebase.appdistribution.internal.AppDistributionReleaseInternal;
20+
21+
public class ReleaseIdentifierStorage {
22+
23+
private static final String RELEASE_IDENTIFIER_PREFERENCES_NAME =
24+
"FirebaseAppDistributionReleaseIdentifierStorage";
25+
26+
private final SharedPreferences releaseIdentifierSharedPreferences;
27+
28+
ReleaseIdentifierStorage(Context applicationContext) {
29+
this.releaseIdentifierSharedPreferences =
30+
applicationContext.getSharedPreferences(
31+
RELEASE_IDENTIFIER_PREFERENCES_NAME, Context.MODE_PRIVATE);
32+
}
33+
34+
void setCodeHashMap(String internalCodeHash, AppDistributionReleaseInternal latestRelease) {
35+
this.releaseIdentifierSharedPreferences
36+
.edit()
37+
.putString(internalCodeHash, latestRelease.getCodeHash())
38+
.apply();
39+
}
40+
41+
String getExternalCodeHash(String internalCodeHash) {
42+
return releaseIdentifierSharedPreferences.getString(internalCodeHash, null);
43+
}
44+
}

firebase-app-distribution/src/main/java/com/google/firebase/appdistribution/UpdateApkClient.java

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import static com.google.firebase.appdistribution.FirebaseAppDistributionException.Status;
1818
import static com.google.firebase.appdistribution.FirebaseAppDistributionException.Status.NETWORK_FAILURE;
19+
import static com.google.firebase.appdistribution.internal.ReleaseIdentificationUtils.calculateApkInternalCodeHash;
1920

2021
import android.app.Activity;
2122
import android.content.Context;
@@ -30,6 +31,7 @@
3031
import com.google.android.gms.tasks.TaskCompletionSource;
3132
import com.google.android.gms.tasks.Tasks;
3233
import com.google.firebase.FirebaseApp;
34+
import com.google.firebase.appdistribution.internal.AppDistributionReleaseInternal;
3335
import java.io.BufferedOutputStream;
3436
import java.io.File;
3537
import java.io.IOException;
@@ -55,6 +57,8 @@ class UpdateApkClient {
5557
@GuardedBy("updateTaskLock")
5658
private UpdateTaskImpl cachedUpdateTask;
5759

60+
private ReleaseIdentifierStorage releaseIdentifierStorage;
61+
5862
@GuardedBy("activityLock")
5963
private Activity currentActivity;
6064

@@ -64,12 +68,15 @@ class UpdateApkClient {
6468
public UpdateApkClient(@NonNull FirebaseApp firebaseApp) {
6569
this.downloadExecutor = Executors.newSingleThreadExecutor();
6670
this.firebaseApp = firebaseApp;
71+
this.releaseIdentifierStorage =
72+
new ReleaseIdentifierStorage(firebaseApp.getApplicationContext());
6773
this.appDistributionNotificationsManager =
6874
new FirebaseAppDistributionNotificationsManager(firebaseApp);
6975
}
7076

7177
public synchronized UpdateTaskImpl updateApk(
72-
@NonNull String downloadUrl, boolean showDownloadNotificationManager) {
78+
@NonNull AppDistributionReleaseInternal latestRelease,
79+
boolean showDownloadNotificationManager) {
7380
synchronized (updateTaskLock) {
7481
if (cachedUpdateTask != null && !cachedUpdateTask.isComplete()) {
7582
return cachedUpdateTask;
@@ -78,7 +85,7 @@ public synchronized UpdateTaskImpl updateApk(
7885
cachedUpdateTask = new UpdateTaskImpl();
7986
}
8087

81-
downloadApk(downloadUrl, showDownloadNotificationManager)
88+
downloadApk(latestRelease, showDownloadNotificationManager)
8289
.addOnSuccessListener(
8390
downloadExecutor,
8491
file ->
@@ -110,24 +117,27 @@ public synchronized UpdateTaskImpl updateApk(
110117

111118
@VisibleForTesting
112119
@NonNull
113-
Task<File> downloadApk(@NonNull String downloadUrl, boolean showDownloadNotificationManager) {
120+
Task<File> downloadApk(
121+
@NonNull AppDistributionReleaseInternal latestRelease,
122+
boolean showDownloadNotificationManager) {
114123
if (downloadTaskCompletionSource != null
115124
&& !downloadTaskCompletionSource.getTask().isComplete()) {
116125
return downloadTaskCompletionSource.getTask();
117126
}
118127

119128
downloadTaskCompletionSource = new TaskCompletionSource<>();
120129

121-
makeApkDownloadRequest(downloadUrl, showDownloadNotificationManager);
130+
makeApkDownloadRequest(latestRelease, showDownloadNotificationManager);
122131
return downloadTaskCompletionSource.getTask();
123132
}
124133

125134
private void makeApkDownloadRequest(
126-
@NonNull String downloadUrl, boolean showDownloadNotificationManager) {
135+
@NonNull AppDistributionReleaseInternal latestRelease,
136+
boolean showDownloadNotificationManager) {
127137
downloadExecutor.execute(
128138
() -> {
129139
try {
130-
HttpsURLConnection connection = openHttpsUrlConnection(downloadUrl);
140+
HttpsURLConnection connection = openHttpsUrlConnection(latestRelease.getDownloadUrl());
131141
connection.setRequestMethod(REQUEST_METHOD);
132142
if (connection.getInputStream() == null) {
133143
setDownloadTaskCompletionError(
@@ -139,10 +149,12 @@ private void makeApkDownloadRequest(
139149
postUpdateProgress(
140150
responseLength, 0, UpdateStatus.PENDING, showDownloadNotificationManager);
141151
String fileName = getApplicationName() + ".apk";
152+
142153
downloadToDisk(
143154
connection.getInputStream(),
144155
responseLength,
145156
fileName,
157+
latestRelease,
146158
showDownloadNotificationManager);
147159
}
148160
} catch (IOException | FirebaseAppDistributionException e) {
@@ -156,7 +168,11 @@ private void makeApkDownloadRequest(
156168
}
157169

158170
private void downloadToDisk(
159-
InputStream input, long totalSize, String fileName, boolean showDownloadNotificationManager) {
171+
InputStream input,
172+
long totalSize,
173+
String fileName,
174+
AppDistributionReleaseInternal latestRelease,
175+
boolean showDownloadNotificationManager) {
160176

161177
File apkFile = getApkFileForApp(fileName);
162178
apkFile.delete();
@@ -213,6 +229,14 @@ private void downloadToDisk(
213229
FirebaseAppDistributionException.Status.DOWNLOAD_FAILURE));
214230
}
215231

232+
File downloadedFile = new File(firebaseApp.getApplicationContext().getFilesDir(), fileName);
233+
234+
String internalCodeHash = calculateApkInternalCodeHash(downloadedFile);
235+
236+
if (internalCodeHash != null) {
237+
releaseIdentifierStorage.setCodeHashMap(internalCodeHash, latestRelease);
238+
}
239+
216240
// completion
217241
postUpdateProgress(
218242
totalSize, totalSize, UpdateStatus.DOWNLOADED, showDownloadNotificationManager);

firebase-app-distribution/src/main/java/com/google/firebase/appdistribution/UpdateAppClient.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,7 @@ synchronized UpdateTask updateApp(
7777
return cachedAabUpdateTask;
7878
}
7979
} else {
80-
return this.updateApkClient.updateApk(
81-
latestRelease.getDownloadUrl(), showDownloadInNotificationManager);
80+
return this.updateApkClient.updateApk(latestRelease, showDownloadInNotificationManager);
8281
}
8382
}
8483

firebase-app-distribution/src/main/java/com/google/firebase/appdistribution/internal/ReleaseIdentificationUtils.java

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,19 @@
2020
import android.util.Log;
2121
import androidx.annotation.NonNull;
2222
import androidx.annotation.Nullable;
23+
import java.io.File;
24+
import java.io.IOException;
25+
import java.nio.ByteBuffer;
26+
import java.security.MessageDigest;
27+
import java.security.NoSuchAlgorithmException;
28+
import java.util.ArrayList;
29+
import java.util.Enumeration;
30+
import java.util.zip.ZipEntry;
31+
import java.util.zip.ZipFile;
2332

2433
public final class ReleaseIdentificationUtils {
2534
private static final String TAG = "ReleaseIdentification";
35+
private static final int BYTES_IN_LONG = 8;
2636

2737
@Nullable
2838
public static String extractInternalAppSharingArtifactId(@NonNull Context appContext) {
@@ -40,4 +50,74 @@ public static String extractInternalAppSharingArtifactId(@NonNull Context appCon
4050
return null;
4151
}
4252
}
53+
54+
@Nullable
55+
public static String calculateApkInternalCodeHash(@NonNull File file) {
56+
Log.v(TAG, String.format("Calculating release id for %s", file.getPath()));
57+
Log.v(TAG, String.format("File size: %d", file.length()));
58+
59+
long start = System.currentTimeMillis();
60+
long entries = 0;
61+
String zipFingerprint = null;
62+
try {
63+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
64+
ArrayList<Byte> checksums = new ArrayList<>();
65+
66+
// Since calculating the codeHash returned from the release backend is computationally
67+
// expensive, using existing checksum data from the ZipFile we can quickly calculate
68+
// an intermediate hash that then gets mapped to the backend's returned release codehash
69+
ZipFile zis = new ZipFile(file);
70+
try {
71+
Enumeration<? extends ZipEntry> zipEntries = zis.entries();
72+
while (zipEntries.hasMoreElements()) {
73+
ZipEntry zip = zipEntries.nextElement();
74+
entries += 1;
75+
byte[] crcBytes = longToByteArray(zip.getCrc());
76+
for (byte b : crcBytes) {
77+
checksums.add(b);
78+
}
79+
}
80+
} finally {
81+
zis.close();
82+
}
83+
byte[] checksumByteArray = digest.digest(arrayListToByteArray(checksums));
84+
StringBuilder sb = new StringBuilder();
85+
for (byte b : checksumByteArray) {
86+
sb.append(String.format("%02x", b));
87+
}
88+
zipFingerprint = sb.toString();
89+
90+
} catch (IOException | NoSuchAlgorithmException e) {
91+
Log.v(TAG, String.format("id calculation failed for %s", file.getPath()));
92+
return null;
93+
} finally {
94+
long elapsed = System.currentTimeMillis() - start;
95+
if (elapsed > 2 * 1000) {
96+
Log.v(
97+
TAG,
98+
String.format(
99+
"Long id calculation time %d ms and %d entries for %s",
100+
elapsed, entries, file.getPath()));
101+
}
102+
103+
Log.v(TAG, String.format("Finished calculating %d entries in %d ms", entries, elapsed));
104+
Log.v(TAG, String.format("%s hashes to %s", file.getPath(), zipFingerprint));
105+
}
106+
107+
return zipFingerprint;
108+
}
109+
110+
private static byte[] longToByteArray(long x) {
111+
ByteBuffer buffer = ByteBuffer.allocate(BYTES_IN_LONG);
112+
buffer.putLong(x);
113+
return buffer.array();
114+
}
115+
116+
private static byte[] arrayListToByteArray(ArrayList<Byte> list) {
117+
byte[] result = new byte[list.size()];
118+
for (int i = 0; i < list.size(); i++) {
119+
result[i] = list.get(i);
120+
}
121+
return result;
122+
}
43123
}

0 commit comments

Comments
 (0)