Skip to content

Commit 6533abc

Browse files
Merge pull request #760 from appdevforall/feat/K2GO-554-app-verify
K2GO-554 feat(download): app-side rootfs signature verify
2 parents 0de52c0 + b7b4727 commit 6533abc

21 files changed

Lines changed: 1105 additions & 70 deletions

File tree

‎.github/workflows/bake-rootfs.yml‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,20 @@ jobs:
265265
if printf '%s\n' "$members" | grep -Eq '^/|(^|/)\.\.(/|$)'; then
266266
echo "FATAL: $tb has an unsafe member (absolute or ..); refusing to sign" >&2; exit 1
267267
fi
268-
echo ">> sign $tb"
269-
minisign -S -H -s "$KEY" -m "$tb" -t "k2go rootfs ${tb%.tar.gz}" </dev/null
268+
# ADR Amendment 1 (A1): the trusted comment is a manifest that the app authenticates with
269+
# the global signature alone, so the phone never hashes the tarball for identity. The
270+
# sha-256 is recomputed here: a stale .sha256 or .meta4 fails the bake instead of shipping
271+
# a manifest that the app rejects.
272+
sha="$(sha256sum "$tb" | awk '{print $1}')"
273+
[ "$sha" = "$(awk '{print $1}' "${tb}.sha256")" ] \
274+
|| { echo "FATAL: ${tb}.sha256 does not match $tb" >&2; exit 1; }
275+
{ [ -f "${tb}.meta4" ] && grep -q "$sha" "${tb}.meta4"; } \
276+
|| { echo "FATAL: ${tb}.meta4 is missing or does not carry the sha-256 of $tb" >&2; exit 1; }
277+
manifest="k2go-rootfs v1 file=$tb size=$(stat -c%s "$tb") sha256=$sha meta4=$(sha256sum "${tb}.meta4" | awk '{print $1}')"
278+
echo ">> sign $tb: $manifest"
279+
minisign -S -H -s "$KEY" -m "$tb" -t "$manifest" </dev/null
280+
[ "$(sed -n 3p "${tb}.minisig")" = "trusted comment: $manifest" ] \
281+
|| { echo "FATAL: ${tb}.minisig does not carry the manifest" >&2; exit 1; }
270282
# The app verifier accepts ONLY the prehashed form (algorithm tag "ED"); assert it here.
271283
tag="$(sed -n 2p "${tb}.minisig" | base64 -d 2>/dev/null | head -c2 || true)"
272284
[ "$tag" = "ED" ] || { echo "FATAL: ${tb}.minisig is not prehashed (tag '$tag', want ED)" >&2; exit 1; }

‎CLAUDE.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,18 @@ Enforces the ABI-separation policy + rootfs sanity at the two untrusted gates.
301301
`tools/rootfs-builder/iiab_tree_hash.py`), the in-app backup-writer emitting both members, and
302302
the arbitrary-file attack-vector analysis.
303303

304+
**Slice (PR #760): signed rootfs trust (`org.appdevforall.k2go.download`)**
305+
K2GO-554: an official signed download skips the archive listing pass.
306+
307+
- `domain/RootfsManifest` (pure JVM): strict parse of the manifest in the minisign
308+
trusted comment, and `judge` = integrity (bytes passed the SHA-256 check) + identity
309+
(authentic manifest names this file, size, sha-256) + the requested tier/ABI.
310+
`deploy/domain/ExtractProgress.trustedStreamTotal` lifts the gzip ISIZE past 4 GiB.
311+
- `data/MinisignVerifier` checks only the global signature (no read of the tarball);
312+
`data/TrustedSigningKeys` pins the public key.
313+
- **Legacy seam:** `Aria2Manager.signedManifestVouchesFor` (after DownloadVerifier) ->
314+
`InstallService` -> `TarExtractor` trusted single pass. ADR-K2GO-554 (Amendment 1).
315+
304316
**Legacy (NOT yet layered)**: the redesign retired the original god classes
305317
`MainActivity` and `DeployFragment` (they no longer exist; DONE-slice seams above
306318
that still name them are pre-redesign records). The current god-tier classes,

‎controller/app/build.gradle‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ dependencies {
323323
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
324324
implementation 'com.github.MuntashirAkon:libadb-android:3.1.1'
325325
implementation 'org.conscrypt:conscrypt-android:2.5.3'
326+
// K2GO-554: Ed25519 check of the minisign signature on the rootfs manifest (lightweight API only,
327+
// R8 strips the rest). Already a transitive dependency; declared because the app uses it directly.
328+
// jdk15to18 variant for Android (no multi-release jar).
329+
implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
326330
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
327331
implementation 'org.nanohttpd:nanohttpd:2.3.1'
328332

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
untrusted comment: minisign public key D64948BB183F9048
2+
RWRIkD8Yu0hJ1rxv2Z+ZRMmTM63cAhHeJ1JyneNfhD1cqoGA6ONj0OXj
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
untrusted comment: signature from minisign secret key
2+
RURIkD8Yu0hJ1iQrOUgUjV9jpdRLo9OcM0jKEeXSCRss2ifGEKo6DYQE6z3tBCFu8bZKaFbodZwo3UX6A1f0CXkf8PW7WncypA8=
3+
trusted comment: spike demo trusted comment
4+
9NYHyKiZwga7qie0aR1cB34HCM7ez9/zDSkDnGs8EiQ5gcqTIQzCQDPdRddbAv3bJpOEguV+AxeQrTYUVT1jBw==
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.appdevforall.k2go.download.data;
2+
3+
import static org.junit.Assert.assertNotNull;
4+
import static org.junit.Assert.assertNull;
5+
6+
import android.content.Context;
7+
8+
import androidx.test.ext.junit.runners.AndroidJUnit4;
9+
import androidx.test.platform.app.InstrumentationRegistry;
10+
11+
import java.io.ByteArrayOutputStream;
12+
import java.io.IOException;
13+
import java.io.InputStream;
14+
import java.nio.charset.StandardCharsets;
15+
16+
import org.junit.Test;
17+
import org.junit.runner.RunWith;
18+
19+
/**
20+
* K2GO-554: run the trusted-comment check on-device, inside K2Go, on the real Android runtime
21+
* (ART, the app's dependency graph, BouncyCastle on device). The vector is produced by minisign
22+
* on the host and bundled as an androidTest asset.
23+
*/
24+
@RunWith(AndroidJUnit4.class)
25+
public class MinisignVerifierInstrumentedTest {
26+
27+
private Context testCtx() {
28+
return InstrumentationRegistry.getInstrumentation().getContext();
29+
}
30+
31+
private String readAsset(String name) throws IOException {
32+
try (InputStream in = testCtx().getAssets().open(name);
33+
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
34+
byte[] b = new byte[4096];
35+
int n;
36+
while ((n = in.read(b)) > 0) {
37+
bos.write(b, 0, n);
38+
}
39+
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
40+
}
41+
}
42+
43+
@Test
44+
public void authenticatesOnDevice() throws Exception {
45+
TrustedSigningKeys keys = TrustedSigningKeys.of(readAsset("minisign/spike.pub"));
46+
assertNotNull("a minisign signature from a pinned key must authenticate on device",
47+
MinisignVerifier.authenticatedComment(readAsset("minisign/test.bin.minisig"), keys));
48+
}
49+
50+
@Test
51+
public void rejectsEditedCommentOnDevice() throws Exception {
52+
TrustedSigningKeys keys = TrustedSigningKeys.of(readAsset("minisign/spike.pub"));
53+
String[] lines = readAsset("minisign/test.bin.minisig").replace("\r\n", "\n").split("\n");
54+
lines[2] = lines[2] + "x";
55+
assertNull("an edited trusted comment must not authenticate on device",
56+
MinisignVerifier.authenticatedComment(String.join("\n", lines) + "\n", keys));
57+
}
58+
}

‎controller/app/src/main/java/org/appdevforall/k2go/Aria2Manager.java‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@
2727
import org.appdevforall.k2go.download.domain.MetalinkSplit;
2828
import org.appdevforall.k2go.download.domain.MetalinkFile;
2929
import org.appdevforall.k2go.download.domain.DownloadVerifier;
30+
import org.appdevforall.k2go.download.domain.RootfsManifest;
31+
import org.appdevforall.k2go.download.data.MinisignVerifier;
32+
import org.appdevforall.k2go.download.data.TrustedSigningKeys;
3033
import org.appdevforall.k2go.download.domain.ByteToken;
3134
import org.appdevforall.k2go.download.domain.DownloadEta;
3235
import org.appdevforall.k2go.download.domain.Aria2ProgressLine;
3336

3437
public class Aria2Manager {
3538

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

113127
/**
@@ -146,6 +160,17 @@ default void onPaused() { }
146160
}
147161

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

@@ -213,7 +238,10 @@ public void startDownload(Context context, String url, DownloadListener listener
213238
// Fully downloaded and verified (no control file) -> skip download.
214239
pruneStaleSiblings(downloadDir, mf.fileName());
215240
Log.d(TAG, "Reusing already-verified artifact: " + mf.fileName() + " (skipping download)");
216-
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath()));
241+
// This branch runs only after DownloadVerifier returned OK above.
242+
final boolean trusted = signedTarget != null
243+
&& signedManifestVouchesFor(mf, true, signedTarget);
244+
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath(), trusted));
217245
return;
218246
} else if (len > mf.sizeBytes()) {
219247
// Larger than the declared size: no piece map can match it. Discard.
@@ -502,6 +530,7 @@ public void startDownload(Context context, String url, DownloadListener listener
502530
// verify the artifact against the .meta4 <size> + file-level SHA-256
503531
// before reporting success, so an incomplete/corrupt/stale file cannot
504532
// reach extraction.
533+
boolean bytesVerified = false;
505534
if (mf != null && mf.canVerify()) {
506535
File artifact = new File(downloadDir, mf.fileName());
507536
DownloadVerifier.Result vr = DownloadVerifier.verify(artifact, mf.sizeBytes(), mf.sha256());
@@ -517,8 +546,11 @@ public void startDownload(Context context, String url, DownloadListener listener
517546
}
518547
pruneStaleSiblings(downloadDir, mf.fileName());
519548
Log.d(TAG, "Integrity verified: " + mf.fileName() + " (" + mf.sizeBytes() + " bytes, sha-256 OK)");
549+
bytesVerified = true;
520550
}
521-
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath()));
551+
final boolean trusted = signedTarget != null
552+
&& signedManifestVouchesFor(mf, bytesVerified, signedTarget);
553+
mainHandler.post(() -> listener.onComplete(downloadDir.getAbsolutePath(), trusted));
522554

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

638+
/**
639+
* K2GO-554 (ADR Amendment 1, A2/A3): is this an OFFICIAL artifact we may fast-extract? True only when
640+
* {@code bytesVerified} (DownloadVerifier returned OK against {@code mf}) and the {@code .minisig}
641+
* next to the tarball carries a manifest, authenticated by a pinned key, that names this file, size
642+
* and sha-256 for the expected tier and ABI. The rule lives in {@link RootfsManifest#judge}. No
643+
* second read of the file happens here. Fail closed: unverified bytes, no sidecar, a bad signature,
644+
* no manifest, a mismatch, or another tier/ABI returns false, and the caller keeps the full path.
645+
*/
646+
private boolean signedManifestVouchesFor(MetalinkFile mf, boolean bytesVerified,
647+
RootfsManifest.Target expected) {
648+
if (mf == null) return false;
649+
if (!bytesVerified) {
650+
Log.i(TAG, "Signature check " + mf.fileName() + ": " + RootfsManifest.Verdict.BYTES_NOT_VERIFIED);
651+
return false;
652+
}
653+
for (String mirror : mf.mirrors()) {
654+
String sig = fetchText(mirror + ".minisig");
655+
if (sig == null) continue;
656+
// The sidecar exists on this mirror, so its verdict is final. Do not try other mirrors
657+
// hoping for a different answer.
658+
RootfsManifest.Verdict v = RootfsManifest.judge(true,
659+
MinisignVerifier.authenticatedComment(sig, TrustedSigningKeys.PRODUCTION),
660+
mf.fileName(), mf.sizeBytes(), mf.sha256(), expected);
661+
Log.i(TAG, "Signature check " + mf.fileName() + ": " + v);
662+
return v == RootfsManifest.Verdict.TRUSTED;
663+
}
664+
Log.i(TAG, "Signature check " + mf.fileName() + ": no .minisig");
665+
return false;
666+
}
667+
668+
/**
669+
* K2GO-554 D6: GET a small text sidecar (the .minisig, about 300 bytes), or null on any non-200,
670+
* error, or a body above {@code SIDECAR_MAX_BYTES}. The mirror URL comes from the unsigned meta4,
671+
* so the read is capped: a hostile host cannot stream an endless body into memory.
672+
*/
673+
private String fetchText(String url) {
674+
HttpURLConnection conn = null;
675+
try {
676+
conn = (HttpURLConnection) new URL(url).openConnection();
677+
conn.setConnectTimeout(10000);
678+
conn.setReadTimeout(10000);
679+
conn.setRequestProperty("User-Agent", "K2Go");
680+
if (conn.getResponseCode() != 200) {
681+
return null;
682+
}
683+
try (InputStream in = conn.getInputStream()) {
684+
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
685+
byte[] buf = new byte[4096];
686+
int r;
687+
while ((r = in.read(buf)) != -1) {
688+
bos.write(buf, 0, r);
689+
if (bos.size() > SIDECAR_MAX_BYTES) {
690+
return null;
691+
}
692+
}
693+
return new String(bos.toByteArray(), java.nio.charset.StandardCharsets.UTF_8);
694+
}
695+
} catch (Exception e) {
696+
return null;
697+
} finally {
698+
if (conn != null) conn.disconnect();
699+
}
700+
}
701+
606702
/**
607703
* ADFA-5119: is there a partial download here, asked of the disk alone?
608704
*

0 commit comments

Comments
 (0)