diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index 745fcb0b16c..c2d36c1c5ad 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -54,6 +54,11 @@ type Config struct { // endpoints (e.g. "http://localhost:4700"). BaseURL string `validate:"required,url"` + // MonitoringPrefix is the mirror's tlog-mirror monitoring prefix, + // under which it serves its copy of the log and its cosigned + // checkpoint (e.g. "http://localhost:8333/mirror"). + MonitoringPrefix string `validate:"required,url"` + // Timeout bounds each request to the mirror. Timeout config.Duration `validate:"required"` } @@ -124,7 +129,7 @@ func main() { mirrorPubKey, err := loadMLDSAPublicKey(c.MTPublisher.Mirror.PublicKeyFile) cmd.FailOnError(err, "Loading mirror public key") - mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.Mirror.BaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, mirrorPubKey, c.MTPublisher.Mirror.Timeout.Duration) + mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.Mirror.BaseURL, c.MTPublisher.Mirror.MonitoringPrefix, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, mirrorPubKey, c.MTPublisher.Mirror.Timeout.Duration) cmd.FailOnError(err, "Creating mirror client") publisher, err := mtpublisher.New(treedb.New(dbMap), c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger) diff --git a/mtca/mtca.go b/mtca/mtca.go index a98274dd614..47ff64ceb47 100644 --- a/mtca/mtca.go +++ b/mtca/mtca.go @@ -27,7 +27,6 @@ import ( mtcapb "github.com/letsencrypt/boulder/mtca/proto" "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" - "github.com/letsencrypt/boulder/trees/cosigned" "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" "github.com/letsencrypt/boulder/trees/pubkey" @@ -576,33 +575,9 @@ func (m *mtca) sequence(ctx context.Context) error { return fmt.Errorf("publishing tiles: %s", err) } - // `cosigner_name` and `log_origin` are computed from the cosigner ID and - // the issuance log's ID (Section 5.1), respectively. They contain the - // concatenation of: - // * The 16-byte ASCII string `oid/1.3.6.1.4.1.` - // * The trust anchor ID's ASCII representation (Section 4 of [I-D.ietf-tls-trust-anchor-ids]) - // This is equivalent to the concatenation of: - // * The four-byte ASCII string `oid/` - // * The trust anchor ID as a full OID, in dotted decimal notation - // - // https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-signature-format - cosignedMessage, err := (&cosigned.Message{ - CosignerName: "oid/1.3.6.1.4.1." + m.logID.CAID, - // Timestamp should always be zero when signing over a subtree (as - // opposed to a checkpoint) - Timestamp: 0, - LogOrigin: "oid/1.3.6.1.4.1." + m.logID.String(), - Start: 0, - End: uint64(m.frontier.TreeSize()), //nolint:gosec // G115: guaranteed non-negative by Frontier - SubtreeHash: newRootHash, - }).Marshal() + subtreeSignature, err := m.cosigner.CosignSubtree(0, m.frontier.TreeSize(), newRootHash) if err != nil { - return fmt.Errorf("marshaling cosigned message: %s", err) - } - - subtreeSignature, err := m.issuer.Signer.Sign(nil, cosignedMessage, nil) - if err != nil { - return err + return fmt.Errorf("signing subtree: %s", err) } // TODO(#9020)(#9038): calculate specific subtree coverage rather than diff --git a/mtca/mtca_test.go b/mtca/mtca_test.go index 3cb14cfb08c..617ea83dd49 100644 --- a/mtca/mtca_test.go +++ b/mtca/mtca_test.go @@ -156,6 +156,33 @@ func (f *fakeDB) InsertCheckpointSubtree(ctx context.Context, s *treedb.Checkpoi return s.ID, nil } +func (f *fakeDB) GetUnsignedSubtrees(ctx context.Context, mtcLogID string) ([]*treedb.CheckpointSubtreeModel, error) { + if mtcLogID != f.mtcLogID { + return nil, fmt.Errorf("wrong log") + } + var subtrees []*treedb.CheckpointSubtreeModel + for i := range f.checkpointSubtrees { + if f.checkpointSubtrees[i].MirrorID == nil { + subtrees = append(subtrees, &f.checkpointSubtrees[i]) + } + } + return subtrees, nil +} + +func (f *fakeDB) AddSubtreeMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { + if mtcLogID != f.mtcLogID { + return fmt.Errorf("wrong log") + } + for i, s := range f.checkpointSubtrees { + if s.ID == id { + f.checkpointSubtrees[i].MirrorID = &mirrorID + f.checkpointSubtrees[i].MirrorSignature = mirrorSignature + return nil + } + } + return fmt.Errorf("subtree %d not found", id) +} + func (f *fakeDB) getCheckpointSubtree(mtcLogID string, id int64) (*treedb.CheckpointSubtreeModel, error) { if mtcLogID != f.mtcLogID { return nil, fmt.Errorf("wrong log") @@ -614,9 +641,9 @@ func mirrorCosign(t *testing.T, m *mtca) { if err != nil { t.Fatalf("mtpublisher.New: %s", err) } - err = p.Publish(t.Context()) + err = p.PublishCheckpoint(t.Context()) if err != nil { - t.Fatalf("mtpublisher.Publish: %s", err) + t.Fatalf("mtpublisher.PublishCheckpoint: %s", err) } } @@ -813,6 +840,9 @@ func TestSequence(t *testing.T) { if len(subtree.MTCASignature) == 0 { t.Error("subtree.MTCASignature: got empty slice, want non-empty slice") } + if !mtca.verifier.VerifySubtree(mtca.logID.Origin(), int64(subtree.SubtreeStart), int64(subtree.SubtreeEnd), tlog.Hash(subtree.SubtreeHash), subtree.MTCASignature) { //nolint:gosec // G115: the subtree covers the 6 entry tree + t.Error("subtree.MTCASignature: does not verify") + } if subtree.MirrorID != nil { t.Error("subtree.MirrorID: got non-nil string pointer, want nil") diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index e4db15f4e62..e3506e074ab 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "context" "crypto/mldsa" + "crypto/sha256" "errors" "fmt" "io" @@ -18,6 +19,7 @@ import ( "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" "github.com/letsencrypt/boulder/trees/mirror" + "github.com/letsencrypt/boulder/trees/subtree" ) // maxMirrorResponseSize caps how much of a mirror's response body the client @@ -45,6 +47,7 @@ var _ Mirror = (*MirrorClient)(nil) // entries to add-entries until the mirror cosigns. type MirrorClient struct { submissionPrefix string + monitoringPrefix string client *http.Client src *Source mirrorID string @@ -61,11 +64,15 @@ type MirrorClient struct { } // NewMirrorClient returns a MirrorClient that submits to the mirror's endpoints -// under baseURL, giving each request timeout to complete. -func NewMirrorClient(baseURL string, src *Source, mirrorID string, mirrorPublicKey *mldsa.PublicKey, timeout time.Duration) (*MirrorClient, error) { +// under baseURL and reads its checkpoint under monitoringPrefix, giving each +// request timeout to complete. +func NewMirrorClient(baseURL, monitoringPrefix string, src *Source, mirrorID string, mirrorPublicKey *mldsa.PublicKey, timeout time.Duration) (*MirrorClient, error) { if baseURL == "" { return nil, errors.New("empty mirror base URL") } + if monitoringPrefix == "" { + return nil, errors.New("empty mirror monitoring prefix") + } if timeout <= 0 { return nil, fmt.Errorf("timeout must be positive, got %s", timeout) } @@ -75,6 +82,7 @@ func NewMirrorClient(baseURL string, src *Source, mirrorID string, mirrorPublicK } return &MirrorClient{ submissionPrefix: baseURL, + monitoringPrefix: monitoringPrefix, client: &http.Client{Timeout: timeout}, src: src, mirrorID: mirrorID, @@ -216,7 +224,7 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. } 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 + // CosignCheckpoint 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. @@ -238,11 +246,10 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. } } -// 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) +// signSubtree requests the mirror's zero timestamp signature over the subtree +// [start, end) from the c2sp.org/tlog-witness sign-subtree endpoint. +func (m *MirrorClient) signSubtree(ctx context.Context, start, end int64, subtreeHash tlog.Hash, proof []tlog.Hash, signedNote []byte) ([]byte, error) { + body, err := mirror.SignSubtreeRequest(start, end, subtreeHash, proof, signedNote) if err != nil { return nil, err } @@ -256,13 +263,13 @@ func (m *MirrorClient) signSubtree(ctx context.Context, tree tlog.Tree, signedNo 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, +// CosignCheckpoint runs the c2sp.org/tlog-mirror submission protocol for the +// checkpoint and returns the mirror's cosignature line from add-entries, // 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) { +func (m *MirrorClient) CosignCheckpoint(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 { @@ -275,30 +282,110 @@ func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, si return nil, fmt.Errorf("add-entries: %w", err) } - // Exchange the mirror's cosignature for its subtree signature. - noteForSignSubtree, err := cp.SignedNote(mirrorCosignatureLines) + // Verify the mirror's signature. + noteText, err := cp.Marshal() if err != nil { - return nil, fmt.Errorf("assembling the sign-subtree note: %w", err) + return nil, fmt.Errorf("marshaling the checkpoint: %w", err) } - subtreeCosignatureLines, err := m.signSubtree(ctx, cp.Tree, noteForSignSubtree) + checkpointCosignature, err := m.verifier.FilterByVerify(noteText, mirrorCosignatureLines) if err != nil { - return nil, fmt.Errorf("sign-subtree: %w", err) + return nil, fmt.Errorf("checkpoint cosignature failed verification: %w", err) + } + if checkpointCosignature.Timestamp == 0 { + return nil, errors.New("checkpoint cosignature has a zero timestamp") } + return checkpointCosignature.Line, nil +} - // Verify the mirror's signature. - noteText, err := cp.Marshal() +// mirrorCheckpoint returns the mirror's checkpoint of the log with the given +// origin and its signed note, with the mirror's cosignature verified. +// +// https://c2sp.org/tlog-mirror#introduction +func (m *MirrorClient) mirrorCheckpoint(ctx context.Context, origin string) (*checkpoint.Checkpoint, []byte, error) { + endpoint, err := url.JoinPath(m.monitoringPrefix, fmt.Sprintf("%x", sha256.Sum256([]byte(origin))), "checkpoint") if err != nil { - return nil, fmt.Errorf("marshaling the checkpoint: %w", err) + return nil, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, nil, err + } + resp, err := m.client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxMirrorResponseSize)) + if err != nil { + return nil, nil, fmt.Errorf("reading mirror checkpoint: %s", err) + } + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("mirror returned status %d for its checkpoint: %s", resp.StatusCode, errorBody(body)) + } + + separator := bytes.Index(body, []byte("\n\n")) + if separator < 0 { + return nil, nil, errors.New("mirror checkpoint has no signature lines") } - zeroTimestampMirrorCosignature, err := m.verifier.FilterByVerify(noteText, subtreeCosignatureLines) + noteText := body[:separator+1] + signatureLines := body[separator+2:] + mirrored, err := checkpoint.Unmarshal(noteText) if err != nil { - return nil, fmt.Errorf("cosignature failed verification: %w", err) + return nil, nil, fmt.Errorf("parsing mirror checkpoint: %w", err) } + if mirrored.Origin != origin { + return nil, nil, fmt.Errorf("mirror checkpoint is of %s, want %s", mirrored.Origin, origin) + } + cosig, err := m.verifier.FilterByVerify(noteText, signatureLines) + if err != nil { + return nil, nil, fmt.Errorf("mirror checkpoint cosignature failed verification: %w", err) + } + signedNote, err := mirrored.SignedNote(cosig.Line) + if err != nil { + return nil, nil, fmt.Errorf("assembling the mirror's signed note: %w", err) + } + return mirrored, signedNote, nil +} - // Finally, extract the raw cosignature we store in the database. - rawMirrorCosignature, err := cosignature.RawSignature(zeroTimestampMirrorCosignature) +// CosignSubtree returns the mirror's signature over the subtree [start, end) +// from sign-subtree, proven against the mirror's own checkpoint. +// +// TODO(#9038): sign and verify subtrees that do not start at 0. +func (m *MirrorClient) CosignSubtree(ctx context.Context, origin string, start, end int64, subtreeHash tlog.Hash) ([]byte, error) { + if start != 0 { + return nil, fmt.Errorf("subtree [%d, %d) does not start at 0", start, end) + } + mirrored, signedNote, err := m.mirrorCheckpoint(ctx, origin) if err != nil { - return nil, fmt.Errorf("cosignature: %w", err) + return nil, err + } + if mirrored.Tree.N < end { + return nil, fmt.Errorf("%w: mirror checkpoint at size %d does not include subtree [%d, %d)", ErrSubtreeNotMirrored, mirrored.Tree.N, start, end) + } + if mirrored.Tree.N == end && mirrored.Tree.Hash != subtreeHash { + return nil, fmt.Errorf("mirror checkpoint at size %d has root hash %x, the log's is %x", end, mirrored.Tree.Hash, subtreeHash) + } + proof, err := subtree.ConsistencyProof(start, end, mirrored.Tree.N, m.src.hashReaderForTree(ctx, mirrored.Tree)) + if err != nil { + return nil, fmt.Errorf("proving subtree [%d, %d) in the mirror's tree of size %d: %s", start, end, mirrored.Tree.N, err) + } + subtreeCosignatureLines, err := m.signSubtree(ctx, start, end, subtreeHash, proof, signedNote) + if err != nil { + return nil, fmt.Errorf("sign-subtree: %w", err) + } + + // Verify the mirror's signature. A subtree starting at 0 is signed exactly + // as a checkpoint of its size would be. + noteText, err := (&checkpoint.Checkpoint{Origin: origin, Tree: tlog.Tree{N: end, Hash: subtreeHash}}).Marshal() + if err != nil { + return nil, fmt.Errorf("marshaling the subtree: %w", err) + } + subtreeCosignature, err := m.verifier.FilterByVerify(noteText, subtreeCosignatureLines) + if err != nil { + return nil, fmt.Errorf("subtree signature failed verification: %w", err) + } + if subtreeCosignature.Timestamp != 0 { + return nil, fmt.Errorf("subtree signature has timestamp %d, want 0", subtreeCosignature.Timestamp) } - return rawMirrorCosignature, nil + return subtreeCosignature.Signature, nil } diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index d2cc39d2445..6f0a4483841 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -23,13 +23,22 @@ import ( 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) + // CosignCheckpoint submits the log's signed note for cp and returns the + // mirror's cosignature line, verified against the mirror's key. + CosignCheckpoint(ctx context.Context, cp *checkpoint.Checkpoint, signedNote []byte) ([]byte, error) + // CosignSubtree returns the mirror's signature over the subtree [start, + // end) of the log with origin, verified against the mirror's key. It + // returns an error wrapping ErrSubtreeNotMirrored if the mirror's + // checkpoint does not include the subtree yet. + CosignSubtree(ctx context.Context, origin string, start, end int64, subtreeHash tlog.Hash) ([]byte, error) } -// mtpublisher obtains and stores its mirror's cosignature over the issuance -// log's latest checkpoint. +// ErrSubtreeNotMirrored reports that the mirror's checkpoint does not include +// the subtree yet. +var ErrSubtreeNotMirrored = errors.New("the mirror has not reached the subtree") + +// mtpublisher obtains and stores its mirror's cosignatures over the issuance +// log's latest checkpoint and the log's subtrees. type mtpublisher struct { treedb checkpointDB interval time.Duration @@ -68,12 +77,14 @@ func New(checkpointDB checkpointDB, interval time.Duration, logID issuancelog.ID 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 + GetUnsignedSubtrees(ctx context.Context, mtcLogID string) ([]*treedb.CheckpointSubtreeModel, error) + AddSubtreeMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) 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 { +// PublishCheckpoint submits the latest checkpoint to the mirror if it lacks a +// mirror cosignature and stores the returned signature line. Start calls it at +// each interval. +func (p *mtpublisher) PublishCheckpoint(ctx context.Context) error { latest, err := p.treedb.LatestCheckpoint(ctx, p.logID.String()) if errors.Is(err, treedb.ErrIssuanceLogNotInitialized) { p.log.Infof("Issuance log %s has no checkpoint yet, waiting", p.logID) @@ -116,14 +127,14 @@ func (p *mtpublisher) Publish(ctx context.Context) error { } // Submit the signed checkpoint to the mirror for cosigning. - mirrorRawCosig, err := p.mirror.Cosign(ctx, cp, signedNoteForMirror) + checkpointCosignatureLine, err := p.mirror.CosignCheckpoint(ctx, cp, signedNoteForMirror) if err != nil { 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.treedb.AddMirrorSignature(ctx, latest.ID, p.mirror.ID(), mirrorRawCosig, p.logID.String()) + err = p.treedb.AddMirrorSignature(ctx, latest.ID, p.mirror.ID(), checkpointCosignatureLine, p.logID.String()) if err != nil { return fmt.Errorf("storing checkpoint %d cosignature (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } @@ -131,15 +142,56 @@ func (p *mtpublisher) Publish(ctx context.Context) error { return nil } -// Start attempts to publish the latest checkpoint at each interval until ctx is +// CosignSubtrees stores the mirror's signature on each subtree of the log that +// lacks one, oldest first, until it reaches one the mirror's checkpoint does +// not include yet. Start calls it at each interval. +func (p *mtpublisher) CosignSubtrees(ctx context.Context) error { + subtrees, err := p.treedb.GetUnsignedSubtrees(ctx, p.logID.String()) + if err != nil { + return fmt.Errorf("selecting the unsigned subtrees of %s: %w", p.logID, err) + } + for _, subtree := range subtrees { + err = subtree.Valid() + if err != nil { + return fmt.Errorf("validating subtree %d: %s", subtree.ID, err) + } + start := int64(subtree.SubtreeStart) //nolint:gosec // G115: Valid checked the bounds + end := int64(subtree.SubtreeEnd) //nolint:gosec // G115: Valid checked the bounds + subtreeHash := tlog.Hash(subtree.SubtreeHash) + if !p.caVerifier.VerifySubtree(p.logID.Origin(), start, end, subtreeHash, subtree.MTCASignature) { + return fmt.Errorf("subtree %d (%s [%d, %d)) MTCA signature does not verify", subtree.ID, subtree.MTCLogID, subtree.SubtreeStart, subtree.SubtreeEnd) + } + + subtreeSignature, err := p.mirror.CosignSubtree(ctx, p.logID.Origin(), start, end, subtreeHash) + if err != nil { + if errors.Is(err, ErrSubtreeNotMirrored) { + p.log.Infof("Subtree %d (%s [%d, %d)) is beyond the mirror's checkpoint, waiting", subtree.ID, subtree.MTCLogID, subtree.SubtreeStart, subtree.SubtreeEnd) + return nil + } + return fmt.Errorf("cosigning subtree %d (%s [%d, %d)): %w", subtree.ID, subtree.MTCLogID, subtree.SubtreeStart, subtree.SubtreeEnd, err) + } + err = p.treedb.AddSubtreeMirrorSignature(ctx, subtree.ID, p.mirror.ID(), subtreeSignature, p.logID.String()) + if err != nil { + return fmt.Errorf("storing subtree %d cosignature (%s [%d, %d)): %w", subtree.ID, subtree.MTCLogID, subtree.SubtreeStart, subtree.SubtreeEnd, err) + } + p.log.Infof("Stored mirror signature for subtree %d (%s [%d, %d))", subtree.ID, subtree.MTCLogID, subtree.SubtreeStart, subtree.SubtreeEnd) + } + return nil +} + +// Start runs PublishCheckpoint and CosignSubtrees at each interval until ctx is // cancelled. func (p *mtpublisher) Start(ctx context.Context) { ticker := time.NewTicker(p.interval) defer ticker.Stop() for { - err := p.Publish(ctx) + err := p.PublishCheckpoint(ctx) + if err != nil { + p.log.Errf("Publishing checkpoint failed: %s", err) + } + err = p.CosignSubtrees(ctx) if err != nil { - p.log.Errf("Publishing pass failed: %s", err) + p.log.Errf("Cosigning subtrees failed: %s", err) } select { case <-ctx.Done(): diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index b0d11c2936d..cb91f59bf98 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "context" "crypto/mldsa" + "crypto/sha256" "encoding/base64" "encoding/binary" "errors" @@ -12,6 +13,7 @@ import ( "io" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -26,6 +28,7 @@ import ( "github.com/letsencrypt/boulder/privatekey" "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" + "github.com/letsencrypt/boulder/trees/cosigned" "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" "github.com/letsencrypt/boulder/trees/mirror" @@ -42,12 +45,13 @@ const ( var testLogID = issuancelog.ID{CAID: "44947.4.1", LogNumber: 44} -// 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. +// fakeCheckpointDB holds the latest checkpoint of one log and its subtrees, or +// none for a log that is not initialized, and stores the mirror cosignatures +// on them. The mtca tests truncate the mtcmeta database and run in parallel +// with these, so they cannot share it. type fakeCheckpointDB struct { - latest *treedb.CheckpointModel + latest *treedb.CheckpointModel + subtrees []*treedb.CheckpointSubtreeModel } func (f *fakeCheckpointDB) LatestCheckpoint(_ context.Context, mtcLogID string) (*treedb.CheckpointModel, error) { @@ -66,6 +70,27 @@ func (f *fakeCheckpointDB) AddMirrorSignature(_ context.Context, id int64, mirro return nil } +func (f *fakeCheckpointDB) GetUnsignedSubtrees(_ context.Context, mtcLogID string) ([]*treedb.CheckpointSubtreeModel, error) { + var unsigned []*treedb.CheckpointSubtreeModel + for _, subtree := range f.subtrees { + if subtree.MTCLogID == mtcLogID && subtree.MirrorID == nil { + unsigned = append(unsigned, subtree) + } + } + return unsigned, nil +} + +func (f *fakeCheckpointDB) AddSubtreeMirrorSignature(_ context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { + for _, subtree := range f.subtrees { + if subtree.ID == id && subtree.MTCLogID == mtcLogID { + subtree.MirrorID = &mirrorID + subtree.MirrorSignature = mirrorSignature + return nil + } + } + return fmt.Errorf("adding mirror signature: subtree %d for %s not found", id, mtcLogID) +} + // testPublisher returns a publisher over checkpoints whose mirror cosigns with // key. func testPublisher(t *testing.T, key *mldsa.PrivateKey, checkpoints *fakeCheckpointDB) *mtpublisher { @@ -112,8 +137,9 @@ func caSignature(t *testing.T, treeSize int64) []byte { } // testCheckpoint returns a checkpoint of 512 entries with a zero root hash, -// signed by the MTCA and awaiting the mirror cosignature. -func testCheckpoint(t *testing.T) *treedb.CheckpointModel { +// signed by the MTCA and awaiting the mirror cosignature, and the subtree +// [0, 512) it covers. +func testCheckpoint(t *testing.T) (*treedb.CheckpointModel, *treedb.CheckpointSubtreeModel) { t.Helper() return &treedb.CheckpointModel{ ID: 1, @@ -121,6 +147,13 @@ func testCheckpoint(t *testing.T) *treedb.CheckpointModel { MTCASignature: caSignature(t, 512), TreeSize: 512, RootHash: make([]byte, 32), + }, &treedb.CheckpointSubtreeModel{ + ID: 7, + MTCLogID: mtcLogID, + MTCASignature: caSignature(t, 512), + SubtreeStart: 0, + SubtreeEnd: 512, + SubtreeHash: make([]byte, 32), } } @@ -155,18 +188,23 @@ func TestPublish(t *testing.T) { p := testPublisher(t, key, checkpoints) // A pass over a log that is not initialized is a no-op. - err := p.Publish(t.Context()) + err := p.PublishCheckpoint(t.Context()) if err != nil { - t.Fatalf("p.Publish() on a log that is not initialized: %s", err) + t.Fatalf("p.PublishCheckpoint() on a log that is not initialized: %s", err) } // The latest checkpoint, which we expect to be cosigned by p.Publish(). - latest := testCheckpoint(t) + latest, subtree := testCheckpoint(t) checkpoints.latest = latest + checkpoints.subtrees = []*treedb.CheckpointSubtreeModel{subtree} - err = p.Publish(t.Context()) + err = p.PublishCheckpoint(t.Context()) if err != nil { - t.Fatalf("p.Publish(): %s", err) + t.Fatalf("p.PublishCheckpoint(): %s", err) + } + err = p.CosignSubtrees(t.Context()) + if err != nil { + t.Fatalf("p.CosignSubtrees(): %s", err) } if latest.MirrorID == nil { @@ -175,72 +213,156 @@ func TestPublish(t *testing.T) { 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 := "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 = checkpoint.Open([]byte(text+"\n"+string(latest.MirrorSignature)), verifier) + if err != nil { + t.Errorf("stored mirror cosignature does not verify against the checkpoint text: %s", err) + } + + if subtree.MirrorID == nil { + t.Fatal("subtree was not cosigned") + } + if *subtree.MirrorID != mirrorID { + t.Errorf("subtree mirrorID = %q, want %q", *subtree.MirrorID, mirrorID) + } + if !verifier.VerifySubtree(testLogID.Origin(), 0, 512, tlog.Hash(subtree.SubtreeHash), subtree.MirrorSignature) { + t.Error("stored subtree signature does not verify") } } -// TestPublishRejectsBadMTCASignature checks that a checkpoint whose stored -// MTCA signature does not verify is neither submitted nor cosigned. +// TestPublishRejectsBadMTCASignature checks that a checkpoint or subtree 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) + latest, subtree := testCheckpoint(t) latest.MTCASignature = caSignature(t, 999) - p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest, subtrees: []*treedb.CheckpointSubtreeModel{subtree}}) - err := p.Publish(t.Context()) + err := p.PublishCheckpoint(t.Context()) if err == nil { 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") } + + // The subtree pass does not depend on the checkpoint pass. + err = p.CosignSubtrees(t.Context()) + if err != nil { + t.Fatalf("p.CosignSubtrees(): %s", err) + } + if !subtree.Mirrored() { + t.Error("subtree was not cosigned after the checkpoint was rejected") + } + + latest, subtree = testCheckpoint(t) + subtree.MTCASignature = caSignature(t, 999) + p = testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest, subtrees: []*treedb.CheckpointSubtreeModel{subtree}}) + err = p.CosignSubtrees(t.Context()) + if err == nil { + t.Error("cosigning a subtree with a bad MTCA signature = nil error, want error") + } + if subtree.Mirrored() { + t.Error("subtree signature was stored despite the MTCA signature failing verification") + } +} + +// mirrorBehind is a Mirror whose checkpoint is at size, so it signs only +// subtrees ending at or before size. +type mirrorBehind struct { + Mirror + size int64 + calls int +} + +func (m *mirrorBehind) CosignSubtree(ctx context.Context, origin string, start, end int64, subtreeHash tlog.Hash) ([]byte, error) { + m.calls++ + if end > m.size { + return nil, ErrSubtreeNotMirrored + } + return m.Mirror.CosignSubtree(ctx, origin, start, end, subtreeHash) +} + +// TestCosignSubtreesStopsAtMirrorCheckpoint checks that a pass signs the +// subtrees within the mirror's checkpoint and stops at the first beyond it. +func TestCosignSubtreesStopsAtMirrorCheckpoint(t *testing.T) { + latest, subtree := testCheckpoint(t) + subtrees := []*treedb.CheckpointSubtreeModel{subtree} + for i, end := range []uint64{1024, 2048} { + subtrees = append(subtrees, &treedb.CheckpointSubtreeModel{ + ID: subtree.ID + int64(i) + 1, + MTCLogID: mtcLogID, + MTCASignature: caSignature(t, int64(end)), //nolint:gosec // G115: the ends above fit. + SubtreeEnd: end, + SubtreeHash: make([]byte, 32), + }) + } + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest, subtrees: subtrees}) + mirror := &mirrorBehind{Mirror: p.mirror, size: 512} + p.mirror = mirror + + err := p.CosignSubtrees(t.Context()) + if err != nil { + t.Fatalf("p.CosignSubtrees(): %s", err) + } + if !subtrees[0].Mirrored() { + t.Error("subtree within the mirror's checkpoint was not cosigned") + } + if subtrees[1].Mirrored() || subtrees[2].Mirrored() { + t.Error("subtree beyond the mirror's checkpoint was cosigned") + } + if mirror.calls != 2 { + t.Errorf("mirror saw %d sign-subtree calls, want 2", mirror.calls) + } } // TestPublishMirrorError checks that a failed cosigning stores nothing. func TestPublishMirrorError(t *testing.T) { - latest := testCheckpoint(t) - p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) + latest, subtree := testCheckpoint(t) + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest, subtrees: []*treedb.CheckpointSubtreeModel{subtree}}) otherLogMirror, err := mtpublishertest.NewTestMirror(mirrorID, "oid/1.3.6.1.4.1.44947.4.2.0.99", privatekey.NewDeterministicSigner(testKey(t))) if err != nil { t.Fatalf("NewTestMirror: %s", err) } p.mirror = otherLogMirror - err = p.Publish(t.Context()) + err = p.PublishCheckpoint(t.Context()) if err == nil { t.Error("publish with a mirror refusing the checkpoint = nil error, want error") } if latest.MirrorID != nil || latest.MirrorSignature != nil { t.Error("cosignature was stored despite the mirror refusing the checkpoint") } + + err = p.CosignSubtrees(t.Context()) + if err == nil { + t.Error("cosigning subtrees with a mirror refusing them = nil error, want error") + } + if subtree.Mirrored() { + t.Error("subtree signature was stored despite the mirror refusing it") + } } func TestPublishWhenLatestAlreadySigned(t *testing.T) { // The latest checkpoint is already cosigned, which must be left untouched. existingMirrorID := "existing.cosigner" - latest := testCheckpoint(t) + latest, _ := testCheckpoint(t) latest.MirrorID = &existingMirrorID latest.MirrorSignature = []byte("already-signed-bruh") p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) - err := p.Publish(t.Context()) + err := p.PublishCheckpoint(t.Context()) if err != nil { - t.Fatalf("p.Publish(): %s", err) + t.Fatalf("p.PublishCheckpoint(): %s", err) } - if *latest.MirrorID != existingMirrorID || string(latest.MirrorSignature) != "already-signed-bruh" { - t.Errorf("existing cosignature was replaced: %s %q", *latest.MirrorID, latest.MirrorSignature) + if string(latest.MirrorSignature) != "already-signed-bruh" { + t.Errorf("existing cosignature was replaced: %q", latest.MirrorSignature) } } @@ -253,15 +375,30 @@ type sourceLog struct { 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 + // mirror cosigns with mirrorKey. The lines are its checkpoint and subtree + // cosignatures over the newer tree, the latter carrying subtreeSignature. + mirrorKey *mldsa.PrivateKey + mirror *mtpublishertest.TestMirror + checkpointCosignatureLine []byte + subtreeCosignatureLine []byte + subtreeSignature []byte } const testTilePrefix = "44947.4.1/44" +// serveMirrorCheckpoint serves the newer tree's checkpoint with the mirror's +// cosignature at the path c2sp.org/tlog-mirror puts it. +func (s *sourceLog) serveMirrorCheckpoint(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + expectPath := fmt.Sprintf("/%x/checkpoint", sha256.Sum256([]byte(s.cp.Origin))) + if r.URL.Path != expectPath { + t.Errorf("GET %s, want %s", r.URL.Path, expectPath) + http.NotFound(w, r) + return + } + w.Write(append(slices.Clone(s.signedNote), s.checkpointCosignatureLine...)) +} + // 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 { @@ -323,25 +460,116 @@ func newSourceLog(t *testing.T) *sourceLog { if err != nil { t.Fatalf("NewPrivateKey: %s", err) } - mirrorCosigner, err := cosignature.NewCosigner(mirrorID, cp.Origin, privatekey.NewDeterministicSigner(mirrorKey)) + mirror, err := mtpublishertest.NewTestMirror(mirrorID, cp.Origin, privatekey.NewDeterministicSigner(mirrorKey)) if err != nil { - t.Fatalf("NewCosigner: %s", err) + t.Fatalf("NewTestMirror: %s", err) } - timestamped, err := mirrorCosigner.CosignCheckpoint(newer) + mirrorVerifier, err := cosignature.NewVerifier(mirrorID, mirrorKey.PublicKey()) if err != nil { - t.Fatalf("CosignCheckpoint: %s", err) + t.Fatalf("NewVerifier: %s", err) } - rawCosig, err := cosignature.RawSignature(timestamped) + // A witness cosigns its checkpoint at the current time, which the + // TestMirror cannot, so the line is signed by hand. + message, err := (&cosigned.Message{ + CosignerName: mirrorVerifier.Name(), + Timestamp: 1_700_000_000, + LogOrigin: cp.Origin, + Start: 0, + End: uint64(newer.N), //nolint:gosec // G115: the tree has 700 entries + SubtreeHash: newer.Hash, + }).Marshal() if err != nil { - t.Fatalf("RawSignature: %s", err) + t.Fatalf("Marshal: %s", err) + } + checkpointSignature, err := mirrorKey.Sign(nil, message, nil) + if err != nil { + t.Fatalf("Sign: %s", err) } - cosigLine, err := mirrorCosigner.SignatureLine(0, rawCosig) + checkpointCosignatureLine, err := cosignature.SignatureLine(mirrorVerifier.Name(), mirrorVerifier.KeyHash(), 1_700_000_000, checkpointSignature) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + subtreeSignature, err := mirror.CosignSubtree(t.Context(), cp.Origin, 0, cp.Tree.N, cp.Tree.Hash) + if err != nil { + t.Fatalf("CosignSubtree: %s", err) + } + subtreeCosignatureLine, err := cosignature.SignatureLine(mirrorVerifier.Name(), mirrorVerifier.KeyHash(), 0, subtreeSignature) 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, + mirrorKey: mirrorKey, mirror: mirror, checkpointCosignatureLine: checkpointCosignatureLine, subtreeCosignatureLine: subtreeCosignatureLine, subtreeSignature: subtreeSignature, + } +} + +// TestMirrorCosignOlderSubtree checks that a subtree the mirror's checkpoint +// has grown past is proven against that checkpoint and signed. +func TestMirrorCosignOlderSubtree(t *testing.T) { + source := newSourceLog(t) + olderSubtreeSignature, err := source.mirror.CosignSubtree(t.Context(), source.cp.Origin, 0, source.older.N, source.older.Hash) + if err != nil { + t.Fatalf("CosignSubtree: %s", err) + } + mirrorVerifier, err := cosignature.NewVerifier(mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + olderSubtreeCosignatureLine, err := cosignature.SignatureLine(mirrorVerifier.Name(), mirrorVerifier.KeyHash(), 0, olderSubtreeSignature) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + source.serveMirrorCheckpoint(t, w, r) + return + } + if r.URL.Path != "/sign-subtree" { + t.Errorf("unexpected request to %s", r.URL.Path) + http.NotFound(w, r) + return + } + header, note, ok := bytes.Cut(requestBody(t, r), []byte("\n\n")) + if !ok || !bytes.HasSuffix(note, source.checkpointCosignatureLine) { + t.Errorf("sign-subtree note %q does not end with the mirror's checkpoint cosignature line", note) + } + lines := strings.Split(string(header), "\n") + expectHeader := fmt.Sprintf("subtree 0 %d\n%s", source.older.N, source.older.Hash) + if len(lines) < 2 || strings.Join(lines[:2], "\n") != expectHeader { + t.Errorf("sign-subtree header %q does not begin with %q", header, expectHeader) + http.Error(w, "bad subtree", http.StatusBadRequest) + return + } + proof := make([]tlog.Hash, len(lines)-2) + for i, l := range lines[2:] { + h, err := tlog.ParseHash(l) + if err != nil { + t.Errorf("proof line %q: %s", l, err) + http.Error(w, "bad proof line", http.StatusBadRequest) + return + } + proof[i] = h + } + if !subtree.VerifyConsistency(0, source.older.N, source.newer.N, proof, source.older.Hash, source.newer.Hash) { + t.Errorf("client's subtree proof for [0, %d) does not verify against the tree of size %d", source.older.N, source.newer.N) + http.Error(w, "bad proof", http.StatusBadRequest) + return + } + w.Write(olderSubtreeCosignatureLine) + })) + defer srv.Close() + + m, err := NewMirrorClient(srv.URL, srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + subtreeSignature, err := m.CosignSubtree(t.Context(), source.cp.Origin, 0, source.older.N, source.older.Hash) + if err != nil { + t.Fatalf("CosignSubtree: %s", err) + } + if !bytes.Equal(subtreeSignature, olderSubtreeSignature) { + t.Errorf("CosignSubtree = %x, want the mirror's %x", subtreeSignature, olderSubtreeSignature) } } @@ -449,10 +677,13 @@ func parseUploadHeader(t *testing.T, body []byte) (int64, []byte) { // 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) { + if r.Method == http.MethodGet { + source.serveMirrorCheckpoint(t, w, r) + return + } body := requestBody(t, r) switch r.URL.Path { case "/add-checkpoint": @@ -503,7 +734,7 @@ func TestMirrorCosign(t *testing.T) { 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) + w.Write(source.checkpointCosignatureLine) } case "/sign-subtree": signSubtreeCalls++ @@ -512,26 +743,33 @@ func TestMirrorCosign(t *testing.T) { if !ok || string(header) != expectHeader { t.Errorf("sign-subtree header %q, want %q", header, expectHeader) } - if !bytes.HasSuffix(note, source.cosigLine) { + if !bytes.HasSuffix(note, source.checkpointCosignatureLine) { t.Errorf("sign-subtree note %q does not end with the add-entries cosignature line", note) } - fmt.Fprint(w, line) + w.Write(source.subtreeCosignatureLine) 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(), 10*time.Second) + m, err := NewMirrorClient(srv.URL, srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - got, err := m.Cosign(t.Context(), source.cp, source.signedNote) + checkpointCosignatureLine, err := m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + subtreeSignature, err := m.CosignSubtree(t.Context(), source.cp.Origin, 0, source.cp.Tree.N, source.cp.Tree.Hash) if err != nil { - t.Fatalf("Cosign: %s", err) + t.Fatalf("CosignSubtree: %s", err) } - if !bytes.Equal(got, source.rawCosig) { - t.Errorf("Cosign = %x, want the mirror's raw cosignature %x", got, source.rawCosig) + if !bytes.Equal(checkpointCosignatureLine, source.checkpointCosignatureLine) { + t.Errorf("CosignCheckpoint checkpoint cosignature line = %q, want the add-entries line %q", checkpointCosignatureLine, source.checkpointCosignatureLine) + } + if !bytes.Equal(subtreeSignature, source.subtreeSignature) { + t.Errorf("CosignSubtree = %x, want the mirror's %x", subtreeSignature, source.subtreeSignature) } 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) @@ -544,7 +782,7 @@ func TestMirrorCosign(t *testing.T) { // advertises a next entry equal to the tree size. func TestMirrorCosignAlreadyMirrored(t *testing.T) { source := newSourceLog(t) - line := string(source.cosigLine) + line := string(source.checkpointCosignatureLine) var addEntriesCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -574,24 +812,22 @@ func TestMirrorCosignAlreadyMirrored(t *testing.T) { 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(), 10*time.Second) + m, err := NewMirrorClient(srv.URL, srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - got, err := m.Cosign(t.Context(), source.cp, source.signedNote) + got, err := m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err != nil { - t.Fatalf("Cosign: %s", err) + t.Fatalf("CosignCheckpoint: %s", err) } - if !bytes.Equal(got, source.rawCosig) { - t.Errorf("Cosign = %x, want the mirror's raw cosignature %x", got, source.rawCosig) + if !bytes.Equal(got, source.checkpointCosignatureLine) { + t.Errorf("CosignCheckpoint = %q, want the add-entries line %q", got, source.checkpointCosignatureLine) } if addEntriesCalls != 2 { t.Errorf("mirror saw %d add-entries calls, want 2", addEntriesCalls) @@ -602,26 +838,30 @@ func TestMirrorCosignAlreadyMirrored(t *testing.T) { // 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(), 10*time.Second) + _, err := NewMirrorClient("", "http://mirror.example", NewSource(nil, testTilePrefix), mirrorID, testKey(t).PublicKey(), 10*time.Second) if err == nil { t.Error("NewMirrorClient with an empty base URL = nil error, want error") } + _, err = NewMirrorClient("http://mirror.example", "", NewSource(nil, testTilePrefix), mirrorID, testKey(t).PublicKey(), 10*time.Second) + if err == nil { + t.Error("NewMirrorClient with an empty monitoring prefix = 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(), 10*time.Second) + m, err := NewMirrorClient(refusing.URL, refusing.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err == nil { - t.Fatal("Cosign against a refusing mirror = nil error, want error") + t.Fatal("CosignCheckpoint 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) + t.Errorf("CosignCheckpoint error %q does not carry the mirror's response", err) } overshooting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -633,16 +873,16 @@ func TestMirrorCosignErrors(t *testing.T) { 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(), 10*time.Second) + m, err = NewMirrorClient(overshooting.URL, overshooting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(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") + t.Fatal("CosignCheckpoint 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) + t.Errorf("CosignCheckpoint error %q does not name upload_start", err) } mismatched := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -654,13 +894,13 @@ func TestMirrorCosignErrors(t *testing.T) { fmt.Fprint(w, "9000\n0\n\n") })) defer mismatched.Close() - m, err = NewMirrorClient(mismatched.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + m, err = NewMirrorClient(mismatched.URL, mismatched.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(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) + t.Errorf("CosignCheckpoint against a mismatched mirror = %s, want an upload_end error", err) } conflicting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -669,13 +909,13 @@ func TestMirrorCosignErrors(t *testing.T) { fmt.Fprintf(w, "%d\n", source.older.N) })) defer conflicting.Close() - m, err = NewMirrorClient(conflicting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + m, err = NewMirrorClient(conflicting.URL, conflicting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err == nil || !strings.Contains(err.Error(), "after retrying") { - t.Errorf("Cosign against a mirror that keeps answering 409 = %s, want an error after one retry", err) + t.Errorf("CosignCheckpoint against a mirror that keeps answering 409 = %s, want an error after one retry", err) } // A mirror that answers 202 without saving any package, which the spec @@ -690,40 +930,107 @@ func TestMirrorCosignErrors(t *testing.T) { fmt.Fprintf(w, "%d\n%d\n\n", source.newer.N, uploadStart) })) defer stalled.Close() - m, err = NewMirrorClient(stalled.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + m, err = NewMirrorClient(stalled.URL, stalled.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err == nil || !strings.Contains(err.Error(), "no progress") { - t.Errorf("Cosign against a mirror that accepts nothing = %s, want a no progress error", err) + t.Errorf("CosignCheckpoint against a mirror that accepts nothing = %s, want a no progress error", err) } // A checkpoint smaller than the mirror's last cosigned size is a rolled // back log, not something to submit. accepting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer accepting.Close() - m, err = NewMirrorClient(accepting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + m, err = NewMirrorClient(accepting.URL, accepting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err == nil || !strings.Contains(err.Error(), "no cosignature lines") { - t.Errorf("Cosign against a mirror answering add-entries with an empty 200 = %s, want an error", err) + t.Errorf("CosignCheckpoint against a mirror answering add-entries with an empty 200 = %s, want an error", err) } m.oldSize = source.newer.N + 1 - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err == nil || !strings.Contains(err.Error(), "already holds") { - t.Errorf("Cosign for a tree smaller than the mirror's size = %s, want an error", err) + t.Errorf("CosignCheckpoint for a tree smaller than the mirror's size = %s, want an error", err) } - unreachable, err := NewMirrorClient("http://127.0.0.1:1", NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + unreachable, err := NewMirrorClient("http://127.0.0.1:1", "http://127.0.0.1:1", NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = unreachable.Cosign(t.Context(), source.cp, source.signedNote) + _, err = unreachable.CosignCheckpoint(t.Context(), source.cp, source.signedNote) if err == nil { - t.Error("Cosign against an unreachable mirror = nil error, want error") + t.Error("CosignCheckpoint against an unreachable mirror = nil error, want error") + } + + // A mirror that cosigns the checkpoint with a zero timestamp. + undated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + w.Write(source.subtreeCosignatureLine) + })) + defer undated.Close() + m, err = NewMirrorClient(undated.URL, undated.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) + if err == nil || !strings.Contains(err.Error(), "zero timestamp") { + t.Errorf("CosignCheckpoint with a zero timestamp checkpoint cosignature = %s, want a zero timestamp error", err) + } + + // A mirror whose checkpoint does not include the subtree yet. + behind := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + older := &checkpoint.Checkpoint{Origin: source.cp.Origin, Tree: source.older} + olderCosignatureLine, err := source.mirror.CosignCheckpoint(t.Context(), older, nil) + if err != nil { + t.Errorf("CosignCheckpoint: %s", err) + return + } + olderNote, err := older.SignedNote(olderCosignatureLine) + if err != nil { + t.Errorf("SignedNote: %s", err) + return + } + w.Write(olderNote) + })) + defer behind.Close() + m, err = NewMirrorClient(behind.URL, behind.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.CosignSubtree(t.Context(), source.cp.Origin, 0, source.cp.Tree.N, source.cp.Tree.Hash) + if !errors.Is(err, ErrSubtreeNotMirrored) { + t.Errorf("CosignSubtree against a mirror behind the subtree = %s, want ErrSubtreeNotMirrored", err) + } + + // A mirror whose checkpoint at the subtree's size has a different root. + split := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + other := &checkpoint.Checkpoint{Origin: source.cp.Origin, Tree: tlog.Tree{N: source.newer.N, Hash: source.older.Hash}} + otherCosignatureLine, err := source.mirror.CosignCheckpoint(t.Context(), other, nil) + if err != nil { + t.Errorf("CosignCheckpoint: %s", err) + return + } + otherNote, err := other.SignedNote(otherCosignatureLine) + if err != nil { + t.Errorf("SignedNote: %s", err) + return + } + w.Write(otherNote) + })) + defer split.Close() + m, err = NewMirrorClient(split.URL, split.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.CosignSubtree(t.Context(), source.cp.Origin, 0, source.cp.Tree.N, source.cp.Tree.Hash) + if err == nil || !strings.Contains(err.Error(), "root hash") { + t.Errorf("CosignSubtree against a mirror with a different root = %s, want a root hash error", err) } // A mirror whose signature line names its key but was signed by another, @@ -757,13 +1064,13 @@ func TestMirrorCosignErrors(t *testing.T) { w.Write(forgedLine) })) defer lying.Close() - m, err = NewMirrorClient(lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) + m, err = NewMirrorClient(lying.URL, lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } - _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + _, err = m.CosignCheckpoint(t.Context(), source.cp, source.signedNote) _, ok := errors.AsType[*note.InvalidSignatureError](err) if !ok { - t.Errorf("Cosign with a forged signature = %s, want note.InvalidSignatureError", err) + t.Errorf("CosignCheckpoint with a forged signature = %s, want note.InvalidSignatureError", err) } } diff --git a/mtpublisher/mtpublishertest/mtpublishertest.go b/mtpublisher/mtpublishertest/mtpublishertest.go index 080572c333e..35599715198 100644 --- a/mtpublisher/mtpublishertest/mtpublishertest.go +++ b/mtpublisher/mtpublishertest/mtpublishertest.go @@ -5,8 +5,11 @@ package mtpublishertest import ( "context" "crypto" + "crypto/mldsa" "fmt" + "golang.org/x/mod/sumdb/tlog" + "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" ) @@ -16,6 +19,7 @@ import ( type TestMirror struct { cosignerID string cosigner *cosignature.Cosigner + verifier *cosignature.Verifier } // NewTestMirror returns a TestMirror that cosigns checkpoints of the log with @@ -25,7 +29,15 @@ func NewTestMirror(mirrorID, origin string, signer crypto.Signer) (*TestMirror, if err != nil { return nil, fmt.Errorf("creating mirror cosigner: %s", err) } - return &TestMirror{cosignerID: mirrorID, cosigner: cosigner}, nil + publicKey, ok := signer.Public().(*mldsa.PublicKey) + if !ok { + return nil, fmt.Errorf("mirror public key is %T, must be ML-DSA-44", signer.Public()) + } + verifier, err := cosignature.NewVerifier(mirrorID, publicKey) + if err != nil { + return nil, fmt.Errorf("creating mirror verifier: %s", err) + } + return &TestMirror{cosignerID: mirrorID, cosigner: cosigner, verifier: verifier}, nil } // ID returns the mirror's cosigner ID. @@ -33,9 +45,9 @@ 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) { +// CosignCheckpoint cosigns the checkpoint and returns the cosignature line. It +// errors if the checkpoint is not of the cosigner's log. +func (m *TestMirror) CosignCheckpoint(_ 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()) } @@ -43,5 +55,18 @@ func (m *TestMirror) Cosign(_ context.Context, cp *checkpoint.Checkpoint, _ []by if err != nil { return nil, err } - return cosignature.RawSignature(timestampedCosignature) + rawCosignature, err := cosignature.RawSignature(timestampedCosignature) + if err != nil { + return nil, err + } + return cosignature.SignatureLine(m.verifier.Name(), m.verifier.KeyHash(), 0, rawCosignature) +} + +// CosignSubtree returns the mirror's signature over the subtree [start, end). +// It errors if origin is not the cosigner's log. +func (m *TestMirror) CosignSubtree(_ context.Context, origin string, start, end int64, subtreeHash tlog.Hash) ([]byte, error) { + if origin != m.cosigner.Origin() { + return nil, fmt.Errorf("origin %q is not this mirror's log %q", origin, m.cosigner.Origin()) + } + return m.cosigner.CosignSubtree(start, end, subtreeHash) } diff --git a/mtpublisher/mtpublishertest/mtpublishertest_test.go b/mtpublisher/mtpublishertest/mtpublishertest_test.go index 92aec93de66..f957fd7e10d 100644 --- a/mtpublisher/mtpublishertest/mtpublishertest_test.go +++ b/mtpublisher/mtpublishertest/mtpublishertest_test.go @@ -34,8 +34,8 @@ func testMirror(t *testing.T) (*TestMirror, *mldsa.PrivateKey) { return mirror, key } -// TestMirrorCosign checks that the mirror's raw cosignature verifies -// through trees/cosignature. +// TestMirrorCosign checks that the checkpoint cosignature line and the subtree +// signature verify. func TestMirrorCosign(t *testing.T) { mirror, key := testMirror(t) @@ -44,21 +44,29 @@ func TestMirrorCosign(t *testing.T) { } 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) + checkpointCosignatureLine, err := mirror.CosignCheckpoint(t.Context(), cp, nil) if err != nil { - t.Fatalf("Cosign: %s", err) + t.Fatalf("CosignCheckpoint: %s", err) + } + subtreeSignature, err := mirror.CosignSubtree(t.Context(), cp.Origin, 0, cp.Tree.N, cp.Tree.Hash) + if err != nil { + t.Fatalf("CosignSubtree: %s", err) } verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) if err != nil { t.Fatalf("NewVerifier: %s", err) } - text, err := cp.Marshal() + signedNote, err := cp.SignedNote(checkpointCosignatureLine) + if err != nil { + t.Fatalf("SignedNote: %s", err) + } + _, _, err = checkpoint.Open(signedNote, verifier) if err != nil { - t.Fatalf("Marshal: %s", err) + t.Errorf("the mirror's cosignature does not verify: %s", err) } - if !verifier.Verify(text, append(make([]byte, 8), raw...)) { - t.Error("the mirror's cosignature does not verify") + if !verifier.VerifySubtree(cp.Origin, 0, cp.Tree.N, cp.Tree.Hash, subtreeSignature) { + t.Error("the mirror's subtree signature does not verify") } } @@ -67,8 +75,8 @@ func TestMirrorCosign(t *testing.T) { 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) + _, err := mirror.CosignCheckpoint(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") + t.Error("CosignCheckpoint with another log's checkpoint = nil error, want error") } } diff --git a/sa/db/01-mtcmeta_44947_4_1_0_44.sql b/sa/db/01-mtcmeta_44947_4_1_0_44.sql index 8b9a96c0d9c..9904bc37cc1 100644 --- a/sa/db/01-mtcmeta_44947_4_1_0_44.sql +++ b/sa/db/01-mtcmeta_44947_4_1_0_44.sql @@ -36,9 +36,8 @@ CREATE TABLE `checkpoints` ( -- `mirrorID` is an ASCII-format OID relative to 1.3.6.1.4.1. -- Note: these two fields start empty and are filled later. `mirrorID` varchar(255), - -- An ML-DSA-44 signature over a CosignedMessage with `timestamp` and `start` both zero. - -- TODO(#8991): Change this to store a signed note signature line instead, since that format can - -- carry a nonzero `timestamp`. + -- The mirror's checkpoint cosignature (https://c2sp.org/tlog-cosignature), with its `timestamp`, + -- as a signed note signature line: https://c2sp.org/signed-note#signatures `mirrorSignature` mediumblob, -- Signed-over data: https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1 @@ -48,14 +47,6 @@ CREATE TABLE `checkpoints` ( `treeSize` bigint(20) unsigned NOT NULL, `rootHash` binary(32) NOT NULL, - -- The IDs of the one or two subtrees in the `checkpointSubtrees` table implied by the delta between - -- this checkpoint and the previous one. These two subtrees must cover all entries between this checkpoint - -- and the previous one. - -- The first checkpoint of an issuance log will have these both NULL. Subsequent checkpoints will have `subtreeID2` NULL - -- if a single subtree covers the delta. - `subtreeID1` bigint(20) unsigned, - `subtreeID2` bigint(20) unsigned, - `created` datetime DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `mtcLogID_treeSize` (`mtcLogID`, `treeSize`) @@ -95,7 +86,9 @@ CREATE TABLE `checkpointSubtrees` ( `subtreeHash` binary(32) NOT NULL, `created` datetime DEFAULT current_timestamp(), - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + -- The publisher polls for the subtrees the mirror has not signed yet. + KEY `mtcLogID_mirrorID` (`mtcLogID`, `mirrorID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; diff --git a/sa/db/02-users.sql b/sa/db/02-users.sql index 6ff71c47d9d..763c26584d0 100644 --- a/sa/db/02-users.sql +++ b/sa/db/02-users.sql @@ -101,10 +101,11 @@ CREATE USER IF NOT EXISTS 'mtpublisher'@'%'; CREATE USER IF NOT EXISTS 'mtca'@'%'; CREATE USER IF NOT EXISTS 'mtcb'@'%'; --- mtpublisher stub: follows the latestCheckpoint pointer to a checkpoint --- awaiting a cosignature and writes one. +-- mtpublisher: follows the latestCheckpoint pointer to a checkpoint awaiting +-- a cosignature and writes the mirror's cosignatures on it and its subtrees. GRANT SELECT,UPDATE ON checkpoints TO 'mtpublisher'@'%'; GRANT SELECT ON latestCheckpoint TO 'mtpublisher'@'%'; +GRANT SELECT,UPDATE ON checkpointSubtrees TO 'mtpublisher'@'%'; -- MTCA GRANT SELECT,INSERT,UPDATE ON checkpoints TO 'mtca'@'%'; diff --git a/sa/db/02-users_next.sql b/sa/db/02-users_next.sql index 8991cd35730..90d929478d6 100644 --- a/sa/db/02-users_next.sql +++ b/sa/db/02-users_next.sql @@ -101,10 +101,11 @@ CREATE USER IF NOT EXISTS 'mtpublisher'@'%'; CREATE USER IF NOT EXISTS 'mtca'@'%'; CREATE USER IF NOT EXISTS 'mtcb'@'%'; --- mtpublisher stub: follows the latestCheckpoint pointer to a checkpoint --- awaiting a cosignature and writes one. +-- mtpublisher: follows the latestCheckpoint pointer to a checkpoint awaiting +-- a cosignature and writes the mirror's cosignatures on it and its subtrees. GRANT SELECT,UPDATE ON checkpoints TO 'mtpublisher'@'%'; GRANT SELECT ON latestCheckpoint TO 'mtpublisher'@'%'; +GRANT SELECT,UPDATE ON checkpointSubtrees TO 'mtpublisher'@'%'; -- MTCA GRANT SELECT,INSERT,UPDATE ON checkpoints TO 'mtca'@'%'; diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json index 1c2ceabe9b4..650a4590483 100644 --- a/test/config-next/mtpublisher.json +++ b/test/config-next/mtpublisher.json @@ -14,6 +14,7 @@ "id": "32473.9", "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", "baseURL": "http://boulder-sunlight:4700", + "monitoringPrefix": "http://boulder-seaweedfs:8333/boulder-sunlight/mirror", "timeout": "15s" }, "s3": { diff --git a/test/config/mtpublisher.json b/test/config/mtpublisher.json index 1c2ceabe9b4..650a4590483 100644 --- a/test/config/mtpublisher.json +++ b/test/config/mtpublisher.json @@ -14,6 +14,7 @@ "id": "32473.9", "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", "baseURL": "http://boulder-sunlight:4700", + "monitoringPrefix": "http://boulder-seaweedfs:8333/boulder-sunlight/mirror", "timeout": "15s" }, "s3": { diff --git a/test/integration/issuance_test.go b/test/integration/issuance_test.go index 233f37fb040..a7f6dfeff3a 100644 --- a/test/integration/issuance_test.go +++ b/test/integration/issuance_test.go @@ -204,11 +204,14 @@ func TestIssuanceMTC(t *testing.T) { idents := []acme.Identifier{{Type: "dns", Value: random_domain()}} - _, err = authAndIssue(client, key, idents, true, "mtcshortlived") - // "finalized order timeout" is as far as we get for now. Once we are collecting - // signatures and constructing standalone certificates, we'll expect this to succeed. - if err == nil || !strings.Contains(err.Error(), "finalized order timeout") { - t.Fatalf("issuing certificate: expected 'finalized order timeout', got %q", err) + // A valid order is as far as we get for now. Once the WFE serves standalone + // certificates for MTC orders, we'll fetch the certificate too. + _, order, err := makeClientAndOrder(client, key, idents, true, "mtcshortlived", nil) + if err != nil { + t.Fatalf("finalizing MTC order: %s", err) + } + if order.Status != "valid" { + t.Fatalf("finalized MTC order status = %q, want valid", order.Status) } // The mtca serves each checkpoint as soon as it is sequenced, and the diff --git a/trees/cosignature/cosignature.go b/trees/cosignature/cosignature.go index 10b968d6596..686b555beac 100644 --- a/trees/cosignature/cosignature.go +++ b/trees/cosignature/cosignature.go @@ -52,24 +52,33 @@ func keyIDFor(name string, publicKey *mldsa.PublicKey) uint32 { return binary.BigEndian.Uint32(h.Sum(nil)[:keyIDSize]) } -// marshalCheckpointMessage serializes the cosigned.Message for a checkpoint -// cosignature, with start 0 and end the tree size as the MTC draft section -// 5.3.1 requires for checkpoints. It rejects a non-positive end. -func marshalCheckpointMessage(name string, timestamp uint64, origin string, end int64, rootHash tlog.Hash) ([]byte, error) { - if end <= 0 { - return nil, fmt.Errorf("non-positive end %d", end) +// marshalMessage serializes the cosigned.Message for a cosignature over the +// subtree [start, end). It rejects a negative start and an end not after it. +// +// TODO(#9038): reject a [start, end) that is not a valid subtree, as the MTC +// draft section 5.3.1 requires. +func marshalMessage(name string, timestamp uint64, origin string, start, end int64, subtreeHash tlog.Hash) ([]byte, error) { + if start < 0 || end <= start { + return nil, fmt.Errorf("invalid subtree [%d, %d)", start, end) } cosignedMessage := cosigned.Message{ CosignerName: name, Timestamp: timestamp, LogOrigin: origin, - Start: 0, - End: uint64(end), - SubtreeHash: rootHash, + Start: uint64(start), + End: uint64(end), //nolint:gosec // G115: the check above leaves end > start >= 0. + SubtreeHash: subtreeHash, } return cosignedMessage.Marshal() } +// marshalCheckpointMessage serializes the cosigned.Message for a checkpoint +// cosignature, with start 0 and end the tree size as the MTC draft section +// 5.3.1 requires for checkpoints. It rejects a non-positive end. +func marshalCheckpointMessage(name string, timestamp uint64, origin string, end int64, rootHash tlog.Hash) ([]byte, error) { + return marshalMessage(name, timestamp, origin, 0, end, rootHash) +} + // checkRelativeOID returns an error if id is not a dotted decimal OID like // "32473.2", nil otherwise. func checkRelativeOID(id string) error { @@ -92,10 +101,10 @@ func checkRelativeOID(id string) error { return nil } -// Cosigner produces cosignatures over checkpoints as an MTC cosigner for a -// single log: the timestamp is zero, as required for cosignatures used in -// certificates, and the key is a crypto.Signer so it may be stored in an HSM. -// Both the CA cosigner and the mirror cosigner use this. +// Cosigner produces cosignatures over checkpoints and subtrees as an MTC +// cosigner for a single log: the timestamp is zero, as required for +// cosignatures used in certificates, and the key is a crypto.Signer so it may +// be stored in an HSM. Both the CA cosigner and the mirror cosigner use this. // // - https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1 // - https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-6.2 @@ -140,6 +149,17 @@ func (c *Cosigner) Origin() string { return c.origin } +func (c *Cosigner) sign(message []byte) ([]byte, error) { + signature, err := c.signer.Sign(nil, message, nil) + if err != nil { + return nil, err + } + if len(signature) != mldsa.MLDSA44SignatureSize { + return nil, fmt.Errorf("signer returned %d bytes, want %d", len(signature), mldsa.MLDSA44SignatureSize) + } + return signature, nil +} + // CosignCheckpoint cosigns the checkpoint described by tree and returns the // cosignature as a timestamped_signature. func (c *Cosigner) CosignCheckpoint(tree tlog.Tree) ([]byte, error) { @@ -147,18 +167,25 @@ func (c *Cosigner) CosignCheckpoint(tree tlog.Tree) ([]byte, error) { if err != nil { return nil, err } - signature, err := c.signer.Sign(nil, message, nil) + signature, err := c.sign(message) if err != nil { return nil, err } - if len(signature) != mldsa.MLDSA44SignatureSize { - return nil, fmt.Errorf("signer returned %d bytes, want %d", len(signature), mldsa.MLDSA44SignatureSize) - } out := make([]byte, timestampedSignatureSize) copy(out[timestampSize:], signature) return out, nil } +// CosignSubtree cosigns the subtree [start, end) with hash subtreeHash and +// returns the raw signature, which carries no timestamp. +func (c *Cosigner) CosignSubtree(start, end int64, subtreeHash tlog.Hash) ([]byte, error) { + message, err := marshalMessage(c.name, 0, c.origin, start, end, subtreeHash) + if err != nil { + return nil, err + } + return c.sign(message) +} + // SignatureLine assembles the note signature line of the cosigner with the // Cosigner's name and key ID plus the provided timestamp and rawSignature as // its timestamped_signature. Callers are responsible for ensuring the signature @@ -168,7 +195,7 @@ func (c *Cosigner) SignatureLine(timestamp uint64, rawSignature []byte) ([]byte, } // Verifier is a note.Verifier that verifies an MTC cosigner's ML-DSA-44 -// cosignatures over checkpoints. +// cosignatures over checkpoints and subtrees. // // - https://c2sp.org/tlog-cosignature // - https://c2sp.org/mtc-tlog @@ -234,11 +261,37 @@ func (v *Verifier) Verify(noteText, timestampedSignature []byte) bool { return mldsa.Verify(v.publicKey, cosignedMessage, timestampedSignature[timestampSize:], nil) == nil } -// FilterByVerify returns the timestamped_signature by this verifier's cosigner +// VerifySubtree reports whether signature is a valid subtree cosignature by +// this cosigner over the subtree [start, end) with hash subtreeHash of the log +// with origin. +// +// https://c2sp.org/tlog-cosignature#subtree-cosignatures +func (v *Verifier) VerifySubtree(origin string, start, end int64, subtreeHash tlog.Hash, signature []byte) bool { + cosignedMessage, err := marshalMessage(v.keyName, 0, origin, start, end, subtreeHash) + if err != nil { + return false + } + return mldsa.Verify(v.publicKey, cosignedMessage, signature, nil) == nil +} + +// CheckpointCosignature is a verified checkpoint cosignature by one cosigner, +// parsed from its signature line on the checkpoint. +// +// https://c2sp.org/tlog-cosignature#checkpoint-cosignatures +type CheckpointCosignature struct { + // Line is the note signature line carrying the cosignature. + Line []byte + // Timestamp is the POSIX timestamp the cosigner signed. + Timestamp uint64 + // Signature is the raw ML-DSA-44 signature. + Signature []byte +} + +// FilterByVerify returns the checkpoint cosignature by this verifier's cosigner // from the signatureLines over noteText, ignoring lines by other keys. It // errors if the two do not form a well-formed note, if no line is by that // cosigner, or if its signature does not verify. -func (v *Verifier) FilterByVerify(noteText, signatureLines []byte) ([]byte, error) { +func (v *Verifier) FilterByVerify(noteText, signatureLines []byte) (*CheckpointCosignature, error) { n, err := note.Open(fmt.Appendf(nil, "%s\n%s", noteText, signatureLines), note.VerifierList(v)) if err != nil { return nil, fmt.Errorf("opening the cosigned note: %w", err) @@ -247,7 +300,11 @@ func (v *Verifier) FilterByVerify(noteText, signatureLines []byte) ([]byte, erro if err != nil { return nil, fmt.Errorf("decoding the signature by %s: %w", v.keyName, err) } - return idSignature[keyIDSize:], nil + return &CheckpointCosignature{ + Line: []byte(noteSignatureLinePrefix + n.Sigs[0].Name + " " + n.Sigs[0].Base64 + "\n"), + Timestamp: binary.BigEndian.Uint64(idSignature[keyIDSize : keyIDSize+timestampSize]), + Signature: idSignature[keyIDSize+timestampSize:], + }, nil } // RawSignature returns the ML-DSA-44 signature from a timestamped_signature, diff --git a/trees/cosignature/cosignature_test.go b/trees/cosignature/cosignature_test.go index 581549a0feb..3d777d0cda9 100644 --- a/trees/cosignature/cosignature_test.go +++ b/trees/cosignature/cosignature_test.go @@ -253,10 +253,10 @@ func TestCosignerRoundTrip(t *testing.T) { if err != nil { t.Fatalf("FilterByVerify on a reassembled note: %s", err) } - if !v.Verify([]byte(text), extracted) { - t.Error("Verify rejected an extracted cosignature") + if extracted.Timestamp != 0 { + t.Errorf("extracted cosignature timestamp = %d, want 0", extracted.Timestamp) } - rebuilt, err := ca.SignatureLine(0, extracted[timestampSize:]) + rebuilt, err := ca.SignatureLine(0, extracted.Signature) if err != nil { t.Fatalf("SignatureLine: %s", err) } @@ -313,6 +313,59 @@ func TestCosignerRejects(t *testing.T) { if err == nil { t.Error("CosignCheckpoint with an empty tree = nil error, want error") } + _, err = ca.CosignSubtree(-1, 1, tlog.Hash{}) + if err == nil { + t.Error("CosignSubtree with a negative start = nil error, want error") + } + _, err = ca.CosignSubtree(4, 4, tlog.Hash{}) + if err == nil { + t.Error("CosignSubtree with an empty subtree = nil error, want error") + } +} + +// TestCosignSubtree checks that a subtree starting at 0 is signed as a +// checkpoint of its size is, and that a later subtree verifies over its own +// message. +func TestCosignSubtree(t *testing.T) { + ca, err := NewCosigner("32473.2", "oid/1.3.6.1.4.1.32473.2.0.42", testSigner(t)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + text := ca.origin + "\n20852163\n" + exampleHashB64 + "\n" + parsed, err := checkpoint.Unmarshal([]byte(text)) + if err != nil { + t.Fatalf("checkpoint.Unmarshal: %s", err) + } + + cosigned, err := ca.CosignCheckpoint(parsed.Tree) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + signature, err := ca.CosignSubtree(0, parsed.Tree.N, parsed.Tree.Hash) + if err != nil { + t.Fatalf("CosignSubtree: %s", err) + } + if !bytes.Equal(signature, cosigned[timestampSize:]) { + t.Error("CosignSubtree over [0, N) differs from CosignCheckpoint over the tree of size N") + } + + signature, err = ca.CosignSubtree(1024, 1536, parsed.Tree.Hash) + if err != nil { + t.Fatalf("CosignSubtree: %s", err) + } + v, err := NewVerifier("32473.2", testPubKey(t)) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + if !v.VerifySubtree(ca.origin, 1024, 1536, parsed.Tree.Hash, signature) { + t.Error("VerifySubtree rejected the subtree signature") + } + if v.VerifySubtree(ca.origin, 1024, 2048, parsed.Tree.Hash, signature) { + t.Error("VerifySubtree accepted the subtree signature over a different end") + } + if v.VerifySubtree(ca.origin, 1024, 1536, parsed.Tree.Hash, signature[1:]) { + t.Error("VerifySubtree accepted a truncated subtree signature") + } } // shortSigner is a crypto.Signer with a valid ML-DSA-44 public key that returns @@ -420,12 +473,12 @@ func TestFilterByVerify(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - timestampedSignature, err := v.FilterByVerify([]byte(text), line) + extracted, err := v.FilterByVerify([]byte(text), line) if err != nil { t.Fatalf("FilterByVerify for the cosigner that signed the note: %s", err) } - if !v.Verify([]byte(text), timestampedSignature) { - t.Fatal("Verify rejected an extracted cosignature") + if !bytes.Equal(extracted.Signature, cosigned[timestampSize:]) { + t.Errorf("FilterByVerify signature = %x, want %x", extracted.Signature, cosigned[timestampSize:]) } other, err := NewVerifier(cosignerID, testPubKey(t)) @@ -438,6 +491,51 @@ func TestFilterByVerify(t *testing.T) { } } +// TestFilterByVerifyTimestamped checks that FilterByVerify returns the +// cosigner's own line and timestamp from among lines by other keys. +func TestFilterByVerifyTimestamped(t *testing.T) { + ca, err := NewCosigner("32473.2", "oid/1.3.6.1.4.1.32473.2.0.42", testSigner(t)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + text := ca.origin + "\n20852163\n" + exampleHashB64 + "\n" + parsed, err := checkpoint.Unmarshal([]byte(text)) + if err != nil { + t.Fatalf("checkpoint.Unmarshal: %s", err) + } + message, err := marshalCheckpointMessage(ca.name, 1_700_000_000, ca.origin, parsed.Tree.N, parsed.Tree.Hash) + if err != nil { + t.Fatalf("marshalCheckpointMessage: %s", err) + } + signature, err := testSigner(t).Sign(nil, message, nil) + if err != nil { + t.Fatalf("Sign: %s", err) + } + line, err := SignatureLine(ca.name, ca.keyID, 1_700_000_000, signature) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + unknownLine, err := SignatureLine("oid/1.3.6.1.4.1.32473.99", 0x01020304, 5, make([]byte, mldsa.MLDSA44SignatureSize)) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + + v, err := NewVerifier("32473.2", testPubKey(t)) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + got, err := v.FilterByVerify([]byte(text), append(unknownLine, line...)) + if err != nil { + t.Fatalf("FilterByVerify for the cosigner that signed the note: %s", err) + } + if !bytes.Equal(got.Line, line) { + t.Errorf("FilterByVerify line = %q, want the cosigner's line %q", got.Line, line) + } + if got.Timestamp != 1_700_000_000 { + t.Errorf("FilterByVerify timestamp = %d, want 1700000000", got.Timestamp) + } +} + // TestFilterByVerifyRejectsForeignFormat checks that a signature line // whose body is not keyID || timestamped_signature (such as x/mod's standard // 64-byte Ed25519 form) fails verification even when its name and key ID match @@ -508,8 +606,8 @@ func TestFilterByVerifyIgnoresUnknownSignatures(t *testing.T) { if err != nil { t.Fatalf("FilterByVerify: %s", err) } - if !bytes.Equal(filtered, knownSignature) { - t.Errorf("FilterByVerify = %x, want the known cosigner's signature %x", filtered, knownSignature) + if !bytes.Equal(filtered.Signature, knownSignature[timestampSize:]) { + t.Errorf("FilterByVerify = %x, want the known cosigner's signature %x", filtered.Signature, knownSignature[timestampSize:]) } _, _, err = checkpoint.Open([]byte(text+"\n"+string(lines)), v) @@ -553,8 +651,8 @@ func TestSignatureLineRoundTrip(t *testing.T) { if err != nil { t.Fatalf("FilterByVerify rejected the reassembled line: %s", err) } - if !bytes.Equal(roundTripped, timestamped) { - t.Errorf("round-tripped signature = %x, want %x", roundTripped, timestamped) + if roundTripped.Timestamp != 0 || !bytes.Equal(roundTripped.Signature, raw) { + t.Errorf("round-tripped signature = %d %x, want 0 %x", roundTripped.Timestamp, roundTripped.Signature, raw) } _, err = ca.SignatureLine(0, raw[1:]) diff --git a/trees/treedb/treedb.go b/trees/treedb/treedb.go index d6ccbd91608..83a520cf5fb 100644 --- a/trees/treedb/treedb.go +++ b/trees/treedb/treedb.go @@ -24,8 +24,6 @@ type CheckpointModel struct { MirrorSignature []byte `db:"mirrorSignature"` TreeSize int64 `db:"treeSize"` RootHash []byte `db:"rootHash"` - SubtreeID1 *int64 `db:"subtreeID1"` - SubtreeID2 *int64 `db:"subtreeID2"` } func (c *CheckpointModel) Valid() error { @@ -98,8 +96,7 @@ func (i *Impl) LatestCheckpoint(ctx context.Context, mtcLogID string) (*Checkpoi var latest CheckpointModel err := i.db.SelectOne(ctx, &latest, `SELECT id, checkpoints.mtcLogID, mtcaSignature, mirrorID, - mirrorSignature, treeSize, rootHash, - subtreeID1, subtreeID2 + mirrorSignature, treeSize, rootHash FROM latestCheckpoint JOIN checkpoints USING(id) WHERE latestCheckpoint.mtcLogID = ? AND @@ -139,6 +136,23 @@ func (i *Impl) AddMirrorSignature(ctx context.Context, id int64, mirrorID string return nil } +func (i *Impl) AddSubtreeMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { + r, err := i.db.ExecContext(ctx, + "UPDATE checkpointSubtrees SET mirrorID = ?, mirrorSignature = ? WHERE id = ? AND mtcLogID = ?", + mirrorID, mirrorSignature, id, mtcLogID) + if err != nil { + return err + } + n, err := r.RowsAffected() + if err != nil { + return fmt.Errorf("getting RowsAffected: %s", err) + } + if n != 1 { + return fmt.Errorf("adding mirror signature to subtree %d: %d rows affected (want 1 row affected)", id, n) + } + return nil +} + func (i *Impl) GetCheckpointSubtree(ctx context.Context, mtcLogID string, id int64) (*CheckpointSubtreeModel, error) { var checkpointSubtree CheckpointSubtreeModel err := i.db.SelectOne(ctx, &checkpointSubtree, @@ -169,6 +183,24 @@ func (i *Impl) InsertCheckpointSubtree(ctx context.Context, model *CheckpointSub return model.ID, nil } +// GetUnsignedSubtrees returns the log's subtrees without a mirror signature, oldest first. +func (i *Impl) GetUnsignedSubtrees(ctx context.Context, mtcLogID string) ([]*CheckpointSubtreeModel, error) { + var subtrees []*CheckpointSubtreeModel + _, err := i.db.Select(ctx, &subtrees, + `SELECT id, mtcLogID, mtcaSignature, + mirrorID, mirrorSignature, + subtreeStart, subtreeEnd, subtreeHash + FROM checkpointSubtrees + WHERE mtcLogID = ? AND + mirrorID IS NULL + ORDER BY id`, + mtcLogID) + if err != nil { + return nil, err + } + return subtrees, nil +} + // WithTransaction calls `github.com/letsencrypt/boulder/db.WithTransaction` for the given // transaction function, with the built-in DB. func (i *Impl) WithTransaction(ctx context.Context, f TxFunc) (any, error) {