diff --git a/sigstore-java/src/main/java/dev/sigstore/KeylessSigner.java b/sigstore-java/src/main/java/dev/sigstore/KeylessSigner.java index 05494209..6b1a2a80 100644 --- a/sigstore-java/src/main/java/dev/sigstore/KeylessSigner.java +++ b/sigstore-java/src/main/java/dev/sigstore/KeylessSigner.java @@ -23,10 +23,10 @@ import com.google.errorprone.annotations.CheckReturnValue; import com.google.errorprone.annotations.concurrent.GuardedBy; import com.google.protobuf.ByteString; -import dev.sigstore.bundle.Bundle; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import dev.sigstore.bundle.*; 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; @@ -42,6 +42,7 @@ import dev.sigstore.oidc.client.OidcTokenMatcher; import dev.sigstore.proto.ProtoMutators; import dev.sigstore.proto.common.v1.X509Certificate; +import dev.sigstore.proto.rekor.v2.DSSERequestV002; import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002; import dev.sigstore.proto.rekor.v2.Signature; import dev.sigstore.proto.rekor.v2.Verifier; @@ -65,6 +66,7 @@ import dev.sigstore.trustroot.Service; import dev.sigstore.trustroot.SigstoreConfigurationException; import dev.sigstore.tuf.SigstoreTufClient; +import io.intoto.EnvelopeOuterClass; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; @@ -379,6 +381,140 @@ public Builder sigstoreStagingDefaults() { } } + public Bundle attest(String payload) throws KeylessSignerException { + // 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; + byte[] encodedCert; + lock.readLock().lock(); + try { + signingCert = this.signingCert; + signingCertPemBytes = this.signingCertPemBytes; + encodedCert = this.encodedCert; + if (signingCert == null) { + throw new IllegalStateException("Signing certificate is null"); + } + } finally { + lock.readLock().unlock(); + } + + var bundleBuilder = ImmutableBundle.builder().certPath(signingCert); + + if (rekorV2Client != null) { // Using Rekor v2 and a TSA + Preconditions.checkNotNull( + timestampClient, "Timestamp client must be configured for Rekor v2"); + Preconditions.checkNotNull( + timestampVerifier, "Timestamp verifier must be configured for Rekor v2"); + + var verifier = + Verifier.newBuilder() + .setX509Certificate( + X509Certificate.newBuilder() + .setRawBytes(ByteString.copyFrom(encodedCert)) + .build()) + .setKeyDetails(ProtoMutators.toPublicKeyDetails(signingAlgorithm)) + .build(); + + var dsse = + ImmutableDsseEnvelope.builder() + .payload(payload.getBytes(StandardCharsets.UTF_8)) + .payloadType("application/vnd.in-toto+json") + .build(); + var pae = dsse.getPAE(); + Bundle.DsseEnvelope dsseSigned; + try { + var sig = signer.sign(pae); + dsseSigned = + ImmutableDsseEnvelope.builder() + .from(dsse) + .addSignatures(ImmutableSignature.builder().sig(sig).build()) + .build(); + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + throw new RuntimeException(e); + } + + var dsseRequest = + DSSERequestV002.newBuilder() + .setEnvelope( + EnvelopeOuterClass.Envelope.newBuilder() + .setPayload(ByteString.copyFrom(dsseSigned.getPayload())) + .setPayloadType(dsseSigned.getPayloadType()) + .addSignatures( + EnvelopeOuterClass.Signature.newBuilder() + .setSig(ByteString.copyFrom(dsseSigned.getSignature()))) + .build()) + .addVerifiers(verifier) + .build(); + + try { + System.out.println(JsonFormat.printer().print(dsseRequest)); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + + var signatureDigest = Hashing.sha256().hashBytes(dsseSigned.getSignature()).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, dsseSigned.getSignature()); + } catch (TimestampVerificationException ex) { + throw new KeylessSignerException("Returned timestamp was invalid", ex); + } + + Bundle.Timestamp timestamp = + ImmutableTimestamp.builder().rfc3161Timestamp(tsResp.getEncoded()).build(); + + bundleBuilder.addTimestamps(timestamp); + + RekorEntry entry; + try { + entry = rekorV2Client.putEntry(dsseRequest); + } catch (IOException | RekorParseException ex) { + throw new KeylessSignerException("Failed to put entry in rekor", ex); + } + + try { + rekorVerifier.verifyEntry(entry); + } catch (RekorVerificationException ex) { + throw new KeylessSignerException("Failed to validate rekor entry after signing", ex); + } + + bundleBuilder.dsseEnvelope(dsseSigned); + + bundleBuilder.addEntries(entry); + } else { + throw new IllegalStateException("Rekor v2 client was not configured."); + } + return bundleBuilder.build(); + } + /** * 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 diff --git a/sigstore-java/src/main/java/dev/sigstore/bundle/BundleWriter.java b/sigstore-java/src/main/java/dev/sigstore/bundle/BundleWriter.java index 72d7c738..c576aa51 100644 --- a/sigstore-java/src/main/java/dev/sigstore/bundle/BundleWriter.java +++ b/sigstore-java/src/main/java/dev/sigstore/bundle/BundleWriter.java @@ -33,6 +33,7 @@ import dev.sigstore.proto.rekor.v1.KindVersion; import dev.sigstore.proto.rekor.v1.TransparencyLogEntry; import dev.sigstore.rekor.client.RekorEntry; +import io.intoto.EnvelopeOuterClass; import java.security.cert.CertificateEncodingException; import java.util.Base64; import java.util.List; @@ -78,28 +79,44 @@ static String writeBundle(Bundle signingResult) { * @return Sigstore Bundle in protobuf builder format */ static dev.sigstore.proto.bundle.v1.Bundle.Builder createBundleBuilder(Bundle bundle) { - if (bundle.getMessageSignature().isEmpty()) { - throw new IllegalStateException("can only serialize bundles with message signatures"); - } - var messageSignature = bundle.getMessageSignature().get(); - if (messageSignature.getMessageDigest().isEmpty()) { + // if (bundle.getMessageSignature().isEmpty()) { + // throw new IllegalStateException("can only serialize bundles with message signatures"); + // } + var builder = + dev.sigstore.proto.bundle.v1.Bundle.newBuilder() + .setMediaType(bundle.getMediaType()) + .setVerificationMaterial(buildVerificationMaterial(bundle)); + if (bundle.getMessageSignature().isPresent()) { + var messageSignature = bundle.getMessageSignature().get(); + if (messageSignature.getMessageDigest().isEmpty()) { + throw new IllegalStateException( + "keyless signature must have artifact digest when serializing to bundle"); + } + builder.setMessageSignature( + MessageSignature.newBuilder() + .setMessageDigest( + HashOutput.newBuilder() + .setAlgorithm( + ProtoMutators.toProtoHashAlgorithm( + messageSignature.getMessageDigest().get().getHashAlgorithm())) + .setDigest( + ByteString.copyFrom( + messageSignature.getMessageDigest().get().getDigest()))) + .setSignature(ByteString.copyFrom(messageSignature.getSignature()))); + } else if (bundle.getDsseEnvelope().isPresent()) { + var de = bundle.getDsseEnvelope().get(); + builder.setDsseEnvelope( + EnvelopeOuterClass.Envelope.newBuilder() + .setPayload(ByteString.copyFrom(de.getPayload())) + .setPayloadType(de.getPayloadType()) + .addSignatures( + EnvelopeOuterClass.Signature.newBuilder() + .setSig(ByteString.copyFrom(de.getSignature())))); + } else { throw new IllegalStateException( - "keyless signature must have artifact digest when serializing to bundle"); + "can only serialize bundles with message signature or dsse envelope"); } - return dev.sigstore.proto.bundle.v1.Bundle.newBuilder() - .setMediaType(bundle.getMediaType()) - .setVerificationMaterial(buildVerificationMaterial(bundle)) - .setMessageSignature( - MessageSignature.newBuilder() - .setMessageDigest( - HashOutput.newBuilder() - .setAlgorithm( - ProtoMutators.toProtoHashAlgorithm( - messageSignature.getMessageDigest().get().getHashAlgorithm())) - .setDigest( - ByteString.copyFrom( - messageSignature.getMessageDigest().get().getDigest()))) - .setSignature(ByteString.copyFrom(messageSignature.getSignature()))); + return builder; } private static VerificationMaterial.Builder buildVerificationMaterial(Bundle bundle) { diff --git a/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2Client.java b/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2Client.java index b71c8007..f0703fce 100644 --- a/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2Client.java +++ b/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2Client.java @@ -15,6 +15,7 @@ */ package dev.sigstore.rekor.v2.client; +import dev.sigstore.proto.rekor.v2.DSSERequestV002; import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002; import dev.sigstore.rekor.client.RekorEntry; import dev.sigstore.rekor.client.RekorParseException; @@ -30,4 +31,6 @@ public interface RekorV2Client { */ RekorEntry putEntry(HashedRekordRequestV002 hashedRekordRequest) throws IOException, RekorParseException; + + RekorEntry putEntry(DSSERequestV002 dsseRequestV002) throws IOException, RekorParseException; } diff --git a/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2ClientHttp.java b/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2ClientHttp.java index 6dddc4a5..091e4b00 100644 --- a/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2ClientHttp.java +++ b/sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2ClientHttp.java @@ -25,6 +25,7 @@ import dev.sigstore.http.HttpParams; import dev.sigstore.http.ImmutableHttpParams; import dev.sigstore.proto.rekor.v2.CreateEntryRequest; +import dev.sigstore.proto.rekor.v2.DSSERequestV002; import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002; import dev.sigstore.rekor.client.RekorEntry; import dev.sigstore.rekor.client.RekorParseException; @@ -108,4 +109,36 @@ public RekorEntry putEntry(HashedRekordRequestV002 hashedRekordRequest) return RekorEntry.fromTLogEntryJson(respEntryJson); } + + @Override + public RekorEntry putEntry(DSSERequestV002 dsseRequestV002) + throws IOException, RekorParseException { + URI rekorPutEndpoint = uri.resolve(REKOR_ENTRIES_PATH); + + String jsonPayload = + JsonFormat.printer() + .print(CreateEntryRequest.newBuilder().setDsseRequestV002(dsseRequestV002).build()); + + HttpRequest req = + HttpClients.newRequestFactory(httpParams) + .buildPostRequest( + new GenericUrl(rekorPutEndpoint), + ByteArrayContent.fromString("application/json", jsonPayload)); + req.getHeaders().set("Accept", "application/json"); + req.getHeaders().set("Content-Type", "application/json"); + + HttpResponse resp = req.execute(); + if (resp.getStatusCode() != 201) { + throw new IOException( + String.format( + Locale.ROOT, + "bad response from rekor @ '%s' : %s", + rekorPutEndpoint, + resp.parseAsString())); + } + + String respEntryJson = resp.parseAsString(); + + return RekorEntry.fromTLogEntryJson(respEntryJson); + } } diff --git a/sigstore-java/src/test/java/dev/sigstore/KeylessSignerTest.java b/sigstore-java/src/test/java/dev/sigstore/KeylessSignerTest.java index 5b98797f..881354ad 100644 --- a/sigstore-java/src/test/java/dev/sigstore/KeylessSignerTest.java +++ b/sigstore-java/src/test/java/dev/sigstore/KeylessSignerTest.java @@ -17,11 +17,14 @@ import com.google.common.hash.Hashing; import dev.sigstore.bundle.Bundle; +import dev.sigstore.oidc.client.OidcClients; import dev.sigstore.oidc.client.OidcTokenMatcher; +import dev.sigstore.oidc.client.TokenStringOidcClient; import dev.sigstore.strings.StringMatcher; import dev.sigstore.testing.matchers.ByteArrayListMatcher; import dev.sigstore.testkit.annotations.EnabledIfOidcExists; import dev.sigstore.testkit.annotations.OidcProviderType; +import dev.sigstore.testkit.oidc.ConformanceTestingTokenProvider; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -29,6 +32,7 @@ import java.util.HashMap; import java.util.List; import java.util.UUID; +import org.bouncycastle.util.encoders.Base64; import org.bouncycastle.util.encoders.Hex; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; @@ -87,6 +91,31 @@ public void sign_file() throws Exception { Assertions.assertEquals(signingResults.get(0), signer.signFile(artifacts.get(0))); } + @Test + public void sign_dssev2() throws Exception { + var signer = + KeylessSigner.builder() + .sigstoreStagingDefaults() + .forceCredentialProviders(OidcClients.of(TokenStringOidcClient.from(ConformanceTestingTokenProvider.newProvider()))) + .build(); + var bundle = + signer.attest( + new String( + Base64.decode( + "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiYS50eHQiLCJkaWdlc3QiOnsic2hhMjU2IjoiYTBjZmM3MTI3MWQ2ZTI3OGU1N2NkMzMyZmY5NTdjM2Y3MDQzZmRkYTM1NGM0Y2JiMTkwYTMwZDU2ZWZhMDFiZiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL2xvb3NlYmF6b29rYS9hYS10ZXN0IiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL3Byb3ZlbmFuY2UueWFtbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoid29ya2Zsb3dfZGlzcGF0Y2giLCJyZXBvc2l0b3J5X2lkIjoiODkxNzE1NDQ0IiwicmVwb3NpdG9yeV9vd25lcl9pZCI6IjEzMDQ4MjYiLCJydW5uZXJfZW52aXJvbm1lbnQiOiJnaXRodWItaG9zdGVkIn19LCJyZXNvbHZlZERlcGVuZGVuY2llcyI6W3sidXJpIjoiZ2l0K2h0dHBzOi8vZ2l0aHViLmNvbS9sb29zZWJhem9va2EvYWEtdGVzdEByZWZzL2hlYWRzL21haW4iLCJkaWdlc3QiOnsiZ2l0Q29tbWl0IjoiZWJmZjhkZmJkNjA5YjdiMjIyMzdjNzcxOWNlMDdmMmRjNzkzNGY1ZiJ9fV19LCJydW5EZXRhaWxzIjp7ImJ1aWxkZXIiOnsiaWQiOiJodHRwczovL2dpdGh1Yi5jb20vbG9vc2ViYXpvb2thL2FhLXRlc3QvLmdpdGh1Yi93b3JrZmxvd3MvcHJvdmVuYW5jZS55YW1sQHJlZnMvaGVhZHMvbWFpbiJ9LCJtZXRhZGF0YSI6eyJpbnZvY2F0aW9uSWQiOiJodHRwczovL2dpdGh1Yi5jb20vbG9vc2ViYXpvb2thL2FhLXRlc3QvYWN0aW9ucy9ydW5zLzExOTQxNDI1NDg3L2F0dGVtcHRzLzEifX19fQ=="), + StandardCharsets.UTF_8)); + var bundle2 = + signer.attest( + new String( + Base64.decode( + "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiYS50eHQiLCJkaWdlc3QiOnsic2hhMjU2IjoiYTBjZmM3MTI3MWQ2ZTI3OGU1N2NkMzMyZmY5NTdjM2Y3MDQzZmRkYTM1NGM0Y2JiMTkwYTMwZDU2ZWZhMDFiZiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL21vb3NlL2FhLXRlc3QiLCJwYXRoIjoiLmdpdGh1Yi93b3JrZmxvd3MvcHJvdmVuYW5jZS55YW1sIn19LCJpbnRlcm5hbFBhcmFtZXRlcnMiOnsiZ2l0aHViIjp7ImV2ZW50X25hbWUiOiJ3b3JrZmxvd19kaXNwYXRjaCIsInJlcG9zaXRvcnlfaWQiOiI4OTE3MTU0NDQiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiMTMwNDgyNiIsInJ1bm5lcl9lbnZpcm9ubWVudCI6ImdpdGh1Yi1ob3N0ZWQifX0sInJlc29sdmVkRGVwZW5kZW5jaWVzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL21vb3NlL2FhLXRlc3RAcmVmcy9oZWFkcy9tYWluIiwiZGlnZXN0Ijp7ImdpdENvbW1pdCI6ImViZmY4ZGZiZDYwOWI3YjIyMjM3Yzc3MTljZTA3ZjJkYzc5MzRmNWYifX1dfSwicnVuRGV0YWlscyI6eyJidWlsZGVyIjp7ImlkIjoiaHR0cHM6Ly9naXRodWIuY29tL21vb3NlL2FhLXRlc3QvLmdpdGh1Yi93b3JrZmxvd3MvcHJvdmVuYW5jZS55YW1sQHJlZnMvaGVhZHMvbWFpbiJ9LCJtZXRhZGF0YSI6eyJpbnZvY2F0aW9uSWQiOiJodHRwczovL2dpdGh1Yi5jb20vbW9vc2UvYWEtdGVzdC9hY3Rpb25zL3J1bnMvMTE5NDE0MjU0ODcvYXR0ZW1wdHMvMSJ9fX19Cg=="), + StandardCharsets.UTF_8)); + System.out.println("====================== bundle 1 ======================="); + System.out.println(bundle.toJson()); + System.out.println("====================== bundle 2 ======================="); + System.out.println(bundle2.toJson()); + } + @Test public void sign_files() throws Exception { var signingResultsMap = new HashMap(); diff --git a/sigstore-java/src/test/java/dev/sigstore/KeylessTest.java b/sigstore-java/src/test/java/dev/sigstore/KeylessTest.java index e38e37b0..3cc25bd6 100644 --- a/sigstore-java/src/test/java/dev/sigstore/KeylessTest.java +++ b/sigstore-java/src/test/java/dev/sigstore/KeylessTest.java @@ -78,8 +78,7 @@ public void sign_production() throws Exception { @EnabledIfOidcExists(provider = OidcProviderType.ANY) @DisabledIfSkipStaging public void sign_staging(boolean enableRekorV2) throws Exception { - var signer = - KeylessSigner.builder().sigstoreStagingDefaults().enableRekorV2(enableRekorV2).build(); + var signer = KeylessSigner.builder().sigstoreStagingDefaults().build(); var results = signer.sign(artifactDigests); verifySigningResult(results); diff --git a/sigstore-testkit/src/main/java/dev/sigstore/testkit/oidc/ConformanceTestingTokenProvider.java b/sigstore-testkit/src/main/java/dev/sigstore/testkit/oidc/ConformanceTestingTokenProvider.java new file mode 100644 index 00000000..0837f1e9 --- /dev/null +++ b/sigstore-testkit/src/main/java/dev/sigstore/testkit/oidc/ConformanceTestingTokenProvider.java @@ -0,0 +1,34 @@ +package dev.sigstore.testkit.oidc; + +import dev.sigstore.oidc.client.TokenStringOidcClient; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +public class ConformanceTestingTokenProvider implements TokenStringOidcClient.TokenStringProvider { + + public static ConformanceTestingTokenProvider newProvider() { + return new ConformanceTestingTokenProvider(); + }; + + @Override + public String getTokenString() throws Exception { + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + URI fileUri = new URI("https://raw.githubusercontent.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/refs/heads/current-token/oidc-token.txt"); + HttpRequest request = HttpRequest.newBuilder() + .uri(fileUri) + .GET() // Specifies a GET request + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new IOException("Failed to read remote test oidc token"); + } + var body = response.body(); + return body; + } +}