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,594 @@
1
+ require 'securerandom'
2
+ require_relative 'crypto'
3
+ require_relative 'constants'
4
+ require_relative 'vec'
5
+ require_relative 'ratchet_tree'
6
+ require_relative 'private_tree'
7
+ require_relative 'epoch_secrets'
8
+ require_relative 'psk'
9
+ require_relative 'struct/structs'
10
+
11
+ # One member's view of one MLS group (RFC 9420 Sections 8, 11 and 12): the public state every
12
+ # member shares (ratchet tree, extensions, transcript hashes, from which the GroupContext is
13
+ # derived), this member's private state (leaf index, private tree, signature key) and the
14
+ # current epoch's key schedule.
15
+ class Melos::Group
16
+ # a member added by a commit: where it landed in the tree, and the KeyPackage it joins with
17
+ Joiner = Struct.new(:leaf_index, :key_package)
18
+
19
+ # 12.4.2: the state of the next epoch, computed from a Commit but not yet in effect.
20
+ # It is produced by #stage_commit (received commits) or #create_commit (our own) and applied by #merge_commit.
21
+ # removes_us: the Commit removed this member; only ratchet_tree, extensions, joiners and psk_ids are then set
22
+ StagedCommit = Struct.new(
23
+ :ratchet_tree, :private_tree, :extensions, :confirmed_transcript_hash, :interim_transcript_hash,
24
+ :epoch_secrets, :group_context, :confirmation_tag, :joiners, :path_secrets, :psk_ids, :removes_us,
25
+ keyword_init: true
26
+ )
27
+
28
+ # the proposal types defined in RFC 9420; any other type is not supported by this implementation (12.2, 13.2)
29
+ KNOWN_PROPOSAL_TYPES = [
30
+ Melos::Constants::ProposalType::ADD, Melos::Constants::ProposalType::UPDATE, Melos::Constants::ProposalType::REMOVE,
31
+ Melos::Constants::ProposalType::PSK, Melos::Constants::ProposalType::REINIT, Melos::Constants::ProposalType::EXTERNAL_INIT,
32
+ Melos::Constants::ProposalType::GROUP_CONTEXT_EXTENSIONS
33
+ ].freeze
34
+
35
+ # what #create_commit produces: the Commit message to send, the Welcome for any joiners (or nil),
36
+ # and the staged state to merge once the Delivery Service has accepted the commit
37
+ CommitResult = Struct.new(:commit, :welcome, :staged, keyword_init: true)
38
+
39
+ attr_reader :suite, :group_id, :epoch, :leaf_index, :ratchet_tree, :private_tree, :extensions,
40
+ :confirmed_transcript_hash, :interim_transcript_hash, :epoch_secrets, :resumption_psks, :external_psks
41
+
42
+ # Use .create or .from_welcome rather than calling this directly.
43
+ # credential_validator: ->(credential, leaf_node) { true/false }, the application's Authentication Service check (5.3.1);
44
+ # the default accepts basic credentials only
45
+ # max_leaf_node_lifetime: longest acceptable Lifetime of a leaf node in seconds (7.2); nil for no limit
46
+ def initialize(suite:, group_id:, epoch:, leaf_index:, signature_private_key:, ratchet_tree:, private_tree:,
47
+ extensions:, confirmed_transcript_hash:, interim_transcript_hash:, epoch_secrets:, external_psks: {},
48
+ credential_validator: DEFAULT_CREDENTIAL_VALIDATOR, max_leaf_node_lifetime: DEFAULT_MAX_LEAF_NODE_LIFETIME)
49
+ @suite = suite
50
+ @group_id = group_id
51
+ @epoch = epoch
52
+ @leaf_index = leaf_index
53
+ @signature_private_key = signature_private_key
54
+ @ratchet_tree = ratchet_tree
55
+ @private_tree = private_tree
56
+ @extensions = extensions
57
+ @confirmed_transcript_hash = confirmed_transcript_hash
58
+ @interim_transcript_hash = interim_transcript_hash
59
+ @epoch_secrets = epoch_secrets
60
+ @external_psks = external_psks # psk_id -> psk
61
+ @credential_validator = credential_validator
62
+ @max_leaf_node_lifetime = max_leaf_node_lifetime
63
+ @resumption_psks = { epoch => epoch_secrets.resumption_psk } # epoch -> resumption_psk (8.4)
64
+ @pending_proposals = {} # ProposalRef -> AuthenticatedContent, sent or received in the current epoch (12.4)
65
+ @group_context = nil
66
+ @active = true # false once a Commit has removed this member (12.4.2)
67
+ end
68
+
69
+ # false after a Commit removed this member: it can no longer send or follow the group,
70
+ # though #unprotect still works for late messages of the last epoch it was in
71
+ def active?
72
+ @active
73
+ end
74
+
75
+ # 6: an MLSMessage's version MUST be mls10
76
+ def self.check_message_version!(mls_message)
77
+ return if mls_message.version == Melos::Constants::Version::MLS10
78
+ raise ArgumentError.new("unsupported MLS version #{mls_message.version}")
79
+ end
80
+
81
+ # 11: create a group whose only member is this leaf
82
+ # external_psks: psk_id -> psk, for external PSKs that commits may reference
83
+ def self.create(suite, group_id, leaf_node, encryption_private_key:, signature_private_key:, extensions: [], external_psks: {}, **options)
84
+ Melos::Struct::Extension.check_unique_types!(extensions, 'GroupContext extensions')
85
+ ratchet_tree = Melos::RatchetTree.new(suite, [Melos::Struct::Node.new_leaf_node(leaf_node)])
86
+ private_tree = Melos::PrivateTree.new
87
+ private_tree[0] = encryption_private_key
88
+ group_context = Melos::Struct::GroupContext.create(
89
+ cipher_suite: suite.suite_id, group_id: group_id, epoch: 0,
90
+ tree_hash: ratchet_tree.root_tree_hash, confirmed_transcript_hash: '', extensions: extensions
91
+ )
92
+ # 8: epoch 0 is derived from a fresh random init_secret, an all-zero commit secret and no PSKs
93
+ epoch_secrets = Melos::EpochSecrets.from_init_secret(suite, SecureRandom.random_bytes(suite.kdf.n_h))
94
+ .next(Melos::Crypto::Util.zero_vector(suite.kdf.n_h), Melos::PSK.psk_secret(suite, []), group_context, n_leaves: 1)
95
+ confirmation_tag = Melos::Crypto.mac(suite, epoch_secrets.confirmation_key, '')
96
+ new(
97
+ suite: suite, group_id: group_id, epoch: 0, leaf_index: 0, signature_private_key: signature_private_key,
98
+ ratchet_tree: ratchet_tree, private_tree: private_tree, extensions: extensions,
99
+ confirmed_transcript_hash: '', interim_transcript_hash: interim_transcript_hash(suite, '', confirmation_tag),
100
+ epoch_secrets: epoch_secrets, external_psks: external_psks, **options
101
+ )
102
+ end
103
+
104
+ # 12.4.3.1: join a group from a Welcome.
105
+ # welcome: a Melos::Struct::MLSMessage carrying a Welcome, or the Welcome itself
106
+ # key_package: the KeyPackage (or MLSMessage carrying it) this member published
107
+ # init_private_key, encryption_private_key: private keys of the KeyPackage's init_key and leaf encryption_key
108
+ # external_psks: psk_id -> psk, for any external PSKs the Welcome references
109
+ # ratchet_tree: serialized tree, if it was delivered outside the Welcome's ratchet_tree extension
110
+ def self.from_welcome(suite, welcome, key_package:, init_private_key:, encryption_private_key:, signature_private_key:,
111
+ external_psks: {}, ratchet_tree: nil, **options)
112
+ if welcome.is_a?(Melos::Struct::MLSMessage)
113
+ check_message_version!(welcome)
114
+ welcome = welcome.welcome
115
+ end
116
+ raise ArgumentError.new('not a Welcome message') if welcome.nil?
117
+ raise ArgumentError.new('Welcome is for a different cipher suite') unless welcome.cipher_suite == suite.suite_id
118
+ key_package = key_package.key_package if key_package.is_a?(Melos::Struct::MLSMessage)
119
+ # 12.4.3.1: the KeyPackage and the Welcome must be for the same cipher suite
120
+ raise ArgumentError.new('KeyPackage and Welcome are for different cipher suites') unless key_package.cipher_suite == welcome.cipher_suite
121
+
122
+ # Identify the entry in welcome.secrets corresponding to our KeyPackage and decrypt it with init_priv
123
+ key_package_ref = key_package.ref(suite)
124
+ encrypted_group_secrets = welcome.secrets.find { _1.new_member == key_package_ref }&.encrypted_group_secrets
125
+ raise ArgumentError.new('Welcome has no entry for this KeyPackage') if encrypted_group_secrets.nil?
126
+ group_secrets = Melos::Struct::GroupSecrets.new(
127
+ Melos::Crypto.decrypt_with_label(
128
+ suite, init_private_key, "Welcome", welcome.encrypted_group_info,
129
+ encrypted_group_secrets.kem_output, encrypted_group_secrets.ciphertext
130
+ )
131
+ )
132
+
133
+ # 8.4: the PSKs named in GroupSecrets
134
+ # TODO: resumption PSKs in a Welcome (reinit, branch)
135
+ psks = group_secrets.psks.map do |psk_id|
136
+ raise ArgumentError.new('unsupported PSK type in Welcome') unless psk_id.psktype == Melos::Constants::PSKType::EXTERNAL
137
+ [psk_id.raw, external_psks.fetch(psk_id.psk_id) { raise ArgumentError.new('Welcome references an unknown external PSK') }]
138
+ end
139
+ psk_secret = Melos::PSK.psk_secret(suite, psks)
140
+
141
+ # Decrypt the GroupInfo
142
+ welcome_key, welcome_nonce = Melos::EpochSecrets.welcome_key_and_nonce(suite, group_secrets.joiner_secret, psk_secret)
143
+ group_info = Melos::Struct::GroupInfo.new(
144
+ Melos::Crypto.aead_decrypt(suite, welcome_key, welcome_nonce, "", welcome.encrypted_group_info)
145
+ )
146
+ group_context = group_info.group_context
147
+ raise ArgumentError.new("unsupported MLS version #{group_context.version} in GroupContext") unless group_context.version == Melos::Constants::Version::MLS10
148
+ raise ArgumentError.new('GroupInfo is for a different cipher suite') unless group_context.cipher_suite == suite.suite_id
149
+ Melos::Struct::Extension.check_unique_types!(group_context.extensions, 'GroupContext extensions')
150
+ Melos::Struct::Extension.check_unique_types!(group_info.extensions, 'GroupInfo extensions')
151
+
152
+ # The ratchet tree: delivered out of band, or in the GroupInfo's ratchet_tree extension
153
+ ratchet_tree_data = ratchet_tree || group_info.extensions.find { _1.extension_type == Melos::Constants::ExtensionType::RATCHET_TREE }&.extension_data
154
+ raise ArgumentError.new('no ratchet tree available') if ratchet_tree_data.nil?
155
+ tree = Melos::RatchetTree.parse(suite, ratchet_tree_data)
156
+ raise ArgumentError.new('tree hash does not match GroupContext') unless tree.root_tree_hash == group_context.tree_hash
157
+
158
+ # Verify the GroupInfo signature with the signer's leaf
159
+ signer_leaf_node = tree.leaf_node_at(group_info.signer)
160
+ raise ArgumentError.new('GroupInfo signer is not in the tree') if signer_leaf_node.nil?
161
+ raise ArgumentError.new('GroupInfo signature does not verify') unless group_info.verify(suite, signer_leaf_node.signature_key)
162
+
163
+ # Find our own leaf: the one identical to our KeyPackage's LeafNode
164
+ leaf_index = tree.find_leaf_index { _1.raw == key_package.leaf_node.raw }
165
+ raise ArgumentError.new('our KeyPackage is not in the tree') if leaf_index.nil?
166
+ private_tree = Melos::PrivateTree.new
167
+ private_tree[leaf_index * 2] = encryption_private_key
168
+ # 12.4.3.1: a path secret in GroupSecrets is that of the common ancestor of our leaf and the signer's
169
+ if group_secrets.path_secret
170
+ common_ancestor = Melos::Tree.common_ancestor_semantic(leaf_index * 2, group_info.signer * 2, tree.n_leaves)
171
+ private_tree.set_path_secret(tree, common_ancestor, group_secrets.path_secret.path_secret)
172
+ end
173
+
174
+ # Enter the epoch's key schedule and check the GroupInfo's confirmation tag against it
175
+ epoch_secrets = Melos::EpochSecrets.from_joiner(suite, group_secrets.joiner_secret, psk_secret, group_context, n_leaves: tree.n_leaves)
176
+ expected_tag = Melos::Crypto.mac(suite, epoch_secrets.confirmation_key, group_context.confirmed_transcript_hash)
177
+ raise ArgumentError.new('GroupInfo confirmation tag does not verify') unless group_info.confirmation_tag == expected_tag
178
+
179
+ group = new(
180
+ suite: suite, group_id: group_context.group_id, epoch: group_context.epoch, leaf_index: leaf_index,
181
+ signature_private_key: signature_private_key, ratchet_tree: tree, private_tree: private_tree,
182
+ extensions: group_context.extensions, confirmed_transcript_hash: group_context.confirmed_transcript_hash,
183
+ interim_transcript_hash: interim_transcript_hash(suite, group_context.confirmed_transcript_hash, group_info.confirmation_tag),
184
+ epoch_secrets: epoch_secrets, external_psks: external_psks, **options
185
+ )
186
+ group.send(:validate_joined_state!)
187
+ group
188
+ end
189
+
190
+ # 8.2: interim_transcript_hash = Hash(confirmed_transcript_hash || InterimTranscriptHashInput)
191
+ def self.interim_transcript_hash(suite, confirmed_transcript_hash, confirmation_tag)
192
+ Melos::Crypto.hash(suite, confirmed_transcript_hash + Melos::Vec.string_to_vec(confirmation_tag))
193
+ end
194
+
195
+ # 8.1: the GroupContext of the current epoch
196
+ def group_context
197
+ @group_context ||= Melos::Struct::GroupContext.create(
198
+ cipher_suite: @suite.suite_id, group_id: @group_id, epoch: @epoch,
199
+ tree_hash: @ratchet_tree.root_tree_hash, confirmed_transcript_hash: @confirmed_transcript_hash, extensions: @extensions
200
+ )
201
+ end
202
+
203
+ def epoch_authenticator
204
+ @epoch_secrets.epoch_authenticator
205
+ end
206
+
207
+ ## Receiving messages (6, 12.4)
208
+
209
+ # Unprotect an MLSMessage sent in this group in the current epoch and verify its signature.
210
+ # Returns the Melos::Struct::AuthenticatedContent.
211
+ def unprotect(mls_message)
212
+ self.class.check_message_version!(mls_message)
213
+ authenticated_content =
214
+ case mls_message.wire_format
215
+ when Melos::Constants::WireFormat::MLS_PUBLIC_MESSAGE
216
+ mls_message.public_message.unprotect(@suite, @epoch_secrets.membership_key, group_context) or
217
+ raise ArgumentError.new('membership tag does not verify')
218
+ when Melos::Constants::WireFormat::MLS_PRIVATE_MESSAGE
219
+ # TODO: messages from earlier epochs need that epoch's secret tree
220
+ mls_message.private_message.unprotect(@suite, @epoch_secrets.secret_tree, @epoch_secrets.sender_data_secret)
221
+ else
222
+ raise ArgumentError.new('not a PublicMessage or PrivateMessage')
223
+ end
224
+ content = authenticated_content.content
225
+ raise ArgumentError.new('message is for a different group') unless content.group_id == @group_id
226
+ raise ArgumentError.new("message is for epoch #{content.epoch}, current epoch is #{@epoch}") unless content.epoch == @epoch
227
+ raise ArgumentError.new('signature does not verify') unless authenticated_content.verify(@suite, sender_signature_key(content.sender), group_context)
228
+ authenticated_content
229
+ end
230
+
231
+ # Unprotect a message and act on it: cache a proposal, apply a commit, hand back application data.
232
+ # Returns the Melos::Struct::AuthenticatedContent in every case.
233
+ def process_message(mls_message)
234
+ authenticated_content = unprotect(mls_message)
235
+ case authenticated_content.content.content_type
236
+ when Melos::Constants::ContentType::PROPOSAL
237
+ process_proposal(authenticated_content)
238
+ when Melos::Constants::ContentType::COMMIT
239
+ process_commit(authenticated_content)
240
+ end
241
+ authenticated_content
242
+ end
243
+
244
+ # 12.4: remember a proposal of this epoch so a Commit can reference it
245
+ def process_proposal(authenticated_content)
246
+ ensure_active!
247
+ raise ArgumentError.new('not a proposal') unless authenticated_content.content.content_type == Melos::Constants::ContentType::PROPOSAL
248
+ @pending_proposals[Melos::Crypto.make_proposal_ref(@suite, authenticated_content.raw)] = authenticated_content
249
+ end
250
+
251
+ def process_commit(authenticated_content)
252
+ merge_commit(stage_commit(authenticated_content))
253
+ end
254
+
255
+ # 12.4.2: compute the next epoch's state from a received Commit without changing this group yet
256
+ def stage_commit(authenticated_content)
257
+ stage(authenticated_content, own_path: nil)
258
+ end
259
+
260
+ # 12.4.2: make a staged commit the current state; the group moves to the next epoch.
261
+ # If the commit removed this member, the group becomes inactive instead: it keeps the secrets of
262
+ # the last epoch it was in so late messages can still be read, but nothing more can be sent or processed.
263
+ def merge_commit(staged)
264
+ if staged.removes_us
265
+ @active = false
266
+ @pending_proposals = {}
267
+ return self
268
+ end
269
+ @ratchet_tree = staged.ratchet_tree
270
+ @private_tree = staged.private_tree
271
+ @extensions = staged.extensions
272
+ @confirmed_transcript_hash = staged.confirmed_transcript_hash
273
+ @interim_transcript_hash = staged.interim_transcript_hash
274
+ @epoch_secrets = staged.epoch_secrets
275
+ @epoch += 1
276
+ @group_context = nil
277
+ @pending_proposals = {}
278
+ @resumption_psks[@epoch] = @epoch_secrets.resumption_psk
279
+ self
280
+ end
281
+
282
+ ## Sending messages (6)
283
+
284
+ # Frame, sign and protect content for the wire in the current epoch. Returns a Melos::Struct::MLSMessage.
285
+ # content_type: Melos::Constants::ContentType; content: application data (String), Proposal or Commit
286
+ def protect(content_type, content, wire_format: Melos::Constants::WireFormat::MLS_PRIVATE_MESSAGE, authenticated_data: '', padding_size: 0)
287
+ ensure_active!
288
+ protect_authenticated_content(frame_and_sign(content_type, content, wire_format: wire_format, authenticated_data: authenticated_data), padding_size: padding_size)
289
+ end
290
+
291
+ # 6.1: application data is always sent as a PrivateMessage.
292
+ # 12.4: a member that has seen proposals in this epoch MUST commit them before sending application data.
293
+ def create_application_message(data, authenticated_data: '', padding_size: 0)
294
+ raise ArgumentError.new('proposals are pending in this epoch; commit them before sending application data') unless @pending_proposals.empty?
295
+ protect(Melos::Constants::ContentType::APPLICATION, data,
296
+ wire_format: Melos::Constants::WireFormat::MLS_PRIVATE_MESSAGE, authenticated_data: authenticated_data, padding_size: padding_size)
297
+ end
298
+
299
+ # 12.1: send a Proposal; it is also remembered here so that our own Commit can reference it
300
+ def create_proposal(proposal, wire_format: Melos::Constants::WireFormat::MLS_PUBLIC_MESSAGE, authenticated_data: '', padding_size: 0)
301
+ authenticated_content = frame_and_sign(Melos::Constants::ContentType::PROPOSAL, proposal, wire_format: wire_format, authenticated_data: authenticated_data)
302
+ process_proposal(authenticated_content)
303
+ protect_authenticated_content(authenticated_content, padding_size: padding_size)
304
+ end
305
+
306
+ # 12.1.1: key_package is the new member's KeyPackage, or the MLSMessage carrying it
307
+ def create_add_proposal(key_package, **options)
308
+ if key_package.is_a?(Melos::Struct::MLSMessage)
309
+ self.class.check_message_version!(key_package)
310
+ # 10: a KeyPackage carried in an MLSMessage MUST have the same version as the message
311
+ raise ArgumentError.new('KeyPackage version differs from its MLSMessage') unless key_package.key_package.version == key_package.version
312
+ key_package = key_package.key_package
313
+ end
314
+ validate_key_package!(key_package, tree: @ratchet_tree, leaf_index: nil, extensions: @extensions, sending: true)
315
+ add = Melos::Struct::Add.create(key_package: key_package)
316
+ create_proposal(Melos::Struct::Proposal.create(proposal_type: Melos::Constants::ProposalType::ADD, add: add), **options)
317
+ end
318
+
319
+ # 12.1.3
320
+ def create_remove_proposal(removed_leaf_index, **options)
321
+ raise ArgumentError.new("leaf #{removed_leaf_index} is blank") if @ratchet_tree.leaf_node_at(removed_leaf_index).nil?
322
+ remove = Melos::Struct::Remove.create(removed: removed_leaf_index)
323
+ create_proposal(Melos::Struct::Proposal.create(proposal_type: Melos::Constants::ProposalType::REMOVE, remove: remove), **options)
324
+ end
325
+
326
+ # 12.1.4 / 8.4: inject the external PSK psk_id, which every member must know, into the next epoch
327
+ def create_psk_proposal(psk_id, **options)
328
+ raise ArgumentError.new('unknown external PSK') unless @external_psks.key?(psk_id)
329
+ psk = Melos::Struct::PreSharedKey.create(
330
+ psk: Melos::Struct::PreSharedKeyID.create_external(psk_id: psk_id, psk_nonce: SecureRandom.random_bytes(@suite.kdf.n_h))
331
+ )
332
+ create_proposal(Melos::Struct::Proposal.create(proposal_type: Melos::Constants::ProposalType::PSK, psk: psk), **options)
333
+ end
334
+
335
+ # 12.4: commit the proposals of this epoch (those sent or received, by reference, plus inline_proposals by value)
336
+ # with a fresh UpdatePath. Returns a CommitResult; call #merge_commit(result.staged) once the Delivery Service
337
+ # has accepted the commit, and send result.welcome to the new members if there are any.
338
+ def create_commit(inline_proposals: [], wire_format: Melos::Constants::WireFormat::MLS_PUBLIC_MESSAGE, authenticated_data: '',
339
+ padding_size: 0, include_ratchet_tree: true)
340
+ ensure_active!
341
+ proposal_or_refs =
342
+ @pending_proposals.keys.map { Melos::Struct::ProposalOrRef.create(type: Melos::Constants::ProposalOrRefType::REFERENCE, proposal: nil, reference: _1) } +
343
+ inline_proposals.map { Melos::Struct::ProposalOrRef.create(type: Melos::Constants::ProposalOrRefType::PROPOSAL, proposal: _1, reference: nil) }
344
+ proposals = resolve_proposals(proposal_or_refs, @leaf_index)
345
+ validate_proposal_list!(proposals, @leaf_index)
346
+
347
+ # 12.4.2: the UpdatePath is built on the tree after the proposals, and not encrypted to the leaves being added
348
+ tree = @ratchet_tree.dup
349
+ private_tree = @private_tree.dup
350
+ extensions, joiners, _psk_ids = apply_proposals(tree, proposals, sending: true)
351
+ # TODO: the path may be left out when 12.4 does not require it
352
+ provisional_group_context = Melos::Struct::GroupContext.create(
353
+ cipher_suite: @suite.suite_id, group_id: @group_id, epoch: @epoch + 1,
354
+ tree_hash: '', confirmed_transcript_hash: @confirmed_transcript_hash, extensions: extensions # tree_hash is filled in by create_update_path
355
+ )
356
+ path = tree.create_update_path(private_tree, @leaf_index, @signature_private_key, provisional_group_context, joiners.map { _1.leaf_index * 2 })
357
+
358
+ commit = Melos::Struct::Commit.create(proposals: proposal_or_refs, path: path[:update_path])
359
+ authenticated_content = frame_and_sign(Melos::Constants::ContentType::COMMIT, commit, wire_format: wire_format, authenticated_data: authenticated_data)
360
+ staged = stage(authenticated_content, own_path: { private_tree: private_tree, commit_secret: path[:commit_secret], path_secrets: path[:path_secrets] })
361
+ authenticated_content.auth.confirmation_tag = staged.confirmation_tag
362
+
363
+ CommitResult.new(
364
+ commit: protect_authenticated_content(authenticated_content, padding_size: padding_size),
365
+ welcome: staged.joiners.empty? ? nil : create_welcome(staged, include_ratchet_tree: include_ratchet_tree),
366
+ staged: staged
367
+ )
368
+ end
369
+
370
+ # 12.4.3.1: the Welcome for the members a staged commit adds
371
+ def create_welcome(staged, include_ratchet_tree: true)
372
+ extensions = []
373
+ extensions << Melos::Struct::Extension.create(extension_type: Melos::Constants::ExtensionType::RATCHET_TREE, extension_data: staged.ratchet_tree.raw) if include_ratchet_tree
374
+ group_info = Melos::Struct::GroupInfo.create(
375
+ group_context: staged.group_context, extensions: extensions, confirmation_tag: staged.confirmation_tag, signer: @leaf_index, signature: nil
376
+ )
377
+ group_info.sign(@suite, @signature_private_key)
378
+ welcome_key, welcome_nonce = staged.epoch_secrets.welcome_key_and_nonce
379
+ encrypted_group_info = Melos::Crypto.aead_encrypt(@suite, welcome_key, welcome_nonce, "", group_info.raw)
380
+
381
+ secrets = staged.joiners.map do |joiner|
382
+ path_secret = nil
383
+ if staged.path_secrets
384
+ # the joiner gets the path secret of the node where its direct path meets ours
385
+ common_ancestor = Melos::Tree.common_ancestor_semantic(joiner.leaf_index * 2, @leaf_index * 2, staged.ratchet_tree.n_leaves)
386
+ path_secret = Melos::Struct::PathSecret.create(path_secret: staged.path_secrets.fetch(common_ancestor))
387
+ end
388
+ group_secrets = Melos::Struct::GroupSecrets.create(joiner_secret: staged.epoch_secrets.joiner_secret, path_secret: path_secret, psks: staged.psk_ids)
389
+ kem_output, ciphertext = Melos::Crypto.encrypt_with_label(@suite, joiner.key_package.init_key, "Welcome", encrypted_group_info, group_secrets.raw)
390
+ Melos::Struct::EncryptedGroupSecrets.create(
391
+ new_member: joiner.key_package.ref(@suite),
392
+ encrypted_group_secrets: Melos::Struct::HPKECipherText.create(kem_output: kem_output, ciphertext: ciphertext)
393
+ )
394
+ end
395
+ welcome = Melos::Struct::Welcome.create(cipher_suite: @suite.suite_id, secrets: secrets, encrypted_group_info: encrypted_group_info)
396
+ Melos::Struct::MLSMessage.create(welcome: welcome)
397
+ end
398
+
399
+ private
400
+
401
+ # 12.4.2: the next epoch's state from a Commit.
402
+ # own_path: when we are the committer, { private_tree:, commit_secret:, path_secrets: } from create_update_path;
403
+ # the confirmation tag is then computed rather than verified
404
+ def stage(authenticated_content, own_path:)
405
+ ensure_active!
406
+ content = authenticated_content.content
407
+ raise ArgumentError.new('not a commit') unless content.content_type == Melos::Constants::ContentType::COMMIT
408
+ raise ArgumentError.new("commit is for epoch #{content.epoch}, current epoch is #{@epoch}") unless content.epoch == @epoch
409
+ # TODO: external and new-member commits
410
+ raise ArgumentError.new('only commits from members are supported') unless content.sender.sender_type == Melos::Constants::SenderType::MEMBER
411
+ committer = content.sender.leaf_index
412
+ raise ArgumentError.new('our own commit must be staged through create_commit') if committer == @leaf_index && own_path.nil?
413
+ commit = content.commit
414
+
415
+ proposals = resolve_proposals(commit.proposals, committer)
416
+ validate_proposal_list!(proposals, committer)
417
+ # 12.4: the path is required if the proposal list is empty or contains any of these
418
+ path_required = proposals.empty? || proposals.any? do |proposal, _sender|
419
+ [Melos::Constants::ProposalType::UPDATE, Melos::Constants::ProposalType::REMOVE,
420
+ Melos::Constants::ProposalType::EXTERNAL_INIT, Melos::Constants::ProposalType::GROUP_CONTEXT_EXTENSIONS].include?(proposal.proposal_type)
421
+ end
422
+ raise ArgumentError.new('commit must have a path') if commit.path.nil? && path_required
423
+
424
+ tree = @ratchet_tree.dup
425
+ private_tree = own_path ? own_path[:private_tree] : @private_tree.dup
426
+ extensions, joiners, psk_ids = apply_proposals(tree, proposals, sending: !own_path.nil?)
427
+
428
+ # 12.4.2: a commit may remove us; we cannot follow it any further, since the path is not encrypted to us.
429
+ # Our leaf index may be non-blank afterwards if an Add in the same commit reused it, so look at the Removes themselves.
430
+ removes_us = proposals.any? { |proposal, _sender| proposal.proposal_type == Melos::Constants::ProposalType::REMOVE && proposal.remove.removed == @leaf_index }
431
+ if removes_us
432
+ return StagedCommit.new(ratchet_tree: tree, extensions: extensions, joiners: joiners, psk_ids: psk_ids, removes_us: true)
433
+ end
434
+
435
+ if commit.path.nil?
436
+ commit_secret = Melos::Crypto::Util.zero_vector(@suite.kdf.n_h)
437
+ else
438
+ validate_update_path!(tree, commit.path, committer, joiners, extensions) unless own_path
439
+ tree.merge_update_path(committer, commit.path)
440
+ if own_path
441
+ commit_secret = own_path[:commit_secret]
442
+ else
443
+ # the UpdatePath is encrypted with the provisional GroupContext: new epoch and tree, old transcript hash
444
+ provisional_group_context = Melos::Struct::GroupContext.create(
445
+ cipher_suite: @suite.suite_id, group_id: @group_id, epoch: @epoch + 1,
446
+ tree_hash: tree.root_tree_hash, confirmed_transcript_hash: @confirmed_transcript_hash, extensions: extensions
447
+ )
448
+ path_secret = tree.decrypt_path_secret(private_tree, commit.path, committer, @leaf_index, provisional_group_context, joiners.map(&:leaf_index))
449
+ commit_secret = tree.calculate_commit_secret(commit.path, committer, @leaf_index, path_secret)
450
+ end
451
+ end
452
+
453
+ # 8.2: transcript hashes
454
+ confirmed_transcript_hash = Melos::Crypto.hash(@suite, @interim_transcript_hash + authenticated_content.confirmed_transcript_hash_input.raw)
455
+ new_group_context = Melos::Struct::GroupContext.create(
456
+ cipher_suite: @suite.suite_id, group_id: @group_id, epoch: @epoch + 1,
457
+ tree_hash: tree.root_tree_hash, confirmed_transcript_hash: confirmed_transcript_hash, extensions: extensions
458
+ )
459
+
460
+ # 8: the next epoch's key schedule, and the confirmation tag that goes with it
461
+ epoch_secrets = @epoch_secrets.next(commit_secret, psk_secret_for(psk_ids), new_group_context, n_leaves: tree.n_leaves)
462
+ confirmation_tag = Melos::Crypto.mac(@suite, epoch_secrets.confirmation_key, confirmed_transcript_hash)
463
+ if own_path.nil? && authenticated_content.auth.confirmation_tag != confirmation_tag
464
+ raise ArgumentError.new('confirmation tag does not verify')
465
+ end
466
+
467
+ StagedCommit.new(
468
+ ratchet_tree: tree, private_tree: private_tree, extensions: extensions,
469
+ confirmed_transcript_hash: confirmed_transcript_hash,
470
+ interim_transcript_hash: self.class.interim_transcript_hash(@suite, confirmed_transcript_hash, confirmation_tag),
471
+ epoch_secrets: epoch_secrets, group_context: new_group_context, confirmation_tag: confirmation_tag,
472
+ joiners: joiners, path_secrets: own_path && own_path[:path_secrets], psk_ids: psk_ids
473
+ )
474
+ end
475
+
476
+ def ensure_active!
477
+ raise ArgumentError.new("this member was removed from the group in epoch #{@epoch}") unless @active
478
+ end
479
+
480
+ # 6.1: the key that signed a message from this sender
481
+ def sender_signature_key(sender)
482
+ case sender.sender_type
483
+ when Melos::Constants::SenderType::MEMBER
484
+ leaf_node = @ratchet_tree.leaf_node_at(sender.leaf_index)
485
+ raise ArgumentError.new("sender leaf #{sender.leaf_index} is blank") if leaf_node.nil?
486
+ leaf_node.signature_key
487
+ else
488
+ # TODO: external senders (external_senders extension) and new members (KeyPackage in the message)
489
+ raise ArgumentError.new('only messages from members are supported')
490
+ end
491
+ end
492
+
493
+ # 12.4: the proposals a Commit covers, by value or by reference, as [proposal, sender leaf index] pairs
494
+ def resolve_proposals(proposal_or_refs, committer)
495
+ proposal_or_refs.map do |proposal_or_ref|
496
+ if proposal_or_ref.proposal
497
+ [proposal_or_ref.proposal, committer]
498
+ else
499
+ authenticated_content = @pending_proposals[proposal_or_ref.reference]
500
+ raise ArgumentError.new('commit references a proposal we have not received') if authenticated_content.nil?
501
+ sender = authenticated_content.content.sender
502
+ raise ArgumentError.new('proposals from external senders are not supported yet') unless sender.sender_type == Melos::Constants::SenderType::MEMBER
503
+ [authenticated_content.content.proposal, sender.leaf_index]
504
+ end
505
+ end
506
+ end
507
+
508
+ # 12.3: apply proposals to tree, in the order GroupContextExtensions, Update, Remove, Add, PreSharedKey,
509
+ # validating each leaf node that goes in (7.3, 10.1) and the resulting membership (12.2).
510
+ # sending: we are the committer, so lifetimes are checked against the current time (7.3)
511
+ # Returns [extensions, joiners (Joiner for each added member), psk_ids]
512
+ def apply_proposals(tree, proposals, sending:)
513
+ of_type = ->(type) { proposals.select { |proposal, _sender| proposal.proposal_type == type } }
514
+ types = proposals.map { |proposal, _sender| proposal.proposal_type }
515
+ # 12.2, 13.2: a proposal type not supported by every member makes the list invalid; we support only the RFC's own types
516
+ unknown = types - KNOWN_PROPOSAL_TYPES
517
+ raise ArgumentError.new("unknown proposal types: #{unknown.map { '0x%04x' % _1 }.join(', ')}") unless unknown.empty?
518
+ unsupported = types & [Melos::Constants::ProposalType::REINIT, Melos::Constants::ProposalType::EXTERNAL_INIT]
519
+ raise ArgumentError.new("unsupported proposal types: #{unsupported.map { Melos::Constants::ProposalType::NAMES[_1] }.join(', ')}") unless unsupported.empty?
520
+ extensions = @extensions
521
+ of_type.(Melos::Constants::ProposalType::GROUP_CONTEXT_EXTENSIONS).each do |proposal, _sender|
522
+ Melos::Struct::Extension.check_unique_types!(proposal.group_context_extensions.extensions, 'GroupContextExtensions proposal')
523
+ extensions = proposal.group_context_extensions.extensions
524
+ end
525
+ of_type.(Melos::Constants::ProposalType::UPDATE).each do |proposal, sender|
526
+ validate_leaf_node!(proposal.update.leaf_node, context: :update, leaf_index: sender, tree: tree, extensions: extensions,
527
+ replacing: tree.leaf_node_at(sender), sending: sending)
528
+ tree.update_leaf_node(sender, proposal.update.leaf_node)
529
+ end
530
+ of_type.(Melos::Constants::ProposalType::REMOVE).each do |proposal, _sender|
531
+ # 12.1.3: removed MUST identify a non-blank leaf (this also rejects two Removes of the same leaf, 12.2)
532
+ raise ArgumentError.new("Remove proposal names a blank leaf (#{proposal.remove.removed})") if tree.leaf_node_at(proposal.remove.removed).nil?
533
+ tree.remove_leaf_node(proposal.remove.removed)
534
+ end
535
+ joiners = of_type.(Melos::Constants::ProposalType::ADD).map do |proposal, _sender|
536
+ key_package = proposal.add.key_package
537
+ # 10.1: the KeyPackage's version and cipher suite MUST match the group's
538
+ raise ArgumentError.new("Add proposal's KeyPackage has version #{key_package.version}") unless key_package.version == Melos::Constants::Version::MLS10
539
+ raise ArgumentError.new("Add proposal's KeyPackage is for cipher suite #{key_package.cipher_suite}") unless key_package.cipher_suite == @suite.suite_id
540
+ leaf_index = tree.add_leaf_node(key_package.leaf_node)
541
+ validate_key_package!(key_package, tree: tree, leaf_index: leaf_index, extensions: extensions, sending: sending)
542
+ Joiner.new(leaf_index, key_package)
543
+ end
544
+ psk_ids = of_type.(Melos::Constants::ProposalType::PSK).map { |proposal, _sender| proposal.psk.psk }
545
+ # 12.2, 12.1.7: the members that remain must all be compatible with the new extensions and with each other
546
+ validate_members!(tree, extensions)
547
+ [extensions, joiners, psk_ids]
548
+ end
549
+
550
+ # 8.4: the PSK secret for the PreSharedKeyIDs of a commit
551
+ def psk_secret_for(psk_ids)
552
+ psks = psk_ids.map do |psk_id|
553
+ # 12.1.4: psk_nonce MUST be KDF.Nh bytes long
554
+ raise ArgumentError.new('psk_nonce has the wrong length') unless psk_id.psk_nonce.bytesize == @suite.kdf.n_h
555
+ value =
556
+ case psk_id.psktype
557
+ when Melos::Constants::PSKType::EXTERNAL
558
+ @external_psks.fetch(psk_id.psk_id) { raise ArgumentError.new('commit references an unknown external PSK') }
559
+ when Melos::Constants::PSKType::RESUMPTION
560
+ # TODO: check psk_group_id and usage
561
+ @resumption_psks.fetch(psk_id.psk_epoch) { raise ArgumentError.new("no resumption PSK for epoch #{psk_id.psk_epoch}") }
562
+ end
563
+ [psk_id.raw, value]
564
+ end
565
+ Melos::PSK.psk_secret(@suite, psks)
566
+ end
567
+
568
+ # 6: FramedContent -> AuthenticatedContent, signed with our key in the current GroupContext
569
+ def frame_and_sign(content_type, content, wire_format:, authenticated_data:)
570
+ framed_content = Melos::Struct::FramedContent.create(
571
+ group_id: @group_id, epoch: @epoch, sender: Melos::Struct::Sender.create_member(@leaf_index),
572
+ authenticated_data: authenticated_data, content_type: content_type, content: content
573
+ )
574
+ authenticated_content = Melos::Struct::AuthenticatedContent.create(wire_format: wire_format, content: framed_content, auth: nil)
575
+ authenticated_content.sign(@suite, @signature_private_key, group_context)
576
+ authenticated_content
577
+ end
578
+
579
+ # 6.2, 6.3: AuthenticatedContent -> PublicMessage or PrivateMessage, wrapped in an MLSMessage
580
+ def protect_authenticated_content(authenticated_content, padding_size:)
581
+ case authenticated_content.wire_format
582
+ when Melos::Constants::WireFormat::MLS_PUBLIC_MESSAGE
583
+ message = Melos::Struct::PublicMessage.protect(authenticated_content, @suite, @epoch_secrets.membership_key, group_context)
584
+ Melos::Struct::MLSMessage.create(public_message: message)
585
+ when Melos::Constants::WireFormat::MLS_PRIVATE_MESSAGE
586
+ message = Melos::Struct::PrivateMessage.protect(authenticated_content, @suite, @epoch_secrets.secret_tree, @epoch_secrets.sender_data_secret, padding_size)
587
+ Melos::Struct::MLSMessage.create(private_message: message)
588
+ else
589
+ raise ArgumentError.new('wire format must be PublicMessage or PrivateMessage')
590
+ end
591
+ end
592
+ end
593
+
594
+ require_relative 'group/validation'
@@ -0,0 +1,59 @@
1
+ require_relative 'crypto'
2
+ require_relative 'constants'
3
+ require_relative 'capabilities'
4
+ require_relative 'struct/structs'
5
+
6
+ # A KeyPackage this client has published (RFC 9420 Section 10), together with the private keys
7
+ # that belong to it: the init key, used once to decrypt the Welcome that adds us, and the leaf's
8
+ # encryption key, which becomes our leaf's private key in the group. Each KeyPackage has its own
9
+ # encryption key pair, so every leaf a client holds is keyed separately.
10
+ class Melos::KeyPackageBundle
11
+ attr_reader :suite, :key_package, :init_private_key, :encryption_private_key
12
+
13
+ DEFAULT_LIFETIME = 60 * 60 * 24 * 7 # seconds
14
+
15
+ def initialize(suite, key_package, init_private_key, encryption_private_key)
16
+ @suite = suite
17
+ @key_package = key_package
18
+ @init_private_key = init_private_key
19
+ @encryption_private_key = encryption_private_key
20
+ end
21
+
22
+ # 10: a fresh KeyPackage signed with signature_private_key, whose leaf carries signature_public_key and credential
23
+ def self.generate(suite, signature_private_key:, signature_public_key:, credential:, capabilities: nil, lifetime: nil, extensions: [])
24
+ init_key_pair = Melos::Crypto.generate_encapsulation_key_pair(suite)
25
+ encryption_key_pair = Melos::Crypto.generate_encapsulation_key_pair(suite)
26
+ now = Time.now.to_i
27
+ leaf_node = Melos::Struct::LeafNode.create(
28
+ encryption_key: Melos::Crypto.serialize_public_key(suite, encryption_key_pair),
29
+ signature_key: signature_public_key,
30
+ credential: credential,
31
+ capabilities: capabilities || Melos::Struct::Capabilities.import(Melos::Capabilities.new),
32
+ leaf_node_source: Melos::Constants::LeafNodeSource::KEY_PACKAGE,
33
+ lifetime: lifetime || Melos::Struct::Lifetime.create(not_before: now, not_after: now + DEFAULT_LIFETIME),
34
+ parent_hash: nil,
35
+ extensions: extensions,
36
+ signature: nil
37
+ )
38
+ leaf_node.sign(suite, signature_private_key, nil, nil) # 7.2: group_id and leaf_index are not part of a key_package leaf's signature
39
+ key_package = Melos::Struct::KeyPackage.create(
40
+ cipher_suite: suite.suite_id,
41
+ init_key: Melos::Crypto.serialize_public_key(suite, init_key_pair),
42
+ leaf_node: leaf_node
43
+ )
44
+ key_package.sign(signature_private_key)
45
+ new(suite, key_package,
46
+ Melos::Crypto.serialize_private_key(suite, init_key_pair),
47
+ Melos::Crypto.serialize_private_key(suite, encryption_key_pair))
48
+ end
49
+
50
+ # 5.2: the KeyPackageRef a Welcome uses to address this KeyPackage
51
+ def ref
52
+ @key_package.ref(@suite)
53
+ end
54
+
55
+ # the KeyPackage as it is published: an MLSMessage
56
+ def message
57
+ Melos::Struct::MLSMessage.create(key_package: @key_package)
58
+ end
59
+ end