Skip to content

Commit c1593dd

Browse files
committed
torchwood: add CosignatureSigner.SignSubtree and CosignatureVerifier.VerifySubtree
1 parent ef4e662 commit c1593dd

3 files changed

Lines changed: 229 additions & 17 deletions

File tree

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
- Added ML-DSA-44 cosignature support.
1010

11+
- Added `CosignatureSigner.SignSubtree` and `CosignatureVerifier.VerifySubtree`
12+
to sign and verify subtree cosignatures.
13+
1114
- Added `NewCosignatureVerifierFromKey` to produce a verifier from a
1215
`crypto.PublicKey` instead of a vkey.
1316

cosignature.go

Lines changed: 119 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
"golang.org/x/crypto/cryptobyte"
2525
"golang.org/x/mod/sumdb/note"
26+
"golang.org/x/mod/sumdb/tlog"
2627
)
2728

2829
const (
@@ -40,7 +41,7 @@ func NewCosignatureSigner(name string, key crypto.Signer) (*CosignatureSigner, e
4041
if err != nil {
4142
return nil, err
4243
}
43-
s := &CosignatureSigner{v: *v}
44+
s := &CosignatureSigner{v: *v, key: key}
4445
switch pubKey.(type) {
4546
case ed25519.PublicKey:
4647
s.sign = func(msg []byte) ([]byte, error) {
@@ -104,18 +105,6 @@ func formatCosignatureV1(t uint64, msg []byte) ([]byte, error) {
104105
}
105106

106107
func formatSubtreeV1(name string, t uint64, msg []byte) ([]byte, error) {
107-
// The signed message is in the following format
108-
//
109-
// struct {
110-
// uint8 label[12] = "subtree/v1\n\0";
111-
// opaque cosigner_name<1..2^8-1>;
112-
// uint64 timestamp;
113-
// opaque log_origin<1..2^8-1>;
114-
// uint64 start;
115-
// uint64 end;
116-
// uint8 hash[32];
117-
// } cosigned_message;
118-
119108
c, err := ParseCheckpoint(string(msg))
120109
if err != nil {
121110
return nil, fmt.Errorf("message being signed is not a valid checkpoint: %w", err)
@@ -128,18 +117,46 @@ func formatSubtreeV1(name string, t uint64, msg []byte) ([]byte, error) {
128117
if string(msg) != c.String() {
129118
return nil, errors.New("message being signed does not match parsed checkpoint")
130119
}
120+
return subtreeCosignedMessage(name, t, c.Origin, 0, c.N, c.Hash)
121+
}
122+
123+
func subtreeCosignedMessage(name string, t uint64, origin string, start, end int64, hash tlog.Hash) ([]byte, error) {
124+
// The signed message is in the following format
125+
//
126+
// struct {
127+
// uint8 label[12] = "subtree/v1\n\0";
128+
// opaque cosigner_name<1..2^8-1>;
129+
// uint64 timestamp;
130+
// opaque log_origin<1..2^8-1>;
131+
// uint64 start;
132+
// uint64 end;
133+
// uint8 hash[32];
134+
// } cosigned_message;
135+
131136
b := &cryptobyte.Builder{}
132137
b.AddBytes([]byte("subtree/v1\n\x00"))
138+
if len(name) == 0 || len(name) > 255 {
139+
return nil, errors.New("cosigner name must be 1-255 bytes")
140+
}
133141
b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) {
134142
b.AddBytes([]byte(name))
135143
})
144+
if t > math.MaxInt64 {
145+
return nil, errors.New("timestamp is too large")
146+
}
147+
if t != 0 && start != 0 {
148+
return nil, errors.New("timestamp must be zero for non-root subtrees")
149+
}
136150
b.AddUint64(t)
151+
if len(origin) == 0 || len(origin) > 255 {
152+
return nil, errors.New("log origin must be 1-255 bytes")
153+
}
137154
b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) {
138-
b.AddBytes([]byte(c.Origin))
155+
b.AddBytes([]byte(origin))
139156
})
140-
b.AddUint64(0)
141-
b.AddUint64(uint64(c.N))
142-
b.AddBytes(c.Hash[:])
157+
b.AddUint64(uint64(start))
158+
b.AddUint64(uint64(end))
159+
b.AddBytes(hash[:])
143160
return b.Bytes()
144161
}
145162

@@ -148,6 +165,7 @@ func formatSubtreeV1(name string, t uint64, msg []byte) ([]byte, error) {
148165
type CosignatureSigner struct {
149166
v CosignatureVerifier
150167
sign func([]byte) ([]byte, error)
168+
key crypto.Signer
151169
}
152170

153171
func (s *CosignatureSigner) Name() string { return s.v.Name() }
@@ -157,6 +175,37 @@ func (s *CosignatureSigner) Verifier() *CosignatureVerifier { return &s.v }
157175

158176
var _ note.Signer = &CosignatureSigner{}
159177

178+
// SignSubtree signs a subtree [start, end) with the given hash for the log with
179+
// the given origin. The timestamp is set to zero. The returned signature is in
180+
// the format of a note signature, starting with the — and ending with a newline.
181+
func (s *CosignatureSigner) SignSubtree(origin string, start, end int64, hash tlog.Hash) ([]byte, error) {
182+
if _, ok := s.v.PublicKey().(*mldsa.PublicKey); !ok {
183+
return nil, errors.New("subtree signatures are only supported for ML-DSA-44 keys")
184+
}
185+
if !ValidSubtree(start, end) {
186+
return nil, errors.New("invalid subtree")
187+
}
188+
189+
m, err := subtreeCosignedMessage(s.Name(), 0, origin, start, end, hash)
190+
if err != nil {
191+
return nil, err
192+
}
193+
194+
ss, err := s.key.Sign(nil, m, crypto.Hash(0))
195+
if err != nil {
196+
return nil, err
197+
}
198+
199+
// key hash || timestamp || signature.
200+
sig := make([]byte, 0, 4+8+mldsa.MLDSA44SignatureSize)
201+
sig = binary.BigEndian.AppendUint32(sig, s.KeyHash())
202+
sig = binary.BigEndian.AppendUint64(sig, 0)
203+
sig = append(sig, ss...)
204+
205+
res := "— " + s.Name() + " " + base64.StdEncoding.EncodeToString(sig) + "\n"
206+
return []byte(res), nil
207+
}
208+
160209
// CosignatureVerifier is a [note.Verifier] that verifies cosignatures
161210
// according to c2sp.org/tlog-cosignature.
162211
type CosignatureVerifier struct {
@@ -281,6 +330,59 @@ func (v *CosignatureVerifier) PublicKey() crypto.PublicKey {
281330
return v.key
282331
}
283332

333+
// VerifySubtree reports whether signature is a valid cosignature by this
334+
// verifier over the subtree [start, end) with the given hash for the log with
335+
// the given origin.
336+
//
337+
// signature must be a single note signature line ending in a newline, like the
338+
// one returned by [CosignatureSigner.SignSubtree], and its key name and hash
339+
// must match this verifier.
340+
//
341+
// Note that a checkpoint cosignature is a valid cosignature over the equivalent
342+
// subtree, and this method allows non-zero timestamps for root subtrees.
343+
func (v *CosignatureVerifier) VerifySubtree(origin string, start, end int64, hash tlog.Hash, signature []byte) bool {
344+
k, ok := v.key.(*mldsa.PublicKey)
345+
if !ok {
346+
return false
347+
}
348+
if !ValidSubtree(start, end) {
349+
return false
350+
}
351+
352+
line, ok := strings.CutSuffix(string(signature), "\n")
353+
if !ok {
354+
return false
355+
}
356+
line, ok = strings.CutPrefix(line, "— ")
357+
if !ok {
358+
return false
359+
}
360+
name, b64, _ := strings.Cut(line, " ")
361+
sig, err := base64.StdEncoding.DecodeString(b64)
362+
if err != nil || b64 == "" || len(sig) < 4 {
363+
return false
364+
}
365+
if name != v.name || binary.BigEndian.Uint32(sig) != v.hash {
366+
return false
367+
}
368+
sig = sig[4:]
369+
370+
if len(sig) != 8+mldsa.MLDSA44SignatureSize {
371+
return false
372+
}
373+
t := binary.BigEndian.Uint64(sig)
374+
sig = sig[8:]
375+
// If start is not zero, the timestamp must be zero.
376+
if t > math.MaxInt64 || (start != 0 && t != 0) {
377+
return false
378+
}
379+
m, err := subtreeCosignedMessage(v.name, t, origin, start, end, hash)
380+
if err != nil {
381+
return false
382+
}
383+
return mldsa.Verify(k, m, sig, nil) == nil
384+
}
385+
284386
// String returns the vkey encoding of the verifier, according to
285387
// c2sp.org/signed-note.
286388
func (v *CosignatureVerifier) String() string {

cosignature_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import (
55
"crypto"
66
"crypto/ed25519"
77
"crypto/rand"
8+
"encoding/base64"
9+
"strings"
810
"testing"
911

1012
"filippo.io/mldsa"
1113
"filippo.io/torchwood"
1214
"golang.org/x/mod/sumdb/note"
15+
"golang.org/x/mod/sumdb/tlog"
1316
)
1417

1518
func TestSignerRoundtrip(t *testing.T) {
@@ -29,6 +32,110 @@ func TestSignerRoundtrip(t *testing.T) {
2932
})
3033
}
3134

35+
func TestSubtreeRoundtrip(t *testing.T) {
36+
k, err := mldsa.GenerateKey(mldsa.MLDSA44())
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
s, err := torchwood.NewCosignatureSigner("witness.example/w1", k)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
v := s.Verifier()
45+
46+
th := tlog.RecordHash([]byte("test"))
47+
sig, err := s.SignSubtree("example.com/log", 8, 13, th)
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
52+
if !v.VerifySubtree("example.com/log", 8, 13, th, sig) {
53+
t.Fatal("signature did not verify")
54+
}
55+
56+
if v.VerifySubtree("example.com/other", 8, 13, th, sig) {
57+
t.Fatal("expected failure verifying wrong origin")
58+
}
59+
if v.VerifySubtree("example.com/log", 8, 12, th, sig) {
60+
t.Fatal("expected failure verifying wrong range")
61+
}
62+
if v.VerifySubtree("example.com/log", 4, 12, th, sig) {
63+
t.Fatal("expected failure verifying invalid subtree")
64+
}
65+
th2 := tlog.RecordHash([]byte("test2"))
66+
if v.VerifySubtree("example.com/log", 8, 13, th2, sig) {
67+
t.Fatal("expected failure verifying wrong hash")
68+
}
69+
70+
// A tampered signature doesn't verify.
71+
line, ok := strings.CutPrefix(strings.TrimSuffix(string(sig), "\n"), "— witness.example/w1 ")
72+
if !ok {
73+
t.Fatalf("unexpected signature line: %q", sig)
74+
}
75+
raw, err := base64.StdEncoding.DecodeString(line)
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
raw[len(raw)-1] ^= 1
80+
tampered := []byte("— witness.example/w1 " + base64.StdEncoding.EncodeToString(raw) + "\n")
81+
if v.VerifySubtree("example.com/log", 8, 13, th, tampered) {
82+
t.Fatal("expected failure verifying tampered signature")
83+
}
84+
85+
// A signature line from another key doesn't verify, and multi-line
86+
// inputs are rejected.
87+
k2, err := mldsa.GenerateKey(mldsa.MLDSA44())
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
s2, err := torchwood.NewCosignatureSigner("witness.example/w2", k2)
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
sig2, err := s2.SignSubtree("example.com/log", 8, 13, th)
96+
if err != nil {
97+
t.Fatal(err)
98+
}
99+
if v.VerifySubtree("example.com/log", 8, 13, th, sig2) {
100+
t.Fatal("expected failure verifying another witness's signature")
101+
}
102+
both := append(append([]byte{}, sig2...), sig...)
103+
if v.VerifySubtree("example.com/log", 8, 13, th, both) {
104+
t.Fatal("expected failure verifying multi-line input")
105+
}
106+
107+
// Ed25519 keys can't sign or verify subtrees.
108+
_, ek, err := ed25519.GenerateKey(rand.Reader)
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
es, err := torchwood.NewCosignatureSigner("witness.example/ed", ek)
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
if _, err := es.SignSubtree("example.com/log", 8, 13, th); err == nil {
117+
t.Fatal("expected error signing subtree with Ed25519 key")
118+
}
119+
if es.Verifier().VerifySubtree("example.com/log", 8, 13, th, sig) {
120+
t.Fatal("expected failure verifying with Ed25519 key")
121+
}
122+
123+
// An ML-DSA checkpoint cosignature verifies as a cosignature over the
124+
// whole tree.
125+
checkpoint := "example.com/log\n123\n" + base64.StdEncoding.EncodeToString(th[:]) + "\n"
126+
n, err := note.Sign(&note.Note{Text: checkpoint}, s)
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
_, checkpointSig, ok := strings.Cut(string(n), "\n\n")
131+
if !ok {
132+
t.Fatalf("unexpected note: %q", n)
133+
}
134+
if !v.VerifySubtree("example.com/log", 0, 123, th, []byte(checkpointSig)) {
135+
t.Fatal("checkpoint cosignature did not verify as subtree cosignature")
136+
}
137+
}
138+
32139
func testSignerRoundtrip(t *testing.T, k crypto.Signer, extensions bool) {
33140
s, err := torchwood.NewCosignatureSigner("example.com", k)
34141
if err != nil {

0 commit comments

Comments
 (0)