Repository: celestiaorg/nmt
Pull request #180 (2023-04-21 23:20:13)

feat!: rejects invalid empty range proofs
## Overview
Resolves https://github.com/celestiaorg/nmt/issues/164 

## Checklist

- [x] New and updated code has appropriate documentation
- [x] New and updated code has new and/or updated testing
- [x] Required CI checks are passing
- [x] Visual proof for any user facing features like CLI or documentation updates
- [x] Linked issues closed with keywords



Patch:
diff --git a/proof.go b/proof.go
index 32d8480..3e854af 100644
--- a/proof.go
+++ b/proof.go
@@ -106,6 +106,11 @@ func NewAbsenceProof(proofStart, proofEnd int, proofNodes [][]byte, leafHash []b
 	return Proof{proofStart, proofEnd, proofNodes, leafHash, ignoreMaxNamespace}
 }
 
+// IsEmptyProof checks whether the proof corresponds to an empty proof as defined in NMT specifications https://github.com/celestiaorg/nmt/blob/master/docs/spec/nmt.md.
+func (proof Proof) IsEmptyProof() bool {
+	return proof.start == proof.end && len(proof.nodes) == 0
+}
+
 // VerifyNamespace verifies a whole namespace, i.e. 1) it verifies inclusion of
 // the provided `data` in the tree (or the proof.leafHash in case of absence
 // proof) 2) it verifies that the namespace is complete i.e., the data items
@@ -150,18 +155,26 @@ func (proof Proof) VerifyNamespace(h hash.Hash, nID namespace.ID, leaves [][]byt
 	}
 
 	isEmptyRange := proof.start == proof.end
-	if len(leaves) == 0 && isEmptyRange && len(proof.nodes) == 0 {
-		// empty proofs are always rejected unless nID is outside the range of
-		// namespaces covered by the root we special case the empty root, since
-		// it purports to cover the zero namespace but does not actually include
-		// any such nodes
-		min := namespace.ID(MinNamespace(root, nIDLen))
-		max := namespace.ID(MaxNamespace(root, nIDLen))
-		if nID.Less(min) || max.Less(nID) || bytes.Equal(root, nth.EmptyRoot()) {
-			return true
+	if isEmptyRange {
+		if proof.IsEmptyProof() && len(leaves) == 0 {
+			rootMin := namespace.ID(MinNamespace(root, nIDLen))
+			rootMax := namespace.ID(MaxNamespace(root, nIDLen))
+			// empty proofs are always rejected unless 1) nID is outside the range of
+			// namespaces covered by the root 2) the root represents an empty tree, since
+			// it purports to cover the zero namespace but does not actually include
+			// any such nodes
+			if nID.Less(rootMin) || rootMax.Less(nID) {
+				return true
+			}
+			if bytes.Equal(root, nth.EmptyRoot()) {
+				return true
+			}
+			return false
 		}
+		// the proof range is empty, and invalid
 		return false
 	}
+
 	gotLeafHashes := make([][]byte, 0, len(leaves))
 	if proof.IsOfAbsence() {
 		gotLeafHashes = append(gotLeafHashes, proof.leafHash)


Test patch:
diff --git a/proof_test.go b/proof_test.go
index 6cd3f0e..c48c36b 100644
--- a/proof_test.go
+++ b/proof_test.go
@@ -13,6 +13,76 @@ import (
 	"github.com/celestiaorg/nmt/namespace"
 )
 
+// TestVerifyNamespace_EmptyProof tests the correct behaviour of VerifyNamespace for valid and invalid empty proofs.
+func TestVerifyNamespace_EmptyProof(t *testing.T) {
+	// create a tree with 4 leaves
+	nIDSize := 1
+	tree := exampleNMT(nIDSize, 1, 2, 3, 4)
+	root, err := tree.Root()
+	require.NoError(t, err)
+
+	// build a proof for an NID that is outside the namespace range of the tree
+	// start = end = 0, nodes = empty
+	nID0 := []byte{0}
+	validEmptyProofZeroRange, err := tree.ProveNamespace(nID0)
+	require.NoError(t, err)
+
+	// build a proof for an NID that is outside the namespace range of the tree
+	// start = end = 1, nodes = nil
+	validEmptyProofNonZeroRange, err := tree.ProveNamespace(nID0)
+	require.NoError(t, err)
+	// modify the proof range to be non-zero, it should still be valid
+	validEmptyProofNonZeroRange.start = 1
+	validEmptyProofNonZeroRange.end = 1
+
+	// build a proof for an NID that is within the namespace range of the tree
+	// start = end = 0, nodes = non-empty
+	nID1 := []byte{1}
+	zeroRangeOnlyProof, err := tree.ProveNamespace(nID1)
+	require.NoError(t, err)
+	// modify the proof to contain a zero range
+	zeroRangeOnlyProof.start = 0
+	zeroRangeOnlyProof.end = 0
+
+	// build a proof for an NID that is within the namespace range of the tree
+	// start = 0, end = 1, nodes = empty
+	emptyNodesOnlyProof, err := tree.ProveNamespace(nID1)
+	require.NoError(t, err)
+	// modify the proof nodes to be empty
+	emptyNodesOnlyProof.nodes = [][]byte{}
+
+	hasher := sha256.New()
+	type args struct {
+		proof  Proof
+		hasher hash.Hash
+		nID    namespace.ID
+		leaves [][]byte
+		root   []byte
+	}
+
+	tests := []struct {
+		name              string
+		args              args
+		want              bool
+		isValidEmptyProof bool
+	}{
+		{"valid empty proof  with (start == end) == 0 and empty leaves", args{validEmptyProofZeroRange, hasher, nID0, [][]byte{}, root}, true, true},
+		{"valid empty proof with (start == end) != 0 and empty leaves", args{validEmptyProofNonZeroRange, hasher, nID0, [][]byte{}, root}, true, true},
+		{"valid empty proof  with (start == end) == 0 and non-empty leaves", args{validEmptyProofZeroRange, hasher, nID0, [][]byte{{1}}, root}, false, true},
+		{"valid empty proof with (start == end) != 0 and non-empty leaves", args{validEmptyProofNonZeroRange, hasher, nID0, [][]byte{{1}}, root}, false, true},
+		{"invalid empty proof: start == end == 0, nodes == non-empty", args{zeroRangeOnlyProof, hasher, nID1, [][]byte{}, root}, false, false},
+		{"invalid empty proof:  start == 0, end == 1, nodes == empty", args{emptyNodesOnlyProof, hasher, nID1, [][]byte{}, root}, false, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require.True(t, tt.args.proof.IsEmptyProof() == tt.isValidEmptyProof)
+			if got := tt.args.proof.VerifyNamespace(tt.args.hasher, tt.args.nID, tt.args.leaves, tt.args.root); got != tt.want {
+				t.Errorf("VerifyNamespace() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestProof_VerifyNamespace_False(t *testing.T) {
 	const testNidLen = 3