Skip to content

Commit d7521af

Browse files
Add tests for verifying GitHub private bundles
* bundles without transparency log entries
1 parent 4450784 commit d7521af

3 files changed

Lines changed: 139 additions & 4 deletions

File tree

sigstore-java/src/main/java/dev/sigstore/bundle/BundleReader.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ static Bundle readBundle(Reader jsonReader) throws BundleParseException {
5454
// instance for private repositories, which relies on a signed RFC 3161 timestamp instead).
5555
// Such bundles are parsed here; whether their absence is acceptable is a verification-time
5656
// policy decision (see VerificationOptions#allowNonTransparencyLogVerification), not a parse
57-
// error. Any tlog entries that ARE present must still be well-formed (inclusion proof required).
57+
// error. Any tlog entries that ARE present must still be well-formed (inclusion proof
58+
// required).
5859
for (var bundleEntry : protoBundle.getVerificationMaterial().getTlogEntriesList()) {
5960
if (!bundleEntry.hasInclusionProof()) {
6061
// all consumed bundles must have an inclusion proof

sigstore-java/src/test/java/dev/sigstore/KeylessVerifierTest.java

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import com.google.common.collect.ImmutableList;
1919
import com.google.common.hash.Hashing;
20+
import com.google.common.io.BaseEncoding;
2021
import com.google.common.io.Resources;
2122
import com.google.gson.JsonParser;
2223
import dev.sigstore.VerificationOptions.CertificateMatcher;
@@ -34,10 +35,13 @@
3435
import java.security.cert.X509Certificate;
3536
import java.util.List;
3637
import java.util.stream.Stream;
38+
import java.util.zip.ZipEntry;
39+
import java.util.zip.ZipInputStream;
3740
import org.hamcrest.CoreMatchers;
3841
import org.hamcrest.MatcherAssert;
3942
import org.junit.jupiter.api.Assertions;
4043
import org.junit.jupiter.api.Test;
44+
import org.junit.jupiter.api.io.TempDir;
4145
import org.junit.jupiter.params.ParameterizedTest;
4246
import org.junit.jupiter.params.provider.Arguments;
4347
import org.junit.jupiter.params.provider.MethodSource;
@@ -519,9 +523,15 @@ public void testVerify_noTlogEntries_rekorV1() throws Exception {
519523
var artifact = Resources.getResource("dev/sigstore/samples/bundles/artifact.txt").getPath();
520524
var verifier = KeylessVerifier.builder().sigstoreStagingDefaults().build();
521525

522-
Assertions.assertThrows(
523-
IndexOutOfBoundsException.class,
524-
() -> verifier.verify(Path.of(artifact), testBundle, VerificationOptions.empty()));
526+
// By default a transparency-log entry is required, so a bundle without one is rejected with a
527+
// clear error (rather than the incidental IndexOutOfBoundsException it used to raise).
528+
var ex =
529+
Assertions.assertThrows(
530+
KeylessVerificationException.class,
531+
() -> verifier.verify(Path.of(artifact), testBundle, VerificationOptions.empty()));
532+
MatcherAssert.assertThat(
533+
ex.getMessage(),
534+
CoreMatchers.startsWith("Bundle does not contain a transparency log entry"));
525535
}
526536

527537
@Test
@@ -871,4 +881,128 @@ public void testVerify_wrongArtifactHashAlgorithm() throws Exception {
871881
CoreMatchers.startsWith(
872882
"Provided artifact digest does not match digest used for verification"));
873883
}
884+
885+
// The fixture below is a real SLSA build-provenance attestation produced by GitHub Actions for a
886+
// *private* repository. Attestations for private repositories are signed by GitHub's own Sigstore
887+
// instance, which (unlike the public-good instance) does not publish to a transparency log and
888+
// does not use certificate transparency: the bundle carries zero tlog entries and relies on a
889+
// signed RFC 3161 timestamp for trusted time, and the trust root that GitHub distributes for it
890+
// (via `gh attestation trusted-root`) contains zero CT logs. Verifying such a bundle therefore
891+
// requires opting in via VerificationOptions.allowNonTransparencyLogVerification(true).
892+
//
893+
// The zip is the exact release asset (unmodified) and contains two files:
894+
// - attestation.sigstore.json : the sigstore bundle (DSSE, in-toto SLSA provenance predicate)
895+
// - trusted_root.jsonl : the trust roots as JSON Lines. `gh attestation trusted-root`
896+
// emits more than one (public-good and GitHub); sigstore-java
897+
// consumes a single trust root, so the tests below select the
898+
// GitHub one (the line describing the fulcio.githubapp.com CA).
899+
private static final String GH_PRIVATE_ATTESTATION_ZIP =
900+
"dev/sigstore/samples/bundles/bundle-github-private-no-tlog.zip";
901+
private static final String GH_PRIVATE_SIGNER_SAN =
902+
"https://github.com/neverendingsupport/slsa-attestations/.github/workflows/attest.yml@9f6d9dc1bfc02986955721eb15f89ad618f1cedb";
903+
private static final String GH_PRIVATE_OIDC_ISSUER =
904+
"https://token.actions.githubusercontent.com";
905+
// sha256 of one of the artifacts recorded as a subject in the attestation.
906+
private static final String GH_PRIVATE_SUBJECT_SHA256 =
907+
"caa015ef69e9bc31a41322d6c71563ed9600c75bb988e4d639b1edc578580551";
908+
909+
@Test
910+
public void testVerify_noTransparencyLog_gitHubPrivateInstance(@TempDir Path tempDir)
911+
throws Exception {
912+
var bundle = gitHubPrivateBundle();
913+
var verifier = gitHubPrivateVerifier(tempDir);
914+
var options =
915+
VerificationOptions.builder()
916+
.allowNonTransparencyLogVerification(true)
917+
.addCertificateMatchers(
918+
CertificateMatcher.fulcio()
919+
.subjectAlternativeName(StringMatcher.string(GH_PRIVATE_SIGNER_SAN))
920+
.issuer(StringMatcher.string(GH_PRIVATE_OIDC_ISSUER))
921+
.build())
922+
.build();
923+
924+
Assertions.assertDoesNotThrow(
925+
() ->
926+
verifier.verify(
927+
BaseEncoding.base16().lowerCase().decode(GH_PRIVATE_SUBJECT_SHA256),
928+
bundle,
929+
options));
930+
}
931+
932+
@Test
933+
public void testVerify_noTransparencyLog_rejectedWithoutOptIn(@TempDir Path tempDir)
934+
throws Exception {
935+
var bundle = gitHubPrivateBundle();
936+
var verifier = gitHubPrivateVerifier(tempDir);
937+
938+
// Default options do not permit skipping the transparency log, so the bundle is rejected.
939+
Assertions.assertThrows(
940+
KeylessVerificationException.class,
941+
() ->
942+
verifier.verify(
943+
BaseEncoding.base16().lowerCase().decode(GH_PRIVATE_SUBJECT_SHA256),
944+
bundle,
945+
VerificationOptions.empty()));
946+
}
947+
948+
@Test
949+
public void testVerify_noTransparencyLog_optInStillChecksArtifactDigest(@TempDir Path tempDir)
950+
throws Exception {
951+
var bundle = gitHubPrivateBundle();
952+
var verifier = gitHubPrivateVerifier(tempDir);
953+
var options = VerificationOptions.builder().allowNonTransparencyLogVerification(true).build();
954+
955+
// Opting in only relaxes the transparency-log requirement; the artifact digest must still be
956+
// one
957+
// of the attested subjects.
958+
var badArtifactDigest =
959+
Hashing.sha256().hashString("nonsense", StandardCharsets.UTF_8).asBytes();
960+
var ex =
961+
Assertions.assertThrows(
962+
KeylessVerificationException.class,
963+
() -> verifier.verify(badArtifactDigest, bundle, options));
964+
MatcherAssert.assertThat(
965+
ex.getMessage(),
966+
CoreMatchers.startsWith(
967+
"Provided artifact digest does not match any subject sha256 digests in DSSE payload"));
968+
}
969+
970+
private static Bundle gitHubPrivateBundle() throws Exception {
971+
return Bundle.from(
972+
new StringReader(
973+
new String(
974+
readZipEntry(GH_PRIVATE_ATTESTATION_ZIP, "attestation.sigstore.json"),
975+
StandardCharsets.UTF_8)));
976+
}
977+
978+
private static KeylessVerifier gitHubPrivateVerifier(Path tempDir) throws Exception {
979+
var jsonl =
980+
new String(
981+
readZipEntry(GH_PRIVATE_ATTESTATION_ZIP, "trusted_root.jsonl"), StandardCharsets.UTF_8);
982+
var gitHubTrustedRoot =
983+
jsonl
984+
.lines()
985+
.filter(line -> line.contains("fulcio.githubapp.com"))
986+
.findFirst()
987+
.orElseThrow(
988+
() ->
989+
new IllegalStateException("no GitHub trust root found in trusted_root.jsonl"));
990+
var trustedRootPath = tempDir.resolve("github-trusted-root.json");
991+
Files.writeString(trustedRootPath, gitHubTrustedRoot);
992+
return KeylessVerifier.builder()
993+
.trustedRootProvider(TrustedRootProvider.from(trustedRootPath))
994+
.build();
995+
}
996+
997+
private static byte[] readZipEntry(String zipResource, String entryName) throws Exception {
998+
try (var zis = new ZipInputStream(Resources.getResource(zipResource).openStream())) {
999+
ZipEntry entry;
1000+
while ((entry = zis.getNextEntry()) != null) {
1001+
if (entry.getName().equals(entryName)) {
1002+
return zis.readAllBytes();
1003+
}
1004+
}
1005+
}
1006+
throw new IllegalStateException("entry '" + entryName + "' not found in " + zipResource);
1007+
}
8741008
}
Binary file not shown.

0 commit comments

Comments
 (0)