TimestampVerifier.java

/*
 * Copyright 2025 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.timestamp.client;

import com.google.common.hash.Hashing;
import dev.sigstore.encryption.certificates.Certificates;
import dev.sigstore.trustroot.CertificateAuthority;
import dev.sigstore.trustroot.SigstoreTrustedRoot;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.Security;
import java.security.cert.CertPath;
import java.security.cert.CertPathValidator;
import java.security.cert.CertPathValidatorException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.PKIXParameters;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
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;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampToken;

public class TimestampVerifier {
  static {
    Security.addProvider(new BouncyCastleProvider());
  }

  private final List<CertificateAuthority> tsas;

  public static TimestampVerifier newTimestampVerifier(SigstoreTrustedRoot trustedRoot)
      throws InvalidAlgorithmParameterException,
          CertificateException,
          InvalidKeySpecException,
          NoSuchAlgorithmException {
    return newTimestampVerifier(trustedRoot.getTSAs());
  }

  public static TimestampVerifier newTimestampVerifier(List<CertificateAuthority> tsas)
      throws InvalidKeySpecException,
          NoSuchAlgorithmException,
          InvalidAlgorithmParameterException,
          CertificateException {
    // check to see if we can use all TSAs (this is a bit eager)
    for (var tsa : tsas) {
      tsa.asTrustAnchor();
    }

    return new TimestampVerifier(tsas);
  }

  private TimestampVerifier(List<CertificateAuthority> tsas) {
    this.tsas = tsas;
  }

  /**
   * Verifies a timestamp response against the configured trusted Timestamp Authorities (TSAs).
   *
   * @param tsResp The timestamp response object containing the raw bytes of the RFC 3161
   *     TimeStampResponse.
   * @param artifact The artifact that was timestamped.
   * @throws TimestampVerificationException if any verification step fails (e.g., no token,
   *     certificate path validation failure, signature validation failure).
   */
  public void verify(TimestampResponse tsResp, byte[] artifact)
      throws TimestampVerificationException {
    // Parse the timestamp response
    TimeStampResponse bcTsResp;
    try {
      bcTsResp = new TimeStampResponse(tsResp.getEncoded());
    } catch (TSPException | IOException e) {
      throw new TimestampVerificationException("Failed to parse TimeStampResponse", e);
    }

    // Get the timestamp token
    var tsToken = bcTsResp.getTimeStampToken();
    if (tsToken == null) {
      throw new TimestampVerificationException("No TimeStampToken found in response");
    }

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

    // Check if the token contains embedded certificates
    var tsCertStore = tsToken.getCertificates();
    var hasEmbeddedCerts = false;
    if (tsCertStore != null) {
      hasEmbeddedCerts = !tsCertStore.getMatches(null).isEmpty();
    }

    // Determine the trusted TSA that signed this token
    CertificateAuthority tsa;
    if (hasEmbeddedCerts) {
      tsa = findVerifyingTsaFromEmbeddedCerts(tsToken);
    } else {
      tsa = findVerifyingTsaByLeafSignature(tsToken);
    }

    // Validate the certificate chain of the TSA
    try {
      validateTsaChain(tsa, tsToken.getTimeStampInfo().getGenTime());
    } catch (TimestampException
        | NoSuchProviderException
        | InvalidAlgorithmParameterException
        | CertPathValidatorException e) {
      throw new TimestampVerificationException("Failed to validate TSA chain", e);
    }

    // Check if the generation time of the timestamp falls within the validity period of the TSA
    if (!tsa.getValidFor().contains(tsToken.getTimeStampInfo().getGenTime().toInstant())) {
      tsaVerificationFailure.put(
          tsa.getUri().toString(),
          "Timestamp generation time is not within TSA's validity period.");
      String errors =
          tsaVerificationFailure.entrySet().stream()
              .map(entry -> entry.getKey() + " (" + entry.getValue() + ")")
              .collect(Collectors.joining("\n"));
      throw new TimestampVerificationException(
          "Certificate was not verifiable against TSAs\n" + errors);
    }

    // Validate the message imprint digest in the token
    var oid = tsToken.getTimeStampInfo().getMessageImprintAlgOID();
    HashAlgorithm hashAlgorithm;
    try {
      hashAlgorithm = HashAlgorithm.from(oid);
    } catch (UnsupportedHashAlgorithmException e) {
      throw new TimestampVerificationException(e);
    }
    byte[] artifactDigest;
    switch (hashAlgorithm) {
      case SHA256:
        artifactDigest = Hashing.sha256().hashBytes(artifact).asBytes();
        break;
      case SHA384:
        artifactDigest = Hashing.sha384().hashBytes(artifact).asBytes();
        break;
      case SHA512:
        artifactDigest = Hashing.sha512().hashBytes(artifact).asBytes();
        break;
      default:
        throw new IllegalStateException(); // We shouldn't be here.
    }
    validateTokenMessageImprintDigest(tsToken, artifactDigest);
  }

  /** Validates the signature of the TimeStampToken using the provided signing certificate. */
  private void validateTokenSignature(TimeStampToken token, X509Certificate signingCert)
      throws TimestampVerificationException {
    try {
      var verifierBuilder = new JcaSimpleSignerInfoVerifierBuilder();
      var verifier = verifierBuilder.setProvider("BC").build(signingCert);
      token.validate(verifier);
    } catch (OperatorCreationException oce) {
      throw new TimestampVerificationException("Failed to build SignerInformationVerifier", oce);
    } catch (TSPException tspe) {
      throw new TimestampVerificationException("Failed to validate TimeStampToken", tspe);
    }
  }

  /**
   * Validates that the message imprint digest in the timestamp token matches the provided artifact
   * digest.
   */
  private void validateTokenMessageImprintDigest(TimeStampToken token, byte[] artifactDigest)
      throws TimestampVerificationException {
    var messageImprintDigest = token.getTimeStampInfo().getMessageImprintDigest();
    if (!Arrays.equals(messageImprintDigest, artifactDigest)) {
      throw new TimestampVerificationException(
          "Timestamp message imprint digest does not match artifact hash");
    }
  }

  /** Validates that the provided TSA's certificate chain is self-consistent. */
  void validateTsaChain(CertificateAuthority tsa, Date tsDate)
      throws TimestampException,
          NoSuchProviderException,
          CertPathValidatorException,
          InvalidAlgorithmParameterException { // Accept validation date
    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);
    }

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

    cpv.validate(tsa.getCertPath(), pkixParams);
  }

  /**
   * Finds the TSA that verifies the provided timestamp token by validating its signature using the
   * certificate chain embedded within the token and matching the leaf to a known TSA.
   */
  CertificateAuthority findVerifyingTsaFromEmbeddedCerts(TimeStampToken tsToken)
      throws TimestampVerificationException {
    var tsCertStore = tsToken.getCertificates();
    List<Certificate> tsCerts = new ArrayList<>();

    // Get list of X509Certificates from token
    var tsCertHolders = tsCertStore.getMatches(null);
    for (var tsCertHolder : tsCertHolders) {
      var converter = new JcaX509CertificateConverter().setProvider("BC");
      try {
        var cert = converter.getCertificate(tsCertHolder);
        tsCerts.add(cert);
      } catch (CertificateException ce) {
        throw new TimestampVerificationException(
            "Unable to convert certificate to X509Certificate", ce);
      }
    }

    // Convert list of X509Certificates to certPath
    CertPath tsCertPath;
    try {
      tsCertPath = Certificates.toCertPath(tsCerts);
    } catch (CertificateException ce) {
      throw new TimestampVerificationException("Cannot convert certificates to CertPath", ce);
    }

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

    for (var tsa : tsas) {
      var tsaChain = tsa.getCertPath();
      // Check if the leaf certificate from the token matches the leaf of the trusted TSA chain
      if (Certificates.getLeaf(tsCertPath).equals(Certificates.getLeaf(tsaChain))) {
        // If the leaves match, proceed to validate the signature using the leaf
        validateTokenSignature(tsToken, Certificates.getLeaf(tsCertPath));
        return tsa;
      } else {
        tsaVerificationFailure.put(
            tsa.getUri().toString(),
            "Embedded leaf certificate does not match this trusted TSA's leaf.");
      }
    }

    String errors =
        tsaVerificationFailure.entrySet().stream()
            .map(entry -> entry.getKey() + " (" + entry.getValue() + ")")
            .collect(Collectors.joining("\n"));
    throw new TimestampVerificationException(
        "Certificates in token were not verifiable against TSAs\n" + errors);
  }

  /**
   * Finds the TSA that verifies the provided timestamp token by validating its signature against
   * the leaf certificates of known trusted TSAs.
   */
  CertificateAuthority findVerifyingTsaByLeafSignature(TimeStampToken tsToken)
      throws TimestampVerificationException {
    Map<String, String> tsaVerificationFailure = new LinkedHashMap<>();

    for (var tsa : tsas) {
      var tsaChain = tsa.getCertPath();
      var tsaLeaf = Certificates.getLeaf(tsaChain);

      // Check if the tsToken's signature matches that TSA's leaf certificate's public key
      try {
        validateTokenSignature(tsToken, tsaLeaf);
        return tsa;
      } catch (TimestampVerificationException tsve) {
        tsaVerificationFailure.put(tsa.getUri().toString(), tsve.getMessage());
      }
    }

    String errors =
        tsaVerificationFailure.entrySet().stream()
            .map(entry -> entry.getKey() + " (" + entry.getValue() + ")")
            .collect(Collectors.joining("\n"));
    throw new TimestampVerificationException(
        "Certificates in token were not verifiable against TSAs\n" + errors);
  }
}