From dee45646567a3c40cd021409e09e233333ea26da Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Mon, 24 Aug 2026 01:02:59 +0530 Subject: [PATCH] rfc6962: implement Merkle leaf hashes and inclusion proof verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/rfc6962/client.go | 19 ++++++ internal/rfc6962/merkle.go | 73 ++++++++++++++++++++ internal/rfc6962/merkle_test.go | 115 ++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 internal/rfc6962/merkle.go create mode 100644 internal/rfc6962/merkle_test.go diff --git a/internal/rfc6962/client.go b/internal/rfc6962/client.go index 76e09fa..0583d97 100644 --- a/internal/rfc6962/client.go +++ b/internal/rfc6962/client.go @@ -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 { diff --git a/internal/rfc6962/merkle.go b/internal/rfc6962/merkle.go new file mode 100644 index 0000000..7cd64b5 --- /dev/null +++ b/internal/rfc6962/merkle.go @@ -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 +} diff --git a/internal/rfc6962/merkle_test.go b/internal/rfc6962/merkle_test.go new file mode 100644 index 0000000..9fff572 --- /dev/null +++ b/internal/rfc6962/merkle_test.go @@ -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") + } +}