From db0e418152fa92af64d1b78eab24a0d96f306594 Mon Sep 17 00:00:00 2001 From: asraa <asraa@google.com> Date: Thu, 29 Jul 2021 10:05:11 -0400 Subject: [PATCH] Add a SignedTimestampNote (#397) * just commit timestampnote Signed-off-by: Asra Ali <asraa@google.com> * add signed timestamp note Signed-off-by: Asra Ali <asraa@google.com> * address validating sha comment Signed-off-by: Asra Ali <asraa@google.com> --- cmd/rekor-cli/app/pflags.go | 20 +- pkg/util/timestamp_note.go | 87 +++--- pkg/util/timestamp_note_test.go | 537 ++++++++++++++++++++++++++++++++ pkg/util/validate.go | 46 +++ 4 files changed, 631 insertions(+), 59 deletions(-) create mode 100644 pkg/util/timestamp_note_test.go create mode 100644 pkg/util/validate.go diff --git a/cmd/rekor-cli/app/pflags.go b/cmd/rekor-cli/app/pflags.go index a144a90..a79de94 100644 --- a/cmd/rekor-cli/app/pflags.go +++ b/cmd/rekor-cli/app/pflags.go @@ -23,6 +23,8 @@ import ( "time" "github.com/sigstore/rekor/pkg/pki" + "github.com/sigstore/rekor/pkg/util" + "github.com/spf13/pflag" "github.com/go-playground/validator" @@ -165,23 +167,11 @@ func isURL(v string) bool { // [sha256:]<64 hexadecimal characters> // where [sha256:] is optional func validateSHA256Value(v string) error { - var prefix, hash string - - split := strings.SplitN(v, ":", 2) - switch len(split) { - case 1: - hash = split[0] - case 2: - prefix = split[0] - hash = split[1] + if err := util.ValidateSHA256Value(v); err != nil { + return fmt.Errorf("error parsing %v flag: %w", shaFlag, err) } - s := struct { - Prefix string `validate:"omitempty,oneof=sha256"` - Hash string `validate:"required,len=64,hexadecimal"` - }{prefix, hash} - - return useValidator(shaFlag, s) + return nil } // validateFileOrURL ensures the provided string is either a valid file path that can be opened or a valid URL diff --git a/pkg/util/timestamp_note.go b/pkg/util/timestamp_note.go index 8399469..0f2961e 100644 --- a/pkg/util/timestamp_note.go +++ b/pkg/util/timestamp_note.go @@ -17,12 +17,7 @@ package util import ( "bytes" - "crypto" - "crypto/rand" - "crypto/sha256" - "crypto/x509" "encoding/base64" - "encoding/binary" "fmt" "net/url" "strconv" @@ -30,7 +25,6 @@ import ( "time" "github.com/pkg/errors" - "golang.org/x/mod/sumdb/note" ) // Signed note based timestamp responses @@ -39,7 +33,7 @@ type TimestampNote struct { // Ecosystem is the ecosystem/version string Ecosystem string // MessageImprint is the hash of the message to timestamp, of the form sha256:<sha> - MessageImprint []byte + MessageImprint string // Nonce is a short random bytes to prove response freshness Nonce []byte // Time is the timestamp to imprint on the message @@ -56,8 +50,8 @@ type TimestampNote struct { func (t TimestampNote) String() string { var b strings.Builder time, _ := t.Time.MarshalText() - fmt.Fprintf(&b, "%s\n%s\n%d\n%s\n%d\n%s", t.Ecosystem, base64.StdEncoding.EncodeToString(t.MessageImprint), - t.Nonce, time, t.Radius, t.CertChainRef) + fmt.Fprintf(&b, "%s\n%s\n%s\n%s\n%d\n%s\n", t.Ecosystem, t.MessageImprint, base64.StdEncoding.EncodeToString(t.Nonce), + time, t.Radius, t.CertChainRef) for _, line := range t.OtherContent { fmt.Fprintf(&b, "%s\n", line) } @@ -75,7 +69,7 @@ func (t TimestampNote) MarshalText() ([]byte, error) { // The supplied data is expected to begin with the following 6 lines of text, // each followed by a newline: // <ecosystem/version string> -// <base64 representation of message hash> +// <message hash of the format sha256:$SHA> // <base64 representation of the nonce> // <RFC 3339 representation of the time> // <decimal representation of radius> @@ -93,10 +87,11 @@ func (t *TimestampNote) UnmarshalText(data []byte) error { if len(eco) == 0 { return errors.New("invalid timestamp note - empty ecosystem") } - h, err := base64.StdEncoding.DecodeString(string(l[1])) - if err != nil { + h := string(l[1]) + if err := ValidateSHA256Value(h); err != nil { return fmt.Errorf("invalid timestamp note - invalid message hash: %w", err) } + nonce, err := base64.StdEncoding.DecodeString(string(l[2])) if err != nil { return fmt.Errorf("invalid timestamp note - invalid nonce: %w", err) @@ -122,8 +117,8 @@ func (t *TimestampNote) UnmarshalText(data []byte) error { Radius: r, CertChainRef: u, } - if len(l) >= 5 { - for _, line := range l[3:] { + if len(l) >= 8 { + for _, line := range l[6:] { if len(line) == 0 { break } @@ -133,41 +128,45 @@ func (t *TimestampNote) UnmarshalText(data []byte) error { return nil } -func (t TimestampNote) Sign(identity string, signer crypto.Signer, opts crypto.SignerOpts) (*note.Signature, error) { - hf := crypto.SHA256 - if opts != nil { - hf = opts.HashFunc() - } - - input, _ := t.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 timestamp note before signing") - } - digest = hasher.Sum(nil) - } else { - digest, _ = t.MarshalText() - } +type SignedTimestampNote struct { + TimestampNote + SignedNote +} - sig, err := signer.Sign(rand.Reader, digest, opts) +func CreateSignedTimestampNote(t TimestampNote) (*SignedTimestampNote, error) { + text, err := t.MarshalText() if err != nil { - return nil, errors.Wrap(err, "signing timestamp note") + return nil, err } - pubKeyBytes, err := x509.MarshalPKIXPublicKey(signer.Public()) - if err != nil { - return nil, errors.Wrap(err, "marshalling public key") + return &SignedTimestampNote{ + TimestampNote: t, + SignedNote: SignedNote{Note: string(text)}, + }, nil +} + +func SignedTimestampNoteValidator(strToValidate string) bool { + s := SignedNote{} + if err := s.UnmarshalText([]byte(strToValidate)); err != nil { + return false } + c := &TimestampNote{} + return c.UnmarshalText([]byte(s.Note)) == nil +} - pkSha := sha256.Sum256(pubKeyBytes) +func TimestampNoteValidator(strToValidate string) bool { + c := &TimestampNote{} + return c.UnmarshalText([]byte(strToValidate)) == nil +} - signature := note.Signature{ - Name: identity, - Hash: binary.BigEndian.Uint32(pkSha[:]), - Base64: base64.StdEncoding.EncodeToString(sig), +func (r *SignedTimestampNote) UnmarshalText(data []byte) error { + s := SignedNote{} + if err := s.UnmarshalText([]byte(data)); err != nil { + return errors.Wrap(err, "unmarshalling signed note") } - - return &signature, nil + t := TimestampNote{} + if err := t.UnmarshalText([]byte(s.Note)); err != nil { + return errors.Wrap(err, "unmarshalling timestamp note") + } + *r = SignedTimestampNote{TimestampNote: t, SignedNote: s} + return nil } diff --git a/pkg/util/timestamp_note_test.go b/pkg/util/timestamp_note_test.go new file mode 100644 index 0000000..5f6e6c4 --- /dev/null +++ b/pkg/util/timestamp_note_test.go @@ -0,0 +1,537 @@ +// +// 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" + "crypto/sha256" + "encoding/hex" + "math/big" + "net/url" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/options" + "golang.org/x/mod/sumdb/note" +) + +// heavily borrowed from https://github.com/google/trillian-examples/blob/master/formats/log/checkpoint_test.go + +func TestMarshalTimestampNote(t *testing.T) { + certChainURL, err := url.Parse("http://localhost:3000/api/v1/timestamp/certchain") + if err != nil { + t.Fatal("error parsing URL") + } + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatal("error loading location") + } + someTime := time.Date(2021, 07, 26, 0, 0, 0, 0, location) + for _, test := range []struct { + msg []byte + t TimestampNote + want string + }{ + { + msg: []byte("bananas"), + t: TimestampNote{ + Ecosystem: "Timestamp Note v0", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + want: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + }, + { + msg: []byte("the view from the tree tops is great!"), + t: TimestampNote{ + Ecosystem: "Timestamp Note v1", + Nonce: big.NewInt(12345678).Bytes(), + Time: someTime, + Radius: 1, + CertChainRef: certChainURL, + }, + want: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + }, { + msg: []byte("bananas"), + t: TimestampNote{ + Ecosystem: "Timestamp Note v7", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + OtherContent: []string{"foo", "bar"}, + }, + want: "Timestamp Note v7\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\nfoo\nbar\n", + }, + } { + t.Run(string(test.t.Ecosystem), func(t *testing.T) { + h := sha256.Sum256([]byte(test.msg)) + test.t.MessageImprint = "sha256:" + hex.EncodeToString(h[:]) + got, err := test.t.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 TestUnmarshalTimestampNote(t *testing.T) { + certChainURL, err := url.Parse("http://localhost:3000/api/v1/timestamp/certchain") + if err != nil { + t.Fatal("error parsing URL") + } + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatal("error loading location") + } + someTime := time.Date(2021, 07, 26, 0, 0, 0, 0, location) + for _, test := range []struct { + desc string + m string + want TimestampNote + wantErr bool + }{ + { + desc: "valid one", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + want: TimestampNote{ + Ecosystem: "Timestamp Note v0", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + wantErr: false, + }, { + desc: "valid with different ecosystem", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + want: TimestampNote{ + Ecosystem: "Timestamp Note v1", + MessageImprint: "sha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e", + Nonce: big.NewInt(12345678).Bytes(), + Time: someTime, + Radius: 1, + CertChainRef: certChainURL, + }, + }, { + desc: "valid with trailing data", + m: "Timestamp Note v7\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\nfoo\nbar\n", + want: TimestampNote{ + Ecosystem: "Timestamp Note v7", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + OtherContent: []string{"foo", "bar"}, + }, + }, { + desc: "valid with trailing newlines", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n\n\n", + want: TimestampNote{ + Ecosystem: "Timestamp Note v1", + MessageImprint: "sha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e", + Nonce: big.NewInt(12345678).Bytes(), + Time: someTime, + Radius: 1, + CertChainRef: certChainURL, + }, + }, { + desc: "invalid - insufficient lines", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\n", + wantErr: true, + }, { + desc: "invalid - empty header", + m: "\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + wantErr: true, + }, { + desc: "invalid - missing newline", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain", + wantErr: true, + }, { + desc: "invalid sha - not a valid sha", + m: "Timestamp Note v1\nsha256:17fb2e8cbf60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + wantErr: true, + }, { + desc: "invalid base64 - nonce", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\n@\n2021-07-26T00:00:00Z\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + wantErr: true, + }, { + desc: "invalid time", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\nabc\n1\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + wantErr: true, + }, { + desc: "invalid radius - not an int", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\na\nhttp://localhost:3000/api/v1/timestamp/certchain\n", + wantErr: true, + }, + { + desc: "invalid cert chain - not a url", + m: "Timestamp Note v1\nsha256:17fb2e8cbf5f60f881c075b1fd0cad32913f2f08b35053fed1c5a785dff90e8e\nvGFO\n2021-07-26T00:00:00Z\n1\n%gh&%ij\n", + wantErr: true, + }, + } { + t.Run(string(test.desc), func(t *testing.T) { + var got TimestampNote + 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 TimestampNote with diff %s", diff) + } + if !test.wantErr != TimestampNoteValidator(test.m) { + t.Fatalf("Validator failed for %s", test.desc) + } + }) + } +} + +func TestSigningRoundtripTimestampNote(t *testing.T) { + certChainURL, err := url.Parse("http://localhost:3000/api/v1/timestamp/certchain") + if err != nil { + t.Fatal("error parsing URL") + } + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatal("error loading location") + } + someTime := time.Date(2021, 07, 26, 0, 0, 0, 0, location) + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ecdsaKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + edPubKey, edPrivKey, _ := ed25519.GenerateKey(rand.Reader) + for _, test := range []struct { + t TimestampNote + identity string + signer crypto.Signer + pubKey crypto.PublicKey + opts crypto.SignerOpts + wantSignErr bool + wantVerifyErr bool + }{ + { + t: TimestampNote{ + Ecosystem: "Timestamp Note RSA v0", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: rsaKey, + pubKey: rsaKey.Public(), + opts: &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthAuto, Hash: crypto.SHA256}, + wantSignErr: false, + wantVerifyErr: false, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note ECDSA v0", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: ecdsaKey, + pubKey: ecdsaKey.Public(), + opts: nil, + wantSignErr: false, + wantVerifyErr: false, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note ED25519 v0", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: edPrivKey, + pubKey: edPubKey, + opts: crypto.Hash(0), + wantSignErr: false, + wantVerifyErr: false, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note Mismatch v0", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: edPrivKey, + pubKey: ecdsaKey.Public(), + opts: crypto.Hash(0), + wantSignErr: false, + wantVerifyErr: true, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note Mismatch v1", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: ecdsaKey, + pubKey: rsaKey.Public(), + opts: &rsa.PSSOptions{Hash: crypto.SHA256}, + wantSignErr: false, + wantVerifyErr: true, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note Mismatch v2", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: edPrivKey, + pubKey: rsaKey.Public(), + opts: &rsa.PSSOptions{Hash: crypto.SHA256}, + wantSignErr: false, + wantVerifyErr: true, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note Mismatch v3", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + identity: "someone", + signer: ecdsaKey, + pubKey: edPubKey, + opts: nil, + wantSignErr: false, + wantVerifyErr: true, + }, + } { + t.Run(string(test.t.Ecosystem), func(t *testing.T) { + text, _ := test.t.MarshalText() + sc := &SignedNote{ + Note: string(text), + } + signer, _ := signature.LoadSigner(test.signer, crypto.SHA256) + if _, ok := test.signer.(*rsa.PrivateKey); ok { + signer, _ = signature.LoadRSAPSSSigner(test.signer.(*rsa.PrivateKey), crypto.SHA256, test.opts.(*rsa.PSSOptions)) + } + + _, err := sc.Sign(test.identity, signer, options.WithCryptoSignerOpts(test.opts)) + if (err != nil) != test.wantSignErr { + t.Fatalf("signing test failed: wantSignErr %v, err %v", test.wantSignErr, err) + } + if !test.wantSignErr { + verifier, _ := signature.LoadVerifier(test.pubKey, crypto.SHA256) + if _, ok := test.pubKey.(*rsa.PublicKey); ok { + verifier, _ = signature.LoadRSAPSSVerifier(test.pubKey.(*rsa.PublicKey), crypto.SHA256, test.opts.(*rsa.PSSOptions)) + } + + if !sc.Verify(verifier) != test.wantVerifyErr { + t.Fatalf("verification test failed %v", sc.Verify(verifier)) + } + if _, err := sc.Sign("second", signer, options.WithCryptoSignerOpts(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) + } + text, _ = test.t.MarshalText() + sc2 := &SignedNote{ + Note: string(text), + } + 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 TestInvalidSigVerificationTimestampNote(t *testing.T) { + certChainURL, err := url.Parse("http://localhost:3000/api/v1/timestamp/certchain") + if err != nil { + t.Fatal("error parsing URL") + } + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatal("error loading location") + } + someTime := time.Date(2021, 07, 26, 0, 0, 0, 0, location) + ecdsaKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + for _, test := range []struct { + t TimestampNote + s []note.Signature + pubKey crypto.PublicKey + expectedResult bool + }{ + { + t: TimestampNote{ + Ecosystem: "Timestamp Note v0", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + s: []note.Signature{}, + pubKey: ecdsaKey.Public(), + expectedResult: false, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note v0 - not base 64", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + pubKey: ecdsaKey.Public(), + s: []note.Signature{ + { + Name: "something", + Hash: 1234, + Base64: "not_base 64 string", + }, + }, + expectedResult: false, + }, + { + t: TimestampNote{ + Ecosystem: "Timestamp Note v0 invalid signature", + MessageImprint: "sha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904", + Nonce: big.NewInt(123).Bytes(), + Time: someTime, + Radius: 123, + CertChainRef: certChainURL, + }, + pubKey: ecdsaKey.Public(), + s: []note.Signature{ + { + Name: "someone", + Hash: 142, + Base64: "bm90IGEgc2ln", // valid base64, not a valid signature + }, + }, + expectedResult: false, + }, + } { + t.Run(string(test.t.Ecosystem), func(t *testing.T) { + text, _ := test.t.MarshalText() + sc := SignedNote{ + Note: string(text), + Signatures: test.s, + } + verifier, _ := signature.LoadVerifier(test.pubKey, crypto.SHA256) + result := sc.Verify(verifier) + if result != test.expectedResult { + t.Fatal("verification test generated unexpected result") + } + }) + } +} + +// does not test validity of signatures but merely parsing logic +func TestUnmarshalSignedTimestampNote(t *testing.T) { + for _, test := range []struct { + desc string + m string + wantErr bool + }{ + { + desc: "invalid timestamp note, no signatures", + m: "Timestamp Note v0\n\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n", + wantErr: true, + }, { + desc: "valid timestamp note, no signatures", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n", + wantErr: true, + }, { + desc: "incorrect signature line format", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n* name not-a-sig\n", + wantErr: true, + }, { + desc: "signature not base64 encoded", + + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n\u2014 name not-b64\n", + wantErr: true, + }, { + desc: "missing identity", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n\u2014 YQ==\n", + wantErr: true, + }, { + desc: "signature base64 encoded but too short", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n\u2014 name YQ==\n", + wantErr: true, + }, { + desc: "valid signed timestamp note - single signature", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\n\n\u2014 name pOhM+S/mYjEYtQsOF4lL8o/dR+nbjoz5Cvg/n486KIismpVq0s4wxBaakmryI7zThjWAqRUyECPL3WSEcVDEBQ==\n", + wantErr: false, + }, { + desc: "valid signed timestamp note - two signatures", + m: "Timestamp Note v0\nsha256:e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904\new==\n2021-07-26T00:00:00Z\n123\nhttp://localhost:3000/api/v1/timestamp/certchain\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 SignedNote + 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 != SignedTimestampNoteValidator(test.m) { + t.Fatalf("Validator failed for %s", test.desc) + } + }) + } +} diff --git a/pkg/util/validate.go b/pkg/util/validate.go new file mode 100644 index 0000000..62420c6 --- /dev/null +++ b/pkg/util/validate.go @@ -0,0 +1,46 @@ +// +// 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 ( + "strings" + + "github.com/go-playground/validator" +) + +// validateSHA256Value ensures that the supplied string matches the following format: +// [sha256:]<64 hexadecimal characters> +// where [sha256:] is optional +func ValidateSHA256Value(v string) error { + var prefix, hash string + + split := strings.SplitN(v, ":", 2) + switch len(split) { + case 1: + hash = split[0] + case 2: + prefix = split[0] + hash = split[1] + } + + s := struct { + Prefix string `validate:"omitempty,oneof=sha256"` + Hash string `validate:"required,len=64,hexadecimal"` + }{prefix, hash} + + validate := validator.New() + return validate.Struct(s) +} -- GitLab