melos 0.0.2 → 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 +4 -4
- data/README.md +63 -11
- data/docs/validation.md +168 -0
- data/lib/melos/capabilities.rb +15 -0
- data/lib/melos/client.rb +85 -0
- data/lib/melos/crypto.rb +84 -15
- data/lib/melos/epoch_secrets.rb +113 -0
- data/lib/melos/group/validation.rb +237 -0
- data/lib/melos/group.rb +594 -0
- data/lib/melos/key_package_bundle.rb +59 -0
- data/lib/melos/private_tree.rb +51 -0
- data/lib/melos/ratchet_tree.rb +407 -0
- data/lib/melos/secret_tree.rb +72 -89
- data/lib/melos/struct/base.rb +46 -20
- data/lib/melos/struct/structs.rb +162 -87
- data/lib/melos/tree.rb +4 -120
- data/lib/melos/vec.rb +20 -35
- data/lib/melos/version.rb +1 -1
- data/lib/melos.rb +9 -2
- metadata +13 -6
- data/lib/melos/key_schedule.rb +0 -62
- data/lib/melos/struct/ratchet_tree.rb +0 -324
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require_relative 'crypto'
|
|
2
|
+
|
|
3
|
+
# A member's private half of the ratchet tree (RFC 9420 Sections 7.4 and 7.5): the HPKE private
|
|
4
|
+
# keys this member holds, by node index. That is its own leaf, plus the parent nodes on its direct
|
|
5
|
+
# path whose path secrets it has learned, either by creating an UpdatePath or by decrypting one.
|
|
6
|
+
class Melos::PrivateTree
|
|
7
|
+
def initialize(keys = {})
|
|
8
|
+
@keys = keys
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def [](node_index)
|
|
12
|
+
@keys[node_index]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def []=(node_index, private_key)
|
|
16
|
+
@keys[node_index] = private_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def delete(node_index)
|
|
20
|
+
@keys.delete(node_index)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def node_indexes
|
|
24
|
+
@keys.keys.sort
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dup
|
|
28
|
+
self.class.new(@keys.dup)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 7.4: starting with path_secret at start_node_index, derive the node private key there and at
|
|
32
|
+
# every node up its filtered direct path on ratchet_tree, each from the next path secret
|
|
33
|
+
def set_path_secret(ratchet_tree, start_node_index, path_secret)
|
|
34
|
+
suite = ratchet_tree.suite
|
|
35
|
+
secret = path_secret
|
|
36
|
+
([start_node_index] + ratchet_tree.filtered_direct_path(start_node_index)).each do |node_index|
|
|
37
|
+
node_secret = Melos::Crypto.derive_secret(suite, secret, "node")
|
|
38
|
+
private_key, _public_key = Melos::Crypto.derive_key_pair(suite, node_secret)
|
|
39
|
+
@keys[node_index] = private_key
|
|
40
|
+
secret = Melos::Crypto.derive_secret(suite, secret, "path")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# every private key here belongs to a non-blank node of ratchet_tree whose public key it matches
|
|
45
|
+
def consistent?(ratchet_tree)
|
|
46
|
+
@keys.all? do |node_index, private_key|
|
|
47
|
+
node = ratchet_tree[node_index]
|
|
48
|
+
!node.nil? && Melos::Crypto.encapsulation_key_pair_corresponds?(ratchet_tree.suite, private_key, node.public_encryption_key)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
require_relative 'tree'
|
|
3
|
+
require_relative 'crypto'
|
|
4
|
+
require_relative 'vec'
|
|
5
|
+
require_relative 'constants'
|
|
6
|
+
require_relative 'struct/structs'
|
|
7
|
+
|
|
8
|
+
# The ratchet tree (RFC 9420 Section 7) in the array representation of Appendix C:
|
|
9
|
+
# nodes[i] is the Melos::Struct::Node at node index i, or nil for a blank node.
|
|
10
|
+
#
|
|
11
|
+
# The tree is always full (7.7): the number of leaves is a power of two, so @nodes
|
|
12
|
+
# always has 2^(d+1) - 1 entries. Extending the tree doubles it with a blank right
|
|
13
|
+
# subtree, and truncating halves it while the right subtree is entirely blank.
|
|
14
|
+
#
|
|
15
|
+
# This is the public tree every member of a group shares. A member's own private keys
|
|
16
|
+
# are kept separately in a Melos::PrivateTree and handed to the methods that need them.
|
|
17
|
+
#
|
|
18
|
+
# Wire format is the ratchet_tree extension (12.4.3.3): optional<Node> ratchet_tree<V>,
|
|
19
|
+
# with the blank nodes after the last non-blank node left out.
|
|
20
|
+
class Melos::RatchetTree
|
|
21
|
+
attr_reader :suite, :nodes
|
|
22
|
+
|
|
23
|
+
# nodes may omit trailing blanks (as the wire format does); it is padded to a full tree
|
|
24
|
+
def initialize(suite, nodes = [])
|
|
25
|
+
@suite = suite
|
|
26
|
+
@nodes = nodes
|
|
27
|
+
pad_to_full_tree!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 12.4.3.3: the sender leaves out the blank nodes after the last non-blank node,
|
|
31
|
+
# so the last node must be non-blank; the receiver extends the tree back to 2^(d+1) - 1 nodes
|
|
32
|
+
def self.parse(suite, stream)
|
|
33
|
+
stream = StringIO.new(stream) if stream.is_a?(String)
|
|
34
|
+
nodes = []
|
|
35
|
+
data_stream = StringIO.new(Melos::Vec.parse_stream(stream))
|
|
36
|
+
until data_stream.eof?
|
|
37
|
+
presence = data_stream.read(1).unpack1('C')
|
|
38
|
+
nodes << (presence == 1 ? Melos::Struct::Node.new(data_stream) : nil)
|
|
39
|
+
end
|
|
40
|
+
raise ArgumentError.new('last node of ratchet_tree must not be blank') if !nodes.empty? && nodes.last.nil?
|
|
41
|
+
new(suite, nodes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 12.4.3.3: blank nodes after the last non-blank node are not included
|
|
45
|
+
def raw
|
|
46
|
+
last = @nodes.rindex { !_1.nil? }
|
|
47
|
+
encoded = last.nil? ? [] : @nodes[0..last]
|
|
48
|
+
buf = encoded.map { |node| node.nil? ? [0].pack('C') : [1].pack('C') + node.raw }.join
|
|
49
|
+
Melos::Vec.string_to_vec(buf)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# deep copy via the wire format, so that in-place changes to nodes
|
|
53
|
+
# (e.g. unmerged_leaves) on one copy do not affect the other
|
|
54
|
+
def dup
|
|
55
|
+
self.class.parse(@suite, raw)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def [](node_index)
|
|
59
|
+
@nodes[node_index]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def node_count
|
|
63
|
+
@nodes.count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# a power of two; some of the leaves may be blank
|
|
67
|
+
def n_leaves
|
|
68
|
+
(@nodes.count + 1) / 2
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# node index of the root
|
|
72
|
+
def root
|
|
73
|
+
Melos::Tree.root(n_leaves)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# the Melos::Struct::LeafNode at leaf_index, or nil if that leaf is blank
|
|
77
|
+
def leaf_node_at(leaf_index)
|
|
78
|
+
@nodes[leaf_index * 2]&.leaf_node
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# yields each non-blank leaf node with its leaf index
|
|
82
|
+
def each_leaf_node
|
|
83
|
+
(0...n_leaves).each do |leaf_index|
|
|
84
|
+
leaf_node = leaf_node_at(leaf_index)
|
|
85
|
+
yield leaf_node, leaf_index unless leaf_node.nil?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# leaf index of the first non-blank leaf for which the block is true, or nil
|
|
90
|
+
def find_leaf_index
|
|
91
|
+
each_leaf_node { |leaf_node, leaf_index| return leaf_index if yield(leaf_node) }
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
## Tree math that depends on the tree's contents (7.2, 7.8); pure index math lives in Melos::Tree
|
|
96
|
+
|
|
97
|
+
# 7.2: node indexes a message to this node must be encrypted to
|
|
98
|
+
def resolution(node_index)
|
|
99
|
+
node = @nodes[node_index]
|
|
100
|
+
if node.nil?
|
|
101
|
+
if Melos::Tree.leaf?(node_index)
|
|
102
|
+
# The resolution of a blank leaf node is the empty list.
|
|
103
|
+
[]
|
|
104
|
+
else
|
|
105
|
+
# The resolution of a blank intermediate node is the result of concatenating the resolution of
|
|
106
|
+
# its left child with the resolution of its right child, in that order.
|
|
107
|
+
resolution(Melos::Tree.left(node_index)) + resolution(Melos::Tree.right(node_index))
|
|
108
|
+
end
|
|
109
|
+
elsif node.parent_node
|
|
110
|
+
# The resolution of a non-blank node comprises the node itself, followed by its list of unmerged leaves, if any.
|
|
111
|
+
[node_index] + node.parent_node.unmerged_leaves.map { _1 * 2 } # leaf index -> node index
|
|
112
|
+
else
|
|
113
|
+
[node_index]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 7.8: direct path of node_index minus the nodes whose copath child has an empty resolution
|
|
118
|
+
def filtered_direct_path(node_index)
|
|
119
|
+
Melos::Tree.direct_path(node_index, n_leaves).reject do |ancestor|
|
|
120
|
+
resolution(Melos::Tree.sibling_from_leaf(node_index, ancestor, n_leaves)) == []
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# for each node on the filtered direct path of node_index, its child on the copath side
|
|
125
|
+
def copath_nodes_of_filtered_direct_path(node_index)
|
|
126
|
+
filtered_direct_path(node_index).map { Melos::Tree.sibling_from_leaf(node_index, _1, n_leaves) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 7.7: while the right subtree of the root is entirely blank, drop it, halving the tree
|
|
130
|
+
def truncate!
|
|
131
|
+
return if n_leaves <= 1
|
|
132
|
+
r = root
|
|
133
|
+
if @nodes[(r + 1)..].all?(nil)
|
|
134
|
+
@nodes.slice!(r..) # the left child of the root becomes the root
|
|
135
|
+
truncate!
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
## Tree hashes (7.8) and parent hashes (7.9)
|
|
140
|
+
|
|
141
|
+
def tree_hash(node_index = root)
|
|
142
|
+
node = @nodes[node_index]
|
|
143
|
+
if Melos::Tree.leaf?(node_index)
|
|
144
|
+
leaf_node_hash_input = [node_index / 2].pack('L>') # leaf index
|
|
145
|
+
leaf_node_hash_input += node.nil? ? [0].pack('C') : [1].pack('C') + node.leaf_node.raw
|
|
146
|
+
tree_hash_input = [1].pack('C') + leaf_node_hash_input # node_type = leaf
|
|
147
|
+
else
|
|
148
|
+
parent_node_hash_input = node.nil? ? [0].pack('C') : [1].pack('C') + node.parent_node.raw
|
|
149
|
+
parent_node_hash_input += Melos::Vec.string_to_vec(tree_hash(Melos::Tree.left(node_index)))
|
|
150
|
+
parent_node_hash_input += Melos::Vec.string_to_vec(tree_hash(Melos::Tree.right(node_index)))
|
|
151
|
+
tree_hash_input = [2].pack('C') + parent_node_hash_input # node_type = parent
|
|
152
|
+
end
|
|
153
|
+
Melos::Crypto.hash(@suite, tree_hash_input)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def root_tree_hash
|
|
157
|
+
tree_hash(root)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# 7.9.2: tree hash of node_index computed as if the given leaves were blank
|
|
161
|
+
def tree_hash_except(node_index, unmerged_leaves)
|
|
162
|
+
blanked = @nodes.dup
|
|
163
|
+
unmerged_leaves.each { blanked[_1 * 2] = nil }
|
|
164
|
+
self.class.new(@suite, blanked).tree_hash(node_index)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# 7.9: the parent hash of the parent node at node_index, for the child opposite to sibling_index
|
|
168
|
+
def parent_hash_of(node_index, sibling_index)
|
|
169
|
+
parent_node = @nodes[node_index].parent_node
|
|
170
|
+
sibling_hash = tree_hash_except(sibling_index, parent_node.unmerged_leaves)
|
|
171
|
+
Melos::Crypto.parent_hash(@suite, parent_node.encryption_key, parent_node.parent_hash, sibling_hash)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# 7.9.2: the parent node at node_index is parent-hash valid with respect to one of its children
|
|
175
|
+
def parent_hash_valid_at?(node_index)
|
|
176
|
+
return false if Melos::Tree.leaf?(node_index) # there is no parent hash to verify on a leaf
|
|
177
|
+
return true if @nodes[node_index].nil?
|
|
178
|
+
left = Melos::Tree.left(node_index)
|
|
179
|
+
right = Melos::Tree.right(node_index)
|
|
180
|
+
has_parent_hash?(left, parent_hash_of(node_index, right)) || has_parent_hash?(right, parent_hash_of(node_index, left))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 7.9.2: every parent node in the tree is parent-hash valid; checked from the bottom level up
|
|
184
|
+
def parent_hash_valid?
|
|
185
|
+
parent_indexes = (1..((@nodes.count - 1) / 2)).map { _1 * 2 - 1 } # odd node indexes
|
|
186
|
+
parent_indexes.sort_by { Melos::Tree.level(_1) }.all? { parent_hash_valid_at?(_1) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 7.9.1: parent hashes along the filtered direct path of leaf_index, given the new encryption keys of an UpdatePath.
|
|
190
|
+
# hashes[i] is the parent hash of the i-th filtered direct path node, which is stored in the node below it:
|
|
191
|
+
# hashes[0] goes into the leaf, hashes[i] into filtered direct path node i-1, and hashes[count] is '' (the root).
|
|
192
|
+
def calculate_parent_hashes(leaf_index, update_path_nodes)
|
|
193
|
+
fdp = filtered_direct_path(leaf_index * 2)
|
|
194
|
+
hashes = []
|
|
195
|
+
hashes[fdp.count] = ''
|
|
196
|
+
calculated = ''
|
|
197
|
+
(fdp.count - 1).downto(0) do |i|
|
|
198
|
+
sibling_index = Melos::Tree.sibling_from_leaf(leaf_index * 2, fdp[i], n_leaves)
|
|
199
|
+
calculated = Melos::Crypto.parent_hash(@suite, update_path_nodes[i].encryption_key, calculated, tree_hash(sibling_index))
|
|
200
|
+
hashes[i] = calculated
|
|
201
|
+
end
|
|
202
|
+
hashes
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# 7.9.2: the leaf node of update_path carries the parent hash its path implies on this tree
|
|
206
|
+
def verify_parent_hash_of_path(leaf_index, update_path)
|
|
207
|
+
update_path.leaf_node.parent_hash == calculate_parent_hashes(leaf_index, update_path.nodes)[0]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
## Applying proposals (12.1)
|
|
211
|
+
|
|
212
|
+
# 12.1.1 Add: place the leaf at the leftmost blank leaf, extending the tree if there is none (7.7:
|
|
213
|
+
# a new blank root whose left subtree is the existing tree and right subtree is all blank);
|
|
214
|
+
# record it as unmerged on every non-blank parent above it. Returns the new leaf index.
|
|
215
|
+
def add_leaf_node(leaf_node)
|
|
216
|
+
node = Melos::Struct::Node.new_leaf_node(leaf_node)
|
|
217
|
+
if @nodes.empty?
|
|
218
|
+
@nodes << node
|
|
219
|
+
return 0
|
|
220
|
+
end
|
|
221
|
+
inserted_node_index = (0...@nodes.count).step(2).find { @nodes[_1].nil? }
|
|
222
|
+
if inserted_node_index.nil?
|
|
223
|
+
@nodes.concat(Array.new(@nodes.count + 1)) # doubles the tree; the old root's index is the new blank root
|
|
224
|
+
inserted_node_index = (0...@nodes.count).step(2).find { @nodes[_1].nil? }
|
|
225
|
+
end
|
|
226
|
+
@nodes[inserted_node_index] = node
|
|
227
|
+
inserted_leaf_index = inserted_node_index / 2
|
|
228
|
+
Melos::Tree.direct_path(inserted_node_index, n_leaves).each do |ancestor|
|
|
229
|
+
@nodes[ancestor].parent_node.unmerged_leaves << inserted_leaf_index if @nodes[ancestor]&.parent_node
|
|
230
|
+
end
|
|
231
|
+
inserted_leaf_index
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# 12.1.2 Update: replace the leaf and blank its direct path
|
|
235
|
+
def update_leaf_node(leaf_index, leaf_node)
|
|
236
|
+
@nodes[leaf_index * 2] = Melos::Struct::Node.new_leaf_node(leaf_node)
|
|
237
|
+
blank_direct_path(leaf_index * 2)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# 12.1.3 Remove: blank the leaf and its direct path, then truncate the tree
|
|
241
|
+
def remove_leaf_node(leaf_index)
|
|
242
|
+
@nodes[leaf_index * 2] = nil
|
|
243
|
+
blank_direct_path(leaf_index * 2)
|
|
244
|
+
truncate!
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
## TreeKEM (7.4, 7.5, 12.4.2)
|
|
248
|
+
|
|
249
|
+
# 7.5 (both sides): replace the sender's leaf and the parent nodes on its filtered direct path with those in update_path
|
|
250
|
+
def merge_update_path(leaf_index, update_path)
|
|
251
|
+
node_index = leaf_index * 2
|
|
252
|
+
fdp = filtered_direct_path(node_index)
|
|
253
|
+
raise ArgumentError.new('malformed update path') unless fdp.count == update_path.nodes.count
|
|
254
|
+
parent_hashes = calculate_parent_hashes(leaf_index, update_path.nodes)
|
|
255
|
+
fdp.each_with_index do |fdp_node_index, i|
|
|
256
|
+
parent_node = Melos::Struct::ParentNode.create(
|
|
257
|
+
encryption_key: update_path.nodes[i].encryption_key,
|
|
258
|
+
parent_hash: parent_hashes[i + 1],
|
|
259
|
+
unmerged_leaves: []
|
|
260
|
+
)
|
|
261
|
+
@nodes[fdp_node_index] = Melos::Struct::Node.new_parent_node(parent_node)
|
|
262
|
+
end
|
|
263
|
+
@nodes[node_index] = Melos::Struct::Node.new_leaf_node(update_path.leaf_node)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# 7.5 (sender side): build an UpdatePath from a fresh leaf secret. Does not modify this tree; the caller
|
|
267
|
+
# merges the returned path with #merge_update_path once the commit is in place. The sender's new leaf
|
|
268
|
+
# private key and the private keys of its filtered direct path are written into private_tree.
|
|
269
|
+
# group_context: the provisional GroupContext of the new epoch (12.4.2). Its tree_hash is ignored and
|
|
270
|
+
# replaced with the hash of the tree after this path is merged, which the path secrets are encrypted with.
|
|
271
|
+
# except: node indexes to leave out of the copath resolutions (leaves being added by the same commit)
|
|
272
|
+
# Returns { update_path:, commit_secret:, path_secrets: } with path_secrets by node index of the filtered direct path.
|
|
273
|
+
def create_update_path(private_tree, sender_leaf_index, signature_private_key, group_context, except = [])
|
|
274
|
+
tree = dup
|
|
275
|
+
group_id = group_context.group_id
|
|
276
|
+
sender_node_index = sender_leaf_index * 2
|
|
277
|
+
original_leaf_node = tree.leaf_node_at(sender_leaf_index)
|
|
278
|
+
fdp = tree.filtered_direct_path(sender_node_index)
|
|
279
|
+
|
|
280
|
+
# 7.4: path secrets up the filtered direct path; the commit secret is one more derivation past the last one
|
|
281
|
+
leaf_secret = SecureRandom.random_bytes(@suite.kdf.n_h)
|
|
282
|
+
path_secrets = []
|
|
283
|
+
secret = leaf_secret
|
|
284
|
+
fdp.each do
|
|
285
|
+
secret = Melos::Crypto.derive_secret(@suite, secret, "path")
|
|
286
|
+
path_secrets << secret
|
|
287
|
+
end
|
|
288
|
+
commit_secret = Melos::Crypto.derive_secret(@suite, secret, "path")
|
|
289
|
+
|
|
290
|
+
leaf_private_key, leaf_public_key = Melos::Crypto.derive_key_pair(@suite, Melos::Crypto.derive_secret(@suite, leaf_secret, "node"))
|
|
291
|
+
node_key_pairs = path_secrets.map { Melos::Crypto.derive_key_pair(@suite, Melos::Crypto.derive_secret(@suite, _1, "node")) }
|
|
292
|
+
|
|
293
|
+
# parent hashes depend on the new public keys only, so compute them from nodes without ciphertexts
|
|
294
|
+
bare_nodes = node_key_pairs.map { |_priv, pub| Melos::Struct::UpdatePathNode.create(encryption_key: pub, encrypted_path_secret: []) }
|
|
295
|
+
parent_hashes = tree.calculate_parent_hashes(sender_leaf_index, bare_nodes)
|
|
296
|
+
new_leaf_node = Melos::Struct::LeafNode.create(
|
|
297
|
+
encryption_key: leaf_public_key,
|
|
298
|
+
signature_key: original_leaf_node.signature_key,
|
|
299
|
+
credential: original_leaf_node.credential,
|
|
300
|
+
capabilities: original_leaf_node.capabilities,
|
|
301
|
+
leaf_node_source: Melos::Constants::LeafNodeSource::COMMIT,
|
|
302
|
+
lifetime: original_leaf_node.lifetime,
|
|
303
|
+
parent_hash: parent_hashes[0],
|
|
304
|
+
extensions: original_leaf_node.extensions,
|
|
305
|
+
signature: nil
|
|
306
|
+
)
|
|
307
|
+
new_leaf_node.sign(@suite, signature_private_key, group_id, sender_leaf_index)
|
|
308
|
+
|
|
309
|
+
# merge into the copy so the copath resolutions below are those of the tree the receivers will see,
|
|
310
|
+
# and so that the provisional GroupContext carries the new tree hash
|
|
311
|
+
tree.merge_update_path(sender_leaf_index, Melos::Struct::UpdatePath.create(leaf_node: new_leaf_node, nodes: bare_nodes))
|
|
312
|
+
provisional_group_context = Melos::Struct::GroupContext.create(
|
|
313
|
+
cipher_suite: group_context.cipher_suite, group_id: group_context.group_id, epoch: group_context.epoch,
|
|
314
|
+
tree_hash: tree.root_tree_hash, confirmed_transcript_hash: group_context.confirmed_transcript_hash, extensions: group_context.extensions
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
copath_nodes = tree.copath_nodes_of_filtered_direct_path(sender_node_index)
|
|
318
|
+
nodes = fdp.each_index.map do |i|
|
|
319
|
+
ciphertexts = (tree.resolution(copath_nodes[i]) - except).map do |resolution_node_index|
|
|
320
|
+
kem_output, ciphertext = Melos::Crypto.encrypt_with_label(
|
|
321
|
+
@suite, tree[resolution_node_index].public_encryption_key, "UpdatePathNode", provisional_group_context.raw, path_secrets[i]
|
|
322
|
+
)
|
|
323
|
+
Melos::Struct::HPKECipherText.create(kem_output: kem_output, ciphertext: ciphertext)
|
|
324
|
+
end
|
|
325
|
+
Melos::Struct::UpdatePathNode.create(encryption_key: node_key_pairs[i][1], encrypted_path_secret: ciphertexts)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private_tree[sender_node_index] = leaf_private_key
|
|
329
|
+
fdp.each_with_index { |fdp_node_index, i| private_tree[fdp_node_index] = node_key_pairs[i][0] }
|
|
330
|
+
|
|
331
|
+
{
|
|
332
|
+
update_path: Melos::Struct::UpdatePath.create(leaf_node: new_leaf_node, nodes: nodes),
|
|
333
|
+
commit_secret: commit_secret,
|
|
334
|
+
path_secrets: fdp.zip(path_secrets).to_h
|
|
335
|
+
}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# 7.5 (receiver side), on the tree with update_path already merged: decrypt the path secret of the node where
|
|
339
|
+
# the receiver's direct path meets the sender's filtered direct path, and derive the private keys from that
|
|
340
|
+
# node up to the root into private_tree.
|
|
341
|
+
# leaves_to_remove: leaf indexes (added by the same commit) left out of the copath resolution
|
|
342
|
+
# Returns the decrypted path secret, for #calculate_commit_secret.
|
|
343
|
+
def decrypt_path_secret(private_tree, update_path, sender_leaf_index, receiver_leaf_index, group_context, leaves_to_remove = [])
|
|
344
|
+
fdp = filtered_direct_path(sender_leaf_index * 2)
|
|
345
|
+
raise ArgumentError.new('malformed update path') unless fdp.count == update_path.nodes.count
|
|
346
|
+
overlap_node_index = Melos::Tree.overlap_with_filtered_direct_path(receiver_leaf_index * 2, fdp, n_leaves)
|
|
347
|
+
overlap_index = fdp.index(overlap_node_index)
|
|
348
|
+
copath_node_index = copath_nodes_of_filtered_direct_path(sender_leaf_index * 2)[overlap_index]
|
|
349
|
+
resolution = resolution(copath_node_index) - leaves_to_remove.map { _1 * 2 } # leaf index -> node index
|
|
350
|
+
update_path_node = update_path.nodes[overlap_index]
|
|
351
|
+
raise ArgumentError.new('# of resolution of copath node does not match with # of encrypted path secrets') unless update_path_node.encrypted_path_secret.count == resolution.count
|
|
352
|
+
|
|
353
|
+
priv_index = resolution.rindex { private_tree[_1] }
|
|
354
|
+
raise ArgumentError.new("priv key not found in tree (sender leaf index: #{sender_leaf_index})") if priv_index.nil?
|
|
355
|
+
encrypted_path_secret = update_path_node.encrypted_path_secret[priv_index]
|
|
356
|
+
path_secret = Melos::Crypto.decrypt_with_label(
|
|
357
|
+
@suite, private_tree[resolution[priv_index]], "UpdatePathNode", group_context.raw,
|
|
358
|
+
encrypted_path_secret.kem_output, encrypted_path_secret.ciphertext
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
private_tree.set_path_secret(self, overlap_node_index, path_secret)
|
|
362
|
+
path_secret
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# 7.4: the commit secret is the path secret one derivation past the last node of the sender's filtered direct path
|
|
366
|
+
def calculate_commit_secret(update_path, sender_leaf_index, receiver_leaf_index, path_secret)
|
|
367
|
+
fdp = filtered_direct_path(sender_leaf_index * 2)
|
|
368
|
+
raise ArgumentError.new('malformed update path') unless fdp.count == update_path.nodes.count
|
|
369
|
+
overlap_node_index = Melos::Tree.overlap_with_filtered_direct_path(receiver_leaf_index * 2, fdp, n_leaves)
|
|
370
|
+
secret = path_secret
|
|
371
|
+
(fdp.count - 1 - fdp.index(overlap_node_index)).times do
|
|
372
|
+
secret = Melos::Crypto.derive_secret(@suite, secret, "path")
|
|
373
|
+
end
|
|
374
|
+
Melos::Crypto.derive_secret(@suite, secret, "path")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# for debugging
|
|
378
|
+
def dump
|
|
379
|
+
@nodes.each_with_index do |node, index|
|
|
380
|
+
if node.nil?
|
|
381
|
+
puts "#{index}, nil"
|
|
382
|
+
elsif node.parent_node
|
|
383
|
+
puts "#{index}, PN (#{Melos::Util.to_hex(node.public_encryption_key)[0, 8]}) - #{node.parent_node.unmerged_leaves}"
|
|
384
|
+
else
|
|
385
|
+
puts "#{index}, LN (#{Melos::Util.to_hex(node.public_encryption_key)[0, 8]})"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
private
|
|
391
|
+
|
|
392
|
+
# extend @nodes with blanks to the next full tree size 2^(d+1) - 1
|
|
393
|
+
def pad_to_full_tree!
|
|
394
|
+
return if @nodes.empty?
|
|
395
|
+
full_leaves = 1
|
|
396
|
+
full_leaves <<= 1 while full_leaves < n_leaves
|
|
397
|
+
@nodes.concat(Array.new(Melos::Tree.node_width(full_leaves) - @nodes.count))
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def has_parent_hash?(child_index, parent_hash_value)
|
|
401
|
+
resolution(child_index).any? { @nodes[_1]&.parent_hash_in_node == parent_hash_value }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def blank_direct_path(node_index)
|
|
405
|
+
Melos::Tree.direct_path(node_index, n_leaves).each { @nodes[_1] = nil }
|
|
406
|
+
end
|
|
407
|
+
end
|
data/lib/melos/secret_tree.rb
CHANGED
|
@@ -1,109 +1,92 @@
|
|
|
1
1
|
require_relative 'tree'
|
|
2
2
|
require_relative 'crypto'
|
|
3
|
+
require_relative 'constants'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
# The secret tree (RFC 9420 Section 9): the per-epoch tree of secrets derived from the
|
|
6
|
+
# encryption_secret, with the same shape as the ratchet tree. Only the two hash ratchets
|
|
7
|
+
# at each leaf are kept; the node secrets above them are discarded once derived.
|
|
8
|
+
class Melos::SecretTree
|
|
9
|
+
# 9.1: a hash ratchet over one leaf's secret. Each generation yields a key and nonce
|
|
10
|
+
# and steps the secret forward; older secrets are forgotten.
|
|
11
|
+
class Ratchet
|
|
12
|
+
attr_reader :next_generation
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
def initialize(suite, secret)
|
|
15
|
+
@suite = suite
|
|
16
|
+
@secret = secret
|
|
17
|
+
@next_generation = 0
|
|
18
|
+
@current = nil # [key, nonce, generation] of the last generation derived
|
|
19
|
+
end
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
left_secret = Melos::Crypto.expand_with_label(suite, secret, "tree", "left", suite.kdf.n_h)
|
|
24
|
-
right_secret = Melos::Crypto.expand_with_label(suite, secret, "tree", "right", suite.kdf.n_h)
|
|
25
|
-
populate_tree_impl(suite, tree, Melos::Tree.left(index), left_secret)
|
|
26
|
-
populate_tree_impl(suite, tree, Melos::Tree.right(index), right_secret)
|
|
21
|
+
# derive the key and nonce of the current generation and step the ratchet
|
|
22
|
+
def ratchet
|
|
23
|
+
generation = @next_generation
|
|
24
|
+
nonce = Melos::Crypto.derive_tree_secret(@suite, @secret, "nonce", generation, @suite.hpke.n_n)
|
|
25
|
+
key = Melos::Crypto.derive_tree_secret(@suite, @secret, "key", generation, @suite.hpke.n_k)
|
|
26
|
+
@secret = Melos::Crypto.derive_tree_secret(@suite, @secret, "secret", generation, @suite.kdf.n_h)
|
|
27
|
+
@next_generation += 1
|
|
28
|
+
@current = [key, nonce, generation]
|
|
27
29
|
end
|
|
28
|
-
end
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
# TODO:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
generation
|
|
39
|
-
]
|
|
40
|
-
else
|
|
41
|
-
ratchet_application_until(suite, tree, leaf_index, generation)
|
|
42
|
-
[
|
|
43
|
-
tree.leaf_at(leaf_index)['application_key'],
|
|
44
|
-
tree.leaf_at(leaf_index)['application_nonce'],
|
|
45
|
-
generation
|
|
46
|
-
]
|
|
31
|
+
# the key and nonce of the given generation, ratcheting forward as needed.
|
|
32
|
+
# TODO (9.1): keep a window of unconsumed generations so that out-of-order messages can be decrypted
|
|
33
|
+
def ratchet_until(generation)
|
|
34
|
+
if generation < @next_generation - 1
|
|
35
|
+
raise ArgumentError.new("generation #{generation} has already been consumed (next generation is #{@next_generation})")
|
|
36
|
+
end
|
|
37
|
+
ratchet while @next_generation <= generation
|
|
38
|
+
@current
|
|
47
39
|
end
|
|
48
40
|
end
|
|
49
41
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
tree.leaf_at(leaf_index)['next_handshake_ratchet_secret_generation'] - 1, # returns current generation
|
|
59
|
-
]
|
|
60
|
-
else
|
|
61
|
-
ratchet_application(suite, tree, leaf_index)
|
|
62
|
-
[
|
|
63
|
-
tree.leaf_at(leaf_index)['application_key'],
|
|
64
|
-
tree.leaf_at(leaf_index)['application_nonce'],
|
|
65
|
-
tree.leaf_at(leaf_index)['next_application_ratchet_secret_generation'] - 1, # returns current generation
|
|
66
|
-
]
|
|
67
|
-
end
|
|
42
|
+
attr_reader :suite, :n_leaves
|
|
43
|
+
|
|
44
|
+
def initialize(suite, n_leaves, encryption_secret)
|
|
45
|
+
@suite = suite
|
|
46
|
+
@n_leaves = n_leaves
|
|
47
|
+
@handshake_ratchets = [] # by leaf index
|
|
48
|
+
@application_ratchets = [] # by leaf index
|
|
49
|
+
populate(Melos::Tree.root(n_leaves), encryption_secret)
|
|
68
50
|
end
|
|
69
51
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
52
|
+
# sender side: the key and nonce of the next generation for this leaf and content type
|
|
53
|
+
# returns [key, nonce, generation]
|
|
54
|
+
def ratchet_and_get(content_type, leaf_index)
|
|
55
|
+
ratchet_for(content_type, leaf_index).ratchet
|
|
74
56
|
end
|
|
75
57
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
ratchet_handshake(suite, tree, leaf_index)
|
|
81
|
-
end
|
|
58
|
+
# receiver side: the key and nonce of a given generation for this leaf and content type
|
|
59
|
+
# returns [key, nonce, generation]
|
|
60
|
+
def ratchet_until_and_get(content_type, leaf_index, generation)
|
|
61
|
+
ratchet_for(content_type, leaf_index).ratchet_until(generation)
|
|
82
62
|
end
|
|
83
63
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# 9: derive the secrets of the subtree under node_index down to the leaves
|
|
67
|
+
def populate(node_index, secret)
|
|
68
|
+
if Melos::Tree.leaf?(node_index)
|
|
69
|
+
leaf_index = node_index / 2
|
|
70
|
+
@handshake_ratchets[leaf_index] = Ratchet.new(@suite, Melos::Crypto.expand_with_label(@suite, secret, "handshake", "", @suite.kdf.n_h))
|
|
71
|
+
@application_ratchets[leaf_index] = Ratchet.new(@suite, Melos::Crypto.expand_with_label(@suite, secret, "application", "", @suite.kdf.n_h))
|
|
72
|
+
else
|
|
73
|
+
left_secret = Melos::Crypto.expand_with_label(@suite, secret, "tree", "left", @suite.kdf.n_h)
|
|
74
|
+
right_secret = Melos::Crypto.expand_with_label(@suite, secret, "tree", "right", @suite.kdf.n_h)
|
|
75
|
+
populate(Melos::Tree.left(node_index), left_secret)
|
|
76
|
+
populate(Melos::Tree.right(node_index), right_secret)
|
|
77
|
+
end
|
|
95
78
|
end
|
|
96
79
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
80
|
+
# 9.1: handshake messages (proposals and commits) and application messages use separate ratchets
|
|
81
|
+
def ratchet_for(content_type, leaf_index)
|
|
82
|
+
ratchets = case content_type
|
|
83
|
+
when Melos::Constants::ContentType::PROPOSAL, Melos::Constants::ContentType::COMMIT
|
|
84
|
+
@handshake_ratchets
|
|
85
|
+
when Melos::Constants::ContentType::APPLICATION
|
|
86
|
+
@application_ratchets
|
|
87
|
+
else
|
|
88
|
+
raise ArgumentError.new("unknown content type #{content_type}")
|
|
89
|
+
end
|
|
90
|
+
ratchets[leaf_index] or raise ArgumentError.new("no leaf #{leaf_index} in secret tree with #{@n_leaves} leaves")
|
|
108
91
|
end
|
|
109
92
|
end
|