From c0e7338c6fb00e6179af47ec991e09a142d0dbaa Mon Sep 17 00:00:00 2001 From: Appu Goundan Date: Tue, 4 Jun 2024 15:09:17 -0400 Subject: [PATCH] Add new OID processing - Add regex to verification options - Correctly parse DER Encoded Strings (vs raw) - Allow arbitrary comparison of bytes Signed-off-by: Appu Goundan --- ...va => FulcioCertificateMatcherFuzzer.java} | 27 +- .../main/java/dev/sigstore/cli/Verify.java | 11 +- .../java/dev/sigstore/KeylessVerifier.java | 35 ++- .../dev/sigstore/VerificationOptions.java | 36 ++- .../client/FulcioCertificateMatcher.java | 253 ++++++++++++++++++ .../client/FulcioCertificateVerifier.java | 134 ---------- .../dev/sigstore/KeylessVerifierTest.java | 60 +++++ .../client/FulcioCertificateMatcherTest.java | 197 ++++++++++++++ .../client/FulcioCertificateVerifierTest.java | 157 ----------- .../dev/sigstore/testing/CertGenerator.java | 9 + 10 files changed, 588 insertions(+), 331 deletions(-) rename fuzzing/src/main/java/fuzzing/{FulcioCertificateVerifierFuzzer.java => FulcioCertificateMatcherFuzzer.java} (64%) create mode 100644 sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateMatcher.java delete mode 100644 sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateVerifier.java create mode 100644 sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateMatcherTest.java delete mode 100644 sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateVerifierTest.java diff --git a/fuzzing/src/main/java/fuzzing/FulcioCertificateVerifierFuzzer.java b/fuzzing/src/main/java/fuzzing/FulcioCertificateMatcherFuzzer.java similarity index 64% rename from fuzzing/src/main/java/fuzzing/FulcioCertificateVerifierFuzzer.java rename to fuzzing/src/main/java/fuzzing/FulcioCertificateMatcherFuzzer.java index c59927fa..e7b4f135 100644 --- a/fuzzing/src/main/java/fuzzing/FulcioCertificateVerifierFuzzer.java +++ b/fuzzing/src/main/java/fuzzing/FulcioCertificateMatcherFuzzer.java @@ -16,19 +16,20 @@ package fuzzing; import com.code_intelligence.jazzer.api.FuzzedDataProvider; -import dev.sigstore.VerificationOptions.CertificateIdentity; -import dev.sigstore.fulcio.client.FulcioCertificateVerifier; -import dev.sigstore.fulcio.client.FulcioVerificationException; +import dev.sigstore.VerificationOptions.UncheckedCertificateException; +import dev.sigstore.fulcio.client.FulcioCertificateMatcher; +import dev.sigstore.fulcio.client.ImmutableFulcioCertificateMatcher; +import dev.sigstore.strings.StringMatcher; import java.io.ByteArrayInputStream; import java.nio.charset.Charset; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; -import java.util.List; -public class FulcioCertificateVerifierFuzzer { +public class FulcioCertificateMatcherFuzzer { public static void fuzzerTestOneInput(FuzzedDataProvider data) { byte[] byteArray = data.consumeRemainingAsBytes(); - String string = new String(byteArray, Charset.defaultCharset()); + String san = new String(byteArray, Charset.defaultCharset()); + String issuer = new String(byteArray, Charset.defaultCharset()); X509Certificate certificate; try { @@ -40,14 +41,14 @@ public static void fuzzerTestOneInput(FuzzedDataProvider data) { } try { - FulcioCertificateVerifier verifier = new FulcioCertificateVerifier(); - List list = - List.of( - CertificateIdentity.builder().subjectAlternativeName(string).issuer(string).build(), - CertificateIdentity.builder().subjectAlternativeName(string).issuer(string).build()); + FulcioCertificateMatcher matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string(san)) + .issuer(StringMatcher.string(issuer)) + .build(); - verifier.verifyCertificateMatches(certificate, list); - } catch (FulcioVerificationException e) { + matcher.test(certificate); + } catch (UncheckedCertificateException e) { // Known exception } } diff --git a/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java b/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java index d98e22db..513ad406 100644 --- a/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java +++ b/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java @@ -21,13 +21,14 @@ import dev.sigstore.KeylessVerifier; import dev.sigstore.TrustedRootProvider; import dev.sigstore.VerificationOptions; -import dev.sigstore.VerificationOptions.CertificateIdentity; +import dev.sigstore.VerificationOptions.CertificateMatcher; import dev.sigstore.bundle.Bundle; import dev.sigstore.bundle.Bundle.HashAlgorithm; import dev.sigstore.bundle.Bundle.MessageSignature; import dev.sigstore.bundle.ImmutableBundle; import dev.sigstore.encryption.certificates.Certificates; import dev.sigstore.rekor.client.RekorEntryFetcher; +import dev.sigstore.strings.StringMatcher; import dev.sigstore.tuf.RootProvider; import dev.sigstore.tuf.SigstoreTufClient; import java.net.URL; @@ -135,10 +136,10 @@ public Integer call() throws Exception { var verificationOptionsBuilder = VerificationOptions.builder(); if (policy != null) { - verificationOptionsBuilder.addCertificateIdentities( - CertificateIdentity.builder() - .issuer(policy.certificateIssuer) - .subjectAlternativeName(policy.certificateSan) + verificationOptionsBuilder.addCertificateMatchers( + CertificateMatcher.fulcio() + .issuer(StringMatcher.string(policy.certificateIssuer)) + .subjectAlternativeName(StringMatcher.string(policy.certificateSan)) .build()); } var verificationOptions = verificationOptionsBuilder.build(); diff --git a/sigstore-java/src/main/java/dev/sigstore/KeylessVerifier.java b/sigstore-java/src/main/java/dev/sigstore/KeylessVerifier.java index bccd0f01..c68e919a 100644 --- a/sigstore-java/src/main/java/dev/sigstore/KeylessVerifier.java +++ b/sigstore-java/src/main/java/dev/sigstore/KeylessVerifier.java @@ -16,12 +16,14 @@ package dev.sigstore; import com.google.api.client.util.Preconditions; +import com.google.common.annotations.VisibleForTesting; import com.google.common.hash.Hashing; import com.google.common.io.Files; +import dev.sigstore.VerificationOptions.CertificateMatcher; +import dev.sigstore.VerificationOptions.UncheckedCertificateException; import dev.sigstore.bundle.Bundle; import dev.sigstore.encryption.certificates.Certificates; import dev.sigstore.encryption.signers.Verifiers; -import dev.sigstore.fulcio.client.FulcioCertificateVerifier; import dev.sigstore.fulcio.client.FulcioVerificationException; import dev.sigstore.fulcio.client.FulcioVerifier; import dev.sigstore.rekor.client.RekorEntry; @@ -37,13 +39,17 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateExpiredException; import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; import java.security.spec.InvalidKeySpecException; import java.sql.Date; import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; import org.bouncycastle.util.encoders.Hex; /** Verify hashrekords from rekor signed using the keyless signing flow with fulcio certificates. */ public class KeylessVerifier { + private final FulcioVerifier fulcioVerifier; private final RekorVerifier rekorVerifier; @@ -57,6 +63,7 @@ public static KeylessVerifier.Builder builder() { } public static class Builder { + private TrustedRootProvider trustedRootProvider; public KeylessVerifier build() @@ -162,15 +169,7 @@ public void verify(byte[] artifactDigest, Bundle bundle, VerificationOptions opt } // verify the certificate identity if options are present - if (options.getCertificateIdentities().size() > 0) { - try { - new FulcioCertificateVerifier() - .verifyCertificateMatches(leafCert, options.getCertificateIdentities()); - } catch (FulcioVerificationException fve) { - throw new KeylessVerificationException( - "Could not verify certificate identities: " + fve.getMessage(), fve); - } - } + checkCertificateMatchers(leafCert, options.getCertificateMatchers()); var signature = messageSignature.getSignature(); @@ -208,4 +207,20 @@ public void verify(byte[] artifactDigest, Bundle bundle, VerificationOptions opt "Signature could not be processed: " + ex.getMessage(), ex); } } + + @VisibleForTesting + void checkCertificateMatchers(X509Certificate cert, List matchers) + throws KeylessVerificationException { + try { + if (matchers.size() > 0 && matchers.stream().noneMatch(matcher -> matcher.test(cert))) { + var matcherSpec = + matchers.stream().map(Object::toString).collect(Collectors.joining(",", "[", "]")); + throw new KeylessVerificationException( + "No provided certificate identities matched values in certificate: " + matcherSpec); + } + } catch (UncheckedCertificateException ce) { + throw new KeylessVerificationException( + "Could not verify certificate identities: " + ce.getMessage()); + } + } } diff --git a/sigstore-java/src/main/java/dev/sigstore/VerificationOptions.java b/sigstore-java/src/main/java/dev/sigstore/VerificationOptions.java index 58f0756c..c7b6379e 100644 --- a/sigstore-java/src/main/java/dev/sigstore/VerificationOptions.java +++ b/sigstore-java/src/main/java/dev/sigstore/VerificationOptions.java @@ -15,26 +15,38 @@ */ package dev.sigstore; +import dev.sigstore.fulcio.client.FulcioCertificateMatcher; +import dev.sigstore.fulcio.client.ImmutableFulcioCertificateMatcher; +import java.security.cert.X509Certificate; import java.util.List; -import java.util.Map; +import java.util.function.Predicate; import org.immutables.value.Value.Immutable; @Immutable(singleton = true) public interface VerificationOptions { /** An allow list of certificate identities to match with. */ - List getCertificateIdentities(); - - @Immutable - interface CertificateIdentity { - String getIssuer(); - - String getSubjectAlternativeName(); - - Map getOther(); + List getCertificateMatchers(); + + /** + * An interface for allowing matching of certificates. Use {@link #fulcio()} to instantiate the + * default {@link FulcioCertificateMatcher} implementation. Custom implementations may throw + * {@link UncheckedCertificateException} if an error occurs processing the certificate on calls to + * {@link #test(X509Certificate)}. Any other runtime exception will not be handled. + */ + interface CertificateMatcher extends Predicate { + @Override + boolean test(X509Certificate certificate) throws UncheckedCertificateException; + + static ImmutableFulcioCertificateMatcher.Builder fulcio() { + return ImmutableFulcioCertificateMatcher.builder(); + } + } - static ImmutableCertificateIdentity.Builder builder() { - return ImmutableCertificateIdentity.builder(); + /** Exceptions thrown by implementations of {@link CertificateMatcher#test(X509Certificate)} */ + class UncheckedCertificateException extends RuntimeException { + public UncheckedCertificateException(String message, Throwable cause) { + super(message, cause); } } diff --git a/sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateMatcher.java b/sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateMatcher.java new file mode 100644 index 00000000..608633f2 --- /dev/null +++ b/sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateMatcher.java @@ -0,0 +1,253 @@ +/* + * Copyright 2024 The Sigstore Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sigstore.fulcio.client; + +import dev.sigstore.VerificationOptions.CertificateMatcher; +import dev.sigstore.VerificationOptions.UncheckedCertificateException; +import dev.sigstore.strings.StringMatcher; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Map; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.ASN1String; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.util.encoders.Hex; +import org.immutables.value.Value.Immutable; + +@Immutable +public abstract class FulcioCertificateMatcher implements CertificateMatcher { + + /** Match against the identity token issuer */ + public abstract StringMatcher getIssuer(); + + /** Match against the identity token subject/email */ + public abstract StringMatcher getSubjectAlternativeName(); + + /** + * For OIDs with raw string entries. This is non-standard, but older fulcio OID extensions values + * use it. + */ + public abstract Map getOidRawStrings(); + + /** + * For OIDs with DER encoded ASN.1 string entries. This is the standard for strings values as OID + * extensions. + */ + public abstract Map getOidDerAsn1Strings(); + + /** + * For comparing raw bytes of the full ASN.1 object extension value as defined by EXTENSION in rfc5280 + * + *

The key is the oid string (ex: 1.2.3.4.5) and the value is a raw byte array. Matching is a + * direct byte array equality check with no mutations on the extension value. + */ + public abstract Map getOidBytes(); + + private static final Logger log = Logger.getLogger(FulcioCertificateMatcher.class.getName()); + private static final String FULCIO_ISSUER_OLD_OID = "1.3.6.1.4.1.57264.1.1"; + private static final String FULCIO_ISSUER_OID = "1.3.6.1.4.1.57264.1.8"; + + private static void logMismatch(String oid, String expected, String actual) { + log.fine(oid + " value did not match - expected:" + expected + ", actual:" + actual); + } + + @Override + public String toString() { + String str = "{issuer:" + getIssuer() + ",san:" + getSubjectAlternativeName(); + if (!getOidRawStrings().isEmpty()) { + str += + ",oidRawStrings:{" + + getOidRawStrings().entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue()) + .collect(Collectors.joining(",")) + + "}"; + } + if (!getOidDerAsn1Strings().isEmpty()) { + str += + ",oidDerAsn1Strings:{" + + getOidDerAsn1Strings().entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue()) + .collect(Collectors.joining(",")) + + "}"; + } + if (!getOidBytes().isEmpty()) { + str += + ",oidBytes:{" + + getOidBytes().entrySet().stream() + .map(e -> e.getKey() + ":" + hexOrNull(e.getValue())) + .collect(Collectors.joining(",")) + + "}"; + } + return str + "}"; + } + + /* Returns true if ALL provided fields exist in the certificate and match the extensions values in the certificate. */ + @Override + public boolean test(X509Certificate certificate) throws UncheckedCertificateException { + try { + var san = extractSan(certificate); + if (!getSubjectAlternativeName().test(san)) { + logMismatch("san", getSubjectAlternativeName().toString(), san); + return false; + } + var issuer = extractIssuer(certificate); + if (!getIssuer().test(issuer)) { + logMismatch("issuer", getIssuer().toString(), issuer); + return false; + } + for (var rawOid : getOidRawStrings().keySet()) { + var entry = getExtensionValueRawUtf8(certificate, rawOid); + var expected = getOidRawStrings().get(rawOid); + if (!expected.test(entry)) { + logMismatch(rawOid, expected.toString(), entry); + return false; + } + } + for (var derOid : getOidDerAsn1Strings().keySet()) { + var entry = getExtensionValueDerAsn1Utf8(certificate, derOid); + var expected = getOidDerAsn1Strings().get(derOid); + if (!expected.test(entry)) { + logMismatch(derOid, expected.toString(), entry); + return false; + } + } + for (var bytesOid : getOidBytes().keySet()) { + var entry = certificate.getExtensionValue(bytesOid); + var expected = getOidBytes().get(bytesOid); + if (!Arrays.equals(entry, expected)) { + logMismatch(bytesOid, hexOrNull(expected), hexOrNull(entry)); + return false; + } + } + return true; + } catch (CertificateException ce) { + throw new UncheckedCertificateException("Failed to process certificate ", ce); + } + } + + /* Looks for only a single SAN and extracts an email or machine id from it. If not a single SAN, + * then errors. If not an rfc822Name(email) or URI(machine-id) then fails. */ + private String extractSan(X509Certificate cert) throws CertificateParsingException { + try { + var sans = cert.getSubjectAlternativeNames(); + if (sans.size() == 0) { + throw new CertificateParsingException("No SANs found in fulcio certificate"); + } + if (sans.size() > 1) { + throw new CertificateParsingException( + "Fulcio certificate must only have 1 SAN, but found " + sans.size()); + } + var san = sans.stream().findFirst().get(); + var type = (Integer) san.get(0); + if (!type.equals(GeneralName.rfc822Name) + && !type.equals(GeneralName.uniformResourceIdentifier)) { + throw new CertificateParsingException( + "Fulcio certificates SAN must be of type rfc822 or URI"); + } + return (String) san.get(1); + } catch (CertificateParsingException cpe) { + throw new CertificateParsingException("Could not parse SAN from fulcio certificate", cpe); + } + } + + private String extractIssuer(X509Certificate cert) throws CertificateParsingException { + var issuer = getExtensionValueDerAsn1Utf8(cert, FULCIO_ISSUER_OID); + if (issuer == null) { + issuer = getExtensionValueRawUtf8(cert, FULCIO_ISSUER_OLD_OID); + } + if (issuer == null) { + throw new CertificateParsingException("No issuer found in fulcio certificate"); + } + return issuer; + } + + /* Extracts the octets from an extension value and converts to utf-8 directly, it does NOT + * account for any ASN1 encoded value. If the extension value is an ASN1 object (like an + * ASN1 encoded string), you need to write a new extraction helper. */ + private String getExtensionValueRawUtf8(X509Certificate cert, String oid) + throws CertificateParsingException { + byte[] extensionValue = cert.getExtensionValue(oid); + + if (extensionValue == null) { + return null; + } + try { + ASN1Primitive derObject = ASN1Sequence.fromByteArray(cert.getExtensionValue(oid)); + if (derObject instanceof DEROctetString) { + DEROctetString derOctetString = (DEROctetString) derObject; + // this is unusual, but the octet is a raw utf8 string in fulcio land (no prefix of type) + // and not an ASN1 object. + return new String(derOctetString.getOctets(), StandardCharsets.UTF_8); + } + throw new CertificateParsingException( + "Could not parse extension " + + oid + + " in certificate because it was not a properly formatted extension sequence"); + } catch (IOException ioe) { + throw new CertificateParsingException( + "Could not parse extension " + oid + " in certificate", ioe); + } + } + + /* Extracts a DER-encoded ASN.1 String from an extension value */ + private String getExtensionValueDerAsn1Utf8(X509Certificate cert, String oid) + throws CertificateParsingException { + byte[] extensionValue = cert.getExtensionValue(oid); + + if (extensionValue == null) { + return null; + } + try { + ASN1Primitive derObject = ASN1Sequence.fromByteArray(cert.getExtensionValue(oid)); + if (derObject instanceof DEROctetString) { + DEROctetString derOctetString = (DEROctetString) derObject; + + ASN1Primitive derString = ASN1Sequence.fromByteArray(derOctetString.getOctets()); + if (derString instanceof ASN1String) { + return ((ASN1String) derString).getString(); + } else { + throw new CertificateParsingException( + "Could not parse extension " + + oid + + " in certificate because it was not a DER encoded ASN.1 string"); + } + } + throw new CertificateParsingException( + "Could not parse extension " + + oid + + " in certificate because it was not a properly formatted extension sequence"); + } catch (IOException ioe) { + throw new CertificateParsingException( + "Could not parse extension " + oid + " in certificate", ioe); + } + } + + private String hexOrNull(byte[] bytes) { + if (bytes == null) { + return "NULL"; + } + return "'hex: " + Hex.toHexString(bytes) + "'"; + } +} diff --git a/sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateVerifier.java b/sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateVerifier.java deleted file mode 100644 index 864323c0..00000000 --- a/sigstore-java/src/main/java/dev/sigstore/fulcio/client/FulcioCertificateVerifier.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2023 The Sigstore Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.sigstore.fulcio.client; - -import dev.sigstore.VerificationOptions.CertificateIdentity; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.cert.CertificateParsingException; -import java.security.cert.X509Certificate; -import java.util.List; -import java.util.Objects; -import java.util.logging.Logger; -import org.bouncycastle.asn1.ASN1Primitive; -import org.bouncycastle.asn1.ASN1Sequence; -import org.bouncycastle.asn1.DEROctetString; -import org.bouncycastle.asn1.x509.GeneralName; - -/** Verifier for fulcio Certificate fields. */ -public class FulcioCertificateVerifier { - private static final String FULCIO_ISSUER_OID = "1.3.6.1.4.1.57264.1.1"; - - private static final Logger log = Logger.getLogger(FulcioCertificateVerifier.class.getName()); - - /** - * Returns {@code true} if for any of the provided certIds, all the extension fields are found in - * the provided certificate AND they are equal. - * - * @param cert the certificate in question - * @param certIds a list of potentially matching certificate parameters - * @throws FulcioVerificationException if no matches were found or an error occurred while - * inspecting the certificate - */ - public void verifyCertificateMatches(X509Certificate cert, List certIds) - throws FulcioVerificationException { - for (var certId : certIds) { - if (certificateMatches(cert, certId)) { - return; // we found one that matches - } - } - throw new FulcioVerificationException( - "No provided certificate identities matched values in certificate"); - } - - /* Returns true if all provided fields in certId exist in the certificate and match - * the extensions values in the certificate. */ - private boolean certificateMatches(X509Certificate cert, CertificateIdentity certId) - throws FulcioVerificationException { - var san = extractSan(cert); - var issuer = getExtensionValueRawUtf8(cert, FULCIO_ISSUER_OID); - if (!Objects.equals(certId.getSubjectAlternativeName(), san)) { - log.fine("san did not match (" + san + "," + certId.getSubjectAlternativeName() + ")"); - return false; - } - if (!Objects.equals(certId.getIssuer(), issuer)) { - log.fine("issuer did not match (" + issuer + "," + certId.getIssuer() + ")"); - return false; - } - for (var otherOid : certId.getOther().keySet()) { - var entry = getExtensionValueRawUtf8(cert, otherOid); - if (!Objects.equals(entry, certId.getOther().get(otherOid))) { - log.fine( - otherOid + " did not match (" + entry + "," + certId.getOther().get(otherOid) + ")"); - return false; - } - } - return true; - } - - /* Looks for only a single SAN and extracts an email or machine id from it. If not a single SAN, - * then errors. If not an rfc822Name(email) or URI(machine-id) then fails. */ - private String extractSan(X509Certificate cert) throws FulcioVerificationException { - try { - var sans = cert.getSubjectAlternativeNames(); - if (sans.size() == 0) { - throw new FulcioVerificationException("No SANs found in fulcio certificate"); - } - if (sans.size() > 1) { - throw new FulcioVerificationException( - "Fulcio certificate must only have 1 SAN, but found " + sans.size()); - } - var san = sans.stream().findFirst().get(); - var type = (Integer) san.get(0); - if (!type.equals(GeneralName.rfc822Name) - && !type.equals(GeneralName.uniformResourceIdentifier)) { - throw new FulcioVerificationException( - "Fulcio certificates SAN must be of type rfc822 or URI"); - } - return (String) san.get(1); - } catch (CertificateParsingException cpe) { - throw new FulcioVerificationException("Could not parse SAN from fulcio certificate", cpe); - } - } - - /* Extracts the octets from an extension value and converts to utf-8 directly, it does NOT - * account for any ASN1 encoded value. If the extension value is an ASN1 object (like an - * ASN1 encoded string), you need to write a new extraction helper. */ - private String getExtensionValueRawUtf8(X509Certificate cert, String oid) - throws FulcioVerificationException { - byte[] extensionValue = cert.getExtensionValue(oid); - - if (extensionValue == null) { - return null; - } - try { - ASN1Primitive derObject = ASN1Sequence.fromByteArray(cert.getExtensionValue(oid)); - if (derObject instanceof DEROctetString) { - DEROctetString derOctetString = (DEROctetString) derObject; - // this is unusual, but the octet is a raw utf8 string in fulcio land (no prefix of type) - // and not an ASN1 object. - return new String(derOctetString.getOctets(), StandardCharsets.UTF_8); - } - throw new FulcioVerificationException( - "Could not parse extension " - + oid - + " in certificate because it was not an octet string"); - } catch (IOException ioe) { - throw new FulcioVerificationException( - "Could not parse extension " + oid + " in certificate", ioe); - } - } -} diff --git a/sigstore-java/src/test/java/dev/sigstore/KeylessVerifierTest.java b/sigstore-java/src/test/java/dev/sigstore/KeylessVerifierTest.java index 6f2e596a..9b82ed53 100644 --- a/sigstore-java/src/test/java/dev/sigstore/KeylessVerifierTest.java +++ b/sigstore-java/src/test/java/dev/sigstore/KeylessVerifierTest.java @@ -15,11 +15,18 @@ */ package dev.sigstore; +import com.google.common.collect.ImmutableList; import com.google.common.io.Resources; +import dev.sigstore.VerificationOptions.CertificateMatcher; import dev.sigstore.bundle.Bundle; +import dev.sigstore.encryption.signers.Signers; +import dev.sigstore.strings.StringMatcher; +import dev.sigstore.testing.CertGenerator; import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.security.cert.X509Certificate; +import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -110,4 +117,57 @@ public void verifyBundle(String artifactResourcePath, String bundleResourcePath) verifier.verify( Path.of(artifact), Bundle.from(new StringReader(bundleFile)), VerificationOptions.empty()); } + + @Test + public void verifyCertificateMatches_noneProvided() throws Exception { + var verifier = KeylessVerifier.builder().sigstorePublicDefaults().build(); + var certificate = + (X509Certificate) CertGenerator.newCert(Signers.newEcdsaSigner().getPublicKey()); + Assertions.assertDoesNotThrow(() -> verifier.checkCertificateMatchers(certificate, List.of())); + } + + @Test + public void verifyCertificateMatches_anyOf() throws Exception { + var verifier = KeylessVerifier.builder().sigstorePublicDefaults().build(); + var certificate = + (X509Certificate) CertGenerator.newCert(Signers.newEcdsaSigner().getPublicKey()); + Assertions.assertDoesNotThrow( + () -> + verifier.checkCertificateMatchers( + certificate, + ImmutableList.of( + CertificateMatcher.fulcio() + .subjectAlternativeName(StringMatcher.string("not-match")) + .issuer(StringMatcher.string("not-match")) + .build(), + CertificateMatcher.fulcio() + .subjectAlternativeName(StringMatcher.string("test@test.com")) + .issuer(StringMatcher.string("https://fakeaccounts.test.com")) + .build()))); + } + + @Test + public void verifyCertificateMatches_noneMatch() throws Exception { + var verifier = KeylessVerifier.builder().sigstorePublicDefaults().build(); + var certificate = + (X509Certificate) CertGenerator.newCert(Signers.newEcdsaSigner().getPublicKey()); + var ex = + Assertions.assertThrows( + KeylessVerificationException.class, + () -> + verifier.checkCertificateMatchers( + certificate, + ImmutableList.of( + CertificateMatcher.fulcio() + .subjectAlternativeName(StringMatcher.string("not-match")) + .issuer(StringMatcher.string("not-match")) + .build(), + CertificateMatcher.fulcio() + .subjectAlternativeName(StringMatcher.string("not-match-again")) + .issuer(StringMatcher.string("not-match-again")) + .build()))); + Assertions.assertEquals( + "No provided certificate identities matched values in certificate: [{issuer:'String: not-match',san:'String: not-match'},{issuer:'String: not-match-again',san:'String: not-match-again'}]", + ex.getMessage()); + } } diff --git a/sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateMatcherTest.java b/sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateMatcherTest.java new file mode 100644 index 00000000..b0258fc0 --- /dev/null +++ b/sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateMatcherTest.java @@ -0,0 +1,197 @@ +/* + * Copyright 2023 The Sigstore Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sigstore.fulcio.client; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Resources; +import dev.sigstore.VerificationOptions.UncheckedCertificateException; +import dev.sigstore.encryption.certificates.Certificates; +import dev.sigstore.encryption.signers.Signers; +import dev.sigstore.strings.StringMatcher; +import dev.sigstore.testing.CertGenerator; +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; +import java.util.Map; +import org.bouncycastle.asn1.DEROctetString; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class FulcioCertificateMatcherTest { + + private static X509Certificate certificate; + + @BeforeAll + public static void createCertificate() throws Exception { + certificate = (X509Certificate) CertGenerator.newCert(Signers.newEcdsaSigner().getPublicKey()); + } + + @Test + public void test_requiredOids() { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("test@test.com")) + .issuer(StringMatcher.string("https://fakeaccounts.test.com")) + .build(); + Assertions.assertTrue(matcher.test(certificate)); + } + + @Test + public void test_withOther() throws Exception { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("test@test.com")) + .issuer(StringMatcher.string("https://fakeaccounts.test.com")) + .oidRawStrings( + ImmutableMap.of("1.3.6.1.4.1.99999.42.42", StringMatcher.string("test value"))) + .oidDerAsn1Strings( + ImmutableMap.of("1.3.6.1.4.1.99999.42.43", StringMatcher.string("test value der"))) + .oidBytes( + ImmutableMap.of( + "1.3.6.1.4.1.99999.42.42", + new DEROctetString("test value".getBytes(StandardCharsets.UTF_8)).getEncoded())) + .build(); + Assertions.assertTrue(matcher.test(certificate)); + } + + @Test + public void test_noMatch() { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("not-match")) + .issuer(StringMatcher.string("not-match")) + .build(); + Assertions.assertFalse(matcher.test(certificate)); + } + + @Test + public void test_wantRawButActualIsDer() { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("test@test.com")) + .issuer(StringMatcher.string("https://fakeaccounts.test.com")) + .oidRawStrings( + ImmutableMap.of("1.3.6.1.4.1.99999.42.43", StringMatcher.string("test value der"))) + .build(); + Assertions.assertFalse(matcher.test(certificate)); + } + + @Test + public void test_wantDerButActualIsRaw() { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("test@test.com")) + .issuer(StringMatcher.string("https://fakeaccounts.test.com")) + .oidDerAsn1Strings( + ImmutableMap.of("1.3.6.1.4.1.99999.42.42", StringMatcher.string("test value"))) + .build(); + Assertions.assertThrows(UncheckedCertificateException.class, () -> matcher.test(certificate)); + } + + @Test + public void test_bytesDoNotMatch() { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("test@test.com")) + .issuer(StringMatcher.string("https://fakeaccounts.test.com")) + .oidBytes( + ImmutableMap.of( + "1.3.6.1.4.1.99999.42.42", "test value".getBytes(StandardCharsets.UTF_8))) + .build(); + Assertions.assertFalse(matcher.test(certificate)); + } + + @Test + public void test_fromCachedEmailCert() throws Exception { + var certificate = + (X509Certificate) + Certificates.fromPem( + Resources.toString( + Resources.getResource("dev/sigstore/samples/certs/cert-single.pem"), + StandardCharsets.UTF_8)); + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.string("appu@google.com")) + .issuer(StringMatcher.string("https://accounts.google.com")) + .build(); + Assertions.assertTrue(matcher.test(certificate)); + } + + @Test + public void test_fromCachedGithubOidcCert() throws Exception { + var certificate = + (X509Certificate) + Certificates.fromPem( + Resources.toString( + Resources.getResource("dev/sigstore/samples/certs/cert-githuboidc.pem"), + StandardCharsets.UTF_8)); + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName( + StringMatcher.string( + "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v1.4.0")) + .issuer(StringMatcher.string("https://token.actions.githubusercontent.com")) + .oidRawStrings( + ImmutableMap.of( + "1.3.6.1.4.1.57264.1.1", + StringMatcher.string("https://token.actions.githubusercontent.com"), + "1.3.6.1.4.1.57264.1.2", + StringMatcher.string("workflow_dispatch"), + "1.3.6.1.4.1.57264.1.3", + StringMatcher.string("4ffe2674e1e9e268c00a4f4afa5264fdd399d453"), + "1.3.6.1.4.1.57264.1.4", + StringMatcher.string("Tag and Build Release"), + "1.3.6.1.4.1.57264.1.5", + StringMatcher.string("sigstore/sigstore-java"), + "1.3.6.1.4.1.57264.1.6", + StringMatcher.string("refs/heads/main"))) + .build(); + Assertions.assertTrue(matcher.test(certificate)); + } + + @Test + public void test_fromCachedGithubOidcCertWithRegEx() throws Exception { + var certificate = + (X509Certificate) + Certificates.fromPem( + Resources.toString( + Resources.getResource("dev/sigstore/samples/certs/cert-githuboidc.pem"), + StandardCharsets.UTF_8)); + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName( + StringMatcher.regex( + "https://github\\.com/slsa-framework/slsa-github-generator/\\.github/workflows/generator_generic_slsa3.yml@refs/tags/v\\d+\\.\\d+\\.\\d+")) + .issuer(StringMatcher.string("https://token.actions.githubusercontent.com")) + .build(); + Assertions.assertTrue(matcher.test(certificate)); + } + + @Test + public void testToString() throws Exception { + var matcher = + ImmutableFulcioCertificateMatcher.builder() + .subjectAlternativeName(StringMatcher.regex("https://github\\.com/.*")) + .issuer(StringMatcher.string("https://token.actions.githubusercontent.com")) + .oidRawStrings(Map.of("1.2.3", StringMatcher.string("test-rawString"))) + .oidDerAsn1Strings(Map.of("1.2.3", StringMatcher.string("test-rawString"))) + .oidBytes(Map.of("1.2.3", "test-rawString".getBytes(StandardCharsets.UTF_8))) + .build(); + Assertions.assertEquals( + "{issuer:'String: https://token.actions.githubusercontent.com',san:'RegEx: https://github\\.com/.*',oidRawStrings:{1.2.3:'String: test-rawString'},oidDerAsn1Strings:{1.2.3:'String: test-rawString'},oidBytes:{1.2.3:'hex: 746573742d726177537472696e67'}}", + matcher.toString()); + } +} diff --git a/sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateVerifierTest.java b/sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateVerifierTest.java deleted file mode 100644 index 11f9c413..00000000 --- a/sigstore-java/src/test/java/dev/sigstore/fulcio/client/FulcioCertificateVerifierTest.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2023 The Sigstore Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.sigstore.fulcio.client; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.io.Resources; -import dev.sigstore.VerificationOptions.CertificateIdentity; -import dev.sigstore.encryption.certificates.Certificates; -import dev.sigstore.encryption.signers.Signers; -import dev.sigstore.testing.CertGenerator; -import java.nio.charset.StandardCharsets; -import java.security.cert.X509Certificate; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -public class FulcioCertificateVerifierTest { - - private static X509Certificate certificate; - private static FulcioCertificateVerifier verifier; - - @BeforeAll - public static void createCertificate() throws Exception { - certificate = (X509Certificate) CertGenerator.newCert(Signers.newEcdsaSigner().getPublicKey()); - verifier = new FulcioCertificateVerifier(); - } - - @Test - public void verifyCertificateMatches_requiredOids() throws Exception { - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName("test@test.com") - .issuer("https://fakeaccounts.test.com") - .build())); - } - - @Test - public void verifyCertificateMatches_withOther() throws Exception { - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName("test@test.com") - .issuer("https://fakeaccounts.test.com") - .other(ImmutableMap.of("1.3.6.1.4.1.99999.42.42", "test value")) - .build())); - } - - @Test - public void verifyCertificateMatches_anyOf() throws Exception { - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName("not-match") - .issuer("not-match") - .build(), - CertificateIdentity.builder() - .subjectAlternativeName("test@test.com") - .issuer("https://fakeaccounts.test.com") - .build())); - } - - @Test - public void verifyCertificateMatches_noMatch() { - Assertions.assertThrows( - FulcioVerificationException.class, - () -> - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName("not-match") - .issuer("not-match") - .build()))); - } - - @Test - public void verifyCertificateMatches_noMatchButMostlyMatch() { - Assertions.assertThrows( - FulcioVerificationException.class, - () -> - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName("test@test.com") - .issuer("https://fakeaccounts.test.com") - .other(ImmutableMap.of("1.3.6.1.4.1.99999.42.42", "not-match")) - .build()))); - } - - @Test - public void verifyCertificateMatches_fromCachedEmailCert() throws Exception { - var certificate = - (X509Certificate) - Certificates.fromPem( - Resources.toString( - Resources.getResource("dev/sigstore/samples/certs/cert-single.pem"), - StandardCharsets.UTF_8)); - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName("appu@google.com") - .issuer("https://accounts.google.com") - .build())); - } - - @Test - public void verifyCertificateMatches_fromCachedGithubOidcCert() throws Exception { - var certificate = - (X509Certificate) - Certificates.fromPem( - Resources.toString( - Resources.getResource("dev/sigstore/samples/certs/cert-githuboidc.pem"), - StandardCharsets.UTF_8)); - verifier.verifyCertificateMatches( - certificate, - ImmutableList.of( - CertificateIdentity.builder() - .subjectAlternativeName( - "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v1.4.0") - .issuer("https://token.actions.githubusercontent.com") - .other( - ImmutableMap.of( - "1.3.6.1.4.1.57264.1.1", - "https://token.actions.githubusercontent.com", - "1.3.6.1.4.1.57264.1.2", - "workflow_dispatch", - "1.3.6.1.4.1.57264.1.3", - "4ffe2674e1e9e268c00a4f4afa5264fdd399d453", - "1.3.6.1.4.1.57264.1.4", - "Tag and Build Release", - "1.3.6.1.4.1.57264.1.5", - "sigstore/sigstore-java", - "1.3.6.1.4.1.57264.1.6", - "refs/heads/main")) - .build())); - } -} diff --git a/sigstore-java/src/test/java/dev/sigstore/testing/CertGenerator.java b/sigstore-java/src/test/java/dev/sigstore/testing/CertGenerator.java index 1b210bad..72508ed2 100644 --- a/sigstore-java/src/test/java/dev/sigstore/testing/CertGenerator.java +++ b/sigstore-java/src/test/java/dev/sigstore/testing/CertGenerator.java @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.TimeZone; import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERUTF8String; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.X500NameBuilder; import org.bouncycastle.asn1.x500.style.BCStyle; @@ -101,10 +102,18 @@ public static Certificate newCert(PublicKey publicKey) new ASN1ObjectIdentifier("1.3.6.1.4.1.57264.1.1"), false, "https://fakeaccounts.test.com".getBytes(StandardCharsets.UTF_8)); + certificate.addExtension( + new ASN1ObjectIdentifier("1.3.6.1.4.1.57264.1.8"), + false, + new DERUTF8String("https://fakeaccounts.test.com").getEncoded()); certificate.addExtension( new ASN1ObjectIdentifier(("1.3.6.1.4.1.99999.42.42")), false, "test value".getBytes(StandardCharsets.UTF_8)); + certificate.addExtension( + new ASN1ObjectIdentifier(("1.3.6.1.4.1.99999.42.43")), + false, + new DERUTF8String("test value der").getEncoded()); // sign cert ContentSigner signer =