KeylessVerifier.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;

import com.google.api.client.util.Preconditions;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.io.Files;
import dev.sigstore.VerificationOptions.CertificateMatcher;
import dev.sigstore.VerificationOptions.UncheckedCertificateException;
import dev.sigstore.bundle.Bundle;
import dev.sigstore.bundle.Bundle.DsseEnvelope;
import dev.sigstore.bundle.Bundle.MessageSignature;
import dev.sigstore.dsse.InTotoPayload;
import dev.sigstore.encryption.Hashers;
import dev.sigstore.encryption.certificates.Certificates;
import dev.sigstore.encryption.signers.Verifiers;
import dev.sigstore.fulcio.client.FulcioVerificationException;
import dev.sigstore.fulcio.client.FulcioVerifier;
import dev.sigstore.json.JsonParseException;
import dev.sigstore.proto.ProtoMutators;
import dev.sigstore.proto.rekor.v2.HashedRekordLogEntryV002;
import dev.sigstore.proto.rekor.v2.Signature;
import dev.sigstore.rekor.client.HashedRekordRequest;
import dev.sigstore.rekor.client.RekorEntry;
import dev.sigstore.rekor.client.RekorEntryBody;
import dev.sigstore.rekor.client.RekorTypeException;
import dev.sigstore.rekor.client.RekorTypes;
import dev.sigstore.rekor.client.RekorVerificationException;
import dev.sigstore.rekor.client.RekorVerifier;
import dev.sigstore.rekor.dsse.v0_0_1.Dsse;
import dev.sigstore.rekor.dsse.v0_0_1.PayloadHash;
import dev.sigstore.timestamp.client.ImmutableTimestampResponse;
import dev.sigstore.timestamp.client.TimestampException;
import dev.sigstore.timestamp.client.TimestampVerificationException;
import dev.sigstore.timestamp.client.TimestampVerifier;
import dev.sigstore.trustroot.SigstoreConfigurationException;
import dev.sigstore.tuf.SigstoreTufClient;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SignatureException;
import java.security.cert.CertificateEncodingException;
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.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.bouncycastle.util.encoders.DecoderException;
import org.bouncycastle.util.encoders.Hex;

/**
 * Verify hashedrekords from rekor signed using the keyless signing flow with fulcio certificates.
 */
public class KeylessVerifier {

  private final FulcioVerifier fulcioVerifier;
  private final RekorVerifier rekorVerifier;
  private final TimestampVerifier timestampVerifier;

  private KeylessVerifier(
      FulcioVerifier fulcioVerifier,
      RekorVerifier rekorVerifier,
      TimestampVerifier timestampVerifier) {
    this.fulcioVerifier = fulcioVerifier;
    this.rekorVerifier = rekorVerifier;
    this.timestampVerifier = timestampVerifier;
  }

  public static KeylessVerifier.Builder builder() {
    return new KeylessVerifier.Builder();
  }

  public static class Builder {

    private TrustedRootProvider trustedRootProvider;

    public KeylessVerifier build()
        throws InvalidAlgorithmParameterException,
            CertificateException,
            InvalidKeySpecException,
            NoSuchAlgorithmException,
            SigstoreConfigurationException {
      Preconditions.checkNotNull(trustedRootProvider);
      var trustedRoot = trustedRootProvider.get();
      var fulcioVerifier = FulcioVerifier.newFulcioVerifier(trustedRoot);
      var rekorVerifier = RekorVerifier.newRekorVerifier(trustedRoot);
      var timestampVerifier = TimestampVerifier.newTimestampVerifier(trustedRoot);
      return new KeylessVerifier(fulcioVerifier, rekorVerifier, timestampVerifier);
    }

    public Builder sigstorePublicDefaults() {
      var sigstoreTufClientBuilder = SigstoreTufClient.builder().usePublicGoodInstance();
      trustedRootProvider = TrustedRootProvider.from(sigstoreTufClientBuilder);
      return this;
    }

    public Builder sigstoreStagingDefaults() {
      var sigstoreTufClientBuilder = SigstoreTufClient.builder().useStagingInstance();
      trustedRootProvider = TrustedRootProvider.from(sigstoreTufClientBuilder);
      return this;
    }

    public Builder trustedRootProvider(TrustedRootProvider trustedRootProvider) {
      this.trustedRootProvider = trustedRootProvider;
      return this;
    }
  }

  private AlgorithmRegistry.HashAlgorithm findHashAlgorithm(Bundle bundle)
      throws KeylessVerificationException {
    try {
      // special case hashedrekord:0.0.1 and dsse:0.0.1 which may contain legacy public key to hash
      // algorithm mappings
      if (!bundle.getEntries().isEmpty()) {
        var rekorEntry = bundle.getEntries().get(0);
        var body = rekorEntry.getBodyDecoded();
        if ("0.0.1".equals(body.getApiVersion())) {
          if ("hashedrekord".equals(body.getKind())) {
            var entry = RekorTypes.getHashedRekordV001(rekorEntry);
            switch (entry.getData().getHash().getAlgorithm()) {
              case SHA_256:
                return AlgorithmRegistry.HashAlgorithm.SHA2_256;
              case SHA_384:
                return AlgorithmRegistry.HashAlgorithm.SHA2_384;
              case SHA_512:
                return AlgorithmRegistry.HashAlgorithm.SHA2_512;
            }
          } else if ("dsse".equals(body.getKind())) {
            // dsse:0.0.1 is always sha256
            return AlgorithmRegistry.HashAlgorithm.SHA2_256;
          }
        }
      }
      var publicKey = Certificates.getLeaf(bundle.getCertPath()).getPublicKey();
      return AlgorithmRegistry.getSigningAlgorithm(publicKey).getHashAlgorithm();
    } catch (JsonParseException | RekorTypeException | UnsupportedAlgorithmException ex) {
      throw new KeylessVerificationException(
          "Could not determine hash algorithm from sigstore bundle", ex);
    }
  }

  /** Convenience wrapper around {@link #verify(byte[], Bundle, VerificationOptions)}. */
  public void verify(Path artifact, Bundle bundle, VerificationOptions options)
      throws KeylessVerificationException {
    var hashAlgorithm = findHashAlgorithm(bundle);
    try {
      var artifactDigest =
          Files.asByteSource(artifact.toFile()).hash(Hashers.from(hashAlgorithm)).asBytes();
      verify(artifactDigest, bundle, options);
    } catch (IOException ex) {
      throw new KeylessVerificationException("Could not hash artifact: " + artifact, ex);
    }
  }

  /**
   * Verify that the inputs can attest to the validity of a signature using sigstore's keyless
   * infrastructure. If no exception is thrown, it should be assumed verification has passed.
   *
   * @param artifactDigest the digest of the artifact that is being verified, this must match the
   *     scheme used to sign the artifact
   * @param bundle the sigstore signature bundle to verify
   * @param options the keyless verification data and options
   * @throws KeylessVerificationException if the signing information could not be verified
   */
  public void verify(byte[] artifactDigest, Bundle bundle, VerificationOptions options)
      throws KeylessVerificationException {

    if (bundle.getDsseEnvelope().isEmpty() && bundle.getMessageSignature().isEmpty()) {
      throw new IllegalStateException(
          "Bundle must contain a message signature or DSSE envelope to verify");
    }

    var signingCert = bundle.getCertPath();
    var leafCert = Certificates.getLeaf(signingCert);

    // a trusted CT log
    try {
      fulcioVerifier.verifySigningCertificate(signingCert, options.getCtLogOptions());
    } catch (FulcioVerificationException | IOException ex) {
      throw new KeylessVerificationException(
          "Fulcio certificate was not valid: " + ex.getMessage(), ex);
    }

    // verify the certificate identity if options are present
    checkCertificateMatchers(leafCert, options.getCertificateMatchers());

    var hashAlgorithm = findHashAlgorithm(bundle);

    // A transparency-log entry is required unless disabled (e.g. a private deployment that verifies
    // via a signed timestamp instead). We also refuse to ignore an entry that is present, so a
    // disabled policy cannot silently skip verifying a log entry the bundle actually contains.
    var entries = bundle.getEntries();
    if (entries.isEmpty() && options.getTLogOptions().isEnabled()) {
      throw new KeylessVerificationException(
          "No transparency log entry found and transparency-log verification is required");
    }
    if (!entries.isEmpty() && !options.getTLogOptions().isEnabled()) {
      throw new KeylessVerificationException(
          "Bundle contains a transparency log entry but transparency-log verification is disabled");
    }
    var rekorEntry = entries.isEmpty() ? null : entries.get(0);

    byte[] signature;
    if (bundle.getMessageSignature().isPresent()) { // hashedrekord
      var messageSignature = bundle.getMessageSignature().get();
      checkMessageSignature(messageSignature, rekorEntry, artifactDigest, hashAlgorithm, leafCert);
      signature = messageSignature.getSignature();
    } else { // dsse
      var dsseEnvelope = bundle.getDsseEnvelope().get();
      checkDsseEnvelope(rekorEntry, dsseEnvelope, artifactDigest, hashAlgorithm, leafCert);
      signature = dsseEnvelope.getSignature();
    }

    // A transparency-log entry that is present is always verified; opting in only relaxes the
    // requirement that one exists, never the verification of one that is provided.
    Instant entryTime = null;
    if (rekorEntry != null) {
      try {
        rekorVerifier.verifyEntry(rekorEntry);
      } catch (RekorVerificationException ex) {
        throw new KeylessVerificationException("Transparency log entry could not be verified", ex);
      }
      // if entry was verified and has a SET, get time from it
      var set = rekorEntry.getVerification().getSignedEntryTimestamp();
      entryTime = set != null ? rekorEntry.getIntegratedTimeInstant() : null;
    }

    verifyTimestamps(leafCert, bundle.getTimestamps(), entryTime, signature);
  }

  private void verifyTimestamps(
      X509Certificate leafCert,
      List<Bundle.Timestamp> timestamps,
      Instant entryTime,
      byte[] signature)
      throws KeylessVerificationException {
    if (timestamps.isEmpty() && entryTime == null) {
      throw new KeylessVerificationException("No valid timestamps found in bundle");
    }
    if (entryTime != null) {
      var entryDate = Date.from(entryTime);
      try {
        leafCert.checkValidity(entryDate);
      } catch (CertificateNotYetValidException e) {
        throw new KeylessVerificationException("Signing time was before certificate validity", e);
      } catch (CertificateExpiredException e) {
        throw new KeylessVerificationException("Signing time was after certificate expiry", e);
      }
    }
    for (Bundle.Timestamp timestamp : timestamps) {
      byte[] tsBytes = timestamp.getRfc3161Timestamp();
      if (tsBytes == null || tsBytes.length == 0) {
        throw new KeylessVerificationException(
            "Found an empty or null RFC3161 timestamp in bundle");
      }
      try {
        var tsResp = ImmutableTimestampResponse.builder().encoded(tsBytes).build();
        timestampVerifier.verify(tsResp, signature);
        leafCert.checkValidity(tsResp.getGenTime());
      } catch (TimestampException
          | CertificateNotYetValidException
          | CertificateExpiredException
          | TimestampVerificationException e) {
        throw new KeylessVerificationException(
            "RFC3161 timestamp verification failed: " + e.getMessage(), e);
      }
    }
  }

  @VisibleForTesting
  void checkCertificateMatchers(X509Certificate cert, List<CertificateMatcher> 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());
    }
  }

  private void checkMessageSignature(
      MessageSignature messageSignature,
      RekorEntry rekorEntry,
      byte[] artifactDigest,
      AlgorithmRegistry.HashAlgorithm hashAlgorithm,
      X509Certificate leafCert)
      throws KeylessVerificationException {
    // this ensures the provided artifact digest matches what may have come from a bundle (in
    // keyless signature)
    if (messageSignature.getMessageDigest().isPresent()) {
      var bundleDigest = messageSignature.getMessageDigest().get().getDigest();
      if (!Arrays.equals(artifactDigest, bundleDigest)) {
        throw new KeylessVerificationException(
            "Provided artifact digest does not match digest used for verification"
                + "\nprovided(hex) : "
                + Hex.toHexString(artifactDigest)
                + "\nverification(hex) : "
                + Hex.toHexString(bundleDigest));
      }
    }

    // verify the signature over the artifact
    var signature = messageSignature.getSignature();
    try {
      if (!Verifiers.newVerifier(leafCert.getPublicKey(), hashAlgorithm)
          .verifyDigest(artifactDigest, signature)) {
        throw new KeylessVerificationException("Artifact signature was not valid");
      }
    } catch (NoSuchAlgorithmException | InvalidKeyException e) {
      throw new RuntimeException(e);
    } catch (SignatureException | UnsupportedAlgorithmException ex) {
      throw new KeylessVerificationException(
          "Signature could not be processed: " + ex.getMessage(), ex);
    }

    // No transparency-log entry (allowed via VerificationOptions): the artifact digest and
    // signature have been verified above, and there is nothing to cross-check against.
    if (rekorEntry == null) {
      return;
    }

    // recreate the log entry and check if it matches what was provided in the entry
    String version;
    try {
      version = rekorEntry.getBodyDecoded().getApiVersion();
    } catch (JsonParseException ex) {
      throw new KeylessVerificationException("Could not extract body from log entry");
    }
    if ("0.0.1".equals(version)) {
      try {
        RekorTypes.getHashedRekordV001(rekorEntry);
        var calculatedHashedRekord =
            HashedRekordRequest.newHashedRekordRequest(
                    artifactDigest, hashAlgorithm, Certificates.toPemBytes(leafCert), signature)
                .toJsonPayload();
        var body =
            new String(Base64.getDecoder().decode(rekorEntry.getBody()), StandardCharsets.UTF_8);
        if (!Objects.equals(calculatedHashedRekord, body)) {
          throw new KeylessVerificationException(
              "Provided verification materials are inconsistent with log entry");
        }
      } catch (IOException e) {
        // this should be unreachable, we know leafCert is a valid certificate at this point
        throw new RuntimeException("Unexpected IOException on valid leafCert", e);
      } catch (RekorTypeException re) {
        throw new KeylessVerificationException("Unexpected rekor type", re);
      }
    } else if ("0.0.2".equals(version)) {
      HashedRekordLogEntryV002 logEntrySpec;
      try {
        logEntrySpec = RekorTypes.getHashedRekordV002(rekorEntry);
      } catch (RekorTypeException re) {
        throw new KeylessVerificationException(
            "Could not parse hashedrekord from log entry body", re);
      }

      try {
        ProtoMutators.toHashAlgorithm(logEntrySpec.getData().getAlgorithm());
      } catch (UnsupportedAlgorithmException ex) {
        throw new KeylessVerificationException("Unsupported digest algorithm in log entry", ex);
      }

      if (!Arrays.equals(logEntrySpec.getData().getDigest().toByteArray(), artifactDigest)) {
        throw new KeylessVerificationException(
            "Artifact digest does not match digest in log entry spec");
      }

      if (!Arrays.equals(logEntrySpec.getSignature().getContent().toByteArray(), signature)) {
        throw new KeylessVerificationException(
            "Signature does not match signature in log entry spec");
      }

      var verifier = logEntrySpec.getSignature().getVerifier();
      if (!verifier.hasX509Certificate()) {
        throw new KeylessVerificationException("Rekor entry verifier is missing X.509 certificate");
      }
      try {
        byte[] certFromRekor = verifier.getX509Certificate().getRawBytes().toByteArray();
        byte[] certFromBundle = leafCert.getEncoded();
        if (!Arrays.equals(certFromRekor, certFromBundle)) {
          throw new KeylessVerificationException(
              "Certificate in rekor entry does not match certificate in bundle");
        }
      } catch (CertificateEncodingException e) {
        throw new KeylessVerificationException(
            "Could not encode leaf certificate for comparison", e);
      }
    } else {
      throw new KeylessVerificationException("Unsupported hashedrekord version: " + version);
    }
  }

  private void checkDsseEnvelope(
      RekorEntry rekorEntry,
      DsseEnvelope dsseEnvelope,
      byte[] artifactDigest,
      AlgorithmRegistry.HashAlgorithm hashAlgorithm,
      X509Certificate leafCert)
      throws KeylessVerificationException {
    // verify the artifact is in the subject list of the envelope
    if (!Objects.equals(InTotoPayload.PAYLOAD_TYPE, dsseEnvelope.getPayloadType())) {
      throw new KeylessVerificationException(
          "DSSE envelope must have payload type "
              + InTotoPayload.PAYLOAD_TYPE
              + ", but found '"
              + dsseEnvelope.getPayloadType()
              + "'");
    }

    InTotoPayload payload;
    try {
      payload = InTotoPayload.from(dsseEnvelope);
    } catch (JsonParseException jpe) {
      throw new KeylessVerificationException("Could not parse DSSE payload", jpe);
    }

    // find one hash in the subject list that matches the artifact hash
    var hashName = hashAlgorithm.toLowercaseString();
    var hashing = Hashers.from(hashAlgorithm);

    if (payload.getSubject().stream()
        .noneMatch(
            subject -> {
              if (subject.getDigest().containsKey(hashName)) {
                try {
                  var digestBytes = Hex.decode(subject.getDigest().get(hashName));
                  return Arrays.equals(artifactDigest, digestBytes);
                } catch (DecoderException de) {
                  // ignore (assume false)
                }
              }
              return false;
            })) {
      var providedHashes =
          payload.getSubject().stream()
              .map(s -> s.getDigest().getOrDefault(hashName, "no-" + hashName + "-hash"))
              .collect(Collectors.joining(",", "[", "]"));

      throw new KeylessVerificationException(
          "Provided artifact digest does not match any subject "
              + hashName
              + " digests in DSSE payload"
              + "\nprovided(hex) : "
              + Hex.toHexString(artifactDigest)
              + "\nverification  : "
              + providedHashes);
    }

    // verify the dsse signature
    if (dsseEnvelope.getSignatures().size() != 1) {
      throw new KeylessVerificationException(
          "DSSE envelope must have exactly 1 signature, but found: "
              + dsseEnvelope.getSignatures().size());
    }
    try {
      if (!Verifiers.newVerifier(leafCert.getPublicKey(), hashAlgorithm)
          .verify(dsseEnvelope.getPAE(), dsseEnvelope.getSignature())) {
        throw new KeylessVerificationException("DSSE signature was not valid");
      }
    } catch (NoSuchAlgorithmException | InvalidKeyException ex) {
      throw new RuntimeException(ex);
    } catch (SignatureException | UnsupportedAlgorithmException se) {
      throw new KeylessVerificationException("Signature could not be processed", se);
    }

    // No transparency-log entry (allowed via VerificationOptions): the payload subject digest and
    // DSSE signature have been verified above, and there is nothing to cross-check against.
    if (rekorEntry == null) {
      return;
    }

    RekorEntryBody entryBody;
    try {
      entryBody = rekorEntry.getBodyDecoded();
    } catch (JsonParseException ex) {
      throw new KeylessVerificationException("Could not extract body from log entry");
    }
    var kind = entryBody.getKind();
    var version = entryBody.getApiVersion();
    if ("0.0.1".equals(version) && "dsse".equals(kind)) {
      Dsse rekorDsse;
      try {
        rekorDsse = RekorTypes.getDsseV001(rekorEntry);
      } catch (RekorTypeException re) {
        throw new KeylessVerificationException("Unexpected rekor type", re);
      }

      var algorithm = rekorDsse.getPayloadHash().getAlgorithm();
      if (algorithm != PayloadHash.Algorithm.SHA_256) {
        throw new KeylessVerificationException(
            "Cannot process DSSE entry with payload hashing algorithm " + algorithm.toString());
      }

      // check if the digest over the dsse payload matches the digest in the transparency log entry
      byte[] payloadDigest;
      try {
        payloadDigest = Hex.decode(rekorDsse.getPayloadHash().getValue());
      } catch (DecoderException de) {
        throw new KeylessVerificationException(
            "Could not decode hex artifact hash in hashedrekord", de);
      }

      byte[] calculatedDigest = hashing.hashBytes(dsseEnvelope.getPayload()).asBytes();
      if (!Arrays.equals(calculatedDigest, payloadDigest)) {
        throw new KeylessVerificationException(
            "Digest of DSSE payload in bundle does not match DSSE payload digest in log entry");
      }

      // check if the signature over the dsse payload matches the signature in the rekorEntry
      if (rekorDsse.getSignatures().size() != 1) {
        throw new KeylessVerificationException(
            "DSSE log entry must have exactly 1 signature, but found: "
                + rekorDsse.getSignatures().size());
      }

      if (!Base64.getEncoder()
          .encodeToString(dsseEnvelope.getSignature())
          .equals(rekorDsse.getSignatures().get(0).getSignature())) {
        throw new KeylessVerificationException(
            "Provided DSSE signature materials are inconsistent with DSSE log entry");
      }
    } else if ("0.0.2".equals(version) && "hashedrekord".equals(kind)) {
      HashedRekordLogEntryV002 logEntrySpec;
      try {
        logEntrySpec = RekorTypes.getHashedRekordV002(rekorEntry);
      } catch (RekorTypeException re) {
        throw new KeylessVerificationException("Could not parse DSSE from log entry body", re);
      }

      // check if the digest over the dsse pae matches the digest in the transparency log entry
      byte[] calculatedDigest = hashing.hashBytes(dsseEnvelope.getPAE()).asBytes();
      if (!Arrays.equals(logEntrySpec.getData().getDigest().toByteArray(), calculatedDigest)) {
        throw new KeylessVerificationException(
            "Digest of DSSE.pae in bundle does not match digest in log entry");
      }

      Signature logSignature = logEntrySpec.getSignature();
      if (!Arrays.equals(dsseEnvelope.getSignature(), logSignature.getContent().toByteArray())) {
        throw new KeylessVerificationException(
            "Signature in DSSE envelope does not match signature in log entry");
      }

      var verifier = logSignature.getVerifier();
      if (!verifier.hasX509Certificate()) {
        throw new KeylessVerificationException("Log entry is missing X.509 certificate");
      }
      try {
        byte[] certFromRekor = verifier.getX509Certificate().getRawBytes().toByteArray();
        byte[] certFromBundle = leafCert.getEncoded();
        if (!Arrays.equals(certFromRekor, certFromBundle)) {
          throw new KeylessVerificationException(
              "Certificate in rekor entry does not match certificate in bundle");
        }
      } catch (CertificateEncodingException e) {
        throw new KeylessVerificationException(
            "Could not encode leaf certificate for comparison", e);
      }
    } else {
      throw new KeylessVerificationException(
          "Unsupported entry type: '" + kind + ":" + version + "' for DSSE bundle");
    }
  }
}