From 851d2ce0976aa7dd88d9ba316dca0e46535c9ef9 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Tue, 18 Feb 2025 17:05:12 +0000 Subject: [PATCH 01/17] Defined witness policy configuration (#488) This allows the required witnesses to be defined and the theshold policies that apply within each group. Arbitrarily nested structures can be built, each with different numbers of signatures. Each WitnessGroup provides the URLs at which the witness can be reached to perform witnessing, and a function that determines if the group is satisfied. This format is consistent with the only other known witness policy configuration format out there: https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md Towards #309. --- witness.go | 145 ++++++++++++++++++++++++++++++++++ witness_test.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 witness.go create mode 100644 witness_test.go diff --git a/witness.go b/witness.go new file mode 100644 index 0000000..f6b90a8 --- /dev/null +++ b/witness.go @@ -0,0 +1,145 @@ +// 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 ( + "crypto/sha256" + "encoding/base64" + "fmt" + "net/url" + "strings" + + "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 + + // URLs returns the URLs for requesting a counter signature from all + // witnesses that are involved in determining the satisfaction of this + // PolicyComponent. + URLs() []*url.URL +} + +// 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 := note.NewVerifier(vkey) + if err != nil { + return Witness{}, err + } + // "key hash" MUST be a lowercase hex-encoded SHA-256 hash of a 32-byte Ed25519 public key. + // This expression cuts off the identity name and hash. + key64 := strings.SplitAfterN(vkey, "+", 3)[2] + key, err := base64.StdEncoding.DecodeString(key64) + if err != nil { + return Witness{}, err + } + h := sha256.Sum256(key) + + u := witnessRoot.JoinPath(fmt.Sprintf("/%x/add", h)) + + return Witness{ + Key: v, + Url: u, + }, 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 *url.URL +} + +// Satisfied returns true if the checkpoint provided is signed by this witness. +// This will return false if there is no signature, and also if the +// checkpoint cannot be read as a valid note. It is up to the caller to ensure +// that the input value represents a valid note. +func (w Witness) Satisfied(cp []byte) bool { + n, err := note.Open(cp, note.VerifierList(w.Key)) + if err != nil { + return false + } + return len(n.Sigs) == 1 +} + +// URLs returns the single URL at which this witness can be reached. +// The return type is a slice in order to allow this method to match the same signature +// of WitnessGroup. +func (w Witness) URLs() []*url.URL { + return []*url.URL{w.Url} +} + +// 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. +func (wg WitnessGroup) Satisfied(cp []byte) bool { + satisfaction := 0 + for _, c := range wg.Components { + if c.Satisfied(cp) { + satisfaction++ + } + if satisfaction >= wg.N { + return true + } + } + return false +} + +// URLs returns the URLs for requesting a counter signature from all +// witnesses that are involved in determining the satisfaction of this +// PolicyComponent. +func (wg WitnessGroup) URLs() []*url.URL { + urls := make([]*url.URL, 0) + for _, c := range wg.Components { + urls = append(urls, c.URLs()...) + } + return urls +} diff --git a/witness_test.go b/witness_test.go new file mode 100644 index 0000000..b33706d --- /dev/null +++ b/witness_test.go @@ -0,0 +1,202 @@ +package tessera_test + +import ( + "net/url" + "slices" + "testing" + + tessera "github.com/transparency-dev/trillian-tessera" + "golang.org/x/mod/sumdb/note" +) + +const ( + wit1_vkey = "Wit1+55ee4561+AVhZSmQj9+SoL+p/nN0Hh76xXmF7QcHfytUrI1XfSClk" + wit1_skey = "PRIVATE+KEY+Wit1+55ee4561+AeadRiG7XM4XiieCHzD8lxysXMwcViy5nYsoXURWGrlE" + wit2_vkey = "Wit2+85ecc407+AWVbwFJte9wMQIPSnEnj4KibeO6vSIOEDUTDp3o63c2x" + wit2_skey = "PRIVATE+KEY+Wit2+85ecc407+AfPTvxw5eUcqSgivo2vaiC7JPOMUZ/9baHPSDrWqgdGm" + wit3_vkey = "Wit3+d3ed3be7+ASb6Uz1+fxAcXkMvDd7nGa3FjDce7LxIKmbbTCT0MpVn" + wit3_skey = "PRIVATE+KEY+Wit3+d3ed3be7+AR2Kg8k6ccBr5QXz5SHtnkOS4UGQGEQaWi6Gfr6Mm3X5" +) + +var ( + bastion1, _ = url.Parse("https://b1.example.com/") + bastion2, _ = url.Parse("https://b2.example.com/") + wit1, _ = tessera.NewWitness(wit1_vkey, bastion1) + wit2, _ = tessera.NewWitness(wit2_vkey, bastion1) + wit3, _ = tessera.NewWitness(wit3_vkey, bastion2) + wit1Sign, _ = note.NewSigner(wit1_skey) + wit2Sign, _ = note.NewSigner(wit2_skey) + wit3Sign, _ = note.NewSigner(wit3_skey) +) + +func TestWitnessGroup_Satisfied(t *testing.T) { + testCases := []struct { + desc string + group tessera.WitnessGroup + signers []note.Signer + expectSatisfied bool + }{ + { + desc: "One witness, required and provided", + group: tessera.NewWitnessGroup(1, wit1), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, required and not provided", + group: tessera.NewWitnessGroup(1, wit1), + signers: []note.Signer{}, + expectSatisfied: false, + }, + { + desc: "One witness, optional and provided", + group: tessera.NewWitnessGroup(0, wit1), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, optional and not provided", + group: tessera.NewWitnessGroup(0, wit1), + signers: []note.Signer{}, + expectSatisfied: true, + }, + { + desc: "One witness, required and provided, in required subgroup", + group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, required and provided, in optional subgroup", + group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), + signers: []note.Signer{wit1Sign}, + expectSatisfied: true, + }, + { + desc: "One witness, required and not provided, in required subgroup", + group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), + signers: []note.Signer{}, + expectSatisfied: false, + }, + { + desc: "One witness, required and not provided, in optional subgroup", + group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), + signers: []note.Signer{}, + expectSatisfied: true, + }, + { + desc: "One required, one of two required, all provided", + group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit1Sign, wit2Sign, wit3Sign}, + expectSatisfied: true, + }, + { + desc: "One required, one of two required, min provided", + group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit1Sign, wit2Sign}, + expectSatisfied: true, + }, + { + desc: "One required, one of two required, only first group satisfied", + group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit1Sign}, + expectSatisfied: false, + }, + { + desc: "One required, one of two required, only second group satisfied", + group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + signers: []note.Signer{wit2Sign, wit3Sign}, + expectSatisfied: false, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + n := ¬e.Note{ + Text: "sign me\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/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add"}, + }, + { + desc: "witness 2", + group: tessera.NewWitnessGroup(1, wit2), + expectedURLs: []string{"https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add"}, + }, + { + desc: "witness 3", + group: tessera.NewWitnessGroup(1, wit3), + expectedURLs: []string{"https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add"}, + }, + { + desc: "all witnesses in one group", + group: tessera.NewWitnessGroup(1, wit1, wit2, wit3), + expectedURLs: []string{ + "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add", + "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add", + "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add", + }, + }, + { + desc: "all witnesses with duplicates in nests", // This currently expects duplicates, but this behaviour may change + group: tessera.NewWitnessGroup(2, tessera.NewWitnessGroup(1, wit1, wit2), tessera.NewWitnessGroup(1, wit1, wit3)), + expectedURLs: []string{ + "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add", + "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add", + "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add", + "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add", + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + gotURLs := tC.group.URLs() + gotStrings := make([]string, len(gotURLs)) + for i, u := range gotURLs { + gotStrings[i] = u.String() + } + slices.Sort(gotStrings) + slices.Sort(tC.expectedURLs) + + if !slices.Equal(gotStrings, tC.expectedURLs) { + t.Errorf("Expected %s but got %s", tC.expectedURLs, gotStrings) + } + }) + } +} + +// 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: "sign me\n", + } + cp, err := note.Sign(n, wit1Sign, wit2Sign, wit3Sign) + if err != nil { + b.Fatal(err) + } + for i := 0; i < b.N; i++ { + if !group.Satisfied(cp) { + b.Fatal("Group should have been satisfied!") + } + } +} From 839736355666a55add25d66414cbe3ba96ac7b77 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Wed, 26 Feb 2025 10:28:08 +0000 Subject: [PATCH 02/17] Implementing witnessing code and API (#494) Towards #309. --- witness.go | 24 ++++++++++++++++-------- witness_test.go | 40 +++++++++++++++++++++++----------------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/witness.go b/witness.go index f6b90a8..d7f350d 100644 --- a/witness.go +++ b/witness.go @@ -34,7 +34,7 @@ type policyComponent interface { // URLs returns the URLs for requesting a counter signature from all // witnesses that are involved in determining the satisfaction of this // PolicyComponent. - URLs() []*url.URL + URLs() []string } // NewWitness returns a Witness given a verifier key and the root URL for where this @@ -53,11 +53,11 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { } h := sha256.Sum256(key) - u := witnessRoot.JoinPath(fmt.Sprintf("/%x/add", h)) + u := witnessRoot.JoinPath(fmt.Sprintf("/%x/add-checkpoint", h)) return Witness{ Key: v, - Url: u, + Url: u.String(), }, err } @@ -66,7 +66,7 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { // provides a predicate to check whether this witness has signed a checkpoint. type Witness struct { Key note.Verifier - Url *url.URL + Url string } // Satisfied returns true if the checkpoint provided is signed by this witness. @@ -84,8 +84,8 @@ func (w Witness) Satisfied(cp []byte) bool { // URLs returns the single URL at which this witness can be reached. // The return type is a slice in order to allow this method to match the same signature // of WitnessGroup. -func (w Witness) URLs() []*url.URL { - return []*url.URL{w.Url} +func (w Witness) URLs() []string { + return []string{w.Url} } // NewWitnessGroup creates a grouping of Witness or WitnessGroup with a configurable threshold @@ -120,7 +120,15 @@ type WitnessGroup struct { // 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) { @@ -136,8 +144,8 @@ func (wg WitnessGroup) Satisfied(cp []byte) bool { // URLs returns the URLs for requesting a counter signature from all // witnesses that are involved in determining the satisfaction of this // PolicyComponent. -func (wg WitnessGroup) URLs() []*url.URL { - urls := make([]*url.URL, 0) +func (wg WitnessGroup) URLs() []string { + urls := make([]string, 0) for _, c := range wg.Components { urls = append(urls, c.URLs()...) } diff --git a/witness_test.go b/witness_test.go index b33706d..98d69ae 100644 --- a/witness_test.go +++ b/witness_test.go @@ -29,6 +29,16 @@ var ( wit3Sign, _ = note.NewSigner(wit3_skey) ) +func TestWitnessGroup_Empty(t *testing.T) { + group := tessera.WitnessGroup{} + if !group.Satisfied([]byte("definitely a checkpoint\n")) { + t.Error("empty group should be satisfied") + } + if len(group.URLs()) != 0 { + t.Error("empty group should have no URLs") + } +} + func TestWitnessGroup_Satisfied(t *testing.T) { testCases := []struct { desc string @@ -134,50 +144,46 @@ func TestWitnessGroup_URLs(t *testing.T) { { desc: "witness 1", group: tessera.NewWitnessGroup(1, wit1), - expectedURLs: []string{"https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add"}, + expectedURLs: []string{"https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint"}, }, { desc: "witness 2", group: tessera.NewWitnessGroup(1, wit2), - expectedURLs: []string{"https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add"}, + expectedURLs: []string{"https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint"}, }, { desc: "witness 3", group: tessera.NewWitnessGroup(1, wit3), - expectedURLs: []string{"https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add"}, + expectedURLs: []string{"https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint"}, }, { desc: "all witnesses in one group", group: tessera.NewWitnessGroup(1, wit1, wit2, wit3), expectedURLs: []string{ - "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add", - "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add", - "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add", + "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", + "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint", + "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint", }, }, { desc: "all witnesses with duplicates in nests", // This currently expects duplicates, but this behaviour may change group: tessera.NewWitnessGroup(2, tessera.NewWitnessGroup(1, wit1, wit2), tessera.NewWitnessGroup(1, wit1, wit3)), expectedURLs: []string{ - "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add", - "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add", - "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add", - "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add", + "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", + "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", + "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint", + "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint", }, }, } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { gotURLs := tC.group.URLs() - gotStrings := make([]string, len(gotURLs)) - for i, u := range gotURLs { - gotStrings[i] = u.String() - } - slices.Sort(gotStrings) + slices.Sort(gotURLs) slices.Sort(tC.expectedURLs) - if !slices.Equal(gotStrings, tC.expectedURLs) { - t.Errorf("Expected %s but got %s", tC.expectedURLs, gotStrings) + if !slices.Equal(gotURLs, tC.expectedURLs) { + t.Errorf("Expected %s but got %s", tC.expectedURLs, gotURLs) } }) } From 83ec627edfc7baba12b445d551a6d4d92528fd2a Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Wed, 26 Feb 2025 15:20:23 +0000 Subject: [PATCH 03/17] [Witnessing] Check responses for valid signatures (#500) This now verifies the body of 200 responses. It checks that the note can be verified using the signature, and then returns only the signature that the log has a verifier for. This means that witnesses that return a valid signature and then a load of other signatures will not be able to pollute the checkpoint with these other signatures. On the other hand, it means we will need to consider how to support witness key rotation in Tessera in the future. There are a few ways to solve this, but I don't believe this approach blocks any of them. --- witness.go | 31 +++++++++++++++---------------- witness_test.go | 10 ++++++---- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/witness.go b/witness.go index d7f350d..f1806a8 100644 --- a/witness.go +++ b/witness.go @@ -31,10 +31,11 @@ type policyComponent interface { // witnesses involved in this policy component. Satisfied(cp []byte) bool - // URLs returns the URLs for requesting a counter signature from all - // witnesses that are involved in determining the satisfaction of this - // PolicyComponent. - URLs() []string + // 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 } // NewWitness returns a Witness given a verifier key and the root URL for where this @@ -81,11 +82,9 @@ func (w Witness) Satisfied(cp []byte) bool { return len(n.Sigs) == 1 } -// URLs returns the single URL at which this witness can be reached. -// The return type is a slice in order to allow this method to match the same signature -// of WitnessGroup. -func (w Witness) URLs() []string { - return []string{w.Url} +// Endpoints implements policyComponent.Endpoints. +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 @@ -141,13 +140,13 @@ func (wg WitnessGroup) Satisfied(cp []byte) bool { return false } -// URLs returns the URLs for requesting a counter signature from all -// witnesses that are involved in determining the satisfaction of this -// PolicyComponent. -func (wg WitnessGroup) URLs() []string { - urls := make([]string, 0) +// Endpoints implements policyComponent.Endpoints. +func (wg WitnessGroup) Endpoints() map[string]note.Verifier { + endpoints := make(map[string]note.Verifier) for _, c := range wg.Components { - urls = append(urls, c.URLs()...) + for u, v := range c.Endpoints() { + endpoints[u] = v + } } - return urls + return endpoints } diff --git a/witness_test.go b/witness_test.go index 98d69ae..98917dc 100644 --- a/witness_test.go +++ b/witness_test.go @@ -34,7 +34,7 @@ func TestWitnessGroup_Empty(t *testing.T) { if !group.Satisfied([]byte("definitely a checkpoint\n")) { t.Error("empty group should be satisfied") } - if len(group.URLs()) != 0 { + if len(group.Endpoints()) != 0 { t.Error("empty group should have no URLs") } } @@ -166,10 +166,9 @@ func TestWitnessGroup_URLs(t *testing.T) { }, }, { - desc: "all witnesses with duplicates in nests", // This currently expects duplicates, but this behaviour may change + 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/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint", "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint", @@ -178,7 +177,10 @@ func TestWitnessGroup_URLs(t *testing.T) { } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { - gotURLs := tC.group.URLs() + gotURLs := make([]string, 0) + for u := range tC.group.Endpoints() { + gotURLs = append(gotURLs, u) + } slices.Sort(gotURLs) slices.Sort(tC.expectedURLs) From ebe9e73a98b0223fb69bb7cd59418f29df377c5b Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Thu, 13 Mar 2025 17:22:27 +0000 Subject: [PATCH 04/17] Replace m[k]=v loop with `maps.Copy` (#533) --- witness.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/witness.go b/witness.go index f1806a8..e2a43b7 100644 --- a/witness.go +++ b/witness.go @@ -21,6 +21,8 @@ import ( "net/url" "strings" + "maps" + "golang.org/x/mod/sumdb/note" ) @@ -144,9 +146,7 @@ func (wg WitnessGroup) Satisfied(cp []byte) bool { func (wg WitnessGroup) Endpoints() map[string]note.Verifier { endpoints := make(map[string]note.Verifier) for _, c := range wg.Components { - for u, v := range c.Endpoints() { - endpoints[u] = v - } + maps.Copy(endpoints, c.Endpoints()) } return endpoints } From 66311716b15aaa22c43b5efe68c8c8a400e5f402 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Mon, 17 Mar 2025 14:22:35 +0000 Subject: [PATCH 05/17] Remove unused things from API (#536) Duplicate docs instead of linking to private docs --- witness.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/witness.go b/witness.go index e2a43b7..9152163 100644 --- a/witness.go +++ b/witness.go @@ -84,7 +84,10 @@ func (w Witness) Satisfied(cp []byte) bool { return len(n.Sigs) == 1 } -// Endpoints implements policyComponent.Endpoints. +// 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} } @@ -142,7 +145,10 @@ func (wg WitnessGroup) Satisfied(cp []byte) bool { return false } -// Endpoints implements policyComponent.Endpoints. +// 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 { From 9b8d6206f6473613d4183d694e33a610b639bed3 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Thu, 17 Apr 2025 12:03:25 +0100 Subject: [PATCH 06/17] Slight API pruning and modernization (#609) A few bits to clean up as we approach a beta release: - Pruned utility method from API - Renamed IntegrationAwaiter to PublicationAwaiter - Modernized some older go idioms --- witness_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/witness_test.go b/witness_test.go index 98917dc..8799103 100644 --- a/witness_test.go +++ b/witness_test.go @@ -202,7 +202,7 @@ func BenchmarkWitnessGroupSatisfaction(b *testing.B) { if err != nil { b.Fatal(err) } - for i := 0; i < b.N; i++ { + for b.Loop() { if !group.Satisfied(cp) { b.Fatal("Group should have been satisfied!") } From 250099373a64e180522009c434a351487ff3cad7 Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Mon, 12 May 2025 17:13:51 +0100 Subject: [PATCH 07/17] Replace all `Url` with `URL` (#636) --- witness.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/witness.go b/witness.go index 9152163..5a19de2 100644 --- a/witness.go +++ b/witness.go @@ -60,7 +60,7 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { return Witness{ Key: v, - Url: u.String(), + URL: u.String(), }, err } @@ -69,7 +69,7 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { // provides a predicate to check whether this witness has signed a checkpoint. type Witness struct { Key note.Verifier - Url string + URL string } // Satisfied returns true if the checkpoint provided is signed by this witness. @@ -89,7 +89,7 @@ func (w Witness) Satisfied(cp []byte) bool { // 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} + return map[string]note.Verifier{w.URL: w.Key} } // NewWitnessGroup creates a grouping of Witness or WitnessGroup with a configurable threshold From 96bc71ca33617fef0b8d1be220bbb821b5c6e427 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 15 May 2025 15:06:24 +0100 Subject: [PATCH 08/17] Follow rename to Tessera #645 --- witness_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/witness_test.go b/witness_test.go index 8799103..dfedcda 100644 --- a/witness_test.go +++ b/witness_test.go @@ -5,7 +5,7 @@ import ( "slices" "testing" - tessera "github.com/transparency-dev/trillian-tessera" + "github.com/transparency-dev/tessera" "golang.org/x/mod/sumdb/note" ) From a3d9344b1f4594c374b7703db68508ba03d10281 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Wed, 3 Sep 2025 15:23:48 +0100 Subject: [PATCH 09/17] Witness policy (#755) This PR adds support for constructing a graph of WitnessGroup/Witness structs which represent the policy defined in a config file complying with the spec here: https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md --- witness.go | 143 ++++++++++++++++++++++++++++++++++++++ witness_policy_test.go | 153 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 witness_policy_test.go diff --git a/witness.go b/witness.go index 5a19de2..6525718 100644 --- a/witness.go +++ b/witness.go @@ -15,10 +15,13 @@ package tessera import ( + "bufio" "crypto/sha256" "encoding/base64" "fmt" + "io" "net/url" + "strconv" "strings" "maps" @@ -40,6 +43,145 @@ type policyComponent interface { Endpoints() map[string]note.Verifier } +// NewWitnessGroupFromPolicy creates a graph of witness objects that represents the +// policy provided via the reader, and which can be passed directly to the WithWitnesses +// appender lifecycle option. +// +// The policy must be structured as per the description in +// https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md +func NewWitnessGroupFromPolicy(r io.Reader) (WitnessGroup, error) { + scanner := bufio.NewScanner(r) + 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) { @@ -156,3 +298,4 @@ func (wg WitnessGroup) Endpoints() map[string]note.Verifier { } return endpoints } + diff --git a/witness_policy_test.go b/witness_policy_test.go new file mode 100644 index 0000000..008c0ff --- /dev/null +++ b/witness_policy_test.go @@ -0,0 +1,153 @@ +// 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) { + 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 +` + r := strings.NewReader(policy) + wg, err := NewWitnessGroupFromPolicy(r) + 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/ +group g1 2 w1 w2 w3 +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) { + r := strings.NewReader(tc.policy) + wg, err := NewWitnessGroupFromPolicy(r) + 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) { + r := strings.NewReader(tc.policy) + _, err := NewWitnessGroupFromPolicy(r) + 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()) + } + }) + } +} + From ca5943c42898845b67f3d71ae8ac9246b6fcb4b4 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 4 Sep 2025 14:28:21 +0100 Subject: [PATCH 10/17] Witness submission prefix is a config item. (#756) --- witness.go | 13 +------------ witness_test.go | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/witness.go b/witness.go index 6525718..3d29b61 100644 --- a/witness.go +++ b/witness.go @@ -16,8 +16,6 @@ package tessera import ( "bufio" - "crypto/sha256" - "encoding/base64" "fmt" "io" "net/url" @@ -189,16 +187,8 @@ func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { if err != nil { return Witness{}, err } - // "key hash" MUST be a lowercase hex-encoded SHA-256 hash of a 32-byte Ed25519 public key. - // This expression cuts off the identity name and hash. - key64 := strings.SplitAfterN(vkey, "+", 3)[2] - key, err := base64.StdEncoding.DecodeString(key64) - if err != nil { - return Witness{}, err - } - h := sha256.Sum256(key) - u := witnessRoot.JoinPath(fmt.Sprintf("/%x/add-checkpoint", h)) + u := witnessRoot.JoinPath("/add-checkpoint") return Witness{ Key: v, @@ -298,4 +288,3 @@ func (wg WitnessGroup) Endpoints() map[string]note.Verifier { } return endpoints } - diff --git a/witness_test.go b/witness_test.go index dfedcda..9c1ad19 100644 --- a/witness_test.go +++ b/witness_test.go @@ -19,14 +19,14 @@ const ( ) var ( - bastion1, _ = url.Parse("https://b1.example.com/") - bastion2, _ = url.Parse("https://b2.example.com/") - wit1, _ = tessera.NewWitness(wit1_vkey, bastion1) - wit2, _ = tessera.NewWitness(wit2_vkey, bastion1) - wit3, _ = tessera.NewWitness(wit3_vkey, bastion2) - wit1Sign, _ = note.NewSigner(wit1_skey) - wit2Sign, _ = note.NewSigner(wit2_skey) - wit3Sign, _ = note.NewSigner(wit3_skey) + 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, _ = note.NewSigner(wit1_skey) + wit2Sign, _ = note.NewSigner(wit2_skey) + wit3Sign, _ = note.NewSigner(wit3_skey) ) func TestWitnessGroup_Empty(t *testing.T) { @@ -144,34 +144,34 @@ func TestWitnessGroup_URLs(t *testing.T) { { desc: "witness 1", group: tessera.NewWitnessGroup(1, wit1), - expectedURLs: []string{"https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint"}, + expectedURLs: []string{"https://b1.example.com/wit1prefix/add-checkpoint"}, }, { desc: "witness 2", group: tessera.NewWitnessGroup(1, wit2), - expectedURLs: []string{"https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint"}, + expectedURLs: []string{"https://b1.example.com/wit2prefix/add-checkpoint"}, }, { desc: "witness 3", group: tessera.NewWitnessGroup(1, wit3), - expectedURLs: []string{"https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint"}, + expectedURLs: []string{"https://witness.example.com/add-checkpoint"}, }, { desc: "all witnesses in one group", group: tessera.NewWitnessGroup(1, wit1, wit2, wit3), expectedURLs: []string{ - "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", - "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint", - "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint", + "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: tessera.NewWitnessGroup(2, tessera.NewWitnessGroup(1, wit1, wit2), tessera.NewWitnessGroup(1, wit1, wit3)), expectedURLs: []string{ - "https://b1.example.com/b490a162bf632bdd72181cd9eb5b8ab8b13e4e973a9ce9a12a0810fd981bc186/add-checkpoint", - "https://b1.example.com/7a99cf3d04ea875d413c4b3fb70d74ef483efaf667eac56e35f0b96a112b1c84/add-checkpoint", - "https://b2.example.com/ae59f4e59ea1802501b6000f875f09eb49d267055d4a1df8b6d862edc004334c/add-checkpoint", + "https://b1.example.com/wit1prefix/add-checkpoint", + "https://b1.example.com/wit2prefix/add-checkpoint", + "https://witness.example.com/add-checkpoint", }, }, } From 08ffcd8b199875cba1b9d2aa81ae666de127c943 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 4 Sep 2025 15:06:07 +0100 Subject: [PATCH 11/17] Fix some witness related nits (#757) --- witness.go | 3 ++- witness_test.go | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/witness.go b/witness.go index 3d29b61..20b956a 100644 --- a/witness.go +++ b/witness.go @@ -24,6 +24,7 @@ import ( "maps" + f_note "github.com/transparency-dev/formats/note" "golang.org/x/mod/sumdb/note" ) @@ -183,7 +184,7 @@ func isBadName(n string) bool { // 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 := note.NewVerifier(vkey) + v, err := f_note.NewVerifierForCosignatureV1(vkey) if err != nil { return Witness{}, err } diff --git a/witness_test.go b/witness_test.go index 9c1ad19..85ea05b 100644 --- a/witness_test.go +++ b/witness_test.go @@ -5,6 +5,7 @@ import ( "slices" "testing" + f_note "github.com/transparency-dev/formats/note" "github.com/transparency-dev/tessera" "golang.org/x/mod/sumdb/note" ) @@ -24,9 +25,9 @@ var ( wit1, _ = tessera.NewWitness(wit1_vkey, bastion.JoinPath("wit1prefix")) wit2, _ = tessera.NewWitness(wit2_vkey, bastion.JoinPath("wit2prefix")) wit3, _ = tessera.NewWitness(wit3_vkey, directURL) - wit1Sign, _ = note.NewSigner(wit1_skey) - wit2Sign, _ = note.NewSigner(wit2_skey) - wit3Sign, _ = note.NewSigner(wit3_skey) + wit1Sign, _ = f_note.NewSignerForCosignatureV1(wit1_skey) + wit2Sign, _ = f_note.NewSignerForCosignatureV1(wit2_skey) + wit3Sign, _ = f_note.NewSignerForCosignatureV1(wit3_skey) ) func TestWitnessGroup_Empty(t *testing.T) { @@ -122,7 +123,8 @@ func TestWitnessGroup_Satisfied(t *testing.T) { for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { n := ¬e.Note{ - Text: "sign me\n", + // 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 { @@ -196,7 +198,8 @@ func TestWitnessGroup_URLs(t *testing.T) { func BenchmarkWitnessGroupSatisfaction(b *testing.B) { group := tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)) n := ¬e.Note{ - Text: "sign me\n", + // 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 { From 92d6b1114180ab4422163412690b2e5f3353f305 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 4 Sep 2025 16:59:47 +0100 Subject: [PATCH 12/17] Read witness policy from []byte (#758) --- witness.go | 8 ++++---- witness_policy_test.go | 9 +++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/witness.go b/witness.go index 20b956a..2aa538c 100644 --- a/witness.go +++ b/witness.go @@ -16,8 +16,8 @@ package tessera import ( "bufio" + "bytes" "fmt" - "io" "net/url" "strconv" "strings" @@ -43,13 +43,13 @@ type policyComponent interface { } // NewWitnessGroupFromPolicy creates a graph of witness objects that represents the -// policy provided via the reader, and which can be passed directly to the WithWitnesses +// policy provided, and which can be passed directly to the WithWitnesses // appender lifecycle option. // // The policy must be structured as per the description in // https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md -func NewWitnessGroupFromPolicy(r io.Reader) (WitnessGroup, error) { - scanner := bufio.NewScanner(r) +func NewWitnessGroupFromPolicy(p []byte) (WitnessGroup, error) { + scanner := bufio.NewScanner(bytes.NewBuffer(p)) components := make(map[string]policyComponent) var quorumName string diff --git a/witness_policy_test.go b/witness_policy_test.go index 008c0ff..a0cca26 100644 --- a/witness_policy_test.go +++ b/witness_policy_test.go @@ -26,8 +26,7 @@ witness w2 example.com+3753d3de+AebBhMcghIUoavZpjuDofa4sW6fYHyVn7gvwDBfvkvuM htt group g1 all w1 w2 quorum g1 ` - r := strings.NewReader(policy) - wg, err := NewWitnessGroupFromPolicy(r) + wg, err := NewWitnessGroupFromPolicy([]byte(policy)) if err != nil { t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) } @@ -83,8 +82,7 @@ quorum g1 for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - r := strings.NewReader(tc.policy) - wg, err := NewWitnessGroupFromPolicy(r) + wg, err := NewWitnessGroupFromPolicy([]byte(tc.policy)) if err != nil { t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) } @@ -139,8 +137,7 @@ func TestNewWitnessGroupFromPolicy_Errors(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - r := strings.NewReader(tc.policy) - _, err := NewWitnessGroupFromPolicy(r) + _, err := NewWitnessGroupFromPolicy([]byte(tc.policy)) if err == nil { t.Fatal("Expected error, got nil") } From c2e3b87bb36f47a437c8bf9d627dd31c0cb0d79f Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 9 Oct 2025 10:41:28 +0100 Subject: [PATCH 13/17] Check policy handles whitespace and comments ok (#788) --- witness_policy_test.go | 49 +++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/witness_policy_test.go b/witness_policy_test.go index a0cca26..0fee487 100644 --- a/witness_policy_test.go +++ b/witness_policy_test.go @@ -20,22 +20,48 @@ import ( ) func TestNewWitnessGroupFromPolicy(t *testing.T) { - policy := ` + 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 -` - wg, err := NewWitnessGroupFromPolicy([]byte(policy)) - if err != nil { - t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) - } +`, + }, { + name: "whitespace and comments", + policy: ` - 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)) +# 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)) + } + }) } } @@ -147,4 +173,3 @@ func TestNewWitnessGroupFromPolicy_Errors(t *testing.T) { }) } } - From 489773e954702a9857b338582a45fb1adb2290ee Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 17 Oct 2025 12:17:40 +0100 Subject: [PATCH 14/17] Bump formats to 404c0d5b696c6a3e4eb9cec706cfc3550cc47e52 (#791) * Bump formats to 404c0d5b696c6a3e4eb9cec706cfc3550cc47e52 & tidy * Add 0x04 key to policy tests --- witness_policy_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/witness_policy_test.go b/witness_policy_test.go index 0fee487..3236428 100644 --- a/witness_policy_test.go +++ b/witness_policy_test.go @@ -77,7 +77,8 @@ func TestNewWitnessGroupFromPolicy_GroupN(t *testing.T) { 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 2 w1 w2 w3 +witness w4 remora.n621.de+da77ade7+BOvN63jn/bLvkieywe8R6UYAtVtNbZpXh34x7onlmtw2 https://example.com/remora +group g1 2 w1 w2 w3 w4 quorum g1 `, wantN: 2, From 31542ea5f7f05f5ad05af65bc72cb8b85deac4f7 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 12 Dec 2025 13:56:51 +0000 Subject: [PATCH 15/17] Clarify witness notes (#827) Makes it easier to find the option to configure witnesses using a policy file, and clarifies that it expects vkey cosig keys for witnesses. --- witness.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/witness.go b/witness.go index 2aa538c..a5d4b56 100644 --- a/witness.go +++ b/witness.go @@ -46,8 +46,9 @@ type policyComponent interface { // policy provided, and which can be passed directly to the WithWitnesses // appender lifecycle option. // -// The policy must be structured as per the description in -// https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md +// 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) From 39e0d46347071abc5dff7b996c2506ea230d1666 Mon Sep 17 00:00:00 2001 From: Hayden <8418760+Hayden-IO@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:01:10 -0800 Subject: [PATCH 16/17] Move to dedicated package Signed-off-by: Hayden <8418760+Hayden-IO@users.noreply.github.com> --- witness.go => witness/witness.go | 2 +- .../witness_policy_test.go | 2 +- witness_test.go => witness/witness_test.go | 65 +++++++++++-------- 3 files changed, 41 insertions(+), 28 deletions(-) rename witness.go => witness/witness.go (99%) rename witness_policy_test.go => witness/witness_policy_test.go (99%) rename witness_test.go => witness/witness_test.go (73%) diff --git a/witness.go b/witness/witness.go similarity index 99% rename from witness.go rename to witness/witness.go index a5d4b56..2da89db 100644 --- a/witness.go +++ b/witness/witness.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package tessera +package witness import ( "bufio" diff --git a/witness_policy_test.go b/witness/witness_policy_test.go similarity index 99% rename from witness_policy_test.go rename to witness/witness_policy_test.go index 3236428..6128679 100644 --- a/witness_policy_test.go +++ b/witness/witness_policy_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package tessera +package witness import ( "strings" diff --git a/witness_test.go b/witness/witness_test.go similarity index 73% rename from witness_test.go rename to witness/witness_test.go index 85ea05b..63ea36f 100644 --- a/witness_test.go +++ b/witness/witness_test.go @@ -1,4 +1,18 @@ -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 witness import ( "net/url" @@ -6,7 +20,6 @@ import ( "testing" f_note "github.com/transparency-dev/formats/note" - "github.com/transparency-dev/tessera" "golang.org/x/mod/sumdb/note" ) @@ -22,16 +35,16 @@ const ( 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) + 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 := tessera.WitnessGroup{} + group := WitnessGroup{} if !group.Satisfied([]byte("definitely a checkpoint\n")) { t.Error("empty group should be satisfied") } @@ -43,79 +56,79 @@ func TestWitnessGroup_Empty(t *testing.T) { func TestWitnessGroup_Satisfied(t *testing.T) { testCases := []struct { desc string - group tessera.WitnessGroup + group WitnessGroup signers []note.Signer expectSatisfied bool }{ { desc: "One witness, required and provided", - group: tessera.NewWitnessGroup(1, wit1), + group: NewWitnessGroup(1, wit1), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, required and not provided", - group: tessera.NewWitnessGroup(1, wit1), + group: NewWitnessGroup(1, wit1), signers: []note.Signer{}, expectSatisfied: false, }, { desc: "One witness, optional and provided", - group: tessera.NewWitnessGroup(0, wit1), + group: NewWitnessGroup(0, wit1), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, optional and not provided", - group: tessera.NewWitnessGroup(0, wit1), + group: NewWitnessGroup(0, wit1), signers: []note.Signer{}, expectSatisfied: true, }, { desc: "One witness, required and provided, in required subgroup", - group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), + group: NewWitnessGroup(1, NewWitnessGroup(1, wit1)), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, required and provided, in optional subgroup", - group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), + group: NewWitnessGroup(0, NewWitnessGroup(1, wit1)), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, required and not provided, in required subgroup", - group: tessera.NewWitnessGroup(1, tessera.NewWitnessGroup(1, wit1)), + group: NewWitnessGroup(1, NewWitnessGroup(1, wit1)), signers: []note.Signer{}, expectSatisfied: false, }, { desc: "One witness, required and not provided, in optional subgroup", - group: tessera.NewWitnessGroup(0, tessera.NewWitnessGroup(1, wit1)), + group: NewWitnessGroup(0, NewWitnessGroup(1, wit1)), signers: []note.Signer{}, expectSatisfied: true, }, { desc: "One required, one of two required, all provided", - group: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + 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: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + 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: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + 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: tessera.NewWitnessGroup(2, wit1, tessera.NewWitnessGroup(1, wit2, wit3)), + group: NewWitnessGroup(2, wit1, NewWitnessGroup(1, wit2, wit3)), signers: []note.Signer{wit2Sign, wit3Sign}, expectSatisfied: false, }, @@ -140,27 +153,27 @@ func TestWitnessGroup_Satisfied(t *testing.T) { func TestWitnessGroup_URLs(t *testing.T) { testCases := []struct { desc string - group tessera.WitnessGroup + group WitnessGroup expectedURLs []string }{ { desc: "witness 1", - group: tessera.NewWitnessGroup(1, wit1), + group: NewWitnessGroup(1, wit1), expectedURLs: []string{"https://b1.example.com/wit1prefix/add-checkpoint"}, }, { desc: "witness 2", - group: tessera.NewWitnessGroup(1, wit2), + group: NewWitnessGroup(1, wit2), expectedURLs: []string{"https://b1.example.com/wit2prefix/add-checkpoint"}, }, { desc: "witness 3", - group: tessera.NewWitnessGroup(1, wit3), + group: NewWitnessGroup(1, wit3), expectedURLs: []string{"https://witness.example.com/add-checkpoint"}, }, { desc: "all witnesses in one group", - group: tessera.NewWitnessGroup(1, wit1, wit2, wit3), + group: NewWitnessGroup(1, wit1, wit2, wit3), expectedURLs: []string{ "https://b1.example.com/wit1prefix/add-checkpoint", "https://b1.example.com/wit2prefix/add-checkpoint", @@ -169,7 +182,7 @@ func TestWitnessGroup_URLs(t *testing.T) { }, { desc: "all witnesses with duplicates in nests", - group: tessera.NewWitnessGroup(2, tessera.NewWitnessGroup(1, wit1, wit2), tessera.NewWitnessGroup(1, wit1, wit3)), + 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", @@ -196,7 +209,7 @@ func TestWitnessGroup_URLs(t *testing.T) { // 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)) + 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", From 2dcbc6f5358ad8fe779d1769a19c48f42dfc2c6d Mon Sep 17 00:00:00 2001 From: Hayden <8418760+Hayden-IO@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:15:30 -0800 Subject: [PATCH 17/17] Update function and struct names to align with style guide Signed-off-by: Hayden <8418760+Hayden-IO@users.noreply.github.com> --- witness/witness.go | 71 +++++++++++++++++----------------- witness/witness_policy_test.go | 16 ++++---- witness/witness_test.go | 56 +++++++++++++-------------- 3 files changed, 71 insertions(+), 72 deletions(-) diff --git a/witness/witness.go b/witness/witness.go index 2da89db..55f4a90 100644 --- a/witness/witness.go +++ b/witness/witness.go @@ -42,14 +42,13 @@ type policyComponent interface { 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. +// ParsePolicy creates a graph of witness objects that represents the +// policy provided. // // 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) { +func ParsePolicy(p []byte) (Group, error) { scanner := bufio.NewScanner(bytes.NewBuffer(p)) components := make(map[string]policyComponent) @@ -72,35 +71,35 @@ func NewWitnessGroupFromPolicy(p []byte) (WitnessGroup, error) { // 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) + return Group{}, 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) + return Group{}, fmt.Errorf("invalid witness name %q", name) } if _, ok := components[name]; ok { - return WitnessGroup{}, fmt.Errorf("duplicate component name: %q", name) + return Group{}, 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) + return Group{}, fmt.Errorf("invalid witness URL %q: %w", witnessURLStr, err) } - w, err := NewWitness(vkey, witnessURL) + w, err := New(vkey, witnessURL) if err != nil { - return WitnessGroup{}, fmt.Errorf("invalid witness config %q: %w", line, err) + return Group{}, 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) + return Group{}, 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) + return Group{}, fmt.Errorf("invalid group name %q", name) } if _, ok := components[name]; ok { - return WitnessGroup{}, fmt.Errorf("duplicate component name: %q", name) + return Group{}, fmt.Errorf("duplicate component name: %q", name) } var n int switch N { @@ -111,57 +110,57 @@ func NewWitnessGroupFromPolicy(p []byte) (WitnessGroup, error) { 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) + return Group{}, 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) + return Group{}, 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) + return Group{}, fmt.Errorf("invalid component name %q", cName) } child, ok := components[cName] if !ok { - return WitnessGroup{}, fmt.Errorf("unknown component %q in group definition", cName) + return Group{}, fmt.Errorf("unknown component %q in group definition", cName) } children[i] = child } - wg := NewWitnessGroup(n, children...) + wg := NewGroup(n, children...) components[name] = wg case "quorum": if len(fields) != 2 { - return WitnessGroup{}, fmt.Errorf("invalid quorum definition: %q", line) + return Group{}, fmt.Errorf("invalid quorum definition: %q", line) } quorumName = fields[1] default: - return WitnessGroup{}, fmt.Errorf("unknown keyword: %q", fields[0]) + return Group{}, fmt.Errorf("unknown keyword: %q", fields[0]) } } if err := scanner.Err(); err != nil { - return WitnessGroup{}, err + return Group{}, err } switch quorumName { case "": - return WitnessGroup{}, fmt.Errorf("policy file must define a quorum") + return Group{}, fmt.Errorf("policy file must define a quorum") case "none": - return NewWitnessGroup(0), nil + return NewGroup(0), nil default: if isBadName(quorumName) { - return WitnessGroup{}, fmt.Errorf("invalid quorum name %q", quorumName) + return Group{}, fmt.Errorf("invalid quorum name %q", quorumName) } policy, ok := components[quorumName] if !ok { - return WitnessGroup{}, fmt.Errorf("quorum component %q not found", quorumName) + return Group{}, fmt.Errorf("quorum component %q not found", quorumName) } - wg, ok := policy.(WitnessGroup) + wg, ok := policy.(Group) if !ok { // A single witness can be a policy. Wrap it in a group. - return NewWitnessGroup(1, policy), nil + return NewGroup(1, policy), nil } return wg, nil } @@ -182,9 +181,9 @@ func isBadName(n string) bool { return isKeyword } -// NewWitness returns a Witness given a verifier key and the root URL for where this +// New 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) { +func New(vkey string, witnessRoot *url.URL) (Witness, error) { v, err := f_note.NewVerifierForCosignatureV1(vkey) if err != nil { return Witness{}, err @@ -226,29 +225,29 @@ 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 +// NewGroup 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 { +func NewGroup(n int, children ...policyComponent) Group { if n < 0 || n > len(children) { panic(fmt.Errorf("threshold of %d outside bounds for children %s", n, children)) } - return WitnessGroup{ + return Group{ Components: children, N: n, } } -// WitnessGroup defines a group of witnesses, and a threshold of +// Group 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 { +type Group struct { Components []policyComponent N int } @@ -263,7 +262,7 @@ type WitnessGroup struct { // 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 { +func (wg Group) Satisfied(cp []byte) bool { if wg.N <= 0 { return true } @@ -283,7 +282,7 @@ func (wg WitnessGroup) Satisfied(cp []byte) bool { // 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 { +func (wg Group) Endpoints() map[string]note.Verifier { endpoints := make(map[string]note.Verifier) for _, c := range wg.Components { maps.Copy(endpoints, c.Endpoints()) diff --git a/witness/witness_policy_test.go b/witness/witness_policy_test.go index 6128679..0b8ffc1 100644 --- a/witness/witness_policy_test.go +++ b/witness/witness_policy_test.go @@ -19,7 +19,7 @@ import ( "testing" ) -func TestNewWitnessGroupFromPolicy(t *testing.T) { +func TestParsePolicy(t *testing.T) { for _, test := range []struct { name string policy string @@ -50,9 +50,9 @@ group g1 all w1 w2 } { t.Run(test.name, func(t *testing.T) { - wg, err := NewWitnessGroupFromPolicy([]byte(test.policy)) + wg, err := ParsePolicy([]byte(test.policy)) if err != nil { - t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) + t.Fatalf("ParsePolicy() failed: %v", err) } if wg.N != 2 { @@ -65,7 +65,7 @@ group g1 all w1 w2 } } -func TestNewWitnessGroupFromPolicy_GroupN(t *testing.T) { +func TestParsePolicy_GroupN(t *testing.T) { testCases := []struct { desc string policy string @@ -109,9 +109,9 @@ quorum g1 for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - wg, err := NewWitnessGroupFromPolicy([]byte(tc.policy)) + wg, err := ParsePolicy([]byte(tc.policy)) if err != nil { - t.Fatalf("NewWitnessGroupFromPolicy() failed: %v", err) + t.Fatalf("ParsePolicy() failed: %v", err) } if wg.N != tc.wantN { t.Errorf("wg.N = %d, want %d", wg.N, tc.wantN) @@ -120,7 +120,7 @@ quorum g1 } } -func TestNewWitnessGroupFromPolicy_Errors(t *testing.T) { +func TestParsePolicy_Errors(t *testing.T) { testCases := []struct { desc string policy string @@ -164,7 +164,7 @@ func TestNewWitnessGroupFromPolicy_Errors(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - _, err := NewWitnessGroupFromPolicy([]byte(tc.policy)) + _, err := ParsePolicy([]byte(tc.policy)) if err == nil { t.Fatal("Expected error, got nil") } diff --git a/witness/witness_test.go b/witness/witness_test.go index 63ea36f..ca0b2f6 100644 --- a/witness/witness_test.go +++ b/witness/witness_test.go @@ -35,16 +35,16 @@ const ( 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) + wit1, _ = New(wit1_vkey, bastion.JoinPath("wit1prefix")) + wit2, _ = New(wit2_vkey, bastion.JoinPath("wit2prefix")) + wit3, _ = New(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{} +func TestGroup_Empty(t *testing.T) { + group := Group{} if !group.Satisfied([]byte("definitely a checkpoint\n")) { t.Error("empty group should be satisfied") } @@ -53,82 +53,82 @@ func TestWitnessGroup_Empty(t *testing.T) { } } -func TestWitnessGroup_Satisfied(t *testing.T) { +func TestGroup_Satisfied(t *testing.T) { testCases := []struct { desc string - group WitnessGroup + group Group signers []note.Signer expectSatisfied bool }{ { desc: "One witness, required and provided", - group: NewWitnessGroup(1, wit1), + group: NewGroup(1, wit1), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, required and not provided", - group: NewWitnessGroup(1, wit1), + group: NewGroup(1, wit1), signers: []note.Signer{}, expectSatisfied: false, }, { desc: "One witness, optional and provided", - group: NewWitnessGroup(0, wit1), + group: NewGroup(0, wit1), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, optional and not provided", - group: NewWitnessGroup(0, wit1), + group: NewGroup(0, wit1), signers: []note.Signer{}, expectSatisfied: true, }, { desc: "One witness, required and provided, in required subgroup", - group: NewWitnessGroup(1, NewWitnessGroup(1, wit1)), + group: NewGroup(1, NewGroup(1, wit1)), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, required and provided, in optional subgroup", - group: NewWitnessGroup(0, NewWitnessGroup(1, wit1)), + group: NewGroup(0, NewGroup(1, wit1)), signers: []note.Signer{wit1Sign}, expectSatisfied: true, }, { desc: "One witness, required and not provided, in required subgroup", - group: NewWitnessGroup(1, NewWitnessGroup(1, wit1)), + group: NewGroup(1, NewGroup(1, wit1)), signers: []note.Signer{}, expectSatisfied: false, }, { desc: "One witness, required and not provided, in optional subgroup", - group: NewWitnessGroup(0, NewWitnessGroup(1, wit1)), + group: NewGroup(0, NewGroup(1, wit1)), signers: []note.Signer{}, expectSatisfied: true, }, { desc: "One required, one of two required, all provided", - group: NewWitnessGroup(2, wit1, NewWitnessGroup(1, wit2, wit3)), + group: NewGroup(2, wit1, NewGroup(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)), + group: NewGroup(2, wit1, NewGroup(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)), + group: NewGroup(2, wit1, NewGroup(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)), + group: NewGroup(2, wit1, NewGroup(1, wit2, wit3)), signers: []note.Signer{wit2Sign, wit3Sign}, expectSatisfied: false, }, @@ -150,30 +150,30 @@ func TestWitnessGroup_Satisfied(t *testing.T) { } } -func TestWitnessGroup_URLs(t *testing.T) { +func TestGroup_URLs(t *testing.T) { testCases := []struct { desc string - group WitnessGroup + group Group expectedURLs []string }{ { desc: "witness 1", - group: NewWitnessGroup(1, wit1), + group: NewGroup(1, wit1), expectedURLs: []string{"https://b1.example.com/wit1prefix/add-checkpoint"}, }, { desc: "witness 2", - group: NewWitnessGroup(1, wit2), + group: NewGroup(1, wit2), expectedURLs: []string{"https://b1.example.com/wit2prefix/add-checkpoint"}, }, { desc: "witness 3", - group: NewWitnessGroup(1, wit3), + group: NewGroup(1, wit3), expectedURLs: []string{"https://witness.example.com/add-checkpoint"}, }, { desc: "all witnesses in one group", - group: NewWitnessGroup(1, wit1, wit2, wit3), + group: NewGroup(1, wit1, wit2, wit3), expectedURLs: []string{ "https://b1.example.com/wit1prefix/add-checkpoint", "https://b1.example.com/wit2prefix/add-checkpoint", @@ -182,7 +182,7 @@ func TestWitnessGroup_URLs(t *testing.T) { }, { desc: "all witnesses with duplicates in nests", - group: NewWitnessGroup(2, NewWitnessGroup(1, wit1, wit2), NewWitnessGroup(1, wit1, wit3)), + group: NewGroup(2, NewGroup(1, wit1, wit2), NewGroup(1, wit1, wit3)), expectedURLs: []string{ "https://b1.example.com/wit1prefix/add-checkpoint", "https://b1.example.com/wit2prefix/add-checkpoint", @@ -208,8 +208,8 @@ func TestWitnessGroup_URLs(t *testing.T) { // 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)) +func BenchmarkGroupSatisfaction(b *testing.B) { + group := NewGroup(2, wit1, NewGroup(1, wit2, wit3)) n := ¬e.Note{ // Text must contain 3 lines to meet cosig expectations. Text: "sign me\nI'm a\nnote\n",