ct_merkle 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a8fad35cd7103e96959b20d2175ae595debd401af2bb04339f6fc23fc85779bb
4
+ data.tar.gz: 68fad29c11401c6d9a83d6b0edaddfd48038b6de074e581043705ba648b04852
5
+ SHA512:
6
+ metadata.gz: cca6fa98c9e29508d1b42b0083bbeda86d460edb845292e72d06121abde03a09617ba16bde3a854f7e95b89fd27de0cc5806d39fbd6b0f666d29b4b65aad2fd4
7
+ data.tar.gz: 3e44639499247aa25ec5e9ea238a55c72d58ef495c001a39bdd14720bcbae54e8db62b925fc120b172f1b6a96db11ca0e3b1217423cd220d1601a6a03e326802
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ken Tanaka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # ct_merkle
2
+
3
+ [RFC 6962](https://www.rfc-editor.org/rfc/rfc6962) / [RFC 9162](https://www.rfc-editor.org/rfc/rfc9162) §2.1(Certificate Transparency)に準拠した、追記型マークルツリーの Ruby 実装。
4
+
5
+ ## 機能
6
+
7
+ - 文字列または任意のハッシュ値を葉としてマークルツリーを作成
8
+ - マークルツリーよりルートのハッシュ、包含証明、整合性証明を取得可能(過去の任意のサイズ時点のツリーからも取得可)
9
+ - 外部から与えられた包含証明、整合性証明を検証可能
10
+ - 検証手順は RFC 9162 §2.1.3.2 / §2.1.4.2 に準拠
11
+ - [transparency-dev/merkle](https://github.com/transparency-dev/merkle) のテストベクタで動作を検証済み
12
+ - 依存ライブラリなし
13
+
14
+ ## インストール
15
+
16
+ ```bash
17
+ gem install ct_merkle
18
+
19
+ # or
20
+
21
+ bundle add ct_merkle
22
+ ```
23
+
24
+ ## 使い方
25
+
26
+ ### ツリー作成、包含証明
27
+
28
+ ```ruby
29
+ # ツリーを作成
30
+ tree = CTMerkle::Tree.new(["a", "b", "c", "d"])
31
+
32
+ # ルートハッシュ、"c"(=index:2)の包含証明を取得
33
+ root = tree.root
34
+ proof = tree.inclusion_proof(2)
35
+
36
+ # 包含証明の検証
37
+ proof.verify(leaf_data: "c", root: root) # => true
38
+ proof.verify(leaf_data: "x", root: root) # => false
39
+ ```
40
+
41
+ ### 追記型ログの整合性証明
42
+
43
+ ```ruby
44
+ tree = CTMerkle::Tree.new
45
+ tree.append("e0")
46
+ tree << "e1" << "e2" << "e3" << "e4"
47
+ tree.tree_size # => 5
48
+
49
+ # ルートハッシュの取得(tree_sizeを指定するとその時点のルートハッシュを取得)
50
+ root3 = tree.root(tree_size: 3)
51
+ root5 = tree.root
52
+
53
+ # 現在のツリーと tree_size:3 の時点のツリーの整合性証明
54
+ cproof = tree.consistency_proof(3)
55
+ cproof.verify(old_root: root3, new_root: root5) # => true
56
+ ```
57
+
58
+ ### ツリーのスナップショット
59
+
60
+ ```ruby
61
+ # tree.at(n)` は `tree_size: n` 時点の読み取り専用ツリーを返却する
62
+ snap = tree.at(4)
63
+ snap.root # tree.root(tree_size: 4) と同じ
64
+ snap.inclusion_proof(0) # tree.inclusion_proof(0, tree_size: 4) と同じ
65
+ snap.consistency_proof(3) # tree.consistency_proof(3, tree_size: 4) と同じ
66
+ ```
67
+
68
+ ### ハッシュ値からインデックスを取得
69
+
70
+ ```ruby
71
+ h = CTMerkle::Hasher::SHA256.leaf("e2")
72
+ tree.index_of(h) # => 2
73
+ tree.inclusion_proof_by_hash(h) # tree.inclusion_proof(2) と同じ
74
+ ```
75
+
76
+ ### 証明の検証
77
+
78
+ ```ruby
79
+ ## 包含証明
80
+ # leaf_hash, leaf_index, tree_size, inclusion_path, root_hex が与えられた状態で
81
+ # leaf_hash が root_hex のツリーに含まれることを検証する
82
+ proof = CTMerkle::InclusionProof.new(
83
+ leaf_index: 2,
84
+ tree_size: 5,
85
+ inclusion_path: path_hex.map { CTMerkle::Digest.from_hex(_1) },
86
+ )
87
+ proof.verify!(leaf_hash: leaf_hash, root: CTMerkle::Digest.from_hex(root_hex))
88
+
89
+ ## 整合性証明
90
+ # old_size, new_size, consistency_path, old_root_hex, new_root_hex が与えられた状態で
91
+ # old_root_hex のツリーが new_root_hex のツリーの先頭部分そのままであることを検証する
92
+ cproof = CTMerkle::ConsistencyProof.new(
93
+ old_size: 3,
94
+ new_size: 5,
95
+ consistency_path: path_hex.map { CTMerkle::Digest.from_hex(_1) },
96
+ )
97
+ cproof.verify!(
98
+ old_root: CTMerkle::Digest.from_hex(old_root_hex),
99
+ new_root: CTMerkle::Digest.from_hex(new_root_hex),
100
+ )
101
+ ```
102
+
103
+ ### ハッシュ値
104
+
105
+ ルートのハッシュ値は `CTMerkle::Digest` クラスで表現される。hex, base64, binary 文字列と相互変換可能
106
+
107
+ ```ruby
108
+ d = tree.root
109
+ d.to_hex # => "33376a3b..."
110
+ d.to_base64 # => "MzdqO9Y+..."
111
+ d.to_binary # => 生のバイト列(BINARY エンコーディング)
112
+
113
+ CTMerkle::Digest.from_hex("33376a3b...")
114
+ CTMerkle::Digest.from_base64("MzdqO9Y+...")
115
+ ```
116
+
117
+ ### 直列化
118
+
119
+ 包含証明、整合性証明は `to_h` / `from_h` で Hash と相互変換可能
120
+
121
+ ```ruby
122
+ require "json"
123
+
124
+ json = JSON.generate(tree.inclusion_proof(2).to_h) # to_h はデフォルトでハッシュ値を hex でエンコーディングする
125
+ # {"leaf_index":2,"tree_size":5,"inclusion_path":["...", "..."]}
126
+
127
+ proof = CTMerkle::InclusionProof.from_h(JSON.parse(json))
128
+ proof.to_h(encoding: :base64) # base64 エンコーディングを指定
129
+ ```
130
+
131
+ Hashのキーは RFC 9162 の `InclusionProofDataV2` / `ConsistencyProofDataV2` のフィールド名に準拠
132
+
133
+ ### CT ログからの検証
134
+
135
+ [RFC 6962 の JSON API](https://www.rfc-editor.org/rfc/rfc6962#section-4) の例
136
+
137
+ ```ruby
138
+ # GET /ct/v1/get-sth
139
+ sth = JSON.parse(sth_body)
140
+ root = CTMerkle::Digest.from_base64(sth["sha256_root_hash"])
141
+
142
+ # GET /ct/v1/get-proof-by-hash?hash=...&tree_size=<sth["tree_size"]>
143
+ res = JSON.parse(proof_body)
144
+ proof = CTMerkle::InclusionProof.new(
145
+ leaf_index: res["leaf_index"],
146
+ tree_size: sth["tree_size"],
147
+ inclusion_path: res["audit_path"].map { CTMerkle::Digest.from_base64(_1) },
148
+ )
149
+ proof.verify(leaf_hash: leaf_hash, root: root)
150
+ ```
151
+
152
+ ### 参照
153
+
154
+ - 各クラス・メソッドの仕様は [docs/reference.md](docs/reference.md) を参照
155
+
156
+ ## 開発
157
+
158
+ ```bash
159
+ bundle install
160
+ bundle exec rake test
161
+ ```
162
+
163
+ Docker を使う場合:
164
+
165
+ ```bash
166
+ docker compose run --rm test # Ruby 4.0
167
+ docker compose run --rm test-3.x # Ruby 3.x
168
+ ```
169
+
170
+ `test/fixtures/transparency-dev` にあるテストベクタは [transparency-dev/merkle](https://github.com/transparency-dev/merkle) から取得したもの
171
+
172
+ ## ライセンス
173
+
174
+ [MIT License](LICENSE.txt)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # Proof that the tree of size old_size is a prefix of the tree of size
5
+ # new_size (RFC 9162 ConsistencyProofDataV2 without log_id).
6
+ ConsistencyProof = Data.define(:old_size, :new_size, :consistency_path) do
7
+ def self.from_h(hash, encoding: :hex)
8
+ h = Serialization.normalize_keys(hash, members)
9
+ new(
10
+ old_size: h[:old_size],
11
+ new_size: h[:new_size],
12
+ consistency_path: Serialization.decode_path(h[:consistency_path], encoding, "consistency_path"),
13
+ )
14
+ end
15
+
16
+ def initialize(old_size:, new_size:, consistency_path:)
17
+ raise ArgumentError, "old_size must be an Integer, got #{old_size.class}" unless old_size.is_a?(Integer)
18
+ raise ArgumentError, "new_size must be an Integer, got #{new_size.class}" unless new_size.is_a?(Integer)
19
+ unless consistency_path.is_a?(Array)
20
+ raise ArgumentError, "consistency_path must be an Array, got #{consistency_path.class}"
21
+ end
22
+
23
+ path = consistency_path.each_with_index.map { |h, i| Digest.wrap(h, "consistency_path[#{i}]") }.freeze
24
+ super(old_size: old_size, new_size: new_size, consistency_path: path)
25
+ end
26
+
27
+ def verify(old_root:, new_root:, hasher: Hasher::SHA256)
28
+ verify!(old_root: old_root, new_root: new_root, hasher: hasher)
29
+ rescue VerificationError
30
+ false
31
+ end
32
+
33
+ def verify!(old_root:, new_root:, hasher: Hasher::SHA256)
34
+ Verifier.verify_consistency!(
35
+ old_size: old_size,
36
+ new_size: new_size,
37
+ old_root: old_root,
38
+ new_root: new_root,
39
+ consistency_path: consistency_path,
40
+ hasher: hasher,
41
+ )
42
+ end
43
+
44
+ def to_h(encoding: :hex)
45
+ {
46
+ old_size: old_size,
47
+ new_size: new_size,
48
+ consistency_path: consistency_path.map { |d| Serialization.encode(d, encoding) },
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # An immutable hash value. The bytes are always held as a frozen BINARY
5
+ # String.
6
+ class Digest
7
+ HEX_PATTERN = /\A(?:[0-9a-fA-F]{2})*\z/
8
+
9
+ class << self
10
+ def from_hex(str)
11
+ raise ArgumentError, "hex must be a String, got #{str.class}" unless str.is_a?(String)
12
+ raise ArgumentError, "invalid hex string: #{str.inspect}" unless HEX_PATTERN.match?(str)
13
+
14
+ new([str].pack("H*"))
15
+ end
16
+
17
+ def from_base64(str)
18
+ raise ArgumentError, "base64 must be a String, got #{str.class}" unless str.is_a?(String)
19
+
20
+ bytes = begin
21
+ str.unpack1("m0")
22
+ rescue ArgumentError
23
+ raise ArgumentError, "invalid base64 string: #{str.inspect}"
24
+ end
25
+ new(bytes)
26
+ end
27
+
28
+ # Returns the bytes of a Digest or a String as a frozen BINARY String.
29
+ # Used internally to accept both kinds of arguments.
30
+ def bytes_of(value, name = "hash")
31
+ case value
32
+ when Digest then value.to_binary
33
+ when String then value.b.freeze
34
+ else raise ArgumentError, "#{name} must be a CTMerkle::Digest or a binary String, got #{value.class}"
35
+ end
36
+ end
37
+
38
+ # Wraps a Digest or a binary String as a Digest.
39
+ def wrap(value, name = "hash")
40
+ value.is_a?(Digest) ? value : new(bytes_of(value, name))
41
+ end
42
+ end
43
+
44
+ def initialize(bytes)
45
+ raise ArgumentError, "bytes must be a String, got #{bytes.class}" unless bytes.is_a?(String)
46
+
47
+ @bytes = bytes.b.freeze
48
+ freeze
49
+ end
50
+
51
+ def to_binary
52
+ @bytes
53
+ end
54
+
55
+ def to_hex
56
+ @bytes.unpack1("H*")
57
+ end
58
+
59
+ def to_base64
60
+ [@bytes].pack("m0")
61
+ end
62
+
63
+ alias to_s to_hex
64
+
65
+ def inspect
66
+ "#<#{self.class.name} #{to_hex}>"
67
+ end
68
+
69
+ def bytesize
70
+ @bytes.bytesize
71
+ end
72
+
73
+ # Compares bytes with another Digest or a String (encoding is ignored).
74
+ def ==(other)
75
+ case other
76
+ when Digest then @bytes == other.to_binary
77
+ when String then @bytes == other.b
78
+ else false
79
+ end
80
+ end
81
+
82
+ # Only Digests are eql?, so that #hash stays consistent.
83
+ def eql?(other)
84
+ other.is_a?(Digest) && @bytes.eql?(other.to_binary)
85
+ end
86
+
87
+ def hash
88
+ [Digest, @bytes].hash
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # Base class of all errors raised by this library.
5
+ # Invalid arguments are reported with the standard ArgumentError instead.
6
+ class Error < StandardError; end
7
+
8
+ # Raised by verify! when a proof does not verify.
9
+ class VerificationError < Error; end
10
+
11
+ # The proof is structurally invalid: wrong path length, index out of range,
12
+ # hash of the wrong size, and so on.
13
+ class MalformedProofError < VerificationError; end
14
+
15
+ # The root computed from the proof does not match the expected root.
16
+ class RootMismatchError < VerificationError; end
17
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module CTMerkle
6
+ # Hashing strategies. A hasher responds to #leaf, #node, #empty_root and
7
+ # #digest_size, and works on binary Strings (not CTMerkle::Digest) because it
8
+ # is called for every node of the tree.
9
+ module Hasher
10
+ # The RFC 6962 / RFC 9162 hashing strategy with SHA-256.
11
+ module SHA256
12
+ LEAF_PREFIX = "\x00".b.freeze
13
+ NODE_PREFIX = "\x01".b.freeze
14
+ EMPTY_ROOT = ::Digest::SHA256.digest("").freeze
15
+
16
+ module_function
17
+
18
+ # HASH(0x00 || data)
19
+ def leaf(data)
20
+ d = ::Digest::SHA256.new
21
+ d.update(LEAF_PREFIX)
22
+ d.update(data)
23
+ d.digest
24
+ end
25
+
26
+ # HASH(0x01 || left || right)
27
+ def node(left, right)
28
+ d = ::Digest::SHA256.new
29
+ d.update(NODE_PREFIX)
30
+ d.update(left)
31
+ d.update(right)
32
+ d.digest
33
+ end
34
+
35
+ # HASH("") — the root of an empty tree.
36
+ def empty_root
37
+ EMPTY_ROOT
38
+ end
39
+
40
+ def digest_size
41
+ 32
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # Proof that a leaf is included in the tree of size tree_size
5
+ # (RFC 9162 InclusionProofDataV2 without log_id).
6
+ InclusionProof = Data.define(:leaf_index, :tree_size, :inclusion_path) do
7
+ def self.from_h(hash, encoding: :hex)
8
+ h = Serialization.normalize_keys(hash, members)
9
+ new(
10
+ leaf_index: h[:leaf_index],
11
+ tree_size: h[:tree_size],
12
+ inclusion_path: Serialization.decode_path(h[:inclusion_path], encoding, "inclusion_path"),
13
+ )
14
+ end
15
+
16
+ def initialize(leaf_index:, tree_size:, inclusion_path:)
17
+ raise ArgumentError, "leaf_index must be an Integer, got #{leaf_index.class}" unless leaf_index.is_a?(Integer)
18
+ raise ArgumentError, "tree_size must be an Integer, got #{tree_size.class}" unless tree_size.is_a?(Integer)
19
+ raise ArgumentError, "inclusion_path must be an Array, got #{inclusion_path.class}" unless inclusion_path.is_a?(Array)
20
+
21
+ path = inclusion_path.each_with_index.map { |h, i| Digest.wrap(h, "inclusion_path[#{i}]") }.freeze
22
+ super(leaf_index: leaf_index, tree_size: tree_size, inclusion_path: path)
23
+ end
24
+
25
+ # Exactly one of leaf_data: or leaf_hash: must be given.
26
+ def verify(root:, leaf_data: nil, leaf_hash: nil, hasher: Hasher::SHA256)
27
+ verify!(root: root, leaf_data: leaf_data, leaf_hash: leaf_hash, hasher: hasher)
28
+ rescue VerificationError
29
+ false
30
+ end
31
+
32
+ def verify!(root:, leaf_data: nil, leaf_hash: nil, hasher: Hasher::SHA256)
33
+ if leaf_data.nil? == leaf_hash.nil?
34
+ raise ArgumentError, "specify exactly one of leaf_data: or leaf_hash:"
35
+ end
36
+
37
+ unless leaf_data.nil? || leaf_data.is_a?(String)
38
+ raise ArgumentError, "leaf_data must be a String, got #{leaf_data.class}"
39
+ end
40
+
41
+ Verifier.verify_inclusion!(
42
+ leaf_hash: leaf_hash || hasher.leaf(leaf_data),
43
+ leaf_index: leaf_index,
44
+ tree_size: tree_size,
45
+ inclusion_path: inclusion_path,
46
+ root: root,
47
+ hasher: hasher,
48
+ )
49
+ end
50
+
51
+ def to_h(encoding: :hex)
52
+ {
53
+ leaf_index: leaf_index,
54
+ tree_size: tree_size,
55
+ inclusion_path: inclusion_path.map { |d| Serialization.encode(d, encoding) },
56
+ }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # Shared helpers for to_h / from_h of the proof classes.
5
+ module Serialization
6
+ ENCODINGS = %i[hex base64].freeze
7
+
8
+ module_function
9
+
10
+ def encode(digest, encoding)
11
+ case check_encoding!(encoding)
12
+ when :hex then digest.to_hex
13
+ when :base64 then digest.to_base64
14
+ end
15
+ end
16
+
17
+ def decode(value, encoding, name)
18
+ raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
19
+
20
+ case check_encoding!(encoding)
21
+ when :hex then Digest.from_hex(value)
22
+ when :base64 then Digest.from_base64(value)
23
+ end
24
+ end
25
+
26
+ def check_encoding!(encoding)
27
+ return encoding if ENCODINGS.include?(encoding)
28
+
29
+ raise ArgumentError, "encoding must be one of #{ENCODINGS.inspect}, got #{encoding.inspect}"
30
+ end
31
+
32
+ # Normalizes keys to Symbols and checks that exactly the expected keys exist.
33
+ def normalize_keys(hash, keys)
34
+ raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
35
+
36
+ normalized = hash.to_h { |k, v| [k.is_a?(String) ? k.to_sym : k, v] }
37
+ raise ArgumentError, "duplicate keys in #{hash.keys.inspect}" if normalized.size != hash.size
38
+
39
+ missing = keys - normalized.keys
40
+ unknown = normalized.keys - keys
41
+ raise ArgumentError, "missing keys: #{missing.join(", ")}" unless missing.empty?
42
+ raise ArgumentError, "unknown keys: #{unknown.join(", ")}" unless unknown.empty?
43
+
44
+ normalized
45
+ end
46
+
47
+ def decode_path(value, encoding, name)
48
+ raise ArgumentError, "#{name} must be an Array, got #{value.class}" unless value.is_a?(Array)
49
+
50
+ value.each_with_index.map { |v, i| decode(v, encoding, "#{name}[#{i}]") }
51
+ end
52
+ end
53
+ private_constant :Serialization
54
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # A read-only view of a Tree at a fixed tree size, returned by Tree#at.
5
+ #
6
+ # The tree is append-only, so appending to the original Tree does not change
7
+ # what a Snapshot returns.
8
+ class Snapshot
9
+ attr_reader :tree_size
10
+ alias size tree_size
11
+
12
+ # Use Tree#at instead of calling this directly.
13
+ def initialize(tree, tree_size)
14
+ @tree = tree
15
+ @tree_size = tree_size
16
+ freeze
17
+ end
18
+
19
+ def root
20
+ @tree.root(tree_size: @tree_size)
21
+ end
22
+
23
+ def leaf_hash(index)
24
+ check_index!(index)
25
+ @tree.leaf_hash(index)
26
+ end
27
+
28
+ def index_of(leaf_hash)
29
+ index = @tree.index_of(leaf_hash)
30
+ index if index && index < @tree_size
31
+ end
32
+
33
+ def inclusion_proof(index)
34
+ @tree.inclusion_proof(index, tree_size: @tree_size)
35
+ end
36
+
37
+ def inclusion_proof_by_hash(leaf_hash)
38
+ @tree.inclusion_proof_by_hash(leaf_hash, tree_size: @tree_size)
39
+ end
40
+
41
+ def consistency_proof(old_size)
42
+ @tree.consistency_proof(old_size, tree_size: @tree_size)
43
+ end
44
+
45
+ def inspect
46
+ "#<#{self.class.name} tree_size=#{@tree_size}>"
47
+ end
48
+
49
+ private
50
+
51
+ def check_index!(index)
52
+ raise ArgumentError, "index must be an Integer, got #{index.class}" unless index.is_a?(Integer)
53
+ return if index.between?(0, @tree_size - 1)
54
+
55
+ raise ArgumentError, "index must be between 0 and #{@tree_size - 1}, got #{index}"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # An append-only Merkle tree (RFC 6962 / RFC 9162 §2.1), held in memory.
5
+ #
6
+ # Every complete subtree hash is cached as leaves are appended, so the root
7
+ # and proofs for any past tree size are computed in O(log n).
8
+ class Tree
9
+ attr_reader :hasher
10
+
11
+ def initialize(leaves = [], hasher: Hasher::SHA256)
12
+ @hasher = hasher
13
+ # @levels[l][i] is the hash of the complete subtree that covers
14
+ # leaves [i * 2**l, (i + 1) * 2**l). @levels[0] holds the leaf hashes.
15
+ @levels = [[]]
16
+ @index = {}
17
+ leaves.each { |data| append(data) }
18
+ end
19
+
20
+ # Appends raw leaf data and returns the index of the new leaf.
21
+ def append(data)
22
+ raise ArgumentError, "leaf data must be a String, got #{data.class}" unless data.is_a?(String)
23
+
24
+ add_leaf_hash(@hasher.leaf(data).freeze)
25
+ end
26
+
27
+ def <<(data)
28
+ append(data)
29
+ self
30
+ end
31
+
32
+ # Appends an already computed leaf hash and returns the index of the new leaf.
33
+ def append_leaf_hash(leaf_hash)
34
+ add_leaf_hash(leaf_hash_bytes(leaf_hash))
35
+ end
36
+
37
+ def tree_size
38
+ @levels[0].size
39
+ end
40
+ alias size tree_size
41
+
42
+ def root(tree_size: self.tree_size)
43
+ check_tree_size!(tree_size)
44
+ Digest.new(tree_size.zero? ? @hasher.empty_root : subtree_hash(0, tree_size))
45
+ end
46
+
47
+ def leaf_hash(index)
48
+ check_index!(index, tree_size)
49
+ Digest.new(@levels[0][index])
50
+ end
51
+
52
+ # Index of the first leaf whose hash is leaf_hash, or nil.
53
+ def index_of(leaf_hash)
54
+ @index[leaf_hash_bytes(leaf_hash)]
55
+ end
56
+
57
+ def at(tree_size)
58
+ check_tree_size!(tree_size)
59
+ Snapshot.new(self, tree_size)
60
+ end
61
+
62
+ def inclusion_proof(index, tree_size: self.tree_size)
63
+ check_tree_size!(tree_size)
64
+ check_index!(index, tree_size)
65
+ path = audit_path(index, 0, tree_size).map { |h| Digest.new(h) }
66
+ InclusionProof.new(leaf_index: index, tree_size: tree_size, inclusion_path: path)
67
+ end
68
+
69
+ # Inclusion proof for the first leaf whose hash is leaf_hash, or nil when no
70
+ # such leaf exists in the tree of size tree_size.
71
+ def inclusion_proof_by_hash(leaf_hash, tree_size: self.tree_size)
72
+ check_tree_size!(tree_size)
73
+ index = index_of(leaf_hash)
74
+ return nil if index.nil? || index >= tree_size
75
+
76
+ inclusion_proof(index, tree_size: tree_size)
77
+ end
78
+
79
+ def consistency_proof(old_size, tree_size: self.tree_size)
80
+ check_tree_size!(tree_size)
81
+ raise ArgumentError, "old_size must be an Integer, got #{old_size.class}" unless old_size.is_a?(Integer)
82
+ raise ArgumentError, "old_size must be positive, got #{old_size}" unless old_size.positive?
83
+ if old_size > tree_size
84
+ raise ArgumentError, "old_size (#{old_size}) must not exceed tree_size (#{tree_size})"
85
+ end
86
+
87
+ path = old_size == tree_size ? [] : subproof(old_size, 0, tree_size, true)
88
+ ConsistencyProof.new(old_size: old_size, new_size: tree_size,
89
+ consistency_path: path.map { |h| Digest.new(h) })
90
+ end
91
+
92
+ def inspect
93
+ "#<#{self.class.name} tree_size=#{tree_size}>"
94
+ end
95
+
96
+ private
97
+
98
+ def add_leaf_hash(bytes)
99
+ index = @levels[0].size
100
+ @levels[0] << bytes
101
+ @index[bytes] = index unless @index.key?(bytes)
102
+
103
+ # Cache every complete subtree that this leaf closes.
104
+ level = 0
105
+ i = index
106
+ while i.odd?
107
+ @levels[level + 1] ||= []
108
+ @levels[level + 1] << @hasher.node(@levels[level][i - 1], @levels[level][i]).freeze
109
+ level += 1
110
+ i >>= 1
111
+ end
112
+ index
113
+ end
114
+
115
+ # MTH(D[start:finish]). The ranges produced by the RFC 6962 recursion start
116
+ # at a multiple of the largest power of two that fits, so complete
117
+ # subtrees can be looked up directly.
118
+ def subtree_hash(start, finish)
119
+ n = finish - start
120
+ if n.nobits?(n - 1) # power of two: a cached complete subtree
121
+ level = n.bit_length - 1
122
+ return @levels[level][start >> level]
123
+ end
124
+
125
+ k = largest_power_of_two_below(n)
126
+ @hasher.node(subtree_hash(start, start + k), subtree_hash(start + k, finish))
127
+ end
128
+
129
+ # PATH(m, D[start:finish]) from RFC 9162 §2.1.3.1.
130
+ def audit_path(m, start, finish)
131
+ n = finish - start
132
+ return [] if n == 1
133
+
134
+ k = largest_power_of_two_below(n)
135
+ if m < k
136
+ audit_path(m, start, start + k) << subtree_hash(start + k, finish)
137
+ else
138
+ audit_path(m - k, start + k, finish) << subtree_hash(start, start + k)
139
+ end
140
+ end
141
+
142
+ # SUBPROOF(m, D[start:finish], b) from RFC 9162 §2.1.4.1.
143
+ def subproof(m, start, finish, complete)
144
+ n = finish - start
145
+ if m == n
146
+ return complete ? [] : [subtree_hash(start, finish)]
147
+ end
148
+
149
+ k = largest_power_of_two_below(n)
150
+ if m <= k
151
+ subproof(m, start, start + k, complete) << subtree_hash(start + k, finish)
152
+ else
153
+ subproof(m - k, start + k, finish, false) << subtree_hash(start, start + k)
154
+ end
155
+ end
156
+
157
+ # The largest power of two smaller than n (n > 1).
158
+ def largest_power_of_two_below(n)
159
+ 1 << ((n - 1).bit_length - 1)
160
+ end
161
+
162
+ def leaf_hash_bytes(leaf_hash)
163
+ bytes = Digest.bytes_of(leaf_hash, "leaf_hash")
164
+ unless bytes.bytesize == @hasher.digest_size
165
+ raise ArgumentError, "leaf_hash must be #{@hasher.digest_size} bytes, got #{bytes.bytesize}"
166
+ end
167
+
168
+ bytes
169
+ end
170
+
171
+ def check_tree_size!(tree_size)
172
+ raise ArgumentError, "tree_size must be an Integer, got #{tree_size.class}" unless tree_size.is_a?(Integer)
173
+ return if tree_size.between?(0, self.tree_size)
174
+
175
+ raise ArgumentError, "tree_size must be between 0 and #{self.tree_size}, got #{tree_size}"
176
+ end
177
+
178
+ def check_index!(index, tree_size)
179
+ raise ArgumentError, "index must be an Integer, got #{index.class}" unless index.is_a?(Integer)
180
+ return if index.between?(0, tree_size - 1)
181
+
182
+ raise ArgumentError, "index must be between 0 and #{tree_size - 1}, got #{index}"
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ # Proof verification that does not need the tree itself.
5
+ #
6
+ # The algorithms follow RFC 9162 §2.1.3.2 (inclusion) and §2.1.4.2
7
+ # (consistency). Hash arguments accept CTMerkle::Digest or binary Strings.
8
+ module Verifier
9
+ module_function
10
+
11
+ # Returns true or false.
12
+ def verify_inclusion(leaf_hash:, leaf_index:, tree_size:, inclusion_path:, root:, hasher: Hasher::SHA256)
13
+ verify_inclusion!(leaf_hash: leaf_hash, leaf_index: leaf_index, tree_size: tree_size,
14
+ inclusion_path: inclusion_path, root: root, hasher: hasher)
15
+ rescue VerificationError
16
+ false
17
+ end
18
+
19
+ # Returns true, or raises MalformedProofError / RootMismatchError.
20
+ def verify_inclusion!(leaf_hash:, leaf_index:, tree_size:, inclusion_path:, root:, hasher: Hasher::SHA256)
21
+ check_integer!(leaf_index, "leaf_index")
22
+ check_integer!(tree_size, "tree_size")
23
+ leaf = hash_bytes(leaf_hash, "leaf_hash", hasher)
24
+ expected_root = hash_bytes(root, "root", hasher)
25
+ path = path_bytes(inclusion_path, "inclusion_path", hasher)
26
+
27
+ if leaf_index.negative? || tree_size.negative?
28
+ raise MalformedProofError, "leaf_index and tree_size must not be negative"
29
+ end
30
+ # Step 1
31
+ if leaf_index >= tree_size
32
+ raise MalformedProofError, "leaf_index (#{leaf_index}) must be less than tree_size (#{tree_size})"
33
+ end
34
+
35
+ # Steps 2-3
36
+ fn = leaf_index
37
+ sn = tree_size - 1
38
+ r = leaf
39
+
40
+ # Step 4
41
+ path.each do |p|
42
+ raise MalformedProofError, "inclusion_path is too long" if sn.zero?
43
+
44
+ if fn.odd? || fn == sn
45
+ r = hasher.node(p, r)
46
+ until fn.odd? || fn.zero?
47
+ fn >>= 1
48
+ sn >>= 1
49
+ end
50
+ else
51
+ r = hasher.node(r, p)
52
+ end
53
+ fn >>= 1
54
+ sn >>= 1
55
+ end
56
+
57
+ # Step 5
58
+ raise MalformedProofError, "inclusion_path is too short" unless sn.zero?
59
+ raise RootMismatchError, "calculated root does not match the expected root" unless r == expected_root
60
+
61
+ true
62
+ end
63
+
64
+ # Returns true or false.
65
+ def verify_consistency(old_size:, new_size:, old_root:, new_root:, consistency_path:, hasher: Hasher::SHA256)
66
+ verify_consistency!(old_size: old_size, new_size: new_size, old_root: old_root, new_root: new_root,
67
+ consistency_path: consistency_path, hasher: hasher)
68
+ rescue VerificationError
69
+ false
70
+ end
71
+
72
+ # Returns true, or raises MalformedProofError / RootMismatchError.
73
+ def verify_consistency!(old_size:, new_size:, old_root:, new_root:, consistency_path:, hasher: Hasher::SHA256)
74
+ check_integer!(old_size, "old_size")
75
+ check_integer!(new_size, "new_size")
76
+ first_hash = hash_bytes(old_root, "old_root", hasher)
77
+ second_hash = hash_bytes(new_root, "new_root", hasher)
78
+ path = path_bytes(consistency_path, "consistency_path", hasher)
79
+
80
+ raise MalformedProofError, "old_size and new_size must not be negative" if old_size.negative? || new_size.negative?
81
+ raise MalformedProofError, "old_size (#{old_size}) is greater than new_size (#{new_size})" if old_size > new_size
82
+ raise MalformedProofError, "a consistency proof from an empty tree is not defined" if old_size.zero?
83
+
84
+ if old_size == new_size
85
+ raise MalformedProofError, "consistency_path must be empty when old_size == new_size" unless path.empty?
86
+ raise RootMismatchError, "old_root and new_root differ although the sizes are equal" unless first_hash == second_hash
87
+
88
+ return true
89
+ end
90
+
91
+ # Step 1
92
+ raise MalformedProofError, "consistency_path is empty" if path.empty?
93
+
94
+ # Step 2
95
+ path = [first_hash, *path] if power_of_two?(old_size)
96
+
97
+ # Step 3
98
+ fn = old_size - 1
99
+ sn = new_size - 1
100
+
101
+ # Step 4
102
+ while fn.odd?
103
+ fn >>= 1
104
+ sn >>= 1
105
+ end
106
+
107
+ # Step 5
108
+ fr = sr = path.first
109
+
110
+ # Step 6
111
+ path.drop(1).each do |c|
112
+ raise MalformedProofError, "consistency_path is too long" if sn.zero?
113
+
114
+ if fn.odd? || fn == sn
115
+ fr = hasher.node(c, fr)
116
+ sr = hasher.node(c, sr)
117
+ until fn.odd? || fn.zero?
118
+ fn >>= 1
119
+ sn >>= 1
120
+ end
121
+ else
122
+ sr = hasher.node(sr, c)
123
+ end
124
+ fn >>= 1
125
+ sn >>= 1
126
+ end
127
+
128
+ # Step 7
129
+ raise MalformedProofError, "consistency_path is too short" unless sn.zero?
130
+ raise RootMismatchError, "calculated old root does not match old_root" unless fr == first_hash
131
+ raise RootMismatchError, "calculated new root does not match new_root" unless sr == second_hash
132
+
133
+ true
134
+ end
135
+
136
+ def power_of_two?(n)
137
+ n.positive? && n.nobits?(n - 1)
138
+ end
139
+
140
+ def check_integer!(value, name)
141
+ raise ArgumentError, "#{name} must be an Integer, got #{value.class}" unless value.is_a?(Integer)
142
+ end
143
+
144
+ def hash_bytes(value, name, hasher)
145
+ bytes = Digest.bytes_of(value, name)
146
+ unless bytes.bytesize == hasher.digest_size
147
+ raise MalformedProofError, "#{name} must be #{hasher.digest_size} bytes, got #{bytes.bytesize}"
148
+ end
149
+
150
+ bytes
151
+ end
152
+
153
+ def path_bytes(path, name, hasher)
154
+ raise ArgumentError, "#{name} must be an Array, got #{path.class}" unless path.is_a?(Array)
155
+
156
+ path.each_with_index.map { |h, i| hash_bytes(h, "#{name}[#{i}]", hasher) }
157
+ end
158
+
159
+ private_class_method :power_of_two?, :check_integer!, :hash_bytes, :path_bytes
160
+ end
161
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CTMerkle
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ct_merkle.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ct_merkle/version"
4
+ require_relative "ct_merkle/errors"
5
+ require_relative "ct_merkle/hasher"
6
+ require_relative "ct_merkle/digest"
7
+ require_relative "ct_merkle/serialization"
8
+ require_relative "ct_merkle/verifier"
9
+ require_relative "ct_merkle/inclusion_proof"
10
+ require_relative "ct_merkle/consistency_proof"
11
+ require_relative "ct_merkle/tree"
12
+ require_relative "ct_merkle/snapshot"
13
+
14
+ # RFC 6962 / RFC 9162 Merkle trees.
15
+ module CTMerkle
16
+ # Computes the root of a tree built from leaves (raw data) in one call.
17
+ def self.root(leaves, hasher: Hasher::SHA256)
18
+ Tree.new(leaves, hasher: hasher).root
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ct_merkle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ken Tanaka
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Merkle tree hashing, inclusion proofs and consistency proofs as defined
13
+ in RFC 6962 and RFC 9162 (Certificate Transparency), with no runtime dependencies.
14
+ email:
15
+ - tanaka-ken@netlab.jp
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE.txt
21
+ - README.md
22
+ - lib/ct_merkle.rb
23
+ - lib/ct_merkle/consistency_proof.rb
24
+ - lib/ct_merkle/digest.rb
25
+ - lib/ct_merkle/errors.rb
26
+ - lib/ct_merkle/hasher.rb
27
+ - lib/ct_merkle/inclusion_proof.rb
28
+ - lib/ct_merkle/serialization.rb
29
+ - lib/ct_merkle/snapshot.rb
30
+ - lib/ct_merkle/tree.rb
31
+ - lib/ct_merkle/verifier.rb
32
+ - lib/ct_merkle/version.rb
33
+ homepage: https://github.com/xketanaka/ct_merkle
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ source_code_uri: https://github.com/xketanaka/ct_merkle
38
+ bug_tracker_uri: https://github.com/xketanaka/ct_merkle/issues
39
+ documentation_uri: https://github.com/xketanaka/ct_merkle/blob/main/README.md
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.3'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.6.9
56
+ specification_version: 4
57
+ summary: Append-only Merkle trees compliant with RFC 6962 / RFC 9162
58
+ test_files: []