diff --git a/go.mod b/go.mod index a325cff2..eccf7add 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/prometheus/client_golang v1.23.2 github.com/transparency-dev/formats v0.1.2-0.20260710124811-af9e607161b6 - github.com/transparency-dev/merkle v0.0.3-0.20240919113952-3c979d16ee14 + github.com/transparency-dev/merkle v0.0.3-0.20260707140218-77df88e508fa github.com/transparency-dev/serverless-log v0.0.0-20250425165558-64e1d2007a10 github.com/transparency-dev/tessera v1.0.3-0.20260303172654-b64a6fdf82f4 go.opentelemetry.io/otel v1.44.0 diff --git a/go.sum b/go.sum index f5ea028a..36030244 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/transparency-dev/formats v0.1.2-0.20260710124811-af9e607161b6 h1:Cjch github.com/transparency-dev/formats v0.1.2-0.20260710124811-af9e607161b6/go.mod h1:8vp6vMDg/v+6K7aAbF4ccndYCcbAO/627+yYfX2UNnM= github.com/transparency-dev/merkle v0.0.3-0.20240919113952-3c979d16ee14 h1:K8JqF1HyGDXfTdDHtHe7VsIzeuFEcfLhioOXaupKB+Q= github.com/transparency-dev/merkle v0.0.3-0.20240919113952-3c979d16ee14/go.mod h1:EoKPjljyIALg1rldsJwRQVKOJO7sLd6eUqki19ruI80= +github.com/transparency-dev/merkle v0.0.3-0.20260707140218-77df88e508fa h1:thPHsl5llVwAR7q5m8HmI/z6rUO0aGDfXVD+5YSBwjA= +github.com/transparency-dev/merkle v0.0.3-0.20260707140218-77df88e508fa/go.mod h1:E+iHk6bS+tIgIJGD4TMeAjSjhQ9wPfL/ST4pXyITjdU= github.com/transparency-dev/serverless-log v0.0.0-20250425165558-64e1d2007a10 h1:mQ9ZzYoywyFZ4Cvm7PtbFQHm/cnKgWvUUJHQlHG608I= github.com/transparency-dev/serverless-log v0.0.0-20250425165558-64e1d2007a10/go.mod h1:CSzJNqTKoCDILXByuUcqXtmmvxpTwHSY5g5jq5LSouI= github.com/transparency-dev/tessera v1.0.3-0.20260303172654-b64a6fdf82f4 h1:aNC1jwA7QMPEzLB0LTgICJKsm0pyiDSzOrCtzS46VwU= diff --git a/omniwitness/omniwitness.go b/omniwitness/omniwitness.go index d242c6b3..b55ad80f 100644 --- a/omniwitness/omniwitness.go +++ b/omniwitness/omniwitness.go @@ -90,6 +90,13 @@ type OperatorConfig struct { // WitnessNetworkConfigInterval is the time between attempts to fetch and merge configs from the // URLs provided above. WitnessNetworkConfigInterval time.Duration + + // EnableSubtreeSigning allows the omniwitness to sign subtrees. + // + // This is only possible if the witness has subtree signers configured and + // EnableSubtreeSigning is true. If EnableSubtreeSigning is true, the + // omniwitness will expose an endpoint for signing subtrees. + EnableSubtreeSigning bool } // LogConfig is the contract of something which knows how to provide log configuration info for the witness. @@ -151,6 +158,9 @@ func Main(ctx context.Context, operatorConfig OperatorConfig, p Persistence, htt } h := witness.NewHTTPHandler(w) operatorConfig.ServeMux.HandleFunc("POST /add-checkpoint", rateLimit(limiter, h.AddCheckpoint)) + if w.SupportsSubtreeSigning() { + operatorConfig.ServeMux.HandleFunc("POST /sign-subtree", rateLimit(limiter, h.SignSubtree)) + } if operatorConfig.BastionAddr != "" && operatorConfig.BastionKey != nil { klog.Infof("My bastion backend ID: %064x", sha256.Sum256(operatorConfig.BastionKey.Public().(ed25519.PublicKey))) diff --git a/witness/http.go b/witness/http.go index 1c6519ae..2dcd796a 100644 --- a/witness/http.go +++ b/witness/http.go @@ -98,6 +98,61 @@ func (a *HTTPHandler) handleUpdate(ctx context.Context, oldSize uint64, newCP [] return http.StatusOK, sigs, "", nil } +// SignSubtree is a http.Handler which speaks the tlog-witness protocol for sign-subtree. +func (a *HTTPHandler) SignSubtree(w http.ResponseWriter, r *http.Request) { + defer func() { + _, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + }() + + start, end, subRoot, proof, cp, err := parseSubtreeBody(http.MaxBytesReader(w, r.Body, MaxRequestBodyBytes)) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + sc, body, contentType, err := a.handleSignSubtree(r.Context(), start, end, subRoot, proof, cp) + if err != nil { + status := http.StatusInternalServerError + w.WriteHeader(status) + return + } + + if contentType != "" { + w.Header().Add("Content-Type", contentType) + } + w.WriteHeader(sc) + if len(body) > 0 { + _, _ = w.Write(body) + } +} + +// handleSignSubtree submits the sign-subtree request to the witness and interprets any errors. +func (a *HTTPHandler) handleSignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, cp []byte) (int, []byte, string, error) { + sigs, err := a.witness.SignSubtree(ctx, start, end, subRoot, proof, cp) + if err != nil { + switch { + case errors.Is(err, ErrUnknownLog): + return http.StatusNotFound, nil, "", nil + case errors.Is(err, ErrNoWitnessSignature): + return http.StatusForbidden, nil, "", nil + case errors.Is(err, ErrSubtreeRangeInvalid): + return http.StatusBadRequest, nil, "", nil + case errors.Is(err, ErrInvalidProof): + return http.StatusUnprocessableEntity, nil, "", nil + case errors.Is(err, ErrNotImplemented): + return http.StatusNotImplemented, nil, "", nil + case errors.Is(err, ErrPushback): + return http.StatusTooManyRequests, nil, "", nil + default: + slog.ErrorContext(ctx, "Unknown error", slog.Any("error", err)) + return http.StatusInternalServerError, nil, "", err + } + } + + return http.StatusOK, sigs, "", nil +} + // parseBody reads the incoming request and parses into constituent parts. // // The request body MUST be a sequence of @@ -137,8 +192,55 @@ func parseBody(r io.Reader) (uint64, [][]byte, []byte, error) { return size, proof, cp, nil } +// parseSubtreeBody reads the incoming request and parses into constituent parts. +func parseSubtreeBody(r io.Reader) (uint64, uint64, []byte, [][]byte, []byte, error) { + b := bufio.NewReader(r) + rangeLine, _, err := b.ReadLine() + if err != nil { + return 0, 0, nil, nil, nil, err + } + var start, end uint64 + if n, err := fmt.Sscanf(string(rangeLine), "subtree %d %d", &start, &end); err != nil || n != 2 { + if err == nil { + err = fmt.Errorf("expected 2 arguments, got %d", n) + } + return 0, 0, nil, nil, nil, fmt.Errorf("failed to parse subtree range line %q: %v", string(rangeLine), err) + } + + hashLine, _, err := b.ReadLine() + if err != nil { + return 0, 0, nil, nil, nil, err + } + subRoot, err := base64.StdEncoding.DecodeString(string(hashLine)) + if err != nil { + return 0, 0, nil, nil, nil, err + } + + proof := [][]byte{} + for { + l, _, err := b.ReadLine() + if err != nil { + return 0, 0, nil, nil, nil, err + } + if len(l) == 0 { + break + } + hash, err := base64.StdEncoding.DecodeString(string(l)) + if err != nil { + return 0, 0, nil, nil, nil, err + } + proof = append(proof, hash) + } + cp, err := io.ReadAll(b) + if err != nil { + return 0, 0, nil, nil, nil, err + } + return start, end, subRoot, proof, cp, nil +} + // witness is the contract expected of the backend for HTTPHandler. // This interface only really exists to make testing easier. type witness interface { Update(ctx context.Context, oldSize uint64, newCP []byte, proof [][]byte) ([]byte, uint64, error) + SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, cp []byte) ([]byte, error) } diff --git a/witness/http_test.go b/witness/http_test.go index 2554058e..03516f5d 100644 --- a/witness/http_test.go +++ b/witness/http_test.go @@ -83,6 +83,66 @@ func TestParseBody(t *testing.T) { } } +func TestParseSubtreeBody(t *testing.T) { + for _, test := range []struct { + name string + body string + wantStart uint64 + wantEnd uint64 + wantSubRoot []byte + wantConsistency [][]byte + wantCheckpoint []byte + wantErr bool + }{ + { + name: "ok", + body: "subtree 8 13\nmbsQCg+dEIMGlpqeGgk94JutQwKKS2Lo5IuDhKmDjiU=\nCD82D2LDm0phY0+xKbHyZfq3Hw21lVkuV7Zis5EFg0k=\n\n" + testCP, + wantStart: 8, + wantEnd: 13, + wantSubRoot: d64(t, "mbsQCg+dEIMGlpqeGgk94JutQwKKS2Lo5IuDhKmDjiU="), + wantConsistency: [][]byte{d64(t, "CD82D2LDm0phY0+xKbHyZfq3Hw21lVkuV7Zis5EFg0k=")}, + wantCheckpoint: []byte(testCP), + }, { + name: "Invalid subtree range line", + body: "subtree 8\nmbsQCg+dEIMGlpqeGgk94JutQwKKS2Lo5IuDhKmDjiU=\n\n" + testCP, + wantErr: true, + }, { + name: "Invalid subroot base64", + body: "subtree 8 13\nnot-base64-!!!\n\n" + testCP, + wantErr: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + start, end, subRoot, proof, cp, err := parseSubtreeBody(bytes.NewBuffer([]byte(test.body))) + if err != nil { + if !test.wantErr { + t.Fatalf("parseSubtreeBody: %v, want no err", err) + } + return + } + if test.wantErr { + t.Fatalf("parseSubtreeBody: no err, want err") + } + if got, want := start, test.wantStart; got != want { + t.Errorf("got start %d, want %d", got, want) + } + if got, want := end, test.wantEnd; got != want { + t.Errorf("got end %d, want %d", got, want) + } + if got, want := subRoot, test.wantSubRoot; !cmp.Equal(got, want) { + t.Errorf("got subRoot %x, want %x", got, want) + } + if got, want := proof, test.wantConsistency; !cmp.Equal(got, want) { + t.Errorf("got proof %x, want %x", got, want) + } + if got, want := cp, test.wantCheckpoint; !cmp.Equal(got, want) { + t.Errorf("got checkpoint %s, want %s", got, want) + } + }) + } +} + + func TestHandler(t *testing.T) { for _, test := range []struct { name string @@ -151,12 +211,77 @@ func TestHandler(t *testing.T) { } } +func TestSubtreeHandler(t *testing.T) { + for _, test := range []struct { + name string + // fake witness control + witness *testWitness + // responses + wantStatus int + wantBody string + wantContentType string + }{ + { + name: "Accepted by witness", + witness: &testWitness{signSubtreeResponse: []byte(testCPSig)}, + wantStatus: 200, + wantBody: testCPSig, + }, { + name: "ErrUnknownLog", + witness: &testWitness{signSubtreeErr: ErrUnknownLog}, + wantStatus: http.StatusNotFound, + }, { + name: "ErrNoWitnessSignature", + witness: &testWitness{signSubtreeErr: ErrNoWitnessSignature}, + wantStatus: http.StatusForbidden, + }, { + name: "ErrSubtreeRangeInvalid", + witness: &testWitness{signSubtreeErr: ErrSubtreeRangeInvalid}, + wantStatus: http.StatusBadRequest, + }, { + name: "ErrInvalidProof", + witness: &testWitness{signSubtreeErr: ErrInvalidProof}, + wantStatus: http.StatusUnprocessableEntity, + }, { + name: "ErrNotImplemented", + witness: &testWitness{signSubtreeErr: ErrNotImplemented}, + wantStatus: http.StatusNotImplemented, + }, { + name: "ErrPushback", + witness: &testWitness{signSubtreeErr: ErrPushback}, + wantStatus: http.StatusTooManyRequests, + }, + } { + t.Run(test.name, func(t *testing.T) { + a := HTTPHandler{ + witness: test.witness, + } + sc, body, ct, err := a.handleSignSubtree(context.Background(), 0, 1, []byte{}, [][]byte{}, []byte(testCP)) + if err != nil { + t.Fatalf("handleSignSubtree: %v", err) + } + if got, want := sc, test.wantStatus; got != want { + t.Errorf("handleSignSubtree got status %d, want %d", got, want) + } + if got, want := ct, test.wantContentType; got != want { + t.Errorf("handleSignSubtree got content type %q, want %q", got, want) + } + if got, want := string(body), test.wantBody; got != want { + t.Errorf("handleSignSubtree got body %q, %q", got, want) + } + }) + } +} + + type testWitness struct { - latestCPErr error - latestCP []byte - updateErr error - updateSize uint64 - updateResponse []byte + latestCPErr error + latestCP []byte + updateErr error + updateSize uint64 + updateResponse []byte + signSubtreeResponse []byte + signSubtreeErr error } func (tw *testWitness) GetLatestCheckpoint(ctx context.Context, logID string) ([]byte, error) { @@ -167,6 +292,10 @@ func (tw *testWitness) Update(ctx context.Context, oldSize uint64, newCP []byte, return tw.updateResponse, tw.updateSize, tw.updateErr } +func (tw *testWitness) SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, cp []byte) ([]byte, error) { + return tw.signSubtreeResponse, tw.signSubtreeErr +} + func d64(t *testing.T, s string) []byte { t.Helper() r, err := base64.StdEncoding.DecodeString(s) diff --git a/witness/witness.go b/witness/witness.go index 2f73d1c6..0be2d045 100644 --- a/witness/witness.go +++ b/witness/witness.go @@ -24,12 +24,14 @@ import ( "encoding/binary" "errors" "fmt" + "math/bits" "strconv" "strings" "unicode" "unicode/utf8" "github.com/transparency-dev/formats/log" + f_note "github.com/transparency-dev/formats/note" "github.com/transparency-dev/merkle/proof" "github.com/transparency-dev/merkle/rfc6962" "go.opentelemetry.io/otel/metric" @@ -63,6 +65,12 @@ var ( ErrRootMismatch = errors.New("roots do not match") // ErrPushback is returned if the witness is overloaded. ErrPushback = errors.New("pushback") + // ErrNoWitnessSignature is returned by calls to SignSubtree if the provided checkpoint has no valid signature by the witness. + ErrNoWitnessSignature = errors.New("no witness signature") + // ErrSubtreeRangeInvalid is returned by calls to SignSubtree if the subtree range is invalid. + ErrSubtreeRangeInvalid = errors.New("subtree range invalid") + // ErrNotImplemented is returned if the operation is not supported by the witness's signers. + ErrNotImplemented = errors.New("not implemented") ) func init() { @@ -87,17 +95,20 @@ func init() { // Opts is the options passed to a witness. type Opts struct { - Persistence LogStatePersistence - Signers []note.Signer - VerifierForLog func(ctx context.Context, origin string) (note.Verifier, bool, error) + Persistence LogStatePersistence + Signers []note.Signer + VerifierForLog func(ctx context.Context, origin string) (note.Verifier, bool, error) + EnableSubtreeSigning bool } // Witness consists of a database for storing checkpoints, a signer, and a list // of logs for which it stores and verifies checkpoints. type Witness struct { - lsp LogStatePersistence - Signers []note.Signer - VerifierForLog func(ctx context.Context, origin string) (note.Verifier, bool, error) + lsp LogStatePersistence + Signers []note.Signer + subtreeSigners []f_note.SubtreeSigner + subtreeVerifiers []note.Verifier + VerifierForLog func(ctx context.Context, origin string) (note.Verifier, bool, error) } // New creates a new witness, which initially has no logs to follow. @@ -106,10 +117,28 @@ func New(ctx context.Context, wo Opts) (*Witness, error) { if err := wo.Persistence.Init(ctx); err != nil { return nil, fmt.Errorf("Persistence.Init(): %v", err) } + + subtreeSigners := make([]f_note.SubtreeSigner, 0, len(wo.Signers)) + subtreeVerifiers := make([]note.Verifier, 0, len(wo.Signers)) + // Ensure we can handle subtree signing, if it is enabled. + if wo.EnableSubtreeSigning { + for _, s := range wo.Signers { + if ss, ok := s.(f_note.SubtreeSigner); ok { + subtreeSigners = append(subtreeSigners, ss) + subtreeVerifiers = append(subtreeVerifiers, ss.Verifier()) + } + } + if len(subtreeSigners) == 0 { + return nil, errors.New("EnableSubtreeSigning is true but no subtree signer provided") + } + } + return &Witness{ - lsp: wo.Persistence, - Signers: wo.Signers, - VerifierForLog: wo.VerifierForLog, + lsp: wo.Persistence, + Signers: wo.Signers, + subtreeSigners: subtreeSigners, + subtreeVerifiers: subtreeVerifiers, + VerifierForLog: wo.VerifierForLog, }, nil } @@ -215,7 +244,7 @@ func (w *Witness) Update(ctx context.Context, oldSize uint64, nextRaw []byte, cP // also identical. if next.Size == prevSize { if !bytes.Equal(next.Hash, prevHash) { - klog.Errorf("%s: INCONSISTENT CHECKPOINTS!:\n%v\n%v", origin, prevRaw, next) + klog.Errorf("%s: INCONSISTENT CHECKPOINTS!:\nPrevious:\n%s\nNext:\n%s", origin, string(prevRaw), string(nextRaw)) counterInconsistentCheckpoints.Add(ctx, 1, metric.WithAttributes(originKey.String(origin))) retSize, retSigs = 0, nil @@ -349,3 +378,124 @@ func checkpointUnsafe(rawCp []byte) (string, uint64, []byte, error) { } return origin, size, hash, nil } + +// SupportsSubtreeSigning returns true if the witness is configured to sign subtrees. +func (w *Witness) SupportsSubtreeSigning() bool { + return len(w.subtreeSigners) > 0 +} + +// SignSubtree validates the checkpoint was signed by the witness, verifies the subtree +// consistency proof from the subtree to the checkpoint, and returns a subtree cosignature. +func (w *Witness) SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, cProof [][]byte, chkptRaw []byte) ([]byte, error) { + // If none of our keys support subtree signing, then bail. + if len(w.subtreeSigners) == 0 { + return nil, ErrNotImplemented + } + + // SPEC: The witness MUST verify that the checkpoint includes a valid cosignature from + // one of its own keys. + // + // We're a bit tighter here - we'll only proceed if the checkpoint was signed by one of our + // *subtree-capable* signers. + n, err := note.Open(chkptRaw, note.VerifierList(w.subtreeVerifiers...)) + if err != nil { + return nil, ErrNoWitnessSignature + } + + var cp log.Checkpoint + if _, err := cp.Unmarshal([]byte(n.Text)); err != nil { + return nil, fmt.Errorf("failed to parse checkpoint: %w", err) + } + + // SPEC: If the checkpoint origin is unknown, the witness MUST respond with a "404 Not Found" HTTP status code. + _, ok, err := w.VerifierForLog(ctx, cp.Origin) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrUnknownLog + } + + // SPEC: The half-open interval [start, end) MUST be a valid subtree per draft-ietf-plants-merkle-tree-certs-03, Section 4.1, + // and end MUST be less than or equal to the checkpoint size. + if end > cp.Size { + return nil, fmt.Errorf("%w: end %d is greater than checkpoint size %d", ErrSubtreeRangeInvalid, end, cp.Size) + } + if err := isSubtreeValid(start, end); err != nil { + return nil, fmt.Errorf("%w: %v", ErrSubtreeRangeInvalid, err) + } + + // SPEC: The client MUST NOT send more than 63 consistency proof lines + if len(cProof) > 63 { + return nil, ErrInvalidProof + } + + // SPEC: The consistency proof lines MUST encode a Subtree Consistency Proof from the subtree to the checkpoint + // according to draft-ietf-plants-merkle-tree-certs-03, Section 4.4. + if err := proof.VerifySubtreeConsistency(rfc6962.DefaultHasher, start, end, cp.Size, cProof, subRoot, cp.Hash); err != nil { + return nil, ErrInvalidProof + } + + var sigs bytes.Buffer + for _, s := range w.subtreeSigners { + // SPEC: If the cosignature format supports timestamps, the timestamp MUST be zero. + sig, err := s.SignSubtree(0, cp.Origin, start, end, subRoot) + if err != nil { + return nil, fmt.Errorf("couldn't sign subtree: %v", err) + } + + name := s.Name() + hash := s.KeyHash() + if !isValidSignerName(name) { + return nil, errors.New("invalid signer") + } + + var hbuf [4]byte + binary.BigEndian.PutUint32(hbuf[:], hash) + b64 := base64.StdEncoding.EncodeToString(append(hbuf[:], sig...)) + _, _ = sigs.WriteString("— ") + _, _ = sigs.WriteString(name) + _, _ = sigs.WriteString(" ") + _, _ = sigs.WriteString(b64) + _, _ = sigs.WriteString("\n") + } + if sigs.Len() == 0 { + return nil, ErrNotImplemented + } + return sigs.Bytes(), nil +} + +// isSubtreeValid returns whether a subtree covers a valid range. +// A subtree is valid if there exists a parent tree node to: +// - all the subtree nodes +// - no extra node to the left of the subtree +// - potentially extra nodes to the right of the subtree +func isSubtreeValid(start, end uint64) error { + if start >= end { + return fmt.Errorf("start %d must be strictly less than end %d", start, end) + } + if start == 0 { + return nil + } + + l := end - start + + // special-case large subtree to avoid panic + if l > uint64(1)<<63 { + return fmt.Errorf("start %d must be 0 when subtree length %d > 1<<63", start, l) + } + if bc := bitCeil(l); start&(bc-1) != 0 { + return fmt.Errorf("start %d not a multiple of bitCeil(end - start) = %d", start, bc) + } + + return nil +} + +// bitCeil returns the smallest power of 2 larger than or equal to n. +// MUST NOT be used with n larger than uint64(1)<<63. +func bitCeil(n uint64) uint64 { + if n <= 1 { + return 1 + } + return uint64(1) << bits.Len64(n-1) +} diff --git a/witness/witness_test.go b/witness/witness_test.go index 40456fb2..f2850b9e 100644 --- a/witness/witness_test.go +++ b/witness/witness_test.go @@ -17,9 +17,11 @@ package witness import ( "bytes" "context" + "encoding/base64" "encoding/hex" "errors" "fmt" + "strings" "sync" "testing" @@ -225,14 +227,14 @@ func TestUpdate(t *testing.T) { origin: "monkeys", initC: mustCreateCheckpoint(t, mSK, "monkeys", 0, rfc6962.DefaultHasher.EmptyRoot()), oldSize: 0, - newC: mustCreateCheckpoint(t, mSK, "monkeys", 0, rfc6962.DefaultHasher.EmptyRoot()), + newC: mustCreateCheckpoint(t, mSK, "monkeys", 0, rfc6962.DefaultHasher.EmptyRoot()), isGood: true, - }, { - desc: "invalid zero size hash", - origin: "monkeys", - initC: mustCreateCheckpoint(t, mSK, "monkeys", 0, rfc6962.DefaultHasher.EmptyRoot()), - oldSize: 0, - newC: mustCreateCheckpoint(t, mSK, "monkeys", 0, dh("e35b268c1522014ef412d2a54fa94838862d453631617b0307e5c77dcbeefc11", 32)), + }, { + desc: "invalid zero size hash", + origin: "monkeys", + initC: mustCreateCheckpoint(t, mSK, "monkeys", 0, rfc6962.DefaultHasher.EmptyRoot()), + oldSize: 0, + newC: mustCreateCheckpoint(t, mSK, "monkeys", 0, dh("e35b268c1522014ef412d2a54fa94838862d453631617b0307e5c77dcbeefc11", 32)), wantError: ErrRootMismatch, }, { desc: "oldSize doesn't match current state", @@ -376,3 +378,202 @@ func (p *testPersistence) Update(_ context.Context, origin string, f func([]byte p.checkpoints[logID] = u return nil } + +func TestSignSubtree(t *testing.T) { + ctx := t.Context() + + const signerPrefix = "witness-mldsa" + ns1 := mustCreateMLDSACosigner(t, fmt.Sprintf("%s-1", signerPrefix)) + ns2 := mustCreateMLDSACosigner(t, fmt.Sprintf("%s-2", signerPrefix)) + + // Setup log verifier. + logMap := make(cfg) + logV, err := note.NewVerifier(mPK) + if err != nil { + t.Fatalf("failed to create log verifier: %v", err) + } + logMap[log.ID("monkeys")] = logV + + w, err := New(ctx, Opts{ + Persistence: newPersistence(), + EnableSubtreeSigning: true, + Signers: []note.Signer{ns1, ns2}, + VerifierForLog: logMap.Log, + }) + if err != nil { + t.Fatalf("failed to create witness: %v", err) + } + + // Create a log checkpoint of size 2. + d0 := make([]byte, 32) + d0[0] = 0xaa + d1 := make([]byte, 32) + d1[0] = 0xbb + root := rfc6962.DefaultHasher.HashChildren(d0, d1) + + logCp := mustCreateCheckpoint(t, mSK, "monkeys", 2, root) + + // Let the witness sign it (update to size 2). + sigs, _, err := w.Update(ctx, 0, logCp, nil) + if err != nil { + t.Fatalf("failed to update witness checkpoint: %v", err) + } + cosignedCp := append(bytes.Clone(logCp), sigs...) + + // Prepare unknown-log checkpoint signed by witness. + unknownLogCp := mustCreateCheckpoint(t, mSK, "unknown-log", 2, root) + n, err := note.Open(unknownLogCp, note.VerifierList(logV)) + if err != nil { + t.Fatal(err) + } + wSigned, _, err := w.signChkpt(n) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + start uint64 + end uint64 + subRoot []byte + proof [][]byte + chkpt []byte + wantErr error + }{ + { + name: "success", + start: 0, + end: 1, + subRoot: d0, + proof: [][]byte{d1}, + chkpt: cosignedCp, + wantErr: nil, + }, { + name: "success - multi subtree-signers", + start: 0, + end: 1, + subRoot: d0, + proof: [][]byte{d1}, + chkpt: cosignedCp, + wantErr: nil, + }, { + name: "unknown log", + start: 0, + end: 1, + subRoot: d0, + proof: [][]byte{d1}, + chkpt: wSigned, + wantErr: ErrUnknownLog, + }, { + name: "no witness signature", + start: 0, + end: 1, + subRoot: d0, + proof: [][]byte{d1}, + chkpt: logCp, + wantErr: ErrNoWitnessSignature, + }, + { + name: "invalid subtree range (start >= end)", + start: 1, + end: 1, + subRoot: d0, + proof: [][]byte{d1}, + chkpt: cosignedCp, + wantErr: ErrSubtreeRangeInvalid, + }, { + name: "invalid subtree range (end > cp.Size)", + start: 0, + end: 3, + subRoot: d0, + proof: [][]byte{d1}, + chkpt: cosignedCp, + wantErr: ErrSubtreeRangeInvalid, + }, { + name: "invalid proof", + start: 0, + end: 1, + subRoot: d0, + proof: [][]byte{make([]byte, 32)}, + chkpt: cosignedCp, + wantErr: ErrInvalidProof, + }, { + name: "too many proof lines", + start: 0, + end: 1, + subRoot: d0, + proof: make([][]byte, 64), + chkpt: cosignedCp, + wantErr: ErrInvalidProof, + }, + } { + t.Run(tc.name, func(t *testing.T) { + sigs, err := w.SignSubtree(ctx, tc.start, tc.end, tc.subRoot, tc.proof, tc.chkpt) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("expected error %v, got %v", tc.wantErr, err) + } + if tc.wantErr != nil { + return + } + + // Returned signatures should be note signatures. + lines := bytes.Split(bytes.TrimSpace(sigs), []byte("\n")) + if len(lines) == 0 { + t.Fatalf("expected at least one signature line and a trailing newline, got: %q", sigs) + } + + vs := make(map[string]f_note.SubtreeVerifier) + for _, s := range w.subtreeSigners { + vs[s.Name()] = s.Verifier() + } + // Verify the returned subtree signature(s) + for _, s := range lines { + sigLine := string(s) + bits := strings.Split(sigLine, " ") + if len(bits) != 3 { + t.Fatalf("unexpected signature line format: %q, want 3 parts", sigLine) + } + if bits[0] != "—" { + t.Fatalf("unexpected signature line format: %q, want prefix %q", sigLine, "—") + } + signerName := bits[1] + b64sig := bits[2] + sigBytes, err := base64.StdEncoding.DecodeString(b64sig) + if err != nil { + t.Fatalf("failed to decode base64 signature: %v", err) + } + // The signature bytes contain keyHash (4 bytes) + sig. + if len(sigBytes) < 4 { + t.Fatalf("signature too short: %d bytes", len(sigBytes)) + } + // Ignore the hash. + actualSig := sigBytes[4:] + + // Now verify the subtree signature. + verifier, ok := vs[signerName] + if !ok { + t.Fatalf("no verifier found for name %q", signerName) + } + // SPEC: If the cosignature format supports timestamps, the timestamp MUST be zero. + if !verifier.VerifySubtree(0, "monkeys", tc.start, tc.end, tc.subRoot, actualSig) { + t.Fatalf("subtree signature verification failed") + } + } + }) + } +} + +func mustCreateMLDSACosigner(t *testing.T, name string) f_note.SubtreeSigner { + skey, _, err := f_note.GenerateMLDSAKey(name) + if err != nil { + t.Fatalf("failed to generate MLDSA key: %v", err) + } + + // Create subtree signer. + ns, err := f_note.NewMLDSASigner(skey) + if err != nil { + t.Fatalf("failed to create MLDSA signer: %v", err) + } + + return ns +}