From 90177aa96ae35a5503f5870487a964ced0983513 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Tue, 18 Aug 2026 14:27:41 +0000 Subject: [PATCH 1/7] Use policy.TLogPolicy from formats to configuyre witnessing/mirroring --- append_lifecycle.go | 127 +++++---- append_lifecycle_test.go | 81 +++--- cmd/conformance/posix/main.go | 11 +- cmd/examples/posix-oneshot/main.go | 7 +- cmd/mtc/log/posix/main.go | 9 +- integration/mirror/posix/mirror_test.go | 11 +- mirror_lifecycle_test.go | 2 +- witness.go | 346 ++++++++---------------- witness_policy_test.go | 168 ------------ witness_test.go | 214 --------------- 10 files changed, 239 insertions(+), 737 deletions(-) delete mode 100644 witness_test.go diff --git a/append_lifecycle.go b/append_lifecycle.go index 055905fd0..761f9557c 100644 --- a/append_lifecycle.go +++ b/append_lifecycle.go @@ -19,7 +19,6 @@ import ( "context" "errors" "fmt" - "maps" "net/http" "net/url" "os" @@ -31,6 +30,7 @@ import ( "log/slog" f_log "github.com/transparency-dev/formats/log" + "github.com/transparency-dev/formats/policy" "github.com/transparency-dev/merkle/rfc6962" "github.com/transparency-dev/tessera/api/layout" m_gateway "github.com/transparency-dev/tessera/internal/mirror/gateway" @@ -677,10 +677,10 @@ type AppendOptions struct { checkpointRepublishInterval time.Duration checkpointPublicationTimeout time.Duration - witnesses WitnessGroup + witnesses policy.TLogPolicy witnessOpts WitnessOptions - mirrors WitnessGroup + mirrors policy.TLogPolicy mirrorOpts MirroringOptions addDecorators []func(AddFn) AddFn @@ -772,19 +772,6 @@ func (o *AppendOptions) WithAntispam(inMemEntries uint, as Antispam) *AppendOpti return o } -// parseURLs converts a list of URL strings to a list of *url.URL, failing if any cannot be parsed. -func parseURLs(us []string) ([]*url.URL, error) { - ret := make([]*url.URL, 0, len(us)) - for _, s := range us { - u, err := url.Parse(s) - if err != nil { - return nil, err - } - ret = append(ret, u) - } - return ret, nil -} - // CheckpointPublisher should not be used. // Deprecated: Use CheckpointPublisherContext. func (o AppendOptions) CheckpointPublisher(lr LogReader, httpClient *http.Client) func(context.Context, uint64, []byte) ([]byte, error) { @@ -821,7 +808,7 @@ func (o AppendOptions) CheckpointPublisherContext(ctx context.Context, lr LogRea defer cancel() var err error - ws, err = witnessCheckpoint(ctx, witnessGateway.CosignCheckpoint, &o.witnesses, cp, cpSize, o.witnessOpts.FailOpen, o.witnessOpts.Greedy) + ws, err = witnessCheckpoint(ctx, witnessGateway.CosignCheckpoint, o.witnesses, cp, cpSize, o.witnessOpts.FailOpen, o.witnessOpts.Greedy) return err }) } @@ -832,7 +819,7 @@ func (o AppendOptions) CheckpointPublisherContext(ctx context.Context, lr LogRea defer cancel() var err error - ms, err = mirrorCheckpoint(ctx, mirrorGateway.CosignCheckpoint, &o.mirrors, cp, cpSize, o.mirrorOpts.FailOpen, false) + ms, err = mirrorCheckpoint(ctx, mirrorGateway.CosignCheckpoint, o.mirrors, cp, cpSize, o.mirrorOpts.FailOpen, false) return err }) } @@ -850,14 +837,13 @@ func (o AppendOptions) CheckpointPublisherContext(ctx context.Context, lr LogRea // witnessGateway creates and returns a witnessGateway instance, or nil if no witnesses are configured. func (o AppendOptions) witnessGateway(ctx context.Context, lr LogReader, httpClient *http.Client) (*witness.WitnessGateway, error) { witnesses := []witness.Witness{} - for uStr, vs := range o.witnesses.WitnessEndpoints() { - u, err := url.Parse(uStr) - if err != nil { - return nil, fmt.Errorf("failed to parse witness URL: %w", err) + for _, w := range o.witnesses.Witnesses { + if w.URL == nil { + return nil, fmt.Errorf("invalid witness policy: witness %q has no URL", w.Name) } witnesses = append(witnesses, witness.Witness{ - URL: u, - Verifiers: vs, + URL: w.URL, + Verifiers: []note.Verifier{w.Verifier}, }) } if len(witnesses) == 0 { @@ -873,9 +859,12 @@ func (o AppendOptions) witnessGateway(ctx context.Context, lr LogReader, httpCli // mirrorGateway creates and returns a mirrorGateway instance, or nil if no mirrors are configured. func (o AppendOptions) mirrorGateway(ctx context.Context, lr LogReader, httpClient *http.Client) (*m_gateway.Gateway, error) { - mirrorURLs, err := parseURLs(slices.Collect(maps.Keys(o.mirrors.WitnessEndpoints()))) - if err != nil { - return nil, fmt.Errorf("failed to parse mirror URLs: %w", err) + mirrorURLs := []*url.URL{} + for _, m := range o.mirrors.Witnesses { + if m.URL == nil { + return nil, fmt.Errorf("invalid mirror policy: mirror %q has no URL", m.Name) + } + mirrorURLs = append(mirrorURLs, m.URL) } if len(mirrorURLs) == 0 { return nil, nil @@ -894,12 +883,12 @@ func (o AppendOptions) mirrorGateway(ctx context.Context, lr LogReader, httpClie // witnessCheckpoint takes care of witnessing the given checkpoint with the provided witness policy. // Returns signatures from witnesses, ready to append to the checkpoint, or an error. -func witnessCheckpoint(ctx context.Context, cosign cosigSource, policy *WitnessGroup, cp []byte, cpSize uint64, failOpen bool, greedy bool) ([]byte, error) { +func witnessCheckpoint(ctx context.Context, cosign cosigSource, wPol policy.TLogPolicy, cp []byte, cpSize uint64, failOpen bool, greedy bool) ([]byte, error) { return otel.Trace(ctx, "tessera.CheckpointPublisher.Witness", tracer, func(ctx context.Context, span trace.Span) ([]byte, error) { start := time.Now() witAttr := []attribute.KeyValue{} - sigs, err := gatherCosignatures(ctx, "witness", cosign, policy, cp, cpSize, failOpen, greedy) + sigs, err := gatherCosignatures(ctx, "witness", cosign, wPol, cp, cpSize, failOpen, greedy) if err != nil { if !errors.Is(err, errFailedOpen) { appenderWitnessRequests.Add(ctx, 1, metric.WithAttributes(attribute.String("error.type", "failed"))) @@ -918,9 +907,9 @@ func witnessCheckpoint(ctx context.Context, cosign cosigSource, policy *WitnessG // 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, cosign cosigSource, policy *WitnessGroup, cp []byte, cpSize uint64, failOpen bool, greedy bool) ([]byte, error) { +func mirrorCheckpoint(ctx context.Context, cosign cosigSource, mPol policy.TLogPolicy, cp []byte, cpSize uint64, failOpen bool, greedy bool) ([]byte, error) { return otel.Trace(ctx, "tessera.CheckpointPublisher.Mirror", tracer, func(ctx context.Context, span trace.Span) ([]byte, error) { - sigs, err := gatherCosignatures(ctx, "mirror", cosign, policy, cp, cpSize, failOpen, greedy) + sigs, err := gatherCosignatures(ctx, "mirror", cosign, mPol, cp, cpSize, failOpen, greedy) if err != nil { if !errors.Is(err, errFailedOpen) { slog.WarnContext(ctx, "Failed to collect mirror signatures", slog.Any("error", err)) @@ -940,13 +929,13 @@ var errFailedOpen = errors.New("failed-open") // gatherCosignatures gathers signatures from a source, applying a policy to determine if the signatures are sufficient. // It returns a set of signatures which satisfy the policy (potentially more than required if greedy is true), or an error if the policy is not met and failOpen is false. -func gatherCosignatures(ctx context.Context, name string, fetcher cosigSource, policy *WitnessGroup, cp []byte, cpSize uint64, failOpen bool, greedy bool) ([]byte, error) { - maxExpectedResponses := len(policy.WitnessEndpoints()) +func gatherCosignatures(ctx context.Context, name string, fetcher cosigSource, pol policy.TLogPolicy, cp []byte, cpSize uint64, failOpen bool, greedy bool) ([]byte, error) { + maxExpectedResponses := len(pol.Witnesses) // checkPolicy checks if the provided signatures satisfy the given policy. checkPolicy := func(sigs []byte, failOpen bool) ([]byte, error) { newCP := append(slices.Clone(cp), sigs...) - if policy.Satisfied(newCP) { + if pol.Satisfied(newCP) { return sigs, nil } if failOpen { @@ -996,7 +985,7 @@ func gatherCosignatures(ctx context.Context, name string, fetcher cosigSource, p } return otel.Trace(ctx, "tessera.gatherCosignatures", tracer, func(ctx context.Context, span trace.Span) ([]byte, error) { - if len(policy.Components) == 0 { + if len(pol.Witnesses) == 0 { return nil, nil } @@ -1157,14 +1146,13 @@ func (o *AppendOptions) WithCheckpointPublicationTimeout(timeout time.Duration) return o } -// 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. +// WithWitnessPolicy 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 policy. +// The checkpoint will be accepted for publishing when a sufficient number of witnesses to satisfy +// the policy have responded. // -// If this method is not called, then the default empty WitnessGroup will be used, which contacts zero -// witnesses and requires zero witnesses in order to publish. -func (o *AppendOptions) WithWitnesses(witnesses WitnessGroup, opts *WitnessOptions) *AppendOptions { +// If this method is not called, then witnessing will not be performed. +func (o *AppendOptions) WithWitnessPolicy(witPolicy policy.TLogPolicy, opts *WitnessOptions) *AppendOptions { if opts == nil { opts = &WitnessOptions{} } @@ -1172,20 +1160,28 @@ func (o *AppendOptions) WithWitnesses(witnesses WitnessGroup, opts *WitnessOptio opts.Timeout = DefaultWitnessTimeout } - o.witnesses = witnesses + o.witnesses = witPolicy o.witnessOpts = *opts return o } -// WithMirrors configures the set of tlog-mirror servers that Tessera will contact in order to obtain +// WithWitnesses configures the set of witnesses that Tessera will contact in order to cosign +// a checkpoint before publishing it. +// +// Deprecated: Use WithWitnessPolicy instead. +func (o *AppendOptions) WithWitnesses(witnesses WitnessGroup, opts *WitnessOptions) *AppendOptions { + return o.WithWitnessPolicy(witnesses.toPolicy(), opts) +} + +// WithMirrorPolicy 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 +// Requests will be sent to every mirror referenced by the policy 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 policy // 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 this method is not called, then mirroring will not be performed. +func (o *AppendOptions) WithMirrorPolicy(mirrors policy.TLogPolicy, opts *MirroringOptions) *AppendOptions { if opts == nil { opts = &MirroringOptions{} } @@ -1198,6 +1194,14 @@ func (o *AppendOptions) WithMirrors(mirrors WitnessGroup, opts *MirroringOptions 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. +// +// Deprecated: Use WithMirrorPolicy instead. +func (o *AppendOptions) WithMirrors(mirrors WitnessGroup, opts *MirroringOptions) *AppendOptions { + return o.WithMirrorPolicy(mirrors.toPolicy(), opts) +} + // WitnessOptions contains extra optional configuration for how Tessera should use/interact with // a user-provided WitnessGroup policy. type WitnessOptions struct { @@ -1290,14 +1294,29 @@ func (o *AppendOptions) LogValue() slog.Value { attrs = append(attrs, slog.Any("additionalSigners", names)) } - if len(o.witnesses.Components) > 0 { - endpoints := o.witnesses.WitnessEndpoints() - urls := make([]string, 0, len(endpoints)) - for u := range endpoints { - urls = append(urls, u) + if len(o.witnesses.Witnesses) > 0 { + urls := make([]string, 0, len(o.witnesses.Witnesses)) + for _, w := range o.witnesses.Witnesses { + if w.URL != nil { + urls = append(urls, w.URL.String()) + } } attrs = append(attrs, slog.Group("witnesses", - slog.Int("threshold", o.witnesses.N), + slog.Int("groups", len(o.witnesses.Groups)), + slog.Any("quorum", o.witnesses.Quorum), + slog.Any("endpoints", urls), + )) + } + if len(o.mirrors.Witnesses) > 0 { + urls := make([]string, 0, len(o.mirrors.Witnesses)) + for _, w := range o.mirrors.Witnesses { + if w.URL != nil { + urls = append(urls, w.URL.String()) + } + } + attrs = append(attrs, slog.Group("mirrors", + slog.Int("groups", len(o.mirrors.Groups)), + slog.Any("quorum", o.mirrors.Quorum), slog.Any("endpoints", urls), )) } diff --git a/append_lifecycle_test.go b/append_lifecycle_test.go index d761357b6..f4d7657b8 100644 --- a/append_lifecycle_test.go +++ b/append_lifecycle_test.go @@ -32,6 +32,7 @@ import ( "time" f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" "github.com/transparency-dev/merkle/rfc6962" "github.com/transparency-dev/witness/config" "github.com/transparency-dev/witness/persistence/inmemory" @@ -97,23 +98,23 @@ func TestAppendOptionsValid(t *testing.T) { name: "Valid: CheckpointPublicationTimeout < WitnessTimeout adjusts publication timeout", opts: NewAppendOptions(). WithCheckpointSigner(mustCreateSigner(t, testSignerKey)). - WithCheckpointPublicationTimeout(1 * time.Second). - WithWitnesses(NewWitnessGroup(0), &WitnessOptions{Timeout: 10 * time.Second}), + WithCheckpointPublicationTimeout(1*time.Second). + WithWitnessPolicy(NewWitnessGroup(0).toPolicy(), &WitnessOptions{Timeout: 10 * time.Second}), wantPublicationTimeout: 10 * time.Second, }, { name: "Valid: CheckpointPublicationTimeout < MirrorTimeout adjusts publication timeout", opts: NewAppendOptions(). WithCheckpointSigner(mustCreateSigner(t, testSignerKey)). - WithCheckpointPublicationTimeout(1 * time.Second). - WithMirrors(NewWitnessGroup(0), &MirroringOptions{Timeout: 15 * time.Second}), + WithCheckpointPublicationTimeout(1*time.Second). + WithMirrorPolicy(NewWitnessGroup(0).toPolicy(), &MirroringOptions{Timeout: 15 * time.Second}), wantPublicationTimeout: 15 * time.Second, }, { name: "Valid: CheckpointPublicationTimeout adjusts to max of WitnessTimeout and MirrorTimeout", opts: NewAppendOptions(). WithCheckpointSigner(mustCreateSigner(t, testSignerKey)). - WithCheckpointPublicationTimeout(1 * time.Second). - WithWitnesses(NewWitnessGroup(0), &WitnessOptions{Timeout: 10 * time.Second}). - WithMirrors(NewWitnessGroup(0), &MirroringOptions{Timeout: 20 * time.Second}), + WithCheckpointPublicationTimeout(1*time.Second). + WithWitnessPolicy(NewWitnessGroup(0).toPolicy(), &WitnessOptions{Timeout: 10 * time.Second}). + WithMirrorPolicy(NewWitnessGroup(0).toPolicy(), &MirroringOptions{Timeout: 20 * time.Second}), wantPublicationTimeout: 20 * time.Second, }, { name: "Error: CheckpointRepublishInterval < CheckpointInterval", @@ -268,7 +269,7 @@ func TestWithMirrors(t *testing.T) { if err != nil { t.Fatalf("failed to create witness: %v", err) } - mirrors := NewWitnessGroup(1, wit) + mirrors := NewWitnessGroup(1, wit).toPolicy() for _, test := range []struct { desc string @@ -302,10 +303,7 @@ func TestWithMirrors(t *testing.T) { }, } { 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)) - } + opts := NewAppendOptions().WithMirrorPolicy(mirrors, test.mirrorOpts) if got, want := opts.mirrorOpts.Timeout, test.expectTimeout; got != want { t.Errorf("expected timeout %v, got %v", want, got) } @@ -316,9 +314,9 @@ func TestWithMirrors(t *testing.T) { } } -func TestWithWitnesses(t *testing.T) { +func TestWithWitnessPolicy(t *testing.T) { wit := mustNewWitness(t, testWit1VKey, "https://witness.example.com") - witnesses := NewWitnessGroup(1, wit) + witnesses := NewWitnessGroup(1, wit).toPolicy() for _, test := range []struct { desc string @@ -358,10 +356,7 @@ func TestWithWitnesses(t *testing.T) { }, } { t.Run(test.desc, func(t *testing.T) { - opts := NewAppendOptions().WithWitnesses(witnesses, test.witnessOpts) - if len(opts.witnesses.Components) != 1 { - t.Errorf("expected 1 witness component, got %d", len(opts.witnesses.Components)) - } + opts := NewAppendOptions().WithWitnessPolicy(witnesses, test.witnessOpts) if got, want := opts.witnessOpts.Timeout, test.expectTimeout; got != want { t.Errorf("expected timeout %v, got %v", want, got) } @@ -432,7 +427,7 @@ func TestGatherCosignatures(t *testing.T) { for _, test := range []struct { desc string - policy WitnessGroup + policy policy.TLogPolicy fetcher func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte timeout time.Duration failOpen bool @@ -443,7 +438,7 @@ func TestGatherCosignatures(t *testing.T) { }{ { desc: "empty policy", - policy: WitnessGroup{}, + policy: policy.TLogPolicy{}, fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte) close(ch) @@ -452,7 +447,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "non-greedy stops after quorum is satisfied (1 of 2)", - policy: NewWitnessGroup(1, wit1, wit2), + policy: NewWitnessGroup(1, wit1, wit2).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 2) ch <- sig1 @@ -464,7 +459,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "greedy gathers surplus signatures (2 of 3 required, 3 provided)", - policy: NewWitnessGroup(2, wit1, wit2, wit3), + policy: NewWitnessGroup(2, wit1, wit2, wit3).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 3) ch <- sig1 @@ -477,7 +472,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "greedy succeeds when quorum is met and channel closes without further signatures (1 of 2 required, 1 provided)", - policy: NewWitnessGroup(1, wit1, wit2), + policy: NewWitnessGroup(1, wit1, wit2).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 1) ch <- sig1 @@ -489,7 +484,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "greedy fails when quorum is not met and channel closes (failOpen=false)", - policy: NewWitnessGroup(2, wit1, wit2), + policy: NewWitnessGroup(2, wit1, wit2).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 1) ch <- sig1 @@ -502,7 +497,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "greedy fails open when quorum is not met and channel closes (failOpen=true)", - policy: NewWitnessGroup(2, wit1, wit2), + policy: NewWitnessGroup(2, wit1, wit2).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 1) ch <- sig1 @@ -516,7 +511,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "greedy fails when quorum is not met on timeout (failOpen=false)", - policy: NewWitnessGroup(2, wit1, wit2), + policy: NewWitnessGroup(2, wit1, wit2).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 1) ch <- sig1 @@ -529,7 +524,7 @@ func TestGatherCosignatures(t *testing.T) { }, { desc: "greedy fails open when quorum is not met on timeout (failOpen=true)", - policy: NewWitnessGroup(2, wit1, wit2), + policy: NewWitnessGroup(2, wit1, wit2).toPolicy(), fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { ch := make(chan []byte, 1) ch <- sig1 @@ -549,7 +544,7 @@ func TestGatherCosignatures(t *testing.T) { ctx, cancel = context.WithTimeout(ctx, test.timeout) defer cancel() } - sigs, err := gatherCosignatures(ctx, "witness", test.fetcher, &test.policy, signedCP, 5, test.failOpen, test.greedy) + sigs, err := gatherCosignatures(ctx, "witness", test.fetcher, test.policy, signedCP, 5, test.failOpen, test.greedy) switch { case test.expectFailedOpen: if !errors.Is(err, errFailedOpen) { @@ -628,7 +623,7 @@ func TestCheckpointPublisher(t *testing.T) { if err != nil { t.Fatalf("failed to create witness 1: %v", err) } - witnesses := NewWitnessGroup(1, wit1) + witnesses := NewWitnessGroup(1, wit1).toPolicy() wit1Verifier, err := f_note.NewVerifierForCosignatureV1(testWit1VKey) if err != nil { t.Fatalf("failed to create witness 1 verifier: %v", err) @@ -651,7 +646,7 @@ func TestCheckpointPublisher(t *testing.T) { t.Fatalf("failed to create witness 2 verifier: %v", err) } - multiWitnesses := NewWitnessGroup(1, wit1, wit2) + multiWitnesses := NewWitnessGroup(1, wit1, wit2).toPolicy() mirrorServer := httptest.NewServer(newMirrorHandler(t, testMirrorSKey)) t.Cleanup(mirrorServer.Close) @@ -665,7 +660,7 @@ func TestCheckpointPublisher(t *testing.T) { if err != nil { t.Fatalf("failed to create mirror: %v", err) } - mirrors := NewWitnessGroup(1, m) + mirrors := NewWitnessGroup(1, m).toPolicy() mirrorVerifier, err := f_note.NewVerifierForCosignatureV1(testMirrorVKey) if err != nil { t.Fatalf("failed to create mirror verifier: %v", err) @@ -686,43 +681,43 @@ func TestCheckpointPublisher(t *testing.T) { }, { desc: "witnesses only", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, &WitnessOptions{Timeout: time.Second}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(witnesses, &WitnessOptions{Timeout: time.Second}), expectCosignatures: []note.Verifier{wit1Verifier}, }, { desc: "mirrors only", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithMirrors(mirrors, &MirroringOptions{Timeout: time.Second}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithMirrorPolicy(mirrors, &MirroringOptions{Timeout: time.Second}), expectCosignatures: []note.Verifier{mirrorVerifier}, }, { desc: "witnesses and mirrors", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, &WitnessOptions{Timeout: time.Second}).WithMirrors(mirrors, &MirroringOptions{Timeout: time.Second}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(witnesses, &WitnessOptions{Timeout: time.Second}).WithMirrorPolicy(mirrors, &MirroringOptions{Timeout: time.Second}), expectCosignatures: []note.Verifier{wit1Verifier, mirrorVerifier}, }, { desc: "witness fails, failOpen=false", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, &WitnessOptions{FailOpen: false, Timeout: time.Second}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(witnesses, &WitnessOptions{FailOpen: false, Timeout: time.Second}), witnessFails: true, expectErr: true, }, { desc: "witness fails, failOpen=true", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(witnesses, &WitnessOptions{FailOpen: true, Timeout: time.Second}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(witnesses, &WitnessOptions{FailOpen: true, Timeout: time.Second}), witnessFails: true, }, { desc: "multi witnesses greedy=false", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(multiWitnesses, &WitnessOptions{Timeout: time.Second, Greedy: false}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(multiWitnesses, &WitnessOptions{Timeout: time.Second, Greedy: false}), expectNumCosignatures: 1, }, { desc: "multi witnesses greedy=true", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(multiWitnesses, &WitnessOptions{Timeout: time.Second, Greedy: true}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(multiWitnesses, &WitnessOptions{Timeout: time.Second, Greedy: true}), expectCosignatures: []note.Verifier{wit1Verifier, wit2Verifier}, }, { desc: "multi witnesses greedy=true with one failing witness", - opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnesses(multiWitnesses, &WitnessOptions{Timeout: time.Second, Greedy: true}), + opts: NewAppendOptions().WithCheckpointSigner(logSigner).WithWitnessPolicy(multiWitnesses, &WitnessOptions{Timeout: time.Second, Greedy: true}), partialWitnessFails: true, expectCosignatures: []note.Verifier{wit1Verifier}, }, @@ -737,10 +732,10 @@ func TestCheckpointPublisher(t *testing.T) { failingURL, _ := url.Parse(failingWitnessServer.URL) failingWit, _ := NewWitness(testWit1VKey, failingURL) - failingWitnesses := NewWitnessGroup(1, failingWit) + failingWitnesses := NewWitnessGroup(1, failingWit).toPolicy() // Re-configure option to use failing witnesses - test.opts.WithWitnesses(failingWitnesses, &test.opts.witnessOpts) + test.opts.WithWitnessPolicy(failingWitnesses, &test.opts.witnessOpts) } if test.partialWitnessFails { failingWitnessServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -750,9 +745,9 @@ func TestCheckpointPublisher(t *testing.T) { failingURL, _ := url.Parse(failingWitnessServer.URL) failingWit, _ := NewWitness(testWit2VKey, failingURL) - partiallyFailingWitnesses := NewWitnessGroup(1, wit1, failingWit) + partiallyFailingWitnesses := NewWitnessGroup(1, wit1, failingWit).toPolicy() - test.opts.WithWitnesses(partiallyFailingWitnesses, &test.opts.witnessOpts) + test.opts.WithWitnessPolicy(partiallyFailingWitnesses, &test.opts.witnessOpts) } lr := newFakeLogReaderForTest(t) diff --git a/cmd/conformance/posix/main.go b/cmd/conformance/posix/main.go index 721d14e37..dadc59038 100644 --- a/cmd/conformance/posix/main.go +++ b/cmd/conformance/posix/main.go @@ -33,6 +33,7 @@ import ( "log/slog" fnote "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" "github.com/transparency-dev/tessera" "github.com/transparency-dev/tessera/storage/posix" badger_as "github.com/transparency-dev/tessera/storage/posix/antispam" @@ -104,16 +105,16 @@ func main() { if *mirrorPolicyFile != "" { b, err := os.ReadFile(*mirrorPolicyFile) if err != nil { - slog.ErrorContext(ctx, "Failed to read mirror policy", slog.Any("error", err)) + slog.ErrorContext(ctx, "Failed to read mirror policy", slog.String("mirrorpolicyfile", *mirrorPolicyFile), slog.Any("error", err)) os.Exit(1) } - policy, err := tessera.NewWitnessGroupFromPolicy(b) - if err != nil { + var mPol policy.TLogPolicy + if err := mPol.Unmarshal(b); err != nil { slog.ErrorContext(ctx, "Failed to parse mirror policy", slog.Any("error", err)) os.Exit(1) } - opts = opts.WithMirrors(policy, nil) - slog.InfoContext(ctx, "Mirroring enabled", slog.Any("policy", policy)) + opts = opts.WithMirrorPolicy(mPol, nil) + slog.InfoContext(ctx, "Mirroring enabled", slog.Any("policy", mPol)) } appender, shutdown, _, err := tessera.NewAppender(ctx, driver, opts) diff --git a/cmd/examples/posix-oneshot/main.go b/cmd/examples/posix-oneshot/main.go index 959914d9f..439f5bfc5 100644 --- a/cmd/examples/posix-oneshot/main.go +++ b/cmd/examples/posix-oneshot/main.go @@ -31,6 +31,7 @@ import ( "log/slog" + "github.com/transparency-dev/formats/policy" "github.com/transparency-dev/tessera" "github.com/transparency-dev/tessera/storage/posix" ) @@ -105,8 +106,8 @@ func main() { slog.ErrorContext(ctx, "Failed to read witness policy file", slog.String("witnesspolicyfile", *witnessPolicyFile), slog.Any("error", err)) os.Exit(1) } - wg, err := tessera.NewWitnessGroupFromPolicy(f) - if err != nil { + var wPol policy.TLogPolicy + if err := wPol.Unmarshal(f); err != nil { slog.ErrorContext(ctx, "Failed to create witness group from policy", slog.Any("error", err)) os.Exit(1) } @@ -115,7 +116,7 @@ func main() { FailOpen: *witnessFailOpen, Timeout: *witnessTimeout, } - opts.WithWitnesses(wg, wOpts) + opts.WithWitnessPolicy(wPol, wOpts) } slog.DebugContext(ctx, "Creating appender") diff --git a/cmd/mtc/log/posix/main.go b/cmd/mtc/log/posix/main.go index be6e8969e..72440f33f 100644 --- a/cmd/mtc/log/posix/main.go +++ b/cmd/mtc/log/posix/main.go @@ -24,6 +24,7 @@ import ( "time" "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" "github.com/transparency-dev/tessera" "github.com/transparency-dev/tessera/cmd/mtc/log" "github.com/transparency-dev/tessera/cmd/mtc/log/internal/handler" @@ -159,13 +160,13 @@ func newAppenderFromFlags(ctx context.Context, origin string, signer note.Subtre slog.ErrorContext(ctx, "Failed to read mirror policy", slog.Any("error", err), slog.String("path", *mirrorPolicyFile)) os.Exit(1) } - policy, err := tessera.NewWitnessGroupFromPolicy(b) - if err != nil { + var mPol policy.TLogPolicy + if err := mPol.Unmarshal(b); err != nil { slog.ErrorContext(ctx, "Failed to parse mirror policy", slog.Any("error", err), slog.String("path", *mirrorPolicyFile)) os.Exit(1) } - opts = opts.WithMirrors(policy, nil) - slog.InfoContext(ctx, "Mirroring enabled", slog.Any("policy", policy), slog.String("path", *mirrorPolicyFile)) + opts = opts.WithMirrorPolicy(mPol, nil) + slog.InfoContext(ctx, "Mirroring enabled", slog.Any("policy", mPol), slog.String("path", *mirrorPolicyFile)) } cfg := posix.Config{ diff --git a/integration/mirror/posix/mirror_test.go b/integration/mirror/posix/mirror_test.go index 1d22ffe83..735376352 100644 --- a/integration/mirror/posix/mirror_test.go +++ b/integration/mirror/posix/mirror_test.go @@ -31,6 +31,7 @@ import ( "github.com/transparency-dev/formats/log" fnote "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" "github.com/transparency-dev/merkle/rfc6962" "github.com/transparency-dev/tessera" "github.com/transparency-dev/tessera/api" @@ -101,16 +102,16 @@ func TestPosixMirrorIntegration(t *testing.T) { t.Cleanup(mirrorServer.Close) // Create mirror policy for the log pointing to the mirror server. - mirrorPolicyStr := fmt.Sprintf(` + mirrorPolicyRaw := fmt.Appendf(nil, ` witness mirror1 %s %s group g1 all mirror1 quorum g1 `, mirrorPubKey, mirrorServer.URL) - mirrorPolicy, err := tessera.NewWitnessGroupFromPolicy([]byte(mirrorPolicyStr)) - if err != nil { - t.Fatalf("NewWitnessGroupFromPolicy: %v", err) + var mirrorPolicy policy.TLogPolicy + if err := mirrorPolicy.Unmarshal(mirrorPolicyRaw); err != nil { + t.Fatalf("policy.Unmarshal: %v", err) } logDriver := mustCreateDriver(t, logStorageDir) @@ -120,7 +121,7 @@ func TestPosixMirrorIntegration(t *testing.T) { WithCheckpointRepublishInterval(time.Minute). WithBatching(256, time.Second). WithAntispam(tessera.DefaultAntispamInMemorySize, nil). - WithMirrors(mirrorPolicy, nil) + WithMirrorPolicy(mirrorPolicy, nil) appender, shutdownAppender, lr, err := tessera.NewAppender(ctx, logDriver, logOpts) if err != nil { diff --git a/mirror_lifecycle_test.go b/mirror_lifecycle_test.go index 7338c2eb7..0dd2c6b8a 100644 --- a/mirror_lifecycle_test.go +++ b/mirror_lifecycle_test.go @@ -208,7 +208,7 @@ func TestMirrorTarget_SealAndOpen(t *testing.T) { t.Errorf("open: eturn unexpected bytes") } - for i := 0; i < len(ticket); i++ { + for i := range ticket { b := ticket[i] ticket[i] = b ^ 0xff if _, err := mt.open(ticket); err == nil { diff --git a/witness.go b/witness.go index a2908228e..0920c0736 100644 --- a/witness.go +++ b/witness.go @@ -15,180 +15,84 @@ package tessera import ( - "bufio" - "bytes" "fmt" "net/url" - "strconv" - "strings" + "sync/atomic" f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" "golang.org/x/mod/sumdb/note" ) // policyComponent describes a component that makes up a policy. This is either a // single Witness, or a WitnessGroup. type policyComponent interface { - // Satisfied returns true if the checkpoint is signed by the quorum of - // witnesses involved in this policy component. - Satisfied(cp []byte) bool - - // WitnessEndpoints returns the details required for updating a witness and checking the - // response. The returned result is a map from the URL that should be used to update - // the witness with a new checkpoint, to the values which are the verifiers to check - // the response is well formed. - WitnessEndpoints() map[string][]note.Verifier + name() string } -// NewWitnessGroupFromPolicy creates a graph of witness objects that represents the -// policy provided, and which can be passed directly to the WithWitnesses -// appender lifecycle option. +// NewWitnessGroupFromPolicy parses a policy description and returns a WitnessGroup +// which can be passed to the WithWitnesses appender lifecycle option. +// +// The policy structure is as described at https://c2sp.org/tlog-policy. // -// The policy structure is as described by [Sigsum's policy format](https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md) -// but with the difference that the configured witness keys MUST be signature type `0x04` `vkey`s as specified -// by C2SP [signed-note](https://github.com/C2SP/C2SP/blob/main/signed-note.md#verifier-keys). +// Deprecated: Use [github.com/transparency-dev/formats/policy] directly instead. func NewWitnessGroupFromPolicy(p []byte) (WitnessGroup, error) { - scanner := bufio.NewScanner(bytes.NewBuffer(p)) - components := make(map[string]policyComponent) - - urlToWitnessName := make(map[string]string) + ret := policy.TLogPolicy{} + if err := ret.Unmarshal(p); err != nil { + return WitnessGroup{}, err + } + return fromPolicy(ret) +} - var quorumName string - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if i := strings.Index(line, "#"); i >= 0 { - line = line[:i] +func fromPolicy(p policy.TLogPolicy) (WitnessGroup, error) { + groups := make(map[string]WitnessGroup, len(p.Groups)) + witnesses := make(map[string]Witness, len(p.Witnesses)) + for _, w := range p.Witnesses { + v, err := f_note.NewVerifierForCosignatureV1(w.VKey) + if err != nil { + return WitnessGroup{}, err } - if line == "" { - continue + var urlStr string + if w.URL != nil { + urlStr = w.URL.String() } - - switch fields := strings.Fields(line); fields[0] { - case "log": - // This keyword is important to clients who might use the policy file, but we don't need to know about it since - // we _are_ the log, so just ignore it. - case "witness": - // Strictly, the URL is optional so policy files can be used client-side, where they don't care about the URL. - // Given this function is parsing to create the graph structure which will be used by a Tessera log to witness - // new checkpoints we'll ignore that special case here. - if len(fields) != 4 { - return WitnessGroup{}, fmt.Errorf("invalid witness definition: %q", line) - } - name, vkey, witnessURLStr := fields[1], fields[2], fields[3] - if isBadName(name) { - return WitnessGroup{}, fmt.Errorf("invalid witness name %q", name) - } - if _, ok := components[name]; ok { - return WitnessGroup{}, fmt.Errorf("duplicate component name: %q", name) - } - witnessURL, err := url.Parse(witnessURLStr) - if err != nil { - return WitnessGroup{}, fmt.Errorf("invalid witness URL %q: %w", witnessURLStr, err) - } - w, err := NewWitness(vkey, witnessURL) - if err != nil { - return WitnessGroup{}, fmt.Errorf("invalid witness config %q: %w", line, err) - } - components[name] = w - if wName, ok := urlToWitnessName[witnessURLStr]; !ok { - urlToWitnessName[witnessURLStr] = w.Key.Name() - } else if wName != w.Key.Name() { - return WitnessGroup{}, fmt.Errorf("witness URL %q has multiple witness signer names assigned %q and %q", witnessURLStr, wName, w.Key.Name()) - } - case "group": - if len(fields) < 3 { - return WitnessGroup{}, fmt.Errorf("invalid group definition: %q", line) - } - - name, N, childrenNames := fields[1], fields[2], fields[3:] - if isBadName(name) { - return WitnessGroup{}, fmt.Errorf("invalid group name %q", name) - } - if _, ok := components[name]; ok { - return WitnessGroup{}, fmt.Errorf("duplicate component name: %q", name) - } - var n int - switch N { - case "any": - n = 1 - case "all": - n = len(childrenNames) - default: - i, err := strconv.ParseUint(N, 10, 8) - if err != nil { - return WitnessGroup{}, fmt.Errorf("invalid threshold %q for group %q: %w", N, name, err) - } - n = int(i) - } - if c := len(childrenNames); n > c { - return WitnessGroup{}, fmt.Errorf("group with %d children cannot have threshold %d", c, n) - } - - children := make([]policyComponent, len(childrenNames)) - for i, cName := range childrenNames { - if isBadName(cName) { - return WitnessGroup{}, fmt.Errorf("invalid component name %q", cName) - } - child, ok := components[cName] - if !ok { - return WitnessGroup{}, fmt.Errorf("unknown component %q in group definition", cName) - } - children[i] = child - } - wg := NewWitnessGroup(n, children...) - components[name] = wg - case "quorum": - if len(fields) != 2 { - return WitnessGroup{}, fmt.Errorf("invalid quorum definition: %q", line) - } - quorumName = fields[1] - default: - return WitnessGroup{}, fmt.Errorf("unknown keyword: %q", fields[0]) + witnesses[w.Name] = Witness{ + vkey: w.VKey, + parsedURL: w.URL, + Key: v, + URL: urlStr, } } - if err := scanner.Err(); err != nil { - return WitnessGroup{}, err + for _, g := range p.Groups { + members := make([]policyComponent, 0, len(g.Members)) + for _, m := range g.Members { + if w, ok := witnesses[m]; ok { + members = append(members, w) + } else if grp, ok := groups[m]; ok { + members = append(members, grp) + } else { + return WitnessGroup{}, fmt.Errorf("invalid policy: member %q not defined", m) + } + } + groups[g.Name] = NewWitnessGroup(int(g.Threshold), members...) } - switch quorumName { - case "": - return WitnessGroup{}, fmt.Errorf("policy file must define a quorum") - case "none": + if p.Quorum == "none" || p.Quorum == "" { return NewWitnessGroup(0), nil - default: - if isBadName(quorumName) { - return WitnessGroup{}, fmt.Errorf("invalid quorum name %q", quorumName) - } - policy, ok := components[quorumName] - if !ok { - return WitnessGroup{}, fmt.Errorf("quorum component %q not found", quorumName) - } - wg, ok := policy.(WitnessGroup) - if !ok { - // A single witness can be a policy. Wrap it in a group. - return NewWitnessGroup(1, policy), nil - } - return wg, nil } -} - -var keywords = map[string]struct{}{ - "witness": {}, - "group": {}, - "any": {}, - "all": {}, - "none": {}, - "quorum": {}, - "log": {}, -} - -func isBadName(n string) bool { - _, isKeyword := keywords[n] - return isKeyword + if root, ok := groups[p.Quorum]; ok { + return root, nil + } + if w, ok := witnesses[p.Quorum]; ok { + return NewWitnessGroup(1, w), nil + } + return WitnessGroup{}, fmt.Errorf("invalid policy: quorum %q not defined", p.Quorum) } // NewWitness returns a Witness given a verifier key and the root URL for where this // witness can be reached. +// +// Deprecated: Use [github.com/transparency-dev/formats/policy] directly instead. func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { v, err := f_note.NewVerifierForCosignatureV1(vkey) if err != nil { @@ -196,62 +100,81 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { } return Witness{ - Key: v, - URL: witnessRoot.String(), + vkey: vkey, + parsedURL: witnessRoot, + Key: v, + URL: witnessRoot.String(), }, nil } // Witness represents a single witness that can be reached in order to perform a witnessing operation. -// The URLs() method returns the URL where it can be reached for witnessing, and the Satisfied method -// provides a predicate to check whether this witness has signed a checkpoint. +// +// Deprecated: Use [github.com/transparency-dev/formats/policy] directly instead. type Witness struct { - Key note.Verifier - URL string + vkey string + Key note.Verifier + URL string + parsedURL *url.URL } -// Satisfied returns true if the checkpoint provided is signed by this witness. -// This will return false if there is no signature, and also if the -// checkpoint cannot be read as a valid note. It is up to the caller to ensure -// that the input value represents a valid note. -func (w Witness) Satisfied(cp []byte) bool { - n, err := note.Open(cp, note.VerifierList(w.Key)) - if err != nil { - return false - } - return len(n.Sigs) == 1 -} - -// Endpoints returns the details required for updating a witness and checking the -// response. -// -// Deprecated: Endpoints is deprecated, use WitnessEndpoints instead. -func (w Witness) Endpoints() map[string]note.Verifier { - return map[string]note.Verifier{w.URL: w.Key} +func (w Witness) name() string { + return w.Key.Name() } -// WitnessEndpoints returns the details required for updating a witness and checking the -// response. The returned result is a map from the URL that should be used to update -// the witness with a new checkpoint, to the values which are the verifiers to check -// the response is well formed. -func (w Witness) WitnessEndpoints() map[string][]note.Verifier { - return map[string][]note.Verifier{w.URL: {w.Key}} -} +var anonGroupNameCounter atomic.Int64 // NewWitnessGroup creates a grouping of Witness or WitnessGroup with a configurable threshold // of these sub-components that need to be satisfied in order for this group to be satisfied. // // The threshold should only be set to less than the number of sub-components if these are // considered fungible. +// +// Deprecated: Use [github.com/transparency-dev/formats/policy] directly instead. func NewWitnessGroup(n int, children ...policyComponent) WitnessGroup { if n < 0 || n > len(children) { panic(fmt.Errorf("threshold of %d outside bounds for children %s", n, children)) } return WitnessGroup{ + grpName: fmt.Sprintf("anonGrp-%d", anonGroupNameCounter.Add(1)), Components: children, N: n, } } +func populatePolicy(p *policy.TLogPolicy, wg WitnessGroup) { + me := &policy.Group{ + Name: wg.name(), + Threshold: uint(wg.N), + Members: make([]string, 0, len(wg.Components)), + } + for _, c := range wg.Components { + switch c := c.(type) { + case Witness: + p.Witnesses = append(p.Witnesses, policy.Witness{ + Name: c.name(), + URL: c.parsedURL, + VKey: c.vkey, + Verifier: c.Key, + }) + me.Members = append(me.Members, c.name()) + case WitnessGroup: + populatePolicy(p, c) + me.Members = append(me.Members, c.name()) + default: + panic(fmt.Errorf("unexpected component type: %T", c)) + } + } + p.Groups = append(p.Groups, *me) +} + +func (wg WitnessGroup) toPolicy() policy.TLogPolicy { + p := policy.TLogPolicy{ + Quorum: wg.name(), + } + populatePolicy(&p, wg) + return p +} + // WitnessGroup defines a group of witnesses, and a threshold of // signatures that must be met for this group to be satisfied. // Witnesses within a group should be fungible, e.g. all of the Armored @@ -259,71 +182,14 @@ func NewWitnessGroup(n int, children ...policyComponent) WitnessGroup { // represent a threshold of the quorum. For some users this will be a // simple majority, but other strategies are available. // N must be <= len(WitnessKeys). +// +// Deprecated: Use [github.com/transparency-dev/formats/policy] directly instead. type WitnessGroup struct { + grpName string Components []policyComponent N int } -// Satisfied returns true if the checkpoint provided has sufficient signatures -// from the witnesses in this group to satisfy the threshold. -// This will return false if there are insufficient signatures, and also if the -// checkpoint cannot be read as a valid note. It is up to the caller to ensure -// that the input value represents a valid note. -// -// The implementation of this requires every witness in the group to verify the -// checkpoint, which is O(N). If this is called every time a witness returns a -// checkpoint then this algorithm is O(N^2). To support large N, this may require -// some rewriting in order to maintain performance. -func (wg WitnessGroup) Satisfied(cp []byte) bool { - if wg.N <= 0 { - return true - } - satisfaction := 0 - for _, c := range wg.Components { - if c.Satisfied(cp) { - satisfaction++ - } - if satisfaction >= wg.N { - return true - } - } - return false -} - -// Endpoints returns the details required for updating a witness and checking the -// response. -// -// Deprecated: Endpoints is deprecated because it cannot handle policies with -// multiple verifiers per endpoint (e.g. witnesses which return multiple signatures). -// Use [WitnessEndpoints] instead. -func (wg WitnessGroup) Endpoints() map[string]note.Verifier { - endpoints := make(map[string]note.Verifier) - for _, c := range wg.Components { - for u, v := range c.WitnessEndpoints() { - if _, ok := endpoints[u]; ok { - panic(fmt.Errorf("the Endpoints func cannot safely handle witnesses which return multiple signatures, use WitnessEndpoints instead")) - } - switch l := len(v); { - case l > 1: - panic(fmt.Errorf("the Endpoints func cannot safely handle witnesses which return multiple signatures, use WitnessEndpoints instead")) - case l == 1: - endpoints[u] = v[0] - } - } - } - return endpoints -} - -// WitnessEndpoints returns the details required for updating a witness and checking the -// response. The returned result is a map from the URL that should be used to update -// the witness with a new checkpoint, to the values which are the verifiers to check -// the response is well formed. -func (wg WitnessGroup) WitnessEndpoints() map[string][]note.Verifier { - endpoints := make(map[string][]note.Verifier) - for _, c := range wg.Components { - for u, vs := range c.WitnessEndpoints() { - endpoints[u] = append(endpoints[u], vs...) - } - } - return endpoints +func (wg WitnessGroup) name() string { + return wg.grpName } diff --git a/witness_policy_test.go b/witness_policy_test.go index 23b635204..f285b9835 100644 --- a/witness_policy_test.go +++ b/witness_policy_test.go @@ -13,171 +13,3 @@ // limitations under the License. package tessera - -import ( - "strings" - "testing" -) - -func TestNewWitnessGroupFromPolicy(t *testing.T) { - for _, test := range []struct { - name string - policy string - }{ - { - name: "tidy", - policy: ` -witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ -witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -group g1 all w1 w2 -quorum g1 -`, - }, { - name: "whitespace and comments", - policy: ` - -# comment -witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ #comment - witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ - - - #comment -group g1 all w1 w2 - - quorum g1 -`, - }, - } { - t.Run(test.name, func(t *testing.T) { - - wg, err := NewWitnessGroupFromPolicy([]byte(test.policy)) - if err != nil { - t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) - } - - if wg.N != 2 { - t.Errorf("Expected top-level group to have N=2, got %d", wg.N) - } - if len(wg.Components) != 2 { - t.Fatalf("Expected top-level group to have 2 components, got %d", len(wg.Components)) - } - }) - } -} - -func TestNewWitnessGroupFromPolicy_GroupN(t *testing.T) { - testCases := []struct { - desc string - policy string - wantN int - }{ - { - desc: "group numerical", - policy: ` -witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ -witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -witness w3 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -witness w4 remora.n621.de+da77ade7+BOvN63jn/bLvkieywe8R6UYAtVtNbZpXh34x7onlmtw2 https://example.com/remora -group g1 2 w1 w2 w3 w4 -quorum g1 -`, - wantN: 2, - }, - { - desc: "group all", - policy: ` -witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ -witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -witness w3 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -group g1 all w1 w2 w3 -quorum g1 -`, - wantN: 3, - }, - { - desc: "group any", - policy: ` -witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ -witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -witness w3 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ -group g1 any w1 -quorum g1 -`, - wantN: 1, - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - wg, err := NewWitnessGroupFromPolicy([]byte(tc.policy)) - if err != nil { - t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) - } - if wg.N != tc.wantN { - t.Errorf("wg.N = %d, want %d", wg.N, tc.wantN) - } - }) - } -} - -func TestNewWitnessGroupFromPolicy_Errors(t *testing.T) { - testCases := []struct { - desc string - policy string - errStr string - }{ - { - desc: "no quorum", - policy: "witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/", - errStr: "policy file must define a quorum", - }, - { - desc: "unknown quorum component", - policy: "quorum unknown", - errStr: "quorum component \"unknown\" not found", - }, - { - desc: "duplicate component name", - policy: "witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/\nwitness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/\nquorum w1", - errStr: "duplicate component name", - }, - { - desc: "negative threshold", - policy: `witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ - witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ - witness w3 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://example.com/witness/ - group g1 -1 w1 - quorum g1`, - errStr: "invalid threshold", - }, - { - desc: "witness name is keyword", - policy: `witness all sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/`, - errStr: "invalid witness name", - }, - { - desc: "witness name is keyword", - policy: `group none 1 witness`, - errStr: "invalid group name", - }, { - desc: "witness URL has multiple signer names", - policy: ` - witness w1 sigsum.org+e4ade967+AZuUY6B08pW3QVHu8uvsrxWPcAv9nykap2Nb4oxCee+r https://sigsum.org/witness/ - witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM https://sigsum.org/witness/ - group g1 1 w1 w2 - quorum g1`, - errStr: "has multiple witness signer names assigned", - }} - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - _, err := NewWitnessGroupFromPolicy([]byte(tc.policy)) - if err == nil { - t.Fatal("Expected error, got nil") - } - if !strings.Contains(err.Error(), tc.errStr) { - t.Errorf("Expected error string to contain %q, got %q", tc.errStr, err.Error()) - } - }) - } -} diff --git a/witness_test.go b/witness_test.go deleted file mode 100644 index e59df2e15..000000000 --- a/witness_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package tessera_test - -import ( - "net/url" - "slices" - "strings" - "testing" - - f_note "github.com/transparency-dev/formats/note" - "github.com/transparency-dev/tessera" - "golang.org/x/mod/sumdb/note" -) - -const ( - wit1_vkey = "Wit1+55ee4561+AVhZSmQj9+SoL+p/nN0Hh76xXmF7QcHfytUrI1XfSClk" - wit1_skey = "PRIVATE+KEY+Wit1+55ee4561+AeadRiG7XM4XiieCHzD8lxysXMwcViy5nYsoXURWGrlE" - wit2_vkey = "Wit2+85ecc407+AWVbwFJte9wMQIPSnEnj4KibeO6vSIOEDUTDp3o63c2x" - wit2_skey = "PRIVATE+KEY+Wit2+85ecc407+AfPTvxw5eUcqSgivo2vaiC7JPOMUZ/9baHPSDrWqgdGm" - wit3_vkey = "Wit3+d3ed3be7+ASb6Uz1+fxAcXkMvDd7nGa3FjDce7LxIKmbbTCT0MpVn" - wit3_skey = "PRIVATE+KEY+Wit3+d3ed3be7+AR2Kg8k6ccBr5QXz5SHtnkOS4UGQGEQaWi6Gfr6Mm3X5" -) - -var ( - bastion, _ = url.Parse("https://b1.example.com/") - directURL, _ = url.Parse("https://witness.example.com/") - wit1, _ = tessera.NewWitness(wit1_vkey, bastion.JoinPath("wit1prefix")) - wit2, _ = tessera.NewWitness(wit2_vkey, bastion.JoinPath("wit2prefix")) - wit3, _ = tessera.NewWitness(wit3_vkey, directURL) - wit1Sign, _ = f_note.NewSignerForCosignatureV1(wit1_skey) - wit2Sign, _ = f_note.NewSignerForCosignatureV1(wit2_skey) - wit3Sign, _ = f_note.NewSignerForCosignatureV1(wit3_skey) -) - -func TestWitnessGroup_Empty(t *testing.T) { - group := tessera.WitnessGroup{} - if !group.Satisfied([]byte("definitely a checkpoint\n")) { - t.Error("empty group should be satisfied") - } - if len(group.WitnessEndpoints()) != 0 { - t.Error("empty group should have no URLs") - } -} - -func TestWitnessGroup_Satisfied(t *testing.T) { - testCases := []struct { - desc string - group tessera.WitnessGroup - signers []note.Signer - expectSatisfied bool - }{ - { - desc: "One witness, required and provided", - group: tessera.NewWitnessGroup(1, wit1), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, - }, - { - desc: "One witness, required and not provided", - group: tessera.NewWitnessGroup(1, wit1), - signers: []note.Signer{}, - expectSatisfied: false, - }, - { - desc: "One witness, optional and provided", - group: tessera.NewWitnessGroup(0, wit1), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, - }, - { - desc: "One witness, optional and not provided", - group: tessera.NewWitnessGroup(0, wit1), - signers: []note.Signer{}, - expectSatisfied: true, - }, - { - desc: "One witness, required and provided, in required subgroup", - group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, - }, - { - desc: "One witness, required and provided, in optional subgroup", - group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, - }, - { - desc: "One witness, required and not provided, in required subgroup", - group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{}, - expectSatisfied: false, - }, - { - desc: "One witness, required and not provided, in optional subgroup", - group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{}, - expectSatisfied: true, - }, - { - desc: "One required, one of two required, all provided", - group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), - signers: []note.Signer{wit1Sign, wit2Sign, wit3Sign}, - expectSatisfied: true, - }, - { - desc: "One required, one of two required, min provided", - group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), - signers: []note.Signer{wit1Sign, wit2Sign}, - expectSatisfied: true, - }, - { - desc: "One required, one of two required, only first group satisfied", - group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), - signers: []note.Signer{wit1Sign}, - expectSatisfied: false, - }, - { - desc: "One required, one of two required, only second group satisfied", - group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), - signers: []note.Signer{wit2Sign, wit3Sign}, - expectSatisfied: false, - }, - } - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - n := ¬e.Note{ - // The body needs to be 3 lines to meet the cosigner expectations. - Text: "sign me\nI'm a\nnote\n", - } - cp, err := note.Sign(n, tC.signers...) - if err != nil { - t.Fatal(err) - } - if got, want := tC.group.Satisfied(cp), tC.expectSatisfied; got != want { - t.Errorf("Expected satisfied = %t but got %t", want, got) - } - }) - } -} - -func TestWitnessGroup_URLs(t *testing.T) { - testCases := []struct { - desc string - group tessera.WitnessGroup - expectedURLs []string - }{ - { - desc: "witness 1", - group: tessera.NewWitnessGroup(1, wit1), - expectedURLs: []string{"https://b1.example.com/wit1prefix"}, - }, - { - desc: "witness 2", - group: tessera.NewWitnessGroup(1, wit2), - expectedURLs: []string{"https://b1.example.com/wit2prefix"}, - }, - { - desc: "witness 3", - group: tessera.NewWitnessGroup(1, wit3), - expectedURLs: []string{"https://witness.example.com"}, - }, - { - desc: "all witnesses in one group", - group: tessera.NewWitnessGroup(1, wit1, wit2, wit3), - expectedURLs: []string{ - "https://b1.example.com/wit1prefix", - "https://b1.example.com/wit2prefix", - "https://witness.example.com", - }, - }, - { - desc: "all witnesses with duplicates in nests", - group: tessera.NewWitnessGroup(2, tessera.NewWitnessGroup(1, wit1, wit2), tessera.NewWitnessGroup(1, wit1, wit3)), - expectedURLs: []string{ - "https://b1.example.com/wit1prefix", - "https://b1.example.com/wit2prefix", - "https://witness.example.com", - }, - }, - } - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - gotURLs := make([]string, 0) - for u := range tC.group.WitnessEndpoints() { - gotURLs = append(gotURLs, strings.TrimRight(u, "/")) - } - slices.Sort(gotURLs) - slices.Sort(tC.expectedURLs) - - if !slices.Equal(gotURLs, tC.expectedURLs) { - t.Errorf("Expected %s but got %s", tC.expectedURLs, gotURLs) - } - }) - } -} - -// This is benchmarked because this may well get called a number of times, and there are potentially -// other ways to implement this that don't involve so many note.Open calls. -func BenchmarkWitnessGroupSatisfaction(b *testing.B) { - group := tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)) - n := ¬e.Note{ - // Text must contain 3 lines to meet cosig expectations. - Text: "sign me\nI'm a\nnote\n", - } - cp, err := note.Sign(n, wit1Sign, wit2Sign, wit3Sign) - if err != nil { - b.Fatal(err) - } - for b.Loop() { - if !group.Satisfied(cp) { - b.Fatal("Group should have been satisfied!") - } - } -} From 9237c61844767171d38ddb099241c164a1f427bb Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Tue, 18 Aug 2026 16:21:16 +0000 Subject: [PATCH 2/7] Update README --- README.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 62db5b77c..16066283d 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ 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 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 + 2. Contact witnesses and/or mirrors, and collect enough cosignatures to satisfy any policies configured by [WithWitnessPolicy](https://pkg.go.dev/github.com/transparency-dev/tessera#AppendOptions.WithWitnessPolicy) and/or [WithMirrorPolicy](https://pkg.go.dev/github.com/transparency-dev/tessera#AppendOptions.WithMirrorPolicy). + 3. If the witness/mirror policies are satisfied, make this new Checkpoint available via the [tlog-tiles API][] 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). Due to the nature of append-only logs, all Checkpoints issued after this point will also commit to inclusion of this entry. @@ -326,18 +326,10 @@ Logs are required to be append-only data structures. This property can be verified by witnesses, and signatures from witnesses can be provided in the published checkpoint to increase confidence for users of the log. Personalities can configure Tessera with options that specify witnesses compatible with the [C2SP Witness Protocol](https://github.com/C2SP/C2SP/blob/main/tlog-witness.md). -Configuring the witnesses is done by either using the [`NewWitnessGroupFromPolicy`](https://pkg.go.dev/github.com/transparency-dev/tessera@main#NewWitnessGroupFromPolicy) -helper, or programatically creating a top-level [`WitnessGroup`](https://pkg.go.dev/github.com/transparency-dev/tessera@main#WitnessGroup) that contains either -sub `WitnessGroup`s, or [`Witness`es](https://pkg.go.dev/github.com/transparency-dev/tessera@main#Witness). -Each `Witness` is configured with a URL at which the witness can be reached, and a `Verifier` for the key that it must sign with. -`WitnessGroup`s are configured with their sub-components, and a number of these components that must be satisfied in order for the group to be satisfied. +Configuring the witnesses is done by passing an instance of a populated [`TLogPolicy`](https://pkg.go.dev/github.com/transparency-dev/formats/policy@main#TLogPolicy) struct to the [`WithWitnessPolicy`](https://pkg.go.dev/github.com/transparency-dev/tessera@main#AppendOptions.WithWitnessPolicy) option. -These primitives allow arbitrarily complex witness policies to be specified. - -Once a top-level `WitnessGroup` is configured, it is passed in to the `Appender` lifecycle options using -[AppendOptions#WithWitnesses](https://pkg.go.dev/github.com/transparency-dev/tessera@main#AppendOptions.WithWitnesses). -If this option is not set, no witnessing will be configured. +If this option is not specified, no witnessing is performed. > [!Note] > If the policy cannot be satisfied then no checkpoint will be published. From 4310fa80278544ad2cb379648841c78ff5adf276 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Tue, 18 Aug 2026 17:04:29 +0000 Subject: [PATCH 3/7] Add tests for witness glue --- internal/witness/client_test.go | 88 +++--- witness_test.go | 492 ++++++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+), 55 deletions(-) create mode 100644 witness_test.go diff --git a/internal/witness/client_test.go b/internal/witness/client_test.go index 9f371c3bb..5e950eea9 100644 --- a/internal/witness/client_test.go +++ b/internal/witness/client_test.go @@ -31,7 +31,6 @@ import ( "github.com/transparency-dev/formats/log" f_note "github.com/transparency-dev/formats/note" - "github.com/transparency-dev/tessera" "github.com/transparency-dev/tessera/api/layout" "github.com/transparency-dev/tessera/internal/witness" "golang.org/x/mod/sumdb/note" @@ -70,30 +69,16 @@ func TestWitnessGateway(t *testing.T) { // The witnesses just sign the checkpoint with whatever key is requested, they don't check the body at all. // An improvement on this would be to make the fake witnesses more realistic, but it's a non-trivial // amount of code to add to this already long test! - var wit1, wit2, witBad, witMulti1, witMulti2 tessera.Witness var witCalls atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w1u, err := url.Parse(wit1.URL) - if err != nil { - t.Fatal(err) - } - w2u, err := url.Parse(wit2.URL) - if err != nil { - t.Fatal(err) - } - wbu, err := url.Parse(witBad.URL) - if err != nil { - t.Fatal(err) - } - switch r.URL.Path { - case w1u.Path + "/add-checkpoint": + case "/wit1/add-checkpoint": witCalls.Add(1) _, _ = w.Write(sigForSigner(t, cp, wit1Skey)) - case w2u.Path + "/add-checkpoint": + case "/wit2/add-checkpoint": witCalls.Add(1) _, _ = w.Write(sigForSigner(t, cp, wit2Skey)) - case wbu.Path + "/add-checkpoint": + case "/add-checkpoint": witCalls.Add(1) _, _ = w.Write([]byte("this is not a signature\n")) case "/wit_multi/add-checkpoint": @@ -109,25 +94,25 @@ func TestWitnessGateway(t *testing.T) { t.Fatal(err) } wit1URL := baseURL.JoinPath("wit1") - wit1, err = tessera.NewWitness(wit1Vkey, wit1URL) + wit1Verifier, err := f_note.NewVerifierForCosignatureV1(wit1Vkey) if err != nil { t.Fatal(err) } wit2URL := baseURL.JoinPath("wit2") - wit2, err = tessera.NewWitness(wit2Vkey, wit2URL) + wit2Verifier, err := f_note.NewVerifierForCosignatureV1(wit2Vkey) if err != nil { t.Fatal(err) } witMulti1URL := baseURL.JoinPath("wit_multi") - witMulti1, err = tessera.NewWitness(wit1Vkey, witMulti1URL) + witMulti1Verifier, err := f_note.NewVerifierForCosignatureV1(wit1Vkey) if err != nil { t.Fatal(err) } - witMulti2, err = tessera.NewWitness(wit2Vkey, witMulti1URL) + witMulti2Verifier, err := f_note.NewVerifierForCosignatureV1(wit2Vkey) if err != nil { t.Fatal(err) } - witBad, err = tessera.NewWitness(witBadVkey, baseURL) + witBadVerifier, err := f_note.NewVerifierForCosignatureV1(witBadVkey) if err != nil { t.Fatal(err) } @@ -147,15 +132,15 @@ func TestWitnessGateway(t *testing.T) { }, { desc: "one witness", - witnesses: []witness.Witness{{URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}}, + witnesses: []witness.Witness{{URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}}, wantSigs: 1, wantWitnessCalls: exactly(1), }, { desc: "two witnesses", witnesses: []witness.Witness{ - {URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}, - {URL: wit2URL, Verifiers: []note.Verifier{wit2.Key}}, + {URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}, + {URL: wit2URL, Verifiers: []note.Verifier{wit2Verifier}}, }, wantSigs: 2, wantWitnessCalls: exactly(2), @@ -163,45 +148,45 @@ func TestWitnessGateway(t *testing.T) { { desc: "one required witness twice", witnesses: []witness.Witness{ - {URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}, - {URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}, + {URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}, + {URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}, }, wantSigs: 1, wantWitnessCalls: exactly(1), }, { desc: "one witness with two keys", - witnesses: []witness.Witness{{URL: witMulti1URL, Verifiers: []note.Verifier{witMulti1.Key, witMulti2.Key}}}, + witnesses: []witness.Witness{{URL: witMulti1URL, Verifiers: []note.Verifier{witMulti1Verifier, witMulti2Verifier}}}, wantSigs: 2, wantWitnessCalls: exactly(1), }, { desc: "two witnesses with same URL but different keys", witnesses: []witness.Witness{ - {URL: witMulti1URL, Verifiers: []note.Verifier{witMulti1.Key}}, - {URL: witMulti1URL, Verifiers: []note.Verifier{witMulti2.Key}}, + {URL: witMulti1URL, Verifiers: []note.Verifier{witMulti1Verifier}}, + {URL: witMulti1URL, Verifiers: []note.Verifier{witMulti2Verifier}}, }, wantSigs: 2, wantWitnessCalls: exactly(1), }, { desc: "bad witness", - witnesses: []witness.Witness{{URL: wit1URL, Verifiers: []note.Verifier{witBad.Key}}}, + witnesses: []witness.Witness{{URL: wit1URL, Verifiers: []note.Verifier{witBadVerifier}}}, wantSigs: 0, wantWitnessCalls: exactly(1), }, { desc: "two bad witnesses", witnesses: []witness.Witness{ - {URL: wit1URL, Verifiers: []note.Verifier{witBad.Key}}, - {URL: wit2URL, Verifiers: []note.Verifier{witBad.Key}}, + {URL: wit1URL, Verifiers: []note.Verifier{witBadVerifier}}, + {URL: wit2URL, Verifiers: []note.Verifier{witBadVerifier}}, }, wantSigs: 0, wantWitnessCalls: exactly(2), }, { desc: "one good, one bad witness", witnesses: []witness.Witness{ - {URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}, - {URL: wit2URL, Verifiers: []note.Verifier{witBad.Key}}, + {URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}, + {URL: wit2URL, Verifiers: []note.Verifier{witBadVerifier}}, }, wantSigs: 1, wantWitnessCalls: exactly(2), @@ -222,7 +207,7 @@ func TestWitnessGateway(t *testing.T) { cpSigs, _ := collectSigs(t, g.CosignCheckpoint(ctx, logSignedCheckpoint, logSignedCheckpointSize)) witnessedCP := append(slices.Clone(logSignedCheckpoint), cpSigs...) - n, err := note.Open(witnessedCP, note.VerifierList(logVerifier, wit1.Key, wit2.Key)) + n, err := note.Open(witnessedCP, note.VerifierList(logVerifier, wit1Verifier, wit2Verifier)) if err != nil { t.Fatalf("failed to open note %q: %v", witnessedCP, err) } @@ -253,11 +238,9 @@ func TestSlipperyWitness(t *testing.T) { // Set up a fake server hosting the witness. // This witness will always reply that a different size is required. - var wit1 tessera.Witness var count int ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w1u := mustURL(t, wit1.URL) - if got, want := r.URL.String(), w1u.Path+"/add-checkpoint"; got != want { + if got, want := r.URL.Path, "/add-checkpoint"; got != want { t.Fatalf("Got request to URL %q but expected %q", got, want) } @@ -269,8 +252,7 @@ func TestSlipperyWitness(t *testing.T) { count++ })) baseURL := mustURL(t, ts.URL) - var err error - wit1, err = tessera.NewWitness(wit1Vkey, baseURL) + wit1Verifier, err := f_note.NewVerifierForCosignatureV1(wit1Vkey) if err != nil { t.Fatal(err) } @@ -278,7 +260,7 @@ func TestSlipperyWitness(t *testing.T) { ctx := t.Context() g, err := witness.NewGateway(ctx, witness.Options{ - Witnesses: []witness.Witness{{URL: baseURL, Verifiers: []note.Verifier{wit1.Key}}}, + Witnesses: []witness.Witness{{URL: baseURL, Verifiers: []note.Verifier{wit1Verifier}}}, HTTPClient: ts.Client(), FetchTiles: testLogTileFetcher, }) @@ -295,7 +277,6 @@ func TestSlipperyWitness(t *testing.T) { } func TestWitnessReusesProofs(t *testing.T) { - var wit1, wit2 tessera.Witness ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { @@ -310,27 +291,24 @@ func TestWitnessReusesProofs(t *testing.T) { if err != nil { t.Fatal(err) } - w1u := mustURL(t, wit1.URL) - w2u := mustURL(t, wit2.URL) - switch r.URL.String() { - case w1u.Path + "/add-checkpoint": + switch r.URL.Path { + case "/wit1/add-checkpoint": _, _ = w.Write(sigForSigner(t, n.Text, wit1Skey)) - case w2u.Path + "/add-checkpoint": + case "/wit2/add-checkpoint": _, _ = w.Write(sigForSigner(t, n.Text, wit2Skey)) default: t.Fatalf("Unknown case: %s", r.URL.String()) } })) baseURL := mustURL(t, ts.URL) - var err error wit1URL := baseURL.JoinPath("wit1") - wit1, err = tessera.NewWitness(wit1Vkey, wit1URL) + wit1Verifier, err := f_note.NewVerifierForCosignatureV1(wit1Vkey) if err != nil { t.Fatal(err) } wit2URL := baseURL.JoinPath("wit2") - wit2, err = tessera.NewWitness(wit2Vkey, wit2URL) + wit2Verifier, err := f_note.NewVerifierForCosignatureV1(wit2Vkey) if err != nil { t.Fatal(err) } @@ -348,7 +326,7 @@ func TestWitnessReusesProofs(t *testing.T) { } g1, err := witness.NewGateway(ctx, witness.Options{ HTTPClient: ts.Client(), - Witnesses: []witness.Witness{{URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}}, + Witnesses: []witness.Witness{{URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}}, FetchTiles: cf1, }) if err != nil { @@ -357,8 +335,8 @@ func TestWitnessReusesProofs(t *testing.T) { g2, err := witness.NewGateway(ctx, witness.Options{ HTTPClient: ts.Client(), Witnesses: []witness.Witness{ - {URL: wit1URL, Verifiers: []note.Verifier{wit1.Key}}, - {URL: wit2URL, Verifiers: []note.Verifier{wit2.Key}}, + {URL: wit1URL, Verifiers: []note.Verifier{wit1Verifier}}, + {URL: wit2URL, Verifiers: []note.Verifier{wit2Verifier}}, }, FetchTiles: cf2, }) diff --git a/witness_test.go b/witness_test.go new file mode 100644 index 000000000..08d5fc94d --- /dev/null +++ b/witness_test.go @@ -0,0 +1,492 @@ +// Copyright 2025 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 tessera + +import ( + "fmt" + "net/url" + "slices" + "testing" + + f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" + "golang.org/x/mod/sumdb/note" +) + +func signNote(t *testing.T, signers ...note.Signer) []byte { + t.Helper() + n := ¬e.Note{ + Text: "sign me\nI'm a\nnote\n", + } + cp, err := note.Sign(n, signers...) + if err != nil { + t.Fatalf("failed to sign note: %v", err) + } + return cp +} + +func TestPopulatePolicy(t *testing.T) { + u1, err := url.Parse("https://wit1.example.com") + if err != nil { + t.Fatalf("failed to parse url: %v", err) + } + u2, err := url.Parse("https://wit2.example.com") + if err != nil { + t.Fatalf("failed to parse url: %v", err) + } + u3, err := url.Parse("https://wit3.example.com") + if err != nil { + t.Fatalf("failed to parse url: %v", err) + } + + w1, err := NewWitness(testWit1VKey, u1) + if err != nil { + t.Fatalf("failed to create witness 1: %v", err) + } + w2, err := NewWitness(testWit2VKey, u2) + if err != nil { + t.Fatalf("failed to create witness 2: %v", err) + } + w3, err := NewWitness(testWit3VKey, u3) + if err != nil { + t.Fatalf("failed to create witness 3: %v", err) + } + + w1Signer, err := f_note.NewSignerForCosignatureV1(testWit1SKey) + if err != nil { + t.Fatalf("failed to create witness 1 signer: %v", err) + } + w2Signer, err := f_note.NewSignerForCosignatureV1(testWit2SKey) + if err != nil { + t.Fatalf("failed to create witness 2 signer: %v", err) + } + w3Signer, err := f_note.NewSignerForCosignatureV1(testWit3SKey) + if err != nil { + t.Fatalf("failed to create witness 3 signer: %v", err) + } + + for _, test := range []struct { + desc string + group WitnessGroup + wantWitnesses []string + satisfyTests []struct { + signers []note.Signer + wantSat bool + } + }{ + { + desc: "single witness", + group: NewWitnessGroup(1, w1), + wantWitnesses: []string{w1.name()}, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer}, wantSat: true}, + {signers: []note.Signer{}, wantSat: false}, + {signers: []note.Signer{w2Signer}, wantSat: false}, + }, + }, + { + desc: "multi witness group (2 of 3)", + group: NewWitnessGroup(2, w1, w2, w3), + wantWitnesses: []string{w1.name(), w2.name(), w3.name()}, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer, w2Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w2Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w2Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer}, wantSat: false}, + {signers: []note.Signer{}, wantSat: false}, + }, + }, + { + desc: "nested witness group (1 required witness and 1 of 2 subgroup)", + group: func() WitnessGroup { + sub := NewWitnessGroup(1, w2, w3) + return NewWitnessGroup(2, w1, sub) + }(), + wantWitnesses: []string{w1.name(), w2.name(), w3.name()}, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer, w2Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w2Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w2Signer, w3Signer}, wantSat: false}, // w1 missing + {signers: []note.Signer{w1Signer}, wantSat: false}, // subgroup missing + {signers: []note.Signer{}, wantSat: false}, + }, + }, + { + desc: "deeply nested witness group", + group: func() WitnessGroup { + sub1 := NewWitnessGroup(1, w1) + sub2 := NewWitnessGroup(1, sub1) + return NewWitnessGroup(1, sub2) + }(), + wantWitnesses: []string{w1.name()}, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer}, wantSat: true}, + {signers: []note.Signer{}, wantSat: false}, + {signers: []note.Signer{w2Signer}, wantSat: false}, + }, + }, + { + desc: "empty group with 0 threshold", + group: NewWitnessGroup(0), + wantWitnesses: nil, + }, + } { + t.Run(test.desc, func(t *testing.T) { + var pol policy.TLogPolicy + pol.Quorum = test.group.name() + populatePolicy(&pol, test.group) + + if got, want := pol.Quorum, test.group.name(); got != want { + t.Errorf("pol.Quorum = %q, want %q", got, want) + } + + var gotWitnesses []string + for _, w := range pol.Witnesses { + gotWitnesses = append(gotWitnesses, w.Name) + } + if !slices.Equal(gotWitnesses, test.wantWitnesses) { + t.Errorf("pol.Witnesses = %v, want %v", gotWitnesses, test.wantWitnesses) + } + + // Verify that every group in pol.Groups has valid member names (either witness or existing subgroup) + validNames := make(map[string]bool) + for _, w := range pol.Witnesses { + validNames[w.Name] = true + } + for _, g := range pol.Groups { + validNames[g.Name] = true + if int(g.Threshold) > len(g.Members) { + t.Errorf("group %q has threshold %d > members count %d (%v)", g.Name, g.Threshold, len(g.Members), g.Members) + } + for _, m := range g.Members { + if !validNames[m] { + t.Errorf("group %q references unknown member %q", g.Name, m) + } + } + } + + // Check satisfaction tests + for _, sTest := range test.satisfyTests { + signedNote := signNote(t, sTest.signers...) + got := pol.Satisfied(signedNote) + if got != sTest.wantSat { + t.Errorf("pol.Satisfied(signers=%d) = %v, want %v", len(sTest.signers), got, sTest.wantSat) + } + } + }) + } +} + +func TestNewWitnessGroupFromPolicy(t *testing.T) { + wit1CoSigVKey, err := f_note.VKeyToCosignatureV1(testWit1VKey) + if err != nil { + t.Fatalf("failed to convert witness 1 vkey: %v", err) + } + wit2CoSigVKey, err := f_note.VKeyToCosignatureV1(testWit2VKey) + if err != nil { + t.Fatalf("failed to convert witness 2 vkey: %v", err) + } + wit3CoSigVKey, err := f_note.VKeyToCosignatureV1(testWit3VKey) + if err != nil { + t.Fatalf("failed to convert witness 3 vkey: %v", err) + } + + w1Signer, err := f_note.NewSignerForCosignatureV1(testWit1SKey) + if err != nil { + t.Fatalf("failed to create witness 1 signer: %v", err) + } + w2Signer, err := f_note.NewSignerForCosignatureV1(testWit2SKey) + if err != nil { + t.Fatalf("failed to create witness 2 signer: %v", err) + } + w3Signer, err := f_note.NewSignerForCosignatureV1(testWit3SKey) + if err != nil { + t.Fatalf("failed to create witness 3 signer: %v", err) + } + + for _, test := range []struct { + desc string + policy string + wantErr bool + wantN int + wantChildren int + checkGroup func(t *testing.T, wg WitnessGroup) + satisfyTests []struct { + signers []note.Signer + wantSat bool + } + }{ + { + desc: "single witness", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +group q 1 w1 +quorum q +`, wit1CoSigVKey), + wantN: 1, + wantChildren: 1, + checkGroup: func(t *testing.T, wg WitnessGroup) { + w, ok := wg.Components[0].(Witness) + if !ok { + t.Fatalf("expected component 0 to be Witness, got %T", wg.Components[0]) + } + if got, want := w.URL, "https://wit1.example.com"; got != want { + t.Errorf("w.URL = %q, want %q", got, want) + } + if got, want := w.vkey, wit1CoSigVKey; got != want { + t.Errorf("w.vkey = %q, want %q", got, want) + } + if got, want := w.Key.Name(), "Wit1"; got != want { + t.Errorf("w.Key.Name() = %q, want %q", got, want) + } + }, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer}, wantSat: true}, + {signers: []note.Signer{}, wantSat: false}, + {signers: []note.Signer{w2Signer}, wantSat: false}, + }, + }, + { + desc: "single witness direct quorum", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +quorum w1 +`, wit1CoSigVKey), + wantN: 1, + wantChildren: 1, + checkGroup: func(t *testing.T, wg WitnessGroup) { + w, ok := wg.Components[0].(Witness) + if !ok { + t.Fatalf("expected component 0 to be Witness, got %T", wg.Components[0]) + } + if got, want := w.URL, "https://wit1.example.com"; got != want { + t.Errorf("w.URL = %q, want %q", got, want) + } + if got, want := w.vkey, wit1CoSigVKey; got != want { + t.Errorf("w.vkey = %q, want %q", got, want) + } + if got, want := w.Key.Name(), "Wit1"; got != want { + t.Errorf("w.Key.Name() = %q, want %q", got, want) + } + }, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer}, wantSat: true}, + {signers: []note.Signer{}, wantSat: false}, + {signers: []note.Signer{w2Signer}, wantSat: false}, + }, + }, + { + desc: "quorum none", + policy: `quorum none`, + wantN: 0, + wantChildren: 0, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{}, wantSat: true}, + {signers: []note.Signer{w1Signer}, wantSat: true}, + }, + }, + { + desc: "witness without URL", + policy: fmt.Sprintf(`witness w1 %s +quorum w1 +`, wit1CoSigVKey), + wantN: 1, + wantChildren: 1, + checkGroup: func(t *testing.T, wg WitnessGroup) { + w, ok := wg.Components[0].(Witness) + if !ok { + t.Fatalf("expected component 0 to be Witness, got %T", wg.Components[0]) + } + if got, want := w.URL, ""; got != want { + t.Errorf("w.URL = %q, want %q", got, want) + } + if got, want := w.parsedURL == nil, true; got != want { + t.Errorf("w.parsedURL == nil is %v, want %v", got, want) + } + if got, want := w.vkey, wit1CoSigVKey; got != want { + t.Errorf("w.vkey = %q, want %q", got, want) + } + if got, want := w.Key.Name(), "Wit1"; got != want { + t.Errorf("w.Key.Name() = %q, want %q", got, want) + } + }, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer}, wantSat: true}, + {signers: []note.Signer{}, wantSat: false}, + }, + }, + { + desc: "multi witness group (2 of 3)", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +witness w2 %s https://wit2.example.com +witness w3 %s https://wit3.example.com +group q 2 w1 w2 w3 +quorum q +`, wit1CoSigVKey, wit2CoSigVKey, wit3CoSigVKey), + wantN: 2, + wantChildren: 3, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer, w2Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w2Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w2Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer}, wantSat: false}, + {signers: []note.Signer{}, wantSat: false}, + }, + }, + { + desc: "nested witness group (1 required and 1 of 2 subgroup)", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +witness w2 %s https://wit2.example.com +witness w3 %s https://wit3.example.com +group sub 1 w2 w3 +group q 2 w1 sub +quorum q +`, wit1CoSigVKey, wit2CoSigVKey, wit3CoSigVKey), + wantN: 2, + wantChildren: 2, + checkGroup: func(t *testing.T, wg WitnessGroup) { + if _, ok := wg.Components[0].(Witness); !ok { + t.Errorf("expected component 0 to be Witness, got %T", wg.Components[0]) + } + sub, ok := wg.Components[1].(WitnessGroup) + if !ok { + t.Fatalf("expected component 1 to be WitnessGroup, got %T", wg.Components[1]) + } + if got, want := sub.N, 1; got != want { + t.Errorf("sub.N = %d, want %d", got, want) + } + if got, want := len(sub.Components), 2; got != want { + t.Errorf("len(sub.Components) = %d, want %d", got, want) + } + }, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer, w2Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer, w2Signer, w3Signer}, wantSat: true}, + {signers: []note.Signer{w2Signer, w3Signer}, wantSat: false}, + {signers: []note.Signer{w1Signer}, wantSat: false}, + {signers: []note.Signer{}, wantSat: false}, + }, + }, + { + desc: "group using any and all keywords", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +witness w2 %s https://wit2.example.com +group sub any w1 w2 +group q all sub +quorum q +`, wit1CoSigVKey, wit2CoSigVKey), + wantN: 1, + wantChildren: 1, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer}, wantSat: true}, + {signers: []note.Signer{w2Signer}, wantSat: true}, + {signers: []note.Signer{}, wantSat: false}, + }, + }, + { + desc: "invalid policy syntax", + policy: "invalid policy text", + wantErr: true, + }, + { + desc: "invalid witness verifier key", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +group q 1 w1 +quorum q +`, "invalid+verifier+key"), + wantErr: true, + }, + { + desc: "group references undefined member", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +group q 1 undefined +quorum q +`, wit1CoSigVKey), + wantErr: true, + }, + { + desc: "quorum references undefined group", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +group q 1 w1 +quorum unknown +`, wit1CoSigVKey), + wantErr: true, + }, + } { + t.Run(test.desc, func(t *testing.T) { + group, err := NewWitnessGroupFromPolicy([]byte(test.policy)) + if (err != nil) != test.wantErr { + t.Fatalf("NewWitnessGroupFromPolicy() error = %v, wantErr = %v", err, test.wantErr) + } + if test.wantErr { + return + } + if got, want := group.N, test.wantN; got != want { + t.Errorf("group.N = %d, want %d", got, want) + } + if got, want := len(group.Components), test.wantChildren; got != want { + t.Errorf("len(group.Components) = %d, want %d", got, want) + } + if test.checkGroup != nil { + test.checkGroup(t, group) + } + + pol := group.toPolicy() + for _, sTest := range test.satisfyTests { + signedNote := signNote(t, sTest.signers...) + got := pol.Satisfied(signedNote) + if got != sTest.wantSat { + t.Errorf("pol.Satisfied(signers=%d) = %v, want %v", len(sTest.signers), got, sTest.wantSat) + } + } + }) + } +} + From 48f735a6905177481f086d3f18eb974c3e41f232 Mon Sep 17 00:00:00 2001 From: Ben Birt Date: Wed, 19 Aug 2026 12:32:43 +0000 Subject: [PATCH 4/7] Remove emptied witness policy test file --- witness_policy_test.go | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 witness_policy_test.go diff --git a/witness_policy_test.go b/witness_policy_test.go deleted file mode 100644 index f285b9835..000000000 --- a/witness_policy_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2025 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 tessera From bce0cc4e5d6a58244d7b2df4c37f39655fac47d4 Mon Sep 17 00:00:00 2001 From: Ben Birt Date: Wed, 19 Aug 2026 12:32:43 +0000 Subject: [PATCH 5/7] Fix stale witness policy references in docs --- README.md | 2 ++ cmd/examples/posix-oneshot/main.go | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 16066283d..f7253ec1b 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,8 @@ This property can be verified by witnesses, and signatures from witnesses can be Personalities can configure Tessera with options that specify witnesses compatible with the [C2SP Witness Protocol](https://github.com/C2SP/C2SP/blob/main/tlog-witness.md). Configuring the witnesses is done by passing an instance of a populated [`TLogPolicy`](https://pkg.go.dev/github.com/transparency-dev/formats/policy@main#TLogPolicy) struct to the [`WithWitnessPolicy`](https://pkg.go.dev/github.com/transparency-dev/tessera@main#AppendOptions.WithWitnessPolicy) option. +A `TLogPolicy` can either be constructed directly, or parsed from a policy file in the format described at [c2sp.org/tlog-policy](https://c2sp.org/tlog-policy) using [`TLogPolicy.Unmarshal`](https://pkg.go.dev/github.com/transparency-dev/formats/policy@main#TLogPolicy.Unmarshal). +Every witness in the policy which Tessera is expected to contact must have a URL configured. If this option is not specified, no witnessing is performed. diff --git a/cmd/examples/posix-oneshot/main.go b/cmd/examples/posix-oneshot/main.go index 439f5bfc5..c4bd3e157 100644 --- a/cmd/examples/posix-oneshot/main.go +++ b/cmd/examples/posix-oneshot/main.go @@ -40,7 +40,7 @@ var ( storageDir = flag.String("storage_dir", "", "Root directory to store log data.") entries = flag.String("entries", "", "File path glob of entries to add to the log.") privKeyFile = flag.String("private_key", "", "Location of private key file. If unset, uses the contents of the LOG_PRIVATE_KEY environment variable.") - witnessPolicyFile = flag.String("witness_policy_file", "", "(Optional) Path to the file containing the witness policy in the format describe at https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md") + witnessPolicyFile = flag.String("witness_policy_file", "", "(Optional) Path to the file containing the witness policy in the format described at https://c2sp.org/tlog-policy") witnessTimeout = flag.Duration("witness_timeout", tessera.DefaultWitnessTimeout, "Maximum time to wait for witness responses.") witnessFailOpen = flag.Bool("witness_fail_open", false, "Still publish a checkpoint even if witness policy could not be met") slogLevel = flag.Int("slog_level", 0, "The cut-off threshold for structured logging. Default is 0 (INFO). See https://pkg.go.dev/log/slog#Level for other levels.") @@ -108,7 +108,7 @@ func main() { } var wPol policy.TLogPolicy if err := wPol.Unmarshal(f); err != nil { - slog.ErrorContext(ctx, "Failed to create witness group from policy", slog.Any("error", err)) + slog.ErrorContext(ctx, "Failed to parse witness policy", slog.String("witnesspolicyfile", *witnessPolicyFile), slog.Any("error", err)) os.Exit(1) } From 933b37e2fe7b7d0a901a10d684166a2b2a8b2603 Mon Sep 17 00:00:00 2001 From: Ben Birt Date: Wed, 19 Aug 2026 12:32:43 +0000 Subject: [PATCH 6/7] Don't wait for cosignatures the policy doesn't need --- append_lifecycle.go | 13 ++++++++++++- append_lifecycle_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/append_lifecycle.go b/append_lifecycle.go index 761f9557c..b30988a33 100644 --- a/append_lifecycle.go +++ b/append_lifecycle.go @@ -988,6 +988,17 @@ func gatherCosignatures(ctx context.Context, name string, fetcher cosigSource, p if len(pol.Witnesses) == 0 { return nil, nil } + // A policy can name witnesses and yet be satisfied by the empty set of cosignatures, + // e.g. one whose quorum is "none". There's nothing to wait for in that case, so publish + // straight away rather than delaying every checkpoint by a witness round-trip (or, if + // the witnesses are unreachable, by the full timeout). + // + // Greedy is the exception: there we've been explicitly asked to collect whatever surplus + // cosignatures we can within the time available. + if !greedy && pol.Satisfied(cp) { + span.AddEvent("Policy satisfied with no cosignatures") + return nil, nil + } span.AddEvent("Starting gathering") sigCh := fetcher(ctx, cp, cpSize) @@ -1203,7 +1214,7 @@ func (o *AppendOptions) WithMirrors(mirrors WitnessGroup, opts *MirroringOptions } // WitnessOptions contains extra optional configuration for how Tessera should use/interact with -// a user-provided WitnessGroup policy. +// a user-provided witness policy. type WitnessOptions struct { // Timeout is the maximum time to wait while attempting to satisfy the configured witness policy. // diff --git a/append_lifecycle_test.go b/append_lifecycle_test.go index f4d7657b8..055ca3a10 100644 --- a/append_lifecycle_test.go +++ b/append_lifecycle_test.go @@ -425,6 +425,16 @@ func TestGatherCosignatures(t *testing.T) { sig2 := createCosignature(t, n, testWit2SKey) sig3 := createCosignature(t, n, testWit3SKey) + // A policy which names a witness, but whose quorum is satisfied without any cosignatures. + wit1CoSigVKey, err := f_note.VKeyToCosignatureV1(testWit1VKey) + if err != nil { + t.Fatalf("failed to convert witness 1 vkey: %v", err) + } + var quorumNone policy.TLogPolicy + if err := quorumNone.Unmarshal(fmt.Appendf(nil, "witness w1 %s https://wit1.example.com\nquorum none\n", wit1CoSigVKey)); err != nil { + t.Fatalf("failed to parse quorum none policy: %v", err) + } + for _, test := range []struct { desc string policy policy.TLogPolicy @@ -536,6 +546,29 @@ func TestGatherCosignatures(t *testing.T) { expectFailedOpen: true, expectCosignatures: []note.Verifier{wit1Verifier}, }, + { + desc: "non-greedy does not contact witnesses when the policy needs no cosignatures", + policy: quorumNone, + fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { + t.Error("fetcher called for a policy which is already satisfied") + ch := make(chan []byte) + close(ch) + return ch + }, + greedy: false, + }, + { + desc: "greedy still gathers surplus cosignatures when the policy needs none", + policy: quorumNone, + fetcher: func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte { + ch := make(chan []byte, 1) + ch <- sig1 + close(ch) + return ch + }, + greedy: true, + expectCosignatures: []note.Verifier{wit1Verifier}, + }, } { t.Run(test.desc, func(t *testing.T) { ctx := t.Context() From d0eb1aeac91a9ecf8789d2556d2f63b683eaedb9 Mon Sep 17 00:00:00 2001 From: Ben Birt Date: Wed, 19 Aug 2026 12:32:43 +0000 Subject: [PATCH 7/7] Keep policy names when converting witness groups --- witness.go | 94 +++++++++++++++++++++++++++++++++++++++---------- witness_test.go | 23 +++++++++--- 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/witness.go b/witness.go index 0920c0736..0995dd173 100644 --- a/witness.go +++ b/witness.go @@ -57,6 +57,7 @@ func fromPolicy(p policy.TLogPolicy) (WitnessGroup, error) { urlStr = w.URL.String() } witnesses[w.Name] = Witness{ + polName: w.Name, vkey: w.VKey, parsedURL: w.URL, Key: v, @@ -74,7 +75,11 @@ func fromPolicy(p policy.TLogPolicy) (WitnessGroup, error) { return WitnessGroup{}, fmt.Errorf("invalid policy: member %q not defined", m) } } - groups[g.Name] = NewWitnessGroup(int(g.Threshold), members...) + wg := NewWitnessGroup(int(g.Threshold), members...) + // Hold on to the name from the policy rather than the generated one, so that converting + // back produces the policy the operator actually wrote. + wg.grpName = g.Name + groups[g.Name] = wg } if p.Quorum == "none" || p.Quorum == "" { @@ -100,6 +105,7 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { } return Witness{ + polName: v.Name(), vkey: vkey, parsedURL: witnessRoot, Key: v, @@ -111,6 +117,10 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { // // Deprecated: Use [github.com/transparency-dev/formats/policy] directly instead. type Witness struct { + // polName is the name this witness is known by within a policy. For a witness parsed from a + // policy this is the name the operator gave it; otherwise it's the witness' key name, which + // is not necessarily unique. See nameAllocator. + polName string vkey string Key note.Verifier URL string @@ -118,7 +128,7 @@ type Witness struct { } func (w Witness) name() string { - return w.Key.Name() + return w.polName } var anonGroupNameCounter atomic.Int64 @@ -141,37 +151,83 @@ func NewWitnessGroup(n int, children ...policyComponent) WitnessGroup { } } -func populatePolicy(p *policy.TLogPolicy, wg WitnessGroup) { - me := &policy.Group{ - Name: wg.name(), +// nameAllocator hands out the component names used by a policy under construction. +// +// Names identify components within a policy, so two distinct components sharing one would be +// conflated by [policy.TLogPolicy.Satisfied], which resolves members via a name-keyed map. Names +// parsed from a policy are already unique, but those derived from a witness' key name are not: +// a witness which has rotated its key has two keys sharing a name, and nothing stops two +// operators picking the same name. Anything which would collide gets a disambiguating suffix. +type nameAllocator struct { + taken map[string]bool + byVKey map[string]string +} + +func newNameAllocator() *nameAllocator { + return &nameAllocator{ + taken: make(map[string]bool), + byVKey: make(map[string]string), + } +} + +// alloc returns a unique name, preferring want. +func (a *nameAllocator) alloc(want string) string { + n := want + for i := 2; a.taken[n]; i++ { + n = fmt.Sprintf("%s-%d", want, i) + } + a.taken[n] = true + return n +} + +// witness returns the name to use for the witness with the given key, along with whether it +// still needs to be added to the policy. A witness used in more than one group is defined once +// and referred to by the same name throughout. +func (a *nameAllocator) witness(want, vkey string) (string, bool) { + if n, ok := a.byVKey[vkey]; ok { + return n, false + } + n := a.alloc(want) + a.byVKey[vkey] = n + return n, true +} + +// populatePolicy adds wg, and everything beneath it, to p. It returns the name assigned to wg. +func populatePolicy(p *policy.TLogPolicy, names *nameAllocator, wg WitnessGroup) string { + me := policy.Group{ Threshold: uint(wg.N), Members: make([]string, 0, len(wg.Components)), } for _, c := range wg.Components { switch c := c.(type) { case Witness: - p.Witnesses = append(p.Witnesses, policy.Witness{ - Name: c.name(), - URL: c.parsedURL, - VKey: c.vkey, - Verifier: c.Key, - }) - me.Members = append(me.Members, c.name()) + n, isNew := names.witness(c.name(), c.vkey) + if isNew { + p.Witnesses = append(p.Witnesses, policy.Witness{ + Name: n, + URL: c.parsedURL, + VKey: c.vkey, + Verifier: c.Key, + }) + } + me.Members = append(me.Members, n) case WitnessGroup: - populatePolicy(p, c) - me.Members = append(me.Members, c.name()) + me.Members = append(me.Members, populatePolicy(p, names, c)) default: panic(fmt.Errorf("unexpected component type: %T", c)) } } - p.Groups = append(p.Groups, *me) + // Named last so that members, which are the ones with names worth preserving, get first + // refusal on the name they'd prefer. Appended last so that every group is defined after + // its members, as the policy format requires. + me.Name = names.alloc(wg.name()) + p.Groups = append(p.Groups, me) + return me.Name } func (wg WitnessGroup) toPolicy() policy.TLogPolicy { - p := policy.TLogPolicy{ - Quorum: wg.name(), - } - populatePolicy(&p, wg) + p := policy.TLogPolicy{} + p.Quorum = populatePolicy(&p, newNameAllocator(), wg) return p } diff --git a/witness_test.go b/witness_test.go index 08d5fc94d..4273dde94 100644 --- a/witness_test.go +++ b/witness_test.go @@ -21,7 +21,6 @@ import ( "testing" f_note "github.com/transparency-dev/formats/note" - "github.com/transparency-dev/formats/policy" "golang.org/x/mod/sumdb/note" ) @@ -63,6 +62,10 @@ func TestPopulatePolicy(t *testing.T) { if err != nil { t.Fatalf("failed to create witness 3: %v", err) } + // w2Clash is w2 under a key whose name happens to match w1's, as would be the case for a + // witness which has rotated its key. + w2Clash := w2 + w2Clash.polName = w1.polName w1Signer, err := f_note.NewSignerForCosignatureV1(testWit1SKey) if err != nil { @@ -156,11 +159,23 @@ func TestPopulatePolicy(t *testing.T) { group: NewWitnessGroup(0), wantWitnesses: nil, }, + { + desc: "witnesses with colliding key names stay distinct", + group: NewWitnessGroup(2, w1, w2Clash), + wantWitnesses: []string{w1.name(), w1.name() + "-2"}, + satisfyTests: []struct { + signers []note.Signer + wantSat bool + }{ + {signers: []note.Signer{w1Signer, w2Signer}, wantSat: true}, + {signers: []note.Signer{w1Signer}, wantSat: false}, + {signers: []note.Signer{w2Signer}, wantSat: false}, + {signers: []note.Signer{}, wantSat: false}, + }, + }, } { t.Run(test.desc, func(t *testing.T) { - var pol policy.TLogPolicy - pol.Quorum = test.group.name() - populatePolicy(&pol, test.group) + pol := test.group.toPolicy() if got, want := pol.Quorum, test.group.name(); got != want { t.Errorf("pol.Quorum = %q, want %q", got, want)