Skip to content

Commit 741d356

Browse files
authored
Merge pull request #657 from sigstore/checkpoints
Handle parsing checkpoints from a rekor entry
2 parents eefa7a2 + f09ebc8 commit 741d356

File tree

12 files changed

+346
-0
lines changed

12 files changed

+346
-0
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2024 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.client;
17+
18+
import com.google.common.base.Splitter;
19+
import dev.sigstore.rekor.client.RekorEntry.Checkpoint;
20+
import dev.sigstore.rekor.client.RekorEntry.CheckpointSignature;
21+
import java.util.ArrayList;
22+
import java.util.Arrays;
23+
import java.util.Base64;
24+
import java.util.List;
25+
import java.util.regex.Pattern;
26+
import java.util.stream.Collectors;
27+
28+
/**
29+
* Checkpoint helper class to parse from a string in the format described in
30+
* https://github.com/transparency-dev/formats/blob/12bf59947efb7ae227c12f218b4740fb17a87e50/log/README.md
31+
*/
32+
class Checkpoints {
33+
private static final Pattern SIGNATURE_BLOCK = Pattern.compile("\\u2014 (\\S+) (\\S+)");
34+
35+
static Checkpoint from(String encoded) throws RekorParseException {
36+
var split = Splitter.on("\n\n").splitToList(encoded);
37+
if (split.size() != 2) {
38+
throw new RekorParseException(
39+
"Checkpoint must contain one blank line, delineating the header from the signature block");
40+
}
41+
var header = split.get(0);
42+
var data = split.get(1);
43+
44+
// note that the string actually contains \n literally, not newlines
45+
var headers = Splitter.on("\n").splitToList(header);
46+
if (headers.size() < 3) {
47+
throw new RekorParseException("Checkpoint header must contain at least 3 lines");
48+
}
49+
50+
var origin = headers.get(0);
51+
long size;
52+
try {
53+
size = Long.parseLong(headers.get(1));
54+
} catch (NumberFormatException nfe) {
55+
throw new RekorParseException(
56+
"Checkpoint header attribute size must be a number, but was: " + headers.get(1));
57+
}
58+
var base64Hash = headers.get(2);
59+
// we don't care about any other headers after this
60+
61+
if (data.length() == 0) {
62+
throw new RekorParseException("Checkpoint body must contain at least one signature");
63+
}
64+
if (!data.endsWith("\n")) {
65+
throw new RekorParseException("Checkpoint signature section must end with newline");
66+
}
67+
68+
List<CheckpointSignature> signatures = new ArrayList<>();
69+
for (String sig : data.lines().collect(Collectors.toList())) {
70+
signatures.add(sigFrom(sig));
71+
}
72+
73+
return ImmutableCheckpoint.builder()
74+
.origin(origin)
75+
.size(size)
76+
.base64Hash(base64Hash)
77+
.addAllSignatures(signatures)
78+
.build();
79+
}
80+
81+
static CheckpointSignature sigFrom(String signatureLine) throws RekorParseException {
82+
var m = SIGNATURE_BLOCK.matcher(signatureLine);
83+
if (!m.find()) {
84+
throw new RekorParseException(
85+
"Checkpoint signature '"
86+
+ signatureLine
87+
+ "' was not in the format '— <id> <base64 keyhint+signature>'");
88+
}
89+
var identity = m.group(1);
90+
var keySig = Base64.getDecoder().decode(m.group(2));
91+
if (keySig.length < 5) {
92+
throw new RekorParseException(
93+
"Checkpoint signature <keyhint + signature> was "
94+
+ keySig.length
95+
+ " bytes long, but must be at least 5 bytes long");
96+
}
97+
var keyHint = Arrays.copyOfRange(keySig, 0, 4);
98+
var signature = Arrays.copyOfRange(keySig, 4, keySig.length);
99+
return ImmutableCheckpointSignature.builder()
100+
.identity(identity)
101+
.keyHint(keyHint)
102+
.signature(signature)
103+
.build();
104+
}
105+
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.immutables.gson.Gson;
2626
import org.immutables.value.Value;
2727
import org.immutables.value.Value.Derived;
28+
import org.immutables.value.Value.Lazy;
2829

2930
/** A local representation of a rekor entry in the log. */
3031
@Gson.TypeAdapters
@@ -68,6 +69,44 @@ interface InclusionProof {
6869

6970
/** The checkpoint (signed tree head) that the inclusion proof is based on. */
7071
String getCheckpoint();
72+
73+
/**
74+
* The checkpoint that {@link #getCheckpoint} provides, but parsed into component parts.
75+
*
76+
* @return a Checkpoint
77+
* @throws RekorParseException if the checkpoint is invalid
78+
*/
79+
@Lazy
80+
default Checkpoint parsedCheckpoint() throws RekorParseException {
81+
return Checkpoints.from(getCheckpoint());
82+
}
83+
}
84+
85+
@Value.Immutable
86+
interface Checkpoint {
87+
/** Unique identity for the log. */
88+
String getOrigin();
89+
90+
/** Size of the log for this checkpoint. */
91+
Long getSize();
92+
93+
/** Log root hash at the defined log size. */
94+
String getBase64Hash();
95+
96+
/** A list of signatures associated with the checkpoint. */
97+
List<CheckpointSignature> getSignatures();
98+
}
99+
100+
@Value.Immutable
101+
interface CheckpointSignature {
102+
/** Human readable log identity */
103+
String getIdentity();
104+
105+
/** First 4 bytes of sha256 key hash as a Public Key hint. */
106+
byte[] getKeyHint();
107+
108+
/** Signature over the tree head. */
109+
byte[] getSignature();
71110
}
72111

73112
/** Returns the content of the log entry. */
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright 2024 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.client;
17+
18+
import com.google.common.io.Resources;
19+
import java.io.IOException;
20+
import java.nio.charset.StandardCharsets;
21+
import java.util.Arrays;
22+
import java.util.Base64;
23+
import org.junit.jupiter.api.Assertions;
24+
import org.junit.jupiter.api.Test;
25+
26+
public class CheckpointsTest {
27+
28+
public static final String REKOR_PUB_KEYID = "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=";
29+
30+
public String getResource(String filename) throws IOException {
31+
return Resources.toString(
32+
Resources.getResource("dev/sigstore/samples/checkpoints/" + filename),
33+
StandardCharsets.UTF_8);
34+
}
35+
36+
@Test
37+
public void from_valid() throws Exception {
38+
var checkpoint = Checkpoints.from(getResource("valid.txt"));
39+
Assertions.assertEquals("rekor.sigstore.dev - 2605736670972794746", checkpoint.getOrigin());
40+
Assertions.assertEquals(37795272, checkpoint.getSize());
41+
Assertions.assertEquals(
42+
"60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=", checkpoint.getBase64Hash());
43+
44+
var keyBytesHintExpected =
45+
Arrays.copyOfRange(Base64.getDecoder().decode(REKOR_PUB_KEYID), 0, 4);
46+
var sig = checkpoint.getSignatures().get(0);
47+
Assertions.assertEquals(1, checkpoint.getSignatures().size());
48+
Assertions.assertEquals("rekor.sigstore.dev", sig.getIdentity());
49+
Assertions.assertArrayEquals(keyBytesHintExpected, sig.getKeyHint());
50+
Assertions.assertEquals(
51+
"MEYCIQCVZQfYdI9rogwhEGAVwhemHcyP3EzvRZHRVUAO8YiX+gIhAKB+9RSNH9fmN7CWqkBYjw24kiJwqlMbri+jpQzl+lKB",
52+
Base64.getEncoder().encodeToString(sig.getSignature()));
53+
}
54+
55+
@Test
56+
public void from_validMultiSig() throws Exception {
57+
var checkpoint = Checkpoints.from(getResource("valid_multi_sig.txt"));
58+
Assertions.assertEquals("rekor.sigstore.dev - 2605736670972794746", checkpoint.getOrigin());
59+
Assertions.assertEquals(37795272, checkpoint.getSize());
60+
Assertions.assertEquals(
61+
"60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=", checkpoint.getBase64Hash());
62+
63+
Assertions.assertEquals(2, checkpoint.getSignatures().size());
64+
var keyBytesHintExpected =
65+
Arrays.copyOfRange(Base64.getDecoder().decode(REKOR_PUB_KEYID), 0, 4);
66+
67+
var sig1 = checkpoint.getSignatures().get(0);
68+
Assertions.assertEquals("rekor.sigstore.dev", sig1.getIdentity());
69+
Assertions.assertArrayEquals(keyBytesHintExpected, sig1.getKeyHint());
70+
Assertions.assertEquals(
71+
"MEYCIQCVZQfYdI9rogwhEGAVwhemHcyP3EzvRZHRVUAO8YiX+gIhAKB+9RSNH9fmN7CWqkBYjw24kiJwqlMbri+jpQzl+lKB",
72+
Base64.getEncoder().encodeToString(sig1.getSignature()));
73+
74+
var sig2 = checkpoint.getSignatures().get(1);
75+
Assertions.assertEquals("bob.loblaw.dev", sig2.getIdentity());
76+
Assertions.assertArrayEquals(keyBytesHintExpected, sig2.getKeyHint());
77+
Assertions.assertEquals(
78+
"MEYCIQCVZQfYdI9rogwhEGAVwhGmHcyP3EzvRZHRVUAO8YiX+gIhAKB+9RSNH9fmN7CWqkBYjw24kiJwqlMbri+jpQzl+lKB",
79+
Base64.getEncoder().encodeToString(sig2.getSignature()));
80+
}
81+
82+
@Test
83+
public void from_noSeparator() throws Exception {
84+
var ex =
85+
Assertions.assertThrows(
86+
RekorParseException.class,
87+
() -> Checkpoints.from(getResource("error_header_body_separator.txt")));
88+
Assertions.assertEquals(
89+
"Checkpoint must contain one blank line, delineating the header from the signature block",
90+
ex.getMessage());
91+
}
92+
93+
@Test
94+
public void from_notEnoughHeaders() throws Exception {
95+
var ex =
96+
Assertions.assertThrows(
97+
RekorParseException.class,
98+
() -> Checkpoints.from(getResource("error_header_count.txt")));
99+
Assertions.assertEquals("Checkpoint header must contain at least 3 lines", ex.getMessage());
100+
}
101+
102+
@Test
103+
public void from_notANumber() throws Exception {
104+
var ex =
105+
Assertions.assertThrows(
106+
RekorParseException.class,
107+
() -> Checkpoints.from(getResource("error_not_a_number.txt")));
108+
Assertions.assertEquals(
109+
"Checkpoint header attribute size must be a number, but was: abcdefg", ex.getMessage());
110+
}
111+
112+
@Test
113+
public void from_noSignatures() throws Exception {
114+
var ex =
115+
Assertions.assertThrows(
116+
RekorParseException.class,
117+
() -> Checkpoints.from(getResource("error_no_signatures.txt")));
118+
Assertions.assertEquals("Checkpoint body must contain at least one signature", ex.getMessage());
119+
}
120+
121+
@Test
122+
public void from_noNewlineAfterSignatures() throws Exception {
123+
var ex =
124+
Assertions.assertThrows(
125+
RekorParseException.class,
126+
() -> Checkpoints.from(getResource("error_no_newline_after_signature.txt")));
127+
Assertions.assertEquals("Checkpoint signature section must end with newline", ex.getMessage());
128+
}
129+
130+
@Test
131+
public void from_signatureFormatInvalid() throws Exception {
132+
var ex =
133+
Assertions.assertThrows(
134+
RekorParseException.class,
135+
() -> Checkpoints.from(getResource("error_signature_format_invalid.txt")));
136+
Assertions.assertEquals(
137+
"Checkpoint signature 'rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==' was not in the format '— <id> <base64 keyhint+signature>'",
138+
ex.getMessage());
139+
}
140+
141+
@Test
142+
public void from_signatureLengthInsufficient() throws Exception {
143+
var ex =
144+
Assertions.assertThrows(
145+
RekorParseException.class,
146+
() -> Checkpoints.from(getResource("error_signature_length_insufficient.txt")));
147+
Assertions.assertEquals(
148+
"Checkpoint signature <keyhint + signature> was 4 bytes long, but must be at least 5 bytes long",
149+
ex.getMessage());
150+
}
151+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
37795272
3+
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
4+
Timestamp: 1697034484441201852
5+
— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
37795272
3+
4+
— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
37795272
3+
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
4+
Timestamp: 1697034484441201852
5+
6+
— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
37795272
3+
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
4+
Timestamp: 1697034484441201852
5+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
abcdefg
3+
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
4+
Timestamp: 1697034484441201852
5+
6+
— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
37795272
3+
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
4+
Timestamp: 1697034484441201852
5+
6+
rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
rekor.sigstore.dev - 2605736670972794746
2+
37795272
3+
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
4+
Timestamp: 1697034484441201852
5+
6+
— rekor.sigstore.dev wNI9aj

0 commit comments

Comments
 (0)