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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/lib/ct_merkle/consistency_proof.rb +52 -0
- data/lib/ct_merkle/digest.rb +91 -0
- data/lib/ct_merkle/errors.rb +17 -0
- data/lib/ct_merkle/hasher.rb +45 -0
- data/lib/ct_merkle/inclusion_proof.rb +59 -0
- data/lib/ct_merkle/serialization.rb +54 -0
- data/lib/ct_merkle/snapshot.rb +58 -0
- data/lib/ct_merkle/tree.rb +185 -0
- data/lib/ct_merkle/verifier.rb +161 -0
- data/lib/ct_merkle/version.rb +5 -0
- data/lib/ct_merkle.rb +20 -0
- metadata +58 -0
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
|
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: []
|