Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/boulder-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 74 additions & 0 deletions cmd/boulder-mtpublisher/main.go
Original file line number Diff line number Diff line change
@@ -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"`
Comment on lines +31 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hyper-nit: indicate that this is a placeholder value. totally optional if this will be replaced quickly.

}
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{}})
}
1 change: 1 addition & 0 deletions cmd/boulder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions cmd/boulder/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions mtpublisher/mtpublisher.go
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +57 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments:

  1. Why Ed25519? We don't use it anywhere else in boulder so far. Why not ECDSA, or totally random bytes?
  2. Comment says that its a timestamp followed by a signature, but the actual return value here always has zeroes for the signature bytes.

@beautifulentropy beautifulentropy Jun 12, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. https://github.com/C2SP/C2SP/blob/main/tlog-cosignature.md specifies two cosignature types: one based on Ed25519, and one based on ML-DSA-44. Totally random bytes would be the wrong shape.
  2. 0s seemed like the correct call, we don't want anyone/anything attempting to verify these. If they are 0s that becomes fairly obvious.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, when I added genmtpki.go I made the wrong choice (to use ECDSA), not realizing that tlog only specifies Ed25519 and ML-DSA-44. I can update that - either to Ed25519, or possibly straight to ML-DSA once #8787 is resolved.

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:
}
}
}
162 changes: 162 additions & 0 deletions mtpublisher/mtpublisher_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
File renamed without changes.
Loading