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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85db7b48fd128cb7bb205689c66ae391964b16228d21d3cd7b1e2f941d332a31
4
- data.tar.gz: d324b915daf2f12f9226f690e9e2a577ab9a34e7235d9088e181e46ffb7d563c
3
+ metadata.gz: f79041ce8bf4db3f70e527f732c1581533617e3d80f16bf9098bb7629dcdeb4a
4
+ data.tar.gz: 641494ab2a7829bbdfbe61cd90d401a8d3848d2a4beb67c35e12fb49ac00902e
5
5
  SHA512:
6
- metadata.gz: b0c1e2b24240f32c119d10c09f16ccebe2c3442ec6422df9d85d8669dba4b058d331bc22d8eff36aafda228bd86c0d9d75a8f83d4fce8e2ad9727537ee54593d
7
- data.tar.gz: 3fe93f19e872cb70e313714008065026e41ddc0374029a5d0f50b9f843d24f4b394cae6974205098fbfdde36cbdcd979ddd8c4bf73ce721181b1c602877e1a22
6
+ metadata.gz: c230d9ef5487be5be256ac46378921f716b3c5bcaf2b01752c5db74a63f450943497884c39a3688226daf8a2b5c1d323ea24c5326bce928b27f29fe61bd8ea54
7
+ data.tar.gz: b4afcee3755000a00319251b7d4755b85e61378c6ea43296230a43e5ec6c4fd552188efe03e4cda5ac42d0fe5729ff647542a82c45e8b692342f67f68167f290
data/README.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Melos
2
2
 
3
+
4
+ [![Gem Version](https://badge.fury.io/rb/melos.svg)](https://badge.fury.io/rb/melos)
5
+
3
6
  a [Messaging Layer Security Protocol](https://www.rfc-editor.org/rfc/rfc9420.html) implementation in Ruby
4
7
 
5
8
  (yes, [an mls gem](https://rubygems.org/gems/mls) happened to exist since 2014, so...)
9
+
10
+ ## Implementation status
11
+
12
+ As of version 0.1.0, this implements:
13
+
14
+ - Serialization and deserialization of the RFC 9420 structures
15
+ - Key schedule, secret tree, pre-shared keys, transcript hashes
16
+ - The ratchet tree: TreeKEM (creating and processing UpdatePaths), tree hashes, parent hashes
17
+ - Groups: creating a group, Add / Remove / PreSharedKey / GroupContextExtensions proposals,
18
+ commits with an UpdatePath and the matching Welcome, joining via Welcome,
19
+ application messages as PrivateMessage, handshake messages as PublicMessage or PrivateMessage
20
+ - Processing Update proposals and commits from other members
21
+ - Most MUST-level validations of the RFC; [docs/validation.md](docs/validation.md) maps each one to the code
22
+ - All test vectors in [mls-implementations/test-vectors.md](https://github.com/mlswg/mls-implementations/blob/main/test-vectors.md),
23
+ plus round-trip tests in which several members create, join, message and leave a group
24
+
25
+ It does not yet implement:
26
+
27
+ - Creating Update proposals
28
+ - ReInit, external commits (ExternalInit), external senders, resumption PSKs, subgroup branching
29
+ - Decrypting application messages from earlier epochs, and keeping unused keys for out-of-order delivery
30
+ - The deletion schedule of Section 9.2 beyond dropping the previous epoch
31
+ - Credential validation beyond basic credentials (an application hook, `credential_validator:`, is provided)
32
+ - Persistence of client and group state
33
+ - Interop testing against other implementations
34
+
35
+ ## Usage
36
+
37
+ Messages cross between members as `Melos::Struct::MLSMessage` objects; `raw` gives the bytes to
38
+ send and `Melos::Struct::MLSMessage.new(bytes)` parses them.
39
+
40
+ ```ruby
41
+ require 'melos'
42
+
43
+ # cipher suite 1 is MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
44
+ alice = Melos::Client.new(1, identity: 'alice')
45
+ bob = Melos::Client.new(1, identity: 'bob')
46
+
47
+ # Bob publishes a KeyPackage
48
+ bob_key_package = bob.create_key_package
49
+
50
+ # Alice creates a group and adds Bob
51
+ group = alice.create_group("some group id")
52
+ group.create_add_proposal(bob_key_package.message)
53
+ result = group.create_commit
54
+ # result.commit is the Commit to send to the group, result.welcome the Welcome for Bob;
55
+ # merge once the Delivery Service has accepted the commit
56
+ group.merge_commit(result.staged)
57
+
58
+ # Bob joins
59
+ bob_group = bob.join_group(result.welcome)
60
+
61
+ # and talks
62
+ message = bob_group.create_application_message("hello")
63
+ group.process_message(message).content.application_data # => "hello"
64
+ ```
65
+
66
+ `Melos::Group` holds one member's view of one group. `Melos::Client` holds the long-lived identity,
67
+ the external PSKs and the KeyPackages it has published.
68
+
69
+ ## Tests
70
+
71
+ ```
72
+ bundle exec rake test
73
+ ```
74
+
75
+ runs a subset of the test vectors; `TEST_ALL=1 bundle exec rake test` runs all of them, for every cipher suite.
76
+ The vectors are the ones from [mls-implementations](https://github.com/mlswg/mls-implementations), in `test_vectors/`.
@@ -0,0 +1,168 @@
1
+ # RFC 9420 validation checklist
2
+
3
+ MUST-level requirements of RFC 9420 that apply to a client processing or producing messages,
4
+ mapped to the code; updated as validation lands (last: tier 5). Status:
5
+
6
+ - **done**: enforced today, with where.
7
+ - **partial**: enforced in some paths or weaker than the RFC asks.
8
+ - **missing**: not enforced.
9
+ - **n/a**: for features melos does not implement yet (external senders, external commits,
10
+ ReInit, branching); the code rejects these inputs with a clear error instead.
11
+
12
+ Items marked *(application)* are MUSTs the RFC places on the application or the Authentication
13
+ Service; melos should offer a hook or a documented default, not decide the policy.
14
+
15
+ ## 1. Message framing and unprotect (Sections 6, 6.1, 6.2, 6.3)
16
+
17
+ | # | Requirement | RFC | Status | Code |
18
+ |---|---|---|---|---|
19
+ | 1.1 | Verify the signature with the key selected by sender_type | 6.1 | partial | `Group#unprotect`, `#sender_signature_key`: member only; external, new_member_proposal, new_member_commit raise "not supported" |
20
+ | 1.2 | external sender: content_type MUST be proposal, proposal_type MUST be one allowed for external senders | 6.1, 12.1.8 | n/a | rejected with 1.1 |
21
+ | 1.3 | new_member_commit: content_type MUST be commit; new_member_proposal: proposal of type add | 6.1 | n/a | rejected with 1.1 |
22
+ | 1.4 | PublicMessage: check membership_tag for member senders | 6.2 | done | `PublicMessage#unprotect` returns nil, `Group#unprotect` raises |
23
+ | 1.5 | FramedContentAuthData valid: signature and, for commits, confirmation_tag | 6.1, 6.2, 6.3.1 | done | signature in `Group#unprotect`; tag in `Group#stage` |
24
+ | 1.6 | PrivateMessage padding MUST be all zero bytes; reject otherwise | 6.3.1 | done | `PrivateMessage#unprotect` via `PrivateMessageContent#padding_zero?` |
25
+ | 1.7 | SenderData.leaf_index MUST identify a non-blank leaf | 6.3.2 | partial | caught later by `#sender_signature_key` ("sender leaf is blank") after decryption; the RFC wants it checked when the sender data is parsed |
26
+ | 1.8 | Application data MUST be sent as PrivateMessage | 6 | done | `AuthenticatedContent#sign` raises on send; `#verify` returns false on receive |
27
+ | 1.9 | Proposal and Commit epoch MUST equal the current epoch | 12.1, 12.4.2 | done | `Group#unprotect` |
28
+ | 1.10 | Application messages from earlier epochs may be decrypted (15.3); not a MUST, but today `Group#unprotect` rejects any epoch other than the current one, which is stricter than the RFC | 15.3 | behavior gap | needs per-epoch secret trees kept for a while (also 9.2) |
29
+ | 1.11 | MLSMessage.version MUST be mls10 | 6 | done | `Group.check_message_version!` in `#unprotect`, `.from_welcome`, `#create_add_proposal` |
30
+ | 1.12 | AAD plus ciphertext MUST fit the AEAD limits | 6.3.1, 15.2 | missing *(application)* | no size accounting anywhere |
31
+
32
+ ## 2. Proposals as received (Section 12.1)
33
+
34
+ | # | Requirement | RFC | Status | Code |
35
+ |---|---|---|---|---|
36
+ | 2.1 | Verify signature and epoch before caching | 12.1 | done | `Group#unprotect`, `#process_proposal` |
37
+ | 2.2 | Add: KeyPackage valid per 10.1 (see section 4) | 12.1.1 | done | `#apply_proposals` -> `#validate_key_package!` |
38
+ | 2.3 | Update: LeafNode valid for an Update per 7.3, source `update`, encryption_key differs from the replaced leaf | 12.1.2, 7.3 | done | `#apply_proposals` -> `#validate_leaf_node!(context: :update)`; creating Update proposals is still unimplemented |
39
+ | 2.4 | Remove: `removed` MUST be a non-blank leaf | 12.1.3 | done | `#apply_proposals` (both sides), `#create_remove_proposal` |
40
+ | 2.5 | PreSharedKey: psk_nonce length MUST be KDF.Nh | 12.1.4 | done | `#psk_secret_for` |
41
+ | 2.6 | PreSharedKey: resumption with usage reinit or branch is invalid outside those operations | 12.1.4, 11.2, 11.3 | **missing** | `#psk_secret_for` looks up any resumption PSK by epoch regardless of usage |
42
+ | 2.7 | ReInit: version MUST NOT be lower than the current one | 12.1.5 | n/a | ReInit rejected as unsupported |
43
+ | 2.8 | GroupContextExtensions: invalid if required_capabilities not supported by all members after the commit | 12.1.7, 11.1 | done | `#validate_members!` after the list is applied |
44
+ | 2.9 | External proposals MUST be PublicMessage; only add, remove, psk, reinit, gce from external senders; external_senders extension must be present | 12.1.8 | n/a | rejected with 1.1 |
45
+ | 2.10 | Unknown (non-default) proposal types MUST be supported by every member that processes the commit | 12.2, 13.2 | done | `#apply_proposals` rejects any type outside `KNOWN_PROPOSAL_TYPES` |
46
+
47
+ ## 3. Proposal list validation (Section 12.2), on both creating and processing a Commit
48
+
49
+ `Group#validate_proposal_list!` runs in both `#create_commit` and `#stage`.
50
+
51
+ | # | Requirement | RFC | Status |
52
+ |---|---|---|---|
53
+ | 3.1 | Every individual proposal valid (sections 2 and 4) | 12.2 | done (sections 2, 4, 5) |
54
+ | 3.2 | No Update proposal by the committer | 12.2 | done |
55
+ | 3.3 | No Remove of the committer | 12.2 | done (`#stage`, `#create_commit`) |
56
+ | 3.4 | No two Update/Remove proposals for the same leaf | 12.2 | done |
57
+ | 3.5 | No two Adds for the same client (e.g. same signature key) | 12.2 | done (same signature key) |
58
+ | 3.6 | No Add of a client already in the group unless a Remove in the same list removes it | 12.2 | done (same signature key) |
59
+ | 3.7 | No two PreSharedKey proposals with the same PreSharedKeyID | 12.2 | done |
60
+ | 3.8 | At most one GroupContextExtensions proposal | 12.2 | done |
61
+ | 3.9 | ReInit MUST be alone in the list | 12.2 | n/a (ReInit rejected) |
62
+ | 3.10 | No ExternalInit in a regular commit | 12.2 | done by accident (`#apply_proposals` raises "unsupported") |
63
+ | 3.11 | After applying, every leaf in the tree is valid per 7.3 | 12.2 | done: capabilities and key uniqueness of every remaining leaf (`#validate_members!`); signatures were verified when each leaf entered |
64
+ | 3.12 | Reject the Commit if the list is invalid | 12.2 | follows from the above |
65
+ | 3.13 | External commit list rules (exactly one ExternalInit, at most one Remove, PSKs only) | 12.2 | n/a |
66
+
67
+ ## 4. KeyPackage validation (Sections 10, 10.1), when adding and when processing an Add
68
+
69
+ | # | Requirement | RFC | Status | Code |
70
+ |---|---|---|---|---|
71
+ | 4.1 | cipher_suite and version match the GroupContext | 10.1 | done | `#apply_proposals` (Add) |
72
+ | 4.2 | leaf_node valid for a KeyPackage per 7.3 (section 5 below) | 10.1 | done | `#validate_key_package!` |
73
+ | 4.3 | Signature verifies with the leaf's signature key | 10, 10.1 | done | `#validate_key_package!` |
74
+ | 4.4 | leaf_node.encryption_key MUST differ from init_key | 10.1 | done | `#validate_key_package!` |
75
+ | 4.5 | leaf_node_source MUST be key_package | 10 | done | `#validate_leaf_node!(context: :key_package)` |
76
+ | 4.6 | Extensions in KeyPackage.extensions and leaf_node.extensions MUST be listed in leaf_node.capabilities | 10 | done | `#validate_key_package!`, `#validate_capabilities!` |
77
+ | 4.7 | KeyPackage.version MUST equal the enclosing MLSMessage.version | 10 | done | `Group#create_add_proposal` |
78
+ | 4.8 | Unknown capabilities values and unknown extensions MUST be ignored | 13.4 | done by construction | nothing inspects them yet; keep it that way when 4.6 is added |
79
+ | 4.9 | Sender: init_key MUST be unique among the client's KeyPackages | 10 | done | fresh random key per `KeyPackageBundle.generate` |
80
+ | 4.10 | Sender: capabilities MUST list every supported value and MUST NOT list default proposal/extension types | 7.2 | done | `Melos::Capabilities#set_default` lists no proposal or extension types |
81
+
82
+ ## 5. LeafNode validation (Section 7.3), for Add, Update, the Commit path leaf, and every leaf when joining
83
+
84
+ | # | Requirement | RFC | Status | Code |
85
+ |---|---|---|---|---|
86
+ | 5.1 | Credential valid per 5.3.1 (Authentication Service) | 7.3, 5.3.1 | done (hook) | `credential_validator:` option; default accepts basic credentials only |
87
+ | 5.2 | Signature valid with signature_key (with group_id and leaf_index for update/commit sources) | 7.3 | done | `#validate_leaf_node!` |
88
+ | 5.3 | If GroupContext has required_capabilities, its extensions, proposals and credential types MUST be in the leaf's capabilities | 7.3, 11.1, 13.4 | done | `#validate_capabilities!` |
89
+ | 5.4 | Credential type supported by all members; leaf supports every credential type in use | 7.3, 13.3 | done | `#validate_leaf_node!`, `#validate_members!` |
90
+ | 5.5 | Lifetime: sender MUST check current time is within lifetime when sending a leaf (Add of a KeyPackage); receiver RECOMMENDED | 7.3 | done | `#validate_leaf_node!(sending: true)` from `#create_add_proposal` and `#create_commit` |
91
+ | 5.6 | Application MUST define a maximum total lifetime and reject longer ones | 7.2 | done (option) | `max_leaf_node_lifetime:`; no default limit because the interop vectors use the full uint64 range |
92
+ | 5.7 | Every non-default extension in the leaf MUST be listed in capabilities.extensions; credential type MUST be in capabilities.credentials | 7.2, 7.3 | done | `#validate_capabilities!` |
93
+ | 5.8 | leaf_node_source matches the context: key_package / update (and new encryption_key) / commit | 7.3 | done | `#validate_leaf_node!` |
94
+ | 5.9 | signature_key and encryption_key unique among members | 7.3, 16.7 | done | `#validate_leaf_node!`, `#validate_members!`, `#validate_tree!` |
95
+ | 5.10 | Each GroupContext extension (non-default) MUST be supported by a new member's capabilities | 13.4 | done | `#validate_capabilities!` |
96
+
97
+ ## 6. Commit processing (Sections 12.4, 12.4.2, 7.5, 7.6, 7.9.2)
98
+
99
+ | # | Requirement | RFC | Status | Code |
100
+ |---|---|---|---|---|
101
+ | 6.1 | Epoch, membership tag or decryption, signature | 12.4.2 | done | `Group#unprotect` |
102
+ | 6.2 | Proposal list valid (section 3) | 12.4.2 | done | `#validate_proposal_list!` in `#stage` and `#create_commit` |
103
+ | 6.3 | All PreSharedKey proposals available | 12.4.2 | done | `#psk_secret_for` raises |
104
+ | 6.4 | Path MUST be present if the list is empty or has a path-required type | 12.4 | done | `Group#stage` |
105
+ | 6.5 | Path leaf valid per 7.3 with leaf_node_source commit | 12.4.2 | done | `#validate_update_path!` -> `#validate_leaf_node!(context: :commit)` |
106
+ | 6.6 | Path leaf encryption_key MUST differ from the committer's current leaf | 12.4.2 | done | `#validate_update_path!` |
107
+ | 6.7 | None of the UpdatePath public keys appear in any node of the new tree | 12.4.2 | done | `#validate_update_path!` |
108
+ | 6.8 | Recompute the committer's leaf parent_hash and verify it matches the supplied leaf | 7.9.2 | done | `#validate_update_path!` via `RatchetTree#verify_parent_hash_of_path` |
109
+ | 6.9 | Each UpdatePathNode's encrypted_path_secret length MUST equal the copath resolution excluding new leaves | 7.6 | done | `#validate_update_path!` checks every node |
110
+ | 6.10 | Confirmation tag verifies against the new epoch | 12.4.2 | done | `Group#stage` |
111
+ | 6.11 | A Commit that removes us: no further sending, delete state (SHOULD) | 12.4.2 | done | `#stage` returns `removes_us`, `#merge_commit` makes the group inactive; last epoch's secrets are kept for late messages |
112
+ | 6.12 | After a ReInit commit the group MUST NOT be used; wait for the Welcome | 12.4.2 | n/a | ReInit rejected |
113
+ | 6.13 | A member that observed proposals MUST commit before sending application data | 12.4 | done | `#create_application_message` |
114
+ | 6.14 | Generating a Commit MUST NOT modify state; Welcome MUST NOT be delivered until the Commit is accepted | 14 | done / *(application)* | `#create_commit` returns a `StagedCommit`; delivery is the caller's |
115
+ | 6.15 | After processing a path, outdated key material MUST be deleted | 7.5, 9.2 | partial | old epochs are dropped on merge; the `PrivateTree` keeps stale keys for replaced nodes, and `EpochSecrets` memoizes derived secrets for the epoch's lifetime |
116
+
117
+ ## 7. Joining via Welcome (Sections 12.4.3, 12.4.3.1, 12.4.3.3, 13.4)
118
+
119
+ | # | Requirement | RFC | Status | Code |
120
+ |---|---|---|---|---|
121
+ | 7.1 | Find the entry for one of our KeyPackages; error if none or if cipher suites differ | 12.4.3.1 | done | `Client#join_group` finds the entry; `Group.from_welcome` compares the KeyPackage's cipher_suite with the Welcome's |
122
+ | 7.2 | Error if a PSK in GroupSecrets is unknown | 12.4.3.1 | done | `Group.from_welcome` |
123
+ | 7.3 | A resumption PSK with usage reinit or branch MUST be the only such PSK; GroupInfo epoch MUST be 1; reinit/branch consistency checks | 12.4.3.1, 11.2, 11.3 | n/a | resumption PSKs in a Welcome rejected as unsupported |
124
+ | 7.4 | GroupInfo signature with the signer leaf's key; error if blank or invalid | 12.4.3 | done | `Group.from_welcome` |
125
+ | 7.5 | group_id MUST be unique among the groups the client is in | 12.4.3 | done | `Client#ensure_group_id_free!` on create and join |
126
+ | 7.6 | GroupInfo cipher_suite matches the KeyPackage's | 12.4.3.1 | done | see 7.1 |
127
+ | 7.7 | Tree hash matches GroupContext.tree_hash | 12.4.3.1, 12.4.3.3 | done | `Group.from_welcome` |
128
+ | 7.8 | Every non-blank parent node is parent-hash valid | 12.4.3.1, 7.9.2 | done | `#validate_tree!` via `RatchetTree#parent_hash_valid?` |
129
+ | 7.9 | Every non-blank leaf valid per 7.3 | 12.4.3.1 | done | `#validate_tree!` -> `#validate_leaf_node!(context: :tree)` |
130
+ | 7.10 | unmerged_leaves: each entry is a non-blank descendant leaf; every non-blank node between also lists it; parent encryption keys unique in the tree | 12.4.3.1 | done | `#validate_tree!` |
131
+ | 7.11 | Our leaf is the one identical to the KeyPackage's LeafNode | 12.4.3.1 | done | `Group.from_welcome` matches the whole LeafNode |
132
+ | 7.12 | Derived private keys MUST correspond to the public keys in the nodes | 12.4.3.1 | done | `#validate_joined_state!` via `PrivateTree#consistent?` |
133
+ | 7.13 | Confirmation tag verifies with the derived confirmation key | 12.4.3.1 | done | `Group.from_welcome` |
134
+ | 7.14 | Joiner MUST support every GroupContext extension, else the GroupInfo is invalid | 13.4 | done | our own leaf through `#validate_tree!`; a non-default extension we do not list is rejected |
135
+ | 7.15 | Unknown GroupInfo.extensions MUST be ignored | 12.4.3, 13.4 | done | only ratchet_tree is looked up |
136
+ | 7.16 | ratchet_tree extension: last node non-blank, pad to 2^(d+1)-1 | 12.4.3.3 | done | `RatchetTree.parse`, `#initialize` |
137
+ | 7.17 | Download the tree before processing if not in the Welcome | 12.4.3.1 | done | `ratchet_tree:` argument; error if neither |
138
+
139
+ ## 8. Sending side
140
+
141
+ | # | Requirement | RFC | Status | Code |
142
+ |---|---|---|---|---|
143
+ | 8.1 | Fresh random 4-byte reuse guard, included in SenderData | 6.3.1 | done | `PrivateMessage.protect` |
144
+ | 8.2 | psk_nonce fresh random of length KDF.Nh | 8.4, 12.1.4 | done | `Group#create_psk_proposal` |
145
+ | 8.3 | Proposal list valid when creating a commit | 12.2, 12.4.1 | done | `#validate_proposal_list!` |
146
+ | 8.4 | Welcome MUST cover every new member; psks in proposal order | 12.4.1 | done | `#create_welcome` |
147
+ | 8.5 | Creator MUST pick the best version/cipher suite supported by all added members | 11 | partial | one suite per group; 4.1 covers the check that an added KeyPackage matches it |
148
+ | 8.6 | Adding a member: its capabilities MUST cover each GroupContext extension and required_capabilities | 13.4, 11.1 | done | `#validate_key_package!` on `#create_add_proposal` |
149
+ | 8.7 | Lifetime of a KeyPackage leaf checked against the current time when sending an Add | 7.3 | done | see 5.5 |
150
+ | 8.8 | Capabilities content (see 4.10) | 7.2 | done | `Melos::Capabilities` |
151
+
152
+ ## 9. Wire format
153
+
154
+ | # | Requirement | RFC | Status | Code |
155
+ |---|---|---|---|---|
156
+ | 9.1 | Variable-length vector headers MUST use the minimum length; prefix 3 is invalid | 2.1.2 | done | `Melos::Vec.parse_stream`; truncated input raises `ArgumentError` too |
157
+ | 9.2 | Any list of extensions MUST NOT contain two of the same type | 13.4 | done | `Extension.check_unique_types!` on join, creation, GroupContextExtensions proposals and Adds; the commit path leaf is covered by tier 3 |
158
+ | 9.3 | version fields (MLSMessage, KeyPackage, GroupContext) are mls10 | 6, 8.1, 10 | done | `Group` entry points, see 1.11, 4.1, 4.7 |
159
+ | 9.4 | Node hash input optional present iff node non-blank | 7.8 | done by construction | `RatchetTree#tree_hash` |
160
+
161
+ ## Suggested order
162
+
163
+ 1. ~~**Cheap and self-contained**: 1.6 padding, 1.11/9.3 version fields, 9.2 duplicate extensions, 2.4 blank Remove, 2.5 nonce length, 2.10 unknown proposal types, 4.10 Capabilities bug, 6.13 commit-before-send, 6.11 detect our own removal.~~ Done in aee3c38, plus 3.3, 4.1, 4.7 and the 9.1 vector checks.
164
+ 2. ~~**Commit integrity**: 6.5 to 6.9 (path leaf validation, key uniqueness, parent hash check, per-node ciphertext counts), then the proposal list rules 3.2 to 3.8 and 3.11, shared by `create_commit` and `stage`.~~ Done.
165
+ 3. ~~**KeyPackage and LeafNode validation**: sections 4 and 5 as one `LeafNode` validator parameterized by context (key_package / update / commit / tree), used by Add processing, commit path processing and join; includes signature, source, extension and capability checks, key uniqueness. Credential validation and maximum lifetime become hooks with defaults.~~ Done.
166
+ 4. ~~**Join integrity**: 7.8 to 7.12 and 7.14 on top of the validator from step 3.~~ Done.
167
+ 5. ~~**Client-level**: 7.5 group_id uniqueness, 7.1 KeyPackage suite check.~~ Done.
168
+ 6. **Later, with the features they belong to**: external senders (1.2, 2.9), resumption PSK rules (2.6, 7.3), ReInit, external commits, old-epoch application messages (1.10), deletion schedule (6.15).
@@ -0,0 +1,15 @@
1
+ class Melos::Capabilities
2
+ attr_accessor :versions, :cipher_suites, :extensions, :proposals, :credentials
3
+
4
+ def initialize
5
+ set_default
6
+ end
7
+
8
+ def set_default
9
+ @versions = [1]
10
+ @cipher_suites = [1, 2, 3, 4, 5, 6, 7]
11
+ @extensions = [] # 7.2: likewise the default extension types
12
+ @proposals = [] # 7.2: the default proposal types (add .. group_context_extensions) MUST NOT be listed
13
+ @credentials = [1]
14
+ end
15
+ end
@@ -0,0 +1,85 @@
1
+ require_relative 'crypto'
2
+ require_relative 'key_package_bundle'
3
+ require_relative 'group'
4
+ require_relative 'struct/structs'
5
+
6
+ # A client (RFC 9420 Section 5.3): a long-lived identity, i.e. a signature key pair and a
7
+ # credential, plus what a client keeps across groups: the external PSKs it knows (8.4) and the
8
+ # KeyPackages it has published and may still be welcomed into a group with (10).
9
+ class Melos::Client
10
+ attr_reader :cipher_suite, :credential, :external_psks, :key_packages, :groups
11
+
12
+ def initialize(cipher_suite_id, identity: '')
13
+ @cipher_suite = Melos::Crypto::CipherSuite.new(cipher_suite_id)
14
+ @signature_key_pair = Melos::Crypto.generate_signature_key_pair(@cipher_suite)
15
+ @credential = Melos::Struct::Credential.create_basic_credential(identity)
16
+ @external_psks = {} # psk_id -> psk; shared by reference with the groups this client is in
17
+ @key_packages = {} # KeyPackageRef -> Melos::KeyPackageBundle, until used to join a group
18
+ @groups = {} # group_id -> Melos::Group this client created or joined
19
+ end
20
+
21
+ def signature_private_key
22
+ Melos::Crypto.serialize_private_key(@cipher_suite, @signature_key_pair)
23
+ end
24
+
25
+ def signature_public_key
26
+ Melos::Crypto.serialize_public_key(@cipher_suite, @signature_key_pair)
27
+ end
28
+
29
+ # 8.4: an external PSK that commits in this client's groups may reference
30
+ def add_external_psk(psk_id, psk)
31
+ @external_psks[psk_id] = psk
32
+ end
33
+
34
+ # 10: publish a KeyPackage. The bundle is kept so that a Welcome addressed to it can be processed.
35
+ def create_key_package(capabilities: nil, lifetime: nil, extensions: [])
36
+ bundle = Melos::KeyPackageBundle.generate(
37
+ @cipher_suite, signature_private_key: signature_private_key, signature_public_key: signature_public_key,
38
+ credential: @credential, capabilities: capabilities, lifetime: lifetime, extensions: extensions
39
+ )
40
+ @key_packages[bundle.ref] = bundle
41
+ bundle
42
+ end
43
+
44
+ # 11: create a group with this client as its only member.
45
+ # options are passed to Melos::Group (credential_validator:, max_leaf_node_lifetime:)
46
+ def create_group(group_id, extensions: [], **options)
47
+ bundle = Melos::KeyPackageBundle.generate(
48
+ @cipher_suite, signature_private_key: signature_private_key, signature_public_key: signature_public_key, credential: @credential
49
+ )
50
+ ensure_group_id_free!(group_id)
51
+ @groups[group_id] = Melos::Group.create(
52
+ @cipher_suite, group_id, bundle.key_package.leaf_node,
53
+ encryption_private_key: bundle.encryption_private_key, signature_private_key: signature_private_key,
54
+ extensions: extensions, external_psks: @external_psks, **options
55
+ )
56
+ end
57
+
58
+ # 12.4.3.1: join the group a Welcome adds us to, using whichever of our KeyPackages it is addressed to.
59
+ # That KeyPackage is then forgotten: its init key is single-use.
60
+ def join_group(welcome, ratchet_tree: nil, **options)
61
+ welcome_struct = welcome.is_a?(Melos::Struct::MLSMessage) ? welcome.welcome : welcome
62
+ raise ArgumentError.new('not a Welcome message') if welcome_struct.nil?
63
+ bundle = welcome_struct.secrets.map(&:new_member).filter_map { @key_packages[_1] }.first
64
+ raise ArgumentError.new('Welcome is not addressed to any KeyPackage of this client') if bundle.nil?
65
+ group = Melos::Group.from_welcome(
66
+ @cipher_suite, welcome,
67
+ key_package: bundle.key_package, init_private_key: bundle.init_private_key,
68
+ encryption_private_key: bundle.encryption_private_key, signature_private_key: signature_private_key,
69
+ external_psks: @external_psks, ratchet_tree: ratchet_tree, **options
70
+ )
71
+ ensure_group_id_free!(group.group_id)
72
+ @key_packages.delete(bundle.ref)
73
+ @groups[group.group_id] = group
74
+ end
75
+
76
+ private
77
+
78
+ # 12.4.3: a group_id MUST be unique among the groups this client is participating in;
79
+ # a group this client has been removed from no longer counts
80
+ def ensure_group_id_free!(group_id)
81
+ existing = @groups[group_id]
82
+ return if existing.nil? || !existing.active?
83
+ raise ArgumentError.new('this client is already in a group with this group_id')
84
+ end
85
+ end
data/lib/melos/crypto.rb CHANGED
@@ -5,6 +5,14 @@ require_relative 'vec'
5
5
  class Melos::Crypto
6
6
  class CipherSuite
7
7
  module X25519
8
+ def self.serialize_public_key(key)
9
+ key.raw_public_key
10
+ end
11
+
12
+ def self.serialize_private_key(key)
13
+ key.raw_private_key
14
+ end
15
+
8
16
  def self.deserialize_public_encapsulation_key(raw)
9
17
  OpenSSL::PKey.new_raw_public_key('X25519', raw)
10
18
  end
@@ -21,12 +29,28 @@ class Melos::Crypto
21
29
  OpenSSL::PKey.new_raw_private_key('ED25519', raw)
22
30
  end
23
31
 
32
+ def self.encapsulation_algorithm_name
33
+ 'X25519'
34
+ end
35
+
36
+ def self.signature_algorithm_name
37
+ 'ED25519'
38
+ end
39
+
24
40
  def self.hash_algorithm
25
41
  nil
26
42
  end
27
43
  end
28
44
 
29
45
  module X448
46
+ def self.serialize_public_key(key)
47
+ key.raw_public_key
48
+ end
49
+
50
+ def self.serialize_private_key(key)
51
+ key.raw_private_key
52
+ end
53
+
30
54
  def self.deserialize_public_encapsulation_key(raw)
31
55
  OpenSSL::PKey.new_raw_public_key('X448', raw)
32
56
  end
@@ -43,6 +67,14 @@ class Melos::Crypto
43
67
  OpenSSL::PKey.new_raw_private_key('ED448', raw)
44
68
  end
45
69
 
70
+ def self.encapsulation_algorithm_name
71
+ 'X448'
72
+ end
73
+
74
+ def self.signature_algorithm_name
75
+ 'ED448'
76
+ end
77
+
46
78
  def self.hash_algorithm
47
79
  nil
48
80
  end
@@ -50,6 +82,14 @@ class Melos::Crypto
50
82
 
51
83
  class EC
52
84
  # also would like to depend on HPKE gem...
85
+ def self.serialize_public_key(key)
86
+ key.public_key.to_bn.to_s(2)
87
+ end
88
+
89
+ def self.serialize_private_key(key)
90
+ key.private_key.to_s(2)
91
+ end
92
+
53
93
  def self.deserialize_private_key(secret)
54
94
  asn1_seq = OpenSSL::ASN1.Sequence([
55
95
  OpenSSL::ASN1.Integer(1),
@@ -116,49 +156,50 @@ class Melos::Crypto
116
156
  end
117
157
  end
118
158
 
119
- attr_accessor :level, :digest, :hpke, :kdf, :pkey
159
+ attr_accessor :level, :digest, :hpke, :kdf, :pkey, :suite_id
120
160
  def initialize(suite_id)
161
+ @suite_id = suite_id
121
162
  case suite_id
122
163
  when 1 # MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
123
164
  @level = 128
124
165
  @digest = OpenSSL::Digest.new('sha256')
125
- @hpke = HPKE.new(:x25519, :sha256, :sha256, :aes_128_gcm)
166
+ @hpke = HPKE.new(HPKE::DHKEM_X25519_HKDF_SHA256, HPKE::HKDF_SHA256, HPKE::AES_128_GCM)
126
167
  @kdf = @hpke.hkdf
127
168
  @pkey = Melos::Crypto::CipherSuite::X25519
128
169
  when 2 # MLS_128_DHKEMP256_AES128GCM_SHA256_P256
129
170
  @level = 128
130
171
  @digest = OpenSSL::Digest.new('sha256')
131
- @hpke = HPKE.new(:p_256, :sha256, :sha256, :aes_128_gcm)
172
+ @hpke = HPKE.new(HPKE::DHKEM_P256_HKDF_SHA256, HPKE::HKDF_SHA256, HPKE::AES_128_GCM)
132
173
  @kdf = @hpke.hkdf
133
174
  @pkey = Melos::Crypto::CipherSuite::P256
134
175
  when 3 # MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519
135
176
  @level = 128
136
177
  @digest = OpenSSL::Digest.new('sha256')
137
- @hpke = HPKE.new(:x25519, :sha256, :sha256, :chacha20_poly1305)
178
+ @hpke = HPKE.new(HPKE::DHKEM_X25519_HKDF_SHA256, HPKE::HKDF_SHA256, HPKE::CHACHA20_POLY1305)
138
179
  @kdf = @hpke.hkdf
139
180
  @pkey = Melos::Crypto::CipherSuite::X25519
140
181
  when 4 # MLS_256_DHKEMX448_AES256GCM_SHA512_Ed448
141
182
  @level = 256
142
183
  @digest = OpenSSL::Digest.new('sha512')
143
- @hpke = HPKE.new(:x448, :sha512, :sha512, :aes_256_gcm)
184
+ @hpke = HPKE.new(HPKE::DHKEM_X448_HKDF_SHA512, HPKE::HKDF_SHA512, HPKE::AES_256_GCM)
144
185
  @kdf = @hpke.hkdf
145
186
  @pkey = Melos::Crypto::CipherSuite::X448
146
187
  when 5 # MLS_256_DHKEMP521_AES256GCM_SHA512_P521
147
188
  @level = 256
148
189
  @digest = OpenSSL::Digest.new('sha512')
149
- @hpke = HPKE.new(:p_521, :sha512, :sha512, :aes_256_gcm)
190
+ @hpke = HPKE.new(HPKE::DHKEM_P521_HKDF_SHA512, HPKE::HKDF_SHA512, HPKE::AES_256_GCM)
150
191
  @kdf = @hpke.hkdf
151
192
  @pkey = Melos::Crypto::CipherSuite::P521
152
193
  when 6 # MLS_256_DHKEMX448_CHACHA20POLY1305_SHA512_Ed448
153
194
  @level = 256
154
195
  @digest = OpenSSL::Digest.new('sha512')
155
- @hpke = HPKE.new(:x448, :sha512, :sha512, :chacha20_poly1305)
196
+ @hpke = HPKE.new(HPKE::DHKEM_X448_HKDF_SHA512, HPKE::HKDF_SHA512, HPKE::CHACHA20_POLY1305)
156
197
  @kdf = @hpke.hkdf
157
198
  @pkey = Melos::Crypto::CipherSuite::X448
158
199
  when 7 # MLS_256_DHKEMP384_AES256GCM_SHA384_P384
159
200
  @level = 256
160
201
  @digest = OpenSSL::Digest.new('sha384')
161
- @hpke = HPKE.new(:p_384, :sha384, :sha384, :aes_256_gcm)
202
+ @hpke = HPKE.new(HPKE::DHKEM_P384_HKDF_SHA384, HPKE::HKDF_SHA384, HPKE::AES_256_GCM)
162
203
  @kdf = @hpke.hkdf
163
204
  @pkey = Melos::Crypto::CipherSuite::P384
164
205
  end
@@ -172,7 +213,7 @@ class Melos::Crypto
172
213
  end
173
214
 
174
215
  def self.ref_hash(suite, label, value)
175
- ref_hash_input = Melos::Vec.from_string(label) + Melos::Vec.from_string(value)
216
+ ref_hash_input = Melos::Vec.string_to_vec(label) + Melos::Vec.string_to_vec(value)
176
217
  suite.digest.digest(ref_hash_input)
177
218
  end
178
219
 
@@ -189,7 +230,7 @@ class Melos::Crypto
189
230
  end
190
231
 
191
232
  def self.expand_with_label(suite, secret, label, context, length)
192
- kdf_label = [length].pack('S>') + Melos::Vec.from_string("MLS 1.0 " + label) + Melos::Vec.from_string(context)
233
+ kdf_label = [length].pack('S>') + Melos::Vec.string_to_vec("MLS 1.0 " + label) + Melos::Vec.string_to_vec(context)
193
234
  suite.kdf.expand(secret, kdf_label, length)
194
235
  end
195
236
 
@@ -227,26 +268,26 @@ class Melos::Crypto
227
268
  end
228
269
 
229
270
  def self.encrypt_with_label(suite, public_key, label, context, plaintext)
230
- encrypt_context = Melos::Vec.from_string("MLS 1.0 " + label) + Melos::Vec.from_string(context)
271
+ encrypt_context = Melos::Vec.string_to_vec("MLS 1.0 " + label) + Melos::Vec.string_to_vec(context)
231
272
  pkey = suite.pkey.deserialize_public_encapsulation_key(public_key)
232
273
  seal_base(suite, pkey, encrypt_context, "", plaintext)
233
274
  end
234
275
 
235
276
  def self.decrypt_with_label(suite, private_key, label, context, kem_output, ciphertext)
236
- encrypt_context = Melos::Vec.from_string("MLS 1.0 " + label) + Melos::Vec.from_string(context)
277
+ encrypt_context = Melos::Vec.string_to_vec("MLS 1.0 " + label) + Melos::Vec.string_to_vec(context)
237
278
  pkey = suite.pkey.deserialize_private_encapsulation_key(private_key)
238
279
  open_base(suite, kem_output, pkey, encrypt_context, "", ciphertext)
239
280
  end
240
281
 
241
282
  def self.sign_with_label(suite, signature_key, label, content)
242
283
  skey = suite.pkey.deserialize_private_signature_key(signature_key)
243
- sign_content = Melos::Vec.from_string("MLS 1.0 " + label) + Melos::Vec.from_string(content)
284
+ sign_content = Melos::Vec.string_to_vec("MLS 1.0 " + label) + Melos::Vec.string_to_vec(content)
244
285
  skey.sign(suite.pkey.hash_algorithm, sign_content)
245
286
  end
246
287
 
247
288
  def self.verify_with_label(suite, verification_key, label, content, signature_value)
248
289
  vkey = suite.pkey.deserialize_public_signature_key(verification_key)
249
- sign_content = Melos::Vec.from_string("MLS 1.0 " + label) + Melos::Vec.from_string(content)
290
+ sign_content = Melos::Vec.string_to_vec("MLS 1.0 " + label) + Melos::Vec.string_to_vec(content)
250
291
  vkey.verify(suite.pkey.hash_algorithm, signature_value, sign_content)
251
292
  end
252
293
 
@@ -301,7 +342,35 @@ class Melos::Crypto
301
342
  end
302
343
 
303
344
  def self.parent_hash(suite, encryption_key, ph_of_parent, sibling_hash)
304
- parent_hash_input = Melos::Vec.from_string(encryption_key) + Melos::Vec.from_string(ph_of_parent) + Melos::Vec.from_string(sibling_hash)
345
+ parent_hash_input = Melos::Vec.string_to_vec(encryption_key) + Melos::Vec.string_to_vec(ph_of_parent) + Melos::Vec.string_to_vec(sibling_hash)
305
346
  Melos::Crypto.hash(suite, parent_hash_input)
306
347
  end
348
+
349
+ def self.generate_encapsulation_key_pair(suite)
350
+ if suite.pkey.equal?(Melos::Crypto::CipherSuite::X25519) || suite.pkey.equal?(Melos::Crypto::CipherSuite::X448)
351
+ # is an Edwards curve
352
+ OpenSSL::PKey.generate_key(suite.pkey.encapsulation_algorithm_name)
353
+ else
354
+ # is an EC
355
+ OpenSSL::PKey::EC.generate(suite.pkey.curve_name)
356
+ end
357
+ end
358
+
359
+ def self.generate_signature_key_pair(suite)
360
+ if suite.pkey.equal?(Melos::Crypto::CipherSuite::X25519) || suite.pkey.equal?(Melos::Crypto::CipherSuite::X448)
361
+ # is an Edwards curve
362
+ OpenSSL::PKey.generate_key(suite.pkey.signature_algorithm_name)
363
+ else
364
+ # is an EC
365
+ OpenSSL::PKey::EC.generate(suite.pkey.curve_name)
366
+ end
367
+ end
368
+
369
+ def self.serialize_public_key(suite, pkey)
370
+ suite.pkey.serialize_public_key(pkey)
371
+ end
372
+
373
+ def self.serialize_private_key(suite, pkey)
374
+ suite.pkey.serialize_private_key(pkey)
375
+ end
307
376
  end