FulcioVerifier.java

/*
 * Copyright 2022 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.annotations.VisibleForTesting;
import dev.sigstore.encryption.certificates.Certificates;
import dev.sigstore.encryption.certificates.transparency.CTLogInfo;
import dev.sigstore.encryption.certificates.transparency.CTVerificationResult;
import dev.sigstore.encryption.certificates.transparency.CTVerifier;
import dev.sigstore.trustroot.CertificateAuthority;
import dev.sigstore.trustroot.SigstoreTrustedRoot;
import dev.sigstore.trustroot.TransparencyLog;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertPath;
import java.security.cert.CertPathValidator;
import java.security.cert.CertPathValidatorException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.PKIXParameters;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/** Verifier for fulcio generated signing cerificates */
public class FulcioVerifier {
  private final List<CertificateAuthority> cas;
  private final List<TransparencyLog> ctLogs;
  private final CTVerifier ctVerifier;

  public static FulcioVerifier newFulcioVerifier(SigstoreTrustedRoot trustRoot)
      throws InvalidAlgorithmParameterException,
          CertificateException,
          InvalidKeySpecException,
          NoSuchAlgorithmException {
    return newFulcioVerifier(trustRoot.getCAs(), trustRoot.getCTLogs());
  }

  public static FulcioVerifier newFulcioVerifier(
      List<CertificateAuthority> cas, List<TransparencyLog> ctLogs)
      throws InvalidKeySpecException,
          NoSuchAlgorithmException,
          InvalidAlgorithmParameterException,
          CertificateException {
    List<CTLogInfo> logs = new ArrayList<>();
    for (var ctLog : ctLogs) {
      logs.add(
          new CTLogInfo(
              ctLog.getPublicKey().toJavaPublicKey(), "CT Log", ctLog.getBaseUrl().toString()));
    }
    var verifier =
        new CTVerifier(
            logId ->
                logs.stream()
                    .filter(ctLogInfo -> Arrays.equals(ctLogInfo.getID(), logId))
                    .findFirst()
                    .orElse(null));

    // check to see if we can use all fulcio roots (this is a bit eager)
    for (var ca : cas) {
      ca.asTrustAnchor();
    }

    return new FulcioVerifier(cas, ctLogs, verifier);
  }

  private FulcioVerifier(
      List<CertificateAuthority> cas, List<TransparencyLog> ctLogs, CTVerifier ctVerifier) {
    this.cas = cas;
    this.ctLogs = ctLogs;
    this.ctVerifier = ctVerifier;
  }

  @VisibleForTesting
  void verifySct(CertPath fullCertPath) throws FulcioVerificationException {
    if (ctLogs.size() == 0) {
      throw new FulcioVerificationException("No ct logs were provided to verifier");
    }

    if (Certificates.getEmbeddedSCTs(Certificates.getLeaf(fullCertPath)).isPresent()) {
      verifyEmbeddedScts(fullCertPath);
    } else {
      throw new FulcioVerificationException("No valid SCTs were found during verification");
    }
  }

  private void verifyEmbeddedScts(CertPath certPath) throws FulcioVerificationException {
    @SuppressWarnings("unchecked")
    var certs = (List<X509Certificate>) certPath.getCertificates();
    CTVerificationResult result;
    try {
      result = ctVerifier.verifySignedCertificateTimestamps(certs, null, null);
    } catch (CertificateEncodingException cee) {
      throw new FulcioVerificationException(
          "Certificates could not be parsed during SCT verification");
    }

    // these are technically valid, but we have the additional constraint of sigstore's trustroot
    // providing a validity period for logs, so make sure all SCTs were signed by a log during
    // that log's validity period
    for (var validSct : result.getValidSCTs()) {
      var sct = validSct.sct;

      var logId = sct.getLogID();
      var entryTime = Instant.ofEpochMilli(sct.getTimestamp());

      var ctLog = TransparencyLog.find(ctLogs, logId, entryTime);
      if (ctLog.isPresent()) {
        // TODO: currently we only require one valid SCT, but maybe this should be configurable?
        // found at least one valid sct with a matching valid log
        return;
      }
    }
    throw new FulcioVerificationException(
        "No valid SCTs were found, all("
            + (result.getValidSCTs().size() + result.getInvalidSCTs().size())
            + ") SCTs were invalid");
  }

  /**
   * Verify that a cert chain is valid and chains up to the trust anchor (fulcio public key)
   * configured in this validator. Also verify that the leaf certificate contains at least one valid
   * SCT
   *
   * @param signingCertificate containing a certificate chain, this chain should not contain any
   *     trusted root or trusted intermediates
   * @throws FulcioVerificationException if verification fails for any reason
   */
  public void verifySigningCertificate(CertPath signingCertificate)
      throws FulcioVerificationException, IOException {
    CertPath fullCertPath = validateCertPath(signingCertificate);
    verifySct(fullCertPath);
  }

  public CertPath trimTrustedParent(CertPath signingCertificate)
      throws FulcioVerificationException, CertificateException {
    for (var ca : cas) {
      if (Certificates.containsParent(signingCertificate, ca.getCertPath())) {
        return Certificates.trimParent(signingCertificate, ca.getCertPath());
      }
    }
    throw new FulcioVerificationException("Certificate does not chain to trusted roots");
  }

  /**
   * Find a valid cert path that chains back up to the trusted root certs and reconstruct a
   * certificate path combining the provided un-trusted certs and a known set of trusted and
   * intermediate certs. If a full certificate is provided with a self signed root, this should
   * attempt to match the root/intermediate with a trusted chain.
   */
  CertPath validateCertPath(CertPath signingCertificate) throws FulcioVerificationException {
    CertPathValidator cpv;
    try {
      cpv = CertPathValidator.getInstance("PKIX");
    } catch (NoSuchAlgorithmException e) {
      //
      throw new RuntimeException(
          "No PKIX CertPathValidator, we probably shouldn't be here, but this seems to be a system library error not a program control flow issue",
          e);
    }

    var leaf = Certificates.getLeaf(signingCertificate);
    var validCAs = CertificateAuthority.find(cas, leaf.getNotBefore().toInstant());

    if (validCAs.size() == 0) {
      throw new FulcioVerificationException(
          "No valid Certificate Authorities found when validating certificate");
    }

    Map<String, String> caVerificationFailure = new LinkedHashMap<>();

    for (var ca : validCAs) {
      PKIXParameters pkixParams;
      try {
        pkixParams = new PKIXParameters(Collections.singleton(ca.asTrustAnchor()));
      } catch (InvalidAlgorithmParameterException | CertificateException e) {
        throw new RuntimeException(
            "Can't create PKIX parameters for fulcioRoot. This should have been checked when generating a verifier instance",
            e);
      }
      pkixParams.setRevocationEnabled(false);

      // these certs are only valid for 15 minutes, so find a time in the validity period
      @SuppressWarnings("JavaUtilDate")
      Date dateInValidityPeriod =
          new Date(Certificates.getLeaf(signingCertificate).getNotBefore().getTime());
      pkixParams.setDate(dateInValidityPeriod);

      CertPath fullCertPath;
      try {
        if (Certificates.isSelfSigned(signingCertificate)) {
          if (Certificates.containsParent(signingCertificate, ca.getCertPath())) {
            fullCertPath = signingCertificate;
          } else {
            // verification failed because we didn't match to a trusted root
            caVerificationFailure.put(
                ca.getUri().toString(), "Trusted root in chain does not match");
            continue;
          }
        } else {
          // build a cert chain with the root-chain in question and the provided signing certificate
          fullCertPath = Certificates.append(ca.getCertPath(), signingCertificate);
        }

        // a result is returned here, but we ignore it
        cpv.validate(fullCertPath, pkixParams);
      } catch (CertPathValidatorException
          | InvalidAlgorithmParameterException
          | CertificateException ve) {
        caVerificationFailure.put(ca.getUri().toString(), ve.getMessage());
        // verification failed
        continue;
      }
      return fullCertPath;
      // verification passed so just end this method
    }
    String errors =
        caVerificationFailure.entrySet().stream()
            .map(entry -> entry.getKey() + " (" + entry.getValue() + ")")
            .collect(Collectors.joining("\n"));
    throw new FulcioVerificationException("Certificate was not verifiable against CAs\n" + errors);
  }
}