Skip to content
Draft
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
235 changes: 235 additions & 0 deletions cmd/mtc/log/internal/subtreewitness/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// Copyright 2026 The Tessera authors. 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 subtreewitness provides a gateway for subtree cosigning with witnesses.
package subtreewitness

import (
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"sync"

f_note "github.com/transparency-dev/formats/note"
"github.com/transparency-dev/tessera"
"github.com/transparency-dev/tessera/cmd/mtc/log/internal/mtcproof"
wc "github.com/transparency-dev/witness/client/http"
"golang.org/x/mod/sumdb/note"
)

// ErrPolicyNotSatisfied is returned when witness responses do not satisfy the required policy.
var ErrPolicyNotSatisfied = errors.New("witness policy was not satisfied")

type witnessKey struct {
name string
keyHash uint32
}

type witness struct {
client SubtreeWitnessClient
verifier f_note.SubtreeVerifier
cosignerID []byte
}

// SubtreeWitnessClient defines the interface for calling a witness's sign-subtree endpoint.
type SubtreeWitnessClient interface {
SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error)
}

// Gateway manages concurrent requests to subtree witnesses and evaluates policy satisfaction.
type Gateway struct {
witnesses map[witnessKey]witness
policy tessera.WitnessGroup
}

// NewGateway creates a new subtree witness Gateway.
func NewGateway(httpClient *http.Client, policy tessera.WitnessGroup) (*Gateway, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}

endpoints := policy.WitnessEndpoints()
witnesses := make(map[witnessKey]witness)

for uStr, vs := range endpoints {
u, err := url.Parse(uStr)
if err != nil {
return nil, fmt.Errorf("invalid witness URL %q: %w", uStr, err)
}
if len(vs) == 0 {
return nil, fmt.Errorf("no verifiers for witness %s", uStr)
}
client := wc.NewWitness(u, httpClient)
for _, v := range vs {
if sv, ok := v.(f_note.SubtreeVerifier); ok {
cosignerID, err := mtcproof.ParseCosignerID(sv.Name())
if err != nil {
return nil, fmt.Errorf("invalid cosigner ID for witness %s: %w", sv.Name(), err)
}
witnesses[witnessKey{name: sv.Name(), keyHash: sv.KeyHash()}] = witness{
client: client,
verifier: sv,
cosignerID: cosignerID,
}
}
}
}

return &Gateway{
witnesses: witnesses,
policy: policy,
}, nil
}

// CosignSubtree sends concurrent subtree cosigning requests to witnesses and returns verified
// SubtreeSignatures as soon as the gateway's policy is satisfied on the reconstructed checkpoint.
func (gw *Gateway) CosignSubtree(ctx context.Context, origin string, logVerifier note.Verifier, start, end uint64, subRoot []byte, consProof [][]byte, rawCp []byte) ([]mtcproof.SubtreeSignature, error) {
if len(gw.witnesses) == 0 {
if gw.policy.Satisfied(rawCp) {
return nil, nil
}
return nil, ErrPolicyNotSatisfied
}

var n *note.Note
noteRes, err := note.Open(rawCp, note.VerifierList(logVerifier))
if err != nil {
var unverified *note.UnverifiedNoteError
if !errors.As(err, &unverified) || len(unverified.Note.Sigs) == 0 {
return nil, fmt.Errorf("checkpoint failed log signature verification: %w", err)
}
n = unverified.Note
} else {
n = noteRes
}

var logSig *note.Signature
for i, s := range n.Sigs {
if s.Name == logVerifier.Name() && s.Hash == logVerifier.KeyHash() {
logSig = &n.Sigs[i]
break
}
}
if logSig == nil {
return nil, fmt.Errorf("checkpoint missing verified signature from log %q (%08x)", logVerifier.Name(), logVerifier.KeyHash())
}

reconstructedCp := fmt.Appendf(nil, "%s\n— %s %s\n", n.Text, logSig.Name, logSig.Base64)

cpSigs := make(map[witnessKey]string)
for _, s := range n.UnverifiedSigs {
cpSigs[witnessKey{name: s.Name, keyHash: s.Hash}] = s.Base64
}

ctx, cancel := context.WithCancel(ctx)
defer cancel()

var waitGroup sync.WaitGroup
type sigOrErr struct {
sig []byte
err error
}
results := make(chan sigOrErr, len(gw.witnesses))

for _, w := range gw.witnesses {
waitGroup.Add(1)
go func(w witness) {
defer waitGroup.Done()
sig, err := w.client.SignSubtree(ctx, start, end, subRoot, consProof, rawCp)
results <- sigOrErr{
sig: sig,
err: err,
}
}(w)
}

go func() {
waitGroup.Wait()
close(results)
}()

var (
reqErr error
verifiedSubtreeSigs []mtcproof.SubtreeSignature
)

for r := range results {
if r.err != nil {
reqErr = errors.Join(reqErr, r.err)
continue
}

var sigNote *note.UnverifiedNoteError
_, sigErr := note.Open(append([]byte("text\n\n"), r.sig...), nil)
if !errors.As(sigErr, &sigNote) {
slog.WarnContext(ctx, "Failed to parse witness subtree signature response", slog.Any("error", sigErr))
continue
}

for _, s := range sigNote.Note.UnverifiedSigs {
raw, bErr := base64.StdEncoding.DecodeString(s.Base64)
if bErr != nil || len(raw) < 4 {
continue
}
keyHash := binary.BigEndian.Uint32(raw[:4])
sigBytes := raw[4:]

k := witnessKey{name: s.Name, keyHash: keyHash}
b64, ok := cpSigs[k]
if !ok {
slog.WarnContext(ctx, "Received subtree signature from witness not present on checkpoint",
slog.String("witness", s.Name),
slog.String("key_hash", fmt.Sprintf("%08x", keyHash)),
)
continue
}

w, ok := gw.witnesses[k]
if !ok {
slog.ErrorContext(ctx, "Received subtree signature from unknown witness key",
slog.String("witness", s.Name),
slog.String("key_hash", fmt.Sprintf("%08x", keyHash)),
)
continue
}

if !w.verifier.VerifySubtree(0, origin, start, end, subRoot, sigBytes) {
slog.ErrorContext(ctx, "Subtree signature verification failed",
slog.String("witness", s.Name),
slog.Uint64("start", start),
slog.Uint64("end", end),
)
continue
}

delete(cpSigs, k)
verifiedSubtreeSigs = append(verifiedSubtreeSigs, mtcproof.SubtreeSignature{
CosignerID: w.cosignerID,
Signature: sigBytes,
})
reconstructedCp = fmt.Appendf(reconstructedCp, "— %s %s\n", s.Name, b64)
}

if gw.policy.Satisfied(reconstructedCp) {
return verifiedSubtreeSigs, nil
}
}

return nil, errors.Join(ErrPolicyNotSatisfied, reqErr)
}
Loading
Loading