diff --git a/CLAUDE.md b/CLAUDE.md index 6d52117..c797bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,6 +204,22 @@ 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. +- 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 a97943a..7796699 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,38 @@ 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()); + 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 + : 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; + } + // 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(() -> { 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..0aee4bf --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java @@ -0,0 +1,202 @@ +/* + * ============================================================================ + * 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, OK_NO_MANIFEST, 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 { + // 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_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_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_NO_MANIFEST : 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/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/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..fdc365e 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -514,4 +514,7 @@ 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. + 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 e6bc46b..5fbe879 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -529,4 +529,7 @@ 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. + Imported, but this backup has no IIAB manifest, so it could not be fully verified. + 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: