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.
@@ -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
@@ -1,109 +1,92 @@
1
1
  require_relative 'tree'
2
2
  require_relative 'crypto'
3
+ require_relative 'constants'
3
4
 
4
- module Melos::SecretTree
5
- def self.create(suite, n_leaves, encryption_secret)
6
- st = Melos::Tree.empty_tree(n_leaves)
7
- populate_tree(suite, st, encryption_secret)
8
- st
9
- end
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
- def self.populate_tree(suite, tree, root_secret)
12
- populate_tree_impl(suite, tree, tree.root, root_secret)
13
- end
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
- def self.populate_tree_impl(suite, tree, index, secret)
16
- tree.array[index] = {
17
- 'handshake_ratchet_secret' => Melos::Crypto.expand_with_label(suite, secret, "handshake", "", suite.kdf.n_h),
18
- 'application_ratchet_secret' => Melos::Crypto.expand_with_label(suite, secret, "application", "", suite.kdf.n_h),
19
- 'next_handshake_ratchet_secret_generation' => 0,
20
- 'next_application_ratchet_secret_generation' => 0
21
- }
22
- unless Melos::Tree.leaf?(index)
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
- def self.ratchet_until_and_get(suite, content_type, tree, leaf_index, generation)
31
- # TODO: clear the value on tree when getting
32
- case content_type
33
- when 0x02, 0x03
34
- ratchet_handshake_until(suite, tree, leaf_index, generation)
35
- [
36
- tree.leaf_at(leaf_index)['handshake_key'],
37
- tree.leaf_at(leaf_index)['handshake_nonce'],
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
- def self.ratchet_and_get(suite, content_type, tree, leaf_index)
51
- # TODO: clear the value on tree when getting
52
- case content_type
53
- when 0x02, 0x03
54
- ratchet_handshake(suite, tree, leaf_index)
55
- [
56
- tree.leaf_at(leaf_index)['handshake_key'],
57
- tree.leaf_at(leaf_index)['handshake_nonce'],
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
- def self.ratchet_application_until(suite, tree, leaf_index, generation)
71
- while (tree.leaf_at(leaf_index)['next_application_ratchet_secret_generation'] <= generation)
72
- ratchet_application(suite, tree, leaf_index)
73
- end
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
- def self.ratchet_handshake_until(suite, tree, leaf_index, generation)
77
- raise ArgumentError.new('cannot generate past generation') if generation < tree.leaf_at(leaf_index)['next_handshake_ratchet_secret_generation'] - 1
78
- # if current generation, do nothing
79
- while (tree.leaf_at(leaf_index)['next_handshake_ratchet_secret_generation'] <= generation)
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
- def self.ratchet_application(suite, tree, leaf_index)
85
- node_index = leaf_index * 2
86
- generation = tree.array[node_index]['next_application_ratchet_secret_generation']
87
- application_ratchet_secret = tree.array[node_index]['application_ratchet_secret']
88
- application_nonce = Melos::Crypto.derive_tree_secret(suite, application_ratchet_secret, "nonce", generation, suite.hpke.n_n)
89
- application_key = Melos::Crypto.derive_tree_secret(suite, application_ratchet_secret, "key", generation, suite.hpke.n_k)
90
- next_application_ratchet_secret = Melos::Crypto.derive_tree_secret(suite, application_ratchet_secret, "secret", generation, suite.kdf.n_h)
91
- tree.array[node_index]['next_application_ratchet_secret_generation'] = generation + 1
92
- tree.array[node_index]['application_ratchet_secret'] = next_application_ratchet_secret
93
- tree.array[node_index]['application_nonce'] = application_nonce
94
- tree.array[node_index]['application_key'] = application_key
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
- def self.ratchet_handshake(suite, tree, leaf_index)
98
- node_index = leaf_index * 2
99
- generation = tree.array[node_index]['next_handshake_ratchet_secret_generation']
100
- handshake_ratchet_secret = tree.array[node_index]['handshake_ratchet_secret']
101
- handshake_nonce = Melos::Crypto.derive_tree_secret(suite, handshake_ratchet_secret, "nonce", generation, suite.hpke.n_n)
102
- handshake_key = Melos::Crypto.derive_tree_secret(suite, handshake_ratchet_secret, "key", generation, suite.hpke.n_k)
103
- next_handshake_ratchet_secret = Melos::Crypto.derive_tree_secret(suite, handshake_ratchet_secret, "secret", generation, suite.kdf.n_h)
104
- tree.array[node_index]['next_handshake_ratchet_secret_generation'] = generation + 1
105
- tree.array[node_index]['handshake_ratchet_secret'] = next_handshake_ratchet_secret
106
- tree.array[node_index]['handshake_nonce'] = handshake_nonce
107
- tree.array[node_index]['handshake_key'] = handshake_key
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