SigstoreTufClient.java

/*
 * 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.tuf;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import dev.sigstore.trustroot.SigstoreConfigurationException;
import dev.sigstore.trustroot.SigstoreSigningConfig;
import dev.sigstore.trustroot.SigstoreTrustedRoot;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.time.Duration;
import java.time.Instant;
import javax.annotation.Nullable;

/**
 * Wrapper around {@link dev.sigstore.tuf.Updater} that provides access to sigstore specific
 * metadata items in a convenient API.
 */
public class SigstoreTufClient {

  @VisibleForTesting static final String TRUST_ROOT_FILENAME = "trusted_root.json";
  @VisibleForTesting static final String SIGNING_CONFIG_FILENAME = "signing_config.v0.2.json";

  public static final String PUBLIC_GOOD_ROOT_RESOURCE =
      "dev/sigstore/tuf/sigstore-tuf-root/root.json";
  public static final String STAGING_ROOT_RESOURCE = "dev/sigstore/tuf/tuf-root-staging/root.json";

  private final Updater updater;
  private Instant lastUpdate;
  private SigstoreTrustedRoot sigstoreTrustedRoot;
  // TODO: this is nullable because we expect all future sigstore tuf repos to contain a signing
  // config
  // but while we transition, we need to handle the null case.
  @Nullable private SigstoreSigningConfig sigstoreSigningConfig;
  private final Duration cacheValidity;

  @VisibleForTesting
  SigstoreTufClient(Updater updater, Duration cacheValidity) {
    this.updater = updater;
    this.cacheValidity = cacheValidity;
  }

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

  public static class Builder {
    Duration cacheValidity = Duration.ofDays(1);
    Path tufCacheLocation =
        Path.of(System.getProperty("user.home")).resolve(".sigstore-java").resolve("root");

    private URL remoteMirror;
    private RootProvider trustedRoot;

    public Builder usePublicGoodInstance() {
      if (remoteMirror != null || trustedRoot != null) {
        throw new IllegalStateException(
            "Using public good after configuring remoteMirror and trustedRoot");
      }
      try {
        tufMirror(
            new URL("https://tuf-repo-cdn.sigstore.dev/"),
            RootProvider.fromResource(PUBLIC_GOOD_ROOT_RESOURCE));
      } catch (MalformedURLException e) {
        throw new AssertionError(e);
      }
      return this;
    }

    public Builder useStagingInstance() {
      if (remoteMirror != null || trustedRoot != null) {
        throw new IllegalStateException(
            "Using staging after configuring remoteMirror and trustedRoot");
      }
      try {
        tufMirror(
            new URL("https://tuf-repo-cdn.sigstage.dev"),
            RootProvider.fromResource(STAGING_ROOT_RESOURCE));
      } catch (MalformedURLException e) {
        throw new AssertionError(e);
      }
      tufCacheLocation =
          Path.of(System.getProperty("user.home"))
              .resolve(".sigstore-java")
              .resolve("staging")
              .resolve("root");
      return this;
    }

    public Builder tufMirror(URL mirror, RootProvider trustedRoot) {
      this.remoteMirror = mirror;
      this.trustedRoot = trustedRoot;
      return this;
    }

    public Builder cacheValidity(Duration duration) {
      this.cacheValidity = duration;
      return this;
    }

    public Builder tufCacheLocation(Path location) {
      this.tufCacheLocation = location;
      return this;
    }

    public SigstoreTufClient build() throws IOException {
      Preconditions.checkState(!cacheValidity.isNegative(), "cacheValidity must be non negative");
      Preconditions.checkNotNull(remoteMirror);
      Preconditions.checkNotNull(trustedRoot);
      if (!Files.isDirectory(tufCacheLocation)) {
        Files.createDirectories(tufCacheLocation);
      }
      var normalizedRemoteMirror =
          remoteMirror.toString().endsWith("/")
              ? remoteMirror
              : new URL(remoteMirror.toExternalForm() + "/");
      var remoteTargetsLocation = new URL(normalizedRemoteMirror.toExternalForm() + "targets");
      var filesystemTufStore = FileSystemTufStore.newFileSystemStore(tufCacheLocation);
      var tufUpdater =
          Updater.builder()
              .setTrustedRootPath(trustedRoot)
              .setTrustedMetaStore(
                  TrustedMetaStore.newTrustedMetaStore(
                      PassthroughCacheMetaStore.newPassthroughMetaCache(filesystemTufStore)))
              .setTargetStore(filesystemTufStore)
              .setMetaFetcher(
                  MetaFetcher.newFetcher(HttpFetcher.newFetcher(normalizedRemoteMirror)))
              .setTargetFetcher(HttpFetcher.newFetcher(remoteTargetsLocation))
              .build();
      return new SigstoreTufClient(tufUpdater, cacheValidity);
    }
  }

  /**
   * Update the tuf metadata if the cache has not been updated for at least {@code cacheValidity}
   * defined on the client.
   */
  public void update() throws SigstoreConfigurationException {
    if (lastUpdate == null
        || Duration.between(lastUpdate, Instant.now()).compareTo(cacheValidity) > 0) {
      this.forceUpdate();
    }
  }

  /** Force an update, ignoring any cache validity. */
  public void forceUpdate() throws SigstoreConfigurationException {
    try {
      updater.update();
    } catch (IOException
        | NoSuchAlgorithmException
        | InvalidKeySpecException
        | InvalidKeyException ex) {
      throw new SigstoreConfigurationException("TUF repo failed to update", ex);
    }
    lastUpdate = Instant.now();
    try {
      sigstoreTrustedRoot =
          SigstoreTrustedRoot.from(
              updater.getTargetStore().getTargetInputSteam(TRUST_ROOT_FILENAME));
    } catch (IOException ex) {
      throw new SigstoreConfigurationException("Failed to read trusted root from target store", ex);
    }
    try {
      if (updater.getTargetStore().hasTarget(SIGNING_CONFIG_FILENAME)) {
        sigstoreSigningConfig =
            SigstoreSigningConfig.from(
                updater.getTargetStore().getTargetInputSteam(SIGNING_CONFIG_FILENAME));
      } else {
        sigstoreSigningConfig = null;
        // TODO: Remove when prod and staging TUF repos have fully configured signing configs, but
        // right now sigstore tuf repos not having sigstoreSigningConfig is a valid state.
      }
    } catch (IOException ex) {
      throw new SigstoreConfigurationException(
          "Failed to read signing config from target store", ex);
    }
  }

  public SigstoreTrustedRoot getSigstoreTrustedRoot() {
    return sigstoreTrustedRoot;
  }

  @Nullable
  public SigstoreSigningConfig getSigstoreSigningConfig() {
    return sigstoreSigningConfig;
  }
}