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
5 changes: 5 additions & 0 deletions api/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
72 changes: 69 additions & 3 deletions client/http/witness_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
const 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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -83,7 +87,7 @@ func (w Witness) Update(ctx context.Context, oldSize uint64, newCP []byte, proof
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)
}
Expand Down Expand Up @@ -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.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)
}
}
225 changes: 225 additions & 0 deletions client/http/witness_client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading