KeylessSigner.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.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.hash.Hashing;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.CheckReturnValue;
import com.google.errorprone.annotations.concurrent.GuardedBy;
import dev.sigstore.bundle.Bundle;
import dev.sigstore.bundle.Bundle.HashAlgorithm;
import dev.sigstore.bundle.Bundle.MessageSignature;
import dev.sigstore.bundle.ImmutableBundle;
import dev.sigstore.bundle.ImmutableTimestamp;
import dev.sigstore.encryption.certificates.Certificates;
import dev.sigstore.encryption.signers.Signer;
import dev.sigstore.encryption.signers.Signers;
import dev.sigstore.fulcio.client.CertificateRequest;
import dev.sigstore.fulcio.client.FulcioClient;
import dev.sigstore.fulcio.client.FulcioClientGrpc;
import dev.sigstore.fulcio.client.FulcioVerificationException;
import dev.sigstore.fulcio.client.FulcioVerifier;
import dev.sigstore.fulcio.client.UnsupportedAlgorithmException;
import dev.sigstore.oidc.client.OidcClients;
import dev.sigstore.oidc.client.OidcException;
import dev.sigstore.oidc.client.OidcToken;
import dev.sigstore.oidc.client.OidcTokenMatcher;
import dev.sigstore.rekor.client.HashedRekordRequest;
import dev.sigstore.rekor.client.RekorClient;
import dev.sigstore.rekor.client.RekorClientHttp;
import dev.sigstore.rekor.client.RekorParseException;
import dev.sigstore.rekor.client.RekorResponse;
import dev.sigstore.rekor.client.RekorVerificationException;
import dev.sigstore.rekor.client.RekorVerifier;
import dev.sigstore.timestamp.client.ImmutableTimestampRequest;
import dev.sigstore.timestamp.client.TimestampClient;
import dev.sigstore.timestamp.client.TimestampClientHttp;
import dev.sigstore.timestamp.client.TimestampException;
import dev.sigstore.timestamp.client.TimestampResponse;
import dev.sigstore.timestamp.client.TimestampVerificationException;
import dev.sigstore.timestamp.client.TimestampVerifier;
import dev.sigstore.trustroot.LegacySigningConfig;
import dev.sigstore.trustroot.Service;
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.CertPath;
import java.security.cert.CertificateException;
import java.security.spec.InvalidKeySpecException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import javax.annotation.Nullable;
import org.bouncycastle.util.encoders.Base64;
/**
* A full sigstore keyless signing flow.
*
* <p>Note: the implementation is thread-safe assuming the clients (Fulcio, OIDC, Rekor) are
* thread-safe
*/
public class KeylessSigner implements AutoCloseable {
/**
* The instance of the {@link KeylessSigner} will try to reuse a previously acquired certificate
* if the expiration time on the certificate is more than {@code minSigningCertificateLifetime}
* time away. Otherwise, it will make a new request (OIDC, Fulcio) to obtain a new updated
* certificate to use for signing. This is a default value for the remaining lifetime of the
* signing certificate that is considered good enough.
*/
public static final Duration DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME = Duration.ofMinutes(5);
private final FulcioClient fulcioClient;
private final FulcioVerifier fulcioVerifier;
private final RekorClient rekorClient;
private final RekorVerifier rekorVerifier;
private final TimestampClient timestampClient;
private final TimestampVerifier timestampVerifier;
private final OidcClients oidcClients;
private final List<OidcTokenMatcher> oidcIdentities;
private final Signer signer;
private final Duration minSigningCertificateLifetime;
/** The code signing certificate from Fulcio. */
@GuardedBy("lock")
@Nullable
private CertPath signingCert;
/**
* Representation {@link #signingCert} in PEM bytes format. This is used to avoid serializing the
* certificate for each use.
*/
@GuardedBy("lock")
@Nullable
private byte[] signingCertPemBytes;
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private KeylessSigner(
FulcioClient fulcioClient,
FulcioVerifier fulcioVerifier,
RekorClient rekorClient,
RekorVerifier rekorVerifier,
TimestampClient timestampClient,
TimestampVerifier timestampVerifier,
OidcClients oidcClients,
List<OidcTokenMatcher> oidcIdentities,
Signer signer,
Duration minSigningCertificateLifetime) {
this.fulcioClient = fulcioClient;
this.fulcioVerifier = fulcioVerifier;
this.rekorClient = rekorClient;
this.rekorVerifier = rekorVerifier;
this.timestampClient = timestampClient;
this.timestampVerifier = timestampVerifier;
this.oidcClients = oidcClients;
this.oidcIdentities = oidcIdentities;
this.signer = signer;
this.minSigningCertificateLifetime = minSigningCertificateLifetime;
}
@Override
public void close() {
lock.writeLock().lock();
try {
signingCert = null;
signingCertPemBytes = null;
} finally {
lock.writeLock().unlock();
}
}
@CheckReturnValue
public static Builder builder() {
return new Builder();
}
public static class Builder {
private TrustedRootProvider trustedRootProvider;
private SigningConfigProvider signingConfigProvider;
private OidcClients oidcClients;
private List<OidcTokenMatcher> oidcIdentities = Collections.emptyList();
private Signer signer;
private Duration minSigningCertificateLifetime = DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME;
@CanIgnoreReturnValue
public Builder trustedRootProvider(TrustedRootProvider trustedRootProvider) {
this.trustedRootProvider = trustedRootProvider;
return this;
}
@CanIgnoreReturnValue
public Builder signingConfigProvider(SigningConfigProvider signingConfigProvider) {
this.signingConfigProvider = signingConfigProvider;
return this;
}
/**
* Deprecated, use {@link #forceCredentialProviders}. sigstore-gradle requires a one version
* deprecation window, so keep this in here until we've done another release.
*/
@Deprecated(forRemoval = true)
public Builder oidcClients(OidcClients oidcClients) {
return forceCredentialProviders(oidcClients);
}
/**
* Override the default set of credential providers (ambient + signingConfig). It should be very
* unusual for anyone to override this outside of testing scenarios.
*/
@CanIgnoreReturnValue
public Builder forceCredentialProviders(OidcClients oidcClients) {
this.oidcClients = oidcClients;
return this;
}
/**
* An allow list OIDC identities to be used during signing. If the OidcClients are misconfigured
* or pick up unexpected credentials, this should prevent signing from proceeding. Cannot be
* null but can be an empty list and will allow all identities.
*/
@CanIgnoreReturnValue
public Builder allowedOidcIdentities(List<OidcTokenMatcher> oidcIdentities) {
this.oidcIdentities = ImmutableList.copyOf(oidcIdentities);
return this;
}
@CanIgnoreReturnValue
public Builder signer(Signer signer) {
this.signer = signer;
return this;
}
/**
* The instance of the {@link KeylessSigner} will try to reuse a previously acquired certificate
* if the expiration time on the certificate is more than {@code minSigningCertificateLifetime}
* time away. Otherwise, it will make a new request (OIDC, Fulcio) to obtain a new updated
* certificate to use for signing. Default {@code minSigningCertificateLifetime} is {@link
* #DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME}".
*
* @param minSigningCertificateLifetime the minimum lifetime of the signing certificate before
* renewal
* @return this builder
* @see <a href="https://docs.sigstore.dev/fulcio/overview/">Fulcio certificate validity</a>
*/
@CanIgnoreReturnValue
public Builder minSigningCertificateLifetime(Duration minSigningCertificateLifetime) {
this.minSigningCertificateLifetime = minSigningCertificateLifetime;
return this;
}
@CheckReturnValue
public KeylessSigner build()
throws CertificateException,
IOException,
NoSuchAlgorithmException,
InvalidKeySpecException,
InvalidKeyException,
InvalidAlgorithmParameterException,
SigstoreConfigurationException {
Preconditions.checkNotNull(trustedRootProvider);
var trustedRoot = trustedRootProvider.get();
Preconditions.checkNotNull(signingConfigProvider);
var signingConfig = signingConfigProvider.get();
Preconditions.checkNotNull(oidcIdentities);
Preconditions.checkNotNull(signer);
Preconditions.checkNotNull(minSigningCertificateLifetime);
var fulcioService = Service.select(signingConfig.getCas(), List.of(1));
if (fulcioService.isEmpty()) {
throw new SigstoreConfigurationException(
"No suitable fulcio target was found in signing config");
}
var fulcioClient = FulcioClientGrpc.builder().setService(fulcioService.get()).build();
var fulcioVerifier = FulcioVerifier.newFulcioVerifier(trustedRoot);
var rekorService = Service.select(signingConfig.getTLogs(), List.of(1));
if (rekorService.isEmpty()) {
throw new SigstoreConfigurationException(
"No suitable rekor target was found in signing config");
}
var rekorClient = RekorClientHttp.builder().setService(rekorService.get()).build();
var rekorVerifier = RekorVerifier.newRekorVerifier(trustedRoot);
TimestampClient timestampClient = null;
TimestampVerifier timestampVerifier = null;
var timestampService = Service.select(signingConfig.getTsas(), List.of(1));
if (timestampService.isEmpty()) {
if (rekorService.get().getApiVersion() != 1) {
// only throw exception for rekor v2+ which will require time
throw new SigstoreConfigurationException(
"No suitable tsa target was found in signing config");
}
} else {
timestampClient = TimestampClientHttp.builder().setService(timestampService.get()).build();
timestampVerifier = TimestampVerifier.newTimestampVerifier(trustedRoot);
}
// if the client hasn't overridden the oidc provider, determine it from the service config
if (oidcClients == null) {
var oidcService = Service.select(signingConfig.getOidcProviders(), List.of(1));
if (oidcService.isEmpty()) {
throw new SigstoreConfigurationException(
"No suitable oidc target was found in signing config");
}
oidcClients = OidcClients.from(oidcService.get());
}
return new KeylessSigner(
fulcioClient,
fulcioVerifier,
rekorClient,
rekorVerifier,
timestampClient,
timestampVerifier,
oidcClients,
oidcIdentities,
signer,
minSigningCertificateLifetime);
}
/**
* Initialize a builder with the sigstore public good instance tuf root and oidc targets with
* ecdsa signing.
*/
@CanIgnoreReturnValue
public Builder sigstorePublicDefaults() {
var sigstoreTufClientBuilder = SigstoreTufClient.builder().usePublicGoodInstance();
trustedRootProvider = TrustedRootProvider.from(sigstoreTufClientBuilder);
// TODO: signing config is not pushed to prod yet
signingConfigProvider =
SigningConfigProvider.fromOrDefault(
sigstoreTufClientBuilder, LegacySigningConfig.PUBLIC_GOOD);
signer(Signers.newEcdsaSigner());
minSigningCertificateLifetime(DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME);
return this;
}
/**
* Initialize a builder with the sigstore staging instance tuf root and oidc targets with ecdsa
* signing.
*/
@CanIgnoreReturnValue
public Builder sigstoreStagingDefaults() {
var sigstoreTufClientBuilder = SigstoreTufClient.builder().useStagingInstance();
trustedRootProvider = TrustedRootProvider.from(sigstoreTufClientBuilder);
signingConfigProvider = SigningConfigProvider.from(sigstoreTufClientBuilder);
signer(Signers.newEcdsaSigner());
minSigningCertificateLifetime(DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME);
return this;
}
}
/**
* Sign one or more artifact digests using the keyless signing workflow. The oidc/fulcio dance to
* obtain a signing certificate will only occur once. The same ephemeral private key will be used
* to sign all artifacts. This method will renew certificates as they expire.
*
* @param artifactDigests sha256 digests of the artifacts to sign.
* @return a list of keyless singing results.
*/
@CheckReturnValue
public List<Bundle> sign(List<byte[]> artifactDigests) throws KeylessSignerException {
if (artifactDigests.size() == 0) {
throw new IllegalArgumentException("Require one or more digests");
}
var result = ImmutableList.<Bundle>builder();
for (var artifactDigest : artifactDigests) {
byte[] signature;
try {
signature = signer.signDigest(artifactDigest);
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException ex) {
throw new KeylessSignerException("Failed to sign artifact", ex);
}
// Technically speaking, it is unlikely the certificate will expire between signing artifacts
// However, files might be large, and it might take time to talk to Rekor
// so we check the certificate expiration here.
try {
renewSigningCertificate();
} catch (FulcioVerificationException
| UnsupportedAlgorithmException
| OidcException
| IOException
| InterruptedException
| InvalidKeyException
| NoSuchAlgorithmException
| SignatureException
| CertificateException ex) {
throw new KeylessSignerException("Failed to obtain signing certificate", ex);
}
CertPath signingCert;
byte[] signingCertPemBytes;
lock.readLock().lock();
try {
signingCert = this.signingCert;
signingCertPemBytes = this.signingCertPemBytes;
if (signingCert == null) {
throw new IllegalStateException("Signing certificate is null");
}
} finally {
lock.readLock().unlock();
}
var rekorRequest =
HashedRekordRequest.newHashedRekordRequest(
artifactDigest, signingCertPemBytes, signature);
RekorResponse rekorResponse;
try {
rekorResponse = rekorClient.putEntry(rekorRequest);
} catch (RekorParseException | IOException ex) {
throw new KeylessSignerException("Failed to put entry in rekor", ex);
}
var calculatedHashedRekord =
Base64.toBase64String(rekorRequest.toJsonPayload().getBytes(StandardCharsets.UTF_8));
if (!Objects.equals(calculatedHashedRekord, rekorResponse.getEntry().getBody())) {
throw new KeylessSignerException("Returned log entry was inconsistent with request");
}
try {
rekorVerifier.verifyEntry(rekorResponse.getEntry());
} catch (RekorVerificationException ex) {
throw new KeylessSignerException("Failed to validate rekor response after signing", ex);
}
var bundleBuilder =
ImmutableBundle.builder()
.certPath(signingCert)
.addEntries(rekorResponse.getEntry())
.messageSignature(
MessageSignature.of(HashAlgorithm.SHA2_256, artifactDigest, signature));
// Timestamp functionality only enabled if timestampUri is provided
if (timestampClient != null && timestampVerifier != null) {
var signatureDigest = Hashing.sha256().hashBytes(signature).asBytes();
var tsReq =
ImmutableTimestampRequest.builder()
.hashAlgorithm(dev.sigstore.timestamp.client.HashAlgorithm.SHA256)
.hash(signatureDigest)
.build();
TimestampResponse tsResp;
try {
tsResp = timestampClient.timestamp(tsReq);
} catch (TimestampException ex) {
throw new KeylessSignerException("Failed to generate timestamp", ex);
}
try {
timestampVerifier.verify(tsResp, signature);
} catch (TimestampVerificationException ex) {
throw new KeylessSignerException("Returned timestamp was invalid", ex);
}
Bundle.Timestamp timestamp =
ImmutableTimestamp.builder().rfc3161Timestamp(tsResp.getEncoded()).build();
bundleBuilder.addTimestamps(timestamp);
}
result.add(bundleBuilder.build());
}
return result.build();
}
private void renewSigningCertificate()
throws InterruptedException,
CertificateException,
IOException,
UnsupportedAlgorithmException,
NoSuchAlgorithmException,
InvalidKeyException,
SignatureException,
FulcioVerificationException,
OidcException,
KeylessSignerException {
// Check if the certificate is still valid
lock.readLock().lock();
try {
if (signingCert != null) {
@SuppressWarnings("JavaUtilDate")
long lifetimeLeft =
Certificates.getLeaf(signingCert).getNotAfter().getTime() - System.currentTimeMillis();
if (lifetimeLeft > minSigningCertificateLifetime.toMillis()) {
// The current certificate is fine, reuse it
return;
}
}
} finally {
lock.readLock().unlock();
}
// Renew Fulcio certificate
lock.writeLock().lock();
try {
signingCert = null;
signingCertPemBytes = null;
OidcToken tokenInfo = oidcClients.getIDToken();
// check if we have an allow list and if so, ensure the provided token is in there
if (!oidcIdentities.isEmpty()) {
if (oidcIdentities.stream().noneMatch(id -> id.test(tokenInfo))) {
throw new KeylessSignerException(
"Obtained Oidc Token " + tokenInfo + " does not match any identities in allow list");
}
}
CertPath renewedSigningCert =
fulcioClient.signingCertificate(
CertificateRequest.newCertificateRequest(
signer.getPublicKey(),
tokenInfo.getIdToken(),
signer.sign(
tokenInfo.getSubjectAlternativeName().getBytes(StandardCharsets.UTF_8))));
// TODO: this signing workflow mandates SCTs, but fulcio itself doesn't, figure out a way to
// allow that to be known
var trimmed = fulcioVerifier.trimTrustedParent(renewedSigningCert);
fulcioVerifier.verifySigningCertificate(trimmed);
this.signingCert = trimmed;
signingCertPemBytes = Certificates.toPemBytes(signingCert);
} finally {
lock.writeLock().unlock();
}
}
/**
* Convenience wrapper around {@link #sign(List)} to sign a single digest
*
* @param artifactDigest sha256 digest of the artifact to sign.
* @return a keyless singing results.
*/
@CheckReturnValue
public Bundle sign(byte[] artifactDigest) throws KeylessSignerException {
return sign(List.of(artifactDigest)).get(0);
}
/**
* Convenience wrapper around {@link #sign(List)} to accept files instead of digests
*
* @param artifacts list of the artifacts to sign.
* @return a map of artifacts and their keyless singing results.
*/
@CheckReturnValue
public Map<Path, Bundle> signFiles(List<Path> artifacts) throws KeylessSignerException {
if (artifacts.size() == 0) {
throw new IllegalArgumentException("Require one or more paths");
}
var digests = new ArrayList<byte[]>(artifacts.size());
for (var artifact : artifacts) {
var artifactByteSource = com.google.common.io.Files.asByteSource(artifact.toFile());
try {
digests.add(artifactByteSource.hash(Hashing.sha256()).asBytes());
} catch (IOException ex) {
throw new KeylessSignerException("Failed to hash artifact " + artifact);
}
}
var signingResult = sign(digests);
var result = ImmutableMap.<Path, Bundle>builder();
for (int i = 0; i < artifacts.size(); i++) {
result.put(artifacts.get(i), signingResult.get(i));
}
return result.build();
}
/**
* Convenience wrapper around {@link #sign(List)} to accept a single file
*
* @param artifact the artifacts to sign
* @return a sigstore bundle
*/
@CheckReturnValue
public Bundle signFile(Path artifact) throws KeylessSignerException {
return signFiles(List.of(artifact)).get(artifact);
}
/**
* Convenience wrapper around {@link #sign(List)} to accept a single file Compat - to be removed
* before 1.0.0
*/
@Deprecated
public Bundle signFile2(Path artifact) throws KeylessSignerException {
return signFiles(List.of(artifact)).get(artifact);
}
}