diff --git a/cmd/boulder-mtca/main.go b/cmd/boulder-mtca/main.go index 32f33f75f9d..45bd17b8a5d 100644 --- a/cmd/boulder-mtca/main.go +++ b/cmd/boulder-mtca/main.go @@ -4,9 +4,13 @@ package notmain import ( "context" + "crypto/mldsa" + "crypto/x509" "database/sql" + "encoding/pem" "errors" "flag" + "fmt" "os" "sync" "time" @@ -50,12 +54,37 @@ type Config struct { // SequencingPeriod controls how frequently the MTCA sequences a batch and signs a checkpoint. SequencingPeriod config.Duration `validate:"required"` + + // Mirror identifies the mirror whose cosignatures must be verified + // before the MTCA serves them as part of a checkpoint. + Mirror cmd.MirrorConfig `validate:"required"` } Syslog cmd.SyslogConfig OpenTelemetry cmd.OpenTelemetryConfig } +// loadMLDSAPublicKey reads a PEM-encoded PKIX ML-DSA-44 public key. +func loadMLDSAPublicKey(filename string) (*mldsa.PublicKey, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("no PUBLIC KEY PEM block in %s", filename) + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + pubKey, ok := parsed.(*mldsa.PublicKey) + if !ok { + return nil, fmt.Errorf("key in %s is %T, must be ML-DSA-44", filename, parsed) + } + return pubKey, nil +} + func main() { grpcAddr := flag.String("addr", "", "gRPC listen address override") debugAddr := flag.String("debug-addr", "", "Debug server address override") @@ -117,6 +146,9 @@ func main() { s3c, err := bs3.FromConfig(c.MTCA.S3, logger) cmd.FailOnError(err, "Loading S3 config") + mirrorPublicKey, err := loadMLDSAPublicKey(c.MTCA.Mirror.PublicKeyFile) + cmd.FailOnError(err, "Loading mirror public key") + mtcaImpl, err := mtca.New( issuer, profiles, @@ -124,6 +156,8 @@ func main() { c.MTCA.SequencingPeriod.Duration, dbMap, s3c, + c.MTCA.Mirror.ID, + mirrorPublicKey, logger, clk) cmd.FailOnError(err, "Building MTCA") diff --git a/mtca/mtca.go b/mtca/mtca.go index e7f263aac75..fd8361e9362 100644 --- a/mtca/mtca.go +++ b/mtca/mtca.go @@ -27,6 +27,7 @@ import ( "github.com/letsencrypt/boulder/issuance" blog "github.com/letsencrypt/boulder/log" mtcapb "github.com/letsencrypt/boulder/mtca/proto" + "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" @@ -47,6 +48,8 @@ func New( sequencingPeriod time.Duration, dbMap *borp.DbMap, s3c simpleS3, + mirrorID string, + mirrorPublicKey *mldsa.PublicKey, logger blog.Logger, clk clock.Clock, ) (*mtca, error) { @@ -66,12 +69,14 @@ func New( issuer: issuer, profiles: profiles, logID: logID, + mirrorID: mirrorID, pool: &pool{maxSize: 100}, sequencingPeriod: sequencingPeriod, db: initDB(dbMap), s3c: s3c, + log: logger, clk: clk, } @@ -92,6 +97,12 @@ func New( } m.verifier = verifier + mirrorVerifier, err := cosignature.NewVerifier(mirrorID, mirrorPublicKey) + if err != nil { + return nil, fmt.Errorf("creating mirror verifier: %s", err) + } + m.mirrorVerifier = mirrorVerifier + return m, nil } @@ -104,6 +115,11 @@ type mtca struct { cosigner *cosignature.Cosigner verifier *cosignature.Verifier + mirrorID string + mirrorVerifier *cosignature.Verifier + + servedCheckpointID int64 + pool *pool // frontier contains all the tiles on the right edge of the tree. @@ -407,9 +423,10 @@ func (m *mtca) Loop(ctx context.Context) { } // sequence takes all entries from the pool, simulates writing them to tile storage, signs -// and stores a new checkpoint, and notifies waiting RPCs. +// and stores a new checkpoint, and notifies waiting RPCs. It also serves the latest +// checkpoint's cosigned note once the mirror signature arrives, when the pool is empty. // -// If the pool is empty, nothing happens. +// If the pool is empty, no sequencing happens. // If the pool is non-empty, but the previous checkpoint doesn't have a mirror signature, // returns an error that wraps ErrCheckpointNotReady (without taking entries from the pool). // This is expected to be a common occurrence. @@ -423,15 +440,22 @@ func (m *mtca) sequence(ctx context.Context) error { return fmt.Errorf("call mtca.Preflight() before sequencing") } - if m.pool.len() == 0 { - return nil - } - latest, err := m.latestCheckpoint(ctx) if err != nil { return err } + if latest.mirrored() { + err = m.serveCheckpoint(ctx, latest) + if err != nil { + return err + } + } + + if m.pool.len() == 0 { + return nil + } + if !latest.mirrored() { return fmt.Errorf("temporary: checkpoint ID %d (tree size %d): %w", latest.ID, latest.TreeSize, ErrCheckpointNotReady) @@ -571,10 +595,6 @@ func (m *mtca) sequence(ctx context.Context) error { // // TODO(#8902): This should include indefinite retries on error. We've committed to the // tree hash by signing it, so nothing can make progress until we've published the tiles. - // - // Once we add publishing of checkpoints as signed notes, publication of the signed note - // should come after this flush succeeds, so monitors don't try to fetch tiles that aren't - // yet available. err = m.frontier.Publish(ctx, m.s3c, m.logID.TilePrefix()) if err != nil { return fmt.Errorf("publishing tiles: %s", err) @@ -590,7 +610,7 @@ func (m *mtca) sequence(ctx context.Context) error { return nil } -// checkpoint represents the database storage of a checkpoint and associated signatures. +// checkpointRow represents the database storage of a checkpoint and associated signatures. // // For signing, the TreeSize and RootHash fields are incorporated into a `cosigned.Message`. type checkpointRow struct { @@ -683,3 +703,49 @@ func (m *mtca) signCheckpoint(c *checkpointRow) ([]byte, error) { } return cosignature.RawSignature(timestampedCosignature) } + +// serveCheckpoint writes latest's note, carrying the MTCA signature and a +// mirror cosignature, to the checkpoint path in tile storage. Per CQRP: "In +// order for landmarks to be served by Chrome's Landmark Service, all +// checkpoints MUST be served with a minimum of 2 cosignatures. One of these +// MUST be from the MTC CA Operator and one MUST be from a Mirroring Cosigner +// recognized by Chrome and not operated by the MTC CA Operator." +func (m *mtca) serveCheckpoint(ctx context.Context, latest *checkpointRow) error { + if latest.ID == m.servedCheckpointID { + return nil + } + if len(latest.RootHash) != tlog.HashSize { + return fmt.Errorf("checkpoint %d root hash is %d bytes, want %d", latest.ID, len(latest.RootHash), tlog.HashSize) + } + + // Verify the MTCA cosignature and produce its signature line. + tree := tlog.Tree{N: latest.TreeSize, Hash: tlog.Hash(latest.RootHash)} + caCosignatureLine, err := m.verifier.SignatureLine(m.logID.Origin(), tree, latest.MTCASignature) + if err != nil { + return fmt.Errorf("checkpoint %d MTCA signature: %s", latest.ID, err) + } + + // Verify the mirror cosignature and produce its signature line. + if latest.MirrorID != m.mirrorID { + return fmt.Errorf("checkpoint %d cosigned by mirror %q, want %q", latest.ID, latest.MirrorID, m.mirrorID) + } + mirrorCosignatureLine, err := m.mirrorVerifier.SignatureLine(m.logID.Origin(), tree, latest.MirrorSignature) + if err != nil { + return fmt.Errorf("checkpoint %d mirror cosignature: %s", latest.ID, err) + } + + // Produce a note containing both cosgnatures. + cp := checkpoint.Checkpoint{Origin: m.logID.Origin(), Tree: tree} + note, err := cp.SignedNoteForServing(caCosignatureLine, mirrorCosignatureLine) + if err != nil { + return err + } + + // Finally, write it to the checkpoint path for serving. + err = tiles.WriteCheckpoint(ctx, m.s3c, m.logID.TilePrefix(), note) + if err != nil { + return fmt.Errorf("serving checkpoint %d: %s", latest.ID, err) + } + m.servedCheckpointID = latest.ID + return nil +} diff --git a/mtca/mtca_test.go b/mtca/mtca_test.go index ade9ba8f373..6c4ecd585d9 100644 --- a/mtca/mtca_test.go +++ b/mtca/mtca_test.go @@ -90,6 +90,10 @@ func setup() (*mtca, *bs3test.FakeS3, func(), error) { } fs3 := bs3test.New() + mirrorKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), make([]byte, 32)) + if err != nil { + return nil, nil, nil, err + } mtca, err := New( issuer, map[string]*issuance.Profile{"mtcExample": profile}, @@ -97,6 +101,8 @@ func setup() (*mtca, *bs3test.FakeS3, func(), error) { 100*time.Millisecond, dbMap, fs3, + "32473.9", + mirrorKey.PublicKey(), logger, clk) if err != nil { @@ -479,6 +485,13 @@ func TestSequenceStorageFailure(t *testing.T) { t.Cleanup(cleanup) mirrorCosign(t, mtca) + // Serve the cosigned checkpoint while storage is healthy, so the failure + // below lands on staging. + err = mtca.sequence(t.Context()) + if err != nil { + t.Fatalf("sequencing to serve the cosigned checkpoint: %s", err) + } + mtca.pool.maxSize = 2 results := issueMany(t, mtca, 2) diff --git a/test/config-next/mtca.json b/test/config-next/mtca.json index 29b3ab723e8..732b25e48e3 100644 --- a/test/config-next/mtca.json +++ b/test/config-next/mtca.json @@ -5,6 +5,10 @@ "logNumber": 44 }, "sequencingPeriod": "100ms", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/mtpki/mirror.pub.pem" + }, "db": { "dbConnectFile": "test/secrets/mtca1_dburl" },