diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index fbac967ee59..f5ba1f69157 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -11,10 +11,11 @@ import ( "fmt" "os" + "github.com/letsencrypt/boulder/bs3" "github.com/letsencrypt/boulder/cmd" "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/issuance" "github.com/letsencrypt/boulder/mtpublisher" - "github.com/letsencrypt/boulder/privatekey" "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/trees/issuancelog" ) @@ -25,25 +26,39 @@ 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"` - - // MirrorPublicKeyFile holds the PEM-encoded ML-DSA-44 public key used - // to verify cosignatures. - MirrorPublicKeyFile string `validate:"required"` - - // MirrorKeyFile holds the PEM-encoded ML-DSA-44 private key used to - // cosign checkpoints. - MirrorKeyFile string `validate:"required"` + // MTCACertFile holds the PEM-encoded certificate of the mtca, whose + // ML-DSA-44 public key is used to reconstruct each checkpoint's signed + // note from the database. + MTCACertFile string `validate:"required"` + + // Mirror identifies the mirror this publisher submits to. Note: this is + // temporary until we start loading support multiple mirrors sourced from + // https://www.gstatic.com/mtcs/cosigners/v1/cosigners.json (schema: + // https://www.gstatic.com/mtcs/cosigners/v1/cosigners_schema.json). + Mirror 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"` + + // BaseURL is the base URL of the mirror's tlog-mirror submission + // endpoints (e.g. "http://localhost:4700"). + BaseURL 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 +109,22 @@ 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") + caCert, err := issuance.LoadCertificate(c.MTPublisher.MTCACertFile) + cmd.FailOnError(err, "Loading MTCA certificate") + caPubKey, ok := caCert.PublicKey.(*mldsa.PublicKey) + if !ok { + cmd.Fail(fmt.Sprintf("MTCA certificate public key is %T, must be ML-DSA-44", caCert.PublicKey)) + } + s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger) + cmd.FailOnError(err, "Loading S3 config") + + mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.Mirror.BaseURL, 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/docker-compose.yml b/docker-compose.yml index 0b33c3e50d7..708faf97380 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,8 @@ services: condition: service_started bminio: condition: service_healthy + bsunlight: + condition: service_started entrypoint: test/entrypoint.sh working_dir: &boulder_working_dir /boulder diff --git a/mtca/mtca_test.go b/mtca/mtca_test.go index a082b2cd7c5..5aa115fa929 100644 --- a/mtca/mtca_test.go +++ b/mtca/mtca_test.go @@ -35,7 +35,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" @@ -285,11 +287,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..c9874bf4de3 --- /dev/null +++ b/mtpublisher/mirror.go @@ -0,0 +1,318 @@ +//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 + +// maxErrorBodySize caps how much of an unexpected mirror response body is +// quoted in an error. +const maxErrorBodySize = 400 + +// errorBody returns respBody trimmed for quoting in an error. +func errorBody(respBody []byte) string { + body := strings.TrimSpace(string(respBody)) + if len(body) > maxErrorBodySize { + return body[:maxErrorBodySize] + "..." + } + return body +} + +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. An up-to-date mirror still receives the checkpoint, with an +// empty proof, since that is the only way to obtain its cosignature. +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: + mirrorSize, err := mirror.ParseSizeResponse(respBody) + if err != nil { + return err + } + if retried { + return fmt.Errorf("add-checkpoint at tree size %d got 409 with mirror tree size %d after retrying", tree.N, mirrorSize) + } + retried = true + oldSize = mirrorSize + default: + return fmt.Errorf("mirror returned status %d: %s", status, errorBody(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) { + m.nextEntry = min(m.nextEntry, tree.N) + for range maxAddEntriesRequests { + packages, err := mirror.Packages(m.nextEntry, 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, m.nextEntry, tree.N, m.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: + if len(respBody) == 0 { + return nil, errors.New("mirror returned no cosignature lines") + } + 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 { + // The spec has the client adopt the advertised tree size, but + // Cosign set the mirror's pending checkpoint to tree.N with + // add-checkpoint before uploading, so the mirror SHOULD have + // echoed it, and a cosignature over any other tree size is no + // use here. + return nil, fmt.Errorf("mirror wants upload_end %d, checkpoint size is %d", info.TreeSize, tree.N) + } + if info.NextEntry > tree.N { + return nil, fmt.Errorf("mirror wants upload_start %d, checkpoint size is %d", info.NextEntry, tree.N) + } + m.nextEntry = info.NextEntry + m.ticket = bytes.Clone(info.Ticket) + + default: + return nil, fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) + } + } + return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests) +} + +// signSubtree requests the mirror's zero timestamp signature over the whole +// tree from the c2sp.org/tlog-witness sign-subtree endpoint, presenting the +// checkpoint note carrying the cosignature lines add-entries returned. +func (m *MirrorClient) signSubtree(ctx context.Context, tree tlog.Tree, signedNote []byte) ([]byte, error) { + body, err := mirror.SignSubtreeRequest(0, tree.N, tree.Hash, nil, signedNote) + if err != nil { + return nil, err + } + status, respBody, err := m.post(ctx, "/sign-subtree", "", false, body) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) + } + return respBody, nil +} + +// Cosign runs the c2sp.org/tlog-mirror submission protocol for the checkpoint +// and returns the mirror's raw signature over the whole tree from sign-subtree, +// verified against the mirror's key. A mirror that is already up to date, +// whether from an earlier submission whose cosignature was never stored or +// from another submitter, still gets the full exchange, since that is the only +// way to obtain its cosignature. +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) + } + + // Exchange the mirror's cosignature for its subtree signature. + noteForSignSubtree, err := cp.SignedNote(mirrorCosignatureLines) + if err != nil { + return nil, fmt.Errorf("assembling the sign-subtree note: %w", err) + } + subtreeCosignatureLines, err := m.signSubtree(ctx, cp.Tree, noteForSignSubtree) + if err != nil { + return nil, fmt.Errorf("sign-subtree: %w", err) + } + + // Verify the mirror's signature. + noteText, err := cp.Marshal() + if err != nil { + return nil, fmt.Errorf("marshaling the checkpoint: %w", err) + } + timestampedMirrorCosignature, err := m.verifier.FilterByVerify(noteText, subtreeCosignatureLines) + 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 e5a3b2e5a15..9fa33241594 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -4,11 +4,8 @@ package mtpublisher import ( "context" - "crypto" "crypto/mldsa" - "crypto/sha256" - "encoding/base64" - "encoding/binary" + "errors" "fmt" "time" @@ -22,126 +19,114 @@ import ( "github.com/letsencrypt/boulder/trees/treedb" ) -// 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 { - treedb checkpointDB - 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 } -type checkpointDB interface { - LatestCheckpoint(ctx context.Context, mtcLogID string) (*treedb.CheckpointModel, error) - AddMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error +// mtpublisher obtains and stores its mirror's cosignature over the issuance +// log's latest checkpoint. +type mtpublisher struct { + treedb checkpointDB + 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{ - treedb: treedb.New(dbMap), - interval: interval, - mtcLogID: logID.String(), - origin: cosigner.Origin(), - mirrorID: mirrorID, - mirrorName: mirrorName, - mirrorKeyID: mirrorKeyID, - mirrorCosigner: cosigner, - verifier: verifier, - log: log, + return &mtpublisher{ + treedb: treedb.New(dbMap), + interval: interval, + logID: logID, + mirror: mirror, + caVerifier: caVerifier, + log: log, }, nil } -// 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 +// checkpointDB is the subset of treedb.Impl the publisher uses, so tests can +// substitute their own. +type checkpointDB interface { + LatestCheckpoint(ctx context.Context, mtcLogID string) (*treedb.CheckpointModel, error) + AddMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error } -// 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 { - latest, err := p.treedb.LatestCheckpoint(ctx, p.mtcLogID) +// 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 { + latest, err := p.treedb.LatestCheckpoint(ctx, p.logID.String()) + if errors.Is(err, treedb.ErrIssuanceLogNotInitialized) { + return nil + } if err != nil { - return err + 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) + // 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) + } + caCosignatureLine, err := cosignature.SignatureLine(p.caVerifier.Name(), p.caVerifier.KeyHash(), 0, latest.MTCASignature) if err != nil { - return fmt.Errorf("cosigning checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + return fmt.Errorf("checkpoint %d MTCA signature: %w", latest.ID, err) } - 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() + // Reconstruct the signed note for submission to the mirror, and verify + // the MTCA signature before submitting it. + signedNoteForMirror, err := cp.SignedNote(caCosignatureLine) if err != nil { - return fmt.Errorf("marshaling checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + return fmt.Errorf("assembling checkpoint %d signed note: %w", latest.ID, err) } - timestampedMirrorCosig, err := p.verifier.FilterByVerify(text, []byte(cosigLine)) + _, _, err = checkpoint.Open(signedNoteForMirror, p.caVerifier) if err != nil { - return fmt.Errorf("checkpoint %d cosignature failed verification before storage: %w", latest.ID, err) + return fmt.Errorf("checkpoint %d MTCA signature: %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) - err = p.treedb.AddMirrorSignature(ctx, latest.ID, p.mirrorID, mirrorCosig, p.mtcLogID) + // Store the mirror's cosignature in the database. + err = p.treedb.AddMirrorSignature(ctx, latest.ID, p.mirror.ID(), mirrorRawCosig, p.logID.String()) if err != nil { return fmt.Errorf("storing checkpoint %d cosignature (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } @@ -149,15 +134,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 c3768e73628..3688cf4c206 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -4,20 +4,31 @@ package mtpublisher import ( "bytes" + "compress/gzip" "context" "crypto/mldsa" "encoding/base64" + "encoding/binary" "fmt" + "io" + "net/http" + "net/http/httptest" "strings" "testing" "time" "golang.org/x/mod/sumdb/tlog" + "github.com/letsencrypt/boulder/bs3/bs3test" blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mtpublisher/mtpublishertest" "github.com/letsencrypt/boulder/privatekey" + "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/pubkey" + "github.com/letsencrypt/boulder/trees/tiles" "github.com/letsencrypt/boulder/trees/treedb" ) @@ -28,54 +39,89 @@ const ( var testLogID = issuancelog.ID{CAID: "44947.4.1", LogNumber: 44} -type mockTreeDB struct { - latestCheckpoint *treedb.CheckpointModel +// fakeCheckpointDB holds the latest checkpoint of one log, or none for a log +// that is not initialized, and stores the mirror cosignature on it. The mtca +// tests truncate the mtcmeta database and run in parallel with these, so they +// cannot share it. +type fakeCheckpointDB struct { + latest *treedb.CheckpointModel } -func newMockDB(mtcLogID string) *mockTreeDB { - var rootHash [32]byte - return &mockTreeDB{ - latestCheckpoint: &treedb.CheckpointModel{ - ID: 1, - RootHash: rootHash[:], - TreeSize: 1, - MTCLogID: mtcLogID, - }, +func (f *fakeCheckpointDB) LatestCheckpoint(_ context.Context, mtcLogID string) (*treedb.CheckpointModel, error) { + if f.latest == nil || f.latest.MTCLogID != mtcLogID { + return nil, treedb.ErrIssuanceLogNotInitialized } + return f.latest, nil } -func (m *mockTreeDB) LatestCheckpoint(ctx context.Context, mtcLogID string) (*treedb.CheckpointModel, error) { - return m.latestCheckpoint, nil +func (f *fakeCheckpointDB) AddMirrorSignature(_ context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { + if f.latest == nil || id != f.latest.ID || mtcLogID != f.latest.MTCLogID { + return fmt.Errorf("adding mirror signature: checkpoint %d for %s not found", id, mtcLogID) + } + f.latest.MirrorID = &mirrorID + f.latest.MirrorSignature = mirrorSignature + return nil } -func (m *mockTreeDB) AddMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { - if m.latestCheckpoint.ID != id { - return fmt.Errorf("test assumption error: tried to add mirror signature for the wrong ID") +// testPublisher returns a publisher over checkpoints whose mirror cosigns with +// key. +func testPublisher(t *testing.T, key *mldsa.PrivateKey, checkpoints *fakeCheckpointDB) *mtpublisher { + t.Helper() + p, err := New(nil, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) + if err != nil { + t.Fatalf("New: %s", err) + } + p.treedb = checkpoints + return p +} + +// 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) } - if m.latestCheckpoint.MTCLogID != mtcLogID { - return fmt.Errorf("test assumption error: tried to add mirror signature for the wrong MTCLogID (%s vs %s)", m.latestCheckpoint.MTCLogID, mtcLogID) + key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) } - m.latestCheckpoint.MirrorID = &mirrorID - m.latestCheckpoint.MirrorSignature = mirrorSignature - return nil + return key } -func insertCheckpoint(t *testing.T, mockDB *mockTreeDB, logID string, treeSize int64) { +// caSignature returns the raw MTCA signature for a checkpoint of treeSize with +// a zero root hash, as testCheckpoint holds. +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 +} - mockDB.latestCheckpoint = &treedb.CheckpointModel{ - ID: mockDB.latestCheckpoint.ID + 1, - MTCLogID: logID, - MTCASignature: []byte("mtca-signature"), +// testCheckpoint returns a checkpoint of treeSize with a zero root hash, signed +// by the MTCA and awaiting the mirror cosignature. +func testCheckpoint(t *testing.T, treeSize int64) *treedb.CheckpointModel { + t.Helper() + return &treedb.CheckpointModel{ + ID: 1, + MTCLogID: mtcLogID, + MTCASignature: caSignature(t, treeSize), TreeSize: treeSize, RootHash: make([]byte, 32), } } -func lacksCosignature(mockDB *mockTreeDB) bool { - return mockDB.latestCheckpoint.MirrorID == nil && len(mockDB.latestCheckpoint.MirrorSignature) == 0 -} - // testKey returns a deterministic ML-DSA-44 key so the test can verify the // cosignatures the publisher stores. func testKey(t *testing.T) *mldsa.PrivateKey { @@ -91,150 +137,475 @@ 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) { +// testMirror returns a TestMirror 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.Fatalf("NewTestMirror: %s", err) + } + return mirror +} + +func TestPublish(t *testing.T) { key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + checkpoints := &fakeCheckpointDB{} + p := testPublisher(t, key, checkpoints) + + // A pass over a log that is not initialized is a no-op. + err := p.Publish(t.Context()) if err != nil { - t.Fatalf("New: %s", err) + t.Fatalf("p.Publish() on a log that is not initialized: %s", err) } - line, err := p.cosign(tlog.Tree{N: 512}) + // The latest checkpoint, which we expect to be cosigned by p.Publish(). + latest := testCheckpoint(t, 512) + checkpoints.latest = latest + + err = p.Publish(t.Context()) if err != nil { - t.Fatalf("cosign: %s", err) + t.Fatalf("p.Publish(): %s", err) + } + + if latest.MirrorID == nil { + t.Fatal("latest checkpoint was not cosigned") } - 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) + if *latest.MirrorID != mirrorID { + t.Errorf("mirrorID = %q, want %q", *latest.MirrorID, mirrorID) + } + if len(latest.MirrorSignature) != mldsa.MLDSA44SignatureSize { + t.Fatalf("latest checkpoint's mirrorSignature is %d bytes, want %d", len(latest.MirrorSignature), mldsa.MLDSA44SignatureSize) } 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 := verifier.FilterByVerify([]byte(text), []byte(line)) - if err != nil { - t.Fatalf("FilterByVerify: %s", err) - } - _, err = cosignature.RawSignature(timestampedSignature) - if err != nil { - t.Errorf("RawSignature: %s", err) + text := "oid/1.3.6.1.4.1." + mtcLogID + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" + timestampedSignature := append(make([]byte, 8), latest.MirrorSignature...) + if !verifier.Verify([]byte(text), timestampedSignature) { + t.Error("stored mirror cosignature does not verify against the checkpoint text") } +} - _, err = p.cosign(tlog.Tree{}) +// TestPublishRejectsBadMTCASignature checks that a checkpoint whose stored +// MTCA signature does not verify is neither submitted nor cosigned. +func TestPublishRejectsBadMTCASignature(t *testing.T) { + // A well-formed MTCA signature over the wrong tree size. + latest := testCheckpoint(t, 512) + latest.MTCASignature = caSignature(t, 999) + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) + + err := p.Publish(t.Context()) if err == nil { - t.Error("cosign with an empty tree = nil error, want error") + t.Error("publish with a bad MTCA signature = nil error, want error") + } + if latest.MirrorID != nil || latest.MirrorSignature != nil { + t.Error("cosignature was stored despite the MTCA signature failing verification") } } -func TestPublish(t *testing.T) { - key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) +func TestPublishWhenLatestAlreadySigned(t *testing.T) { + // The latest checkpoint is already cosigned, which must be left untouched. + existingMirrorID := "existing.cosigner" + latest := testCheckpoint(t, 512) + latest.MirrorID = &existingMirrorID + latest.MirrorSignature = []byte("already-signed-bruh") + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) + + err := p.Publish(t.Context()) if err != nil { - t.Fatalf("New: %s", err) + t.Fatalf("p.Publish(): %s", err) } - mockDB := newMockDB(mtcLogID) - p.treedb = mockDB - - // A pass over an empty table is a no-op. - err = p.Publish(t.Context()) - if err != nil { - t.Fatalf("p.Publish() on an empty table: %s", err) + if *latest.MirrorID != existingMirrorID || string(latest.MirrorSignature) != "already-signed-bruh" { + t.Errorf("existing cosignature was replaced: %s %q", *latest.MirrorID, latest.MirrorSignature) } +} - // The latest checkpoint, which we expect to be cosigned by p.Publish(). - insertCheckpoint(t, mockDB, mtcLogID, 512) +// 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 +} - err = p.Publish(t.Context()) - if err != nil { - t.Fatalf("p.Publish(): %s", err) +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{}, &pubkey.MTCPublicKey{}) + 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} - cosigned, err := mockDB.LatestCheckpoint(context.Background(), mtcLogID) + caSeed := make([]byte, 32) + for i := range caSeed { + caSeed[i] = byte(i + 101) + } + caKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), caSeed) if err != nil { - t.Fatal(err) + t.Fatalf("NewPrivateKey: %s", err) } - - // Check that the latest checkpoint was cosigned, and the others were - // untouched. - if cosigned.MirrorID == nil || *cosigned.MirrorID != mirrorID { - t.Errorf("mirrorID = %v, want %q", cosigned.MirrorID, mirrorID) + ca, err := cosignature.NewCosigner(testLogID.CAID, testLogID.Origin(), privatekey.NewDeterministicSigner(caKey)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) } - if len(cosigned.MirrorSignature) != mldsa.MLDSA44SignatureSize { - t.Fatalf("latest checkpoint's mirrorSignature is %d bytes, want %d", len(cosigned.MirrorSignature), mldsa.MLDSA44SignatureSize) + timestampedCA, err := ca.CosignCheckpoint(newer) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) } - - verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) + 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) } - text := "oid/1.3.6.1.4.1." + mtcLogID + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" - timestampedSignature := append(make([]byte, 8), cosigned.MirrorSignature...) - if !verifier.Verify([]byte(text), timestampedSignature) { - t.Error("stored mirror cosignature does not verify against the checkpoint text") + caLine, err := cosignature.SignatureLine(caVerifier.Name(), caVerifier.KeyHash(), 0, rawCA) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + signedNote, err := cp.SignedNote(caLine) + if err != nil { + t.Fatalf("SignedNote: %s", err) } -} -// TestPublishRejectsMismatchedKey checks that a cosignature that fails to -// verify against the configured public key is not stored. -func TestPublishRejectsMismatchedKey(t *testing.T) { - otherSeed := make([]byte, 32) - for i := range otherSeed { - otherSeed[i] = byte(255 - i) + mirrorSeed := make([]byte, 32) + for i := range mirrorSeed { + mirrorSeed[i] = byte(i + 201) } - otherKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), otherSeed) + 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 := cosignature.SignatureLine(mirrorVerifier.Name(), mirrorVerifier.KeyHash(), 0, 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, + } +} - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(testKey(t)), otherKey.PublicKey(), blog.NewMock()) +// 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("New: %s", err) + t.Fatalf("reading request body: %s", err) } + return body +} - mockDB := newMockDB(mtcLogID) - p.treedb = mockDB +// 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])) //nolint:gosec // G115: the client writes upload_start from an int64 entry index. + ticketLen := int(binary.BigEndian.Uint16(rest[16:18])) + return uploadStart, rest[18 : 18+ticketLen] +} - insertCheckpoint(t, mockDB, mtcLogID, 512) +// 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, which the client presents at +// sign-subtree for the subtree signature over the whole tree. +func TestMirrorCosign(t *testing.T) { + source := newSourceLog(t) + line := string(source.cosigLine) + + var addCheckpointCalls, addEntriesCalls, signSubtreeCalls 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) + fmt.Fprint(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) + fmt.Fprint(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) + } + fmt.Fprint(w, line) + } + case "/sign-subtree": + signSubtreeCalls++ + header, note, ok := bytes.Cut(body, []byte("\n\n")) + expectHeader := fmt.Sprintf("subtree 0 %d\n%s", source.newer.N, source.newer.Hash) + if !ok || string(header) != expectHeader { + t.Errorf("sign-subtree header %q, want %q", header, expectHeader) + } + if !bytes.HasSuffix(note, source.cosigLine) { + t.Errorf("sign-subtree note %q does not end with the add-entries cosignature line", note) + } + fmt.Fprint(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 || signSubtreeCalls != 1 { + t.Errorf("mirror saw %d add-checkpoint, %d add-entries, and %d sign-subtree calls, want 2, 2, and 1", addCheckpointCalls, addEntriesCalls, signSubtreeCalls) + } +} - err = p.Publish(t.Context()) - if err == nil { - t.Error("publish with a mismatched public key = nil error, want error") +// TestMirrorCosignAlreadyMirrored covers a publisher starting over against a +// mirror that already holds every entry: add-checkpoint at the mirror's own +// size with an empty proof, then an empty add-entries upload once the mirror +// advertises a next entry equal to the tree size. +func TestMirrorCosignAlreadyMirrored(t *testing.T) { + source := newSourceLog(t) + line := string(source.cosigLine) + + var 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": + if bytes.HasPrefix(body, []byte("old 0\n\n")) { + w.Header().Set("Content-Type", "text/x.tlog.size") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, "%d\n", source.newer.N) + return + } + expect := fmt.Sprintf("old %d\n\n", source.newer.N) + if !bytes.HasPrefix(body, []byte(expect)) { + t.Errorf("add-checkpoint body %q does not claim old size %d with an empty proof", body, source.newer.N) + } + case "/add-entries": + addEntriesCalls++ + uploadStart, _ := parseUploadHeader(t, body) + if addEntriesCalls == 1 { + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "%d\n%d\n\n", source.newer.N, source.newer.N) + return + } + if uploadStart != source.newer.N { + t.Errorf("second add-entries upload_start = %d, want %d", uploadStart, source.newer.N) + } + fmt.Fprint(w, line) + case "/sign-subtree": + fmt.Fprint(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) } - if !lacksCosignature(mockDB) { - t.Error("cosignature was stored despite failing verification") + 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 addEntriesCalls != 2 { + t.Errorf("mirror saw %d add-entries calls, want 2", addEntriesCalls) } } -func TestPublishWhenLatestAlreadySigned(t *testing.T) { - key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) +// 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("New: %s", err) + 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) } - mockDB := newMockDB(mtcLogID) - p.treedb = mockDB + overshooting := 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.StatusAccepted) + fmt.Fprintf(w, "%d\n%d\n\n", source.newer.N, source.newer.N+1) + })) + defer overshooting.Close() + m, err = NewMirrorClient(overshooting.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 mirror wanting upload_start past the tree size = nil error, want error") + } + if !strings.Contains(err.Error(), "upload_start") { + t.Errorf("Cosign error %q does not name upload_start", err) + } - // Insert a checkpoint that is already cosigned, which must be left - // untouched. - insertCheckpoint(t, mockDB, mtcLogID, 512) - existing := "existing.cosigner" - mockDB.latestCheckpoint.MirrorID = &existing - mockDB.latestCheckpoint.MirrorSignature = []byte("already-signed-bruh") + 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) + fmt.Fprint(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) + } - err = p.Publish(t.Context()) + unreachable, err := NewMirrorClient("http://127.0.0.1:1", NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) if err != nil { - t.Fatalf("p.Publish(): %s", err) + 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") } - // The latest checkpoint was already cosigned, so the pass must leave it untouched. - if !bytes.Equal(mockDB.latestCheckpoint.MirrorSignature, []byte("already-signed-bruh")) { - t.Errorf("MirrorSignature: got %x, want %x", mockDB.latestCheckpoint.MirrorSignature, []byte("already-signed-bruh")) + // 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) } - if mockDB.latestCheckpoint.MirrorID == nil || *mockDB.latestCheckpoint.MirrorID != "existing.cosigner" { - t.Errorf("MirrorID: got %v, want %s", mockDB.latestCheckpoint.ID, "existing.cosigner") + _, 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..7a66703d09d --- /dev/null +++ b/mtpublisher/mtpublishertest/mtpublishertest_test.go @@ -0,0 +1,81 @@ +//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 TestMirror 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 +} + +// TestMirrorCosign checks that the mirror's raw cosignature verifies +// through trees/cosignature. +func TestMirrorCosign(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) + } + line, err := cosignature.SignatureLine(verifier.Name(), verifier.KeyHash(), 0, raw) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + text, err := cp.Marshal() + if err != nil { + t.Fatalf("Marshal: %s", err) + } + _, err = verifier.FilterByVerify(text, line) + if err != nil { + t.Errorf("the mirror's cosignature does not verify: %s", err) + } +} + +// TestMirrorCosignRejects checks that the mirror only cosigns checkpoints +// of its own log. +func TestMirrorCosignRejects(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..810ac15a7e2 --- /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("fetching subtree consistency proof [%d, %d), tree size %d: %s", p.SubtreeStart, p.End, tree.N, err) + } + return mirror.EntryPackage(entries, proof) +} diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json index db004b1531b..d730b8033c7 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/sunlight/mirror.key.pem", - "mirrorPublicKeyFile": "test/certs/sunlight/mirror.pub.pem" + "mtcaCertFile": "test/certs/mtpki/mtca1.cert.pem", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", + "baseURL": "http://bsunlight: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 db004b1531b..d730b8033c7 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/sunlight/mirror.key.pem", - "mirrorPublicKeyFile": "test/certs/sunlight/mirror.pub.pem" + "mtcaCertFile": "test/certs/mtpki/mtca1.cert.pem", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", + "baseURL": "http://bsunlight: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/sunlight/entrypoint.sh b/test/sunlight/entrypoint.sh old mode 100644 new mode 100755 diff --git a/test/sunlight/genkeys/main.go b/test/sunlight/genkeys/main.go index 383934ecbbb..1cf2dcb9e96 100644 --- a/test/sunlight/genkeys/main.go +++ b/test/sunlight/genkeys/main.go @@ -169,20 +169,6 @@ func main2() error { return err } - mirrorPKCS8, err := x509.MarshalPKCS8PrivateKey(mirrorKey) - if err != nil { - return err - } - mirrorKeyFile, err := os.OpenFile(path.Join(*outputDir, "mirror.key.pem"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - defer mirrorKeyFile.Close() - err = pem.Encode(mirrorKeyFile, &pem.Block{Type: "PRIVATE KEY", Bytes: mirrorPKCS8}) - if err != nil { - return err - } - err = rootsFile(path.Join(*outputDir, "ctlog-roots.pem")) if err != nil { return err