Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions proof/tlog_proof.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright 2026 Google LLC. 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 proof

import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
)

const (
tlogProofHeaderV1 = "c2sp.org/tlog-proof@v1"
)

// TLogProof represents a transparency log proof as described in https://c2sp.org/tlog-proof
type TLogProof struct {
// Index is the index of an entry in the log
Index uint64
// Hashes is the Merkle inclusion proof as described in https://www.rfc-editor.org/rfc/rfc6962.html#section-2.1.1
Hashes [][sha256.Size]byte
// Checkpoint is the signed note as described in https://c2sp.org/tlog-checkpoint
Checkpoint []byte
// ExtraData contains optional application-specific data
ExtraData []byte
}

func (p TLogProof) Marshal() []byte {
var proof bytes.Buffer
fmt.Fprintf(&proof, "%s\n", tlogProofHeaderV1)
if p.ExtraData != nil {
proof.WriteString("extra ")
fmt.Fprintf(&proof, "%s\n", base64.StdEncoding.EncodeToString(p.ExtraData))
}
fmt.Fprintf(&proof, "index %d\n", p.Index)
for _, h := range p.Hashes {
fmt.Fprintf(&proof, "%s\n", base64.StdEncoding.EncodeToString(h[:]))
}
proof.WriteByte('\n')
proof.Write(p.Checkpoint)
return proof.Bytes()
}

func (p *TLogProof) Unmarshal(data []byte) error {
var err error
b := bufio.NewScanner(bytes.NewReader(data))

if b.Scan(); b.Text() != tlogProofHeaderV1 {
return fmt.Errorf("tlog proof missing expected header")
}

// Handle optional extra line
var extra []byte
if b.Scan(); strings.HasPrefix(b.Text(), "extra ") {
e, _ := strings.CutPrefix(b.Text(), "extra ")
extra, err = base64.StdEncoding.DecodeString(e)
if err != nil {
return fmt.Errorf("tlog proof extra data not base64 encoded: %w", err)
}
b.Scan()
}

var idx uint64
idxStr, ok := strings.CutPrefix(b.Text(), "index ")
if !ok {
return fmt.Errorf("tlog proof missing required index")
}
idx, err = strconv.ParseUint(idxStr, 10, 64)
if err != nil {
return fmt.Errorf("tlog proof index not a valid uint64: %w", err)
}

var hashes [][sha256.Size]byte
for b.Scan() {
if b.Text() == "" {
break
}
hash, err := base64.StdEncoding.DecodeString(b.Text())
if err != nil {
return fmt.Errorf("tlog proof hash not base64 encoded: %w", err)
}
if len(hash) != sha256.Size {
return fmt.Errorf("tlog proof hash length was %d, expected %d", len(hash), sha256.Size)
}
hashes = append(hashes, [sha256.Size]byte(hash))
}

var checkpoint bytes.Buffer
for b.Scan() {
checkpoint.Write(b.Bytes())
checkpoint.WriteByte('\n')
}

if err := b.Err(); err != nil {
return fmt.Errorf("scanning tlog proof: %w", err)
}

p.Index = idx
p.Hashes = hashes
p.Checkpoint = checkpoint.Bytes()
p.ExtraData = extra

return nil
}
203 changes: 203 additions & 0 deletions proof/tlog_proof_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright 2026 Google LLC. 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 proof

import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"testing"
)

func TestMarshal(t *testing.T) {
h1 := sha256.Sum256([]byte("hash1"))
h2 := sha256.Sum256([]byte("hash2"))
h1b64 := base64.StdEncoding.EncodeToString(h1[:])
h2b64 := base64.StdEncoding.EncodeToString(h2[:])
extra := []byte("extra information")
extraB64 := base64.StdEncoding.EncodeToString(extra)

tests := []struct {
name string
proof TLogProof
want string
}{
{
name: "proof without extra data",
proof: TLogProof{
Index: 5,
Hashes: [][sha256.Size]byte{h1, h2},
Checkpoint: []byte("test checkpoint\n"),
},
want: fmt.Sprintf("c2sp.org/tlog-proof@v1\nindex 5\n%s\n%s\n\ntest checkpoint\n", h1b64, h2b64),
},
{
name: "proof with extra data",
proof: TLogProof{
Index: 10,
Hashes: [][sha256.Size]byte{h1},
Checkpoint: []byte("checkpoint data\n"),
ExtraData: extra,
},
want: fmt.Sprintf("c2sp.org/tlog-proof@v1\nextra %s\nindex 10\n%s\n\ncheckpoint data\n", extraB64, h1b64),
},
{
name: "proof with empty hashes",
proof: TLogProof{
Index: 0,
Hashes: [][sha256.Size]byte{},
Checkpoint: []byte("checkpoint\n"),
},
want: "c2sp.org/tlog-proof@v1\nindex 0\n\ncheckpoint\n",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := string(tt.proof.Marshal()); got != tt.want {
t.Errorf("Marshal() = %q, want %q", got, tt.want)
}
})
}
}

func TestUnmarshalErrors(t *testing.T) {
tests := []struct {
name string
proof []byte
wantErrSubstr string
}{
{
name: "missing header",
proof: []byte("wrong-header\nindex 0\n\ncheckpoint\n"),
wantErrSubstr: "missing expected header",
},
{
name: "invalid extra data encoding",
proof: []byte("c2sp.org/tlog-proof@v1\nextra !!notbase64!!\nindex 0\n\ncheckpoint\n"),
wantErrSubstr: "extra data not base64 encoded",
},
{
name: "missing index",
proof: []byte("c2sp.org/tlog-proof@v1\n\n\ncheckpoint\n"),
wantErrSubstr: "missing required index",
},
{
name: "invalid index - not a number",
proof: []byte("c2sp.org/tlog-proof@v1\nindex notanumber\n\ncheckpoint\n"),
wantErrSubstr: "not a valid uint64",
},
{
name: "invalid index - negative",
proof: []byte("c2sp.org/tlog-proof@v1\nindex -5\n\ncheckpoint\n"),
wantErrSubstr: "not a valid uint64",
},
{
name: "invalid hash base64",
proof: []byte("c2sp.org/tlog-proof@v1\nindex 0\n!!notbase64!!\n\ncheckpoint\n"),
wantErrSubstr: "hash not base64 encoded",
},
{
name: "incorrect hash length",
proof: []byte("c2sp.org/tlog-proof@v1\nindex 0\n" +
base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\n\ncheckpoint\n"),
wantErrSubstr: "hash length",
},
{
name: "scanner error - buffer too large",
proof: []byte("c2sp.org/tlog-proof@v1\nindex 0\n" + strings.Repeat("a", 65*1024) + "\n"),
wantErrSubstr: "scanning tlog proof",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var p TLogProof
err := p.Unmarshal(tt.proof)

if err == nil {
t.Fatal("expected error but got none")
}

if !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Errorf("error message doesn't contain %q, got: %v", tt.wantErrSubstr, err)
}
})
}
}

func TestRoundTrip(t *testing.T) {
tests := []struct {
name string
proof TLogProof
}{
{
name: "simple proof",
proof: TLogProof{
Index: 123,
Hashes: [][sha256.Size]byte{sha256.Sum256([]byte("a")), sha256.Sum256([]byte("b"))},
Checkpoint: []byte("some checkpoint\n"),
},
},
{
name: "proof with extra data",
proof: TLogProof{
Index: 456,
Hashes: [][sha256.Size]byte{sha256.Sum256([]byte("c"))},
Checkpoint: []byte("another checkpoint\n"),
ExtraData: []byte("some extra data"),
},
},
{
name: "empty hashes",
proof: TLogProof{
Index: 789,
Hashes: nil,
Checkpoint: []byte("checkpoint\n"),
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
marshaled := tt.proof.Marshal()

var unmarshaled TLogProof
if err := unmarshaled.Unmarshal(marshaled); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}

if unmarshaled.Index != tt.proof.Index {
t.Errorf("Index mismatch: got %d, want %d", unmarshaled.Index, tt.proof.Index)
}
if !bytes.Equal(unmarshaled.Checkpoint, tt.proof.Checkpoint) {
t.Errorf("Checkpoint mismatch: got %q, want %q", unmarshaled.Checkpoint, tt.proof.Checkpoint)
}
if !bytes.Equal(unmarshaled.ExtraData, tt.proof.ExtraData) {
t.Errorf("ExtraData mismatch: got %q, want %q", unmarshaled.ExtraData, tt.proof.ExtraData)
}
if len(unmarshaled.Hashes) != len(tt.proof.Hashes) {
t.Errorf("Hashes length mismatch: got %d, want %d", len(unmarshaled.Hashes), len(tt.proof.Hashes))
}
for i := range unmarshaled.Hashes {
if unmarshaled.Hashes[i] != tt.proof.Hashes[i] {
t.Errorf("Hash %d mismatch", i)
}
}
})
}
}