From d9ecc24f8f38df569340dfdeef0973ec5f2b13ee Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Tue, 18 Feb 2025 17:05:12 +0000 Subject: [PATCH 01/16] 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 6db446f03d70de0c103ae774f345227326459490 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Wed, 26 Feb 2025 10:28:08 +0000 Subject: [PATCH 02/16] 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 788f9b42b9abe8f751ef2da8ad8e7b2f58d74190 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Wed, 26 Feb 2025 15:20:23 +0000 Subject: [PATCH 03/16] [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 77402a52273616e72b06f06c8c99f08192d4f413 Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Thu, 13 Mar 2025 17:22:27 +0000 Subject: [PATCH 04/16] 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 464aac35b65c3ce86373b1c9957e54f07a8d064b Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Mon, 17 Mar 2025 14:22:35 +0000 Subject: [PATCH 05/16] 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 00c9d246dead687c9c0c5044e2c54ece66f4eba8 Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Thu, 17 Apr 2025 12:03:25 +0100 Subject: [PATCH 06/16] 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 500c208a87ed3d8bceaf53e52c07659fd2d8e188 Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Mon, 12 May 2025 17:13:51 +0100 Subject: [PATCH 07/16] 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 e0e473e9107769114ac03e46d4698619a1601a94 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 15 May 2025 15:06:24 +0100 Subject: [PATCH 08/16] 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 af1bef36f8fc8386a1425d774d1e289a8b93630b Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Wed, 3 Sep 2025 15:23:48 +0100 Subject: [PATCH 09/16] 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 b1e05598c96e3d5fb95166cf9a79328476098045 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 4 Sep 2025 14:28:21 +0100 Subject: [PATCH 10/16] 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 792f075999ed60c041bbb6df312067ddc9624a21 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 4 Sep 2025 15:06:07 +0100 Subject: [PATCH 11/16] 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 fd0aa91fd4ff8b4bd7a8cefa628977f3348ee9d9 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 4 Sep 2025 16:59:47 +0100 Subject: [PATCH 12/16] 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 e9403c960df7d324dee744098b85339025c44227 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Thu, 9 Oct 2025 10:41:28 +0100 Subject: [PATCH 13/16] 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 9294f4c06f41e328a067af3a65fa1fc6a0f09f28 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 17 Oct 2025 12:17:40 +0100 Subject: [PATCH 14/16] 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 a7891d3606a52c0a8e9c3a12907d7754bb2a2e91 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 12 Dec 2025 13:56:51 +0000 Subject: [PATCH 15/16] 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 1c29105e5e3b3c579246524e2027c442b6e75749 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/16] 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",