-
Notifications
You must be signed in to change notification settings - Fork 25
Add support for creating and verifying DSSE attestations #1084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,11 +22,15 @@ | |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; | ||
| import com.google.errorprone.annotations.CheckReturnValue; | ||
| import com.google.errorprone.annotations.concurrent.GuardedBy; | ||
| import com.google.gson.JsonSyntaxException; | ||
| import com.google.protobuf.ByteString; | ||
| import dev.sigstore.bundle.Bundle; | ||
| import dev.sigstore.bundle.Bundle.MessageSignature; | ||
| import dev.sigstore.bundle.ImmutableBundle; | ||
| import dev.sigstore.bundle.ImmutableDsseEnvelope; | ||
| import dev.sigstore.bundle.ImmutableSignature; | ||
| import dev.sigstore.bundle.ImmutableTimestamp; | ||
| import dev.sigstore.dsse.InTotoPayload; | ||
| import dev.sigstore.encryption.certificates.Certificates; | ||
| import dev.sigstore.encryption.signers.Signer; | ||
| import dev.sigstore.encryption.signers.Signers; | ||
|
|
@@ -42,6 +46,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 +70,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; | ||
|
|
@@ -102,6 +108,8 @@ public class KeylessSigner implements AutoCloseable { | |
| */ | ||
| public static final Duration DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME = Duration.ofMinutes(5); | ||
|
|
||
| public static final String DEFAULT_INTOTO_PAYLOAD_TYPE = "https://in-toto.io/Statement/v1"; | ||
|
|
||
| private final FulcioClient fulcioClient; | ||
| private final FulcioVerifier fulcioVerifier; | ||
| private final RekorClient rekorClient; | ||
|
|
@@ -671,4 +679,163 @@ public Map<Path, Bundle> signFiles(List<Path> artifacts) throws KeylessSignerExc | |
| public Bundle signFile(Path artifact) throws KeylessSignerException { | ||
| return signFiles(List.of(artifact)).get(artifact); | ||
| } | ||
|
|
||
| public Bundle attest(String payload) throws KeylessSignerException { | ||
| 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"); | ||
| } else { | ||
| throw new IllegalStateException("No rekor v2 client was configured."); | ||
| } | ||
|
|
||
| if (payload == null || payload.isEmpty()) { | ||
| throw new IllegalArgumentException("Payload must be non-empty"); | ||
| } | ||
|
|
||
| InTotoPayload inTotoPayload; | ||
| try { | ||
| inTotoPayload = InTotoPayload.from(payload); | ||
| } catch (JsonSyntaxException jse) { | ||
| throw new IllegalArgumentException("Payload is not a valid in-toto statement"); | ||
| } | ||
|
|
||
| if (!inTotoPayload.getType().equals(DEFAULT_INTOTO_PAYLOAD_TYPE)) { | ||
| throw new IllegalArgumentException( | ||
| "Payload must be of type \"" | ||
| + DEFAULT_INTOTO_PAYLOAD_TYPE | ||
| + "\" but was \"" | ||
| + inTotoPayload.getType() | ||
| + "\""); | ||
| } | ||
|
|
||
| if (inTotoPayload.getSubject() == null || inTotoPayload.getSubject().isEmpty()) { | ||
| throw new IllegalArgumentException("Payload must contain at least one subject"); | ||
| } | ||
|
|
||
| for (var subject : inTotoPayload.getSubject()) { | ||
| if (subject.getName() != null && !subject.getName().isEmpty()) { | ||
| continue; | ||
| } | ||
| throw new IllegalArgumentException("Payload must contain at least one non-empty subject"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe, this error message could be more descriptive? Payload must contain valid subjects? |
||
| } | ||
|
|
||
| // 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[] encodedCert; | ||
| lock.readLock().lock(); | ||
| try { | ||
| signingCert = this.signingCert; | ||
| encodedCert = this.encodedCert; | ||
| if (signingCert == null) { | ||
| throw new IllegalStateException("Signing certificate is null"); | ||
| } | ||
| } finally { | ||
| lock.readLock().unlock(); | ||
| } | ||
|
|
||
| var bundleBuilder = ImmutableBundle.builder().certPath(signingCert); | ||
|
|
||
| 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 | SignatureException | InvalidKeyException ex) { | ||
| throw new KeylessSignerException("Failed to sign artifact", ex); | ||
| } | ||
|
|
||
| var verifier = | ||
| Verifier.newBuilder() | ||
| .setX509Certificate( | ||
| X509Certificate.newBuilder().setRawBytes(ByteString.copyFrom(encodedCert)).build()) | ||
| .setKeyDetails(ProtoMutators.toPublicKeyDetails(signingAlgorithm)) | ||
| .build(); | ||
|
|
||
| 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(); | ||
|
|
||
| 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); | ||
|
|
||
| return bundleBuilder.build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.security.cert.CertPath; | ||
| import java.util.Arrays; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import org.immutables.gson.Gson; | ||
|
|
@@ -149,6 +150,7 @@ public interface DsseEnvelope { | |
| * content. | ||
| */ | ||
| @Gson.Ignore | ||
| @Value.Auxiliary | ||
| @Derived | ||
| default byte[] getPAE() { | ||
| return ("DSSEv1 " | ||
|
|
@@ -162,6 +164,21 @@ default byte[] getPAE() { | |
| .getBytes(StandardCharsets.UTF_8); | ||
| } | ||
|
|
||
| @Override | ||
| int hashCode(); | ||
|
|
||
| @Override | ||
| boolean equals(Object obj); | ||
|
|
||
| @Value.Check | ||
| default void check() { | ||
| // This is a workaround for immutables not using Arrays.equals for derived byte[] | ||
| // see: https://github.com/immutables/immutables/issues/1610 | ||
| if (!Arrays.equals(getPAE(), getPAE())) { | ||
| throw new IllegalStateException("Should be unreachable"); | ||
| } | ||
| } | ||
|
Comment on lines
+167
to
+180
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is any of this extra stuff necessary, seems like |
||
|
|
||
| @Lazy | ||
| @Gson.Ignore | ||
| default String getPayloadAsString() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Attest seems to be pretty similar to sign, can we refactor out some of the common parts?
It looks like it might a little complicated, but I'd likk to see what it could look like?