|
17 | 17 |
|
18 | 18 | import com.google.common.collect.ImmutableList; |
19 | 19 | import com.google.common.hash.Hashing; |
| 20 | +import com.google.common.io.BaseEncoding; |
20 | 21 | import com.google.common.io.Resources; |
21 | 22 | import com.google.gson.JsonParser; |
22 | 23 | import dev.sigstore.VerificationOptions.CertificateMatcher; |
|
34 | 35 | import java.security.cert.X509Certificate; |
35 | 36 | import java.util.List; |
36 | 37 | import java.util.stream.Stream; |
| 38 | +import java.util.zip.ZipEntry; |
| 39 | +import java.util.zip.ZipInputStream; |
37 | 40 | import org.hamcrest.CoreMatchers; |
38 | 41 | import org.hamcrest.MatcherAssert; |
39 | 42 | import org.junit.jupiter.api.Assertions; |
40 | 43 | import org.junit.jupiter.api.Test; |
| 44 | +import org.junit.jupiter.api.io.TempDir; |
41 | 45 | import org.junit.jupiter.params.ParameterizedTest; |
42 | 46 | import org.junit.jupiter.params.provider.Arguments; |
43 | 47 | import org.junit.jupiter.params.provider.MethodSource; |
@@ -519,9 +523,15 @@ public void testVerify_noTlogEntries_rekorV1() throws Exception { |
519 | 523 | var artifact = Resources.getResource("dev/sigstore/samples/bundles/artifact.txt").getPath(); |
520 | 524 | var verifier = KeylessVerifier.builder().sigstoreStagingDefaults().build(); |
521 | 525 |
|
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")); |
525 | 535 | } |
526 | 536 |
|
527 | 537 | @Test |
@@ -871,4 +881,128 @@ public void testVerify_wrongArtifactHashAlgorithm() throws Exception { |
871 | 881 | CoreMatchers.startsWith( |
872 | 882 | "Provided artifact digest does not match digest used for verification")); |
873 | 883 | } |
| 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 | + } |
874 | 1008 | } |
0 commit comments