Skip to content

Commit 105fc46

Browse files
Merge pull request #30 from appdevforall/feat/ota-updater-security-redesign
feat(controller): OTA updater security + correctness redesign (F15, PR A)
2 parents 52e136e + 0c69557 commit 105fc46

11 files changed

Lines changed: 383 additions & 30 deletions

File tree

‎CLAUDE.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,18 @@ Phase-1 security slice closing tech-debt **S4** (arbitrary on-device shell via A
193193
- **Legacy seam:** `IIABAdbManager.executeCommand` validates and fails closed
194194
before opening the `shell:` stream.
195195

196+
**Slice (IN PROGRESS) — OTA self-updater (`org.iiab.controller.update`)**
197+
Phase-1 security + functional redesign of the in-app updater (tech-debt **F15**).
198+
199+
- `domain/` — `UpdateCheck` (is the server build newer?) + `CertDigests.sameSigner`
200+
(pure signer-set comparison). Unit-tested.
201+
- `data/ApkVerifier` — the downloaded APK must be signed by the same certificate
202+
as the running app (public certs only); rejects MITM/tampered or non-APK
203+
downloads before install.
204+
- **Legacy seam:** `MainActivity` stages the APK privately, checks the download
205+
status, verifies the signature, handles the install permission, and registers
206+
the completion receiver NOT_EXPORTED. (PR A.) Presentation/progress UX = PR B.
207+
196208
**Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat:
197209
god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable
198210
state on public/static fields, hand-rolled `HttpURLConnection` calls duplicated

‎controller/app/src/main/AndroidManifest.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package="org.iiab.controller">
55

66
<uses-permission android:name="android.permission.INTERNET" />
7+
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
78
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
89
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
910
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

‎controller/app/src/main/java/org/iiab/controller/MainActivity.java‎

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import android.content.pm.PackageManager;
2727
import android.os.Environment;
2828
import android.util.Log;
29+
import org.iiab.controller.update.data.ApkVerifier;
2930
import android.view.View;
3031
import android.widget.ImageButton;
3132
import android.widget.TextView;
@@ -739,7 +740,7 @@ protected void onResume() {
739740
// Register download listener
740741
IntentFilter filter = new IntentFilter(android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE);
741742
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
742-
registerReceiver(downloadReceiver, filter, Context.RECEIVER_EXPORTED);
743+
registerReceiver(downloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
743744
} else {
744745
registerReceiver(downloadReceiver, filter);
745746
}
@@ -1296,7 +1297,7 @@ private void startDownload(String downloadUrl) {
12961297

12971298
// 3. We delete previous failed downloads with THIS same name
12981299
java.io.File oldApk = new java.io.File(
1299-
android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
1300+
getExternalFilesDir(android.os.Environment.DIRECTORY_DOWNLOADS),
13001301
apkName
13011302
);
13021303
if (oldApk.exists()) {
@@ -1310,8 +1311,9 @@ private void startDownload(String downloadUrl) {
13101311
request.setMimeType("application/vnd.android.package-archive");
13111312
request.setNotificationVisibility(android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
13121313

1313-
// 4. We tell DownloadManager to use the dynamic name
1314-
request.setDestinationInExternalPublicDir(android.os.Environment.DIRECTORY_DOWNLOADS, apkName);
1314+
// 4. F15: stage the APK in the app's PRIVATE external dir (not public
1315+
// Downloads) so another app cannot swap it before install.
1316+
request.setDestinationInExternalFilesDir(this, android.os.Environment.DIRECTORY_DOWNLOADS, apkName);
13151317

13161318
android.app.DownloadManager manager = (android.app.DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
13171319
if (manager != null) {
@@ -1324,48 +1326,99 @@ private void startDownload(String downloadUrl) {
13241326
@Override
13251327
public void onReceive(Context context, Intent intent) {
13261328
long id = intent.getLongExtra(android.app.DownloadManager.EXTRA_DOWNLOAD_ID, -1);
1327-
if (id == updateDownloadId) {
1329+
if (id != updateDownloadId) {
1330+
return;
1331+
}
1332+
// F15: only install if the download actually SUCCEEDED. DownloadManager
1333+
// reports completion even when the server returned an error/HTML page.
1334+
if (isDownloadSuccessful(id)) {
13281335
installApk();
1336+
} else {
1337+
Log.e(TAG, "OTA: download did not complete successfully; not installing.");
1338+
Toast.makeText(context, R.string.ota_error_download_failed, Toast.LENGTH_LONG).show();
13291339
}
13301340
}
13311341
};
13321342

1343+
/** Did the DownloadManager job with this id finish with STATUS_SUCCESSFUL? */
1344+
private boolean isDownloadSuccessful(long id) {
1345+
android.app.DownloadManager manager =
1346+
(android.app.DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
1347+
if (manager == null) {
1348+
return false;
1349+
}
1350+
try (android.database.Cursor c =
1351+
manager.query(new android.app.DownloadManager.Query().setFilterById(id))) {
1352+
if (c != null && c.moveToFirst()) {
1353+
int idx = c.getColumnIndex(android.app.DownloadManager.COLUMN_STATUS);
1354+
return idx >= 0 && c.getInt(idx) == android.app.DownloadManager.STATUS_SUCCESSFUL;
1355+
}
1356+
} catch (Exception e) {
1357+
Log.e(TAG, "OTA: error querying download status", e);
1358+
}
1359+
return false;
1360+
}
1361+
13331362
private void installApk() {
13341363
String apkName = getSharedPreferences(getString(R.string.pref_file_internal), Context.MODE_PRIVATE)
13351364
.getString("ota_apk_name", "iiab_update.apk");
13361365

13371366
java.io.File apkFile = new java.io.File(
1338-
android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
1367+
getExternalFilesDir(android.os.Environment.DIRECTORY_DOWNLOADS),
13391368
apkName
13401369
);
13411370

1342-
if (apkFile.exists()) {
1343-
Intent intent = new Intent(Intent.ACTION_VIEW);
1344-
android.net.Uri apkUri = androidx.core.content.FileProvider.getUriForFile(
1345-
this,
1346-
getPackageName() + ".provider",
1347-
apkFile
1348-
);
1349-
1350-
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
1351-
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
1352-
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
1353-
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
1371+
if (!apkFile.exists()) {
1372+
Log.e(TAG, "OTA: Downloaded APK file not found at " + apkFile.getAbsolutePath());
1373+
return;
1374+
}
13541375

1355-
List<android.content.pm.ResolveInfo> resInfoList = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
1356-
for (android.content.pm.ResolveInfo resolveInfo : resInfoList) {
1357-
String packageName = resolveInfo.activityInfo.packageName;
1358-
grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
1359-
}
1376+
// F15: verify the APK is signed by the SAME certificate as this app before
1377+
// installing. Rejects MITM/tampered APKs and non-APK downloads (e.g. an
1378+
// HTML/text error page saved with a .apk name).
1379+
if (!ApkVerifier.isSignedBySameCertAsApp(this, apkFile)) {
1380+
Log.e(TAG, "OTA: APK failed signature verification; deleting and aborting install.");
1381+
apkFile.delete();
1382+
Toast.makeText(this, R.string.ota_error_verify_failed, Toast.LENGTH_LONG).show();
1383+
return;
1384+
}
13601385

1386+
// F15: on API 26+ the user must allow this app to install packages.
1387+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
1388+
&& !getPackageManager().canRequestPackageInstalls()) {
1389+
Toast.makeText(this, R.string.ota_msg_enable_unknown_sources, Toast.LENGTH_LONG).show();
13611390
try {
1362-
startActivity(intent);
1391+
startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
1392+
android.net.Uri.parse("package:" + getPackageName())));
13631393
} catch (Exception e) {
1364-
Log.e(TAG, "OTA: Error launching installer", e);
1365-
Toast.makeText(this, R.string.ota_error_launching_installer, Toast.LENGTH_LONG).show();
1394+
Log.e(TAG, "OTA: could not open unknown-sources settings", e);
13661395
}
1367-
} else {
1368-
Log.e(TAG, "OTA: Downloaded APK file not found at " + apkFile.getAbsolutePath());
1396+
return;
1397+
}
1398+
1399+
Intent intent = new Intent(Intent.ACTION_VIEW);
1400+
android.net.Uri apkUri = androidx.core.content.FileProvider.getUriForFile(
1401+
this,
1402+
getPackageName() + ".provider",
1403+
apkFile
1404+
);
1405+
1406+
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
1407+
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
1408+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
1409+
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
1410+
1411+
List<android.content.pm.ResolveInfo> resInfoList = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
1412+
for (android.content.pm.ResolveInfo resolveInfo : resInfoList) {
1413+
String packageName = resolveInfo.activityInfo.packageName;
1414+
grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
1415+
}
1416+
1417+
try {
1418+
startActivity(intent);
1419+
} catch (Exception e) {
1420+
Log.e(TAG, "OTA: Error launching installer", e);
1421+
Toast.makeText(this, R.string.ota_error_launching_installer, Toast.LENGTH_LONG).show();
13691422
}
13701423
}
13711424

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* ============================================================================
3+
* Name : ApkVerifier.java
4+
* Author : IIAB Project
5+
* Copyright : Copyright (c) 2026 IIAB Project
6+
* Description : Verifies a downloaded OTA APK is signed by the SAME certificate
7+
* as the running app, before it is installed. Closes part of
8+
* tech-debt F15 (no integrity verification of the update APK).
9+
* ============================================================================
10+
*/
11+
package org.iiab.controller.update.data;
12+
13+
import android.content.Context;
14+
import android.content.pm.PackageInfo;
15+
import android.content.pm.PackageManager;
16+
import android.content.pm.Signature;
17+
import android.os.Build;
18+
import android.util.Log;
19+
20+
import org.iiab.controller.update.domain.CertDigests;
21+
22+
import java.io.File;
23+
import java.security.MessageDigest;
24+
import java.util.HashSet;
25+
import java.util.Set;
26+
27+
/**
28+
* Reads APK signing certificates via {@link PackageManager} (no private key,
29+
* no secrets — only public certs) and checks the downloaded update APK was
30+
* signed by the same certificate as the installed app.
31+
*
32+
* <p>This rejects a tampered/MITM'd APK (different signer) and a download that
33+
* is not even a validly-signed APK (e.g. an HTML/text error page → no signer →
34+
* empty set), so it must be called <strong>before</strong> launching the
35+
* installer.
36+
*/
37+
public final class ApkVerifier {
38+
39+
private static final String TAG = "IIAB-ApkVerifier";
40+
41+
private ApkVerifier() {
42+
// Static utility; not instantiable.
43+
}
44+
45+
/** True only if {@code apkFile} is signed by the same certificate(s) as the running app. */
46+
public static boolean isSignedBySameCertAsApp(Context context, File apkFile) {
47+
if (apkFile == null || !apkFile.exists()) {
48+
return false;
49+
}
50+
PackageManager pm = context.getPackageManager();
51+
Set<String> appDigests = digestsForInstalledApp(pm, context.getPackageName());
52+
Set<String> apkDigests = digestsForArchive(pm, apkFile.getAbsolutePath());
53+
boolean ok = CertDigests.sameSigner(appDigests, apkDigests);
54+
if (!ok) {
55+
Log.w(TAG, "Update APK signature does not match the running app (or it is not a signed APK).");
56+
}
57+
return ok;
58+
}
59+
60+
private static Set<String> digestsForInstalledApp(PackageManager pm, String pkg) {
61+
try {
62+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
63+
PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES);
64+
return digestsFromSigningInfo(pi);
65+
}
66+
@SuppressWarnings("deprecation")
67+
PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES);
68+
return digestsFromSignatures(pi == null ? null : pi.signatures);
69+
} catch (Exception e) {
70+
Log.e(TAG, "Could not read installed app signatures", e);
71+
return new HashSet<>();
72+
}
73+
}
74+
75+
private static Set<String> digestsForArchive(PackageManager pm, String path) {
76+
try {
77+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
78+
PackageInfo pi = pm.getPackageArchiveInfo(path, PackageManager.GET_SIGNING_CERTIFICATES);
79+
return digestsFromSigningInfo(pi);
80+
}
81+
@SuppressWarnings("deprecation")
82+
PackageInfo pi = pm.getPackageArchiveInfo(path, PackageManager.GET_SIGNATURES);
83+
return digestsFromSignatures(pi == null ? null : pi.signatures);
84+
} catch (Exception e) {
85+
Log.e(TAG, "Could not read APK archive signatures", e);
86+
return new HashSet<>();
87+
}
88+
}
89+
90+
private static Set<String> digestsFromSigningInfo(PackageInfo pi) {
91+
if (pi == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P || pi.signingInfo == null) {
92+
return new HashSet<>();
93+
}
94+
// Use the current APK content signers for both app and archive so the
95+
// sets are directly comparable.
96+
return digestsFromSignatures(pi.signingInfo.getApkContentsSigners());
97+
}
98+
99+
private static Set<String> digestsFromSignatures(Signature[] sigs) {
100+
Set<String> out = new HashSet<>();
101+
if (sigs == null) {
102+
return out;
103+
}
104+
for (Signature s : sigs) {
105+
String d = sha256Hex(s.toByteArray());
106+
if (d != null) {
107+
out.add(d);
108+
}
109+
}
110+
return out;
111+
}
112+
113+
private static String sha256Hex(byte[] data) {
114+
try {
115+
byte[] h = MessageDigest.getInstance("SHA-256").digest(data);
116+
StringBuilder sb = new StringBuilder(h.length * 2);
117+
for (byte b : h) {
118+
sb.append(String.format("%02x", b));
119+
}
120+
return sb.toString();
121+
} catch (Exception e) {
122+
return null;
123+
}
124+
}
125+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* ============================================================================
3+
* Name : CertDigests.java
4+
* Author : IIAB Project
5+
* Copyright : Copyright (c) 2026 IIAB Project
6+
* Description : Domain rule: does a downloaded APK's set of signing-certificate
7+
* digests match the running app's? Pure JVM, unit-testable.
8+
* ============================================================================
9+
*/
10+
package org.iiab.controller.update.domain;
11+
12+
import java.util.Set;
13+
14+
/**
15+
* Pure comparison of APK signing-certificate digests.
16+
*
17+
* <p>The OTA updater must only install an APK signed by the <em>same</em>
18+
* certificate as the running app (Android enforces this for updates anyway;
19+
* checking it ourselves before launching the installer rejects MITM/tampered
20+
* or non-APK downloads early with a clear message — see tech-debt F15).
21+
*
22+
* <p>Certificate extraction (Android {@code PackageManager}) lives in the data
23+
* layer; this only compares the resulting digest sets, so it is unit-testable
24+
* on a plain JVM.
25+
*/
26+
public final class CertDigests {
27+
28+
private CertDigests() {
29+
// Static utility; not instantiable.
30+
}
31+
32+
/**
33+
* True only if both sets are non-empty and identical. An empty candidate set
34+
* (e.g. the download was not a validly-signed APK — a text/HTML error page)
35+
* therefore fails, as does any set signed by a different certificate.
36+
*/
37+
public static boolean sameSigner(Set<String> appDigests, Set<String> apkDigests) {
38+
if (appDigests == null || apkDigests == null) {
39+
return false;
40+
}
41+
if (appDigests.isEmpty() || apkDigests.isEmpty()) {
42+
return false;
43+
}
44+
return appDigests.equals(apkDigests);
45+
}
46+
}

0 commit comments

Comments
 (0)