merkle 0.3.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce4faaabf24a85b734bd6cfe35081dfcc897bf418c5b391e68a84f8ec0aa9977
4
- data.tar.gz: 944144e97463b2d0ad1ff7b2a882210548786035f52fa0090d505bbad7b9f88e
3
+ metadata.gz: 9b9d4e8996bfda0b7e8baa3c2324f51222503f658fc43c066819058ef028bdc7
4
+ data.tar.gz: 107526e231fb5925e181c7747f8130489278684ecfd37355ccc869adbcdc6d97
5
5
  SHA512:
6
- metadata.gz: 49988d95199cf3e6a5e5944f69205d6b354ed4e5d811325c4e6ca8d4a868044b6c3dfc380491d1aa9c577ca7848770ea3c7e56d9212a1bb2a60cbc5c330a0060
7
- data.tar.gz: 107fc3472cca81407b6fbc97a0981711f5538cdb2e657a032911d7866021fa37a4a30ac00ce5bcbebe2585849f700fc01c892c41670f619150d809c578fbb007
6
+ metadata.gz: 1467fc1c5715adfc418cc3ae5a44dab2c6dcad7b32f10d6fe2cd9eb264bace3d22d7f0f544cb87827048c67b6644a142c2b0754b5faaef2556966cf4493e515f
7
+ data.tar.gz: eb7621a6e1d771a96a219dcb283f379ace0c5528bb6ea5d8b1c08263b8e6f3f52166715848c35736aee449a1d6cbea890441a3c4f33861f7857a5ac664917c7b
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- ruby-3.4.5
1
+ ruby-4.0.0
data/README.md CHANGED
@@ -33,7 +33,12 @@ Or install it yourself as:
33
33
  require 'merkle'
34
34
 
35
35
  # Create configuration
36
- config = Merkle::Config.new(hash_type: :sha256)
36
+ # element_encoding says how the elements passed to .from_elements are read:
37
+ # :hex - elements are hex strings and are decoded before hashing
38
+ # :binary - elements are byte strings and are hashed as-is
39
+ # :auto - legacy mode, see "Upgrading from 0.4.0 and earlier"
40
+ # It has no default: guessing it silently changes the merkle root.
41
+ config = Merkle::Config.new(element_encoding: :binary, hash_type: :sha256)
37
42
 
38
43
  # Method 1: Using pre-hashed leaves
39
44
  leaves = [
@@ -74,12 +79,12 @@ tree = Merkle::BinaryTree.from_elements(
74
79
  root = tree.compute_root
75
80
  puts "Root from elements: #{root}"
76
81
 
77
- # With optional leaf tag for tagged hashing (e.g., Taproot)
78
- taproot_config = Merkle::Config.taptree
82
+ # Tags live on the config, so leaves and branches cannot drift apart.
83
+ # Config.taptree carries leaf_tag: 'TapLeaf' and branch_tag: 'TapBranch'.
84
+ taproot_config = Merkle::Config.taptree(element_encoding: :binary)
79
85
  tagged_tree = Merkle::AdaptiveTree.from_elements(
80
86
  config: taproot_config,
81
- elements: elements,
82
- leaf_tag: 'TapLeaf' # Optional tag for leaf hashing
87
+ elements: elements
83
88
  )
84
89
 
85
90
  # Generate and verify proof
@@ -105,10 +110,11 @@ puts "Adaptive tree proof valid: #{proof.valid?}"
105
110
  # This gives you precise control over how leaves are grouped
106
111
 
107
112
  # Example 1: Basic usage with pre-hashed leaves
108
- leaf_a = config.tagged_hash('A')
109
- leaf_b = config.tagged_hash('B')
110
- leaf_c = config.tagged_hash('C')
111
- leaf_d = config.tagged_hash('D')
113
+ # Leaves are always 64-character hex strings, the same form #compute_root returns.
114
+ leaf_a = config.tagged_hash(config.encode_element('A')).unpack1('H*')
115
+ leaf_b = config.tagged_hash(config.encode_element('B')).unpack1('H*')
116
+ leaf_c = config.tagged_hash(config.encode_element('C')).unpack1('H*')
117
+ leaf_d = config.tagged_hash(config.encode_element('D')).unpack1('H*')
112
118
 
113
119
  # Define structure: [[A, [B, C]], D]
114
120
  nested_leaves = [[leaf_a, [leaf_b, leaf_c]], leaf_d]
@@ -120,21 +126,24 @@ puts "Custom tree root: #{root}"
120
126
  # Valid structures:
121
127
  # - [A, B] → Simple binary node
122
128
  # - [[A, B], C] → Left subtree with right leaf
123
- # - [A] → Single child node
129
+ # - [A] → A tree holding a single leaf
124
130
  # Invalid: [A, B, C] → Error (max 2 children per node)
131
+ # Invalid: [[A, B]] → Error (a single-child node just passes its child's hash up,
132
+ # so it would commit to the same root as [A, B])
125
133
  ```
126
134
 
127
135
  ### Configuration Options
128
136
 
129
137
  ```ruby
130
138
  # Bitcoin-compatible configuration with double SHA256
131
- bitcoin_config = Merkle::Config.new(hash_type: :double_sha256)
139
+ bitcoin_config = Merkle::Config.new(element_encoding: :hex, hash_type: :double_sha256)
132
140
 
133
141
  # Configuration with tagged hashing (Taproot-style)
134
- taproot_config = Merkle::Config.taptree
142
+ taproot_config = Merkle::Config.taptree(element_encoding: :hex)
135
143
 
136
144
  # Configuration with non-sorted hashing (directions needed in proofs)
137
145
  non_sorted_config = Merkle::Config.new(
146
+ element_encoding: :binary,
138
147
  hash_type: :sha256,
139
148
  sort_hashes: false
140
149
  )
@@ -162,3 +171,75 @@ The library generates compact Merkle proofs that include:
162
171
  proof = tree.generate_proof(leaf_index)
163
172
  is_valid = proof.valid? # Returns true/false
164
173
  ```
174
+
175
+ `#valid?` folds `leaf` upwards through `siblings` and compares the result to `root`. It answers
176
+ "do these hashes chain to this root", and nothing more. In particular it does not check that
177
+ `leaf` sits at the bottom of the tree.
178
+
179
+ **The verifier must derive `leaf` itself.** Hash the data you care about and build the proof
180
+ around that value:
181
+
182
+ ```ruby
183
+ leaf = config.tagged_hash(config.encode_element(my_data), config.leaf_tag).unpack1('H*')
184
+ proof = Merkle::Proof.new(config: config, root: trusted_root, leaf: leaf,
185
+ siblings: received_siblings, directions: received_directions)
186
+ proof.valid?
187
+ ```
188
+
189
+ Taking `leaf` from whoever supplied the proof defeats it: any internal node of the tree is a
190
+ value that chains to the root, so it would be accepted as if it were a leaf.
191
+
192
+ ## Security considerations
193
+
194
+ This library lets you build trees that are not second-preimage resistant, because Bitcoin's
195
+ transaction merkle tree is one of them and cannot be changed. Two properties are left to the
196
+ protocol built on top of it:
197
+
198
+ - **Domain separation.** Give `leaf_tag` and `branch_tag` different values so that a leaf hash
199
+ can never equal an internal node hash. With both left empty, an attacker can craft an element
200
+ whose leaf hash equals an internal node and prove membership of something that was never in
201
+ the tree. `Config.taptree` sets both for you.
202
+ - **Duplicate leaves (CVE-2012-2459).** `BinaryTree` duplicates the last node when a level holds
203
+ an odd number of them, exactly as Bitcoin does, so `[a, b, c]` and `[a, b, c, c]` share a root.
204
+ Use `AdaptiveTree` or `CustomTree` if you do not need Bitcoin compatibility.
205
+
206
+ Element encoding, by contrast, is not left to guesswork: `element_encoding` is required on
207
+ `Config`, so `'hello'` and `'68656c6c6f'` cannot silently resolve to the same leaf.
208
+
209
+ ### Upgrading from 0.4.0 and earlier
210
+
211
+ `element_encoding` has no default, so every `Config` construction has to be updated. Pick the
212
+ value that matches what you were already passing to `.from_elements`:
213
+
214
+ | What you pass as elements | Use |
215
+ | --- | --- |
216
+ | Hex strings | `:hex` |
217
+ | Raw byte strings | `:binary` |
218
+ | A mix of both | `:auto` |
219
+
220
+ `:auto` reproduces the old behaviour exactly, including its collisions: `'hello'` and
221
+ `'68656c6c6f'` share a leaf under it, and so do `'AB'` and `'ab'`. Use it to keep verifying roots
222
+ you already committed to, not for a new protocol. It reproduces 0.4.0; 0.3.1 and earlier also
223
+ padded odd-length hex, so `'abc'` and `'abc0'` shared a leaf there and no mode reproduces that.
224
+
225
+ `leaf_tag` moved from `.from_elements` to `Config`, next to `branch_tag`, so a protocol's tag
226
+ spec lives in one place. Pass it to `Config.new` instead. `Config.taptree` now sets
227
+ `leaf_tag: 'TapLeaf'` itself: if you were calling it without passing a leaf tag, your leaves were
228
+ untagged and your roots were not BIP341 script trees. They are now, which changes those roots.
229
+
230
+ Leaves are now always 64-character hex strings. If you were passing binary digests
231
+ (`config.tagged_hash(...)`) as leaves, append `.unpack1('H*')`.
232
+
233
+ **`Config#tagged_hash` is the one change that does not announce itself.** It now hashes its
234
+ argument as bytes instead of decoding it when it looks like hex, so a direct call keeps working
235
+ and returns a different digest:
236
+
237
+ ```ruby
238
+ config.tagged_hash('deadbeef') # 0.4.0: hashed 4 bytes
239
+ config.tagged_hash(config.encode_element('deadbeef')) # 1.0.0: same 4 bytes, with :hex
240
+ ```
241
+
242
+ Everything reached through `.from_elements` already goes through `encode_element`, so this only
243
+ affects code that calls `tagged_hash` itself — typically to precompute leaves for `.new`. Audit
244
+ those call sites: wrap the argument in `encode_element` to keep the old digest, or leave it bare
245
+ if you were passing raw bytes all along.
@@ -15,20 +15,22 @@ module Merkle
15
15
  raise ArgumentError, 'config must be Merkle::Config' unless config.is_a?(Merkle::Config)
16
16
  raise ArgumentError, 'leaves must be Array' unless leaves.is_a?(Array)
17
17
  @config = config
18
- @leaves = leaves
18
+ # Copy, so that the array the caller keeps cannot change this tree's root behind its back.
19
+ @leaves = leaves.dup
20
+ validate_leaves!
19
21
  end
20
22
 
21
23
  # Create tree from +elements+. For each element in elements,
22
24
  # we compute a tagged hash, which becomes the leaf value.
25
+ # The resulting leaves are hex strings, the same representation as leaves passed to #initialize.
23
26
  # @param [Merkle::Config] config Configuration for merkle tree.
24
27
  # @param [Array] elements An array of element that will be hashed to become leaves.
25
- # @param [String] leaf_tag An optional tag to use when computing the leaf hash.
26
- def self.from_elements(config:, elements:, leaf_tag: '')
28
+ # The tag used for the leaf hash comes from +config.leaf_tag+.
29
+ def self.from_elements(config:, elements:)
27
30
  raise ArgumentError, 'config must be Merkle::Config' unless config.is_a?(Merkle::Config)
28
31
  raise ArgumentError, 'elements must be Array' unless elements.is_a?(Array)
29
- raise ArgumentError, 'leaf_tag must be string' unless leaf_tag.is_a?(String)
30
32
  leaves = elements.map do |element|
31
- config.tagged_hash(element, leaf_tag)
33
+ config.tagged_hash(config.encode_element(element), config.leaf_tag).unpack1('H*')
32
34
  end
33
35
  self.new(config: config, leaves: leaves)
34
36
  end
@@ -38,8 +40,7 @@ module Merkle
38
40
  # @raise [Merkle::Error] If leaves is empty.
39
41
  def compute_root
40
42
  raise Error, 'leaves is empty' if leaves.empty?
41
- # nodes = leaves
42
- nodes = leaves.map {|leaf| hex_to_bin(leaf) }
43
+ nodes = leaves.map {|leaf| decode_hash(leaf) }
43
44
  while nodes.length > 1
44
45
  nodes = build_next_level(nodes)
45
46
  end
@@ -63,6 +64,12 @@ module Merkle
63
64
 
64
65
  private
65
66
 
67
+ # Validate the leaves this tree was built with.
68
+ # @raise [ArgumentError] If any leaf is not a node hash.
69
+ def validate_leaves!
70
+ leaves.each { |leaf| decode_hash(leaf) }
71
+ end
72
+
66
73
  # Gets the siblings that corresponds to +leaf_index+ and its directions (if necessary).
67
74
  # @param [Integer] leaf_index The leaf index.
68
75
  # @return [Array] An array of siblings and directions.
@@ -12,7 +12,7 @@ module Merkle
12
12
  directions = []
13
13
 
14
14
  current_index = leaf_index
15
- nodes = leaves.map {|leaf| hex_to_bin(leaf) }
15
+ nodes = leaves.map {|leaf| decode_hash(leaf) }
16
16
 
17
17
  while nodes.length > 1
18
18
  # For adaptive tree, odd nodes are promoted to next level
@@ -13,7 +13,7 @@ module Merkle
13
13
  directions = []
14
14
 
15
15
  current_index = leaf_index
16
- nodes = leaves.map {|leaf| hex_to_bin(leaf) }
16
+ nodes = leaves.map {|leaf| decode_hash(leaf) }
17
17
 
18
18
  while nodes.length > 1
19
19
  # If odd number of nodes, duplicate the last one
data/lib/merkle/config.rb CHANGED
@@ -6,36 +6,79 @@ module Merkle
6
6
  # Supported Hash type.
7
7
  HASH_TYPES = [:sha256, :double_sha256]
8
8
 
9
- attr_reader :hash_type, :branch_tag, :sort_hashes
9
+ # How the elements passed to .from_elements are turned into bytes.
10
+ # :hex - each element is a hex string and is decoded before hashing.
11
+ # :binary - each element is already a byte string and is hashed as-is.
12
+ # :auto - each element is decoded if it looks like hex, otherwise hashed as-is.
13
+ #
14
+ # :auto exists to reproduce roots computed by 0.4.0 and earlier, where this was the only
15
+ # behaviour. Do not choose it for a new protocol: 'hello' and '68656c6c6f' resolve to the
16
+ # same leaf under it, and so do 'AB' and 'ab'. Note it reproduces 0.4.0, not 0.3.1 and
17
+ # earlier, which also padded odd-length hex ('abc' and 'abc0' shared a leaf there).
18
+ ELEMENT_ENCODINGS = [:hex, :binary, :auto]
19
+
20
+ attr_reader :hash_type, :leaf_tag, :branch_tag, :sort_hashes, :element_encoding
10
21
 
11
22
  # Constructor
23
+ # @param [Symbol] element_encoding How elements are interpreted, :hex, :binary or :auto.
24
+ # This has no default on purpose. Guessing it silently changes the merkle root.
25
+ # See ELEMENT_ENCODINGS before reaching for :auto.
12
26
  # @param [Symbol] hash_type The hashing algorithm used to hash the internal nodes.
27
+ # @param [String] leaf_tag Tag to use when hashing leaves.
28
+ # Give this and +branch_tag+ different values so that a leaf hash can never equal an internal
29
+ # node hash. With both left empty the tree is not second-preimage resistant.
13
30
  # @param [String] branch_tag Tags to use when hashing internal nodes.
14
31
  # @param [Boolean] sort_hashes Whether to sort internal nodes in lexicographical order and hash them.
15
32
  # If you enable this, Merkle::Proof's directions are not required.
16
33
  # @raise [ArgumentError]
17
- def initialize(hash_type: :sha256, branch_tag: '', sort_hashes: true)
34
+ def initialize(element_encoding:, hash_type: :sha256, leaf_tag: '', branch_tag: '', sort_hashes: true)
35
+ raise ArgumentError, "element_encoding #{element_encoding} does not supported." unless ELEMENT_ENCODINGS.include?(element_encoding)
18
36
  raise ArgumentError, "hash_type #{hash_type} does not supported." unless HASH_TYPES.include?(hash_type)
37
+ raise ArgumentError, "leaf_tag must be string." unless leaf_tag.is_a?(String)
19
38
  raise ArgumentError, "internal_tag must be string." unless branch_tag.is_a?(String)
20
39
  raise ArgumentError, "sort_hashes must be boolean." unless sort_hashes.is_a?(TrueClass) || sort_hashes.is_a?(FalseClass)
40
+ @element_encoding = element_encoding
21
41
  @hash_type = hash_type
22
- @branch_tag = branch_tag
42
+ # Freeze the tags. A config is shared by every tree built with it, so mutating one in place
43
+ # would change the root of all of them and invalidate proofs already handed out.
44
+ @leaf_tag = leaf_tag.dup.freeze
45
+ @branch_tag = branch_tag.dup.freeze
23
46
  @sort_hashes = sort_hashes
24
47
  end
25
48
 
26
49
  # Bitcoin configuration.
50
+ # @param [Symbol] element_encoding How elements are interpreted, :hex, :binary or :auto.
27
51
  # @return [Merkle::Config]
28
- def self.bitcoin
29
- Config.new(hash_type: :double_sha256, sort_hashes: false)
52
+ def self.bitcoin(element_encoding:)
53
+ Config.new(element_encoding: element_encoding, hash_type: :double_sha256, sort_hashes: false)
30
54
  end
31
55
 
32
56
  # Taptree configuration.
57
+ # @param [Symbol] element_encoding How elements are interpreted, :hex, :binary or :auto.
33
58
  # @return [Merkle::Config]
34
- def self.taptree
35
- Config.new(branch_tag: 'TapBranch')
59
+ def self.taptree(element_encoding:)
60
+ Config.new(element_encoding: element_encoding, leaf_tag: 'TapLeaf', branch_tag: 'TapBranch')
61
+ end
62
+
63
+ # Convert +element+ into the byte string to be hashed, following element_encoding.
64
+ # @param [String] element An element as given to .from_elements.
65
+ # @return [String] Byte string.
66
+ # @raise [ArgumentError] If +element+ does not match element_encoding.
67
+ def encode_element(element)
68
+ raise ArgumentError, "element must be string." unless element.is_a?(String)
69
+ case element_encoding
70
+ when :hex
71
+ raise ArgumentError, "element must be a hex string." unless hex_string?(element)
72
+ [element].pack('H*')
73
+ when :binary
74
+ element.b
75
+ when :auto
76
+ hex_string?(element) ? [element].pack('H*') : element.b
77
+ end
36
78
  end
37
79
 
38
- # Generate tagged hash.
80
+ # Generate tagged hash. +data+ is always hashed as a byte string.
81
+ # To hash an element written as hex, pass it through #encode_element first.
39
82
  # @param [String] data The data to be hashed.
40
83
  # @param [String] tag Tag string used tagging.
41
84
  # @return [String] Tagged hash value.
@@ -43,7 +86,7 @@ module Merkle
43
86
  raise ArgumentError, "data must be string." unless data.is_a?(String)
44
87
  raise ArgumentError, "tag must be a String." unless tag.is_a?(String)
45
88
 
46
- data_bin = hex_to_bin(data).b
89
+ data_bin = data.b
47
90
 
48
91
  unless tag.empty?
49
92
  tag_bin = Digest::SHA256.digest(tag).b
@@ -9,28 +9,29 @@ module Merkle
9
9
  # Each element can be a leaf hash (hex string) or an array of child nodes.
10
10
  def initialize(config:, leaves:)
11
11
  super(config: config, leaves: leaves)
12
- # Validate nested structure before calling super
13
- validate_leaves!(extract_leaves(leaves))
14
12
  end
15
13
 
16
14
  # Create tree from elements with custom structure
17
15
  # @param [Merkle::Config] config Configuration for merkle tree.
18
16
  # @param [Array] elements A nested array of elements that will be hashed to become leaves.
19
- # @param [String] leaf_tag An optional tag to use when computing the leaf hash.
20
- def self.from_elements(config:, elements:, leaf_tag: '')
17
+ # The tag used for the leaf hash comes from +config.leaf_tag+.
18
+ def self.from_elements(config:, elements:)
21
19
  raise ArgumentError, 'config must be Merkle::Config' unless config.is_a?(Merkle::Config)
22
20
  raise ArgumentError, 'elements must be Array' unless elements.is_a?(Array)
23
- raise ArgumentError, 'leaf_tag must be string' unless leaf_tag.is_a?(String)
24
-
25
- # Convert elements to hashes while preserving structure
26
- hashed_structure = convert_elements_to_hashes(elements, config, leaf_tag)
27
-
21
+
22
+ # Convert elements to hashes while preserving structure. This walks the input before the
23
+ # constructor gets to check it, so it has to enforce the depth limit itself.
24
+ hashed_structure = convert_elements_to_hashes(elements, config)
25
+
28
26
  self.new(config: config, leaves: hashed_structure)
29
27
  end
30
28
 
31
29
  # Compute merkle root using custom structure
32
30
  # @return [String] merkle root
33
31
  def compute_root
32
+ # Re-check here rather than trusting the constructor. +leaves+ is readable and its arrays
33
+ # are mutable, so a structure that was rejected at construction can be assembled afterwards.
34
+ validate_leaves!
34
35
  all_leaves = extract_leaves(@leaves)
35
36
  raise Error, 'leaves is empty' if all_leaves.empty?
36
37
  result = compute_node_hash(@leaves)
@@ -38,12 +39,13 @@ module Merkle
38
39
  end
39
40
 
40
41
  # Convert nested elements to nested hashes
41
- def self.convert_elements_to_hashes(node, config, leaf_tag)
42
+ def self.convert_elements_to_hashes(node, config, depth = 0)
42
43
  if node.is_a?(Array)
43
- node.map { |child| convert_elements_to_hashes(child, config, leaf_tag) }
44
+ raise ArgumentError, "Binary tree must not be deeper than #{MAX_DEPTH}" if depth >= MAX_DEPTH
45
+ node.map { |child| convert_elements_to_hashes(child, config, depth + 1) }
44
46
  else
45
47
  # This is a leaf element, hash it and convert to hex
46
- config.tagged_hash(node, leaf_tag).unpack1('H*')
48
+ config.tagged_hash(config.encode_element(node), config.leaf_tag).unpack1('H*')
47
49
  end
48
50
  end
49
51
 
@@ -72,12 +74,18 @@ module Merkle
72
74
  end
73
75
  else
74
76
  # Leaf node: already a hash, convert to binary
75
- hex_to_bin(node)
77
+ decode_hash(node)
76
78
  end
77
79
  end
78
80
 
81
+ private_class_method :convert_elements_to_hashes
82
+ private :compute_node_hash
83
+
79
84
  # Override generate_proof to work with nested structure
80
85
  def generate_proof(leaf_index)
86
+ # Walking the structure before checking it would hit the recursion limit on a structure
87
+ # assembled after construction, and SystemStackError escapes the caller's rescue.
88
+ validate_leaves!
81
89
  all_leaves = extract_leaves(@leaves)
82
90
  raise ArgumentError, 'leaf_index must be Integer' unless leaf_index.is_a?(Integer)
83
91
  raise ArgumentError, 'leaf_index out of range' if leaf_index < 0 || all_leaves.length <= leaf_index
@@ -107,99 +115,85 @@ module Merkle
107
115
  end
108
116
  end
109
117
 
110
- # Validate that all leaves are valid hex strings and structure is binary
111
- def validate_leaves!(leaves_to_validate)
112
- leaves_to_validate.each do |leaf|
113
- raise ArgumentError, "leaf hash must be string." unless leaf.is_a?(String)
114
- end
115
- validate_binary_structure(@leaves)
118
+ # Validate that the structure is a binary tree and that every leaf is a node hash.
119
+ def validate_leaves!
120
+ validate_binary_structure(@leaves, root: true)
121
+ extract_leaves(@leaves).each { |leaf| decode_hash(leaf) }
116
122
  end
117
-
118
- # Validate that the structure is a binary tree (max 2 children per node)
119
- def validate_binary_structure(node)
120
- if node.is_a?(Array)
121
- if node.length == 0
122
- raise ArgumentError, "Binary tree nodes cannot be empty"
123
- elsif node.length > 2
124
- raise ArgumentError, "Binary tree nodes can have at most 2 children, got #{node.length}"
123
+
124
+ # Validate that the structure is a binary tree (exactly 2 children per node)
125
+ # @param [Object] node A subtree (nested Array) or a leaf hash.
126
+ # @param [Boolean] root Whether +node+ is the whole tree.
127
+ # @param [Integer] depth How far below the root +node+ sits.
128
+ def validate_binary_structure(node, root: false, depth: 0)
129
+ return unless node.is_a?(Array)
130
+ # +depth+ counts the branches above this node, so a node here puts its children at
131
+ # depth + 1. Stopping at MAX_DEPTH keeps the deepest leaf within MAX_DEPTH branches,
132
+ # which is what Proof::MAX_SIBLINGS allows a proof to carry.
133
+ raise ArgumentError, "Binary tree must not be deeper than #{MAX_DEPTH}" if depth >= MAX_DEPTH
134
+
135
+ case node.length
136
+ when 0
137
+ raise ArgumentError, "Binary tree nodes cannot be empty"
138
+ when 1
139
+ # A node with one child contributes no branch hash, it just passes the child up.
140
+ # That would let [[a]] and [a, [b]] commit to the same root as [a] and [a, b].
141
+ # A tree holding a single leaf is the one case where there is nothing to confuse it with.
142
+ unless root && !node[0].is_a?(Array)
143
+ raise ArgumentError, "Binary tree nodes must have 2 children unless the tree is a single leaf"
125
144
  end
126
- node.each { |child| validate_binary_structure(child) }
145
+ when 2
146
+ node.each { |child| validate_binary_structure(child, depth: depth + 1) }
147
+ else
148
+ raise ArgumentError, "Binary tree nodes can have at most 2 children, got #{node.length}"
127
149
  end
128
150
  end
129
151
 
130
152
  # Override siblings_with_directions for proof generation
131
153
  def siblings_with_directions(leaf_index)
132
- all_leaves = extract_leaves(@leaves)
133
- target_leaf = all_leaves[leaf_index]
134
154
  siblings = []
135
155
  directions = []
136
-
137
- # Build proof by finding the path to the target leaf
138
- proof_path = build_proof_path(@leaves, target_leaf)
139
-
140
- proof_path.each do |level_info|
141
- next if level_info[:siblings].empty?
142
-
143
- level_info[:siblings].each do |sibling|
144
- siblings << sibling[:hash]
145
- directions << sibling[:direction]
146
- end
147
- end
148
-
156
+ collect_path(@leaves, leaf_index, siblings, directions)
149
157
  [siblings, directions]
150
158
  end
151
-
152
- # Build the proof path with siblings at each level
153
- def build_proof_path(node, target_leaf, path = [])
154
- if node.is_a?(Array)
155
- # Find which child contains the target
156
- node.each_with_index do |child, idx|
157
- child_path = build_proof_path(child, target_leaf, path)
158
-
159
- if child_path
160
- # Found the path, now collect siblings at this level
161
- level_siblings = []
162
- node.each_with_index do |sibling, sibling_idx|
163
- next if sibling_idx == idx # Skip the path we're on
164
-
165
- sibling_hash = compute_node_hash(sibling)
166
- direction = sibling_idx < idx ? 0 : 1
167
- level_siblings << { hash: sibling_hash, direction: direction }
168
- end
169
-
170
- return child_path + [{ siblings: level_siblings }]
171
- end
172
- end
173
- nil
174
- else
175
- # Leaf node
176
- if node == target_leaf
177
- []
178
- else
179
- nil
180
- end
159
+
160
+ # Walk down the structure toward the leaf at +index+ (counted within +node+), collecting the
161
+ # sibling hash and its direction at each branch. Siblings are collected deepest first, the
162
+ # order Proof#valid? folds them in.
163
+ # The descent is driven by the index rather than by the leaf value, so duplicate leaf hashes
164
+ # still yield the proof for the requested position.
165
+ # @param [Object] node A subtree (nested Array) or a leaf hash.
166
+ # @param [Integer] index The leaf index within +node+.
167
+ # @param [Array] siblings Collected sibling hashes(binary format).
168
+ # @param [Array] directions Collected directions(0: left, 1: right).
169
+ def collect_path(node, index, siblings, directions)
170
+ return unless node.is_a?(Array)
171
+
172
+ if node.length == 1
173
+ # Single child contributes no sibling, its hash is passed through unchanged.
174
+ return collect_path(node[0], index, siblings, directions)
181
175
  end
182
- end
183
176
 
184
- # Find the leaf index by searching through the tree
185
- def find_leaf_index(node, target_leaf, current_index = [0])
186
- if node.is_a?(Array)
187
- node.each do |child|
188
- result = find_leaf_index(child, target_leaf, current_index)
189
- return result if result
190
- end
191
- nil
177
+ left, right = node
178
+ left_leaves = leaf_count(left)
179
+ if index < left_leaves
180
+ collect_path(left, index, siblings, directions)
181
+ siblings << compute_node_hash(right)
182
+ directions << 1 # sibling is on the right
192
183
  else
193
- # This is a leaf
194
- if node == target_leaf
195
- current_index[0]
196
- else
197
- current_index[0] += 1
198
- nil
199
- end
184
+ collect_path(right, index - left_leaves, siblings, directions)
185
+ siblings << compute_node_hash(left)
186
+ directions << 0 # sibling is on the left
200
187
  end
201
188
  end
202
189
 
190
+ # Count the leaves under +node+.
191
+ # @param [Object] node A subtree (nested Array) or a leaf hash.
192
+ # @return [Integer] Number of leaves.
193
+ def leaf_count(node)
194
+ node.is_a?(Array) ? node.sum { |child| leaf_count(child) } : 1
195
+ end
196
+
203
197
  # Not used in custom tree - structure is determined by nested array
204
198
  def build_next_level(nodes)
205
199
  raise NotImplementedError, "CustomTree uses structure-based computation"
data/lib/merkle/proof.rb CHANGED
@@ -2,35 +2,56 @@ module Merkle
2
2
  class Proof
3
3
  include Util
4
4
 
5
+ # Upper bound on the number of siblings, i.e. the depth of the tree the proof came from.
6
+ # A proof longer than this cannot correspond to any realistic tree, so it is rejected
7
+ # rather than hashed. It matches the deepest tree the library will build, which is also the
8
+ # deepest a BIP341 script tree can be: a control block carries at most 128 path elements.
9
+ MAX_SIBLINGS = MAX_DEPTH
10
+
5
11
  attr_reader :config, :root, :leaf, :siblings, :directions
6
12
 
7
13
  # Constructor.
8
14
  # @param [Merkle::Config] config
9
15
  # @param [String] root
10
16
  # @param [String] leaf
11
- # @param [Array] siblings
17
+ # @param [Array] siblings An array of sibling hashes(64-character hex strings).
12
18
  # @param [Array] directions Array of positions at each level(0: left, 1: right),
13
19
  # only required if sort_hashes is false in config.
14
20
  def initialize(config:, root:, leaf:, siblings:, directions: [])
15
21
  raise ArgumentError, 'config must be a Merkle::Config' unless config.is_a?(Merkle::Config)
16
22
  raise ArgumentError, 'root must be string' unless root.is_a?(String)
23
+ raise ArgumentError, "root must be a #{HASH_SIZE * 2}-character hex string" unless node_hash?(root)
17
24
  raise ArgumentError, 'leaf must be string' unless leaf.is_a?(String)
25
+ raise ArgumentError, "leaf must be a #{HASH_SIZE * 2}-character hex string" unless node_hash?(leaf)
26
+ raise ArgumentError, 'siblings must be an Array' unless siblings.is_a?(Array)
27
+ raise ArgumentError, "siblings must not exceed #{MAX_SIBLINGS} elements" if siblings.length > MAX_SIBLINGS
28
+ siblings.each do |sibling|
29
+ raise ArgumentError, 'sibling must be string' unless sibling.is_a?(String)
30
+ raise ArgumentError, "sibling must be a #{HASH_SIZE * 2}-character hex string" unless node_hash?(sibling)
31
+ end
18
32
  raise ArgumentError, 'directions must be an Array' unless directions.is_a?(Array)
19
33
  raise ArgumentError, 'No directions are required because sorted_hash is enabled' if config.sort_hashes && !directions.empty?
34
+ unless config.sort_hashes
35
+ raise ArgumentError, 'directions must have the same length as siblings' unless directions.length == siblings.length
36
+ raise ArgumentError, 'direction must be 0 or 1' unless directions.all? { |direction| direction == 0 || direction == 1 }
37
+ end
20
38
  @config = config
21
- @root = root
22
- @leaf = leaf
23
- @siblings = siblings
24
- @directions = directions
39
+ # Normalize and freeze. The checks above only bind if the arrays cannot grow afterwards:
40
+ # a caller holding the array it passed in could otherwise append past MAX_SIBLINGS, or
41
+ # shorten directions until #valid? reads nil and folds as if the sibling were on the right.
42
+ @root = normalize_hash(root)
43
+ @leaf = normalize_hash(leaf)
44
+ @siblings = siblings.map { |sibling| normalize_hash(sibling) }.freeze
45
+ @directions = directions.dup.freeze
25
46
  end
26
47
 
27
48
  # Verify the proof.
28
49
  # @return [Boolean] true if the proof is valid, false otherwise.
29
50
  def valid?
30
- current = hex_to_bin(leaf)
51
+ current = decode_hash(leaf)
31
52
 
32
53
  siblings.each_with_index do |sibling, index|
33
- sibling_bin = hex_to_bin(sibling)
54
+ sibling_bin = decode_hash(sibling)
34
55
 
35
56
  if config.sort_hashes
36
57
  # Sort lexicographically when combining
@@ -44,7 +65,9 @@ module Merkle
44
65
  current = config.tagged_hash(combined)
45
66
  end
46
67
 
47
- current.unpack1('H*') == root
68
+ # Compare the decoded bytes. Comparing the hex would make the result depend on the case
69
+ # the caller happened to write +root+ in, even though both spell the same hash.
70
+ current == decode_hash(root)
48
71
  end
49
72
 
50
73
  end
data/lib/merkle/util.rb CHANGED
@@ -1,22 +1,61 @@
1
1
  module Merkle
2
2
  module Util
3
3
 
4
+ # Size of a node hash in bytes. Leaves, siblings and internal nodes are all this size.
5
+ HASH_SIZE = 32
6
+
7
+ # Deepest tree accepted. A tree deeper than this cannot be walked without risking a
8
+ # SystemStackError, which is not a StandardError and so escapes a caller's rescue.
9
+ # 128 is also the deepest a BIP341 script tree can be.
10
+ MAX_DEPTH = 128
11
+
4
12
  # Check whether +data+ is hex string or not.
13
+ # An odd-length string is not a hex string. Treating it as one would let
14
+ # +pack('H*')+ pad the missing nibble with zero, so 'abc' and 'abc0' would
15
+ # collide.
5
16
  # @param [String] data
6
17
  # @return [Boolean]
7
18
  # @raise [ArgumentError]
8
19
  def hex_string?(data)
9
20
  raise ArgumentError, 'data must be string' unless data.is_a?(String)
10
- data.match?(/\A[0-9a-fA-F]+\z/)
21
+ # Match on the bytes. Matching the string itself raises Encoding::CompatibilityError for a
22
+ # UTF-16 string and ArgumentError for invalid UTF-8, neither of which the caller expects.
23
+ bytes = data.b
24
+ bytes.bytesize.even? && bytes.match?(/\A[0-9a-fA-F]+\z/)
11
25
  end
12
26
 
13
- # Convert hex string +data+ to binary.
14
- # @param [String] data
15
- # @return [String]
27
+ # Check whether +hex+ is the hex representation of a node hash.
28
+ # @param [String] hex
29
+ # @return [Boolean]
30
+ def node_hash?(hex)
31
+ return false unless hex.is_a?(String)
32
+ # Match on the bytes. Matching the string itself raises on a value that claims to be UTF-8
33
+ # but holds invalid bytes, which would surface as an unrelated ArgumentError.
34
+ bytes = hex.b
35
+ bytes.bytesize == HASH_SIZE * 2 && bytes.match?(/\A[0-9a-fA-F]+\z/)
36
+ end
37
+
38
+ # Convert a node hash from its hex representation to binary.
39
+ # Node hashes are always +HASH_SIZE+ bytes written as hex, so anything else is rejected
40
+ # rather than guessed at. Accepting arbitrary lengths here would make the concatenation
41
+ # in an internal node ambiguous: ['aa', 'bbcc'] and ['aabb', 'cc'] would hash alike.
42
+ # @param [String] hex
43
+ # @return [String] Binary format hash.
16
44
  # @raise [ArgumentError]
17
- def hex_to_bin(data)
18
- raise ArgumentError, 'data must be string' unless data.is_a?(String)
19
- hex_string?(data) ? [data].pack('H*') : data
45
+ def decode_hash(hex)
46
+ raise ArgumentError, 'hash must be string' unless hex.is_a?(String)
47
+ raise ArgumentError, "hash must be a #{HASH_SIZE * 2}-character hex string" unless node_hash?(hex)
48
+ [hex].pack('H*')
49
+ end
50
+
51
+ # Rewrite a node hash in the one spelling the library uses: lower case hex.
52
+ # Upper case names the same hash, so returning it verbatim would let a caller that matches
53
+ # leaves as strings miss a leaf whose proof verifies.
54
+ # @param [String] hex
55
+ # @return [String] Frozen lower case hex.
56
+ # @raise [ArgumentError]
57
+ def normalize_hash(hex)
58
+ bin_to_hex(decode_hash(hex)).freeze
20
59
  end
21
60
 
22
61
  # Convert binary string +data+ to hex string.
@@ -25,7 +64,7 @@ module Merkle
25
64
  # @raise [ArgumentError]
26
65
  def bin_to_hex(data)
27
66
  raise ArgumentError, 'data must be string' unless data.is_a?(String)
28
- hex_string?(data) ? data : data.unpack1('H*')
67
+ data.unpack1('H*')
29
68
  end
30
69
 
31
70
  # Combine two elements(+left+ and +right+) with sort configuration.
@@ -48,4 +87,4 @@ module Merkle
48
87
  end
49
88
 
50
89
  end
51
- end
90
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Merkle
4
- VERSION = "0.3.1"
4
+ VERSION = "1.0.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: merkle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - azuchi
@@ -54,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0'
56
56
  requirements: []
57
- rubygems_version: 3.6.9
57
+ rubygems_version: 4.0.3
58
58
  specification_version: 4
59
59
  summary: A Ruby library for Merkle trees
60
60
  test_files: []