diff --git a/README.md b/README.md index 62db5b77c..f7253ec1b 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,12 @@ 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. +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. -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. diff --git a/append_lifecycle.go b/append_lifecycle.go index 055905fd0..b30988a33 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,18 @@ 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 + } + // 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 } @@ -1157,14 +1157,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 +1171,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,8 +1205,16 @@ 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. +// a user-provided witness policy. type WitnessOptions struct { // Timeout is the maximum time to wait while attempting to satisfy the configured witness policy. // @@ -1290,14 +1305,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..055ca3a10 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) } @@ -430,9 +425,19 @@ 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 WitnessGroup + policy policy.TLogPolicy fetcher func(ctx context.Context, cp []byte, cpSize uint64) <-chan []byte timeout time.Duration failOpen bool @@ -443,7 +448,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 +457,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 +469,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 +482,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 +494,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 +507,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 +521,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 +534,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 @@ -541,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() @@ -549,7 +577,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 +656,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 +679,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 +693,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 +714,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 +765,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 +778,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..c4bd3e157 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" ) @@ -39,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.") @@ -105,9 +106,9 @@ 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 { - slog.ErrorContext(ctx, "Failed to create witness group from policy", slog.Any("error", err)) + var wPol policy.TLogPolicy + if err := wPol.Unmarshal(f); err != nil { + slog.ErrorContext(ctx, "Failed to parse witness policy", slog.String("witnesspolicyfile", *witnessPolicyFile), 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/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/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..0995dd173 100644 --- a/witness.go +++ b/witness.go @@ -15,180 +15,89 @@ 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{ + polName: w.Name, + 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) + } + } + 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 } - 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,134 +105,147 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { } return Witness{ - Key: v, - URL: witnessRoot.String(), + polName: v.Name(), + 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 -} - -// 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 + // 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 + parsedURL *url.URL } -// 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.polName } -// 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, } } -// 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 -// Witness devices form a logical group, and N should be picked to -// 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). -type WitnessGroup struct { - Components []policyComponent - N int +// 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 } -// 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 +func newNameAllocator() *nameAllocator { + return &nameAllocator{ + taken: make(map[string]bool), + byVKey: make(map[string]string), } - satisfaction := 0 - for _, c := range wg.Components { - if c.Satisfied(cp) { - satisfaction++ - } - if satisfaction >= wg.N { - return true - } +} + +// 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) } - return false + a.taken[n] = true + return n } -// 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] - } - } +// 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 } - return endpoints + n := a.alloc(want) + a.byVKey[vkey] = n + return n, true } -// 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) +// 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 { - for u, vs := range c.WitnessEndpoints() { - endpoints[u] = append(endpoints[u], vs...) + switch c := c.(type) { + case Witness: + 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: + me.Members = append(me.Members, populatePolicy(p, names, c)) + default: + panic(fmt.Errorf("unexpected component type: %T", c)) } } - return endpoints + // 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{} + p.Quorum = populatePolicy(&p, newNameAllocator(), 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 +// Witness devices form a logical group, and N should be picked to +// 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 +} + +func (wg WitnessGroup) name() string { + return wg.grpName } diff --git a/witness_policy_test.go b/witness_policy_test.go deleted file mode 100644 index 23b635204..000000000 --- a/witness_policy_test.go +++ /dev/null @@ -1,183 +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 - -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 index e59df2e15..4273dde94 100644 --- a/witness_test.go +++ b/witness_test.go @@ -1,214 +1,507 @@ -package tessera_test +// 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" - "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" -) +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 +} -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 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) + } -func TestWitnessGroup_Empty(t *testing.T) { - group := tessera.WitnessGroup{} - if !group.Satisfied([]byte("definitely a checkpoint\n")) { - t.Error("empty group should be satisfied") + w1, err := NewWitness(testWit1VKey, u1) + if err != nil { + t.Fatalf("failed to create witness 1: %v", err) } - if len(group.WitnessEndpoints()) != 0 { - t.Error("empty group should have no URLs") + 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) + } + // 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 { + 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) } -} -func TestWitnessGroup_Satisfied(t *testing.T) { - testCases := []struct { - desc string - group tessera.WitnessGroup - signers []note.Signer - expectSatisfied bool + for _, test := range []struct { + desc string + group WitnessGroup + wantWitnesses []string + satisfyTests []struct { + signers []note.Signer + wantSat bool + } }{ { - desc: "One witness, required and provided", - group: tessera.NewWitnessGroup(1, wit1), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, + 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: "One witness, required and not provided", - group: tessera.NewWitnessGroup(1, wit1), - signers: []note.Signer{}, - expectSatisfied: 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: "One witness, optional and provided", - group: tessera.NewWitnessGroup(0, wit1), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, + 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: "One witness, optional and not provided", - group: tessera.NewWitnessGroup(0, wit1), - signers: []note.Signer{}, - expectSatisfied: true, + 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: "One witness, required and provided, in required subgroup", - group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, + desc: "empty group with 0 threshold", + group: NewWitnessGroup(0), + wantWitnesses: nil, }, { - desc: "One witness, required and provided, in optional subgroup", - group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{wit1Sign}, - expectSatisfied: true, + 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) { + pol := test.group.toPolicy() + + 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: "One witness, required and not provided, in required subgroup", - group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{}, - expectSatisfied: false, + 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: "One witness, required and not provided, in optional subgroup", - group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), - signers: []note.Signer{}, - expectSatisfied: true, + 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: "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: "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: "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: "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: "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: "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: "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, + 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}, + }, }, - } - 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: "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: "witness 2", - group: tessera.NewWitnessGroup(1, wit2), - expectedURLs: []string{"https://b1.example.com/wit2prefix"}, + desc: "invalid policy syntax", + policy: "invalid policy text", + wantErr: true, }, { - desc: "witness 3", - group: tessera.NewWitnessGroup(1, wit3), - expectedURLs: []string{"https://witness.example.com"}, + 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: "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: "group references undefined member", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +group q 1 undefined +quorum q +`, wit1CoSigVKey), + wantErr: true, }, { - 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", - }, + desc: "quorum references undefined group", + policy: fmt.Sprintf(`witness w1 %s https://wit1.example.com +group q 1 w1 +quorum unknown +`, wit1CoSigVKey), + wantErr: true, }, - } - 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, "/")) + } { + 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) } - slices.Sort(gotURLs) - slices.Sort(tC.expectedURLs) - if !slices.Equal(gotURLs, tC.expectedURLs) { - t.Errorf("Expected %s but got %s", tC.expectedURLs, gotURLs) + 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) + } } }) } } -// 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!") - } - } -}