diff --git a/cmd/boulder-mtca/main.go b/cmd/boulder-mtca/main.go index 589d6747c17..f3ce780e3a1 100644 --- a/cmd/boulder-mtca/main.go +++ b/cmd/boulder-mtca/main.go @@ -4,11 +4,14 @@ package notmain import ( "context" + "database/sql" "flag" + "log/slog" "os" "github.com/jmhodges/clock" + "github.com/letsencrypt/borp" "github.com/letsencrypt/boulder/blog" "github.com/letsencrypt/boulder/cmd" bgrpc "github.com/letsencrypt/boulder/grpc" @@ -23,6 +26,8 @@ type Config struct { GRPCMTCA *cmd.GRPCServerConfig + DB cmd.DBConfig + // Issuer holds the configuration for a single MTCA instance with a single mtcaID. // We run a separate process for each issuer. // TODO: the issuance package parses the CA certificate as a self-signed X.509 @@ -39,6 +44,9 @@ func main() { grpcAddr := flag.String("addr", "", "gRPC listen address override") debugAddr := flag.String("debug-addr", "", "Debug server address override") configFile := flag.String("config", "", "File path to the configuration file for this service") + initLog := flag.Bool("init-log", false, "Initialize log metadata in the database and exit") + initLogForTest := flag.Bool("init-log-for-test", false, "For testing: initialize log metadata (ignoring errors), then serve") + flag.Parse() if *configFile == "" { flag.Usage() @@ -68,7 +76,27 @@ func main() { issuer, err := issuance.LoadIssuer(c.MTCA.Issuer, clk) cmd.FailOnError(err, "Loading issuer") - mtcaImpl := mtca.New(issuer) + url, err := c.MTCA.DB.URL() + cmd.FailOnError(err, "Reading DB URL") + db, err := sql.Open("mysql", url) + cmd.FailOnError(err, "Opening DB") + dbMap := &borp.DbMap{Db: db, Dialect: borp.MySQLDialect{}} + + mtcaImpl := mtca.New(issuer, dbMap, logger) + + if *initLog { + err = mtcaImpl.InitLog(context.Background()) + cmd.FailOnError(err, "Initializing log") + return + } + if *initLogForTest { + err = mtcaImpl.InitLog(context.Background()) + if err != nil { + logger.Info(context.Background(), + "Non-fatal error initializing MTC log DB for test", + slog.String("info", err.Error())) + } + } srv := bgrpc.NewServer(c.MTCA.GRPCMTCA, logger).Add( &mtcapb.MTCA_ServiceDesc, mtcaImpl) @@ -76,6 +104,12 @@ func main() { start, err := srv.Build(tlsConfig, scope, clk) cmd.FailOnError(err, "Unable to setup MTCA gRPC server") + ctx, cancel := context.WithCancel(context.Background()) + // Cancel will be called after start() returns, which happens after GracefulStop() returns. + // That means all inflight RPCs will be done, which means the last of the pool has been sequenced. + defer cancel() + go mtcaImpl.Loop(ctx) + cmd.FailOnError(start(), "MTCA gRPC service failed") } diff --git a/mtca/mtca.go b/mtca/mtca.go index c40eb1df21c..6ea72569b1b 100644 --- a/mtca/mtca.go +++ b/mtca/mtca.go @@ -4,17 +4,28 @@ package mtca import ( "context" + "crypto/rand" + "crypto/sha256" "encoding/asn1" + "errors" "fmt" + "reflect" "sync" + "time" + "github.com/letsencrypt/borp" + "github.com/letsencrypt/boulder/blog" + corepb "github.com/letsencrypt/boulder/core/proto" + "github.com/letsencrypt/boulder/db" "github.com/letsencrypt/boulder/issuance" mtcapb "github.com/letsencrypt/boulder/mtca/proto" + "github.com/letsencrypt/boulder/trees/cosigned" ) var _ mtcapb.MTCAServer = &mtca{} -func New(issuer *issuance.Issuer) *mtca { +// New creates a new MTCA service. +func New(issuer *issuance.Issuer, dbMap *borp.DbMap, logger blog.Logger) *mtca { var mtcaID string testingTrustAnchorIDOID := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44363, 47, 1} for _, attribute := range issuer.Cert.Subject.Names { @@ -23,15 +34,85 @@ func New(issuer *issuance.Issuer) *mtca { break } } + + dbMap.AddTableWithName(checkpoint{}, "checkpoints").SetKeys(true, "ID") + return &mtca{ + log: logger, + db: db.NewWrappedMap(dbMap), issuer: issuer, mtcaID: mtcaID, // TODO: collect this from config - logNumber: 0, - latestEntryIndex: 0, + logNumber: 0, + pool: &pool{maxSize: 100}, } } +// InitLog creates the database metadata for a new, empty log: one checkpoint and the row +// in `latestCheckpoint` that refers to it. Should only be run once in a log's lifetime. +func (m *mtca) InitLog(ctx context.Context) error { + var numResults int64 + err := m.db.SelectOne(ctx, &numResults, "SELECT COUNT(*) FROM checkpoints WHERE mtcLogID = ?", + m.mtcLogID()) + if err != nil { + return err + } + if numResults > 0 { + return fmt.Errorf("%d checkpoints already exist for %s", numResults, m.mtcLogID()) + } + + err = m.db.SelectOne(ctx, &numResults, "SELECT COUNT(*) FROM latestCheckpoint WHERE mtcLogID = ?", + m.mtcLogID()) + if err != nil { + return err + } + if numResults > 0 { + return fmt.Errorf("%d latestCheckpoint rows for %s", numResults, m.mtcLogID()) + } + + // null_entry has empty extensions and a MerkleTreeCertEntryType of 0. Since extensions can be up to 2^16 long + // there's two bytes of length prefix. Since MerkleTreeCertEntryType can have up to 2^16 values, it's also two bytes. + // All the bytes are zero: empty extensions, null_entry type is enum value zero. + // https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-log-entries + // To calculate the Merkle Tree Hash of a single-entry list, we prepend 0x00 (as compared with 0x01 when hashing + // two nodes). So five zeroes total. + // https://www.rfc-editor.org/info/rfc9162/#name-definition-of-the-merkle-tr + nullEntry := []byte{0, 0, 0, 0, 0} + rootHash := sha256.Sum256(nullEntry) + + firstCheckpoint := checkpoint{ + MTCLogID: m.mtcLogID(), + MTCASignature: nil, + MirrorID: "", + MirrorSignature: nil, + TreeSize: 1, + RootHash: rootHash[:], + } + + err = m.db.Insert(ctx, &firstCheckpoint) + if err != nil { + return err + } + + err = m.signCheckpoint(ctx, &firstCheckpoint) + if err != nil { + return err + } + + rowsUpdated, err := m.db.Update(ctx, &firstCheckpoint) + if err != nil { + return err + } + if rowsUpdated != 1 { + return fmt.Errorf("%d rows updated for checkpoint", rowsUpdated) + } + + _, err = m.db.ExecContext(ctx, "INSERT INTO latestCheckpoint (id, mtcLogID) VALUES (?, ?)", + firstCheckpoint.ID, m.mtcLogID()) + + return err +} + type mtca struct { mtcapb.UnimplementedMTCAServer @@ -39,10 +120,48 @@ type mtca struct { mtcaID string logNumber uint16 - // This is just a dummy for testing; in reality this will come from the DB. - latestEntryIndex int64 + db *db.WrappedMap + log blog.Logger + + pool *pool - sequencing sync.Mutex + sequencingMu sync.Mutex +} + +type entry struct { + pubkey []byte + identifiers []*corepb.Identifier + ch chan<- int64 +} + +type pool struct { + sync.Mutex + entries []entry + maxSize int +} + +func (p *pool) take() []entry { + p.Lock() + defer p.Unlock() + ret := p.entries + p.entries = nil + return ret +} + +func (p *pool) len() int { + p.Lock() + defer p.Unlock() + return len(p.entries) +} + +func (p *pool) append(e entry) error { + p.Lock() + defer p.Unlock() + if len(p.entries) >= p.maxSize { + return fmt.Errorf("pool is full") + } + p.entries = append(p.entries, e) + return nil } // mtcLogID returns the string-formatted relative OID for this log. @@ -53,12 +172,288 @@ func (m *mtca) mtcLogID() string { } func (m *mtca) Issue(ctx context.Context, req *mtcapb.IssueRequest) (*mtcapb.IssueResponse, error) { - m.sequencing.Lock() - defer m.sequencing.Unlock() - m.latestEntryIndex++ - - return &mtcapb.IssueResponse{ - MtcLogID: m.mtcLogID(), - MtcEntryIndex: m.latestEntryIndex, - }, nil + ch := make(chan int64, 1) + err := m.pool.append(entry{ + pubkey: req.Pubkey, + identifiers: req.Identifiers, + ch: ch, + }) + if err != nil { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case entryIndex := <-ch: + return &mtcapb.IssueResponse{ + MtcLogID: m.mtcLogID(), + MtcEntryIndex: entryIndex, + }, nil + } +} + +// Loop periodically sequences all entries in the pool and sends notifications to the waiting RPCs. +// +// At process shutdown, this context should be canceled _after_ GracefulStop returns. That ensures +// there are no inflight RPCs from clients, which in turn ensures that we have sequenced everything +// had in the pool. +func (m *mtca) Loop(ctx context.Context) { + go m.fakePublisher(ctx) + + ticker := time.NewTicker(300 * time.Millisecond) + for { + select { + case <-ticker.C: + err := m.sequence(ctx) + if err != nil { + m.log.Error(ctx, "sequencing", err) + continue + } + case <-ctx.Done(): + poolSize := m.pool.len() + if poolSize != 0 { + err := fmt.Errorf("pool has %d entries left. ungraceful stop?", poolSize) + m.log.Error(ctx, "shutting down loop", err) + } + return + } + } +} + +// TODO: remove once a real publisher is available in integration. +func (m *mtca) fakePublisher(ctx context.Context) { + ticker := time.NewTicker(100 * time.Millisecond) + for { + select { + case <-ticker.C: + latest, err := m.latest(ctx) + if err != nil { + m.log.Error(ctx, "getting latest checkpoint for fake publisher", err) + continue + } + latest.MirrorID = "fake fake" + latest.MirrorSignature = []byte("fake fake") + _, err = m.db.Update(ctx, latest) + if err != nil { + m.log.Error(ctx, "updating latest checkpoint with fake signature", err) + continue + } + case <-ctx.Done(): + return + } + } +} + +func (m *mtca) sequence(ctx context.Context) error { + latest, err := m.latest(ctx) + if err != nil { + return err + } + + if !latest.SequencingReady() { + return fmt.Errorf("temporary: latest checkpoint (%d) not ready", latest.TreeSize) + } + + m.sequencingMu.Lock() + defer m.sequencingMu.Unlock() + entries := m.pool.take() + + if len(entries) == 0 { + return nil + } + + // Simulate writing to tile storage + latestTreeSize := latest.TreeSize + var entryIndexes []int64 + for range entries { + entryIndexes = append(entryIndexes, latestTreeSize) + latestTreeSize++ + } + + // TODO: calculate new root hash for real + var newRootHash [sha256.Size]byte + rand.Read(newRootHash[:]) + + newCheckpoint := checkpoint{ + ID: 0, + MTCLogID: m.mtcLogID(), + MTCASignature: nil, + MirrorID: "", + MirrorSignature: nil, + TreeSize: latestTreeSize, + RootHash: newRootHash[:], + } + + // Precommit to the new checkpoint. This will allow us to do recovery if we crash between signing + // the new checkpoint and writing it to the database. + // + // Note: Insert() updates the ID field of its parameter due to SetKeys(true, "ID") + return m.db.Insert(ctx, &newCheckpoint) + + _, err = db.WithTransaction(ctx, m.db, func(tx db.Executor) (any, error) { + var latestID int64 + // Lock the latestCheckpoint to make sure there is no concurrent signer/writer, avoiding signing a split view. + // The FOR UPDATE does the heavy lifting here. + // https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/selecting-data/for-update + err := tx.SelectOne(ctx, &latestID, + `SELECT id from latestCheckpoint WHERE mtcLogID = ? FOR UPDATE`, + m.mtcLogID()) + if err != nil { + return nil, err + } + if latestID != latest.ID { + return nil, fmt.Errorf("latestCheckpoint changed during sequencing from %d to %d. multiple writers?", + latest.ID, latestID) + } + + // Note that we're doing HSM work while holding a database lock. That's intentional. + err = m.signCheckpoint(ctx, &newCheckpoint) + if err != nil { + return nil, err + } + + rowsUpdated, err := tx.Update(ctx, &newCheckpoint) + if err != nil { + return nil, err + } + if rowsUpdated == 0 { + return nil, errors.New("no rows updated") + } + + result, err := tx.ExecContext(ctx, "UPDATE latestCheckpoint SET id = ? WHERE mtcLogID = ? AND id = ?", + newCheckpoint.ID, m.mtcLogID(), latestID) + if err != nil { + return nil, fmt.Errorf("updating latestCheckpoint: %s", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("updating latestCheckpoint, getting rows affected: %s", err) + } + if rowsAffected != 1 { + return nil, fmt.Errorf("updating latestCheckpoint: %d rows updated, rolling back", rowsAffected) + } + + return nil, nil + }) + if err != nil { + return err + } + + // Notify waiting RPCs. If there's no listener on the channel, don't block. + for i, e := range entries { + select { + case e.ch <- entryIndexes[i]: + default: + } + } + + return nil +} + +type checkpoint struct { + ID int64 + MTCLogID string + MTCASignature []byte + MirrorID string + MirrorSignature []byte + TreeSize int64 + RootHash []byte +} + +func (c *checkpoint) Valid() error { + if c.ID == 0 { + return errors.New("ID is 0") + } + if len(c.MTCLogID) == 0 { + return errors.New("MTCLogID is empty") + } + if c.TreeSize == 0 { + return errors.New("TreeSize is 0") + } + if len(c.RootHash) == 0 { + return errors.New("RootHash is empty") + } + if len(c.RootHash) != sha256.Size { + return fmt.Errorf("RootHash is %d bytes", len(c.RootHash)) + } + + return nil +} + +func (c *checkpoint) Equals(other *checkpoint) bool { + return reflect.DeepEqual(c, other) +} + +func (c *checkpoint) SequencingReady() bool { + return len(c.MTCASignature) > 0 && len(c.MirrorSignature) > 0 +} + +// String returns a string that is reasonable to print in logs, omitting the (large) signatures. +func (c *checkpoint) String() string { + caSig := "empty" + if len(c.MTCASignature) > 0 { + caSig = "non-empty" + } + mirrorSig := "empty" + if len(c.MirrorSignature) > 0 { + mirrorSig = "non-empty" + } + return fmt.Sprintf("ID:%d MTCLogID:%s MTCASignature:%s MirrorID:%s MirrorSignature:%s TreeSize:%d RootHash:%x", + c.ID, c.MTCLogID, caSig, c.MirrorID, mirrorSig, c.TreeSize, c.RootHash) +} + +func (m *mtca) latest(ctx context.Context) (*checkpoint, error) { + var latestCheckpoint checkpoint + err := m.db.SelectOne(ctx, &latestCheckpoint, + `SELECT id, checkpoints.mtcLogID, mtcaSignature, mirrorID, + mirrorSignature, treeSize, rootHash + FROM latestCheckpoint JOIN checkpoints + USING(id) + WHERE latestCheckpoint.mtcLogID = ? AND + checkpoints.mtcLogID = ?`, + m.mtcLogID(), + m.mtcLogID()) + if err != nil { + return nil, fmt.Errorf("getting latest checkpoint for %q: %w", m.mtcLogID(), err) + } + + return &latestCheckpoint, nil +} + +func (m *mtca) signCheckpoint(ctx context.Context, c *checkpoint) error { + err := c.Valid() + if err != nil { + return fmt.Errorf("validating checkpoint: %s", err) + } + + if len(c.MTCASignature) > 0 { + return errors.New("already MTCA-signed") + } + if len(c.MirrorSignature) > 0 { + return errors.New("already mirror-signed") + } + + message := cosigned.Message{ + CosignerName: "oid/1.3.6.1.4.1." + m.mtcaID, + Timestamp: 0, + LogOrigin: "oid/1.3.6.1.4.1." + m.mtcLogID(), + Start: 0, + End: uint64(c.TreeSize), + SubtreeHash: [32]byte(c.RootHash), + } + + marshaled, err := message.Marshal() + if err != nil { + return err + } + + sig, err := m.issuer.Signer.Sign(nil, marshaled, nil) + if err != nil { + return err + } + + c.MTCASignature = sig + + return err } diff --git a/sa/db/01-mtcmeta_44947_4_1_0_44.sql b/sa/db/01-mtcmeta_44947_4_1_0_44.sql index 425d4961f09..a33482e8aa4 100644 --- a/sa/db/01-mtcmeta_44947_4_1_0_44.sql +++ b/sa/db/01-mtcmeta_44947_4_1_0_44.sql @@ -1,5 +1,14 @@ USE mtcmeta_44947_4_1_0_44; +-- latestCheckpoint is a single-row table pointing to the id of the latest checkpoints. +-- Its single row is locked with SELECT ... FOR UPDATE before signing happens, and then +-- updated after signing happens. +CREATE TABLE `latestCheckpoint` ( + `id` bigint(20) NOT NULL, + -- ASCII-format OID relative to 1.3.6.1.4.1 + `mtcLogID` varchar(255) NOT NULL +); + CREATE TABLE `checkpoints` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, -- ASCII-format OID relative to 1.3.6.1.4.1 @@ -8,7 +17,7 @@ CREATE TABLE `checkpoints` ( -- This is redundant with the database/keyspace name and will be used for extra checks to ensure -- configuration errors can't result in using the wrong database/keyspace. `mtcLogID` varchar(255) NOT NULL, - `mtcaSignature` mediumblob NOT NULL, + `mtcaSignature` mediumblob, -- For simplicity we start out with a hardcoded assumption of one mirror signature, -- the planned CQRP requirement. If requirements increase we can add more fields. -- `mirrorID` is an ASCII-format OID relative to 1.3.6.1.4.1. diff --git a/sa/db/02-users_next.sql b/sa/db/02-users_next.sql index 986a7110b59..90b83ff0f66 100644 --- a/sa/db/02-users_next.sql +++ b/sa/db/02-users_next.sql @@ -98,9 +98,14 @@ GRANT ALL PRIVILEGES ON * to 'test_setup'@'%'; USE mtcmeta_44947_4_1_0_44; CREATE USER IF NOT EXISTS 'mtpublisher'@'%'; +CREATE USER IF NOT EXISTS 'mtca'@'%'; -- MTPublisher stub: reads checkpoints awaiting a cosignature and writes one. GRANT SELECT,UPDATE ON checkpoints TO 'mtpublisher'@'%'; +-- MTCA +GRANT SELECT,INSERT,UPDATE ON checkpoints TO 'mtca'@'%'; +GRANT SELECT,INSERT,UPDATE ON latestCheckpoint TO 'mtca'@'%'; + -- Test setup and teardown GRANT ALL PRIVILEGES ON * to 'test_setup'@'%'; diff --git a/test/certs/genmtpki/genmtpki.go b/test/certs/genmtpki/genmtpki.go index a72ebf407ee..e71f6d3b7d3 100644 --- a/test/certs/genmtpki/genmtpki.go +++ b/test/certs/genmtpki/genmtpki.go @@ -112,10 +112,10 @@ func mtcaSubject() pkix.Name { // idRDNATrustAnchorID := asn1.ObjectIdentifier{ 1, 3, 6, 1, 5, 5, 7, 25 } idRDNATrustAnchorIDExperimental := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44363, 47, 1} - // https://www.alvestrand.no/objectid/1.3.6.1.4.1.44947.1.html - // 44947.1 is Let's Encrypt; 44947.1.2 will be temporarily for our prototype MTC implementation, with ".1" + // https://letsencrypt.org/docs/oids/ + // 44947 is ISRG; 44947.4.1 will be temporarily for our prototype MTC implementation, with ".1" // representing one CA instance. - mtcaID := "44947.1.2.1" + mtcaID := "44947.4.1" attributes := []pkix.AttributeTypeAndValue{ { Type: idRDNATrustAnchorIDExperimental, diff --git a/test/config-next/mtca.json b/test/config-next/mtca.json index 679125a63e8..b10a85e7548 100644 --- a/test/config-next/mtca.json +++ b/test/config-next/mtca.json @@ -1,5 +1,8 @@ { "mtca": { + "db": { + "dbConnectFile": "test/secrets/mtca1_dburl" + }, "tls": { "caCertFile": "test/certs/ipki/minica.pem", "certFile": "test/certs/ipki/mtca.boulder/cert.pem", @@ -31,7 +34,7 @@ } }, "syslog": { - "stdoutlevel": 4, + "stdoutlevel": 7, "sysloglevel": -1 }, "openTelemetry": { diff --git a/test/config-next/proxysql/mtca1_dburl b/test/config-next/proxysql/mtca1_dburl new file mode 100644 index 00000000000..b3c930ad2e3 --- /dev/null +++ b/test/config-next/proxysql/mtca1_dburl @@ -0,0 +1 @@ +mtca@tcp(boulder-proxysql:6033)/mtcmeta_44947_4_1_0_44 diff --git a/test/config-next/vitess/mtca1_dburl b/test/config-next/vitess/mtca1_dburl new file mode 100644 index 00000000000..117eb22c209 --- /dev/null +++ b/test/config-next/vitess/mtca1_dburl @@ -0,0 +1 @@ +mtca@tcp(boulder-vitess:33577)/mtcmeta_44947_4_1_0_44 diff --git a/test/config/proxysql/mtca1_dburl b/test/config/proxysql/mtca1_dburl new file mode 100644 index 00000000000..b3c930ad2e3 --- /dev/null +++ b/test/config/proxysql/mtca1_dburl @@ -0,0 +1 @@ +mtca@tcp(boulder-proxysql:6033)/mtcmeta_44947_4_1_0_44 diff --git a/test/config/vitess/mtca1_dburl b/test/config/vitess/mtca1_dburl new file mode 100644 index 00000000000..117eb22c209 --- /dev/null +++ b/test/config/vitess/mtca1_dburl @@ -0,0 +1 @@ +mtca@tcp(boulder-vitess:33577)/mtcmeta_44947_4_1_0_44 diff --git a/test/entrypoint.sh b/test/entrypoint.sh index 741805f7b99..396065231ef 100755 --- a/test/entrypoint.sh +++ b/test/entrypoint.sh @@ -16,6 +16,7 @@ DB_URL_FILES=( incidents_dburl incidents_admin_dburl mtpublisher_dburl + mtca1_dburl revoker_dburl sa_dburl sa_ro_dburl diff --git a/test/mtca/README.md b/test/mtca/README.md new file mode 100644 index 00000000000..f1d9807af03 --- /dev/null +++ b/test/mtca/README.md @@ -0,0 +1,6 @@ +This directory contains a handful of utility scripts for manually interacting +with boulder-mtca. + + - init.sh: initialize the demo log SQL DB. + - clear.sh: clear the demo log SQL DB. + - start.sh: run boulder-mtca as a single component. diff --git a/test/mtca/clear.sh b/test/mtca/clear.sh new file mode 100755 index 00000000000..236c9aaa81d --- /dev/null +++ b/test/mtca/clear.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -feuxo pipefail +#mysql -h boulder-mariadb -u root -D mtcmeta_44947_4_1_0_44 -e "SELECT * from latestCheckpoint" +#mysql -h boulder-mariadb -u root -D mtcmeta_44947_4_1_0_44 -e "SELECT * from checkpoints" +mysql -h boulder-mariadb -u root -D mtcmeta_44947_4_1_0_44 -e "TRUNCATE TABLE checkpoints" +mysql -h boulder-mariadb -u root -D mtcmeta_44947_4_1_0_44 -e "TRUNCATE TABLE latestCheckpoint" diff --git a/test/mtca/init.sh b/test/mtca/init.sh new file mode 100755 index 00000000000..8ea0f6a636d --- /dev/null +++ b/test/mtca/init.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -feuxo pipefail + +make GO=gotip GO_BUILD_FLAGS= +exec ./bin/boulder boulder-mtca -config test/config-next/mtca.json -addr :9396 -init-log "$@" diff --git a/test/mtca/start.sh b/test/mtca/start.sh new file mode 100755 index 00000000000..ef6c94967f5 --- /dev/null +++ b/test/mtca/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -feuxo pipefail + +make GO=gotip GO_BUILD_FLAGS= +exec ./bin/boulder boulder-mtca -config test/config-next/mtca.json -addr :9396 "$@" diff --git a/test/proxysql/proxysql.cnf b/test/proxysql/proxysql.cnf index 16001f98546..f9ee171a403 100644 --- a/test/proxysql/proxysql.cnf +++ b/test/proxysql/proxysql.cnf @@ -103,6 +103,9 @@ mysql_users = }, { username = "mtpublisher"; + }, + { + username = "mtca"; } ); mysql_query_rules = diff --git a/test/secrets/mtca1_dburl b/test/secrets/mtca1_dburl new file mode 120000 index 00000000000..081aac5ec3f --- /dev/null +++ b/test/secrets/mtca1_dburl @@ -0,0 +1 @@ +../../test/config/proxysql/mtca1_dburl \ No newline at end of file diff --git a/test/startservers.py b/test/startservers.py index 56d677a15d7..f9f8ea417dc 100644 --- a/test/startservers.py +++ b/test/startservers.py @@ -149,7 +149,8 @@ SERVICES.extend([ Service('boulder-mtca-1', 8010, 9396, 'mtca.boulder', - ('./bin/boulder', 'boulder-mtca', '--config', os.path.join(config_dir, 'mtca.json'), '--addr', ':9396', '--debug-addr', ':8010'), + ('./bin/boulder', 'boulder-mtca', '--config', os.path.join(config_dir, 'mtca.json'), '--addr', ':9396', '--debug-addr', ':8010', + '-init-log-for-test'), None), Service('boulder-mtpublisher-1', 8025, None, None, diff --git a/trees/cosigned/message.go b/trees/cosigned/message.go new file mode 100644 index 00000000000..8b2277630de --- /dev/null +++ b/trees/cosigned/message.go @@ -0,0 +1,122 @@ +// Package cosigned implements CosignedMessage from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1. +package cosigned + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + + "golang.org/x/crypto/cryptobyte" +) + +// Message represents a CosignedMessage from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1. +type Message struct { + CosignerName string + Timestamp uint64 + LogOrigin string + Start uint64 + End uint64 + SubtreeHash [sha256.Size]byte +} + +const subtreeLabel = "subtree/v1\n\x00" + +// Marshal encodes the Message as bytes. +// +// It errors if cosigner_name or log_origin are too long or too short. It does not validate semantic constraints, +// like start < end. +// +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1 +// opaque HashValue[HASH_SIZE]; +// +// struct { +// uint8 label[12] = "subtree/v1\n\0"; +// opaque cosigner_name<1..2^8-1>; +// uint64 timestamp; +// opaque log_origin<1..2^8-1>; +// uint64 start; +// uint64 end; +// HashValue subtree_hash; +// } CosignedMessage; +func (message *Message) Marshal() ([]byte, error) { + if len(message.CosignerName) < 1 || len(message.CosignerName) > 255 { + return nil, fmt.Errorf("invalid cosigner_name length %d", len(message.CosignerName)) + } + if len(message.LogOrigin) < 1 || len(message.LogOrigin) > 255 { + return nil, fmt.Errorf("invalid log_origin length %d", len(message.LogOrigin)) + } + + var b cryptobyte.Builder + b.AddBytes([]byte(subtreeLabel)) + b.AddUint8LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes([]byte(message.CosignerName)) + }) + b.AddUint64(message.Timestamp) + b.AddUint8LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes([]byte(message.LogOrigin)) + }) + b.AddUint64(message.Start) + b.AddUint64(message.End) + b.AddBytes(message.SubtreeHash[:]) + + return b.Bytes() +} + +// Unmarshal unmarshals the input bytes and returns a *Message. +func Unmarshal(input []byte) (*Message, error) { + var out Message + + s := cryptobyte.String(input) + var label []byte + if !s.ReadBytes(&label, len(subtreeLabel)) { + return nil, errors.New("invalid label") + } + if !bytes.Equal(label, []byte(subtreeLabel)) { + return nil, errors.New("label was not subtree/v1") + } + + var cosignerName cryptobyte.String + if !s.ReadUint8LengthPrefixed(&cosignerName) { + return nil, errors.New("invalid cosigner_name") + } + if len(cosignerName) < 1 { + return nil, errors.New("empty cosigner_name") + } + out.CosignerName = string(cosignerName) + + if !s.ReadUint64(&out.Timestamp) { + return nil, errors.New("invalid timestamp") + } + + var logOrigin cryptobyte.String + if !s.ReadUint8LengthPrefixed(&logOrigin) { + return nil, errors.New("invalid log_origin") + } + if len(logOrigin) < 1 { + return nil, errors.New("empty log_origin") + } + out.LogOrigin = string(logOrigin) + + if !s.ReadUint64(&out.Start) { + return nil, errors.New("invalid start") + } + + if !s.ReadUint64(&out.End) { + return nil, errors.New("invalid end") + } + + var subtreeHash []byte + if !s.ReadBytes(&subtreeHash, len(out.SubtreeHash)) { + return nil, errors.New("invalid subtree hash") + } + copy(out.SubtreeHash[:], subtreeHash) + + if !s.Empty() { + return nil, errors.New("trailing bytes") + } + + return &out, nil +} diff --git a/trees/cosigned/message_test.go b/trees/cosigned/message_test.go new file mode 100644 index 00000000000..b6503c21b5d --- /dev/null +++ b/trees/cosigned/message_test.go @@ -0,0 +1,126 @@ +package cosigned + +import ( + "encoding/hex" + "reflect" + "strings" + "testing" +) + +func TestMessageRoundtrip(t *testing.T) { + m := Message{ + CosignerName: "alpha", + Timestamp: 1234, + LogOrigin: "beta", + Start: 999, + End: 1000, + SubtreeHash: [32]byte{}, + } + + copy(m.SubtreeHash[:], []byte("0123456789abcdef0123456789abcdef")) + + out, err := m.Marshal() + if err != nil { + t.Fatalf("marshaling: %s", err) + } + + m2, err := Unmarshal(out) + if err != nil { + t.Fatalf("unmarshaling encoded message: %s", err) + } + + if !reflect.DeepEqual(m, *m2) { + t.Errorf("round-tripping message: got %#v, want %#v", m, *m2) + } +} + +func TestMarshalErrors(t *testing.T) { + m := Message{ + CosignerName: "Michigan", + Timestamp: 1337000, + LogOrigin: "Illinois", + Start: 9, + End: 87654321, + SubtreeHash: [32]byte{}, + } + + type testCase struct { + name, expected string + distorter func(target *Message) + } + + testCases := []testCase{ + {"short CosignerName", "invalid cosigner_name length 0", func(target *Message) { + target.CosignerName = "" + }}, + {"long CosignerName", "invalid cosigner_name length 256", func(target *Message) { + target.CosignerName = strings.Repeat("a", 256) + }}, + {"short LogOrigin", "invalid log_origin length 0", func(target *Message) { + target.LogOrigin = "" + }}, + {"long LogOrigin", "invalid log_origin length 256", func(target *Message) { + target.LogOrigin = strings.Repeat("a", 256) + }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m2 := m + tc.distorter(&m2) + _, err := m2.Marshal() + if err == nil { + t.Fatalf("got no error, want %q", tc.expected) + } + if err.Error() != tc.expected { + t.Errorf("marshal with short name: got %q, want %q", err, tc.expected) + } + }) + } +} + +func TestUnmarshalErrors(t *testing.T) { + m := Message{ + CosignerName: "Debut", + Timestamp: 55555, + LogOrigin: "Post", + Start: 11, + End: 22, + SubtreeHash: [32]byte{}, + } + + out, err := m.Marshal() + if err != nil { + t.Fatalf("marshal: %s", err) + } + t.Logf("%x", out) + + _, err = Unmarshal(out[:len(out)-1]) + if err == nil { + t.Errorf("unmarshal with short input: got no error") + } + + long := append(out, byte('a')) + _, err = Unmarshal(long) + if err == nil { + t.Errorf("unmarshal with trailing bytes: got no error") + } + + emptyCosigner, err := hex.DecodeString("737562747265652f76310a0000000000000000d90304506f7374000000000000000b00000000000000160000000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Errorf("decoding hex: %s", err) + } + _, err = Unmarshal(emptyCosigner) + if err == nil { + t.Errorf("unmarshal with empty cosigner_name: got no error") + } + + emptyLogOrigin, err := hex.DecodeString("737562747265652f76310a00054465627574000000000000d90300000000000000000b00000000000000160000000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Errorf("decoding hex: %s", err) + } + _, err = Unmarshal(emptyLogOrigin) + if err == nil { + t.Errorf("unmarshal with empty log_origin: got no error") + } +}