Skip to content

Commit cbed770

Browse files
committed
torchwood: add Policy and VerifyCheckpoint
1 parent 277820f commit cbed770

4 files changed

Lines changed: 262 additions & 93 deletions

File tree

checkpoint.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strconv"
1212
"strings"
1313

14+
"golang.org/x/mod/sumdb/note"
1415
"golang.org/x/mod/sumdb/tlog"
1516
)
1617

@@ -34,6 +35,7 @@ type Checkpoint struct {
3435
Extension string
3536
}
3637

38+
// ParseCheckpoint parses a c2sp.org/tlog-checkpoint payload without signatures.
3739
func ParseCheckpoint(text string) (Checkpoint, error) {
3840
// This is an extended version of tlog.ParseTree.
3941

@@ -78,3 +80,42 @@ func (c Checkpoint) String() string {
7880
c.Extension,
7981
)
8082
}
83+
84+
type unverifiedNoteError struct {
85+
err error
86+
n *note.Note
87+
}
88+
89+
func (e *unverifiedNoteError) Error() string {
90+
return fmt.Sprintf("note verification failed: %v", e.err)
91+
}
92+
93+
func (e *unverifiedNoteError) Unwrap() []error {
94+
return []error{e.err, &note.UnverifiedNoteError{Note: e.n}}
95+
}
96+
97+
// VerifyCheckpoint parses and verifies a signed c2sp.org/tlog-checkpoint.
98+
//
99+
// If the note signatures do not satisfy the provided policy, an error wrapping
100+
// *[note.UnverifiedNoteError] is returned.
101+
func VerifyCheckpoint(origin string, policy Policy, signedCheckpoint []byte) (Checkpoint, *note.Note, error) {
102+
// Peek at the origin, if it's wrong, opening will likely fail.
103+
if s, _, _ := strings.Cut(string(signedCheckpoint), "\n"); s != origin {
104+
return Checkpoint{}, nil, fmt.Errorf("checkpoint origin mismatch: got %q, want %q", s, origin)
105+
}
106+
n, err := note.Open(signedCheckpoint, policy)
107+
if err != nil {
108+
return Checkpoint{}, nil, err
109+
}
110+
if err := policy.Check(n.Sigs); err != nil {
111+
return Checkpoint{}, nil, &unverifiedNoteError{err: err, n: n}
112+
}
113+
c, err := ParseCheckpoint(n.Text)
114+
if err != nil {
115+
return Checkpoint{}, nil, err
116+
}
117+
if c.Origin != origin {
118+
return Checkpoint{}, nil, fmt.Errorf("checkpoint origin mismatch: got %q, want %q", c.Origin, origin)
119+
}
120+
return c, n, nil
121+
}

policy.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package torchwood
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
9+
"golang.org/x/mod/sumdb/note"
10+
)
11+
12+
// Policy encodes the requirements for a set of (co)signatures on a [note.Note].
13+
//
14+
// The Verifier method allows the policy to be passed in as the known parameter
15+
// to [note.Open], while the Check method must be applied to [note.Note.Sigs]
16+
// after [note.Open].
17+
type Policy interface {
18+
// Check returns nil if the provided signatures satisfy the policy.
19+
//
20+
// The signatures must already have been verified with their respective
21+
// verifiers, and would usually be obtained from [note.Note.Sigs].
22+
Check(sigs []note.Signature) error
23+
24+
// Verifier implements [note.Verifiers], returning the Verifier for any of
25+
// the cosigners in the policy.
26+
Verifier(name string, hash uint32) (note.Verifier, error)
27+
}
28+
29+
// SingleVerifierPolicy returns a Policy that requires a single verifier to have
30+
// signed the note.
31+
func SingleVerifierPolicy(v note.Verifier) Policy {
32+
return &singleVerifierPolicy{v: v}
33+
}
34+
35+
type singleVerifierPolicy struct {
36+
v note.Verifier
37+
}
38+
39+
func (w *singleVerifierPolicy) Check(sigs []note.Signature) error {
40+
for _, sig := range sigs {
41+
if sig.Name == w.v.Name() && sig.Hash == w.v.KeyHash() {
42+
return nil
43+
}
44+
}
45+
return fmt.Errorf("verifier %q (%08x) did not sign", w.v.Name(), w.v.KeyHash())
46+
}
47+
48+
func (w *singleVerifierPolicy) Verifier(name string, hash uint32) (note.Verifier, error) {
49+
if name == w.v.Name() && hash == w.v.KeyHash() {
50+
return w.v, nil
51+
}
52+
return nil, &note.UnknownVerifierError{Name: name, KeyHash: hash}
53+
}
54+
55+
// ThresholdPolicy returns a Policy that requires at least n of the
56+
// provided policies to be satisfied.
57+
//
58+
// It panics if n is less than zero or greater than the number of polcies.
59+
func ThresholdPolicy(n int, policies ...Policy) Policy {
60+
if n < 0 || n > len(policies) {
61+
panic(fmt.Errorf("threshold of %d outside bounds for policies %s", n, policies))
62+
}
63+
return &thresholdPolicy{
64+
policies: policies,
65+
threshold: n,
66+
}
67+
}
68+
69+
type thresholdPolicy struct {
70+
policies []Policy
71+
threshold int
72+
}
73+
74+
func (w *thresholdPolicy) Check(sigs []note.Signature) error {
75+
satisfied := 0
76+
for _, p := range w.policies {
77+
if err := p.Check(sigs); err == nil {
78+
satisfied++
79+
}
80+
}
81+
if satisfied >= w.threshold {
82+
return nil
83+
}
84+
return fmt.Errorf("only %d/%d required policies satisfied", satisfied, w.threshold)
85+
}
86+
87+
func (w *thresholdPolicy) Verifier(name string, hash uint32) (note.Verifier, error) {
88+
var verifier note.Verifier
89+
for _, p := range w.policies {
90+
v, err := p.Verifier(name, hash)
91+
if _, ok := err.(*note.UnknownVerifierError); ok {
92+
continue
93+
}
94+
if err != nil {
95+
return nil, err
96+
}
97+
if verifier != nil {
98+
// This, for now, requires not having the same verifier in multiple
99+
// groups, which matches the Sigsum policy specification. If we
100+
// change our mind, we will need some way to check the verifiers for
101+
// equality.
102+
return nil, fmt.Errorf("multiple verifiers found for %q (%08x)", name, hash)
103+
}
104+
verifier = v
105+
}
106+
if verifier != nil {
107+
return verifier, nil
108+
}
109+
return nil, &note.UnknownVerifierError{Name: name, KeyHash: hash}
110+
}
111+
112+
// ParsePolicy parses a witness policy from the provided byte slice.
113+
//
114+
// As the policy format doesn't currently support specifying a log vkey, the
115+
// return value would usually be paired with a [note.Verifier] with
116+
// [ThresholdPolicy] and a threshold of 2-of-2. The log origin also needs to be
117+
// passed to [VerifyProof] or [VerifyCheckpoint].
118+
//
119+
// The policy format is EXPERIMENTAL and may change in future releases. It is
120+
// based on [the Sigsum policy format] but it uses vkeys instead of raw public
121+
// keys. It is compatible with Tessera witness policies.
122+
//
123+
// [the Sigsum policy format]: https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md
124+
func ParsePolicy(p []byte) (Policy, error) {
125+
var quorum string
126+
policies := make(map[string]Policy)
127+
for i, line := range strings.Split(string(p), "\n") {
128+
line, _, _ = strings.Cut(line, "#")
129+
if strings.Trim(line, " \t") == "" {
130+
continue
131+
}
132+
switch fields := strings.Fields(line); fields[0] {
133+
case "witness":
134+
if len(fields) < 3 {
135+
return nil, fmt.Errorf("line %d: invalid witness definition: %q", i+1, line)
136+
}
137+
name, vkey := fields[1], fields[2]
138+
if _, ok := policies[name]; ok {
139+
return nil, fmt.Errorf("line %d: duplicate component name: %q", i+1, name)
140+
}
141+
v, err := NewCosignatureVerifier(vkey)
142+
if err != nil {
143+
return nil, fmt.Errorf("line %d: invalid witness vkey %q: %w", i+1, vkey, err)
144+
}
145+
policies[name] = SingleVerifierPolicy(v)
146+
case "group":
147+
if len(fields) < 4 {
148+
return nil, fmt.Errorf("line %d: invalid group definition: %q", i+1, line)
149+
}
150+
name, nStr, children := fields[1], fields[2], fields[3:]
151+
if _, ok := policies[name]; ok {
152+
return nil, fmt.Errorf("line %d: duplicate component name: %q", i+1, name)
153+
}
154+
var n int
155+
switch nStr {
156+
case "any":
157+
n = 1
158+
case "all":
159+
n = len(children)
160+
default:
161+
var err error
162+
n, err = strconv.Atoi(nStr)
163+
if err != nil || n < 1 || n > len(children) {
164+
return nil, fmt.Errorf("line %d: invalid group threshold %q", i+1, nStr)
165+
}
166+
}
167+
c := make([]Policy, 0, len(children))
168+
for _, cn := range children {
169+
child, ok := policies[cn]
170+
if !ok {
171+
return nil, fmt.Errorf("line %d: unknown component %q in group %q definition", i+1, cn, name)
172+
}
173+
c = append(c, child)
174+
}
175+
policies[name] = ThresholdPolicy(n, c...)
176+
case "quorum":
177+
if len(fields) != 2 {
178+
return nil, fmt.Errorf("line %d: invalid quorum definition: %q", i+1, line)
179+
}
180+
if quorum != "" {
181+
return nil, fmt.Errorf("line %d: multiple quorum definitions", i+1)
182+
}
183+
quorum = fields[1]
184+
default:
185+
return nil, fmt.Errorf("line %d: unknown keyword: %q", i+1, fields[0])
186+
}
187+
}
188+
switch quorum {
189+
case "":
190+
return nil, errors.New("no quorum defined in policy")
191+
case "none":
192+
return ThresholdPolicy(0), nil
193+
default:
194+
policy, ok := policies[quorum]
195+
if !ok {
196+
return nil, fmt.Errorf("quorum %q not defined in policy", quorum)
197+
}
198+
return policy, nil
199+
}
200+
}

spicy.go

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"strconv"
99
"strings"
1010

11-
"golang.org/x/mod/sumdb/note"
1211
"golang.org/x/mod/sumdb/tlog"
1312
)
1413

@@ -60,11 +59,11 @@ func (e *VerifyRecordError) Error() string {
6059
// c2sp.org/tlog-sig@v1) for a record hash rh (generally produced with
6160
// [tlog.RecordHash]).
6261
//
63-
// The origin must match the log's origin, and the open function will be used to
64-
// verify the signed checkpoint included in the proof. If open returns an error,
65-
// it is returned directly. If the proof is valid but does not verify the record
66-
// hash rh at the given index, a *[VerifyRecordError] is returned.
67-
func VerifyProof(origin string, open func([]byte) (*note.Note, error), rh tlog.Hash, proof []byte) error {
62+
// If the note signatures do not satisfy the provided policy, an error wrapping
63+
// *[note.UnverifiedNoteError] is returned. If the proof is valid but does not
64+
// verify the record hash rh at the given index, a *[VerifyRecordError] is
65+
// returned.
66+
func VerifyProof(origin string, policy Policy, rh tlog.Hash, proof []byte) error {
6867
hdr, rest, ok := strings.Cut(string(proof), "\n")
6968
if !ok || hdr != "c2sp.org/tlog-sig@v1" {
7069
return errors.New("malformed tlog proof: missing header, this may not be a tlog proof")
@@ -116,21 +115,10 @@ func VerifyProof(origin string, open func([]byte) (*note.Note, error), rh tlog.H
116115
}
117116
p = append(p, tlog.Hash(h))
118117
}
119-
// Peek at the origin, if it's wrong, opening will likely fail.
120-
if s, _, _ := strings.Cut(rest, "\n"); s != origin {
121-
return fmt.Errorf("proof origin mismatch: got %q, want %q", s, origin)
122-
}
123-
n, err := open([]byte(rest))
118+
c, _, err := VerifyCheckpoint(origin, policy, []byte(rest))
124119
if err != nil {
125120
return err
126121
}
127-
c, err := ParseCheckpoint(n.Text)
128-
if err != nil {
129-
return fmt.Errorf("invalid checkpoint in proof: %w", err)
130-
}
131-
if c.Origin != origin {
132-
return fmt.Errorf("checkpoint origin mismatch: got %q, want %q", c.Origin, origin)
133-
}
134122
if err := tlog.CheckRecord(p, c.N, c.Hash, idx, rh); err != nil {
135123
return &VerifyRecordError{
136124
Index: idx,

0 commit comments

Comments
 (0)