rfc6962: implement Merkle leaf hashes and inclusion proof verification

Add LeafHash (SHA-256 of 0x00 || leaf per RFC 6962 §2), interior node
hashing, VerifyInclusion implementing the §2.1.2 audit-path algorithm,
and a ProofByHash client method for get-proof-by-hash (query properly
URL-escaped for base64 '+' and '/' bytes).

This is the machinery needed to bind fetched entries to a verified
signed tree head; the tailer wires it up in the next change.

Tests build a reference tree recursively (independent of the iterative
verifier) and cross-check every index for sizes 1..33, plus rejection
of wrong root, tampered/truncated paths, wrong index, foreign leaf.
This commit is contained in:
lakshit verma 2026-08-24 01:02:59 +05:30
parent 74888a20a9
commit dee4564656
No known key found for this signature in database
3 changed files with 207 additions and 0 deletions

View file

@ -15,6 +15,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@ -114,6 +115,24 @@ func (c *Client) Entries(ctx context.Context, start, end int64) ([]LeafEntry, er
return out.Entries, nil
}
type InclusionProof struct {
LeafIndex int64 `json:"leaf_index"`
AuditPath [][]byte `json:"audit_path"`
}
// ProofByHash fetches get-proof-by-hash for leafHash at the given tree
// size. Verify the result with VerifyInclusion against a signed root.
func (c *Client) ProofByHash(ctx context.Context, leafHash []byte, treeSize int64) (*InclusionProof, error) {
q := url.Values{}
q.Set("hash", base64.StdEncoding.EncodeToString(leafHash))
q.Set("tree_size", strconv.FormatInt(treeSize, 10))
var out InclusionProof
if err := c.getJSON(ctx, "/ct/v1/get-proof-by-hash?"+q.Encode(), &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) getJSON(ctx context.Context, path string, v any) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.BaseURL+path, nil)
if err != nil {

View file

@ -0,0 +1,73 @@
package rfc6962
import (
"crypto/sha256"
"fmt"
)
// LeafHash returns the RFC 6962 §2 hash of a single leaf:
// SHA-256(0x00 || leaf). Entries are committed to a log's signed root
// hash under exactly this identity.
func LeafHash(leaf []byte) []byte {
h := sha256.New()
h.Write([]byte{0x00})
h.Write(leaf)
return h.Sum(nil)
}
// nodeHash returns SHA-256(0x01 || l || r), the hash of an interior node.
func nodeHash(l, r []byte) []byte {
h := sha256.New()
h.Write([]byte{0x01})
h.Write(l)
h.Write(r)
return h.Sum(nil)
}
// VerifyInclusion checks an audit path (the body of get-proof-by-hash)
// against a signed root: that leafHash sits at leafIndex in a tree of
// treeSize leaves and hashes up to root, per RFC 6962 §2.1.2. The
// verification is computed locally, so neither the log nor a man in the
// middle can forge it for data the root does not commit to.
func VerifyInclusion(leafHash []byte, leafIndex, treeSize int64, auditPath [][]byte, root []byte) error {
if len(leafHash) != sha256.Size {
return fmt.Errorf("leaf hash has %d bytes; want 32", len(leafHash))
}
if len(root) != sha256.Size {
return fmt.Errorf("root hash has %d bytes; want 32", len(root))
}
if treeSize <= 0 {
return fmt.Errorf("tree size %d must be positive", treeSize)
}
if leafIndex < 0 || leafIndex >= treeSize {
return fmt.Errorf("leaf index %d out of range for tree size %d", leafIndex, treeSize)
}
fn := leafIndex
sn := treeSize - 1
r := leafHash
for _, p := range auditPath {
if len(p) != sha256.Size {
return fmt.Errorf("audit path node has %d bytes; want 32", len(p))
}
if fn&1 == 1 || fn == sn {
r = nodeHash(p, r)
if fn&1 == 0 {
for fn != 0 && fn&1 == 0 {
fn >>= 1
sn >>= 1
}
}
} else {
r = nodeHash(r, p)
}
fn >>= 1
sn >>= 1
}
if sn != 0 {
return fmt.Errorf("audit path too short for tree size %d", treeSize)
}
if string(r) != string(root) {
return fmt.Errorf("computed root does not match signed root")
}
return nil
}

View file

@ -0,0 +1,115 @@
package rfc6962
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"testing"
)
// rootOf and pathOf are a reference RFC 6962 §2 Merkle tree built
// recursively, deliberately independent of the iterative verifier in
// merkle.go so the two must agree.
func rootOf(leaves [][]byte) []byte {
if len(leaves) == 0 {
h := sha256.Sum256(nil)
return h[:]
}
if len(leaves) == 1 {
return LeafHash(leaves[0])
}
k := 1
for k*2 < len(leaves) {
k *= 2
}
return nodeHash(rootOf(leaves[:k]), rootOf(leaves[k:]))
}
func pathOf(leaves [][]byte, idx int) [][]byte {
if len(leaves) <= 1 {
return nil
}
k := 1
for k*2 < len(leaves) {
k *= 2
}
if idx < k {
return append(pathOf(leaves[:k], idx), rootOf(leaves[k:]))
}
return append(pathOf(leaves[k:], idx-k), rootOf(leaves[:k]))
}
func randomLeaves(n int) [][]byte {
leaves := make([][]byte, n)
for i := range leaves {
b := make([]byte, 40)
rand.Read(b)
leaves[i] = b
}
return leaves
}
func TestVerifyInclusionAgainstReferenceTree(t *testing.T) {
// 1..33 crosses every power-of-two boundary up to 32.
for n := 1; n <= 33; n++ {
leaves := randomLeaves(n)
root := rootOf(leaves)
for i := 0; i < n; i++ {
lh := LeafHash(leaves[i])
if err := VerifyInclusion(lh, int64(i), int64(n), pathOf(leaves, i), root); err != nil {
t.Fatalf("n=%d i=%d: %v", n, i, err)
}
}
}
}
func TestVerifyInclusionRejectsTampering(t *testing.T) {
n := 8
leaves := randomLeaves(n)
root := rootOf(leaves)
idx := 3
lh := LeafHash(leaves[idx])
path := pathOf(leaves, idx)
if err := VerifyInclusion(lh, int64(idx), int64(n), path, root); err != nil {
t.Fatal(err)
}
flip := func(b []byte) []byte {
out := bytes.Clone(b)
out[0] ^= 1
return out
}
if err := VerifyInclusion(lh, int64(idx), int64(n), path, flip(root)); err == nil {
t.Fatal("wrong root accepted")
}
tamperedPath := make([][]byte, len(path))
copy(tamperedPath, path)
tamperedPath[1] = flip(tamperedPath[1])
if err := VerifyInclusion(lh, int64(idx), int64(n), tamperedPath, root); err == nil {
t.Fatal("tampered audit path accepted")
}
if err := VerifyInclusion(lh, int64(idx+1), int64(n), path, root); err == nil {
t.Fatal("wrong leaf index accepted")
}
if err := VerifyInclusion(lh, int64(idx), int64(n), path[:1], root); err == nil {
t.Fatal("truncated audit path accepted")
}
if err := VerifyInclusion(lh, int64(n), int64(n), path, root); err == nil {
t.Fatal("out-of-range leaf index accepted")
}
if err := VerifyInclusion(lh, int64(idx), 0, path, root); err == nil {
t.Fatal("zero tree size accepted")
}
// A leaf that is not in the tree, even with the honest path.
foreign := LeafHash([]byte("not in this tree"))
if err := VerifyInclusion(foreign, int64(idx), int64(n), path, root); err == nil {
t.Fatal("foreign leaf hash accepted")
}
}