Skip to content

Commit e2be1c9

Browse files
committed
Add support for creating and verifying DSSE attestations
Signed-off-by: Aaron Lew <[email protected]>
1 parent bb05993 commit e2be1c9

File tree

8 files changed

+341
-25
lines changed

8 files changed

+341
-25
lines changed

sigstore-java/src/main/java/dev/sigstore/KeylessSigner.java

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@
2222
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2323
import com.google.errorprone.annotations.CheckReturnValue;
2424
import com.google.errorprone.annotations.concurrent.GuardedBy;
25+
import com.google.gson.JsonSyntaxException;
2526
import com.google.protobuf.ByteString;
2627
import dev.sigstore.bundle.Bundle;
2728
import dev.sigstore.bundle.Bundle.MessageSignature;
2829
import dev.sigstore.bundle.ImmutableBundle;
30+
import dev.sigstore.bundle.ImmutableDsseEnvelope;
31+
import dev.sigstore.bundle.ImmutableSignature;
2932
import dev.sigstore.bundle.ImmutableTimestamp;
33+
import dev.sigstore.dsse.InTotoPayload;
3034
import dev.sigstore.encryption.certificates.Certificates;
3135
import dev.sigstore.encryption.signers.Signer;
3236
import dev.sigstore.encryption.signers.Signers;
@@ -42,6 +46,7 @@
4246
import dev.sigstore.oidc.client.OidcTokenMatcher;
4347
import dev.sigstore.proto.ProtoMutators;
4448
import dev.sigstore.proto.common.v1.X509Certificate;
49+
import dev.sigstore.proto.rekor.v2.DSSERequestV002;
4550
import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002;
4651
import dev.sigstore.proto.rekor.v2.Signature;
4752
import dev.sigstore.proto.rekor.v2.Verifier;
@@ -65,6 +70,7 @@
6570
import dev.sigstore.trustroot.Service;
6671
import dev.sigstore.trustroot.SigstoreConfigurationException;
6772
import dev.sigstore.tuf.SigstoreTufClient;
73+
import io.intoto.EnvelopeOuterClass;
6874
import java.io.IOException;
6975
import java.nio.charset.StandardCharsets;
7076
import java.nio.file.Path;
@@ -102,6 +108,8 @@ public class KeylessSigner implements AutoCloseable {
102108
*/
103109
public static final Duration DEFAULT_MIN_SIGNING_CERTIFICATE_LIFETIME = Duration.ofMinutes(5);
104110

111+
public static final String DEFAULT_INTOTO_PAYLOAD_TYPE = "https://in-toto.io/Statement/v1";
112+
105113
private final FulcioClient fulcioClient;
106114
private final FulcioVerifier fulcioVerifier;
107115
private final RekorClient rekorClient;
@@ -671,4 +679,163 @@ public Map<Path, Bundle> signFiles(List<Path> artifacts) throws KeylessSignerExc
671679
public Bundle signFile(Path artifact) throws KeylessSignerException {
672680
return signFiles(List.of(artifact)).get(artifact);
673681
}
682+
683+
public Bundle attest(String payload) throws KeylessSignerException {
684+
if (rekorV2Client != null) { // Using Rekor v2 and a TSA
685+
Preconditions.checkNotNull(
686+
timestampClient, "Timestamp client must be configured for Rekor v2");
687+
Preconditions.checkNotNull(
688+
timestampVerifier, "Timestamp verifier must be configured for Rekor v2");
689+
} else {
690+
throw new IllegalStateException("No rekor v2 client was configured.");
691+
}
692+
693+
if (payload == null || payload.isEmpty()) {
694+
throw new IllegalArgumentException("Payload must be non-empty");
695+
}
696+
697+
InTotoPayload inTotoPayload;
698+
try {
699+
inTotoPayload = InTotoPayload.from(payload);
700+
} catch (JsonSyntaxException jse) {
701+
throw new IllegalArgumentException("Payload is not a valid in-toto statement");
702+
}
703+
704+
if (!inTotoPayload.getType().equals(DEFAULT_INTOTO_PAYLOAD_TYPE)) {
705+
throw new IllegalArgumentException(
706+
"Payload must be of type \""
707+
+ DEFAULT_INTOTO_PAYLOAD_TYPE
708+
+ "\" but was \""
709+
+ inTotoPayload.getType()
710+
+ "\"");
711+
}
712+
713+
if (inTotoPayload.getSubject() == null || inTotoPayload.getSubject().isEmpty()) {
714+
throw new IllegalArgumentException("Payload must contain at least one subject");
715+
}
716+
717+
for (var subject : inTotoPayload.getSubject()) {
718+
if (subject.getName() != null && !subject.getName().isEmpty()) {
719+
continue;
720+
}
721+
throw new IllegalArgumentException("Payload must contain at least one non-empty subject");
722+
}
723+
724+
// Technically speaking, it is unlikely the certificate will expire between signing artifacts
725+
// However, files might be large, and it might take time to talk to Rekor
726+
// so we check the certificate expiration here.
727+
try {
728+
renewSigningCertificate();
729+
} catch (FulcioVerificationException
730+
| UnsupportedAlgorithmException
731+
| OidcException
732+
| IOException
733+
| InterruptedException
734+
| InvalidKeyException
735+
| NoSuchAlgorithmException
736+
| SignatureException
737+
| CertificateException ex) {
738+
throw new KeylessSignerException("Failed to obtain signing certificate", ex);
739+
}
740+
741+
CertPath signingCert;
742+
byte[] encodedCert;
743+
lock.readLock().lock();
744+
try {
745+
signingCert = this.signingCert;
746+
encodedCert = this.encodedCert;
747+
if (signingCert == null) {
748+
throw new IllegalStateException("Signing certificate is null");
749+
}
750+
} finally {
751+
lock.readLock().unlock();
752+
}
753+
754+
var bundleBuilder = ImmutableBundle.builder().certPath(signingCert);
755+
756+
var dsse =
757+
ImmutableDsseEnvelope.builder()
758+
.payload(payload.getBytes(StandardCharsets.UTF_8))
759+
.payloadType("application/vnd.in-toto+json")
760+
.build();
761+
762+
var pae = dsse.getPAE();
763+
764+
Bundle.DsseEnvelope dsseSigned;
765+
try {
766+
var sig = signer.sign(pae);
767+
dsseSigned =
768+
ImmutableDsseEnvelope.builder()
769+
.from(dsse)
770+
.addSignatures(ImmutableSignature.builder().sig(sig).build())
771+
.build();
772+
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException ex) {
773+
throw new KeylessSignerException("Failed to sign artifact", ex);
774+
}
775+
776+
var verifier =
777+
Verifier.newBuilder()
778+
.setX509Certificate(
779+
X509Certificate.newBuilder().setRawBytes(ByteString.copyFrom(encodedCert)).build())
780+
.setKeyDetails(ProtoMutators.toPublicKeyDetails(signingAlgorithm))
781+
.build();
782+
783+
var dsseRequest =
784+
DSSERequestV002.newBuilder()
785+
.setEnvelope(
786+
EnvelopeOuterClass.Envelope.newBuilder()
787+
.setPayload(ByteString.copyFrom(dsseSigned.getPayload()))
788+
.setPayloadType(dsseSigned.getPayloadType())
789+
.addSignatures(
790+
EnvelopeOuterClass.Signature.newBuilder()
791+
.setSig(ByteString.copyFrom(dsseSigned.getSignature())))
792+
.build())
793+
.addVerifiers(verifier)
794+
.build();
795+
796+
var signatureDigest = Hashing.sha256().hashBytes(dsseSigned.getSignature()).asBytes();
797+
798+
var tsReq =
799+
ImmutableTimestampRequest.builder()
800+
.hashAlgorithm(dev.sigstore.timestamp.client.HashAlgorithm.SHA256)
801+
.hash(signatureDigest)
802+
.build();
803+
804+
TimestampResponse tsResp;
805+
try {
806+
tsResp = timestampClient.timestamp(tsReq);
807+
} catch (TimestampException ex) {
808+
throw new KeylessSignerException("Failed to generate timestamp", ex);
809+
}
810+
811+
try {
812+
timestampVerifier.verify(tsResp, dsseSigned.getSignature());
813+
} catch (TimestampVerificationException ex) {
814+
throw new KeylessSignerException("Returned timestamp was invalid", ex);
815+
}
816+
817+
Bundle.Timestamp timestamp =
818+
ImmutableTimestamp.builder().rfc3161Timestamp(tsResp.getEncoded()).build();
819+
820+
bundleBuilder.addTimestamps(timestamp);
821+
822+
RekorEntry entry;
823+
try {
824+
entry = rekorV2Client.putEntry(dsseRequest);
825+
} catch (IOException | RekorParseException ex) {
826+
throw new KeylessSignerException("Failed to put entry in rekor", ex);
827+
}
828+
829+
try {
830+
rekorVerifier.verifyEntry(entry);
831+
} catch (RekorVerificationException ex) {
832+
throw new KeylessSignerException("Failed to validate rekor entry after signing", ex);
833+
}
834+
835+
bundleBuilder.dsseEnvelope(dsseSigned);
836+
837+
bundleBuilder.addEntries(entry);
838+
839+
return bundleBuilder.build();
840+
}
674841
}

sigstore-java/src/main/java/dev/sigstore/bundle/BundleWriter.java

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import dev.sigstore.proto.rekor.v1.KindVersion;
3434
import dev.sigstore.proto.rekor.v1.TransparencyLogEntry;
3535
import dev.sigstore.rekor.client.RekorEntry;
36+
import io.intoto.EnvelopeOuterClass.Envelope;
3637
import java.security.cert.CertificateEncodingException;
3738
import java.util.Base64;
3839
import java.util.List;
@@ -78,28 +79,49 @@ static String writeBundle(Bundle signingResult) {
7879
* @return Sigstore Bundle in protobuf builder format
7980
*/
8081
static dev.sigstore.proto.bundle.v1.Bundle.Builder createBundleBuilder(Bundle bundle) {
81-
if (bundle.getMessageSignature().isEmpty()) {
82-
throw new IllegalStateException("can only serialize bundles with message signatures");
82+
if (bundle.getMessageSignature().isEmpty() && bundle.getDsseEnvelope().isEmpty()) {
83+
throw new IllegalStateException("Either message signature or DSSE envelope must be present");
8384
}
84-
var messageSignature = bundle.getMessageSignature().get();
85-
if (messageSignature.getMessageDigest().isEmpty()) {
85+
if (bundle.getMessageSignature().isPresent() && bundle.getDsseEnvelope().isPresent()) {
86+
throw new IllegalStateException(
87+
"Only one of message signature or DSSE envelope must be present");
88+
}
89+
if (bundle.getMessageSignature().isPresent()
90+
&& bundle.getMessageSignature().get().getMessageDigest().isEmpty()) {
8691
throw new IllegalStateException(
8792
"keyless signature must have artifact digest when serializing to bundle");
8893
}
89-
return dev.sigstore.proto.bundle.v1.Bundle.newBuilder()
90-
.setMediaType(bundle.getMediaType())
91-
.setVerificationMaterial(buildVerificationMaterial(bundle))
92-
.setMessageSignature(
93-
MessageSignature.newBuilder()
94-
.setMessageDigest(
95-
HashOutput.newBuilder()
96-
.setAlgorithm(
97-
ProtoMutators.toProtoHashAlgorithm(
98-
messageSignature.getMessageDigest().get().getHashAlgorithm()))
99-
.setDigest(
100-
ByteString.copyFrom(
101-
messageSignature.getMessageDigest().get().getDigest())))
102-
.setSignature(ByteString.copyFrom(messageSignature.getSignature())));
94+
dev.sigstore.proto.bundle.v1.Bundle.Builder builder =
95+
dev.sigstore.proto.bundle.v1.Bundle.newBuilder()
96+
.setMediaType(bundle.getMediaType())
97+
.setVerificationMaterial(buildVerificationMaterial(bundle));
98+
if (bundle.getMessageSignature().isPresent()) {
99+
var messageSignature = bundle.getMessageSignature().get();
100+
builder.setMessageSignature(
101+
MessageSignature.newBuilder()
102+
.setMessageDigest(
103+
HashOutput.newBuilder()
104+
.setAlgorithm(
105+
ProtoMutators.toProtoHashAlgorithm(
106+
messageSignature.getMessageDigest().get().getHashAlgorithm()))
107+
.setDigest(
108+
ByteString.copyFrom(
109+
messageSignature.getMessageDigest().get().getDigest())))
110+
.setSignature(ByteString.copyFrom(messageSignature.getSignature())));
111+
}
112+
if (bundle.getDsseEnvelope().isPresent()) {
113+
var dsseEnvelope = bundle.getDsseEnvelope().get();
114+
builder.setDsseEnvelope(
115+
Envelope.newBuilder()
116+
.setPayload(ByteString.copyFrom(dsseEnvelope.getPayload()))
117+
.setPayloadType(dsseEnvelope.getPayloadType())
118+
.addSignatures(
119+
io.intoto.EnvelopeOuterClass.Signature.newBuilder()
120+
.setSig(ByteString.copyFrom(dsseEnvelope.getSignature()))
121+
.build())
122+
.build());
123+
}
124+
return builder;
103125
}
104126

105127
private static VerificationMaterial.Builder buildVerificationMaterial(Bundle bundle) {

sigstore-java/src/main/java/dev/sigstore/dsse/InTotoPayload.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ interface Subject {
5151
Map<String, String> getDigest();
5252
}
5353

54+
static InTotoPayload from(String payload) {
55+
return GSON.get().fromJson(payload, InTotoPayload.class);
56+
}
57+
5458
static InTotoPayload from(DsseEnvelope dsseEnvelope) {
55-
return GSON.get().fromJson(dsseEnvelope.getPayloadAsString(), InTotoPayload.class);
59+
return from(dsseEnvelope.getPayloadAsString());
5660
}
5761
}

sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2Client.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package dev.sigstore.rekor.v2.client;
1717

18+
import dev.sigstore.proto.rekor.v2.DSSERequestV002;
1819
import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002;
1920
import dev.sigstore.rekor.client.RekorEntry;
2021
import dev.sigstore.rekor.client.RekorParseException;
@@ -30,4 +31,12 @@ public interface RekorV2Client {
3031
*/
3132
RekorEntry putEntry(HashedRekordRequestV002 hashedRekordRequest)
3233
throws IOException, RekorParseException;
34+
35+
/**
36+
* Put a new dsse entry on the Rekor log.
37+
*
38+
* @param dsseRequest the request to send to rekor
39+
* @return a {@link RekorEntry} with information about the log entry
40+
*/
41+
RekorEntry putEntry(DSSERequestV002 dsseRequest) throws IOException, RekorParseException;
3342
}

sigstore-java/src/main/java/dev/sigstore/rekor/v2/client/RekorV2ClientHttp.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import dev.sigstore.http.HttpParams;
2626
import dev.sigstore.http.ImmutableHttpParams;
2727
import dev.sigstore.proto.rekor.v2.CreateEntryRequest;
28+
import dev.sigstore.proto.rekor.v2.DSSERequestV002;
2829
import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002;
2930
import dev.sigstore.rekor.client.RekorEntry;
3031
import dev.sigstore.rekor.client.RekorParseException;
@@ -77,20 +78,26 @@ public RekorV2ClientHttp build() {
7778
@Override
7879
public RekorEntry putEntry(HashedRekordRequestV002 hashedRekordRequest)
7980
throws IOException, RekorParseException {
81+
return putEntry(
82+
CreateEntryRequest.newBuilder().setHashedRekordRequestV002(hashedRekordRequest).build());
83+
}
84+
85+
@Override
86+
public RekorEntry putEntry(DSSERequestV002 dsseRequest) throws IOException, RekorParseException {
87+
return putEntry(CreateEntryRequest.newBuilder().setDsseRequestV002(dsseRequest).build());
88+
}
89+
90+
private RekorEntry putEntry(CreateEntryRequest request) throws IOException, RekorParseException {
8091
URI rekorPutEndpoint = uri.resolve(REKOR_ENTRIES_PATH);
8192

82-
String jsonPayload =
83-
JsonFormat.printer()
84-
.print(
85-
CreateEntryRequest.newBuilder()
86-
.setHashedRekordRequestV002(hashedRekordRequest)
87-
.build());
93+
String jsonPayload = JsonFormat.printer().print(request);
8894

8995
HttpRequest req =
9096
HttpClients.newRequestFactory(httpParams)
9197
.buildPostRequest(
9298
new GenericUrl(rekorPutEndpoint),
9399
ByteArrayContent.fromString("application/json", jsonPayload));
100+
94101
req.getHeaders().set("Accept", "application/json");
95102
req.getHeaders().set("Content-Type", "application/json");
96103

0 commit comments

Comments
 (0)