async-matrix 3.0.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.
@@ -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