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,113 @@
1
+ require_relative 'crypto'
2
+ require_relative 'secret_tree'
3
+
4
+ # The key schedule (RFC 9420 Section 8) for one epoch: the epoch_secret and the secrets
5
+ # derived from it (Table 4), the joiner and PSK secrets that produced it (needed to build
6
+ # a Welcome, 12.4.3.1), and the epoch's secret tree (Section 9).
7
+ #
8
+ # A new epoch comes from #next with the commit secret, PSK secret and new GroupContext,
9
+ # which uses this epoch's init_secret as Section 8 describes.
10
+ class Melos::EpochSecrets
11
+ attr_reader :suite, :epoch_secret, :joiner_secret, :psk_secret, :n_leaves
12
+
13
+ # Table 4, plus init_secret which is derived the same way
14
+ DERIVED_SECRETS = {
15
+ sender_data_secret: 'sender data',
16
+ encryption_secret: 'encryption',
17
+ exporter_secret: 'exporter',
18
+ external_secret: 'external',
19
+ confirmation_key: 'confirm',
20
+ membership_key: 'membership',
21
+ resumption_psk: 'resumption',
22
+ epoch_authenticator: 'authentication',
23
+ init_secret: 'init'
24
+ }.freeze
25
+
26
+ DERIVED_SECRETS.each do |name, label|
27
+ define_method(name) do
28
+ @derived[name] ||= Melos::Crypto.derive_secret(@suite, require_epoch_secret(name), label)
29
+ end
30
+ end
31
+
32
+ # derived: secrets known without an epoch_secret (see .from_init_secret and .from_components)
33
+ def initialize(suite, epoch_secret: nil, joiner_secret: nil, psk_secret: nil, n_leaves: nil, derived: {})
34
+ unknown = derived.keys - DERIVED_SECRETS.keys
35
+ raise ArgumentError.new("unknown secrets: #{unknown.join(', ')}") unless unknown.empty?
36
+ @suite = suite
37
+ @epoch_secret = epoch_secret
38
+ @joiner_secret = joiner_secret
39
+ @psk_secret = psk_secret
40
+ @n_leaves = n_leaves
41
+ @derived = derived.dup
42
+ end
43
+
44
+ def self.from_epoch_secret(suite, epoch_secret, n_leaves: nil)
45
+ new(suite, epoch_secret: epoch_secret, n_leaves: n_leaves)
46
+ end
47
+
48
+ # 8: epoch_secret = ExpandWithLabel(Extract(joiner_secret, psk_secret), "epoch", GroupContext)
49
+ # This is how a new member joins an epoch from a Welcome (12.4.3.1).
50
+ def self.from_joiner(suite, joiner_secret, psk_secret, group_context, n_leaves: nil)
51
+ member_secret = Melos::Crypto.kdf_extract(suite, joiner_secret, psk_secret)
52
+ epoch_secret = Melos::Crypto.expand_with_label(suite, member_secret, "epoch", group_context.raw, suite.kdf.n_h)
53
+ new(suite, epoch_secret: epoch_secret, joiner_secret: joiner_secret, psk_secret: psk_secret, n_leaves: n_leaves)
54
+ end
55
+
56
+ # 8: the state before an epoch exists, when only init_secret is known (a fresh random
57
+ # value for a new group). Only #next is meaningful on it.
58
+ def self.from_init_secret(suite, init_secret)
59
+ new(suite, derived: { init_secret: init_secret })
60
+ end
61
+
62
+ # an epoch given by some of its derived secrets rather than by epoch_secret; for test vectors
63
+ def self.from_components(suite, n_leaves: nil, **secrets)
64
+ new(suite, n_leaves: n_leaves, derived: secrets)
65
+ end
66
+
67
+ # 8: welcome_secret = DeriveSecret(Extract(joiner_secret, psk_secret), "welcome")
68
+ def self.welcome_secret(suite, joiner_secret, psk_secret)
69
+ Melos::Crypto.derive_secret(suite, Melos::Crypto.kdf_extract(suite, joiner_secret, psk_secret), "welcome")
70
+ end
71
+
72
+ # 12.4.3.1: the key and nonce that encrypt the GroupInfo in a Welcome
73
+ def self.welcome_key_and_nonce(suite, joiner_secret, psk_secret)
74
+ ws = welcome_secret(suite, joiner_secret, psk_secret)
75
+ [
76
+ Melos::Crypto.expand_with_label(suite, ws, "key", "", suite.hpke.n_k),
77
+ Melos::Crypto.expand_with_label(suite, ws, "nonce", "", suite.hpke.n_n)
78
+ ]
79
+ end
80
+
81
+ def welcome_secret
82
+ self.class.welcome_secret(@suite, require_joiner_secret(:welcome_secret), @psk_secret)
83
+ end
84
+
85
+ def welcome_key_and_nonce
86
+ self.class.welcome_key_and_nonce(@suite, require_joiner_secret(:welcome_key_and_nonce), @psk_secret)
87
+ end
88
+
89
+ # 8: the next epoch. joiner_secret = ExpandWithLabel(Extract(init_secret, commit_secret), "joiner", GroupContext)
90
+ # n_leaves: leaf count of the ratchet tree in the new epoch, for its secret tree
91
+ def next(commit_secret, psk_secret, group_context, n_leaves: @n_leaves)
92
+ joiner_secret = Melos::Crypto.expand_with_label(
93
+ @suite, Melos::Crypto.kdf_extract(@suite, init_secret, commit_secret), "joiner", group_context.raw, @suite.kdf.n_h
94
+ )
95
+ self.class.from_joiner(@suite, joiner_secret, psk_secret, group_context, n_leaves: n_leaves)
96
+ end
97
+
98
+ # 9: this epoch's secret tree, built on first use
99
+ def secret_tree
100
+ raise ArgumentError.new('n_leaves is needed to build the secret tree') if @n_leaves.nil?
101
+ @secret_tree ||= Melos::SecretTree.new(@suite, @n_leaves, encryption_secret)
102
+ end
103
+
104
+ private
105
+
106
+ def require_epoch_secret(name)
107
+ @epoch_secret or raise ArgumentError.new("#{name} is not available: this epoch has no epoch_secret")
108
+ end
109
+
110
+ def require_joiner_secret(name)
111
+ @joiner_secret or raise ArgumentError.new("#{name} is not available: this epoch has no joiner_secret")
112
+ end
113
+ end
@@ -0,0 +1,237 @@
1
+ # Validation of the pieces that enter a group: proposal lists (12.2), UpdatePaths (12.4.2),
2
+ # KeyPackages (10.1) and LeafNodes (7.3). All methods raise ArgumentError on failure.
3
+ class Melos::Group
4
+ # 7.2: the proposal and extension types every client implements; they are not listed in Capabilities
5
+ DEFAULT_PROPOSAL_TYPES = (0x0001..0x0007).to_a.freeze
6
+ DEFAULT_EXTENSION_TYPES = (0x0001..0x0005).to_a.freeze
7
+
8
+ # 7.2: applications MUST define a maximum acceptable leaf node lifetime (seconds) and pass it as
9
+ # max_leaf_node_lifetime. There is no default limit: interop test vectors use the full uint64 range.
10
+ DEFAULT_MAX_LEAF_NODE_LIFETIME = nil
11
+
12
+ # 5.3.1: credentials are validated by the application's Authentication Service. Without one,
13
+ # only basic credentials are accepted, since nothing else can be checked here.
14
+ DEFAULT_CREDENTIAL_VALIDATOR = ->(credential, _leaf_node) { credential.credential_type == Melos::Constants::CredentialType::BASIC }
15
+
16
+ private
17
+
18
+ # 12.2: the rules a regular commit's proposal list must satisfy as a whole; proposals is [proposal, sender] pairs.
19
+ # "The same client" is taken to mean the same signature key, which the RFC leaves to the application.
20
+ def validate_proposal_list!(proposals, committer)
21
+ of_type = ->(type) { proposals.select { |proposal, _sender| proposal.proposal_type == type } }
22
+ updates = of_type.(Melos::Constants::ProposalType::UPDATE)
23
+ removes = of_type.(Melos::Constants::ProposalType::REMOVE)
24
+ adds = of_type.(Melos::Constants::ProposalType::ADD)
25
+ psks = of_type.(Melos::Constants::ProposalType::PSK)
26
+ gces = of_type.(Melos::Constants::ProposalType::GROUP_CONTEXT_EXTENSIONS)
27
+
28
+ raise ArgumentError.new('commit contains an Update from the committer') if updates.any? { |_proposal, sender| sender == committer }
29
+ removed = removes.map { |proposal, _sender| proposal.remove.removed }
30
+ raise ArgumentError.new('commit removes its own sender') if removed.include?(committer)
31
+ touched = updates.map { |_proposal, sender| sender } + removed
32
+ repeated = touched.tally.select { |_leaf, count| count > 1 }.keys
33
+ raise ArgumentError.new("more than one Update or Remove for leaf #{repeated.join(', ')}") unless repeated.empty?
34
+
35
+ added_keys = adds.map { |proposal, _sender| proposal.add.key_package.leaf_node.signature_key }
36
+ raise ArgumentError.new('more than one Add for the same client') unless added_keys.uniq.size == added_keys.size
37
+ staying_keys = []
38
+ @ratchet_tree.each_leaf_node { |leaf_node, leaf_index| staying_keys << leaf_node.signature_key unless removed.include?(leaf_index) }
39
+ raise ArgumentError.new('Add for a client already in the group') unless (added_keys & staying_keys).empty?
40
+
41
+ psk_ids = psks.map { |proposal, _sender| proposal.psk.psk.raw }
42
+ raise ArgumentError.new('more than one PreSharedKey proposal for the same PreSharedKeyID') unless psk_ids.uniq.size == psk_ids.size
43
+ raise ArgumentError.new('more than one GroupContextExtensions proposal') if gces.size > 1
44
+ # ReInit must be alone and ExternalInit must be absent (12.2); both are rejected as unsupported in apply_proposals
45
+ end
46
+
47
+ # 12.4.2, 7.6, 7.9.2: a received UpdatePath, checked against the tree after the proposals and before the merge
48
+ def validate_update_path!(tree, update_path, committer, joiners, extensions)
49
+ leaf_node = update_path.leaf_node
50
+ validate_leaf_node!(leaf_node, context: :commit, leaf_index: committer, tree: tree, extensions: extensions,
51
+ replacing: tree.leaf_node_at(committer), sending: false)
52
+ # 7.9.2: the parent hash in the new leaf is the one this tree and path imply
53
+ raise ArgumentError.new('UpdatePath leaf node parent hash does not verify') unless tree.verify_parent_hash_of_path(committer, update_path)
54
+ # 12.4.2, 16.7: the path's public keys are new to the tree and distinct from each other
55
+ path_keys = [leaf_node.encryption_key] + update_path.nodes.map(&:encryption_key)
56
+ raise ArgumentError.new('UpdatePath public keys are not distinct') unless path_keys.uniq.size == path_keys.size
57
+ tree_keys = tree.nodes.compact.map(&:public_encryption_key)
58
+ raise ArgumentError.new('a public key in the UpdatePath already appears in the tree') unless (path_keys & tree_keys).empty?
59
+ # 7.6: each node carries one ciphertext per member of its copath resolution, the new leaves excluded
60
+ copath_nodes = tree.copath_nodes_of_filtered_direct_path(committer * 2)
61
+ raise ArgumentError.new('malformed update path') unless copath_nodes.count == update_path.nodes.count
62
+ excluded = joiners.map { _1.leaf_index * 2 }
63
+ update_path.nodes.each_with_index do |node, i|
64
+ expected = (tree.resolution(copath_nodes[i]) - excluded).count
65
+ next if node.encrypted_path_secret.count == expected
66
+ raise ArgumentError.new("UpdatePath node #{i} has #{node.encrypted_path_secret.count} ciphertexts, expected #{expected}")
67
+ end
68
+ end
69
+
70
+
71
+ # 10.1: a KeyPackage being added, in the tree it is (or will be) added to.
72
+ # leaf_index: where it sits in tree, or nil if it is not in tree yet
73
+ def validate_key_package!(key_package, tree:, leaf_index:, extensions:, sending:)
74
+ raise ArgumentError.new("KeyPackage has version #{key_package.version}") unless key_package.version == Melos::Constants::Version::MLS10
75
+ raise ArgumentError.new("KeyPackage is for cipher suite #{key_package.cipher_suite}") unless key_package.cipher_suite == @suite.suite_id
76
+ Melos::Struct::Extension.check_unique_types!(key_package.extensions, 'KeyPackage extensions')
77
+ validate_leaf_node!(key_package.leaf_node, context: :key_package, leaf_index: leaf_index, tree: tree, extensions: extensions, sending: sending)
78
+ raise ArgumentError.new('KeyPackage signature does not verify') unless key_package.verify
79
+ raise ArgumentError.new('KeyPackage init_key equals its leaf encryption_key') if key_package.init_key == key_package.leaf_node.encryption_key
80
+ # 10: extensions of the KeyPackage itself must also be in the leaf's capabilities
81
+ unsupported = key_package.extensions.map(&:extension_type) - DEFAULT_EXTENSION_TYPES - key_package.leaf_node.capabilities.export.extensions
82
+ raise ArgumentError.new("KeyPackage extensions not in its capabilities: #{unsupported.map { '0x%04x' % _1 }.join(', ')}") unless unsupported.empty?
83
+ end
84
+
85
+ # 7.3: a LeafNode in one of its contexts.
86
+ # context: :key_package (in a KeyPackage), :update (an Update proposal), :commit (the leaf of an UpdatePath),
87
+ # or :tree (a leaf already in the tree, e.g. when joining; its source is taken as given)
88
+ # leaf_index: the leaf's index in tree (needed to verify update/commit signatures); nil if not in tree yet
89
+ # tree, extensions: the ratchet tree and GroupContext extensions the leaf must be compatible with
90
+ # replacing: for :update and :commit, the leaf node being replaced
91
+ # sending: the leaf is in a message we are about to send, so its lifetime is checked against the current time
92
+ def validate_leaf_node!(leaf_node, context:, leaf_index:, tree:, extensions:, replacing: nil, sending: false)
93
+ expected_source = { key_package: Melos::Constants::LeafNodeSource::KEY_PACKAGE, update: Melos::Constants::LeafNodeSource::UPDATE,
94
+ commit: Melos::Constants::LeafNodeSource::COMMIT }[context]
95
+ if expected_source && leaf_node.leaf_node_source != expected_source
96
+ raise ArgumentError.new("leaf node source is #{leaf_node.leaf_node_source}, expected #{expected_source} for #{context}")
97
+ end
98
+
99
+ # 5.3.1: the credential, by the application
100
+ raise ArgumentError.new('leaf node credential rejected') unless @credential_validator.call(leaf_node.credential, leaf_node)
101
+
102
+ # the signature; update and commit leaves are bound to this group and leaf index
103
+ verified = if leaf_node.leaf_node_source == Melos::Constants::LeafNodeSource::KEY_PACKAGE
104
+ leaf_node.verify(@suite, nil, nil)
105
+ else
106
+ raise ArgumentError.new('leaf index needed to verify this leaf node') if leaf_index.nil?
107
+ leaf_node.verify(@suite, @group_id, leaf_index)
108
+ end
109
+ raise ArgumentError.new('leaf node signature does not verify') unless verified
110
+
111
+ # 7.3, 11: compatible with the group's version, cipher suite, extensions and required capabilities
112
+ capabilities = leaf_node.capabilities.export
113
+ raise ArgumentError.new('leaf node does not support MLS 1.0') unless capabilities.versions.include?(Melos::Constants::Version::MLS10)
114
+ raise ArgumentError.new("leaf node does not support cipher suite #{@suite.suite_id}") unless capabilities.cipher_suites.include?(@suite.suite_id)
115
+ others = []
116
+ tree.each_leaf_node { |other, index| others << other unless index == leaf_index }
117
+ validate_capabilities!(leaf_node, capabilities, extensions, others.map { _1.credential.credential_type })
118
+ # 7.3, 13.3: the other members support this leaf's credential type
119
+ others.each do |other|
120
+ next if other.capabilities.export.credentials.include?(leaf_node.credential.credential_type)
121
+ raise ArgumentError.new('a current member does not support this leaf node\'s credential type')
122
+ end
123
+
124
+ # 7.2, 7.3: lifetime, for leaves that came from a KeyPackage
125
+ if leaf_node.leaf_node_source == Melos::Constants::LeafNodeSource::KEY_PACKAGE
126
+ lifetime = leaf_node.lifetime
127
+ if @max_leaf_node_lifetime && lifetime.not_after - lifetime.not_before > @max_leaf_node_lifetime
128
+ raise ArgumentError.new('leaf node lifetime is longer than allowed')
129
+ end
130
+ if sending
131
+ now = Time.now.to_i
132
+ raise ArgumentError.new('leaf node lifetime does not cover the current time') unless lifetime.not_before <= now && now <= lifetime.not_after
133
+ end
134
+ end
135
+
136
+ Melos::Struct::Extension.check_unique_types!(leaf_node.extensions, 'leaf node extensions')
137
+
138
+ # 7.3, 12.4.2: a replacement leaf brings a new encryption key
139
+ if replacing && leaf_node.encryption_key == replacing.encryption_key
140
+ raise ArgumentError.new('replacement leaf node reuses the encryption key of the leaf it replaces')
141
+ end
142
+ # 7.3, 16.7: keys are unique among members
143
+ others.each do |other|
144
+ raise ArgumentError.new('leaf node signature key is already used by another member') if other.signature_key == leaf_node.signature_key
145
+ raise ArgumentError.new('leaf node encryption key is already used by another member') if other.encryption_key == leaf_node.encryption_key
146
+ end
147
+ end
148
+
149
+ # 7.2, 7.3, 11.1, 13.4: a leaf's capabilities cover its own extensions and credential type, the group's
150
+ # non-default extensions, the required_capabilities extension if any, and the credential types in use
151
+ def validate_capabilities!(leaf_node, capabilities, extensions, credential_types_in_use)
152
+ own_extension_types = leaf_node.extensions.map(&:extension_type) - DEFAULT_EXTENSION_TYPES
153
+ missing = own_extension_types - capabilities.extensions
154
+ raise ArgumentError.new("leaf node extensions not in its capabilities: #{missing.map { '0x%04x' % _1 }.join(', ')}") unless missing.empty?
155
+ raise ArgumentError.new('leaf node credential type not in its capabilities') unless capabilities.credentials.include?(leaf_node.credential.credential_type)
156
+
157
+ group_extension_types = extensions.map(&:extension_type) - DEFAULT_EXTENSION_TYPES
158
+ missing = group_extension_types - capabilities.extensions
159
+ raise ArgumentError.new("leaf node does not support GroupContext extensions #{missing.map { '0x%04x' % _1 }.join(', ')}") unless missing.empty?
160
+
161
+ if (required = required_capabilities(extensions))
162
+ missing = (required.extension_types - DEFAULT_EXTENSION_TYPES) - capabilities.extensions
163
+ raise ArgumentError.new("leaf node lacks required extensions #{missing.map { '0x%04x' % _1 }.join(', ')}") unless missing.empty?
164
+ missing = (required.proposal_types - DEFAULT_PROPOSAL_TYPES) - capabilities.proposals
165
+ raise ArgumentError.new("leaf node lacks required proposal types #{missing.map { '0x%04x' % _1 }.join(', ')}") unless missing.empty?
166
+ missing = required.credential_types - capabilities.credentials
167
+ raise ArgumentError.new("leaf node lacks required credential types #{missing.join(', ')}") unless missing.empty?
168
+ end
169
+
170
+ missing = credential_types_in_use.uniq - capabilities.credentials
171
+ raise ArgumentError.new("leaf node does not support credential types in use: #{missing.join(', ')}") unless missing.empty?
172
+ end
173
+
174
+ # 12.2, 12.1.7: after a proposal list is applied, every remaining member is compatible with the (possibly
175
+ # new) extensions and with every other member, and keys are unique. Signatures were checked when each leaf
176
+ # entered the tree, so they are not verified again here.
177
+ def validate_members!(tree, extensions)
178
+ leaves = []
179
+ tree.each_leaf_node { |leaf_node, _index| leaves << leaf_node }
180
+ signature_keys = leaves.map(&:signature_key)
181
+ raise ArgumentError.new('two members share a signature key') unless signature_keys.uniq.size == signature_keys.size
182
+ encryption_keys = leaves.map(&:encryption_key)
183
+ raise ArgumentError.new('two members share an encryption key') unless encryption_keys.uniq.size == encryption_keys.size
184
+ credential_types = leaves.map { _1.credential.credential_type }
185
+ leaves.each do |leaf_node|
186
+ validate_capabilities!(leaf_node, leaf_node.capabilities.export, extensions, credential_types)
187
+ end
188
+ end
189
+
190
+ # 12.4.3.1: what a new member checks about the state it was welcomed into, once it is assembled
191
+ def validate_joined_state!
192
+ validate_tree!(@ratchet_tree, @extensions)
193
+ # the private keys derived from the Welcome's path secret must be the ones for the public keys in the tree
194
+ raise ArgumentError.new('derived private keys do not match the ratchet tree') unless @private_tree.consistent?(@ratchet_tree)
195
+ end
196
+
197
+ # 12.4.3.1, 7.9.2, 7.3: the integrity of a ratchet tree received from someone else.
198
+ # Also what makes 13.4 hold for us: validate_leaf_node! on our own leaf checks that we support every GroupContext extension.
199
+ def validate_tree!(tree, extensions)
200
+ raise ArgumentError.new('ratchet tree has a parent node that is not parent-hash valid') unless tree.parent_hash_valid?
201
+ tree.each_leaf_node do |leaf_node, leaf_index|
202
+ validate_leaf_node!(leaf_node, context: :tree, leaf_index: leaf_index, tree: tree, extensions: extensions)
203
+ end
204
+
205
+ encryption_keys = []
206
+ tree.each_leaf_node { |leaf_node, _index| encryption_keys << leaf_node.encryption_key }
207
+ tree.nodes.each_with_index do |node, node_index|
208
+ next if node.nil? || node.parent_node.nil?
209
+ parent_node = node.parent_node
210
+ encryption_keys << parent_node.encryption_key
211
+ parent_node.unmerged_leaves.each do |leaf_index|
212
+ raise ArgumentError.new("unmerged leaf #{leaf_index} is blank") if tree.leaf_node_at(leaf_index).nil?
213
+ direct_path = Melos::Tree.direct_path(leaf_index * 2, tree.n_leaves)
214
+ raise ArgumentError.new("unmerged leaf #{leaf_index} is not under node #{node_index}") unless direct_path.include?(node_index)
215
+ # every non-blank node between the leaf and this node lists the leaf as unmerged too
216
+ direct_path.take_while { _1 != node_index }.each do |between|
217
+ next if tree[between].nil?
218
+ next if tree[between].parent_node.unmerged_leaves.include?(leaf_index)
219
+ raise ArgumentError.new("unmerged leaf #{leaf_index} of node #{node_index} is merged at node #{between} below it")
220
+ end
221
+ end
222
+ end
223
+ # 12.4.3.1, 16.7: no encryption key appears in two nodes
224
+ raise ArgumentError.new('an encryption key appears in more than one node of the ratchet tree') unless encryption_keys.uniq.size == encryption_keys.size
225
+ end
226
+
227
+ # 11.1: the required_capabilities extension of a GroupContext, decoded, or nil.
228
+ # Returns an object with extension_types, proposal_types and credential_types as integer arrays.
229
+ def required_capabilities(extensions)
230
+ extension = extensions.find { _1.extension_type == Melos::Constants::ExtensionType::REQUIRED_CAPABILITIES }
231
+ return nil if extension.nil?
232
+ struct = Melos::Struct::RequiredCapabilities.new(extension.extension_data)
233
+ Struct.new(:extension_types, :proposal_types, :credential_types).new(
234
+ struct.extension_types.unpack('S>*'), struct.proposal_types.unpack('S>*'), struct.credential_types.unpack('S>*')
235
+ )
236
+ end
237
+ end