From 3358d01e537622ac6097ab26e573ef70c67c1788 Mon Sep 17 00:00:00 2001 From: Samantha Date: Fri, 21 Aug 2026 15:50:17 -0400 Subject: [PATCH 01/13] trees: Updates for mtpublisher HTTP client and checkpoints in tlog --- mtpublisher/mtpublisher.go | 2 +- mtpublisher/mtpublisher_test.go | 2 +- trees/checkpoint/checkpoint.go | 35 ++++++++++++++++ trees/checkpoint/checkpoint_test.go | 42 +++++++++++++++++++ trees/cosignature/cosignature.go | 18 +++++++- trees/cosignature/cosignature_test.go | 59 +++++++++++++++++++++++++-- trees/mirror/mirror.go | 15 +++---- trees/mirror/mirror_test.go | 18 +++----- trees/tiles/tiles.go | 21 ++++++++++ trees/tiles/tiles_test.go | 30 ++++++++++++++ 10 files changed, 213 insertions(+), 29 deletions(-) diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 3d3fa41bab2..4adfa6f506c 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -132,7 +132,7 @@ func (p *publisher) Publish(ctx context.Context) error { if err != nil { return fmt.Errorf("marshaling checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } - timestampedMirrorCosig, err := cosignature.TimestampedSignature(text, cosigLine, p.verifier) + timestampedMirrorCosig, err := cosignature.TimestampedSignature(text, []byte(cosigLine), p.verifier) if err != nil { return fmt.Errorf("checkpoint %d cosignature failed verification before storage: %w", latest.ID, err) } diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index 0d04074bf96..21997743223 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -113,7 +113,7 @@ func TestCosign(t *testing.T) { t.Fatalf("NewVerifier: %s", err) } text := p.origin + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" - timestampedSignature, err := cosignature.TimestampedSignature([]byte(text), line, verifier) + timestampedSignature, err := cosignature.TimestampedSignature([]byte(text), []byte(line), verifier) if err != nil { t.Fatalf("TimestampedSignature: %s", err) } diff --git a/trees/checkpoint/checkpoint.go b/trees/checkpoint/checkpoint.go index c9c18b5761d..04118d0baa9 100644 --- a/trees/checkpoint/checkpoint.go +++ b/trees/checkpoint/checkpoint.go @@ -98,6 +98,41 @@ func (c *Checkpoint) Marshal() ([]byte, error) { return noteText, nil } +// signedNote returns the checkpoint as a signed note: the note text followed by +// the given signature line(s). +func (c *Checkpoint) signedNote(signatureLines ...[]byte) ([]byte, error) { + text, err := c.Marshal() + if err != nil { + return nil, err + } + assembled := append(text, '\n') + for _, line := range signatureLines { + assembled = append(assembled, line...) + } + return assembled, nil +} + +// SignedNoteForMirror returns the checkpoint as a signed note carrying the MTCA +// cosignature line, for submission to a mirror. +func (c *Checkpoint) SignedNoteForMirror(caCosignatureLine []byte) ([]byte, error) { + if len(caCosignatureLine) == 0 { + return nil, errors.New("missing MTCA cosignature line") + } + return c.signedNote(caCosignatureLine) +} + +// SignedNoteForServing returns the checkpoint as a signed note carrying the +// MTCA and mirror cosignature lines, for serving at the checkpoint path. +func (c *Checkpoint) SignedNoteForServing(caCosignatureLine, mirrorCosignatureLine []byte) ([]byte, error) { + if len(caCosignatureLine) == 0 { + return nil, errors.New("missing MTCA cosignature line") + } + if len(mirrorCosignatureLine) == 0 { + return nil, errors.New("missing mirror cosignature line") + } + return c.signedNote(caCosignatureLine, mirrorCosignatureLine) +} + // Unmarshal parses a checkpoint note text. The text must not have any signature // lines. For a signed note, use Open. // diff --git a/trees/checkpoint/checkpoint_test.go b/trees/checkpoint/checkpoint_test.go index 92a814c9cfb..db83dbac157 100644 --- a/trees/checkpoint/checkpoint_test.go +++ b/trees/checkpoint/checkpoint_test.go @@ -6,6 +6,7 @@ import ( "testing" "golang.org/x/mod/sumdb/note" + "golang.org/x/mod/sumdb/tlog" ) // exampleCheckpoint is a canonical tlog-checkpoint note body the cosignature @@ -158,6 +159,47 @@ func TestCheckpointMarshal(t *testing.T) { } } +// TestSignedNotes checks note assembly through both exported wrappers and +// that each rejects a missing signature line. +func TestSignedNotes(t *testing.T) { + cp := &Checkpoint{Origin: "example.com/log", Tree: tlog.Tree{N: 5}} + text, err := cp.Marshal() + if err != nil { + t.Fatalf("Marshal: %s", err) + } + caLine := []byte("— ca sig\n") + mirrorLine := []byte("— mirror sig\n") + + forMirror, err := cp.SignedNoteForMirror(caLine) + if err != nil { + t.Fatalf("SignedNoteForMirror: %s", err) + } + if string(forMirror) != string(text)+"\n"+string(caLine) { + t.Errorf("SignedNoteForMirror = %q", forMirror) + } + + forServing, err := cp.SignedNoteForServing(caLine, mirrorLine) + if err != nil { + t.Fatalf("SignedNoteForServing: %s", err) + } + if string(forServing) != string(text)+"\n"+string(caLine)+string(mirrorLine) { + t.Errorf("SignedNoteForServing = %q", forServing) + } + + _, err = cp.SignedNoteForMirror(nil) + if err == nil { + t.Error("SignedNoteForMirror without a line = nil error, want error") + } + _, err = cp.SignedNoteForServing(nil, mirrorLine) + if err == nil { + t.Error("SignedNoteForServing without the MTCA line = nil error, want error") + } + _, err = cp.SignedNoteForServing(caLine, nil) + if err == nil { + t.Error("SignedNoteForServing without the mirror line = nil error, want error") + } +} + // TestOpenCheckpoint covers Open's happy path and its rejection of a note no // trusted key signed, using a generic note signer. func TestOpenCheckpoint(t *testing.T) { diff --git a/trees/cosignature/cosignature.go b/trees/cosignature/cosignature.go index 1ba109f14dc..f3529880398 100644 --- a/trees/cosignature/cosignature.go +++ b/trees/cosignature/cosignature.go @@ -253,7 +253,7 @@ func (v *Verifier) Verify(noteText, signature []byte) bool { // and returns the timestamped_signature by verifier's cosigner. An error is // returned if noteText and signatureLine do not form a well-formed note or if // verifier rejects the signature. Signatures from unknown keys are ignored. -func TimestampedSignature(noteText []byte, signatureLine string, verifier *Verifier) ([]byte, error) { +func TimestampedSignature(noteText, signatureLine []byte, verifier *Verifier) ([]byte, error) { n, err := note.Open(fmt.Appendf(nil, "%s\n%s", noteText, signatureLine), note.VerifierList(verifier)) if err != nil { return nil, fmt.Errorf("opening the cosigned note: %s", err) @@ -282,3 +282,19 @@ func RawSignature(timestampedSignature []byte) ([]byte, error) { } return timestampedSignature[timestampSize:], nil } + +// SignatureLine verifies rawSignature over the checkpoint described by origin +// and tree, and reassembles the cosigner's note signature line, restoring the +// zero timestamp RawSignature stripped. +func (v *Verifier) SignatureLine(origin string, tree tlog.Tree, rawSignature []byte) ([]byte, error) { + if len(rawSignature) != mldsa.MLDSA44SignatureSize { + return nil, fmt.Errorf("raw signature is %d bytes, want %d", len(rawSignature), mldsa.MLDSA44SignatureSize) + } + timestamped := make([]byte, timestampedSignatureSize) + copy(timestamped[timestampSize:], rawSignature) + err := v.VerifyCheckpoint(origin, tree, timestamped) + if err != nil { + return nil, err + } + return []byte(signatureLineFor(v.name, v.keyID, timestamped)), nil +} diff --git a/trees/cosignature/cosignature_test.go b/trees/cosignature/cosignature_test.go index a53046a448d..5d8b1d277ce 100644 --- a/trees/cosignature/cosignature_test.go +++ b/trees/cosignature/cosignature_test.go @@ -264,7 +264,7 @@ func TestCosignerRoundTrip(t *testing.T) { t.Errorf("line %q has unexpected prefix", line) } - extracted, err := TimestampedSignature([]byte(text), line, v) + extracted, err := TimestampedSignature([]byte(text), []byte(line), v) if err != nil { t.Fatalf("TimestampedSignature on a reassembled note: %s", err) } @@ -429,7 +429,7 @@ func TestTimestampedSignature(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - timestampedSignature, err := TimestampedSignature([]byte(text), line, v) + timestampedSignature, err := TimestampedSignature([]byte(text), []byte(line), v) if err != nil { t.Fatalf("TimestampedSignature for the cosigner that signed the note: %s", err) } @@ -441,7 +441,7 @@ func TestTimestampedSignature(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - _, err = TimestampedSignature([]byte(text), line, other) + _, err = TimestampedSignature([]byte(text), []byte(line), other) if err == nil { t.Error("TimestampedSignature for a cosigner that did not sign the note = nil error, want error") } @@ -456,7 +456,7 @@ func TestTimestampedSignatureRejectsForeignFormat(t *testing.T) { idSignature := make([]byte, keyIDSize+64) binary.BigEndian.PutUint32(idSignature[:keyIDSize], v.KeyHash()) line := noteSignatureLinePrefix + v.Name() + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n" - _, err := TimestampedSignature([]byte(exampleCheckpoint), line, v) + _, err := TimestampedSignature([]byte(exampleCheckpoint), []byte(line), v) if err == nil { t.Error("TimestampedSignature with a 64-byte signature body = nil error, want error") } @@ -519,3 +519,54 @@ func TestOpenIgnoresUnknownSignatures(t *testing.T) { t.Errorf("UnverifiedSigs = %+v, want the unknown cosigner's", n.UnverifiedSigs) } } + +// TestSignatureLineRoundTrip checks that a raw signature extracted with +// RawSignature reassembles into a verified signature line, and that +// reassembly rejects short signatures and checkpoints the signature does not +// cover. +func TestSignatureLineRoundTrip(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) + } + timestamped, err := ca.CosignCheckpoint(parsed.Tree) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + raw, err := RawSignature(timestamped) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + + v, err := NewVerifier("32473.2", testPubKey(t)) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + line, err := v.SignatureLine(ca.Origin(), parsed.Tree, raw) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + roundTripped, err := TimestampedSignature([]byte(text), line, v) + if err != nil { + t.Fatalf("TimestampedSignature rejected the reassembled line: %s", err) + } + if !bytes.Equal(roundTripped, timestamped) { + t.Errorf("round-tripped signature = %x, want %x", roundTripped, timestamped) + } + + _, err = v.SignatureLine(ca.Origin(), parsed.Tree, raw[1:]) + if err == nil { + t.Error("SignatureLine with a short signature = nil error, want error") + } + + tampered := tlog.Tree{N: parsed.Tree.N + 1, Hash: parsed.Tree.Hash} + _, err = v.SignatureLine(ca.Origin(), tampered, raw) + if err == nil { + t.Error("SignatureLine over a different tree = nil error, want error") + } +} diff --git a/trees/mirror/mirror.go b/trees/mirror/mirror.go index d8ceabce109..f70900ef0fc 100644 --- a/trees/mirror/mirror.go +++ b/trees/mirror/mirror.go @@ -159,8 +159,10 @@ func Packages(uploadStart, uploadEnd, maxPackages int64) ([]Package, error) { return packages, nil } -// EntryPackage builds the wire form of one entry package. -func EntryPackage(entries [][]byte, proof []tlog.Hash) ([]byte, error) { +// EntryPackage builds the wire form of one entry package. entries must be the +// package's entries already in wire form as tiles.EntriesForPackage provides +// them, each entry with a big-endian uint16 length prefix. +func EntryPackage(entries []byte, proof []tlog.Hash) ([]byte, error) { if len(entries) == 0 { return nil, errors.New("entry package with no entries") } @@ -168,14 +170,7 @@ func EntryPackage(entries [][]byte, proof []tlog.Hash) ([]byte, error) { return nil, fmt.Errorf("entry package has %d proof hashes, want at most %d", len(proof), maxPackageProofHashes) } var b cryptobyte.Builder - for _, entry := range entries { - if len(entry) > 0xFFFF { - return nil, fmt.Errorf("entry is %d bytes, want at most %d", len(entry), 0xFFFF) - } - b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { - child.AddBytes(entry) - }) - } + b.AddBytes(entries) b.AddUint8(uint8(len(proof))) //nolint:gosec // G115: the check above rejects proofs over maxPackageProofHashes hashes. for _, h := range proof { b.AddBytes(h[:]) diff --git a/trees/mirror/mirror_test.go b/trees/mirror/mirror_test.go index 0241c3898d6..4130d85ae32 100644 --- a/trees/mirror/mirror_test.go +++ b/trees/mirror/mirror_test.go @@ -171,17 +171,15 @@ func TestPackages(t *testing.T) { func TestEntryPackage(t *testing.T) { proof := []tlog.Hash{mustHash(t, "PlRNCrwHpqhGrupue0L7gxbjbMiKA9temvuZZDDpkaw=")} - body, err := EntryPackage([][]byte{[]byte("abc"), []byte("de")}, proof) + // EntryPackage carries the entries opaquely, so any bytes exercise it. + entries := []byte("wire form entries") + + body, err := EntryPackage(entries, proof) if err != nil { t.Fatalf("EntryPackage: %s", err) } var expect cryptobyte.Builder - expect.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { - child.AddBytes([]byte("abc")) - }) - expect.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { - child.AddBytes([]byte("de")) - }) + expect.AddBytes(entries) expect.AddUint8(1) expect.AddBytes(proof[0][:]) if !bytes.Equal(body, expect.BytesOrPanic()) { @@ -192,11 +190,7 @@ func TestEntryPackage(t *testing.T) { if err == nil { t.Error("EntryPackage with no entries = nil error, want error") } - _, err = EntryPackage([][]byte{make([]byte, 0x10000)}, proof) - if err == nil { - t.Error("EntryPackage with an oversize entry = nil error, want error") - } - _, err = EntryPackage([][]byte{[]byte("abc")}, make([]tlog.Hash, 64)) + _, err = EntryPackage(entries, make([]tlog.Hash, 64)) if err == nil { t.Error("EntryPackage with 64 proof hashes = nil error, want error") } diff --git a/trees/tiles/tiles.go b/trees/tiles/tiles.go index af5c03d5b79..038014f0043 100644 --- a/trees/tiles/tiles.go +++ b/trees/tiles/tiles.go @@ -846,3 +846,24 @@ func EntriesForPackage(ctx context.Context, s3c simpleS3Reader, start, end, tree } return body[entriesBegin : len(body)-len(rest)], nil } + +// WriteCheckpoint stores the log's signed checkpoint note at the "checkpoint" +// key under prefix per c2sp.org/tlog-tiles, overwriting the previous one. The +// note must be written only after the tiles its tree covers are published. +func WriteCheckpoint(ctx context.Context, s3c simpleS3, prefix string, signedNote []byte) error { + key := path.Join(prefix, "checkpoint") + contentType := "text/plain; charset=utf-8" + cacheControl := "no-store" + bucket := s3c.Bucket() + _, err := s3c.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &key, + ContentType: &contentType, + CacheControl: &cacheControl, + Body: bytes.NewReader(signedNote), + }) + if err != nil { + return fmt.Errorf("writing s3://%s/%s: %w", bucket, key, err) + } + return nil +} diff --git a/trees/tiles/tiles_test.go b/trees/tiles/tiles_test.go index a5ca253eee2..191267ad312 100644 --- a/trees/tiles/tiles_test.go +++ b/trees/tiles/tiles_test.go @@ -620,6 +620,36 @@ func TestTileReaderTreeHash(t *testing.T) { } } +// readCheckpoint fetches the "checkpoint" key under prefix, checking what +// WriteCheckpoint stored. Nothing outside tests reads it back through this +// package. +func readCheckpoint(t *testing.T, s3c *bs3test.FakeS3, prefix string) []byte { + t.Helper() + obj, ok := s3c.Objects[prefix+"/checkpoint"] + if !ok { + t.Fatalf("no checkpoint stored under %q", prefix) + } + return obj.Data +} + +// TestWriteReadCheckpoint checks the checkpoint note round trip and that a +// newer note overwrites the previous one. +func TestWriteReadCheckpoint(t *testing.T) { + fs3 := bs3test.New() + err := WriteCheckpoint(t.Context(), fs3, testPrefix, []byte("first note\n")) + if err != nil { + t.Fatalf("WriteCheckpoint: %s", err) + } + err = WriteCheckpoint(t.Context(), fs3, testPrefix, []byte("second note\n")) + if err != nil { + t.Fatalf("WriteCheckpoint overwriting: %s", err) + } + got := readCheckpoint(t, fs3, testPrefix) + if string(got) != "second note\n" { + t.Errorf("stored checkpoint = %q, want %q", got, "second note\n") + } +} + // TestEntriesForPackage checks reading entry intervals back from stored // bundles in wire form, from both full and partial bundles, and that invalid // and bundle-spanning intervals are rejected. From aa2c65ef669ca6abd632c7c601870190d0133759 Mon Sep 17 00:00:00 2001 From: Samantha Date: Tue, 1 Sep 2026 17:18:41 -0400 Subject: [PATCH 02/13] Addressing comments and adjusting for future CQRP changes --- trees/checkpoint/checkpoint.go | 33 ++++++-- trees/checkpoint/checkpoint_test.go | 67 +++++++++++++++- trees/cosignature/cosignature.go | 83 +++++++++++++------- trees/cosignature/cosignature_test.go | 105 +++++++++++++++----------- 4 files changed, 207 insertions(+), 81 deletions(-) diff --git a/trees/checkpoint/checkpoint.go b/trees/checkpoint/checkpoint.go index 04118d0baa9..f6100ed23a1 100644 --- a/trees/checkpoint/checkpoint.go +++ b/trees/checkpoint/checkpoint.go @@ -98,6 +98,16 @@ func (c *Checkpoint) Marshal() ([]byte, error) { return noteText, nil } +// SignedNote returns the checkpoint as a signed note: the note text followed +// by signatureLines. +func (c *Checkpoint) SignedNote(signatureLines []byte) ([]byte, error) { + text, err := c.Marshal() + if err != nil { + return nil, err + } + return append(append(text, '\n'), signatureLines...), nil +} + // signedNote returns the checkpoint as a signed note: the note text followed by // the given signature line(s). func (c *Checkpoint) signedNote(signatureLines ...[]byte) ([]byte, error) { @@ -181,18 +191,29 @@ func Unmarshal(noteText []byte) (*Checkpoint, error) { } // Open opens a signed checkpoint note and parses its text. An error is returned -// if signedNote is not a well-formed note, if any of the verifiers rejects a -// signature (note.InvalidSignatureError), if none of the verifiers has signed -// the note (note.UnverifiedNoteError), or if the note's text is not a -// well-formed checkpoint. Signatures from unknown keys are ignored. +// if signedNote is not a well-formed note, if its signature lines are not +// exactly one verified signature per verifier and nothing else +// (note.InvalidSignatureError for a rejected one), or if the note's text is not +// a well-formed checkpoint. // // - https://c2sp.org/tlog-checkpoint // - https://c2sp.org/signed-note -func Open(signedNote []byte, verifiers note.Verifiers) (*Checkpoint, *note.Note, error) { - n, err := note.Open(signedNote, verifiers) +func Open(signedNote []byte, verifiers ...note.Verifier) (*Checkpoint, *note.Note, error) { + n, err := note.Open(signedNote, note.VerifierList(verifiers...)) if err != nil { return nil, nil, err } + // n.Sigs holds one verified signature per signing key in verifiers, so its + // length is the number of verifiers whose key signed the note. + if len(n.Sigs) != len(verifiers) { + return nil, nil, fmt.Errorf("%d of %d verifiers signed the note", len(n.Sigs), len(verifiers)) + } + // note.Open ignores signatures from unknown keys and repeated signatures + // from known ones, so the lines themselves are counted as well. + signatureLines := bytes.Count(signedNote[len(n.Text)+1:], []byte("\n")) + if signatureLines != len(verifiers) { + return nil, nil, fmt.Errorf("note has %d signature lines, want %d", signatureLines, len(verifiers)) + } c, err := Unmarshal([]byte(n.Text)) if err != nil { return nil, nil, err diff --git a/trees/checkpoint/checkpoint_test.go b/trees/checkpoint/checkpoint_test.go index db83dbac157..bba62e81c52 100644 --- a/trees/checkpoint/checkpoint_test.go +++ b/trees/checkpoint/checkpoint_test.go @@ -159,6 +159,25 @@ func TestCheckpointMarshal(t *testing.T) { } } +// TestSignedNote checks that the signature lines follow the note text after a +// blank line. +func TestSignedNote(t *testing.T) { + cp := &Checkpoint{Origin: "example.com/log", Tree: tlog.Tree{N: 5}} + text, err := cp.Marshal() + if err != nil { + t.Fatalf("Marshal: %s", err) + } + lines := []byte("— ca sig\n— mirror sig\n") + + signed, err := cp.SignedNote(lines) + if err != nil { + t.Fatalf("SignedNote: %s", err) + } + if string(signed) != string(text)+"\n"+string(lines) { + t.Errorf("SignedNote = %q", signed) + } +} + // TestSignedNotes checks note assembly through both exported wrappers and // that each rejects a missing signature line. func TestSignedNotes(t *testing.T) { @@ -221,7 +240,7 @@ func TestOpenCheckpoint(t *testing.T) { } t.Run("Valid", func(t *testing.T) { - cp, n, err := Open(signed, note.VerifierList(verifier)) + cp, n, err := Open(signed, verifier) if err != nil { t.Fatalf("Open: %s", err) } @@ -236,6 +255,48 @@ func TestOpenCheckpoint(t *testing.T) { } }) + t.Run("Missing a verifier", func(t *testing.T) { + _, otherVkey, err := note.GenerateKey(rand.Reader, "example.com/other") + if err != nil { + t.Fatalf("GenerateKey: %s", err) + } + otherV, err := note.NewVerifier(otherVkey) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + _, _, err = Open(signed, verifier, otherV) + if err == nil { + t.Error("Open with a verifier that did not sign = nil error, want error") + } + }) + + t.Run("Extra signature from an unknown key", func(t *testing.T) { + otherSkey, _, err := note.GenerateKey(rand.Reader, "example.com/other") + if err != nil { + t.Fatalf("GenerateKey: %s", err) + } + otherSigner, err := note.NewSigner(otherSkey) + if err != nil { + t.Fatalf("NewSigner: %s", err) + } + twiceSigned, err := note.Sign(¬e.Note{Text: exampleCheckpoint}, signer, otherSigner) + if err != nil { + t.Fatalf("note.Sign: %s", err) + } + _, _, err = Open(twiceSigned, verifier) + if err == nil { + t.Error("Open with an extra signature from an unknown key = nil error, want error") + } + }) + + t.Run("Repeated signature", func(t *testing.T) { + repeated := append(slices.Clone(signed), signed[len(exampleCheckpoint)+1:]...) + _, _, err := Open(repeated, verifier) + if err == nil { + t.Error("Open with a repeated signature = nil error, want error") + } + }) + t.Run("Wrong key", func(t *testing.T) { _, otherVkey, err := note.GenerateKey(rand.Reader, "example.com/behind-the-sofa") if err != nil { @@ -245,7 +306,7 @@ func TestOpenCheckpoint(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - _, _, err = Open(signed, note.VerifierList(otherV)) + _, _, err = Open(signed, otherV) if err == nil { t.Error("Open with wrong key = nil error, want error") } @@ -271,7 +332,7 @@ func TestOpenRejectsNonCheckpointBody(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - _, _, err = Open(signed, note.VerifierList(verifier)) + _, _, err = Open(signed, verifier) if err == nil { t.Error("Open of a verified non-checkpoint note = nil error, want error") } diff --git a/trees/cosignature/cosignature.go b/trees/cosignature/cosignature.go index f3529880398..897a05ec8e4 100644 --- a/trees/cosignature/cosignature.go +++ b/trees/cosignature/cosignature.go @@ -72,15 +72,6 @@ func marshalCheckpointMessage(name string, timestamp uint64, origin string, end return cosignedMessage.Marshal() } -// signatureLineFor assembles the signature line "— base64(keyID || -// timestamped_signature)\n". -func signatureLineFor(name string, keyID uint32, timestampedSignature []byte) string { - idSignature := make([]byte, keyIDSize+len(timestampedSignature)) - binary.BigEndian.PutUint32(idSignature[:keyIDSize], keyID) - copy(idSignature[keyIDSize:], timestampedSignature) - return noteSignatureLinePrefix + name + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n" -} - // checkRelativeOID returns an error if id is not a dotted decimal OID like // "32473.2", nil otherwise. func checkRelativeOID(id string) error { @@ -176,7 +167,7 @@ func (c *Cosigner) CosignCheckpoint(tree tlog.Tree) ([]byte, error) { // - https://c2sp.org/tlog-cosignature // - https://c2sp.org/mtc-tlog type Verifier struct { - name string + keyName string keyID uint32 publicKey *mldsa.PublicKey } @@ -195,7 +186,7 @@ func NewVerifier(cosignerID string, publicKey *mldsa.PublicKey) (*Verifier, erro return nil, errors.New("public key must be ML-DSA-44") } return &Verifier{ - name: oidPrefix + cosignerID, + keyName: oidPrefix + cosignerID, keyID: keyIDFor(oidPrefix+cosignerID, publicKey), publicKey: publicKey, }, nil @@ -203,7 +194,7 @@ func NewVerifier(cosignerID string, publicKey *mldsa.PublicKey) (*Verifier, erro // Name satisfies note.Verifier. func (v *Verifier) Name() string { - return v.name + return v.keyName } // KeyHash satisfies note.Verifier. @@ -225,7 +216,7 @@ func (v *Verifier) VerifyCheckpoint(origin string, tree tlog.Tree, timestampedSi if timestamp > math.MaxInt64 { return fmt.Errorf("timestamp %d exceeds 2^63-1", timestamp) } - cosignedMessage, err := marshalCheckpointMessage(v.name, timestamp, origin, tree.N, tree.Hash) + cosignedMessage, err := marshalCheckpointMessage(v.keyName, timestamp, origin, tree.N, tree.Hash) if err != nil { return err } @@ -241,12 +232,37 @@ func (v *Verifier) VerifyCheckpoint(origin string, tree tlog.Tree, timestampedSi // and root hash from noteText, so extension lines do not affect the result. // Verify is the note.Verifier entry point. For an already parsed checkpoint, // use VerifyCheckpoint. -func (v *Verifier) Verify(noteText, signature []byte) bool { +func (v *Verifier) Verify(noteText, timestampedSignature []byte) bool { parsed, err := checkpoint.Unmarshal(noteText) if err != nil { return false } - return v.VerifyCheckpoint(parsed.Origin, parsed.Tree, signature) == nil + return v.VerifyCheckpoint(parsed.Origin, parsed.Tree, timestampedSignature) == nil +} + +// FilterByVerify returns the timestamped_signature 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) { + 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: %s", err) + } + idSignature, err := base64.StdEncoding.DecodeString(n.Sigs[0].Base64) + if err != nil { + return nil, fmt.Errorf("decoding the signature by %s: %s", v.keyName, err) + } + return idSignature[keyIDSize:], nil +} + +// signatureLineFor assembles the signature line "— base64(keyID || +// timestamped_signature)\n". +func signatureLineFor(name string, keyID uint32, timestampedSignature []byte) string { + idSignature := make([]byte, keyIDSize+len(timestampedSignature)) + binary.BigEndian.PutUint32(idSignature[:keyIDSize], keyID) + copy(idSignature[keyIDSize:], timestampedSignature) + return noteSignatureLinePrefix + name + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n" } // TimestampedSignature verifies signatureLine against noteText with verifier @@ -262,11 +278,27 @@ func TimestampedSignature(noteText, signatureLine []byte, verifier *Verifier) ([ // is by the verifier's cosigner, verified and length-checked. idSignature, err := base64.StdEncoding.DecodeString(n.Sigs[0].Base64) if err != nil { - return nil, fmt.Errorf("decoding the signature by %s: %s", verifier.name, err) + return nil, fmt.Errorf("decoding the signature by %s: %s", verifier.keyName, err) } return idSignature[keyIDSize:], nil } +// SignatureLine verifies rawSignature over the checkpoint described by origin +// and tree, and reassembles the cosigner's note signature line, restoring the +// zero timestamp RawSignature stripped. +func (v *Verifier) SignatureLine(origin string, tree tlog.Tree, rawSignature []byte) ([]byte, error) { + if len(rawSignature) != mldsa.MLDSA44SignatureSize { + return nil, fmt.Errorf("raw signature is %d bytes, want %d", len(rawSignature), mldsa.MLDSA44SignatureSize) + } + timestamped := make([]byte, timestampedSignatureSize) + copy(timestamped[timestampSize:], rawSignature) + err := v.VerifyCheckpoint(origin, tree, timestamped) + if err != nil { + return nil, err + } + return []byte(signatureLineFor(v.keyName, v.keyID, timestamped)), nil +} + // RawSignature returns the ML-DSA-44 signature from a timestamped_signature, // the form certificates embed. It errors if the input has the wrong length or a // non-zero timestamp, which certificates cannot carry. @@ -283,18 +315,15 @@ func RawSignature(timestampedSignature []byte) ([]byte, error) { return timestampedSignature[timestampSize:], nil } -// SignatureLine verifies rawSignature over the checkpoint described by origin -// and tree, and reassembles the cosigner's note signature line, restoring the -// zero timestamp RawSignature stripped. -func (v *Verifier) SignatureLine(origin string, tree tlog.Tree, rawSignature []byte) ([]byte, error) { +// SignatureLine assembles the note signature line of the cosigner with the +// given keyName, keyID, and rawSignature. Callers are responsible for ensuring +// the signature line is valid for any note text they append it to. +func SignatureLine(keyName string, keyID uint32, rawSignature []byte) ([]byte, error) { if len(rawSignature) != mldsa.MLDSA44SignatureSize { return nil, fmt.Errorf("raw signature is %d bytes, want %d", len(rawSignature), mldsa.MLDSA44SignatureSize) } - timestamped := make([]byte, timestampedSignatureSize) - copy(timestamped[timestampSize:], rawSignature) - err := v.VerifyCheckpoint(origin, tree, timestamped) - if err != nil { - return nil, err - } - return []byte(signatureLineFor(v.name, v.keyID, timestamped)), nil + idSignature := make([]byte, keyIDSize+timestampedSignatureSize) + binary.BigEndian.PutUint32(idSignature[:keyIDSize], keyID) + copy(idSignature[keyIDSize+timestampSize:], rawSignature) + return []byte(noteSignatureLinePrefix + keyName + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n"), nil } diff --git a/trees/cosignature/cosignature_test.go b/trees/cosignature/cosignature_test.go index 5d8b1d277ce..32ad7d10b86 100644 --- a/trees/cosignature/cosignature_test.go +++ b/trees/cosignature/cosignature_test.go @@ -16,7 +16,6 @@ import ( "strings" "testing" - "golang.org/x/mod/sumdb/note" "golang.org/x/mod/sumdb/tlog" "github.com/letsencrypt/boulder/privatekey" @@ -259,21 +258,27 @@ func TestCosignerRoundTrip(t *testing.T) { t.Error("VerifyCheckpoint accepted a cosignature over a different origin") } - line := signatureLineFor(ca.name, ca.keyID, signature) - if !strings.HasPrefix(line, noteSignatureLinePrefix+ca.name+" ") { + line, err := SignatureLine(ca.name, ca.keyID, signature[timestampSize:]) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + if !strings.HasPrefix(string(line), noteSignatureLinePrefix+ca.name+" ") { t.Errorf("line %q has unexpected prefix", line) } - extracted, err := TimestampedSignature([]byte(text), []byte(line), v) + extracted, err := v.FilterByVerify([]byte(text), line) if err != nil { - t.Fatalf("TimestampedSignature on a reassembled note: %s", err) + t.Fatalf("FilterByVerify on a reassembled note: %s", err) } if !v.Verify([]byte(text), extracted) { t.Error("Verify rejected an extracted cosignature") } - rebuilt := signatureLineFor(ca.name, ca.keyID, extracted) - if rebuilt != line { - t.Errorf("signatureLineFor = %q, want %q", rebuilt, line) + rebuilt, err := SignatureLine(ca.name, ca.keyID, extracted[timestampSize:]) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + if !bytes.Equal(rebuilt, line) { + t.Errorf("SignatureLine = %q, want %q", rebuilt, line) } } @@ -406,10 +411,10 @@ func TestRawSignature(t *testing.T) { } } -// TestTimestampedSignature checks that the extracted timestamped_signature +// TestFilterByVerify checks that the extracted timestamped_signature // verifies on its own, and that extraction errors for a verifier that did not // sign. -func TestTimestampedSignature(t *testing.T) { +func TestFilterByVerify(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) @@ -423,15 +428,18 @@ func TestTimestampedSignature(t *testing.T) { if err != nil { t.Fatalf("CosignCheckpoint: %s", err) } - line := signatureLineFor(ca.name, ca.keyID, cosigned) + line, err := SignatureLine(ca.name, ca.keyID, cosigned[timestampSize:]) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } v, err := NewVerifier("32473.2", testPubKey(t)) if err != nil { t.Fatalf("NewVerifier: %s", err) } - timestampedSignature, err := TimestampedSignature([]byte(text), []byte(line), v) + timestampedSignature, err := v.FilterByVerify([]byte(text), line) if err != nil { - t.Fatalf("TimestampedSignature for the cosigner that signed the note: %s", err) + 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") @@ -441,31 +449,33 @@ func TestTimestampedSignature(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - _, err = TimestampedSignature([]byte(text), []byte(line), other) + _, err = other.FilterByVerify([]byte(text), line) if err == nil { - t.Error("TimestampedSignature for a cosigner that did not sign the note = nil error, want error") + t.Error("FilterByVerify for a cosigner that did not sign the note = nil error, want error") } } -// TestTimestampedSignatureRejectsForeignFormat checks that a signature line +// 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 // the verifier's. -func TestTimestampedSignatureRejectsForeignFormat(t *testing.T) { +func TestFilterByVerifyRejectsForeignFormat(t *testing.T) { v := newVerifier(t) idSignature := make([]byte, keyIDSize+64) binary.BigEndian.PutUint32(idSignature[:keyIDSize], v.KeyHash()) line := noteSignatureLinePrefix + v.Name() + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n" - _, err := TimestampedSignature([]byte(exampleCheckpoint), []byte(line), v) + _, err := v.FilterByVerify([]byte(exampleCheckpoint), []byte(line)) if err == nil { - t.Error("TimestampedSignature with a 64-byte signature body = nil error, want error") + t.Error("FilterByVerify with a 64-byte signature body = nil error, want error") } } -// TestOpenIgnoresUnknownSignatures covers signed-note's "verifiers MUST ignore -// signatures from unknown keys" with a note cosigned for one log by two MTC -// cosigners and opened by one verifier, the shape of every real exchange. -func TestOpenIgnoresUnknownSignatures(t *testing.T) { +// TestFilterByVerifyIgnoresUnknownSignatures covers signed-note's "verifiers +// MUST ignore signatures from unknown keys" with a note cosigned for one log by +// two MTC cosigners and filtered by one verifier, the shape of every mirror +// exchange. checkpoint.Open, which only opens notes we assembled, rejects the +// same note. +func TestFilterByVerifyIgnoresUnknownSignatures(t *testing.T) { known, 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) @@ -493,37 +503,42 @@ func TestOpenIgnoresUnknownSignatures(t *testing.T) { if err != nil { t.Fatalf("CosignCheckpoint: %s", err) } - knownLine := signatureLineFor(known.name, known.keyID, knownSignature) + knownLine, err := SignatureLine(known.name, known.keyID, knownSignature[timestampSize:]) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } unknownSignature, err := unknown.CosignCheckpoint(parsed.Tree) if err != nil { t.Fatalf("CosignCheckpoint: %s", err) } - unknownLine := signatureLineFor(unknown.name, unknown.keyID, unknownSignature) - signed := []byte(text + "\n" + knownLine + unknownLine) + unknownLine, err := SignatureLine(unknown.name, unknown.keyID, unknownSignature[timestampSize:]) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + lines := append(knownLine, unknownLine...) v, err := NewVerifier("32473.2", testPubKey(t)) if err != nil { t.Fatalf("NewVerifier: %s", err) } - cp, n, err := checkpoint.Open(signed, note.VerifierList(v)) + filtered, err := v.FilterByVerify([]byte(text), lines) if err != nil { - t.Fatalf("checkpoint.Open: %s", err) - } - if cp.Origin != known.origin { - t.Errorf("Origin = %q, want %q", cp.Origin, known.origin) + t.Fatalf("FilterByVerify: %s", err) } - if len(n.Sigs) != 1 || n.Sigs[0].Name != known.name { - t.Fatalf("Sigs = %+v, want only the known cosigner's", n.Sigs) + if !bytes.Equal(filtered, knownSignature) { + t.Errorf("FilterByVerify = %x, want the known cosigner's signature %x", filtered, knownSignature) } - if len(n.UnverifiedSigs) != 1 || n.UnverifiedSigs[0].Name != unknown.name { - t.Errorf("UnverifiedSigs = %+v, want the unknown cosigner's", n.UnverifiedSigs) + + _, _, err = checkpoint.Open([]byte(text+"\n"+string(lines)), v) + if err == nil { + t.Error("checkpoint.Open with a signature from an unknown key = nil error, want error") } } // TestSignatureLineRoundTrip checks that a raw signature extracted with -// RawSignature reassembles into a verified signature line, and that -// reassembly rejects short signatures and checkpoints the signature does not -// cover. +// RawSignature reassembles into a signature line that verifies against the +// checkpoint it covers and not against another, and that reassembly rejects +// short signatures. func TestSignatureLineRoundTrip(t *testing.T) { ca, err := NewCosigner("32473.2", "oid/1.3.6.1.4.1.32473.2.0.42", testSigner(t)) if err != nil { @@ -547,26 +562,26 @@ func TestSignatureLineRoundTrip(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - line, err := v.SignatureLine(ca.Origin(), parsed.Tree, raw) + line, err := SignatureLine(v.Name(), v.KeyHash(), raw) if err != nil { t.Fatalf("SignatureLine: %s", err) } - roundTripped, err := TimestampedSignature([]byte(text), line, v) + roundTripped, err := v.FilterByVerify([]byte(text), line) if err != nil { - t.Fatalf("TimestampedSignature rejected the reassembled line: %s", err) + t.Fatalf("FilterByVerify rejected the reassembled line: %s", err) } if !bytes.Equal(roundTripped, timestamped) { t.Errorf("round-tripped signature = %x, want %x", roundTripped, timestamped) } - _, err = v.SignatureLine(ca.Origin(), parsed.Tree, raw[1:]) + _, err = SignatureLine(v.Name(), v.KeyHash(), raw[1:]) if err == nil { t.Error("SignatureLine with a short signature = nil error, want error") } - tampered := tlog.Tree{N: parsed.Tree.N + 1, Hash: parsed.Tree.Hash} - _, err = v.SignatureLine(ca.Origin(), tampered, raw) + tampered := ca.origin + "\n20852164\n" + exampleHashB64 + "\n" + _, err = v.FilterByVerify([]byte(tampered), line) if err == nil { - t.Error("SignatureLine over a different tree = nil error, want error") + t.Error("FilterByVerify accepted the reassembled line over a different tree") } } From 629ba03a2b491b5333e7d2caed61c5c7c9f4bf1a Mon Sep 17 00:00:00 2001 From: Samantha Date: Tue, 25 Aug 2026 12:03:21 -0400 Subject: [PATCH 03/13] trees: Support for SignSubtree --- trees/checkpoint/checkpoint.go | 9 ++++++++ trees/checkpoint/checkpoint_test.go | 12 ++++++++++ trees/mirror/mirror.go | 27 ++++++++++++++++++++++ trees/mirror/mirror_test.go | 36 +++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/trees/checkpoint/checkpoint.go b/trees/checkpoint/checkpoint.go index f6100ed23a1..fa400e03f23 100644 --- a/trees/checkpoint/checkpoint.go +++ b/trees/checkpoint/checkpoint.go @@ -131,6 +131,15 @@ func (c *Checkpoint) SignedNoteForMirror(caCosignatureLine []byte) ([]byte, erro return c.signedNote(caCosignatureLine) } +// SignedNoteForSignSubtree returns the checkpoint as a signed note carrying the +// cosignature lines the mirror returned from add-entries. +func (c *Checkpoint) SignedNoteForSignSubtree(mirrorCosignatureLines []byte) ([]byte, error) { + if len(mirrorCosignatureLines) == 0 { + return nil, errors.New("missing mirror cosignature lines") + } + return c.signedNote(mirrorCosignatureLines) +} + // SignedNoteForServing returns the checkpoint as a signed note carrying the // MTCA and mirror cosignature lines, for serving at the checkpoint path. func (c *Checkpoint) SignedNoteForServing(caCosignatureLine, mirrorCosignatureLine []byte) ([]byte, error) { diff --git a/trees/checkpoint/checkpoint_test.go b/trees/checkpoint/checkpoint_test.go index bba62e81c52..80983654476 100644 --- a/trees/checkpoint/checkpoint_test.go +++ b/trees/checkpoint/checkpoint_test.go @@ -205,6 +205,14 @@ func TestSignedNotes(t *testing.T) { t.Errorf("SignedNoteForServing = %q", forServing) } + forSignSubtree, err := cp.SignedNoteForSignSubtree(mirrorLine) + if err != nil { + t.Fatalf("SignedNoteForSignSubtree: %s", err) + } + if string(forSignSubtree) != string(text)+"\n"+string(mirrorLine) { + t.Errorf("SignedNoteForSignSubtree = %q", forSignSubtree) + } + _, err = cp.SignedNoteForMirror(nil) if err == nil { t.Error("SignedNoteForMirror without a line = nil error, want error") @@ -217,6 +225,10 @@ func TestSignedNotes(t *testing.T) { if err == nil { t.Error("SignedNoteForServing without the mirror line = nil error, want error") } + _, err = cp.SignedNoteForSignSubtree(nil) + if err == nil { + t.Error("SignedNoteForSignSubtree without lines = nil error, want error") + } } // TestOpenCheckpoint covers Open's happy path and its rejection of a note no diff --git a/trees/mirror/mirror.go b/trees/mirror/mirror.go index f70900ef0fc..1be9f7df16f 100644 --- a/trees/mirror/mirror.go +++ b/trees/mirror/mirror.go @@ -55,6 +55,33 @@ func AddCheckpointRequest(oldSize int64, proof []tlog.Hash, signedCheckpoint []b return b.Bytes(), nil } +// SignSubtreeRequest builds the sign-subtree request body per +// c2sp.org/tlog-witness. The proof must be a Subtree Consistency Proof from the +// subtree to the checkpoint, empty when the subtree is the whole tree. +func SignSubtreeRequest(start, end int64, subtreeHash tlog.Hash, proof []tlog.Hash, signedCheckpoint []byte) ([]byte, error) { + if start < 0 { + return nil, fmt.Errorf("negative subtree start %d", start) + } + if end <= start { + return nil, fmt.Errorf("subtree end %d not after start %d", end, start) + } + if len(proof) > maxProofLines { + return nil, fmt.Errorf("consistency proof has %d lines, want at most %d", len(proof), maxProofLines) + } + if len(signedCheckpoint) == 0 { + return nil, errors.New("empty checkpoint") + } + var b bytes.Buffer + fmt.Fprintf(&b, "subtree %d %d\n%s\n", start, end, subtreeHash) + for _, h := range proof { + b.WriteString(h.String()) + b.WriteByte('\n') + } + b.WriteByte('\n') + b.Write(signedCheckpoint) + return b.Bytes(), nil +} + // parseDecimal parses an ASCII decimal string, accepting leading zeroes since // the specs require canonical decimals only in request bodies. func parseDecimal(s string) (int64, error) { diff --git a/trees/mirror/mirror_test.go b/trees/mirror/mirror_test.go index 4130d85ae32..bfb4c31b857 100644 --- a/trees/mirror/mirror_test.go +++ b/trees/mirror/mirror_test.go @@ -196,6 +196,42 @@ func TestEntryPackage(t *testing.T) { } } +func TestSignSubtreeRequest(t *testing.T) { + hash := mustHash(t, "CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=") + proof := []tlog.Hash{mustHash(t, "PlRNCrwHpqhGrupue0L7gxbjbMiKA9temvuZZDDpkaw=")} + note := []byte("example.com/log\n512\n" + hash.String() + "\n\n— example.com/log AAAA\n") + + body, err := SignSubtreeRequest(256, 512, hash, proof, note) + if err != nil { + t.Fatalf("SignSubtreeRequest: %s", err) + } + expect := "subtree 256 512\n" + hash.String() + "\n" + proof[0].String() + "\n\n" + string(note) + if string(body) != expect { + t.Errorf("SignSubtreeRequest = %q, want %q", body, expect) + } + + _, err = SignSubtreeRequest(-1, 512, hash, nil, note) + if err == nil { + t.Error("SignSubtreeRequest with a negative start = nil error, want error") + } + _, err = SignSubtreeRequest(512, 256, hash, nil, note) + if err == nil { + t.Error("SignSubtreeRequest with end before start = nil error, want error") + } + _, err = SignSubtreeRequest(512, 512, hash, nil, note) + if err == nil { + t.Error("SignSubtreeRequest with an empty subtree = nil error, want error") + } + _, err = SignSubtreeRequest(0, 512, hash, make([]tlog.Hash, 64), note) + if err == nil { + t.Error("SignSubtreeRequest with 64 proof lines = nil error, want error") + } + _, err = SignSubtreeRequest(0, 512, hash, nil, nil) + if err == nil { + t.Error("SignSubtreeRequest with an empty checkpoint = nil error, want error") + } +} + func TestAddEntriesRequest(t *testing.T) { origin := "oid/1.3.6.1.4.1.44947.4.1.0.44" pkg := []byte{0, 1, 'x', 0} From d272f773f10b4241e7b98e17994c6fe8577ac657 Mon Sep 17 00:00:00 2001 From: Samantha Date: Fri, 21 Aug 2026 16:10:49 -0400 Subject: [PATCH 04/13] mtpublisher: Replace the stub with a tlog-mirror client --- cmd/boulder-mtpublisher/main.go | 45 +- cmd/config.go | 8 + mtca/mtca_test.go | 16 +- mtpublisher/mirror.go | 263 ++++++++ mtpublisher/mtpublisher.go | 168 +++-- mtpublisher/mtpublisher_test.go | 600 ++++++++++++++---- .../mtpublishertest/mtpublishertest.go | 57 ++ .../mtpublishertest/mtpublishertest_test.go | 73 +++ mtpublisher/source.go | 60 ++ test/config-next/mtpublisher.json | 15 +- test/config/mtpublisher.json | 15 +- test/sunlight/genkeys/main.go | 14 - 12 files changed, 1092 insertions(+), 242 deletions(-) create mode 100644 mtpublisher/mirror.go create mode 100644 mtpublisher/mtpublishertest/mtpublishertest.go create mode 100644 mtpublisher/mtpublishertest/mtpublishertest_test.go create mode 100644 mtpublisher/source.go diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index fbac967ee59..96891515893 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -11,10 +11,10 @@ import ( "fmt" "os" + "github.com/letsencrypt/boulder/bs3" "github.com/letsencrypt/boulder/cmd" "github.com/letsencrypt/boulder/config" "github.com/letsencrypt/boulder/mtpublisher" - "github.com/letsencrypt/boulder/privatekey" "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/trees/issuancelog" ) @@ -25,25 +25,29 @@ type Config struct { DebugAddr string `validate:"omitempty,hostname_port"` - // PollInterval is how often the stub scans for checkpoints that still - // lack a mirror cosignature. + // PollInterval is how often the publisher scans for checkpoints that + // still lack a mirror cosignature. PollInterval config.Duration `validate:"required"` // LogID identifies the issuance log this publisher operates on. It must // match the mtca's. LogID issuancelog.ID `validate:"required"` - // MirrorID identifies the cosigner this publisher writes alongside each - // cosignature (e.g. "32473.9"). - MirrorID string `validate:"required"` + // MTCAPublicKeyFile holds the PEM-encoded ML-DSA-44 public key the mtca + // cosigns checkpoints with, used to reconstruct each checkpoint's + // signed note from the database. + MTCAPublicKeyFile string `validate:"required"` - // MirrorPublicKeyFile holds the PEM-encoded ML-DSA-44 public key used - // to verify cosignatures. - MirrorPublicKeyFile string `validate:"required"` + // Mirror identifies the mirror this publisher submits to. + Mirror cmd.MirrorConfig `validate:"required"` - // MirrorKeyFile holds the PEM-encoded ML-DSA-44 private key used to - // cosign checkpoints. - MirrorKeyFile string `validate:"required"` + // MirrorBaseURL is the base URL of the mirror's tlog-mirror submission + // endpoints (e.g. "http://localhost:4700"). + MirrorBaseURL string `validate:"required,url"` + + // S3 locates the source log's tile storage, which the publisher reads + // entries and proof hashes from when submitting to the mirror. + S3 bs3.Config `validate:"required"` } Syslog cmd.SyslogConfig OpenTelemetry cmd.OpenTelemetryConfig @@ -94,13 +98,18 @@ func main() { dbMap, err := sa.InitWrappedDb(c.MTPublisher.DB, scope, logger) cmd.FailOnError(err, "While initializing dbMap") - signer, _, err := privatekey.Load(c.MTPublisher.MirrorKeyFile) - cmd.FailOnError(err, "Loading cosigner key") - pubKey, err := loadMLDSAPublicKey(c.MTPublisher.MirrorPublicKeyFile) - cmd.FailOnError(err, "Loading cosigner public key") + pubKey, err := loadMLDSAPublicKey(c.MTPublisher.Mirror.PublicKeyFile) + cmd.FailOnError(err, "Loading mirror public key") + caPubKey, err := loadMLDSAPublicKey(c.MTPublisher.MTCAPublicKeyFile) + cmd.FailOnError(err, "Loading MTCA public key") + s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger) + cmd.FailOnError(err, "Loading S3 config") + + mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.MirrorBaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, pubKey) + cmd.FailOnError(err, "Creating mirror client") - publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, c.MTPublisher.MirrorID, signer, pubKey, logger) - cmd.FailOnError(err, "Failed to create MTPublisher stub") + publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger) + cmd.FailOnError(err, "Failed to create MTPublisher") ctx, cancel := context.WithCancel(context.Background()) go cmd.CatchSignals(cancel) diff --git a/cmd/config.go b/cmd/config.go index 853d2b61ffd..9d9c149d958 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -129,6 +129,14 @@ type HostnamePolicyConfig struct { HostnamePolicyFile string `validate:"required"` } +// MirrorConfig identifies an MTC mirror cosigner. +type MirrorConfig struct { + // ID is the mirror's ID (e.g. "32473.9"). + ID string `validate:"required"` + // PublicKeyFile holds the mirror's PEM-encoded ML-DSA-44 public key. + PublicKeyFile string `validate:"required"` +} + // TLSConfig represents certificates and a key for authenticated TLS. type TLSConfig struct { CertFile string `validate:"required"` diff --git a/mtca/mtca_test.go b/mtca/mtca_test.go index a082b2cd7c5..5aa115fa929 100644 --- a/mtca/mtca_test.go +++ b/mtca/mtca_test.go @@ -35,7 +35,9 @@ import ( blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/mtca/proto" "github.com/letsencrypt/boulder/mtpublisher" + "github.com/letsencrypt/boulder/mtpublisher/mtpublishertest" "github.com/letsencrypt/boulder/privatekey" + "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/test/vars" "github.com/letsencrypt/boulder/trees/cosigned" "github.com/letsencrypt/boulder/trees/entry" @@ -285,11 +287,23 @@ func (e *errorS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optF // in for the daemon, so sequencing can proceed. func mirrorCosign(t *testing.T, m *mtca) { t.Helper() + caPub, ok := m.issuer.Signer.Public().(*mldsa.PublicKey) + if !ok { + t.Fatalf("issuer public key is %T, must be ML-DSA-44", m.issuer.Signer.Public()) + } key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), make([]byte, 32)) if err != nil { t.Fatalf("NewPrivateKey: %s", err) } - p, err := mtpublisher.New(m.db, time.Second, m.logID, "32473.9", privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + mirror, err := mtpublishertest.NewTestMirror("32473.9", m.logID.Origin(), privatekey.NewDeterministicSigner(key)) + if err != nil { + t.Fatalf("mtpublishertest.NewTestMirror: %s", err) + } + dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms) + if err != nil { + t.Fatalf("opening mtcmeta dbMap: %s", err) + } + p, err := mtpublisher.New(dbMap, time.Second, m.logID, caPub, mirror, blog.NewMock()) if err != nil { t.Fatalf("mtpublisher.New: %s", err) } diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go new file mode 100644 index 00000000000..07d7f33d0b7 --- /dev/null +++ b/mtpublisher/mirror.go @@ -0,0 +1,263 @@ +//go:build go1.27 + +package mtpublisher + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/mldsa" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/mod/sumdb/tlog" + + "github.com/letsencrypt/boulder/trees/checkpoint" + "github.com/letsencrypt/boulder/trees/cosignature" + "github.com/letsencrypt/boulder/trees/mirror" +) + +// maxMirrorResponseSize caps how much of a mirror's response body the client +// reads. The largest expected body is a few ML-DSA-44 signature lines of under +// 4KB each. +const maxMirrorResponseSize = 64 << 10 + +var _ Mirror = (*MirrorClient)(nil) + +// MirrorClient is a Mirror that uses the c2sp.org/tlog-mirror submission +// protocol, submitting the checkpoint to add-checkpoint and uploading the log's +// entries to add-entries until the mirror cosigns. +type MirrorClient struct { + submissionPrefix string + client *http.Client + src *Source + mirrorID string + verifier *cosignature.Verifier + + // oldSize is the tree size of the mirror's latest cosigned checkpoint, the + // old size of the next add-checkpoint request. + oldSize int64 + // nextEntry is the next entry the mirror expects to receive. + nextEntry int64 + // ticket is the opaque value from the mirror's last mirror-info response, + // to be sent back in the next add-entries request. + ticket []byte + // lastSigned is when Cosign last succeeded. + lastSigned time.Time +} + +// NewMirrorClient returns a MirrorClient that submits to the mirror's endpoints +// under baseURL. +func NewMirrorClient(baseURL string, src *Source, mirrorID string, mirrorPublicKey *mldsa.PublicKey) (*MirrorClient, error) { + if baseURL == "" { + return nil, errors.New("empty mirror base URL") + } + verifier, err := cosignature.NewVerifier(mirrorID, mirrorPublicKey) + if err != nil { + return nil, fmt.Errorf("creating mirror verifier: %s", err) + } + return &MirrorClient{ + submissionPrefix: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + src: src, + mirrorID: mirrorID, + verifier: verifier, + }, nil +} + +// ID returns the mirror's cosigner ID. +func (m *MirrorClient) ID() string { + return m.mirrorID +} + +// post sends body to the endpoint at path and returns the response status and +// body. If compress is true, the body is gzip compressed. +func (m *MirrorClient) post(ctx context.Context, path, contentType string, compress bool, body []byte) (int, []byte, error) { + if compress { + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + _, err := zw.Write(body) + if err != nil { + return 0, nil, err + } + err = zw.Close() + if err != nil { + return 0, nil, err + } + body = compressed.Bytes() + } + endpoint, err := url.JoinPath(m.submissionPrefix, path) + if err != nil { + return 0, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return 0, nil, err + } + if compress { + req.Header.Set("Content-Encoding", "gzip") + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := m.client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxMirrorResponseSize)) + if err != nil { + return 0, nil, fmt.Errorf("reading mirror response: %s", err) + } + return resp.StatusCode, respBody, nil +} + +// addCheckpoint submits the log's signed checkpoint note with a consistency +// proof from the mirror's last known size, updating the mirror's pending +// checkpoint. On a "409 Conflict" it adopts the size the mirror advertises and +// retries once. +func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signedNote []byte) error { + oldSize := m.oldSize + retried := false + for { + if oldSize > tree.N { + return fmt.Errorf("mirror already holds size %d, checkpoint size is %d", oldSize, tree.N) + } + var proof []tlog.Hash + if oldSize > 0 && oldSize < tree.N { + treeProof, err := m.src.consistencyProof(ctx, tree, oldSize) + if err != nil { + return fmt.Errorf("proving consistency from size %d: %s", oldSize, err) + } + proof = treeProof + } + body, err := mirror.AddCheckpointRequest(oldSize, proof, signedNote) + if err != nil { + return err + } + status, respBody, err := m.post(ctx, "/add-checkpoint", "", false, body) + if err != nil { + return err + } + switch status { + case http.StatusOK: + m.oldSize = tree.N + return nil + case http.StatusConflict: + if retried { + return errors.New("mirror rejected the old size twice") + } + retried = true + oldSize, err = mirror.ParseSizeResponse(respBody) + if err != nil { + return err + } + default: + return fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + } + } +} + +// maxAddEntriesRequests bounds one Cosign call's add-entries requests, each of +// up to MaxPackagesPerRequest entry packages, so an upload terminates against a +// mirror that never makes progress. +const maxAddEntriesRequests = 100 + +// addEntries uploads the entries the mirror is missing, up to the tree size, +// and returns the cosignature lines from the mirror's "200 Success" response. +// On "202 Accepted" and "409 Conflict" it resumes from the next entry and +// ticket the mirror advertises. +func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog.Tree) ([]byte, error) { + start := min(m.nextEntry, tree.N) + ticket := m.ticket + for range maxAddEntriesRequests { + packages, err := mirror.Packages(start, tree.N, mirror.MaxPackagesPerRequest) + if err != nil { + return nil, err + } + var bodies [][]byte + for _, p := range packages { + body, err := m.src.entryPackage(ctx, tree, p) + if err != nil { + return nil, err + } + bodies = append(bodies, body) + } + reqBody, err := mirror.AddEntriesRequest(origin, start, tree.N, ticket, bodies) + if err != nil { + return nil, err + } + status, respBody, err := m.post(ctx, "/add-entries", "application/octet-stream", true, reqBody) + if err != nil { + return nil, err + } + switch status { + case http.StatusOK: + m.nextEntry = tree.N + m.ticket = nil + return respBody, nil + + case http.StatusAccepted, http.StatusConflict: + info, err := mirror.ParseMirrorInfo(respBody) + if err != nil { + return nil, err + } + if info.TreeSize != tree.N { + return nil, fmt.Errorf("mirror wants upload_end %d, checkpoint size is %d", info.TreeSize, tree.N) + } + start = info.NextEntry + ticket = info.Ticket + m.nextEntry = info.NextEntry + m.ticket = bytes.Clone(info.Ticket) + + default: + return nil, fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + } + } + return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests) +} + +// Cosign runs the c2sp.org/tlog-mirror submission protocol for the checkpoint +// and returns the mirror's raw cosignature, verified against the mirror's key. +func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNoteForMirror []byte) ([]byte, error) { + // Submit the checkpoint to the mirror. + err := m.addCheckpoint(ctx, cp.Tree, signedNoteForMirror) + if err != nil { + return nil, fmt.Errorf("add-checkpoint: %w", err) + } + + // Upload the checkpoint's entries to the mirror until it cosigns. + mirrorCosignatureLines, err := m.addEntries(ctx, cp.Origin, cp.Tree) + if err != nil { + return nil, fmt.Errorf("add-entries: %w", err) + } + + // Verify the mirror's cosignature. + noteText, err := cp.Marshal() + if err != nil { + return nil, fmt.Errorf("marshaling the checkpoint: %w", err) + } + timestampedMirrorCosignature, err := cosignature.TimestampedSignature(noteText, mirrorCosignatureLines, m.verifier) + if err != nil { + return nil, fmt.Errorf("cosignature failed verification: %w", err) + } + + // Finally, extract the raw cosignature we store in the database. + rawMirrorCosignature, err := cosignature.RawSignature(timestampedMirrorCosignature) + if err != nil { + return nil, fmt.Errorf("cosignature: %w", err) + } + m.lastSigned = time.Now() + return rawMirrorCosignature, nil +} + +// LastSigned returns when Cosign last succeeded, zero before it has. +func (m *MirrorClient) LastSigned() time.Time { + return m.lastSigned +} diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 4adfa6f506c..910d010aa02 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -4,11 +4,8 @@ package mtpublisher import ( "context" - "crypto" "crypto/mldsa" - "crypto/sha256" - "encoding/base64" - "encoding/binary" + "errors" "fmt" "time" @@ -22,126 +19,109 @@ import ( "github.com/letsencrypt/boulder/trees/treedb" ) -// publisher polls the MTC issuance log and cosigns the latest checkpoint if it -// lacks a mirror cosignature, playing both halves of the future exchange: it -// signs a signature line as the mirror, then ingests it through the note layer -// as the publisher will once the mirror is a separate server. It is a stub for -// the real MTPublisher. -type publisher struct { - treedb checkpointDB - interval time.Duration - mtcLogID string - origin string - mirrorID string - mirrorName string - mirrorKeyID uint32 - mirrorCosigner *cosignature.Cosigner - verifier *cosignature.Verifier - log blog.Logger +// Mirror cosigns checkpoints, requiring the entries they commit to before +// signing. +// +// https://c2sp.org/tlog-cosignature +type Mirror interface { + // ID returns the mirror's cosigner ID. + ID() string + // Cosign submits the log's signed note for cp and returns the mirror's raw + // cosignature, verified against the mirror's key. + Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNote []byte) ([]byte, error) + // LastSigned returns when Cosign last succeeded, zero before it has. + LastSigned() time.Time } -type checkpointDB interface { - LatestCheckpoint(ctx context.Context, mtcLogID string) (*treedb.CheckpointModel, error) - AddMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error +// mtpublisher obtains and stores its mirror's cosignature over the issuance +// log's latest checkpoint. +type mtpublisher struct { + treedb checkpointDB + interval time.Duration + logID issuancelog.ID + mirror Mirror + caVerifier *cosignature.Verifier + log blog.Logger } -// New returns a publisher for the issuance log logID. It cosigns as the mirror -// with mirrorID using signer, and verifies each cosignature against pubKey -// before storing it. -func New(dbMap *db.WrappedMap, interval time.Duration, logID issuancelog.ID, mirrorID string, signer crypto.Signer, pubKey *mldsa.PublicKey, log blog.Logger) (*publisher, error) { +// New returns a publisher for the issuance log logID. It reconstructs each +// checkpoint's signed note from the stored MTCA signature, verified against +// mtcaPublicKey, and obtains each cosignature from mirror, which verifies it +// before returning it. +func New(dbMap *db.WrappedMap, interval time.Duration, logID issuancelog.ID, mtcaPublicKey *mldsa.PublicKey, mirror Mirror, log blog.Logger) (*mtpublisher, error) { if interval <= 0 { return nil, fmt.Errorf("interval must be positive, got %s", interval) } - cosigner, err := cosignature.NewCosigner(mirrorID, logID.Origin(), signer) - if err != nil { - return nil, fmt.Errorf("creating mirror cosigner: %s", err) - } - verifier, err := cosignature.NewVerifier(mirrorID, pubKey) + + caVerifier, err := cosignature.NewVerifier(logID.CAID, mtcaPublicKey) if err != nil { - return nil, fmt.Errorf("creating mirror verifier: %s", err) + return nil, fmt.Errorf("creating MTCA verifier: %s", err) } - // The mirror's key ID per c2sp.org/tlog-cosignature, repeated from - // trees/cosignature for the stub's mirror half like the line encoding in - // cosignatureLine. - mirrorName := "oid/1.3.6.1.4.1." + mirrorID - h := sha256.New() - h.Write([]byte(mirrorName)) - h.Write([]byte{'\n', 0x06}) - h.Write(pubKey.Bytes()) - mirrorKeyID := binary.BigEndian.Uint32(h.Sum(nil)[:4]) - - return &publisher{ - treedb: treedb.New(dbMap), - interval: interval, - mtcLogID: logID.String(), - origin: cosigner.Origin(), - mirrorID: mirrorID, - mirrorName: mirrorName, - mirrorKeyID: mirrorKeyID, - mirrorCosigner: cosigner, - verifier: verifier, - log: log, + return &mtpublisher{ + treedb: treedb.New(dbMap), + interval: interval, + logID: logID, + mirror: mirror, + caVerifier: caVerifier, + log: log, }, nil } -// cosign cosigns the checkpoint described by tree as the mirror and returns the -// signature line it would send to the publisher. -// -// - https://c2sp.org/tlog-cosignature -// - https://c2sp.org/tlog-mirror -func (p *publisher) cosign(tree tlog.Tree) (string, error) { - timestampedCosignature, err := p.mirrorCosigner.CosignCheckpoint(tree) - if err != nil { - return "", err - } - idSignature := make([]byte, 4+len(timestampedCosignature)) - binary.BigEndian.PutUint32(idSignature[:4], p.mirrorKeyID) - copy(idSignature[4:], timestampedCosignature) - return "— " + p.mirrorName + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n", nil +// checkpointDB is the subset of treedb.Impl the publisher uses, so tests can +// substitute their own. +type checkpointDB interface { + LatestCheckpoint(ctx context.Context, mtcLogID string) (*treedb.CheckpointModel, error) + AddMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error } -// Publish cosigns the latest checkpoint in the database if it lacks a mirror -// cosignature and stores the raw signature in the database. Start calls it at -// each interval. -func (p *publisher) Publish(ctx context.Context) error { - latest, err := p.treedb.LatestCheckpoint(ctx, p.mtcLogID) +// Publish submits the latest checkpoint to the mirror if it lacks a mirror +// cosignature and stores the returned raw cosignature. Start calls it at each +// interval. +func (p *mtpublisher) Publish(ctx context.Context) error { + latest, err := p.treedb.LatestCheckpoint(ctx, p.logID.String()) + if errors.Is(err, treedb.ErrIssuanceLogNotInitialized) { + return nil + } if err != nil { - return err + return fmt.Errorf("selecting the latest checkpoint: %w", err) } - - if latest.MirrorSignature != nil { + if len(latest.MirrorSignature) > 0 { return nil } if len(latest.RootHash) != tlog.HashSize { return fmt.Errorf("checkpoint %d root hash is %d bytes, want %d", latest.ID, len(latest.RootHash), tlog.HashSize) } + + // Assemble the checkpoint for submission to the mirror. tree := tlog.Tree{N: latest.TreeSize, Hash: tlog.Hash(latest.RootHash)} + cp := &checkpoint.Checkpoint{Origin: p.logID.Origin(), Tree: tree} - // The mirror's half of the exchange. - cosigLine, err := p.cosign(tree) - if err != nil { - return fmt.Errorf("cosigning checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + // Reconstruct the MTCA's cosignature line from the stored MTCA signature. + if len(latest.MTCASignature) == 0 { + return fmt.Errorf("checkpoint %d (%s size %d) has no MTCA signature", latest.ID, latest.MTCLogID, latest.TreeSize) } - p.log.Infof("Cosigned checkpoint %d (%s size %d)", latest.ID, latest.MTCLogID, latest.TreeSize) - - // The publisher's half of the exchange. - cp := checkpoint.Checkpoint{Origin: p.origin, Tree: tree} - text, err := cp.Marshal() + caCosignatureLine, err := p.caVerifier.SignatureLine(cp.Origin, tree, latest.MTCASignature) if err != nil { - return fmt.Errorf("marshaling checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) + return fmt.Errorf("checkpoint %d MTCA signature: %w", latest.ID, err) } - timestampedMirrorCosig, err := cosignature.TimestampedSignature(text, []byte(cosigLine), p.verifier) + + // Reconstruct the signed note for submission to the mirror. + signedNoteForMirror, err := cp.SignedNoteForMirror(caCosignatureLine) if err != nil { - return fmt.Errorf("checkpoint %d cosignature failed verification before storage: %w", latest.ID, err) + return fmt.Errorf("assembling checkpoint %d signed note: %w", latest.ID, err) } - mirrorCosig, err := cosignature.RawSignature(timestampedMirrorCosig) + + // Submit the signed checkpoint to the mirror for cosigning. + mirrorRawCosig, err := p.mirror.Cosign(ctx, cp, signedNoteForMirror) if err != nil { - return fmt.Errorf("checkpoint %d cosignature: %w", latest.ID, err) + return fmt.Errorf("publishing checkpoint %d (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } + p.log.Infof("Published checkpoint %d (%s size %d)", latest.ID, latest.MTCLogID, latest.TreeSize) - err = p.treedb.AddMirrorSignature(ctx, latest.ID, p.mirrorID, mirrorCosig, p.mtcLogID) + // Store the mirror's cosignature in the database. + err = p.treedb.AddMirrorSignature(ctx, latest.ID, p.mirror.ID(), mirrorRawCosig, p.logID.String()) if err != nil { return fmt.Errorf("storing checkpoint %d cosignature (%s size %d): %w", latest.ID, latest.MTCLogID, latest.TreeSize, err) } @@ -149,15 +129,15 @@ func (p *publisher) Publish(ctx context.Context) error { return nil } -// Start attempts to cosign the latest checkpoint at each interval until ctx is +// Start attempts to publish the latest checkpoint at each interval until ctx is // cancelled. -func (p *publisher) Start(ctx context.Context) { +func (p *mtpublisher) Start(ctx context.Context) { ticker := time.NewTicker(p.interval) defer ticker.Stop() for { err := p.Publish(ctx) if err != nil { - p.log.Errf("Cosigning pass failed: %s", err) + p.log.Errf("Publishing pass failed: %s", err) } select { case <-ctx.Done(): diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index 21997743223..456ad621806 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -4,21 +4,34 @@ package mtpublisher import ( "bytes" + "compress/gzip" "context" "crypto/mldsa" "encoding/base64" - "fmt" + "encoding/binary" + "io" + "net/http" + "net/http/httptest" "strings" "testing" "time" "golang.org/x/mod/sumdb/tlog" + "github.com/letsencrypt/boulder/bs3/bs3test" + + "github.com/letsencrypt/boulder/db" blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/mtpublisher/mtpublishertest" "github.com/letsencrypt/boulder/privatekey" + "github.com/letsencrypt/boulder/sa" + "github.com/letsencrypt/boulder/test/vars" + "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" + "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" - "github.com/letsencrypt/boulder/trees/treedb" + "github.com/letsencrypt/boulder/trees/pubkey" + "github.com/letsencrypt/boulder/trees/tiles" ) const ( @@ -28,61 +41,52 @@ const ( var testLogID = issuancelog.ID{CAID: "44947.4.1", LogNumber: 44} -type mockTreeDB struct { - latestCheckpoint *treedb.CheckpointModel -} +func setupDB(t *testing.T) *db.WrappedMap { + t.Helper() -func newMockDB(mtcLogID string) *mockTreeDB { - var rootHash [32]byte - return &mockTreeDB{ - latestCheckpoint: &treedb.CheckpointModel{ - ID: 1, - RootHash: rootHash[:], - TreeSize: 1, - MTCLogID: mtcLogID, - }, + dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms) + if err != nil { + t.Fatalf("opening mtcmeta dbMap: %s", err) } -} - -func (m *mockTreeDB) LatestCheckpoint(ctx context.Context, mtcLogID string) (*treedb.CheckpointModel, error) { - return m.latestCheckpoint, nil -} - -func (m *mockTreeDB) AddMirrorSignature(ctx context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { - if m.latestCheckpoint.ID != id { - return fmt.Errorf("test assumption error: tried to add mirror signature for the wrong ID") + truncate := func(ctx context.Context) error { + _, err := dbMap.ExecContext(ctx, "TRUNCATE TABLE checkpoints") + if err != nil { + return err + } + _, err = dbMap.ExecContext(ctx, "TRUNCATE TABLE latestCheckpoint") + return err } - if m.latestCheckpoint.MTCLogID != mtcLogID { - return fmt.Errorf("test assumption error: tried to add mirror signature for the wrong MTCLogID (%s vs %s)", m.latestCheckpoint.MTCLogID, mtcLogID) + err = truncate(t.Context()) + if err != nil { + t.Fatalf("truncating tables: %s", err) } - m.latestCheckpoint.MirrorID = &mirrorID - m.latestCheckpoint.MirrorSignature = mirrorSignature - return nil + t.Cleanup(func() { + err := truncate(context.Background()) + if err != nil { + t.Logf("cleaning up tables: %s", err) + } + }) + return dbMap } -func insertCheckpoint(t *testing.T, mockDB *mockTreeDB, logID string, treeSize int64) { +// setLatest points latestCheckpoint at the checkpoint with the given id, as the +// sequencer does when it adopts a checkpoint. +func setLatest(t *testing.T, dbMap *db.WrappedMap, logID string, id int64) { t.Helper() - - mockDB.latestCheckpoint = &treedb.CheckpointModel{ - ID: mockDB.latestCheckpoint.ID + 1, - MTCLogID: logID, - MTCASignature: []byte("mtca-signature"), - TreeSize: treeSize, - RootHash: make([]byte, 32), + _, err := dbMap.ExecContext(t.Context(), + "REPLACE INTO latestCheckpoint (mtcLogID, id) VALUES (?, ?)", logID, id) + if err != nil { + t.Fatalf("pointing latestCheckpoint at %d: %s", id, err) } } -func lacksCosignature(mockDB *mockTreeDB) bool { - return mockDB.latestCheckpoint.MirrorID == nil && len(mockDB.latestCheckpoint.MirrorSignature) == 0 -} - -// testKey returns a deterministic ML-DSA-44 key so the test can verify the -// cosignatures the publisher stores. -func testKey(t *testing.T) *mldsa.PrivateKey { +// testCAKey returns a deterministic ML-DSA-44 key standing in for the mtca's +// checkpoint signing key. +func testCAKey(t *testing.T) *mldsa.PrivateKey { t.Helper() seed := make([]byte, 32) for i := range seed { - seed[i] = byte(i + 1) + seed[i] = byte(i + 101) } key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed) if err != nil { @@ -91,79 +95,138 @@ func testKey(t *testing.T) *mldsa.PrivateKey { return key } -// TestCosign checks that the mirror's cosignature line verifies through -// trees/cosignature and yields the timestamped_signature it encodes. -func TestCosign(t *testing.T) { - key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) +// caSignature returns the raw MTCA signature for a checkpoint of treeSize with +// a zero root hash, as insertCheckpoint stores. +func caSignature(t *testing.T, treeSize int64) []byte { + t.Helper() + ca, err := cosignature.NewCosigner(testLogID.CAID, testLogID.Origin(), privatekey.NewDeterministicSigner(testCAKey(t))) if err != nil { - t.Fatalf("New: %s", err) + t.Fatalf("NewCosigner: %s", err) } - - line, err := p.cosign(tlog.Tree{N: 512}) + timestamped, err := ca.CosignCheckpoint(tlog.Tree{N: treeSize}) if err != nil { - t.Fatalf("cosign: %s", err) + t.Fatalf("CosignCheckpoint: %s", err) } - if !strings.HasPrefix(line, "— oid/1.3.6.1.4.1."+mirrorID+" ") || !strings.HasSuffix(line, "\n") { - t.Errorf("line %q is not a cosignature line for the mirror", line) + raw, err := cosignature.RawSignature(timestamped) + if err != nil { + t.Fatalf("RawSignature: %s", err) } + return raw +} - verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) +func insertCheckpoint(t *testing.T, dbMap *db.WrappedMap, logID string, treeSize int64) int64 { + t.Helper() + + res, err := dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", + logID, caSignature(t, treeSize), treeSize, make([]byte, 32)) if err != nil { - t.Fatalf("NewVerifier: %s", err) + t.Fatalf("inserting checkpoint (%s size %d): %s", logID, treeSize, err) } - text := p.origin + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" - timestampedSignature, err := cosignature.TimestampedSignature([]byte(text), []byte(line), verifier) + id, err := res.LastInsertId() if err != nil { - t.Fatalf("TimestampedSignature: %s", err) + t.Fatalf("reading insert id: %s", err) } - _, err = cosignature.RawSignature(timestampedSignature) + return id +} + +func lacksCosignature(t *testing.T, dbMap *db.WrappedMap, id int64) bool { + t.Helper() + var count int64 + err := dbMap.SelectOne(t.Context(), &count, + "SELECT COUNT(*) FROM checkpoints WHERE id = ? AND mirrorID IS NULL AND mirrorSignature IS NULL", id) if err != nil { - t.Errorf("RawSignature: %s", err) + t.Fatalf("querying checkpoint %d: %s", id, err) } + return count == 1 +} - _, err = p.cosign(tlog.Tree{}) - if err == nil { - t.Error("cosign with an empty tree = nil error, want error") +// testKey returns a deterministic ML-DSA-44 key so the test can verify the +// cosignatures the publisher stores. +func testKey(t *testing.T) *mldsa.PrivateKey { + t.Helper() + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i + 1) + } + key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + return key +} + +// testMirror returns a LocalMirror that cosigns with key. +func testMirror(t *testing.T, key *mldsa.PrivateKey) *mtpublishertest.TestMirror { + t.Helper() + mirror, err := mtpublishertest.NewTestMirror(mirrorID, testLogID.Origin(), privatekey.NewDeterministicSigner(key)) + if err != nil { + t.Fatalf("NewTestMirror: %s", err) } + return mirror } func TestPublish(t *testing.T) { + dbMap := setupDB(t) key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { t.Fatalf("New: %s", err) } - mockDB := newMockDB(mtcLogID) - p.treedb = mockDB - // A pass over an empty table is a no-op. err = p.Publish(t.Context()) if err != nil { t.Fatalf("p.Publish() on an empty table: %s", err) } + // An older checkpoint that is not cosigned, which must be left untouched. + olderCheckpointID := insertCheckpoint(t, dbMap, mtcLogID, 256) + // The latest checkpoint, which we expect to be cosigned by p.Publish(). - insertCheckpoint(t, mockDB, mtcLogID, 512) + latestCheckpointID := insertCheckpoint(t, dbMap, mtcLogID, 512) + setLatest(t, dbMap, mtcLogID, latestCheckpointID) + + // A checkpoint for another log that was somehow inserted into this table, + // which must be left untouched thanks to the mtcLogID guard. + otherLogCheckpointID := insertCheckpoint(t, dbMap, "44947.4.2.0.99", 1024) + + // A precommitted checkpoint the CA has not signed yet, which must be left + // untouched because latestCheckpoint does not reference it, even though its + // tree is the largest. + res, err := dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, treeSize, rootHash) VALUES (?, ?, ?)", + mtcLogID, int64(2048), make([]byte, 32)) + if err != nil { + t.Fatalf("inserting precommitted checkpoint: %s", err) + } + precommitID, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) + } err = p.Publish(t.Context()) if err != nil { t.Fatalf("p.Publish(): %s", err) } - cosigned, err := mockDB.LatestCheckpoint(context.Background(), mtcLogID) + type row struct { + MirrorID string `db:"mirrorID"` + MirrorSig []byte `db:"mirrorSignature"` + } + var cosigned row + err = dbMap.SelectOne(t.Context(), &cosigned, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE id = ?", latestCheckpointID) if err != nil { - t.Fatal(err) + t.Fatalf("selecting the latest checkpoint: %s", err) } // Check that the latest checkpoint was cosigned, and the others were // untouched. - if cosigned.MirrorID == nil || *cosigned.MirrorID != mirrorID { - t.Errorf("mirrorID = %v, want %q", cosigned.MirrorID, mirrorID) + if cosigned.MirrorID != mirrorID { + t.Errorf("mirrorID = %q, want %q", cosigned.MirrorID, mirrorID) } - if len(cosigned.MirrorSignature) != mldsa.MLDSA44SignatureSize { - t.Fatalf("latest checkpoint's mirrorSignature is %d bytes, want %d", len(cosigned.MirrorSignature), mldsa.MLDSA44SignatureSize) + if len(cosigned.MirrorSig) != mldsa.MLDSA44SignatureSize { + t.Fatalf("latest checkpoint's mirrorSignature is %d bytes, want %d", len(cosigned.MirrorSig), mldsa.MLDSA44SignatureSize) } verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) @@ -171,70 +234,389 @@ func TestPublish(t *testing.T) { t.Fatalf("NewVerifier: %s", err) } text := "oid/1.3.6.1.4.1." + mtcLogID + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" - timestampedSignature := append(make([]byte, 8), cosigned.MirrorSignature...) + timestampedSignature := append(make([]byte, 8), cosigned.MirrorSig...) if !verifier.Verify([]byte(text), timestampedSignature) { t.Error("stored mirror cosignature does not verify against the checkpoint text") } -} - -// TestPublishRejectsMismatchedKey checks that a cosignature that fails to -// verify against the configured public key is not stored. -func TestPublishRejectsMismatchedKey(t *testing.T) { - otherSeed := make([]byte, 32) - for i := range otherSeed { - otherSeed[i] = byte(255 - i) + if !lacksCosignature(t, dbMap, olderCheckpointID) { + t.Error("older checkpoint was cosigned, only the latest should be") } - otherKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), otherSeed) - if err != nil { - t.Fatalf("NewPrivateKey: %s", err) + if !lacksCosignature(t, dbMap, otherLogCheckpointID) { + t.Errorf("another log's checkpoint (id=%d) was cosigned, despite the mtcLogID guard", otherLogCheckpointID) } + if !lacksCosignature(t, dbMap, precommitID) { + t.Error("precommitted checkpoint was cosigned before the CA signed it") + } +} - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(testKey(t)), otherKey.PublicKey(), blog.NewMock()) +// TestPublishRejectsBadMTCASignature checks that a checkpoint whose stored +// MTCA signature does not verify is neither submitted nor cosigned. +func TestPublishRejectsBadMTCASignature(t *testing.T) { + dbMap := setupDB(t) + key := testKey(t) + p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { t.Fatalf("New: %s", err) } - mockDB := newMockDB(mtcLogID) - p.treedb = mockDB - - insertCheckpoint(t, mockDB, mtcLogID, 512) + // A well-formed MTCA signature over the wrong tree size. + res, err := dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", + mtcLogID, caSignature(t, 999), int64(512), make([]byte, 32)) + if err != nil { + t.Fatalf("inserting checkpoint: %s", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) + } + setLatest(t, dbMap, mtcLogID, id) err = p.Publish(t.Context()) if err == nil { - t.Error("publish with a mismatched public key = nil error, want error") + t.Error("publish with a bad MTCA signature = nil error, want error") } - if !lacksCosignature(mockDB) { - t.Error("cosignature was stored despite failing verification") + if !lacksCosignature(t, dbMap, id) { + t.Error("cosignature was stored despite the MTCA signature failing verification") } } func TestPublishWhenLatestAlreadySigned(t *testing.T) { + dbMap := setupDB(t) key := testKey(t) - p, err := New(nil, time.Second, testLogID, mirrorID, privatekey.NewDeterministicSigner(key), key.PublicKey(), blog.NewMock()) + p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { t.Fatalf("New: %s", err) } - mockDB := newMockDB(mtcLogID) - p.treedb = mockDB - // Insert a checkpoint that is already cosigned, which must be left // untouched. - insertCheckpoint(t, mockDB, mtcLogID, 512) - existing := "existing.cosigner" - mockDB.latestCheckpoint.MirrorID = &existing - mockDB.latestCheckpoint.MirrorSignature = []byte("already-signed-bruh") + res, err := dbMap.ExecContext(t.Context(), + "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash, mirrorID, mirrorSignature) VALUES (?, ?, ?, ?, ?, ?)", + mtcLogID, caSignature(t, 512), int64(512), make([]byte, 32), "existing.cosigner", []byte("already-signed-bruh")) + if err != nil { + t.Fatalf("inserting cosigned checkpoint: %s", err) + } + cosignedID, err := res.LastInsertId() + if err != nil { + t.Fatalf("reading insert id: %s", err) + } + setLatest(t, dbMap, mtcLogID, cosignedID) + + // Insert an older (non-latest) checkpoint that is not cosigned, which must + // be left untouched. + olderID := insertCheckpoint(t, dbMap, mtcLogID, 256) err = p.Publish(t.Context()) if err != nil { t.Fatalf("p.Publish(): %s", err) } - // The latest checkpoint was already cosigned, so the pass must leave it untouched. - if !bytes.Equal(mockDB.latestCheckpoint.MirrorSignature, []byte("already-signed-bruh")) { - t.Errorf("MirrorSignature: got %x, want %x", mockDB.latestCheckpoint.MirrorSignature, []byte("already-signed-bruh")) + // The latest checkpoint is already cosigned, so the pass must leave both + // checkpoints untouched. + if !lacksCosignature(t, dbMap, olderID) { + t.Error("older checkpoint was cosigned, the pass should have stopped at the signed latest") + } + var mirrorCosignature []byte + err = dbMap.SelectOne(t.Context(), &mirrorCosignature, "SELECT mirrorSignature FROM checkpoints WHERE mtcLogID = ? AND treeSize = 512", mtcLogID) + if err != nil { + t.Fatalf("selecting the cosigned checkpoint: %s", err) + } + if string(mirrorCosignature) != "already-signed-bruh" { + t.Errorf("existing cosignature was replaced: %q", mirrorCosignature) + } +} + +// sourceLog is a published source log in fake tile storage, with an earlier +// published tree so tests can exercise consistency proofs between the two. +type sourceLog struct { + fs3 *bs3test.FakeS3 + older tlog.Tree + newer tlog.Tree + cp *checkpoint.Checkpoint + signedNote []byte + + // mirrorKey signs cosigLine, the mirror's signature line over the newer + // tree, which carries the raw cosignature rawCosig. + mirrorKey *mldsa.PrivateKey + cosigLine []byte + rawCosig []byte +} + +const testTilePrefix = "44947.4.1/44" + +// newSourceLog publishes a 300 entry tree and grows it to 700 entries, +// returning the storage and the newer tree's checkpoint text. +func newSourceLog(t *testing.T) *sourceLog { + t.Helper() + fs3 := bs3test.New() + f := &tiles.Frontier{} + grow := func(n int64) tlog.Tree { + t.Helper() + for range n { + err := f.AppendEntry(&entry.MTCLogEntry{}, &pubkey.MTCPublicKey{}) + if err != nil { + t.Fatalf("AppendEntry: %s", err) + } + } + err := f.Publish(t.Context(), fs3, testTilePrefix) + if err != nil { + t.Fatalf("Publish: %s", err) + } + return tlog.Tree{N: f.TreeSize(), Hash: f.RootHash()} + } + older := grow(300) + newer := grow(400) + cp := &checkpoint.Checkpoint{Origin: "oid/1.3.6.1.4.1." + mtcLogID, Tree: newer} + + caSeed := make([]byte, 32) + for i := range caSeed { + caSeed[i] = byte(i + 101) + } + caKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), caSeed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + ca, err := cosignature.NewCosigner(testLogID.CAID, testLogID.Origin(), privatekey.NewDeterministicSigner(caKey)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + timestampedCA, err := ca.CosignCheckpoint(newer) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + rawCA, err := cosignature.RawSignature(timestampedCA) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + caVerifier, err := cosignature.NewVerifier(testLogID.CAID, caKey.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + caLine, err := caVerifier.SignatureLine(cp.Origin, newer, rawCA) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + signedNote, err := cp.SignedNoteForMirror(caLine) + if err != nil { + t.Fatalf("SignedNoteForMirror: %s", err) + } + + mirrorSeed := make([]byte, 32) + for i := range mirrorSeed { + mirrorSeed[i] = byte(i + 201) + } + mirrorKey, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), mirrorSeed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + mirrorCosigner, err := cosignature.NewCosigner(mirrorID, cp.Origin, privatekey.NewDeterministicSigner(mirrorKey)) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + timestamped, err := mirrorCosigner.CosignCheckpoint(newer) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + rawCosig, err := cosignature.RawSignature(timestamped) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + mirrorVerifier, err := cosignature.NewVerifier(mirrorID, mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + cosigLine, err := mirrorVerifier.SignatureLine(cp.Origin, newer, rawCosig) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } + return &sourceLog{ + fs3: fs3, older: older, newer: newer, cp: cp, signedNote: signedNote, + mirrorKey: mirrorKey, cosigLine: cosigLine, rawCosig: rawCosig, + } +} + +// requestBody reads a request body, requiring gzip compression on add-entries +// requests. +func requestBody(t *testing.T, r *http.Request) []byte { + t.Helper() + if r.URL.Path == "/add-entries" && r.Header.Get("Content-Encoding") != "gzip" { + t.Error("add-entries request is not gzip compressed") + } + reader := io.Reader(r.Body) + if r.Header.Get("Content-Encoding") == "gzip" { + zr, err := gzip.NewReader(r.Body) + if err != nil { + t.Fatalf("opening request body: %s", err) + } + reader = zr + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("reading request body: %s", err) + } + return body +} + +// parseUploadHeader pulls upload_start and the ticket out of an add-entries +// request body. +func parseUploadHeader(t *testing.T, body []byte) (int64, []byte) { + t.Helper() + originLen := int(binary.BigEndian.Uint16(body[:2])) + rest := body[2+originLen:] + uploadStart := int64(binary.BigEndian.Uint64(rest[:8])) + ticketLen := int(binary.BigEndian.Uint16(rest[16:18])) + return uploadStart, rest[18 : 18+ticketLen] +} + +// TestMirrorCosign drives the client through a scripted exchange. The mirror +// answers the first add-checkpoint with "409 Conflict" at size 300 so the +// client must prove consistency from there, then answers the first add-entries +// with "202 Accepted" at entry 512 and a ticket the client must echo before the +// "200 Success" carrying the cosignature line. +func TestMirrorCosign(t *testing.T) { + source := newSourceLog(t) + line := string(source.cosigLine) + + var addCheckpointCalls, addEntriesCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := requestBody(t, r) + switch r.URL.Path { + case "/add-checkpoint": + addCheckpointCalls++ + switch addCheckpointCalls { + case 1: + if !bytes.HasPrefix(body, []byte("old 0\n\n")) { + t.Errorf("first add-checkpoint body %q does not claim old size 0 with an empty proof", body) + } + w.Header().Set("Content-Type", "text/x.tlog.size") + w.WriteHeader(http.StatusConflict) + io.WriteString(w, "300\n") + default: + header, _, ok := bytes.Cut(body, []byte("\n\n")) + lines := strings.Split(string(header), "\n") + if !ok || lines[0] != "old 300" { + t.Fatalf("second add-checkpoint body %q does not claim old size 300", body) + } + proof := make(tlog.TreeProof, len(lines)-1) + for i, l := range lines[1:] { + h, err := tlog.ParseHash(l) + if err != nil { + t.Fatalf("proof line %q: %s", l, err) + } + proof[i] = h + } + err := tlog.CheckTree(proof, source.newer.N, source.newer.Hash, source.older.N, source.older.Hash) + if err != nil { + t.Errorf("client's consistency proof does not verify: %s", err) + } + } + case "/add-entries": + addEntriesCalls++ + uploadStart, ticket := parseUploadHeader(t, body) + switch addEntriesCalls { + case 1: + if uploadStart != 0 || len(ticket) != 0 { + t.Errorf("first add-entries upload_start = %d ticket = %q, want 0 and empty", uploadStart, ticket) + } + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusAccepted) + io.WriteString(w, "700\n512\n"+base64.StdEncoding.EncodeToString([]byte("resume"))+"\n") + default: + if uploadStart != 512 || string(ticket) != "resume" { + t.Errorf("second add-entries upload_start = %d ticket = %q, want 512 and \"resume\"", uploadStart, ticket) + } + io.WriteString(w, line) + } + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } + })) + defer srv.Close() + + m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + got, err := m.Cosign(t.Context(), source.cp, source.signedNote) + if err != nil { + t.Fatalf("Cosign: %s", err) + } + if !bytes.Equal(got, source.rawCosig) { + t.Errorf("Cosign = %x, want the mirror's raw cosignature %x", got, source.rawCosig) + } + if addCheckpointCalls != 2 || addEntriesCalls != 2 { + t.Errorf("mirror saw %d add-checkpoint and %d add-entries calls, want 2 and 2", addCheckpointCalls, addEntriesCalls) + } +} + +// TestMirrorCosignErrors covers the client's failure paths, with a mirror that +// refuses the checkpoint, a mirror demanding an upload_end the checkpoint +// cannot satisfy, and an unreachable mirror. +func TestMirrorCosignErrors(t *testing.T) { + _, err := NewMirrorClient("", NewSource(nil, testTilePrefix), mirrorID, testKey(t).PublicKey()) + if err == nil { + t.Error("NewMirrorClient with an empty base URL = nil error, want error") + } + + source := newSourceLog(t) + refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "checkpoint refused", http.StatusForbidden) + })) + defer refusing.Close() + m, err := NewMirrorClient(refusing.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil { + t.Fatal("Cosign against a refusing mirror = nil error, want error") + } + if !strings.Contains(err.Error(), "checkpoint refused") { + t.Errorf("Cosign error %q does not carry the mirror's response", err) + } + + mismatched := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusConflict) + io.WriteString(w, "9000\n0\n\n") + })) + defer mismatched.Close() + m, err = NewMirrorClient(mismatched.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil || !strings.Contains(err.Error(), "upload_end") { + t.Errorf("Cosign against a mismatched mirror = %s, want an upload_end error", err) + } + + unreachable, err := NewMirrorClient("http://127.0.0.1:1", NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = unreachable.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil { + t.Error("Cosign against an unreachable mirror = nil error, want error") + } + + // A mirror whose cosignature does not verify against the configured key. + lying := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + w.Write(source.cosigLine) + })) + defer lying.Close() + m, err = NewMirrorClient(lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, testKey(t).PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) } - if mockDB.latestCheckpoint.MirrorID == nil || *mockDB.latestCheckpoint.MirrorID != "existing.cosigner" { - t.Errorf("MirrorID: got %v, want %s", mockDB.latestCheckpoint.ID, "existing.cosigner") + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil || !strings.Contains(err.Error(), "verification") { + t.Errorf("Cosign with a mismatched key = %s, want a verification error", err) } } diff --git a/mtpublisher/mtpublishertest/mtpublishertest.go b/mtpublisher/mtpublishertest/mtpublishertest.go new file mode 100644 index 00000000000..554c56d0e57 --- /dev/null +++ b/mtpublisher/mtpublishertest/mtpublishertest.go @@ -0,0 +1,57 @@ +//go:build go1.27 + +// Package mtpublishertest provides an in-process cosigner for unit tests of the +// mtca and the mtpublisher. +package mtpublishertest + +import ( + "context" + "crypto" + "fmt" + "time" + + "github.com/letsencrypt/boulder/trees/checkpoint" + "github.com/letsencrypt/boulder/trees/cosignature" +) + +// TestMirror is a mtpublisher.Mirror that cosigns in process with its own key, +// without checking that the checkpoint's entries exist anywhere. +type TestMirror struct { + cosignerID string + cosigner *cosignature.Cosigner + lastSigned time.Time +} + +// NewTestMirror returns a TestMirror that cosigns checkpoints of the log with +// the given origin as the cosigner with ID mirrorID. +func NewTestMirror(mirrorID, origin string, signer crypto.Signer) (*TestMirror, error) { + cosigner, err := cosignature.NewCosigner(mirrorID, origin, signer) + if err != nil { + return nil, fmt.Errorf("creating mirror cosigner: %s", err) + } + return &TestMirror{cosignerID: mirrorID, cosigner: cosigner}, nil +} + +// ID returns the mirror's cosigner ID. +func (m *TestMirror) ID() string { + return m.cosignerID +} + +// Cosign cosigns the checkpoint and returns the raw cosignature. It errors if +// the checkpoint is not of the cosigner's log. +func (m *TestMirror) Cosign(_ context.Context, cp *checkpoint.Checkpoint, _ []byte) ([]byte, error) { + if cp.Origin != m.cosigner.Origin() { + return nil, fmt.Errorf("checkpoint origin %q is not this mirror's log %q", cp.Origin, m.cosigner.Origin()) + } + timestampedCosignature, err := m.cosigner.CosignCheckpoint(cp.Tree) + if err != nil { + return nil, err + } + m.lastSigned = time.Now() + return cosignature.RawSignature(timestampedCosignature) +} + +// LastSigned returns when Cosign last succeeded, zero before it has. +func (m *TestMirror) LastSigned() time.Time { + return m.lastSigned +} diff --git a/mtpublisher/mtpublishertest/mtpublishertest_test.go b/mtpublisher/mtpublishertest/mtpublishertest_test.go new file mode 100644 index 00000000000..9894ed71bb6 --- /dev/null +++ b/mtpublisher/mtpublishertest/mtpublishertest_test.go @@ -0,0 +1,73 @@ +//go:build go1.27 + +package mtpublishertest + +import ( + "crypto/mldsa" + "testing" + + "golang.org/x/mod/sumdb/tlog" + + "github.com/letsencrypt/boulder/privatekey" + "github.com/letsencrypt/boulder/trees/checkpoint" + "github.com/letsencrypt/boulder/trees/cosignature" +) + +const ( + mtcLogID = "44947.4.1.0.44" + mirrorID = "32473.9" +) + +// testMirror returns a LocalMirror and the deterministic key it cosigns with. +func testMirror(t *testing.T) (*TestMirror, *mldsa.PrivateKey) { + t.Helper() + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i + 1) + } + key, err := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed) + if err != nil { + t.Fatalf("NewPrivateKey: %s", err) + } + mirror, err := NewTestMirror(mirrorID, "oid/1.3.6.1.4.1."+mtcLogID, privatekey.NewDeterministicSigner(key)) + if err != nil { + t.Fatalf("NewTestMirror: %s", err) + } + return mirror, key +} + +// TestLocalMirrorCosign checks that the mirror's raw cosignature verifies +// through trees/cosignature. +func TestLocalMirrorCosign(t *testing.T) { + mirror, key := testMirror(t) + + if mirror.ID() != mirrorID { + t.Errorf("ID() = %q, want %q", mirror.ID(), mirrorID) + } + + cp := &checkpoint.Checkpoint{Origin: "oid/1.3.6.1.4.1." + mtcLogID, Tree: tlog.Tree{N: 512}} + raw, err := mirror.Cosign(t.Context(), cp, nil) + if err != nil { + t.Fatalf("Cosign: %s", err) + } + + verifier, err := cosignature.NewVerifier(mirrorID, key.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + _, err = verifier.SignatureLine(cp.Origin, cp.Tree, raw) + if err != nil { + t.Errorf("SignatureLine rejected the mirror's cosignature: %s", err) + } +} + +// TestLocalMirrorCosignRejects checks that the mirror only cosigns checkpoints +// of its own log. +func TestLocalMirrorCosignRejects(t *testing.T) { + mirror, _ := testMirror(t) + + _, err := mirror.Cosign(t.Context(), &checkpoint.Checkpoint{Origin: "oid/1.3.6.1.4.1.32473.999", Tree: tlog.Tree{N: 512}}, nil) + if err == nil { + t.Error("Cosign with another log's checkpoint = nil error, want error") + } +} diff --git a/mtpublisher/source.go b/mtpublisher/source.go new file mode 100644 index 00000000000..83bf240c35d --- /dev/null +++ b/mtpublisher/source.go @@ -0,0 +1,60 @@ +//go:build go1.27 + +package mtpublisher + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/letsencrypt/boulder/trees/mirror" + "github.com/letsencrypt/boulder/trees/subtree" + "github.com/letsencrypt/boulder/trees/tiles" + "golang.org/x/mod/sumdb/tlog" +) + +// simpleS3 matches the subset of the bs3.Client interface which we use, to +// allow simpler mocking in tests. +type simpleS3 interface { + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) + Bucket() string +} + +// Source builds consistency proofs and entry packages from the log's tile +// storage. In the future, when we want to support multiple mirrors, we may want +// to improve Source to memoize the tiles, consistency proofs, and entry +// packages built for the latest tree, purging the memo when the tree changes. +type Source struct { + s3c simpleS3 + tilePrefix string +} + +// NewSource returns a Source over the tiles stored in s3c under tilePrefix. +func NewSource(s3c simpleS3, tilePrefix string) *Source { + return &Source{s3c: s3c, tilePrefix: tilePrefix} +} + +// hashReaderForTree returns a HashReader that reads tree's hashes from the +// log's tiles. +func (s *Source) hashReaderForTree(ctx context.Context, tree tlog.Tree) tlog.HashReader { + return tlog.TileHashReader(tree, tiles.NewTileReader(ctx, s.s3c, s.tilePrefix)) +} + +// consistencyProof returns the RFC 6962 consistency proof from oldSize to tree. +func (s *Source) consistencyProof(ctx context.Context, tree tlog.Tree, oldSize int64) ([]tlog.Hash, error) { + return tlog.ProveTree(tree.N, oldSize, s.hashReaderForTree(ctx, tree)) +} + +// entryPackage returns the marshaled entry package covering p, proven against +// tree. +func (s *Source) entryPackage(ctx context.Context, tree tlog.Tree, p mirror.Package) ([]byte, error) { + entries, err := tiles.EntriesForPackage(ctx, s.s3c, p.EntriesStart, p.End, tree.N, s.tilePrefix) + if err != nil { + return nil, err + } + proof, err := subtree.ConsistencyProof(p.SubtreeStart, p.End, tree.N, s.hashReaderForTree(ctx, tree)) + if err != nil { + return nil, fmt.Errorf("proving subtree [%d, %d): %s", p.SubtreeStart, p.End, err) + } + return mirror.EntryPackage(entries, proof) +} diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json index db004b1531b..8295e7c4a9c 100644 --- a/test/config-next/mtpublisher.json +++ b/test/config-next/mtpublisher.json @@ -9,9 +9,18 @@ "caID": "44947.4.1", "logNumber": 44 }, - "mirrorID": "32473.9", - "mirrorKeyFile": "test/certs/sunlight/mirror.key.pem", - "mirrorPublicKeyFile": "test/certs/sunlight/mirror.pub.pem" + "mtcaPublicKeyFile": "test/certs/mtpki/mtca1.pub.pem", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/sunlight/mirror.pub.pem" + }, + "mirrorBaseURL": "http://bsunlight:4700", + "s3": { + "s3endpoint": "http://boulder-minio:9000", + "s3bucket": "boulder-mtc-tiles", + "awsConfigFile": "test/config-next/mtca-s3-config.ini", + "awsCredsFile": "test/secrets/mtca-s3-creds.ini" + } }, "syslog": { "stdoutlevel": 6, diff --git a/test/config/mtpublisher.json b/test/config/mtpublisher.json index db004b1531b..8295e7c4a9c 100644 --- a/test/config/mtpublisher.json +++ b/test/config/mtpublisher.json @@ -9,9 +9,18 @@ "caID": "44947.4.1", "logNumber": 44 }, - "mirrorID": "32473.9", - "mirrorKeyFile": "test/certs/sunlight/mirror.key.pem", - "mirrorPublicKeyFile": "test/certs/sunlight/mirror.pub.pem" + "mtcaPublicKeyFile": "test/certs/mtpki/mtca1.pub.pem", + "mirror": { + "id": "32473.9", + "publicKeyFile": "test/certs/sunlight/mirror.pub.pem" + }, + "mirrorBaseURL": "http://bsunlight:4700", + "s3": { + "s3endpoint": "http://boulder-minio:9000", + "s3bucket": "boulder-mtc-tiles", + "awsConfigFile": "test/config-next/mtca-s3-config.ini", + "awsCredsFile": "test/secrets/mtca-s3-creds.ini" + } }, "syslog": { "stdoutlevel": 6, diff --git a/test/sunlight/genkeys/main.go b/test/sunlight/genkeys/main.go index 383934ecbbb..1cf2dcb9e96 100644 --- a/test/sunlight/genkeys/main.go +++ b/test/sunlight/genkeys/main.go @@ -169,20 +169,6 @@ func main2() error { return err } - mirrorPKCS8, err := x509.MarshalPKCS8PrivateKey(mirrorKey) - if err != nil { - return err - } - mirrorKeyFile, err := os.OpenFile(path.Join(*outputDir, "mirror.key.pem"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - defer mirrorKeyFile.Close() - err = pem.Encode(mirrorKeyFile, &pem.Block{Type: "PRIVATE KEY", Bytes: mirrorPKCS8}) - if err != nil { - return err - } - err = rootsFile(path.Join(*outputDir, "ctlog-roots.pem")) if err != nil { return err From bb0be09ecdf04db5a92bdb8a362425c1731f2be8 Mon Sep 17 00:00:00 2001 From: Samantha Date: Tue, 25 Aug 2026 12:03:29 -0400 Subject: [PATCH 05/13] mtpublisher: Obtain cosignature from sign-subtree endpoint --- mtpublisher/mirror.go | 35 ++++++++++++++++++++++++++++++--- mtpublisher/mtpublisher_test.go | 21 ++++++++++++++++---- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index 07d7f33d0b7..ee316c85d62 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -223,8 +223,27 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests) } +// signSubtree requests the mirror's zero timestamp signature over the whole +// tree from the c2sp.org/tlog-witness sign-subtree endpoint, presenting the +// checkpoint note carrying the cosignature lines add-entries returned. +func (m *MirrorClient) signSubtree(ctx context.Context, tree tlog.Tree, signedNote []byte) ([]byte, error) { + body, err := mirror.SignSubtreeRequest(0, tree.N, tree.Hash, nil, signedNote) + if err != nil { + return nil, err + } + status, respBody, err := m.post(ctx, "/sign-subtree", "", false, body) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + } + return respBody, nil +} + // 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. +// and returns the mirror's raw signature over the whole tree from sign-subtree, +// 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) @@ -238,12 +257,22 @@ func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, si return nil, fmt.Errorf("add-entries: %w", err) } - // Verify the mirror's cosignature. + // Exchange the mirror's cosignature for its subtree signature. + noteForSignSubtree, err := cp.SignedNoteForSignSubtree(mirrorCosignatureLines) + if err != nil { + return nil, fmt.Errorf("assembling the sign-subtree note: %w", err) + } + subtreeCosignatureLines, err := m.signSubtree(ctx, cp.Tree, noteForSignSubtree) + if err != nil { + return nil, fmt.Errorf("sign-subtree: %w", err) + } + + // Verify the mirror's signature. noteText, err := cp.Marshal() if err != nil { return nil, fmt.Errorf("marshaling the checkpoint: %w", err) } - timestampedMirrorCosignature, err := cosignature.TimestampedSignature(noteText, mirrorCosignatureLines, m.verifier) + timestampedMirrorCosignature, err := cosignature.TimestampedSignature(noteText, subtreeCosignatureLines, m.verifier) if err != nil { return nil, fmt.Errorf("cosignature failed verification: %w", err) } diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index 456ad621806..cc7cfd0b225 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -9,6 +9,7 @@ import ( "crypto/mldsa" "encoding/base64" "encoding/binary" + "fmt" "io" "net/http" "net/http/httptest" @@ -473,12 +474,13 @@ func parseUploadHeader(t *testing.T, body []byte) (int64, []byte) { // answers the first add-checkpoint with "409 Conflict" at size 300 so the // client must prove consistency from there, then answers the first add-entries // with "202 Accepted" at entry 512 and a ticket the client must echo before the -// "200 Success" carrying the cosignature line. +// "200 Success" carrying the cosignature line, which the client presents at +// sign-subtree for the subtree signature over the whole tree. func TestMirrorCosign(t *testing.T) { source := newSourceLog(t) line := string(source.cosigLine) - var addCheckpointCalls, addEntriesCalls int + var addCheckpointCalls, addEntriesCalls, signSubtreeCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := requestBody(t, r) switch r.URL.Path { @@ -528,6 +530,17 @@ func TestMirrorCosign(t *testing.T) { } io.WriteString(w, line) } + case "/sign-subtree": + signSubtreeCalls++ + header, note, ok := bytes.Cut(body, []byte("\n\n")) + expectHeader := fmt.Sprintf("subtree 0 %d\n%s", source.newer.N, source.newer.Hash) + if !ok || string(header) != expectHeader { + t.Errorf("sign-subtree header %q, want %q", header, expectHeader) + } + if !bytes.HasSuffix(note, source.cosigLine) { + t.Errorf("sign-subtree note %q does not end with the add-entries cosignature line", note) + } + io.WriteString(w, line) default: t.Errorf("unexpected request to %s", r.URL.Path) } @@ -545,8 +558,8 @@ func TestMirrorCosign(t *testing.T) { if !bytes.Equal(got, source.rawCosig) { t.Errorf("Cosign = %x, want the mirror's raw cosignature %x", got, source.rawCosig) } - if addCheckpointCalls != 2 || addEntriesCalls != 2 { - t.Errorf("mirror saw %d add-checkpoint and %d add-entries calls, want 2 and 2", addCheckpointCalls, addEntriesCalls) + 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) } } From 6158fbc7ef0641d2eda269037d14b98942a5ee55 Mon Sep 17 00:00:00 2001 From: Samantha Date: Tue, 1 Sep 2026 17:20:00 -0400 Subject: [PATCH 06/13] Addressing comments and adjusting for future CQRP changes --- cmd/boulder-mtpublisher/main.go | 39 +- docker-compose.yml | 2 + mtpublisher/mirror.go | 62 +++- mtpublisher/mtpublisher.go | 11 +- mtpublisher/mtpublisher_test.go | 338 ++++++++---------- .../mtpublishertest/mtpublishertest_test.go | 22 +- mtpublisher/source.go | 2 +- test/config-next/mtpublisher.json | 6 +- test/config/mtpublisher.json | 6 +- test/sunlight/entrypoint.sh | 0 trees/checkpoint/checkpoint.go | 18 - trees/checkpoint/checkpoint_test.go | 28 +- trees/cosignature/cosignature.go | 18 - 13 files changed, 262 insertions(+), 290 deletions(-) mode change 100644 => 100755 test/sunlight/entrypoint.sh diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index 96891515893..f5ba1f69157 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -14,6 +14,7 @@ import ( "github.com/letsencrypt/boulder/bs3" "github.com/letsencrypt/boulder/cmd" "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/issuance" "github.com/letsencrypt/boulder/mtpublisher" "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/trees/issuancelog" @@ -33,17 +34,27 @@ type Config struct { // match the mtca's. LogID issuancelog.ID `validate:"required"` - // MTCAPublicKeyFile holds the PEM-encoded ML-DSA-44 public key the mtca - // cosigns checkpoints with, used to reconstruct each checkpoint's - // signed note from the database. - MTCAPublicKeyFile string `validate:"required"` + // MTCACertFile holds the PEM-encoded certificate of the mtca, whose + // ML-DSA-44 public key is used to reconstruct each checkpoint's signed + // note from the database. + MTCACertFile string `validate:"required"` - // Mirror identifies the mirror this publisher submits to. - Mirror cmd.MirrorConfig `validate:"required"` + // Mirror identifies the mirror this publisher submits to. Note: this is + // temporary until we start loading support multiple mirrors sourced from + // https://www.gstatic.com/mtcs/cosigners/v1/cosigners.json (schema: + // https://www.gstatic.com/mtcs/cosigners/v1/cosigners_schema.json). + Mirror struct { + // ID is the mirror's ID (e.g. "32473.9"). + ID string `validate:"required"` - // MirrorBaseURL is the base URL of the mirror's tlog-mirror submission - // endpoints (e.g. "http://localhost:4700"). - MirrorBaseURL string `validate:"required,url"` + // PublicKeyFile holds the mirror's PEM-encoded ML-DSA-44 public + // key. + PublicKeyFile string `validate:"required"` + + // BaseURL is the base URL of the mirror's tlog-mirror submission + // endpoints (e.g. "http://localhost:4700"). + BaseURL string `validate:"required,url"` + } // S3 locates the source log's tile storage, which the publisher reads // entries and proof hashes from when submitting to the mirror. @@ -100,12 +111,16 @@ func main() { 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") + caCert, err := issuance.LoadCertificate(c.MTPublisher.MTCACertFile) + cmd.FailOnError(err, "Loading MTCA certificate") + caPubKey, ok := caCert.PublicKey.(*mldsa.PublicKey) + if !ok { + cmd.Fail(fmt.Sprintf("MTCA certificate public key is %T, must be ML-DSA-44", caCert.PublicKey)) + } s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger) cmd.FailOnError(err, "Loading S3 config") - mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.MirrorBaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, pubKey) + mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.Mirror.BaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, pubKey) cmd.FailOnError(err, "Creating mirror client") publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger) diff --git a/docker-compose.yml b/docker-compose.yml index 32452506871..772d94bb6f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,8 @@ services: condition: service_started bminio: condition: service_healthy + bsunlight: + condition: service_started entrypoint: test/entrypoint.sh working_dir: &boulder_working_dir /boulder diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index ee316c85d62..c9874bf4de3 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -27,6 +27,19 @@ import ( // 4KB each. const maxMirrorResponseSize = 64 << 10 +// maxErrorBodySize caps how much of an unexpected mirror response body is +// quoted in an error. +const maxErrorBodySize = 400 + +// errorBody returns respBody trimmed for quoting in an error. +func errorBody(respBody []byte) string { + body := strings.TrimSpace(string(respBody)) + if len(body) > maxErrorBodySize { + return body[:maxErrorBodySize] + "..." + } + return body +} + var _ Mirror = (*MirrorClient)(nil) // MirrorClient is a Mirror that uses the c2sp.org/tlog-mirror submission @@ -121,7 +134,8 @@ func (m *MirrorClient) post(ctx context.Context, path, contentType string, compr // 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. +// retries once. An up-to-date mirror still receives the checkpoint, with an +// empty proof, since that is the only way to obtain its cosignature. func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signedNote []byte) error { oldSize := m.oldSize retried := false @@ -150,16 +164,17 @@ func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signed 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) + mirrorSize, err := mirror.ParseSizeResponse(respBody) if err != nil { return err } + if retried { + return fmt.Errorf("add-checkpoint at tree size %d got 409 with mirror tree size %d after retrying", tree.N, mirrorSize) + } + retried = true + oldSize = mirrorSize default: - return fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + return fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) } } } @@ -174,10 +189,9 @@ const maxAddEntriesRequests = 100 // 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 + m.nextEntry = min(m.nextEntry, tree.N) for range maxAddEntriesRequests { - packages, err := mirror.Packages(start, tree.N, mirror.MaxPackagesPerRequest) + packages, err := mirror.Packages(m.nextEntry, tree.N, mirror.MaxPackagesPerRequest) if err != nil { return nil, err } @@ -189,7 +203,7 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. } bodies = append(bodies, body) } - reqBody, err := mirror.AddEntriesRequest(origin, start, tree.N, ticket, bodies) + reqBody, err := mirror.AddEntriesRequest(origin, m.nextEntry, tree.N, m.ticket, bodies) if err != nil { return nil, err } @@ -199,6 +213,9 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. } switch status { case http.StatusOK: + if len(respBody) == 0 { + return nil, errors.New("mirror returned no cosignature lines") + } m.nextEntry = tree.N m.ticket = nil return respBody, nil @@ -209,15 +226,21 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. return nil, err } if info.TreeSize != tree.N { + // The spec has the client adopt the advertised tree size, but + // Cosign set the mirror's pending checkpoint to tree.N with + // add-checkpoint before uploading, so the mirror SHOULD have + // echoed it, and a cosignature over any other tree size is no + // use here. return nil, fmt.Errorf("mirror wants upload_end %d, checkpoint size is %d", info.TreeSize, tree.N) } - start = info.NextEntry - ticket = info.Ticket + if info.NextEntry > tree.N { + return nil, fmt.Errorf("mirror wants upload_start %d, checkpoint size is %d", info.NextEntry, tree.N) + } m.nextEntry = info.NextEntry m.ticket = bytes.Clone(info.Ticket) default: - return nil, fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + return nil, fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) } } return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests) @@ -236,14 +259,17 @@ func (m *MirrorClient) signSubtree(ctx context.Context, tree tlog.Tree, signedNo return nil, err } if status != http.StatusOK { - return nil, fmt.Errorf("mirror returned status %d: %s", status, strings.TrimSpace(string(respBody))) + return nil, fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) } return respBody, nil } // Cosign runs the c2sp.org/tlog-mirror submission protocol for the checkpoint // and returns the mirror's raw signature over the whole tree from sign-subtree, -// verified against the mirror's key. +// verified against the mirror's key. A mirror that is already up to date, +// whether from an earlier submission whose cosignature was never stored or +// from another submitter, still gets the full exchange, since that is the only +// way to obtain its cosignature. func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNoteForMirror []byte) ([]byte, error) { // Submit the checkpoint to the mirror. err := m.addCheckpoint(ctx, cp.Tree, signedNoteForMirror) @@ -258,7 +284,7 @@ func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, si } // Exchange the mirror's cosignature for its subtree signature. - noteForSignSubtree, err := cp.SignedNoteForSignSubtree(mirrorCosignatureLines) + noteForSignSubtree, err := cp.SignedNote(mirrorCosignatureLines) if err != nil { return nil, fmt.Errorf("assembling the sign-subtree note: %w", err) } @@ -272,7 +298,7 @@ func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, si if err != nil { return nil, fmt.Errorf("marshaling the checkpoint: %w", err) } - timestampedMirrorCosignature, err := cosignature.TimestampedSignature(noteText, subtreeCosignatureLines, m.verifier) + timestampedMirrorCosignature, err := m.verifier.FilterByVerify(noteText, subtreeCosignatureLines) if err != nil { return nil, fmt.Errorf("cosignature failed verification: %w", err) } diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 910d010aa02..4ac473e1599 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -102,16 +102,21 @@ func (p *mtpublisher) Publish(ctx context.Context) error { if len(latest.MTCASignature) == 0 { return fmt.Errorf("checkpoint %d (%s size %d) has no MTCA signature", latest.ID, latest.MTCLogID, latest.TreeSize) } - caCosignatureLine, err := p.caVerifier.SignatureLine(cp.Origin, tree, latest.MTCASignature) + caCosignatureLine, err := cosignature.SignatureLine(p.caVerifier.Name(), p.caVerifier.KeyHash(), latest.MTCASignature) if err != nil { return fmt.Errorf("checkpoint %d MTCA signature: %w", latest.ID, err) } - // Reconstruct the signed note for submission to the mirror. - signedNoteForMirror, err := cp.SignedNoteForMirror(caCosignatureLine) + // Reconstruct the signed note for submission to the mirror, and verify + // the MTCA signature before submitting it. + signedNoteForMirror, err := cp.SignedNote(caCosignatureLine) if err != nil { return fmt.Errorf("assembling checkpoint %d signed note: %w", latest.ID, err) } + _, _, err = checkpoint.Open(signedNoteForMirror, p.caVerifier) + if err != nil { + return fmt.Errorf("checkpoint %d MTCA signature: %w", latest.ID, err) + } // Submit the signed checkpoint to the mirror for cosigning. mirrorRawCosig, err := p.mirror.Cosign(ctx, cp, signedNoteForMirror) diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index cc7cfd0b225..05e489d4de4 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -20,19 +20,16 @@ import ( "golang.org/x/mod/sumdb/tlog" "github.com/letsencrypt/boulder/bs3/bs3test" - - "github.com/letsencrypt/boulder/db" blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/mtpublisher/mtpublishertest" "github.com/letsencrypt/boulder/privatekey" - "github.com/letsencrypt/boulder/sa" - "github.com/letsencrypt/boulder/test/vars" "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" "github.com/letsencrypt/boulder/trees/pubkey" "github.com/letsencrypt/boulder/trees/tiles" + "github.com/letsencrypt/boulder/trees/treedb" ) const ( @@ -42,43 +39,40 @@ const ( var testLogID = issuancelog.ID{CAID: "44947.4.1", LogNumber: 44} -func setupDB(t *testing.T) *db.WrappedMap { - t.Helper() +// fakeCheckpointDB holds the latest checkpoint of one log, or none for a log +// that is not initialized, and stores the mirror cosignature on it. The mtca +// tests truncate the mtcmeta database and run in parallel with these, so they +// cannot share it. +type fakeCheckpointDB struct { + latest *treedb.CheckpointModel +} - dbMap, err := sa.DBMapForTest(vars.DBConnMTCMeta_44947_4_1_0_44FullPerms) - if err != nil { - t.Fatalf("opening mtcmeta dbMap: %s", err) +func (f *fakeCheckpointDB) LatestCheckpoint(_ context.Context, mtcLogID string) (*treedb.CheckpointModel, error) { + if f.latest == nil || f.latest.MTCLogID != mtcLogID { + return nil, treedb.ErrIssuanceLogNotInitialized } - truncate := func(ctx context.Context) error { - _, err := dbMap.ExecContext(ctx, "TRUNCATE TABLE checkpoints") - if err != nil { - return err - } - _, err = dbMap.ExecContext(ctx, "TRUNCATE TABLE latestCheckpoint") - return err - } - err = truncate(t.Context()) - if err != nil { - t.Fatalf("truncating tables: %s", err) + return f.latest, nil +} + +func (f *fakeCheckpointDB) AddMirrorSignature(_ context.Context, id int64, mirrorID string, mirrorSignature []byte, mtcLogID string) error { + if f.latest == nil || id != f.latest.ID || mtcLogID != f.latest.MTCLogID { + return fmt.Errorf("adding mirror signature: checkpoint %d for %s not found", id, mtcLogID) } - t.Cleanup(func() { - err := truncate(context.Background()) - if err != nil { - t.Logf("cleaning up tables: %s", err) - } - }) - return dbMap + f.latest.MirrorID = &mirrorID + f.latest.MirrorSignature = mirrorSignature + return nil } -// setLatest points latestCheckpoint at the checkpoint with the given id, as the -// sequencer does when it adopts a checkpoint. -func setLatest(t *testing.T, dbMap *db.WrappedMap, logID string, id int64) { +// testPublisher returns a publisher over checkpoints whose mirror cosigns with +// key. +func testPublisher(t *testing.T, key *mldsa.PrivateKey, checkpoints *fakeCheckpointDB) *mtpublisher { t.Helper() - _, err := dbMap.ExecContext(t.Context(), - "REPLACE INTO latestCheckpoint (mtcLogID, id) VALUES (?, ?)", logID, id) + p, err := New(nil, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) if err != nil { - t.Fatalf("pointing latestCheckpoint at %d: %s", id, err) + t.Fatalf("New: %s", err) } + p.treedb = checkpoints + return p } // testCAKey returns a deterministic ML-DSA-44 key standing in for the mtca's @@ -97,7 +91,7 @@ func testCAKey(t *testing.T) *mldsa.PrivateKey { } // caSignature returns the raw MTCA signature for a checkpoint of treeSize with -// a zero root hash, as insertCheckpoint stores. +// a zero root hash, as testCheckpoint holds. func caSignature(t *testing.T, treeSize int64) []byte { t.Helper() ca, err := cosignature.NewCosigner(testLogID.CAID, testLogID.Origin(), privatekey.NewDeterministicSigner(testCAKey(t))) @@ -115,31 +109,17 @@ func caSignature(t *testing.T, treeSize int64) []byte { return raw } -func insertCheckpoint(t *testing.T, dbMap *db.WrappedMap, logID string, treeSize int64) int64 { +// testCheckpoint returns a checkpoint of treeSize with a zero root hash, signed +// by the MTCA and awaiting the mirror cosignature. +func testCheckpoint(t *testing.T, treeSize int64) *treedb.CheckpointModel { t.Helper() - - res, err := dbMap.ExecContext(t.Context(), - "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", - logID, caSignature(t, treeSize), treeSize, make([]byte, 32)) - if err != nil { - t.Fatalf("inserting checkpoint (%s size %d): %s", logID, treeSize, err) + return &treedb.CheckpointModel{ + ID: 1, + MTCLogID: mtcLogID, + MTCASignature: caSignature(t, treeSize), + TreeSize: treeSize, + RootHash: make([]byte, 32), } - id, err := res.LastInsertId() - if err != nil { - t.Fatalf("reading insert id: %s", err) - } - return id -} - -func lacksCosignature(t *testing.T, dbMap *db.WrappedMap, id int64) bool { - t.Helper() - var count int64 - err := dbMap.SelectOne(t.Context(), &count, - "SELECT COUNT(*) FROM checkpoints WHERE id = ? AND mirrorID IS NULL AND mirrorSignature IS NULL", id) - if err != nil { - t.Fatalf("querying checkpoint %d: %s", id, err) - } - return count == 1 } // testKey returns a deterministic ML-DSA-44 key so the test can verify the @@ -157,7 +137,7 @@ func testKey(t *testing.T) *mldsa.PrivateKey { return key } -// testMirror returns a LocalMirror that cosigns with key. +// testMirror returns a TestMirror that cosigns with key. func testMirror(t *testing.T, key *mldsa.PrivateKey) *mtpublishertest.TestMirror { t.Helper() mirror, err := mtpublishertest.NewTestMirror(mirrorID, testLogID.Origin(), privatekey.NewDeterministicSigner(key)) @@ -168,66 +148,33 @@ func testMirror(t *testing.T, key *mldsa.PrivateKey) *mtpublishertest.TestMirror } func TestPublish(t *testing.T) { - dbMap := setupDB(t) key := testKey(t) - p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) - if err != nil { - t.Fatalf("New: %s", err) - } + checkpoints := &fakeCheckpointDB{} + p := testPublisher(t, key, checkpoints) - // A pass over an empty table is a no-op. - err = p.Publish(t.Context()) + // A pass over a log that is not initialized is a no-op. + err := p.Publish(t.Context()) if err != nil { - t.Fatalf("p.Publish() on an empty table: %s", err) + t.Fatalf("p.Publish() on a log that is not initialized: %s", err) } - // An older checkpoint that is not cosigned, which must be left untouched. - olderCheckpointID := insertCheckpoint(t, dbMap, mtcLogID, 256) - // The latest checkpoint, which we expect to be cosigned by p.Publish(). - latestCheckpointID := insertCheckpoint(t, dbMap, mtcLogID, 512) - setLatest(t, dbMap, mtcLogID, latestCheckpointID) - - // A checkpoint for another log that was somehow inserted into this table, - // which must be left untouched thanks to the mtcLogID guard. - otherLogCheckpointID := insertCheckpoint(t, dbMap, "44947.4.2.0.99", 1024) - - // A precommitted checkpoint the CA has not signed yet, which must be left - // untouched because latestCheckpoint does not reference it, even though its - // tree is the largest. - res, err := dbMap.ExecContext(t.Context(), - "INSERT INTO checkpoints (mtcLogID, treeSize, rootHash) VALUES (?, ?, ?)", - mtcLogID, int64(2048), make([]byte, 32)) - if err != nil { - t.Fatalf("inserting precommitted checkpoint: %s", err) - } - precommitID, err := res.LastInsertId() - if err != nil { - t.Fatalf("reading insert id: %s", err) - } + latest := testCheckpoint(t, 512) + checkpoints.latest = latest err = p.Publish(t.Context()) if err != nil { t.Fatalf("p.Publish(): %s", err) } - type row struct { - MirrorID string `db:"mirrorID"` - MirrorSig []byte `db:"mirrorSignature"` + if latest.MirrorID == nil { + t.Fatal("latest checkpoint was not cosigned") } - var cosigned row - err = dbMap.SelectOne(t.Context(), &cosigned, "SELECT mirrorID, mirrorSignature FROM checkpoints WHERE id = ?", latestCheckpointID) - if err != nil { - t.Fatalf("selecting the latest checkpoint: %s", err) - } - - // Check that the latest checkpoint was cosigned, and the others were - // untouched. - if cosigned.MirrorID != mirrorID { - t.Errorf("mirrorID = %q, want %q", cosigned.MirrorID, mirrorID) + if *latest.MirrorID != mirrorID { + t.Errorf("mirrorID = %q, want %q", *latest.MirrorID, mirrorID) } - if len(cosigned.MirrorSig) != mldsa.MLDSA44SignatureSize { - t.Fatalf("latest checkpoint's mirrorSignature is %d bytes, want %d", len(cosigned.MirrorSig), mldsa.MLDSA44SignatureSize) + 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()) @@ -235,96 +182,44 @@ func TestPublish(t *testing.T) { t.Fatalf("NewVerifier: %s", err) } text := "oid/1.3.6.1.4.1." + mtcLogID + "\n512\n" + base64.StdEncoding.EncodeToString(make([]byte, 32)) + "\n" - timestampedSignature := append(make([]byte, 8), cosigned.MirrorSig...) + 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") } - if !lacksCosignature(t, dbMap, olderCheckpointID) { - t.Error("older checkpoint was cosigned, only the latest should be") - } - if !lacksCosignature(t, dbMap, otherLogCheckpointID) { - t.Errorf("another log's checkpoint (id=%d) was cosigned, despite the mtcLogID guard", otherLogCheckpointID) - } - if !lacksCosignature(t, dbMap, precommitID) { - t.Error("precommitted checkpoint was cosigned before the CA signed it") - } } // TestPublishRejectsBadMTCASignature checks that a checkpoint whose stored // MTCA signature does not verify is neither submitted nor cosigned. func TestPublishRejectsBadMTCASignature(t *testing.T) { - dbMap := setupDB(t) - key := testKey(t) - p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) - if err != nil { - t.Fatalf("New: %s", err) - } - // A well-formed MTCA signature over the wrong tree size. - res, err := dbMap.ExecContext(t.Context(), - "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash) VALUES (?, ?, ?, ?)", - mtcLogID, caSignature(t, 999), int64(512), make([]byte, 32)) - if err != nil { - t.Fatalf("inserting checkpoint: %s", err) - } - id, err := res.LastInsertId() - if err != nil { - t.Fatalf("reading insert id: %s", err) - } - setLatest(t, dbMap, mtcLogID, id) + latest := testCheckpoint(t, 512) + latest.MTCASignature = caSignature(t, 999) + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) - err = p.Publish(t.Context()) + err := p.Publish(t.Context()) if err == nil { t.Error("publish with a bad MTCA signature = nil error, want error") } - if !lacksCosignature(t, dbMap, id) { + if latest.MirrorID != nil || latest.MirrorSignature != nil { t.Error("cosignature was stored despite the MTCA signature failing verification") } } func TestPublishWhenLatestAlreadySigned(t *testing.T) { - dbMap := setupDB(t) - key := testKey(t) - p, err := New(dbMap, time.Second, testLogID, testCAKey(t).PublicKey(), testMirror(t, key), blog.NewMock()) - if err != nil { - t.Fatalf("New: %s", err) - } - - // Insert a checkpoint that is already cosigned, which must be left - // untouched. - res, err := dbMap.ExecContext(t.Context(), - "INSERT INTO checkpoints (mtcLogID, mtcaSignature, treeSize, rootHash, mirrorID, mirrorSignature) VALUES (?, ?, ?, ?, ?, ?)", - mtcLogID, caSignature(t, 512), int64(512), make([]byte, 32), "existing.cosigner", []byte("already-signed-bruh")) - if err != nil { - t.Fatalf("inserting cosigned checkpoint: %s", err) - } - cosignedID, err := res.LastInsertId() - if err != nil { - t.Fatalf("reading insert id: %s", err) - } - setLatest(t, dbMap, mtcLogID, cosignedID) - - // Insert an older (non-latest) checkpoint that is not cosigned, which must - // be left untouched. - olderID := insertCheckpoint(t, dbMap, mtcLogID, 256) + // The latest checkpoint is already cosigned, which must be left untouched. + existingMirrorID := "existing.cosigner" + latest := testCheckpoint(t, 512) + latest.MirrorID = &existingMirrorID + latest.MirrorSignature = []byte("already-signed-bruh") + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) - err = p.Publish(t.Context()) + err := p.Publish(t.Context()) if err != nil { t.Fatalf("p.Publish(): %s", err) } - // The latest checkpoint is already cosigned, so the pass must leave both - // checkpoints untouched. - if !lacksCosignature(t, dbMap, olderID) { - t.Error("older checkpoint was cosigned, the pass should have stopped at the signed latest") - } - var mirrorCosignature []byte - err = dbMap.SelectOne(t.Context(), &mirrorCosignature, "SELECT mirrorSignature FROM checkpoints WHERE mtcLogID = ? AND treeSize = 512", mtcLogID) - if err != nil { - t.Fatalf("selecting the cosigned checkpoint: %s", err) - } - if string(mirrorCosignature) != "already-signed-bruh" { - t.Errorf("existing cosignature was replaced: %q", mirrorCosignature) + if *latest.MirrorID != existingMirrorID || string(latest.MirrorSignature) != "already-signed-bruh" { + t.Errorf("existing cosignature was replaced: %s %q", *latest.MirrorID, latest.MirrorSignature) } } @@ -394,13 +289,13 @@ func newSourceLog(t *testing.T) *sourceLog { if err != nil { t.Fatalf("NewVerifier: %s", err) } - caLine, err := caVerifier.SignatureLine(cp.Origin, newer, rawCA) + caLine, err := cosignature.SignatureLine(caVerifier.Name(), caVerifier.KeyHash(), rawCA) if err != nil { t.Fatalf("SignatureLine: %s", err) } - signedNote, err := cp.SignedNoteForMirror(caLine) + signedNote, err := cp.SignedNote(caLine) if err != nil { - t.Fatalf("SignedNoteForMirror: %s", err) + t.Fatalf("SignedNote: %s", err) } mirrorSeed := make([]byte, 32) @@ -427,7 +322,7 @@ func newSourceLog(t *testing.T) *sourceLog { if err != nil { t.Fatalf("NewVerifier: %s", err) } - cosigLine, err := mirrorVerifier.SignatureLine(cp.Origin, newer, rawCosig) + cosigLine, err := cosignature.SignatureLine(mirrorVerifier.Name(), mirrorVerifier.KeyHash(), rawCosig) if err != nil { t.Fatalf("SignatureLine: %s", err) } @@ -465,7 +360,7 @@ func parseUploadHeader(t *testing.T, body []byte) (int64, []byte) { t.Helper() originLen := int(binary.BigEndian.Uint16(body[:2])) rest := body[2+originLen:] - uploadStart := int64(binary.BigEndian.Uint64(rest[:8])) + uploadStart := int64(binary.BigEndian.Uint64(rest[:8])) //nolint:gosec // G115: the client writes upload_start from an int64 entry index. ticketLen := int(binary.BigEndian.Uint16(rest[16:18])) return uploadStart, rest[18 : 18+ticketLen] } @@ -493,7 +388,7 @@ func TestMirrorCosign(t *testing.T) { } w.Header().Set("Content-Type", "text/x.tlog.size") w.WriteHeader(http.StatusConflict) - io.WriteString(w, "300\n") + fmt.Fprint(w, "300\n") default: header, _, ok := bytes.Cut(body, []byte("\n\n")) lines := strings.Split(string(header), "\n") @@ -523,12 +418,12 @@ func TestMirrorCosign(t *testing.T) { } w.Header().Set("Content-Type", "text/x.tlog.mirror-info") w.WriteHeader(http.StatusAccepted) - io.WriteString(w, "700\n512\n"+base64.StdEncoding.EncodeToString([]byte("resume"))+"\n") + fmt.Fprint(w, "700\n512\n"+base64.StdEncoding.EncodeToString([]byte("resume"))+"\n") default: if uploadStart != 512 || string(ticket) != "resume" { t.Errorf("second add-entries upload_start = %d ticket = %q, want 512 and \"resume\"", uploadStart, ticket) } - io.WriteString(w, line) + fmt.Fprint(w, line) } case "/sign-subtree": signSubtreeCalls++ @@ -540,7 +435,7 @@ func TestMirrorCosign(t *testing.T) { if !bytes.HasSuffix(note, source.cosigLine) { t.Errorf("sign-subtree note %q does not end with the add-entries cosignature line", note) } - io.WriteString(w, line) + fmt.Fprint(w, line) default: t.Errorf("unexpected request to %s", r.URL.Path) } @@ -563,6 +458,66 @@ func TestMirrorCosign(t *testing.T) { } } +// TestMirrorCosignAlreadyMirrored covers a publisher starting over against a +// mirror that already holds every entry: add-checkpoint at the mirror's own +// size with an empty proof, then an empty add-entries upload once the mirror +// advertises a next entry equal to the tree size. +func TestMirrorCosignAlreadyMirrored(t *testing.T) { + source := newSourceLog(t) + line := string(source.cosigLine) + + var addEntriesCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := requestBody(t, r) + switch r.URL.Path { + case "/add-checkpoint": + if bytes.HasPrefix(body, []byte("old 0\n\n")) { + w.Header().Set("Content-Type", "text/x.tlog.size") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, "%d\n", source.newer.N) + return + } + expect := fmt.Sprintf("old %d\n\n", source.newer.N) + if !bytes.HasPrefix(body, []byte(expect)) { + t.Errorf("add-checkpoint body %q does not claim old size %d with an empty proof", body, source.newer.N) + } + case "/add-entries": + addEntriesCalls++ + uploadStart, _ := parseUploadHeader(t, body) + if addEntriesCalls == 1 { + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "%d\n%d\n\n", source.newer.N, source.newer.N) + return + } + if uploadStart != source.newer.N { + t.Errorf("second add-entries upload_start = %d, want %d", uploadStart, source.newer.N) + } + fmt.Fprint(w, line) + case "/sign-subtree": + fmt.Fprint(w, line) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } + })) + defer srv.Close() + + m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + got, err := m.Cosign(t.Context(), source.cp, source.signedNote) + if err != nil { + t.Fatalf("Cosign: %s", err) + } + if !bytes.Equal(got, source.rawCosig) { + t.Errorf("Cosign = %x, want the mirror's raw cosignature %x", got, source.rawCosig) + } + if addEntriesCalls != 2 { + t.Errorf("mirror saw %d add-entries calls, want 2", addEntriesCalls) + } +} + // TestMirrorCosignErrors covers the client's failure paths, with a mirror that // refuses the checkpoint, a mirror demanding an upload_end the checkpoint // cannot satisfy, and an unreachable mirror. @@ -589,13 +544,34 @@ func TestMirrorCosignErrors(t *testing.T) { t.Errorf("Cosign error %q does not carry the mirror's response", err) } + overshooting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "%d\n%d\n\n", source.newer.N, source.newer.N+1) + })) + defer overshooting.Close() + m, err = NewMirrorClient(overshooting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(t.Context(), source.cp, source.signedNote) + if err == nil { + t.Fatal("Cosign against a mirror wanting upload_start past the tree size = nil error, want error") + } + if !strings.Contains(err.Error(), "upload_start") { + t.Errorf("Cosign error %q does not name upload_start", err) + } + mismatched := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/add-checkpoint" { return } w.Header().Set("Content-Type", "text/x.tlog.mirror-info") w.WriteHeader(http.StatusConflict) - io.WriteString(w, "9000\n0\n\n") + fmt.Fprint(w, "9000\n0\n\n") })) defer mismatched.Close() m, err = NewMirrorClient(mismatched.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) diff --git a/mtpublisher/mtpublishertest/mtpublishertest_test.go b/mtpublisher/mtpublishertest/mtpublishertest_test.go index 9894ed71bb6..195a6264ddd 100644 --- a/mtpublisher/mtpublishertest/mtpublishertest_test.go +++ b/mtpublisher/mtpublishertest/mtpublishertest_test.go @@ -18,7 +18,7 @@ const ( mirrorID = "32473.9" ) -// testMirror returns a LocalMirror and the deterministic key it cosigns with. +// testMirror returns a TestMirror and the deterministic key it cosigns with. func testMirror(t *testing.T) (*TestMirror, *mldsa.PrivateKey) { t.Helper() seed := make([]byte, 32) @@ -36,9 +36,9 @@ func testMirror(t *testing.T) (*TestMirror, *mldsa.PrivateKey) { return mirror, key } -// TestLocalMirrorCosign checks that the mirror's raw cosignature verifies +// TestMirrorCosign checks that the mirror's raw cosignature verifies // through trees/cosignature. -func TestLocalMirrorCosign(t *testing.T) { +func TestMirrorCosign(t *testing.T) { mirror, key := testMirror(t) if mirror.ID() != mirrorID { @@ -55,15 +55,23 @@ func TestLocalMirrorCosign(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - _, err = verifier.SignatureLine(cp.Origin, cp.Tree, raw) + line, err := cosignature.SignatureLine(verifier.Name(), verifier.KeyHash(), raw) if err != nil { - t.Errorf("SignatureLine rejected the mirror's cosignature: %s", err) + t.Fatalf("SignatureLine: %s", err) + } + text, err := cp.Marshal() + if err != nil { + t.Fatalf("Marshal: %s", err) + } + _, err = verifier.FilterByVerify(text, line) + if err != nil { + t.Errorf("the mirror's cosignature does not verify: %s", err) } } -// TestLocalMirrorCosignRejects checks that the mirror only cosigns checkpoints +// TestMirrorCosignRejects checks that the mirror only cosigns checkpoints // of its own log. -func TestLocalMirrorCosignRejects(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) diff --git a/mtpublisher/source.go b/mtpublisher/source.go index 83bf240c35d..810ac15a7e2 100644 --- a/mtpublisher/source.go +++ b/mtpublisher/source.go @@ -54,7 +54,7 @@ func (s *Source) entryPackage(ctx context.Context, tree tlog.Tree, p mirror.Pack } proof, err := subtree.ConsistencyProof(p.SubtreeStart, p.End, tree.N, s.hashReaderForTree(ctx, tree)) if err != nil { - return nil, fmt.Errorf("proving subtree [%d, %d): %s", p.SubtreeStart, p.End, err) + return nil, fmt.Errorf("fetching subtree consistency proof [%d, %d), tree size %d: %s", p.SubtreeStart, p.End, tree.N, err) } return mirror.EntryPackage(entries, proof) } diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json index 8295e7c4a9c..d730b8033c7 100644 --- a/test/config-next/mtpublisher.json +++ b/test/config-next/mtpublisher.json @@ -9,12 +9,12 @@ "caID": "44947.4.1", "logNumber": 44 }, - "mtcaPublicKeyFile": "test/certs/mtpki/mtca1.pub.pem", + "mtcaCertFile": "test/certs/mtpki/mtca1.cert.pem", "mirror": { "id": "32473.9", - "publicKeyFile": "test/certs/sunlight/mirror.pub.pem" + "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", + "baseURL": "http://bsunlight:4700" }, - "mirrorBaseURL": "http://bsunlight:4700", "s3": { "s3endpoint": "http://boulder-minio:9000", "s3bucket": "boulder-mtc-tiles", diff --git a/test/config/mtpublisher.json b/test/config/mtpublisher.json index 8295e7c4a9c..d730b8033c7 100644 --- a/test/config/mtpublisher.json +++ b/test/config/mtpublisher.json @@ -9,12 +9,12 @@ "caID": "44947.4.1", "logNumber": 44 }, - "mtcaPublicKeyFile": "test/certs/mtpki/mtca1.pub.pem", + "mtcaCertFile": "test/certs/mtpki/mtca1.cert.pem", "mirror": { "id": "32473.9", - "publicKeyFile": "test/certs/sunlight/mirror.pub.pem" + "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", + "baseURL": "http://bsunlight:4700" }, - "mirrorBaseURL": "http://bsunlight:4700", "s3": { "s3endpoint": "http://boulder-minio:9000", "s3bucket": "boulder-mtc-tiles", diff --git a/test/sunlight/entrypoint.sh b/test/sunlight/entrypoint.sh old mode 100644 new mode 100755 diff --git a/trees/checkpoint/checkpoint.go b/trees/checkpoint/checkpoint.go index fa400e03f23..c1c2e434568 100644 --- a/trees/checkpoint/checkpoint.go +++ b/trees/checkpoint/checkpoint.go @@ -122,24 +122,6 @@ func (c *Checkpoint) signedNote(signatureLines ...[]byte) ([]byte, error) { return assembled, nil } -// SignedNoteForMirror returns the checkpoint as a signed note carrying the MTCA -// cosignature line, for submission to a mirror. -func (c *Checkpoint) SignedNoteForMirror(caCosignatureLine []byte) ([]byte, error) { - if len(caCosignatureLine) == 0 { - return nil, errors.New("missing MTCA cosignature line") - } - return c.signedNote(caCosignatureLine) -} - -// SignedNoteForSignSubtree returns the checkpoint as a signed note carrying the -// cosignature lines the mirror returned from add-entries. -func (c *Checkpoint) SignedNoteForSignSubtree(mirrorCosignatureLines []byte) ([]byte, error) { - if len(mirrorCosignatureLines) == 0 { - return nil, errors.New("missing mirror cosignature lines") - } - return c.signedNote(mirrorCosignatureLines) -} - // SignedNoteForServing returns the checkpoint as a signed note carrying the // MTCA and mirror cosignature lines, for serving at the checkpoint path. func (c *Checkpoint) SignedNoteForServing(caCosignatureLine, mirrorCosignatureLine []byte) ([]byte, error) { diff --git a/trees/checkpoint/checkpoint_test.go b/trees/checkpoint/checkpoint_test.go index 80983654476..ba927a45854 100644 --- a/trees/checkpoint/checkpoint_test.go +++ b/trees/checkpoint/checkpoint_test.go @@ -178,8 +178,8 @@ func TestSignedNote(t *testing.T) { } } -// TestSignedNotes checks note assembly through both exported wrappers and -// that each rejects a missing signature line. +// TestSignedNotes checks note assembly through SignedNoteForServing and that +// it rejects a missing signature line. func TestSignedNotes(t *testing.T) { cp := &Checkpoint{Origin: "example.com/log", Tree: tlog.Tree{N: 5}} text, err := cp.Marshal() @@ -189,14 +189,6 @@ func TestSignedNotes(t *testing.T) { caLine := []byte("— ca sig\n") mirrorLine := []byte("— mirror sig\n") - forMirror, err := cp.SignedNoteForMirror(caLine) - if err != nil { - t.Fatalf("SignedNoteForMirror: %s", err) - } - if string(forMirror) != string(text)+"\n"+string(caLine) { - t.Errorf("SignedNoteForMirror = %q", forMirror) - } - forServing, err := cp.SignedNoteForServing(caLine, mirrorLine) if err != nil { t.Fatalf("SignedNoteForServing: %s", err) @@ -205,18 +197,6 @@ func TestSignedNotes(t *testing.T) { t.Errorf("SignedNoteForServing = %q", forServing) } - forSignSubtree, err := cp.SignedNoteForSignSubtree(mirrorLine) - if err != nil { - t.Fatalf("SignedNoteForSignSubtree: %s", err) - } - if string(forSignSubtree) != string(text)+"\n"+string(mirrorLine) { - t.Errorf("SignedNoteForSignSubtree = %q", forSignSubtree) - } - - _, err = cp.SignedNoteForMirror(nil) - if err == nil { - t.Error("SignedNoteForMirror without a line = nil error, want error") - } _, err = cp.SignedNoteForServing(nil, mirrorLine) if err == nil { t.Error("SignedNoteForServing without the MTCA line = nil error, want error") @@ -225,10 +205,6 @@ func TestSignedNotes(t *testing.T) { if err == nil { t.Error("SignedNoteForServing without the mirror line = nil error, want error") } - _, err = cp.SignedNoteForSignSubtree(nil) - if err == nil { - t.Error("SignedNoteForSignSubtree without lines = nil error, want error") - } } // TestOpenCheckpoint covers Open's happy path and its rejection of a note no diff --git a/trees/cosignature/cosignature.go b/trees/cosignature/cosignature.go index 897a05ec8e4..44c8ebcf5df 100644 --- a/trees/cosignature/cosignature.go +++ b/trees/cosignature/cosignature.go @@ -265,24 +265,6 @@ func signatureLineFor(name string, keyID uint32, timestampedSignature []byte) st return noteSignatureLinePrefix + name + " " + base64.StdEncoding.EncodeToString(idSignature) + "\n" } -// TimestampedSignature verifies signatureLine against noteText with verifier -// and returns the timestamped_signature by verifier's cosigner. An error is -// returned if noteText and signatureLine do not form a well-formed note or if -// verifier rejects the signature. Signatures from unknown keys are ignored. -func TimestampedSignature(noteText, signatureLine []byte, verifier *Verifier) ([]byte, error) { - n, err := note.Open(fmt.Appendf(nil, "%s\n%s", noteText, signatureLine), note.VerifierList(verifier)) - if err != nil { - return nil, fmt.Errorf("opening the cosigned note: %s", err) - } - // verifier is the only verifier in the list, so every signature in n.Sigs - // is by the verifier's cosigner, verified and length-checked. - idSignature, err := base64.StdEncoding.DecodeString(n.Sigs[0].Base64) - if err != nil { - return nil, fmt.Errorf("decoding the signature by %s: %s", verifier.keyName, err) - } - return idSignature[keyIDSize:], nil -} - // SignatureLine verifies rawSignature over the checkpoint described by origin // and tree, and reassembles the cosigner's note signature line, restoring the // zero timestamp RawSignature stripped. From 86395c57acc116b66e6e0cba588c373ff1c7596f Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 12:18:34 -0400 Subject: [PATCH 07/13] Address comments --- cmd/boulder-mtpublisher/main.go | 12 +++++++----- mtpublisher/mirror.go | 19 +++++++++---------- .../mtpublishertest/mtpublishertest_test.go | 9 ++------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index f5ba1f69157..7d5bb2a5e92 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -109,18 +109,20 @@ func main() { dbMap, err := sa.InitWrappedDb(c.MTPublisher.DB, scope, logger) cmd.FailOnError(err, "While initializing dbMap") - pubKey, err := loadMLDSAPublicKey(c.MTPublisher.Mirror.PublicKeyFile) - cmd.FailOnError(err, "Loading mirror public key") + s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger) + cmd.FailOnError(err, "Loading S3 config") + caCert, err := issuance.LoadCertificate(c.MTPublisher.MTCACertFile) cmd.FailOnError(err, "Loading MTCA certificate") caPubKey, ok := caCert.PublicKey.(*mldsa.PublicKey) if !ok { cmd.Fail(fmt.Sprintf("MTCA certificate public key is %T, must be ML-DSA-44", caCert.PublicKey)) } - s3c, err := bs3.FromConfig(c.MTPublisher.S3, logger) - cmd.FailOnError(err, "Loading S3 config") - mirror, err := mtpublisher.NewMirrorClient(c.MTPublisher.Mirror.BaseURL, mtpublisher.NewSource(s3c, c.MTPublisher.LogID.TilePrefix()), c.MTPublisher.Mirror.ID, pubKey) + 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) cmd.FailOnError(err, "Creating mirror client") publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger) diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index c9874bf4de3..9a0cd8fecb6 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -137,21 +137,20 @@ func (m *MirrorClient) post(ctx context.Context, path, contentType string, compr // retries once. An up-to-date mirror still receives the checkpoint, with an // empty proof, since that is the only way to obtain its cosignature. func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signedNote []byte) error { - oldSize := m.oldSize retried := false for { - if oldSize > tree.N { - return fmt.Errorf("mirror already holds size %d, checkpoint size is %d", oldSize, tree.N) + if m.oldSize > tree.N { + return fmt.Errorf("mirror already holds size %d, checkpoint size is %d", m.oldSize, tree.N) } var proof []tlog.Hash - if oldSize > 0 && oldSize < tree.N { - treeProof, err := m.src.consistencyProof(ctx, tree, oldSize) + if m.oldSize > 0 && m.oldSize < tree.N { + treeProof, err := m.src.consistencyProof(ctx, tree, m.oldSize) if err != nil { - return fmt.Errorf("proving consistency from size %d: %s", oldSize, err) + return fmt.Errorf("proving consistency from size %d: %s", m.oldSize, err) } proof = treeProof } - body, err := mirror.AddCheckpointRequest(oldSize, proof, signedNote) + body, err := mirror.AddCheckpointRequest(m.oldSize, proof, signedNote) if err != nil { return err } @@ -172,7 +171,7 @@ func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signed return fmt.Errorf("add-checkpoint at tree size %d got 409 with mirror tree size %d after retrying", tree.N, mirrorSize) } retried = true - oldSize = mirrorSize + m.oldSize = mirrorSize default: return fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) } @@ -298,13 +297,13 @@ func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, si if err != nil { return nil, fmt.Errorf("marshaling the checkpoint: %w", err) } - timestampedMirrorCosignature, err := m.verifier.FilterByVerify(noteText, subtreeCosignatureLines) + zeroTimestampMirrorCosignature, err := m.verifier.FilterByVerify(noteText, subtreeCosignatureLines) if err != nil { return nil, fmt.Errorf("cosignature failed verification: %w", err) } // Finally, extract the raw cosignature we store in the database. - rawMirrorCosignature, err := cosignature.RawSignature(timestampedMirrorCosignature) + rawMirrorCosignature, err := cosignature.RawSignature(zeroTimestampMirrorCosignature) if err != nil { return nil, fmt.Errorf("cosignature: %w", err) } diff --git a/mtpublisher/mtpublishertest/mtpublishertest_test.go b/mtpublisher/mtpublishertest/mtpublishertest_test.go index 7a66703d09d..e3945a79ccc 100644 --- a/mtpublisher/mtpublishertest/mtpublishertest_test.go +++ b/mtpublisher/mtpublishertest/mtpublishertest_test.go @@ -55,17 +55,12 @@ func TestMirrorCosign(t *testing.T) { if err != nil { t.Fatalf("NewVerifier: %s", err) } - line, err := cosignature.SignatureLine(verifier.Name(), verifier.KeyHash(), 0, raw) - if err != nil { - t.Fatalf("SignatureLine: %s", err) - } text, err := cp.Marshal() if err != nil { t.Fatalf("Marshal: %s", err) } - _, err = verifier.FilterByVerify(text, line) - if err != nil { - t.Errorf("the mirror's cosignature does not verify: %s", err) + if !verifier.Verify(text, append(make([]byte, 8), raw...)) { + t.Error("the mirror's cosignature does not verify") } } From 478f61bdbad26636dc18cdb9d78d87fb250f9212 Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 12:19:28 -0400 Subject: [PATCH 08/13] Small mirror cleanups --- mtpublisher/mirror.go | 17 ++--------------- mtpublisher/mtpublisher.go | 2 -- mtpublisher/mtpublishertest/mtpublishertest.go | 8 -------- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index 9a0cd8fecb6..5e7916dafaa 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -60,8 +60,6 @@ type MirrorClient struct { // 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 @@ -137,8 +135,7 @@ func (m *MirrorClient) post(ctx context.Context, path, contentType string, compr // retries once. An up-to-date mirror still receives the checkpoint, with an // empty proof, since that is the only way to obtain its cosignature. func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signedNote []byte) error { - retried := false - for { + for range 2 { if m.oldSize > tree.N { return fmt.Errorf("mirror already holds size %d, checkpoint size is %d", m.oldSize, tree.N) } @@ -167,15 +164,12 @@ func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signed if err != nil { return err } - if retried { - return fmt.Errorf("add-checkpoint at tree size %d got 409 with mirror tree size %d after retrying", tree.N, mirrorSize) - } - retried = true m.oldSize = mirrorSize default: return fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) } } + return fmt.Errorf("add-checkpoint at tree size %d got 409 with mirror tree size %d after retrying", tree.N, m.oldSize) } // maxAddEntriesRequests bounds one Cosign call's add-entries requests, each of @@ -188,7 +182,6 @@ const maxAddEntriesRequests = 100 // On "202 Accepted" and "409 Conflict" it resumes from the next entry and // ticket the mirror advertises. func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog.Tree) ([]byte, error) { - m.nextEntry = min(m.nextEntry, tree.N) for range maxAddEntriesRequests { packages, err := mirror.Packages(m.nextEntry, tree.N, mirror.MaxPackagesPerRequest) if err != nil { @@ -307,11 +300,5 @@ func (m *MirrorClient) Cosign(ctx context.Context, cp *checkpoint.Checkpoint, si if err != nil { return nil, fmt.Errorf("cosignature: %w", err) } - m.lastSigned = time.Now() return rawMirrorCosignature, nil } - -// LastSigned returns when Cosign last succeeded, zero before it has. -func (m *MirrorClient) LastSigned() time.Time { - return m.lastSigned -} diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 9fa33241594..7659371152c 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -29,8 +29,6 @@ type Mirror interface { // Cosign submits the log's signed note for cp and returns the mirror's raw // cosignature, verified against the mirror's key. Cosign(ctx context.Context, cp *checkpoint.Checkpoint, signedNote []byte) ([]byte, error) - // LastSigned returns when Cosign last succeeded, zero before it has. - LastSigned() time.Time } // mtpublisher obtains and stores its mirror's cosignature over the issuance diff --git a/mtpublisher/mtpublishertest/mtpublishertest.go b/mtpublisher/mtpublishertest/mtpublishertest.go index 554c56d0e57..30b86f43131 100644 --- a/mtpublisher/mtpublishertest/mtpublishertest.go +++ b/mtpublisher/mtpublishertest/mtpublishertest.go @@ -8,7 +8,6 @@ import ( "context" "crypto" "fmt" - "time" "github.com/letsencrypt/boulder/trees/checkpoint" "github.com/letsencrypt/boulder/trees/cosignature" @@ -19,7 +18,6 @@ import ( type TestMirror struct { cosignerID string cosigner *cosignature.Cosigner - lastSigned time.Time } // NewTestMirror returns a TestMirror that cosigns checkpoints of the log with @@ -47,11 +45,5 @@ func (m *TestMirror) Cosign(_ context.Context, cp *checkpoint.Checkpoint, _ []by if err != nil { return nil, err } - m.lastSigned = time.Now() return cosignature.RawSignature(timestampedCosignature) } - -// LastSigned returns when Cosign last succeeded, zero before it has. -func (m *TestMirror) LastSigned() time.Time { - return m.lastSigned -} From fc55baa9d5c27255db1c9caa4a8fb730b726e5c5 Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 12:45:16 -0400 Subject: [PATCH 09/13] Make mirror timeouts configurable --- cmd/boulder-mtpublisher/main.go | 5 ++++- mtpublisher/mirror.go | 9 ++++++--- mtpublisher/mtpublisher_test.go | 16 ++++++++-------- test/config-next/mtpublisher.json | 3 ++- test/config/mtpublisher.json | 3 ++- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/cmd/boulder-mtpublisher/main.go b/cmd/boulder-mtpublisher/main.go index 7d5bb2a5e92..3867e3eab16 100644 --- a/cmd/boulder-mtpublisher/main.go +++ b/cmd/boulder-mtpublisher/main.go @@ -54,6 +54,9 @@ type Config struct { // BaseURL is the base URL of the mirror's tlog-mirror submission // endpoints (e.g. "http://localhost:4700"). BaseURL string `validate:"required,url"` + + // Timeout bounds each request to the mirror. + Timeout config.Duration `validate:"required"` } // S3 locates the source log's tile storage, which the publisher reads @@ -122,7 +125,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) + 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) cmd.FailOnError(err, "Creating mirror client") publisher, err := mtpublisher.New(dbMap, c.MTPublisher.PollInterval.Duration, c.MTPublisher.LogID, caPubKey, mirror, logger) diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index 5e7916dafaa..759896c2e28 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -63,18 +63,21 @@ type MirrorClient struct { } // 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) { +// under baseURL, giving each request timeout to complete. +func NewMirrorClient(baseURL string, src *Source, mirrorID string, mirrorPublicKey *mldsa.PublicKey, timeout time.Duration) (*MirrorClient, error) { if baseURL == "" { return nil, errors.New("empty mirror base URL") } + if timeout <= 0 { + return nil, fmt.Errorf("timeout must be positive, got %s", timeout) + } 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}, + client: &http.Client{Timeout: timeout}, src: src, mirrorID: mirrorID, verifier: verifier, diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index 3688cf4c206..c3b7833e496 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -442,7 +442,7 @@ func TestMirrorCosign(t *testing.T) { })) defer srv.Close() - m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } @@ -502,7 +502,7 @@ func TestMirrorCosignAlreadyMirrored(t *testing.T) { })) defer srv.Close() - m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + m, err := NewMirrorClient(srv.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } @@ -522,7 +522,7 @@ 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()) + _, err := NewMirrorClient("", 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") } @@ -532,7 +532,7 @@ func TestMirrorCosignErrors(t *testing.T) { http.Error(w, "checkpoint refused", http.StatusForbidden) })) defer refusing.Close() - m, err := NewMirrorClient(refusing.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + m, err := NewMirrorClient(refusing.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } @@ -553,7 +553,7 @@ 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()) + m, err = NewMirrorClient(overshooting.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } @@ -574,7 +574,7 @@ 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()) + m, err = NewMirrorClient(mismatched.URL, NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } @@ -583,7 +583,7 @@ func TestMirrorCosignErrors(t *testing.T) { t.Errorf("Cosign against a mismatched mirror = %s, want an upload_end error", err) } - unreachable, err := NewMirrorClient("http://127.0.0.1:1", NewSource(source.fs3, testTilePrefix), mirrorID, source.mirrorKey.PublicKey()) + unreachable, err := NewMirrorClient("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) } @@ -600,7 +600,7 @@ func TestMirrorCosignErrors(t *testing.T) { w.Write(source.cosigLine) })) defer lying.Close() - m, err = NewMirrorClient(lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, testKey(t).PublicKey()) + m, err = NewMirrorClient(lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, testKey(t).PublicKey(), 10*time.Second) if err != nil { t.Fatalf("NewMirrorClient: %s", err) } diff --git a/test/config-next/mtpublisher.json b/test/config-next/mtpublisher.json index d730b8033c7..d280ab98bb6 100644 --- a/test/config-next/mtpublisher.json +++ b/test/config-next/mtpublisher.json @@ -13,7 +13,8 @@ "mirror": { "id": "32473.9", "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", - "baseURL": "http://bsunlight:4700" + "baseURL": "http://bsunlight:4700", + "timeout": "15s" }, "s3": { "s3endpoint": "http://boulder-minio:9000", diff --git a/test/config/mtpublisher.json b/test/config/mtpublisher.json index d730b8033c7..d280ab98bb6 100644 --- a/test/config/mtpublisher.json +++ b/test/config/mtpublisher.json @@ -13,7 +13,8 @@ "mirror": { "id": "32473.9", "publicKeyFile": "test/certs/sunlight/mirror.pub.pem", - "baseURL": "http://bsunlight:4700" + "baseURL": "http://bsunlight:4700", + "timeout": "15s" }, "s3": { "s3endpoint": "http://boulder-minio:9000", From bb9b3d7c163f88d67e10b650602c87b44468a191 Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 13:17:17 -0400 Subject: [PATCH 10/13] Testing improvements suggested by Claude --- mtpublisher/mtpublisher_test.go | 164 ++++++++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 9 deletions(-) diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index c3b7833e496..dd7a6eb38e5 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -9,6 +9,7 @@ import ( "crypto/mldsa" "encoding/base64" "encoding/binary" + "errors" "fmt" "io" "net/http" @@ -17,6 +18,8 @@ import ( "testing" "time" + "golang.org/x/crypto/cryptobyte" + "golang.org/x/mod/sumdb/note" "golang.org/x/mod/sumdb/tlog" "github.com/letsencrypt/boulder/bs3/bs3test" @@ -27,7 +30,9 @@ import ( "github.com/letsencrypt/boulder/trees/cosignature" "github.com/letsencrypt/boulder/trees/entry" "github.com/letsencrypt/boulder/trees/issuancelog" + "github.com/letsencrypt/boulder/trees/mirror" "github.com/letsencrypt/boulder/trees/pubkey" + "github.com/letsencrypt/boulder/trees/subtree" "github.com/letsencrypt/boulder/trees/tiles" "github.com/letsencrypt/boulder/trees/treedb" ) @@ -205,6 +210,25 @@ func TestPublishRejectsBadMTCASignature(t *testing.T) { } } +// TestPublishMirrorError checks that a failed cosigning stores nothing. +func TestPublishMirrorError(t *testing.T) { + latest := testCheckpoint(t, 512) + p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) + 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()) + 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") + } +} + func TestPublishWhenLatestAlreadySigned(t *testing.T) { // The latest checkpoint is already cosigned, which must be left untouched. existingMirrorID := "existing.cosigner" @@ -332,6 +356,67 @@ func newSourceLog(t *testing.T) *sourceLog { } } +// TestSourceEntryPackage checks the first package of an upload that does not +// begin on a package boundary: it must carry only the entries from +// EntriesStart, with a subtree consistency proof for the whole package subtree +// from SubtreeStart. +func TestSourceEntryPackage(t *testing.T) { + source := newSourceLog(t) + packages, err := mirror.Packages(300, source.newer.N, mirror.MaxPackagesPerRequest) + if err != nil { + t.Fatalf("Packages: %s", err) + } + p := packages[0] + if p.EntriesStart == p.SubtreeStart { + t.Fatalf("Packages(300, %d)[0] = %+v, want a package carrying entries from past its subtree start", source.newer.N, p) + } + + body, err := NewSource(source.fs3, testTilePrefix).entryPackage(t.Context(), source.newer, p) + if err != nil { + t.Fatalf("entryPackage: %s", err) + } + rest := cryptobyte.String(body) + var entries []cryptobyte.String + for range p.End - p.EntriesStart { + var e cryptobyte.String + if !rest.ReadUint16LengthPrefixed(&e) { + t.Fatalf("entry package holds fewer than %d entries", p.End-p.EntriesStart) + } + entries = append(entries, e) + } + var numHashes uint8 + if !rest.ReadUint8(&numHashes) { + t.Fatal("entry package ends before num_hashes") + } + proof := make([]tlog.Hash, numHashes) + for i := range proof { + if !rest.CopyBytes(proof[i][:]) { + t.Fatalf("entry package ends inside proof hash %d", i) + } + } + if !rest.Empty() { + t.Errorf("entry package has %d trailing bytes", len(rest)) + } + + indexes := make([]int64, 0, p.End-p.SubtreeStart) + for i := p.SubtreeStart; i < p.End; i++ { + indexes = append(indexes, tlog.StoredHashIndex(0, i)) + } + reader := tlog.TileHashReader(source.newer, tiles.NewTileReader(t.Context(), source.fs3, testTilePrefix)) + leaves, err := reader.ReadHashes(indexes) + if err != nil { + t.Fatalf("reading leaf hashes: %s", err) + } + for i, e := range entries { + if tlog.RecordHash(e) != leaves[p.EntriesStart-p.SubtreeStart+int64(i)] { + t.Errorf("entry %d of the package does not hash to leaf %d", i, p.EntriesStart+int64(i)) + } + } + if !subtree.VerifyConsistency(p.SubtreeStart, p.End, source.newer.N, proof, subtree.MTH(leaves), source.newer.Hash) { + t.Errorf("subtree consistency proof for [%d, %d) does not verify against the tree of size %d", p.SubtreeStart, p.End, source.newer.N) + } +} + // requestBody reads a request body, requiring gzip compression on add-entries // requests. func requestBody(t *testing.T, r *http.Request) []byte { @@ -343,13 +428,15 @@ func requestBody(t *testing.T, r *http.Request) []byte { if r.Header.Get("Content-Encoding") == "gzip" { zr, err := gzip.NewReader(r.Body) if err != nil { - t.Fatalf("opening request body: %s", err) + t.Errorf("opening request body: %s", err) + return nil } reader = zr } body, err := io.ReadAll(reader) if err != nil { - t.Fatalf("reading request body: %s", err) + t.Errorf("reading request body: %s", err) + return nil } return body } @@ -393,13 +480,17 @@ func TestMirrorCosign(t *testing.T) { header, _, ok := bytes.Cut(body, []byte("\n\n")) lines := strings.Split(string(header), "\n") if !ok || lines[0] != "old 300" { - t.Fatalf("second add-checkpoint body %q does not claim old size 300", body) + t.Errorf("second add-checkpoint body %q does not claim old size 300", body) + http.Error(w, "bad old size", http.StatusBadRequest) + return } proof := make(tlog.TreeProof, len(lines)-1) for i, l := range lines[1:] { h, err := tlog.ParseHash(l) if err != nil { - t.Fatalf("proof line %q: %s", l, err) + t.Errorf("proof line %q: %s", l, err) + http.Error(w, "bad proof line", http.StatusBadRequest) + return } proof[i] = h } @@ -583,6 +674,39 @@ func TestMirrorCosignErrors(t *testing.T) { t.Errorf("Cosign against a mismatched mirror = %s, want an upload_end error", err) } + conflicting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/x.tlog.size") + w.WriteHeader(http.StatusConflict) + 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) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(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) + } + + // 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) + if err != nil { + t.Fatalf("NewMirrorClient: %s", err) + } + _, err = m.Cosign(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) + } + m.oldSize = source.newer.N + 1 + _, err = m.Cosign(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) + } + unreachable, err := NewMirrorClient("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) @@ -592,20 +716,42 @@ func TestMirrorCosignErrors(t *testing.T) { t.Error("Cosign against an unreachable mirror = nil error, want error") } - // A mirror whose cosignature does not verify against the configured key. + // A mirror whose signature line names its key but was signed by another, + // so the signature is checked and rejected rather than ignored as unknown. + forger, err := cosignature.NewCosigner(mirrorID, source.cp.Origin, privatekey.NewDeterministicSigner(testKey(t))) + if err != nil { + t.Fatalf("NewCosigner: %s", err) + } + forgedTimestamped, err := forger.CosignCheckpoint(source.newer) + if err != nil { + t.Fatalf("CosignCheckpoint: %s", err) + } + forgedRaw, err := cosignature.RawSignature(forgedTimestamped) + if err != nil { + t.Fatalf("RawSignature: %s", err) + } + mirrorVerifier, err := cosignature.NewVerifier(mirrorID, source.mirrorKey.PublicKey()) + if err != nil { + t.Fatalf("NewVerifier: %s", err) + } + forgedLine, err := cosignature.SignatureLine(mirrorVerifier.Name(), mirrorVerifier.KeyHash(), 0, forgedRaw) + if err != nil { + t.Fatalf("SignatureLine: %s", err) + } lying := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/add-checkpoint" { return } - w.Write(source.cosigLine) + w.Write(forgedLine) })) defer lying.Close() - m, err = NewMirrorClient(lying.URL, NewSource(source.fs3, testTilePrefix), mirrorID, testKey(t).PublicKey(), 10*time.Second) + m, err = NewMirrorClient(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) - if err == nil || !strings.Contains(err.Error(), "verification") { - t.Errorf("Cosign with a mismatched key = %s, want a verification error", err) + _, ok := errors.AsType[*note.InvalidSignatureError](err) + if !ok { + t.Errorf("Cosign with a forged signature = %s, want note.InvalidSignatureError", err) } } From cf39cef0af9555b77f047bc1be951bacbab78883 Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 13:28:29 -0400 Subject: [PATCH 11/13] Detect stalled add-entries uploads directly --- mtpublisher/mirror.go | 15 +++++++-------- mtpublisher/mtpublisher_test.go | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/mtpublisher/mirror.go b/mtpublisher/mirror.go index 759896c2e28..885ad66fa05 100644 --- a/mtpublisher/mirror.go +++ b/mtpublisher/mirror.go @@ -175,17 +175,13 @@ func (m *MirrorClient) addCheckpoint(ctx context.Context, tree tlog.Tree, signed return fmt.Errorf("add-checkpoint at tree size %d got 409 with mirror tree size %d after retrying", tree.N, m.oldSize) } -// 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. +// ticket the mirror advertises. The upload fails if the mirror stops advancing +// the next entry or answers in any way the client cannot resume from. func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog.Tree) ([]byte, error) { - for range maxAddEntriesRequests { + for { packages, err := mirror.Packages(m.nextEntry, tree.N, mirror.MaxPackagesPerRequest) if err != nil { return nil, err @@ -231,14 +227,17 @@ func (m *MirrorClient) addEntries(ctx context.Context, origin string, tree tlog. if info.NextEntry > tree.N { return nil, fmt.Errorf("mirror wants upload_start %d, checkpoint size is %d", info.NextEntry, tree.N) } + sentUploadStart := m.nextEntry m.nextEntry = info.NextEntry m.ticket = bytes.Clone(info.Ticket) + if info.NextEntry <= sentUploadStart { + return nil, fmt.Errorf("mirror made no progress from upload_start %d, wants %d", sentUploadStart, info.NextEntry) + } default: return nil, fmt.Errorf("mirror returned status %d: %s", status, errorBody(respBody)) } } - return nil, fmt.Errorf("upload incomplete after %d add-entries requests", maxAddEntriesRequests) } // signSubtree requests the mirror's zero timestamp signature over the whole diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index dd7a6eb38e5..6efd617cb03 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -689,6 +689,27 @@ func TestMirrorCosignErrors(t *testing.T) { t.Errorf("Cosign 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 + // rules out, so the upload must end rather than loop. + stalled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/add-checkpoint" { + return + } + uploadStart, _ := parseUploadHeader(t, requestBody(t, r)) + w.Header().Set("Content-Type", "text/x.tlog.mirror-info") + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "%d\n%d\n\n", source.newer.N, uploadStart) + })) + defer stalled.Close() + m, err = NewMirrorClient(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) + 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) + } + // 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) {})) From cb357c979d5060544f989553da32dfbdc692f1f3 Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 13:39:25 -0400 Subject: [PATCH 12/13] Add helpful log line for ErrIssuanceLogNotInitialized --- mtpublisher/mtpublisher.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mtpublisher/mtpublisher.go b/mtpublisher/mtpublisher.go index 7659371152c..7331aa653e4 100644 --- a/mtpublisher/mtpublisher.go +++ b/mtpublisher/mtpublisher.go @@ -79,6 +79,7 @@ type checkpointDB interface { func (p *mtpublisher) Publish(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) return nil } if err != nil { From f40e64164bd16bf49239914dd1a7a643303eda6f Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 3 Sep 2026 14:00:17 -0400 Subject: [PATCH 13/13] Satisfying lints --- mtpublisher/mtpublisher_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mtpublisher/mtpublisher_test.go b/mtpublisher/mtpublisher_test.go index 6efd617cb03..f198c97f558 100644 --- a/mtpublisher/mtpublisher_test.go +++ b/mtpublisher/mtpublisher_test.go @@ -114,15 +114,15 @@ func caSignature(t *testing.T, treeSize int64) []byte { return raw } -// testCheckpoint returns a checkpoint of treeSize with a zero root hash, signed -// by the MTCA and awaiting the mirror cosignature. -func testCheckpoint(t *testing.T, treeSize int64) *treedb.CheckpointModel { +// 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 { t.Helper() return &treedb.CheckpointModel{ ID: 1, MTCLogID: mtcLogID, - MTCASignature: caSignature(t, treeSize), - TreeSize: treeSize, + MTCASignature: caSignature(t, 512), + TreeSize: 512, RootHash: make([]byte, 32), } } @@ -164,7 +164,7 @@ func TestPublish(t *testing.T) { } // The latest checkpoint, which we expect to be cosigned by p.Publish(). - latest := testCheckpoint(t, 512) + latest := testCheckpoint(t) checkpoints.latest = latest err = p.Publish(t.Context()) @@ -197,7 +197,7 @@ func TestPublish(t *testing.T) { // MTCA signature does not verify is neither submitted nor cosigned. func TestPublishRejectsBadMTCASignature(t *testing.T) { // A well-formed MTCA signature over the wrong tree size. - latest := testCheckpoint(t, 512) + latest := testCheckpoint(t) latest.MTCASignature = caSignature(t, 999) p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) @@ -212,7 +212,7 @@ func TestPublishRejectsBadMTCASignature(t *testing.T) { // TestPublishMirrorError checks that a failed cosigning stores nothing. func TestPublishMirrorError(t *testing.T) { - latest := testCheckpoint(t, 512) + latest := testCheckpoint(t) p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest}) otherLogMirror, err := mtpublishertest.NewTestMirror(mirrorID, "oid/1.3.6.1.4.1.44947.4.2.0.99", privatekey.NewDeterministicSigner(testKey(t))) if err != nil { @@ -232,7 +232,7 @@ func TestPublishMirrorError(t *testing.T) { func TestPublishWhenLatestAlreadySigned(t *testing.T) { // The latest checkpoint is already cosigned, which must be left untouched. existingMirrorID := "existing.cosigner" - latest := testCheckpoint(t, 512) + latest := testCheckpoint(t) latest.MirrorID = &existingMirrorID latest.MirrorSignature = []byte("already-signed-bruh") p := testPublisher(t, testKey(t), &fakeCheckpointDB{latest: latest})