async-matrix 2.1.0-arm-linux → 3.0.1-arm-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,1621 @@
|
|
|
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 "digest"
|
|
7
|
+
|
|
8
|
+
require_relative "../../protocol/matrix/encrypted_message"
|
|
9
|
+
require_relative "../../protocol/matrix/keys"
|
|
10
|
+
|
|
11
|
+
module Async
|
|
12
|
+
module Matrix
|
|
13
|
+
# One device's key material, and the ability to read anything it holds a key
|
|
14
|
+
# for.
|
|
15
|
+
#
|
|
16
|
+
# A CLIENT CLASS, NOT A PROTOCOL ONE. Protocol::Matrix owns formats and the
|
|
17
|
+
# rules for reading them, and holds no state and no key material. This holds
|
|
18
|
+
# both: a live Olm account, the ratchets, and every room key the device has
|
|
19
|
+
# accumulated. It is also where the native crypto primitives are injected,
|
|
20
|
+
# which the format layer must never name.
|
|
21
|
+
#
|
|
22
|
+
# This is what you hand to a sync client. It owns the Olm account, the 1:1
|
|
23
|
+
# sessions with other devices, and every Megolm room key the device has been
|
|
24
|
+
# given — so it can decrypt a message from ANY room, because a room key is
|
|
25
|
+
# just an entry in here.
|
|
26
|
+
#
|
|
27
|
+
# store = DeviceStore.new(
|
|
28
|
+
# user_id: "@bot:example.org", device_id: "ABCDEFGHIJ",
|
|
29
|
+
# account: account, e2ee: Async::Matrix::E2EE,
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
# store.decrypt(message) # => message if it could be read, nil if not
|
|
33
|
+
#
|
|
34
|
+
# IT ACCUMULATES AS IT READS. Decrypting an Olm to-device message routinely
|
|
35
|
+
# yields an `m.room_key`, and the store absorbs it on the spot. That is what
|
|
36
|
+
# makes a sync loop work with no orchestration above it: feed the batch
|
|
37
|
+
# through in order and the keys arriving early in it unlock the messages
|
|
38
|
+
# later in it.
|
|
39
|
+
#
|
|
40
|
+
# NO PERSISTENCE, BY CONSTRUCTION. The primitives are handed in, already
|
|
41
|
+
# unpickled, and every ratchet step is reported through #changes for the
|
|
42
|
+
# caller to write wherever it likes. This class never sees a database, and
|
|
43
|
+
# it holds no opinion about where its keys came from — which is also why it
|
|
44
|
+
# can be driven in a test with three fake objects and no cryptography.
|
|
45
|
+
#
|
|
46
|
+
# THE PRIMITIVES ARE INJECTED. `account` is duck-typed, and `e2ee` is
|
|
47
|
+
# whatever module provides InboundGroupSession. The protocol layer therefore
|
|
48
|
+
# names no native extension and keeps its dependency list empty; the caller
|
|
49
|
+
# decides which implementation backs it.
|
|
50
|
+
class DeviceStore
|
|
51
|
+
class Error < Protocol::Matrix::Error; end
|
|
52
|
+
|
|
53
|
+
# The same message, delivered twice, with a different event id.
|
|
54
|
+
class ReplayError < Error; end
|
|
55
|
+
|
|
56
|
+
# A key issued for one room used against a message in another. Refused
|
|
57
|
+
# rather than decrypted: a sender who can place a message in a room they
|
|
58
|
+
# hold no key for could otherwise have it attributed to a room they do.
|
|
59
|
+
class RoomMismatchError < Error; end
|
|
60
|
+
|
|
61
|
+
# The store was asked to absorb a room key it cannot build, because no
|
|
62
|
+
# primitive factory was supplied.
|
|
63
|
+
class MissingPrimitivesError < Error; end
|
|
64
|
+
|
|
65
|
+
# Rotation is a security property, not housekeeping: everyone holding the
|
|
66
|
+
# current key can read everything encrypted with it, so a session must not
|
|
67
|
+
# live forever. These are the spec defaults for m.room.encryption when the
|
|
68
|
+
# room does not override them.
|
|
69
|
+
DEFAULT_ROTATION_MESSAGES = 100
|
|
70
|
+
DEFAULT_ROTATION_MS = 7 * 24 * 60 * 60 * 1000
|
|
71
|
+
|
|
72
|
+
ROOM_KEY = "m.room_key"
|
|
73
|
+
FORWARDED_ROOM_KEY = "m.forwarded_room_key"
|
|
74
|
+
MEGOLM = Protocol::Matrix::EncryptedMessage::MEGOLM
|
|
75
|
+
|
|
76
|
+
# @parameter account [Object] an unpickled Olm account, answering
|
|
77
|
+
# #curve25519_key, #ed25519_key, #sign and #create_inbound_session.
|
|
78
|
+
# @parameter e2ee [Module] supplies InboundGroupSession, used to build a
|
|
79
|
+
# session from a received room key. Optional: a store that only ever
|
|
80
|
+
# reads keys handed to it directly does not need one.
|
|
81
|
+
# @parameter olm_sessions [Hash] sender_key => [session, ...], most
|
|
82
|
+
# recently used first.
|
|
83
|
+
# @parameter group_sessions [Hash] session_id => {session:, room_id:}.
|
|
84
|
+
def initialize(user_id:, device_id:, account:, e2ee: nil, olm_sessions: {}, group_sessions: {})
|
|
85
|
+
@user_id = user_id
|
|
86
|
+
@device_id = device_id
|
|
87
|
+
@account = account
|
|
88
|
+
@e2ee = e2ee
|
|
89
|
+
@olm_sessions = olm_sessions
|
|
90
|
+
@group_sessions = group_sessions
|
|
91
|
+
|
|
92
|
+
@outbound_sessions = {}
|
|
93
|
+
@message_indexes = {}
|
|
94
|
+
@olm_hashes = {}
|
|
95
|
+
|
|
96
|
+
@changed_olm_sessions = []
|
|
97
|
+
@changed_group_sessions = []
|
|
98
|
+
@changed_outbound_rooms = []
|
|
99
|
+
@account_changed = false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
attr_reader :user_id, :device_id, :account
|
|
103
|
+
|
|
104
|
+
# Our own curve25519 identity key: what other devices encrypt Olm messages
|
|
105
|
+
# to, and the key an Olm ciphertext map is addressed under.
|
|
106
|
+
def identity_key = @account.curve25519_key
|
|
107
|
+
|
|
108
|
+
# Our own ed25519 key: what our signatures are verified against.
|
|
109
|
+
def fingerprint = @account.ed25519_key
|
|
110
|
+
|
|
111
|
+
# ── Publishing ──────────────────────────────────────────────────────────
|
|
112
|
+
#
|
|
113
|
+
# Until these are uploaded the device is unreachable: no peer can claim a
|
|
114
|
+
# one-time key, so none can open an Olm session, so none can send us a
|
|
115
|
+
# room key -- and every message in an encrypted room stays ciphertext
|
|
116
|
+
# forever. Publication is not an optimisation; it is the precondition for
|
|
117
|
+
# receiving anything at all.
|
|
118
|
+
|
|
119
|
+
# This device's signed identity document, for POST /keys/upload.
|
|
120
|
+
def device_keys
|
|
121
|
+
Protocol::Matrix::Keys.device_keys(
|
|
122
|
+
user_id: @user_id,
|
|
123
|
+
device_id: @device_id,
|
|
124
|
+
curve25519: identity_key,
|
|
125
|
+
ed25519: fingerprint,
|
|
126
|
+
signer: @account,
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Generate +count+ one-time keys and return them signed, ready to upload.
|
|
131
|
+
#
|
|
132
|
+
# NOT MARKED PUBLISHED HERE. The account only forgets a key once
|
|
133
|
+
# #mark_keys_published! is called, and calling that before the upload
|
|
134
|
+
# succeeds would discard keys the server never received -- which looks,
|
|
135
|
+
# later, exactly like a peer claiming a key we have no record of.
|
|
136
|
+
def generate_one_time_keys(count = default_one_time_key_count)
|
|
137
|
+
@account.generate_one_time_keys(count)
|
|
138
|
+
@account_changed = true
|
|
139
|
+
|
|
140
|
+
Protocol::Matrix::Keys.one_time_keys(
|
|
141
|
+
@account.one_time_keys,
|
|
142
|
+
user_id: @user_id,
|
|
143
|
+
device_id: @device_id,
|
|
144
|
+
signer: @account,
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The fallback key, which answers claims once the one-time keys run out.
|
|
149
|
+
# It is NOT consumed when used, which is what stops a peer who has
|
|
150
|
+
# exhausted our keys being unable to reach us at all.
|
|
151
|
+
def generate_fallback_key
|
|
152
|
+
@account.generate_fallback_key
|
|
153
|
+
@account_changed = true
|
|
154
|
+
|
|
155
|
+
Protocol::Matrix::Keys.fallback_keys(
|
|
156
|
+
@account.fallback_key,
|
|
157
|
+
user_id: @user_id,
|
|
158
|
+
device_id: @device_id,
|
|
159
|
+
signer: @account,
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Call this ONLY after the upload succeeded.
|
|
164
|
+
def mark_keys_published!
|
|
165
|
+
@account.mark_keys_as_published
|
|
166
|
+
@account_changed = true
|
|
167
|
+
self
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The spec suggests keeping around half of the maximum available, since a
|
|
171
|
+
# device that runs out cannot be reached.
|
|
172
|
+
def default_one_time_key_count
|
|
173
|
+
@account.max_number_of_one_time_keys / 2
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Should we top up? Compared against what the SERVER last reported it
|
|
177
|
+
# still holds, not against what we generated: keys are consumed by other
|
|
178
|
+
# devices claiming them, which we never observe directly.
|
|
179
|
+
def needs_one_time_keys?(server_count)
|
|
180
|
+
server_count < default_one_time_key_count
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ── Reading ─────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
# Read +message+ with whatever key this store holds for it.
|
|
186
|
+
#
|
|
187
|
+
# @returns [EncryptedMessage | Nil] the message, decrypted, or nil when we
|
|
188
|
+
# hold no key for it.
|
|
189
|
+
#
|
|
190
|
+
# NIL IS NOT AN ERROR. A message whose room key has not arrived is the
|
|
191
|
+
# single most common state in an encrypted room, and it is recoverable:
|
|
192
|
+
# the key may turn up later in this batch, in a later one, or from backup.
|
|
193
|
+
# Raising here would make the normal case exceptional and force every sync
|
|
194
|
+
# loop to rescue it.
|
|
195
|
+
def decrypt(message)
|
|
196
|
+
case message.algorithm
|
|
197
|
+
when MEGOLM
|
|
198
|
+
decrypt_megolm(message)
|
|
199
|
+
when Protocol::Matrix::EncryptedMessage::OLM
|
|
200
|
+
decrypt_olm(message)
|
|
201
|
+
else
|
|
202
|
+
raise Protocol::Matrix::EncryptedMessage::UnsupportedAlgorithmError,
|
|
203
|
+
"unsupported algorithm: #{message.algorithm.inspect}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ── Writing ─────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
# Encrypt a room event, rotating the session first if policy says so.
|
|
210
|
+
#
|
|
211
|
+
# @returns [Array] [content, session_id, targets_already_holding_the_key]
|
|
212
|
+
# The caller needs the last two: before this content can be read by
|
|
213
|
+
# anyone, the session key has to reach every device in the room that
|
|
214
|
+
# does not already have it, and only the caller can talk to the
|
|
215
|
+
# homeserver to do that.
|
|
216
|
+
def encrypt(room_id:, type:, content:, rotation: {})
|
|
217
|
+
entry = outbound_session(room_id, rotation: rotation)
|
|
218
|
+
payload = Protocol::Matrix::EncryptedMessage.room_payload(
|
|
219
|
+
type: type, content: content, room_id: room_id,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
ciphertext = entry[:session].encrypt(JSON.generate(payload))
|
|
223
|
+
entry[:message_count] += 1
|
|
224
|
+
@changed_outbound_rooms << room_id
|
|
225
|
+
|
|
226
|
+
[
|
|
227
|
+
Protocol::Matrix::EncryptedMessage.megolm_content(
|
|
228
|
+
ciphertext: ciphertext,
|
|
229
|
+
session_id: entry[:session].session_id,
|
|
230
|
+
sender_key: identity_key,
|
|
231
|
+
device_id: @device_id,
|
|
232
|
+
),
|
|
233
|
+
entry[:session].session_id,
|
|
234
|
+
entry[:shared_with],
|
|
235
|
+
]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The live outbound session for a room, started or rotated as needed.
|
|
239
|
+
def outbound_session(room_id, rotation: {})
|
|
240
|
+
entry = @outbound_sessions[room_id]
|
|
241
|
+
|
|
242
|
+
if entry.nil? || expired?(entry, rotation)
|
|
243
|
+
rotate!(room_id)
|
|
244
|
+
else
|
|
245
|
+
entry
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Start a fresh session for a room, discarding the old one.
|
|
250
|
+
#
|
|
251
|
+
# CALL THIS WHEN ANYONE LEAVES. Whoever left still holds the current key,
|
|
252
|
+
# so every message encrypted with it afterwards would be readable by them;
|
|
253
|
+
# rotation is the only thing that stops that, and no timer will do it in
|
|
254
|
+
# time.
|
|
255
|
+
def rotate!(room_id)
|
|
256
|
+
if @e2ee.nil?
|
|
257
|
+
raise MissingPrimitivesError, "no e2ee factory was supplied, so no session can be started"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
session = @e2ee::GroupSession.new
|
|
261
|
+
@changed_outbound_rooms << room_id
|
|
262
|
+
|
|
263
|
+
@outbound_sessions[room_id] = {
|
|
264
|
+
session: session,
|
|
265
|
+
started_at: now_ms,
|
|
266
|
+
message_count: 0,
|
|
267
|
+
shared_with: {},
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The m.room_key payload handing a room's current session to someone else.
|
|
272
|
+
#
|
|
273
|
+
# ALSO STORED FOR OURSELVES. An outbound Megolm session cannot decrypt, so
|
|
274
|
+
# without keeping the matching inbound session we could not read our own
|
|
275
|
+
# messages back.
|
|
276
|
+
def room_key_payload(room_id)
|
|
277
|
+
entry = @outbound_sessions[room_id]
|
|
278
|
+
|
|
279
|
+
if entry.nil?
|
|
280
|
+
nil
|
|
281
|
+
else
|
|
282
|
+
session_key = entry[:session].session_key
|
|
283
|
+
|
|
284
|
+
add_group_session(@e2ee::InboundGroupSession.new(session_key), room_id: room_id)
|
|
285
|
+
|
|
286
|
+
Protocol::Matrix::EncryptedMessage.room_key_payload(
|
|
287
|
+
room_id: room_id,
|
|
288
|
+
session_id: entry[:session].session_id,
|
|
289
|
+
session_key: session_key,
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Has this device already been given the room's current key? Re-sharing to
|
|
295
|
+
# everyone on every message would be correct but wasteful.
|
|
296
|
+
def shared_with?(room_id, matrix_user_id, device_id)
|
|
297
|
+
entry = @outbound_sessions[room_id]
|
|
298
|
+
|
|
299
|
+
if entry.nil?
|
|
300
|
+
false
|
|
301
|
+
else
|
|
302
|
+
Array(entry[:shared_with][matrix_user_id]).include?(device_id)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Record that a set of devices now holds the room's current key.
|
|
307
|
+
# +targets+ is { "@user:server" => ["DEVICEID", ...] }.
|
|
308
|
+
def record_shared!(room_id, targets)
|
|
309
|
+
@outbound_sessions[room_id].tap do |entry|
|
|
310
|
+
unless entry.nil?
|
|
311
|
+
targets.each do |matrix_user_id, device_ids|
|
|
312
|
+
held = entry[:shared_with][matrix_user_id] || []
|
|
313
|
+
entry[:shared_with][matrix_user_id] = held | Array(device_ids)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
@changed_outbound_rooms << room_id
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Wrap a payload in Olm for one device.
|
|
322
|
+
#
|
|
323
|
+
# @parameter one_time_key [String] a claimed key, needed only when we have
|
|
324
|
+
# no session with this device yet. Without either, the device is
|
|
325
|
+
# unreachable and this answers nil rather than guessing.
|
|
326
|
+
def encrypt_to_device(payload, recipient:, recipient_identity_key:, recipient_key:, one_time_key: nil)
|
|
327
|
+
session = olm_sessions_with(recipient_identity_key).first ||
|
|
328
|
+
open_outbound_session(recipient_identity_key, one_time_key)
|
|
329
|
+
|
|
330
|
+
if session.nil?
|
|
331
|
+
nil
|
|
332
|
+
else
|
|
333
|
+
body = Protocol::Matrix::EncryptedMessage.olm_payload(
|
|
334
|
+
type: payload["type"],
|
|
335
|
+
content: payload["content"],
|
|
336
|
+
sender: @user_id,
|
|
337
|
+
sender_key: fingerprint,
|
|
338
|
+
recipient: recipient,
|
|
339
|
+
recipient_key: recipient_key,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
type, ciphertext = session.encrypt(JSON.generate(body))
|
|
343
|
+
@changed_olm_sessions << session.session_id
|
|
344
|
+
|
|
345
|
+
Protocol::Matrix::EncryptedMessage.olm_content(
|
|
346
|
+
sender_key: identity_key,
|
|
347
|
+
ciphertext: {
|
|
348
|
+
recipient_identity_key => Protocol::Matrix::EncryptedMessage.olm_ciphertext(
|
|
349
|
+
type: type, body: ciphertext,
|
|
350
|
+
),
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# ── Room keys ───────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
# Take a room key out of a decrypted to-device payload.
|
|
359
|
+
#
|
|
360
|
+
# Idempotent: the same key is commonly sent more than once -- a re-share,
|
|
361
|
+
# or a second device forwarding it -- and the session already held is the
|
|
362
|
+
# one further back in the ratchet, so replacing it would LOSE history.
|
|
363
|
+
#
|
|
364
|
+
# @returns [String | Nil] the session id absorbed, or nil if it was
|
|
365
|
+
# already held or the payload was not a room key.
|
|
366
|
+
def absorb(payload)
|
|
367
|
+
type = payload["type"]
|
|
368
|
+
|
|
369
|
+
if type == ROOM_KEY || type == FORWARDED_ROOM_KEY
|
|
370
|
+
store_room_key(payload["content"] || {}, forwarded: type == FORWARDED_ROOM_KEY)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Import a session recovered from server-side key backup.
|
|
375
|
+
#
|
|
376
|
+
# A DIFFERENT KEY FORMAT from a live m.room_key, which is the whole reason
|
|
377
|
+
# this is its own method: a backup blob carries an unsigned, version-1
|
|
378
|
+
# ExportedSessionKey, and InboundGroupSession.new rejects it outright --
|
|
379
|
+
# only .import accepts one. A session built this way carries no signature,
|
|
380
|
+
# so it cannot be attributed to the device that originally created it;
|
|
381
|
+
# `signing_key` is what the blob claims, kept for whatever wants to decide
|
|
382
|
+
# how much to trust it.
|
|
383
|
+
#
|
|
384
|
+
# @returns [String | Nil] the session id, or nil if already held.
|
|
385
|
+
def import_session(room_id:, session_id:, session_key:, sender_key: nil, signing_key: nil)
|
|
386
|
+
if @e2ee.nil?
|
|
387
|
+
raise MissingPrimitivesError, "no e2ee factory was supplied, so no session can be imported"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if @group_sessions.key?(session_id)
|
|
391
|
+
nil
|
|
392
|
+
else
|
|
393
|
+
put_group_session(
|
|
394
|
+
session_id,
|
|
395
|
+
@e2ee::InboundGroupSession.import(session_key),
|
|
396
|
+
room_id,
|
|
397
|
+
sender_key: sender_key,
|
|
398
|
+
signing_key: signing_key,
|
|
399
|
+
)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Add a room key we already hold a session object for.
|
|
404
|
+
def add_group_session(session, room_id:)
|
|
405
|
+
put_group_session(session.session_id, session, room_id)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def knows_session?(session_id) = @group_sessions.key?(session_id)
|
|
409
|
+
|
|
410
|
+
# Every room key held, as session_id => room_id.
|
|
411
|
+
def room_keys
|
|
412
|
+
@group_sessions.transform_values { |entry| entry[:room_id] }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def olm_sessions_with(sender_key) = Array(@olm_sessions[sender_key])
|
|
416
|
+
|
|
417
|
+
# ── What the caller must persist ────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
# Every piece of state this store advanced since the last #flush_changes!.
|
|
420
|
+
#
|
|
421
|
+
# THE CALLER MUST WRITE THESE. Olm and Megolm both ratchet, so a session
|
|
422
|
+
# that decrypted a message and was not saved will fail on the next one --
|
|
423
|
+
# permanently, because the peer has moved on and we have not. The account
|
|
424
|
+
# matters for the same reason: a prekey message consumes a one-time key.
|
|
425
|
+
def changes
|
|
426
|
+
{
|
|
427
|
+
account: @account_changed ? @account : nil,
|
|
428
|
+
olm_sessions: @changed_olm_sessions.uniq,
|
|
429
|
+
group_sessions: @changed_group_sessions.uniq,
|
|
430
|
+
outbound_rooms: @changed_outbound_rooms.uniq,
|
|
431
|
+
}
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Everything that changed, PICKLED AND READY TO WRITE.
|
|
435
|
+
#
|
|
436
|
+
# #changes names what moved; this hands over the bytes. The distinction
|
|
437
|
+
# matters because #changes alone was not enough to act on: it reports
|
|
438
|
+
# session ids, and an id cannot be pickled -- only the object can, and
|
|
439
|
+
# only the store holds those.
|
|
440
|
+
#
|
|
441
|
+
# Returns plain data. Where it goes, in what table, under what column, on
|
|
442
|
+
# what schedule, is the application's business entirely.
|
|
443
|
+
#
|
|
444
|
+
# @parameter pickle_key [PickleKey] see that class for why losing it loses
|
|
445
|
+
# everything that was ever encrypted to this device.
|
|
446
|
+
def export(pickle_key)
|
|
447
|
+
key = pickle_key.to_s
|
|
448
|
+
|
|
449
|
+
{
|
|
450
|
+
account: @account_changed ? @account.pickle(key) : nil,
|
|
451
|
+
olm_sessions: export_olm_sessions(key),
|
|
452
|
+
group_sessions: export_group_sessions(key),
|
|
453
|
+
outbound_sessions: export_outbound_sessions(key),
|
|
454
|
+
}
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def changed?
|
|
459
|
+
@account_changed || @changed_olm_sessions.any? ||
|
|
460
|
+
@changed_group_sessions.any? || @changed_outbound_rooms.any?
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def flush_changes!
|
|
464
|
+
@changed_olm_sessions = []
|
|
465
|
+
@changed_group_sessions = []
|
|
466
|
+
@changed_outbound_rooms = []
|
|
467
|
+
@account_changed = false
|
|
468
|
+
self
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private
|
|
472
|
+
|
|
473
|
+
# Looked up by id rather than kept as a list of objects, so a session
|
|
474
|
+
# that ratcheted several times in one batch is pickled once, at its
|
|
475
|
+
# final position.
|
|
476
|
+
def export_olm_sessions(key)
|
|
477
|
+
@changed_olm_sessions.uniq.filter_map do |session_id|
|
|
478
|
+
found = find_olm_session(session_id)
|
|
479
|
+
|
|
480
|
+
if found
|
|
481
|
+
{session_id: session_id, sender_key: found.first, pickle: found.last.pickle(key)}
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# No index from session id to peer, because the store is keyed the way
|
|
487
|
+
# the protocol is: Olm sessions are found BY PEER when decrypting. The
|
|
488
|
+
# scan costs nothing at the size this ever reaches -- a handful of
|
|
489
|
+
# sessions changed in one batch.
|
|
490
|
+
def find_olm_session(session_id)
|
|
491
|
+
pair = nil
|
|
492
|
+
|
|
493
|
+
@olm_sessions.each do |sender_key, sessions|
|
|
494
|
+
session = sessions.find { |candidate| candidate.session_id == session_id }
|
|
495
|
+
|
|
496
|
+
if session
|
|
497
|
+
pair = [sender_key, session]
|
|
498
|
+
break
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
pair
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def export_group_sessions(key)
|
|
506
|
+
@changed_group_sessions.uniq.filter_map do |session_id|
|
|
507
|
+
entry = @group_sessions[session_id]
|
|
508
|
+
|
|
509
|
+
if entry
|
|
510
|
+
{
|
|
511
|
+
session_id: session_id,
|
|
512
|
+
room_id: entry[:room_id],
|
|
513
|
+
sender_key: entry[:sender_key],
|
|
514
|
+
signing_key: entry[:signing_key],
|
|
515
|
+
pickle: entry[:session].pickle(key),
|
|
516
|
+
}
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def export_outbound_sessions(key)
|
|
522
|
+
@changed_outbound_rooms.uniq.filter_map do |room_id|
|
|
523
|
+
entry = @outbound_sessions[room_id]
|
|
524
|
+
|
|
525
|
+
if entry
|
|
526
|
+
{
|
|
527
|
+
room_id: room_id,
|
|
528
|
+
session_id: entry[:session].session_id,
|
|
529
|
+
message_count: entry[:message_count],
|
|
530
|
+
started_at: entry[:started_at],
|
|
531
|
+
shared_with: entry[:shared_with],
|
|
532
|
+
pickle: entry[:session].pickle(key),
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# The session id is the key, and it comes from ONE place per path: the
|
|
540
|
+
# payload when absorbing a received key, the session object when the
|
|
541
|
+
# caller supplies one directly. The two agree in practice; keying off
|
|
542
|
+
# whichever was to hand would hide it when they did not.
|
|
543
|
+
# The spec: a client "should remember the megolm `message_index` ... of
|
|
544
|
+
# each event they decrypt for each session" and treat a repeat as
|
|
545
|
+
# invalid -- UNLESS the event_id and origin_server_ts also match, which
|
|
546
|
+
# is the legitimate case of decrypting the same event twice.
|
|
547
|
+
#
|
|
548
|
+
# Without this, anyone who can place an event in the room can replay an
|
|
549
|
+
# old message under a fresh event id and have it accepted as new.
|
|
550
|
+
def check_message_index(message)
|
|
551
|
+
index = message.message_index
|
|
552
|
+
|
|
553
|
+
if index.nil?
|
|
554
|
+
nil
|
|
555
|
+
else
|
|
556
|
+
seen = @message_indexes[message.session_id] ||= {}
|
|
557
|
+
previous = seen[index]
|
|
558
|
+
current = [message.event_id, message.origin_server_ts]
|
|
559
|
+
|
|
560
|
+
if previous.nil?
|
|
561
|
+
seen[index] = current
|
|
562
|
+
elsif previous != current
|
|
563
|
+
raise ReplayError,
|
|
564
|
+
"session #{message.session_id} index #{index} already decrypted as " \
|
|
565
|
+
"#{previous.first.inspect}; this event claims #{message.event_id.inspect}"
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# The same guard for Olm: a to-device ciphertext delivered twice is a
|
|
571
|
+
# replay, and an Olm session will happily decrypt a prekey message again.
|
|
572
|
+
def check_olm_replay(sender_key, info)
|
|
573
|
+
digest = Digest::SHA256.hexdigest("#{sender_key}|#{info['type']}|#{info['body']}")
|
|
574
|
+
|
|
575
|
+
if @olm_hashes.key?(digest)
|
|
576
|
+
raise ReplayError, "olm message from #{sender_key} was already decrypted"
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
@olm_hashes[digest] = now_ms
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def expired?(entry, rotation)
|
|
583
|
+
messages = rotation[:rotation_period_msgs] || rotation["rotation_period_msgs"] ||
|
|
584
|
+
DEFAULT_ROTATION_MESSAGES
|
|
585
|
+
period = rotation[:rotation_period_ms] || rotation["rotation_period_ms"] ||
|
|
586
|
+
DEFAULT_ROTATION_MS
|
|
587
|
+
|
|
588
|
+
entry[:message_count] >= messages || (now_ms - entry[:started_at]) >= period
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def open_outbound_session(identity_key, one_time_key)
|
|
592
|
+
if one_time_key.nil?
|
|
593
|
+
nil
|
|
594
|
+
else
|
|
595
|
+
if one_time_key.is_a?(Hash)
|
|
596
|
+
key = (one_time_key["key"] || one_time_key[:key])
|
|
597
|
+
else
|
|
598
|
+
key = one_time_key
|
|
599
|
+
end
|
|
600
|
+
session = @account.create_outbound_session(identity_key, key)
|
|
601
|
+
@account_changed = true
|
|
602
|
+
(@olm_sessions[identity_key] ||= []).unshift(session)
|
|
603
|
+
session
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def now_ms = (Time.now.to_f * 1000).to_i
|
|
608
|
+
|
|
609
|
+
def put_group_session(session_id, session, room_id, sender_key: nil, signing_key: nil)
|
|
610
|
+
if @group_sessions.key?(session_id)
|
|
611
|
+
nil
|
|
612
|
+
else
|
|
613
|
+
@group_sessions[session_id] = {
|
|
614
|
+
session: session,
|
|
615
|
+
room_id: room_id,
|
|
616
|
+
sender_key: sender_key,
|
|
617
|
+
signing_key: signing_key,
|
|
618
|
+
}
|
|
619
|
+
@changed_group_sessions << session_id
|
|
620
|
+
session_id
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def decrypt_megolm(message)
|
|
625
|
+
entry = @group_sessions[message.session_id]
|
|
626
|
+
|
|
627
|
+
if entry.nil?
|
|
628
|
+
nil
|
|
629
|
+
else
|
|
630
|
+
# A key is issued FOR A ROOM. Using one against a message in another
|
|
631
|
+
# room would let a sender have a message attributed to a room they
|
|
632
|
+
# hold no key for.
|
|
633
|
+
if message.room_id && entry[:room_id] && message.room_id != entry[:room_id]
|
|
634
|
+
raise RoomMismatchError,
|
|
635
|
+
"session #{message.session_id} belongs to #{entry[:room_id]}, " \
|
|
636
|
+
"message claims #{message.room_id}"
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
message.decrypt!(entry[:session])
|
|
640
|
+
@changed_group_sessions << message.session_id
|
|
641
|
+
check_message_index(message)
|
|
642
|
+
|
|
643
|
+
# The payload carries its own room_id, and a sender whose payload
|
|
644
|
+
# disagrees with the envelope is attempting the same substitution
|
|
645
|
+
# from the inside.
|
|
646
|
+
if message.payload_room_id && message.room_id &&
|
|
647
|
+
message.payload_room_id != message.room_id
|
|
648
|
+
raise RoomMismatchError,
|
|
649
|
+
"payload claims #{message.payload_room_id}, envelope says #{message.room_id}"
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
message
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def decrypt_olm(message)
|
|
657
|
+
info = message.ciphertext_for(identity_key)
|
|
658
|
+
|
|
659
|
+
if info.nil?
|
|
660
|
+
nil
|
|
661
|
+
else
|
|
662
|
+
check_olm_replay(message.sender_key, info)
|
|
663
|
+
payload = with_known_session(message) || with_new_session(message, info)
|
|
664
|
+
|
|
665
|
+
if payload
|
|
666
|
+
absorb(payload)
|
|
667
|
+
message
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Every session we already have with this peer, newest first. Several can
|
|
673
|
+
# exist when both sides opened one at the same time, and only one holds
|
|
674
|
+
# the ratchet state that reads this message.
|
|
675
|
+
def with_known_session(message)
|
|
676
|
+
decrypted = nil
|
|
677
|
+
|
|
678
|
+
olm_sessions_with(message.sender_key).each do |session|
|
|
679
|
+
decrypted = try_session(message, session)
|
|
680
|
+
|
|
681
|
+
if decrypted
|
|
682
|
+
@changed_olm_sessions << session.session_id
|
|
683
|
+
break
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
decrypted
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def try_session(message, session)
|
|
691
|
+
message.decrypt!(session, identity_key: identity_key)
|
|
692
|
+
rescue Protocol::Matrix::EncryptedMessage::DecryptionError
|
|
693
|
+
nil
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# A prekey message carries enough to open a NEW inbound session, and
|
|
697
|
+
# doing so CONSUMES one of our one-time keys -- which is why the account
|
|
698
|
+
# is marked changed even when the decryption then fails.
|
|
699
|
+
def with_new_session(message, info)
|
|
700
|
+
if info["type"] == Protocol::Matrix::EncryptedMessage::PREKEY
|
|
701
|
+
session = open_inbound_session(message, info)
|
|
702
|
+
@account_changed = true
|
|
703
|
+
|
|
704
|
+
(@olm_sessions[message.sender_key] ||= []).unshift(session)
|
|
705
|
+
@changed_olm_sessions << session.session_id
|
|
706
|
+
|
|
707
|
+
# Deliberately OUTSIDE the rescue below: a payload that decrypts to
|
|
708
|
+
# nonsense is a malformed message, not a failure to establish a
|
|
709
|
+
# session, and conflating them sends a reader looking in the wrong
|
|
710
|
+
# place.
|
|
711
|
+
try_session(message, session)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Consumes one of our one-time keys, whether or not what follows works --
|
|
716
|
+
# which is why the account is marked changed even on a later failure.
|
|
717
|
+
def open_inbound_session(message, info)
|
|
718
|
+
session, _plaintext = @account.create_inbound_session(message.sender_key, info["body"])
|
|
719
|
+
session
|
|
720
|
+
rescue StandardError => e
|
|
721
|
+
raise Error, "could not open an inbound session from #{message.sender_key}: #{e.message}"
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def store_room_key(content, forwarded:)
|
|
725
|
+
if content["algorithm"] != MEGOLM
|
|
726
|
+
nil
|
|
727
|
+
else
|
|
728
|
+
session_id = content["session_id"]
|
|
729
|
+
|
|
730
|
+
if session_id.nil? || @group_sessions.key?(session_id)
|
|
731
|
+
# Checked BEFORE building: constructing the session would be
|
|
732
|
+
# wasted work, and for a forwarded key it would also be the wrong
|
|
733
|
+
# answer -- the one already held is further back in the ratchet.
|
|
734
|
+
nil
|
|
735
|
+
else
|
|
736
|
+
put_group_session(
|
|
737
|
+
session_id,
|
|
738
|
+
build_group_session(content, forwarded: forwarded),
|
|
739
|
+
content["room_id"],
|
|
740
|
+
sender_key: content["sender_key"],
|
|
741
|
+
signing_key: (content["sender_claimed_keys"] || {})["ed25519"],
|
|
742
|
+
)
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# A direct room key is a signed, version-2 SessionKey; a FORWARDED one is
|
|
748
|
+
# an unsigned, version-1 ExportedSessionKey, which .new rejects outright.
|
|
749
|
+
# Different constructors, and the distinction is not cosmetic: a session
|
|
750
|
+
# built from a forwarded key carries no signature and so cannot be
|
|
751
|
+
# attributed to the device that originally created it.
|
|
752
|
+
def build_group_session(content, forwarded:)
|
|
753
|
+
if @e2ee.nil?
|
|
754
|
+
raise MissingPrimitivesError,
|
|
755
|
+
"no e2ee factory was supplied, so a received room key cannot be built"
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
if forwarded
|
|
759
|
+
@e2ee::InboundGroupSession.import(content["session_key"])
|
|
760
|
+
else
|
|
761
|
+
@e2ee::InboundGroupSession.new(content["session_key"])
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
__END__
|
|
769
|
+
describe "Async::Matrix::DeviceStore" do
|
|
770
|
+
# Fake primitives. The store names no native extension -- the account and
|
|
771
|
+
# the factory are injected -- so the whole class is driveable with three
|
|
772
|
+
# plain objects and no cryptography.
|
|
773
|
+
def fake_account(identity: "ourcurve", fingerprint: "oured")
|
|
774
|
+
account = Object.new
|
|
775
|
+
opened = []
|
|
776
|
+
account.define_singleton_method(:curve25519_key) { identity }
|
|
777
|
+
account.define_singleton_method(:pickle) { |_key| "account-pickle" }
|
|
778
|
+
account.define_singleton_method(:ed25519_key) { fingerprint }
|
|
779
|
+
account.define_singleton_method(:opened) { opened }
|
|
780
|
+
published = 0
|
|
781
|
+
account.define_singleton_method(:sign) { |_message| "SIGNED" }
|
|
782
|
+
account.define_singleton_method(:published) { published }
|
|
783
|
+
account.define_singleton_method(:max_number_of_one_time_keys) { 100 }
|
|
784
|
+
account.define_singleton_method(:generate_one_time_keys) { |_count| nil }
|
|
785
|
+
account.define_singleton_method(:one_time_keys) { {"k1" => "otk1", "k2" => "otk2"} }
|
|
786
|
+
account.define_singleton_method(:generate_fallback_key) { nil }
|
|
787
|
+
account.define_singleton_method(:fallback_key) { {"fb1" => "fbkey"} }
|
|
788
|
+
account.define_singleton_method(:mark_keys_as_published) { published += 1 }
|
|
789
|
+
account.define_singleton_method(:create_outbound_session) do |identity_key, one_time_key|
|
|
790
|
+
session = Object.new
|
|
791
|
+
session.define_singleton_method(:session_id) { "outbound-olm" }
|
|
792
|
+
session.define_singleton_method(:pickle) { |_key| "session-pickle" }
|
|
793
|
+
session.define_singleton_method(:encrypt) { |plaintext| [0, "prekey:#{plaintext.length}"] }
|
|
794
|
+
session
|
|
795
|
+
end
|
|
796
|
+
account.define_singleton_method(:create_inbound_session) do |sender_key, body|
|
|
797
|
+
opened << [sender_key, body]
|
|
798
|
+
payload = JSON.generate({
|
|
799
|
+
"type" => "m.dummy", "content" => {},
|
|
800
|
+
"sender" => "@bob:example.org", "recipient" => "@bot:example.org",
|
|
801
|
+
"recipient_keys" => {"ed25519" => "oured"}, "keys" => {"ed25519" => "theired"},
|
|
802
|
+
})
|
|
803
|
+
session = Object.new
|
|
804
|
+
session.define_singleton_method(:session_id) { "new-olm-session" }
|
|
805
|
+
session.define_singleton_method(:pickle) { |_key| "session-pickle" }
|
|
806
|
+
session.define_singleton_method(:decrypt) { |_type, _body| payload }
|
|
807
|
+
[session, payload]
|
|
808
|
+
end
|
|
809
|
+
account
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def fake_olm_session(id, plaintext)
|
|
813
|
+
session = Object.new
|
|
814
|
+
session.define_singleton_method(:session_id) { id }
|
|
815
|
+
session.define_singleton_method(:pickle) { |_key| "session-pickle" }
|
|
816
|
+
session.define_singleton_method(:decrypt) do |_type, _body|
|
|
817
|
+
plaintext || raise(RuntimeError, "BAD_MESSAGE_MAC")
|
|
818
|
+
end
|
|
819
|
+
session
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def olm_encrypting_session
|
|
823
|
+
session = Object.new
|
|
824
|
+
session.define_singleton_method(:session_id) { "existing-olm" }
|
|
825
|
+
session.define_singleton_method(:pickle) { |_key| "session-pickle" }
|
|
826
|
+
session.define_singleton_method(:encrypt) { |plaintext| [1, "msg:#{plaintext.length}"] }
|
|
827
|
+
session
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Reports a new message index each time, as a real session does.
|
|
831
|
+
def counting_group_session
|
|
832
|
+
index = -1
|
|
833
|
+
session = Object.new
|
|
834
|
+
session.define_singleton_method(:session_id) { "s" }
|
|
835
|
+
session.define_singleton_method(:pickle) { |_key| "inboundgroupsession-pickle" }
|
|
836
|
+
session.define_singleton_method(:decrypt) do |_ciphertext|
|
|
837
|
+
index += 1
|
|
838
|
+
[JSON.generate({"type" => "m.room.message", "content" => {}, "room_id" => "!room:example.org"}), index]
|
|
839
|
+
end
|
|
840
|
+
session
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def fake_group_session(id, plaintext, index = 0)
|
|
844
|
+
session = Object.new
|
|
845
|
+
session.define_singleton_method(:session_id) { id }
|
|
846
|
+
session.define_singleton_method(:pickle) { |_key| "inboundgroupsession-pickle" }
|
|
847
|
+
session.define_singleton_method(:decrypt) { |_ciphertext| [plaintext, index] }
|
|
848
|
+
session
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Stands in for Async::Matrix::E2EE: all the store asks of it is
|
|
852
|
+
# InboundGroupSession.new / .import.
|
|
853
|
+
def fake_e2ee
|
|
854
|
+
built = []
|
|
855
|
+
# A payload the reader accepts: anything without type and content is
|
|
856
|
+
# refused, and rightly.
|
|
857
|
+
valid_payload = JSON.generate({
|
|
858
|
+
"type" => "m.room.message",
|
|
859
|
+
"content" => {"msgtype" => "m.text", "body" => "from backup"},
|
|
860
|
+
"room_id" => "!room:example.org",
|
|
861
|
+
})
|
|
862
|
+
factory = Module.new
|
|
863
|
+
klass = Class.new do
|
|
864
|
+
define_singleton_method(:built) { built }
|
|
865
|
+
define_singleton_method(:new) do |session_key|
|
|
866
|
+
built << [:new, session_key]
|
|
867
|
+
session = Object.new
|
|
868
|
+
session.define_singleton_method(:session_id) { "session-from-#{session_key}" }
|
|
869
|
+
session.define_singleton_method(:pickle) { |_key| "inboundgroupsession-pickle" }
|
|
870
|
+
session.define_singleton_method(:decrypt) { |_c| [valid_payload, 0] }
|
|
871
|
+
session
|
|
872
|
+
end
|
|
873
|
+
define_singleton_method(:import) do |exported|
|
|
874
|
+
built << [:import, exported]
|
|
875
|
+
session = Object.new
|
|
876
|
+
session.define_singleton_method(:session_id) { "session-from-#{exported}" }
|
|
877
|
+
session.define_singleton_method(:pickle) { |_key| "inboundgroupsession-pickle" }
|
|
878
|
+
session.define_singleton_method(:decrypt) { |_c| [valid_payload, 0] }
|
|
879
|
+
session
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
factory.const_set(:InboundGroupSession, klass)
|
|
883
|
+
|
|
884
|
+
group = Class.new do
|
|
885
|
+
define_singleton_method(:new) do
|
|
886
|
+
counter = 0
|
|
887
|
+
session = Object.new
|
|
888
|
+
session.define_singleton_method(:session_id) { "outbound1" }
|
|
889
|
+
session.define_singleton_method(:session_key) { "AgAAAAoutbound" }
|
|
890
|
+
session.define_singleton_method(:pickle) { |_key| "groupsession-pickle" }
|
|
891
|
+
session.define_singleton_method(:encrypt) do |plaintext|
|
|
892
|
+
counter += 1
|
|
893
|
+
"cipher#{counter}:#{plaintext.length}"
|
|
894
|
+
end
|
|
895
|
+
session
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
factory.const_set(:GroupSession, group)
|
|
899
|
+
factory
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
def build_store(**options)
|
|
903
|
+
Async::Matrix::DeviceStore.new(
|
|
904
|
+
**{
|
|
905
|
+
user_id: "@bot:example.org",
|
|
906
|
+
device_id: "ABCDEFGHIJ",
|
|
907
|
+
account: fake_account,
|
|
908
|
+
}.merge(options),
|
|
909
|
+
)
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def megolm_message(session_id: "session1", room_id: "!room:example.org")
|
|
913
|
+
Protocol::Matrix::EncryptedMessage.new({
|
|
914
|
+
"type" => "m.room.encrypted",
|
|
915
|
+
"event_id" => "$evt1",
|
|
916
|
+
"room_id" => room_id,
|
|
917
|
+
"sender" => "@alice:example.org",
|
|
918
|
+
"content" => {
|
|
919
|
+
"algorithm" => "m.megolm.v1.aes-sha2",
|
|
920
|
+
"ciphertext" => "AwgAEnAC",
|
|
921
|
+
"session_id" => session_id,
|
|
922
|
+
},
|
|
923
|
+
})
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def olm_message(type: 0, sender_key: "theircurve", recipient: "ourcurve")
|
|
927
|
+
Protocol::Matrix::EncryptedMessage.new({
|
|
928
|
+
"type" => "m.room.encrypted",
|
|
929
|
+
"sender" => "@bob:example.org",
|
|
930
|
+
"content" => {
|
|
931
|
+
"algorithm" => "m.olm.v1.curve25519-aes-sha2",
|
|
932
|
+
"sender_key" => sender_key,
|
|
933
|
+
"ciphertext" => {recipient => {"type" => type, "body" => "olmbody"}},
|
|
934
|
+
},
|
|
935
|
+
})
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
def room_key_payload(session_id: "shared1", room_id: "!room:example.org", type: "m.room_key")
|
|
939
|
+
JSON.generate({
|
|
940
|
+
"type" => type,
|
|
941
|
+
"content" => {
|
|
942
|
+
"algorithm" => "m.megolm.v1.aes-sha2",
|
|
943
|
+
"room_id" => room_id,
|
|
944
|
+
"session_id" => session_id,
|
|
945
|
+
"session_key" => "AgAAAAsession",
|
|
946
|
+
},
|
|
947
|
+
"sender" => "@bob:example.org",
|
|
948
|
+
"recipient" => "@bot:example.org",
|
|
949
|
+
"recipient_keys" => {"ed25519" => "oured"},
|
|
950
|
+
"keys" => {"ed25519" => "theired"},
|
|
951
|
+
})
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def megolm_payload(room_id = "!room:example.org")
|
|
955
|
+
JSON.generate({
|
|
956
|
+
"type" => "m.room.message",
|
|
957
|
+
"content" => {"msgtype" => "m.text", "body" => "hello"},
|
|
958
|
+
"room_id" => room_id,
|
|
959
|
+
})
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
# ── Identity ──────────────────────────────────────────────────────────────
|
|
963
|
+
|
|
964
|
+
it "exposes the device's own keys" do
|
|
965
|
+
store = build_store
|
|
966
|
+
|
|
967
|
+
store.identity_key.should == "ourcurve"
|
|
968
|
+
store.fingerprint.should == "oured"
|
|
969
|
+
store.user_id.should == "@bot:example.org"
|
|
970
|
+
store.device_id.should == "ABCDEFGHIJ"
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# ── Reading megolm ────────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
it "decrypts a room message with the key it holds" do
|
|
976
|
+
store = build_store(
|
|
977
|
+
group_sessions: {
|
|
978
|
+
"session1" => {session: fake_group_session("session1", megolm_payload), room_id: "!room:example.org"},
|
|
979
|
+
},
|
|
980
|
+
)
|
|
981
|
+
message = store.decrypt(megolm_message)
|
|
982
|
+
|
|
983
|
+
message.decrypted?.should == true
|
|
984
|
+
message.type.should == "m.room.message"
|
|
985
|
+
message.content.should == {"msgtype" => "m.text", "body" => "hello"}
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# A key for any room: the store holds them all, so nothing about the room
|
|
989
|
+
# has to be arranged in advance.
|
|
990
|
+
it "decrypts messages from several rooms" do
|
|
991
|
+
store = build_store(
|
|
992
|
+
group_sessions: {
|
|
993
|
+
"s-a" => {session: fake_group_session("s-a", megolm_payload("!a:example.org")), room_id: "!a:example.org"},
|
|
994
|
+
"s-b" => {session: fake_group_session("s-b", megolm_payload("!b:example.org")), room_id: "!b:example.org"},
|
|
995
|
+
},
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
store.decrypt(megolm_message(session_id: "s-a", room_id: "!a:example.org")).decrypted?.should == true
|
|
999
|
+
store.decrypt(megolm_message(session_id: "s-b", room_id: "!b:example.org")).decrypted?.should == true
|
|
1000
|
+
store.room_keys.length.should == 2
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
# NOT AN ERROR. The key may arrive later in this batch, in a later one, or
|
|
1004
|
+
# from backup; raising would make the commonest state exceptional.
|
|
1005
|
+
it "answers nil for a message it holds no key for" do
|
|
1006
|
+
store = build_store
|
|
1007
|
+
|
|
1008
|
+
store.decrypt(megolm_message).should.be.nil
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# A key is issued FOR A ROOM. Honouring it elsewhere would let a sender have
|
|
1012
|
+
# a message attributed to a room they hold no key for.
|
|
1013
|
+
it "refuses a session issued for a different room" do
|
|
1014
|
+
store = build_store(
|
|
1015
|
+
group_sessions: {
|
|
1016
|
+
"session1" => {session: fake_group_session("session1", megolm_payload), room_id: "!elsewhere:example.org"},
|
|
1017
|
+
},
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
lambda {
|
|
1021
|
+
store.decrypt(megolm_message(room_id: "!room:example.org"))
|
|
1022
|
+
}.should.raise(Async::Matrix::DeviceStore::RoomMismatchError)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# The same substitution attempted from inside the ciphertext.
|
|
1026
|
+
it "refuses a payload whose room disagrees with the envelope" do
|
|
1027
|
+
store = build_store(
|
|
1028
|
+
group_sessions: {
|
|
1029
|
+
"session1" => {
|
|
1030
|
+
session: fake_group_session("session1", megolm_payload("!other:example.org")),
|
|
1031
|
+
room_id: "!room:example.org",
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
lambda {
|
|
1037
|
+
store.decrypt(megolm_message(room_id: "!room:example.org"))
|
|
1038
|
+
}.should.raise(Async::Matrix::DeviceStore::RoomMismatchError)
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
# ── Reading olm, and absorbing what it carries ────────────────────────────
|
|
1042
|
+
|
|
1043
|
+
it "decrypts a to-device message with a session it already has" do
|
|
1044
|
+
store = build_store(
|
|
1045
|
+
e2ee: fake_e2ee,
|
|
1046
|
+
olm_sessions: {"theircurve" => [fake_olm_session("olm1", room_key_payload)]},
|
|
1047
|
+
)
|
|
1048
|
+
message = store.decrypt(olm_message(type: 1))
|
|
1049
|
+
|
|
1050
|
+
message.decrypted?.should == true
|
|
1051
|
+
message.type.should == "m.room_key"
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
# THE POINT OF THE CLASS: reading a to-device message leaves the store able
|
|
1055
|
+
# to read the room.
|
|
1056
|
+
it "absorbs a room key as it reads it" do
|
|
1057
|
+
store = build_store(
|
|
1058
|
+
e2ee: fake_e2ee,
|
|
1059
|
+
olm_sessions: {"theircurve" => [fake_olm_session("olm1", room_key_payload(session_id: "shared1"))]},
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
store.knows_session?("shared1").should == false
|
|
1063
|
+
store.decrypt(olm_message(type: 1))
|
|
1064
|
+
store.knows_session?("shared1").should == true
|
|
1065
|
+
store.room_keys["shared1"].should == "!room:example.org"
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
it "tries every session it has with a peer, newest first" do
|
|
1069
|
+
store = build_store(
|
|
1070
|
+
e2ee: fake_e2ee,
|
|
1071
|
+
olm_sessions: {
|
|
1072
|
+
"theircurve" => [
|
|
1073
|
+
fake_olm_session("stale", nil),
|
|
1074
|
+
fake_olm_session("good", room_key_payload),
|
|
1075
|
+
],
|
|
1076
|
+
},
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
store.decrypt(olm_message(type: 1)).decrypted?.should == true
|
|
1080
|
+
store.changes[:olm_sessions].should == ["good"]
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
# A prekey message is the only kind that can open a new session, and doing
|
|
1084
|
+
# so consumes one of our one-time keys.
|
|
1085
|
+
it "opens a new inbound session from a prekey message" do
|
|
1086
|
+
account = fake_account
|
|
1087
|
+
store = build_store(account: account, e2ee: fake_e2ee)
|
|
1088
|
+
|
|
1089
|
+
store.decrypt(olm_message(type: 0))
|
|
1090
|
+
|
|
1091
|
+
account.opened.should == [["theircurve", "olmbody"]]
|
|
1092
|
+
store.olm_sessions_with("theircurve").length.should == 1
|
|
1093
|
+
store.changes[:account].should == account
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
it "does not open a session for an ordinary olm message" do
|
|
1097
|
+
account = fake_account
|
|
1098
|
+
store = build_store(account: account)
|
|
1099
|
+
|
|
1100
|
+
store.decrypt(olm_message(type: 1)).should.be.nil
|
|
1101
|
+
account.opened.should == []
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
it "ignores a to-device message addressed to another device" do
|
|
1105
|
+
store = build_store
|
|
1106
|
+
|
|
1107
|
+
store.decrypt(olm_message(recipient: "someone-elses-key")).should.be.nil
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# ── Absorbing room keys directly ──────────────────────────────────────────
|
|
1111
|
+
|
|
1112
|
+
it "builds a direct room key with .new and a forwarded one with .import" do
|
|
1113
|
+
factory = fake_e2ee
|
|
1114
|
+
store = build_store(e2ee: factory)
|
|
1115
|
+
|
|
1116
|
+
store.absorb(JSON.parse(room_key_payload(session_id: "a")))
|
|
1117
|
+
store.absorb(JSON.parse(room_key_payload(session_id: "b", type: "m.forwarded_room_key")))
|
|
1118
|
+
|
|
1119
|
+
factory::InboundGroupSession.built.map(&:first).should == [:new, :import]
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# The session already held is the one further back in the ratchet, so
|
|
1123
|
+
# replacing it would lose history.
|
|
1124
|
+
it "keeps the key it already holds rather than replacing it" do
|
|
1125
|
+
factory = fake_e2ee
|
|
1126
|
+
store = build_store(e2ee: factory)
|
|
1127
|
+
|
|
1128
|
+
store.absorb(JSON.parse(room_key_payload)).should == "shared1"
|
|
1129
|
+
store.absorb(JSON.parse(room_key_payload)).should.be.nil
|
|
1130
|
+
factory::InboundGroupSession.built.length.should == 1
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
it "ignores a payload that is not a room key" do
|
|
1134
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1135
|
+
|
|
1136
|
+
store.absorb({"type" => "m.room.message", "content" => {}}).should.be.nil
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
it "ignores a room key for an algorithm it does not implement" do
|
|
1140
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1141
|
+
payload = JSON.parse(room_key_payload)
|
|
1142
|
+
payload["content"]["algorithm"] = "m.megolm.v2.made-up"
|
|
1143
|
+
|
|
1144
|
+
store.absorb(payload).should.be.nil
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
it "reports plainly when no factory was supplied" do
|
|
1148
|
+
store = build_store
|
|
1149
|
+
|
|
1150
|
+
lambda {
|
|
1151
|
+
store.absorb(JSON.parse(room_key_payload))
|
|
1152
|
+
}.should.raise(Async::Matrix::DeviceStore::MissingPrimitivesError)
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
# ── What the caller must persist ──────────────────────────────────────────
|
|
1156
|
+
|
|
1157
|
+
# Both algorithms ratchet, so a session that read a message and was not
|
|
1158
|
+
# saved fails on the next one -- permanently.
|
|
1159
|
+
it "reports the sessions that ratcheted" do
|
|
1160
|
+
store = build_store(
|
|
1161
|
+
group_sessions: {
|
|
1162
|
+
"session1" => {session: fake_group_session("session1", megolm_payload), room_id: "!room:example.org"},
|
|
1163
|
+
},
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
store.changed?.should == false
|
|
1167
|
+
store.decrypt(megolm_message)
|
|
1168
|
+
store.changed?.should == true
|
|
1169
|
+
store.changes[:group_sessions].should == ["session1"]
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
it "clears what it reported once the caller has written it" do
|
|
1173
|
+
store = build_store(
|
|
1174
|
+
group_sessions: {
|
|
1175
|
+
"session1" => {session: fake_group_session("session1", megolm_payload), room_id: "!room:example.org"},
|
|
1176
|
+
},
|
|
1177
|
+
)
|
|
1178
|
+
store.decrypt(megolm_message)
|
|
1179
|
+
store.flush_changes!
|
|
1180
|
+
|
|
1181
|
+
store.changed?.should == false
|
|
1182
|
+
store.changes[:group_sessions].should == []
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
it "reports a new room key as something to persist" do
|
|
1186
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1187
|
+
store.absorb(JSON.parse(room_key_payload))
|
|
1188
|
+
|
|
1189
|
+
store.changes[:group_sessions].should == ["shared1"]
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
it "does not report the account until a one-time key is consumed" do
|
|
1193
|
+
store = build_store(
|
|
1194
|
+
olm_sessions: {"theircurve" => [fake_olm_session("olm1", room_key_payload)]},
|
|
1195
|
+
e2ee: fake_e2ee,
|
|
1196
|
+
)
|
|
1197
|
+
store.decrypt(olm_message(type: 1))
|
|
1198
|
+
|
|
1199
|
+
store.changes[:account].should.be.nil
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
# ── Unknown algorithms ────────────────────────────────────────────────────
|
|
1203
|
+
|
|
1204
|
+
it "refuses an algorithm it does not implement" do
|
|
1205
|
+
store = build_store
|
|
1206
|
+
message = Protocol::Matrix::EncryptedMessage.new({
|
|
1207
|
+
"type" => "m.room.encrypted",
|
|
1208
|
+
"content" => {"algorithm" => "m.made.up", "ciphertext" => "x"},
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
lambda {
|
|
1212
|
+
store.decrypt(message)
|
|
1213
|
+
}.should.raise(Protocol::Matrix::EncryptedMessage::UnsupportedAlgorithmError)
|
|
1214
|
+
end
|
|
1215
|
+
# ── Publishing ────────────────────────────────────────────────────────────
|
|
1216
|
+
|
|
1217
|
+
it "builds its own signed device keys" do
|
|
1218
|
+
store = build_store
|
|
1219
|
+
keys = store.device_keys
|
|
1220
|
+
|
|
1221
|
+
keys["user_id"].should == "@bot:example.org"
|
|
1222
|
+
keys["device_id"].should == "ABCDEFGHIJ"
|
|
1223
|
+
keys["keys"].should == {
|
|
1224
|
+
"curve25519:ABCDEFGHIJ" => "ourcurve",
|
|
1225
|
+
"ed25519:ABCDEFGHIJ" => "oured",
|
|
1226
|
+
}
|
|
1227
|
+
keys["signatures"]["@bot:example.org"]["ed25519:ABCDEFGHIJ"].should == "SIGNED"
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
it "generates and signs one-time keys" do
|
|
1231
|
+
store = build_store
|
|
1232
|
+
keys = store.generate_one_time_keys(2)
|
|
1233
|
+
|
|
1234
|
+
keys.keys.sort.should == ["signed_curve25519:k1", "signed_curve25519:k2"]
|
|
1235
|
+
keys["signed_curve25519:k1"]["key"].should == "otk1"
|
|
1236
|
+
keys["signed_curve25519:k1"]["signatures"].should ==
|
|
1237
|
+
{"@bot:example.org" => {"ed25519:ABCDEFGHIJ" => "SIGNED"}}
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
it "generates a fallback key, marked as one" do
|
|
1241
|
+
store = build_store
|
|
1242
|
+
|
|
1243
|
+
store.generate_fallback_key["signed_curve25519:fb1"]["fallback"].should == true
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
# Generating consumes account state, so the caller has to save it.
|
|
1247
|
+
it "reports the account as changed after generating keys" do
|
|
1248
|
+
store = build_store
|
|
1249
|
+
store.generate_one_time_keys(1)
|
|
1250
|
+
|
|
1251
|
+
store.changes[:account].should.not.be.nil
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
# The account forgets a key once marked published, so marking before the
|
|
1255
|
+
# upload succeeds discards keys the server never got.
|
|
1256
|
+
it "does not mark keys published until told to" do
|
|
1257
|
+
account = fake_account
|
|
1258
|
+
store = build_store(account: account)
|
|
1259
|
+
store.generate_one_time_keys(1)
|
|
1260
|
+
|
|
1261
|
+
account.published.should == 0
|
|
1262
|
+
|
|
1263
|
+
store.mark_keys_published!
|
|
1264
|
+
account.published.should == 1
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
it "asks for half the maximum, as the spec suggests" do
|
|
1268
|
+
build_store.default_one_time_key_count.should == 50
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
# Compared against what the SERVER says it holds: keys are consumed by peers
|
|
1272
|
+
# claiming them, which we never see.
|
|
1273
|
+
it "tops up based on the server's count" do
|
|
1274
|
+
store = build_store
|
|
1275
|
+
|
|
1276
|
+
store.needs_one_time_keys?(0).should == true
|
|
1277
|
+
store.needs_one_time_keys?(49).should == true
|
|
1278
|
+
store.needs_one_time_keys?(50).should == false
|
|
1279
|
+
end
|
|
1280
|
+
# ── Writing: megolm ───────────────────────────────────────────────────────
|
|
1281
|
+
|
|
1282
|
+
it "encrypts a room event and says which session it used" do
|
|
1283
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1284
|
+
content, session_id, shared = store.encrypt(
|
|
1285
|
+
room_id: "!room:example.org", type: "m.room.message", content: {"body" => "hi"},
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
content["algorithm"].should == "m.megolm.v1.aes-sha2"
|
|
1289
|
+
content["session_id"].should == "outbound1"
|
|
1290
|
+
content["sender_key"].should == "ourcurve"
|
|
1291
|
+
content["device_id"].should == "ABCDEFGHIJ"
|
|
1292
|
+
session_id.should == "outbound1"
|
|
1293
|
+
shared.should == {}
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
it "reuses the room's session across messages" do
|
|
1297
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1298
|
+
first = store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1299
|
+
second = store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1300
|
+
|
|
1301
|
+
first[1].should == second[1]
|
|
1302
|
+
first[0]["ciphertext"].should.not == second[0]["ciphertext"]
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
# Spec defaults for m.room.encryption.
|
|
1306
|
+
it "rotates after the message limit" do
|
|
1307
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1308
|
+
100.times { store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {}) }
|
|
1309
|
+
entry = store.outbound_session("!r:example.org")
|
|
1310
|
+
|
|
1311
|
+
entry[:message_count].should == 0
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
it "honours the room's own rotation policy" do
|
|
1315
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1316
|
+
rotation = {"rotation_period_msgs" => 2}
|
|
1317
|
+
3.times { store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {}, rotation: rotation) }
|
|
1318
|
+
|
|
1319
|
+
store.outbound_session("!r:example.org", rotation: rotation)[:message_count].should == 1
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
# Whoever left still holds the current key, so every later message would be
|
|
1323
|
+
# readable by them. No timer does this in time.
|
|
1324
|
+
it "starts a new session when told to rotate" do
|
|
1325
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1326
|
+
store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1327
|
+
store.record_shared!("!r:example.org", {"@ada:example.org" => ["DEV1"]})
|
|
1328
|
+
|
|
1329
|
+
store.rotate!("!r:example.org")
|
|
1330
|
+
|
|
1331
|
+
store.shared_with?("!r:example.org", "@ada:example.org", "DEV1").should == false
|
|
1332
|
+
store.outbound_session("!r:example.org")[:message_count].should == 0
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
it "tracks who already holds the room's key" do
|
|
1336
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1337
|
+
store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1338
|
+
|
|
1339
|
+
store.shared_with?("!r:example.org", "@ada:example.org", "DEV1").should == false
|
|
1340
|
+
store.record_shared!("!r:example.org", {"@ada:example.org" => ["DEV1", "DEV2"]})
|
|
1341
|
+
store.shared_with?("!r:example.org", "@ada:example.org", "DEV1").should == true
|
|
1342
|
+
store.record_shared!("!r:example.org", {"@ada:example.org" => ["DEV3"]})
|
|
1343
|
+
store.shared_with?("!r:example.org", "@ada:example.org", "DEV1").should == true
|
|
1344
|
+
store.shared_with?("!r:example.org", "@ada:example.org", "DEV3").should == true
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
# An outbound session cannot decrypt, so without keeping the inbound twin we
|
|
1348
|
+
# could not read our own messages back.
|
|
1349
|
+
it "keeps its own room key when handing it out" do
|
|
1350
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1351
|
+
store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1352
|
+
payload = store.room_key_payload("!r:example.org")
|
|
1353
|
+
|
|
1354
|
+
payload["type"].should == "m.room_key"
|
|
1355
|
+
payload["content"]["session_id"].should == "outbound1"
|
|
1356
|
+
payload["content"]["room_id"].should == "!r:example.org"
|
|
1357
|
+
store.room_keys.values.should == ["!r:example.org"]
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
it "has no room key payload for a room it never sent to" do
|
|
1361
|
+
build_store(e2ee: fake_e2ee).room_key_payload("!never:example.org").should.be.nil
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
it "refuses to start a session with no factory" do
|
|
1365
|
+
lambda {
|
|
1366
|
+
build_store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1367
|
+
}.should.raise(Async::Matrix::DeviceStore::MissingPrimitivesError)
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# ── Writing: olm ──────────────────────────────────────────────────────────
|
|
1371
|
+
|
|
1372
|
+
it "wraps a payload for a device it already has a session with" do
|
|
1373
|
+
store = build_store(olm_sessions: {"theircurve" => [olm_encrypting_session]})
|
|
1374
|
+
content = store.encrypt_to_device(
|
|
1375
|
+
{"type" => "m.room_key", "content" => {"a" => 1}},
|
|
1376
|
+
recipient: "@bob:example.org",
|
|
1377
|
+
recipient_identity_key: "theircurve",
|
|
1378
|
+
recipient_key: "theired",
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
content["algorithm"].should == "m.olm.v1.curve25519-aes-sha2"
|
|
1382
|
+
content["sender_key"].should == "ourcurve"
|
|
1383
|
+
content["ciphertext"]["theircurve"]["type"].should == 1
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
it "opens a session from a claimed key when it has none" do
|
|
1387
|
+
store = build_store
|
|
1388
|
+
content = store.encrypt_to_device(
|
|
1389
|
+
{"type" => "m.room_key", "content" => {}},
|
|
1390
|
+
recipient: "@bob:example.org",
|
|
1391
|
+
recipient_identity_key: "theircurve",
|
|
1392
|
+
recipient_key: "theired",
|
|
1393
|
+
one_time_key: {"key" => "theirotk"},
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
content["ciphertext"]["theircurve"]["type"].should == 0
|
|
1397
|
+
store.olm_sessions_with("theircurve").length.should == 1
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
# Unreachable rather than guessable: with no session and no claimed key
|
|
1401
|
+
# there is nothing to encrypt to.
|
|
1402
|
+
it "answers nil for a device it cannot reach" do
|
|
1403
|
+
build_store.encrypt_to_device(
|
|
1404
|
+
{"type" => "m.room_key", "content" => {}},
|
|
1405
|
+
recipient: "@bob:example.org",
|
|
1406
|
+
recipient_identity_key: "theircurve",
|
|
1407
|
+
recipient_key: "theired",
|
|
1408
|
+
).should.be.nil
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
# ── Replay protection ─────────────────────────────────────────────────────
|
|
1412
|
+
|
|
1413
|
+
# The spec: remember the message_index per session and treat a repeat as
|
|
1414
|
+
# invalid, unless event_id and origin_server_ts also match.
|
|
1415
|
+
it "accepts the same event decrypted twice" do
|
|
1416
|
+
store = build_store(
|
|
1417
|
+
group_sessions: {
|
|
1418
|
+
"session1" => {session: fake_group_session("session1", megolm_payload, 5), room_id: "!room:example.org"},
|
|
1419
|
+
},
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
store.decrypt(megolm_message).should.not.be.nil
|
|
1423
|
+
store.decrypt(megolm_message).should.not.be.nil
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
# A message replayed under a fresh event id would otherwise be accepted as
|
|
1427
|
+
# new.
|
|
1428
|
+
it "refuses a reused message index under a different event id" do
|
|
1429
|
+
store = build_store(
|
|
1430
|
+
group_sessions: {
|
|
1431
|
+
"session1" => {session: fake_group_session("session1", megolm_payload, 5), room_id: "!room:example.org"},
|
|
1432
|
+
},
|
|
1433
|
+
)
|
|
1434
|
+
store.decrypt(megolm_message)
|
|
1435
|
+
|
|
1436
|
+
replayed = Protocol::Matrix::EncryptedMessage.new({
|
|
1437
|
+
"type" => "m.room.encrypted",
|
|
1438
|
+
"event_id" => "$different",
|
|
1439
|
+
"room_id" => "!room:example.org",
|
|
1440
|
+
"sender" => "@alice:example.org",
|
|
1441
|
+
"content" => {
|
|
1442
|
+
"algorithm" => "m.megolm.v1.aes-sha2",
|
|
1443
|
+
"ciphertext" => "AwgAEnAC",
|
|
1444
|
+
"session_id" => "session1",
|
|
1445
|
+
},
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
lambda { store.decrypt(replayed) }.should.raise(Async::Matrix::DeviceStore::ReplayError)
|
|
1449
|
+
end
|
|
1450
|
+
|
|
1451
|
+
it "allows different indexes in the same session" do
|
|
1452
|
+
store = build_store(
|
|
1453
|
+
group_sessions: {
|
|
1454
|
+
"s" => {session: counting_group_session, room_id: "!room:example.org"},
|
|
1455
|
+
},
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
store.decrypt(megolm_message(session_id: "s")).should.not.be.nil
|
|
1459
|
+
store.decrypt(
|
|
1460
|
+
Protocol::Matrix::EncryptedMessage.new({
|
|
1461
|
+
"type" => "m.room.encrypted", "event_id" => "$two", "room_id" => "!room:example.org",
|
|
1462
|
+
"sender" => "@alice:example.org",
|
|
1463
|
+
"content" => {"algorithm" => "m.megolm.v1.aes-sha2", "ciphertext" => "c", "session_id" => "s"},
|
|
1464
|
+
}),
|
|
1465
|
+
).should.not.be.nil
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
# An Olm session will decrypt a prekey message again quite happily.
|
|
1469
|
+
it "refuses the same olm ciphertext twice" do
|
|
1470
|
+
store = build_store(
|
|
1471
|
+
e2ee: fake_e2ee,
|
|
1472
|
+
olm_sessions: {"theircurve" => [fake_olm_session("olm1", room_key_payload)]},
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
store.decrypt(olm_message(type: 1)).should.not.be.nil
|
|
1476
|
+
lambda { store.decrypt(olm_message(type: 1)) }.should.raise(Async::Matrix::DeviceStore::ReplayError)
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
it "reports an outbound room as needing persistence" do
|
|
1480
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1481
|
+
store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1482
|
+
|
|
1483
|
+
store.changes[:outbound_rooms].should == ["!r:example.org"]
|
|
1484
|
+
store.flush_changes!
|
|
1485
|
+
store.changes[:outbound_rooms].should == []
|
|
1486
|
+
end
|
|
1487
|
+
# ── Importing from backup ─────────────────────────────────────────────────
|
|
1488
|
+
|
|
1489
|
+
# The format is the point: a backup blob carries an unsigned, version-1
|
|
1490
|
+
# ExportedSessionKey, which .new rejects -- only .import takes one.
|
|
1491
|
+
it "imports a session recovered from backup" do
|
|
1492
|
+
factory = fake_e2ee
|
|
1493
|
+
store = build_store(e2ee: factory)
|
|
1494
|
+
|
|
1495
|
+
store.import_session(
|
|
1496
|
+
room_id: "!old:example.org",
|
|
1497
|
+
session_id: "backed-up-1",
|
|
1498
|
+
session_key: "AgAAAADxexported",
|
|
1499
|
+
sender_key: "theircurve",
|
|
1500
|
+
signing_key: "theired",
|
|
1501
|
+
).should == "backed-up-1"
|
|
1502
|
+
|
|
1503
|
+
store.knows_session?("backed-up-1").should == true
|
|
1504
|
+
store.room_keys["backed-up-1"].should == "!old:example.org"
|
|
1505
|
+
factory::InboundGroupSession.built.should == [[:import, "AgAAAADxexported"]]
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# The live key we already hold is further back in the ratchet, so a backup
|
|
1509
|
+
# copy must not replace it.
|
|
1510
|
+
it "keeps a session it already holds" do
|
|
1511
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1512
|
+
store.import_session(room_id: "!r:example.org", session_id: "s1", session_key: "k")
|
|
1513
|
+
|
|
1514
|
+
store.import_session(room_id: "!r:example.org", session_id: "s1", session_key: "k").should.be.nil
|
|
1515
|
+
end
|
|
1516
|
+
|
|
1517
|
+
it "reports an imported session as something to persist" do
|
|
1518
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1519
|
+
store.import_session(room_id: "!r:example.org", session_id: "s1", session_key: "k")
|
|
1520
|
+
|
|
1521
|
+
store.changes[:group_sessions].should == ["s1"]
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
it "says plainly when it has no factory to import with" do
|
|
1525
|
+
lambda {
|
|
1526
|
+
build_store.import_session(room_id: "!r:example.org", session_id: "s1", session_key: "k")
|
|
1527
|
+
}.should.raise(Async::Matrix::DeviceStore::MissingPrimitivesError)
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
# Decrypting works the same whether the key arrived live or from backup.
|
|
1531
|
+
it "decrypts with an imported session" do
|
|
1532
|
+
factory = fake_e2ee
|
|
1533
|
+
store = build_store(e2ee: factory)
|
|
1534
|
+
store.import_session(
|
|
1535
|
+
room_id: "!room:example.org", session_id: "session1", session_key: "exported",
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
store.decrypt(megolm_message).should.not.be.nil
|
|
1539
|
+
end
|
|
1540
|
+
# ── Exporting what to persist ─────────────────────────────────────────────
|
|
1541
|
+
|
|
1542
|
+
def pickle_key = Async::Matrix::E2EE::PickleKey.derive("a test secret")
|
|
1543
|
+
|
|
1544
|
+
# #changes names what moved; #export hands over the bytes. The difference is
|
|
1545
|
+
# the point: an id cannot be pickled, and only the store holds the objects.
|
|
1546
|
+
it "exports the pickled account once a one-time key is consumed" do
|
|
1547
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1548
|
+
store.decrypt(olm_message(type: 0))
|
|
1549
|
+
|
|
1550
|
+
store.export(pickle_key)[:account].should == "account-pickle"
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
it "exports nothing for an untouched store" do
|
|
1554
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1555
|
+
exported = store.export(pickle_key)
|
|
1556
|
+
|
|
1557
|
+
exported[:account].should.be.nil
|
|
1558
|
+
exported[:olm_sessions].should == []
|
|
1559
|
+
exported[:group_sessions].should == []
|
|
1560
|
+
exported[:outbound_sessions].should == []
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
# An Olm session is found BY PEER when decrypting, so there is no index from
|
|
1564
|
+
# session id back to peer -- export has to look it up, and it must find it.
|
|
1565
|
+
it "exports a ratcheted olm session with the peer it belongs to" do
|
|
1566
|
+
store = build_store(
|
|
1567
|
+
e2ee: fake_e2ee,
|
|
1568
|
+
olm_sessions: {"theircurve" => [fake_olm_session("olm1", room_key_payload)]},
|
|
1569
|
+
)
|
|
1570
|
+
store.decrypt(olm_message(type: 1))
|
|
1571
|
+
rows = store.export(pickle_key)[:olm_sessions]
|
|
1572
|
+
|
|
1573
|
+
rows.length.should == 1
|
|
1574
|
+
rows.first[:session_id].should == "olm1"
|
|
1575
|
+
rows.first[:sender_key].should == "theircurve"
|
|
1576
|
+
rows.first[:pickle].should == "session-pickle"
|
|
1577
|
+
end
|
|
1578
|
+
|
|
1579
|
+
it "exports a received room key with what it claimed about its sender" do
|
|
1580
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1581
|
+
store.absorb(JSON.parse(room_key_payload))
|
|
1582
|
+
row = store.export(pickle_key)[:group_sessions].first
|
|
1583
|
+
|
|
1584
|
+
row[:session_id].should == "shared1"
|
|
1585
|
+
row[:room_id].should == "!room:example.org"
|
|
1586
|
+
row[:pickle].should == "inboundgroupsession-pickle"
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
it "exports an outbound session with its rotation bookkeeping" do
|
|
1590
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1591
|
+
store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1592
|
+
store.record_shared!("!r:example.org", {"@ada:example.org" => ["DEV1"]})
|
|
1593
|
+
row = store.export(pickle_key)[:outbound_sessions].first
|
|
1594
|
+
|
|
1595
|
+
row[:room_id].should == "!r:example.org"
|
|
1596
|
+
row[:session_id].should == "outbound1"
|
|
1597
|
+
row[:message_count].should == 1
|
|
1598
|
+
row[:shared_with].should == {"@ada:example.org" => ["DEV1"]}
|
|
1599
|
+
row[:pickle].should == "groupsession-pickle"
|
|
1600
|
+
row[:started_at].should.not.be.nil
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
# A session that ratchets several times in one batch is pickled once, at its
|
|
1604
|
+
# final position -- not once per message.
|
|
1605
|
+
it "exports a repeatedly ratcheted session once" do
|
|
1606
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1607
|
+
3.times { store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {}) }
|
|
1608
|
+
rows = store.export(pickle_key)[:outbound_sessions]
|
|
1609
|
+
|
|
1610
|
+
rows.length.should == 1
|
|
1611
|
+
rows.first[:message_count].should == 3
|
|
1612
|
+
end
|
|
1613
|
+
|
|
1614
|
+
it "exports nothing again once the caller has flushed" do
|
|
1615
|
+
store = build_store(e2ee: fake_e2ee)
|
|
1616
|
+
store.encrypt(room_id: "!r:example.org", type: "m.room.message", content: {})
|
|
1617
|
+
store.flush_changes!
|
|
1618
|
+
|
|
1619
|
+
store.export(pickle_key)[:outbound_sessions].should == []
|
|
1620
|
+
end
|
|
1621
|
+
end
|