|
| 1 | +# IIAB-oA rootfs manifest — contract (`schema: 1`) |
| 2 | + |
| 3 | +Single source of truth for the two small members embedded in every IIAB-oA rootfs |
| 4 | +tarball. **Two producers, one schema:** the build pipeline |
| 5 | +(`tools/build-iiab-rootfs.sh`) and the in-app backup writer both emit these |
| 6 | +members; the in-app validator reads them on **import/restore** (the untrusted |
| 7 | +gates). Both sides reference THIS file instead of re-describing the recipe, so |
| 8 | +they cannot drift. |
| 9 | + |
| 10 | +It exists because downloads are already covered by the sidecar `.meta4` |
| 11 | +(`aria2 --check-integrity`), but a **manual import** from external storage |
| 12 | +(USB/SD) has no sidecar — so the tarball must be able to validate **itself**. |
| 13 | + |
| 14 | +Scope: **integrity** (corruption / "did this arrive intact, and is it the rootfs |
| 15 | +I think it is"). It is **not** anti-tamper — an attacker who edits the tree can |
| 16 | +recompute the manifest. Authenticity (signing) is a possible future layer, not |
| 17 | +this one. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## The two members |
| 22 | + |
| 23 | +Both live inside the rootfs tree (the archive's top-level dir is |
| 24 | +`installed-rootfs/iiab/`): |
| 25 | + |
| 26 | +| Member | Path (in-archive) | Position in tar | In treehash? | |
| 27 | +|--------|-------------------|-----------------|--------------| |
| 28 | +| Identity | `installed-rootfs/iiab/.iiab-rootfs.json` | **FIRST** | **yes** | |
| 29 | +| Integrity | `installed-rootfs/iiab/.iiab-rootfs.integrity.json` | **LAST** | **no (excluded)** | |
| 30 | + |
| 31 | +- **Identity first** so the validator can read it after decompressing only a few |
| 32 | + KB — a fast arch/structure gate with no full scan. |
| 33 | +- **Integrity last** because its `treehash` cannot be known until the rest of the |
| 34 | + tree is packed (chicken-and-egg); it is the only member excluded from the hash. |
| 35 | + |
| 36 | +### Identity — `.iiab-rootfs.json` |
| 37 | +```json |
| 38 | +{ "schema": 1, "kind": "iiab-rootfs", |
| 39 | + "arch": "armeabi-v7a", "deb_arch": "arm", |
| 40 | + "tier": "standard", "iiab_commit": "ab88e5d", |
| 41 | + "built": "2026.158", "base": "debian-trixie", |
| 42 | + "builder": "build-iiab-rootfs.sh" } |
| 43 | +``` |
| 44 | +- `arch` uses the **Android ABI id** (`arm64-v8a` | `armeabi-v7a`); `deb_arch` |
| 45 | + (`aarch64` | `arm`) is included for reference. |
| 46 | +- The validator uses `kind` + `arch`; the rest is metadata it may surface in the |
| 47 | + UI. Unknown extra keys must be ignored (forward-compatible). |
| 48 | + |
| 49 | +### Integrity — `.iiab-rootfs.integrity.json` |
| 50 | +```json |
| 51 | +{ "schema": 1, "algo": "iiab-tree-sha256-v1", "treehash": "<lowercase hex>" } |
| 52 | +``` |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## `iiab-tree-sha256-v1` — the digest (FROZEN) |
| 57 | + |
| 58 | +Computed over the archive's **logical tar members** — never over an on-disk |
| 59 | +filesystem walk. (Reason: hardlinks. On disk a hardlink is a second name for one |
| 60 | +inode; in the tar, GNU tar emits the first occurrence as a regular file and the |
| 61 | +rest as hardlink entries. Hashing the members of the actual artifact makes the |
| 62 | +representation identical for whoever reads that artifact.) |
| 63 | + |
| 64 | +### Per-member digest |
| 65 | +For every logical member **except** the integrity member |
| 66 | +(`installed-rootfs/iiab/.iiab-rootfs.integrity.json`), and **including** the |
| 67 | +identity member: |
| 68 | + |
| 69 | +``` |
| 70 | +m_i = SHA256( normalized_path + 0x00 + type + 0x00 + payload ) |
| 71 | +``` |
| 72 | + |
| 73 | +- **`normalized_path`** (UTF-8 bytes): replace `\` -> `/`; remove one leading |
| 74 | + `./`; remove all leading `/`; remove all trailing `/`. Keep the full |
| 75 | + `installed-rootfs/...` prefix. **Do NOT trim whitespace** (a path may |
| 76 | + legitimately contain leading/trailing spaces). (D11 already guarantees no `..`.) |
| 77 | +- **`type`** (one ASCII byte) and **`payload`**, by tar typeflag: |
| 78 | + |
| 79 | + | tar typeflag | `type` | `payload` | |
| 80 | + |---|---|---| |
| 81 | + | REGTYPE `0`/`\0`, CONTTYPE | `f` | file content bytes | |
| 82 | + | DIRTYPE `5` | `d` | (empty) | |
| 83 | + | SYMTYPE `2` | `l` | symlink target **as stored (raw)** + `0x00` | |
| 84 | + | LNKTYPE `1` (hardlink) | `h` | **`normalized_path` of the target** + `0x00` | |
| 85 | + | anything else (char/block/fifo/socket) | — | **build ABORTS; verifier -> `CORRUPT`/unverifiable** | |
| 86 | + |
| 87 | + (The build already `--exclude '*/dev/*'` and tar drops sockets, so no other |
| 88 | + types should appear in a valid artifact.) |
| 89 | +- **pax / GNU long-name / extended-header entries are not members.** Both sides |
| 90 | + resolve them to the logical member (real name + content) and never feed the |
| 91 | + auxiliary pseudo-entry into the digest. (Python `tarfile` and a ustar/pax reader |
| 92 | + both do this transparently.) |
| 93 | + |
| 94 | +### Combine (order-independent + domain-separated) |
| 95 | +``` |
| 96 | +treehash = lowercase_hex( SHA256( ascii("iiab-tree-sha256-v1") + 0x00 |
| 97 | + + concat( m_i sorted ascending by raw 32-byte value ) ) ) |
| 98 | +``` |
| 99 | +The combine is **order-independent**: a byte-sort over fixed 32-byte digests, with |
| 100 | +no locale/collation/tar-version dependence. Cost is N x 32 bytes held in memory |
| 101 | +plus a sort (tens of MB even for a huge tree); member content is streamed, never |
| 102 | +buffered whole. |
| 103 | + |
| 104 | +### Per-artifact, not cross-artifact |
| 105 | +The treehash is defined over the logical members of **the artifact as packed**, |
| 106 | +and is always verified against **that same artifact's** embedded value. Because |
| 107 | +the hardlink `f`/`h` assignment follows the archive's member order, two |
| 108 | +independent packings of the "same tree" may yield different treehashes — that is |
| 109 | +expected and is not a parity problem: a producer hashes the members it writes, and |
| 110 | +the verifier reads those same members. Producers therefore do **not** need a |
| 111 | +canonical cross-producer member order. (The build still packs the non-identity |
| 112 | +members with `tar --sort=name` for reproducible artifacts, but the recipe does not |
| 113 | +require it.) |
| 114 | + |
| 115 | +### Lock points (anything below changes the hash -> bump `algo`) |
| 116 | +1. Domain prefix bytes `iiab-tree-sha256-v1` and the `0x00` after it. |
| 117 | +2. Per-member field order and the two `0x00` separators. |
| 118 | +3. The typeflag -> `type` byte mapping in the table above. |
| 119 | +4. Symlink payload is the **raw stored** target; hardlink payload is the |
| 120 | + **normalized** target; both followed by a trailing `0x00`. |
| 121 | +5. Path normalization rules exactly as stated, including **no whitespace trim**. |
| 122 | +6. Combine = SHA256 over the **ascending byte-sorted** 32-byte digests, prefixed |
| 123 | + by the domain string + `0x00`. |
| 124 | +7. The integrity member is the **only** exclusion; the identity member **is** |
| 125 | + hashed. |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +## Producing it (build pipeline & app backup) |
| 130 | + |
| 131 | +1. Pack the tree with the **identity member first** and **without** the integrity |
| 132 | + member. |
| 133 | +2. Compute `treehash` over that archive's logical members (excluding the integrity |
| 134 | + member by name — it is absent at this point). |
| 135 | +3. Append the **integrity member last**, carrying `treehash`. |
| 136 | +4. **Self-verify:** re-read the finished artifact, recompute over all members |
| 137 | + except the integrity member, and assert it equals the stored `treehash`. Never |
| 138 | + ship an artifact your own verifier would reject. |
| 139 | + |
| 140 | +The reference implementation is a small streaming pass (Python `tarfile` on the |
| 141 | +build side; a ustar/pax reader in the app). It streams member content (no |
| 142 | +whole-file buffering), so it scales to multi-GB / 80 GB rootfs images. |
| 143 | + |
| 144 | +## Verifying it (import / restore) |
| 145 | +1. Read the **identity** member (fast, first) -> structural + `arch` gate. |
| 146 | +2. Stream the gzip->tar once, recompute the treehash over all members except the |
| 147 | + integrity member, and compare to the integrity member's `treehash`. |
| 148 | +3. On mismatch or an unverifiable member type: `Result.CORRUPT`. |
| 149 | + |
| 150 | +## Rollout — soft -> strict |
| 151 | +- **Soft (now):** validate the manifest when present; fall back to the legacy |
| 152 | + ELF/structure heuristic when absent. Nothing breaks for older artifacts. |
| 153 | +- **Strict (later):** require the manifest, once both producers reliably emit it. |
| 154 | + |
| 155 | +## Implementations |
| 156 | +- Build pipeline: `tools/build-iiab-rootfs.sh` (generation + build self-verify). |
| 157 | +- App: `RootfsArchiveValidator` (verifier, `Result.CORRUPT`) and the backup writer. |
| 158 | + |
| 159 | +Bump `schema` on any breaking change to the members; bump the `algo` string |
| 160 | +(`iiab-tree-sha256-v2`, ...) on any change to the digest recipe. |
| 161 | + |
| 162 | +--- |
| 163 | +*Copyright (c) 2026 AppDevForAll.* |
0 commit comments