diff --git a/.github/workflows/boulder-ci.yml b/.github/workflows/boulder-ci.yml index 559be38538b..1d8b462c0f9 100644 --- a/.github/workflows/boulder-ci.yml +++ b/.github/workflows/boulder-ci.yml @@ -66,7 +66,7 @@ jobs: # use in tests. It will be set appropriately for each tag in the list # defined in the matrix. BOULDER_TOOLS_TAG: ${{ matrix.BOULDER_TOOLS_TAG }} - BOULDER_VTCOMBOSERVER_TAG: vitessv23.0.0_2026-03-05 + BOULDER_VTCOMBOSERVER_TAG: vitessv23.0.0_2026-06-09 # Sequence of tasks that will be executed as part of the job. steps: diff --git a/.gitignore b/.gitignore index 6545c0c980a..d3b34996c98 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ test/secrets/badkeyrevoker_dburl test/secrets/cert_checker_dburl test/secrets/incidents_dburl test/secrets/incidents_admin_dburl +test/secrets/mtpublisher_dburl test/secrets/revoker_dburl test/secrets/sa_dburl test/secrets/sa_ro_dburl diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go new file mode 100644 index 00000000000..6e947506689 --- /dev/null +++ b/cmd/boulder-mtpublisher/main.go @@ -0,0 +1,74 @@ +package notmain + +import ( + "context" + "flag" + "os" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/mtpublisher" + "github.com/letsencrypt/boulder/sa" +) + +type Config struct { + MTPublisher struct { + DB cmd.DBConfig + + DebugAddr string `validate:"omitempty,hostname_port"` + + // PollInterval is how often the stub scans for checkpoints that still + // lack a mirror cosignature. + PollInterval config.Duration `validate:"required"` + + // MTCLogID is the log this MTPublisher operates on (e.g. + // "44947.4.1.0.44"). Used as a guard on the `mtcLogID` column of the + // connected checkpoints table. + MTCLogID string `validate:"required"` + + // MirrorID identifies the cosigner this publisher writes alongside each + // cosignature (e.g. "32473.9"). + MirrorID string `validate:"required"` + } + Syslog cmd.SyslogConfig + OpenTelemetry cmd.OpenTelemetryConfig +} + +func main() { + debugAddr := flag.String("debug-addr", "", "Debug server address override") + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c Config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + if *debugAddr != "" { + c.MTPublisher.DebugAddr = *debugAddr + } + + scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.MTPublisher.DebugAddr) + defer oTelShutdown(context.Background()) + cmd.LogStartup(logger) + clk := clock.New() + + dbMap, err := sa.InitWrappedDb(c.MTPublisher.DB, scope, logger) + cmd.FailOnError(err, "While initializing dbMap") + + publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.MTCLogID, c.MTPublisher.MirrorID, clk, logger) + cmd.FailOnError(err, "Failed to create MTPublisher stub") + + ctx, cancel := context.WithCancel(context.Background()) + go cmd.CatchSignals(cancel) + publisher.Start(ctx) +} + +func init() { + cmd.RegisterCommand("boulder-mtpublisher", main, &cmd.ConfigValidator{Config: &Config{}}) +} diff --git a/cmd/boulder/main.go b/cmd/boulder/main.go index f202fae8de5..981887c8ec8 100644 --- a/cmd/boulder/main.go +++ b/cmd/boulder/main.go @@ -8,6 +8,7 @@ import ( _ "github.com/letsencrypt/boulder/cmd/bad-key-revoker" _ "github.com/letsencrypt/boulder/cmd/boulder-ca" _ "github.com/letsencrypt/boulder/cmd/boulder-mtca" + _ "github.com/letsencrypt/boulder/cmd/boulder-mtpublisher" _ "github.com/letsencrypt/boulder/cmd/boulder-observer" _ "github.com/letsencrypt/boulder/cmd/boulder-publisher" _ "github.com/letsencrypt/boulder/cmd/boulder-ra" diff --git a/cmd/boulder/main_test.go b/cmd/boulder/main_test.go index d27e56e8837..20f65cfdda8 100644 --- a/cmd/boulder/main_test.go +++ b/cmd/boulder/main_test.go @@ -37,6 +37,8 @@ func TestConfigValidation(t *testing.T) { fileNames = []string{"observer.yml"} case "boulder-publisher": fileNames = []string{"publisher.json"} + case "boulder-mtpublisher": + fileNames = []string{"mtpublisher.json"} case "boulder-ra": fileNames = []string{"ra.json"} case "boulder-sa": diff --git a/docker-compose.yml b/docker-compose.yml index 235ae2515f1..5779221a766 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -163,8 +163,8 @@ services: environment: # By specifying KEYSPACES vttestserver will create the corresponding # databases on startup. - KEYSPACES: boulder_sa,incidents_sa,boulder_sa_next,incidents_sa_next - NUM_SHARDS: 1,1,1,1 + KEYSPACES: boulder_sa,incidents_sa,boulder_sa_next,incidents_sa_next,mtcmeta_44947_4_1_0_44 + NUM_SHARDS: 1,1,1,1,1 healthcheck: # Make sure the service is up and the tables are created. Use `serials` because it happens # to be last in the SQL initialization files, so if it exists the other tables do too. diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go new file mode 100644 index 00000000000..c7d303aacfb --- /dev/null +++ b/mtpublisher/mtpublisher.go @@ -0,0 +1,106 @@ +package mtpublisher + +import ( + "context" + "crypto/ed25519" + "database/sql" + "encoding/binary" + "errors" + "fmt" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" +) + +// MTPublisher polls the MTC issuance log and adds a dummy cosignature to the +// latest checkpoint if it lacks one. It is a stub for the real MTPublisher. +type MTPublisher struct { + db *db.WrappedMap + interval time.Duration + mtcLogID string + mirrorID string + clk clock.Clock + log blog.Logger +} + +// New returns a new *MTPublisher. +func New(dbMap *db.WrappedMap, interval time.Duration, mtcLogID, mirrorID string, clk clock.Clock, log blog.Logger) (*MTPublisher, error) { + if interval <= 0 { + return nil, fmt.Errorf("interval must be positive, got %s", interval) + } + if mtcLogID == "" { + return nil, errors.New("mtcLogID must not be empty") + } + if mirrorID == "" { + return nil, errors.New("mirrorID must not be empty") + } + return &MTPublisher{ + db: dbMap, + interval: interval, + mtcLogID: mtcLogID, + mirrorID: mirrorID, + clk: clk, + log: log, + }, nil +} + +type checkpointEntry struct { + ID int64 `db:"id"` + MTCLogID string `db:"mtcLogID"` + TreeSize int64 `db:"treeSize"` + MirrorSignature []byte `db:"mirrorSignature"` +} + +// dummyCosignature returns a dummy Ed25519 tlog-cosignature: a big-endian +// uint64 timestamp followed by the Ed25519 signature. +func (p *MTPublisher) dummyCosignature() []byte { + out := make([]byte, 8+ed25519.SignatureSize) + binary.BigEndian.PutUint64(out[:8], uint64(p.clk.Now().Unix())) //nolint:gosec // G115: a Unix timestamp is non-negative. + return out +} + +func (p *MTPublisher) publish(ctx context.Context) error { + var latest checkpointEntry + err := p.db.SelectOne(ctx, &latest, + "SELECT id, mtcLogID, treeSize, mirrorSignature FROM checkpoints WHERE mtcLogID = ? ORDER BY treeSize DESC LIMIT 1", + p.mtcLogID) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("selecting the latest checkpoint: %w", err) + } + if latest.MirrorSignature != nil { + return nil + } + + _, err = p.db.ExecContext(ctx, + "UPDATE checkpoints SET mirrorID = ?, mirrorSignature = ? WHERE id = ? AND mtcLogID = ?", + p.mirrorID, p.dummyCosignature(), latest.ID, p.mtcLogID) + if err != nil { + return fmt.Errorf("cosigning checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + } + p.log.Infof("Cosigned checkpoint %d (%s size %d)", latest.ID, latest.MTCLogID, latest.TreeSize) + return nil +} + +// Start attempts to cosign the latest checkpoint at each interval until ctx is +// cancelled. +func (p *MTPublisher) Start(ctx context.Context) { + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for { + err := p.publish(ctx) + if err != nil { + p.log.Errf("Cosigning pass failed: %s", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go new file mode 100644 index 00000000000..702ee0f5e8d --- /dev/null +++ b/mtpublisher/mtpublisher_test.go @@ -0,0 +1,162 @@ +package mtpublisher + +import ( + "context" + "crypto/ed25519" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/db" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test/vars" +) + +const ( + mtcLogID = "44947.4.1.0.44" + mirrorID = "32473.9" +) + +func setupDB(t *testing.T) *db.WrappedMap { + t.Helper() + + dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms) + if err != nil { + t.Fatalf("opening mtcmeta dbMap: %s", err) + } + _, err = dbMap.ExecContext(t.Context(), "TRUNCATE TABLE checkpoints") + if err != nil { + t.Fatalf("truncating checkpoints: %s", err) + } + t.Cleanup(func() { + _, err := dbMap.ExecContext(context.Background(), "TRUNCATE TABLE checkpoints") + if err != nil { + t.Logf("cleaning up checkpoints: %s", err) + } + }) + return dbMap +} + +func insertCheckpoint(t *testing.T, dbMap *db.WrappedMap, logID string, treeSize int64) int64 { + t.Helper() + + res, err := dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", + logID, []byte("mtca-signature"), treeSize, make([]byte, 32)) + if err != nil { + t.Fatalf("inserting checkpoint (%s size %d): %s", logID, treeSize, err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) + } + return id +} + +func lacksCosignature(t *testing.T, dbMap *db.WrappedMap, id int64) bool { + t.Helper() + var count int64 + err := dbMap.SelectOne(t.Context(), &count, + "SELECT COUNT(*) FROM checkpoints WHERE id = ? AND mirrorID IS NULL AND mirrorSignature IS NULL", id) + if err != nil { + t.Fatalf("querying checkpoint %d: %s", id, err) + } + return count == 1 +} + +func TestPublish(t *testing.T) { + dbMap := setupDB(t) + p, err := New(dbMap, time.Second, mtcLogID, mirrorID, clock.NewFake(), blog.NewMock()) + if err != nil { + t.Fatalf("New: %s", err) + } + + // When there are no checkpoints at all, p.publish() should return without + // error. + err = p.publish(t.Context()) + if err != nil { + t.Fatalf("p.publish() on an empty table: %s", err) + } + + // An older checkpoint that is not cosigned, which must be left untouched. + olderCheckpointID := insertCheckpoint(t, dbMap, mtcLogID, 256) + + // The latest checkpoint, which we expect to be cosigned by p.publish(). + latestCheckpointID := insertCheckpoint(t, dbMap, mtcLogID, 512) + + // A checkpoint for another log that was somehow inserted into this table, + // which must be left untouched thanks to the mtcLogID guard. + otherLogID := insertCheckpoint(t, dbMap, "44947.4.2.0.99", 1024) + + err = p.publish(t.Context()) + if err != nil { + t.Fatalf("p.publish(): %s", err) + } + + // Fetch the latest checkpoint. + type row struct { + MirrorID string `db:"mirrorID"` + MirrorSig []byte `db:"mirrorSignature"` + } + var cosigned row + err = dbMap.SelectOne(t.Context(), &cosigned, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE id = ?", latestCheckpointID) + if err != nil { + t.Fatalf("selecting the latest checkpoint: %s", err) + } + + // Check that the latest checkpoint was cosigned, and the others were + // untouched. + if cosigned.MirrorID != mirrorID { + t.Errorf("mirrorID = %q, want %q", cosigned.MirrorID, mirrorID) + } + if len(cosigned.MirrorSig) != 8+ed25519.SignatureSize { + t.Errorf("latest checkpoint's mirrorSignature is %d bytes, want %d", len(cosigned.MirrorSig), 8+ed25519.SignatureSize) + } + if !lacksCosignature(t, dbMap, olderCheckpointID) { + t.Error("older checkpoint was cosigned, only the latest should be") + } + if !lacksCosignature(t, dbMap, otherLogID) { + t.Errorf("otherLogID checkpoint (id=%d), despite guard on mtcLogID", otherLogID) + } +} + +func TestPublishWhenLatestAlreadySigned(t *testing.T) { + dbMap := setupDB(t) + p, err := New(dbMap, time.Second, mtcLogID, mirrorID, clock.NewFake(), blog.NewMock()) + if err != nil { + t.Fatalf("New: %s", err) + } + + // Insert a checkpoint that is already cosigned, which must be left + // untouched. + _, err = dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash, mirrorID, mirrorSignature) VALUES (?, ?, ?, ?, ?, ?)", + mtcLogID, []byte("mtca-signature"), int64(512), make([]byte, 32), "existing.cosigner", []byte("already-signed-bruh")) + if err != nil { + t.Fatalf("inserting cosigned checkpoint: %s", err) + } + + // Insert an older (non-latest) checkpoint that is not cosigned, which must + // be left untouched. + olderID := insertCheckpoint(t, dbMap, mtcLogID, 256) + + err = p.publish(t.Context()) + if err != nil { + t.Fatalf("p.publish(): %s", err) + } + + // The latest checkpoint is already cosigned and the older checkpoint is left untouched. + if !lacksCosignature(t, dbMap, olderID) { + t.Error("older checkpoint was cosigned, the pass should have stopped at the signed latest") + } + var cosignature []byte + err = dbMap.SelectOne(t.Context(), &cosignature, "SELECT mirrorSignature FROM checkpoints WHERE mtcLogID = ? AND treeSize = 512", mtcLogID) + if err != nil { + t.Fatalf("selecting the cosigned checkpoint: %s", err) + } + if string(cosignature) != "already-signed-bruh" { + t.Errorf("existing cosignature was replaced: %q", cosignature) + } +} diff --git a/sa/db/01-mtca.sql b/sa/db/01-mtcmeta_44947_4_1_0_44.sql similarity index 100% rename from sa/db/01-mtca.sql rename to sa/db/01-mtcmeta_44947_4_1_0_44.sql diff --git a/sa/db/02-users.sql b/sa/db/02-users.sql index 436d1ef7e3b..19f9840398f 100644 --- a/sa/db/02-users.sql +++ b/sa/db/02-users.sql @@ -94,3 +94,13 @@ GRANT CREATE,SELECT,INSERT ON * TO 'incidents_sa_admin'@'%'; -- Test setup and teardown GRANT ALL PRIVILEGES ON * to 'test_setup'@'%'; + +USE mtcmeta_44947_4_1_0_44; + +CREATE USER IF NOT EXISTS 'mtpublisher'@'%'; + +-- MTPublisher stub: reads checkpoints awaiting a cosignature and writes one. +GRANT SELECT,UPDATE ON checkpoints TO 'mtpublisher'@'%'; + +-- Test setup and teardown +GRANT ALL PRIVILEGES ON * to 'test_setup'@'%'; diff --git a/sa/db/02-users_next.sql b/sa/db/02-users_next.sql index 9d5237a9326..986a7110b59 100644 --- a/sa/db/02-users_next.sql +++ b/sa/db/02-users_next.sql @@ -94,3 +94,13 @@ GRANT CREATE,SELECT,INSERT ON * TO 'incidents_sa_admin'@'%'; -- Test setup and teardown GRANT ALL PRIVILEGES ON * to 'test_setup'@'%'; + +USE mtcmeta_44947_4_1_0_44; + +CREATE USER IF NOT EXISTS 'mtpublisher'@'%'; + +-- MTPublisher stub: reads checkpoints awaiting a cosignature and writes one. +GRANT SELECT,UPDATE ON checkpoints TO 'mtpublisher'@'%'; + +-- Test setup and teardown +GRANT ALL PRIVILEGES ON * to 'test_setup'@'%'; diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json new file mode 100644 index 00000000000..defba8e85b3 --- /dev/null +++ b/test/config-next/mtpublisher.json @@ -0,0 +1,19 @@ +{ + "mtPublisher": { + "db": { + "dbConnectFile": "test/secrets/mtpublisher_dburl", + "maxOpenConns": 10 + }, + "pollInterval": "1s", + "mtcLogID": "44947.4.1.0.44", + "mirrorID": "32473.9" + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/test/config-next/proxysql/mtpublisher_dburl b/test/config-next/proxysql/mtpublisher_dburl new file mode 100644 index 00000000000..0675bf58824 --- /dev/null +++ b/test/config-next/proxysql/mtpublisher_dburl @@ -0,0 +1 @@ +mtpublisher@tcp(boulder-proxysql:6033)/mtcmeta_44947_4_1_0_44?readTimeout=14s&writeTimeout=14s&timeout=1s diff --git a/test/config-next/vitess/mtpublisher_dburl b/test/config-next/vitess/mtpublisher_dburl new file mode 100644 index 00000000000..79f0bec4bf7 --- /dev/null +++ b/test/config-next/vitess/mtpublisher_dburl @@ -0,0 +1 @@ +mtpublisher@tcp(boulder-vitess:33577)/mtcmeta_44947_4_1_0_44?readTimeout=14s&writeTimeout=14s&timeout=1s diff --git a/test/config/mtpublisher.json b/test/config/mtpublisher.json new file mode 100644 index 00000000000..defba8e85b3 --- /dev/null +++ b/test/config/mtpublisher.json @@ -0,0 +1,19 @@ +{ + "mtPublisher": { + "db": { + "dbConnectFile": "test/secrets/mtpublisher_dburl", + "maxOpenConns": 10 + }, + "pollInterval": "1s", + "mtcLogID": "44947.4.1.0.44", + "mirrorID": "32473.9" + }, + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 6 + }, + "openTelemetry": { + "endpoint": "bjaeger:4317", + "sampleratio": 1 + } +} diff --git a/test/config/proxysql/mtpublisher_dburl b/test/config/proxysql/mtpublisher_dburl new file mode 100644 index 00000000000..0675bf58824 --- /dev/null +++ b/test/config/proxysql/mtpublisher_dburl @@ -0,0 +1 @@ +mtpublisher@tcp(boulder-proxysql:6033)/mtcmeta_44947_4_1_0_44?readTimeout=14s&writeTimeout=14s&timeout=1s diff --git a/test/config/vitess/mtpublisher_dburl b/test/config/vitess/mtpublisher_dburl new file mode 100644 index 00000000000..79f0bec4bf7 --- /dev/null +++ b/test/config/vitess/mtpublisher_dburl @@ -0,0 +1 @@ +mtpublisher@tcp(boulder-vitess:33577)/mtcmeta_44947_4_1_0_44?readTimeout=14s&writeTimeout=14s&timeout=1s diff --git a/test/entrypoint.sh b/test/entrypoint.sh index bf5301e793f..741805f7b99 100755 --- a/test/entrypoint.sh +++ b/test/entrypoint.sh @@ -15,6 +15,7 @@ DB_URL_FILES=( cert_checker_dburl incidents_dburl incidents_admin_dburl + mtpublisher_dburl revoker_dburl sa_dburl sa_ro_dburl diff --git a/test/proxysql/proxysql.cnf b/test/proxysql/proxysql.cnf index b31a14b0768..16001f98546 100644 --- a/test/proxysql/proxysql.cnf +++ b/test/proxysql/proxysql.cnf @@ -100,6 +100,9 @@ mysql_users = }, { username = "incidents_sa_admin"; + }, + { + username = "mtpublisher"; } ); mysql_query_rules = diff --git a/test/startservers.py b/test/startservers.py index f1de143d4ba..cdc7695bca5 100644 --- a/test/startservers.py +++ b/test/startservers.py @@ -57,6 +57,10 @@ 8010, 9396, 'mtca.boulder', ('./bin/boulder', 'boulder-mtca', '--config', os.path.join(config_dir, 'mtca.json'), '--addr', ':9396', '--debug-addr', ':8010'), None), + Service('boulder-mtpublisher-1', + 8025, None, None, + ('./bin/boulder', 'boulder-mtpublisher', '--config', os.path.join(config_dir, 'mtpublisher.json'), '--debug-addr', ':8025'), + None), Service('boulder-ca-1', 8001, 9393, 'ca.boulder', ('./bin/boulder', 'boulder-ca', '--config', os.path.join(config_dir, 'ca.json'), '--addr', ':9393', '--debug-addr', ':8001'), diff --git a/test/vars/vars.go b/test/vars/vars.go index b3e73ae2983..cda59d839d3 100644 --- a/test/vars/vars.go +++ b/test/vars/vars.go @@ -30,4 +30,8 @@ var ( DBConnIncidentsAdmin = dsn("incidents_sa_admin", "incidents_sa") // DBConnIncidentsFullPerms is the incidents database connection with full perms. DBConnIncidentsFullPerms = dsn("test_setup", "incidents_sa") + // DBConnMTCMeta_44947_4_1_0_44FullPerms is the mtcmeta_44947_4_1_0_44 database + // connection with full perms. It builds the DSN directly because mtcmeta has + // no _next variant for dsn() to append. + DBConnMTCMeta_44947_4_1_0_44FullPerms = fmt.Sprintf("test_setup@tcp(%s)/mtcmeta_44947_4_1_0_44", os.Getenv("DB_ADDR")) ) diff --git a/test/vtcomboserver/run.sh b/test/vtcomboserver/run.sh index 428ff67c5e0..438fa2dfc9e 100755 --- a/test/vtcomboserver/run.sh +++ b/test/vtcomboserver/run.sh @@ -32,7 +32,7 @@ rm -vf "$VTDATAROOT"/"$tablet_dir"/{mysql.sock,mysql.sock.lock} VTSCHEMADIR=/vt/schema/ cp -r /boulder/sa/vtschema/ "${VTSCHEMADIR}" -for DB in boulder_sa boulder_sa_next incidents_sa incidents_sa_next ; do +for DB in boulder_sa boulder_sa_next incidents_sa incidents_sa_next mtcmeta_44947_4_1_0_44 ; do # In MariaDB land, we need a `USE` statement in the SQL. In Vitess, # it's disallowed. grep --ignore-case --invert-match '^USE ' \