From 5c92fa77d3689eeb71a5bf30de423f1c1e2b813b Mon Sep 17 00:00:00 2001 From: Samantha Date: Tue, 9 Jun 2026 18:06:05 -0400 Subject: [PATCH 1/2] mtpublisher: Add skeleton that pushes dummy cosignatures --- .github/workflows/boulder-ci.yml | 2 +- .gitignore | 1 + cmd/boulder-mtpublisher/main.go | 74 +++++++++++ cmd/boulder/main.go | 1 + cmd/boulder/main_test.go | 2 + docker-compose.yml | 4 +- mtpublisher/mtpublisher.go | 102 +++++++++++++++ mtpublisher/mtpublisher_test.go | 123 ++++++++++++++++++ ...mtca.sql => 01-mtcmeta_44947_4_1_0_44.sql} | 0 sa/db/02-users.sql | 10 ++ sa/db/02-users_next.sql | 10 ++ test/config-next/mtpublisher.json | 19 +++ test/config-next/proxysql/mtpublisher_dburl | 1 + test/config-next/vitess/mtpublisher_dburl | 1 + test/config/mtpublisher.json | 19 +++ test/config/proxysql/mtpublisher_dburl | 1 + test/config/vitess/mtpublisher_dburl | 1 + test/entrypoint.sh | 1 + test/proxysql/proxysql.cnf | 3 + test/startservers.py | 4 + test/vars/vars.go | 4 + test/vtcomboserver/run.sh | 2 +- 22 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 cmd/boulder-mtpublisher/main.go create mode 100644 mtpublisher/mtpublisher.go create mode 100644 mtpublisher/mtpublisher_test.go rename sa/db/{01-mtca.sql => 01-mtcmeta_44947_4_1_0_44.sql} (100%) create mode 100644 test/config-next/mtpublisher.json create mode 100644 test/config-next/proxysql/mtpublisher_dburl create mode 100644 test/config-next/vitess/mtpublisher_dburl create mode 100644 test/config/mtpublisher.json create mode 100644 test/config/proxysql/mtpublisher_dburl create mode 100644 test/config/vitess/mtpublisher_dburl 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..38e062d81f4 --- /dev/null +++ b/mtpublisher/mtpublisher.go @@ -0,0 +1,102 @@ +package mtpublisher + +import ( + "context" + "crypto/ed25519" + "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 for checkpoints that still lack a +// mirror cosignature, and adds a dummy cosignature to them. 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 *Publisher. +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 pendingCheckpoint struct { + ID int64 `db:"id"` + MTCLogID string `db:"mtcLogID"` + TreeSize int64 `db:"treeSize"` +} + +// 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) cosignPending(ctx context.Context) error { + var pending []pendingCheckpoint + _, err := p.db.Select(ctx, &pending, + "SELECT id, mtcLogID, treeSize FROM checkpoints WHERE mtcLogID = ? AND mirrorSignature IS NULL", + p.mtcLogID) + if err != nil { + return fmt.Errorf("selecting checkpoints awaiting a cosignature: %w", err) + } + + for _, cp := range pending { + _, err := p.db.ExecContext(ctx, + "UPDATE checkpoints SET mirrorID = ?, mirrorSignature = ? WHERE id = ? AND mtcLogID = ?", + p.mirrorID, p.dummyCosignature(), cp.ID, p.mtcLogID) + if err != nil { + p.log.Errf("Failed to cosign checkpoint %d (%s size %d): %s", cp.ID, cp.MTCLogID, cp.TreeSize, err) + continue + } + p.log.Infof("Cosigned checkpoint %d (%s size %d)", cp.ID, cp.MTCLogID, cp.TreeSize) + } + return nil +} + +// Start attempts to cosign pending checkpoints 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.cosignPending(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..b11ff4b5fc2 --- /dev/null +++ b/mtpublisher/mtpublisher_test.go @@ -0,0 +1,123 @@ +package mtpublisher + +import ( + "context" + "crypto/ed25519" + "testing" + "time" + + "github.com/jmhodges/clock" + + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test/vars" +) + +func TestCosignPending(t *testing.T) { + ctx := context.Background() + + dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms) + if err != nil { + t.Fatalf("opening mtcmeta dbMap: %s", err) + } + _, err = dbMap.ExecContext(ctx, "TRUNCATE TABLE checkpoints") + if err != nil { + t.Fatalf("truncating checkpoints: %s", err) + } + t.Cleanup(func() { + _, err := dbMap.ExecContext(ctx, "TRUNCATE TABLE checkpoints") + if err != nil { + t.Logf("cleaning up checkpoints: %s", err) + } + }) + + const ( + mtcLogID = "44947.4.1.0.44" + mirrorID = "32473.9" + ) + rootHash := make([]byte, 32) + + // A checkpoint awaiting a cosignature. + res, err := dbMap.ExecContext(ctx, + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", + mtcLogID, []byte("mtca-signature"), int64(256), rootHash) + if err != nil { + t.Fatalf("inserting pending checkpoint: %s", err) + } + pendingID, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) + } + + // A checkpoint that is already cosigned, which must be left untouched. + _, err = dbMap.ExecContext(ctx, + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash, mirrorID, mirrorSignature) VALUES (?, ?, ?, ?, ?, ?)", + mtcLogID, []byte("mtca-signature"), int64(512), rootHash, "existing.cosigner", []byte("existing-signature")) + if err != nil { + t.Fatalf("inserting cosigned checkpoint: %s", err) + } + + // A pending checkpoint from another log, that somehow got inserted in this table, which must be left untouched. + res, err = dbMap.ExecContext(ctx, + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", + "44947.4.2.0.99", []byte("other-log-mtca-signature"), int64(256), rootHash) + if err != nil { + t.Fatalf("inserting other-log pending checkpoint: %s", err) + } + otherLogID, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) + } + + p, err := New(dbMap, time.Second, mtcLogID, mirrorID, clock.NewFake(), blog.NewMock()) + if err != nil { + t.Fatalf("New: %s", err) + } + err = p.cosignPending(ctx) + if err != nil { + t.Fatalf("cosignPending: %s", err) + } + + type row struct { + MirrorID string `db:"mirrorID"` + MirrorSig []byte `db:"mirrorSignature"` + } + + // The pending checkpoint now carries our mirrorID and a 72-byte cosignature. + var cosigned []row + _, err = dbMap.Select(ctx, &cosigned, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE id = ?", pendingID) + if err != nil { + t.Fatalf("selecting the pending checkpoint: %s", err) + } + if len(cosigned) != 1 { + t.Fatalf("found %d rows for the pending checkpoint, want 1", len(cosigned)) + } + if cosigned[0].MirrorID != mirrorID { + t.Errorf("mirrorID = %q, want %q", cosigned[0].MirrorID, mirrorID) + } + if len(cosigned[0].MirrorSig) != 8+ed25519.SignatureSize { + t.Errorf("mirrorSignature is %d bytes, want %d", len(cosigned[0].MirrorSig), 8+ed25519.SignatureSize) + } + + // The already-cosigned checkpoint keeps its original cosignature. + var existing []row + _, err = dbMap.Select(ctx, &existing, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE mirrorID = ?", "existing.cosigner") + if err != nil { + t.Fatalf("selecting the cosigned checkpoint: %s", err) + } + if len(existing) != 1 || string(existing[0].MirrorSig) != "existing-signature" { + t.Errorf("already-cosigned checkpoint was modified: %+v", existing) + } + + // The somehow inserted pending checkpoint from another log is left untouched. + var stillPendingOtherLog int64 + err = dbMap.SelectOne(ctx, &stillPendingOtherLog, + "SELECT COUNT(*) FROM checkpoints WHERE id = ? AND mirrorID IS NULL AND mirrorSignature IS NULL", + otherLogID) + if err != nil { + t.Fatalf("checking the other-log checkpoint: %s", err) + } + if stillPendingOtherLog != 1 { + t.Errorf("other-log checkpoint was cosigned despite mtcLogID guard (id=%d)", otherLogID) + } +} 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 ' \ From 7ccb76990f19d30a1f09a7f401ec15c4eb0450f0 Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 11 Jun 2026 14:46:33 -0400 Subject: [PATCH 2/2] Address comments --- mtpublisher/mtpublisher.go | 52 ++++++----- mtpublisher/mtpublisher_test.go | 153 ++++++++++++++++++++------------ 2 files changed, 124 insertions(+), 81 deletions(-) diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 38e062d81f4..c7d303aacfb 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -3,6 +3,7 @@ package mtpublisher import ( "context" "crypto/ed25519" + "database/sql" "encoding/binary" "errors" "fmt" @@ -14,9 +15,8 @@ import ( blog "github.com/letsencrypt/boulder/log" ) -// MTPublisher polls the MTC issuance log for checkpoints that still lack a -// mirror cosignature, and adds a dummy cosignature to them. It is a stub for -// the real MTPublisher. +// 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 @@ -26,7 +26,7 @@ type MTPublisher struct { log blog.Logger } -// New returns a new *Publisher. +// 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) @@ -47,10 +47,11 @@ func New(dbMap *db.WrappedMap, interval time.Duration, mtcLogID, mirrorID string }, nil } -type pendingCheckpoint struct { - ID int64 `db:"id"` - MTCLogID string `db:"mtcLogID"` - TreeSize int64 `db:"treeSize"` +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 @@ -61,35 +62,38 @@ func (p *MTPublisher) dummyCosignature() []byte { return out } -func (p *MTPublisher) cosignPending(ctx context.Context) error { - var pending []pendingCheckpoint - _, err := p.db.Select(ctx, &pending, - "SELECT id, mtcLogID, treeSize FROM checkpoints WHERE mtcLogID = ? AND mirrorSignature IS NULL", +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 checkpoints awaiting a cosignature: %w", err) + return fmt.Errorf("selecting the latest checkpoint: %w", err) + } + if latest.MirrorSignature != nil { + return nil } - for _, cp := range pending { - _, err := p.db.ExecContext(ctx, - "UPDATE checkpoints SET mirrorID = ?, mirrorSignature = ? WHERE id = ? AND mtcLogID = ?", - p.mirrorID, p.dummyCosignature(), cp.ID, p.mtcLogID) - if err != nil { - p.log.Errf("Failed to cosign checkpoint %d (%s size %d): %s", cp.ID, cp.MTCLogID, cp.TreeSize, err) - continue - } - p.log.Infof("Cosigned checkpoint %d (%s size %d)", cp.ID, cp.MTCLogID, cp.TreeSize) + _, 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 pending checkpoints at each interval until ctx is +// 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.cosignPending(ctx) + err := p.publish(ctx) if err != nil { p.log.Errf("Cosigning pass failed: %s", err) } diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index b11ff4b5fc2..702ee0f5e8d 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -8,116 +8,155 @@ import ( "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" ) -func TestCosignPending(t *testing.T) { - ctx := context.Background() +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(ctx, "TRUNCATE TABLE checkpoints") + _, err = dbMap.ExecContext(t.Context(), "TRUNCATE TABLE checkpoints") if err != nil { t.Fatalf("truncating checkpoints: %s", err) } t.Cleanup(func() { - _, err := dbMap.ExecContext(ctx, "TRUNCATE TABLE checkpoints") + _, err := dbMap.ExecContext(context.Background(), "TRUNCATE TABLE checkpoints") if err != nil { t.Logf("cleaning up checkpoints: %s", err) } }) + return dbMap +} - const ( - mtcLogID = "44947.4.1.0.44" - mirrorID = "32473.9" - ) - rootHash := make([]byte, 32) +func insertCheckpoint(t *testing.T, dbMap *db.WrappedMap, logID string, treeSize int64) int64 { + t.Helper() - // A checkpoint awaiting a cosignature. - res, err := dbMap.ExecContext(ctx, + res, err := dbMap.ExecContext(t.Context(), "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", - mtcLogID, []byte("mtca-signature"), int64(256), rootHash) + logID, []byte("mtca-signature"), treeSize, make([]byte, 32)) if err != nil { - t.Fatalf("inserting pending checkpoint: %s", err) + t.Fatalf("inserting checkpoint (%s size %d): %s", logID, treeSize, err) } - pendingID, err := res.LastInsertId() + id, err := res.LastInsertId() if err != nil { t.Fatalf("reading insert id: %s", err) } + return id +} - // A checkpoint that is already cosigned, which must be left untouched. - _, err = dbMap.ExecContext(ctx, - "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash, mirrorID, mirrorSignature) VALUES (?, ?, ?, ?, ?, ?)", - mtcLogID, []byte("mtca-signature"), int64(512), rootHash, "existing.cosigner", []byte("existing-signature")) +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("inserting cosigned checkpoint: %s", err) + t.Fatalf("querying checkpoint %d: %s", id, err) } + return count == 1 +} - // A pending checkpoint from another log, that somehow got inserted in this table, which must be left untouched. - res, err = dbMap.ExecContext(ctx, - "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", - "44947.4.2.0.99", []byte("other-log-mtca-signature"), int64(256), rootHash) - if err != nil { - t.Fatalf("inserting other-log pending checkpoint: %s", err) - } - otherLogID, err := res.LastInsertId() +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("reading insert id: %s", err) + t.Fatalf("New: %s", err) } - p, err := New(dbMap, time.Second, mtcLogID, mirrorID, clock.NewFake(), blog.NewMock()) + // When there are no checkpoints at all, p.publish() should return without + // error. + err = p.publish(t.Context()) if err != nil { - t.Fatalf("New: %s", err) + t.Fatalf("p.publish() on an empty table: %s", err) } - err = p.cosignPending(ctx) + + // 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("cosignPending: %s", err) + t.Fatalf("p.publish(): %s", err) } + // Fetch the latest checkpoint. type row struct { MirrorID string `db:"mirrorID"` MirrorSig []byte `db:"mirrorSignature"` } - - // The pending checkpoint now carries our mirrorID and a 72-byte cosignature. - var cosigned []row - _, err = dbMap.Select(ctx, &cosigned, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE id = ?", pendingID) + var cosigned row + err = dbMap.SelectOne(t.Context(), &cosigned, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE id = ?", latestCheckpointID) if err != nil { - t.Fatalf("selecting the pending checkpoint: %s", err) + t.Fatalf("selecting the latest checkpoint: %s", err) } - if len(cosigned) != 1 { - t.Fatalf("found %d rows for the pending checkpoint, want 1", len(cosigned)) + + // 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 cosigned[0].MirrorID != mirrorID { - t.Errorf("mirrorID = %q, want %q", cosigned[0].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 len(cosigned[0].MirrorSig) != 8+ed25519.SignatureSize { - t.Errorf("mirrorSignature is %d bytes, want %d", len(cosigned[0].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) + } +} - // The already-cosigned checkpoint keeps its original cosignature. - var existing []row - _, err = dbMap.Select(ctx, &existing, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE mirrorID = ?", "existing.cosigner") +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("selecting the cosigned checkpoint: %s", err) + 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) } - if len(existing) != 1 || string(existing[0].MirrorSig) != "existing-signature" { - t.Errorf("already-cosigned checkpoint was modified: %+v", existing) + + // 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 somehow inserted pending checkpoint from another log is left untouched. - var stillPendingOtherLog int64 - err = dbMap.SelectOne(ctx, &stillPendingOtherLog, - "SELECT COUNT(*) FROM checkpoints WHERE id = ? AND mirrorID IS NULL AND mirrorSignature IS NULL", - otherLogID) + // 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("checking the other-log checkpoint: %s", err) + t.Fatalf("selecting the cosigned checkpoint: %s", err) } - if stillPendingOtherLog != 1 { - t.Errorf("other-log checkpoint was cosigned despite mtcLogID guard (id=%d)", otherLogID) + if string(cosignature) != "already-signed-bruh" { + t.Errorf("existing cosignature was replaced: %q", cosignature) } }