You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by cs...@apache.org on 2026/08/31 11:07:11 UTC

(maven-resolver) branch master updated: fix: deploy-side integrity must fail closed (#2084)

This is an automated email from the ASF dual-hosted git repository.

cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new c82abf7eb fix: deploy-side integrity must fail closed (#2084)
c82abf7eb is described below

commit c82abf7ebcf2ee34d89b1927158787f87647f6d2
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Mon Aug 31 13:07:04 2026 +0200

    fix: deploy-side integrity must fail closed (#2084)
    
    ## Summary
    
    Fixes 4 findings from the maven-resolver security audit (scan-maven-resolver-20260811):
    
    | Finding | Severity | Description |
    |---------|----------|-------------|
    | f022 | LOW | Checksum upload failures swallowed; deploy succeeds without published checksums |
    | f023 | LOW | Provided/trusted checksums never cover metadata downloads |
    | f029 | LOW | First matching checksum algorithm accepts, skipping stronger provided checksums |
    | f030 | LOW | One pre-existing `.asc` silently disables signing of all deploy artifacts |
    
    **Root cause:** On the publication and strongest-verification paths, failures degrade silently: checksums that fail to upload are logged and forgotten, one pre-existing signature disables signing wholesale, and trusted-checksums has structural coverage gaps.
    
    **Fix:** Deploy must be atomic across artifact bytes and integrity metadata: propagate failures, cover metadata, compare all checksums, per-artifact signature skip.
---
 .../connector/basic/BasicRepositoryConnector.java  |  50 +++++++--
 .../BasicRepositoryConnectorConfigurationKeys.java |  18 +++
 .../aether/connector/basic/ChecksumValidator.java  |  18 ++-
 .../connector/basic/ChecksumValidatorTest.java     |  48 ++++++++
 .../gnupg/GnupgSignatureArtifactGenerator.java     |  66 ++++++++---
 .../generator/gnupg/GpgSignerFactoryTest.java      |  26 +++++
 .../SigstoreSignatureArtifactGenerator.java        | 121 +++++++++++++--------
 .../FileTrustedChecksumsSourceSupport.java         |  37 +++++++
 .../SparseDirectoryTrustedChecksumsSource.java     |  77 ++++++++++++-
 .../SummaryFileTrustedChecksumsSource.java         |  75 +++++++++++--
 .../TrustedToProvidedChecksumsSourceAdapter.java   |  31 ++++++
 .../FileTrustedChecksumsSourceTestSupport.java     |  53 +++++++++
 ...rustedToProvidedChecksumsSourceAdapterTest.java |  52 +++++++++
 .../spi/checksums/ProvidedChecksumsSource.java     |  27 +++++
 .../spi/checksums/TrustedChecksumsSource.java      |  45 ++++++++
 15 files changed, 657 insertions(+), 87 deletions(-)

diff --git a/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnector.java b/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnector.java
index 399d363ab..0cb2028c4 100644
--- a/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnector.java
+++ b/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnector.java
@@ -69,11 +69,13 @@ import org.slf4j.LoggerFactory;
 
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_DOWNSTREAM_THREADS;
+import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_FAIL_ON_CHECKSUM_UPLOAD_FAILURE;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_INCLUDED_CHECKSUMS;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_PARALLEL_PUT;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_PERSISTED_CHECKSUMS;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_THREADS;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.CONFIG_PROP_UPSTREAM_THREADS;
+import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.DEFAULT_FAIL_ON_CHECKSUM_UPLOAD_FAILURE;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.DEFAULT_INCLUDED_CHECKSUMS;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.DEFAULT_PARALLEL_PUT;
 import static org.eclipse.aether.connector.basic.BasicRepositoryConnectorConfigurationKeys.DEFAULT_PERSISTED_CHECKSUMS;
@@ -111,6 +113,8 @@ final class BasicRepositoryConnector implements RepositoryConnector {
 
     private final boolean persistedChecksums;
 
+    private final boolean failOnChecksumUploadFailure;
+
     private final ConcurrentHashMap<Boolean, SmartExecutor> executors;
 
     private final AtomicBoolean closed;
@@ -170,6 +174,11 @@ final class BasicRepositoryConnector implements RepositoryConnector {
                 CONFIG_PROP_PARALLEL_PUT);
         persistedChecksums =
                 ConfigUtils.getBoolean(session, DEFAULT_PERSISTED_CHECKSUMS, CONFIG_PROP_PERSISTED_CHECKSUMS);
+        failOnChecksumUploadFailure = ConfigUtils.getBoolean(
+                session,
+                DEFAULT_FAIL_ON_CHECKSUM_UPLOAD_FAILURE,
+                CONFIG_PROP_FAIL_ON_CHECKSUM_UPLOAD_FAILURE + "." + repository.getId(),
+                CONFIG_PROP_FAIL_ON_CHECKSUM_UPLOAD_FAILURE);
     }
 
     /**
@@ -221,6 +230,17 @@ final class BasicRepositoryConnector implements RepositoryConnector {
         boolean first = true;
 
         for (MetadataDownload transfer : safeMetadataDownloads) {
+            Map<String, String> providedChecksums = Collections.emptyMap();
+            for (ProvidedChecksumsSource providedChecksumsSource : providedChecksumsSources.values()) {
+                Map<String, String> provided = providedChecksumsSource.getProvidedMetadataChecksums(
+                        session, transfer, repository, checksumAlgorithmFactories);
+
+                if (provided != null) {
+                    providedChecksums = provided;
+                    break;
+                }
+            }
+
             URI location = layout.getLocation(transfer.getMetadata(), false);
 
             TransferResource resource = newTransferResource(location, transfer);
@@ -239,7 +259,7 @@ final class BasicRepositoryConnector implements RepositoryConnector {
                     checksumPolicy,
                     checksumAlgorithmFactories,
                     checksumLocations,
-                    null,
+                    providedChecksums,
                     listener);
             if (executor == null || first) {
                 task.run();
@@ -583,43 +603,49 @@ final class BasicRepositoryConnector implements RepositoryConnector {
          * @param path  source
          * @param bytes transformed data from file or {@code null}
          */
-        private void uploadChecksums(Path path, byte[] bytes) {
+        private void uploadChecksums(Path path, byte[] bytes) throws Exception {
             if (checksumLocations.isEmpty()) {
                 return;
             }
+            Map<String, String> sumsByAlgo;
             try {
                 ArrayList<ChecksumAlgorithmFactory> algorithms = new ArrayList<>();
                 for (RepositoryLayout.ChecksumLocation checksumLocation : checksumLocations) {
                     algorithms.add(checksumLocation.getChecksumAlgorithmFactory());
                 }
 
-                Map<String, String> sumsByAlgo;
                 if (bytes != null) {
                     sumsByAlgo = ChecksumAlgorithmHelper.calculate(bytes, algorithms);
                 } else {
                     sumsByAlgo = ChecksumAlgorithmHelper.calculate(path, algorithms);
                 }
-
-                for (RepositoryLayout.ChecksumLocation checksumLocation : checksumLocations) {
-                    uploadChecksum(
-                            checksumLocation.getLocation(),
-                            sumsByAlgo.get(checksumLocation
-                                    .getChecksumAlgorithmFactory()
-                                    .getName()));
-                }
             } catch (IOException e) {
                 LOGGER.warn("Failed to upload checksums for {}", file, e);
                 throw new UncheckedIOException(e);
             }
+
+            for (RepositoryLayout.ChecksumLocation checksumLocation : checksumLocations) {
+                uploadChecksum(
+                        checksumLocation.getLocation(),
+                        sumsByAlgo.get(
+                                checksumLocation.getChecksumAlgorithmFactory().getName()));
+            }
         }
 
-        private void uploadChecksum(URI location, Object checksum) {
+        private void uploadChecksum(URI location, Object checksum) throws Exception {
             try {
                 if (checksum instanceof Exception) {
                     throw (Exception) checksum;
                 }
                 transporter.put(new PutTask(location).setDataString((String) checksum));
             } catch (Exception e) {
+                if (failOnChecksumUploadFailure) {
+                    // Fail-closed (default): a deploy must be atomic across artifact bytes and integrity
+                    // metadata. Swallowing this failure would report a successful deploy that published the
+                    // artifact without checksums for consumers to verify.
+                    LOGGER.error("Failed to upload checksum to {}", location, e);
+                    throw e;
+                }
                 LOGGER.warn("Failed to upload checksum to {}", location, e);
             }
         }
diff --git a/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnectorConfigurationKeys.java b/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnectorConfigurationKeys.java
index 154595ced..23a6c761c 100644
--- a/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnectorConfigurationKeys.java
+++ b/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnectorConfigurationKeys.java
@@ -117,4 +117,22 @@ public final class BasicRepositoryConnectorConfigurationKeys {
     public static final String CONFIG_PROP_INCLUDED_CHECKSUMS = CONFIG_PROPS_PREFIX + "includedChecksums";
 
     public static final boolean DEFAULT_INCLUDED_CHECKSUMS = true;
+
+    /**
+     * Flag indicating whether a failed checksum upload should fail the whole PUT transfer. When enabled
+     * (default), a deploy is atomic across the artifact bytes and their integrity metadata: if a checksum
+     * cannot be calculated or its upload fails, the transfer fails, instead of the remote repository silently
+     * ending up with an artifact published without checksums for consumers to verify. When disabled, checksum
+     * upload failures are only logged at WARN level (legacy, fail-open behavior).
+     *
+     * @since 2.0.23
+     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
+     * @configurationType {@link java.lang.Boolean}
+     * @configurationDefaultValue {@link #DEFAULT_FAIL_ON_CHECKSUM_UPLOAD_FAILURE}
+     * @configurationRepoIdSuffix Yes
+     */
+    public static final String CONFIG_PROP_FAIL_ON_CHECKSUM_UPLOAD_FAILURE =
+            CONFIG_PROPS_PREFIX + "failOnChecksumUploadFailure";
+
+    public static final boolean DEFAULT_FAIL_ON_CHECKSUM_UPLOAD_FAILURE = true;
 }
diff --git a/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/ChecksumValidator.java b/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/ChecksumValidator.java
index 39b0cc3ee..7ab4dc67b 100644
--- a/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/ChecksumValidator.java
+++ b/maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/ChecksumValidator.java
@@ -120,6 +120,8 @@ final class ChecksumValidator {
 
     private boolean validateChecksums(Map<String, ?> actualChecksums, ChecksumKind kind, Map<String, ?> checksums)
             throws ChecksumFailureException {
+        boolean accepted = false;
+        boolean rejected = false;
         for (Map.Entry<String, ?> entry : checksums.entrySet()) {
             String algo = entry.getKey();
             Object calculated = actualChecksums.get(algo);
@@ -139,18 +141,24 @@ final class ChecksumValidator {
             checksumExpectedValues.put(getChecksumPath(checksumAlgorithmFactory), expected);
 
             if (!isEqualChecksum(expected, actual)) {
+                // ALL comparable checksums of this kind must match: a mismatch on any algorithm rejects the
+                // whole kind, so a match on a weaker algorithm (map iteration order is not strength-ordered)
+                // cannot mask a recorded mismatch on a stronger one. The policy decides whether the mismatch
+                // fails the transfer immediately (default) or is merely recorded.
+                rejected = true;
                 checksumPolicy.onChecksumMismatch(
                         checksumAlgorithmFactory.getName(),
                         kind,
                         ChecksumFailureException.mismatch(expected, kind.name(), actual));
             } else if (checksumPolicy.onChecksumMatch(checksumAlgorithmFactory.getName(), kind)) {
-                return true;
+                accepted = true;
             }
         }
-        return false;
+        return accepted && !rejected;
     }
 
     private boolean validateExternalChecksums(Map<String, ?> actualChecksums) throws ChecksumFailureException {
+        boolean rejected = false;
         for (ChecksumLocation checksumLocation : checksumLocations) {
             ChecksumAlgorithmFactory factory = checksumLocation.getChecksumAlgorithmFactory();
             Object calculated = actualChecksums.get(factory.getName());
@@ -182,11 +190,15 @@ final class ChecksumValidator {
                 checksumExpectedValues.put(checksumFile, expected);
 
                 if (!isEqualChecksum(expected, actual)) {
+                    rejected = true;
                     checksumPolicy.onChecksumMismatch(
                             factory.getName(),
                             ChecksumKind.REMOTE_EXTERNAL,
                             ChecksumFailureException.mismatch(expected, ChecksumKind.REMOTE_EXTERNAL.name(), actual));
-                } else if (checksumPolicy.onChecksumMatch(factory.getName(), ChecksumKind.REMOTE_EXTERNAL)) {
+                } else if (checksumPolicy.onChecksumMatch(factory.getName(), ChecksumKind.REMOTE_EXTERNAL)
+                        && !rejected) {
+                    // a fetched checksum that mismatched earlier already rejected this download: a later
+                    // (possibly weaker) matching algorithm must not accept it
                     return true;
                 }
             } catch (IOException e) {
diff --git a/maven-resolver-connector-basic/src/test/java/org/eclipse/aether/connector/basic/ChecksumValidatorTest.java b/maven-resolver-connector-basic/src/test/java/org/eclipse/aether/connector/basic/ChecksumValidatorTest.java
index ce6748c79..ad18ca861 100644
--- a/maven-resolver-connector-basic/src/test/java/org/eclipse/aether/connector/basic/ChecksumValidatorTest.java
+++ b/maven-resolver-connector-basic/src/test/java/org/eclipse/aether/connector/basic/ChecksumValidatorTest.java
@@ -254,6 +254,54 @@ public class ChecksumValidatorTest {
         policy.assertCallbacks("mismatch(SHA-1, REMOTE_EXTERNAL)");
     }
 
+    @Test
+    void testValidate_ProvidedAllComparableAlgorithmsMustMatch() {
+        // the weaker algorithm matches, the stronger one does not: the artifact must be rejected regardless
+        // of map iteration order (previously the first matching algorithm accepted the artifact and skipped
+        // the remaining, stronger checksums)
+        Map<String, String> provided = new LinkedHashMap<>();
+        provided.put(MD5, "bar");
+        provided.put(SHA1, "foo");
+        ChecksumValidator validator = newValidator(provided, SHA1, MD5);
+        try {
+            validator.validate(checksums(SHA1, "not-foo", MD5, "bar"), null);
+            fail("expected exception");
+        } catch (ChecksumFailureException e) {
+            assertEquals("foo", e.getExpected());
+            assertEquals(ChecksumKind.PROVIDED.name(), e.getExpectedKind());
+            assertEquals("not-foo", e.getActual());
+            assertTrue(e.isRetryWorthy());
+        }
+        fetcher.assertFetchedFiles();
+        policy.assertCallbacks("match(MD5, PROVIDED)", "mismatch(SHA-1, PROVIDED)");
+    }
+
+    @Test
+    void testValidate_ProvidedAllMatchingAccepts() throws Exception {
+        Map<String, String> provided = new LinkedHashMap<>();
+        provided.put(SHA1, "foo");
+        provided.put(MD5, "bar");
+        ChecksumValidator validator = newValidator(provided, SHA1, MD5);
+        validator.validate(checksums(SHA1, "foo", MD5, "bar"), null);
+        fetcher.assertFetchedFiles();
+        policy.assertCallbacks("match(SHA-1, PROVIDED)", "match(MD5, PROVIDED)");
+    }
+
+    @Test
+    void testValidate_IncludedWeakMatchDoesNotMaskStrongMismatch() {
+        ChecksumValidator validator = newValidator(SHA1, MD5);
+        try {
+            validator.validate(checksums(SHA1, "not-foo", MD5, "bar"), checksums(MD5, "bar", SHA1, "foo"));
+            fail("expected exception");
+        } catch (ChecksumFailureException e) {
+            assertEquals("foo", e.getExpected());
+            assertEquals(ChecksumKind.REMOTE_INCLUDED.name(), e.getExpectedKind());
+            assertEquals("not-foo", e.getActual());
+        }
+        fetcher.assertFetchedFiles();
+        policy.assertCallbacks("match(MD5, REMOTE_INCLUDED)", "mismatch(SHA-1, REMOTE_INCLUDED)");
+    }
+
     @Test
     void testValidate_AcceptOnEnd() throws Exception {
         policy.inspectAll = true;
diff --git a/maven-resolver-generator-gnupg/src/main/java/org/eclipse/aether/generator/gnupg/GnupgSignatureArtifactGenerator.java b/maven-resolver-generator-gnupg/src/main/java/org/eclipse/aether/generator/gnupg/GnupgSignatureArtifactGenerator.java
index c9d3ed491..a29eec498 100644
--- a/maven-resolver-generator-gnupg/src/main/java/org/eclipse/aether/generator/gnupg/GnupgSignatureArtifactGenerator.java
+++ b/maven-resolver-generator-gnupg/src/main/java/org/eclipse/aether/generator/gnupg/GnupgSignatureArtifactGenerator.java
@@ -87,28 +87,48 @@ final class GnupgSignatureArtifactGenerator implements ArtifactGenerator {
         try {
             artifacts.addAll(generatedArtifacts);
 
-            // back out if PGP signatures found among artifacts
-            if (artifacts.stream().anyMatch(a -> a.getExtension().endsWith(ARTIFACT_EXTENSION))) {
-                logger.debug("GPG signatures are present among artifacts, bailing out");
+            // Determine, per artifact, which signable artifacts still need a signature. A pre-existing signature
+            // (e.g. produced by maven-gpg-plugin) skips only the artifact it covers; it must not disable signing
+            // of the whole artifact set, which would silently publish partially unsigned releases.
+            ArrayList<Artifact> artifactsToSign = new ArrayList<>();
+            for (Artifact artifact : artifacts) {
+                if (isSignatureArtifact(artifact)) {
+                    continue; // never sign a signature
+                }
+                if (!signableArtifactPredicate.test(artifact)) {
+                    continue;
+                }
+                if (hasSignature(artifact)) {
+                    logger.debug("GPG signature already present for {}, not signing it again", artifact);
+                    continue;
+                }
+                artifactsToSign.add(artifact);
+            }
+            if (artifactsToSign.isEmpty()) {
+                logger.debug("GPG signatures are present for all signable artifacts, nothing to sign");
                 return Collections.emptyList();
             }
+            if (artifacts.stream().anyMatch(this::isSignatureArtifact)) {
+                logger.info(
+                        "GPG signatures are present for some artifacts only; signing the remaining {} artifact(s) with key {}",
+                        artifactsToSign.size(),
+                        keyInfo);
+            }
 
             // sign relevant artifacts
             ArrayList<Artifact> result = new ArrayList<>();
-            for (Artifact artifact : artifacts) {
-                if (signableArtifactPredicate.test(artifact)) {
-                    Path signatureTempFile = Files.createTempFile("signer-pgp", "tmp");
-                    signatureTempFiles.add(signatureTempFile);
-                    try (InputStream artifactContent = Files.newInputStream(artifact.getPath());
-                            OutputStream signatureContent = Files.newOutputStream(signatureTempFile)) {
-                        sign(artifactContent, signatureContent);
-                    }
-                    result.add(new SubArtifact(
-                            artifact,
-                            artifact.getClassifier(),
-                            artifact.getExtension() + ARTIFACT_EXTENSION,
-                            signatureTempFile.toFile()));
+            for (Artifact artifact : artifactsToSign) {
+                Path signatureTempFile = Files.createTempFile("signer-pgp", "tmp");
+                signatureTempFiles.add(signatureTempFile);
+                try (InputStream artifactContent = Files.newInputStream(artifact.getPath());
+                        OutputStream signatureContent = Files.newOutputStream(signatureTempFile)) {
+                    sign(artifactContent, signatureContent);
                 }
+                result.add(new SubArtifact(
+                        artifact,
+                        artifact.getClassifier(),
+                        artifact.getExtension() + ARTIFACT_EXTENSION,
+                        signatureTempFile.toFile()));
             }
             logger.debug("Signed {} artifacts with key {}", result.size(), keyInfo);
             return result;
@@ -117,6 +137,20 @@ final class GnupgSignatureArtifactGenerator implements ArtifactGenerator {
         }
     }
 
+    private boolean isSignatureArtifact(Artifact artifact) {
+        return artifact.getExtension().endsWith(ARTIFACT_EXTENSION);
+    }
+
+    private boolean hasSignature(Artifact artifact) {
+        String signatureExtension = artifact.getExtension() + ARTIFACT_EXTENSION;
+        return artifacts.stream()
+                .anyMatch(a -> a.getExtension().equals(signatureExtension)
+                        && a.getClassifier().equals(artifact.getClassifier())
+                        && a.getArtifactId().equals(artifact.getArtifactId())
+                        && a.getGroupId().equals(artifact.getGroupId())
+                        && a.getVersion().equals(artifact.getVersion()));
+    }
+
     @Override
     public void close() {
         if (closed.compareAndSet(false, true)) {
diff --git a/maven-resolver-generator-gnupg/src/test/java/org/eclipse/aether/generator/gnupg/GpgSignerFactoryTest.java b/maven-resolver-generator-gnupg/src/test/java/org/eclipse/aether/generator/gnupg/GpgSignerFactoryTest.java
index 9f746f2df..94c4400a5 100644
--- a/maven-resolver-generator-gnupg/src/test/java/org/eclipse/aether/generator/gnupg/GpgSignerFactoryTest.java
+++ b/maven-resolver-generator-gnupg/src/test/java/org/eclipse/aether/generator/gnupg/GpgSignerFactoryTest.java
@@ -154,6 +154,32 @@ public class GpgSignerFactoryTest {
         }
     }
 
+    @Test
+    void signRemainingArtifactsWhenSomeSignaturesArePresent() throws Exception {
+        Path keyFile =
+                Paths.get("src/test/resources/gpg-signing/gpg-secret.key").toAbsolutePath();
+        String keyPass = "TheBigSecret";
+        GnupgSignatureArtifactGeneratorFactory factory = createFactory();
+        try (ArtifactGenerator signer =
+                factory.newInstance(createSession(keyFile, keyPass, false), new DeployRequest())) {
+            assertNotNull(signer);
+            Path artifactPath = Paths.get("src/test/resources/gpg-signing/artifact.txt");
+
+            // the jar already carries its signature; the pom does not: the pre-existing signature must skip
+            // only the jar, not disable signing of the whole artifact set (which would silently publish a
+            // partially unsigned release)
+            Collection<? extends Artifact> signatures = signer.generate(Arrays.asList(
+                    new DefaultArtifact("org.apache.maven.resolver:test:jar:1.0").setPath(artifactPath),
+                    new DefaultArtifact("org.apache.maven.resolver:test:jar.asc:1.0").setPath(artifactPath),
+                    new DefaultArtifact("org.apache.maven.resolver:test:pom:1.0").setPath(artifactPath)));
+
+            assertEquals(1, signatures.size());
+            Artifact signature = signatures.iterator().next();
+            assertEquals("pom.asc", signature.getExtension());
+            assertEquals("", signature.getClassifier());
+        }
+    }
+
     @Test
     void signNonInteractive() throws Exception {
         Path keyFile =
diff --git a/maven-resolver-generator-sigstore/src/main/java/org/eclipse/aether/generator/sigstore/SigstoreSignatureArtifactGenerator.java b/maven-resolver-generator-sigstore/src/main/java/org/eclipse/aether/generator/sigstore/SigstoreSignatureArtifactGenerator.java
index 63906cf07..472bcb6f0 100644
--- a/maven-resolver-generator-sigstore/src/main/java/org/eclipse/aether/generator/sigstore/SigstoreSignatureArtifactGenerator.java
+++ b/maven-resolver-generator-sigstore/src/main/java/org/eclipse/aether/generator/sigstore/SigstoreSignatureArtifactGenerator.java
@@ -79,11 +79,32 @@ final class SigstoreSignatureArtifactGenerator implements ArtifactGenerator {
         try {
             artifacts.addAll(generatedArtifacts);
 
-            // back out if Sigstore signatures found among artifacts
-            if (artifacts.stream().anyMatch(a -> a.getExtension().endsWith(ARTIFACT_EXTENSION))) {
-                logger.debug("Sigstore signatures are present among artifacts, bailing out");
+            // Determine, per artifact, which signable artifacts still need a signature. A pre-existing signature
+            // skips only the artifact it covers; it must not disable signing of the whole artifact set, which
+            // would silently publish partially unsigned releases.
+            ArrayList<Artifact> artifactsToSign = new ArrayList<>();
+            for (Artifact artifact : artifacts) {
+                if (isSignatureArtifact(artifact)) {
+                    continue; // never sign a signature
+                }
+                if (!signableArtifactPredicate.test(artifact)) {
+                    continue;
+                }
+                if (hasSignature(artifact)) {
+                    logger.debug("Sigstore signature already present for {}, not signing it again", artifact);
+                    continue;
+                }
+                artifactsToSign.add(artifact);
+            }
+            if (artifactsToSign.isEmpty()) {
+                logger.debug("Sigstore signatures are present for all signable artifacts, nothing to sign");
                 return Collections.emptyList();
             }
+            if (artifacts.stream().anyMatch(this::isSignatureArtifact)) {
+                logger.info(
+                        "Sigstore signatures are present for some artifacts only; signing the remaining {} artifact(s)",
+                        artifactsToSign.size());
+            }
 
             // sign relevant artifacts
             ArrayList<Artifact> result = new ArrayList<>();
@@ -92,47 +113,45 @@ final class SigstoreSignatureArtifactGenerator implements ArtifactGenerator {
             try (KeylessSigner signer = publicStaging
                     ? KeylessSigner.builder().sigstoreStagingDefaults().build()
                     : KeylessSigner.builder().sigstorePublicDefaults().build()) {
-                for (Artifact artifact : artifacts) {
-                    if (signableArtifactPredicate.test(artifact)) {
-                        Path fileToSign = artifact.getPath();
-                        Path signatureTempFile = Files.createTempFile("signer-sigstore", "tmp");
-                        signatureTempFiles.add(signatureTempFile);
-
-                        logger.debug("Signing " + artifact);
-                        long start = System.currentTimeMillis();
-                        Bundle bundle = signer.signFile(fileToSign);
-
-                        X509Certificate cert = (X509Certificate)
-                                bundle.getCertPath().getCertificates().get(0);
-                        long durationMinutes = Certificates.validity(cert, ChronoUnit.MINUTES);
-
-                        logger.debug("  Fulcio certificate (valid for "
-                                + durationMinutes
-                                + " m) obtained for "
-                                + cert.getSubjectAlternativeNames()
-                                        .iterator()
-                                        .next()
-                                        .get(1)
-                                + " (by "
-                                + FulcioOidHelper.getIssuerV2(cert)
-                                + " IdP)");
-
-                        pathProcessor.write(signatureTempFile, bundle.toJson());
-
-                        long duration = System.currentTimeMillis() - start;
-                        logger.debug("  > Rekor entry "
-                                + bundle.getEntries().get(0).getLogIndex()
-                                + " obtained in "
-                                + duration
-                                + " ms, saved to "
-                                + signatureTempFile);
-
-                        result.add(new SubArtifact(
-                                artifact,
-                                artifact.getClassifier(),
-                                artifact.getExtension() + ARTIFACT_EXTENSION,
-                                signatureTempFile.toFile()));
-                    }
+                for (Artifact artifact : artifactsToSign) {
+                    Path fileToSign = artifact.getPath();
+                    Path signatureTempFile = Files.createTempFile("signer-sigstore", "tmp");
+                    signatureTempFiles.add(signatureTempFile);
+
+                    logger.debug("Signing " + artifact);
+                    long start = System.currentTimeMillis();
+                    Bundle bundle = signer.signFile(fileToSign);
+
+                    X509Certificate cert = (X509Certificate)
+                            bundle.getCertPath().getCertificates().get(0);
+                    long durationMinutes = Certificates.validity(cert, ChronoUnit.MINUTES);
+
+                    logger.debug("  Fulcio certificate (valid for "
+                            + durationMinutes
+                            + " m) obtained for "
+                            + cert.getSubjectAlternativeNames()
+                                    .iterator()
+                                    .next()
+                                    .get(1)
+                            + " (by "
+                            + FulcioOidHelper.getIssuerV2(cert)
+                            + " IdP)");
+
+                    pathProcessor.write(signatureTempFile, bundle.toJson());
+
+                    long duration = System.currentTimeMillis() - start;
+                    logger.debug("  > Rekor entry "
+                            + bundle.getEntries().get(0).getLogIndex()
+                            + " obtained in "
+                            + duration
+                            + " ms, saved to "
+                            + signatureTempFile);
+
+                    result.add(new SubArtifact(
+                            artifact,
+                            artifact.getClassifier(),
+                            artifact.getExtension() + ARTIFACT_EXTENSION,
+                            signatureTempFile.toFile()));
                 }
             } finally {
                 Thread.currentThread().setContextClassLoader(originalClassLoader);
@@ -150,6 +169,20 @@ final class SigstoreSignatureArtifactGenerator implements ArtifactGenerator {
         }
     }
 
+    private boolean isSignatureArtifact(Artifact artifact) {
+        return artifact.getExtension().endsWith(ARTIFACT_EXTENSION);
+    }
+
+    private boolean hasSignature(Artifact artifact) {
+        String signatureExtension = artifact.getExtension() + ARTIFACT_EXTENSION;
+        return artifacts.stream()
+                .anyMatch(a -> a.getExtension().equals(signatureExtension)
+                        && a.getClassifier().equals(artifact.getClassifier())
+                        && a.getArtifactId().equals(artifact.getArtifactId())
+                        && a.getGroupId().equals(artifact.getGroupId())
+                        && a.getVersion().equals(artifact.getVersion()));
+    }
+
     @Override
     public void close() {
         if (closed.compareAndSet(false, true)) {
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceSupport.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceSupport.java
index 8f052b92b..b41d845ae 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceSupport.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceSupport.java
@@ -27,6 +27,7 @@ import java.util.Map;
 import org.eclipse.aether.ConfigurationProperties;
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.repository.ArtifactRepository;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.spi.checksums.TrustedChecksumsSource;
@@ -101,6 +102,29 @@ public abstract class FileTrustedChecksumsSourceSupport implements TrustedChecks
         return null;
     }
 
+    /**
+     * This implementation will call into underlying code only if enabled, and will enforce non-{@code null} return
+     * value. In worst case, empty map should be returned, meaning "no trusted checksums available".
+     *
+     * @since 2.0.23
+     */
+    @Override
+    public Map<String, String> getTrustedMetadataChecksums(
+            RepositorySystemSession session,
+            Metadata metadata,
+            ArtifactRepository artifactRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        requireNonNull(session, "session is null");
+        requireNonNull(metadata, "metadata is null");
+        requireNonNull(artifactRepository, "artifactRepository is null");
+        requireNonNull(checksumAlgorithmFactories, "checksumAlgorithmFactories is null");
+        if (isEnabled(session)) {
+            return requireNonNull(
+                    doGetTrustedMetadataChecksums(session, metadata, artifactRepository, checksumAlgorithmFactories));
+        }
+        return null;
+    }
+
     /**
      * This implementation will call into underlying code only if enabled. Underlying implementation may still choose
      * to return {@code null}.
@@ -123,6 +147,19 @@ public abstract class FileTrustedChecksumsSourceSupport implements TrustedChecks
             ArtifactRepository artifactRepository,
             List<ChecksumAlgorithmFactory> checksumAlgorithmFactories);
 
+    /**
+     * Implementors MUST NOT return {@code null} at this point, as this source is enabled. Metadata checksums are
+     * looked up using the same file conventions as artifact checksums, with the metadata path composed from the
+     * origin repository key (for example {@code g/a/v/maven-metadata-central.xml}).
+     *
+     * @since 2.0.23
+     */
+    protected abstract Map<String, String> doGetTrustedMetadataChecksums(
+            RepositorySystemSession session,
+            Metadata metadata,
+            ArtifactRepository artifactRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories);
+
     /**
      * Implementors may override this method and return {@link Writer} instance.
      */
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SparseDirectoryTrustedChecksumsSource.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SparseDirectoryTrustedChecksumsSource.java
index 8094374ca..c72366e64 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SparseDirectoryTrustedChecksumsSource.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SparseDirectoryTrustedChecksumsSource.java
@@ -34,6 +34,7 @@ import java.util.function.Function;
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.internal.impl.LocalPathComposer;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.repository.ArtifactRepository;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 import org.eclipse.aether.spi.io.ChecksumProcessor;
@@ -48,7 +49,9 @@ import static java.util.Objects.requireNonNull;
 /**
  * Sparse file {@link FileTrustedChecksumsSourceSupport} implementation that use specified directory as base
  * directory, where it expects artifacts checksums on standard Maven2 "local" layout. This implementation uses Artifact
- * coordinates solely to form path from basedir, pretty much as Maven local repository does.
+ * coordinates solely to form path from basedir, pretty much as Maven local repository does. Metadata checksums are
+ * covered as well, on the same layout, with the metadata path composed from the origin repository key (for example
+ * {@code g/a/v/maven-metadata-central.xml.sha1}).
  * <p>
  * The source by default is "origin aware", it will factor in origin repository ID as well into base directory name
  * (for example ".checksums/central/...").
@@ -163,6 +166,45 @@ public final class SparseDirectoryTrustedChecksumsSource extends FileTrustedChec
         return checksums;
     }
 
+    @Override
+    protected Map<String, String> doGetTrustedMetadataChecksums(
+            RepositorySystemSession session,
+            Metadata metadata,
+            ArtifactRepository artifactRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        final boolean originAware = isOriginAware(session);
+        final HashMap<String, String> checksums = new HashMap<>();
+        Path basedir = getBasedir(session, LOCAL_REPO_PREFIX_DIR, CONFIG_PROP_BASEDIR, false);
+        if (Files.isDirectory(basedir)) {
+            for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
+                Path checksumPath = basedir.resolve(calculateMetadataPath(
+                        originAware, metadata, repositoryKey(session, artifactRepository), checksumAlgorithmFactory));
+
+                if (!Files.isRegularFile(checksumPath)) {
+                    LOGGER.debug(
+                            "Metadata '{}' trusted checksum '{}' not found on path '{}'",
+                            metadata,
+                            checksumAlgorithmFactory.getName(),
+                            checksumPath);
+                    continue;
+                }
+
+                try {
+                    String checksum = checksumProcessor.readChecksum(checksumPath);
+                    if (checksum != null) {
+                        checksums.put(checksumAlgorithmFactory.getName(), checksum);
+                    }
+                } catch (IOException e) {
+                    // unexpected, log
+                    LOGGER.warn(
+                            "Could not read metadata '{}' trusted checksum on path '{}'", metadata, checksumPath, e);
+                    throw new UncheckedIOException(e);
+                }
+            }
+        }
+        return checksums;
+    }
+
     @Override
     protected Writer doGetTrustedArtifactChecksumsWriter(RepositorySystemSession session) {
         return new SparseDirectoryWriter(
@@ -187,6 +229,21 @@ public final class SparseDirectoryTrustedChecksumsSource extends FileTrustedChec
         return path;
     }
 
+    private String calculateMetadataPath(
+            boolean originAware,
+            Metadata metadata,
+            String safeRepositoryId,
+            ChecksumAlgorithmFactory checksumAlgorithmFactory) {
+        String path = localPathComposer.getPathForMetadata(metadata, safeRepositoryId) + "."
+                + checksumAlgorithmFactory.getFileExtension();
+        if (originAware) {
+            // defense in depth: same guard as for artifact paths, the repository key is spliced into a directory
+            PathUtils.validatePathComponent(safeRepositoryId, "repository key");
+            path = safeRepositoryId + "/" + path;
+        }
+        return path;
+    }
+
     private class SparseDirectoryWriter implements Writer {
         private final Path basedir;
 
@@ -218,5 +275,23 @@ public final class SparseDirectoryTrustedChecksumsSource extends FileTrustedChec
                 checksumProcessor.writeChecksum(checksumPath, checksum);
             }
         }
+
+        @Override
+        public void addTrustedMetadataChecksums(
+                Metadata metadata,
+                ArtifactRepository artifactRepository,
+                List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
+                Map<String, String> trustedMetadataChecksums)
+                throws IOException {
+            for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
+                Path checksumPath = basedir.resolve(calculateMetadataPath(
+                        originAware,
+                        metadata,
+                        idToPathSegmentFunction.apply(artifactRepository),
+                        checksumAlgorithmFactory));
+                String checksum = requireNonNull(trustedMetadataChecksums.get(checksumAlgorithmFactory.getName()));
+                checksumProcessor.writeChecksum(checksumPath, checksum);
+            }
+        }
     }
 }
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SummaryFileTrustedChecksumsSource.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SummaryFileTrustedChecksumsSource.java
index f7f451546..bc982be8c 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SummaryFileTrustedChecksumsSource.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/SummaryFileTrustedChecksumsSource.java
@@ -43,6 +43,7 @@ import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.impl.RepositorySystemLifecycle;
 import org.eclipse.aether.internal.impl.LocalPathComposer;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.repository.ArtifactRepository;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 import org.eclipse.aether.spi.io.PathProcessor;
@@ -57,7 +58,8 @@ import static java.util.Objects.requireNonNull;
  * Compact file {@link FileTrustedChecksumsSourceSupport} implementation that use specified directory as base
  * directory, where it expects a "summary" file named as "checksums.${checksumExt}" for each checksum algorithm.
  * File format is GNU Coreutils compatible: each line holds checksum followed by two spaces and artifact relative path
- * (from local repository root, without leading "./"). This means that trusted checksums summary file can be used to
+ * (from local repository root, without leading "./"). Metadata checksums are covered as well, keyed by the metadata
+ * path composed from the origin repository key (for example {@code g/a/v/maven-metadata-central.xml}). This means that trusted checksums summary file can be used to
  * validate artifacts or generate it using standard GNU tools like GNU {@code sha1sum} is (for BSD derivatives same
  * file can be used with {@code -r} switch).
  * <p>
@@ -170,10 +172,38 @@ public final class SummaryFileTrustedChecksumsSource extends FileTrustedChecksum
             Artifact artifact,
             ArtifactRepository artifactRepository,
             List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        return doGetTrustedPathChecksums(
+                session,
+                localPathComposer.getPathForArtifact(artifact, false),
+                artifactRepository,
+                checksumAlgorithmFactories);
+    }
+
+    @Override
+    protected Map<String, String> doGetTrustedMetadataChecksums(
+            RepositorySystemSession session,
+            Metadata metadata,
+            ArtifactRepository artifactRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        return doGetTrustedPathChecksums(
+                session,
+                localPathComposer.getPathForMetadata(metadata, repositoryKey(session, artifactRepository)),
+                artifactRepository,
+                checksumAlgorithmFactories);
+    }
+
+    /**
+     * Looks up the trusted checksums for given local-repository style path (the summary file 2nd column), be it
+     * an artifact or a metadata path.
+     */
+    private Map<String, String> doGetTrustedPathChecksums(
+            RepositorySystemSession session,
+            String path,
+            ArtifactRepository artifactRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
         final HashMap<String, String> result = new HashMap<>();
         final Path basedir = getBasedir(session, LOCAL_REPO_PREFIX_DIR, CONFIG_PROP_BASEDIR, false);
         if (Files.isDirectory(basedir)) {
-            final String artifactPath = localPathComposer.getPathForArtifact(artifact, false);
             final boolean originAware = isOriginAware(session);
             for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
                 Path summaryFile = summaryFile(
@@ -183,7 +213,7 @@ public final class SummaryFileTrustedChecksumsSource extends FileTrustedChecksum
                         checksumAlgorithmFactory.getFileExtension());
                 ConcurrentHashMap<String, String> algorithmChecksums =
                         checksums.computeIfAbsent(summaryFile, f -> loadProvidedChecksums(summaryFile));
-                String checksum = algorithmChecksums.get(artifactPath);
+                String checksum = algorithmChecksums.get(path);
                 if (checksum != null) {
                     result.put(checksumAlgorithmFactory.getName(), checksum);
                 }
@@ -284,27 +314,50 @@ public final class SummaryFileTrustedChecksumsSource extends FileTrustedChecksum
                 ArtifactRepository artifactRepository,
                 List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
                 Map<String, String> trustedArtifactChecksums) {
-            String artifactPath = localPathComposer.getPathForArtifact(artifact, false);
+            addTrustedPathChecksums(
+                    artifact,
+                    localPathComposer.getPathForArtifact(artifact, false),
+                    artifactRepository,
+                    checksumAlgorithmFactories,
+                    trustedArtifactChecksums);
+        }
+
+        @Override
+        public void addTrustedMetadataChecksums(
+                Metadata metadata,
+                ArtifactRepository artifactRepository,
+                List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
+                Map<String, String> trustedMetadataChecksums) {
+            addTrustedPathChecksums(
+                    metadata,
+                    localPathComposer.getPathForMetadata(metadata, repositoryKeyFunction.apply(artifactRepository)),
+                    artifactRepository,
+                    checksumAlgorithmFactories,
+                    trustedMetadataChecksums);
+        }
+
+        private void addTrustedPathChecksums(
+                Object subject,
+                String path,
+                ArtifactRepository artifactRepository,
+                List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
+                Map<String, String> trustedChecksums) {
             for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
                 Path summaryFile = summaryFile(
                         basedir,
                         originAware,
                         repositoryKeyFunction.apply(artifactRepository),
                         checksumAlgorithmFactory.getFileExtension());
-                String checksum = requireNonNull(trustedArtifactChecksums.get(checksumAlgorithmFactory.getName()));
+                String checksum = requireNonNull(trustedChecksums.get(checksumAlgorithmFactory.getName()));
 
                 String oldChecksum = cache.computeIfAbsent(summaryFile, k -> loadProvidedChecksums(summaryFile))
-                        .put(artifactPath, checksum);
+                        .put(path, checksum);
 
                 if (oldChecksum == null) {
                     changedChecksums.put(summaryFile, Boolean.TRUE); // new
                 } else if (!Objects.equals(oldChecksum, checksum)) {
                     changedChecksums.put(summaryFile, Boolean.TRUE); // replaced
-                    LOGGER.info(
-                            "Trusted checksum for artifact {} replaced: old {}, new {}",
-                            artifact,
-                            oldChecksum,
-                            checksum);
+                    LOGGER.info("Trusted checksum for {} replaced: old {}, new {}", subject, oldChecksum, checksum);
                 }
             }
         }
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapter.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapter.java
index 374cb8f54..16d2ca368 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapter.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapter.java
@@ -27,10 +27,12 @@ import java.util.Map;
 
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.spi.checksums.ProvidedChecksumsSource;
 import org.eclipse.aether.spi.checksums.TrustedChecksumsSource;
 import org.eclipse.aether.spi.connector.ArtifactDownload;
+import org.eclipse.aether.spi.connector.MetadataDownload;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 
 import static java.util.Objects.requireNonNull;
@@ -83,4 +85,33 @@ public final class TrustedToProvidedChecksumsSourceAdapter implements ProvidedCh
         }
         return null;
     }
+
+    @Override
+    public Map<String, String> getProvidedMetadataChecksums(
+            RepositorySystemSession session,
+            MetadataDownload transfer,
+            RemoteRepository repository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        Metadata metadata = transfer.getMetadata();
+        Map<String, String> trustedChecksums;
+        // check for connector repository
+        for (TrustedChecksumsSource trustedChecksumsSource : trustedChecksumsSources.values()) {
+            trustedChecksums = trustedChecksumsSource.getTrustedMetadataChecksums(
+                    session, metadata, repository, checksumAlgorithmFactories);
+            if (trustedChecksums != null && !trustedChecksums.isEmpty()) {
+                return trustedChecksums;
+            }
+        }
+        // if repo above is "mirrorOf", this one kicks in
+        for (RemoteRepository remoteRepository : transfer.getRepositories()) {
+            for (TrustedChecksumsSource trustedChecksumsSource : trustedChecksumsSources.values()) {
+                trustedChecksums = trustedChecksumsSource.getTrustedMetadataChecksums(
+                        session, metadata, remoteRepository, checksumAlgorithmFactories);
+                if (trustedChecksums != null && !trustedChecksums.isEmpty()) {
+                    return trustedChecksums;
+                }
+            }
+        }
+        return null;
+    }
 }
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceTestSupport.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceTestSupport.java
index eef4f2cc1..14c0349e1 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceTestSupport.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/FileTrustedChecksumsSourceTestSupport.java
@@ -28,6 +28,8 @@ import org.eclipse.aether.artifact.DefaultArtifact;
 import org.eclipse.aether.impl.RepositorySystemLifecycle;
 import org.eclipse.aether.internal.impl.DefaultRepositorySystemLifecycle;
 import org.eclipse.aether.internal.test.util.TestUtils;
+import org.eclipse.aether.metadata.DefaultMetadata;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.spi.checksums.TrustedChecksumsSource;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 import org.junit.jupiter.api.BeforeEach;
@@ -43,6 +45,14 @@ public abstract class FileTrustedChecksumsSourceTestSupport {
 
     private static final String ARTIFACT_TRUSTED_CHECKSUM = "trustedChecksum";
 
+    private static final Metadata METADATA_WITHOUT_CHECKSUM =
+            new DefaultMetadata("test", "test", "1.0", "maven-metadata.xml", Metadata.Nature.RELEASE);
+
+    private static final Metadata METADATA_WITH_CHECKSUM =
+            new DefaultMetadata("test", "test", "2.0", "maven-metadata.xml", Metadata.Nature.RELEASE);
+
+    private static final String METADATA_TRUSTED_CHECKSUM = "trustedMetadataChecksum";
+
     private DefaultRepositorySystemSession session;
 
     private ChecksumAlgorithmFactory checksumAlgorithmFactory;
@@ -73,6 +83,13 @@ public abstract class FileTrustedChecksumsSourceTestSupport {
                     prepareSession.getLocalRepository(),
                     Collections.singletonList(checksumAlgorithmFactory),
                     checksums);
+            HashMap<String, String> metadataChecksums = new HashMap<>();
+            metadataChecksums.put(checksumAlgorithmFactory.getName(), METADATA_TRUSTED_CHECKSUM);
+            writer.addTrustedMetadataChecksums(
+                    METADATA_WITH_CHECKSUM,
+                    prepareSession.getLocalRepository(),
+                    Collections.singletonList(checksumAlgorithmFactory),
+                    metadataChecksums);
             checksumWritten = true;
         }
     }
@@ -116,4 +133,40 @@ public abstract class FileTrustedChecksumsSourceTestSupport {
         assertEquals(1, providedChecksums.size());
         assertEquals(ARTIFACT_TRUSTED_CHECKSUM, providedChecksums.get(checksumAlgorithmFactory.getName()));
     }
+
+    @Test
+    void metadataNotEnabled() {
+        assertNull(subject.getTrustedMetadataChecksums(
+                session,
+                METADATA_WITH_CHECKSUM,
+                session.getLocalRepository(),
+                Collections.singletonList(checksumAlgorithmFactory)));
+    }
+
+    @Test
+    void noProvidedMetadataChecksum() {
+        enableSource(session);
+        Map<String, String> providedChecksums = subject.getTrustedMetadataChecksums(
+                session,
+                METADATA_WITHOUT_CHECKSUM,
+                session.getLocalRepository(),
+                Collections.singletonList(checksumAlgorithmFactory));
+        assertNotNull(providedChecksums);
+        assertTrue(providedChecksums.isEmpty());
+    }
+
+    @Test
+    void haveProvidedMetadataChecksum() {
+        assumeTrue(checksumWritten);
+        enableSource(session);
+        Map<String, String> providedChecksums = subject.getTrustedMetadataChecksums(
+                session,
+                METADATA_WITH_CHECKSUM,
+                session.getLocalRepository(),
+                Collections.singletonList(checksumAlgorithmFactory));
+        assertNotNull(providedChecksums);
+        assertFalse(providedChecksums.isEmpty());
+        assertEquals(1, providedChecksums.size());
+        assertEquals(METADATA_TRUSTED_CHECKSUM, providedChecksums.get(checksumAlgorithmFactory.getName()));
+    }
 }
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapterTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapterTest.java
index 760fd3a3b..00ced5873 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapterTest.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/checksum/TrustedToProvidedChecksumsSourceAdapterTest.java
@@ -26,9 +26,12 @@ import java.util.Map;
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.metadata.DefaultMetadata;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.spi.checksums.TrustedChecksumsSource;
 import org.eclipse.aether.spi.connector.ArtifactDownload;
+import org.eclipse.aether.spi.connector.MetadataDownload;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -43,6 +46,12 @@ public class TrustedToProvidedChecksumsSourceAdapterTest {
 
     private final Artifact artifactWithoutChecksum = new DefaultArtifact("g:a:v2");
 
+    private final Metadata metadataWithChecksum =
+            new DefaultMetadata("g", "a", "v1", "maven-metadata.xml", Metadata.Nature.RELEASE);
+
+    private final Metadata metadataWithoutChecksum =
+            new DefaultMetadata("g", "a", "v2", "maven-metadata.xml", Metadata.Nature.RELEASE);
+
     private final RemoteRepository repository =
             new RemoteRepository.Builder("repo", "default", "https://example.com").build();
 
@@ -62,6 +71,11 @@ public class TrustedToProvidedChecksumsSourceAdapterTest {
         when(trustedChecksumsSource.getTrustedArtifactChecksums(
                         eq(session), eq(artifactWithChecksum), eq(repository), eq(checksums)))
                 .thenReturn(result);
+        HashMap<String, String> metadataResult = new HashMap<>();
+        metadataResult.put(Sha1ChecksumAlgorithmFactory.NAME, "bar");
+        when(trustedChecksumsSource.getTrustedMetadataChecksums(
+                        eq(session), eq(metadataWithChecksum), eq(repository), eq(checksums)))
+                .thenReturn(metadataResult);
         adapter = new TrustedToProvidedChecksumsSourceAdapter(
                 Collections.singletonMap("trusted", trustedChecksumsSource));
     }
@@ -103,4 +117,42 @@ public class TrustedToProvidedChecksumsSourceAdapterTest {
         Map<String, String> chk = adapter.getProvidedArtifactChecksums(session, transfer, mrm, checksums);
         assertNull(chk);
     }
+
+    @Test
+    void testMetadataSimplePositive() {
+        MetadataDownload transfer = new MetadataDownload();
+        transfer.setMetadata(metadataWithChecksum);
+        Map<String, String> chk = adapter.getProvidedMetadataChecksums(session, transfer, repository, checksums);
+        assertNotNull(chk);
+        assertEquals("bar", chk.get(Sha1ChecksumAlgorithmFactory.NAME));
+    }
+
+    @Test
+    void testMetadataSimpleNegative() {
+        MetadataDownload transfer = new MetadataDownload();
+        transfer.setMetadata(metadataWithoutChecksum);
+        Map<String, String> chk = adapter.getProvidedMetadataChecksums(session, transfer, repository, checksums);
+        assertNull(chk);
+    }
+
+    @Test
+    void testMetadataMrmPositive() {
+        RemoteRepository mrm = new RemoteRepository.Builder("mrm", "default", "https://example.com").build();
+        MetadataDownload transfer = new MetadataDownload();
+        transfer.setMetadata(metadataWithChecksum);
+        transfer.setRepositories(Collections.singletonList(repository));
+        Map<String, String> chk = adapter.getProvidedMetadataChecksums(session, transfer, mrm, checksums);
+        assertNotNull(chk);
+        assertEquals("bar", chk.get(Sha1ChecksumAlgorithmFactory.NAME));
+    }
+
+    @Test
+    void testMetadataMrmNegative() {
+        RemoteRepository mrm = new RemoteRepository.Builder("mrm", "default", "https://example.com").build();
+        MetadataDownload transfer = new MetadataDownload();
+        transfer.setMetadata(metadataWithoutChecksum);
+        transfer.setRepositories(Collections.singletonList(repository));
+        Map<String, String> chk = adapter.getProvidedMetadataChecksums(session, transfer, mrm, checksums);
+        assertNull(chk);
+    }
 }
diff --git a/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/ProvidedChecksumsSource.java b/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/ProvidedChecksumsSource.java
index ddab3cf8f..ff4f83356 100644
--- a/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/ProvidedChecksumsSource.java
+++ b/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/ProvidedChecksumsSource.java
@@ -24,6 +24,7 @@ import java.util.Map;
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.spi.connector.ArtifactDownload;
+import org.eclipse.aether.spi.connector.MetadataDownload;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 import org.eclipse.aether.spi.connector.checksum.ChecksumPolicy;
 
@@ -57,4 +58,30 @@ public interface ProvidedChecksumsSource {
             ArtifactDownload transfer,
             RemoteRepository remoteRepository,
             List<ChecksumAlgorithmFactory> checksumAlgorithmFactories);
+
+    /**
+     * May return the provided checksums (for given metadata transfer) from source other than remote repository,
+     * or {@code null} if it has no checksums available for given transfer. Semantics are the same as for
+     * {@link #getProvidedArtifactChecksums(RepositorySystemSession, ArtifactDownload, RemoteRepository, List)},
+     * but covering metadata downloads: metadata like {@code maven-metadata.xml} influences resolution decisions
+     * (for example version range selection), so it should be coverable by the strongest configured integrity
+     * source just like artifacts are.
+     * <p>
+     * The default implementation returns {@code null} ("nothing to add here"), preserving the behavior of
+     * implementations written before this method existed.
+     *
+     * @param session                    The current session.
+     * @param transfer                   The metadata transfer that is about to be executed.
+     * @param remoteRepository           The remote repository connector is about to contact.
+     * @param checksumAlgorithmFactories The checksum algorithms that are expected.
+     * @return Map of expected checksums, or {@code null}.
+     * @since 2.0.23
+     */
+    default Map<String, String> getProvidedMetadataChecksums(
+            RepositorySystemSession session,
+            MetadataDownload transfer,
+            RemoteRepository remoteRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        return null;
+    }
 }
diff --git a/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/TrustedChecksumsSource.java b/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/TrustedChecksumsSource.java
index 2af3590cc..b74d70529 100644
--- a/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/TrustedChecksumsSource.java
+++ b/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/checksums/TrustedChecksumsSource.java
@@ -24,6 +24,7 @@ import java.util.Map;
 
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.metadata.Metadata;
 import org.eclipse.aether.repository.ArtifactRepository;
 import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
 
@@ -55,6 +56,31 @@ public interface TrustedChecksumsSource {
             ArtifactRepository artifactRepository,
             List<ChecksumAlgorithmFactory> checksumAlgorithmFactories);
 
+    /**
+     * May return the trusted checksums (for given metadata) from trusted source, or {@code null} if not enabled
+     * or the implementation does not cover metadata. Semantics are the same as for
+     * {@link #getTrustedArtifactChecksums(RepositorySystemSession, Artifact, ArtifactRepository, List)}, but
+     * covering metadata: metadata like {@code maven-metadata.xml} influences resolution decisions (for example
+     * version range selection), so trusted sources able to attest metadata should expose that here.
+     * <p>
+     * The default implementation returns {@code null} ("not enabled for metadata"), preserving the behavior of
+     * implementations written before this method existed.
+     *
+     * @param session                    The repository system session, never {@code null}.
+     * @param metadata                   The metadata we want checksums for, never {@code null}.
+     * @param artifactRepository         The origin repository: local, workspace, remote repository, never {@code null}.
+     * @param checksumAlgorithmFactories The checksum algorithms that are expected, never {@code null}.
+     * @return Map of expected checksums, or {@code null}.
+     * @since 2.0.23
+     */
+    default Map<String, String> getTrustedMetadataChecksums(
+            RepositorySystemSession session,
+            Metadata metadata,
+            ArtifactRepository artifactRepository,
+            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
+        return null;
+    }
+
     /**
      * A writer that is able to write/add trusted checksums to this implementation.
      */
@@ -70,6 +96,25 @@ public interface TrustedChecksumsSource {
                 List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
                 Map<String, String> trustedArtifactChecksums)
                 throws IOException;
+
+        /**
+         * Performs whatever implementation requires to "set" (write/add/append) given map of trusted checksums,
+         * for given metadata. Semantics are the same as for
+         * {@link #addTrustedArtifactChecksums(Artifact, ArtifactRepository, List, Map)}, but covering metadata.
+         * <p>
+         * The default implementation records nothing, preserving the behavior of implementations written before
+         * this method existed.
+         *
+         * @since 2.0.23
+         */
+        default void addTrustedMetadataChecksums(
+                Metadata metadata,
+                ArtifactRepository artifactRepository,
+                List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
+                Map<String, String> trustedMetadataChecksums)
+                throws IOException {
+            // no-op: implementations written before this method existed record artifact checksums only
+        }
     }
 
     /**