async-matrix 3.0.0 → 3.0.1

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,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require_relative "encrypted_message"
7
+ require_relative "error"
8
+ require_relative "signing"
9
+
10
+ module Protocol
11
+ module Matrix
12
+ # The three key documents a device publishes through `POST /keys/upload`,
13
+ # built and signed to the spec.
14
+ #
15
+ # device_keys who this device is: its identity keys, signed by itself.
16
+ # Other devices verify everything we sign against the
17
+ # ed25519 key in here.
18
+ # one_time_keys consumable curve25519 keys. Another device CLAIMS one to
19
+ # open an Olm session with us, and the server deletes it.
20
+ # Run out and nobody can reach us: they cannot establish a
21
+ # session, so they cannot send us a room key, so every
22
+ # message in an encrypted room stays ciphertext.
23
+ # fallback_keys one key per algorithm, answering claims after the
24
+ # one-time keys are exhausted. NOT deleted when used,
25
+ # which is the whole point — it is the backstop against
26
+ # the failure above.
27
+ #
28
+ # Each takes raw key strings and an injected signer, so this builds and
29
+ # signs documents without holding a key or knowing where one came from.
30
+ module Keys
31
+ # The algorithm name under which one-time and fallback keys are published.
32
+ # "signed_" because the key object carries its own signature, which is
33
+ # what lets a claimer verify it came from the device it names.
34
+ SIGNED_CURVE25519 = "signed_curve25519"
35
+
36
+ CURVE25519 = "curve25519"
37
+ ED25519 = "ed25519"
38
+
39
+ # What we tell other devices we can do. The same two algorithms
40
+ # EncryptedMessage knows how to read, because claiming one we cannot
41
+ # decrypt would have peers encrypt into a void.
42
+ ALGORITHMS = EncryptedMessage::ALGORITHMS
43
+
44
+ class Error < Protocol::Matrix::Error; end
45
+
46
+ # The signed device identity document.
47
+ #
48
+ # @parameter curve25519 [String] the device's identity key.
49
+ # @parameter ed25519 [String] the device's fingerprint key.
50
+ # @parameter signer [Object] answers `sign(String) -> base64`.
51
+ # @returns [Hash] a DeviceKeys object, signed by this device.
52
+ def self.device_keys(user_id:, device_id:, curve25519:, ed25519:, signer:, algorithms: ALGORITHMS)
53
+ Signing.sign(
54
+ {
55
+ "algorithms" => algorithms,
56
+ "device_id" => device_id,
57
+ "keys" => {
58
+ "#{CURVE25519}:#{device_id}" => curve25519,
59
+ "#{ED25519}:#{device_id}" => ed25519,
60
+ },
61
+ "user_id" => user_id,
62
+ },
63
+ signer: signer,
64
+ user_id: user_id,
65
+ key_id: Signing.key_id(device_id),
66
+ )
67
+ end
68
+
69
+ # Sign a batch of one-time keys for upload.
70
+ #
71
+ # @parameter keys [Hash] key_id => base64 curve25519 key, as the account
72
+ # reports its unpublished keys.
73
+ # @returns [Hash] "signed_curve25519:<key_id>" => {"key" =>, "signatures" =>}
74
+ #
75
+ # EACH KEY IS SIGNED SEPARATELY, and that is not incidental: a claimer
76
+ # receives one key object on its own, with no device-keys document
77
+ # alongside it, so the signature on the key object is the only thing
78
+ # tying it to our device.
79
+ def self.one_time_keys(keys, user_id:, device_id:, signer:)
80
+ keys.to_h do |key_id, key|
81
+ [
82
+ "#{SIGNED_CURVE25519}:#{key_id}",
83
+ signed_key({"key" => key}, user_id: user_id, device_id: device_id, signer: signer),
84
+ ]
85
+ end
86
+ end
87
+
88
+ # A fallback key, in the same shape plus the marker that distinguishes it.
89
+ #
90
+ # "When uploading a signed key, an additional `fallback: true` key should
91
+ # be included to denote that the key is a fallback key." It is part of the
92
+ # SIGNED object, so a server cannot silently reclassify a one-time key as
93
+ # a fallback (or the reverse) without breaking the signature.
94
+ def self.fallback_keys(keys, user_id:, device_id:, signer:)
95
+ keys.to_h do |key_id, key|
96
+ [
97
+ "#{SIGNED_CURVE25519}:#{key_id}",
98
+ signed_key(
99
+ {"fallback" => true, "key" => key},
100
+ user_id: user_id,
101
+ device_id: device_id,
102
+ signer: signer,
103
+ ),
104
+ ]
105
+ end
106
+ end
107
+
108
+ def self.signed_key(object, user_id:, device_id:, signer:)
109
+ Signing.sign(
110
+ object,
111
+ signer: signer,
112
+ user_id: user_id,
113
+ key_id: Signing.key_id(device_id),
114
+ )
115
+ end
116
+
117
+ # ── Reading other devices' keys ─────────────────────────────────────────
118
+
119
+ # Pull one algorithm's key out of a DeviceKeys object.
120
+ #
121
+ # The key names are "<algorithm>:<device_id>", and the device_id must be
122
+ # taken from the document rather than assumed, because a response from
123
+ # /keys/query carries many devices at once.
124
+ def self.key_for(device_keys, algorithm)
125
+ device_id = device_keys["device_id"]
126
+
127
+ if device_id.nil?
128
+ nil
129
+ else
130
+ (device_keys["keys"] || {})["#{algorithm}:#{device_id}"]
131
+ end
132
+ end
133
+
134
+ def self.identity_key(device_keys) = key_for(device_keys, CURVE25519)
135
+ def self.fingerprint(device_keys) = key_for(device_keys, ED25519)
136
+
137
+ # Is this DeviceKeys object correctly self-signed, and does it describe the
138
+ # device it claims to?
139
+ #
140
+ # BOTH HALVES MATTER. A valid signature over a document whose user_id or
141
+ # device_id disagrees with where we found it is a document for a DIFFERENT
142
+ # device, replayed — so the spec has us "confirm that the `user_id` and
143
+ # `device_id` match those of the top-level map entry".
144
+ def self.valid_device_keys?(device_keys, user_id:, device_id:, verifier:)
145
+ key = fingerprint(device_keys)
146
+
147
+ if key.nil? || device_keys["user_id"] != user_id || device_keys["device_id"] != device_id
148
+ false
149
+ else
150
+ Signing.verify(
151
+ device_keys,
152
+ key: key,
153
+ verifier: verifier,
154
+ user_id: user_id,
155
+ key_id: Signing.key_id(device_id),
156
+ )
157
+ end
158
+ rescue Signing::MissingSignatureError
159
+ false
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ __END__
166
+ describe "Protocol::Matrix::Keys" do
167
+ def signer(signature = "SIG")
168
+ signer = Object.new
169
+ signed = []
170
+ signer.define_singleton_method(:signed) { signed }
171
+ signer.define_singleton_method(:sign) do |message|
172
+ signed << message
173
+ signature
174
+ end
175
+ signer
176
+ end
177
+
178
+ def verifier(result = true)
179
+ verifier = Object.new
180
+ verifier.define_singleton_method(:verify_signature) { |_key, _message, _signature| result }
181
+ verifier
182
+ end
183
+
184
+ def device_keys(**overrides)
185
+ Protocol::Matrix::Keys.device_keys(
186
+ **{
187
+ user_id: "@alice:example.com",
188
+ device_id: "JLAFKJWSCS",
189
+ curve25519: "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
190
+ ed25519: "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI",
191
+ signer: signer,
192
+ }.merge(overrides),
193
+ )
194
+ end
195
+
196
+ # ── device_keys ───────────────────────────────────────────────────────────
197
+
198
+ # Every field the DeviceKeys schema requires.
199
+ it "builds the document the schema requires" do
200
+ keys = device_keys
201
+
202
+ keys["user_id"].should == "@alice:example.com"
203
+ keys["device_id"].should == "JLAFKJWSCS"
204
+ keys["algorithms"].should == ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"]
205
+ keys["keys"].should == {
206
+ "curve25519:JLAFKJWSCS" => "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
207
+ "ed25519:JLAFKJWSCS" => "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI",
208
+ }
209
+ keys["signatures"].should == {"@alice:example.com" => {"ed25519:JLAFKJWSCS" => "SIG"}}
210
+ end
211
+
212
+ # The key names are "<algorithm>:<device_id>", so a different device id
213
+ # renames them -- they are not fixed strings.
214
+ it "names the keys after the device" do
215
+ device_keys(device_id: "OTHERDEV")["keys"].keys.sort
216
+ .should == ["curve25519:OTHERDEV", "ed25519:OTHERDEV"]
217
+ end
218
+
219
+ # We advertise exactly what EncryptedMessage can read: claiming an algorithm
220
+ # we cannot decrypt would have peers encrypt into a void.
221
+ it "advertises only algorithms we can actually read" do
222
+ Protocol::Matrix::Keys::ALGORITHMS.should == Protocol::Matrix::EncryptedMessage::ALGORITHMS
223
+ end
224
+
225
+ it "accepts an explicit algorithm list" do
226
+ device_keys(algorithms: ["m.megolm.v1.aes-sha2"])["algorithms"]
227
+ .should == ["m.megolm.v1.aes-sha2"]
228
+ end
229
+
230
+ it "signs the canonical form of the document" do
231
+ sign = signer
232
+ device_keys(signer: sign)
233
+
234
+ sign.signed.length.should == 1
235
+ sign.signed.first.should ==
236
+ '{"algorithms":["m.olm.v1.curve25519-aes-sha2","m.megolm.v1.aes-sha2"],' \
237
+ '"device_id":"JLAFKJWSCS",' \
238
+ '"keys":{"curve25519:JLAFKJWSCS":"3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",' \
239
+ '"ed25519:JLAFKJWSCS":"lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"},' \
240
+ '"user_id":"@alice:example.com"}'
241
+ end
242
+
243
+ # ── one_time_keys ─────────────────────────────────────────────────────────
244
+
245
+ it "publishes one-time keys under signed_curve25519" do
246
+ keys = Protocol::Matrix::Keys.one_time_keys(
247
+ {"AAAAHg" => "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs"},
248
+ user_id: "@alice:example.com",
249
+ device_id: "JLAFKJWSCS",
250
+ signer: signer,
251
+ )
252
+
253
+ keys.keys.should == ["signed_curve25519:AAAAHg"]
254
+ keys["signed_curve25519:AAAAHg"].should == {
255
+ "key" => "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs",
256
+ "signatures" => {"@alice:example.com" => {"ed25519:JLAFKJWSCS" => "SIG"}},
257
+ }
258
+ end
259
+
260
+ # A claimer receives ONE key object alone, with no device-keys document
261
+ # beside it, so the signature on each key is the only thing tying it to us.
262
+ it "signs each key separately" do
263
+ sign = signer
264
+ Protocol::Matrix::Keys.one_time_keys(
265
+ {"one" => "KEY1", "two" => "KEY2"},
266
+ user_id: "@alice:example.com",
267
+ device_id: "DEV",
268
+ signer: sign,
269
+ )
270
+
271
+ sign.signed.should == ['{"key":"KEY1"}', '{"key":"KEY2"}']
272
+ end
273
+
274
+ it "publishes nothing for an empty batch" do
275
+ Protocol::Matrix::Keys.one_time_keys({}, user_id: "@a:b", device_id: "D", signer: signer)
276
+ .should == {}
277
+ end
278
+
279
+ # ── fallback_keys ─────────────────────────────────────────────────────────
280
+
281
+ # The marker is inside the SIGNED object, so a server cannot reclassify a
282
+ # one-time key as a fallback (or the reverse) without breaking the signature.
283
+ it "marks a fallback key, inside the signature" do
284
+ sign = signer
285
+ keys = Protocol::Matrix::Keys.fallback_keys(
286
+ {"AAAAGj" => "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs"},
287
+ user_id: "@alice:example.com",
288
+ device_id: "JLAFKJWSCS",
289
+ signer: sign,
290
+ )
291
+
292
+ keys["signed_curve25519:AAAAGj"]["fallback"].should == true
293
+ sign.signed.first.should ==
294
+ '{"fallback":true,"key":"zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs"}'
295
+ end
296
+
297
+ # ── Reading another device's keys ─────────────────────────────────────────
298
+
299
+ it "pulls the identity and fingerprint keys out of a document" do
300
+ keys = device_keys
301
+
302
+ Protocol::Matrix::Keys.identity_key(keys)
303
+ .should == "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI"
304
+ Protocol::Matrix::Keys.fingerprint(keys)
305
+ .should == "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"
306
+ end
307
+
308
+ # The device id comes from the document, because a /keys/query response
309
+ # carries many devices at once.
310
+ it "reads the keys of whichever device the document describes" do
311
+ Protocol::Matrix::Keys.identity_key(device_keys(device_id: "OTHERDEV", curve25519: "THEIRS"))
312
+ .should == "THEIRS"
313
+ end
314
+
315
+ it "answers nil for a document with no device id" do
316
+ Protocol::Matrix::Keys.identity_key({"keys" => {"curve25519:D" => "K"}}).should.be.nil
317
+ end
318
+
319
+ it "accepts a correctly self-signed document" do
320
+ Protocol::Matrix::Keys.valid_device_keys?(
321
+ device_keys,
322
+ user_id: "@alice:example.com",
323
+ device_id: "JLAFKJWSCS",
324
+ verifier: verifier,
325
+ ).should == true
326
+ end
327
+
328
+ it "rejects a document whose signature does not verify" do
329
+ Protocol::Matrix::Keys.valid_device_keys?(
330
+ device_keys,
331
+ user_id: "@alice:example.com",
332
+ device_id: "JLAFKJWSCS",
333
+ verifier: verifier(false),
334
+ ).should == false
335
+ end
336
+
337
+ # "Confirm that the user_id and device_id match those of the top-level map
338
+ # entry": a valid signature over a document describing a DIFFERENT device is
339
+ # that other device's document, replayed.
340
+ it "rejects a document that describes a different device" do
341
+ Protocol::Matrix::Keys.valid_device_keys?(
342
+ device_keys(device_id: "SOMEONEELSE"),
343
+ user_id: "@alice:example.com",
344
+ device_id: "JLAFKJWSCS",
345
+ verifier: verifier,
346
+ ).should == false
347
+ end
348
+
349
+ it "rejects a document that claims a different user" do
350
+ Protocol::Matrix::Keys.valid_device_keys?(
351
+ device_keys(user_id: "@mallory:example.com"),
352
+ user_id: "@alice:example.com",
353
+ device_id: "JLAFKJWSCS",
354
+ verifier: verifier,
355
+ ).should == false
356
+ end
357
+
358
+ it "rejects a document with no fingerprint key to verify against" do
359
+ keys = device_keys
360
+ keys["keys"].delete("ed25519:JLAFKJWSCS")
361
+
362
+ Protocol::Matrix::Keys.valid_device_keys?(
363
+ keys,
364
+ user_id: "@alice:example.com",
365
+ device_id: "JLAFKJWSCS",
366
+ verifier: verifier,
367
+ ).should == false
368
+ end
369
+
370
+ it "rejects an unsigned document rather than raising" do
371
+ keys = device_keys
372
+ keys.delete("signatures")
373
+
374
+ Protocol::Matrix::Keys.valid_device_keys?(
375
+ keys,
376
+ user_id: "@alice:example.com",
377
+ device_id: "JLAFKJWSCS",
378
+ verifier: verifier,
379
+ ).should == false
380
+ end
381
+ end