diff --git a/sigstore-java/src/main/java/dev/sigstore/rekor/client/Checkpoints.java b/sigstore-java/src/main/java/dev/sigstore/rekor/client/Checkpoints.java new file mode 100644 index 000000000..dca542a38 --- /dev/null +++ b/sigstore-java/src/main/java/dev/sigstore/rekor/client/Checkpoints.java @@ -0,0 +1,98 @@ +/* + * Copyright 2024 The Sigstore Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sigstore.rekor.client; + +import com.google.common.base.Splitter; +import dev.sigstore.rekor.client.RekorEntry.Checkpoint; +import dev.sigstore.rekor.client.RekorEntry.CheckpointSignature; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Checkpoint helper class to parse from a string in the format described in + * https://github.com/transparency-dev/formats/blob/12bf59947efb7ae227c12f218b4740fb17a87e50/log/README.md + */ +class Checkpoints { + static Checkpoint from(String encoded) throws RekorParseException { + var split = Splitter.on("\n\n").splitToList(encoded); + if (split.size() != 2) { + throw new RekorParseException( + "Checkpoint must contain one blank line, delineating the header from the signature block"); + } + var header = split.get(0); + var data = split.get(1); + + // note that the string actually contains \n literally, not newlines + var headers = Splitter.on("\n").splitToList(header); + if (headers.size() < 3) { + throw new RekorParseException("Checkpoint header must contain at least 3 lines"); + } + + var origin = headers.get(0); + long size; + try { + size = Long.parseLong(headers.get(1)); + } catch (NumberFormatException nfe) { + throw new RekorParseException("Checkpoint header attribute size must be a number"); + } + var base64Hash = headers.get(2); + // we don't care about any other headers after this + + if (data.length() == 0) { + throw new RekorParseException("Checkpoint body must contain at least one signature"); + } + if (!data.endsWith("\n")) { + throw new RekorParseException("Checkpoint signature section must end with newline"); + } + + List signatures = new ArrayList<>(); + for (String sig : data.lines().collect(Collectors.toList())) { + signatures.add(sigFrom(sig)); + } + + return ImmutableCheckpoint.builder() + .origin(origin) + .size(size) + .base64Hash(base64Hash) + .addAllSignatures(signatures) + .build(); + } + + static CheckpointSignature sigFrom(String signatureLine) throws RekorParseException { + var sigBlock = Pattern.compile("\\u2014 (\\S+) (\\S+)"); + var m = sigBlock.matcher(signatureLine); + if (!m.find()) { + // really shouldn't be getting here because we check + throw new RekorParseException("Checkpoint signature was invalid or improperly formatted"); + } + var identity = m.group(1); + var keySig = Base64.getDecoder().decode(m.group(2)); + if (keySig.length < 5) { + throw new RekorParseException("Checkpoint signature contains too few bytes"); + } + var keyHint = Arrays.copyOfRange(keySig, 0, 4); + var signature = Arrays.copyOfRange(keySig, 4, keySig.length); + return ImmutableCheckpointSignature.builder() + .identity(identity) + .keyHint(keyHint) + .signature(signature) + .build(); + } +} diff --git a/sigstore-java/src/main/java/dev/sigstore/rekor/client/RekorEntry.java b/sigstore-java/src/main/java/dev/sigstore/rekor/client/RekorEntry.java index 128de3a5c..4ed67f984 100644 --- a/sigstore-java/src/main/java/dev/sigstore/rekor/client/RekorEntry.java +++ b/sigstore-java/src/main/java/dev/sigstore/rekor/client/RekorEntry.java @@ -25,6 +25,7 @@ import org.immutables.gson.Gson; import org.immutables.value.Value; import org.immutables.value.Value.Derived; +import org.immutables.value.Value.Lazy; /** A local representation of a rekor entry in the log. */ @Gson.TypeAdapters @@ -68,6 +69,44 @@ interface InclusionProof { /** The checkpoint (signed tree head) that the inclusion proof is based on. */ String getCheckpoint(); + + /** + * The checkpoint that {@link #getCheckpoint} provides, but parsed into component parts. + * + * @return a Checkpoint + * @throws RekorParseException if the checkpoint is invalid + */ + @Lazy + default Checkpoint parsedCheckpoint() throws RekorParseException { + return Checkpoints.from(getCheckpoint()); + } + } + + @Value.Immutable + interface Checkpoint { + /** Unique identity for the log. */ + String getOrigin(); + + /** Size of the log for this checkpoint. */ + Long getSize(); + + /** Log root hash at the defined log size. */ + String getBase64Hash(); + + /** A list of signatures associated with the checkpoint. */ + List getSignatures(); + } + + @Value.Immutable + interface CheckpointSignature { + /** Human readable log identity */ + String getIdentity(); + + /** First 4 bytes of sha256 key hash as a Public Key hint. */ + byte[] getKeyHint(); + + /** Signature over the tree head. */ + byte[] getSignature(); } /** Returns the content of the log entry. */ diff --git a/sigstore-java/src/test/java/dev/sigstore/rekor/client/CheckpointsTest.java b/sigstore-java/src/test/java/dev/sigstore/rekor/client/CheckpointsTest.java new file mode 100644 index 000000000..fac3d8638 --- /dev/null +++ b/sigstore-java/src/test/java/dev/sigstore/rekor/client/CheckpointsTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2024 The Sigstore Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sigstore.rekor.client; + +import com.google.common.io.Resources; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CheckpointsTest { + + public static final String REKOR_PUB_KEYID = "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="; + + public String getResource(String filename) throws IOException { + return Resources.toString( + Resources.getResource("dev/sigstore/samples/checkpoints/" + filename), + StandardCharsets.UTF_8); + } + + @Test + public void from_valid() throws Exception { + var checkpoint = Checkpoints.from(getResource("valid.txt")); + Assertions.assertEquals("rekor.sigstore.dev - 2605736670972794746", checkpoint.getOrigin()); + Assertions.assertEquals(37795272, checkpoint.getSize()); + Assertions.assertEquals( + "60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=", checkpoint.getBase64Hash()); + + var keyBytesHintExpected = + Arrays.copyOfRange(Base64.getDecoder().decode(REKOR_PUB_KEYID), 0, 4); + var sig = checkpoint.getSignatures().get(0); + Assertions.assertEquals(1, checkpoint.getSignatures().size()); + Assertions.assertEquals("rekor.sigstore.dev", sig.getIdentity()); + Assertions.assertArrayEquals(keyBytesHintExpected, sig.getKeyHint()); + Assertions.assertEquals( + "MEYCIQCVZQfYdI9rogwhEGAVwhemHcyP3EzvRZHRVUAO8YiX+gIhAKB+9RSNH9fmN7CWqkBYjw24kiJwqlMbri+jpQzl+lKB", + Base64.getEncoder().encodeToString(sig.getSignature())); + } + + @Test + public void from_validMultiSig() throws Exception { + var checkpoint = Checkpoints.from(getResource("valid_multi_sig.txt")); + Assertions.assertEquals("rekor.sigstore.dev - 2605736670972794746", checkpoint.getOrigin()); + Assertions.assertEquals(37795272, checkpoint.getSize()); + Assertions.assertEquals( + "60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=", checkpoint.getBase64Hash()); + + Assertions.assertEquals(2, checkpoint.getSignatures().size()); + var keyBytesHintExpected = + Arrays.copyOfRange(Base64.getDecoder().decode(REKOR_PUB_KEYID), 0, 4); + + var sig1 = checkpoint.getSignatures().get(0); + Assertions.assertEquals("rekor.sigstore.dev", sig1.getIdentity()); + Assertions.assertArrayEquals(keyBytesHintExpected, sig1.getKeyHint()); + Assertions.assertEquals( + "MEYCIQCVZQfYdI9rogwhEGAVwhemHcyP3EzvRZHRVUAO8YiX+gIhAKB+9RSNH9fmN7CWqkBYjw24kiJwqlMbri+jpQzl+lKB", + Base64.getEncoder().encodeToString(sig1.getSignature())); + + var sig2 = checkpoint.getSignatures().get(1); + Assertions.assertEquals("bob.loblaw.dev", sig2.getIdentity()); + Assertions.assertArrayEquals(keyBytesHintExpected, sig2.getKeyHint()); + Assertions.assertEquals( + "MEYCIQCVZQfYdI9rogwhEGAVwhGmHcyP3EzvRZHRVUAO8YiX+gIhAKB+9RSNH9fmN7CWqkBYjw24kiJwqlMbri+jpQzl+lKB", + Base64.getEncoder().encodeToString(sig2.getSignature())); + } + + @Test + public void from_noSeparator() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_header_body_separator.txt"))); + Assertions.assertEquals( + "Checkpoint must contain one blank line, delineating the header from the signature block", + ex.getMessage()); + } + + @Test + public void from_notEnoughHeaders() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_header_count.txt"))); + Assertions.assertEquals("Checkpoint header must contain at least 3 lines", ex.getMessage()); + } + + @Test + public void from_notANumber() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_not_a_number.txt"))); + Assertions.assertEquals("Checkpoint header attribute size must be a number", ex.getMessage()); + } + + @Test + public void from_noSignatures() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_no_signatures.txt"))); + Assertions.assertEquals("Checkpoint body must contain at least one signature", ex.getMessage()); + } + + @Test + public void from_noNewlineAfterSignatures() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_no_newline_after_signature.txt"))); + Assertions.assertEquals("Checkpoint signature section must end with newline", ex.getMessage()); + } + + @Test + public void from_signatureFormatInvalid() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_signature_format_invalid.txt"))); + Assertions.assertEquals( + "Checkpoint signature was invalid or improperly formatted", ex.getMessage()); + } + + @Test + public void from_signatureLengthInsufficient() throws Exception { + var ex = + Assertions.assertThrows( + RekorParseException.class, + () -> Checkpoints.from(getResource("error_signature_length_insufficient.txt"))); + Assertions.assertEquals("Checkpoint signature contains too few bytes", ex.getMessage()); + } +} diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_header_body_separator.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_header_body_separator.txt new file mode 100644 index 000000000..9ffd9e0ed --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_header_body_separator.txt @@ -0,0 +1,5 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 +— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_header_count.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_header_count.txt new file mode 100644 index 000000000..83133cab9 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_header_count.txt @@ -0,0 +1,4 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 + +— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_no_newline_after_signature.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_no_newline_after_signature.txt new file mode 100644 index 000000000..90b442d60 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_no_newline_after_signature.txt @@ -0,0 +1,6 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + +— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== \ No newline at end of file diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_no_signatures.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_no_signatures.txt new file mode 100644 index 000000000..547a444d7 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_no_signatures.txt @@ -0,0 +1,5 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_not_a_number.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_not_a_number.txt new file mode 100644 index 000000000..264f087ce --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_not_a_number.txt @@ -0,0 +1,6 @@ +rekor.sigstore.dev - 2605736670972794746 +abcdefg +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + +— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_signature_format_invalid.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_signature_format_invalid.txt new file mode 100644 index 000000000..b1420351a --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_signature_format_invalid.txt @@ -0,0 +1,6 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + +rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_signature_length_insufficient.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_signature_length_insufficient.txt new file mode 100644 index 000000000..ea41b8e74 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/error_signature_length_insufficient.txt @@ -0,0 +1,6 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + +— rekor.sigstore.dev wNI9aj diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/valid.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/valid.txt new file mode 100644 index 000000000..a1d91f952 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/valid.txt @@ -0,0 +1,6 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + +— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== diff --git a/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/valid_multi_sig.txt b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/valid_multi_sig.txt new file mode 100644 index 000000000..e1d8a0dd5 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/samples/checkpoints/valid_multi_sig.txt @@ -0,0 +1,7 @@ +rekor.sigstore.dev - 2605736670972794746 +37795272 +60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4= +Timestamp: 1697034484441201852 + +— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ== +— bob.loblaw.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIRph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==