From d00ebd746377c348e647a91a6bf9f4dfbe78b8c9 Mon Sep 17 00:00:00 2001 From: Appu Goundan Date: Tue, 17 May 2022 13:21:44 -0400 Subject: [PATCH] Add rekor client method to upload a record - Also adds a cert generator to bypass fulcio/dex in testing Signed-off-by: Appu Goundan --- build.gradle.kts | 4 +- .../sigstore/encryption/signers/Signer.java | 5 + .../rekor/client/HashedRekordRequest.java | 84 ++++++++++++++ .../sigstore/rekor/client/RekorClient.java | 31 ++++- .../sigstore/rekor/client/RekorResponse.java | 30 +++++ .../{hashrekord.json => hashedrekord.json} | 0 .../rekor/client/RekorClientTest.java | 67 +++++++++++ .../dev/sigstore/testing/CertGenerator.java | 108 ++++++++++++++++++ 8 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/sigstore/rekor/client/HashedRekordRequest.java create mode 100644 src/main/java/dev/sigstore/rekor/client/RekorResponse.java rename src/main/resources/rekor/model/{hashrekord.json => hashedrekord.json} (100%) create mode 100644 src/test/java/dev/sigstore/rekor/client/RekorClientTest.java create mode 100644 src/test/java/dev/sigstore/testing/CertGenerator.java diff --git a/build.gradle.kts b/build.gradle.kts index 12edbc33d..e9a52385c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { because("contains library code for all platforms") } implementation("org.bouncycastle:bcutil-jdk18on:1.71") + implementation("org.bouncycastle:bcpkix-jdk18on:1.71") implementation(platform("com.google.oauth-client:google-oauth-client-bom:1.33.3")) implementation("com.google.oauth-client:google-oauth-client") @@ -52,7 +53,6 @@ dependencies { testImplementation("no.nav.security:mock-oauth2-server:0.4.4") testImplementation("com.squareup.okhttp3:mockwebserver:4.9.3") - testImplementation("org.bouncycastle:bcutil-jdk15on:1.70") testImplementation("net.sourceforge.htmlunit:htmlunit:2.61.0") implementation("javax.validation:validation-api:2.0.1.Final") @@ -84,4 +84,6 @@ jsonSchema2Pojo { source.setFrom(files("${sourceSets.main.get().output.resourcesDir}/rekor/model")) targetDirectoryPrefix.set(file("${project.buildDir}/generated/sources/rekor-model/")) targetPackage.set("dev.sigstore.rekor") + generateBuilders.set(true) + annotationStyle.set("gson") } diff --git a/src/main/java/dev/sigstore/encryption/signers/Signer.java b/src/main/java/dev/sigstore/encryption/signers/Signer.java index 8b6972277..c11d9a8e8 100644 --- a/src/main/java/dev/sigstore/encryption/signers/Signer.java +++ b/src/main/java/dev/sigstore/encryption/signers/Signer.java @@ -30,6 +30,9 @@ public interface Signer { /** * Sign the content. Implementations should use an algorithm that hashes with sha256 before * signing. + * + * @param content the full content to be signed (not a digest) + * @param charset the charset of the string {@code content} */ byte[] sign(String content, Charset charset) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException; @@ -37,6 +40,8 @@ byte[] sign(String content, Charset charset) /** * Sign the content. Implementations should use an algorithm that hashes with sha256 before * signing. + * + * @param content the full content to be signed (not a digest) */ byte[] sign(byte[] content) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException; diff --git a/src/main/java/dev/sigstore/rekor/client/HashedRekordRequest.java b/src/main/java/dev/sigstore/rekor/client/HashedRekordRequest.java new file mode 100644 index 000000000..bcf358924 --- /dev/null +++ b/src/main/java/dev/sigstore/rekor/client/HashedRekordRequest.java @@ -0,0 +1,84 @@ +/* + * 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.rekor.client; + +import dev.sigstore.json.GsonSupplier; +import dev.sigstore.rekor.*; +import dev.sigstore.rekor.PublicKey; +import dev.sigstore.rekor.Signature; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.cert.Certificate; +import java.util.Base64; +import java.util.HashMap; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.util.encoders.Hex; + +public class HashedRekordRequest { + + private final Hashedrekord hashedrekord; + + private HashedRekordRequest(Hashedrekord hashedrekord) { + this.hashedrekord = hashedrekord; + } + + /** + * Create a new HashedRekorRequest. + * + * @param artifactDigest the sha256 digest of the artifact (not hex/base64 encoded) + * @param leafCert the leaf certificate used to verify {@code signature}, usually obtained from + * fulcio + * @param signature the signature over the {@code artifactDigest} (not hex/base64 encoded) + */ + public static HashedRekordRequest newHashedRekordRequest( + byte[] artifactDigest, Certificate leafCert, byte[] signature) throws IOException { + + System.out.println(Base64.getEncoder().encodeToString(leafCert.getPublicKey().getEncoded())); + + var certWriter = new StringWriter(); + try (JcaPEMWriter pemWriter = new JcaPEMWriter(certWriter)) { + pemWriter.writeObject(leafCert); + pemWriter.flush(); + } + + var certPem = certWriter.toString().getBytes(StandardCharsets.UTF_8); + var hashedrekord = + new Hashedrekord() + .withData( + new Data() + .withHash( + new Hash() + .withValue(new String(Hex.encode(artifactDigest))) + .withAlgorithm(Hash.Algorithm.SHA_256))) + .withSignature( + new Signature() + .withContent(Base64.getEncoder().encodeToString(signature)) + .withPublicKey( + new PublicKey().withContent(Base64.getEncoder().encodeToString(certPem)))); + return new HashedRekordRequest(hashedrekord); + } + + public String toJsonPayload() { + var data = new HashMap(); + data.put("kind", "hashedrekord"); + data.put("apiVersion", "0.0.1"); + data.put("spec", hashedrekord); + + return new GsonSupplier().get().toJson(data); + } +} diff --git a/src/main/java/dev/sigstore/rekor/client/RekorClient.java b/src/main/java/dev/sigstore/rekor/client/RekorClient.java index afebf643e..a53994353 100644 --- a/src/main/java/dev/sigstore/rekor/client/RekorClient.java +++ b/src/main/java/dev/sigstore/rekor/client/RekorClient.java @@ -15,12 +15,18 @@ */ package dev.sigstore.rekor.client; +import com.google.api.client.http.ByteArrayContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; import dev.sigstore.http.HttpProvider; +import java.io.IOException; import java.net.URI; /** A client to communicate with a rekor service instance. */ public class RekorClient { public static final String PUBLIC_REKOR_SERVER = "https://rekor.sigstore.dev"; + public static final String REKOR_ENTRIES_PATH = "/api/v1/log/entries"; private final HttpProvider httpProvider; private final URI serverUrl; @@ -59,8 +65,29 @@ public RekorClient build() { } /** Put an entry on rekor. */ - public void putEntry() { - throw new UnsupportedOperationException("I'm a worthless upload function"); + public RekorResponse putEntry(HashedRekordRequest hashedRekordRequest) throws IOException { + URI rekorEndpoint = serverUrl.resolve(REKOR_ENTRIES_PATH); + + HttpRequest req = + httpProvider + .getHttpTransport() + .createRequestFactory() + .buildPostRequest( + new GenericUrl(rekorEndpoint), + ByteArrayContent.fromString( + "application/json", hashedRekordRequest.toJsonPayload())); + 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( + "bad response from rekor @ '%s' : %s", rekorEndpoint, resp.parseAsString())); + } + + URI rekorEntryUri = serverUrl.resolve(resp.getHeaders().getLocation()); + return new RekorResponse(rekorEntryUri); } /** Obtain an entry for an artifact from rekor. */ diff --git a/src/main/java/dev/sigstore/rekor/client/RekorResponse.java b/src/main/java/dev/sigstore/rekor/client/RekorResponse.java new file mode 100644 index 000000000..4614c5e4d --- /dev/null +++ b/src/main/java/dev/sigstore/rekor/client/RekorResponse.java @@ -0,0 +1,30 @@ +/* + * 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.rekor.client; + +import java.net.URI; + +public class RekorResponse { + private final URI entryLocation; + + public RekorResponse(URI entryLocation) { + this.entryLocation = entryLocation; + } + + public URI getEntryLocation() { + return entryLocation; + } +} diff --git a/src/main/resources/rekor/model/hashrekord.json b/src/main/resources/rekor/model/hashedrekord.json similarity index 100% rename from src/main/resources/rekor/model/hashrekord.json rename to src/main/resources/rekor/model/hashedrekord.json diff --git a/src/test/java/dev/sigstore/rekor/client/RekorClientTest.java b/src/test/java/dev/sigstore/rekor/client/RekorClientTest.java new file mode 100644 index 000000000..0587df61a --- /dev/null +++ b/src/test/java/dev/sigstore/rekor/client/RekorClientTest.java @@ -0,0 +1,67 @@ +/* + * 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.rekor.client; + +import dev.sigstore.encryption.signers.Signers; +import dev.sigstore.testing.CertGenerator; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import org.bouncycastle.operator.OperatorCreationException; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.Test; + +public class RekorClientTest { + + @Test + public void putEntry_toStaging() + throws CertificateException, IOException, NoSuchAlgorithmException, OperatorCreationException, + SignatureException, InvalidKeyException, URISyntaxException { + // the data we want to sign + var data = "some data"; + + // get the digest + var artifactDigest = + MessageDigest.getInstance("SHA-256").digest(data.getBytes(StandardCharsets.UTF_8)); + + // sign the full content (these signers do the artifact hashing themselves) + var signer = Signers.newEcdsaSigner(); + var signature = signer.sign(data.getBytes(StandardCharsets.UTF_8)); + + // create a fake signing cert (not fulcio/dex) + var cert = CertGenerator.newCert(signer.getPublicKey()); + + var req = HashedRekordRequest.newHashedRekordRequest(artifactDigest, cert, signature); + + // this tests directly against rekor in staging, it's a bit hard to bring up a rekor instance + // without + // docker compose. + var client = RekorClient.builder().setServerUrl(new URI("https://rekor.sigstage.dev")).build(); + var resp = client.putEntry(req); + + // pretty basic testing + MatcherAssert.assertThat( + resp.getEntryLocation().toString(), + CoreMatchers.startsWith("https://rekor.sigstage.dev/api/v1/log/entries/")); + } +} diff --git a/src/test/java/dev/sigstore/testing/CertGenerator.java b/src/test/java/dev/sigstore/testing/CertGenerator.java new file mode 100644 index 000000000..414602d6f --- /dev/null +++ b/src/test/java/dev/sigstore/testing/CertGenerator.java @@ -0,0 +1,108 @@ +/* + * 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.testing; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.util.Base64; +import java.util.Calendar; +import java.util.Date; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * A certificate generator, useful when trying to talk to rekor without actually using fulcio/oidc. + */ +public class CertGenerator { + public static Certificate newCert(PublicKey publicKey) + throws OperatorCreationException, CertificateException, IOException, + NoSuchAlgorithmException { + SecureRandom random = new SecureRandom(); + + System.out.println(Base64.getEncoder().encodeToString(publicKey.getEncoded())); + + KeyPairGenerator keypairGen = KeyPairGenerator.getInstance("EC"); + keypairGen.initialize(256); + KeyPair certSigningKeyPair = keypairGen.generateKeyPair(); + + X500Name subject = + new X500NameBuilder(BCStyle.INSTANCE) + .addRDN(BCStyle.CN, "test") + .addRDN(BCStyle.O, "test certificate") + .build(); + + Date startDate = new Date(System.currentTimeMillis()); + var calendar = Calendar.getInstance(); + calendar.setTime(startDate); + calendar.add(Calendar.MINUTE, 20); + var endDate = calendar.getTime(); + + BigInteger serial = new BigInteger(160, random); + + X509v3CertificateBuilder certificate = + new JcaX509v3CertificateBuilder(subject, serial, startDate, endDate, subject, publicKey); + + var id = new byte[20]; + random.nextBytes(id); + + var keyIdgen = new JcaX509ExtensionUtils(); + certificate.addExtension( + Extension.subjectKeyIdentifier, false, keyIdgen.createSubjectKeyIdentifier(publicKey)); + certificate.addExtension( + Extension.authorityKeyIdentifier, + false, + keyIdgen.createAuthorityKeyIdentifier(certSigningKeyPair.getPublic())); // this is nonsense + certificate.addExtension( + Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()); + certificate.addExtension( + Extension.extendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning).getEncoded()); + certificate.addExtension( + Extension.basicConstraints, true, new BasicConstraints(false).getEncoded()); + + ContentSigner signer = + new JcaContentSignerBuilder("SHA256withECDSA").build(certSigningKeyPair.getPrivate()); + X509CertificateHolder holder = certificate.build(signer); + + JcaX509CertificateConverter converter = new JcaX509CertificateConverter(); + converter.setProvider(new BouncyCastleProvider()); + + return converter.getCertificate(holder); + } +}