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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .github/workflows/bake-rootfs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,20 @@ jobs:
if printf '%s\n' "$members" | grep -Eq '^/|(^|/)\.\.(/|$)'; then
echo "FATAL: $tb has an unsafe member (absolute or ..); refusing to sign" >&2; exit 1
fi
echo ">> sign $tb"
minisign -S -H -s "$KEY" -m "$tb" -t "k2go rootfs ${tb%.tar.gz}" </dev/null
# ADR Amendment 1 (A1): the trusted comment is a manifest that the app authenticates with
# the global signature alone, so the phone never hashes the tarball for identity. The
# sha-256 is recomputed here: a stale .sha256 or .meta4 fails the bake instead of shipping
# a manifest that the app rejects.
sha="$(sha256sum "$tb" | awk '{print $1}')"
[ "$sha" = "$(awk '{print $1}' "${tb}.sha256")" ] \
|| { echo "FATAL: ${tb}.sha256 does not match $tb" >&2; exit 1; }
{ [ -f "${tb}.meta4" ] && grep -q "$sha" "${tb}.meta4"; } \
|| { echo "FATAL: ${tb}.meta4 is missing or does not carry the sha-256 of $tb" >&2; exit 1; }
manifest="k2go-rootfs v1 file=$tb size=$(stat -c%s "$tb") sha256=$sha meta4=$(sha256sum "${tb}.meta4" | awk '{print $1}')"
echo ">> sign $tb: $manifest"
minisign -S -H -s "$KEY" -m "$tb" -t "$manifest" </dev/null
[ "$(sed -n 3p "${tb}.minisig")" = "trusted comment: $manifest" ] \
|| { echo "FATAL: ${tb}.minisig does not carry the manifest" >&2; exit 1; }
# The app verifier accepts ONLY the prehashed form (algorithm tag "ED"); assert it here.
tag="$(sed -n 2p "${tb}.minisig" | base64 -d 2>/dev/null | head -c2 || true)"
[ "$tag" = "ED" ] || { echo "FATAL: ${tb}.minisig is not prehashed (tag '$tag', want ED)" >&2; exit 1; }
Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,18 @@ Enforces the ABI-separation policy + rootfs sanity at the two untrusted gates.
`tools/rootfs-builder/iiab_tree_hash.py`), the in-app backup-writer emitting both members, and
the arbitrary-file attack-vector analysis.

**Slice (PR #760): signed rootfs trust (`org.appdevforall.k2go.download`)**
K2GO-554: an official signed download skips the archive listing pass.

- `domain/RootfsManifest` (pure JVM): strict parse of the manifest in the minisign
trusted comment, and `judge` = integrity (bytes passed the SHA-256 check) + identity
(authentic manifest names this file, size, sha-256) + the requested tier/ABI.
`deploy/domain/ExtractProgress.trustedStreamTotal` lifts the gzip ISIZE past 4 GiB.
- `data/MinisignVerifier` checks only the global signature (no read of the tarball);
`data/TrustedSigningKeys` pins the public key.
- **Legacy seam:** `Aria2Manager.signedManifestVouchesFor` (after DownloadVerifier) ->
`InstallService` -> `TarExtractor` trusted single pass. ADR-K2GO-554 (Amendment 1).

**Legacy (NOT yet layered)**: the redesign retired the original god classes
`MainActivity` and `DeployFragment` (they no longer exist; DONE-slice seams above
that still name them are pre-redesign records). The current god-tier classes,
Expand Down
4 changes: 4 additions & 0 deletions controller/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ dependencies {
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.github.MuntashirAkon:libadb-android:3.1.1'
implementation 'org.conscrypt:conscrypt-android:2.5.3'
// K2GO-554: Ed25519 check of the minisign signature on the rootfs manifest (lightweight API only,
// R8 strips the rest). Already a transitive dependency; declared because the app uses it directly.
// jdk15to18 variant for Android (no multi-release jar).
implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
implementation 'org.nanohttpd:nanohttpd:2.3.1'

Expand Down
2 changes: 2 additions & 0 deletions controller/app/src/androidTest/assets/minisign/spike.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
untrusted comment: minisign public key D64948BB183F9048
RWRIkD8Yu0hJ1rxv2Z+ZRMmTM63cAhHeJ1JyneNfhD1cqoGA6ONj0OXj
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
untrusted comment: signature from minisign secret key
RURIkD8Yu0hJ1iQrOUgUjV9jpdRLo9OcM0jKEeXSCRss2ifGEKo6DYQE6z3tBCFu8bZKaFbodZwo3UX6A1f0CXkf8PW7WncypA8=
trusted comment: spike demo trusted comment
9NYHyKiZwga7qie0aR1cB34HCM7ez9/zDSkDnGs8EiQ5gcqTIQzCQDPdRddbAv3bJpOEguV+AxeQrTYUVT1jBw==
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.appdevforall.k2go.download.data;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;

import android.content.Context;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import org.junit.Test;
import org.junit.runner.RunWith;

/**
* K2GO-554: run the trusted-comment check on-device, inside K2Go, on the real Android runtime
* (ART, the app's dependency graph, BouncyCastle on device). The vector is produced by minisign
* on the host and bundled as an androidTest asset.
*/
@RunWith(AndroidJUnit4.class)
public class MinisignVerifierInstrumentedTest {

private Context testCtx() {
return InstrumentationRegistry.getInstrumentation().getContext();
}

private String readAsset(String name) throws IOException {
try (InputStream in = testCtx().getAssets().open(name);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] b = new byte[4096];
int n;
while ((n = in.read(b)) > 0) {
bos.write(b, 0, n);
}
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
}
}

@Test
public void authenticatesOnDevice() throws Exception {
TrustedSigningKeys keys = TrustedSigningKeys.of(readAsset("minisign/spike.pub"));
assertNotNull("a minisign signature from a pinned key must authenticate on device",
MinisignVerifier.authenticatedComment(readAsset("minisign/test.bin.minisig"), keys));
}

@Test
public void rejectsEditedCommentOnDevice() throws Exception {
TrustedSigningKeys keys = TrustedSigningKeys.of(readAsset("minisign/spike.pub"));
String[] lines = readAsset("minisign/test.bin.minisig").replace("\r\n", "\n").split("\n");
lines[2] = lines[2] + "x";
assertNull("an edited trusted comment must not authenticate on device",
MinisignVerifier.authenticatedComment(String.join("\n", lines) + "\n", keys));
}
}
100 changes: 98 additions & 2 deletions controller/app/src/main/java/org/appdevforall/k2go/Aria2Manager.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@
import org.appdevforall.k2go.download.domain.MetalinkSplit;
import org.appdevforall.k2go.download.domain.MetalinkFile;
import org.appdevforall.k2go.download.domain.DownloadVerifier;
import org.appdevforall.k2go.download.domain.RootfsManifest;
import org.appdevforall.k2go.download.data.MinisignVerifier;
import org.appdevforall.k2go.download.data.TrustedSigningKeys;
import org.appdevforall.k2go.download.domain.ByteToken;
import org.appdevforall.k2go.download.domain.DownloadEta;
import org.appdevforall.k2go.download.domain.Aria2ProgressLine;

public class Aria2Manager {

private static final String TAG = "IIAB-Aria2-Native";
/** K2GO-554: upper bound for a signature sidecar read; a minisig is about 300 bytes. */
private static final int SIDECAR_MAX_BYTES = 16 * 1024;
/**
* ADFA-4895: volatile for the same reason as the flag below, and it is the more important of
* the two. It is assigned on the download thread and read by {@code stopDownload()} on the
Expand Down Expand Up @@ -108,6 +113,15 @@ default void onProgress(int percentage, String speed, String eta,
onProgress(percentage, speed, eta);
}
void onComplete(String downloadPath);
/**
* K2GO-554 D6: the download completed AND {@code trusted} = it was verified against the .meta4
* and carries a signature from a pinned key. A default forwards to the single-arg form, so callers
* that do not care about trust are untouched; the install path overrides this to skip the listing
* pass on a trusted artifact.
*/
default void onComplete(String downloadPath, boolean trusted) {
onComplete(downloadPath);
}
void onError(String error);

/**
Expand Down Expand Up @@ -146,6 +160,17 @@ default void onPaused() { }
}

public void startDownload(Context context, String url, DownloadListener listener) {
startDownload(context, url, null, listener);
}

/**
* K2GO-554: {@code signedTarget} non-null = after the integrity gate passes, also look for an
* official signed manifest for this tier and ABI next to the artifact, and report trust via
* {@link DownloadListener#onComplete(String, boolean)}. Only the rootfs install opts in; other
* downloads pass null, so they pay no sidecar lookups.
*/
public void startDownload(Context context, String url, RootfsManifest.Target signedTarget,
DownloadListener listener) {
stopRequest = Stop.NONE;
Handler mainHandler = new Handler(Looper.getMainLooper());

Expand Down Expand Up @@ -213,7 +238,10 @@ public void startDownload(Context context, String url, DownloadListener listener
// Fully downloaded and verified (no control file) -> skip download.
pruneStaleSiblings(downloadDir, mf.fileName());
Log.d(TAG, "Reusing already-verified artifact: " + mf.fileName() + " (skipping download)");
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath()));
// This branch runs only after DownloadVerifier returned OK above.
final boolean trusted = signedTarget != null
&& signedManifestVouchesFor(mf, true, signedTarget);
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath(), trusted));
return;
} else if (len > mf.sizeBytes()) {
// Larger than the declared size: no piece map can match it. Discard.
Expand Down Expand Up @@ -502,6 +530,7 @@ public void startDownload(Context context, String url, DownloadListener listener
// verify the artifact against the .meta4 <size> + file-level SHA-256
// before reporting success, so an incomplete/corrupt/stale file cannot
// reach extraction.
boolean bytesVerified = false;
if (mf != null && mf.canVerify()) {
File artifact = new File(downloadDir, mf.fileName());
DownloadVerifier.Result vr = DownloadVerifier.verify(artifact, mf.sizeBytes(), mf.sha256());
Expand All @@ -517,8 +546,11 @@ public void startDownload(Context context, String url, DownloadListener listener
}
pruneStaleSiblings(downloadDir, mf.fileName());
Log.d(TAG, "Integrity verified: " + mf.fileName() + " (" + mf.sizeBytes() + " bytes, sha-256 OK)");
bytesVerified = true;
}
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath()));
final boolean trusted = signedTarget != null
&& signedManifestVouchesFor(mf, bytesVerified, signedTarget);
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath(), trusted));

} catch (Exception e) {
// The stream was closed by our own stop; a deliberate stop is not a fatal error,
Expand Down Expand Up @@ -603,6 +635,70 @@ private MetalinkFile fetchMetalink(String url) {
}
}

/**
* K2GO-554 (ADR Amendment 1, A2/A3): is this an OFFICIAL artifact we may fast-extract? True only when
* {@code bytesVerified} (DownloadVerifier returned OK against {@code mf}) and the {@code .minisig}
* next to the tarball carries a manifest, authenticated by a pinned key, that names this file, size
* and sha-256 for the expected tier and ABI. The rule lives in {@link RootfsManifest#judge}. No
* second read of the file happens here. Fail closed: unverified bytes, no sidecar, a bad signature,
* no manifest, a mismatch, or another tier/ABI returns false, and the caller keeps the full path.
*/
private boolean signedManifestVouchesFor(MetalinkFile mf, boolean bytesVerified,
RootfsManifest.Target expected) {
if (mf == null) return false;
if (!bytesVerified) {
Log.i(TAG, "Signature check " + mf.fileName() + ": " + RootfsManifest.Verdict.BYTES_NOT_VERIFIED);
return false;
}
for (String mirror : mf.mirrors()) {
String sig = fetchText(mirror + ".minisig");
if (sig == null) continue;
// The sidecar exists on this mirror, so its verdict is final. Do not try other mirrors
// hoping for a different answer.
RootfsManifest.Verdict v = RootfsManifest.judge(true,
MinisignVerifier.authenticatedComment(sig, TrustedSigningKeys.PRODUCTION),
mf.fileName(), mf.sizeBytes(), mf.sha256(), expected);
Log.i(TAG, "Signature check " + mf.fileName() + ": " + v);
return v == RootfsManifest.Verdict.TRUSTED;
}
Log.i(TAG, "Signature check " + mf.fileName() + ": no .minisig");
return false;
}

/**
* K2GO-554 D6: GET a small text sidecar (the .minisig, about 300 bytes), or null on any non-200,
* error, or a body above {@code SIDECAR_MAX_BYTES}. The mirror URL comes from the unsigned meta4,
* so the read is capped: a hostile host cannot stream an endless body into memory.
*/
private String fetchText(String url) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
conn.setRequestProperty("User-Agent", "K2Go");
if (conn.getResponseCode() != 200) {
return null;
}
try (InputStream in = conn.getInputStream()) {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[4096];
int r;
while ((r = in.read(buf)) != -1) {
bos.write(buf, 0, r);
if (bos.size() > SIDECAR_MAX_BYTES) {
return null;
}
}
return new String(bos.toByteArray(), java.nio.charset.StandardCharsets.UTF_8);
}
} catch (Exception e) {
return null;
} finally {
if (conn != null) conn.disconnect();
}
}

/**
* ADFA-5119: is there a partial download here, asked of the disk alone?
*
Expand Down
Loading
Loading