From 250652a3dda80029a7256da924e565d4e2e6a115 Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Fri, 21 Aug 2026 21:30:40 +0530 Subject: [PATCH] tailer: require pinned log keys and stop on STH verification failure VerifySTH silently passed when no log key was pinned or when an STH arrived without a signature, and a failed verification only produced a log line while ingestion continued from the unauthenticated source. NewClient now refuses logs without keys, the tailer skips the fetch cycle on verification failure, and it rejects list entries whose log_id does not match SHA-256 of the key. --- internal/rfc6962/client.go | 32 +++++++++------- internal/tailer/tailer.go | 8 ++++ internal/tailer/tailer_test.go | 68 +++++++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 24 deletions(-) diff --git a/internal/rfc6962/client.go b/internal/rfc6962/client.go index a882055..d792c2e 100644 --- a/internal/rfc6962/client.go +++ b/internal/rfc6962/client.go @@ -36,19 +36,20 @@ type Client struct { func NewClient(baseURL, keyB64 string) (*Client, error) { c := &Client{BaseURL: baseURL, HTTP: &http.Client{Timeout: 30 * time.Second}} - if keyB64 != "" { - der, err := base64.StdEncoding.DecodeString(keyB64) - if err != nil { - return nil, fmt.Errorf("bad log key: %w", err) - } - pub, err := x509.ParsePKIXPublicKey(der) - if err != nil { - return nil, fmt.Errorf("bad log key: %w", err) - } - c.pubKey = pub - sum := sha256.Sum256(der) - c.logID = sum[:] + if keyB64 == "" { + return nil, fmt.Errorf("missing log key") } + der, err := base64.StdEncoding.DecodeString(keyB64) + if err != nil { + return nil, fmt.Errorf("bad log key: %w", err) + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("bad log key: %w", err) + } + c.pubKey = pub + sum := sha256.Sum256(der) + c.logID = sum[:] return c, nil } @@ -114,8 +115,11 @@ func (e *HTTPError) Error() string { } func (c *Client) VerifySTH(sth *STH) error { - if c.pubKey == nil || len(sth.TreeHeadSignature) == 0 { - return nil + if c.pubKey == nil { + return fmt.Errorf("no log key pinned") + } + if len(sth.TreeHeadSignature) == 0 { + return fmt.Errorf("sth has no signature") } ds, err := decodeDigitallySigned(sth.TreeHeadSignature) if err != nil { diff --git a/internal/tailer/tailer.go b/internal/tailer/tailer.go index f010fa9..0fafde0 100644 --- a/internal/tailer/tailer.go +++ b/internal/tailer/tailer.go @@ -62,6 +62,10 @@ func (t *Tailer) tailOne(ctx context.Context, lg loglist.Log, state string) { log.Printf("tail %s: %v", lg.LogID, err) return } + if expected := client.LogID(); lg.LogID != "" && expected != lg.LogID { + log.Printf("tail %s: log list key mismatch (sha256(key) = %s), skipping", lg.LogID, expected) + return + } id := lg.LogID wm, err := t.Store.Watermark(id) if err != nil { @@ -105,6 +109,10 @@ func (t *Tailer) tailOne(ctx context.Context, lg loglist.Log, state string) { } if err := client.VerifySTH(sth); err != nil { log.Printf("tail %s: verify: %v", client.ShortID(), err) + if !sleepCtx(ctx, jitter(interval)) { + return + } + continue } target := sth.TreeSize if drainTarget >= 0 && drainTarget < target { diff --git a/internal/tailer/tailer_test.go b/internal/tailer/tailer_test.go index 5516e47..1e34334 100644 --- a/internal/tailer/tailer_test.go +++ b/internal/tailer/tailer_test.go @@ -2,8 +2,11 @@ package tailer import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/binary" @@ -24,6 +27,45 @@ type fakeLog struct { entries [][]byte sthHits int shortRead bool + key *ecdsa.PrivateKey +} + +func newFakeLog(t *testing.T) *fakeLog { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return &fakeLog{key: key} +} + +// keyB64 returns the log's public key in the log list format, and logID its +// RFC 6962 log id (base64 of SHA-256 of the public key DER). +func (f *fakeLog) keyB64(t *testing.T) (keyB64, logID string) { + t.Helper() + der, err := x509.MarshalPKIXPublicKey(&f.key.PublicKey) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(der) + return base64.StdEncoding.EncodeToString(der), base64.StdEncoding.EncodeToString(sum[:]) +} + +func (f *fakeLog) signSTH(t *testing.T, ts int64, size int64, root []byte) []byte { + t.Helper() + input := make([]byte, 0, 2+8+8+32) + input = append(input, 0, 1) + input = binary.BigEndian.AppendUint64(input, uint64(ts)) + input = binary.BigEndian.AppendUint64(input, uint64(size)) + input = append(input, root...) + digest := sha256.Sum256(input) + sig, err := ecdsa.SignASN1(rand.Reader, f.key, digest[:]) + if err != nil { + t.Fatal(err) + } + out := []byte{4, 3} + out = binary.BigEndian.AppendUint16(out, uint16(len(sig))) + return append(out, sig...) } func (f *fakeLog) leafInput(der []byte, ts int64) []byte { @@ -45,9 +87,10 @@ func (f *fakeLog) serve(t *testing.T) *httptest.Server { root := make([]byte, 32) rand.Read(root) json.NewEncoder(w).Encode(map[string]any{ - "tree_size": len(f.entries), - "timestamp": time.Now().UnixMilli(), - "sha256_root_hash": base64.StdEncoding.EncodeToString(root), + "tree_size": len(f.entries), + "timestamp": time.Now().UnixMilli(), + "sha256_root_hash": base64.StdEncoding.EncodeToString(root), + "tree_head_signature": base64.StdEncoding.EncodeToString(f.signSTH(t, time.Now().UnixMilli(), int64(len(f.entries)), root)), }) case "/ct/v1/get-entries": var start, end int64 @@ -99,7 +142,9 @@ func TestTailToSearchRoundTrip(t *testing.T) { for _, short := range []bool{false, true} { name := fmt.Sprintf("shortread=%v", short) t.Run(name, func(t *testing.T) { - fl := &fakeLog{shortRead: short} + fl := newFakeLog(t) + fl.shortRead = short + keyB64, logID := fl.keyB64(t) ts1 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli() ts2 := time.Date(2021, 6, 15, 12, 0, 0, 0, time.UTC).UnixMilli() c1 := makeLeafCert(t, []string{"roundtrip.example.com", "www.roundtrip.example.com"}) @@ -116,7 +161,8 @@ func TestTailToSearchRoundTrip(t *testing.T) { tr := &Tailer{Store: st, Interval: time.Millisecond, Window: 1} logs := []loglist.Log{{ - LogID: "testlog", + LogID: logID, + Key: keyB64, URL: srv.URL, State: map[string]loglist.StateDetail{"usable": {}}, }} @@ -149,7 +195,7 @@ func TestTailToSearchRoundTrip(t *testing.T) { if res[0].Sub != "roundtrip.example.com" || res[2].Sub != "mail.roundtrip.example.com" { t.Errorf("insertion order wrong: %+v", res) } - wm, err := st.Watermark("testlog") + wm, err := st.Watermark(logID) if err != nil || wm != 2 { t.Errorf("watermark = %d, %v", wm, err) } @@ -158,7 +204,8 @@ func TestTailToSearchRoundTrip(t *testing.T) { } func TestSTHRegressionIgnored(t *testing.T) { - fl := &fakeLog{} + fl := newFakeLog(t) + keyB64, logID := fl.keyB64(t) der := makeLeafCert(t, []string{"reg.example.com"}) fl.entries = [][]byte{fl.leafInput(der, 1000)} srv := fl.serve(t) @@ -169,17 +216,18 @@ func TestSTHRegressionIgnored(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.SetWatermarkSync("reglog", 5); err != nil { + if err := st.SetWatermarkSync(logID, 5); err != nil { t.Fatal(err) } tr := &Tailer{Store: st, Interval: time.Millisecond} tr.Sync(context.Background(), []loglist.Log{{ - LogID: "reglog", + LogID: logID, + Key: keyB64, URL: srv.URL, State: map[string]loglist.StateDetail{"usable": {}}, }}) time.Sleep(50 * time.Millisecond) - wm, _ := st.Watermark("reglog") + wm, _ := st.Watermark(logID) if wm < 5 { t.Errorf("watermark rewound to %d", wm) }