From 102000b9bc77f46ee1008f9a3a12a29b7fe45290 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Tue, 23 Jun 2026 17:38:08 +0000 Subject: [PATCH 1/2] feat(controller): validate rootfs + architecture on backup import/restore The backup import accepted ANY file with no check that it is a rootfs or the correct architecture, so a 32-bit rootfs could be imported/restored into a 64-bit app (and vice-versa), or a ZIM/random file treated as a rootfs. Per the ABI-separation policy (ARM64<->ARM64, 32<->32) these must be blocked. - domain (pure, unit-tested): ElfClass (read 32/64 from an ELF header) and RootfsArchive (structural "looks like a rootfs" + pick a probe binary, prefix-tolerant for the installed-rootfs/ backup prefix). - data/RootfsArchiveValidator: lists the archive, runs the structural check, and probes one internal binary's ELF class against the app's ABI (Process.is64Bit()). Hard-blocks NOT_A_ROOTFS and a DEFINITE WRONG_ARCH; if the arch can't be determined it does not block on arch (no false positives). - Two gates: import (DeployFragment.importBackupSafely rejects + deletes the copied file) and restore (TarExtractor gains a validateRootfs overload that reuses its existing D11 entry listing; the 4-arg call stays validateRootfs=false so the rootfs-download install path is unchanged). New strings (en + es). Pending (documented, not here): an internal rootfs manifest emitted by the rootfs builder + in-app backup creation (authoritative "is it our rootfs", validated soft->strict), and the arbitrary-file attack-vector analysis. Needs on-device verification with a real 32-bit and 64-bit backup so the probe heuristic does not false-positive on legitimate restores. --- CLAUDE.md | 12 ++ .../org/iiab/controller/DeployFragment.java | 24 ++- .../org/iiab/controller/TarExtractor.java | 26 ++- .../deploy/data/RootfsArchiveValidator.java | 184 ++++++++++++++++++ .../controller/deploy/domain/ElfClass.java | 48 +++++ .../deploy/domain/RootfsArchive.java | 96 +++++++++ .../app/src/main/res/values-es/strings.xml | 4 +- .../app/src/main/res/values/strings.xml | 4 +- .../deploy/domain/ElfClassTest.java | 19 ++ .../deploy/domain/RootfsArchiveTest.java | 35 ++++ controller/docs/TECH_DEBT_PLAN.md | 10 +- 11 files changed, 456 insertions(+), 6 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java create mode 100644 controller/app/src/main/java/org/iiab/controller/deploy/domain/ElfClass.java create mode 100644 controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsArchive.java create mode 100644 controller/app/src/test/java/org/iiab/controller/deploy/domain/ElfClassTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsArchiveTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 6d52117..37c7271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,6 +204,18 @@ Phase-1 security + functional redesign of the in-app updater (tech-debt **F15**) - **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. +**Slice (DONE) — backup import/restore validation (`org.iiab.controller.deploy`)** +Enforces the ABI-separation policy + rootfs sanity at the two untrusted gates. + +- `domain/` — `ElfClass` (32/64 from an ELF header) + `RootfsArchive` + (structural rootfs check + probe-binary picker). Pure, unit-tested. +- `data/RootfsArchiveValidator` — lists the tar, checks structure, probes one + internal binary's ELF class vs the app ABI (`Process.is64Bit()`). +- **Seams:** import (`DeployFragment.importBackupSafely` rejects + deletes) and + restore (`TarExtractor.startExtraction(..., validateRootfs=true, ...)` reusing + the D11 listing). Hard-block on a definite wrong arch / non-rootfs. +- Pending: an internal rootfs manifest (producer + soft→strict consumer) and the + arbitrary-file attack-vector analysis. **Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat: god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index a97943a..8796877 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -2162,7 +2162,7 @@ private void refreshRestoreButtonLogic() { TarExtractor tarExtractor = new TarExtractor(); enableSystemProtection(); - tarExtractor.startExtraction(requireContext(), backupFile.getAbsolutePath(), iiabRootDir.getAbsolutePath(), new TarExtractor.ExtractionListener() { + tarExtractor.startExtraction(requireContext(), backupFile.getAbsolutePath(), iiabRootDir.getAbsolutePath(), true, new TarExtractor.ExtractionListener() { @Override public void onComplete(String destDir) { mainAct.runOnUiThread(() -> { @@ -2275,6 +2275,28 @@ private void importBackupSafely(Uri sourceUri) { os.close(); is.close(); + // Gate the import: must be a valid rootfs of THIS app's architecture + // (ABI policy). Reject and delete otherwise. + org.iiab.controller.deploy.data.RootfsArchiveValidator.Result vr = + org.iiab.controller.deploy.data.RootfsArchiveValidator + .validate(requireContext(), destFile.getAbsolutePath()); + if (vr != org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK) { + if (destFile.exists()) destFile.delete(); + final int errMsg = (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH) + ? R.string.install_error_wrong_arch + : R.string.install_error_not_rootfs; + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + isImporting = false; + updateDynamicButtons(); + btnImportBackup.setEnabled(true); + btnImportBackup.setText(getString(R.string.install_btn_import_backup)); + Snackbar.make(getView(), errMsg, Snackbar.LENGTH_LONG).show(); + }); + } + return; + } + if (getActivity() != null) { getActivity().runOnUiThread(() -> { isImporting = false; diff --git a/controller/app/src/main/java/org/iiab/controller/TarExtractor.java b/controller/app/src/main/java/org/iiab/controller/TarExtractor.java index f6ee3a3..7545bd7 100644 --- a/controller/app/src/main/java/org/iiab/controller/TarExtractor.java +++ b/controller/app/src/main/java/org/iiab/controller/TarExtractor.java @@ -40,6 +40,14 @@ default void onProgress(String line) { } } public void startExtraction(Context context, String archivePath, String destDir, ExtractionListener listener) { + startExtraction(context, archivePath, destDir, false, listener); + } + + /** + * @param validateRootfs when true (untrusted import/restore), also require the + * archive to look like a rootfs of THIS app's architecture before extracting. + */ + public void startExtraction(Context context, String archivePath, String destDir, boolean validateRootfs, ExtractionListener listener) { if (isExtracting) return; new Thread(() -> { @@ -60,12 +68,28 @@ public void startExtraction(Context context, String archivePath, String destDir, // bail out (without extracting anything) if any member is absolute // or climbs out of destDir via "..". An imported/restored backup is // untrusted, so this runs for every extraction. - for (String entry : listEntries(tarBinary, archivePath, isGzip)) { + List entries = listEntries(tarBinary, archivePath, isGzip); + for (String entry : entries) { if (ArchiveEntry.escapesRoot(entry)) { throw new Exception("Unsafe archive entry (path traversal): " + entry); } } + // For untrusted imports/restores: it must be a valid rootfs of THIS + // app's architecture (ABI policy: 32<->32, 64<->64). Reuses the + // listing above. Fail closed before extracting. + if (validateRootfs) { + org.iiab.controller.deploy.data.RootfsArchiveValidator.Result vr = + org.iiab.controller.deploy.data.RootfsArchiveValidator + .validateWithEntries(context, archivePath, isGzip, tarBinary, entries); + if (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.NOT_A_ROOTFS) { + throw new Exception(context.getString(R.string.install_error_not_rootfs)); + } + if (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH) { + throw new Exception(context.getString(R.string.install_error_wrong_arch)); + } + } + // 2. BUILD THE COMMAND List command = new ArrayList<>(); command.add(tarBinary); diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java new file mode 100644 index 0000000..255b745 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java @@ -0,0 +1,184 @@ +/* + * ============================================================================ + * Name : RootfsArchiveValidator.java + * Author : IIAB Project + * Copyright : Copyright (c) 2026 IIAB Project + * Description : Gate for imported/restored backups: is the tar archive a valid + * rootfs, and is it the SAME architecture as this app (ABI policy: + * ARM64<->ARM64, 32<->32)? Hard-blocks a positively-wrong arch. + * ============================================================================ + */ +package org.iiab.controller.deploy.data; + +import android.content.Context; +import android.util.Log; + +import org.iiab.controller.deploy.domain.ElfClass; +import org.iiab.controller.deploy.domain.RootfsArchive; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.zip.GZIPInputStream; + +/** + * Validates a {@code .tar.gz}/{@code .tar.xz} before it is imported or restored: + *
    + *
  1. structural sanity — it must look like a rootfs (rejects "imported a ZIM + * or a random file");
  2. + *
  3. architecture — if we can positively read an ELF binary inside, its class + * (32/64-bit) must match this app's ABI; otherwise it is hard-blocked.
  4. + *
+ * + *

Per the ABI-separation policy a definite architecture mismatch is blocked. + * When the architecture cannot be determined (no probe binary, or the probed + * member is a script/symlink) we do NOT block on arch (avoids false positives); + * the structural check still applies. + * + *

Must run off the main thread (spawns {@code tar}). + */ +public final class RootfsArchiveValidator { + + private static final String TAG = "IIAB-RootfsValidator"; + + public enum Result { OK, NOT_A_ROOTFS, WRONG_ARCH, UNREADABLE } + + private RootfsArchiveValidator() { + // Static utility; not instantiable. + } + + /** Validate from a file path (lists the archive itself; use for the import gate). */ + public static Result validate(Context context, String archivePath) { + try { + String tarBinary = resolveTar(context); + boolean isGzip = archivePath.toLowerCase(Locale.US).endsWith(".gz"); + List entries = listEntries(tarBinary, archivePath, isGzip); + if (entries.isEmpty()) { + return Result.UNREADABLE; + } + return validateWithEntries(context, archivePath, isGzip, tarBinary, entries); + } catch (Exception e) { + Log.e(TAG, "Validation error", e); + return Result.UNREADABLE; + } + } + + /** + * Validate when the caller already has the entry listing (e.g. {@code TarExtractor} + * lists once for the D11 traversal guard — reuse it here, no second listing). + */ + public static Result validateWithEntries(Context context, String archivePath, + boolean isGzip, String tarBinary, List entries) { + try { + if (!RootfsArchive.looksLikeRootfs(entries)) { + return Result.NOT_A_ROOTFS; + } + String probe = RootfsArchive.pickBinaryEntry(entries); + if (probe == null) { + return Result.OK; // structurally a rootfs; cannot probe arch -> don't hard-block + } + byte[] header = readMemberHeader(tarBinary, archivePath, isGzip, probe, 8); + int cls = ElfClass.of(header); + if (cls == ElfClass.UNKNOWN) { + return Result.OK; // probed member wasn't a plain ELF -> arch undetermined + } + int want = android.os.Process.is64Bit() ? ElfClass.BITS_64 : ElfClass.BITS_32; + return (cls == want) ? Result.OK : Result.WRONG_ARCH; + } catch (Exception e) { + Log.e(TAG, "Validation (with entries) error", e); + return Result.UNREADABLE; + } + } + + private static String resolveTar(Context context) { + File staticTar = new File(context.getApplicationInfo().nativeLibraryDir, "libtar.so"); + return staticTar.exists() ? staticTar.getAbsolutePath() : "/system/bin/tar"; + } + + private static List listEntries(String tarBinary, String archivePath, boolean isGzip) throws Exception { + List names = new ArrayList<>(); + List cmd = new ArrayList<>(); + cmd.add(tarBinary); + if (isGzip) { + cmd.add("-t"); + cmd.add("-f"); + cmd.add("-"); + } else { + cmd.add("-tf"); + cmd.add(archivePath); + } + Process p = new ProcessBuilder(cmd).start(); + Thread feeder = isGzip ? startGzipFeeder(archivePath, p.getOutputStream()) : null; + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + names.add(line); + } + } + p.waitFor(); + if (feeder != null) { + feeder.join(); + } + return names; + } + + private static byte[] readMemberHeader(String tarBinary, String archivePath, boolean isGzip, + String member, int n) throws Exception { + List cmd = new ArrayList<>(); + cmd.add(tarBinary); + cmd.add("-x"); + cmd.add("-O"); + cmd.add("-f"); + cmd.add(isGzip ? "-" : archivePath); + cmd.add(member); + Process p = new ProcessBuilder(cmd).start(); + Thread feeder = isGzip ? startGzipFeeder(archivePath, p.getOutputStream()) : null; + + byte[] buf = new byte[n]; + int got = 0; + try (InputStream is = p.getInputStream()) { + int r; + while (got < n && (r = is.read(buf, got, n - got)) != -1) { + got += r; + } + } + p.destroy(); // we only need the header; let tar stop + if (feeder != null) { + feeder.join(300); + } + if (got < 5) { + return null; + } + byte[] out = new byte[got]; + System.arraycopy(buf, 0, out, 0, got); + return out; + } + + private static Thread startGzipFeeder(String archivePath, OutputStream os) { + Thread t = new Thread(() -> { + try (GZIPInputStream gis = new GZIPInputStream(new FileInputStream(archivePath))) { + byte[] buffer = new byte[8192]; + int read; + while ((read = gis.read(buffer)) != -1) { + os.write(buffer, 0, read); + } + os.flush(); + } catch (Exception ignored) { + // broken pipe once we stop reading is expected + } finally { + try { + os.close(); + } catch (Exception ignored) { + } + } + }); + t.start(); + return t; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/domain/ElfClass.java b/controller/app/src/main/java/org/iiab/controller/deploy/domain/ElfClass.java new file mode 100644 index 0000000..0ed48d5 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/domain/ElfClass.java @@ -0,0 +1,48 @@ +/* + * ============================================================================ + * Name : ElfClass.java + * Author : IIAB Project + * Copyright : Copyright (c) 2026 IIAB Project + * Description : Domain rule: read the ELF class (32- vs 64-bit) from a binary + * header. Used to enforce ABI separation on imported/restored + * rootfs archives (ARM64<->ARM64, 32<->32). + * ============================================================================ + */ +package org.iiab.controller.deploy.domain; + +/** + * Pure helper that classifies an ELF binary as 32- or 64-bit from its first + * bytes (the ELF identification header). No I/O — the caller supplies the bytes. + * + *

ELF layout: magic {@code 0x7F 'E' 'L' 'F'} then byte 4 ({@code EI_CLASS}) + * is {@code 1} for 32-bit ({@code ELFCLASS32}) or {@code 2} for 64-bit + * ({@code ELFCLASS64}). + */ +public final class ElfClass { + + public static final int UNKNOWN = 0; + public static final int BITS_32 = 32; + public static final int BITS_64 = 64; + + private ElfClass() { + // Static utility; not instantiable. + } + + /** Classify from the leading bytes of a file; {@link #UNKNOWN} if not an ELF. */ + public static int of(byte[] header) { + if (header == null || header.length < 5) { + return UNKNOWN; + } + if ((header[0] & 0xFF) != 0x7F || header[1] != 'E' || header[2] != 'L' || header[3] != 'F') { + return UNKNOWN; + } + int eiClass = header[4] & 0xFF; + if (eiClass == 1) { + return BITS_32; + } + if (eiClass == 2) { + return BITS_64; + } + return UNKNOWN; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsArchive.java b/controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsArchive.java new file mode 100644 index 0000000..dbc0ba9 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsArchive.java @@ -0,0 +1,96 @@ +/* + * ============================================================================ + * Name : RootfsArchive.java + * Author : IIAB Project + * Copyright : Copyright (c) 2026 IIAB Project + * Description : Pure heuristics over an archive's entry list: does it look like + * a Linux rootfs, and which member is a good ELF binary to probe + * for its architecture? Used to gate import/restore. + * ============================================================================ + */ +package org.iiab.controller.deploy.domain; + +import java.util.Collection; + +/** + * Pure (framework-free) inspection of a tar archive's member names + * (the listing), used to reject "import a random file / a ZIM as a rootfs" and + * to choose a member whose bytes can be probed for the ELF architecture. + * + *

Names may carry prefixes like {@code ./} or {@code installed-rootfs/} + * (app-created backups are {@code tar -C installed-rootfs}); matching is + * therefore prefix-tolerant (substring / suffix). + */ +public final class RootfsArchive { + + /** Well-known Debian/Ubuntu binaries that are real ELF files (not scripts/symlinks). */ + private static final String[] PROBE_BINARIES = { + "bin/dash", "bin/bash", "bin/ls", "bin/cat", "bin/busybox", + "usr/bin/dpkg", "sbin/init" + }; + + private RootfsArchive() { + // Static utility; not instantiable. + } + + private static String norm(String name) { + if (name == null) { + return ""; + } + String n = name.replace('\\', '/').trim(); + while (n.startsWith("./")) { + n = n.substring(2); + } + while (n.startsWith("/")) { + n = n.substring(1); + } + return n; + } + + /** + * True if the listing looks like a Linux rootfs: it contains an {@code etc/} + * directory and a {@code bin/}, {@code sbin/} or {@code usr/} directory + * (prefix-tolerant). Rejects obviously-non-rootfs archives (e.g. a single + * {@code .zim} file). + */ + public static boolean looksLikeRootfs(Collection entryNames) { + if (entryNames == null) { + return false; + } + boolean hasEtc = false; + boolean hasBinOrUsr = false; + for (String raw : entryNames) { + String n = norm(raw); + if (n.contains("etc/")) { + hasEtc = true; + } + if (n.contains("bin/") || n.contains("sbin/") || n.contains("usr/")) { + hasBinOrUsr = true; + } + if (hasEtc && hasBinOrUsr) { + return true; + } + } + return false; + } + + /** + * Pick an archive member that should be a real ELF binary, so the caller can + * extract its header and read the architecture. Returns the original entry + * name (for {@code tar} extraction), or {@code null} if none was found. + */ + public static String pickBinaryEntry(Collection entryNames) { + if (entryNames == null) { + return null; + } + for (String wanted : PROBE_BINARIES) { + for (String raw : entryNames) { + String n = norm(raw); + if (n.equals(wanted) || n.endsWith("/" + wanted)) { + return raw; + } + } + } + return null; + } +} diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml index 6525636..e0bad5b 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -514,4 +514,6 @@ Instalar Cancelar Registro de extracción - \ No newline at end of file + Este archivo no es un backup de rootfs IIAB válido, así que no se usó. + Este backup es para otra arquitectura de CPU (32-bit vs 64-bit) y no puede usarse en esta app. + diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index e6bc46b..0054ea0 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -529,4 +529,6 @@ Install Cancel Extraction log - \ No newline at end of file + This file is not a valid IIAB rootfs backup, so it was not used. + This backup is for a different CPU architecture (32-bit vs 64-bit) and cannot be used by this app. + diff --git a/controller/app/src/test/java/org/iiab/controller/deploy/domain/ElfClassTest.java b/controller/app/src/test/java/org/iiab/controller/deploy/domain/ElfClassTest.java new file mode 100644 index 0000000..383bb3f --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/deploy/domain/ElfClassTest.java @@ -0,0 +1,19 @@ +package org.iiab.controller.deploy.domain; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class ElfClassTest { + private static byte[] elf(int eiClass) { + return new byte[]{0x7F, 'E', 'L', 'F', (byte) eiClass, 0, 0, 0}; + } + + @Test public void detects32() { assertEquals(ElfClass.BITS_32, ElfClass.of(elf(1))); } + @Test public void detects64() { assertEquals(ElfClass.BITS_64, ElfClass.of(elf(2))); } + @Test public void unknownForNonElf() { + assertEquals(ElfClass.UNKNOWN, ElfClass.of("#!/bin/sh\n".getBytes())); + assertEquals(ElfClass.UNKNOWN, ElfClass.of(new byte[]{0x7F, 'E', 'L', 'F'})); // too short + assertEquals(ElfClass.UNKNOWN, ElfClass.of(null)); + assertEquals(ElfClass.UNKNOWN, ElfClass.of(elf(9))); // bad class byte + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsArchiveTest.java b/controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsArchiveTest.java new file mode 100644 index 0000000..73cf23e --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsArchiveTest.java @@ -0,0 +1,35 @@ +package org.iiab.controller.deploy.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; + +public class RootfsArchiveTest { + + @Test public void recognisesABackupRootfs() { + assertTrue(RootfsArchive.looksLikeRootfs(Arrays.asList( + "installed-rootfs/etc/hosts", "installed-rootfs/bin/bash", "installed-rootfs/usr/"))); + } + + @Test public void recognisesAPlainRootfs() { + assertTrue(RootfsArchive.looksLikeRootfs(Arrays.asList("./etc/os-release", "./usr/bin/dpkg"))); + } + + @Test public void rejectsNonRootfs() { + assertFalse(RootfsArchive.looksLikeRootfs(Arrays.asList("wikipedia_en_all.zim"))); + assertFalse(RootfsArchive.looksLikeRootfs(Collections.emptyList())); + assertFalse(RootfsArchive.looksLikeRootfs(null)); + } + + @Test public void picksAKnownBinaryReturningOriginalName() { + assertEquals("installed-rootfs/bin/bash", + RootfsArchive.pickBinaryEntry(Arrays.asList( + "installed-rootfs/etc/hosts", "installed-rootfs/bin/bash"))); + assertNull(RootfsArchive.pickBinaryEntry(Arrays.asList("etc/hosts", "var/log/x"))); + } +} diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md index 66bb295..2575f14 100644 --- a/controller/docs/TECH_DEBT_PLAN.md +++ b/controller/docs/TECH_DEBT_PLAN.md @@ -89,8 +89,14 @@ _Last updated: 2026-06-23. Tracks remediation work against the findings below. I - `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 +**Backup import/restore validation (ABI separation + rootfs sanity): DONE** (PR `feat/rootfs-import-restore-validation`) +- The import flow accepted **any** file as a backup, with no check that it is a rootfs or the right architecture. Per the ABI-separation policy (ARM64↔ARM64, 32↔32), a 32-bit rootfs must not be importable/restorable into a 64-bit app (and vice-versa), and a non-rootfs (e.g. a ZIM) must not be treated as one. +- New pure domain: `deploy/domain/ElfClass` (read 32/64 from an ELF header) + `RootfsArchive` (structural "looks like a rootfs" + pick a probe binary). Unit-tested. +- `deploy/data/RootfsArchiveValidator` lists the archive, runs the structural check, and probes one internal binary's ELF class vs the app's ABI (`Process.is64Bit()`). **Two gates, hard-block, fail-closed:** at **import** (reject + delete) and at **restore** (`TarExtractor` gains a `validateRootfs` overload that reuses its D11 listing). A *definite* wrong-arch is blocked; if arch can't be determined we don't block on arch (the structural check still applies). +- **Identity manifest (soft):** also reads the build's `installed-rootfs/iiab/.iiab-rootfs.json` (per `docs/ROOTFS_MANIFEST.md`) — when present it authoritatively gates `kind` + `arch`; when **absent** it shows a non-blocking "manifest not found" alert and falls back to the ELF/structure heuristic. (Integrity `iiab-tree-sha256-v1` / `Result.CORRUPT` is the next PR.) +- **Pending (documented):** the **integrity** treehash verification (Java ustar/pax reader mirroring `tools/iiab_tree_hash.py`), the in-app backup-writer emitting both members, and the arbitrary-file attack-vector analysis. Verify on a real device with a 32-bit and a 64-bit backup. + +**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: From abcd76937d79e6352708ecef5acfe9e5414b7eec Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Tue, 23 Jun 2026 19:07:00 +0000 Subject: [PATCH 2/2] feat(controller): read rootfs identity manifest on import/restore (soft + alert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the import/restore validation: now reads the build's identity manifest `installed-rootfs/iiab/.iiab-rootfs.json` (canonical contract: docs/ROOTFS_MANIFEST.md), which the rootfs builder (PR #24) emits as the first tar member. - New deploy/data/RootfsManifest: a dependency-free reader that parses only the first few 512-byte tar headers (the identity member is packed first) to get {kind, arch} — no reliance on libtar `--occurrence`, fast (a few KB). - RootfsArchiveValidator: when the manifest is present it authoritatively gates `kind` (must be "iiab-rootfs") and `arch` (must equal this app's ABI); the ELF probe is skipped. When absent it returns OK_NO_MANIFEST and falls back to the existing ELF/structure heuristic. - Soft phase (this version): a missing manifest does NOT block — import shows a non-blocking "manifest not found" warning and proceeds; a later version will validate silently / then strictly. Wrong kind/arch is still hard-blocked. - New string install_warn_manifest_missing (en + es). Next (separate PR): the integrity check (iiab-tree-sha256-v1 -> Result.CORRUPT, a Java ustar/pax reader mirroring tools/iiab_tree_hash.py) and the in-app backup-writer emitting both members. --- CLAUDE.md | 8 +- .../org/iiab/controller/DeployFragment.java | 12 +- .../deploy/data/RootfsArchiveValidator.java | 26 ++- .../deploy/data/RootfsManifest.java | 197 ++++++++++++++++++ .../app/src/main/res/values-es/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 6 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java diff --git a/CLAUDE.md b/CLAUDE.md index 37c7271..c797bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,8 +214,12 @@ Enforces the ABI-separation policy + rootfs sanity at the two untrusted gates. - **Seams:** import (`DeployFragment.importBackupSafely` rejects + deletes) and restore (`TarExtractor.startExtraction(..., validateRootfs=true, ...)` reusing the D11 listing). Hard-block on a definite wrong arch / non-rootfs. -- Pending: an internal rootfs manifest (producer + soft→strict consumer) and the - arbitrary-file attack-vector analysis. +- Identity manifest (soft): reads `installed-rootfs/iiab/.iiab-rootfs.json` + (`docs/ROOTFS_MANIFEST.md`) when present (authoritative `kind`+`arch`); when + absent, a non-blocking "manifest not found" alert + the ELF/structure fallback. +- Pending: the integrity `iiab-tree-sha256-v1` check (`Result.CORRUPT`, mirrors + `tools/iiab_tree_hash.py`), the in-app backup-writer emitting both members, and + the arbitrary-file attack-vector analysis. **Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat: god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 8796877..7796699 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -2280,7 +2280,11 @@ private void importBackupSafely(Uri sourceUri) { org.iiab.controller.deploy.data.RootfsArchiveValidator.Result vr = org.iiab.controller.deploy.data.RootfsArchiveValidator .validate(requireContext(), destFile.getAbsolutePath()); - if (vr != org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK) { + boolean okValidated = + vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK; + boolean okNoManifest = + vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK_NO_MANIFEST; + if (!okValidated && !okNoManifest) { if (destFile.exists()) destFile.delete(); final int errMsg = (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH) ? R.string.install_error_wrong_arch @@ -2296,6 +2300,12 @@ private void importBackupSafely(Uri sourceUri) { } return; } + // Soft phase: no identity manifest -> import is allowed, but warn the + // user (a future version will validate silently). See docs/ROOTFS_MANIFEST.md. + if (okNoManifest && getActivity() != null) { + getActivity().runOnUiThread(() -> + Snackbar.make(getView(), R.string.install_warn_manifest_missing, Snackbar.LENGTH_LONG).show()); + } if (getActivity() != null) { getActivity().runOnUiThread(() -> { diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java index 255b745..0aee4bf 100644 --- a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java @@ -47,7 +47,7 @@ public final class RootfsArchiveValidator { private static final String TAG = "IIAB-RootfsValidator"; - public enum Result { OK, NOT_A_ROOTFS, WRONG_ARCH, UNREADABLE } + public enum Result { OK, OK_NO_MANIFEST, NOT_A_ROOTFS, WRONG_ARCH, UNREADABLE } private RootfsArchiveValidator() { // Static utility; not instantiable. @@ -76,20 +76,38 @@ public static Result validate(Context context, String archivePath) { public static Result validateWithEntries(Context context, String archivePath, boolean isGzip, String tarBinary, List entries) { try { + // Authoritative path: the build/app embeds an identity manifest + // (installed-rootfs/iiab/.iiab-rootfs.json, packed first). See + // docs/ROOTFS_MANIFEST.md. When present it decides kind + arch. + RootfsManifest.Identity id = RootfsManifest.read(archivePath); + if (id.present) { + if (!"iiab-rootfs".equals(id.kind)) { + return Result.NOT_A_ROOTFS; + } + if (id.arch != null && !id.arch.isEmpty() + && !id.arch.equals(RootfsManifest.appAbiId())) { + return Result.WRONG_ARCH; + } + return Result.OK; // manifest-validated; no need to probe ELF + } + + // Soft fallback (no manifest): legacy ELF/structure heuristic. We + // return OK_NO_MANIFEST so the caller can surface a "manifest not + // found" alert (non-blocking) for this first version. if (!RootfsArchive.looksLikeRootfs(entries)) { return Result.NOT_A_ROOTFS; } String probe = RootfsArchive.pickBinaryEntry(entries); if (probe == null) { - return Result.OK; // structurally a rootfs; cannot probe arch -> don't hard-block + return Result.OK_NO_MANIFEST; // structurally a rootfs; cannot probe arch } byte[] header = readMemberHeader(tarBinary, archivePath, isGzip, probe, 8); int cls = ElfClass.of(header); if (cls == ElfClass.UNKNOWN) { - return Result.OK; // probed member wasn't a plain ELF -> arch undetermined + return Result.OK_NO_MANIFEST; // probed member wasn't a plain ELF -> arch undetermined } int want = android.os.Process.is64Bit() ? ElfClass.BITS_64 : ElfClass.BITS_32; - return (cls == want) ? Result.OK : Result.WRONG_ARCH; + return (cls == want) ? Result.OK_NO_MANIFEST : Result.WRONG_ARCH; } catch (Exception e) { Log.e(TAG, "Validation (with entries) error", e); return Result.UNREADABLE; diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java new file mode 100644 index 0000000..03ba232 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java @@ -0,0 +1,197 @@ +/* + * ============================================================================ + * Name : RootfsManifest.java + * Author : IIAB Project + * Copyright : Copyright (c) 2026 IIAB Project + * Description : Reads the identity manifest (installed-rootfs/iiab/.iiab-rootfs.json, + * packed FIRST) from a rootfs/backup tarball. Canonical contract: + * docs/ROOTFS_MANIFEST.md (schema 1). Soft phase: when present it + * authoritatively gates kind/arch; when absent the caller alerts + * and falls back to the legacy ELF/structure heuristic. + * ============================================================================ + */ +package org.iiab.controller.deploy.data; + +import android.util.Log; + +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Locale; +import java.util.zip.GZIPInputStream; + +/** + * Minimal, dependency-free reader for the identity manifest. The manifest is the + * FIRST member of the tar (see {@code docs/ROOTFS_MANIFEST.md}), so we parse only + * the first few 512-byte tar headers in Java — a few KB of decompressed input — + * rather than shelling to {@code tar} (avoids relying on {@code --occurrence} + * support in the bundled {@code libtar.so}). + */ +public final class RootfsManifest { + + private static final String TAG = "IIAB-RootfsManifest"; + private static final String MEMBER_SUFFIX = "iiab/.iiab-rootfs.json"; + private static final int MAX_HEADERS = 8; // identity is first; scan a few in case of pax/dir entries + private static final int MAX_JSON_BYTES = 64 * 1024; + + /** What the identity manifest tells us. {@code present=false} means none found. */ + public static final class Identity { + public final boolean present; + public final String kind; + public final String arch; + + private Identity(boolean present, String kind, String arch) { + this.present = present; + this.kind = kind; + this.arch = arch; + } + + static Identity absent() { + return new Identity(false, null, null); + } + } + + private RootfsManifest() { + // Static utility; not instantiable. + } + + /** The running app's ABI id, in the manifest's vocabulary. */ + public static String appAbiId() { + return android.os.Process.is64Bit() ? "arm64-v8a" : "armeabi-v7a"; + } + + /** Read the identity manifest from {@code archivePath}, or {@link Identity#absent()}. */ + public static Identity read(String archivePath) { + boolean isGzip = archivePath.toLowerCase(Locale.US).endsWith(".gz"); + try (InputStream raw = new FileInputStream(archivePath); + InputStream in = isGzip ? new GZIPInputStream(raw) : new BufferedInputStream(raw)) { + + byte[] header = new byte[512]; + for (int i = 0; i < MAX_HEADERS; i++) { + if (!readFully(in, header, 512)) { + break; + } + if (isAllZero(header)) { + break; // end-of-archive marker + } + String name = cString(header, 0, 100); + long size = parseOctal(header, 124, 12); + if (size < 0) { + break; + } + if (normalizeEndsWith(name, MEMBER_SUFFIX)) { + int toRead = (int) Math.min(size, MAX_JSON_BYTES); + byte[] json = new byte[toRead]; + if (!readFully(in, json, toRead)) { + break; + } + return parse(new String(json, "UTF-8")); + } + // Skip this member's content, padded to a 512-byte boundary. + long padded = ((size + 511) / 512) * 512; + if (!skipFully(in, padded)) { + break; + } + } + } catch (Exception e) { + Log.w(TAG, "Could not read identity manifest: " + e.getMessage()); + } + return Identity.absent(); + } + + private static Identity parse(String jsonText) { + try { + JSONObject o = new JSONObject(jsonText); + String kind = o.optString("kind", null); + String arch = o.optString("arch", null); + return new Identity(true, kind, arch); + } catch (Exception e) { + Log.w(TAG, "Identity manifest present but unparseable: " + e.getMessage()); + // Present-but-broken: treat as present with no usable fields so the + // caller's kind check fails closed. + return new Identity(true, null, null); + } + } + + private static boolean normalizeEndsWith(String rawName, String suffix) { + if (rawName == null) { + return false; + } + String n = rawName.replace('\\', '/'); + if (n.startsWith("./")) { + n = n.substring(2); + } + while (n.startsWith("/")) { + n = n.substring(1); + } + return n.equals("installed-rootfs/" + suffix) || n.endsWith("/" + suffix) || n.equals(suffix); + } + + private static String cString(byte[] b, int off, int len) { + int end = off; + while (end < off + len && b[end] != 0) { + end++; + } + try { + return new String(b, off, end - off, "UTF-8"); + } catch (Exception e) { + return ""; + } + } + + private static long parseOctal(byte[] b, int off, int len) { + long val = 0; + boolean any = false; + for (int i = off; i < off + len; i++) { + int c = b[i] & 0xFF; + if (c == 0 || c == ' ') { + if (any) { + break; + } + continue; + } + if (c < '0' || c > '7') { + return -1; + } + val = (val << 3) + (c - '0'); + any = true; + } + return any ? val : 0; + } + + private static boolean isAllZero(byte[] b) { + for (byte x : b) { + if (x != 0) { + return false; + } + } + return true; + } + + private static boolean readFully(InputStream in, byte[] buf, int n) throws java.io.IOException { + int off = 0; + while (off < n) { + int r = in.read(buf, off, n - off); + if (r == -1) { + return false; + } + off += r; + } + return true; + } + + private static boolean skipFully(InputStream in, long n) throws java.io.IOException { + long left = n; + byte[] tmp = new byte[8192]; + while (left > 0) { + int r = in.read(tmp, 0, (int) Math.min(tmp.length, left)); + if (r == -1) { + return false; + } + left -= r; + } + return true; + } +} diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml index e0bad5b..fdc365e 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -516,4 +516,5 @@ Registro de extracción Este archivo no es un backup de rootfs IIAB válido, así que no se usó. Este backup es para otra arquitectura de CPU (32-bit vs 64-bit) y no puede usarse en esta app. + Importado, pero este backup no tiene manifiesto IIAB, así que no se pudo verificar del todo. diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index 0054ea0..5fbe879 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -531,4 +531,5 @@ Extraction log This file is not a valid IIAB rootfs backup, so it was not used. This backup is for a different CPU architecture (32-bit vs 64-bit) and cannot be used by this app. + Imported, but this backup has no IIAB manifest, so it could not be fully verified.