Chapter 28Lesson 02~355 minutes

Dependency Verification, Checksums, Signatures, Repository Content Filtering, and Supply-Chain Security: Guided Hands-On Workflow and Core Operations

Build a disposable local supply-chain lab: create a deterministic internal artifact, bind its group to one repository, bootstrap and review Gradle verification metadata, verify Wrapper integrity, inspect Maven checksum/signature controls, then trigger and explain a harmless checksum failure.

verification-metadata.xmlexclusiveContentWrapperSHA-256Local fixture

Learning objectives

  • Create two disposable Maven-layout repositories containing the same coordinate but different bytes.
  • Configure Gradle exclusive content so an internal group can only resolve from the trusted repository.
  • Bootstrap and review SHA-256 dependency verification metadata and interpret strict verification.
  • Verify Gradle Wrapper distribution/JAR integrity without trusting the checkout as its own source of truth.
  • Inspect Maven checksum/signature mechanics and simulate a harmless checksum mismatch locally.

Lab boundary. Run only in a disposable Chapter 28 directory. The fixture uses synthetic group dev.academy.internal and project-relative repositories. Never point these mutation steps at a production artifact repository, shared cache, normal ~/.gradle, or normal ~/.m2.

1. Preflight and identity record

The lab assumes the trusted project Wrapper from Chapter 15 is already present and pinned to Gradle 9.7.1. JDK 21 is used to build the synthetic JAR and Java 17 is the bytecode target. Maven 3.9.16 is optional for the Maven consumer inspection; the integrity experiments themselves do not require Maven.

set -euo pipefail
mkdir ch28-lab
cd ch28-lab
export GRADLE_USER_HOME="$PWD/.lab-gradle-home"

java -version 2>&1 | tee java-version.txt
javac -version | tee javac-version.txt
# After copying the trusted wrapper files into this disposable lab:
./gradlew --version | tee gradle-version.txt
cat gradle/wrapper/gradle-wrapper.properties | tee wrapper-properties.txt

2. Create trusted and alternate repositories

Both repositories expose the exact same Maven coordinate. The trusted repository contains a deterministic Java 17 JAR; the alternate repository contains different bytes. This creates a harmless dependency-confusion fixture without any network service.

set -euo pipefail
mkdir -p trusted-repo/dev/academy/internal/policy-lib/1.0.0
mkdir -p alternate-repo/dev/academy/internal/policy-lib/1.0.0
mkdir -p fixture-src/dev/academy/internal fixture-classes

cat > fixture-src/dev/academy/internal/PolicyLib.java <<'JAVA'
package dev.academy.internal;
public final class PolicyLib {
    private PolicyLib() {}
    public static String message() { return "trusted-v1"; }
}
JAVA

javac --release 17 -d fixture-classes fixture-src/dev/academy/internal/PolicyLib.java
jar --create --date=2026-01-01T00:00:00Z \
  --file trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar \
  -C fixture-classes .

cat > trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.pom <<'POM'
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>dev.academy.internal</groupId>
  <artifactId>policy-lib</artifactId>
  <version>1.0.0</version>
</project>
POM

# Create an alternate repository entry with the same coordinate but different bytes.
printf 'not-the-trusted-jar\n' > alternate-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar
cp trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.pom \
   alternate-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.pom

sha256sum trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar \
  | tee trusted-artifact.sha256
sha256sum alternate-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar \
  | tee alternate-artifact.sha256

The two SHA-256 values must differ. Preserve them as evidence; do not delete or overwrite either artifact yet.

3. Create the consuming Gradle project

Repository scope lives in settings.gradle.kts, not inside individual subprojects. FAIL_ON_PROJECT_REPOS prevents later build scripts from silently adding a broad repository and bypassing the settings-level policy.

pluginManagement {
    repositories {
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        // Public repository is deliberately listed first.
        maven {
            name = "alternateRepo"
            url = uri("alternate-repo")
        }

        // The internal namespace is exclusive to this repository.
        exclusiveContent {
            forRepository {
                maven {
                    name = "academyInternal"
                    url = uri("trusted-repo")
                    metadataSources { mavenPom(); artifact() }
                }
            }
            filter {
                includeGroup("dev.academy.internal")
            }
        }
    }
}

rootProject.name = "chapter28-supply-chain"
plugins {
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

dependencies {
    implementation("dev.academy.internal:policy-lib:1.0.0")
}
package dev.academy.app;

import dev.academy.internal.PolicyLib;

public final class App {
    private App() {}
    public static String message() { return PolicyLib.message(); }
}
mkdir -p src/main/java/dev/academy/app
cat > src/main/java/dev/academy/app/App.java <<'JAVA'
package dev.academy.app;
import dev.academy.internal.PolicyLib;
public final class App {
    private App() {}
    public static String message() { return PolicyLib.message(); }
}
JAVA
./gradlew dependencies --configuration compileClasspath | tee dependency-baseline.txt
./gradlew dependencyInsight \
  --dependency dev.academy.internal:policy-lib \
  --configuration compileClasspath | tee dependency-insight.txt

4. Bootstrap verification metadata—then stop and review

The first metadata generation is not a security verdict. It records the artifacts the current resolver selected. Run it only after verifying repository scope and coordinates, then inspect the diff before committing anything.

./gradlew --write-verification-metadata sha256 clean compileJava \
  | tee verification-bootstrap.log

test -f gradle/verification-metadata.xml
grep -n 'dev.academy.internal' gradle/verification-metadata.xml
grep -n 'policy-lib-1.0.0.jar' gradle/verification-metadata.xml
cp gradle/verification-metadata.xml verification-metadata.review-copy.xml

Compare the generated digest with trusted-artifact.sha256. The coordinate should not refer to alternate-repo. For real third-party dependencies, a second trusted channel—not the local cache—must validate the digest or key.

5. Prove strict verification on an unchanged build

Once verification metadata exists, strict mode is the default. An unchanged clean build should verify successfully.

./gradlew clean compileJava | tee strict-success.log
sha256sum trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar \
  | tee strict-trusted.sha256

Do not switch to --dependency-verification off merely to make a failure disappear. Lenient mode can be useful during a controlled metadata update because it collects errors, but it is inherently weaker and should not become the release gate.

6. Replace bytes under the same coordinate and preserve the failure

This simulates a repository compromise or mutable release: 1.0.0 keeps its coordinate but changes bytes.

cp trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar trusted-v1.jar

cat > fixture-src/dev/academy/internal/PolicyLib.java <<'JAVA'
package dev.academy.internal;
public final class PolicyLib {
    private PolicyLib() {}
    public static String message() { return "changed-same-coordinate"; }
}
JAVA
rm -rf fixture-classes && mkdir fixture-classes
javac --release 17 -d fixture-classes fixture-src/dev/academy/internal/PolicyLib.java
jar --create --date=2026-01-01T00:00:00Z \
  --file trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar \
  -C fixture-classes .
sha256sum trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar \
  | tee changed-artifact.sha256

set +e
./gradlew --refresh-dependencies clean compileJava > checksum-mismatch.log 2>&1
rc=$?
set -e
printf 'mismatch rc=%s\n' "$rc"
test "$rc" -ne 0
grep -Ei 'verification|checksum|policy-lib' checksum-mismatch.log | head -40

The correct repair is investigation plus restoration or an intentional reviewed version upgrade—not replacing the expected checksum with the attacker-controlled digest. Keep checksum-mismatch.log, old/new digests, and both JARs until the incident is understood.

7. Restore trusted bytes and re-verify

Restore the original immutable artifact, not merely the cache.

cp trusted-v1.jar trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar
./gradlew --refresh-dependencies clean compileJava | tee restored.log
sha256sum -c trusted-artifact.sha256

8. Prove the alternate repository cannot satisfy the protected group

Now test repository scope independently of checksum verification. Temporarily make the trusted repository unable to serve the coordinate while leaving the alternate copy available. Because the group is exclusive, resolution must fail instead of falling through.

mv trusted-repo/dev/academy/internal/policy-lib/1.0.0 \
   trusted-repo/dev/academy/internal/policy-lib/1.0.0.off
set +e
./gradlew --refresh-dependencies clean compileJava > repository-scope-failure.log 2>&1
rc=$?
set -e
printf 'scope rc=%s\n' "$rc"
test "$rc" -ne 0
grep -Ei 'policy-lib|could not find|resolve' repository-scope-failure.log | head -40
mv trusted-repo/dev/academy/internal/policy-lib/1.0.0.off \
   trusted-repo/dev/academy/internal/policy-lib/1.0.0
./gradlew --refresh-dependencies clean compileJava | tee repository-scope-restored.log

If the alternate repository satisfies the coordinate, the settings policy is wrong. Check whether exclusiveContent was replaced with a non-exclusive content filter or whether a project repository escaped settings governance.

9. Verify the Gradle Wrapper from independent release data

Wrapper verification is outside dependency verification. The 9.7.1 distribution checksum should be pinned in gradle-wrapper.properties. Verify the Wrapper JAR against Gradle’s separately published checksum/signature; do not derive the expected value from the checked-in JAR.

grep '^distributionUrl=' gradle/wrapper/gradle-wrapper.properties
grep '^distributionSha256Sum=' gradle/wrapper/gradle-wrapper.properties
sha256sum gradle/wrapper/gradle-wrapper.jar | tee wrapper-jar.actual.sha256

# When network access is allowed, obtain expected Wrapper JAR checksum independently:
# curl -fsSLo wrapper-jar.expected.sha256 \
#   https://services.gradle.org/distributions/gradle-9.7.1-wrapper.jar.sha256
# Compare expected value to the actual committed JAR before executing an untrusted wrapper change.

10. Maven checksum/signature inspection without pretending it is Gradle verification

Create SHA-256/SHA-512 sidecars for the synthetic artifact and verify them directly. This teaches the underlying repository evidence. Maven Resolver supports SHA-256 and SHA-512, but its default remote-external checksum algorithm order is commonly SHA-1,MD5 unless configured otherwise. checksumPolicy=fail governs missing or mismatched expected checksums; direct SHA-256/SHA-512 comparison here remains independent review evidence, while signature/fingerprint policy is a separate control.

artifact=trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar
sha256sum "$artifact" > "$artifact.sha256"
sha512sum "$artifact" > "$artifact.sha512"
sha256sum -c "$artifact.sha256"
sha512sum -c "$artifact.sha512"
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>dev.academy.consumer</groupId>
  <artifactId>maven-check</artifactId>
  <version>1.0.0</version>
  <repositories>
    <repository>
      <id>academy-internal</id>
      <url>file://${project.basedir}/../trusted-repo</url>
      <releases>
        <enabled>true</enabled>
        <checksumPolicy>fail</checksumPolicy>
      </releases>
      <snapshots><enabled>false</enabled></snapshots>
    </repository>
  </repositories>
  <dependencies>
    <dependency>
      <groupId>dev.academy.internal</groupId>
      <artifactId>policy-lib</artifactId>
      <version>1.0.0</version>
    </dependency>
  </dependencies>
</project>

If Maven is installed or its trusted Wrapper is available, use an isolated local repository such as -Dmaven.repo.local="$PWD/.lab-m2". Do not use the normal user repository as evidence that a clean consumer can resolve securely.

11. Optional local signature mechanics with a disposable key

This optional exercise demonstrates detached signatures only. A key generated inside the same lab is not independent proof of publisher identity; it simply lets you practice verification and fingerprint recording.

export GNUPGHOME="$PWD/.lab-gnupg"
mkdir -m 700 "$GNUPGHOME"
gpg --batch --passphrase '' --quick-gen-key \
  'Chapter28 Synthetic <chapter28@example.invalid>' ed25519 sign 1d
fingerprint=$(gpg --batch --with-colons --fingerprint 'Chapter28 Synthetic' \
  | awk -F: '$1=="fpr" {print $10; exit}')
printf 'synthetic signing fingerprint=%s\n' "$fingerprint" | tee signing-fingerprint.txt
artifact=trusted-repo/dev/academy/internal/policy-lib/1.0.0/policy-lib-1.0.0.jar
gpg --batch --yes --armor --detach-sign "$artifact"
gpg --verify "$artifact.asc" "$artifact"

Production trust would require the expected fingerprint to be anchored through an organization-approved, independent channel. Never commit the disposable private key.

12. Challenge: choose the missing control

A team has a lockfile, immutable dependency versions, and dependency verification, but internal coordinates can still be looked up in Maven Central before the internal repository. What is the narrowest missing control?

Expected reasoning: add repository exclusivity/routing for the internal namespace. A checksum protects bytes only after selection; it does not make an unintended repository ineligible.

13. Cleanup only disposable state

Keep logs only if you want them as study evidence, then remove the lab directory.

cd ..
rm -rf ch28-lab

Never delete normal ~/.gradle, ~/.m2, global keyrings, or production repository state as a routine response to a verification failure.

Knowledge check

What should you do immediately after generating verification metadata?

Why does the checksum mismatch experiment keep the coordinate at 1.0.0?

Why remove the trusted artifact for the repository-scope test?

Is a disposable self-generated PGP key proof of real publisher provenance?

Why isolate Gradle/Maven cache state in the lab?

Official references and version notes

Version-sensitive behavior was rechecked against primary documentation on 2026-08-24. Mandatory labs are local/free and use synthetic artifacts only. No production repository, credential, signing key, shared cache, or hosted CI service is required.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.