Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions cmd/boulder-mtpublisher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import (
"fmt"
"os"

"github.com/letsencrypt/boulder/bs3"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/config"
"github.com/letsencrypt/boulder/mtpublisher"
"github.com/letsencrypt/boulder/privatekey"
"github.com/letsencrypt/boulder/sa"
"github.com/letsencrypt/boulder/trees/issuancelog"
)
Expand All @@ -25,25 +25,29 @@ type Config struct {

DebugAddr string `validate:"omitempty,hostname_port"`

// PollInterval is how often the stub scans for checkpoints that still
// lack a mirror cosignature.
// PollInterval is how often the publisher scans for checkpoints that
// still lack a mirror cosignature.
PollInterval config.Duration `validate:"required"`

// LogID identifies the issuance log this publisher operates on. It must
// match the mtca's.
LogID issuancelog.ID `validate:"required"`

// MirrorID identifies the cosigner this publisher writes alongside each
// cosignature (e.g. "32473.9").
MirrorID string `validate:"required"`
// MTCAPublicKeyFile holds the PEM-encoded ML-DSA-44 public key the mtca
Comment on lines 12 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we could alternately take the CA certificate file here, which would save us from having to manage a separate artifact containing just the CA public key. Even though our CA certificates are unsigned, Go can parse them - it just can't verify them.

// cosigns checkpoints with, used to reconstruct each checkpoint's
// signed note from the database.
MTCAPublicKeyFile string `validate:"required"`

// MirrorPublicKeyFile holds the PEM-encoded ML-DSA-44 public key used
// to verify cosignatures.
MirrorPublicKeyFile string `validate:"required"`
// Mirror identifies the mirror this publisher submits to.
Mirror cmd.MirrorConfig `validate:"required"`

// MirrorKeyFile holds the PEM-encoded ML-DSA-44 private key used to
// cosign checkpoints.
MirrorKeyFile string `validate:"required"`
// MirrorBaseURL is the base URL of the mirror's tlog-mirror submission
// endpoints (e.g. "http://localhost:4700").
MirrorBaseURL string `validate:"required,url"`
Comment on lines +44 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this belong in cmd.MirrorConfig instead?

Also, I was wondering: does this need to include some CA-specific prefix also, e.g. the CA ID?

Turns out, no: there's just one submission prefix for a given mirror. The relevant submission endpoints (add-checkpoint, add-entries, sign-subtree) carry the issuance log's origin in the request body. https://github.com/C2SP/C2SP/blob/main/tlog-mirror.md#introduction


// 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
Expand Down Expand Up @@ -94,13 +98,18 @@ func main() {
dbMap, err := sa.InitWrappedDb(c.MTPublisher.DB, scope, logger)
cmd.FailOnError(err, "While initializing dbMap")

signer, _, err := privatekey.Load(c.MTPublisher.MirrorKeyFile)
cmd.FailOnError(err, "Loading cosigner key")
pubKey, err := loadMLDSAPublicKey(c.MTPublisher.MirrorPublicKeyFile)
cmd.FailOnError(err, "Loading cosigner public key")
pubKey, err := loadMLDSAPublicKey(c.MTPublisher.Mirror.PublicKeyFile)
cmd.FailOnError(err, "Loading mirror public key")
caPubKey, err := loadMLDSAPublicKey(c.MTPublisher.MTCAPublicKeyFile)
cmd.FailOnError(err, "Loading MTCA public key")
s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger)
cmd.FailOnError(err, "Loading S3 config")

mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.MirrorBaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, pubKey)
cmd.FailOnError(err, "Creating mirror client")

publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, c.MTPublisher.MirrorID, signer, pubKey, logger)
cmd.FailOnError(err, "Failed to create MTPublisher stub")
publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger)
cmd.FailOnError(err, "Failed to create MTPublisher")

ctx, cancel := context.WithCancel(context.Background())
go cmd.CatchSignals(cancel)
Expand Down
8 changes: 8 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ type HostnamePolicyConfig struct {
HostnamePolicyFile string `validate:"required"`
}

// MirrorConfig identifies an MTC mirror cosigner.
type MirrorConfig struct {
// ID is the mirror's ID (e.g. "32473.9").
ID string `validate:"required"`
// PublicKeyFile holds the mirror's PEM-encoded ML-DSA-44 public key.
PublicKeyFile string `validate:"required"`
}

// TLSConfig represents certificates and a key for authenticated TLS.
type TLSConfig struct {
CertFile string `validate:"required"`
Expand Down
16 changes: 15 additions & 1 deletion mtca/mtca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ import (
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/mtca/proto"
"github.com/letsencrypt/boulder/mtpublisher"
"github.com/letsencrypt/boulder/mtpublisher/mtpublishertest"
"github.com/letsencrypt/boulder/privatekey"
"github.com/letsencrypt/boulder/sa"
"github.com/letsencrypt/boulder/test/vars"
"github.com/letsencrypt/boulder/trees/cosigned"
"github.com/letsencrypt/boulder/trees/entry"
Expand Down Expand Up @@ -273,11 +275,23 @@ func (e *errorS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optF
// in for the daemon, so sequencing can proceed.
func mirrorCosign(t *testing.T, m *mtca) {
t.Helper()
caPub, ok := m.issuer.Signer.Public().(*mldsa.PublicKey)
if !ok {
t.Fatalf("issuer public key is %T, must be ML-DSA-44", m.issuer.Signer.Public())
}
key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), make([]byte, 32))
if err != nil {
t.Fatalf("NewPrivateKey: %s", err)
}
p, err := mtpublisher.New(m.db, time.Second, m.logID, "32473.9", privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock())
mirror, err := mtpublishertest.NewTestMirror("32473.9", m.logID.Origin(), privatekey.NewDeterministicSigner(key))
if err != nil {
t.Fatalf("mtpublishertest.NewTestMirror: %s", err)
}
dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms)
if err != nil {
t.Fatalf("opening mtcmeta dbMap: %s", err)
}
p, err := mtpublisher.New(dbMap, time.Second, m.logID, caPub, mirror, blog.NewMock())
if err != nil {
t.Fatalf("mtpublisher.New: %s", err)
}
Expand Down
263 changes: 263 additions & 0 deletions mtpublisher/mirror.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
//go:build go1.27

package mtpublisher

import (
"bytes"
"compress/gzip"
"context"
"crypto/mldsa"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"golang.org/x/mod/sumdb/tlog"

"github.com/letsencrypt/boulder/trees/checkpoint"
"github.com/letsencrypt/boulder/trees/cosignature"
"github.com/letsencrypt/boulder/trees/mirror"
)

// maxMirrorResponseSize caps how much of a mirror's response body the client
// reads. The largest expected body is a few ML-DSA-44 signature lines of under
// 4KB each.
const maxMirrorResponseSize = 64 << 10

var _ Mirror = (*MirrorClient)(nil)

// MirrorClient is a Mirror that uses the c2sp.org/tlog-mirror submission
// protocol, submitting the checkpoint to add-checkpoint and uploading the log's
// entries to add-entries until the mirror cosigns.
type MirrorClient struct {
submissionPrefix string
client *http.Client
src *Source
mirrorID string
verifier *cosignature.Verifier

// oldSize is the tree size of the mirror's latest cosigned checkpoint, the
// old size of the next add-checkpoint request.
oldSize int64
// nextEntry is the next entry the mirror expects to receive.
nextEntry int64
// ticket is the opaque value from the mirror's last mirror-info response,
// to be sent back in the next add-entries request.
ticket []byte
// lastSigned is when Cosign last succeeded.
lastSigned time.Time
}

// NewMirrorClient returns a MirrorClient that submits to the mirror's endpoints
// under baseURL.
func NewMirrorClient(baseURL string, src *Source, mirrorID string, mirrorPublicKey *mldsa.PublicKey) (*MirrorClient, error) {
if baseURL == "" {
return nil, errors.New("empty mirror base URL")
}
verifier, err := cosignature.NewVerifier(mirrorID, mirrorPublicKey)
if err != nil {
return nil, fmt.Errorf("creating mirror verifier: %s", err)
}
return &MirrorClient{
submissionPrefix: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
src: src,
mirrorID: mirrorID,
verifier: verifier,
}, nil
}

// ID returns the mirror's cosigner ID.
func (m *MirrorClient) ID() string {
return m.mirrorID
}

// post sends body to the endpoint at path and returns the response status and
// body. If compress is true, the body is gzip compressed.
func (m *MirrorClient) post(ctx context.Context, path, contentType string, compress bool, body []byte) (int, []byte, error) {
if compress {
var compressed bytes.Buffer
zw := gzip.NewWriter(&compressed)
_, err := zw.Write(body)
if err != nil {
return 0, nil, err
}
err = zw.Close()
if err != nil {
return 0, nil, err
}
body = compressed.Bytes()
}
endpoint, err := url.JoinPath(m.submissionPrefix, path)
if err != nil {
return 0, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
if compress {
req.Header.Set("Content-Encoding", "gzip")
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := m.client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()

respBody, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxMirrorResponseSize))
if err != nil {
return 0, nil, fmt.Errorf("reading mirror response: %s", err)
}
return resp.StatusCode, respBody, nil
}

// addCheckpoint submits the log's signed checkpoint note with a consistency
// proof from the mirror's last known size, updating the mirror's pending
// checkpoint. On a "409 Conflict" it adopts the size the mirror advertises and
// retries once.
func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signedNote []byte) error {
oldSize := m.oldSize
retried := false
for {
if oldSize > tree.N {
return fmt.Errorf("mirror already holds size %d, checkpoint size is %d", oldSize, tree.N)
}
var proof []tlog.Hash
if oldSize > 0 && oldSize < tree.N {
treeProof, err := m.src.consistencyProof(ctx, tree, oldSize)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means we post with an empty consistency proof for two cases:

  1. empty issuance log.
  2. pushing a checkpoint at the same size the mirror already has.

For (1) - I think we'll never have that, right? We always initialize our trees with a null_entry and sign a tree size of 1.

For (2): I had to think about this a bit, but I think it makes sense. It's possible for all mirrors to be fully up-to-date with our latest checkpoint, but have no MirrorSignature in the database, for instance if we previously sync'ed those mirrors but failed to write to the database. Of course we don't want to wedge in such a case, so we push the current checkpoint even though it's "old news", then send an empty add-entries request, and get the mirror's note signature line in the response.

Perhaps that's worth a comment in the addCheckpoint and Cosign doccomments? For instance "Makes requests even when the mirror is up-to-date, in order to get a mirror cosignature."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also possible for a mirror to become unexpectedly up-to-date because someone else is mirroring faster than we are.

if err != nil {
return fmt.Errorf("proving consistency from size %d: %s", oldSize, err)
}
proof = treeProof
}
body, err := mirror.AddCheckpointRequest(oldSize, proof, signedNote)
if err != nil {
return err
}
status, respBody, err := m.post(ctx, "/add-checkpoint", "", false, body)
if err != nil {
return err
}
switch status {
case http.StatusOK:
m.oldSize = tree.N
return nil
case http.StatusConflict:
if retried {
return errors.New("mirror rejected the old size twice")
}
retried = true
oldSize, err = mirror.ParseSizeResponse(respBody)
Comment on lines +153 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move the error return below ParseSizeResponse so we can include the parsed size in the error message. E.g.:

return errors.New("add-checkpoint at tree size %d got 409 with mirror tree size %d (after retry)",
   ...)

if err != nil {
return err
}
default:
return fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's trim the size of the response body even more aggressively than maxMirrorResponseSize. I think we probably don't want more than, say, 400 bytes of the response for debugging purposes.

}
}
}

// maxAddEntriesRequests bounds one Cosign call's add-entries requests, each of
// up to MaxPackagesPerRequest entry packages, so an upload terminates against a
// mirror that never makes progress.
const maxAddEntriesRequests = 100

// addEntries uploads the entries the mirror is missing, up to the tree size,
// and returns the cosignature lines from the mirror's "200 Success" response.
// On "202 Accepted" and "409 Conflict" it resumes from the next entry and
// ticket the mirror advertises.
func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog.Tree) ([]byte, error) {
start := min(m.nextEntry, tree.N)
ticket := m.ticket
for range maxAddEntriesRequests {
packages, err := mirror.Packages(start, tree.N, mirror.MaxPackagesPerRequest)
if err != nil {
return nil, err
}
var bodies [][]byte
for _, p := range packages {
body, err := m.src.entryPackage(ctx, tree, p)
if err != nil {
return nil, err
}
bodies = append(bodies, body)
}
reqBody, err := mirror.AddEntriesRequest(origin, start, tree.N, ticket, bodies)
if err != nil {
return nil, err
}
status, respBody, err := m.post(ctx, "/add-entries", "application/octet-stream", true, reqBody)
if err != nil {
return nil, err
}
switch status {
case http.StatusOK:
m.nextEntry = tree.N
m.ticket = nil
return respBody, nil

case http.StatusAccepted, http.StatusConflict:
info, err := mirror.ParseMirrorInfo(respBody)
if err != nil {
return nil, err
}
if info.TreeSize != tree.N {
return nil, fmt.Errorf("mirror wants upload_end %d, checkpoint size is %d", info.TreeSize, tree.N)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/C2SP/C2SP/blob/main/tlog-mirror.md#add-entries

When sending a "409 Conflict" or "202 Accepted" response, the response body MUST have a Content-Type of text/x.tlog.mirror-info and consist of three lines, each followed by a newline (U+000A):

The tree size of a valid pending checkpoint, in decimal
...
If the client's upload_end value was valid, the first line SHOULD contain upload_end. This allows the client to resume an interrupted upload without recomputing subtree consistency proofs. Otherwise, the first line SHOULD be the tree size of the current pending checkpoint.

After receiving a "409 Conflict" or "202 Accepted" response, the client SHOULD retry setting upload_end to the tree size, upload_start to the advertised next entry value, and the ticket to the received ticket.

The spec wants us to retry with an updated upload_end.

However, if we were to do that, the eventual "200 Success" response would give us note signature lines over a checkpoint at the wrong tree size (the new upload_end), not the one we asked for.

Since we're violating a SHOULD, let's comment. E.g. "Don't set upload_start to the tree size from the response, because we need upload_end to be the tree size of the checkpoint we are seeking a cosignature for. A cosignature on a different tree size won't do."

But I think we don't have to error out in this case, and probably shouldn't. The log's obligations with regards to this value are also only "SHOULD."

Perhaps in a future revision we want to implement "retry with updated upload_end to try to advance the log, but then return an error instead of a cosignature."

}
start = info.NextEntry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do some bounds checking on the NextEntry the mirror sent us. It should not be greater than or equal to the issuance log's tree size. It should not be less than zero.

ticket = info.Ticket
m.nextEntry = info.NextEntry
m.ticket = bytes.Clone(info.Ticket)

default:
return nil, fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody)))
}
}
return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests)
}

// Cosign runs the c2sp.org/tlog-mirror submission protocol for the checkpoint
// and returns the mirror's raw cosignature, verified against the mirror's key.
func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNoteForMirror []byte) ([]byte, error) {
// Submit the checkpoint to the mirror.
err := m.addCheckpoint(ctx, cp.Tree, signedNoteForMirror)
if err != nil {
return nil, fmt.Errorf("add-checkpoint: %w", err)
}

// Upload the checkpoint's entries to the mirror until it cosigns.
mirrorCosignatureLines, err := m.addEntries(ctx, cp.Origin, cp.Tree)
if err != nil {
return nil, fmt.Errorf("add-entries: %w", err)
}

// Verify the mirror's cosignature.
noteText, err := cp.Marshal()
if err != nil {
return nil, fmt.Errorf("marshaling the checkpoint: %w", err)
}
timestampedMirrorCosignature, err := cosignature.TimestampedSignature(noteText, mirrorCosignatureLines, m.verifier)
if err != nil {
return nil, fmt.Errorf("cosignature failed verification: %w", err)
}

// Finally, extract the raw cosignature we store in the database.
rawMirrorCosignature, err := cosignature.RawSignature(timestampedMirrorCosignature)
if err != nil {
return nil, fmt.Errorf("cosignature: %w", err)
}
m.lastSigned = time.Now()
return rawMirrorCosignature, nil
}

// LastSigned returns when Cosign last succeeded, zero before it has.
func (m *MirrorClient) LastSigned() time.Time {
return m.lastSigned
}
Loading
Loading