melos 0.0.1 → 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 +71 -0
- 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 +69 -65
- data/lib/melos/struct/structs.rb +163 -87
- data/lib/melos/tree.rb +4 -120
- data/lib/melos/vec.rb +29 -41
- data/lib/melos/version.rb +1 -1
- data/lib/melos.rb +9 -2
- metadata +14 -8
- data/.rspec +0 -3
- data/lib/melos/key_schedule.rb +0 -62
- data/lib/melos/struct/ratchet_tree.rb +0 -324
data/lib/melos/group.rb
ADDED
|
@@ -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
|