mirror of
https://github.com/vee1e/subidx.git
synced 2026-09-01 09:50:18 +00:00
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:
parent
74888a20a9
commit
dee4564656
3 changed files with 207 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
73
internal/rfc6962/merkle.go
Normal file
73
internal/rfc6962/merkle.go
Normal 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
|
||||
}
|
||||
115
internal/rfc6962/merkle_test.go
Normal file
115
internal/rfc6962/merkle_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue