diff --git a/witness/witness.go b/witness/witness.go new file mode 100644 index 0000000..2da89db --- /dev/null +++ b/witness/witness.go @@ -0,0 +1,292 @@ +// 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 witness + +import ( + "bufio" + "bytes" + "fmt" + "net/url" + "strconv" + "strings" + + "maps" + + f_note "github.com/transparency-dev/formats/note" + "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 + + // Endpoints 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 value which is the verifier to check + // the response is well formed. + Endpoints() map[string]note.Verifier +} + +// NewWitnessGroupFromPolicy creates a graph of witness objects that represents the +// policy provided, and which can be passed directly to the WithWitnesses +// appender lifecycle option. +// +// 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). +func NewWitnessGroupFromPolicy(p []byte) (WitnessGroup, error) { + scanner := bufio.NewScanner(bytes.NewBuffer(p)) + components := make(map[string]policyComponent) + + var quorumName string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if i := strings.Index(line, "#"); i >= 0 { + line = line[:i] + } + if line == "" { + continue + } + + 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 + 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]) + } + } + if err := scanner.Err(); err != nil { + return WitnessGroup{}, err + } + + switch quorumName { + case "": + return WitnessGroup{}, fmt.Errorf("policy file must define a quorum") + case "none": + 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 +} + +// NewWitness returns a Witness given a verifier key and the root URL for where this +// witness can be reached. +func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { + v, err := f_note.NewVerifierForCosignatureV1(vkey) + if err != nil { + return Witness{}, err + } + + u := witnessRoot.JoinPath("/add-checkpoint") + + return Witness{ + Key: v, + URL: u.String(), + }, err +} + +// 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. +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 +} + +// Endpoints 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 value which is the verifier to check +// the response is well formed. +func (w Witness) Endpoints() map[string]note.Verifier { + return map[string]note.Verifier{w.URL: w.Key} +} + +// 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. +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{ + 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 +} + +// 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. The returned result is a map from the URL that should be used to update +// the witness with a new checkpoint, to the value which is the verifier to check +// the response is well formed. +func (wg WitnessGroup) Endpoints() map[string]note.Verifier { + endpoints := make(map[string]note.Verifier) + for _, c := range wg.Components { + maps.Copy(endpoints, c.Endpoints()) + } + return endpoints +} diff --git a/witness/witness_policy_test.go b/witness/witness_policy_test.go new file mode 100644 index 0000000..6128679 --- /dev/null +++ b/witness/witness_policy_test.go @@ -0,0 +1,176 @@ +// 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 witness + +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", + }, + } + + 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/witness_test.go b/witness/witness_test.go new file mode 100644 index 0000000..63ea36f --- /dev/null +++ b/witness/witness_test.go @@ -0,0 +1,226 @@ +// 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 witness + +import ( + "net/url" + "slices" + "testing" + + f_note "github.com/transparency-dev/formats/note" + "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, _ = NewWitness(wit1_vkey, bastion.JoinPath("wit1prefix")) + wit2, _ = NewWitness(wit2_vkey, bastion.JoinPath("wit2prefix")) + wit3, _ = 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 := WitnessGroup{} + if !group.Satisfied([]byte("definitely a checkpoint\n")) { + t.Error("empty group should be satisfied") + } + if len(group.Endpoints()) != 0 { + t.Error("empty group should have no URLs") + } +} + +func TestWitnessGroup_Satisfied(t *testing.T) { + testCases := []struct { + desc string + group WitnessGroup + signers []note.Signer + expectSatisfied bool + }{ + { + desc: "One witness, required and provided", + group: NewWitnessGroup(1, wit1), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, required and not provided", + group: NewWitnessGroup(1, wit1), + signers: []note.Signer{}, + expectSatisfied: false, + }, + { + desc: "One witness, optional and provided", + group: NewWitnessGroup(0, wit1), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, optional and not provided", + group: NewWitnessGroup(0, wit1), + signers: []note.Signer{}, + expectSatisfied: true, + }, + { + desc: "One witness, required and provided, in required subgroup", + group: NewWitnessGroup(1, NewWitnessGroup(1, wit1)), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, required and provided, in optional subgroup", + group: NewWitnessGroup(0, NewWitnessGroup(1, wit1)), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, required and not provided, in required subgroup", + group: NewWitnessGroup(1, NewWitnessGroup(1, wit1)), + signers: []note.Signer{}, + expectSatisfied: false, + }, + { + desc: "One witness, required and not provided, in optional subgroup", + group: NewWitnessGroup(0, NewWitnessGroup(1, wit1)), + signers: []note.Signer{}, + expectSatisfied: true, + }, + { + desc: "One required, one of two required, all provided", + group: NewWitnessGroup(2, wit1, NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit1Sign, wit2Sign, wit3Sign}, + expectSatisfied: true, + }, + { + desc: "One required, one of two required, min provided", + group: NewWitnessGroup(2, wit1, NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit1Sign, wit2Sign}, + expectSatisfied: true, + }, + { + desc: "One required, one of two required, only first group satisfied", + group: NewWitnessGroup(2, wit1, NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit1Sign}, + expectSatisfied: false, + }, + { + desc: "One required, one of two required, only second group satisfied", + group: NewWitnessGroup(2, wit1, 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 WitnessGroup + expectedURLs []string + }{ + { + desc: "witness 1", + group: NewWitnessGroup(1, wit1), + expectedURLs: []string{"https://b1.example.com/wit1prefix/add-checkpoint"}, + }, + { + desc: "witness 2", + group: NewWitnessGroup(1, wit2), + expectedURLs: []string{"https://b1.example.com/wit2prefix/add-checkpoint"}, + }, + { + desc: "witness 3", + group: NewWitnessGroup(1, wit3), + expectedURLs: []string{"https://witness.example.com/add-checkpoint"}, + }, + { + desc: "all witnesses in one group", + group: NewWitnessGroup(1, wit1, wit2, wit3), + expectedURLs: []string{ + "https://b1.example.com/wit1prefix/add-checkpoint", + "https://b1.example.com/wit2prefix/add-checkpoint", + "https://witness.example.com/add-checkpoint", + }, + }, + { + desc: "all witnesses with duplicates in nests", + group: NewWitnessGroup(2, NewWitnessGroup(1, wit1, wit2), NewWitnessGroup(1, wit1, wit3)), + expectedURLs: []string{ + "https://b1.example.com/wit1prefix/add-checkpoint", + "https://b1.example.com/wit2prefix/add-checkpoint", + "https://witness.example.com/add-checkpoint", + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + gotURLs := make([]string, 0) + for u := range tC.group.Endpoints() { + gotURLs = append(gotURLs, 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 := NewWitnessGroup(2, wit1, 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!") + } + } +}