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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
10 changes: 10 additions & 0 deletions omniwitness/omniwitness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)))
Expand Down
102 changes: 102 additions & 0 deletions witness/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
139 changes: 134 additions & 5 deletions witness/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
Loading
Loading