diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/Configuration.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/Configuration.java index fda088fa9d..0d82e8b3ed 100644 --- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/Configuration.java +++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/Configuration.java @@ -43,6 +43,12 @@ public interface Configuration { */ String index(String deflt); + /** + * The path to the trusted checksum file corresponding to index file + * + */ + String checksumFile(); + /** * Content added to the index file. Content maybe one line without CR/LF as * long as there is a comma or whitespace separating the GAVs. Further same diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MavenBndRepository.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MavenBndRepository.java index c0b2e3a928..d91f998803 100644 --- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MavenBndRepository.java +++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MavenBndRepository.java @@ -1,6 +1,7 @@ package aQute.bnd.repository.maven.provider; import static aQute.bnd.osgi.Constants.BSN_SOURCE_SUFFIX; +import static aQute.bnd.repository.maven.provider.TrustedChecksums.createTrustedChecksumFile; import static aQute.bnd.service.tags.Tags.parse; import java.io.Closeable; @@ -133,6 +134,7 @@ public class MavenBndRepository extends BaseRepository implements RepositoryPlug private final AtomicReference open = new AtomicReference<>(); Optional workspace; private AtomicBoolean polling = new AtomicBoolean(false); + private TrustedChecksums trustedChecksums; /** * Put result @@ -708,10 +710,14 @@ synchronized boolean init() { } } + File indexFile = getIndexFile(); + this.trustedChecksums = loadTrustedChecksumFile(indexFile); + this.trustedChecksums.open(); for (MavenBackingRepository mbr : release) { if (mbr.isRemote()) { remote = true; + mbr.setTrustedChecksums(trustedChecksums); break; } } @@ -719,6 +725,7 @@ synchronized boolean init() { for (MavenBackingRepository mbr : snapshot) { if (mbr.isRemote()) { remote = true; + mbr.setTrustedChecksums(trustedChecksums); break; } } @@ -732,7 +739,6 @@ synchronized boolean init() { storageMvn.setSonatypePublishSnapshotUrl(sonatypeSnapshotUrl); } - File indexFile = getIndexFile(); Processor domain = (registry != null) ? registry.getPlugin(Processor.class) : null; String source = configuration.source(); if (source != null) { @@ -765,6 +771,20 @@ synchronized boolean init() { } } + private TrustedChecksums loadTrustedChecksumFile(File indexFile) { + String checksumFile = configuration.checksumFile(); + + File trustedChecksumFile; + if (checksumFile != null) { + trustedChecksumFile = IO.getFile(base, checksumFile); + } else { + File indexDir = indexFile.getParentFile() != null ? indexFile.getParentFile() : base; + trustedChecksumFile = IO.getFile(indexDir, indexFile.getName() + ".checksums"); + } + + return new TrustedChecksums(trustedChecksumFile); + } + private void validateUris(List release, Formatter f) { release.stream() .map(mb -> { @@ -911,13 +931,21 @@ public String tooltip(Object... target) throws Exception { if (status != null) f.format("STATUS = %s", status); else { + String status = getStatus(); f.format("MavenBndRepository : %s\n", getName()); + if (status != null) { + f.format("Status : %s\n", status); + } f.format("Tags : %s\n", getTags()); f.format("Revisions : %s\n", index.getArchives() .size()); f.format("Storage : %s\n", localRepo); f.format("Index : %s\n", index.indexFile); f.format("Format : %s\n", index.isPom ? "pom.xml" : "text"); + File trustedChecksumsFile = trustedChecksums.getFile(); + if (trustedChecksumsFile.exists()) { + f.format("Trusted Checksums File : %s\n", trustedChecksumsFile); + } f.format("Release repos : \n %s\n", storage.getReleaseRepositories() .stream() .filter(Objects::nonNull) @@ -1143,4 +1171,13 @@ public HttpClient getClient() { return client; } + void createTrustedChecksumsFile() { + try { + createTrustedChecksumFile(storage, trustedChecksums.getFile(), index.archives.keySet()); + } catch (Exception e) { + throw Exceptions.duck(e); + } + + } + } diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java index 048fe282fe..3a7649d3ca 100644 --- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java +++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/MbrUpdater.java @@ -140,6 +140,10 @@ void convertTextXml() throws Exception { repo.index.convertTextXml(); } + void createTrustedChecksumsFile() throws Exception { + repo.createTrustedChecksumsFile(); + } + private String format(MultiMap overlap) { Justif j = new Justif(140, 50, 60, 70, 80, 90, 100, 110); j.formatter() diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java index aeec7caffd..4e8ada6e77 100644 --- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java +++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/RepoActions.java @@ -56,7 +56,9 @@ Map getRepoActions(final Clipboard clipboard) throws Exception map.put("Convert :: Text <--> pom.xml", () -> { convertTextXmlRevisions(); }); - + map.put("Create Trusted Checksums file", () -> { + createTrustedChecksumsFile(); + }); map.put("Update Revisions :: Dry run to clipboard - Update to higher MICRO revision", () -> { clipboard.copy(preview(micro)); }); @@ -278,4 +280,13 @@ private void convertTextXmlRevisions() { } } + private void createTrustedChecksumsFile() { + try { + mbr.createTrustedChecksumsFile(); + repo.refresh(); + } catch (Exception e) { + throw Exceptions.duck(e); + } + } + } diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/TrustedChecksums.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/TrustedChecksums.java new file mode 100644 index 0000000000..3506bb8770 --- /dev/null +++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/TrustedChecksums.java @@ -0,0 +1,138 @@ +package aQute.bnd.repository.maven.provider; + +import static java.util.stream.Collectors.toSet; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import aQute.lib.io.IO; +import aQute.lib.strings.Strings; +import aQute.libg.cryptography.MD5; +import aQute.libg.cryptography.SHA1; +import aQute.libg.cryptography.SHA256; +import aQute.libg.cryptography.SHA512; +import aQute.maven.api.Archive; +import aQute.maven.api.IMavenRepo; + +/** + * Helper for handling trusted checksum verification for Maven artifacts + * {@link IndexFile} + */ +public final class TrustedChecksums { + + private final File sidecarFile; + private Map index = Map.of(); + + TrustedChecksums(File trustedChecksumFile) { + this.sidecarFile = trustedChecksumFile; + } + + public File getFile() { + return this.sidecarFile; + } + + public ArtifactChecksum get(Archive a) { + return index.get(a); + } + + public void open() throws IOException { + if (sidecarFile == null || !sidecarFile.isFile()) { + return; + } + + Set set = read(IO.collect(sidecarFile)); + index = set.stream() + .collect(Collectors.toMap(acs -> acs.archive(), acs -> acs)); + + } + + public static void createTrustedChecksumFile(IMavenRepo repo, File checksumFile, Collection sortedArchives) + throws Exception { + + try (FileOutputStream fos = new FileOutputStream( + checksumFile); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(fos, StandardCharsets.UTF_8))) { + + writer.write("# Trusted Checksums for each GAV in the maven index"); + writer.newLine(); + + for (Archive arch : sortedArchives) { + File localFile = repo.toLocalFile(arch); + String line = arch.toString() + "=sha1:" + SHA1.digest(localFile) + .asHex(); + writer.write(line); + writer.newLine(); + } + writer.flush(); + } + } + + private Set read(String source) { + Set archives = Strings.splitLinesAsStream(source) + .map(s -> ArtifactChecksum.parse(s)) + .filter(Objects::nonNull) + .collect(toSet()); + return archives; + } + + public record ArtifactChecksum(Archive archive, String hashType, String hash) { + public static ArtifactChecksum parse(String line) { + if (line.startsWith("#") || line.isEmpty()) + return null; + + String[] parts = line.split("=", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid checksum line format: " + line); + } + + Archive archive = Archive.valueOf(parts[0]); + + int colon = parts[1].indexOf(':'); + if (colon < 0) { + throw new IllegalArgumentException("Missing hash type separator: " + line); + } + + return new ArtifactChecksum(archive, parts[1].substring(0, colon), parts[1].substring(colon + 1)); + } + } + + public static String computeHash(File file, String hashType) throws Exception { + return switch (hashType.toLowerCase()) { + case "sha-256", "sha256" -> SHA256.digest(file) + .asHex(); + case "sha-512", "sha512" -> SHA512.digest(file) + .asHex(); + case "md5" -> MD5.digest(file) + .asHex(); + case "sha1", "sha-1" -> SHA1.digest(file) + .asHex(); + default -> throw new IllegalArgumentException("Unsupported hash type: " + hashType); + }; + } + + @Override + public String toString() { + return String.valueOf(sidecarFile); + } + + + public static class TrustedChecksumException extends Exception { + + private static final long serialVersionUID = 1L; + + public TrustedChecksumException(String message) { + super(message); + } + + } + +} diff --git a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java index 6914d20e28..de59ec2553 100644 --- a/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java +++ b/biz.aQute.repository/src/aQute/bnd/repository/maven/provider/package-info.java @@ -1,4 +1,4 @@ -@Version("2.3.0") +@Version("2.4.0") package aQute.bnd.repository.maven.provider; import org.osgi.annotation.versioning.Version; diff --git a/biz.aQute.repository/src/aQute/maven/provider/MavenBackingRepository.java b/biz.aQute.repository/src/aQute/maven/provider/MavenBackingRepository.java index 24137f5046..6773360d3d 100644 --- a/biz.aQute.repository/src/aQute/maven/provider/MavenBackingRepository.java +++ b/biz.aQute.repository/src/aQute/maven/provider/MavenBackingRepository.java @@ -1,5 +1,6 @@ package aQute.maven.provider; +import static aQute.bnd.repository.maven.provider.TrustedChecksums.computeHash; import static java.util.stream.Collectors.toList; import java.io.Closeable; @@ -18,6 +19,9 @@ import java.util.regex.Pattern; import aQute.bnd.http.HttpClient; +import aQute.bnd.repository.maven.provider.TrustedChecksums; +import aQute.bnd.repository.maven.provider.TrustedChecksums.ArtifactChecksum; +import aQute.bnd.repository.maven.provider.TrustedChecksums.TrustedChecksumException; import aQute.bnd.service.url.State; import aQute.bnd.service.url.TaggedData; import aQute.bnd.version.MavenVersion; @@ -44,6 +48,7 @@ public abstract class MavenBackingRepository implements Closeable { final String id; final File local; final Reporter reporter; + TrustedChecksums trustedChecksums; public MavenBackingRepository(File root, String base, Reporter reporter) throws Exception { this.local = root; @@ -254,4 +259,45 @@ void refreshSnapshots() { revisions.clear(); } + public void setTrustedChecksums(TrustedChecksums trustedChecksums) { + this.trustedChecksums = trustedChecksums; + } + + /** + * Validates a downloaded artifact against a configured trusted checksum. + * + * @param path the repository-relative artifact path + * @param file the downloaded artifact file + * @return {@code true} if a trusted checksum exists for the artifact and + * the file successfully matches it; {@code false} if no trusted + * checksum is configured + * @throws Exception if checksum calculation fails or the file does not + * match the configured trusted checksum. In the mismatch case + * the file is deleted before the exception is thrown. + */ + boolean checkTrustedChecksum(String path, File file) throws Exception { + Archive archive = Archive.fromFilepath(path); + if (trustedChecksums == null || archive == null) { + return false; + } + + ArtifactChecksum expected = trustedChecksums.get(archive); + if (expected == null) { + return false; + } + + String fileHash = computeHash(file, expected.hashType()); + + // Reuse existing checkDigest() with format: "algorithm=hash" + try { + checkDigest(fileHash, expected.hash(), file); + } catch (Exception e) { + throw new TrustedChecksumException(e.getMessage()); + } + + return true; + } + + + } diff --git a/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java b/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java index 5f3bbda530..ab4c83f590 100644 --- a/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java +++ b/biz.aQute.repository/src/aQute/maven/provider/MavenFileRepository.java @@ -39,6 +39,10 @@ public TaggedData fetch(String path, File dest, boolean force) throws Exception IO.mkdirs(dest.getParentFile()); IO.delete(dest); IO.copy(source, dest); + + // Check Trusted checksum + checkTrustedChecksum(path, dest); + return new TaggedData(toURI(path), 200, dest); } else { return new TaggedData(toURI(path), 404, dest); diff --git a/biz.aQute.repository/src/aQute/maven/provider/MavenRemoteRepository.java b/biz.aQute.repository/src/aQute/maven/provider/MavenRemoteRepository.java index b0a78abeed..d08cc9eb00 100644 --- a/biz.aQute.repository/src/aQute/maven/provider/MavenRemoteRepository.java +++ b/biz.aQute.repository/src/aQute/maven/provider/MavenRemoteRepository.java @@ -17,6 +17,7 @@ import aQute.bnd.exceptions.Exceptions; import aQute.bnd.http.HttpClient; import aQute.bnd.http.HttpRequestException; +import aQute.bnd.repository.maven.provider.TrustedChecksums.TrustedChecksumException; import aQute.bnd.service.url.State; import aQute.bnd.service.url.TaggedData; import aQute.libg.cryptography.MD5; @@ -72,6 +73,12 @@ private Promise fetch(String path, File file, int retries, long dela if ((tag.getState() != State.UPDATED) || path.endsWith("/maven-metadata.xml")) { return success; } + + // Trusted checksum is authoritative. + if (checkTrustedChecksum(path, file)) { + return success; + } + // https://issues.sonatype.org/browse/NEXUS-4900 return client.build() .asString() @@ -99,10 +106,20 @@ private Promise fetch(String path, File file, int retries, long dela }); }) .recoverWith(failed -> { + + Throwable failure = failed.getFailure(); + // Trusted checksum failures bypass retry logic entirely + if (failure instanceof TrustedChecksumException) { + // Fatal error - don't retry + logger.info("Trusted checksum error: {}", failure.getMessage()); + return null; + } + if (retries < 1) { return null; // no recovery } - logger.info("Retrying invalid download: {}. delay={}, retries={}", failed.getFailure() + logger.info("Retrying invalid download: {}. delay={}, retries={}", + failure .getMessage(), delay, retries); @SuppressWarnings("unchecked") Promise delayed = (Promise) failed.delay(delay); diff --git a/biz.aQute.repository/src/aQute/maven/provider/packageinfo b/biz.aQute.repository/src/aQute/maven/provider/packageinfo index e4836f9d19..bac9126e8b 100644 --- a/biz.aQute.repository/src/aQute/maven/provider/packageinfo +++ b/biz.aQute.repository/src/aQute/maven/provider/packageinfo @@ -1 +1 @@ -version 2.8 +version 2.9.0 diff --git a/biz.aQute.repository/test/aQute/maven/provider/TrustedChecksumsTest.java b/biz.aQute.repository/test/aQute/maven/provider/TrustedChecksumsTest.java new file mode 100644 index 0000000000..724ed02696 --- /dev/null +++ b/biz.aQute.repository/test/aQute/maven/provider/TrustedChecksumsTest.java @@ -0,0 +1,29 @@ +package aQute.maven.provider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import aQute.bnd.repository.maven.provider.TrustedChecksums.ArtifactChecksum; +import aQute.maven.api.Archive; + +public class TrustedChecksumsTest { + @Test + public void testParseValidChecksum() throws Exception { + String line = "org.example:artifact:1.0=sha256:abc123def456"; + ArtifactChecksum ac = ArtifactChecksum.parse(line); + assertEquals(Archive.valueOf("org.example:artifact:1.0"), ac.archive()); + assertEquals("sha256", ac.hashType()); + assertEquals("abc123def456", ac.hash()); + } + + @Test + public void testParseInvalidFormat() throws Exception { + String invalidLine = "invalid_format_no_equals"; + assertThrows(IllegalArgumentException.class, () -> + ArtifactChecksum.parse(invalidLine) + ); + } +} +