From 0c695579fb674fd5fd9aa92aab2c7d61df8a8a79 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Tue, 23 Jun 2026 03:41:36 +0000 Subject: [PATCH] feat(controller): OTA updater security + correctness redesign (F15, PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app OTA self-updater downloaded the new APK to public Downloads and installed it with no integrity check. DownloadManager reports "complete" even when the server returned an HTML/text error page, so a wrong or MITM'd response was installed as garbage (the "downloaded a text file" bug); the download receiver was also registered EXPORTED. PR A — security + functional correctness, as a layered `update` slice: - domain: UpdateCheck (version rule) and CertDigests.sameSigner — pure JVM, unit-tested (UpdateCheckTest, CertDigestsTest). - data/ApkVerifier: the downloaded APK must be signed by the SAME certificate as the running app (public certs via PackageManager — no secrets, no server change). Rejects MITM/tampered APKs and non-APK downloads (kills the text-file bug), handling API <28 (GET_SIGNATURES) vs >=28 (GET_SIGNING_CERTIFICATES). - MainActivity seam: stage the APK in the app's PRIVATE external dir (not public Downloads); only install when the DownloadManager status is SUCCESSFUL; verify the signature before install (delete + clear error on failure); handle the API 26+ "install unknown apps" permission; register the completion receiver NOT_EXPORTED. New strings (en + es); REQUEST_INSTALL_PACKAGES permission. Out of scope (follow-ups): PR B (UpdateViewModel + in-app download-progress UX) and a network-security-config to scope cleartext to the local box hosts (S18), deferred to avoid risking box connectivity. --- CLAUDE.md | 12 ++ controller/app/src/main/AndroidManifest.xml | 1 + .../org/iiab/controller/MainActivity.java | 109 +++++++++++---- .../controller/update/data/ApkVerifier.java | 125 ++++++++++++++++++ .../controller/update/domain/CertDigests.java | 46 +++++++ .../controller/update/domain/UpdateCheck.java | 38 ++++++ .../app/src/main/res/values-es/strings.xml | 3 + .../app/src/main/res/values/strings.xml | 3 + .../update/domain/CertDigestsTest.java | 40 ++++++ .../update/domain/UpdateCheckTest.java | 25 ++++ controller/docs/TECH_DEBT_PLAN.md | 11 +- 11 files changed, 383 insertions(+), 30 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java create mode 100644 controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java create mode 100644 controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java create mode 100644 controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 0779325..6d52117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/controller/app/src/main/AndroidManifest.xml b/controller/app/src/main/AndroidManifest.xml index 8dece59..b1f3e48 100644 --- a/controller/app/src/main/AndroidManifest.xml +++ b/controller/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ package="org.iiab.controller"> + diff --git a/controller/app/src/main/java/org/iiab/controller/MainActivity.java b/controller/app/src/main/java/org/iiab/controller/MainActivity.java index 2b6c068..44f720b 100644 --- a/controller/app/src/main/java/org/iiab/controller/MainActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/MainActivity.java @@ -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; @@ -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); } @@ -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()) { @@ -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) { @@ -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 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 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(); } } diff --git a/controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java b/controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java new file mode 100644 index 0000000..1af0895 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java @@ -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. + * + *

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 before 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 appDigests = digestsForInstalledApp(pm, context.getPackageName()); + Set 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 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 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 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 digestsFromSignatures(Signature[] sigs) { + Set 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; + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java b/controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java new file mode 100644 index 0000000..adf132a --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java @@ -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. + * + *

The OTA updater must only install an APK signed by the same + * 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). + * + *

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 appDigests, Set apkDigests) { + if (appDigests == null || apkDigests == null) { + return false; + } + if (appDigests.isEmpty() || apkDigests.isEmpty()) { + return false; + } + return appDigests.equals(apkDigests); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java b/controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java new file mode 100644 index 0000000..998fb4e --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java @@ -0,0 +1,38 @@ +/* + * ============================================================================ + * Name : UpdateCheck.java + * Author : IIAB Project + * Copyright : Copyright (c) 2026 IIAB Project + * Description : Domain rule for the OTA self-updater: is the server build newer + * than the installed one? Pure JVM, unit-testable. + * ============================================================================ + */ +package org.iiab.controller.update.domain; + +/** + * Pure (framework-free) rules for the in-app OTA updater. + * + *

The server publishes a {@code versionCodeBase}; the installed app's raw + * {@code versionCode} maps to the same base by integer division by 10 (the + * project's per-ABI versioning scheme). An update is offered only when the + * server base is strictly greater. + */ +public final class UpdateCheck { + + /** The project encodes the ABI in the last digit of the versionCode. */ + public static final int VERSION_CODE_ABI_FACTOR = 10; + + private UpdateCheck() { + // Static utility; not instantiable. + } + + /** The shared base of a raw per-ABI {@code versionCode}. */ + public static int baseOf(int rawVersionCode) { + return rawVersionCode / VERSION_CODE_ABI_FACTOR; + } + + /** True if the server build (already a base) is newer than the installed raw code. */ + public static boolean isUpdateAvailable(int serverVersionCodeBase, int installedRawVersionCode) { + return serverVersionCodeBase > baseOf(installedRawVersionCode); + } +} diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml index ee11369..be407f5 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -500,4 +500,7 @@ Hardware compatible. Iniciando conexión... Credenciales de sincronización inválidas o no seguras. Escanea un código QR de sincronización IIAB válido. + No se pudo verificar la actualización y no se instaló. Inténtalo de nuevo. + La descarga de la actualización falló. Inténtalo de nuevo. + Permite instalar actualizaciones desde esta app y vuelve a tocar actualizar. \ No newline at end of file diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index bef9101..79cdcd7 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -515,4 +515,7 @@ Hardware compatible. Starting connection... Invalid or unsafe sync credentials. Please scan a valid IIAB sync QR code. + The update could not be verified and was not installed. Please try again. + The update download failed. Please try again. + Allow installing updates from this app, then tap update again. \ No newline at end of file diff --git a/controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java b/controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java new file mode 100644 index 0000000..a568d30 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java @@ -0,0 +1,40 @@ +package org.iiab.controller.update.domain; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.junit.Test; + +public class CertDigestsTest { + + private static Set of(String... v) { + return new HashSet<>(java.util.Arrays.asList(v)); + } + + @Test + public void acceptsIdenticalNonEmptySets() { + assertTrue(CertDigests.sameSigner(of("AA:BB"), of("AA:BB"))); + } + + @Test + public void rejectsDifferentSigner() { + assertFalse(CertDigests.sameSigner(of("AA:BB"), of("CC:DD"))); + } + + @Test + public void rejectsEmptyCandidate() { + // e.g. the download was a text/HTML page, not a signed APK + assertFalse(CertDigests.sameSigner(of("AA:BB"), Collections.emptySet())); + } + + @Test + public void rejectsNullsAndEmptyApp() { + assertFalse(CertDigests.sameSigner(null, of("AA"))); + assertFalse(CertDigests.sameSigner(of("AA"), null)); + assertFalse(CertDigests.sameSigner(Collections.emptySet(), of("AA"))); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java b/controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java new file mode 100644 index 0000000..bb593e0 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java @@ -0,0 +1,25 @@ +package org.iiab.controller.update.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class UpdateCheckTest { + + @Test + public void baseStripsTheAbiDigit() { + assertEquals(50, UpdateCheck.baseOf(500)); + assertEquals(50, UpdateCheck.baseOf(501)); + assertEquals(50, UpdateCheck.baseOf(509)); + } + + @Test + public void offersUpdateOnlyWhenServerBaseIsStrictlyNewer() { + assertTrue(UpdateCheck.isUpdateAvailable(51, 500)); // server 51 > local base 50 + assertFalse(UpdateCheck.isUpdateAvailable(50, 500)); // equal + assertFalse(UpdateCheck.isUpdateAvailable(49, 500)); // older + assertFalse(UpdateCheck.isUpdateAvailable(50, 509)); // same base, different ABI digit + } +} diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md index 07df08b..eabc6a5 100644 --- a/controller/docs/TECH_DEBT_PLAN.md +++ b/controller/docs/TECH_DEBT_PLAN.md @@ -76,8 +76,15 @@ _Last updated: 2026-06-17. Tracks remediation work against the findings below. I - New pure domain rule `org.iiab.controller.adb.domain.AdbShellCommand.isSafe(command)` (rejects `; | & $ \` ( ) < > ' " \\`, CR/LF and control chars). Unit-tested (`AdbShellCommandTest`). - `executeCommand` now fails closed (logs + does not open the stream) on an unsafe command; the two legitimate `settings put` / `device_config put` calls are unaffected. -**Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4** (PR #10), **D6** (PR #12), **D2** (PR #13), **D12** (PR #16), **D11** (PR #15). **S3 reverted** (see above), **S4**. Remaining: **F15**. - +**F15 — OTA self-updater security + correctness, PR A** (PR `feat/ota-updater-security-redesign`) +- The OTA updater downloaded the new APK to **public Downloads** and installed it with **no integrity check**; `DownloadManager` reports "complete" even for an HTML/text error page, so a wrong/MITM'd response was installed as garbage (the "downloaded a text file" bug), and the completion receiver was registered **EXPORTED**. +- PR A (security + functional correctness, layered `org.iiab.controller.update` slice): + - `domain/` — `UpdateCheck` (version rule) + `CertDigests.sameSigner` (pure, unit-tested). + - `data/ApkVerifier` — the downloaded APK must be signed by the **same certificate as the running app** (public certs via `PackageManager`, no secrets); rejects MITM/tampered APKs and non-APK downloads (kills the text-file bug). + - `MainActivity` seam: stage the APK in the app's **private** external dir (not public Downloads); only install when `DownloadManager` status is SUCCESSFUL; **verify signature before install**; handle the API 26+ "install unknown apps" permission; register the receiver **NOT_EXPORTED**. +- Follow-ups: **PR B** (presentation: `UpdateViewModel` + in-app download-progress UX) and a separate `network-security-config` to scope cleartext to the local box hosts (**S18**; deferred to avoid risking box connectivity). + +**Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4** (PR #10), **D6** (PR #12), **D2** (PR #13), **D12** (PR #16), **D11** (PR #15), **S4** (PR #28). **S3 reverted** (see above). Remaining: **F15** (PR A in review). ## 1. Executive summary The Controller is functional and shows real security intent (it SHA256-audits native binaries at build time, scrubs the keystore in CI, and scopes most broadcasts). But it carries debt on four fronts that scale badly toward the README's "millions of users" goal: