Skip to content

Commit 6b2211e

Browse files
committed
Implement c2sp.org/tlog-proof
This adds a new struct that represents the newly added tlog-proof C2SP spec. Signed-off-by: Hayden <8418760+Hayden-IO@users.noreply.github.com>
1 parent 0e991b4 commit 6b2211e

2 files changed

Lines changed: 322 additions & 0 deletions

File tree

proof/tlog_proof.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright 2026 Google LLC. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package proof
16+
17+
import (
18+
"bufio"
19+
"bytes"
20+
"crypto/sha256"
21+
"encoding/base64"
22+
"fmt"
23+
"strconv"
24+
"strings"
25+
)
26+
27+
const (
28+
tlogProofHeaderV1 = "c2sp.org/tlog-proof@v1"
29+
)
30+
31+
// TLogProof represents a transparency log proof as described in https://c2sp.org/tlog-proof
32+
type TLogProof struct {
33+
// Index is the index of an entry in the log
34+
Index uint64
35+
// Hashes is the Merkle inclusion proof as described in https://www.rfc-editor.org/rfc/rfc6962.html#section-2.1.1
36+
Hashes [][sha256.Size]byte
37+
// Checkpoint is the signed note as described in https://c2sp.org/tlog-checkpoint
38+
Checkpoint []byte
39+
// ExtraData contains optional application-specific data
40+
ExtraData []byte
41+
}
42+
43+
func (p TLogProof) Marshal() []byte {
44+
var proof bytes.Buffer
45+
fmt.Fprintf(&proof, "%s\n", tlogProofHeaderV1)
46+
if p.ExtraData != nil {
47+
proof.WriteString("extra ")
48+
fmt.Fprintf(&proof, "%s\n", base64.StdEncoding.EncodeToString(p.ExtraData))
49+
}
50+
fmt.Fprintf(&proof, "index %d\n", p.Index)
51+
for _, h := range p.Hashes {
52+
fmt.Fprintf(&proof, "%s\n", base64.StdEncoding.EncodeToString(h[:]))
53+
}
54+
proof.WriteByte('\n')
55+
proof.Write(p.Checkpoint)
56+
return proof.Bytes()
57+
}
58+
59+
func (p *TLogProof) Unmarshal(data []byte) error {
60+
var err error
61+
b := bufio.NewScanner(bytes.NewReader(data))
62+
63+
if b.Scan(); b.Text() != tlogProofHeaderV1 {
64+
return fmt.Errorf("tlog proof missing expected header")
65+
}
66+
67+
// Handle optional extra line
68+
var extra []byte
69+
if b.Scan(); strings.HasPrefix(b.Text(), "extra ") {
70+
e, _ := strings.CutPrefix(b.Text(), "extra ")
71+
extra, err = base64.StdEncoding.DecodeString(e)
72+
if err != nil {
73+
return fmt.Errorf("tlog proof extra data not base64 encoded: %w", err)
74+
}
75+
b.Scan()
76+
}
77+
78+
var idx uint64
79+
idxStr, ok := strings.CutPrefix(b.Text(), "index ")
80+
if !ok {
81+
return fmt.Errorf("tlog proof missing required index")
82+
}
83+
idx, err = strconv.ParseUint(idxStr, 10, 64)
84+
if err != nil {
85+
return fmt.Errorf("tlog proof index not a valid uint64: %w", err)
86+
}
87+
88+
var hashes [][sha256.Size]byte
89+
for b.Scan() {
90+
if b.Text() == "" {
91+
break
92+
}
93+
hash, err := base64.StdEncoding.DecodeString(b.Text())
94+
if err != nil {
95+
return fmt.Errorf("tlog proof hash not base64 encoded: %w", err)
96+
}
97+
if len(hash) != sha256.Size {
98+
return fmt.Errorf("tlog proof hash length was %d, expected %d", len(hash), sha256.Size)
99+
}
100+
hashes = append(hashes, [sha256.Size]byte(hash))
101+
}
102+
103+
var checkpoint bytes.Buffer
104+
for b.Scan() {
105+
checkpoint.Write(b.Bytes())
106+
checkpoint.WriteByte('\n')
107+
}
108+
109+
if err := b.Err(); err != nil {
110+
return fmt.Errorf("scanning tlog proof: %w", err)
111+
}
112+
113+
p.Index = idx
114+
p.Hashes = hashes
115+
p.Checkpoint = checkpoint.Bytes()
116+
p.ExtraData = extra
117+
118+
return nil
119+
}

proof/tlog_proof_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright 2026 Google LLC. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package proof
16+
17+
import (
18+
"bytes"
19+
"crypto/sha256"
20+
"encoding/base64"
21+
"fmt"
22+
"strings"
23+
"testing"
24+
)
25+
26+
func TestMarshal(t *testing.T) {
27+
h1 := sha256.Sum256([]byte("hash1"))
28+
h2 := sha256.Sum256([]byte("hash2"))
29+
h1b64 := base64.StdEncoding.EncodeToString(h1[:])
30+
h2b64 := base64.StdEncoding.EncodeToString(h2[:])
31+
extra := []byte("extra information")
32+
extraB64 := base64.StdEncoding.EncodeToString(extra)
33+
34+
tests := []struct {
35+
name string
36+
proof TLogProof
37+
want string
38+
}{
39+
{
40+
name: "proof without extra data",
41+
proof: TLogProof{
42+
Index: 5,
43+
Hashes: [][sha256.Size]byte{h1, h2},
44+
Checkpoint: []byte("test checkpoint\n"),
45+
},
46+
want: fmt.Sprintf("c2sp.org/tlog-proof@v1\nindex 5\n%s\n%s\n\ntest checkpoint\n", h1b64, h2b64),
47+
},
48+
{
49+
name: "proof with extra data",
50+
proof: TLogProof{
51+
Index: 10,
52+
Hashes: [][sha256.Size]byte{h1},
53+
Checkpoint: []byte("checkpoint data\n"),
54+
ExtraData: extra,
55+
},
56+
want: fmt.Sprintf("c2sp.org/tlog-proof@v1\nextra %s\nindex 10\n%s\n\ncheckpoint data\n", extraB64, h1b64),
57+
},
58+
{
59+
name: "proof with empty hashes",
60+
proof: TLogProof{
61+
Index: 0,
62+
Hashes: [][sha256.Size]byte{},
63+
Checkpoint: []byte("checkpoint\n"),
64+
},
65+
want: "c2sp.org/tlog-proof@v1\nindex 0\n\ncheckpoint\n",
66+
},
67+
}
68+
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
if got := string(tt.proof.Marshal()); got != tt.want {
72+
t.Errorf("Marshal() = %q, want %q", got, tt.want)
73+
}
74+
})
75+
}
76+
}
77+
78+
func TestUnmarshalErrors(t *testing.T) {
79+
tests := []struct {
80+
name string
81+
proof []byte
82+
wantErrSubstr string
83+
}{
84+
{
85+
name: "missing header",
86+
proof: []byte("wrong-header\nindex 0\n\ncheckpoint\n"),
87+
wantErrSubstr: "missing expected header",
88+
},
89+
{
90+
name: "invalid extra data encoding",
91+
proof: []byte("c2sp.org/tlog-proof@v1\nextra !!notbase64!!\nindex 0\n\ncheckpoint\n"),
92+
wantErrSubstr: "extra data not base64 encoded",
93+
},
94+
{
95+
name: "missing index",
96+
proof: []byte("c2sp.org/tlog-proof@v1\n\n\ncheckpoint\n"),
97+
wantErrSubstr: "missing required index",
98+
},
99+
{
100+
name: "invalid index - not a number",
101+
proof: []byte("c2sp.org/tlog-proof@v1\nindex notanumber\n\ncheckpoint\n"),
102+
wantErrSubstr: "not a valid uint64",
103+
},
104+
{
105+
name: "invalid index - negative",
106+
proof: []byte("c2sp.org/tlog-proof@v1\nindex -5\n\ncheckpoint\n"),
107+
wantErrSubstr: "not a valid uint64",
108+
},
109+
{
110+
name: "invalid hash base64",
111+
proof: []byte("c2sp.org/tlog-proof@v1\nindex 0\n!!notbase64!!\n\ncheckpoint\n"),
112+
wantErrSubstr: "hash not base64 encoded",
113+
},
114+
{
115+
name: "incorrect hash length",
116+
proof: []byte("c2sp.org/tlog-proof@v1\nindex 0\n" +
117+
base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\n\ncheckpoint\n"),
118+
wantErrSubstr: "hash length",
119+
},
120+
{
121+
name: "scanner error - buffer too large",
122+
proof: []byte("c2sp.org/tlog-proof@v1\nindex 0\n" + strings.Repeat("a", 65*1024) + "\n"),
123+
wantErrSubstr: "scanning tlog proof",
124+
},
125+
}
126+
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
var p TLogProof
130+
err := p.Unmarshal(tt.proof)
131+
132+
if err == nil {
133+
t.Fatal("expected error but got none")
134+
}
135+
136+
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
137+
t.Errorf("error message doesn't contain %q, got: %v", tt.wantErrSubstr, err)
138+
}
139+
})
140+
}
141+
}
142+
143+
func TestRoundTrip(t *testing.T) {
144+
tests := []struct {
145+
name string
146+
proof TLogProof
147+
}{
148+
{
149+
name: "simple proof",
150+
proof: TLogProof{
151+
Index: 123,
152+
Hashes: [][sha256.Size]byte{sha256.Sum256([]byte("a")), sha256.Sum256([]byte("b"))},
153+
Checkpoint: []byte("some checkpoint\n"),
154+
},
155+
},
156+
{
157+
name: "proof with extra data",
158+
proof: TLogProof{
159+
Index: 456,
160+
Hashes: [][sha256.Size]byte{sha256.Sum256([]byte("c"))},
161+
Checkpoint: []byte("another checkpoint\n"),
162+
ExtraData: []byte("some extra data"),
163+
},
164+
},
165+
{
166+
name: "empty hashes",
167+
proof: TLogProof{
168+
Index: 789,
169+
Hashes: nil,
170+
Checkpoint: []byte("checkpoint\n"),
171+
},
172+
},
173+
}
174+
175+
for _, tt := range tests {
176+
t.Run(tt.name, func(t *testing.T) {
177+
marshaled := tt.proof.Marshal()
178+
179+
var unmarshaled TLogProof
180+
if err := unmarshaled.Unmarshal(marshaled); err != nil {
181+
t.Fatalf("Unmarshal failed: %v", err)
182+
}
183+
184+
if unmarshaled.Index != tt.proof.Index {
185+
t.Errorf("Index mismatch: got %d, want %d", unmarshaled.Index, tt.proof.Index)
186+
}
187+
if !bytes.Equal(unmarshaled.Checkpoint, tt.proof.Checkpoint) {
188+
t.Errorf("Checkpoint mismatch: got %q, want %q", unmarshaled.Checkpoint, tt.proof.Checkpoint)
189+
}
190+
if !bytes.Equal(unmarshaled.ExtraData, tt.proof.ExtraData) {
191+
t.Errorf("ExtraData mismatch: got %q, want %q", unmarshaled.ExtraData, tt.proof.ExtraData)
192+
}
193+
if len(unmarshaled.Hashes) != len(tt.proof.Hashes) {
194+
t.Errorf("Hashes length mismatch: got %d, want %d", len(unmarshaled.Hashes), len(tt.proof.Hashes))
195+
}
196+
for i := range unmarshaled.Hashes {
197+
if unmarshaled.Hashes[i] != tt.proof.Hashes[i] {
198+
t.Errorf("Hash %d mismatch", i)
199+
}
200+
}
201+
})
202+
}
203+
}

0 commit comments

Comments
 (0)