Skip to content

Commit

Permalink
Handle parsing checkpoints from a rekor entry
Browse files Browse the repository at this point in the history
This does not do validation on the checkpoint yet

Signed-off-by: Appu Goundan <[email protected]>
  • Loading branch information
loosebazooka committed Mar 11, 2024
1 parent 7076d94 commit 52f7210
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<CheckpointSignature> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CheckpointSignature> 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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
rekor.sigstore.dev - 2605736670972794746
37795272
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852
— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
rekor.sigstore.dev - 2605736670972794746
37795272

— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
rekor.sigstore.dev - 2605736670972794746
37795272
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852

— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
rekor.sigstore.dev - 2605736670972794746
37795272
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
rekor.sigstore.dev - 2605736670972794746
abcdefg
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852

— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
rekor.sigstore.dev - 2605736670972794746
37795272
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852

rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
rekor.sigstore.dev - 2605736670972794746
37795272
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852

— rekor.sigstore.dev wNI9aj
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
rekor.sigstore.dev - 2605736670972794746
37795272
60ll7idWI1jYRZzxc+jKflYoW+4jWxgZaGR15ASsWt4=
Timestamp: 1697034484441201852

— rekor.sigstore.dev wNI9ajBGAiEAlWUH2HSPa6IMIRBgFcIXph3Mj9xM70WR0VVADvGIl/oCIQCgfvUUjR/X5jewlqpAWI8NuJIicKpTG64vo6UM5fpSgQ==
Original file line number Diff line number Diff line change
@@ -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==

0 comments on commit 52f7210

Please sign in to comment.