2026-09-22 13:13:10 +02:00
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-09-22 17:45:18 +02:00
|
|
|
"crypto/sha256"
|
2026-09-22 13:13:10 +02:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"strconv"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
|
|
|
|
|
|
"software.sslmate.com/src/certspotter/cttypes"
|
|
|
|
|
"software.sslmate.com/src/certspotter/loglist"
|
|
|
|
|
"software.sslmate.com/src/certspotter/monitor"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ monitor.StateProvider = (*Store)(nil)
|
|
|
|
|
|
|
|
|
|
// Prepare implements monitor.StateProvider. It applies the embedded schema so
|
|
|
|
|
// the container needs no writable filesystem.
|
|
|
|
|
func (s *Store) Prepare(ctx context.Context) error {
|
|
|
|
|
if err := s.Migrate(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PrepareLog is a no-op: checkpoint rows are created lazily by StoreLogState.
|
|
|
|
|
func (s *Store) PrepareLog(ctx context.Context, logID monitor.LogID) error { return nil }
|
|
|
|
|
|
|
|
|
|
// StoreLogState persists the certspotter monitor position for a log.
|
|
|
|
|
func (s *Store) StoreLogState(ctx context.Context, logID monitor.LogID, state *monitor.LogState) error {
|
|
|
|
|
data, err := json.Marshal(state)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-09-22 17:45:18 +02:00
|
|
|
key := logID.Base64URLString()
|
|
|
|
|
hash := sha256.Sum256(data)
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
if prev, ok := s.lastState[key]; ok && prev == hash {
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
return nil // unchanged; CertSpotter calls this per batch
|
|
|
|
|
}
|
|
|
|
|
s.lastState[key] = hash
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
|
2026-09-22 13:13:10 +02:00
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
|
|
|
INSERT INTO ct_checkpoints (log_id, state, updated_at)
|
|
|
|
|
VALUES ($1, $2, now())
|
|
|
|
|
ON CONFLICT (log_id) DO UPDATE SET state = EXCLUDED.state, updated_at = now()`,
|
2026-09-22 17:45:18 +02:00
|
|
|
key, data)
|
2026-09-22 13:13:10 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LoadLogState returns nil when no checkpoint exists for the log.
|
|
|
|
|
func (s *Store) LoadLogState(ctx context.Context, logID monitor.LogID) (*monitor.LogState, error) {
|
|
|
|
|
var data []byte
|
|
|
|
|
err := s.pool.QueryRow(ctx, `SELECT state FROM ct_checkpoints WHERE log_id = $1`, logID.Base64URLString()).Scan(&data)
|
|
|
|
|
if err == pgx.ErrNoRows {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
state := new(monitor.LogState)
|
|
|
|
|
if err := json.Unmarshal(data, state); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("corrupt checkpoint for log %s: %w", logID.Base64URLString(), err)
|
|
|
|
|
}
|
|
|
|
|
return state, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sthKey(sth *cttypes.SignedTreeHead) string {
|
|
|
|
|
return strconv.FormatUint(sth.TreeSize, 10) + "-" +
|
|
|
|
|
strconv.FormatUint(sth.Timestamp, 10) + "-" +
|
|
|
|
|
sth.RootHash.Base64String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// StoreSTH stores an unverified signed tree head. If it already exists the
|
|
|
|
|
// original stored_at is preserved.
|
|
|
|
|
func (s *Store) StoreSTH(ctx context.Context, logID monitor.LogID, sth *cttypes.SignedTreeHead) (*monitor.StoredSTH, error) {
|
|
|
|
|
data, err := json.Marshal(sth)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var storedAt time.Time
|
|
|
|
|
err = s.pool.QueryRow(ctx, `
|
|
|
|
|
INSERT INTO ct_sths (log_id, sth_key, tree_size, sth, stored_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, now())
|
|
|
|
|
ON CONFLICT (log_id, sth_key) DO UPDATE SET log_id = EXCLUDED.log_id
|
|
|
|
|
RETURNING stored_at`,
|
|
|
|
|
logID.Base64URLString(), sthKey(sth), sth.TreeSize, data).Scan(&storedAt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &monitor.StoredSTH{SignedTreeHead: *sth, StoredAt: storedAt}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LoadSTHs loads all stored STHs for a log, sorted by tree size.
|
|
|
|
|
func (s *Store) LoadSTHs(ctx context.Context, logID monitor.LogID) ([]*monitor.StoredSTH, error) {
|
|
|
|
|
rows, err := s.pool.Query(ctx,
|
|
|
|
|
`SELECT sth, stored_at FROM ct_sths WHERE log_id = $1 ORDER BY tree_size ASC`,
|
|
|
|
|
logID.Base64URLString())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
var out []*monitor.StoredSTH
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var (
|
|
|
|
|
data []byte
|
|
|
|
|
storedAt time.Time
|
|
|
|
|
)
|
|
|
|
|
if err := rows.Scan(&data, &storedAt); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var sth cttypes.SignedTreeHead
|
|
|
|
|
if err := json.Unmarshal(data, &sth); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
out = append(out, &monitor.StoredSTH{SignedTreeHead: sth, StoredAt: storedAt})
|
|
|
|
|
}
|
|
|
|
|
return out, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RemoveSTH deletes an STH once it has been verified against the Merkle tree.
|
|
|
|
|
func (s *Store) RemoveSTH(ctx context.Context, logID monitor.LogID, sth *cttypes.SignedTreeHead) error {
|
|
|
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM ct_sths WHERE log_id = $1 AND sth_key = $2`,
|
|
|
|
|
logID.Base64URLString(), sthKey(sth))
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 17:48:30 +02:00
|
|
|
// StoreIssuer and LoadIssuer are no-ops. bucketeer's identifier sink bypasses
|
2026-09-22 13:13:10 +02:00
|
|
|
// certificate-chain retrieval entirely, so the issuer cache is never consulted;
|
|
|
|
|
// keeping it empty also satisfies the "no certificate contents in the state
|
|
|
|
|
// store" requirement. The Go runtime cache handles any incidental calls.
|
|
|
|
|
func (s *Store) StoreIssuer(ctx context.Context, fingerprint *[32]byte, issuer []byte) error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) LoadIssuer(ctx context.Context, fingerprint *[32]byte) ([]byte, error) {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NotifyCert is never called because the identifier sink handles certificates.
|
|
|
|
|
func (s *Store) NotifyCert(ctx context.Context, cert *monitor.DiscoveredCert) error { return nil }
|
|
|
|
|
|
|
|
|
|
// NotifyMalformedEntry counts unparseable log entries without logging per entry.
|
|
|
|
|
func (s *Store) NotifyMalformedEntry(ctx context.Context, entry *monitor.LogEntry, parseError error) error {
|
|
|
|
|
if s.metrics != nil {
|
|
|
|
|
s.metrics.MalformedCT.Add(1)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NotifyHealthCheckFailure is rare (once per health-check interval at most), so
|
|
|
|
|
// it is safe to log.
|
|
|
|
|
func (s *Store) NotifyHealthCheckFailure(ctx context.Context, ctlog *loglist.Log, info monitor.HealthCheckFailure) error {
|
|
|
|
|
log.Printf("ct health check failure: %s", info.Summary())
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NotifyError counts transient CT errors. It deliberately does not log, to keep
|
|
|
|
|
// logging off the high-frequency error path.
|
|
|
|
|
func (s *Store) NotifyError(ctx context.Context, ctlog *loglist.Log, notifyErr error) error {
|
|
|
|
|
if s.metrics != nil {
|
|
|
|
|
s.metrics.CTErrors.Add(1)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 17:48:30 +02:00
|
|
|
// GetErrors returns the recent CT errors. bucketeer keeps only aggregate counters.
|
2026-09-22 13:13:10 +02:00
|
|
|
func (s *Store) GetErrors(ctx context.Context, ctlog *loglist.Log, count int) (string, error) {
|
|
|
|
|
return "", nil
|
|
|
|
|
}
|