From 3358d01e537622ac6097ab26e573ef70c67c1788 Mon Sep 17 00:00:00 2001 From: Samantha Date: Fri, 21 Aug 2026 15:50:17 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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}