2727import org .appdevforall .k2go .download .domain .MetalinkSplit ;
2828import org .appdevforall .k2go .download .domain .MetalinkFile ;
2929import 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 ;
3033import org .appdevforall .k2go .download .domain .ByteToken ;
3134import org .appdevforall .k2go .download .domain .DownloadEta ;
3235import org .appdevforall .k2go .download .domain .Aria2ProgressLine ;
3336
3437public 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