diff --git a/go.mod b/go.mod index 6d393ee..e7e4f45 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/transparency-dev/formats go 1.21 require ( + github.com/google/certificate-transparency-go v1.1.8 github.com/google/go-cmp v0.6.0 golang.org/x/mod v0.17.0 ) + +require golang.org/x/crypto v0.21.0 // indirect diff --git a/go.sum b/go.sum index 324f938..5f6e3e1 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJXEeYVAjV6/EAEJOf1to= +github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= diff --git a/note/note_cosigv1.go b/note/note_cosigv1.go index 267bb35..3cfb687 100644 --- a/note/note_cosigv1.go +++ b/note/note_cosigv1.go @@ -24,6 +24,7 @@ const ( algEd25519 = 1 algECDSAWithSHA256 = 2 algEd25519CosignatureV1 = 4 + algRFC6962STH = 5 ) const ( diff --git a/note/note_rfc6962.go b/note/note_rfc6962.go new file mode 100644 index 0000000..614d1cf --- /dev/null +++ b/note/note_rfc6962.go @@ -0,0 +1,266 @@ +// Copyright 2024 Google LLC. All Rights Reserved. +// +// 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 note + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + ct "github.com/google/certificate-transparency-go" + "golang.org/x/mod/sumdb/note" +) + +// RFC6962VerifierString creates a note style verifier string for use with NewRFC6962Verifier below. +// logURL is the root URL of the log. +// pubK is the public key of the log. +func RFC6962VerifierString(logURL string, pubK crypto.PublicKey) (string, error) { + if !isValidName(logURL) { + return "", errors.New("invalid name") + } + pubSer, err := x509.MarshalPKIXPublicKey(pubK) + if err != nil { + return "", err + } + logID := sha256.Sum256(pubSer) + name := rfc6962LogName(logURL) + hash := rfc6962Keyhash(name, logID) + return fmt.Sprintf("%s+%08x+%s", name, hash, base64.StdEncoding.EncodeToString(append([]byte{algRFC6962STH}, pubSer...))), nil +} + +// NewRFC6962Verifier creates a note verifier for Sunlight/RFC6962 checkpoint signatures. +func NewRFC6962Verifier(vkey string) (note.Verifier, error) { + name, vkey, _ := strings.Cut(vkey, "+") + hash16, key64, _ := strings.Cut(vkey, "+") + key, err := base64.StdEncoding.DecodeString(key64) + if len(hash16) != 8 || err != nil || !isValidName(name) || len(key) == 0 { + return nil, errVerifierID + } + + v := &rfc6962Verifer{ + name: name, + } + + alg, key := key[0], key[1:] + if alg != algRFC6962STH { + return nil, errVerifierAlg + } + + pubK, err := x509.ParsePKIXPublicKey(key) + if err != nil { + return nil, errors.New("invalid key") + } + + logID := sha256.Sum256(key) + v.keyHash = rfc6962Keyhash(name, logID) + v.v = verifyRFC6962(pubK) + + return v, nil +} + +// signedTreeHead represents the structure returned by the get-sth CT method +// after base64 decoding; see sections 3.5 and 4.3. +type signedTreeHead struct { + Version int `json:"sth_version"` // The version of the protocol to which the STH conforms + TreeSize uint64 `json:"tree_size"` // The number of entries in the new tree + Timestamp uint64 `json:"timestamp"` // The time at which the STH was created + SHA256RootHash []byte `json:"sha256_root_hash"` // The root hash of the log's Merkle tree + TreeHeadSignature []byte `json:"tree_head_signature"` // Log's signature over a TLS-encoded TreeHeadSignature + LogID []byte `json:"log_id"` // The SHA256 hash of the log's public key +} + +// RFC6962STHToCheckpoint converts the provided RFC6962 JSON representation of a CT Signed Tree Head structure to +// a sunlight style signed checkpoint. +// The passed in verifier must be an RFC6929Verifier containing the correct details for the log which signed the STH. +func RFC6962STHToCheckpoint(j []byte, v note.Verifier) ([]byte, error) { + var sth signedTreeHead + if err := json.Unmarshal(j, &sth); err != nil { + return nil, err + } + logName := v.Name() + body := fmt.Sprintf("%s\n%d\n%s\n", logName, sth.TreeSize, base64.StdEncoding.EncodeToString(sth.SHA256RootHash)) + sigBytes := binary.BigEndian.AppendUint32(nil, v.KeyHash()) + sigBytes = binary.BigEndian.AppendUint64(sigBytes, sth.Timestamp) + sigBytes = append(sigBytes, sth.TreeHeadSignature...) + sigLine := fmt.Sprintf("\u2014 %s %s", logName, base64.StdEncoding.EncodeToString(sigBytes)) + + n := []byte(fmt.Sprintf("%s\n%s\n", body, sigLine)) + if _, err := note.Open(n, note.VerifierList(v)); err != nil { + return nil, err + } + return n, nil +} + +// RFC6962STHTimestamp extracts the embedded timestamp from a translated RFC6962 STH signature. +func RFC6962STHTimestamp(s note.Signature) (time.Time, error) { + r, err := base64.StdEncoding.DecodeString(s.Base64) + if err != nil { + return time.UnixMilli(0), errMalformedSig + } + if len(r) <= keyHashSize+timestampSize { + return time.UnixMilli(0), errVerifierAlg + } + r = r[keyHashSize:] // Skip the hash + // Next 8 bytes are the timestamp as Unix millis-since-epoch: + return time.Unix(0, int64(binary.BigEndian.Uint64(r)*1000)), nil +} + +func rfc6962Keyhash(name string, logID [32]byte) uint32 { + h := sha256.New() + h.Write([]byte(name)) + h.Write([]byte{0x0A, algRFC6962STH}) + h.Write(logID[:]) + r := h.Sum(nil) + return binary.BigEndian.Uint32(r) +} + +// rfc6962LogName returns a sunlight checkpoint compatible log name from the +// passed in CT log root URL. +// +// "For example, a log with submission prefix https://rome.ct.example.com/2024h1/ will use rome.ct.example.com/2024h1 as the checkpoint origin line" +// (see https://github.com/C2SP/C2SP/blob/main/sunlight.md#checkpoints) +func rfc6962LogName(logURL string) string { + logURL = strings.ToLower(logURL) + logURL = strings.TrimPrefix(logURL, "http://") + logURL = strings.TrimPrefix(logURL, "https://") + logURL = strings.TrimSuffix(logURL, "/") + return logURL +} + +type rfc6962Verifer struct { + name string + keyHash uint32 + v func(msg []byte, origin string, sig []byte) bool +} + +// Name returns the name associated with the key this verifier is based on. +func (v *rfc6962Verifer) Name() string { + return v.name +} + +// KeyHash returns a truncated hash of the key this verifier is based on. +func (v *rfc6962Verifer) KeyHash() uint32 { + return v.keyHash +} + +// Verify checks that the provided sig is valid over msg for the key this verifier is based on. +func (v *rfc6962Verifer) Verify(msg, sig []byte) bool { + return v.v(msg, v.name, sig) +} + +func verifyRFC6962(key crypto.PublicKey) func([]byte, string, []byte) bool { + return func(msg []byte, origin string, sig []byte) bool { + if len(sig) < timestampSize { + return false + } + t := binary.BigEndian.Uint64(sig) + // slice off timestamp bytes + sig = sig[timestampSize:] + + hAlg := sig[0] + sAlg := sig[1] + // slice off the hAlg and sAlg bytes read above + sig = sig[2:] + + // Figure out sig bytes length + sigLen := binary.BigEndian.Uint16(sig) + sig = sig[2:] // Slice off length bytes + + // All that remains should be the signature bytes themselves, and nothing more. + if len(sig) != int(sigLen) { + return false + } + + // SHA256 (RFC 5246 s7.4.1.4.1.) + if hAlg != 0x04 { + return false + } + + o, m, err := formatRFC6962STH(t, msg) + if err != nil { + return false + } + if origin != o { + return false + } + dgst := sha256.Sum256(m) + switch k := key.(type) { + case *ecdsa.PublicKey: + // RFC 5246 s7.4.1.4.1. + if sAlg != 0x03 { + return false + } + return ecdsa.VerifyASN1(k, dgst[:], sig) + case *rsa.PublicKey: + // RFC 5246 s7.4.1.4.1. + if sAlg != 0x01 { + return false + } + return rsa.VerifyPKCS1v15(k, crypto.SHA256, dgst[:], sig) != nil + default: + return false + } + } +} + +// formatRFC6962STH uses the provided timestamp and checkpoint body to +// recreate the RFC6962 STH structure over which the signature was made. +func formatRFC6962STH(t uint64, msg []byte) (string, []byte, error) { + // Must be: + // origin (schema-less log root url) "\n" + // tree size (decimal) "\n" + // root hash (b64) "\n" + lines := strings.Split(string(msg), "\n") + if len(lines) != 4 { + return "", nil, errors.New("wrong number of lines") + } + if len(lines[3]) != 0 { + return "", nil, errors.New("extension line(s) present") + } + size, err := strconv.ParseUint(lines[1], 10, 64) + if err != nil { + return "", nil, err + } + root, err := base64.StdEncoding.DecodeString(lines[2]) + if err != nil { + return "", nil, err + } + if len(root) != 32 { + return "", nil, errors.New("invalid root hash size") + } + rootHash := [32]byte{} + copy(rootHash[:], root) + + sth := ct.SignedTreeHead{ + TreeSize: size, + Timestamp: t, + SHA256RootHash: rootHash, + } + input, err := ct.SerializeSTHSignatureInput(sth) + if err != nil { + return "", nil, err + } + return lines[0], input, nil +} diff --git a/note/note_rfc6962_test.go b/note/note_rfc6962_test.go new file mode 100644 index 0000000..5877fc9 --- /dev/null +++ b/note/note_rfc6962_test.go @@ -0,0 +1,195 @@ +// Copyright 2024 Google LLC. All Rights Reserved. +// +// 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 note + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/mod/sumdb/note" +) + +const ( + romeCP = "rome.ct.filippo.io/2024h1\n115474666\n2q1K6aiIJR+F7TyhiWOghoWOjY0/3dVBLsBbAvB4xCw=\n\n— rome.ct.filippo.io/2024h1 ePSrrgAAAY5+gVlSBAMARzBFAiEAv8bOMzo3Ed/GbU9fzzJvaStX6i8xTsmEF+NqvpGhIO0CIEn1X+zzVEerdix64GEn97XCXObA2G5JQ8UDDqCKdG5m\n" + romeURL = "https://rome.ct.filippo.io/2024h1/" + romePKDER = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAXM8Ld9qn64g1zVFDh5FtgxS3zj5sqQDwYMs3wrBV3MCBiFhK/iRLxdKF4YsAcJaEglMlu4Lewvzxs0xO2uwEw==" +) + +func TestRFC6962VerifierString(t *testing.T) { + for _, test := range []struct { + name string + url string + pubK []byte + want string + wantErr bool + }{ + { + name: "works - argon ECDSA", + url: "https://ct.googleapis.com/logs/us1/argon2024/", + pubK: mustB64(t, "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHblsqctplMVc5ramA7vSuNxUQxcomQwGAVAdnWTAWUYr3MgDHQW0LagJ95lB7QT75Ve6JgT2EVLOFGU7L3YrwA=="), + want: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", + }, { + name: "works - rome ECDSA", + url: romeURL, + pubK: mustB64(t, romePKDER), + want: "rome.ct.filippo.io/2024h1+78f4abae+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAFzPC3fap+uINc1RQ4eRbYMUt84+bKkA8GDLN8KwVdzAgYhYSv4kS8XSheGLAHCWhIJTJbuC3sL88bNMTtrsBM=", + }, { + name: "works - no scheme", + url: "ct.googleapis.com/logs/us1/argon2024/", + pubK: mustB64(t, "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHblsqctplMVc5ramA7vSuNxUQxcomQwGAVAdnWTAWUYr3MgDHQW0LagJ95lB7QT75Ve6JgT2EVLOFGU7L3YrwA=="), + want: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", + }, { + name: "works - no trailing slash", + url: "ct.googleapis.com/logs/us1/argon2024", + pubK: mustB64(t, "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHblsqctplMVc5ramA7vSuNxUQxcomQwGAVAdnWTAWUYr3MgDHQW0LagJ95lB7QT75Ve6JgT2EVLOFGU7L3YrwA=="), + want: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", + }, { + name: "invalid name", + url: "ct.googleapis.com/logs/us1/argon2024+cheese", + pubK: mustB64(t, "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHblsqctplMVc5ramA7vSuNxUQxcomQwGAVAdnWTAWUYr3MgDHQW0LagJ95lB7QT75Ve6JgT2EVLOFGU7L3YrwA=="), + wantErr: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + k, err := x509.ParsePKIXPublicKey(test.pubK) + if err != nil { + t.Fatalf("Bad test data, couldn't parse key: %v", err) + } + got, err := RFC6962VerifierString(test.url, k) + if gotErr := err != nil; gotErr != test.wantErr { + t.Fatalf("Got err %v, but wantErr: %T", err, test.wantErr) + } + if got != test.want { + t.Fatalf("Got %q, want %q", got, test.want) + } + }) + } +} + +func TestVerify(t *testing.T) { + for _, test := range []struct { + name string + cp []byte + verifier string + wantErr bool + }{ + { + name: "works - rome", + cp: []byte(romeCP), + verifier: "rome.ct.filippo.io/2024h1+78f4abae+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAFzPC3fap+uINc1RQ4eRbYMUt84+bKkA8GDLN8KwVdzAgYhYSv4kS8XSheGLAHCWhIJTJbuC3sL88bNMTtrsBM=", + }, { + name: "invalid signature", + cp: []byte("B0rked" + romeCP), + verifier: "rome.ct.filippo.io/2024h1+78f4abae+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAFzPC3fap+uINc1RQ4eRbYMUt84+bKkA8GDLN8KwVdzAgYhYSv4kS8XSheGLAHCWhIJTJbuC3sL88bNMTtrsBM=", + wantErr: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + v, err := NewRFC6962Verifier(test.verifier) + if err != nil { + t.Fatalf("Invalid verifier: %v", err) + } + + n, err := note.Open(test.cp, note.VerifierList(v)) + if gotErr := err != nil; gotErr != test.wantErr { + t.Fatalf("Got err %q, want err %t", err, test.wantErr) + } + t.Logf("%v", n) + }) + } +} + +func TestRFC6962STHToCheckpoint(t *testing.T) { + for _, test := range []struct { + name string + sth []byte + verifier string + wantErr bool + }{ + { + name: "works", + sth: []byte(`{"tree_size":1267285836,"timestamp":1711642477482,"sha256_root_hash":"SHySaYoaGIV5oCMANTytRfUjfzXb7wvO9xQiGkDJlfQ=","tree_head_signature":"BAMARzBFAiAQWbsL/MbJdeR4jk8xYKWDBDGHyDcntBim9Jr1BvwPnAIhAMedQo0YuBo+ajNd9xyVOMvhOdVAeJYgOhBLQn8rca94"}`), + verifier: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", + }, { + name: "invalid JSON", + sth: []byte(`Bananas are cool : {"tree_size":1267285836,"timestamp":1711642477482,"sha256_root_hash":"SHySaYoaGIV5oCMANTytRfUjfzXb7wvO9xQiGkDJlfQ=","tree_head_signature":"BAMARzBFAiAQWbsL/MbJdeR4jk8xYKWDBDGHyDcntBim9Jr1BvwPnAIhAMedQo0YuBo+ajNd9xyVOMvhOdVAeJYgOhBLQn8rca94"}`), + verifier: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", + wantErr: true, + }, { + name: "invalid STH", + sth: []byte(`{"tree_size":1267285836,"timestamp":1711642477482,"sha256_root_hash":"SHySaYoaGIV5oCMANTytRfUjfzXb7wvO9xQiGkDJlfQ=","tree_head_signature":"BananaSignature"}`), + verifier: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", + wantErr: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + v, err := NewRFC6962Verifier(test.verifier) + if err != nil { + t.Fatalf("Invalid verifier: %v", err) + } + + nRaw, err := RFC6962STHToCheckpoint(test.sth, v) + if gotErr := err != nil; gotErr != test.wantErr { + t.Fatalf("Got err %q, wantErr: %t", err, test.wantErr) + } + if test.wantErr { + return + } + n, err := note.Open(nRaw, note.VerifierList(v)) + if err != nil { + t.Fatalf("Failed to open note: %v", err) + } + + var sth signedTreeHead + if err := json.Unmarshal(test.sth, &sth); err != nil { + t.Fatalf("Failed to parse STH json: %v", err) + } + + lines := strings.Split(n.Text, "\n") + if got, want := lines[0], v.Name(); got != want { + t.Errorf("Got origin %q, want %q", got, want) + } + if got, want := lines[1], strconv.FormatUint(sth.TreeSize, 10); got != want { + t.Errorf("Got treesize %q, want %q", got, want) + } + if got, want := lines[2], base64.StdEncoding.EncodeToString(sth.SHA256RootHash); got != want { + t.Errorf("Got roothash %q, want %q", got, want) + } + + ts, err := RFC6962STHTimestamp(n.Sigs[0]) + if err != nil { + t.Fatalf("RFC6962STHTimestamp: %v", err) + } + if got, want := ts, time.Unix(0, int64(sth.Timestamp*1000)); got != want { + t.Fatalf("Got %v, want %v", got, want) + } + + }) + } +} + +func mustB64(t *testing.T, s string) []byte { + t.Helper() + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + t.Fatalf("invalid base64: %v", err) + } + return b +} diff --git a/note/note_verifier.go b/note/note_verifier.go index 835700d..f6ecd7a 100644 --- a/note/note_verifier.go +++ b/note/note_verifier.go @@ -47,6 +47,8 @@ func NewVerifier(key string) (note.Verifier, error) { return NewECDSAVerifier(key) case algEd25519CosignatureV1: return NewVerifierForCosignatureV1(key) + case algRFC6962STH: + return NewRFC6962Verifier(key) default: return note.NewVerifier(key) } diff --git a/note/note_verifier_test.go b/note/note_verifier_test.go index 671b002..34a4f8b 100644 --- a/note/note_verifier_test.go +++ b/note/note_verifier_test.go @@ -45,6 +45,9 @@ func TestNewVerifier(t *testing.T) { }, { name: "ECDSA works", key: sigStoreKey, + }, { + name: "Sunlight works", + key: "ct.googleapis.com/logs/us1/argon2024+7deb49d0+BTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABB25bKnLaZTFXOa2pgO70rjcVEMXKJkMBgFQHZ1kwFlGK9zIAx0FtC2oCfeZQe0E++VXuiYE9hFSzhRlOy92K8A=", }, } { t.Run(test.name, func(t *testing.T) {