async-matrix 2.1.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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +8 -8
  3. data/README.md +33 -58
  4. data/ext/async_matrix_e2ee/Cargo.toml +7 -1
  5. data/lib/async/matrix/api/chain.rb +120 -94
  6. data/lib/async/matrix/api/concat.rb +28 -22
  7. data/lib/async/matrix/api/path_tree.rb +71 -44
  8. data/lib/async/matrix/api.rb +6 -1
  9. data/lib/async/matrix/app_service_client.rb +213 -0
  10. data/lib/async/matrix/auth_error.rb +20 -0
  11. data/lib/async/matrix/bad_json_error.rb +20 -0
  12. data/lib/async/matrix/client/encryption.rb +413 -0
  13. data/lib/async/matrix/client/rooms.rb +478 -0
  14. data/lib/async/matrix/client/sync.rb +508 -0
  15. data/lib/async/matrix/client.rb +217 -113
  16. data/lib/async/matrix/config/vivify.rb +107 -0
  17. data/lib/async/matrix/config.rb +184 -0
  18. data/lib/async/matrix/device_store.rb +1621 -0
  19. data/lib/async/matrix/double_puppet_client.rb +1 -1
  20. data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
  21. data/lib/async/matrix/error.rb +9 -36
  22. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  23. data/lib/async/matrix/homeserver_error.rb +20 -0
  24. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  25. data/lib/async/matrix/media_client.rb +46 -35
  26. data/lib/async/matrix/not_found_error.rb +20 -0
  27. data/lib/async/matrix/notifier.rb +7 -3
  28. data/lib/async/matrix/response_too_large_error.rb +20 -0
  29. data/lib/async/matrix/version.rb +1 -1
  30. data/lib/async/matrix.rb +16 -7
  31. data/lib/protocol/matrix/canonical_json.rb +200 -0
  32. data/lib/protocol/matrix/content.rb +106 -0
  33. data/lib/protocol/matrix/encrypted_message.rb +1018 -0
  34. data/lib/protocol/matrix/error.rb +49 -0
  35. data/lib/protocol/matrix/event.rb +208 -0
  36. data/lib/protocol/matrix/key_backup.rb +347 -0
  37. data/lib/protocol/matrix/keys.rb +381 -0
  38. data/lib/protocol/matrix/message_batch.rb +482 -0
  39. data/lib/protocol/matrix/schema/registry.rb +381 -0
  40. data/lib/protocol/matrix/schema/validation_error.rb +234 -0
  41. data/lib/protocol/matrix/schema.rb +170 -0
  42. data/lib/protocol/matrix/secret_storage.rb +535 -0
  43. data/lib/protocol/matrix/signing.rb +278 -0
  44. data/lib/protocol/matrix.rb +25 -0
  45. metadata +42 -135
  46. data/lib/async/discord/api/path_tree.rb +0 -127
  47. data/lib/async/discord/api.rb +0 -151
  48. data/lib/async/discord/client.rb +0 -283
  49. data/lib/async/discord/error.rb +0 -84
  50. data/lib/async/discord/gateway.rb +0 -359
  51. data/lib/async/discord.rb +0 -15
  52. data/lib/async/matrix/application_service/bot.rb +0 -232
  53. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  54. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  55. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  56. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  57. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  58. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  59. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  60. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  61. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  62. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  63. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  64. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  65. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  66. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  67. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  68. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  69. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  70. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  71. data/lib/async/matrix/application_service/config.rb +0 -225
  72. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  73. data/lib/async/matrix/application_service/event.rb +0 -285
  74. data/lib/async/matrix/application_service/server.rb +0 -430
  75. data/lib/async/matrix/application_service/transaction.rb +0 -66
  76. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  77. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  78. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  79. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  80. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  81. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  82. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  83. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  84. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  85. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  86. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  87. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  88. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  89. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  90. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  91. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  92. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  93. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  94. data/lib/async/matrix/bridge/discord/db.rb +0 -138
  95. data/lib/async/matrix/schema/registry.rb +0 -354
  96. data/lib/async/matrix/schema/validation_error.rb +0 -225
  97. data/lib/async/matrix/schema.rb +0 -170
@@ -0,0 +1,413 @@
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 "securerandom"
7
+
8
+ module Async
9
+ module Matrix
10
+ class Client
11
+ # The endpoints that make a device exist, be reachable, and be able to
12
+ # share keys. Mixed into Client.
13
+ #
14
+ # ORDER MATTERS, and it is the one thing a reader needs from this file:
15
+ #
16
+ # register_user the account exists (appservices only)
17
+ # create_device the device exists, without /login (MSC4190)
18
+ # upload_keys other devices can now find and reach us
19
+ # query_keys we learn who else is in the room, and their keys
20
+ # claim_keys we take a key to open a session with one of them
21
+ # send_to_device we hand them a room key through that session
22
+ #
23
+ # Until `upload_keys` has run the device is invisible: nobody can claim a
24
+ # one-time key, so nobody can open an Olm session, so nobody can send us a
25
+ # room key, and every message in an encrypted room stays ciphertext. It is
26
+ # a precondition, not an optimisation.
27
+ #
28
+ # Every method routes through #api, so the path is validated against the
29
+ # vendored OpenAPI tree before a request is made -- a typo is an
30
+ # InvalidEndpointError here rather than a 404 from the homeserver.
31
+ module Encryption
32
+ # ── Publishing our own keys ───────────────────────────────────────────
33
+
34
+ # POST /keys/upload. Any combination of the three may be sent; a nil is
35
+ # omitted rather than sent as null.
36
+ #
37
+ # @parameter device_keys [Hash] from Protocol::Matrix::Keys.device_keys
38
+ # @parameter one_time_keys [Hash] from Keys.one_time_keys
39
+ # @parameter fallback_keys [Hash] from Keys.fallback_keys
40
+ # @returns [Hash] with `one_time_key_counts`, which is what tells us how
41
+ # many the server now holds.
42
+ def upload_keys(device_keys: nil, one_time_keys: nil, fallback_keys: nil)
43
+ body = {}
44
+
45
+ if device_keys
46
+ body[:device_keys] = device_keys
47
+ end
48
+
49
+ unless one_time_keys.nil? || one_time_keys.empty?
50
+ body[:one_time_keys] = one_time_keys
51
+ end
52
+
53
+ unless fallback_keys.nil? || fallback_keys.empty?
54
+ body[:fallback_keys] = fallback_keys
55
+ end
56
+
57
+ api.keys.upload.post(body)
58
+ end
59
+
60
+ # POST /keys/device_signing/upload -- the cross-signing keys.
61
+ #
62
+ # MSC4190 removed the user-interactive auth requirement here for
63
+ # appservices, which is what makes this reachable without a password.
64
+ # It matters because MSC4153 lets senders refuse to share room keys with
65
+ # a device that is not cross-signed.
66
+ def upload_cross_signing_keys(master_key: nil, self_signing_key: nil, user_signing_key: nil)
67
+ body = {}
68
+
69
+ if master_key
70
+ body[:master_key] = master_key
71
+ end
72
+
73
+ if self_signing_key
74
+ body[:self_signing_key] = self_signing_key
75
+ end
76
+
77
+ if user_signing_key
78
+ body[:user_signing_key] = user_signing_key
79
+ end
80
+
81
+ api.keys.device_signing.upload.post(body)
82
+ end
83
+
84
+ # POST /keys/signatures/upload -- our signatures over other keys.
85
+ def upload_signatures(signatures)
86
+ api.keys.signatures.upload.post(signatures)
87
+ end
88
+
89
+ # ── Learning about other devices ──────────────────────────────────────
90
+
91
+ # POST /keys/query. +user_ids+ is who to ask about; an empty device list
92
+ # per user means "all of their devices".
93
+ #
94
+ # @parameter token [String] the `device_lists` sync token, so the server
95
+ # can tell us whether our view is already current.
96
+ def query_keys(user_ids:, token: nil)
97
+ body = {device_keys: user_ids.to_h { |user_id| [user_id, []] }}
98
+
99
+ if token
100
+ body[:token] = token
101
+ end
102
+
103
+ api.keys.query.post(body)
104
+ end
105
+
106
+ # POST /keys/claim -- take one one-time key per device, to open a
107
+ # session.
108
+ #
109
+ # ONLY FOR DEVICES WE HAVE NO SESSION WITH. Each claim consumes a key
110
+ # from a finite pool, so claiming for a device we can already reach
111
+ # burns one for nothing.
112
+ #
113
+ # @parameter devices [Hash] { "@user:server" => ["DEVICEID", ...] }
114
+ def claim_keys(devices:, algorithm: Protocol::Matrix::Keys::SIGNED_CURVE25519, timeout: nil)
115
+ body = {
116
+ one_time_keys: devices.transform_values { |ids|
117
+ ids.to_h { |device_id| [device_id, algorithm] }
118
+ },
119
+ }
120
+
121
+ if timeout
122
+ body[:timeout] = timeout
123
+ end
124
+
125
+ api.keys.claim.post(body)
126
+ end
127
+
128
+ # ── Talking to devices directly ───────────────────────────────────────
129
+
130
+ # PUT /sendToDevice/{eventType}/{txnId}
131
+ #
132
+ # @parameter messages [Hash] { "@user:server" => { "DEVICEID" => content } }
133
+ #
134
+ # This is how a room key travels. The transaction id makes it
135
+ # idempotent, so a retry after a timeout cannot deliver twice.
136
+ def send_to_device(event_type:, messages:, txn_id: nil)
137
+ api.sendToDevice(event_type, txn_id || SecureRandom.uuid).put({messages: messages})
138
+ end
139
+
140
+ # ── Device lifecycle ──────────────────────────────────────────────────
141
+
142
+ # PUT /devices/{deviceId} -- MSC4190.
143
+ #
144
+ # THE REASON THIS EXISTS: appservices used to create devices by calling
145
+ # /login with `m.login.application_service`, and that route is gone on a
146
+ # homeserver fronted by OAuth2 (MAS). This endpoint creates the device
147
+ # with no login at all, answering 201 for a new one and 200 for one that
148
+ # already existed.
149
+ #
150
+ # The device id is OURS to choose and must never change afterwards: it
151
+ # is the identity every room key we hold is bound to.
152
+ def create_device(device_id:, display_name: nil)
153
+ body = {}
154
+
155
+ if display_name
156
+ body[:display_name] = display_name
157
+ end
158
+
159
+ api.devices(device_id).put(body)
160
+ end
161
+
162
+ # DELETE /devices/{deviceId}. MSC4190 removed the UIA requirement for
163
+ # appservices, which is what makes this callable unattended.
164
+ def delete_device(device_id:)
165
+ api.devices(device_id).delete
166
+ end
167
+
168
+ def devices = api.devices.get
169
+
170
+ def device(device_id:) = api.devices(device_id).get
171
+
172
+ # POST /register for an appservice-owned user.
173
+ #
174
+ # `inhibit_login` IS MANDATORY, not tidiness: honouring a login would
175
+ # mean issuing an access token, and under OAuth2 the homeserver no
176
+ # longer owns that -- so without it the call fails with
177
+ # M_APPSERVICE_LOGIN_UNSUPPORTED.
178
+ def register_user(username:)
179
+ api.register.post(
180
+ {
181
+ type: "m.login.application_service",
182
+ username: username,
183
+ inhibit_login: true,
184
+ },
185
+ )
186
+ end
187
+
188
+ # ── Key backup ────────────────────────────────────────────────────────
189
+
190
+ # GET /room_keys/version[/{version}] -- the backup's algorithm and
191
+ # auth_data, which carries the public key it was encrypted to.
192
+ def key_backup_version(version: nil)
193
+ if version
194
+ api.room_keys.version(version).get
195
+ else
196
+ api.room_keys.version.get
197
+ end
198
+ end
199
+
200
+ # GET /room_keys/keys -- every backed-up session, as
201
+ # rooms -> sessions -> session_data blobs for
202
+ # Protocol::Matrix::KeyBackup to decrypt.
203
+ def room_keys(version:)
204
+ # Plain keys, not the "?"-prefixed form: that convention exists to
205
+ # separate query params from a BODY on POST/PUT, and a GET has no
206
+ # body -- every kwarg is already a query parameter.
207
+ api.room_keys.keys.get(version: version)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ __END__
215
+ describe "Async::Matrix::Client::Encryption" do
216
+ # A real Client with its transport stubbed, so these specs exercise the
217
+ # actual path construction AND the OpenAPI path-tree validation -- a wrong
218
+ # path fails here rather than as a 404 from a homeserver.
219
+ def recording_client(response = {})
220
+ # The REAL path tree. Api memoises it process-wide, and the Api specs
221
+ # inject a small fixture tree without restoring it (Api.reset! exists for
222
+ # that and goes uncalled), so without this these specs pass or fail
223
+ # according to the order scampi happens to load files in.
224
+ Async::Matrix::Api.reset!
225
+
226
+ config = Async::Matrix::Config.new({
227
+ "homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
228
+ "appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
229
+ })
230
+ client = Async::Matrix::Client.new(config)
231
+ calls = []
232
+ client.define_singleton_method(:calls) { calls }
233
+ client.define_singleton_method(:request) do |method, path, body = nil, **_options|
234
+ calls << [method, path, body]
235
+ response
236
+ end
237
+ client
238
+ end
239
+
240
+ def last(client) = client.calls.last
241
+
242
+ # ── Publishing ────────────────────────────────────────────────────────────
243
+
244
+ it "uploads device keys" do
245
+ client = recording_client
246
+ client.upload_keys(device_keys: {"user_id" => "@bot:example.org"})
247
+
248
+ last(client)[0].should == "POST"
249
+ last(client)[1].should == "/_matrix/client/v3/keys/upload"
250
+ last(client)[2].should == {device_keys: {"user_id" => "@bot:example.org"}}
251
+ end
252
+
253
+ it "uploads all three kinds of key together" do
254
+ client = recording_client
255
+ client.upload_keys(
256
+ device_keys: {"a" => 1},
257
+ one_time_keys: {"signed_curve25519:k" => {"key" => "k"}},
258
+ fallback_keys: {"signed_curve25519:f" => {"key" => "f", "fallback" => true}},
259
+ )
260
+
261
+ last(client)[2].keys.sort.should == [:device_keys, :fallback_keys, :one_time_keys]
262
+ end
263
+
264
+ # A nil is omitted rather than sent as null -- "May be absent if no new
265
+ # one-time keys are required".
266
+ it "omits what it was not given" do
267
+ client = recording_client
268
+ client.upload_keys(one_time_keys: {})
269
+
270
+ last(client)[2].should == {}
271
+ end
272
+
273
+ it "uploads cross-signing keys" do
274
+ client = recording_client
275
+ client.upload_cross_signing_keys(master_key: {"keys" => {}})
276
+
277
+ last(client)[1].should == "/_matrix/client/v3/keys/device_signing/upload"
278
+ last(client)[2].should == {master_key: {"keys" => {}}}
279
+ end
280
+
281
+ it "uploads signatures" do
282
+ client = recording_client
283
+ client.upload_signatures({"@bot:example.org" => {"ed25519:DEV" => {}}})
284
+
285
+ last(client)[1].should == "/_matrix/client/v3/keys/signatures/upload"
286
+ end
287
+
288
+ # ── Querying ──────────────────────────────────────────────────────────────
289
+
290
+ # An empty device list per user means "all of their devices".
291
+ it "queries keys for a set of users" do
292
+ client = recording_client
293
+ client.query_keys(user_ids: ["@ada:example.org", "@bob:example.org"])
294
+
295
+ last(client)[1].should == "/_matrix/client/v3/keys/query"
296
+ last(client)[2].should == {
297
+ device_keys: {"@ada:example.org" => [], "@bob:example.org" => []},
298
+ }
299
+ end
300
+
301
+ it "passes the device_lists token when it has one" do
302
+ client = recording_client
303
+ client.query_keys(user_ids: ["@ada:example.org"], token: "s72")
304
+
305
+ last(client)[2][:token].should == "s72"
306
+ end
307
+
308
+ it "claims one key per device, naming the algorithm" do
309
+ client = recording_client
310
+ client.claim_keys(devices: {"@ada:example.org" => ["DEV1", "DEV2"]})
311
+
312
+ last(client)[1].should == "/_matrix/client/v3/keys/claim"
313
+ last(client)[2].should == {
314
+ one_time_keys: {
315
+ "@ada:example.org" => {
316
+ "DEV1" => "signed_curve25519",
317
+ "DEV2" => "signed_curve25519",
318
+ },
319
+ },
320
+ }
321
+ end
322
+
323
+ # ── To-device ─────────────────────────────────────────────────────────────
324
+
325
+ it "sends to-device messages with a transaction id" do
326
+ client = recording_client
327
+ client.send_to_device(
328
+ event_type: "m.room.encrypted",
329
+ messages: {"@ada:example.org" => {"DEV1" => {"algorithm" => "m.olm.v1.curve25519-aes-sha2"}}},
330
+ )
331
+
332
+ last(client)[0].should == "PUT"
333
+ last(client)[1].should.be.start_with? "/_matrix/client/v3/sendToDevice/m.room.encrypted/"
334
+ last(client)[2].should == {
335
+ messages: {"@ada:example.org" => {"DEV1" => {"algorithm" => "m.olm.v1.curve25519-aes-sha2"}}},
336
+ }
337
+ end
338
+
339
+ # Idempotence: a retry after a timeout must not deliver twice.
340
+ it "lets the caller supply the transaction id" do
341
+ client = recording_client
342
+ client.send_to_device(event_type: "m.room.encrypted", messages: {}, txn_id: "txn1")
343
+
344
+ last(client)[1].should == "/_matrix/client/v3/sendToDevice/m.room.encrypted/txn1"
345
+ end
346
+
347
+ it "generates a different transaction id each time" do
348
+ client = recording_client
349
+ client.send_to_device(event_type: "m.room.encrypted", messages: {})
350
+ client.send_to_device(event_type: "m.room.encrypted", messages: {})
351
+
352
+ client.calls[0][1].should.not == client.calls[1][1]
353
+ end
354
+
355
+ # ── Devices ───────────────────────────────────────────────────────────────
356
+
357
+ # MSC4190: creates the device with no /login, which is the only way under
358
+ # OAuth2.
359
+ it "creates a device" do
360
+ client = recording_client
361
+ client.create_device(device_id: "ABCDEFGHIJ", display_name: "controller")
362
+
363
+ last(client)[0].should == "PUT"
364
+ last(client)[1].should == "/_matrix/client/v3/devices/ABCDEFGHIJ"
365
+ last(client)[2].should == {display_name: "controller"}
366
+ end
367
+
368
+ it "deletes a device" do
369
+ client = recording_client
370
+ client.delete_device(device_id: "ABCDEFGHIJ")
371
+
372
+ last(client)[0].should == "DELETE"
373
+ last(client)[1].should == "/_matrix/client/v3/devices/ABCDEFGHIJ"
374
+ end
375
+
376
+ # inhibit_login is mandatory under OAuth2: honouring a login would mean
377
+ # issuing an access token the homeserver no longer owns.
378
+ it "registers an appservice user without logging it in" do
379
+ client = recording_client
380
+ client.register_user(username: "controller")
381
+
382
+ last(client)[1].should == "/_matrix/client/v3/register"
383
+ last(client)[2].should == {
384
+ type: "m.login.application_service",
385
+ username: "controller",
386
+ inhibit_login: true,
387
+ }
388
+ end
389
+
390
+ # ── Key backup ────────────────────────────────────────────────────────────
391
+
392
+ it "reads the current backup version" do
393
+ client = recording_client
394
+ client.key_backup_version
395
+
396
+ last(client)[0].should == "GET"
397
+ last(client)[1].should == "/_matrix/client/v3/room_keys/version"
398
+ end
399
+
400
+ it "reads a specific backup version" do
401
+ client = recording_client
402
+ client.key_backup_version(version: "3")
403
+
404
+ last(client)[1].should == "/_matrix/client/v3/room_keys/version/3"
405
+ end
406
+
407
+ it "fetches the backed-up keys for a version" do
408
+ client = recording_client
409
+ client.room_keys(version: "3")
410
+
411
+ last(client)[1].should == "/_matrix/client/v3/room_keys/keys?version=3"
412
+ end
413
+ end