async-matrix 3.0.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/lib/async/matrix/app_service_client.rb +213 -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 +77 -5
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -9
- data/lib/async/matrix/version.rb +3 -3
- data/lib/async/matrix.rb +16 -0
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/{async → protocol}/matrix/content.rb +9 -9
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/{async → protocol}/matrix/event.rb +28 -17
- 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/{async → protocol}/matrix/schema/registry.rb +17 -17
- data/lib/{async → protocol}/matrix/schema/validation_error.rb +14 -12
- data/lib/{async → protocol}/matrix/schema.rb +17 -17
- 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 +22 -7
|
@@ -0,0 +1,381 @@
|
|
|
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_relative "encrypted_message"
|
|
7
|
+
require_relative "error"
|
|
8
|
+
require_relative "signing"
|
|
9
|
+
|
|
10
|
+
module Protocol
|
|
11
|
+
module Matrix
|
|
12
|
+
# The three key documents a device publishes through `POST /keys/upload`,
|
|
13
|
+
# built and signed to the spec.
|
|
14
|
+
#
|
|
15
|
+
# device_keys who this device is: its identity keys, signed by itself.
|
|
16
|
+
# Other devices verify everything we sign against the
|
|
17
|
+
# ed25519 key in here.
|
|
18
|
+
# one_time_keys consumable curve25519 keys. Another device CLAIMS one to
|
|
19
|
+
# open an Olm session with us, and the server deletes it.
|
|
20
|
+
# Run out and nobody can reach us: they cannot establish a
|
|
21
|
+
# session, so they cannot send us a room key, so every
|
|
22
|
+
# message in an encrypted room stays ciphertext.
|
|
23
|
+
# fallback_keys one key per algorithm, answering claims after the
|
|
24
|
+
# one-time keys are exhausted. NOT deleted when used,
|
|
25
|
+
# which is the whole point — it is the backstop against
|
|
26
|
+
# the failure above.
|
|
27
|
+
#
|
|
28
|
+
# Each takes raw key strings and an injected signer, so this builds and
|
|
29
|
+
# signs documents without holding a key or knowing where one came from.
|
|
30
|
+
module Keys
|
|
31
|
+
# The algorithm name under which one-time and fallback keys are published.
|
|
32
|
+
# "signed_" because the key object carries its own signature, which is
|
|
33
|
+
# what lets a claimer verify it came from the device it names.
|
|
34
|
+
SIGNED_CURVE25519 = "signed_curve25519"
|
|
35
|
+
|
|
36
|
+
CURVE25519 = "curve25519"
|
|
37
|
+
ED25519 = "ed25519"
|
|
38
|
+
|
|
39
|
+
# What we tell other devices we can do. The same two algorithms
|
|
40
|
+
# EncryptedMessage knows how to read, because claiming one we cannot
|
|
41
|
+
# decrypt would have peers encrypt into a void.
|
|
42
|
+
ALGORITHMS = EncryptedMessage::ALGORITHMS
|
|
43
|
+
|
|
44
|
+
class Error < Protocol::Matrix::Error; end
|
|
45
|
+
|
|
46
|
+
# The signed device identity document.
|
|
47
|
+
#
|
|
48
|
+
# @parameter curve25519 [String] the device's identity key.
|
|
49
|
+
# @parameter ed25519 [String] the device's fingerprint key.
|
|
50
|
+
# @parameter signer [Object] answers `sign(String) -> base64`.
|
|
51
|
+
# @returns [Hash] a DeviceKeys object, signed by this device.
|
|
52
|
+
def self.device_keys(user_id:, device_id:, curve25519:, ed25519:, signer:, algorithms: ALGORITHMS)
|
|
53
|
+
Signing.sign(
|
|
54
|
+
{
|
|
55
|
+
"algorithms" => algorithms,
|
|
56
|
+
"device_id" => device_id,
|
|
57
|
+
"keys" => {
|
|
58
|
+
"#{CURVE25519}:#{device_id}" => curve25519,
|
|
59
|
+
"#{ED25519}:#{device_id}" => ed25519,
|
|
60
|
+
},
|
|
61
|
+
"user_id" => user_id,
|
|
62
|
+
},
|
|
63
|
+
signer: signer,
|
|
64
|
+
user_id: user_id,
|
|
65
|
+
key_id: Signing.key_id(device_id),
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Sign a batch of one-time keys for upload.
|
|
70
|
+
#
|
|
71
|
+
# @parameter keys [Hash] key_id => base64 curve25519 key, as the account
|
|
72
|
+
# reports its unpublished keys.
|
|
73
|
+
# @returns [Hash] "signed_curve25519:<key_id>" => {"key" =>, "signatures" =>}
|
|
74
|
+
#
|
|
75
|
+
# EACH KEY IS SIGNED SEPARATELY, and that is not incidental: a claimer
|
|
76
|
+
# receives one key object on its own, with no device-keys document
|
|
77
|
+
# alongside it, so the signature on the key object is the only thing
|
|
78
|
+
# tying it to our device.
|
|
79
|
+
def self.one_time_keys(keys, user_id:, device_id:, signer:)
|
|
80
|
+
keys.to_h do |key_id, key|
|
|
81
|
+
[
|
|
82
|
+
"#{SIGNED_CURVE25519}:#{key_id}",
|
|
83
|
+
signed_key({"key" => key}, user_id: user_id, device_id: device_id, signer: signer),
|
|
84
|
+
]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A fallback key, in the same shape plus the marker that distinguishes it.
|
|
89
|
+
#
|
|
90
|
+
# "When uploading a signed key, an additional `fallback: true` key should
|
|
91
|
+
# be included to denote that the key is a fallback key." It is part of the
|
|
92
|
+
# SIGNED object, so a server cannot silently reclassify a one-time key as
|
|
93
|
+
# a fallback (or the reverse) without breaking the signature.
|
|
94
|
+
def self.fallback_keys(keys, user_id:, device_id:, signer:)
|
|
95
|
+
keys.to_h do |key_id, key|
|
|
96
|
+
[
|
|
97
|
+
"#{SIGNED_CURVE25519}:#{key_id}",
|
|
98
|
+
signed_key(
|
|
99
|
+
{"fallback" => true, "key" => key},
|
|
100
|
+
user_id: user_id,
|
|
101
|
+
device_id: device_id,
|
|
102
|
+
signer: signer,
|
|
103
|
+
),
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.signed_key(object, user_id:, device_id:, signer:)
|
|
109
|
+
Signing.sign(
|
|
110
|
+
object,
|
|
111
|
+
signer: signer,
|
|
112
|
+
user_id: user_id,
|
|
113
|
+
key_id: Signing.key_id(device_id),
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ── Reading other devices' keys ─────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
# Pull one algorithm's key out of a DeviceKeys object.
|
|
120
|
+
#
|
|
121
|
+
# The key names are "<algorithm>:<device_id>", and the device_id must be
|
|
122
|
+
# taken from the document rather than assumed, because a response from
|
|
123
|
+
# /keys/query carries many devices at once.
|
|
124
|
+
def self.key_for(device_keys, algorithm)
|
|
125
|
+
device_id = device_keys["device_id"]
|
|
126
|
+
|
|
127
|
+
if device_id.nil?
|
|
128
|
+
nil
|
|
129
|
+
else
|
|
130
|
+
(device_keys["keys"] || {})["#{algorithm}:#{device_id}"]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.identity_key(device_keys) = key_for(device_keys, CURVE25519)
|
|
135
|
+
def self.fingerprint(device_keys) = key_for(device_keys, ED25519)
|
|
136
|
+
|
|
137
|
+
# Is this DeviceKeys object correctly self-signed, and does it describe the
|
|
138
|
+
# device it claims to?
|
|
139
|
+
#
|
|
140
|
+
# BOTH HALVES MATTER. A valid signature over a document whose user_id or
|
|
141
|
+
# device_id disagrees with where we found it is a document for a DIFFERENT
|
|
142
|
+
# device, replayed — so the spec has us "confirm that the `user_id` and
|
|
143
|
+
# `device_id` match those of the top-level map entry".
|
|
144
|
+
def self.valid_device_keys?(device_keys, user_id:, device_id:, verifier:)
|
|
145
|
+
key = fingerprint(device_keys)
|
|
146
|
+
|
|
147
|
+
if key.nil? || device_keys["user_id"] != user_id || device_keys["device_id"] != device_id
|
|
148
|
+
false
|
|
149
|
+
else
|
|
150
|
+
Signing.verify(
|
|
151
|
+
device_keys,
|
|
152
|
+
key: key,
|
|
153
|
+
verifier: verifier,
|
|
154
|
+
user_id: user_id,
|
|
155
|
+
key_id: Signing.key_id(device_id),
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
rescue Signing::MissingSignatureError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
__END__
|
|
166
|
+
describe "Protocol::Matrix::Keys" do
|
|
167
|
+
def signer(signature = "SIG")
|
|
168
|
+
signer = Object.new
|
|
169
|
+
signed = []
|
|
170
|
+
signer.define_singleton_method(:signed) { signed }
|
|
171
|
+
signer.define_singleton_method(:sign) do |message|
|
|
172
|
+
signed << message
|
|
173
|
+
signature
|
|
174
|
+
end
|
|
175
|
+
signer
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def verifier(result = true)
|
|
179
|
+
verifier = Object.new
|
|
180
|
+
verifier.define_singleton_method(:verify_signature) { |_key, _message, _signature| result }
|
|
181
|
+
verifier
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def device_keys(**overrides)
|
|
185
|
+
Protocol::Matrix::Keys.device_keys(
|
|
186
|
+
**{
|
|
187
|
+
user_id: "@alice:example.com",
|
|
188
|
+
device_id: "JLAFKJWSCS",
|
|
189
|
+
curve25519: "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
|
|
190
|
+
ed25519: "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI",
|
|
191
|
+
signer: signer,
|
|
192
|
+
}.merge(overrides),
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# ── device_keys ───────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
# Every field the DeviceKeys schema requires.
|
|
199
|
+
it "builds the document the schema requires" do
|
|
200
|
+
keys = device_keys
|
|
201
|
+
|
|
202
|
+
keys["user_id"].should == "@alice:example.com"
|
|
203
|
+
keys["device_id"].should == "JLAFKJWSCS"
|
|
204
|
+
keys["algorithms"].should == ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"]
|
|
205
|
+
keys["keys"].should == {
|
|
206
|
+
"curve25519:JLAFKJWSCS" => "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
|
|
207
|
+
"ed25519:JLAFKJWSCS" => "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI",
|
|
208
|
+
}
|
|
209
|
+
keys["signatures"].should == {"@alice:example.com" => {"ed25519:JLAFKJWSCS" => "SIG"}}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The key names are "<algorithm>:<device_id>", so a different device id
|
|
213
|
+
# renames them -- they are not fixed strings.
|
|
214
|
+
it "names the keys after the device" do
|
|
215
|
+
device_keys(device_id: "OTHERDEV")["keys"].keys.sort
|
|
216
|
+
.should == ["curve25519:OTHERDEV", "ed25519:OTHERDEV"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# We advertise exactly what EncryptedMessage can read: claiming an algorithm
|
|
220
|
+
# we cannot decrypt would have peers encrypt into a void.
|
|
221
|
+
it "advertises only algorithms we can actually read" do
|
|
222
|
+
Protocol::Matrix::Keys::ALGORITHMS.should == Protocol::Matrix::EncryptedMessage::ALGORITHMS
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "accepts an explicit algorithm list" do
|
|
226
|
+
device_keys(algorithms: ["m.megolm.v1.aes-sha2"])["algorithms"]
|
|
227
|
+
.should == ["m.megolm.v1.aes-sha2"]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "signs the canonical form of the document" do
|
|
231
|
+
sign = signer
|
|
232
|
+
device_keys(signer: sign)
|
|
233
|
+
|
|
234
|
+
sign.signed.length.should == 1
|
|
235
|
+
sign.signed.first.should ==
|
|
236
|
+
'{"algorithms":["m.olm.v1.curve25519-aes-sha2","m.megolm.v1.aes-sha2"],' \
|
|
237
|
+
'"device_id":"JLAFKJWSCS",' \
|
|
238
|
+
'"keys":{"curve25519:JLAFKJWSCS":"3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",' \
|
|
239
|
+
'"ed25519:JLAFKJWSCS":"lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"},' \
|
|
240
|
+
'"user_id":"@alice:example.com"}'
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# ── one_time_keys ─────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
it "publishes one-time keys under signed_curve25519" do
|
|
246
|
+
keys = Protocol::Matrix::Keys.one_time_keys(
|
|
247
|
+
{"AAAAHg" => "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs"},
|
|
248
|
+
user_id: "@alice:example.com",
|
|
249
|
+
device_id: "JLAFKJWSCS",
|
|
250
|
+
signer: signer,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
keys.keys.should == ["signed_curve25519:AAAAHg"]
|
|
254
|
+
keys["signed_curve25519:AAAAHg"].should == {
|
|
255
|
+
"key" => "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs",
|
|
256
|
+
"signatures" => {"@alice:example.com" => {"ed25519:JLAFKJWSCS" => "SIG"}},
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A claimer receives ONE key object alone, with no device-keys document
|
|
261
|
+
# beside it, so the signature on each key is the only thing tying it to us.
|
|
262
|
+
it "signs each key separately" do
|
|
263
|
+
sign = signer
|
|
264
|
+
Protocol::Matrix::Keys.one_time_keys(
|
|
265
|
+
{"one" => "KEY1", "two" => "KEY2"},
|
|
266
|
+
user_id: "@alice:example.com",
|
|
267
|
+
device_id: "DEV",
|
|
268
|
+
signer: sign,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
sign.signed.should == ['{"key":"KEY1"}', '{"key":"KEY2"}']
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it "publishes nothing for an empty batch" do
|
|
275
|
+
Protocol::Matrix::Keys.one_time_keys({}, user_id: "@a:b", device_id: "D", signer: signer)
|
|
276
|
+
.should == {}
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# ── fallback_keys ─────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
# The marker is inside the SIGNED object, so a server cannot reclassify a
|
|
282
|
+
# one-time key as a fallback (or the reverse) without breaking the signature.
|
|
283
|
+
it "marks a fallback key, inside the signature" do
|
|
284
|
+
sign = signer
|
|
285
|
+
keys = Protocol::Matrix::Keys.fallback_keys(
|
|
286
|
+
{"AAAAGj" => "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs"},
|
|
287
|
+
user_id: "@alice:example.com",
|
|
288
|
+
device_id: "JLAFKJWSCS",
|
|
289
|
+
signer: sign,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
keys["signed_curve25519:AAAAGj"]["fallback"].should == true
|
|
293
|
+
sign.signed.first.should ==
|
|
294
|
+
'{"fallback":true,"key":"zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs"}'
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# ── Reading another device's keys ─────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
it "pulls the identity and fingerprint keys out of a document" do
|
|
300
|
+
keys = device_keys
|
|
301
|
+
|
|
302
|
+
Protocol::Matrix::Keys.identity_key(keys)
|
|
303
|
+
.should == "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI"
|
|
304
|
+
Protocol::Matrix::Keys.fingerprint(keys)
|
|
305
|
+
.should == "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The device id comes from the document, because a /keys/query response
|
|
309
|
+
# carries many devices at once.
|
|
310
|
+
it "reads the keys of whichever device the document describes" do
|
|
311
|
+
Protocol::Matrix::Keys.identity_key(device_keys(device_id: "OTHERDEV", curve25519: "THEIRS"))
|
|
312
|
+
.should == "THEIRS"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it "answers nil for a document with no device id" do
|
|
316
|
+
Protocol::Matrix::Keys.identity_key({"keys" => {"curve25519:D" => "K"}}).should.be.nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "accepts a correctly self-signed document" do
|
|
320
|
+
Protocol::Matrix::Keys.valid_device_keys?(
|
|
321
|
+
device_keys,
|
|
322
|
+
user_id: "@alice:example.com",
|
|
323
|
+
device_id: "JLAFKJWSCS",
|
|
324
|
+
verifier: verifier,
|
|
325
|
+
).should == true
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "rejects a document whose signature does not verify" do
|
|
329
|
+
Protocol::Matrix::Keys.valid_device_keys?(
|
|
330
|
+
device_keys,
|
|
331
|
+
user_id: "@alice:example.com",
|
|
332
|
+
device_id: "JLAFKJWSCS",
|
|
333
|
+
verifier: verifier(false),
|
|
334
|
+
).should == false
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# "Confirm that the user_id and device_id match those of the top-level map
|
|
338
|
+
# entry": a valid signature over a document describing a DIFFERENT device is
|
|
339
|
+
# that other device's document, replayed.
|
|
340
|
+
it "rejects a document that describes a different device" do
|
|
341
|
+
Protocol::Matrix::Keys.valid_device_keys?(
|
|
342
|
+
device_keys(device_id: "SOMEONEELSE"),
|
|
343
|
+
user_id: "@alice:example.com",
|
|
344
|
+
device_id: "JLAFKJWSCS",
|
|
345
|
+
verifier: verifier,
|
|
346
|
+
).should == false
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it "rejects a document that claims a different user" do
|
|
350
|
+
Protocol::Matrix::Keys.valid_device_keys?(
|
|
351
|
+
device_keys(user_id: "@mallory:example.com"),
|
|
352
|
+
user_id: "@alice:example.com",
|
|
353
|
+
device_id: "JLAFKJWSCS",
|
|
354
|
+
verifier: verifier,
|
|
355
|
+
).should == false
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it "rejects a document with no fingerprint key to verify against" do
|
|
359
|
+
keys = device_keys
|
|
360
|
+
keys["keys"].delete("ed25519:JLAFKJWSCS")
|
|
361
|
+
|
|
362
|
+
Protocol::Matrix::Keys.valid_device_keys?(
|
|
363
|
+
keys,
|
|
364
|
+
user_id: "@alice:example.com",
|
|
365
|
+
device_id: "JLAFKJWSCS",
|
|
366
|
+
verifier: verifier,
|
|
367
|
+
).should == false
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it "rejects an unsigned document rather than raising" do
|
|
371
|
+
keys = device_keys
|
|
372
|
+
keys.delete("signatures")
|
|
373
|
+
|
|
374
|
+
Protocol::Matrix::Keys.valid_device_keys?(
|
|
375
|
+
keys,
|
|
376
|
+
user_id: "@alice:example.com",
|
|
377
|
+
device_id: "JLAFKJWSCS",
|
|
378
|
+
verifier: verifier,
|
|
379
|
+
).should == false
|
|
380
|
+
end
|
|
381
|
+
end
|