Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,18 @@ Phase-1 security slice closing tech-debt **S4** (arbitrary on-device shell via A
- **Legacy seam:** `IIABAdbManager.executeCommand` validates and fails closed
before opening the `shell:` stream.

**Slice (IN PROGRESS) — OTA self-updater (`org.iiab.controller.update`)**
Phase-1 security + functional redesign of the in-app updater (tech-debt **F15**).

- `domain/` — `UpdateCheck` (is the server build newer?) + `CertDigests.sameSigner`
(pure signer-set comparison). Unit-tested.
- `data/ApkVerifier` — the downloaded APK must be signed by the same certificate
as the running app (public certs only); rejects MITM/tampered or non-APK
downloads before install.
- **Legacy seam:** `MainActivity` stages the APK privately, checks the download
status, verifies the signature, handles the install permission, and registers
the completion receiver NOT_EXPORTED. (PR A.) Presentation/progress UX = PR B.

**Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat:
god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable
state on public/static fields, hand-rolled `HttpURLConnection` calls duplicated
Expand Down
1 change: 1 addition & 0 deletions controller/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package="org.iiab.controller">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
Expand Down
109 changes: 81 additions & 28 deletions controller/app/src/main/java/org/iiab/controller/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import android.content.pm.PackageManager;
import android.os.Environment;
import android.util.Log;
import org.iiab.controller.update.data.ApkVerifier;
import android.view.View;
import android.widget.ImageButton;
import android.widget.TextView;
Expand Down Expand Up @@ -739,7 +740,7 @@ protected void onResume() {
// Register download listener
IntentFilter filter = new IntentFilter(android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
registerReceiver(downloadReceiver, filter, Context.RECEIVER_EXPORTED);
registerReceiver(downloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(downloadReceiver, filter);
}
Expand Down Expand Up @@ -1296,7 +1297,7 @@ private void startDownload(String downloadUrl) {

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

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

android.app.DownloadManager manager = (android.app.DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
if (manager != null) {
Expand All @@ -1324,48 +1326,99 @@ private void startDownload(String downloadUrl) {
@Override
public void onReceive(Context context, Intent intent) {
long id = intent.getLongExtra(android.app.DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (id == updateDownloadId) {
if (id != updateDownloadId) {
return;
}
// F15: only install if the download actually SUCCEEDED. DownloadManager
// reports completion even when the server returned an error/HTML page.
if (isDownloadSuccessful(id)) {
installApk();
} else {
Log.e(TAG, "OTA: download did not complete successfully; not installing.");
Toast.makeText(context, R.string.ota_error_download_failed, Toast.LENGTH_LONG).show();
}
}
};

/** Did the DownloadManager job with this id finish with STATUS_SUCCESSFUL? */
private boolean isDownloadSuccessful(long id) {
android.app.DownloadManager manager =
(android.app.DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
if (manager == null) {
return false;
}
try (android.database.Cursor c =
manager.query(new android.app.DownloadManager.Query().setFilterById(id))) {
if (c != null && c.moveToFirst()) {
int idx = c.getColumnIndex(android.app.DownloadManager.COLUMN_STATUS);
return idx >= 0 && c.getInt(idx) == android.app.DownloadManager.STATUS_SUCCESSFUL;
}
} catch (Exception e) {
Log.e(TAG, "OTA: error querying download status", e);
}
return false;
}

private void installApk() {
String apkName = getSharedPreferences(getString(R.string.pref_file_internal), Context.MODE_PRIVATE)
.getString("ota_apk_name", "iiab_update.apk");

java.io.File apkFile = new java.io.File(
android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
getExternalFilesDir(android.os.Environment.DIRECTORY_DOWNLOADS),
apkName
);

if (apkFile.exists()) {
Intent intent = new Intent(Intent.ACTION_VIEW);
android.net.Uri apkUri = androidx.core.content.FileProvider.getUriForFile(
this,
getPackageName() + ".provider",
apkFile
);

intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
if (!apkFile.exists()) {
Log.e(TAG, "OTA: Downloaded APK file not found at " + apkFile.getAbsolutePath());
return;
}

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

// F15: on API 26+ the user must allow this app to install packages.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !getPackageManager().canRequestPackageInstalls()) {
Toast.makeText(this, R.string.ota_msg_enable_unknown_sources, Toast.LENGTH_LONG).show();
try {
startActivity(intent);
startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:" + getPackageName())));
} catch (Exception e) {
Log.e(TAG, "OTA: Error launching installer", e);
Toast.makeText(this, R.string.ota_error_launching_installer, Toast.LENGTH_LONG).show();
Log.e(TAG, "OTA: could not open unknown-sources settings", e);
}
} else {
Log.e(TAG, "OTA: Downloaded APK file not found at " + apkFile.getAbsolutePath());
return;
}

Intent intent = new Intent(Intent.ACTION_VIEW);
android.net.Uri apkUri = androidx.core.content.FileProvider.getUriForFile(
this,
getPackageName() + ".provider",
apkFile
);

intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);

List<android.content.pm.ResolveInfo> resInfoList = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
for (android.content.pm.ResolveInfo resolveInfo : resInfoList) {
String packageName = resolveInfo.activityInfo.packageName;
grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
}

try {
startActivity(intent);
} catch (Exception e) {
Log.e(TAG, "OTA: Error launching installer", e);
Toast.makeText(this, R.string.ota_error_launching_installer, Toast.LENGTH_LONG).show();
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* ============================================================================
* Name : ApkVerifier.java
* Author : IIAB Project
* Copyright : Copyright (c) 2026 IIAB Project
* Description : Verifies a downloaded OTA APK is signed by the SAME certificate
* as the running app, before it is installed. Closes part of
* tech-debt F15 (no integrity verification of the update APK).
* ============================================================================
*/
package org.iiab.controller.update.data;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.os.Build;
import android.util.Log;

import org.iiab.controller.update.domain.CertDigests;

import java.io.File;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Set;

/**
* Reads APK signing certificates via {@link PackageManager} (no private key,
* no secrets — only public certs) and checks the downloaded update APK was
* signed by the same certificate as the installed app.
*
* <p>This rejects a tampered/MITM'd APK (different signer) and a download that
* is not even a validly-signed APK (e.g. an HTML/text error page → no signer →
* empty set), so it must be called <strong>before</strong> launching the
* installer.
*/
public final class ApkVerifier {

private static final String TAG = "IIAB-ApkVerifier";

private ApkVerifier() {
// Static utility; not instantiable.
}

/** True only if {@code apkFile} is signed by the same certificate(s) as the running app. */
public static boolean isSignedBySameCertAsApp(Context context, File apkFile) {
if (apkFile == null || !apkFile.exists()) {
return false;
}
PackageManager pm = context.getPackageManager();
Set<String> appDigests = digestsForInstalledApp(pm, context.getPackageName());
Set<String> apkDigests = digestsForArchive(pm, apkFile.getAbsolutePath());
boolean ok = CertDigests.sameSigner(appDigests, apkDigests);
if (!ok) {
Log.w(TAG, "Update APK signature does not match the running app (or it is not a signed APK).");
}
return ok;
}

private static Set<String> digestsForInstalledApp(PackageManager pm, String pkg) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES);
return digestsFromSigningInfo(pi);
}
@SuppressWarnings("deprecation")
PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES);
return digestsFromSignatures(pi == null ? null : pi.signatures);
} catch (Exception e) {
Log.e(TAG, "Could not read installed app signatures", e);
return new HashSet<>();
}
}

private static Set<String> digestsForArchive(PackageManager pm, String path) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageInfo pi = pm.getPackageArchiveInfo(path, PackageManager.GET_SIGNING_CERTIFICATES);
return digestsFromSigningInfo(pi);
}
@SuppressWarnings("deprecation")
PackageInfo pi = pm.getPackageArchiveInfo(path, PackageManager.GET_SIGNATURES);
return digestsFromSignatures(pi == null ? null : pi.signatures);
} catch (Exception e) {
Log.e(TAG, "Could not read APK archive signatures", e);
return new HashSet<>();
}
}

private static Set<String> digestsFromSigningInfo(PackageInfo pi) {
if (pi == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P || pi.signingInfo == null) {
return new HashSet<>();
}
// Use the current APK content signers for both app and archive so the
// sets are directly comparable.
return digestsFromSignatures(pi.signingInfo.getApkContentsSigners());
}

private static Set<String> digestsFromSignatures(Signature[] sigs) {
Set<String> out = new HashSet<>();
if (sigs == null) {
return out;
}
for (Signature s : sigs) {
String d = sha256Hex(s.toByteArray());
if (d != null) {
out.add(d);
}
}
return out;
}

private static String sha256Hex(byte[] data) {
try {
byte[] h = MessageDigest.getInstance("SHA-256").digest(data);
StringBuilder sb = new StringBuilder(h.length * 2);
for (byte b : h) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* ============================================================================
* Name : CertDigests.java
* Author : IIAB Project
* Copyright : Copyright (c) 2026 IIAB Project
* Description : Domain rule: does a downloaded APK's set of signing-certificate
* digests match the running app's? Pure JVM, unit-testable.
* ============================================================================
*/
package org.iiab.controller.update.domain;

import java.util.Set;

/**
* Pure comparison of APK signing-certificate digests.
*
* <p>The OTA updater must only install an APK signed by the <em>same</em>
* certificate as the running app (Android enforces this for updates anyway;
* checking it ourselves before launching the installer rejects MITM/tampered
* or non-APK downloads early with a clear message — see tech-debt F15).
*
* <p>Certificate extraction (Android {@code PackageManager}) lives in the data
* layer; this only compares the resulting digest sets, so it is unit-testable
* on a plain JVM.
*/
public final class CertDigests {

private CertDigests() {
// Static utility; not instantiable.
}

/**
* True only if both sets are non-empty and identical. An empty candidate set
* (e.g. the download was not a validly-signed APK — a text/HTML error page)
* therefore fails, as does any set signed by a different certificate.
*/
public static boolean sameSigner(Set<String> appDigests, Set<String> apkDigests) {
if (appDigests == null || apkDigests == null) {
return false;
}
if (appDigests.isEmpty() || apkDigests.isEmpty()) {
return false;
}
return appDigests.equals(apkDigests);
}
}
Loading
Loading