diff --git a/cmd/rekor-cli/app/get.go b/cmd/rekor-cli/app/get.go index 16da04c94..90d59dffc 100644 --- a/cmd/rekor-cli/app/get.go +++ b/cmd/rekor-cli/app/get.go @@ -118,6 +118,13 @@ var getCmd = &cobra.Command{ return nil, fmt.Errorf("unable to verify entry was added to log: %w", err) } + // verify checkpoint + if entry.Verification.InclusionProof.Checkpoint != nil { + if err := verify.VerifyCheckpointSignature(&e, verifier); err != nil { + return nil, err + } + } + return parseEntry(ix, entry) } } diff --git a/cmd/rekor-cli/app/upload.go b/cmd/rekor-cli/app/upload.go index f504072db..a5ec22340 100644 --- a/cmd/rekor-cli/app/upload.go +++ b/cmd/rekor-cli/app/upload.go @@ -141,6 +141,17 @@ var uploadCmd = &cobra.Command{ if err := verify.VerifySignedEntryTimestamp(ctx, &logEntry, verifier); err != nil { return nil, fmt.Errorf("unable to verify entry was added to log: %w", err) } + // TODO: Remove conditional once inclusion proof/checkpoint is always returned by server. + if logEntry.Verification.InclusionProof != nil { + // verify inclusion proof + if err := verify.VerifyInclusion(ctx, &logEntry); err != nil { + return nil, fmt.Errorf("error verifying inclusion proof: %w", err) + } + // verify checkpoint + if err := verify.VerifyCheckpointSignature(&logEntry, verifier); err != nil { + return nil, err + } + } return &uploadCmdOutput{ Location: string(resp.Location), diff --git a/cmd/rekor-cli/app/verify.go b/cmd/rekor-cli/app/verify.go index 85293174a..844b4bbc1 100644 --- a/cmd/rekor-cli/app/verify.go +++ b/cmd/rekor-cli/app/verify.go @@ -37,18 +37,24 @@ import ( ) type verifyCmdOutput struct { - RootHash string - EntryUUID string - Index int64 - Size int64 - Hashes []string + RootHash string + EntryUUID string + Index int64 + Size int64 + Hashes []string + Checkpoint string } func (v *verifyCmdOutput) String() string { s := fmt.Sprintf("Current Root Hash: %v\n", v.RootHash) s += fmt.Sprintf("Entry Hash: %v\n", v.EntryUUID) s += fmt.Sprintf("Entry Index: %v\n", v.Index) - s += fmt.Sprintf("Current Tree Size: %v\n\n", v.Size) + s += fmt.Sprintf("Current Tree Size: %v\n", v.Size) + if len(v.Checkpoint) > 0 { + s += fmt.Sprintf("Checkpoint:\n%v\n\n", v.Checkpoint) + } else { + s += "\n" + } s += "Inclusion Proof:\n" hasher := rfc6962.DefaultHasher @@ -147,6 +153,9 @@ var verifyCmd = &cobra.Command{ Size: *v.Verification.InclusionProof.TreeSize, Hashes: v.Verification.InclusionProof.Hashes, } + if v.Verification.InclusionProof.Checkpoint != nil { + o.Checkpoint = *v.Verification.InclusionProof.Checkpoint + } entry = v } @@ -167,6 +176,7 @@ var verifyCmd = &cobra.Command{ return nil, err } + // verify inclusion proof, checkpoint, and SET if err := verify.VerifyLogEntry(ctx, &entry, verifier); err != nil { return nil, fmt.Errorf("validating entry: %w", err) } diff --git a/openapi.yaml b/openapi.yaml index 93af089b6..d6311e302 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -631,11 +631,16 @@ definitions: type: string description: SHA256 hash value expressed in hexadecimal format pattern: '^[0-9a-fA-F]{64}$' + checkpoint: + type: string + format: signedCheckpoint + description: The checkpoint (signed tree head) that the inclusion proof is based on required: - logIndex - rootHash - treeSize - hashes + - checkpoint Error: type: object diff --git a/pkg/api/entries.go b/pkg/api/entries.go index c16220389..c4d1e50c4 100644 --- a/pkg/api/entries.go +++ b/pkg/api/entries.go @@ -42,6 +42,7 @@ import ( "github.com/sigstore/rekor/pkg/log" "github.com/sigstore/rekor/pkg/sharding" "github.com/sigstore/rekor/pkg/types" + "github.com/sigstore/rekor/pkg/util" "github.com/sigstore/sigstore/pkg/signature" "github.com/sigstore/sigstore/pkg/signature/options" ) @@ -92,11 +93,17 @@ func logEntryFromLeaf(ctx context.Context, signer signature.Signer, tc TrillianC return nil, fmt.Errorf("signing entry error: %w", err) } + scBytes, err := util.CreateAndSignCheckpoint(ctx, viper.GetString("rekor_server.hostname"), tc.logID, root, api.signer) + if err != nil { + return nil, err + } + inclusionProof := models.InclusionProof{ - TreeSize: swag.Int64(int64(root.TreeSize)), - RootHash: swag.String(hex.EncodeToString(root.RootHash)), - LogIndex: swag.Int64(proof.GetLeafIndex()), - Hashes: hashes, + TreeSize: swag.Int64(int64(root.TreeSize)), + RootHash: swag.String(hex.EncodeToString(root.RootHash)), + LogIndex: swag.Int64(proof.GetLeafIndex()), + Hashes: hashes, + Checkpoint: stringPointer(string(scBytes)), } uuid := hex.EncodeToString(leaf.MerkleLeafHash) @@ -261,7 +268,30 @@ func createLogEntry(params entries.CreateLogEntryParams) (models.LogEntry, middl return nil, handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("signing entry error: %v", err), signingError) } + root := &ttypes.LogRootV1{} + if err := root.UnmarshalBinary(resp.getLeafAndProofResult.SignedLogRoot.LogRoot); err != nil { + return nil, handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("error unmarshalling log root: %v", err), sthGenerateError) + } + hashes := []string{} + for _, hash := range resp.getLeafAndProofResult.Proof.Hashes { + hashes = append(hashes, hex.EncodeToString(hash)) + } + + scBytes, err := util.CreateAndSignCheckpoint(ctx, viper.GetString("rekor_server.hostname"), tc.logID, root, api.signer) + if err != nil { + return nil, handleRekorAPIError(params, http.StatusInternalServerError, err, sthGenerateError) + } + + inclusionProof := models.InclusionProof{ + TreeSize: swag.Int64(int64(root.TreeSize)), + RootHash: swag.String(hex.EncodeToString(root.RootHash)), + LogIndex: swag.Int64(queuedLeaf.LeafIndex), + Hashes: hashes, + Checkpoint: stringPointer(string(scBytes)), + } + logEntryAnon.Verification = &models.LogEntryAnonVerification{ + InclusionProof: &inclusionProof, SignedEntryTimestamp: strfmt.Base64(signature), } diff --git a/pkg/api/tlog.go b/pkg/api/tlog.go index c5993dc9e..33e7d699e 100644 --- a/pkg/api/tlog.go +++ b/pkg/api/tlog.go @@ -21,7 +21,6 @@ import ( "fmt" "net/http" "strconv" - "time" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/swag" @@ -33,7 +32,6 @@ import ( "github.com/sigstore/rekor/pkg/generated/restapi/operations/tlog" "github.com/sigstore/rekor/pkg/log" "github.com/sigstore/rekor/pkg/util" - "github.com/sigstore/sigstore/pkg/signature/options" ) // GetLogInfoHandler returns the current size of the tree and the STH @@ -68,32 +66,16 @@ func GetLogInfoHandler(params tlog.GetLogInfoParams) middleware.Responder { hashString := hex.EncodeToString(root.RootHash) treeSize := int64(root.TreeSize) - sth, err := util.CreateSignedCheckpoint(util.Checkpoint{ - Origin: fmt.Sprintf("%s - %d", viper.GetString("rekor_server.hostname"), tc.ranges.ActiveTreeID()), - Size: root.TreeSize, - Hash: root.RootHash, - }) + scBytes, err := util.CreateAndSignCheckpoint(params.HTTPRequest.Context(), + viper.GetString("rekor_server.hostname"), tc.ranges.ActiveTreeID(), root, api.signer) if err != nil { - return handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("marshalling error: %w", err), sthGenerateError) + return handleRekorAPIError(params, http.StatusInternalServerError, err, sthGenerateError) } - sth.SetTimestamp(uint64(time.Now().UnixNano())) - - // sign the log root ourselves to get the log root signature - _, err = sth.Sign(viper.GetString("rekor_server.hostname"), api.signer, options.WithContext(params.HTTPRequest.Context())) - if err != nil { - return handleRekorAPIError(params, http.StatusInternalServerError, fmt.Errorf("signing error: %w", err), signingError) - } - - scBytes, err := sth.SignedNote.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: &scString, + SignedTreeHead: stringPointer(string(scBytes)), TreeID: stringPointer(fmt.Sprintf("%d", tc.logID)), InactiveShards: inactiveShards, } @@ -169,25 +151,11 @@ func inactiveShardLogInfo(ctx context.Context, tid int64) (*models.InactiveShard hashString := hex.EncodeToString(root.RootHash) treeSize := int64(root.TreeSize) - sth, err := util.CreateSignedCheckpoint(util.Checkpoint{ - Origin: fmt.Sprintf("%s - %d", viper.GetString("rekor_server.hostname"), tid), - Size: root.TreeSize, - Hash: root.RootHash, - }) + scBytes, err := util.CreateAndSignCheckpoint(ctx, viper.GetString("rekor_server.hostname"), tid, root, api.signer) if err != nil { return nil, err } - sth.SetTimestamp(uint64(time.Now().UnixNano())) - // sign the log root ourselves to get the log root signature - if _, err := sth.Sign(viper.GetString("rekor_server.hostname"), api.signer, options.WithContext(ctx)); err != nil { - return nil, err - } - - scBytes, err := sth.SignedNote.MarshalText() - if err != nil { - return nil, err - } m := models.InactiveShardLogInfo{ RootHash: &hashString, TreeSize: &treeSize, diff --git a/pkg/api/trillian_client.go b/pkg/api/trillian_client.go index cf625aa16..6e9278d8b 100644 --- a/pkg/api/trillian_client.go +++ b/pkg/api/trillian_client.go @@ -185,6 +185,8 @@ func (t *TrillianClient) addLeaf(byteValue []byte) *Response { status: status.Code(err), err: err, getAddResult: resp, + // include getLeafAndProofResult for inclusion proof + getLeafAndProofResult: leafResp.getLeafAndProofResult, } } diff --git a/pkg/generated/models/inclusion_proof.go b/pkg/generated/models/inclusion_proof.go index 61399816c..86f0d7b94 100644 --- a/pkg/generated/models/inclusion_proof.go +++ b/pkg/generated/models/inclusion_proof.go @@ -36,6 +36,10 @@ import ( // swagger:model InclusionProof type InclusionProof struct { + // The checkpoint (signed tree head) that the inclusion proof is based on + // Required: true + Checkpoint *string `json:"checkpoint"` + // A list of hashes required to compute the inclusion proof, sorted in order from leaf to root // Required: true Hashes []string `json:"hashes"` @@ -60,6 +64,10 @@ type InclusionProof struct { func (m *InclusionProof) Validate(formats strfmt.Registry) error { var res []error + if err := m.validateCheckpoint(formats); err != nil { + res = append(res, err) + } + if err := m.validateHashes(formats); err != nil { res = append(res, err) } @@ -82,6 +90,15 @@ func (m *InclusionProof) Validate(formats strfmt.Registry) error { return nil } +func (m *InclusionProof) validateCheckpoint(formats strfmt.Registry) error { + + if err := validate.Required("checkpoint", "body", m.Checkpoint); err != nil { + return err + } + + return nil +} + func (m *InclusionProof) validateHashes(formats strfmt.Registry) error { if err := validate.Required("hashes", "body", m.Hashes); err != nil { diff --git a/pkg/generated/restapi/embedded_spec.go b/pkg/generated/restapi/embedded_spec.go index 708261d4e..ad13f3fd5 100644 --- a/pkg/generated/restapi/embedded_spec.go +++ b/pkg/generated/restapi/embedded_spec.go @@ -432,9 +432,15 @@ func init() { "logIndex", "rootHash", "treeSize", - "hashes" + "hashes", + "checkpoint" ], "properties": { + "checkpoint": { + "description": "The checkpoint (signed tree head) that the inclusion proof is based on", + "type": "string", + "format": "signedCheckpoint" + }, "hashes": { "description": "A list of hashes required to compute the inclusion proof, sorted in order from leaf to root", "type": "array", @@ -1831,9 +1837,15 @@ func init() { "logIndex", "rootHash", "treeSize", - "hashes" + "hashes", + "checkpoint" ], "properties": { + "checkpoint": { + "description": "The checkpoint (signed tree head) that the inclusion proof is based on", + "type": "string", + "format": "signedCheckpoint" + }, "hashes": { "description": "A list of hashes required to compute the inclusion proof, sorted in order from leaf to root", "type": "array", diff --git a/pkg/util/checkpoint.go b/pkg/util/checkpoint.go index efa7b8bb1..ee6059e2b 100644 --- a/pkg/util/checkpoint.go +++ b/pkg/util/checkpoint.go @@ -17,11 +17,17 @@ package util import ( "bytes" + "context" "encoding/base64" "errors" "fmt" "strconv" "strings" + "time" + + "github.com/google/trillian/types" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/options" ) // heavily borrowed from https://github.com/google/trillian-examples/blob/master/formats/log/checkpoint.go @@ -160,3 +166,24 @@ func (r *SignedCheckpoint) GetTimestamp() uint64 { } return ts } + +// CreateAndSignCheckpoint creates a signed checkpoint as a commitment to the current root hash +func CreateAndSignCheckpoint(ctx context.Context, hostname string, treeID int64, root *types.LogRootV1, signer signature.Signer) ([]byte, error) { + sth, err := CreateSignedCheckpoint(Checkpoint{ + Origin: fmt.Sprintf("%s - %d", hostname, treeID), + Size: root.TreeSize, + Hash: root.RootHash, + }) + if err != nil { + return nil, fmt.Errorf("error creating checkpoint: %v", err) + } + sth.SetTimestamp(uint64(time.Now().UnixNano())) + if _, err := sth.Sign(hostname, signer, options.WithContext(ctx)); err != nil { + return nil, fmt.Errorf("error signing checkpoint: %v", err) + } + scBytes, err := sth.SignedNote.MarshalText() + if err != nil { + return nil, fmt.Errorf("error marshalling checkpoint: %v", err) + } + return scBytes, nil +} diff --git a/pkg/util/checkpoint_test.go b/pkg/util/checkpoint_test.go index 008c0aa1e..d78d6d5ff 100644 --- a/pkg/util/checkpoint_test.go +++ b/pkg/util/checkpoint_test.go @@ -16,16 +16,21 @@ package util import ( + "bytes" + "context" "crypto" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/sha256" + "fmt" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/google/trillian/types" "github.com/sigstore/sigstore/pkg/signature" "github.com/sigstore/sigstore/pkg/signature/options" "golang.org/x/mod/sumdb/note" @@ -442,3 +447,37 @@ func TestUnmarshalSignedCheckpoint(t *testing.T) { }) } } + +func TestSignCheckpoint(t *testing.T) { + hostname := "rekor.localhost" + treeID := int64(123) + rootHash := sha256.Sum256([]byte{1, 2, 3}) + treeSize := uint64(42) + signer, _, err := signature.NewDefaultECDSASignerVerifier() + if err != nil { + t.Fatalf("error generating signer: %v", err) + } + ctx := context.Background() + scBytes, err := CreateAndSignCheckpoint(ctx, hostname, treeID, &types.LogRootV1{TreeSize: treeSize, RootHash: rootHash[:]}, signer) + if err != nil { + t.Fatalf("error creating signed checkpoint: %v", err) + } + + sth := SignedCheckpoint{} + if err := sth.UnmarshalText(scBytes); err != nil { + t.Fatalf("error unmarshalling signed checkpoint: %v", err) + } + if !sth.Verify(signer) { + t.Fatalf("checkpoint signature invalid") + } + expectedOrigin := fmt.Sprintf("%s - %d", hostname, treeID) + if sth.Origin != fmt.Sprintf("%s - %d", hostname, treeID) { + t.Fatalf("unexpected origin: got %s, expected %s", expectedOrigin, sth.Origin) + } + if !bytes.Equal(sth.Hash, rootHash[:]) { + t.Fatalf("unexpected mismatch of root hash") + } + if sth.Size != treeSize { + t.Fatalf("unexpected tree size: got %d, expected %d", sth.Size, treeSize) + } +} diff --git a/pkg/verify/verify.go b/pkg/verify/verify.go index 516aed339..d01dad8e8 100644 --- a/pkg/verify/verify.go +++ b/pkg/verify/verify.go @@ -75,7 +75,7 @@ func ProveConsistency(ctx context.Context, rClient *client.Rekor, // VerifyCurrentCheckpoint verifies the provided checkpoint by verifying consistency // against a newly fetched Checkpoint. -//nolint +// nolint func VerifyCurrentCheckpoint(ctx context.Context, rClient *client.Rekor, verifier signature.Verifier, oldSTH *util.SignedCheckpoint) (*util.SignedCheckpoint, error) { // The oldSTH should already be verified, but check for robustness. @@ -108,10 +108,24 @@ func VerifyCurrentCheckpoint(ctx context.Context, rClient *client.Rekor, verifie return &sth, nil } +// VerifyCheckpointSignature verifies the signature on a checkpoint (signed tree head). It does +// not verify consistency against other checkpoints. +// nolint +func VerifyCheckpointSignature(e *models.LogEntryAnon, verifier signature.Verifier) error { + sth := &util.SignedCheckpoint{} + if err := sth.UnmarshalText([]byte(*e.Verification.InclusionProof.Checkpoint)); err != nil { + return fmt.Errorf("unmarshalling log entry checkpoint to SignedCheckpoint: %w", err) + } + if !sth.Verify(verifier) { + return errors.New("signature on checkpoint did not verify") + } + return nil +} + // VerifyInclusion verifies an entry's inclusion proof. Clients MUST either verify // the root hash against a new STH (via VerifyCurrentCheckpoint) or against a // trusted, existing STH (via ProveConsistency). -//nolint +// nolint func VerifyInclusion(ctx context.Context, e *models.LogEntryAnon) error { if e.Verification == nil || e.Verification.InclusionProof == nil { return errors.New("inclusion proof not provided") @@ -145,7 +159,7 @@ func VerifyInclusion(ctx context.Context, e *models.LogEntryAnon) error { // VerifySignedEntryTimestamp verifies the entry's SET against the provided // public key. -//nolint +// nolint func VerifySignedEntryTimestamp(ctx context.Context, e *models.LogEntryAnon, verifier signature.Verifier) error { if e.Verification == nil { return fmt.Errorf("missing verification") @@ -185,18 +199,23 @@ func VerifySignedEntryTimestamp(ctx context.Context, e *models.LogEntryAnon, ver } // VerifyLogEntry performs verification of a LogEntry given a Rekor verifier. -// Performs inclusion proof verification up to a verified root hash and -// SignedEntryTimestamp verification. -//nolint +// Performs inclusion proof verification up to a verified root hash, +// SignedEntryTimestamp verification, and checkpoint verification. +// nolint func VerifyLogEntry(ctx context.Context, e *models.LogEntryAnon, verifier signature.Verifier) error { // Verify the inclusion proof using the body's leaf hash. if err := VerifyInclusion(ctx, e); err != nil { return err } - // TODO: If/when we return an STH in the response, verify that too, against an - // optional known STH as well. + // TODO: Add support for verifying consistency against an optional provided checkpoint. // See https://github.com/sigstore/rekor/issues/988 + // TODO: Remove conditional once checkpoint is always returned by server. + if e.Verification.InclusionProof.Checkpoint != nil { + if err := VerifyCheckpointSignature(e, verifier); err != nil { + return err + } + } // Verify the Signed Entry Timestamp. if err := VerifySignedEntryTimestamp(ctx, e, verifier); err != nil { diff --git a/tests/e2e_test.go b/tests/e2e_test.go index 34d018b42..0cd9ec732 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -154,6 +154,7 @@ func TestUploadVerifyRekord(t *testing.T) { // Now we should be able to verify it. out = runCli(t, "verify", "--artifact", artifactPath, "--signature", sigPath, "--public-key", pubPath) outputContains(t, out, "Inclusion Proof:") + outputContains(t, out, "Checkpoint:") } func TestUploadVerifyHashedRekord(t *testing.T) { @@ -183,6 +184,7 @@ func TestUploadVerifyHashedRekord(t *testing.T) { // Now we should be able to verify it. out = runCli(t, "verify", "--type=hashedrekord", "--pki-format=x509", "--artifact-hash", dataSHA, "--signature", sigPath, "--public-key", pubPath) outputContains(t, out, "Inclusion Proof:") + outputContains(t, out, "Checkpoint:") } func TestUploadVerifyRpm(t *testing.T) {