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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ and
[WithCheckpointRepublishInterval](https://pkg.go.dev/github.com/transparency-dev/tessera#AppendOptions.WithCheckpointRepublishInterval))
and performs the following steps:
1. Create a new Checkpoint and sign it with the signer provided by [WithCheckpointSigner](https://pkg.go.dev/github.com/transparency-dev/tessera#AppendOptions.WithCheckpointSigner)
2. Contact witnesses and collect enough countersignatures to satisfy any witness policy configured by [WithWitnesses](https://pkg.go.dev/github.com/transparency-dev/tessera#AppendOptions.WithWitnesses)
2. Contact witnesses and collect enough cosignatures to satisfy any witness policy configured by [WithWitnesses](https://pkg.go.dev/github.com/transparency-dev/tessera#AppendOptions.WithWitnesses)
3. If the witness policy is satisfied, make this new Checkpoint public available

An entry is considered published once it is committed to by a published Checkpoint (i.e. a published Checkpoint's size is larger than the entry's assigned index).
Expand Down
83 changes: 76 additions & 7 deletions append_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
"net/http"
"os"
"slices"
"sync"
"sync/atomic"
"time"
Expand Down Expand Up @@ -58,7 +59,8 @@ const (
DefaultAntispamInMemorySize = 256 << 10
// DefaultWitnessTimeout is the default maximum time to wait for responses from configured witnesses.
DefaultWitnessTimeout = 1 * time.Second

// DefaultMirrorTimeout is the default maximum time to wait for responses from configured mirrors.
DefaultMirrorTimeout = 10 * time.Second
// DefaultEntrySizeLimit is the maximum possible size of data for a single entry, as specified by C2SP tlog-tiles.
DefaultEntrySizeLimit = 1<<16 - 1

Expand Down Expand Up @@ -670,6 +672,9 @@ type AppendOptions struct {
witnesses WitnessGroup
witnessOpts WitnessOptions

mirrors WitnessGroup
mirrorOpts MirroringOptions

addDecorators []func(AddFn) AddFn
followers []Follower

Expand Down Expand Up @@ -735,13 +740,24 @@ func (o AppendOptions) CheckpointPublisher(lr LogReader, httpClient *http.Client
span.AddEvent("Created CP")
appenderSignedSize.Record(ctx, otel.Clamp64(size))

wSigs, err := witnessCheckpoint(ctx, cp, size, o.witnesses, lr, httpClient, o.witnessOpts)
if err != nil {
return nil, err
}
var ws, ms []byte
eg := errgroup.Group{}
eg.Go(func() error {
var err error
ws, err = witnessCheckpoint(ctx, cp, size, o.witnesses, lr, httpClient, o.witnessOpts)
return err
})
eg.Go(func() error {
var err error
ms, err = mirrorCheckpoint(ctx, cp, size, o.mirrors, lr, httpClient, o.mirrorOpts)
return err
})

cp = append(cp, wSigs...)
if err := eg.Wait(); err != nil {
return nil, fmt.Errorf("failed to fetch cosignatures: %v", err)
}

cp = append(append(slices.Clone(cp), ws...), ms...)
return cp, nil
})
}
Expand Down Expand Up @@ -796,6 +812,18 @@ func witnessCheckpoint(ctx context.Context, cp []byte, cpSize uint64, witnesses
})
}

// mirrorCheckpoint takes care of mirroring the given checkpoint with the provided mirror policy.
// Returns signatures from mirrors, ready to append to the checkpoint, or an error.
func mirrorCheckpoint(ctx context.Context, cp []byte, cpSize uint64, mirrors WitnessGroup, lr LogReader, httpClient *http.Client, opts MirroringOptions) ([]byte, error) {
return otel.Trace(ctx, "tessera.mirrorCheckpoint", tracer, func(ctx context.Context, span trace.Span) ([]byte, error) {
if len(mirrors.Components) == 0 {
return nil, nil
}
span.AddEvent("Starting mirroring")
return nil, nil
})
}

func (o AppendOptions) BatchMaxAge() time.Duration {
return o.batchMaxAge
}
Expand Down Expand Up @@ -930,7 +958,7 @@ func (o *AppendOptions) WithCheckpointRepublishInterval(interval time.Duration)
return o
}

// WithWitnesses configures the set of witnesses that Tessera will contact in order to counter-sign
// WithWitnesses configures the set of witnesses that Tessera will contact in order to cosign
// a checkpoint before publishing it. A request will be sent to every witness referenced by the group
// using the URLs method. The checkpoint will be accepted for publishing when a sufficient number of
// witnesses to Satisfy the group have responded.
Expand All @@ -950,6 +978,27 @@ func (o *AppendOptions) WithWitnesses(witnesses WitnessGroup, opts *WitnessOptio
return o
}

// WithMirrors configures the set of tlog-mirror servers that Tessera will contact in order to obtain
// mirror cosignatures on a checkpoint before publishing it.
//
// Requests will be sent to every mirror referenced by the group using the tlog-mirror API at the configured URL.
// The checkpoint will be accepted for publishing when a sufficient number of mirrors to satisfy the group
// have responded.
//
// If this method is not called, then no mirror cosignatures will be required to publish.
func (o *AppendOptions) WithMirrors(mirrors WitnessGroup, opts *MirroringOptions) *AppendOptions {
if opts == nil {
opts = &MirroringOptions{}
}
if opts.Timeout == 0 {
opts.Timeout = DefaultMirrorTimeout
}

o.mirrors = mirrors
o.mirrorOpts = *opts
return o
}

// WitnessOptions contains extra optional configuration for how Tessera should use/interact with
// a user-provided WitnessGroup policy.
type WitnessOptions struct {
Expand All @@ -970,6 +1019,26 @@ type WitnessOptions struct {
FailOpen bool
}

// MirroringOptions contains extra optional configuration for how Tessera should use/interact with
// the tlog-mirror servers.
type MirroringOptions struct {
// Timeout is the maximum time to wait while attempting to satisfy the configured mirror policy.
//
// If the policy has not already been satisfied at the point this duration has passed, Tessera
// will stop waiting for more responses. The FailOpen option below controls whether or not the
// checkpoint will be published in this case.
//
// If unset, uses DefaultMirrorTimeout.
Timeout time.Duration

// FailOpen controls whether a checkpoint, for which the mirror policy was unable to be met,
// should still be published.
//
// This setting is intended only for facilitating early "non-blocking" adoption of mirroring,
// and will be disabled and/or removed in the future.
FailOpen bool
}

// WithGarbageCollectionInterval allows the interval between scans to remove obsolete partial
// tiles and entry bundles.
//
Expand Down
216 changes: 216 additions & 0 deletions append_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"

f_note "github.com/transparency-dev/formats/note"
"github.com/transparency-dev/witness/config"
"github.com/transparency-dev/witness/persistence/inmemory"
"github.com/transparency-dev/witness/witness"
"golang.org/x/mod/sumdb/note"
)

Expand Down Expand Up @@ -218,3 +225,212 @@ func TestAddUpdatesWantTreeSize(t *testing.T) {
t.Fatalf("wantTreeSize should be %d after adding index %d, got %d", wantIdx+1, wantIdx, got)
}
}

func TestWithMirrors(t *testing.T) {
u, err := url.Parse("https://mirror.example.com")
if err != nil {
t.Fatalf("failed to parse url: %v", err)
}
wit, err := NewWitness("Wit1+55ee4561+AVhZSmQj9+SoL+p/nN0Hh76xXmF7QcHfytUrI1XfSClk", u)
if err != nil {
t.Fatalf("failed to create witness: %v", err)
}
mirrors := NewWitnessGroup(1, wit)

for _, test := range []struct {
desc string
mirrorOpts *MirroringOptions
expectTimeout time.Duration
expectFailOpen bool
}{
{
desc: "nil options",
mirrorOpts: nil,
expectTimeout: DefaultMirrorTimeout,
expectFailOpen: false,
},
{
desc: "custom options",
mirrorOpts: &MirroringOptions{
Timeout: 5 * time.Second,
FailOpen: true,
},
expectTimeout: 5 * time.Second,
expectFailOpen: true,
},
{
desc: "zero timeout uses default",
mirrorOpts: &MirroringOptions{
Timeout: 0,
FailOpen: true,
},
expectTimeout: DefaultMirrorTimeout,
expectFailOpen: true,
},
} {
t.Run(test.desc, func(t *testing.T) {
opts := NewAppendOptions().WithMirrors(mirrors, test.mirrorOpts)
if len(opts.mirrors.Components) != 1 {
t.Errorf("expected 1 mirror component, got %d", len(opts.mirrors.Components))
}
if got, want := opts.mirrorOpts.Timeout, test.expectTimeout; got != want {
t.Errorf("expected timeout %v, got %v", want, got)
}
if got, want := opts.mirrorOpts.FailOpen, test.expectFailOpen; got != want {
t.Errorf("expected FailOpen %t, got %t", want, got)
}
})
}
}

const (
testWit1VKey = "Wit1+55ee4561+AVhZSmQj9+SoL+p/nN0Hh76xXmF7QcHfytUrI1XfSClk"
testWit1SKey = "PRIVATE+KEY+Wit1+55ee4561+AeadRiG7XM4XiieCHzD8lxysXMwcViy5nYsoXURWGrlE"
)

func newWitnessHandler(t *testing.T, logVerifier note.Verifier, witnessSKey string) http.HandlerFunc {
witnessSigner, err := f_note.NewSignerForCosignatureV1(witnessSKey)
if err != nil {
t.Fatalf("failed to create witness signer: %v", err)
}

p := inmemory.New()
logCfg := config.Log{
Origin: "example.com/log/testdata",
Verifier: logVerifier,
VKey: "example.com/log/testdata+33d7b496+AeHTu4Q3hEIMHNqc6fASMsq3rKNx280NI+oO5xCFkkSx",
}
if err := p.AddLogs(t.Context(), []config.Log{logCfg}); err != nil {
t.Fatalf("failed to add log config to persistence: %v", err)
}

wOpts := witness.Opts{
Persistence: p,
Signers: []note.Signer{witnessSigner},
VerifierForLog: func(ctx context.Context, origin string) (note.Verifier, bool, error) {
if origin == "example.com/log/testdata" {
return logVerifier, true, nil
}
return nil, false, nil
},
}
witSvc, err := witness.New(t.Context(), wOpts)
if err != nil {
t.Fatalf("failed to create witness service: %v", err)
}

return witness.NewHTTPHandler(witSvc).AddCheckpoint
}

func TestCheckpointPublisher(t *testing.T) {
logSigner := mustCreateSigner(t, testSignerKey)
logVerifier, err := note.NewVerifier("example.com/log/testdata+33d7b496+AeHTu4Q3hEIMHNqc6fASMsq3rKNx280NI+oO5xCFkkSx")
if err != nil {
t.Fatalf("failed to create log verifier: %v", err)
}

witnessServer := httptest.NewServer(newWitnessHandler(t, logVerifier, testWit1SKey))
t.Cleanup(witnessServer.Close)

witnessServerURL, err := url.Parse(witnessServer.URL)
if err != nil {
t.Fatalf("failed to parse witness server url: %v", err)
}

wit, err := NewWitness(testWit1VKey, witnessServerURL)
if err != nil {
t.Fatalf("failed to create witness: %v", err)
}
witnesses := NewWitnessGroup(1, wit)
witVerifier, err := f_note.NewVerifierForCosignatureV1(testWit1VKey)
if err != nil {
t.Fatalf("failed to create witness verifier: %v", err)
}

dummyMirrors := NewWitnessGroup(1, wit)

for _, test := range []struct {
desc string
opts *AppendOptions
witnessFails bool
expectCosignatures []note.Verifier
expectErr bool
}{
{
desc: "no witnesses, no mirrors",
opts: NewAppendOptions().WithCheckpointSigner(logSigner),
},
{
desc: "witnesses only",
opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, nil),
expectCosignatures: []note.Verifier{witVerifier},
},
{
desc: "mirrors only",
opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithMirrors(dummyMirrors, nil),
},
{
desc: "witnesses and mirrors",
opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, nil).WithMirrors(dummyMirrors, nil),
expectCosignatures: []note.Verifier{witVerifier},
},
{
desc: "witness fails, failOpen=false",
opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, &WitnessOptions{FailOpen: false}),
witnessFails: true,
expectErr: true,
},
{
desc: "witness fails, failOpen=true",
opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, &WitnessOptions{FailOpen: true}),
witnessFails: true,
},
} {
t.Run(test.desc, func(t *testing.T) {
client := http.DefaultClient
if test.witnessFails {
failingWitnessServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
}))
defer failingWitnessServer.Close()

failingURL, _ := url.Parse(failingWitnessServer.URL)
failingWit, _ := NewWitness(testWit1VKey, failingURL)
failingWitnesses := NewWitnessGroup(1, failingWit)

// Re-configure option to use failing witnesses
wOpts := &WitnessOptions{FailOpen: test.opts.witnessOpts.FailOpen}
test.opts.WithWitnesses(failingWitnesses, wOpts)
}

lr := &fakeLogReader{
readCheckpoint: func(ctx context.Context) ([]byte, error) {
return nil, errors.New("no checkpoint yet")
},
}

publisher := test.opts.CheckpointPublisher(lr, client)
cp, err := publisher(t.Context(), 5, []byte("12345678901234567890123456789012"))
if (err != nil) != test.expectErr {
t.Fatalf("expected error %v but got: %v", test.expectErr, err)
}
if err != nil {
return
}

// Open checkpoint to verify signatures
wantV := append([]note.Verifier{logVerifier}, test.expectCosignatures...)
n, err := note.Open(cp, note.VerifierList(wantV...))
if err != nil {
t.Fatalf("failed to open signed checkpoint: %v", err)
}

// Check that all required verifiers signed it
if len(n.Sigs) != len(wantV) {
t.Logf("cp = %q", string(cp))
t.Logf("n.Sigs = %+v", n.Sigs)
t.Errorf("expected %d signatures, got %d", len(wantV), len(n.Sigs))
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/mtc/mirror/posix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func mirrorConfigFromFlags(ctx context.Context) []witnessConfig.Log {
slog.ErrorContext(ctx, "Duplicate origin in mirror config", slog.String("origin", o))
os.Exit(1)
}
seenOrigins[o]=struct{}{}
seenOrigins[o] = struct{}{}
}
return cfg
}
Expand Down
Loading
Loading