diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index fbac967ee59..96891515893 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -11,10 +11,10 @@ import ( "fmt" "os" + "github.com/letsencrypt/boulder/bs3" "github.com/letsencrypt/boulder/cmd" "github.com/letsencrypt/boulder/config" "github.com/letsencrypt/boulder/mtpublisher" - "github.com/letsencrypt/boulder/privatekey" "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/trees/issuancelog" ) @@ -25,25 +25,29 @@ type Config struct { DebugAddr string `validate:"omitempty,hostname_port"` - // PollInterval is how often the stub scans for checkpoints that still - // lack a mirror cosignature. + // PollInterval is how often the publisher scans for checkpoints that + // still lack a mirror cosignature. PollInterval config.Duration `validate:"required"` // LogID identifies the issuance log this publisher operates on. It must // match the mtca's. LogID issuancelog.ID `validate:"required"` - // MirrorID identifies the cosigner this publisher writes alongside each - // cosignature (e.g. "32473.9"). - MirrorID string `validate:"required"` + // MTCAPublicKeyFile holds the PEM-encoded ML-DSA-44 public key the mtca + // cosigns checkpoints with, used to reconstruct each checkpoint's + // signed note from the database. + MTCAPublicKeyFile string `validate:"required"` - // MirrorPublicKeyFile holds the PEM-encoded ML-DSA-44 public key used - // to verify cosignatures. - MirrorPublicKeyFile string `validate:"required"` + // Mirror identifies the mirror this publisher submits to. + Mirror cmd.MirrorConfig `validate:"required"` - // MirrorKeyFile holds the PEM-encoded ML-DSA-44 private key used to - // cosign checkpoints. - MirrorKeyFile string `validate:"required"` + // MirrorBaseURL is the base URL of the mirror's tlog-mirror submission + // endpoints (e.g. "http://localhost:4700"). + MirrorBaseURL string `validate:"required,url"` + + // S3 locates the source log's tile storage, which the publisher reads + // entries and proof hashes from when submitting to the mirror. + S3 bs3.Config `validate:"required"` } Syslog cmd.SyslogConfig OpenTelemetry cmd.OpenTelemetryConfig @@ -94,13 +98,18 @@ func main() { dbMap, err := sa.InitWrappedDb(c.MTPublisher.DB, scope, logger) cmd.FailOnError(err, "While initializing dbMap") - signer, _, err := privatekey.Load(c.MTPublisher.MirrorKeyFile) - cmd.FailOnError(err, "Loading cosigner key") - pubKey, err := loadMLDSAPublicKey(c.MTPublisher.MirrorPublicKeyFile) - cmd.FailOnError(err, "Loading cosigner public key") + pubKey, err := loadMLDSAPublicKey(c.MTPublisher.Mirror.PublicKeyFile) + cmd.FailOnError(err, "Loading mirror public key") + caPubKey, err := loadMLDSAPublicKey(c.MTPublisher.MTCAPublicKeyFile) + cmd.FailOnError(err, "Loading MTCA public key") + s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger) + cmd.FailOnError(err, "Loading S3 config") + + mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.MirrorBaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, pubKey) + cmd.FailOnError(err, "Creating mirror client") - publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, c.MTPublisher.MirrorID, signer, pubKey, logger) - cmd.FailOnError(err, "Failed to create MTPublisher stub") + publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger) + cmd.FailOnError(err, "Failed to create MTPublisher") ctx, cancel := context.WithCancel(context.Background()) go cmd.CatchSignals(cancel) diff --git a/cmd/config.go b/cmd/config.go index 853d2b61ffd..9d9c149d958 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -129,6 +129,14 @@ type HostnamePolicyConfig struct { HostnamePolicyFile string `validate:"required"` } +// MirrorConfig identifies an MTC mirror cosigner. +type MirrorConfig struct { + // ID is the mirror's ID (e.g. "32473.9"). + ID string `validate:"required"` + // PublicKeyFile holds the mirror's PEM-encoded ML-DSA-44 public key. + PublicKeyFile string `validate:"required"` +} + // TLSConfig represents certificates and a key for authenticated TLS. type TLSConfig struct { CertFile string `validate:"required"` diff --git a/mtca/mtca_test.go b/mtca/mtca_test.go index 4d9d9b42928..ade9ba8f373 100644 --- a/mtca/mtca_test.go +++ b/mtca/mtca_test.go @@ -34,7 +34,9 @@ import ( blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/mtca/proto" "github.com/letsencrypt/boulder/mtpublisher" + "github.com/letsencrypt/boulder/mtpublisher/mtpublishertest" "github.com/letsencrypt/boulder/privatekey" + "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/test/vars" "github.com/letsencrypt/boulder/trees/cosigned" "github.com/letsencrypt/boulder/trees/entry" @@ -273,11 +275,23 @@ func (e *errorS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optF // in for the daemon, so sequencing can proceed. func mirrorCosign(t *testing.T, m *mtca) { t.Helper() + caPub, ok := m.issuer.Signer.Public().(*mldsa.PublicKey) + if !ok { + t.Fatalf("issuer public key is %T, must be ML-DSA-44", m.issuer.Signer.Public()) + } key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), make([]byte, 32)) if err != nil { t.Fatalf("NewPrivateKey: %s", err) } - p, err := mtpublisher.New(m.db, time.Second, m.logID, "32473.9", privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + mirror, err := mtpublishertest.NewTestMirror("32473.9", m.logID.Origin(), privatekey.NewDeterministicSigner(key)) + if err != nil { + t.Fatalf("mtpublishertest.NewTestMirror: %s", err) + } + dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms) + if err != nil { + t.Fatalf("opening mtcmeta dbMap: %s", err) + } + p, err := mtpublisher.New(dbMap, time.Second, m.logID, caPub, mirror, blog.NewMock()) if err != nil { t.Fatalf("mtpublisher.New: %s", err) } diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go new file mode 100644 index 00000000000..07d7f33d0b7 --- /dev/null +++ b/mtpublisher/mirror.go @@ -0,0 +1,263 @@ +//go:build go1.27 + +package mtpublisher + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/mldsa" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/mod/sumdb/tlog" + + "github.com/letsencrypt/boulder/trees/checkpoint" + "github.com/letsencrypt/boulder/trees/cosignature" + "github.com/letsencrypt/boulder/trees/mirror" +) + +// maxMirrorResponseSize caps how much of a mirror's response body the client +// reads. The largest expected body is a few ML-DSA-44 signature lines of under +// 4KB each. +const maxMirrorResponseSize = 64 << 10 + +var _ Mirror = (*MirrorClient)(nil) + +// MirrorClient is a Mirror that uses the c2sp.org/tlog-mirror submission +// protocol, submitting the checkpoint to add-checkpoint and uploading the log's +// entries to add-entries until the mirror cosigns. +type MirrorClient struct { + submissionPrefix string + client *http.Client + src *Source + mirrorID string + verifier *cosignature.Verifier + + // oldSize is the tree size of the mirror's latest cosigned checkpoint, the + // old size of the next add-checkpoint request. + oldSize int64 + // nextEntry is the next entry the mirror expects to receive. + nextEntry int64 + // ticket is the opaque value from the mirror's last mirror-info response, + // to be sent back in the next add-entries request. + ticket []byte + // lastSigned is when Cosign last succeeded. + lastSigned time.Time +} + +// NewMirrorClient returns a MirrorClient that submits to the mirror's endpoints +// under baseURL. +func NewMirrorClient(baseURL string, src *Source, mirrorID string, mirrorPublicKey *mldsa.PublicKey) (*MirrorClient, error) { + if baseURL == "" { + return nil, errors.New("empty mirror base URL") + } + verifier, err := cosignature.NewVerifier(mirrorID, mirrorPublicKey) + if err != nil { + return nil, fmt.Errorf("creating mirror verifier: %s", err) + } + return &MirrorClient{ + submissionPrefix: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + src: src, + mirrorID: mirrorID, + verifier: verifier, + }, nil +} + +// ID returns the mirror's cosigner ID. +func (m *MirrorClient) ID() string { + return m.mirrorID +} + +// post sends body to the endpoint at path and returns the response status and +// body. If compress is true, the body is gzip compressed. +func (m *MirrorClient) post(ctx context.Context, path, contentType string, compress bool, body []byte) (int, []byte, error) { + if compress { + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + _, err := zw.Write(body) + if err != nil { + return 0, nil, err + } + err = zw.Close() + if err != nil { + return 0, nil, err + } + body = compressed.Bytes() + } + endpoint, err := url.JoinPath(m.submissionPrefix, path) + if err != nil { + return 0, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return 0, nil, err + } + if compress { + req.Header.Set("Content-Encoding", "gzip") + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := m.client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxMirrorResponseSize)) + if err != nil { + return 0, nil, fmt.Errorf("reading mirror response: %s", err) + } + return resp.StatusCode, respBody, nil +} + +// addCheckpoint submits the log's signed checkpoint note with a consistency +// proof from the mirror's last known size, updating the mirror's pending +// checkpoint. On a "409 Conflict" it adopts the size the mirror advertises and +// retries once. +func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signedNote []byte) error { + oldSize := m.oldSize + retried := false + for { + if oldSize > tree.N { + return fmt.Errorf("mirror already holds size %d, checkpoint size is %d", oldSize, tree.N) + } + var proof []tlog.Hash + if oldSize > 0 && oldSize < tree.N { + treeProof, err := m.src.consistencyProof(ctx, tree, oldSize) + if err != nil { + return fmt.Errorf("proving consistency from size %d: %s", oldSize, err) + } + proof = treeProof + } + body, err := mirror.AddCheckpointRequest(oldSize, proof, signedNote) + if err != nil { + return err + } + status, respBody, err := m.post(ctx, "/add-checkpoint", "", false, body) + if err != nil { + return err + } + switch status { + case http.StatusOK: + m.oldSize = tree.N + return nil + case http.StatusConflict: + if retried { + return errors.New("mirror rejected the old size twice") + } + retried = true + oldSize, err = mirror.ParseSizeResponse(respBody) + if err != nil { + return err + } + default: + return fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + } + } +} + +// maxAddEntriesRequests bounds one Cosign call's add-entries requests, each of +// up to MaxPackagesPerRequest entry packages, so an upload terminates against a +// mirror that never makes progress. +const maxAddEntriesRequests = 100 + +// addEntries uploads the entries the mirror is missing, up to the tree size, +// and returns the cosignature lines from the mirror's "200 Success" response. +// On "202 Accepted" and "409 Conflict" it resumes from the next entry and +// ticket the mirror advertises. +func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog.Tree) ([]byte, error) { + start := min(m.nextEntry, tree.N) + ticket := m.ticket + for range maxAddEntriesRequests { + packages, err := mirror.Packages(start, tree.N, mirror.MaxPackagesPerRequest) + if err != nil { + return nil, err + } + var bodies [][]byte + for _, p := range packages { + body, err := m.src.entryPackage(ctx, tree, p) + if err != nil { + return nil, err + } + bodies = append(bodies, body) + } + reqBody, err := mirror.AddEntriesRequest(origin, start, tree.N, ticket, bodies) + if err != nil { + return nil, err + } + status, respBody, err := m.post(ctx, "/add-entries", "application/octet-stream", true, reqBody) + if err != nil { + return nil, err + } + switch status { + case http.StatusOK: + m.nextEntry = tree.N + m.ticket = nil + return respBody, nil + + case http.StatusAccepted, http.StatusConflict: + info, err := mirror.ParseMirrorInfo(respBody) + if err != nil { + return nil, err + } + if info.TreeSize != tree.N { + return nil, fmt.Errorf("mirror wants upload_end %d, checkpoint size is %d", info.TreeSize, tree.N) + } + start = info.NextEntry + ticket = info.Ticket + m.nextEntry = info.NextEntry + m.ticket = bytes.Clone(info.Ticket) + + default: + return nil, fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + } + } + return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests) +} + +// Cosign runs the c2sp.org/tlog-mirror submission protocol for the checkpoint +// and returns the mirror's raw cosignature, verified against the mirror's key. +func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNoteForMirror []byte) ([]byte, error) { + // Submit the checkpoint to the mirror. + err := m.addCheckpoint(ctx, cp.Tree, signedNoteForMirror) + if err != nil { + return nil, fmt.Errorf("add-checkpoint: %w", err) + } + + // Upload the checkpoint's entries to the mirror until it cosigns. + mirrorCosignatureLines, err := m.addEntries(ctx, cp.Origin, cp.Tree) + if err != nil { + return nil, fmt.Errorf("add-entries: %w", err) + } + + // Verify the mirror's cosignature. + noteText, err := cp.Marshal() + if err != nil { + return nil, fmt.Errorf("marshaling the checkpoint: %w", err) + } + timestampedMirrorCosignature, err := cosignature.TimestampedSignature(noteText, mirrorCosignatureLines, m.verifier) + if err != nil { + return nil, fmt.Errorf("cosignature failed verification: %w", err) + } + + // Finally, extract the raw cosignature we store in the database. + rawMirrorCosignature, err := cosignature.RawSignature(timestampedMirrorCosignature) + if err != nil { + return nil, fmt.Errorf("cosignature: %w", err) + } + m.lastSigned = time.Now() + return rawMirrorCosignature, nil +} + +// LastSigned returns when Cosign last succeeded, zero before it has. +func (m *MirrorClient) LastSigned() time.Time { + return m.lastSigned +} diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 3c9951c9ebd..388d26fd502 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -4,12 +4,8 @@ package mtpublisher import ( "context" - "crypto" "crypto/mldsa" - "crypto/sha256" "database/sql" - "encoding/base64" - "encoding/binary" "errors" "fmt" "time" @@ -23,143 +19,125 @@ import ( "github.com/letsencrypt/boulder/trees/issuancelog" ) -// publisher polls the MTC issuance log and cosigns the latest checkpoint if it -// lacks a mirror cosignature, playing both halves of the future exchange: it -// signs a signature line as the mirror, then ingests it through the note layer -// as the publisher will once the mirror is a separate server. It is a stub for -// the real MTPublisher. -type publisher struct { - db *db.WrappedMap - interval time.Duration - mtcLogID string - origin string - mirrorID string - mirrorName string - mirrorKeyID uint32 - mirrorCosigner *cosignature.Cosigner - verifier *cosignature.Verifier - log blog.Logger +// Mirror cosigns checkpoints, requiring the entries they commit to before +// signing. +// +// https://c2sp.org/tlog-cosignature +type Mirror interface { + // ID returns the mirror's cosigner ID. + ID() string + // Cosign submits the log's signed note for cp and returns the mirror's raw + // cosignature, verified against the mirror's key. + Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNote []byte) ([]byte, error) + // LastSigned returns when Cosign last succeeded, zero before it has. + LastSigned() time.Time +} + +// mtpublisher obtains and stores its mirror's cosignature over the issuance +// log's latest checkpoint. +type mtpublisher struct { + db *db.WrappedMap + interval time.Duration + logID issuancelog.ID + mirror Mirror + caVerifier *cosignature.Verifier + log blog.Logger } -// New returns a publisher for the issuance log logID. It cosigns as the mirror -// with mirrorID using signer, and verifies each cosignature against pubKey -// before storing it. -func New(dbMap *db.WrappedMap, interval time.Duration, logID issuancelog.ID, mirrorID string, signer crypto.Signer, pubKey *mldsa.PublicKey, log blog.Logger) (*publisher, error) { +// New returns a publisher for the issuance log logID. It reconstructs each +// checkpoint's signed note from the stored MTCA signature, verified against +// mtcaPublicKey, and obtains each cosignature from mirror, which verifies it +// before returning it. +func New(dbMap *db.WrappedMap, interval time.Duration, logID issuancelog.ID, mtcaPublicKey *mldsa.PublicKey, mirror Mirror, log blog.Logger) (*mtpublisher, error) { if interval <= 0 { return nil, fmt.Errorf("interval must be positive, got %s", interval) } - cosigner, err := cosignature.NewCosigner(mirrorID, logID.Origin(), signer) - if err != nil { - return nil, fmt.Errorf("creating mirror cosigner: %s", err) - } - verifier, err := cosignature.NewVerifier(mirrorID, pubKey) + + caVerifier, err := cosignature.NewVerifier(logID.CAID, mtcaPublicKey) if err != nil { - return nil, fmt.Errorf("creating mirror verifier: %s", err) + return nil, fmt.Errorf("creating MTCA verifier: %s", err) } - // The mirror's key ID per c2sp.org/tlog-cosignature, repeated from - // trees/cosignature for the stub's mirror half like the line encoding in - // cosignatureLine. - mirrorName := "oid/1.3.6.1.4.1." + mirrorID - h := sha256.New() - h.Write([]byte(mirrorName)) - h.Write([]byte{'\n', 0x06}) - h.Write(pubKey.Bytes()) - mirrorKeyID := binary.BigEndian.Uint32(h.Sum(nil)[:4]) - - return &publisher{ - db: dbMap, - interval: interval, - mtcLogID: logID.String(), - origin: cosigner.Origin(), - mirrorID: mirrorID, - mirrorName: mirrorName, - mirrorKeyID: mirrorKeyID, - mirrorCosigner: cosigner, - verifier: verifier, - log: log, + return &mtpublisher{ + db: dbMap, + interval: interval, + logID: logID, + mirror: mirror, + caVerifier: caVerifier, + log: log, }, nil } type checkpointRow struct { - ID int64 `db:"id"` - MTCLogID string `db:"mtcLogID"` - MTCASignature []byte `db:"mtcaSignature"` - MirrorID string `db:"mirrorID"` - MirrorSignature []byte `db:"mirrorSignature"` - TreeSize int64 `db:"treeSize"` - RootHash []byte `db:"rootHash"` + ID int64 `db:"id"` + MTCLogID string `db:"mtcLogID"` + MTCASignature []byte `db:"mtcaSignature"` + MirrorID string `db:"mirrorID"` + MirrorSignature []byte `db:"mirrorSignature"` + TreeSize int64 `db:"treeSize"` + RootHash []byte `db:"rootHash"` + Created time.Time `db:"created"` } -// cosign cosigns the checkpoint described by tree as the mirror and returns the -// signature line it would send to the publisher. -// -// - https://c2sp.org/tlog-cosignature -// - https://c2sp.org/tlog-mirror -func (p *publisher) cosign(tree tlog.Tree) (string, error) { - timestampedCosignature, err := p.mirrorCosigner.CosignCheckpoint(tree) - if err != nil { - return "", err - } - idSignature := make([]byte, 4+len(timestampedCosignature)) - binary.BigEndian.PutUint32(idSignature[:4], p.mirrorKeyID) - copy(idSignature[4:], timestampedCosignature) - return "— " + p.mirrorName + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n", nil -} - -// Publish cosigns the latest checkpoint in the database if it lacks a mirror -// cosignature and stores the raw signature in the database. Start calls it at -// each interval. -func (p *publisher) Publish(ctx context.Context) error { +// Publish submits the latest checkpoint to the mirror if it lacks a mirror +// cosignature and stores the returned raw cosignature. Start calls it at each +// interval. +func (p *mtpublisher) Publish(ctx context.Context) error { var latest checkpointRow err := p.db.SelectOne(ctx, &latest, - `SELECT id, checkpoints.mtcLogID, mtcaSignature, mirrorID, - mirrorSignature, treeSize, rootHash + `SELECT id, checkpoints.mtcLogID, mtcaSignature, + COALESCE(mirrorID, '') AS mirrorID, + mirrorSignature, treeSize, rootHash, created FROM latestCheckpoint JOIN checkpoints USING(id) WHERE latestCheckpoint.mtcLogID = ? AND checkpoints.mtcLogID = ?`, - p.mtcLogID, - p.mtcLogID) + p.logID.String(), + p.logID.String()) if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { return fmt.Errorf("selecting the latest checkpoint: %w", err) } - if latest.MirrorSignature != nil { + if len(latest.MirrorSignature) > 0 { 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) } + + // Assemble the checkpoint for submission to the mirror. tree := tlog.Tree{N: latest.TreeSize, Hash: tlog.Hash(latest.RootHash)} + cp := &checkpoint.Checkpoint{Origin: p.logID.Origin(), Tree: tree} - // The mirror's half of the exchange. - cosigLine, err := p.cosign(tree) - if err != nil { - return fmt.Errorf("cosigning checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + // Reconstruct the MTCA's cosignature line from the stored MTCA signature. + if len(latest.MTCASignature) == 0 { + return fmt.Errorf("checkpoint %d (%s size %d) has no MTCA signature", latest.ID, latest.MTCLogID, latest.TreeSize) } - p.log.Infof("Cosigned checkpoint %d (%s size %d)", latest.ID, latest.MTCLogID, latest.TreeSize) - - // The publisher's half of the exchange. - cp := checkpoint.Checkpoint{Origin: p.origin, Tree: tree} - text, err := cp.Marshal() + caCosignatureLine, err := p.caVerifier.SignatureLine(cp.Origin, tree, latest.MTCASignature) if err != nil { - return fmt.Errorf("marshaling checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + return fmt.Errorf("checkpoint %d MTCA signature: %w", latest.ID, err) } - timestampedMirrorCosig, err := cosignature.TimestampedSignature(text, []byte(cosigLine), p.verifier) + + // Reconstruct the signed note for submission to the mirror. + signedNoteForMirror, err := cp.SignedNoteForMirror(caCosignatureLine) if err != nil { - return fmt.Errorf("checkpoint %d cosignature failed verification before storage: %w", latest.ID, err) + return fmt.Errorf("assembling checkpoint %d signed note: %w", latest.ID, err) } - mirrorCosig, err := cosignature.RawSignature(timestampedMirrorCosig) + + // Submit the signed checkpoint to the mirror for cosigning. + mirrorRawCosig, err := p.mirror.Cosign(ctx, cp, signedNoteForMirror) if err != nil { - return fmt.Errorf("checkpoint %d cosignature: %w", latest.ID, err) + return fmt.Errorf("publishing checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } + p.log.Infof("Published checkpoint %d (%s size %d)", latest.ID, latest.MTCLogID, latest.TreeSize) + + // Store the mirror's cosignature in the database. _, err = p.db.ExecContext(ctx, "UPDATE checkpoints SET mirrorID = ?, mirrorSignature = ? WHERE id = ? AND mtcLogID = ?", - p.mirrorID, mirrorCosig, latest.ID, p.mtcLogID) + p.mirror.ID(), mirrorRawCosig, latest.ID, p.logID.String()) if err != nil { return fmt.Errorf("storing checkpoint %d cosignature (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } @@ -167,15 +145,15 @@ func (p *publisher) Publish(ctx context.Context) error { return nil } -// Start attempts to cosign the latest checkpoint at each interval until ctx is +// Start attempts to publish the latest checkpoint at each interval until ctx is // cancelled. -func (p *publisher) Start(ctx context.Context) { +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) + p.log.Errf("Publishing pass failed: %s", err) } select { case <-ctx.Done(): diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index e2eb6763a04..6437df349c7 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -3,22 +3,34 @@ package mtpublisher import ( + "bytes" + "compress/gzip" "context" "crypto/mldsa" "encoding/base64" + "encoding/binary" + "io" + "net/http" + "net/http/httptest" "strings" "testing" "time" "golang.org/x/mod/sumdb/tlog" + "github.com/letsencrypt/boulder/bs3/bs3test" + "github.com/letsencrypt/boulder/db" blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mtpublisher/mtpublishertest" "github.com/letsencrypt/boulder/privatekey" "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/test/vars" + "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" + "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" + "github.com/letsencrypt/boulder/trees/tiles" ) const ( @@ -67,12 +79,46 @@ func setLatest(t *testing.T, dbMap *db.WrappedMap, logID string, id int64) { } } +// testCAKey returns a deterministic ML-DSA-44 key standing in for the mtca's +// checkpoint signing key. +func testCAKey(t *testing.T) *mldsa.PrivateKey { + t.Helper() + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i + 101) + } + key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + return key +} + +// caSignature returns the raw MTCA signature for a checkpoint of treeSize with +// a zero root hash, as insertCheckpoint stores. +func caSignature(t *testing.T, treeSize int64) []byte { + t.Helper() + ca, err := cosignature.NewCosigner(testLogID.CAID, testLogID.Origin(), privatekey.NewDeterministicSigner(testCAKey(t))) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + timestamped, err := ca.CosignCheckpoint(tlog.Tree{N: treeSize}) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + raw, err := cosignature.RawSignature(timestamped) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + return raw +} + 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)) + logID, caSignature(t, treeSize), treeSize, make([]byte, 32)) if err != nil { t.Fatalf("inserting checkpoint (%s size %d): %s", logID, treeSize, err) } @@ -109,47 +155,20 @@ func testKey(t *testing.T) *mldsa.PrivateKey { return key } -// TestCosign checks that the mirror's cosignature line verifies through -// trees/cosignature and yields the timestamped_signature it encodes. -func TestCosign(t *testing.T) { - key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) - if err != nil { - t.Fatalf("New: %s", err) - } - - line, err := p.cosign(tlog.Tree{N: 512}) - if err != nil { - t.Fatalf("cosign: %s", err) - } - if !strings.HasPrefix(line, "— oid/1.3.6.1.4.1."+mirrorID+" ") || !strings.HasSuffix(line, "\n") { - t.Errorf("line %q is not a cosignature line for the mirror", line) - } - - verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) - if err != nil { - t.Fatalf("NewVerifier: %s", err) - } - text := p.origin + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" - timestampedSignature, err := cosignature.TimestampedSignature([]byte(text), []byte(line), verifier) - if err != nil { - t.Fatalf("TimestampedSignature: %s", err) - } - _, err = cosignature.RawSignature(timestampedSignature) +// testMirror returns a LocalMirror that cosigns with key. +func testMirror(t *testing.T, key *mldsa.PrivateKey) *mtpublishertest.TestMirror { + t.Helper() + mirror, err := mtpublishertest.NewTestMirror(mirrorID, testLogID.Origin(), privatekey.NewDeterministicSigner(key)) if err != nil { - t.Errorf("RawSignature: %s", err) - } - - _, err = p.cosign(tlog.Tree{}) - if err == nil { - t.Error("cosign with an empty tree = nil error, want error") + t.Fatalf("NewTestMirror: %s", err) } + return mirror } func TestPublish(t *testing.T) { dbMap := setupDB(t) key := testKey(t) - p, err := New(dbMap, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { t.Fatalf("New: %s", err) } @@ -229,41 +248,42 @@ func TestPublish(t *testing.T) { } } -// TestPublishRejectsMismatchedKey checks that a cosignature that fails to -// verify against the configured public key is not stored. -func TestPublishRejectsMismatchedKey(t *testing.T) { +// TestPublishRejectsBadMTCASignature checks that a checkpoint whose stored +// MTCA signature does not verify is neither submitted nor cosigned. +func TestPublishRejectsBadMTCASignature(t *testing.T) { dbMap := setupDB(t) - - otherSeed := make([]byte, 32) - for i := range otherSeed { - otherSeed[i] = byte(255 - i) - } - otherKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), otherSeed) + key := testKey(t) + p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { - t.Fatalf("NewPrivateKey: %s", err) + t.Fatalf("New: %s", err) } - p, err := New(dbMap, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(testKey(t)), otherKey.PublicKey(), blog.NewMock()) + // A well-formed MTCA signature over the wrong tree size. + res, err := dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", + mtcLogID, caSignature(t, 999), int64(512), make([]byte, 32)) if err != nil { - t.Fatalf("New: %s", err) + t.Fatalf("inserting checkpoint: %s", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) } - - id := insertCheckpoint(t, dbMap, mtcLogID, 512) setLatest(t, dbMap, mtcLogID, id) err = p.Publish(t.Context()) if err == nil { - t.Error("publish with a mismatched public key = nil error, want error") + t.Error("publish with a bad MTCA signature = nil error, want error") } if !lacksCosignature(t, dbMap, id) { - t.Error("cosignature was stored despite failing verification") + t.Error("cosignature was stored despite the MTCA signature failing verification") } } func TestPublishWhenLatestAlreadySigned(t *testing.T) { dbMap := setupDB(t) key := testKey(t) - p, err := New(dbMap, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { t.Fatalf("New: %s", err) } @@ -272,7 +292,7 @@ func TestPublishWhenLatestAlreadySigned(t *testing.T) { // untouched. res, 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")) + mtcLogID, caSignature(t, 512), int64(512), make([]byte, 32), "existing.cosigner", []byte("already-signed-bruh")) if err != nil { t.Fatalf("inserting cosigned checkpoint: %s", err) } @@ -305,3 +325,297 @@ func TestPublishWhenLatestAlreadySigned(t *testing.T) { t.Errorf("existing cosignature was replaced: %q", mirrorCosignature) } } + +// sourceLog is a published source log in fake tile storage, with an earlier +// published tree so tests can exercise consistency proofs between the two. +type sourceLog struct { + fs3 *bs3test.FakeS3 + older tlog.Tree + newer tlog.Tree + cp *checkpoint.Checkpoint + signedNote []byte + + // mirrorKey signs cosigLine, the mirror's signature line over the newer + // tree, which carries the raw cosignature rawCosig. + mirrorKey *mldsa.PrivateKey + cosigLine []byte + rawCosig []byte +} + +const testTilePrefix = "44947.4.1/44" + +// newSourceLog publishes a 300 entry tree and grows it to 700 entries, +// returning the storage and the newer tree's checkpoint text. +func newSourceLog(t *testing.T) *sourceLog { + t.Helper() + fs3 := bs3test.New() + f := &tiles.Frontier{} + grow := func(n int64) tlog.Tree { + t.Helper() + for range n { + err := f.AppendEntry(&entry.MTCLogEntry{}) + if err != nil { + t.Fatalf("AppendEntry: %s", err) + } + } + err := f.Publish(t.Context(), fs3, testTilePrefix) + if err != nil { + t.Fatalf("Publish: %s", err) + } + return tlog.Tree{N: f.TreeSize(), Hash: f.RootHash()} + } + older := grow(300) + newer := grow(400) + cp := &checkpoint.Checkpoint{Origin: "oid/1.3.6.1.4.1." + mtcLogID, Tree: newer} + + caSeed := make([]byte, 32) + for i := range caSeed { + caSeed[i] = byte(i + 101) + } + caKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), caSeed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + ca, err := cosignature.NewCosigner(testLogID.CAID, testLogID.Origin(), privatekey.NewDeterministicSigner(caKey)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + timestampedCA, err := ca.CosignCheckpoint(newer) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + rawCA, err := cosignature.RawSignature(timestampedCA) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + caVerifier, err := cosignature.NewVerifier(testLogID.CAID, caKey.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + caLine, err := caVerifier.SignatureLine(cp.Origin, newer, rawCA) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + signedNote, err := cp.SignedNoteForMirror(caLine) + if err != nil { + t.Fatalf("SignedNoteForMirror: %s", err) + } + + mirrorSeed := make([]byte, 32) + for i := range mirrorSeed { + mirrorSeed[i] = byte(i + 201) + } + mirrorKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), mirrorSeed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + mirrorCosigner, err := cosignature.NewCosigner(mirrorID, cp.Origin, privatekey.NewDeterministicSigner(mirrorKey)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + timestamped, err := mirrorCosigner.CosignCheckpoint(newer) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + rawCosig, err := cosignature.RawSignature(timestamped) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + mirrorVerifier, err := cosignature.NewVerifier(mirrorID, mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + cosigLine, err := mirrorVerifier.SignatureLine(cp.Origin, newer, rawCosig) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + return &sourceLog{ + fs3: fs3, older: older, newer: newer, cp: cp, signedNote: signedNote, + mirrorKey: mirrorKey, cosigLine: cosigLine, rawCosig: rawCosig, + } +} + +// requestBody reads a request body, requiring gzip compression on add-entries +// requests. +func requestBody(t *testing.T, r *http.Request) []byte { + t.Helper() + if r.URL.Path == "/add-entries" && r.Header.Get("Content-Encoding") != "gzip" { + t.Error("add-entries request is not gzip compressed") + } + reader := io.Reader(r.Body) + if r.Header.Get("Content-Encoding") == "gzip" { + zr, err := gzip.NewReader(r.Body) + if err != nil { + t.Fatalf("opening request body: %s", err) + } + reader = zr + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("reading request body: %s", err) + } + return body +} + +// parseUploadHeader pulls upload_start and the ticket out of an add-entries +// request body. +func parseUploadHeader(t *testing.T, body []byte) (int64, []byte) { + t.Helper() + originLen := int(binary.BigEndian.Uint16(body[:2])) + rest := body[2+originLen:] + uploadStart := int64(binary.BigEndian.Uint64(rest[:8])) + ticketLen := int(binary.BigEndian.Uint16(rest[16:18])) + return uploadStart, rest[18 : 18+ticketLen] +} + +// TestMirrorCosign drives the client through a scripted exchange. The mirror +// answers the first add-checkpoint with "409 Conflict" at size 300 so the +// client must prove consistency from there, then answers the first add-entries +// with "202 Accepted" at entry 512 and a ticket the client must echo before the +// "200 Success" carrying the cosignature line. +func TestMirrorCosign(t *testing.T) { + source := newSourceLog(t) + line := string(source.cosigLine) + + var addCheckpointCalls, addEntriesCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := requestBody(t, r) + switch r.URL.Path { + case "/add-checkpoint": + addCheckpointCalls++ + switch addCheckpointCalls { + case 1: + if !bytes.HasPrefix(body, []byte("old 0\n\n")) { + t.Errorf("first add-checkpoint body %q does not claim old size 0 with an empty proof", body) + } + w.Header().Set("Content-Type", "text/x.tlog.size") + w.WriteHeader(http.StatusConflict) + io.WriteString(w, "300\n") + default: + header, _, ok := bytes.Cut(body, []byte("\n\n")) + lines := strings.Split(string(header), "\n") + if !ok || lines[0] != "old 300" { + t.Fatalf("second add-checkpoint body %q does not claim old size 300", body) + } + proof := make(tlog.TreeProof, len(lines)-1) + for i, l := range lines[1:] { + h, err := tlog.ParseHash(l) + if err != nil { + t.Fatalf("proof line %q: %s", l, err) + } + proof[i] = h + } + err := tlog.CheckTree(proof, source.newer.N, source.newer.Hash, source.older.N, source.older.Hash) + if err != nil { + t.Errorf("client's consistency proof does not verify: %s", err) + } + } + case "/add-entries": + addEntriesCalls++ + uploadStart, ticket := parseUploadHeader(t, body) + switch addEntriesCalls { + case 1: + if uploadStart != 0 || len(ticket) != 0 { + t.Errorf("first add-entries upload_start = %d ticket = %q, want 0 and empty", uploadStart, ticket) + } + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusAccepted) + io.WriteString(w, "700\n512\n"+base64.StdEncoding.EncodeToString([]byte("resume"))+"\n") + default: + if uploadStart != 512 || string(ticket) != "resume" { + t.Errorf("second add-entries upload_start = %d ticket = %q, want 512 and \"resume\"", uploadStart, ticket) + } + io.WriteString(w, line) + } + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } + })) + defer srv.Close() + + m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + got, err := m.Cosign(t.Context(), source.cp, source.signedNote) + if err != nil { + t.Fatalf("Cosign: %s", err) + } + if !bytes.Equal(got, source.rawCosig) { + t.Errorf("Cosign = %x, want the mirror's raw cosignature %x", got, source.rawCosig) + } + if addCheckpointCalls != 2 || addEntriesCalls != 2 { + t.Errorf("mirror saw %d add-checkpoint and %d add-entries calls, want 2 and 2", addCheckpointCalls, addEntriesCalls) + } +} + +// TestMirrorCosignErrors covers the client's failure paths, with a mirror that +// refuses the checkpoint, a mirror demanding an upload_end the checkpoint +// cannot satisfy, and an unreachable mirror. +func TestMirrorCosignErrors(t *testing.T) { + _, err := NewMirrorClient("", NewSource(nil, testTilePrefix), mirrorID, testKey(t).PublicKey()) + if err == nil { + t.Error("NewMirrorClient with an empty base URL = nil error, want error") + } + + source := newSourceLog(t) + refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "checkpoint refused", http.StatusForbidden) + })) + defer refusing.Close() + m, err := NewMirrorClient(refusing.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil { + t.Fatal("Cosign against a refusing mirror = nil error, want error") + } + if !strings.Contains(err.Error(), "checkpoint refused") { + t.Errorf("Cosign error %q does not carry the mirror's response", err) + } + + mismatched := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusConflict) + io.WriteString(w, "9000\n0\n\n") + })) + defer mismatched.Close() + m, err = NewMirrorClient(mismatched.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil || !strings.Contains(err.Error(), "upload_end") { + t.Errorf("Cosign against a mismatched mirror = %s, want an upload_end error", err) + } + + unreachable, err := NewMirrorClient("http://127.0.0.1:1", NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = unreachable.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil { + t.Error("Cosign against an unreachable mirror = nil error, want error") + } + + // A mirror whose cosignature does not verify against the configured key. + lying := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + w.Write(source.cosigLine) + })) + defer lying.Close() + m, err = NewMirrorClient(lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, testKey(t).PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil || !strings.Contains(err.Error(), "verification") { + t.Errorf("Cosign with a mismatched key = %s, want a verification error", err) + } +} diff --git a/mtpublisher/mtpublishertest/mtpublishertest.go b/mtpublisher/mtpublishertest/mtpublishertest.go new file mode 100644 index 00000000000..554c56d0e57 --- /dev/null +++ b/mtpublisher/mtpublishertest/mtpublishertest.go @@ -0,0 +1,57 @@ +//go:build go1.27 + +// Package mtpublishertest provides an in-process cosigner for unit tests of the +// mtca and the mtpublisher. +package mtpublishertest + +import ( + "context" + "crypto" + "fmt" + "time" + + "github.com/letsencrypt/boulder/trees/checkpoint" + "github.com/letsencrypt/boulder/trees/cosignature" +) + +// TestMirror is a mtpublisher.Mirror that cosigns in process with its own key, +// without checking that the checkpoint's entries exist anywhere. +type TestMirror struct { + cosignerID string + cosigner *cosignature.Cosigner + lastSigned time.Time +} + +// NewTestMirror returns a TestMirror that cosigns checkpoints of the log with +// the given origin as the cosigner with ID mirrorID. +func NewTestMirror(mirrorID, origin string, signer crypto.Signer) (*TestMirror, error) { + cosigner, err := cosignature.NewCosigner(mirrorID, origin, signer) + if err != nil { + return nil, fmt.Errorf("creating mirror cosigner: %s", err) + } + return &TestMirror{cosignerID: mirrorID, cosigner: cosigner}, nil +} + +// ID returns the mirror's cosigner ID. +func (m *TestMirror) ID() string { + return m.cosignerID +} + +// Cosign cosigns the checkpoint and returns the raw cosignature. It errors if +// the checkpoint is not of the cosigner's log. +func (m *TestMirror) Cosign(_ context.Context, cp *checkpoint.Checkpoint, _ []byte) ([]byte, error) { + if cp.Origin != m.cosigner.Origin() { + return nil, fmt.Errorf("checkpoint origin %q is not this mirror's log %q", cp.Origin, m.cosigner.Origin()) + } + timestampedCosignature, err := m.cosigner.CosignCheckpoint(cp.Tree) + if err != nil { + return nil, err + } + m.lastSigned = time.Now() + return cosignature.RawSignature(timestampedCosignature) +} + +// LastSigned returns when Cosign last succeeded, zero before it has. +func (m *TestMirror) LastSigned() time.Time { + return m.lastSigned +} diff --git a/mtpublisher/mtpublishertest/mtpublishertest_test.go b/mtpublisher/mtpublishertest/mtpublishertest_test.go new file mode 100644 index 00000000000..9894ed71bb6 --- /dev/null +++ b/mtpublisher/mtpublishertest/mtpublishertest_test.go @@ -0,0 +1,73 @@ +//go:build go1.27 + +package mtpublishertest + +import ( + "crypto/mldsa" + "testing" + + "golang.org/x/mod/sumdb/tlog" + + "github.com/letsencrypt/boulder/privatekey" + "github.com/letsencrypt/boulder/trees/checkpoint" + "github.com/letsencrypt/boulder/trees/cosignature" +) + +const ( + mtcLogID = "44947.4.1.0.44" + mirrorID = "32473.9" +) + +// testMirror returns a LocalMirror and the deterministic key it cosigns with. +func testMirror(t *testing.T) (*TestMirror, *mldsa.PrivateKey) { + t.Helper() + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i + 1) + } + key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + mirror, err := NewTestMirror(mirrorID, "oid/1.3.6.1.4.1."+mtcLogID, privatekey.NewDeterministicSigner(key)) + if err != nil { + t.Fatalf("NewTestMirror: %s", err) + } + return mirror, key +} + +// TestLocalMirrorCosign checks that the mirror's raw cosignature verifies +// through trees/cosignature. +func TestLocalMirrorCosign(t *testing.T) { + mirror, key := testMirror(t) + + if mirror.ID() != mirrorID { + t.Errorf("ID() = %q, want %q", mirror.ID(), mirrorID) + } + + cp := &checkpoint.Checkpoint{Origin: "oid/1.3.6.1.4.1." + mtcLogID, Tree: tlog.Tree{N: 512}} + raw, err := mirror.Cosign(t.Context(), cp, nil) + if err != nil { + t.Fatalf("Cosign: %s", err) + } + + verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + _, err = verifier.SignatureLine(cp.Origin, cp.Tree, raw) + if err != nil { + t.Errorf("SignatureLine rejected the mirror's cosignature: %s", err) + } +} + +// TestLocalMirrorCosignRejects checks that the mirror only cosigns checkpoints +// of its own log. +func TestLocalMirrorCosignRejects(t *testing.T) { + mirror, _ := testMirror(t) + + _, err := mirror.Cosign(t.Context(), &checkpoint.Checkpoint{Origin: "oid/1.3.6.1.4.1.32473.999", Tree: tlog.Tree{N: 512}}, nil) + if err == nil { + t.Error("Cosign with another log's checkpoint = nil error, want error") + } +} diff --git a/mtpublisher/source.go b/mtpublisher/source.go new file mode 100644 index 00000000000..83bf240c35d --- /dev/null +++ b/mtpublisher/source.go @@ -0,0 +1,60 @@ +//go:build go1.27 + +package mtpublisher + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/letsencrypt/boulder/trees/mirror" + "github.com/letsencrypt/boulder/trees/subtree" + "github.com/letsencrypt/boulder/trees/tiles" + "golang.org/x/mod/sumdb/tlog" +) + +// simpleS3 matches the subset of the bs3.Client interface which we use, to +// allow simpler mocking in tests. +type simpleS3 interface { + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) + Bucket() string +} + +// Source builds consistency proofs and entry packages from the log's tile +// storage. In the future, when we want to support multiple mirrors, we may want +// to improve Source to memoize the tiles, consistency proofs, and entry +// packages built for the latest tree, purging the memo when the tree changes. +type Source struct { + s3c simpleS3 + tilePrefix string +} + +// NewSource returns a Source over the tiles stored in s3c under tilePrefix. +func NewSource(s3c simpleS3, tilePrefix string) *Source { + return &Source{s3c: s3c, tilePrefix: tilePrefix} +} + +// hashReaderForTree returns a HashReader that reads tree's hashes from the +// log's tiles. +func (s *Source) hashReaderForTree(ctx context.Context, tree tlog.Tree) tlog.HashReader { + return tlog.TileHashReader(tree, tiles.NewTileReader(ctx, s.s3c, s.tilePrefix)) +} + +// consistencyProof returns the RFC 6962 consistency proof from oldSize to tree. +func (s *Source) consistencyProof(ctx context.Context, tree tlog.Tree, oldSize int64) ([]tlog.Hash, error) { + return tlog.ProveTree(tree.N, oldSize, s.hashReaderForTree(ctx, tree)) +} + +// entryPackage returns the marshaled entry package covering p, proven against +// tree. +func (s *Source) entryPackage(ctx context.Context, tree tlog.Tree, p mirror.Package) ([]byte, error) { + entries, err := tiles.EntriesForPackage(ctx, s.s3c, p.EntriesStart, p.End, tree.N, s.tilePrefix) + if err != nil { + return nil, err + } + proof, err := subtree.ConsistencyProof(p.SubtreeStart, p.End, tree.N, s.hashReaderForTree(ctx, tree)) + if err != nil { + return nil, fmt.Errorf("proving subtree [%d, %d): %s", p.SubtreeStart, p.End, err) + } + return mirror.EntryPackage(entries, proof) +} diff --git a/test/certs/genmtpki/genmtpki.go b/test/certs/genmtpki/genmtpki.go index 129083d6830..b33c5940372 100644 --- a/test/certs/genmtpki/genmtpki.go +++ b/test/certs/genmtpki/genmtpki.go @@ -112,6 +112,22 @@ func main2() error { return err } + caSPKI, err := x509.MarshalPKIXPublicKey(key.PublicKey()) + if err != nil { + return err + } + + caPubFile, err := os.OpenFile(basepath+".pub.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer caPubFile.Close() + + err = pem.Encode(caPubFile, &pem.Block{Type: "PUBLIC KEY", Bytes: caSPKI}) + if err != nil { + return err + } + mirrorKey, err := mldsa.GenerateKey(mldsa.MLDSA44()) if err != nil { return err diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json index aee4d13a098..2d8b168e01b 100644 --- a/test/config-next/mtpublisher.json +++ b/test/config-next/mtpublisher.json @@ -9,9 +9,18 @@ "caID": "44947.4.1", "logNumber": 44 }, - "mirrorID": "32473.9", - "mirrorKeyFile": "test/certs/mtpki/mirror.key.pem", - "mirrorPublicKeyFile": "test/certs/mtpki/mirror.pub.pem" + "mtcaPublicKeyFile": "test/certs/mtpki/mtca1.pub.pem", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/mtpki/mirror.pub.pem" + }, + "mirrorBaseURL": "http://localhost:4700", + "s3": { + "s3endpoint": "http://boulder-minio:9000", + "s3bucket": "boulder-mtc-tiles", + "awsConfigFile": "test/config-next/mtca-s3-config.ini", + "awsCredsFile": "test/secrets/mtca-s3-creds.ini" + } }, "syslog": { "stdoutlevel": 6, diff --git a/test/config/mtpublisher.json b/test/config/mtpublisher.json index aee4d13a098..2d8b168e01b 100644 --- a/test/config/mtpublisher.json +++ b/test/config/mtpublisher.json @@ -9,9 +9,18 @@ "caID": "44947.4.1", "logNumber": 44 }, - "mirrorID": "32473.9", - "mirrorKeyFile": "test/certs/mtpki/mirror.key.pem", - "mirrorPublicKeyFile": "test/certs/mtpki/mirror.pub.pem" + "mtcaPublicKeyFile": "test/certs/mtpki/mtca1.pub.pem", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/mtpki/mirror.pub.pem" + }, + "mirrorBaseURL": "http://localhost:4700", + "s3": { + "s3endpoint": "http://boulder-minio:9000", + "s3bucket": "boulder-mtc-tiles", + "awsConfigFile": "test/config-next/mtca-s3-config.ini", + "awsCredsFile": "test/secrets/mtca-s3-creds.ini" + } }, "syslog": { "stdoutlevel": 6,