async-matrix 2.1.0-aarch64-linux → 3.0.1-aarch64-linux

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -58
  3. data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
  4. data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
  5. data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
  6. data/lib/async/matrix/api/chain.rb +120 -94
  7. data/lib/async/matrix/api/concat.rb +28 -22
  8. data/lib/async/matrix/api/path_tree.rb +71 -44
  9. data/lib/async/matrix/api.rb +6 -1
  10. data/lib/async/matrix/app_service_client.rb +213 -0
  11. data/lib/async/matrix/auth_error.rb +20 -0
  12. data/lib/async/matrix/bad_json_error.rb +20 -0
  13. data/lib/async/matrix/client/encryption.rb +413 -0
  14. data/lib/async/matrix/client/rooms.rb +478 -0
  15. data/lib/async/matrix/client/sync.rb +508 -0
  16. data/lib/async/matrix/client.rb +217 -113
  17. data/lib/async/matrix/config/vivify.rb +107 -0
  18. data/lib/async/matrix/config.rb +184 -0
  19. data/lib/async/matrix/device_store.rb +1621 -0
  20. data/lib/async/matrix/double_puppet_client.rb +1 -1
  21. data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
  22. data/lib/async/matrix/error.rb +9 -36
  23. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  24. data/lib/async/matrix/homeserver_error.rb +20 -0
  25. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  26. data/lib/async/matrix/media_client.rb +46 -35
  27. data/lib/async/matrix/not_found_error.rb +20 -0
  28. data/lib/async/matrix/notifier.rb +7 -3
  29. data/lib/async/matrix/response_too_large_error.rb +20 -0
  30. data/lib/async/matrix/version.rb +1 -1
  31. data/lib/async/matrix.rb +16 -7
  32. data/lib/protocol/matrix/canonical_json.rb +200 -0
  33. data/lib/protocol/matrix/content.rb +106 -0
  34. data/lib/protocol/matrix/encrypted_message.rb +1018 -0
  35. data/lib/protocol/matrix/error.rb +49 -0
  36. data/lib/protocol/matrix/event.rb +208 -0
  37. data/lib/protocol/matrix/key_backup.rb +347 -0
  38. data/lib/protocol/matrix/keys.rb +381 -0
  39. data/lib/protocol/matrix/message_batch.rb +482 -0
  40. data/lib/protocol/matrix/schema/registry.rb +381 -0
  41. data/lib/protocol/matrix/schema/validation_error.rb +234 -0
  42. data/lib/protocol/matrix/schema.rb +170 -0
  43. data/lib/protocol/matrix/secret_storage.rb +535 -0
  44. data/lib/protocol/matrix/signing.rb +278 -0
  45. data/lib/protocol/matrix.rb +25 -0
  46. metadata +43 -136
  47. data/lib/async/discord/api/path_tree.rb +0 -127
  48. data/lib/async/discord/api.rb +0 -151
  49. data/lib/async/discord/client.rb +0 -283
  50. data/lib/async/discord/error.rb +0 -84
  51. data/lib/async/discord/gateway.rb +0 -359
  52. data/lib/async/discord.rb +0 -15
  53. data/lib/async/matrix/application_service/bot.rb +0 -232
  54. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  55. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  56. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  57. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  58. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  59. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  60. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  61. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  62. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  63. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  64. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  65. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  66. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  67. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  68. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  69. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  70. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  71. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  72. data/lib/async/matrix/application_service/config.rb +0 -225
  73. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  74. data/lib/async/matrix/application_service/event.rb +0 -285
  75. data/lib/async/matrix/application_service/server.rb +0 -430
  76. data/lib/async/matrix/application_service/transaction.rb +0 -66
  77. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  78. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  79. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  80. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  81. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  82. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  83. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  84. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  85. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  86. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  87. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  88. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  89. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  90. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  91. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  92. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  93. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  94. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  95. data/lib/async/matrix/bridge/discord/db.rb +0 -138
  96. data/lib/async/matrix/schema/registry.rb +0 -354
  97. data/lib/async/matrix/schema/validation_error.rb +0 -225
  98. data/lib/async/matrix/schema.rb +0 -170
@@ -0,0 +1,1018 @@
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 "json"
7
+
8
+ require_relative "error"
9
+
10
+ module Protocol
11
+ module Matrix
12
+ # An `m.room.encrypted` event: parsed, identified, not yet readable.
13
+ #
14
+ # One event type carries two unrelated payload shapes, and `algorithm`
15
+ # decides which you have:
16
+ #
17
+ # m.olm.v1.curve25519-aes-sha2 to-device, 1:1. `ciphertext` is a MAP
18
+ # from each recipient's curve25519 identity
19
+ # key to `{"type" =>, "body" =>}`, so one
20
+ # event addresses several devices and only
21
+ # the entry under our own key is ours.
22
+ # m.megolm.v1.aes-sha2 room events, 1:many. `ciphertext` is a
23
+ # single string and `session_id` names the
24
+ # megolm session that can read it.
25
+ #
26
+ # THIS CLASS IS THE FORMAT AND NOTHING ELSE. It parses, it says what it is,
27
+ # it hands its ciphertext to a session you supply, and it never holds a key,
28
+ # opens a session or reads storage. Two things follow: it is unit testable
29
+ # with a Hash and no crypto, and it cannot be the reason a key is used
30
+ # against the wrong message.
31
+ #
32
+ # message = EncryptedMessage.new(event)
33
+ # message.megolm? # => true
34
+ # message.decrypted? # => false
35
+ # message.decrypt!(session)
36
+ # message.type # => "m.room.message" — the REAL type
37
+ # message.content # => the plaintext content
38
+ #
39
+ # Both halves stay reachable: #encrypted_content is the envelope as it
40
+ # arrived, #content is the plaintext. An undecryptable message is still a
41
+ # perfectly good object -- a message whose room key has not arrived yet is
42
+ # the single most common state in an encrypted room, not an error.
43
+ class EncryptedMessage
44
+ # The schema's algorithm enum for m.room.encrypted.
45
+ OLM = "m.olm.v1.curve25519-aes-sha2"
46
+ MEGOLM = "m.megolm.v1.aes-sha2"
47
+
48
+ ALGORITHMS = [OLM, MEGOLM].freeze
49
+
50
+ # Olm message types. A PREKEY message carries enough to establish a NEW
51
+ # inbound session, and consumes one of our one-time keys doing it; a
52
+ # MESSAGE can only be read by a session that already exists.
53
+ PREKEY = 0
54
+ MESSAGE = 1
55
+
56
+ # The OlmPayload schema's required set. These fields sit INSIDE the
57
+ # ciphertext, which is what makes an Olm message attributable at all: a
58
+ # sender cannot forge them for a session it does not hold, so comparing
59
+ # them against who we expected is what stops one device claiming to be
60
+ # another. Verifying the values needs identities this class does not have;
61
+ # refusing a payload that omits them does not.
62
+ OLM_PAYLOAD_REQUIRED = %w[type content sender recipient recipient_keys keys].freeze
63
+
64
+ # A message that does not match the format its own algorithm demands.
65
+ class MalformedError < Error; end
66
+
67
+ # An algorithm this library does not implement. NOT fatal to a batch: an
68
+ # unknown algorithm is a message we cannot read, which is the same
69
+ # practical state as a missing key.
70
+ class UnsupportedAlgorithmError < Error; end
71
+
72
+ # An Olm event whose ciphertext map has no entry for our identity key.
73
+ class NotAddressedError < Error; end
74
+
75
+ # The cryptography itself refused the message: a corrupt ciphertext, a
76
+ # session that cannot read it, a ratchet too far advanced. Wrapped rather
77
+ # than propagated so a caller rescues one protocol error instead of
78
+ # whichever RuntimeError the vodozemac binding happened to raise.
79
+ class DecryptionError < Error; end
80
+
81
+ # #payload, #type, #content or #message_index asked for before #decrypt!.
82
+ class NotDecryptedError < Error; end
83
+
84
+ # Does this event need decrypting at all? Lets a caller sort a mixed batch
85
+ # without rescuing.
86
+ def self.encrypted?(data)
87
+ data["type"] == "m.room.encrypted"
88
+ end
89
+
90
+ # ── Building ────────────────────────────────────────────────────────────
91
+ #
92
+ # The outgoing side of the same two formats. These produce the `content`
93
+ # of an `m.room.encrypted` event from ciphertext somebody else computed,
94
+ # which keeps the envelope rules in one place for both directions.
95
+
96
+ # Megolm content, for a room event.
97
+ #
98
+ # `sender_key` and `device_id` ARE included, even though #sender_key and
99
+ # #device_id refuse to return them when reading. That asymmetry is the
100
+ # spec's: since Matrix 1.3 they "must not be read from" for Megolm, but
101
+ # "should still be included on outgoing messages" -- older clients look
102
+ # for them, and omitting them breaks those while reading them breaks us.
103
+ def self.megolm_content(ciphertext:, session_id:, sender_key: nil, device_id: nil)
104
+ {
105
+ "algorithm" => MEGOLM,
106
+ "ciphertext" => ciphertext,
107
+ "session_id" => session_id,
108
+ }.tap do |content|
109
+ if sender_key
110
+ content["sender_key"] = sender_key
111
+ end
112
+
113
+ if device_id
114
+ content["device_id"] = device_id
115
+ end
116
+ end
117
+ end
118
+
119
+ # Olm content, for a to-device message. `ciphertext` maps each recipient's
120
+ # curve25519 identity key to its own `{"type" =>, "body" =>}`, so one event
121
+ # can address many devices.
122
+ def self.olm_content(sender_key:, ciphertext:)
123
+ {
124
+ "algorithm" => OLM,
125
+ "ciphertext" => ciphertext,
126
+ "sender_key" => sender_key,
127
+ }
128
+ end
129
+
130
+ # One entry in an Olm ciphertext map.
131
+ def self.olm_ciphertext(type:, body:)
132
+ {"type" => type, "body" => body}
133
+ end
134
+
135
+ # The plaintext a Megolm room event encrypts.
136
+ #
137
+ # THE ROOM ID IS INSIDE. That is what lets a recipient detect a message
138
+ # moved between rooms: the envelope says one thing, the ciphertext says
139
+ # another, and only the sender could have made them agree.
140
+ def self.room_payload(type:, content:, room_id:)
141
+ {"type" => type, "content" => content, "room_id" => room_id}
142
+ end
143
+
144
+ # The plaintext an Olm to-device message encrypts, with every field the
145
+ # OlmPayload schema requires.
146
+ #
147
+ # These are what make the message attributable: they sit inside the
148
+ # ciphertext, so a sender cannot forge them for a session it does not
149
+ # hold, and the recipient compares them against who it expected.
150
+ def self.olm_payload(type:, content:, sender:, sender_key:, recipient:, recipient_key:)
151
+ {
152
+ "type" => type,
153
+ "content" => content,
154
+ "sender" => sender,
155
+ "keys" => {"ed25519" => sender_key},
156
+ "recipient" => recipient,
157
+ "recipient_keys" => {"ed25519" => recipient_key},
158
+ }
159
+ end
160
+
161
+ # The `m.room_key` payload that hands a Megolm session to another device.
162
+ def self.room_key_payload(room_id:, session_id:, session_key:)
163
+ {
164
+ "type" => "m.room_key",
165
+ "content" => {
166
+ "algorithm" => MEGOLM,
167
+ "room_id" => room_id,
168
+ "session_id" => session_id,
169
+ "session_key" => session_key,
170
+ },
171
+ }
172
+ end
173
+
174
+ # @parameter data [Hash] the raw event with string keys, exactly as it
175
+ # arrived. A to-device event has no `room_id`, `event_id` or
176
+ # `origin_server_ts`; a room event has all three. Both are valid.
177
+ def initialize(data)
178
+ @raw = data
179
+ @encrypted_content = data["content"] || {}
180
+
181
+ @algorithm = @encrypted_content["algorithm"]
182
+ @ciphertext = @encrypted_content["ciphertext"]
183
+ @session_id = @encrypted_content["session_id"]
184
+
185
+ @event_id = data["event_id"]
186
+ @room_id = data["room_id"]
187
+ @sender = data["sender"]
188
+ @origin_server_ts = data["origin_server_ts"]
189
+
190
+ @decrypted = false
191
+ @payload = nil
192
+ @message_index = nil
193
+ end
194
+
195
+ attr_reader :raw,
196
+ :encrypted_content,
197
+ :algorithm,
198
+ :ciphertext,
199
+ :event_id,
200
+ :room_id,
201
+ :sender,
202
+ :origin_server_ts
203
+
204
+ # The counterpart of Event#encrypted?, so a batch can yield both kinds and
205
+ # a consumer can read either without a type check.
206
+ def encrypted? = true
207
+
208
+ def olm? = @algorithm == OLM
209
+ def megolm? = @algorithm == MEGOLM
210
+ def supported? = ALGORITHMS.include?(@algorithm)
211
+
212
+ # The megolm session that can read this, or nil for Olm. THE ONLY
213
+ # legitimate way to find one -- see #sender_key for why the other
214
+ # candidate field is not.
215
+ def session_id
216
+ if megolm?
217
+ @session_id
218
+ end
219
+ end
220
+
221
+ # The sender's curve25519 identity key -- Olm only, deliberately.
222
+ #
223
+ # Megolm events still carry `sender_key` on the wire and senders are still
224
+ # told to include it, but since Matrix 1.3 the spec says it "must not be
225
+ # read from if the encrypted event is using Megolm" and "must not be used
226
+ # to find the corresponding session". Returning nil is how that MUST NOT
227
+ # becomes unreachable instead of a comment; what arrived is still visible
228
+ # through #encrypted_content.
229
+ def sender_key
230
+ unless megolm?
231
+ @encrypted_content["sender_key"]
232
+ end
233
+ end
234
+
235
+ # The sending device id -- Olm only, for the same reason as #sender_key.
236
+ def device_id
237
+ unless megolm?
238
+ @encrypted_content["device_id"]
239
+ end
240
+ end
241
+
242
+ # ── Olm addressing ──────────────────────────────────────────────────────
243
+
244
+ # The `{"type" =>, "body" =>}` addressed to +identity_key+, or nil.
245
+ def ciphertext_for(identity_key)
246
+ if olm? && @ciphertext.is_a?(Hash)
247
+ @ciphertext[identity_key]
248
+ end
249
+ end
250
+
251
+ def addressed_to?(identity_key)
252
+ !ciphertext_for(identity_key).nil?
253
+ end
254
+
255
+ # Every identity key this event addresses.
256
+ def recipients
257
+ if olm? && @ciphertext.is_a?(Hash)
258
+ @ciphertext.keys
259
+ else
260
+ []
261
+ end
262
+ end
263
+
264
+ # PREKEY (0) or MESSAGE (1) for +identity_key+, or nil if not addressed.
265
+ def message_type(identity_key)
266
+ info = ciphertext_for(identity_key)
267
+
268
+ if info
269
+ info["type"]
270
+ end
271
+ end
272
+
273
+ # Does reading this require establishing a new inbound session, and so
274
+ # consuming a one-time key?
275
+ def prekey?(identity_key)
276
+ message_type(identity_key) == PREKEY
277
+ end
278
+
279
+ # ── Validation ──────────────────────────────────────────────────────────
280
+
281
+ def valid?
282
+ validate!
283
+ true
284
+ rescue Error
285
+ false
286
+ end
287
+
288
+ # @raises [MalformedError] a required field is absent, or the ciphertext is
289
+ # the wrong shape for the algorithm.
290
+ # @raises [UnsupportedAlgorithmError] an algorithm we do not implement.
291
+ def validate!
292
+ if @algorithm.nil?
293
+ raise MalformedError, "m.room.encrypted with no algorithm"
294
+ end
295
+
296
+ if @ciphertext.nil?
297
+ raise MalformedError, "#{@algorithm} with no ciphertext"
298
+ end
299
+
300
+ unless supported?
301
+ raise UnsupportedAlgorithmError, "unsupported algorithm: #{@algorithm}"
302
+ end
303
+
304
+ if megolm?
305
+ validate_megolm!
306
+ else
307
+ validate_olm!
308
+ end
309
+
310
+ true
311
+ end
312
+
313
+ # ── Decryption ──────────────────────────────────────────────────────────
314
+
315
+ def decrypted? = @decrypted
316
+
317
+ # Read this message with +session+, which is whichever session the
318
+ # algorithm calls for:
319
+ #
320
+ # megolm an inbound group session whose id is #session_id, answering
321
+ # `decrypt(ciphertext) -> [plaintext, message_index]`
322
+ # olm a 1:1 session with #sender_key, answering
323
+ # `decrypt(type, body) -> plaintext`
324
+ #
325
+ # A SESSION, NOT A KEY. Both algorithms ratchet, so reading advances state
326
+ # that belongs to the caller. This class never holds that state and never
327
+ # saves it: after a successful call the caller must persist the session it
328
+ # passed in, or the next message desynchronises.
329
+ #
330
+ # IDEMPOTENT. A second call returns the first payload rather than
331
+ # decrypting again, because a second `session.decrypt` would ratchet the
332
+ # session forward for a message that has already been read.
333
+ #
334
+ # @parameter identity_key [String] our own curve25519 key. Required for
335
+ # Olm, to pick our entry out of the ciphertext map; ignored for megolm.
336
+ # @returns [Hash] the decrypted payload.
337
+ def decrypt!(session, identity_key: nil)
338
+ if @decrypted
339
+ @payload
340
+ else
341
+ validate!
342
+ @payload = decrypt_with(session, identity_key)
343
+ @decrypted = true
344
+ @payload
345
+ end
346
+ end
347
+
348
+ # The decrypted payload: `{"type" =>, "content" =>, ...}`.
349
+ def payload
350
+ unless @decrypted
351
+ raise NotDecryptedError, "message has not been decrypted"
352
+ end
353
+
354
+ @payload
355
+ end
356
+
357
+ # The REAL event type, which exists only once decrypted -- the envelope's
358
+ # own type is always "m.room.encrypted" and must never reach a consumer.
359
+ def type = payload["type"]
360
+
361
+ def content = payload["content"] || {}
362
+
363
+ # The room the PAYLOAD claims. Megolm payloads carry their own `room_id`,
364
+ # and a sender whose payload disagrees with the envelope is trying to move
365
+ # a message between rooms -- so both are kept rather than merged.
366
+ def payload_room_id = payload["room_id"]
367
+
368
+ # The megolm message index this event decrypted at.
369
+ #
370
+ # THE CALLER MUST CHECK IT. The spec: a client "should remember the megolm
371
+ # `message_index` ... of each event they decrypt for each session" and
372
+ # treat a repeat as invalid unless `event_id` and `origin_server_ts` also
373
+ # match -- which is what makes a message replayed under a fresh event id
374
+ # detectable. This class surfaces the index; it cannot do the remembering,
375
+ # because remembering is storage.
376
+ def message_index
377
+ unless @decrypted
378
+ raise NotDecryptedError, "message has not been decrypted"
379
+ end
380
+
381
+ @message_index
382
+ end
383
+
384
+ private
385
+
386
+ def validate_megolm!
387
+ unless @ciphertext.is_a?(String)
388
+ raise MalformedError, "megolm ciphertext must be a string"
389
+ end
390
+
391
+ if @session_id.nil?
392
+ raise MalformedError, "megolm event with no session_id"
393
+ end
394
+ end
395
+
396
+ def validate_olm!
397
+ unless @ciphertext.is_a?(Hash)
398
+ raise MalformedError, "olm ciphertext must be a map of recipient keys"
399
+ end
400
+
401
+ # Not deprecated for Olm: this is how the recipient finds the session.
402
+ if @encrypted_content["sender_key"].nil?
403
+ raise MalformedError, "olm event with no sender_key"
404
+ end
405
+ end
406
+
407
+ # ONE BRANCH PER ALGORITHM, in the shape protocol-grpc uses for content
408
+ # encodings (Body::Readable#decompress): dispatch on the format field,
409
+ # wrap the primitive so a failure inside it surfaces as a protocol error,
410
+ # and make an algorithm we do not implement a named error rather than a
411
+ # silent nil. A third algorithm is a `when` clause and a method.
412
+ def decrypt_with(session, identity_key)
413
+ case @algorithm
414
+ when MEGOLM
415
+ decrypt_megolm(session)
416
+ when OLM
417
+ decrypt_olm(session, identity_key)
418
+ else
419
+ raise UnsupportedAlgorithmError, "unsupported algorithm: #{@algorithm.inspect}"
420
+ end
421
+ end
422
+
423
+ def decrypt_megolm(session)
424
+ plaintext, index = megolm_plaintext(session)
425
+ @message_index = index
426
+ payload = parse(plaintext)
427
+
428
+ unless payload.key?("type") && payload.key?("content")
429
+ raise MalformedError, "megolm payload has no type/content"
430
+ end
431
+
432
+ payload
433
+ end
434
+
435
+ # Returns [plaintext, message_index].
436
+ def megolm_plaintext(session)
437
+ session.decrypt(@ciphertext)
438
+ rescue StandardError => e
439
+ raise DecryptionError, "failed to decrypt megolm message: #{e.class}: #{e.message}"
440
+ end
441
+
442
+ def decrypt_olm(session, identity_key)
443
+ if identity_key.nil?
444
+ raise ArgumentError, "identity_key is required to decrypt an olm message"
445
+ end
446
+
447
+ info = ciphertext_for(identity_key)
448
+
449
+ if info.nil?
450
+ raise NotAddressedError, "olm event is not addressed to #{identity_key}"
451
+ end
452
+
453
+ payload = parse(olm_plaintext(session, info))
454
+ missing = OLM_PAYLOAD_REQUIRED.reject { |field| payload.key?(field) }
455
+
456
+ unless missing.empty?
457
+ raise MalformedError, "olm payload is missing #{missing.join(', ')}"
458
+ end
459
+
460
+ payload
461
+ end
462
+
463
+ def olm_plaintext(session, info)
464
+ session.decrypt(info["type"], info["body"])
465
+ rescue StandardError => e
466
+ raise DecryptionError, "failed to decrypt olm message: #{e.class}: #{e.message}"
467
+ end
468
+
469
+ def parse(plaintext)
470
+ JSON.parse(plaintext)
471
+ rescue JSON::ParserError => e
472
+ raise MalformedError, "decrypted payload was not JSON: #{e.message}"
473
+ end
474
+ end
475
+ end
476
+ end
477
+
478
+ __END__
479
+ describe "Protocol::Matrix::EncryptedMessage" do
480
+ # Session doubles. The format object holds no keys and opens no sessions, so
481
+ # every shape below is testable with no crypto at all -- which is the point
482
+ # of the class existing separately from the machine that drives it.
483
+ def megolm_session(plaintext, index = 0)
484
+ calls = 0
485
+ session = Object.new
486
+ session.define_singleton_method(:decrypt) do |_ciphertext|
487
+ calls += 1
488
+ [plaintext, index]
489
+ end
490
+ session.define_singleton_method(:calls) { calls }
491
+ session
492
+ end
493
+
494
+ def olm_session(plaintext)
495
+ received = []
496
+ session = Object.new
497
+ session.define_singleton_method(:decrypt) do |type, body|
498
+ received << [type, body]
499
+ plaintext
500
+ end
501
+ session.define_singleton_method(:received) { received }
502
+ session
503
+ end
504
+
505
+ def exploding_session(message = "BAD_MESSAGE_MAC")
506
+ session = Object.new
507
+ session.define_singleton_method(:decrypt) { |*| raise(RuntimeError, message) }
508
+ session
509
+ end
510
+
511
+ def our_key = "7qZcfnBmbEGzxxaWfBjElJuvn7BZx+lSz/SvFrDF/z8"
512
+
513
+ def megolm_event(content_overrides = {})
514
+ {
515
+ "type" => "m.room.encrypted",
516
+ "event_id" => "$evt1",
517
+ "room_id" => "!room:example.org",
518
+ "sender" => "@alice:example.org",
519
+ "origin_server_ts" => 1234567890,
520
+ "content" => {
521
+ "algorithm" => "m.megolm.v1.aes-sha2",
522
+ "ciphertext" => "AwgAEnACgAkLmt6qF84IK++J7UDH2Za1YVchHyprqTqsg",
523
+ "session_id" => "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ",
524
+ }.merge(content_overrides),
525
+ }
526
+ end
527
+
528
+ def olm_event(content_overrides = {})
529
+ {
530
+ "type" => "m.room.encrypted",
531
+ "sender" => "@bob:example.org",
532
+ "content" => {
533
+ "algorithm" => "m.olm.v1.curve25519-aes-sha2",
534
+ "sender_key" => "Szl29ksW/L8yZGWAX+8dY1XyFi+i5wm+DRhTGkbMiwU",
535
+ "ciphertext" => {
536
+ our_key => {
537
+ "type" => 0,
538
+ "body" => "AwogGJJzMhf/S3GQFXAOrCZ3iKyGU5ZScVtjI0KypTYrW",
539
+ },
540
+ },
541
+ }.merge(content_overrides),
542
+ }
543
+ end
544
+
545
+ def megolm_payload
546
+ JSON.generate({
547
+ "type" => "m.room.message",
548
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
549
+ "room_id" => "!room:example.org",
550
+ })
551
+ end
552
+
553
+ def olm_payload(overrides = {})
554
+ JSON.generate({
555
+ "type" => "m.room_key",
556
+ "content" => {"algorithm" => "m.megolm.v1.aes-sha2"},
557
+ "sender" => "@bob:example.org",
558
+ "recipient" => "@alice:example.org",
559
+ "recipient_keys" => {"ed25519" => "ours"},
560
+ "keys" => {"ed25519" => "theirs"},
561
+ }.merge(overrides))
562
+ end
563
+
564
+ def message_for(event) = Protocol::Matrix::EncryptedMessage.new(event)
565
+
566
+ # ── Identifying the format ────────────────────────────────────────────────
567
+
568
+ it "recognises an event that needs decrypting" do
569
+ Protocol::Matrix::EncryptedMessage.encrypted?(megolm_event).should == true
570
+ Protocol::Matrix::EncryptedMessage.encrypted?({"type" => "m.room.message"}).should == false
571
+ end
572
+
573
+ it "identifies a megolm event" do
574
+ message = message_for(megolm_event)
575
+
576
+ message.megolm?.should == true
577
+ message.olm?.should == false
578
+ message.supported?.should == true
579
+ message.valid?.should == true
580
+ end
581
+
582
+ it "identifies an olm event" do
583
+ message = message_for(olm_event)
584
+
585
+ message.olm?.should == true
586
+ message.megolm?.should == false
587
+ message.supported?.should == true
588
+ message.valid?.should == true
589
+ end
590
+
591
+ it "carries the envelope of a room event" do
592
+ message = message_for(megolm_event)
593
+
594
+ message.event_id.should == "$evt1"
595
+ message.room_id.should == "!room:example.org"
596
+ message.sender.should == "@alice:example.org"
597
+ message.origin_server_ts.should == 1234567890
598
+ end
599
+
600
+ # A to-device event is the same event type with no room envelope at all.
601
+ it "accepts a to-device event with no room envelope" do
602
+ message = message_for(olm_event)
603
+
604
+ message.event_id.should.be.nil
605
+ message.room_id.should.be.nil
606
+ message.origin_server_ts.should.be.nil
607
+ message.valid?.should == true
608
+ end
609
+
610
+ # ── Megolm session lookup, and the deprecated fields ──────────────────────
611
+
612
+ it "exposes session_id for megolm" do
613
+ message_for(megolm_event).session_id
614
+ .should == "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ"
615
+ end
616
+
617
+ # Matrix 1.3: sender_key and device_id "must not be read from if the
618
+ # encrypted event is using Megolm", and must not be used to find the session.
619
+ it "refuses to surface sender_key or device_id for megolm, even when present" do
620
+ message = message_for(
621
+ megolm_event(
622
+ "sender_key" => "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA",
623
+ "device_id" => "RJYKSTBOIE",
624
+ ),
625
+ )
626
+
627
+ message.sender_key.should.be.nil
628
+ message.device_id.should.be.nil
629
+ message.encrypted_content["sender_key"].should == "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA"
630
+ end
631
+
632
+ it "accepts a megolm event carrying neither deprecated field" do
633
+ message_for(megolm_event).valid?.should == true
634
+ end
635
+
636
+ it "has no session_id for olm" do
637
+ message_for(olm_event).session_id.should.be.nil
638
+ end
639
+
640
+ it "exposes sender_key for olm, where it is not deprecated" do
641
+ message_for(olm_event).sender_key
642
+ .should == "Szl29ksW/L8yZGWAX+8dY1XyFi+i5wm+DRhTGkbMiwU"
643
+ end
644
+
645
+ # ── Olm addressing ────────────────────────────────────────────────────────
646
+
647
+ it "finds the ciphertext addressed to us" do
648
+ message = message_for(olm_event)
649
+
650
+ message.addressed_to?(our_key).should == true
651
+ message.ciphertext_for(our_key)["type"].should == 0
652
+ message.recipients.should == [our_key]
653
+ end
654
+
655
+ it "reports an event addressed to a different device" do
656
+ message = message_for(olm_event)
657
+
658
+ message.addressed_to?("someone-elses-key").should == false
659
+ message.ciphertext_for("someone-elses-key").should.be.nil
660
+ message.message_type("someone-elses-key").should.be.nil
661
+ end
662
+
663
+ it "handles an event addressed to several devices at once" do
664
+ message = message_for(
665
+ olm_event(
666
+ "ciphertext" => {
667
+ our_key => {"type" => 1, "body" => "ours"},
668
+ "another-device-key" => {"type" => 0, "body" => "theirs"},
669
+ },
670
+ ),
671
+ )
672
+
673
+ message.recipients.length.should == 2
674
+ message.ciphertext_for(our_key)["body"].should == "ours"
675
+ message.message_type("another-device-key").should == 0
676
+ end
677
+
678
+ it "distinguishes a prekey message from an ordinary one" do
679
+ prekey = message_for(olm_event)
680
+ prekey.message_type(our_key).should == Protocol::Matrix::EncryptedMessage::PREKEY
681
+ prekey.prekey?(our_key).should == true
682
+
683
+ ordinary = message_for(olm_event("ciphertext" => {our_key => {"type" => 1, "body" => "b"}}))
684
+ ordinary.message_type(our_key).should == Protocol::Matrix::EncryptedMessage::MESSAGE
685
+ ordinary.prekey?(our_key).should == false
686
+ end
687
+
688
+ it "has no olm addressing for a megolm event" do
689
+ message = message_for(megolm_event)
690
+
691
+ message.recipients.should == []
692
+ message.ciphertext_for(our_key).should.be.nil
693
+ message.addressed_to?(our_key).should == false
694
+ end
695
+
696
+ # ── Malformed and unsupported ─────────────────────────────────────────────
697
+
698
+ it "rejects an event with no algorithm" do
699
+ message = message_for({"content" => {"ciphertext" => "x"}})
700
+
701
+ message.supported?.should == false
702
+ message.valid?.should == false
703
+ lambda { message.validate! }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
704
+ end
705
+
706
+ it "rejects an event with no ciphertext" do
707
+ message = message_for({"content" => {"algorithm" => "m.megolm.v1.aes-sha2"}})
708
+
709
+ lambda { message.validate! }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
710
+ end
711
+
712
+ # An unknown algorithm is not a crash: it is a message we cannot read, the
713
+ # same practical state as a missing key.
714
+ it "reports an unsupported algorithm without raising on construction" do
715
+ message = message_for(olm_event("algorithm" => "m.megolm.v2.made-up"))
716
+
717
+ message.supported?.should == false
718
+ message.olm?.should == false
719
+ message.megolm?.should == false
720
+ message.valid?.should == false
721
+ lambda {
722
+ message.validate!
723
+ }.should.raise(Protocol::Matrix::EncryptedMessage::UnsupportedAlgorithmError)
724
+ end
725
+
726
+ it "rejects a megolm event whose ciphertext is a recipient map" do
727
+ message = message_for(megolm_event("ciphertext" => {our_key => {"type" => 0, "body" => "b"}}))
728
+
729
+ lambda { message.validate! }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
730
+ end
731
+
732
+ it "rejects a megolm event with no session_id" do
733
+ event = megolm_event
734
+ event["content"].delete("session_id")
735
+
736
+ lambda {
737
+ message_for(event).validate!
738
+ }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
739
+ end
740
+
741
+ it "rejects an olm event whose ciphertext is a bare string" do
742
+ message = message_for(olm_event("ciphertext" => "not-a-map"))
743
+
744
+ lambda { message.validate! }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
745
+ end
746
+
747
+ it "rejects an olm event with no sender_key" do
748
+ event = olm_event
749
+ event["content"].delete("sender_key")
750
+
751
+ lambda {
752
+ message_for(event).validate!
753
+ }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
754
+ end
755
+
756
+ # ── Decrypting megolm ─────────────────────────────────────────────────────
757
+
758
+ it "decrypts a megolm event through the session it is handed" do
759
+ message = message_for(megolm_event)
760
+
761
+ message.decrypted?.should == false
762
+ message.decrypt!(megolm_session(megolm_payload, 7))
763
+
764
+ message.decrypted?.should == true
765
+ message.type.should == "m.room.message"
766
+ message.content.should == {"msgtype" => "m.text", "body" => "hello"}
767
+ message.payload_room_id.should == "!room:example.org"
768
+ message.message_index.should == 7
769
+ end
770
+
771
+ # Both halves stay reachable: the envelope is evidence, not scaffolding.
772
+ it "keeps the encrypted content after decrypting" do
773
+ message = message_for(megolm_event)
774
+ message.decrypt!(megolm_session(megolm_payload))
775
+
776
+ message.encrypted_content["algorithm"].should == "m.megolm.v1.aes-sha2"
777
+ message.ciphertext.should == "AwgAEnACgAkLmt6qF84IK++J7UDH2Za1YVchHyprqTqsg"
778
+ end
779
+
780
+ # A second decrypt would ratchet the session forward for a message already
781
+ # read, so the first payload is returned instead.
782
+ it "is idempotent and does not touch the session twice" do
783
+ message = message_for(megolm_event)
784
+ session = megolm_session(megolm_payload)
785
+
786
+ first = message.decrypt!(session)
787
+ second = message.decrypt!(session)
788
+
789
+ second.should == first
790
+ session.calls.should == 1
791
+ end
792
+
793
+ it "rejects a megolm payload with no type or content" do
794
+ message = message_for(megolm_event)
795
+ session = megolm_session(JSON.generate({"room_id" => "!r:example.org"}))
796
+
797
+ lambda {
798
+ message.decrypt!(session)
799
+ }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
800
+ end
801
+
802
+ it "rejects a payload that is not JSON" do
803
+ message = message_for(megolm_event)
804
+
805
+ lambda {
806
+ message.decrypt!(megolm_session("not json at all"))
807
+ }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
808
+ end
809
+
810
+ # Whatever the cryptography raises becomes one protocol error, so a caller
811
+ # rescues this rather than whichever RuntimeError the binding chose.
812
+ it "wraps a failure inside the megolm primitive" do
813
+ message = message_for(megolm_event)
814
+
815
+ lambda {
816
+ message.decrypt!(exploding_session)
817
+ }.should.raise(Protocol::Matrix::EncryptedMessage::DecryptionError)
818
+ end
819
+
820
+ # ── Decrypting olm ────────────────────────────────────────────────────────
821
+
822
+ it "decrypts an olm event with the entry addressed to us" do
823
+ message = message_for(olm_event)
824
+ session = olm_session(olm_payload)
825
+
826
+ message.decrypt!(session, identity_key: our_key)
827
+
828
+ session.received.should == [[0, "AwogGJJzMhf/S3GQFXAOrCZ3iKyGU5ZScVtjI0KypTYrW"]]
829
+ message.type.should == "m.room_key"
830
+ message.content.should == {"algorithm" => "m.megolm.v1.aes-sha2"}
831
+ end
832
+
833
+ it "has no message index for olm" do
834
+ message = message_for(olm_event)
835
+ message.decrypt!(olm_session(olm_payload), identity_key: our_key)
836
+
837
+ message.message_index.should.be.nil
838
+ end
839
+
840
+ it "requires an identity key to decrypt olm" do
841
+ message = message_for(olm_event)
842
+
843
+ lambda { message.decrypt!(olm_session(olm_payload)) }.should.raise(ArgumentError)
844
+ end
845
+
846
+ it "refuses to decrypt an olm event not addressed to us" do
847
+ message = message_for(olm_event)
848
+
849
+ lambda {
850
+ message.decrypt!(olm_session(olm_payload), identity_key: "not-our-key")
851
+ }.should.raise(Protocol::Matrix::EncryptedMessage::NotAddressedError)
852
+ end
853
+
854
+ # The OlmPayload required set. These fields live inside the ciphertext, so
855
+ # they are what makes an olm message attributable; a payload without them
856
+ # cannot be checked against who we expected.
857
+ it "rejects an olm payload missing any required field" do
858
+ %w[type content sender recipient recipient_keys keys].each do |field|
859
+ payload = JSON.parse(olm_payload)
860
+ payload.delete(field)
861
+ message = message_for(olm_event)
862
+
863
+ lambda {
864
+ message.decrypt!(olm_session(JSON.generate(payload)), identity_key: our_key)
865
+ }.should.raise(Protocol::Matrix::EncryptedMessage::MalformedError)
866
+ end
867
+ end
868
+
869
+ it "wraps a failure inside the olm primitive" do
870
+ message = message_for(olm_event)
871
+
872
+ lambda {
873
+ message.decrypt!(exploding_session, identity_key: our_key)
874
+ }.should.raise(Protocol::Matrix::EncryptedMessage::DecryptionError)
875
+ end
876
+
877
+ # ── Before decryption ─────────────────────────────────────────────────────
878
+
879
+ it "raises rather than guessing when read before decryption" do
880
+ message = message_for(megolm_event)
881
+
882
+ lambda { message.payload }.should.raise(Protocol::Matrix::EncryptedMessage::NotDecryptedError)
883
+ lambda { message.type }.should.raise(Protocol::Matrix::EncryptedMessage::NotDecryptedError)
884
+ lambda { message.content }.should.raise(Protocol::Matrix::EncryptedMessage::NotDecryptedError)
885
+ lambda {
886
+ message.message_index
887
+ }.should.raise(Protocol::Matrix::EncryptedMessage::NotDecryptedError)
888
+ end
889
+
890
+ it "validates before attempting to decrypt" do
891
+ message = message_for(megolm_event("algorithm" => "made.up.algorithm"))
892
+
893
+ lambda {
894
+ message.decrypt!(megolm_session(megolm_payload))
895
+ }.should.raise(Protocol::Matrix::EncryptedMessage::UnsupportedAlgorithmError)
896
+ end
897
+ # ── Building the outgoing side ────────────────────────────────────────────
898
+
899
+ it "builds megolm content" do
900
+ content = Protocol::Matrix::EncryptedMessage.megolm_content(
901
+ ciphertext: "AwgAEnAC", session_id: "session1",
902
+ sender_key: "ourcurve", device_id: "OURDEV",
903
+ )
904
+
905
+ content.should == {
906
+ "algorithm" => "m.megolm.v1.aes-sha2",
907
+ "ciphertext" => "AwgAEnAC",
908
+ "session_id" => "session1",
909
+ "sender_key" => "ourcurve",
910
+ "device_id" => "OURDEV",
911
+ }
912
+ end
913
+
914
+ # The spec's asymmetry: "must not be read from" for Megolm, but "should
915
+ # still be included on outgoing messages".
916
+ it "includes the deprecated fields outgoing, though it refuses to read them" do
917
+ content = Protocol::Matrix::EncryptedMessage.megolm_content(
918
+ ciphertext: "c", session_id: "s", sender_key: "ourcurve", device_id: "OURDEV",
919
+ )
920
+ message = Protocol::Matrix::EncryptedMessage.new(
921
+ {"type" => "m.room.encrypted", "content" => content},
922
+ )
923
+
924
+ content["sender_key"].should == "ourcurve"
925
+ message.sender_key.should.be.nil
926
+ end
927
+
928
+ it "omits the deprecated fields when not given them" do
929
+ content = Protocol::Matrix::EncryptedMessage.megolm_content(ciphertext: "c", session_id: "s")
930
+
931
+ content.key?("sender_key").should == false
932
+ content.key?("device_id").should == false
933
+ end
934
+
935
+ it "builds olm content addressing several devices" do
936
+ content = Protocol::Matrix::EncryptedMessage.olm_content(
937
+ sender_key: "ourcurve",
938
+ ciphertext: {
939
+ "theirs" => Protocol::Matrix::EncryptedMessage.olm_ciphertext(type: 0, body: "b1"),
940
+ "others" => Protocol::Matrix::EncryptedMessage.olm_ciphertext(type: 1, body: "b2"),
941
+ },
942
+ )
943
+ message = Protocol::Matrix::EncryptedMessage.new(
944
+ {"type" => "m.room.encrypted", "content" => content},
945
+ )
946
+
947
+ message.olm?.should == true
948
+ message.recipients.sort.should == ["others", "theirs"]
949
+ message.message_type("theirs").should == 0
950
+ message.sender_key.should == "ourcurve"
951
+ end
952
+
953
+ it "builds content its own reader accepts" do
954
+ megolm = Protocol::Matrix::EncryptedMessage.new({
955
+ "type" => "m.room.encrypted",
956
+ "content" => Protocol::Matrix::EncryptedMessage.megolm_content(
957
+ ciphertext: "c", session_id: "s",
958
+ ),
959
+ })
960
+ olm = Protocol::Matrix::EncryptedMessage.new({
961
+ "type" => "m.room.encrypted",
962
+ "content" => Protocol::Matrix::EncryptedMessage.olm_content(
963
+ sender_key: "k",
964
+ ciphertext: {"them" => Protocol::Matrix::EncryptedMessage.olm_ciphertext(type: 0, body: "b")},
965
+ ),
966
+ })
967
+
968
+ megolm.valid?.should == true
969
+ olm.valid?.should == true
970
+ end
971
+
972
+ # THE ROOM ID IS INSIDE the ciphertext, which is what lets a recipient catch
973
+ # a message moved between rooms.
974
+ it "puts the room id inside the megolm plaintext" do
975
+ Protocol::Matrix::EncryptedMessage.room_payload(
976
+ type: "m.room.message", content: {"body" => "hi"}, room_id: "!room:example.org",
977
+ ).should == {
978
+ "type" => "m.room.message",
979
+ "content" => {"body" => "hi"},
980
+ "room_id" => "!room:example.org",
981
+ }
982
+ end
983
+
984
+ # Every field the OlmPayload schema requires -- which is also exactly what
985
+ # #decrypt! refuses a payload for omitting.
986
+ it "builds an olm payload with every required field" do
987
+ payload = Protocol::Matrix::EncryptedMessage.olm_payload(
988
+ type: "m.room_key", content: {"a" => 1},
989
+ sender: "@us:example.org", sender_key: "oured",
990
+ recipient: "@them:example.org", recipient_key: "theired",
991
+ )
992
+
993
+ payload.should == {
994
+ "type" => "m.room_key",
995
+ "content" => {"a" => 1},
996
+ "sender" => "@us:example.org",
997
+ "keys" => {"ed25519" => "oured"},
998
+ "recipient" => "@them:example.org",
999
+ "recipient_keys" => {"ed25519" => "theired"},
1000
+ }
1001
+ Protocol::Matrix::EncryptedMessage::OLM_PAYLOAD_REQUIRED.all? { |field| payload.key?(field) }
1002
+ .should == true
1003
+ end
1004
+
1005
+ it "builds a room key payload" do
1006
+ Protocol::Matrix::EncryptedMessage.room_key_payload(
1007
+ room_id: "!room:example.org", session_id: "s1", session_key: "AgAAAAkey",
1008
+ ).should == {
1009
+ "type" => "m.room_key",
1010
+ "content" => {
1011
+ "algorithm" => "m.megolm.v1.aes-sha2",
1012
+ "room_id" => "!room:example.org",
1013
+ "session_id" => "s1",
1014
+ "session_key" => "AgAAAAkey",
1015
+ },
1016
+ }
1017
+ end
1018
+ end