async-matrix 2.1.0-x86_64-linux → 3.0.1-x86_64-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.
- checksums.yaml +4 -4
- data/README.md +33 -58
- data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/api/chain.rb +120 -94
- data/lib/async/matrix/api/concat.rb +28 -22
- data/lib/async/matrix/api/path_tree.rb +71 -44
- data/lib/async/matrix/api.rb +6 -1
- data/lib/async/matrix/app_service_client.rb +213 -0
- data/lib/async/matrix/auth_error.rb +20 -0
- data/lib/async/matrix/bad_json_error.rb +20 -0
- data/lib/async/matrix/client/encryption.rb +413 -0
- data/lib/async/matrix/client/rooms.rb +478 -0
- data/lib/async/matrix/client/sync.rb +508 -0
- data/lib/async/matrix/client.rb +217 -113
- data/lib/async/matrix/config/vivify.rb +107 -0
- data/lib/async/matrix/config.rb +184 -0
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/double_puppet_client.rb +1 -1
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -36
- data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
- data/lib/async/matrix/homeserver_error.rb +20 -0
- data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
- data/lib/async/matrix/media_client.rb +46 -35
- data/lib/async/matrix/not_found_error.rb +20 -0
- data/lib/async/matrix/notifier.rb +7 -3
- data/lib/async/matrix/response_too_large_error.rb +20 -0
- data/lib/async/matrix/version.rb +1 -1
- data/lib/async/matrix.rb +16 -7
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/protocol/matrix/content.rb +106 -0
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/protocol/matrix/event.rb +208 -0
- data/lib/protocol/matrix/key_backup.rb +347 -0
- data/lib/protocol/matrix/keys.rb +381 -0
- data/lib/protocol/matrix/message_batch.rb +482 -0
- data/lib/protocol/matrix/schema/registry.rb +381 -0
- data/lib/protocol/matrix/schema/validation_error.rb +234 -0
- data/lib/protocol/matrix/schema.rb +170 -0
- data/lib/protocol/matrix/secret_storage.rb +535 -0
- data/lib/protocol/matrix/signing.rb +278 -0
- data/lib/protocol/matrix.rb +25 -0
- metadata +43 -136
- data/lib/async/discord/api/path_tree.rb +0 -127
- data/lib/async/discord/api.rb +0 -151
- data/lib/async/discord/client.rb +0 -283
- data/lib/async/discord/error.rb +0 -84
- data/lib/async/discord/gateway.rb +0 -359
- data/lib/async/discord.rb +0 -15
- data/lib/async/matrix/application_service/bot.rb +0 -232
- data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
- data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
- data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
- data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
- data/lib/async/matrix/application_service/config/schema/config.json +0 -61
- data/lib/async/matrix/application_service/config/schema/database.json +0 -38
- data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
- data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
- data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
- data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
- data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
- data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
- data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
- data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
- data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
- data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
- data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
- data/lib/async/matrix/application_service/config/vivify.rb +0 -109
- data/lib/async/matrix/application_service/config.rb +0 -225
- data/lib/async/matrix/application_service/dispatcher.rb +0 -185
- data/lib/async/matrix/application_service/event.rb +0 -285
- data/lib/async/matrix/application_service/server.rb +0 -430
- data/lib/async/matrix/application_service/transaction.rb +0 -66
- data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
- data/lib/async/matrix/application_service/transaction_store.rb +0 -80
- data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
- data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
- data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
- data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
- data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
- data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
- data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
- data/lib/async/matrix/bridge/discord/db.rb +0 -138
- data/lib/async/matrix/schema/registry.rb +0 -354
- data/lib/async/matrix/schema/validation_error.rb +0 -225
- 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
|