diff --git a/pkg/note/note.go b/pkg/note/note.go index e79de1d..d04c8eb 100644 --- a/pkg/note/note.go +++ b/pkg/note/note.go @@ -21,6 +21,7 @@ package note import ( "bytes" "context" + "crypto" "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" @@ -67,6 +68,24 @@ func (n *noteSigner) Sign(msg []byte) ([]byte, error) { return n.sign(msg) } +type noteVerifier struct { + name string + hash uint32 + verify func(msg, sig []byte) bool +} + +func (n *noteVerifier) Name() string { + return n.name +} + +func (n *noteVerifier) KeyHash() uint32 { + return n.hash +} + +func (n *noteVerifier) Verify(msg, sig []byte) bool { + return n.verify(msg, sig) +} + // isValidName reports whether the name conforms to the spec for the origin string of the note text // as defined in https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md#note-text. func isValidName(name string) bool { @@ -112,32 +131,45 @@ func rsaKeyHash(name string, key *rsa.PublicKey) (uint32, error) { return genConformantKeyHash(name, rsaAlg, marshaled), nil } -// NewNoteSigner converts a sigstore/sigstore/pkg/signature.Signer into a note.Signer. -func NewNoteSigner(ctx context.Context, origin string, signer signature.Signer) (note.Signer, error) { - if !isValidName(origin) { - return ¬eSigner{}, fmt.Errorf("invalid name %s", origin) - } - - pubKey, err := signer.PublicKey() - if err != nil { - return ¬eSigner{}, fmt.Errorf("getting public key: %w", err) - } +// keyHash generates a 4-byte identifier for a public key/origin +func keyHash(origin string, key crypto.PublicKey) (uint32, error) { var keyID uint32 - switch pk := pubKey.(type) { + var err error + + switch pk := key.(type) { case *ecdsa.PublicKey: keyID, err = ecdsaKeyHash(pk) if err != nil { - return ¬eSigner{}, fmt.Errorf("getting ECDSA key hash: %w", err) + return 0, fmt.Errorf("getting ECDSA key hash: %w", err) } case ed25519.PublicKey: keyID = ed25519KeyHash(origin, pk) case *rsa.PublicKey: keyID, err = rsaKeyHash(origin, pk) if err != nil { - return ¬eSigner{}, fmt.Errorf("getting RSA key hash: %w", err) + return 0, fmt.Errorf("getting RSA key hash: %w", err) } default: - return ¬eSigner{}, fmt.Errorf("unsupported key type: %T", pubKey) + return 0, fmt.Errorf("unsupported key type: %T", key) + } + + return keyID, nil +} + +// NewNoteSigner converts a sigstore/sigstore/pkg/signature.Signer into a note.Signer. +func NewNoteSigner(ctx context.Context, origin string, signer signature.Signer) (note.Signer, error) { + if !isValidName(origin) { + return nil, fmt.Errorf("invalid name %s", origin) + } + + pubKey, err := signer.PublicKey() + if err != nil { + return nil, fmt.Errorf("getting public key: %w", err) + } + + keyID, err := keyHash(origin, pubKey) + if err != nil { + return nil, err } sign := func(msg []byte) ([]byte, error) { @@ -150,3 +182,30 @@ func NewNoteSigner(ctx context.Context, origin string, signer signature.Signer) sign: sign, }, nil } + +func NewNoteVerifier(origin string, verifier signature.Verifier) (note.Verifier, error) { + if !isValidName(origin) { + return nil, fmt.Errorf("invalid name %s", origin) + } + + pubKey, err := verifier.PublicKey() + if err != nil { + return nil, fmt.Errorf("getting public key: %w", err) + } + + keyID, err := keyHash(origin, pubKey) + if err != nil { + return nil, err + } + + return ¬eVerifier{ + name: origin, + hash: keyID, + verify: func(msg, sig []byte) bool { + if err := verifier.VerifySignature(bytes.NewReader(sig), bytes.NewReader(msg)); err != nil { + return false + } + return true + }, + }, nil +} diff --git a/pkg/verify/verify.go b/pkg/verify/verify.go new file mode 100644 index 0000000..cb4ca21 --- /dev/null +++ b/pkg/verify/verify.go @@ -0,0 +1,59 @@ +// +// Copyright 2025 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 ( + "fmt" + + pbs "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1" + "github.com/sigstore/rekor-tiles/pkg/tessera" + f_log "github.com/transparency-dev/formats/log" + "github.com/transparency-dev/merkle/proof" + "github.com/transparency-dev/merkle/rfc6962" + sumdb_note "golang.org/x/mod/sumdb/note" +) + +// VerifyInclusionProof verifies an entry's inclusion proof +func VerifyInclusionProof(entry *pbs.TransparencyLogEntry, cp *f_log.Checkpoint) error { //nolint: revive + leafHash := rfc6962.DefaultHasher.HashLeaf(entry.CanonicalizedBody) + index, err := tessera.NewSafeInt64(entry.LogIndex) + if err != nil { + return fmt.Errorf("invalid index: %w", err) + } + if err := proof.VerifyInclusion(rfc6962.DefaultHasher, index.U(), cp.Size, leafHash, entry.InclusionProof.Hashes, cp.Hash); err != nil { + return fmt.Errorf("verifying inclusion: %w", err) + } + return nil +} + +// VerifyCheckpoint verifies the signature on the entry's inclusion proof checkpoint +func VerifyCheckpoint(entry *pbs.TransparencyLogEntry, verifier sumdb_note.Verifier) (*f_log.Checkpoint, error) { //nolint: revive + cp, _, _, err := f_log.ParseCheckpoint([]byte(entry.InclusionProof.GetCheckpoint().GetEnvelope()), verifier.Name(), verifier) + if err != nil { + return nil, fmt.Errorf("unverified checkpoint signature: %v", err) + } + return cp, nil +} + +// VerifyLogEntry verifies the log entry. This includes verifying the signature on the entry's +// inclusion proof checkpoint and verifying the entry inclusion proof +func VerifyLogEntry(entry *pbs.TransparencyLogEntry, verifier sumdb_note.Verifier) error { //nolint: revive + cp, err := VerifyCheckpoint(entry, verifier) + if err != nil { + return err + } + return VerifyInclusionProof(entry, cp) +} diff --git a/pkg/verify/verify_test.go b/pkg/verify/verify_test.go new file mode 100644 index 0000000..267d01d --- /dev/null +++ b/pkg/verify/verify_test.go @@ -0,0 +1,233 @@ +// +// Copyright 2025 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/sha256" + "testing" + + pbs "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1" + rekornote "github.com/sigstore/rekor-tiles/pkg/note" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/stretchr/testify/assert" + f_log "github.com/transparency-dev/formats/log" + note "golang.org/x/mod/sumdb/note" +) + +func TestVerifyInclusionProof(t *testing.T) { + hash := []byte{89, 165, 117, 241, 87, 39, 71, 2, 195, 141, 227, 171, 30, 23, 132, 34, 111, 57, 31, 183, 149, 0, 235, 249, 240, 43, 68, 57, 251, 119, 87, 76} + rootHash := []byte{91, 225, 117, 141, 210, 34, 138, 207, 175, 37, 70, 180, 182, 206, 138, 164, 12, 130, 163, 116, 143, 61, 203, 85, 14, 13, 103, 186, 52, 240, 42, 69} + body := []byte("{\"apiVersion\":\"0.0.1\",\"kind\":\"rekord\",\"spec\":{\"data\":{\"hash\":{\"algorithm\":\"sha256\",\"value\":\"ecdc5536f73bdae8816f0ea40726ef5e9b810d914493075903bb90623d97b1d8\"}},\"signature\":{\"content\":\"MEYCIQD/PdPQmKWC1+0BNEd5gKvQGr1xxl3ieUffv3jk1zzJKwIhALBj3xfAyWxlz4jpoIEIV1UfK9vnkUUOSoeZxBZPHKPC\",\"format\":\"x509\",\"publicKey\":{\"content\":\"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFTU9jVGZSQlM5amlYTTgxRlo4Z20vMStvbWVNdwptbi8zNDcvNTU2Zy9scmlTNzJ1TWhZOUxjVCs1VUo2ZkdCZ2xyNVo4TDBKTlN1YXN5ZWQ5T3RhUnZ3PT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==\"}}}}") + + for _, test := range []struct { + name string + proof *pbs.InclusionProof + logSize uint64 + wantErr bool + }{ + { + name: "valid inclusionproof", + proof: &pbs.InclusionProof{ + LogIndex: 1, + TreeSize: 2, + Hashes: [][]byte{ + []byte(hash), + }, + }, + logSize: 2, + wantErr: false, + }, + { + name: "invalid hash", + proof: &pbs.InclusionProof{ + LogIndex: 1, + TreeSize: 2, + Hashes: [][]byte{ + []byte([]byte{0, 165, 117, 241, 87, 39, 71, 2, 195, 141, 227, 171, 30, 23, 132, 34, 111, 57, 31, 183, 149, 0, 235, 249, 240, 43, 68, 57, 251, 119, 87, 76}), + }, + }, + logSize: 2, + wantErr: true, + }, + { + name: "inclusion index beyond log size", + proof: &pbs.InclusionProof{ + LogIndex: 1, + TreeSize: 2, + Hashes: [][]byte{ + []byte(hash), + }, + }, + logSize: 1, + wantErr: true, + }, + { + name: "wrong proof size", + proof: &pbs.InclusionProof{ + LogIndex: 1, + TreeSize: 2, + Hashes: [][]byte{ + []byte(hash), + }, + }, + logSize: 3, + wantErr: true, + }, + } { + t.Run(string(test.name), func(t *testing.T) { + checkpoint := &f_log.Checkpoint{ + Size: test.logSize, + Hash: rootHash, + } + + entry := &pbs.TransparencyLogEntry{ + LogIndex: 1, + InclusionProof: test.proof, + CanonicalizedBody: body, + } + gotErr := VerifyInclusionProof(entry, checkpoint) + if (gotErr != nil) != test.wantErr { + t.Fatalf("VerifyCheckpoint = %t, wantErr %t", gotErr, test.wantErr) + } + }) + } +} + +func getTestEntry(t *testing.T, signer signature.Signer, hostname string) *pbs.TransparencyLogEntry { + noteSigner, err := rekornote.NewNoteSigner(context.Background(), hostname, signer) + if err != nil { + t.Fatal(err) + } + rootHash := sha256.Sum256([]byte{1, 2, 3}) + cpRaw := f_log.Checkpoint{ + Origin: hostname, + Size: uint64(2), + Hash: rootHash[:], + }.Marshal() + + n, err := note.Sign(¬e.Note{Text: string(cpRaw)}, noteSigner) + if err != nil { + t.Fatal(err) + } + + return &pbs.TransparencyLogEntry{ + InclusionProof: &pbs.InclusionProof{ + Checkpoint: &pbs.Checkpoint{ + Envelope: string(n), + }, + }, + } +} + +func TestVerifyCheckpoint(t *testing.T) { + hostname := "rekor.localhost" + sv, _, err := signature.NewDefaultECDSASignerVerifier() + if err != nil { + t.Fatal(err) + } + + otherSigner, _, err := signature.NewDefaultECDSASignerVerifier() + if err != nil { + t.Fatal(err) + } + + noteVerifier, err := rekornote.NewNoteVerifier(hostname, sv) + if err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + entry *pbs.TransparencyLogEntry + wantErr bool + }{ + { + name: "valid checkpoint", + entry: getTestEntry(t, sv, hostname), + wantErr: false, + }, + { + name: "hostname mismatch", + entry: getTestEntry(t, sv, "other.host"), + wantErr: true, + }, + { + name: "signature mismatch", + entry: getTestEntry(t, otherSigner, hostname), + wantErr: true, + }, + } { + t.Run(string(test.name), func(t *testing.T) { + _, gotErr := VerifyCheckpoint(test.entry, noteVerifier) + if (gotErr != nil) != test.wantErr { + t.Fatalf("VerifyCheckpoint = %t, wantErr %t", gotErr, test.wantErr) + } + }) + } +} + +func TestVerifyLogEntry(t *testing.T) { + hostname := "rekor.localhost" + hash := []byte{89, 165, 117, 241, 87, 39, 71, 2, 195, 141, 227, 171, 30, 23, 132, 34, 111, 57, 31, 183, 149, 0, 235, 249, 240, 43, 68, 57, 251, 119, 87, 76} + rootHash := []byte{91, 225, 117, 141, 210, 34, 138, 207, 175, 37, 70, 180, 182, 206, 138, 164, 12, 130, 163, 116, 143, 61, 203, 85, 14, 13, 103, 186, 52, 240, 42, 69} + body := []byte("{\"apiVersion\":\"0.0.1\",\"kind\":\"rekord\",\"spec\":{\"data\":{\"hash\":{\"algorithm\":\"sha256\",\"value\":\"ecdc5536f73bdae8816f0ea40726ef5e9b810d914493075903bb90623d97b1d8\"}},\"signature\":{\"content\":\"MEYCIQD/PdPQmKWC1+0BNEd5gKvQGr1xxl3ieUffv3jk1zzJKwIhALBj3xfAyWxlz4jpoIEIV1UfK9vnkUUOSoeZxBZPHKPC\",\"format\":\"x509\",\"publicKey\":{\"content\":\"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFTU9jVGZSQlM5amlYTTgxRlo4Z20vMStvbWVNdwptbi8zNDcvNTU2Zy9scmlTNzJ1TWhZOUxjVCs1VUo2ZkdCZ2xyNVo4TDBKTlN1YXN5ZWQ5T3RhUnZ3PT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==\"}}}}") + + sv, _, err := signature.NewDefaultECDSASignerVerifier() + if err != nil { + t.Fatal(err) + } + + noteVerifier, err := rekornote.NewNoteVerifier(hostname, sv) + if err != nil { + t.Fatal(err) + } + + noteSigner, err := rekornote.NewNoteSigner(context.Background(), hostname, sv) + if err != nil { + t.Fatal(err) + } + cpRaw := f_log.Checkpoint{ + Origin: hostname, + Size: uint64(2), + Hash: rootHash, + }.Marshal() + + n, err := note.Sign(¬e.Note{Text: string(cpRaw)}, noteSigner) + if err != nil { + t.Fatal(err) + } + + proof := &pbs.InclusionProof{ + LogIndex: 1, + TreeSize: 2, + Hashes: [][]byte{ + []byte(hash), + }, + Checkpoint: &pbs.Checkpoint{ + Envelope: string(n), + }, + } + + entry := &pbs.TransparencyLogEntry{ + CanonicalizedBody: body, + InclusionProof: proof, + LogIndex: 1, + } + + gotErr := VerifyLogEntry(entry, noteVerifier) + assert.NoError(t, gotErr) +}