TimestampClientHttp.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.api.client.http.ByteArrayContent;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.util.Preconditions;
import com.google.common.annotations.VisibleForTesting;
import dev.sigstore.http.HttpClients;
import dev.sigstore.http.HttpParams;
import dev.sigstore.trustroot.Service;
import java.io.IOException;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampRequestGenerator;
import org.bouncycastle.tsp.TimeStampResponse;

/** A client to communicate with a timestamp service instance. */
public class TimestampClientHttp implements TimestampClient {
  private static final String CONTENT_TYPE_TIMESTAMP_QUERY = "application/timestamp-query";
  private static final String ACCEPT_TYPE_TIMESTAMP_REPLY = "application/timestamp-reply";

  private final HttpRequestFactory requestFactory;
  private final URI uri;

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

  @VisibleForTesting
  TimestampClientHttp(HttpRequestFactory requestFactory, URI uri) {
    this.requestFactory = requestFactory;
    this.uri = uri;
  }

  public static class Builder {
    private HttpParams httpParams = HttpParams.builder().build();
    private Service service;

    private Builder() {}

    /** Configure the http properties, see {@link HttpParams}. */
    public Builder setHttpParams(HttpParams httpParams) {
      this.httpParams = httpParams;
      return this;
    }

    /** Base url of the timestamp authority. */
    public Builder setService(Service service) {
      this.service = service;
      return this;
    }

    public TimestampClientHttp build() throws IOException {
      Preconditions.checkNotNull(service);
      var requestFactory = HttpClients.newRequestFactory(httpParams);
      return new TimestampClientHttp(requestFactory, service.getUrl());
    }
  }

  @Override
  public TimestampResponse timestamp(TimestampRequest tsReq) throws TimestampException {
    TimeStampRequestGenerator bcTsReqGen = new TimeStampRequestGenerator();

    // Prepare and send the timestamp request
    var bcAlgorithmOid = tsReq.getHashAlgorithm().getOid();
    var artifactHashBytes = tsReq.getHash();
    var nonce = tsReq.getNonce();
    bcTsReqGen.setCertReq(tsReq.requestCertificates());
    TimeStampRequest bcTsReq;
    HttpResponse httpTsResp;
    try {
      bcTsReq = bcTsReqGen.generate(bcAlgorithmOid, artifactHashBytes, nonce);
      var requestBytes = bcTsReq.getEncoded();
      httpTsResp = sendTimestampRequest(uri, requestBytes);
    } catch (IOException e) {
      throw new TimestampException("Timestamp request failed: " + e.getMessage(), e);
    }

    // Parse the timestamp response
    TimestampResponse tsResp;
    try {
      var bcTsResp = getBcTimestampResponse(httpTsResp, bcTsReq);
      var tsRespBytes = bcTsResp.getEncoded();
      tsResp = ImmutableTimestampResponse.builder().encoded(tsRespBytes).build();
    } catch (IOException | TSPException e) {
      throw new TimestampException(
          "Timestamp response validation or parsing failed: " + e.getMessage(), e);
    }

    return tsResp;
  }

  HttpResponse sendTimestampRequest(URI tsaUri, byte[] requestBytes) throws IOException {
    Objects.requireNonNull(tsaUri, "tsaUri cannot be null");
    Objects.requireNonNull(requestBytes, "requestBytes cannot be null");
    var httpReq =
        requestFactory.buildPostRequest(
            new GenericUrl(tsaUri),
            new ByteArrayContent(CONTENT_TYPE_TIMESTAMP_QUERY, requestBytes));
    httpReq.getHeaders().setAccept(ACCEPT_TYPE_TIMESTAMP_REPLY);
    httpReq.setThrowExceptionOnExecuteError(false);
    // Skip exception thrown by API to manually handle error code below
    httpReq.setNumberOfRetries(5);
    var httpResp = httpReq.execute();
    if (!(httpResp.getStatusCode() >= 200 && httpResp.getStatusCode() < 300)) {
      throw new IOException(
          String.format(
              Locale.ROOT,
              "bad response from timestamp @ '%s' : %s",
              tsaUri,
              httpResp.parseAsString()));
    }
    return httpResp;
  }

  private TimeStampResponse getBcTimestampResponse(
      HttpResponse httpTsResp, TimeStampRequest bcTsReq) throws IOException, TSPException {
    Objects.requireNonNull(httpTsResp, "HttpResponse cannot be null");
    Objects.requireNonNull(bcTsReq, "TimeStampRequest cannot be null");

    var bcTsResp = new TimeStampResponse(httpTsResp.getContent());
    bcTsResp.validate(bcTsReq);
    return bcTsResp;
  }
}