From a8f9450c219be4ab70a5b4e9d45ef4905836e0b0 Mon Sep 17 00:00:00 2001
From: Bob Callaway <bobcallaway@users.noreply.github.com>
Date: Mon, 7 Jun 2021 15:40:03 -0400
Subject: [PATCH] Convert STH to checkpoint format (#322)

* Convert STH to checkpoint format

This switches away from sending the (now deprecated) Trillian LogRootV1
format over to the checkpoint format documented at
https://github.com/google/trillian-examples/tree/master/formats/log

Fixes: #313

Signed-off-by: Bob Callaway <bob.callaway@gmail.com>
---
 cmd/rekor-cli/app/log_info.go                 |  52 +-
 cmd/rekor-cli/app/root.go                     |   5 +-
 cmd/rekor-cli/app/state/state.go              |  10 +-
 cmd/rekor-server/app/root.go                  |   1 +
 cmd/rekor-server/app/watch.go                 |  33 +-
 go.mod                                        |   3 +-
 go.sum                                        |   3 +-
 openapi.yaml                                  |  20 +-
 pkg/api/error.go                              |   1 +
 pkg/api/tlog.go                               |  40 +-
 pkg/generated/models/log_info.go              | 131 +-----
 .../restapi/configure_rekor_server.go         |   2 +
 pkg/generated/restapi/embedded_spec.go        |  76 +--
 pkg/util/checkpoint.go                        | 319 +++++++++++++
 pkg/util/checkpoint_test.go                   | 443 ++++++++++++++++++
 pkg/verify/log_root.go                        |  65 ---
 pkg/verify/log_root_test.go                   |  55 ---
 17 files changed, 851 insertions(+), 408 deletions(-)
 create mode 100644 pkg/util/checkpoint.go
 create mode 100644 pkg/util/checkpoint_test.go
 delete mode 100644 pkg/verify/log_root.go
 delete mode 100644 pkg/verify/log_root_test.go

diff --git a/cmd/rekor-cli/app/log_info.go b/cmd/rekor-cli/app/log_info.go
index 95e781f..e52f509 100644
--- a/cmd/rekor-cli/app/log_info.go
+++ b/cmd/rekor-cli/app/log_info.go
@@ -22,7 +22,6 @@ import (
 	"encoding/pem"
 	"errors"
 	"fmt"
-	"strings"
 	"time"
 
 	"github.com/google/trillian/merkle/logverifier"
@@ -34,7 +33,7 @@ import (
 	"github.com/sigstore/rekor/cmd/rekor-cli/app/state"
 	"github.com/sigstore/rekor/pkg/generated/client/tlog"
 	"github.com/sigstore/rekor/pkg/log"
-	"github.com/sigstore/rekor/pkg/verify"
+	"github.com/sigstore/rekor/pkg/util"
 )
 
 type logInfoCmdOutput struct {
@@ -72,14 +71,11 @@ var logInfoCmd = &cobra.Command{
 
 		logInfo := result.GetPayload()
 
-		logRoot := *logInfo.SignedTreeHead.LogRoot
-		if logRoot == nil {
-			return nil, errors.New("logroot should not be nil")
-		}
-		signature := *logInfo.SignedTreeHead.Signature
-		if signature == nil {
-			return nil, errors.New("signature should not be nil")
+		sth := util.RekorSTH{}
+		if err := sth.UnmarshalText([]byte(*logInfo.SignedTreeHead)); err != nil {
+			return nil, err
 		}
+
 		publicKey := viper.GetString("rekor_server_public_key")
 		if publicKey == "" {
 			// fetch key from server
@@ -100,33 +96,25 @@ var logInfoCmd = &cobra.Command{
 			return nil, err
 		}
 
-		lr, err := verify.SignedLogRoot(pub, logRoot, signature)
-		if err != nil {
-			return nil, err
+		if !sth.Verify(pub) {
+			return nil, errors.New("signature on tree head did not verify")
 		}
+
 		cmdOutput := &logInfoCmdOutput{
 			TreeSize:       *logInfo.TreeSize,
 			RootHash:       *logInfo.RootHash,
-			TimestampNanos: lr.TimestampNanos,
-		}
-
-		if lr.TreeSize != uint64(*logInfo.TreeSize) {
-			return nil, errors.New("tree size in signed tree head does not match value returned in API call")
-		}
-
-		if !strings.EqualFold(hex.EncodeToString(lr.RootHash), *logInfo.RootHash) {
-			return nil, errors.New("root hash in signed tree head does not match value returned in API call")
+			TimestampNanos: sth.GetTimestamp(),
 		}
 
 		oldState := state.Load(serverURL)
 		if oldState != nil {
-			persistedSize := oldState.TreeSize
-			if persistedSize < lr.TreeSize {
-				log.CliLogger.Infof("Found previous log state, proving consistency between %d and %d", oldState.TreeSize, lr.TreeSize)
+			persistedSize := oldState.Size
+			if persistedSize < sth.Size {
+				log.CliLogger.Infof("Found previous log state, proving consistency between %d and %d", oldState.Size, sth.Size)
 				params := tlog.NewGetLogProofParams()
 				firstSize := int64(persistedSize)
 				params.FirstSize = &firstSize
-				params.LastSize = int64(lr.TreeSize)
+				params.LastSize = int64(sth.Size)
 				proof, err := rekorClient.Tlog.GetLogProof(params)
 				if err != nil {
 					return nil, err
@@ -137,25 +125,25 @@ var logInfoCmd = &cobra.Command{
 					hashes = append(hashes, b)
 				}
 				v := logverifier.New(rfc6962.DefaultHasher)
-				if err := v.VerifyConsistencyProof(firstSize, int64(lr.TreeSize), oldState.RootHash,
-					lr.RootHash, hashes); err != nil {
+				if err := v.VerifyConsistencyProof(firstSize, int64(sth.Size), oldState.Hash,
+					sth.Hash, hashes); err != nil {
 					return nil, err
 				}
 				log.CliLogger.Infof("Consistency proof valid!")
-			} else if persistedSize == lr.TreeSize {
-				if !bytes.Equal(oldState.RootHash, lr.RootHash) {
+			} else if persistedSize == sth.Size {
+				if !bytes.Equal(oldState.Hash, sth.Hash) {
 					return nil, errors.New("root hash returned from server does not match previously persisted state")
 				}
 				log.CliLogger.Infof("Persisted log state matches the current state of the log")
-			} else if persistedSize > lr.TreeSize {
-				return nil, fmt.Errorf("current size of tree reported from server %d is less than previously persisted state %d", lr.TreeSize, persistedSize)
+			} else if persistedSize > sth.Size {
+				return nil, fmt.Errorf("current size of tree reported from server %d is less than previously persisted state %d", sth.Size, persistedSize)
 			}
 		} else {
 			log.CliLogger.Infof("No previous log state stored, unable to prove consistency")
 		}
 
 		if viper.GetBool("store_tree_state") {
-			if err := state.Dump(serverURL, lr); err != nil {
+			if err := state.Dump(serverURL, &sth); err != nil {
 				log.CliLogger.Infof("Unable to store previous state: %v", err)
 			}
 		}
diff --git a/cmd/rekor-cli/app/root.go b/cmd/rekor-cli/app/root.go
index c9fae09..16b349b 100644
--- a/cmd/rekor-cli/app/root.go
+++ b/cmd/rekor-cli/app/root.go
@@ -131,7 +131,10 @@ func GetRekorClient(rekorServerURL string) (*client.Rekor, error) {
 	if viper.GetString("api-key") != "" {
 		rt.DefaultAuthentication = httptransport.APIKeyAuth("apiKey", "query", viper.GetString("api-key"))
 	}
-	return client.New(rt, strfmt.Default), nil
+
+	registry := strfmt.Default
+	registry.Add("signedCheckpoint", &util.SignedCheckpoint{}, util.SignedCheckpointValidator)
+	return client.New(rt, registry), nil
 }
 
 type urlFlag struct {
diff --git a/cmd/rekor-cli/app/state/state.go b/cmd/rekor-cli/app/state/state.go
index bcc3989..9ae0807 100644
--- a/cmd/rekor-cli/app/state/state.go
+++ b/cmd/rekor-cli/app/state/state.go
@@ -21,13 +21,13 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/google/trillian/types"
 	"github.com/mitchellh/go-homedir"
+	"github.com/sigstore/rekor/pkg/util"
 )
 
-type persistedState map[string]*types.LogRootV1
+type persistedState map[string]*util.RekorSTH
 
-func Dump(url string, lr *types.LogRootV1) error {
+func Dump(url string, sth *util.RekorSTH) error {
 	rekorDir, err := getRekorDir()
 	if err != nil {
 		return err
@@ -38,7 +38,7 @@ func Dump(url string, lr *types.LogRootV1) error {
 	if state == nil {
 		state = make(persistedState)
 	}
-	state[url] = lr
+	state[url] = sth
 
 	b, err := json.Marshal(&state)
 	if err != nil {
@@ -67,7 +67,7 @@ func loadStateFile() persistedState {
 	return result
 }
 
-func Load(url string) *types.LogRootV1 {
+func Load(url string) *util.RekorSTH {
 	if state := loadStateFile(); state != nil {
 		return state[url]
 	}
diff --git a/cmd/rekor-server/app/root.go b/cmd/rekor-server/app/root.go
index 0b11aba..33022ae 100644
--- a/cmd/rekor-server/app/root.go
+++ b/cmd/rekor-server/app/root.go
@@ -59,6 +59,7 @@ func init() {
 	rootCmd.PersistentFlags().String("trillian_log_server.address", "127.0.0.1", "Trillian log server address")
 	rootCmd.PersistentFlags().Uint16("trillian_log_server.port", 8090, "Trillian log server port")
 	rootCmd.PersistentFlags().Uint("trillian_log_server.tlog_id", 0, "Trillian tree id")
+	rootCmd.PersistentFlags().String("rekor_server.hostname", "rekor.sigstore.dev", "public hostname of instance")
 	rootCmd.PersistentFlags().String("rekor_server.address", "127.0.0.1", "Address to bind to")
 	rootCmd.PersistentFlags().String("rekor_server.signer", "memory", "Rekor signer to use. Current valid options include: [gcpkms, memory]")
 	rootCmd.PersistentFlags().String("rekor_server.timestamp_chain", "", "PEM encoded cert chain to use for timestamping")
diff --git a/cmd/rekor-server/app/watch.go b/cmd/rekor-server/app/watch.go
index 5e2d043..1278cbd 100644
--- a/cmd/rekor-server/app/watch.go
+++ b/cmd/rekor-server/app/watch.go
@@ -29,7 +29,6 @@ import (
 	_ "gocloud.dev/blob/fileblob" // fileblob
 	_ "gocloud.dev/blob/gcsblob"
 
-	"github.com/google/trillian/types"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
@@ -37,9 +36,8 @@ import (
 
 	"github.com/sigstore/rekor/cmd/rekor-cli/app"
 	"github.com/sigstore/rekor/pkg/generated/client"
-	"github.com/sigstore/rekor/pkg/generated/models"
 	"github.com/sigstore/rekor/pkg/log"
-	"github.com/sigstore/rekor/pkg/verify"
+	"github.com/sigstore/rekor/pkg/util"
 )
 
 const rekorSthBucketEnv = "REKOR_STH_BUCKET"
@@ -109,10 +107,10 @@ var watchCmd = &cobra.Command{
 				log.Logger.Warnf("error verifiying tree: %s", err)
 				continue
 			}
-			log.Logger.Infof("Found and verified state at %d %d", lr.VerifiedLogRoot.TreeSize, lr.VerifiedLogRoot.TimestampNanos)
-			if last != nil && last.VerifiedLogRoot.TreeSize == lr.VerifiedLogRoot.TreeSize {
+			log.Logger.Infof("Found and verified state at %d", lr.VerifiedLogRoot.Size)
+			if last != nil && last.VerifiedLogRoot.Size == lr.VerifiedLogRoot.Size {
 				log.Logger.Infof("Last tree size is the same as the current one: %d %d",
-					last.VerifiedLogRoot.TreeSize, lr.VerifiedLogRoot.TreeSize)
+					last.VerifiedLogRoot.Size, lr.VerifiedLogRoot.Size)
 				// If it's the same, it shouldn't have changed but we'll still upload anyway
 				// in case that failed.
 			}
@@ -136,23 +134,17 @@ func doCheck(c *client.Rekor, pub crypto.PublicKey) (*SignedAndUnsignedLogRoot,
 	if err != nil {
 		return nil, errors.Wrap(err, "getting log info")
 	}
-	logRoot := *li.Payload.SignedTreeHead.LogRoot
-	if logRoot == nil {
-		return nil, errors.New("logroot should not be nil")
-	}
-	signature := *li.Payload.SignedTreeHead.Signature
-	if signature == nil {
-		return nil, errors.New("signature should not be nil")
+	sth := util.RekorSTH{}
+	if err := sth.UnmarshalText([]byte(*li.Payload.SignedTreeHead)); err != nil {
+		return nil, errors.Wrap(err, "unmarshalling tree head")
 	}
 
-	verifiedLogRoot, err := verify.SignedLogRoot(pub, logRoot, signature)
-	if err != nil {
-		return nil, errors.Wrap(err, "signing log root")
+	if !sth.Verify(pub) {
+		return nil, errors.Wrap(err, "signed tree head failed verification")
 	}
 
 	return &SignedAndUnsignedLogRoot{
-		SignedLogRoot:   li.GetPayload().SignedTreeHead,
-		VerifiedLogRoot: verifiedLogRoot,
+		VerifiedLogRoot: &sth,
 	}, nil
 }
 
@@ -162,7 +154,7 @@ func uploadToBlobStorage(ctx context.Context, bucket *blob.Bucket, lr *SignedAnd
 		return err
 	}
 
-	objName := fmt.Sprintf("sth-%d.json", lr.VerifiedLogRoot.TreeSize)
+	objName := fmt.Sprintf("sth-%d.json", lr.VerifiedLogRoot.Size)
 	w, err := bucket.NewWriter(ctx, objName, nil)
 	if err != nil {
 		return err
@@ -176,6 +168,5 @@ func uploadToBlobStorage(ctx context.Context, bucket *blob.Bucket, lr *SignedAnd
 
 // For JSON marshalling
 type SignedAndUnsignedLogRoot struct {
-	SignedLogRoot   *models.LogInfoSignedTreeHead
-	VerifiedLogRoot *types.LogRootV1
+	VerifiedLogRoot *util.RekorSTH
 }
diff --git a/go.mod b/go.mod
index 2580ef8..8584a86 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/go-openapi/swag v0.19.15
 	github.com/go-openapi/validate v0.20.2
 	github.com/go-playground/validator v9.31.0+incompatible
-	github.com/google/go-cmp v0.5.5
+	github.com/google/go-cmp v0.5.6
 	github.com/google/rpmpack v0.0.0-20210107155803-d6befbf05148
 	github.com/google/trillian v1.3.14-0.20210413093047-5e12fb368c8f
 	github.com/in-toto/in-toto-golang v0.1.1-0.20210528150343-f7dc21abaccf
@@ -40,6 +40,7 @@ require (
 	go.uber.org/zap v1.17.0
 	gocloud.dev v0.23.0
 	golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf
+	golang.org/x/mod v0.4.2
 	golang.org/x/net v0.0.0-20210505214959-0714010a04ed
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
diff --git a/go.sum b/go.sum
index f113f74..9d5f416 100644
--- a/go.sum
+++ b/go.sum
@@ -568,8 +568,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-containerregistry v0.4.1 h1:Lrcj2AOoZ7WKawsoKAh2O0dH0tBqMW2lTEmozmK4Z3k=
 github.com/google/go-containerregistry v0.4.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
 github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM=
diff --git a/openapi.yaml b/openapi.yaml
index 2827d95..b4709ad 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -444,25 +444,9 @@ definitions:
         description: The current number of nodes in the merkle tree
         minimum: 1
       signedTreeHead:
-        type: object
+        type: string
+        format: signedCheckpoint
         description: The current signed tree head
-        properties: 
-          keyHint:
-            type: string
-            description: Key hint
-            format: byte
-          logRoot:
-            type: string
-            description: Log root
-            format: byte
-          signature:
-            type: string
-            description: Signature for log root
-            format: byte
-        required:
-          - keyHint
-          - logRoot
-          - signature
     required:
       - rootHash
       - treeSize
diff --git a/pkg/api/error.go b/pkg/api/error.go
index bb87015..9fd0543 100644
--- a/pkg/api/error.go
+++ b/pkg/api/error.go
@@ -45,6 +45,7 @@ const (
 	lastSizeGreaterThanKnown          = "The tree size requested(%d) was greater than what is currently observable(%d)"
 	signingError                      = "Error signing"
 	failedToGenerateTimestampResponse = "Error generating timestamp response"
+	sthGenerateError                  = "Error generating signed tree head"
 )
 
 func errorMsg(message string, code int) *models.Error {
diff --git a/pkg/api/tlog.go b/pkg/api/tlog.go
index 5a55ab5..7080a1f 100644
--- a/pkg/api/tlog.go
+++ b/pkg/api/tlog.go
@@ -16,17 +16,22 @@
 package api
 
 import (
+	"encoding/base64"
+	"encoding/binary"
 	"encoding/hex"
 	"fmt"
 	"net/http"
+	"time"
 
 	"github.com/go-openapi/runtime/middleware"
-	"github.com/go-openapi/strfmt"
 	"github.com/google/trillian/types"
+	"github.com/spf13/viper"
+	"golang.org/x/mod/sumdb/note"
 	"google.golang.org/grpc/codes"
 
 	"github.com/sigstore/rekor/pkg/generated/models"
 	"github.com/sigstore/rekor/pkg/generated/restapi/operations/tlog"
+	"github.com/sigstore/rekor/pkg/util"
 )
 
 // GetLogInfoHandler returns the current size of the tree and the STH
@@ -46,25 +51,42 @@ func GetLogInfoHandler(params tlog.GetLogInfoParams) middleware.Responder {
 
 	hashString := hex.EncodeToString(root.RootHash)
 	treeSize := int64(root.TreeSize)
-	logRoot := strfmt.Base64(result.SignedLogRoot.GetLogRoot())
+
+	sth := util.RekorSTH{
+		SignedCheckpoint: util.SignedCheckpoint{
+			Checkpoint: util.Checkpoint{
+				Ecosystem: "Rekor",
+				Size:      root.TreeSize,
+				Hash:      root.RootHash,
+			},
+		},
+	}
+	sth.SetTimestamp(uint64(time.Now().UnixNano()))
+	// TODO: once api.signer implements crypto.Signer, switch to using Sign() API on Checkpoint
 
 	// sign the log root ourselves to get the log root signature
-	sig, _, err := api.signer.Sign(params.HTTPRequest.Context(), result.SignedLogRoot.GetLogRoot())
+	cpString, _ := sth.Checkpoint.MarshalText()
+	sig, _, err := api.signer.Sign(params.HTTPRequest.Context(), []byte(cpString))
 	if err != nil {
-		return handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("signing error: %w", err), trillianCommunicationError)
+		return handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("signing error: %w", err), signingError)
 	}
 
-	signature := strfmt.Base64(sig)
+	sth.Signatures = append(sth.Signatures, note.Signature{
+		Name:   viper.GetString("rekor_server.hostname"),
+		Hash:   binary.BigEndian.Uint32([]byte(api.pubkeyHash)[0:4]),
+		Base64: base64.StdEncoding.EncodeToString(sig),
+	})
 
-	sth := models.LogInfoSignedTreeHead{
-		LogRoot:   &logRoot,
-		Signature: &signature,
+	scBytes, err := sth.MarshalText()
+	if err != nil {
+		return handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("marshalling error: %w", err), sthGenerateError)
 	}
+	scString := string(scBytes)
 
 	logInfo := models.LogInfo{
 		RootHash:       &hashString,
 		TreeSize:       &treeSize,
-		SignedTreeHead: &sth,
+		SignedTreeHead: &scString,
 	}
 	return tlog.NewGetLogInfoOK().WithPayload(&logInfo)
 }
diff --git a/pkg/generated/models/log_info.go b/pkg/generated/models/log_info.go
index f51a1d4..b576bf0 100644
--- a/pkg/generated/models/log_info.go
+++ b/pkg/generated/models/log_info.go
@@ -40,9 +40,9 @@ type LogInfo struct {
 	// Pattern: ^[0-9a-fA-F]{64}$
 	RootHash *string `json:"rootHash"`
 
-	// signed tree head
+	// The current signed tree head
 	// Required: true
-	SignedTreeHead *LogInfoSignedTreeHead `json:"signedTreeHead"`
+	SignedTreeHead *string `json:"signedTreeHead"`
 
 	// The current number of nodes in the merkle tree
 	// Required: true
@@ -91,15 +91,6 @@ func (m *LogInfo) validateSignedTreeHead(formats strfmt.Registry) error {
 		return err
 	}
 
-	if m.SignedTreeHead != nil {
-		if err := m.SignedTreeHead.Validate(formats); err != nil {
-			if ve, ok := err.(*errors.Validation); ok {
-				return ve.ValidateName("signedTreeHead")
-			}
-			return err
-		}
-	}
-
 	return nil
 }
 
@@ -116,31 +107,8 @@ func (m *LogInfo) validateTreeSize(formats strfmt.Registry) error {
 	return nil
 }
 
-// ContextValidate validate this log info based on the context it is used
+// ContextValidate validates this log info based on context it is used
 func (m *LogInfo) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
-	var res []error
-
-	if err := m.contextValidateSignedTreeHead(ctx, formats); err != nil {
-		res = append(res, err)
-	}
-
-	if len(res) > 0 {
-		return errors.CompositeValidationError(res...)
-	}
-	return nil
-}
-
-func (m *LogInfo) contextValidateSignedTreeHead(ctx context.Context, formats strfmt.Registry) error {
-
-	if m.SignedTreeHead != nil {
-		if err := m.SignedTreeHead.ContextValidate(ctx, formats); err != nil {
-			if ve, ok := err.(*errors.Validation); ok {
-				return ve.ValidateName("signedTreeHead")
-			}
-			return err
-		}
-	}
-
 	return nil
 }
 
@@ -161,96 +129,3 @@ func (m *LogInfo) UnmarshalBinary(b []byte) error {
 	*m = res
 	return nil
 }
-
-// LogInfoSignedTreeHead The current signed tree head
-//
-// swagger:model LogInfoSignedTreeHead
-type LogInfoSignedTreeHead struct {
-
-	// Key hint
-	// Required: true
-	// Format: byte
-	KeyHint *strfmt.Base64 `json:"keyHint"`
-
-	// Log root
-	// Required: true
-	// Format: byte
-	LogRoot *strfmt.Base64 `json:"logRoot"`
-
-	// Signature for log root
-	// Required: true
-	// Format: byte
-	Signature *strfmt.Base64 `json:"signature"`
-}
-
-// Validate validates this log info signed tree head
-func (m *LogInfoSignedTreeHead) Validate(formats strfmt.Registry) error {
-	var res []error
-
-	if err := m.validateKeyHint(formats); err != nil {
-		res = append(res, err)
-	}
-
-	if err := m.validateLogRoot(formats); err != nil {
-		res = append(res, err)
-	}
-
-	if err := m.validateSignature(formats); err != nil {
-		res = append(res, err)
-	}
-
-	if len(res) > 0 {
-		return errors.CompositeValidationError(res...)
-	}
-	return nil
-}
-
-func (m *LogInfoSignedTreeHead) validateKeyHint(formats strfmt.Registry) error {
-
-	if err := validate.Required("signedTreeHead"+"."+"keyHint", "body", m.KeyHint); err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (m *LogInfoSignedTreeHead) validateLogRoot(formats strfmt.Registry) error {
-
-	if err := validate.Required("signedTreeHead"+"."+"logRoot", "body", m.LogRoot); err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (m *LogInfoSignedTreeHead) validateSignature(formats strfmt.Registry) error {
-
-	if err := validate.Required("signedTreeHead"+"."+"signature", "body", m.Signature); err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// ContextValidate validates this log info signed tree head based on context it is used
-func (m *LogInfoSignedTreeHead) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
-	return nil
-}
-
-// MarshalBinary interface implementation
-func (m *LogInfoSignedTreeHead) MarshalBinary() ([]byte, error) {
-	if m == nil {
-		return nil, nil
-	}
-	return swag.WriteJSON(m)
-}
-
-// UnmarshalBinary interface implementation
-func (m *LogInfoSignedTreeHead) UnmarshalBinary(b []byte) error {
-	var res LogInfoSignedTreeHead
-	if err := swag.ReadJSON(b, &res); err != nil {
-		return err
-	}
-	*m = res
-	return nil
-}
diff --git a/pkg/generated/restapi/configure_rekor_server.go b/pkg/generated/restapi/configure_rekor_server.go
index 0d22cfe..262d71e 100644
--- a/pkg/generated/restapi/configure_rekor_server.go
+++ b/pkg/generated/restapi/configure_rekor_server.go
@@ -94,6 +94,8 @@ func configureAPI(api *operations.RekorServerAPI) http.Handler {
 	api.TimestampGetTimestampResponseHandler = timestamp.GetTimestampResponseHandlerFunc(pkgapi.TimestampResponseHandler)
 	api.TimestampGetTimestampCertChainHandler = timestamp.GetTimestampCertChainHandlerFunc(pkgapi.GetTimestampCertChainHandler)
 
+	api.RegisterFormat("signedCheckpoint", &util.SignedCheckpoint{}, util.SignedCheckpointValidator)
+
 	api.PreServerShutdown = func() {}
 
 	api.ServerShutdown = func() {}
diff --git a/pkg/generated/restapi/embedded_spec.go b/pkg/generated/restapi/embedded_spec.go
index 0a1bbe1..ab2d4da 100644
--- a/pkg/generated/restapi/embedded_spec.go
+++ b/pkg/generated/restapi/embedded_spec.go
@@ -523,29 +523,8 @@ func init() {
         },
         "signedTreeHead": {
           "description": "The current signed tree head",
-          "type": "object",
-          "required": [
-            "keyHint",
-            "logRoot",
-            "signature"
-          ],
-          "properties": {
-            "keyHint": {
-              "description": "Key hint",
-              "type": "string",
-              "format": "byte"
-            },
-            "logRoot": {
-              "description": "Log root",
-              "type": "string",
-              "format": "byte"
-            },
-            "signature": {
-              "description": "Signature for log root",
-              "type": "string",
-              "format": "byte"
-            }
-          }
+          "type": "string",
+          "format": "signedCheckpoint"
         },
         "treeSize": {
           "description": "The current number of nodes in the merkle tree",
@@ -1496,29 +1475,8 @@ func init() {
         },
         "signedTreeHead": {
           "description": "The current signed tree head",
-          "type": "object",
-          "required": [
-            "keyHint",
-            "logRoot",
-            "signature"
-          ],
-          "properties": {
-            "keyHint": {
-              "description": "Key hint",
-              "type": "string",
-              "format": "byte"
-            },
-            "logRoot": {
-              "description": "Log root",
-              "type": "string",
-              "format": "byte"
-            },
-            "signature": {
-              "description": "Signature for log root",
-              "type": "string",
-              "format": "byte"
-            }
-          }
+          "type": "string",
+          "format": "signedCheckpoint"
         },
         "treeSize": {
           "description": "The current number of nodes in the merkle tree",
@@ -1527,32 +1485,6 @@ func init() {
         }
       }
     },
-    "LogInfoSignedTreeHead": {
-      "description": "The current signed tree head",
-      "type": "object",
-      "required": [
-        "keyHint",
-        "logRoot",
-        "signature"
-      ],
-      "properties": {
-        "keyHint": {
-          "description": "Key hint",
-          "type": "string",
-          "format": "byte"
-        },
-        "logRoot": {
-          "description": "Log root",
-          "type": "string",
-          "format": "byte"
-        },
-        "signature": {
-          "description": "Signature for log root",
-          "type": "string",
-          "format": "byte"
-        }
-      }
-    },
     "ProposedEntry": {
       "type": "object",
       "required": [
diff --git a/pkg/util/checkpoint.go b/pkg/util/checkpoint.go
new file mode 100644
index 0000000..d16f0f0
--- /dev/null
+++ b/pkg/util/checkpoint.go
@@ -0,0 +1,319 @@
+//
+// Copyright 2021 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 util
+
+import (
+	"bufio"
+	"bytes"
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/binary"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+	"golang.org/x/mod/sumdb/note"
+)
+
+// heavily borrowed from https://github.com/google/trillian-examples/blob/master/formats/log/checkpoint.go
+
+type Checkpoint struct {
+	// Ecosystem is the ecosystem/version string
+	Ecosystem string
+	// Size is the number of entries in the log at this checkpoint.
+	Size uint64
+	// Hash is the hash which commits to the contents of the entire log.
+	Hash []byte
+	// OtherContent is any additional data to be included in the signed payload; each element is assumed to be one line
+	OtherContent []string
+}
+
+// String returns the String representation of the Checkpoint
+func (c Checkpoint) String() string {
+	var b strings.Builder
+	fmt.Fprintf(&b, "%s\n%d\n%s\n", c.Ecosystem, c.Size, base64.StdEncoding.EncodeToString(c.Hash))
+	for _, line := range c.OtherContent {
+		fmt.Fprintf(&b, "%s\n", line)
+	}
+	return b.String()
+}
+
+// MarshalText returns the common format representation of this Checkpoint.
+func (c Checkpoint) MarshalText() ([]byte, error) {
+	return []byte(c.String()), nil
+}
+
+// UnmarshalText parses the common formatted checkpoint data and stores the result
+// in the Checkpoint.
+//
+// The supplied data is expected to begin with the following 3 lines of text,
+// each followed by a newline:
+// <ecosystem/version string>
+// <decimal representation of log size>
+// <base64 representation of root hash>
+// <optional non-empty line of other content>...
+// <optional non-empty line of other content>...
+//
+// This will discard any content found after the checkpoint (including signatures)
+func (c *Checkpoint) UnmarshalText(data []byte) error {
+	l := bytes.Split(data, []byte("\n"))
+	if len(l) < 4 {
+		return errors.New("invalid checkpoint - too few newlines")
+	}
+	eco := string(l[0])
+	if len(eco) == 0 {
+		return errors.New("invalid checkpoint - empty ecosystem")
+	}
+	size, err := strconv.ParseUint(string(l[1]), 10, 64)
+	if err != nil {
+		return fmt.Errorf("invalid checkpoint - size invalid: %w", err)
+	}
+	h, err := base64.StdEncoding.DecodeString(string(l[2]))
+	if err != nil {
+		return fmt.Errorf("invalid checkpoint - invalid hash: %w", err)
+	}
+	*c = Checkpoint{
+		Ecosystem: eco,
+		Size:      size,
+		Hash:      h,
+	}
+	if len(l) >= 5 {
+		for _, line := range l[3:] {
+			if len(line) == 0 {
+				break
+			}
+			c.OtherContent = append(c.OtherContent, string(line))
+		}
+	}
+	return nil
+}
+
+func (c Checkpoint) Sign(identity string, signer crypto.Signer, opts crypto.SignerOpts) (*note.Signature, error) {
+	hf := crypto.SHA256
+	if opts != nil {
+		hf = opts.HashFunc()
+	}
+
+	input, _ := c.MarshalText()
+	var digest []byte
+	if hf != crypto.Hash(0) {
+		hasher := hf.New()
+		_, err := hasher.Write(input)
+		if err != nil {
+			return nil, errors.Wrap(err, "hashing checkpoint before signing")
+		}
+		digest = hasher.Sum(nil)
+	} else {
+		digest, _ = c.MarshalText()
+	}
+
+	sig, err := signer.Sign(rand.Reader, digest, opts)
+	if err != nil {
+		return nil, errors.Wrap(err, "signing checkpoint")
+	}
+	pubKeyBytes, err := x509.MarshalPKIXPublicKey(signer.Public())
+	if err != nil {
+		return nil, errors.Wrap(err, "marshalling public key")
+	}
+
+	pkSha := sha256.Sum256(pubKeyBytes)
+
+	signature := note.Signature{
+		Name:   identity,
+		Hash:   binary.BigEndian.Uint32(pkSha[:]),
+		Base64: base64.StdEncoding.EncodeToString(sig),
+	}
+
+	return &signature, nil
+
+}
+
+type SignedCheckpoint struct {
+	Checkpoint
+	// Signatures are one or more signature lines covering the payload
+	Signatures []note.Signature
+}
+
+// String returns the String representation of the SignedCheckpoint
+func (s SignedCheckpoint) String() string {
+	var b strings.Builder
+	b.WriteString(s.Checkpoint.String())
+	b.WriteRune('\n')
+	for _, sig := range s.Signatures {
+		var hbuf [4]byte
+		binary.BigEndian.PutUint32(hbuf[:], sig.Hash)
+		sigBytes, _ := base64.StdEncoding.DecodeString(sig.Base64)
+		b64 := base64.StdEncoding.EncodeToString(append(hbuf[:], sigBytes...))
+		fmt.Fprintf(&b, "%c %s %s\n", '\u2014', sig.Name, b64)
+	}
+
+	return b.String()
+}
+
+// UnmarshalText parses the common formatted checkpoint data and stores the result
+// in the SignedCheckpoint. THIS DOES NOT VERIFY SIGNATURES INSIDE THE CONTENT!
+//
+// The supplied data is expected to contain a single Checkpoint, followed by a single
+// line with no comment, followed by one or more lines with the following format:
+//
+// \u2014 name signature
+//
+// * name is the string associated with the signer
+// * signature is a base64 encoded string; the first 4 bytes of the decoded value is a
+//   hint to the public key; it is a big-endian encoded uint32 representing the first
+//   4 bytes of the SHA256 hash of the public key
+func (s *SignedCheckpoint) UnmarshalText(data []byte) error {
+	sc := SignedCheckpoint{}
+
+	if err := sc.Checkpoint.UnmarshalText(data); err != nil {
+		return errors.Wrap(err, "parsing checkpoint portion")
+	}
+
+	b := bufio.NewScanner(bytes.NewReader(data))
+	var pastCheckpoint bool
+	for b.Scan() {
+		if len(b.Text()) == 0 {
+			pastCheckpoint = true
+			continue
+		}
+		if pastCheckpoint {
+			var name, signature string
+			if _, err := fmt.Fscanf(strings.NewReader(b.Text()), "\u2014 %s %s\n", &name, &signature); err != nil {
+				return errors.Wrap(err, "parsing signature")
+			}
+
+			sigBytes, err := base64.StdEncoding.DecodeString(signature)
+			if err != nil {
+				return errors.Wrap(err, "decoding signature")
+			}
+			if len(sigBytes) < 5 {
+				return errors.New("signature is too small")
+			}
+
+			sig := note.Signature{
+				Name:   name,
+				Hash:   binary.BigEndian.Uint32(sigBytes[0:4]),
+				Base64: base64.StdEncoding.EncodeToString(sigBytes[4:]),
+			}
+			sc.Signatures = append(sc.Signatures, sig)
+		}
+	}
+	if len(sc.Signatures) == 0 {
+		return errors.New("no signatures found in input")
+	}
+
+	// copy sc to s
+	*s = sc
+	return nil
+}
+
+// Verify checks that one of the signatures can be successfully verified using
+// the supplied public key
+func (s SignedCheckpoint) Verify(public crypto.PublicKey) bool {
+	if len(s.Signatures) == 0 {
+		return false
+	}
+
+	msg, _ := s.Checkpoint.MarshalText()
+	//TODO: generalize this
+	digest := sha256.Sum256(msg)
+
+	for _, s := range s.Signatures {
+		sigBytes, err := base64.StdEncoding.DecodeString(s.Base64)
+		if err != nil {
+			return false
+		}
+		switch pk := public.(type) {
+		case *rsa.PublicKey:
+			if err := rsa.VerifyPSS(pk, crypto.SHA256, digest[:], sigBytes, &rsa.PSSOptions{Hash: crypto.SHA256}); err == nil {
+				return true
+			}
+		case *ecdsa.PublicKey:
+			if ecdsa.VerifyASN1(pk, digest[:], sigBytes) {
+				return true
+			}
+		case *ed25519.PublicKey:
+			if ed25519.Verify(*pk, msg, sigBytes) {
+				return true
+			}
+		default:
+			return false
+		}
+	}
+	return false
+}
+
+// Sign adds an additional signature to a SignedCheckpoint object
+// The signature is added to the signature array as well as being directly returned to the caller
+func (s *SignedCheckpoint) Sign(identity string, signer crypto.Signer, opts crypto.SignerOpts) (*note.Signature, error) {
+	sig, err := s.Checkpoint.Sign(identity, signer, opts)
+	if err != nil {
+		return nil, err
+	}
+	s.Signatures = append(s.Signatures, *sig)
+	return sig, nil
+}
+
+// MarshalText returns the common format representation of this SignedCheckpoint.
+func (s SignedCheckpoint) MarshalText() ([]byte, error) {
+	return []byte(s.String()), nil
+}
+
+func SignedCheckpointValidator(strToValidate string) bool {
+	s := SignedCheckpoint{}
+	return s.UnmarshalText([]byte(strToValidate)) == nil
+}
+
+func CheckpointValidator(strToValidate string) bool {
+	c := Checkpoint{}
+	return c.UnmarshalText([]byte(strToValidate)) == nil
+}
+
+type RekorSTH struct {
+	SignedCheckpoint
+}
+
+func (r *RekorSTH) SetTimestamp(timestamp uint64) {
+	var ts uint64
+	for i, val := range r.OtherContent {
+		if n, _ := fmt.Fscanf(strings.NewReader(val), "Timestamp: %d", &ts); n == 1 {
+			r.OtherContent = append(r.OtherContent[:i], r.OtherContent[i+1:]...)
+		}
+	}
+	r.OtherContent = append(r.OtherContent, fmt.Sprintf("Timestamp: %d", timestamp))
+}
+
+func (r *RekorSTH) GetTimestamp() uint64 {
+	var ts uint64
+	for _, val := range r.OtherContent {
+		if n, _ := fmt.Fscanf(strings.NewReader(val), "Timestamp: %d", &ts); n == 1 {
+			break
+		}
+	}
+	return ts
+}
+
+func RekorSTHValidator(strToValidate string) bool {
+	r := RekorSTH{}
+	return r.UnmarshalText([]byte(strToValidate)) == nil
+}
diff --git a/pkg/util/checkpoint_test.go b/pkg/util/checkpoint_test.go
new file mode 100644
index 0000000..2a91a43
--- /dev/null
+++ b/pkg/util/checkpoint_test.go
@@ -0,0 +1,443 @@
+//
+// Copyright 2021 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 util
+
+import (
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/mod/sumdb/note"
+)
+
+// heavily borrowed from https://github.com/google/trillian-examples/blob/master/formats/log/checkpoint_test.go
+
+func TestMarshalCheckpoint(t *testing.T) {
+	for _, test := range []struct {
+		c    Checkpoint
+		want string
+	}{
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint v0",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			want: "Log Checkpoint v0\n123\nYmFuYW5hcw==\n",
+		}, {
+			c: Checkpoint{
+				Ecosystem: "Banana Checkpoint v5",
+				Size:      9944,
+				Hash:      []byte("the view from the tree tops is great!"),
+			},
+			want: "Banana Checkpoint v5\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n",
+		}, {
+			c: Checkpoint{
+				Ecosystem:    "Banana Checkpoint v7",
+				Size:         9943,
+				Hash:         []byte("the view from the tree tops is great!"),
+				OtherContent: []string{"foo", "bar"},
+			},
+			want: "Banana Checkpoint v7\n9943\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\nfoo\nbar\n",
+		},
+	} {
+		t.Run(string(test.c.Hash), func(t *testing.T) {
+			got, err := test.c.MarshalText()
+			if err != nil {
+				t.Fatalf("unexpected error marshalling: %v", err)
+			}
+			if string(got) != test.want {
+				t.Fatalf("Marshal = %q, want %q", got, test.want)
+			}
+		})
+	}
+}
+
+func TestUnmarshalCheckpoint(t *testing.T) {
+	for _, test := range []struct {
+		desc    string
+		m       string
+		want    Checkpoint
+		wantErr bool
+	}{
+		{
+			desc: "valid one",
+			m:    "Log Checkpoint v0\n123\nYmFuYW5hcw==\n",
+			want: Checkpoint{
+				Ecosystem: "Log Checkpoint v0",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+		}, {
+			desc: "valid with different ecosystem",
+			m:    "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n",
+			want: Checkpoint{
+				Ecosystem: "Banana Checkpoint v1",
+				Size:      9944,
+				Hash:      []byte("the view from the tree tops is great!"),
+			},
+		}, {
+			desc: "valid with trailing data",
+			m:    "Log Checkpoint v0\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\nHere's some associated data.\n",
+			want: Checkpoint{
+				Ecosystem:    "Log Checkpoint v0",
+				Size:         9944,
+				Hash:         []byte("the view from the tree tops is great!"),
+				OtherContent: []string{"Here's some associated data."},
+			},
+		}, {
+			desc: "valid with multiple trailing data lines",
+			m:    "Log Checkpoint v0\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\nlots\nof\nlines\n",
+			want: Checkpoint{
+				Ecosystem:    "Log Checkpoint v0",
+				Size:         9944,
+				Hash:         []byte("the view from the tree tops is great!"),
+				OtherContent: []string{"lots", "of", "lines"},
+			},
+		}, {
+			desc: "valid with trailing newlines",
+			m:    "Log Checkpoint v0\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n\n\n",
+			want: Checkpoint{
+				Ecosystem: "Log Checkpoint v0",
+				Size:      9944,
+				Hash:      []byte("the view from the tree tops is great!"),
+			},
+		}, {
+			desc:    "invalid - insufficient lines",
+			m:       "Head\n9944\n",
+			wantErr: true,
+		}, {
+			desc:    "invalid - empty header",
+			m:       "\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n",
+			wantErr: true,
+		}, {
+			desc:    "invalid - missing newline on roothash",
+			m:       "Log Checkpoint v0\n123\nYmFuYW5hcw==",
+			wantErr: true,
+		}, {
+			desc:    "invalid size - not a number",
+			m:       "Log Checkpoint v0\nbananas\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n",
+			wantErr: true,
+		}, {
+			desc:    "invalid size - negative",
+			m:       "Log Checkpoint v0\n-34\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n",
+			wantErr: true,
+		}, {
+			desc:    "invalid size - too large",
+			m:       "Log Checkpoint v0\n3438945738945739845734895735\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n",
+			wantErr: true,
+		}, {
+			desc:    "invalid roothash - not base64",
+			m:       "Log Checkpoint v0\n123\nThisIsn'tBase64\n",
+			wantErr: true,
+		},
+	} {
+		t.Run(string(test.desc), func(t *testing.T) {
+			var got Checkpoint
+			var gotErr error
+			if gotErr = got.UnmarshalText([]byte(test.m)); (gotErr != nil) != test.wantErr {
+				t.Fatalf("Unmarshal = %q, wantErr: %T", gotErr, test.wantErr)
+			}
+			if diff := cmp.Diff(test.want, got); len(diff) != 0 {
+				t.Fatalf("Unmarshalled Checkpoint with diff %s", diff)
+			}
+			if !test.wantErr != CheckpointValidator(test.m) {
+				t.Fatalf("Validator failed for %s", test.desc)
+			}
+		})
+	}
+}
+
+func TestSigningRoundtripCheckpoint(t *testing.T) {
+	rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	ecdsaKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	edPubKey, edPrivKey, _ := ed25519.GenerateKey(rand.Reader)
+	for _, test := range []struct {
+		c             Checkpoint
+		identity      string
+		signer        crypto.Signer
+		pubKey        crypto.PublicKey
+		opts          crypto.SignerOpts
+		wantSignErr   bool
+		wantVerifyErr bool
+	}{
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint RSA v0",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        rsaKey,
+			pubKey:        rsaKey.Public(),
+			opts:          &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthAuto, Hash: crypto.SHA256},
+			wantSignErr:   false,
+			wantVerifyErr: false,
+		},
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint ECDSA v0",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        ecdsaKey,
+			pubKey:        ecdsaKey.Public(),
+			opts:          nil,
+			wantSignErr:   false,
+			wantVerifyErr: false,
+		},
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint Ed25519 v0",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        edPrivKey,
+			pubKey:        &edPubKey,
+			opts:          crypto.Hash(0),
+			wantSignErr:   false,
+			wantVerifyErr: false,
+		},
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint Mismatch v0",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        edPrivKey,
+			pubKey:        ecdsaKey.Public(),
+			opts:          crypto.Hash(0),
+			wantSignErr:   false,
+			wantVerifyErr: true,
+		},
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint Mismatch v1",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        ecdsaKey,
+			pubKey:        rsaKey.Public(),
+			opts:          &rsa.PSSOptions{Hash: crypto.SHA256},
+			wantSignErr:   false,
+			wantVerifyErr: true,
+		},
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint Mismatch v2",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        edPrivKey,
+			pubKey:        rsaKey.Public(),
+			opts:          crypto.Hash(0),
+			wantSignErr:   false,
+			wantVerifyErr: true,
+		},
+		{
+			c: Checkpoint{
+				Ecosystem: "Log Checkpoint Mismatch v3",
+				Size:      123,
+				Hash:      []byte("bananas"),
+			},
+			identity:      "someone",
+			signer:        ecdsaKey,
+			pubKey:        &edPubKey,
+			opts:          nil,
+			wantSignErr:   false,
+			wantVerifyErr: true,
+		},
+	} {
+		t.Run(string(test.c.Ecosystem), func(t *testing.T) {
+			sig, err := test.c.Sign(test.identity, test.signer, test.opts)
+			if (err != nil) != test.wantSignErr {
+				t.Fatalf("signing test failed: wantSignErr %v, err %v", test.wantSignErr, err)
+			}
+			if !test.wantSignErr {
+				sc := SignedCheckpoint{
+					Checkpoint: test.c,
+					Signatures: []note.Signature{
+						*sig,
+					},
+				}
+				if !sc.Verify(test.pubKey) != test.wantVerifyErr {
+					t.Fatalf("verification test failed %v", sc.Verify(test.pubKey))
+				}
+				if _, err := sc.Sign("second", test.signer, test.opts); err != nil {
+					t.Fatalf("adding second signature failed: %v", err)
+				}
+				if len(sc.Signatures) != 2 {
+					t.Fatalf("expected two signatures on checkpoint, only found %v", len(sc.Signatures))
+				}
+				// finally, test marshalling object and unmarshalling
+				marshalledSc, err := sc.MarshalText()
+				if err != nil {
+					t.Fatalf("error during marshalling: %v", err)
+				}
+				sc2 := SignedCheckpoint{}
+				if err := sc2.UnmarshalText(marshalledSc); err != nil {
+					t.Fatalf("error unmarshalling just marshalled object %v\n%v", err, string(marshalledSc))
+				}
+				if diff := cmp.Diff(sc, sc2); len(diff) != 0 {
+					t.Fatalf("UnmarshalText = diff %s", diff)
+				}
+			}
+		})
+	}
+}
+
+func TestInvalidSigVerification(t *testing.T) {
+	for _, test := range []struct {
+		sc             SignedCheckpoint
+		pubKey         crypto.PublicKey
+		expectedResult bool
+	}{
+		{
+			sc: SignedCheckpoint{
+				Checkpoint: Checkpoint{
+					Ecosystem: "Log Checkpoint v0",
+					Size:      123,
+					Hash:      []byte("bananas"),
+				},
+				Signatures: []note.Signature{},
+			},
+			expectedResult: false,
+		},
+		{
+			sc: SignedCheckpoint{
+				Checkpoint: Checkpoint{
+					Ecosystem: "Log Checkpoint v0",
+					Size:      123,
+					Hash:      []byte("bananas"),
+				},
+				Signatures: []note.Signature{
+					{
+						Name:   "something",
+						Hash:   1234,
+						Base64: "not_base 64 string",
+					},
+				},
+			},
+			expectedResult: false,
+		},
+		{
+			sc: SignedCheckpoint{
+				Checkpoint: Checkpoint{
+					Ecosystem: "Log Checkpoint v0",
+					Size:      123,
+					Hash:      []byte("bananas"),
+				},
+				Signatures: []note.Signature{
+					{
+						Name:   "someone",
+						Hash:   142,
+						Base64: "bm90IGEgc2ln", // valid base64, not a valid signature
+					},
+				},
+			},
+			expectedResult: false,
+		},
+		{
+			sc: SignedCheckpoint{
+				Checkpoint: Checkpoint{
+					Ecosystem: "Log Checkpoint Ed25519 v0",
+					Size:      123,
+					Hash:      []byte("bananas"),
+				},
+				Signatures: []note.Signature{
+					{
+						Name:   "someone",
+						Hash:   1390313051,
+						Base64: "pOhM+S/mYjEYtQsOF4lL8o/dR+nbjoz5Cvg/n486KIismpVq0s4wxBaakmryI7zThjWAqRUyECPL3WSEcVDEBQ==",
+					},
+				},
+			},
+			pubKey:         nil, // valid input, invalid key
+			expectedResult: false,
+		},
+	} {
+		t.Run(string(test.sc.Ecosystem), func(t *testing.T) {
+			result := test.sc.Verify(test.pubKey)
+			if result != test.expectedResult {
+				t.Fatal("verification test generated unexpected result")
+			}
+		})
+	}
+}
+
+// does not test validity of signatures but merely parsing logic
+func TestUnmarshalSignedCheckpoint(t *testing.T) {
+	for _, test := range []struct {
+		desc    string
+		m       string
+		wantErr bool
+	}{
+		{
+			desc:    "invalid checkpoint, no signatures",
+			m:       "Log Checkpoint v0\n\nYmFuYW5hcw==\n\n",
+			wantErr: true,
+		}, {
+			desc:    "valid checkpoint, no signatures",
+			m:       "Log Checkpoint v0\n123\nYmFuYW5hcw==\n\n",
+			wantErr: true,
+		}, {
+			desc:    "incorrect signature line format",
+			m:       "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n* name not-a-sig\n",
+			wantErr: true,
+		}, {
+			desc:    "signature not base64 encoded",
+			m:       "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n\u2014 name not-b64\n",
+			wantErr: true,
+		}, {
+			desc:    "missing identity",
+			m:       "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n\u2014 YQ==\n",
+			wantErr: true,
+		}, {
+			desc:    "signature base64 encoded but too short",
+			m:       "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n\u2014 name YQ==\n",
+			wantErr: true,
+		}, {
+			desc:    "valid signed checkpoint - single signature",
+			m:       "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n\u2014 name pOhM+S/mYjEYtQsOF4lL8o/dR+nbjoz5Cvg/n486KIismpVq0s4wxBaakmryI7zThjWAqRUyECPL3WSEcVDEBQ==\n",
+			wantErr: false,
+		}, {
+			desc:    "valid signed checkpoint - two signatures",
+			m:       "Banana Checkpoint v1\n9944\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n\n\u2014 name pOhM+S/mYjEYtQsOF4lL8o/dR+nbjoz5Cvg/n486KIismpVq0s4wxBaakmryI7zThjWAqRUyECPL3WSEcVDEBQ==\n\u2014 another_name pOhM+S/mYjEYtQsOF4lL8o/dR+nbjoz5Cvg/n486KIismpVq0s4wxBaakmryI7zThjWAqRUyECPL3WSEcVDEBQ==\n",
+			wantErr: false,
+		},
+	} {
+		t.Run(string(test.desc), func(t *testing.T) {
+			var got SignedCheckpoint
+			var gotErr error
+			if gotErr = got.UnmarshalText([]byte(test.m)); (gotErr != nil) != test.wantErr {
+				t.Fatalf("UnmarshalText(%s) = %q, wantErr: %v", test.desc, gotErr, test.wantErr)
+			}
+			if !test.wantErr != SignedCheckpointValidator(test.m) {
+				t.Fatalf("Validator failed for %s", test.desc)
+			}
+		})
+	}
+}
diff --git a/pkg/verify/log_root.go b/pkg/verify/log_root.go
deleted file mode 100644
index 20c0a7b..0000000
--- a/pkg/verify/log_root.go
+++ /dev/null
@@ -1,65 +0,0 @@
-//
-// Copyright 2021 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 verify
-
-import (
-	"crypto"
-	"crypto/ecdsa"
-	"fmt"
-
-	"github.com/google/trillian/types"
-	"github.com/pkg/errors"
-)
-
-// this verification copied from https://github.com/google/trillian/blob/v1.3.13/crypto/verifier.go
-// which has since been deleted
-
-// SignedLogRoot verifies the signed log root and returns its contents
-func SignedLogRoot(pub crypto.PublicKey, logRoot, logRootSignature []byte) (*types.LogRootV1, error) {
-	hash := crypto.SHA256
-	if err := verify(pub, hash, logRoot, logRootSignature); err != nil {
-		return nil, err
-	}
-
-	var lr types.LogRootV1
-	if err := lr.UnmarshalBinary(logRoot); err != nil {
-		return nil, err
-	}
-	return &lr, nil
-}
-
-// verify cryptographically verifies the output of Signer.
-func verify(pub crypto.PublicKey, hasher crypto.Hash, data, sig []byte) error {
-	if sig == nil {
-		return errors.New("signature is nil")
-	}
-
-	h := hasher.New()
-	if _, err := h.Write(data); err != nil {
-		return errors.Wrap(err, "write")
-	}
-	digest := h.Sum(nil)
-
-	switch pub := pub.(type) {
-	case *ecdsa.PublicKey:
-		if !ecdsa.VerifyASN1(pub, digest, sig) {
-			return errors.New("verification failed")
-		}
-	default:
-		return fmt.Errorf("unknown public key type: %T", pub)
-	}
-	return nil
-}
diff --git a/pkg/verify/log_root_test.go b/pkg/verify/log_root_test.go
deleted file mode 100644
index 3ce72ad..0000000
--- a/pkg/verify/log_root_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-//
-// Copyright 2021 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 verify
-
-import (
-	"context"
-	"crypto"
-	"testing"
-
-	"github.com/sigstore/rekor/pkg/signer"
-)
-
-func TestVerify(t *testing.T) {
-	signer, err := signer.NewMemory()
-	if err != nil {
-		t.Fatalf("getting signer: %v", signer)
-	}
-
-	// sign and verify
-	ctx := context.Background()
-	msg := []byte("foo")
-	signature, _, err := signer.Sign(ctx, msg)
-	if err != nil {
-		t.Fatalf("signing: %v", err)
-	}
-
-	// get public key
-	pubKey, err := signer.PublicKey(ctx)
-	if err != nil {
-		t.Fatalf("getting public key: %v", err)
-	}
-
-	// verify should work with correct signature
-	if err := verify(pubKey, crypto.SHA256, msg, signature); err != nil {
-		t.Fatalf("error verifying: %v", err)
-	}
-
-	// and fail with an incorrect signature
-	if err := verify(pubKey, crypto.SHA256, msg, []byte("nope")); err == nil {
-		t.Fatalf("expected failure with incorrect signature")
-	}
-}
-- 
GitLab