Skip to content

Commit 6f3643c

Browse files
committed
Add Rekor v2 client and verifier
Signed-off-by: Aaron Lew <[email protected]>
1 parent ba45087 commit 6f3643c

File tree

8 files changed

+713
-2
lines changed

8 files changed

+713
-2
lines changed

sigstore-java/src/main/java/dev/sigstore/rekor/client/Checkpoints.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
* Checkpoint helper class to parse from a string in the format described in
3030
* https://github.com/transparency-dev/formats/blob/12bf59947efb7ae227c12f218b4740fb17a87e50/log/README.md
3131
*/
32-
class Checkpoints {
32+
public class Checkpoints {
3333
private static final Pattern SIGNATURE_BLOCK = Pattern.compile("\\u2014 (\\S+) (\\S+)");
3434

35-
static Checkpoint from(String encoded) throws RekorParseException {
35+
public static Checkpoint from(String encoded) throws RekorParseException {
3636
var split = Splitter.on("\n\n").splitToList(encoded);
3737
if (split.size() != 2) {
3838
throw new RekorParseException(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2025 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.rekor.v2.client;
17+
18+
import dev.sigstore.proto.rekor.v1.TransparencyLogEntry;
19+
import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002;
20+
import dev.sigstore.rekor.client.RekorParseException;
21+
import java.io.IOException;
22+
23+
/** A client to communicate with a rekor v2 service instance. */
24+
public interface RekorV2Client {
25+
/**
26+
* Put a new hashedrekord entry on the Rekor log.
27+
*
28+
* @param hashedRekordRequest the request to send to rekor
29+
* @return a {@link TransparencyLogEntry} with information about the log entry
30+
*/
31+
TransparencyLogEntry putEntry(HashedRekordRequestV002 hashedRekordRequest)
32+
throws IOException, RekorParseException;
33+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2025 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.rekor.v2.client;
17+
18+
import com.google.api.client.http.ByteArrayContent;
19+
import com.google.api.client.http.GenericUrl;
20+
import com.google.api.client.http.HttpRequest;
21+
import com.google.api.client.http.HttpResponse;
22+
import com.google.api.client.util.Preconditions;
23+
import com.google.gson.reflect.TypeToken;
24+
import com.google.protobuf.InvalidProtocolBufferException;
25+
import com.google.protobuf.util.JsonFormat;
26+
import dev.sigstore.http.HttpClients;
27+
import dev.sigstore.http.HttpParams;
28+
import dev.sigstore.http.ImmutableHttpParams;
29+
import dev.sigstore.json.GsonSupplier;
30+
import dev.sigstore.json.ProtoJson;
31+
import dev.sigstore.proto.rekor.v1.TransparencyLogEntry;
32+
import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002;
33+
import dev.sigstore.rekor.client.RekorParseException;
34+
import dev.sigstore.trustroot.Service;
35+
import java.io.IOException;
36+
import java.lang.reflect.Type;
37+
import java.net.URI;
38+
import java.util.HashMap;
39+
import java.util.Locale;
40+
import java.util.Map;
41+
42+
/** A client to communicate with a rekor v2 service instance over http. */
43+
public class RekorV2ClientHttp implements RekorV2Client {
44+
public static final String REKOR_ENTRIES_PATH = "/api/v2/log/entries";
45+
public static final String REKOR_CHECKPOINT_PATH = "/api/v2/checkpoint";
46+
47+
private final HttpParams httpParams;
48+
private final URI uri;
49+
50+
public static RekorV2ClientHttp.Builder builder() {
51+
return new RekorV2ClientHttp.Builder();
52+
}
53+
54+
private RekorV2ClientHttp(HttpParams httpParams, URI uri) {
55+
this.uri = uri;
56+
this.httpParams = httpParams;
57+
}
58+
59+
public static class Builder {
60+
private HttpParams httpParams = ImmutableHttpParams.builder().build();
61+
private Service service;
62+
63+
private Builder() {}
64+
65+
/** Configure the http properties, see {@link HttpParams}, {@link ImmutableHttpParams}. */
66+
public Builder setHttpParams(HttpParams httpParams) {
67+
this.httpParams = httpParams;
68+
return this;
69+
}
70+
71+
/** Service information for a remote rekor instance. */
72+
public Builder setService(Service service) {
73+
this.service = service;
74+
return this;
75+
}
76+
77+
public RekorV2ClientHttp build() {
78+
Preconditions.checkNotNull(service);
79+
return new RekorV2ClientHttp(httpParams, service.getUrl());
80+
}
81+
}
82+
83+
@Override
84+
public TransparencyLogEntry putEntry(HashedRekordRequestV002 hashedRekordRequest)
85+
throws IOException, RekorParseException {
86+
URI rekorPutEndpoint = uri.resolve(REKOR_ENTRIES_PATH);
87+
88+
String jsonPayload;
89+
try {
90+
String innerJson = JsonFormat.printer().print(hashedRekordRequest);
91+
92+
Type type = new TypeToken<Map<String, Object>>() {}.getType();
93+
Map<String, Object> innerMap = GsonSupplier.GSON.get().fromJson(innerJson, type);
94+
95+
var requestMap = new HashMap<String, Object>();
96+
requestMap.put("hashedRekordRequestV002", innerMap);
97+
98+
jsonPayload = GsonSupplier.GSON.get().toJson(requestMap);
99+
} catch (InvalidProtocolBufferException e) {
100+
throw new RekorParseException("Failed to serialize HashedRekordRequestV002 to JSON", e);
101+
}
102+
103+
HttpRequest req =
104+
HttpClients.newRequestFactory(httpParams)
105+
.buildPostRequest(
106+
new GenericUrl(rekorPutEndpoint),
107+
ByteArrayContent.fromString("application/json", jsonPayload));
108+
req.getHeaders().set("Accept", "application/json");
109+
req.getHeaders().set("Content-Type", "application/json");
110+
111+
HttpResponse resp = req.execute();
112+
if (resp.getStatusCode() != 201) {
113+
throw new IOException(
114+
String.format(
115+
Locale.ROOT,
116+
"bad response from rekor @ '%s' : %s",
117+
rekorPutEndpoint,
118+
resp.parseAsString()));
119+
}
120+
121+
String respEntryJson = resp.parseAsString();
122+
123+
try {
124+
TransparencyLogEntry.Builder builder = TransparencyLogEntry.newBuilder();
125+
ProtoJson.parser().merge(respEntryJson, builder);
126+
return builder.build();
127+
} catch (InvalidProtocolBufferException e) {
128+
throw new RekorParseException("Failed to parse Rekor response JSON", e);
129+
}
130+
}
131+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2025 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.rekor.v2.client;
17+
18+
import com.google.common.hash.Hashing;
19+
import com.google.protobuf.ByteString;
20+
import dev.sigstore.encryption.signers.Verifiers;
21+
import dev.sigstore.merkle.InclusionProofVerificationException;
22+
import dev.sigstore.merkle.InclusionProofVerifier;
23+
import dev.sigstore.proto.rekor.v1.TransparencyLogEntry;
24+
import dev.sigstore.rekor.client.Checkpoints;
25+
import dev.sigstore.rekor.client.RekorEntry.Checkpoint;
26+
import dev.sigstore.rekor.client.RekorEntry.CheckpointSignature;
27+
import dev.sigstore.rekor.client.RekorParseException;
28+
import dev.sigstore.rekor.client.RekorVerificationException;
29+
import dev.sigstore.trustroot.SigstoreTrustedRoot;
30+
import dev.sigstore.trustroot.TransparencyLog;
31+
import java.nio.charset.StandardCharsets;
32+
import java.security.InvalidKeyException;
33+
import java.security.NoSuchAlgorithmException;
34+
import java.security.SignatureException;
35+
import java.security.spec.InvalidKeySpecException;
36+
import java.time.Instant;
37+
import java.util.ArrayList;
38+
import java.util.Arrays;
39+
import java.util.Base64;
40+
import java.util.List;
41+
import java.util.Optional;
42+
43+
/* Verifier for rekor v2 entries. */
44+
public class RekorV2Verifier {
45+
private final List<TransparencyLog> tlogs;
46+
47+
public static RekorV2Verifier newRekorV2Verifier(SigstoreTrustedRoot trustRoot) {
48+
return newRekorV2Verifier(trustRoot.getTLogs());
49+
}
50+
51+
public static RekorV2Verifier newRekorV2Verifier(List<TransparencyLog> tlogs) {
52+
return new RekorV2Verifier(tlogs);
53+
}
54+
55+
private RekorV2Verifier(List<TransparencyLog> tlogs) {
56+
this.tlogs = tlogs;
57+
}
58+
59+
public void verifyEntry(TransparencyLogEntry entry, Instant timestamp)
60+
throws RekorVerificationException {
61+
if (entry.getInclusionProof() == null) {
62+
throw new RekorVerificationException("No inclusion proof in entry.");
63+
}
64+
65+
var tlog =
66+
TransparencyLog.find(tlogs, entry.getLogId().getKeyId().toByteArray(), timestamp)
67+
.orElseThrow(
68+
() ->
69+
new RekorVerificationException(
70+
"Log entry (logid, timestamp) does not match any provided transparency logs."));
71+
72+
// verify inclusion proof
73+
verifyInclusionProof(entry);
74+
verifyCheckpoint(entry, tlog);
75+
}
76+
77+
/** Verify that a Rekor Entry is in the log by checking inclusion proof. */
78+
private void verifyInclusionProof(TransparencyLogEntry entry) throws RekorVerificationException {
79+
var inclusionProof = entry.getInclusionProof();
80+
81+
var leafHash =
82+
Hashing.sha256()
83+
.newHasher()
84+
.putByte((byte) 0x00)
85+
.putBytes(entry.getCanonicalizedBody().toByteArray())
86+
.hash()
87+
.asBytes();
88+
89+
List<byte[]> hashes = new ArrayList<>();
90+
for (ByteString hash : inclusionProof.getHashesList()) {
91+
hashes.add(hash.toByteArray());
92+
}
93+
94+
byte[] expectedRootHash = inclusionProof.getRootHash().toByteArray();
95+
96+
try {
97+
InclusionProofVerifier.verify(
98+
leafHash,
99+
inclusionProof.getLogIndex(),
100+
inclusionProof.getTreeSize(),
101+
hashes,
102+
expectedRootHash);
103+
} catch (InclusionProofVerificationException e) {
104+
throw new RekorVerificationException("Inclusion proof verification failed", e);
105+
}
106+
}
107+
108+
private void verifyCheckpoint(TransparencyLogEntry entry, TransparencyLog tlog)
109+
throws RekorVerificationException {
110+
var checkpoint = entry.getInclusionProof().getCheckpoint();
111+
Checkpoint parsedCheckpoint;
112+
try {
113+
parsedCheckpoint = Checkpoints.from(checkpoint.getEnvelope());
114+
} catch (RekorParseException ex) {
115+
throw new RekorVerificationException("Could not parse checkpoint from envelope", ex);
116+
}
117+
118+
final int MAX_CHECKPOINT_SIGNATURES = 20;
119+
if (parsedCheckpoint.getSignatures().size() > MAX_CHECKPOINT_SIGNATURES) {
120+
throw new RekorVerificationException(
121+
"Checkpoint contains an excessive number of signatures ("
122+
+ parsedCheckpoint.getSignatures().size()
123+
+ "), exceeding the maximum allowed of "
124+
+ MAX_CHECKPOINT_SIGNATURES);
125+
}
126+
127+
byte[] inclusionRootHash = entry.getInclusionProof().getRootHash().toByteArray();
128+
byte[] checkpointRootHash = Base64.getDecoder().decode(parsedCheckpoint.getBase64Hash());
129+
130+
if (!Arrays.equals(inclusionRootHash, checkpointRootHash)) {
131+
throw new RekorVerificationException(
132+
"Checkpoint root hash does not match root hash provided in inclusion proof");
133+
}
134+
135+
Optional<CheckpointSignature> matchingSig =
136+
parsedCheckpoint.getSignatures().stream()
137+
.filter(sig -> sig.getIdentity().equals(tlog.getBaseUrl().getHost()))
138+
.findFirst();
139+
140+
if (!matchingSig.isPresent()) {
141+
throw new RekorVerificationException(
142+
"No matching checkpoint signature found for transparency log: "
143+
+ tlog.getBaseUrl().getHost());
144+
}
145+
146+
var signedData = parsedCheckpoint.getSignedData();
147+
148+
try {
149+
if (!Verifiers.newVerifier(tlog.getPublicKey().toJavaPublicKey())
150+
.verify(signedData.getBytes(StandardCharsets.UTF_8), matchingSig.get().getSignature())) {
151+
throw new RekorVerificationException("Checkpoint signature was invalid");
152+
}
153+
} catch (NoSuchAlgorithmException
154+
| InvalidKeySpecException
155+
| SignatureException
156+
| InvalidKeyException ex) {
157+
throw new RekorVerificationException("Could not verify checkpoint signature", ex);
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)