Skip to content

Commit f6df56d

Browse files
committed
Add Rekor v2 client and verifier
Signed-off-by: Aaron Lew <64337293+aaronlew02@users.noreply.github.com>
1 parent b097d2a commit f6df56d

File tree

8 files changed

+643
-2
lines changed

8 files changed

+643
-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: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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.RekorParseException;
27+
import dev.sigstore.rekor.client.RekorVerificationException;
28+
import dev.sigstore.trustroot.SigstoreTrustedRoot;
29+
import dev.sigstore.trustroot.TransparencyLog;
30+
import java.nio.charset.StandardCharsets;
31+
import java.security.InvalidKeyException;
32+
import java.security.NoSuchAlgorithmException;
33+
import java.security.SignatureException;
34+
import java.security.spec.InvalidKeySpecException;
35+
import java.time.Instant;
36+
import java.util.ArrayList;
37+
import java.util.Arrays;
38+
import java.util.Base64;
39+
import java.util.List;
40+
41+
/* Verifier for rekor v2 entries. */
42+
public class RekorV2Verifier {
43+
private final List<TransparencyLog> tlogs;
44+
45+
public static RekorV2Verifier newRekorV2Verifier(SigstoreTrustedRoot trustRoot) {
46+
return newRekorV2Verifier(trustRoot.getTLogs());
47+
}
48+
49+
public static RekorV2Verifier newRekorV2Verifier(List<TransparencyLog> tlogs) {
50+
return new RekorV2Verifier(tlogs);
51+
}
52+
53+
private RekorV2Verifier(List<TransparencyLog> tlogs) {
54+
this.tlogs = tlogs;
55+
}
56+
57+
public void verifyEntry(TransparencyLogEntry entry, Instant timestamp)
58+
throws RekorVerificationException {
59+
if (entry.getInclusionProof() == null) {
60+
throw new RekorVerificationException("No inclusion proof in entry.");
61+
}
62+
63+
var tlog =
64+
TransparencyLog.find(tlogs, entry.getLogId().getKeyId().toByteArray(), timestamp)
65+
.orElseThrow(
66+
() ->
67+
new RekorVerificationException(
68+
"Log entry (logid, timestamp) does not match any provided transparency logs."));
69+
70+
// verify inclusion proof
71+
verifyInclusionProof(entry);
72+
verifyCheckpoint(entry, tlog);
73+
}
74+
75+
/** Verify that a Rekor Entry is in the log by checking inclusion proof. */
76+
private void verifyInclusionProof(TransparencyLogEntry entry) throws RekorVerificationException {
77+
var inclusionProof = entry.getInclusionProof();
78+
79+
var leafHash =
80+
Hashing.sha256()
81+
.hashBytes(
82+
InclusionProofVerifier.combineBytes(
83+
new byte[] {0x00}, entry.getCanonicalizedBody().toByteArray()))
84+
.asBytes();
85+
86+
List<byte[]> hashes = new ArrayList<>();
87+
for (ByteString hash : inclusionProof.getHashesList()) {
88+
hashes.add(hash.toByteArray());
89+
}
90+
91+
byte[] expectedRootHash = inclusionProof.getRootHash().toByteArray();
92+
93+
try {
94+
InclusionProofVerifier.verify(
95+
leafHash,
96+
inclusionProof.getLogIndex(),
97+
inclusionProof.getTreeSize(),
98+
hashes,
99+
expectedRootHash);
100+
} catch (InclusionProofVerificationException e) {
101+
throw new RekorVerificationException("Inclusion proof verification failed", e);
102+
}
103+
}
104+
105+
private void verifyCheckpoint(TransparencyLogEntry entry, TransparencyLog tlog)
106+
throws RekorVerificationException {
107+
var checkpoint = entry.getInclusionProof().getCheckpoint();
108+
Checkpoint parsedCheckpoint;
109+
try {
110+
parsedCheckpoint = Checkpoints.from(checkpoint.getEnvelope());
111+
} catch (RekorParseException ex) {
112+
throw new RekorVerificationException("Could not parse checkpoint from envelope", ex);
113+
}
114+
115+
byte[] inclusionRootHash = entry.getInclusionProof().getRootHash().toByteArray();
116+
byte[] checkpointRootHash = Base64.getDecoder().decode(parsedCheckpoint.getBase64Hash());
117+
118+
if (!Arrays.equals(inclusionRootHash, checkpointRootHash)) {
119+
throw new RekorVerificationException(
120+
"Checkpoint root hash does not match root hash provided in inclusion proof");
121+
}
122+
var keyHash = tlog.getLogId().getKeyId();
123+
// checkpoint 0 is always the log, not any of the cross signing verifiers/monitors
124+
var sig = parsedCheckpoint.getSignatures().get(0);
125+
for (int i = 0; i < 4; i++) {
126+
if (sig.getKeyHint()[i] != keyHash[i]) {
127+
throw new RekorVerificationException(
128+
"Checkpoint key hint did not match provided log public key");
129+
}
130+
}
131+
var signedData = parsedCheckpoint.getSignedData();
132+
133+
try {
134+
if (!Verifiers.newVerifier(tlog.getPublicKey().toJavaPublicKey())
135+
.verify(signedData.getBytes(StandardCharsets.UTF_8), sig.getSignature())) {
136+
throw new RekorVerificationException("Checkpoint signature was invalid");
137+
}
138+
} catch (NoSuchAlgorithmException
139+
| InvalidKeySpecException
140+
| SignatureException
141+
| InvalidKeyException ex) {
142+
throw new RekorVerificationException("Could not verify checkpoint signature", ex);
143+
}
144+
}
145+
}

0 commit comments

Comments
 (0)