From 5dad3140af4b124c929ef4c5d6350aea2cf4dcfc Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Fri, 14 Aug 2026 11:57:41 +0000 Subject: [PATCH 1/2] Implement SignSubtree in witness client --- api/http.go | 5 + client/http/witness_client.go | 74 +++++++++- client/http/witness_client_test.go | 225 +++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 client/http/witness_client_test.go diff --git a/api/http.go b/api/http.go index cea573f9..1bf4e95a 100644 --- a/api/http.go +++ b/api/http.go @@ -19,4 +19,9 @@ const ( // HTTPAddCheckpoint is the path of the URL to update to a new checkpoint. // This endpoint expects a https://c2sp.org/tlog-witness compliant request. HTTPAddCheckpoint = "/add-checkpoint" + + // HTTPSignSubtree is the path of the URL to request a subtree cosignature from the witness, + // by providing a checkpoint signed by the witness and a subtree consistency proof. + // This endpoint expects a https://c2sp.org/tlog-witness compliant request. + HTTPSignSubtree = "/sign-subtree" ) diff --git a/client/http/witness_client.go b/client/http/witness_client.go index 96df8739..d39fd60d 100644 --- a/client/http/witness_client.go +++ b/client/http/witness_client.go @@ -32,6 +32,10 @@ import ( "k8s.io/klog/v2" ) +// maxResponseBodyBytes is the limit on the number of bytes we'll read from incoming responses. +// 16 should be more than enough, even in a PQ world. +var maxResponseBodyBytes int64 = 16 << 10 + // NewWitness returns a Witness accessed over http at the given URL // using the client provided. func NewWitness(url *url.URL, c *http.Client) Witness { @@ -65,11 +69,11 @@ func (w Witness) Update(ctx context.Context, oldSize uint64, newCP []byte, proof _, _ = fmt.Fprintln(reqBody) _, _ = reqBody.Write(newCP) - req, err := http.NewRequest(http.MethodPost, w.url.JoinPath(api.HTTPAddCheckpoint).String(), reqBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.url.JoinPath(api.HTTPAddCheckpoint).String(), reqBody) if err != nil { return nil, 0, fmt.Errorf("failed to create request: %v", err) } - resp, err := w.client.Do(req.WithContext(ctx)) + resp, err := w.client.Do(req) if err != nil { return nil, 0, fmt.Errorf("failed to do http request: %v", err) } @@ -79,11 +83,11 @@ func (w Witness) Update(ctx context.Context, oldSize uint64, newCP []byte, proof } }() - if resp.Request.Method != http.MethodPost { + if resp.Request != nil && resp.Request.Method != http.MethodPost { return nil, 0, fmt.Errorf("POST request to %q was converted to %s request to %q", w.url.String(), resp.Request.Method, resp.Request.URL) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) if err != nil { return nil, 0, fmt.Errorf("failed to read body: %v", err) } @@ -114,3 +118,65 @@ func (w Witness) Update(ctx context.Context, oldSize uint64, newCP []byte, proof return nil, 0, fmt.Errorf("unexpected status code %d", resp.StatusCode) } } + +// SignSubtree attempts to request a subtree cosignature from the witness, +// by providing a checkpoint signed by the witness and a subtree consistency +// proof. +func (w Witness) SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, cp []byte) ([]byte, error) { + if l := len(proof); l > 63 { + return nil, errors.New("too many proof lines") + } + + // bytes.Buffer cannot return an error for writes, so we can omit error checking on writes below. + reqBody := &bytes.Buffer{} + + _, _ = fmt.Fprintf(reqBody, "subtree %d %d\n", start, end) + _, _ = fmt.Fprintln(reqBody, base64.StdEncoding.EncodeToString(subRoot)) + for _, p := range proof { + _, _ = fmt.Fprintln(reqBody, base64.StdEncoding.EncodeToString(p)) + } + _, _ = fmt.Fprintln(reqBody) + _, _ = reqBody.Write(cp) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.url.JoinPath(api.HTTPSignSubtree).String(), reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + resp, err := w.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to do http request: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + klog.Errorf("Failed to close response body: %v", err) + } + }() + + if resp.Request != nil && resp.Request.Method != http.MethodPost { + return nil, fmt.Errorf("POST request to %q was converted to %s request to %q", w.url.String(), resp.Request.Method, resp.Request.URL) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to read body: %v", err) + } + + switch resp.StatusCode { + case http.StatusOK, 0: + return body, nil + case http.StatusBadRequest: + return nil, witness.ErrSubtreeRangeInvalid + case http.StatusForbidden: + return nil, witness.ErrNoWitnessSignature + case http.StatusNotFound: + return nil, witness.ErrUnknownLog + case http.StatusUnprocessableEntity: + return nil, witness.ErrInvalidProof + case http.StatusNotImplemented: + return nil, witness.ErrNotImplemented + case http.StatusTooManyRequests: + return nil, witness.ErrPushback + default: + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } +} diff --git a/client/http/witness_client_test.go b/client/http/witness_client_test.go new file mode 100644 index 00000000..05486c51 --- /dev/null +++ b/client/http/witness_client_test.go @@ -0,0 +1,225 @@ +// Copyright 2026 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/transparency-dev/witness/api" + "github.com/transparency-dev/witness/witness" +) + +const ( + testCPOrigin = "example.com/test-log" + testCPSize = 14 + testCPRoot = "/CcKVZM9n65aH7jLaIZuUB4/MSlEFQ4+ldwUC21rDHM=" + testCPSig = "— witness.example/w1 LijDAFSpKvDUj+ZtaJ4lUVcnvnooXd\n" +) + +var testCP = fmt.Sprintf("%s\n%d\n%s\n\n%s", testCPOrigin, testCPSize, testCPRoot, testCPSig) + +func TestSignSubtree(t *testing.T) { + subRoot := []byte("12345678901234567890123456789012") + proof := [][]byte{ + []byte("abcdefghijklmnopqrstuvwxyz123456"), + []byte("0123456789abcdef0123456789abcdef"), + } + cp := []byte(testCP) + wantSig := []byte("— witness.example/w1 GuvvwNqqDmhh5OoDEJyEWiNUB2F1vR\n") + + for _, tc := range []struct { + name string + start uint64 + end uint64 + subRoot []byte + proof [][]byte + cp []byte + handler http.HandlerFunc + wantBody []byte + wantErr error + wantErrMsg string + }{ + { + name: "ok", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("got method %s, want POST", r.Method) + } + if r.URL.Path != api.HTTPSignSubtree { + t.Errorf("got path %s, want %s", r.URL.Path, api.HTTPSignSubtree) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + expectedBody := fmt.Sprintf("subtree 8 13\n%s\n%s\n%s\n\n%s", + base64.StdEncoding.EncodeToString(subRoot), + base64.StdEncoding.EncodeToString(proof[0]), + base64.StdEncoding.EncodeToString(proof[1]), + string(cp), + ) + if got, want := string(body), expectedBody; got != want { + t.Errorf("request body mismatch:\ngot:\n%s\nwant:\n%s", got, want) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(wantSig) + }, + wantBody: wantSig, + }, + { + name: "too many proof lines", + start: 8, + end: 13, + subRoot: subRoot, + proof: make([][]byte, 64), + cp: cp, + wantErrMsg: "too many proof lines", + }, + { + name: "400 Bad Request", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + wantErr: witness.ErrSubtreeRangeInvalid, + }, + { + name: "403 Forbidden", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }, + wantErr: witness.ErrNoWitnessSignature, + }, + { + name: "404 Not Found", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + wantErr: witness.ErrUnknownLog, + }, + { + name: "422 Unprocessable Entity", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + }, + wantErr: witness.ErrInvalidProof, + }, + { + name: "429 Too Many Requests", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }, + wantErr: witness.ErrPushback, + }, + { + name: "501 Not Implemented", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) + }, + wantErr: witness.ErrNotImplemented, + }, + { + name: "500 Internal Server Error", + start: 8, + end: 13, + subRoot: subRoot, + proof: proof, + cp: cp, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErrMsg: "unexpected status code 500", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var u *url.URL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + var err error + u, err = url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server url: %v", err) + } + } else { + u, _ = url.Parse("http://localhost:2026") + } + + client := NewWitness(u, http.DefaultClient) + gotBody, err := client.SignSubtree(t.Context(), tc.start, tc.end, tc.subRoot, tc.proof, tc.cp) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Errorf("got error %v, want %v", err, tc.wantErr) + } + return + } + if tc.wantErrMsg != "" { + if err == nil || !bytes.Contains([]byte(err.Error()), []byte(tc.wantErrMsg)) { + t.Errorf("got error %v, want error containing %q", err, tc.wantErrMsg) + } + return + } + if err != nil { + t.Fatalf("SignSubtree unexpected error: %v", err) + } + if !cmp.Equal(gotBody, tc.wantBody) { + t.Errorf("SignSubtree got body %q, want %q", gotBody, tc.wantBody) + } + }) + } +} From e2338423bec9ddb4af46e4bdb2bb8f1d4564651b Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Fri, 14 Aug 2026 15:36:22 +0000 Subject: [PATCH 2/2] Address comments --- client/http/witness_client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/http/witness_client.go b/client/http/witness_client.go index d39fd60d..b7c01b77 100644 --- a/client/http/witness_client.go +++ b/client/http/witness_client.go @@ -34,7 +34,7 @@ import ( // maxResponseBodyBytes is the limit on the number of bytes we'll read from incoming responses. // 16 should be more than enough, even in a PQ world. -var maxResponseBodyBytes int64 = 16 << 10 +const maxResponseBodyBytes int64 = 16 << 10 // NewWitness returns a Witness accessed over http at the given URL // using the client provided. @@ -83,7 +83,7 @@ func (w Witness) Update(ctx context.Context, oldSize uint64, newCP []byte, proof } }() - if resp.Request != nil && resp.Request.Method != http.MethodPost { + if resp.Request.Method != http.MethodPost { return nil, 0, fmt.Errorf("POST request to %q was converted to %s request to %q", w.url.String(), resp.Request.Method, resp.Request.URL) } @@ -152,7 +152,7 @@ func (w Witness) SignSubtree(ctx context.Context, start, end uint64, subRoot []b } }() - if resp.Request != nil && resp.Request.Method != http.MethodPost { + if resp.Request.Method != http.MethodPost { return nil, fmt.Errorf("POST request to %q was converted to %s request to %q", w.url.String(), resp.Request.Method, resp.Request.URL) }