async-matrix 3.0.0-aarch64-linux → 3.0.1-aarch64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b5019a1e531cc2e4130955f983b0ab97858020be746cb05ed7c2fd0fdfa18d4
|
|
4
|
+
data.tar.gz: b9a60430770fc16598f4e7d8b9b02548f74b34804b3b077b61e534c1202df0f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a59e748998b8f5a4629204ebec300ad78867953356517addcd22595762abff097d047c4265066bb8967a5604d2b3902f2da5a6a1164ee90b2218500fb6a4397
|
|
7
|
+
data.tar.gz: 309cc2fcd6ea442563e744d385fd0f1f4ce487bfc91c54e0e254ac643e2874eae46f85f1f3dcf540fdeb8257c4ec381ed7fccd3cfacf773a9acab59f532f5982
|
|
@@ -0,0 +1,213 @@
|
|
|
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 "client"
|
|
7
|
+
|
|
8
|
+
module Async
|
|
9
|
+
module Matrix
|
|
10
|
+
# A Client that acts AS one of the appservice's users, on one of that user's
|
|
11
|
+
# devices.
|
|
12
|
+
#
|
|
13
|
+
# client = AppServiceClient.new(config, user_id: "@ada:example.org", device_id: "ABCDEFGHIJ")
|
|
14
|
+
# client.upload_keys(device_keys: ...) # uploaded for Ada's device, not the bot's
|
|
15
|
+
#
|
|
16
|
+
# This is double puppeting, and it is the officially intended mechanism
|
|
17
|
+
# rather than a trick. Identity assertion -- `?user_id=` -- has been in the
|
|
18
|
+
# application service spec for years; [MSC4326] added `?device_id=` beside
|
|
19
|
+
# it and is MERGED, so the plain parameter names below are stable spec, not
|
|
20
|
+
# the unstable `org.matrix.msc3202.device_id` an older implementation would
|
|
21
|
+
# have sent.
|
|
22
|
+
#
|
|
23
|
+
# WHY NOT A TOKEN. Bridges used to get a per-user access token by calling
|
|
24
|
+
# /login with `m.login.application_service` ([MSC2778]), and that route is
|
|
25
|
+
# gone on a homeserver fronted by OAuth2 -- it answers
|
|
26
|
+
# M_APPSERVICE_LOGIN_UNSUPPORTED. [MSC4190], which is also merged, replaced
|
|
27
|
+
# it: an appservice creates devices directly (`PUT /devices/{deviceId}`) and
|
|
28
|
+
# then acts as them with these two parameters. No token is ever issued, so
|
|
29
|
+
# none can expire or need refreshing.
|
|
30
|
+
#
|
|
31
|
+
# IT STILL WORKS BEHIND MAS. Synapse checks an as_token against its own
|
|
32
|
+
# appservice registry before it introspects anything at the authentication
|
|
33
|
+
# service, so masquerading is unaffected by next-generation auth.
|
|
34
|
+
#
|
|
35
|
+
# THE USER MUST BE IN THE REGISTRATION'S NAMESPACE. The homeserver refuses
|
|
36
|
+
# otherwise -- which is why a double-puppet registration claims a wide user
|
|
37
|
+
# namespace non-exclusively rather than naming individuals.
|
|
38
|
+
#
|
|
39
|
+
# [MSC4326]: https://github.com/matrix-org/matrix-spec-proposals/pull/4326
|
|
40
|
+
# [MSC4190]: https://github.com/matrix-org/matrix-spec-proposals/pull/4190
|
|
41
|
+
# [MSC2778]: https://github.com/matrix-org/matrix-spec-proposals/pull/2778
|
|
42
|
+
class AppServiceClient < Client
|
|
43
|
+
# @parameter user_id [String] the user to act as. Must match the
|
|
44
|
+
# registration's user namespace.
|
|
45
|
+
# @parameter device_id [String] the device to act as.
|
|
46
|
+
#
|
|
47
|
+
# WITHOUT IT THE REQUEST HAS NO DEVICE, and an appservice request with
|
|
48
|
+
# no device cannot upload one-time keys, claim keys or send to-device
|
|
49
|
+
# messages -- every call encryption is made of. It is optional only
|
|
50
|
+
# because the device has to be CREATED before it can be acted as, and
|
|
51
|
+
# that one call is made without it.
|
|
52
|
+
def initialize(config, user_id:, device_id: nil, **options)
|
|
53
|
+
super(config, **options)
|
|
54
|
+
|
|
55
|
+
@user_id = user_id
|
|
56
|
+
@device_id = device_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :user_id, :device_id
|
|
60
|
+
|
|
61
|
+
# NAMED ON EVERY REQUEST, not only the ones that obviously need it. An
|
|
62
|
+
# appservice request that omits them is not an error: it silently acts as
|
|
63
|
+
# the registration's sender_localpart, with no device, which is far worse
|
|
64
|
+
# than a failure because it succeeds.
|
|
65
|
+
def default_query
|
|
66
|
+
{user_id: @user_id}.tap do |query|
|
|
67
|
+
if @device_id
|
|
68
|
+
query[:device_id] = @device_id
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A sibling client for another user or device, sharing this one's config
|
|
74
|
+
# and retry policy.
|
|
75
|
+
#
|
|
76
|
+
# One appservice commonly acts for many users, and the alternative --
|
|
77
|
+
# mutating user_id on a single client -- races itself the moment two
|
|
78
|
+
# fibers use it, which is exactly what an async bridge does.
|
|
79
|
+
def as(user_id:, device_id: nil)
|
|
80
|
+
self.class.new(@config, user_id: user_id, device_id: device_id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The same user, now on a device: what you call once the device exists.
|
|
84
|
+
def with_device(device_id)
|
|
85
|
+
as(user_id: @user_id, device_id: device_id)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
__END__
|
|
92
|
+
describe "Async::Matrix::AppServiceClient" do
|
|
93
|
+
def config
|
|
94
|
+
Async::Matrix::Config.new({
|
|
95
|
+
"homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
|
|
96
|
+
"appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
|
|
97
|
+
})
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def recording_client(user_id: "@ada:example.org", device_id: "ABCDEFGHIJ")
|
|
101
|
+
Async::Matrix::Api.reset!
|
|
102
|
+
|
|
103
|
+
client = Async::Matrix::AppServiceClient.new(config, user_id: user_id, device_id: device_id)
|
|
104
|
+
calls = []
|
|
105
|
+
client.define_singleton_method(:calls) { calls }
|
|
106
|
+
client.define_singleton_method(:internet) do
|
|
107
|
+
internet = Object.new
|
|
108
|
+
internet.define_singleton_method(:call) do |method, url, _headers, _body|
|
|
109
|
+
calls << [method, url]
|
|
110
|
+
|
|
111
|
+
# Shaped like what Client#read_limited consumes: a body that yields
|
|
112
|
+
# chunks and reports its own length.
|
|
113
|
+
body = Object.new
|
|
114
|
+
body.define_singleton_method(:length) { 2 }
|
|
115
|
+
body.define_singleton_method(:each) { |&block| block.call("{}") }
|
|
116
|
+
body.define_singleton_method(:close) { nil }
|
|
117
|
+
|
|
118
|
+
response = Object.new
|
|
119
|
+
response.define_singleton_method(:status) { 200 }
|
|
120
|
+
response.define_singleton_method(:body) { body }
|
|
121
|
+
response.define_singleton_method(:close) { nil }
|
|
122
|
+
response
|
|
123
|
+
end
|
|
124
|
+
internet
|
|
125
|
+
end
|
|
126
|
+
client
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def last_url(client) = client.calls.last[1]
|
|
130
|
+
|
|
131
|
+
it "asserts the user and device on every request" do
|
|
132
|
+
client = recording_client
|
|
133
|
+
client.whoami
|
|
134
|
+
|
|
135
|
+
last_url(client).should ==
|
|
136
|
+
"http://synapse:8008/_matrix/client/v3/account/whoami" \
|
|
137
|
+
"?user_id=%40ada%3Aexample.org&device_id=ABCDEFGHIJ"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Not only on the calls that obviously need it: an appservice request that
|
|
141
|
+
# omits them silently acts as the sender_localpart, with no device.
|
|
142
|
+
it "asserts them through the api chain too" do
|
|
143
|
+
client = recording_client
|
|
144
|
+
client.joined_members(room_id: "!ops:example.org")
|
|
145
|
+
|
|
146
|
+
last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
|
|
147
|
+
last_url(client).should.be.include? "device_id=ABCDEFGHIJ"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The chain appends its own query first, so this is the case that catches a
|
|
151
|
+
# naive "?" concatenation.
|
|
152
|
+
it "appends to a path that already carries a query" do
|
|
153
|
+
client = recording_client
|
|
154
|
+
client.messages(room_id: "!ops:example.org", limit: 10)
|
|
155
|
+
|
|
156
|
+
last_url(client).should.be.include? "?dir=b&limit=10&"
|
|
157
|
+
last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
|
|
158
|
+
last_url(client).count("?").should == 1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The device has to be created before it can be acted as, and that one call
|
|
162
|
+
# is made without a device.
|
|
163
|
+
it "omits the device when it has none" do
|
|
164
|
+
client = recording_client(device_id: nil)
|
|
165
|
+
client.whoami
|
|
166
|
+
|
|
167
|
+
last_url(client).should.be.include? "user_id=%40ada%3Aexample.org"
|
|
168
|
+
last_url(client).should.not.be.include? "device_id"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Overwriting it would mean acting as somebody other than the caller asked
|
|
172
|
+
# for, which is worse than a failure.
|
|
173
|
+
it "lets a caller's own parameter win" do
|
|
174
|
+
client = recording_client
|
|
175
|
+
client.get("/_matrix/client/v3/account/whoami?user_id=%40bob%3Aexample.org")
|
|
176
|
+
|
|
177
|
+
last_url(client).should.be.include? "user_id=%40bob%3Aexample.org"
|
|
178
|
+
last_url(client).scan("user_id=").length.should == 1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "encodes the punctuation in a user id" do
|
|
182
|
+
client = recording_client(user_id: "@ada:example.org")
|
|
183
|
+
client.whoami
|
|
184
|
+
|
|
185
|
+
last_url(client).should.be.include? "%40ada%3Aexample.org"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# One appservice acts for many users; mutating a shared client would race
|
|
189
|
+
# itself the moment two fibers used it.
|
|
190
|
+
it "makes a sibling client for another user" do
|
|
191
|
+
client = recording_client
|
|
192
|
+
other = client.as(user_id: "@bob:example.org", device_id: "OTHERDEV")
|
|
193
|
+
|
|
194
|
+
other.user_id.should == "@bob:example.org"
|
|
195
|
+
other.device_id.should == "OTHERDEV"
|
|
196
|
+
client.user_id.should == "@ada:example.org"
|
|
197
|
+
other.config.should.be == client.config
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "makes a client for the same user on a device" do
|
|
201
|
+
client = recording_client(device_id: nil)
|
|
202
|
+
with_device = client.with_device("NEWDEVICE")
|
|
203
|
+
|
|
204
|
+
with_device.user_id.should == "@ada:example.org"
|
|
205
|
+
with_device.device_id.should == "NEWDEVICE"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# An ordinary client asserts nothing: a user's own token already says who
|
|
209
|
+
# the request is for.
|
|
210
|
+
it "is the only client that asserts anything" do
|
|
211
|
+
Async::Matrix::Client.new(config).default_query.should == {}
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,413 @@
|
|
|
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 "securerandom"
|
|
7
|
+
|
|
8
|
+
module Async
|
|
9
|
+
module Matrix
|
|
10
|
+
class Client
|
|
11
|
+
# The endpoints that make a device exist, be reachable, and be able to
|
|
12
|
+
# share keys. Mixed into Client.
|
|
13
|
+
#
|
|
14
|
+
# ORDER MATTERS, and it is the one thing a reader needs from this file:
|
|
15
|
+
#
|
|
16
|
+
# register_user the account exists (appservices only)
|
|
17
|
+
# create_device the device exists, without /login (MSC4190)
|
|
18
|
+
# upload_keys other devices can now find and reach us
|
|
19
|
+
# query_keys we learn who else is in the room, and their keys
|
|
20
|
+
# claim_keys we take a key to open a session with one of them
|
|
21
|
+
# send_to_device we hand them a room key through that session
|
|
22
|
+
#
|
|
23
|
+
# Until `upload_keys` has run the device is invisible: nobody can claim a
|
|
24
|
+
# one-time key, so nobody can open an Olm session, so nobody can send us a
|
|
25
|
+
# room key, and every message in an encrypted room stays ciphertext. It is
|
|
26
|
+
# a precondition, not an optimisation.
|
|
27
|
+
#
|
|
28
|
+
# Every method routes through #api, so the path is validated against the
|
|
29
|
+
# vendored OpenAPI tree before a request is made -- a typo is an
|
|
30
|
+
# InvalidEndpointError here rather than a 404 from the homeserver.
|
|
31
|
+
module Encryption
|
|
32
|
+
# ── Publishing our own keys ───────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# POST /keys/upload. Any combination of the three may be sent; a nil is
|
|
35
|
+
# omitted rather than sent as null.
|
|
36
|
+
#
|
|
37
|
+
# @parameter device_keys [Hash] from Protocol::Matrix::Keys.device_keys
|
|
38
|
+
# @parameter one_time_keys [Hash] from Keys.one_time_keys
|
|
39
|
+
# @parameter fallback_keys [Hash] from Keys.fallback_keys
|
|
40
|
+
# @returns [Hash] with `one_time_key_counts`, which is what tells us how
|
|
41
|
+
# many the server now holds.
|
|
42
|
+
def upload_keys(device_keys: nil, one_time_keys: nil, fallback_keys: nil)
|
|
43
|
+
body = {}
|
|
44
|
+
|
|
45
|
+
if device_keys
|
|
46
|
+
body[:device_keys] = device_keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unless one_time_keys.nil? || one_time_keys.empty?
|
|
50
|
+
body[:one_time_keys] = one_time_keys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless fallback_keys.nil? || fallback_keys.empty?
|
|
54
|
+
body[:fallback_keys] = fallback_keys
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
api.keys.upload.post(body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# POST /keys/device_signing/upload -- the cross-signing keys.
|
|
61
|
+
#
|
|
62
|
+
# MSC4190 removed the user-interactive auth requirement here for
|
|
63
|
+
# appservices, which is what makes this reachable without a password.
|
|
64
|
+
# It matters because MSC4153 lets senders refuse to share room keys with
|
|
65
|
+
# a device that is not cross-signed.
|
|
66
|
+
def upload_cross_signing_keys(master_key: nil, self_signing_key: nil, user_signing_key: nil)
|
|
67
|
+
body = {}
|
|
68
|
+
|
|
69
|
+
if master_key
|
|
70
|
+
body[:master_key] = master_key
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if self_signing_key
|
|
74
|
+
body[:self_signing_key] = self_signing_key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if user_signing_key
|
|
78
|
+
body[:user_signing_key] = user_signing_key
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
api.keys.device_signing.upload.post(body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# POST /keys/signatures/upload -- our signatures over other keys.
|
|
85
|
+
def upload_signatures(signatures)
|
|
86
|
+
api.keys.signatures.upload.post(signatures)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Learning about other devices ──────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
# POST /keys/query. +user_ids+ is who to ask about; an empty device list
|
|
92
|
+
# per user means "all of their devices".
|
|
93
|
+
#
|
|
94
|
+
# @parameter token [String] the `device_lists` sync token, so the server
|
|
95
|
+
# can tell us whether our view is already current.
|
|
96
|
+
def query_keys(user_ids:, token: nil)
|
|
97
|
+
body = {device_keys: user_ids.to_h { |user_id| [user_id, []] }}
|
|
98
|
+
|
|
99
|
+
if token
|
|
100
|
+
body[:token] = token
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
api.keys.query.post(body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# POST /keys/claim -- take one one-time key per device, to open a
|
|
107
|
+
# session.
|
|
108
|
+
#
|
|
109
|
+
# ONLY FOR DEVICES WE HAVE NO SESSION WITH. Each claim consumes a key
|
|
110
|
+
# from a finite pool, so claiming for a device we can already reach
|
|
111
|
+
# burns one for nothing.
|
|
112
|
+
#
|
|
113
|
+
# @parameter devices [Hash] { "@user:server" => ["DEVICEID", ...] }
|
|
114
|
+
def claim_keys(devices:, algorithm: Protocol::Matrix::Keys::SIGNED_CURVE25519, timeout: nil)
|
|
115
|
+
body = {
|
|
116
|
+
one_time_keys: devices.transform_values { |ids|
|
|
117
|
+
ids.to_h { |device_id| [device_id, algorithm] }
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if timeout
|
|
122
|
+
body[:timeout] = timeout
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
api.keys.claim.post(body)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Talking to devices directly ───────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
# PUT /sendToDevice/{eventType}/{txnId}
|
|
131
|
+
#
|
|
132
|
+
# @parameter messages [Hash] { "@user:server" => { "DEVICEID" => content } }
|
|
133
|
+
#
|
|
134
|
+
# This is how a room key travels. The transaction id makes it
|
|
135
|
+
# idempotent, so a retry after a timeout cannot deliver twice.
|
|
136
|
+
def send_to_device(event_type:, messages:, txn_id: nil)
|
|
137
|
+
api.sendToDevice(event_type, txn_id || SecureRandom.uuid).put({messages: messages})
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ── Device lifecycle ──────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
# PUT /devices/{deviceId} -- MSC4190.
|
|
143
|
+
#
|
|
144
|
+
# THE REASON THIS EXISTS: appservices used to create devices by calling
|
|
145
|
+
# /login with `m.login.application_service`, and that route is gone on a
|
|
146
|
+
# homeserver fronted by OAuth2 (MAS). This endpoint creates the device
|
|
147
|
+
# with no login at all, answering 201 for a new one and 200 for one that
|
|
148
|
+
# already existed.
|
|
149
|
+
#
|
|
150
|
+
# The device id is OURS to choose and must never change afterwards: it
|
|
151
|
+
# is the identity every room key we hold is bound to.
|
|
152
|
+
def create_device(device_id:, display_name: nil)
|
|
153
|
+
body = {}
|
|
154
|
+
|
|
155
|
+
if display_name
|
|
156
|
+
body[:display_name] = display_name
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
api.devices(device_id).put(body)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# DELETE /devices/{deviceId}. MSC4190 removed the UIA requirement for
|
|
163
|
+
# appservices, which is what makes this callable unattended.
|
|
164
|
+
def delete_device(device_id:)
|
|
165
|
+
api.devices(device_id).delete
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def devices = api.devices.get
|
|
169
|
+
|
|
170
|
+
def device(device_id:) = api.devices(device_id).get
|
|
171
|
+
|
|
172
|
+
# POST /register for an appservice-owned user.
|
|
173
|
+
#
|
|
174
|
+
# `inhibit_login` IS MANDATORY, not tidiness: honouring a login would
|
|
175
|
+
# mean issuing an access token, and under OAuth2 the homeserver no
|
|
176
|
+
# longer owns that -- so without it the call fails with
|
|
177
|
+
# M_APPSERVICE_LOGIN_UNSUPPORTED.
|
|
178
|
+
def register_user(username:)
|
|
179
|
+
api.register.post(
|
|
180
|
+
{
|
|
181
|
+
type: "m.login.application_service",
|
|
182
|
+
username: username,
|
|
183
|
+
inhibit_login: true,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# ── Key backup ────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
# GET /room_keys/version[/{version}] -- the backup's algorithm and
|
|
191
|
+
# auth_data, which carries the public key it was encrypted to.
|
|
192
|
+
def key_backup_version(version: nil)
|
|
193
|
+
if version
|
|
194
|
+
api.room_keys.version(version).get
|
|
195
|
+
else
|
|
196
|
+
api.room_keys.version.get
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# GET /room_keys/keys -- every backed-up session, as
|
|
201
|
+
# rooms -> sessions -> session_data blobs for
|
|
202
|
+
# Protocol::Matrix::KeyBackup to decrypt.
|
|
203
|
+
def room_keys(version:)
|
|
204
|
+
# Plain keys, not the "?"-prefixed form: that convention exists to
|
|
205
|
+
# separate query params from a BODY on POST/PUT, and a GET has no
|
|
206
|
+
# body -- every kwarg is already a query parameter.
|
|
207
|
+
api.room_keys.keys.get(version: version)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
__END__
|
|
215
|
+
describe "Async::Matrix::Client::Encryption" do
|
|
216
|
+
# A real Client with its transport stubbed, so these specs exercise the
|
|
217
|
+
# actual path construction AND the OpenAPI path-tree validation -- a wrong
|
|
218
|
+
# path fails here rather than as a 404 from a homeserver.
|
|
219
|
+
def recording_client(response = {})
|
|
220
|
+
# The REAL path tree. Api memoises it process-wide, and the Api specs
|
|
221
|
+
# inject a small fixture tree without restoring it (Api.reset! exists for
|
|
222
|
+
# that and goes uncalled), so without this these specs pass or fail
|
|
223
|
+
# according to the order scampi happens to load files in.
|
|
224
|
+
Async::Matrix::Api.reset!
|
|
225
|
+
|
|
226
|
+
config = Async::Matrix::Config.new({
|
|
227
|
+
"homeserver" => {"address" => "http://synapse:8008", "domain" => "example.org"},
|
|
228
|
+
"appservice" => {"as_token" => "as", "hs_token" => "hs", "bot" => {"username" => "bot"}},
|
|
229
|
+
})
|
|
230
|
+
client = Async::Matrix::Client.new(config)
|
|
231
|
+
calls = []
|
|
232
|
+
client.define_singleton_method(:calls) { calls }
|
|
233
|
+
client.define_singleton_method(:request) do |method, path, body = nil, **_options|
|
|
234
|
+
calls << [method, path, body]
|
|
235
|
+
response
|
|
236
|
+
end
|
|
237
|
+
client
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def last(client) = client.calls.last
|
|
241
|
+
|
|
242
|
+
# ── Publishing ────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
it "uploads device keys" do
|
|
245
|
+
client = recording_client
|
|
246
|
+
client.upload_keys(device_keys: {"user_id" => "@bot:example.org"})
|
|
247
|
+
|
|
248
|
+
last(client)[0].should == "POST"
|
|
249
|
+
last(client)[1].should == "/_matrix/client/v3/keys/upload"
|
|
250
|
+
last(client)[2].should == {device_keys: {"user_id" => "@bot:example.org"}}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "uploads all three kinds of key together" do
|
|
254
|
+
client = recording_client
|
|
255
|
+
client.upload_keys(
|
|
256
|
+
device_keys: {"a" => 1},
|
|
257
|
+
one_time_keys: {"signed_curve25519:k" => {"key" => "k"}},
|
|
258
|
+
fallback_keys: {"signed_curve25519:f" => {"key" => "f", "fallback" => true}},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
last(client)[2].keys.sort.should == [:device_keys, :fallback_keys, :one_time_keys]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A nil is omitted rather than sent as null -- "May be absent if no new
|
|
265
|
+
# one-time keys are required".
|
|
266
|
+
it "omits what it was not given" do
|
|
267
|
+
client = recording_client
|
|
268
|
+
client.upload_keys(one_time_keys: {})
|
|
269
|
+
|
|
270
|
+
last(client)[2].should == {}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
it "uploads cross-signing keys" do
|
|
274
|
+
client = recording_client
|
|
275
|
+
client.upload_cross_signing_keys(master_key: {"keys" => {}})
|
|
276
|
+
|
|
277
|
+
last(client)[1].should == "/_matrix/client/v3/keys/device_signing/upload"
|
|
278
|
+
last(client)[2].should == {master_key: {"keys" => {}}}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "uploads signatures" do
|
|
282
|
+
client = recording_client
|
|
283
|
+
client.upload_signatures({"@bot:example.org" => {"ed25519:DEV" => {}}})
|
|
284
|
+
|
|
285
|
+
last(client)[1].should == "/_matrix/client/v3/keys/signatures/upload"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# ── Querying ──────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
# An empty device list per user means "all of their devices".
|
|
291
|
+
it "queries keys for a set of users" do
|
|
292
|
+
client = recording_client
|
|
293
|
+
client.query_keys(user_ids: ["@ada:example.org", "@bob:example.org"])
|
|
294
|
+
|
|
295
|
+
last(client)[1].should == "/_matrix/client/v3/keys/query"
|
|
296
|
+
last(client)[2].should == {
|
|
297
|
+
device_keys: {"@ada:example.org" => [], "@bob:example.org" => []},
|
|
298
|
+
}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "passes the device_lists token when it has one" do
|
|
302
|
+
client = recording_client
|
|
303
|
+
client.query_keys(user_ids: ["@ada:example.org"], token: "s72")
|
|
304
|
+
|
|
305
|
+
last(client)[2][:token].should == "s72"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "claims one key per device, naming the algorithm" do
|
|
309
|
+
client = recording_client
|
|
310
|
+
client.claim_keys(devices: {"@ada:example.org" => ["DEV1", "DEV2"]})
|
|
311
|
+
|
|
312
|
+
last(client)[1].should == "/_matrix/client/v3/keys/claim"
|
|
313
|
+
last(client)[2].should == {
|
|
314
|
+
one_time_keys: {
|
|
315
|
+
"@ada:example.org" => {
|
|
316
|
+
"DEV1" => "signed_curve25519",
|
|
317
|
+
"DEV2" => "signed_curve25519",
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# ── To-device ─────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
it "sends to-device messages with a transaction id" do
|
|
326
|
+
client = recording_client
|
|
327
|
+
client.send_to_device(
|
|
328
|
+
event_type: "m.room.encrypted",
|
|
329
|
+
messages: {"@ada:example.org" => {"DEV1" => {"algorithm" => "m.olm.v1.curve25519-aes-sha2"}}},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
last(client)[0].should == "PUT"
|
|
333
|
+
last(client)[1].should.be.start_with? "/_matrix/client/v3/sendToDevice/m.room.encrypted/"
|
|
334
|
+
last(client)[2].should == {
|
|
335
|
+
messages: {"@ada:example.org" => {"DEV1" => {"algorithm" => "m.olm.v1.curve25519-aes-sha2"}}},
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Idempotence: a retry after a timeout must not deliver twice.
|
|
340
|
+
it "lets the caller supply the transaction id" do
|
|
341
|
+
client = recording_client
|
|
342
|
+
client.send_to_device(event_type: "m.room.encrypted", messages: {}, txn_id: "txn1")
|
|
343
|
+
|
|
344
|
+
last(client)[1].should == "/_matrix/client/v3/sendToDevice/m.room.encrypted/txn1"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "generates a different transaction id each time" do
|
|
348
|
+
client = recording_client
|
|
349
|
+
client.send_to_device(event_type: "m.room.encrypted", messages: {})
|
|
350
|
+
client.send_to_device(event_type: "m.room.encrypted", messages: {})
|
|
351
|
+
|
|
352
|
+
client.calls[0][1].should.not == client.calls[1][1]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# ── Devices ───────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
# MSC4190: creates the device with no /login, which is the only way under
|
|
358
|
+
# OAuth2.
|
|
359
|
+
it "creates a device" do
|
|
360
|
+
client = recording_client
|
|
361
|
+
client.create_device(device_id: "ABCDEFGHIJ", display_name: "controller")
|
|
362
|
+
|
|
363
|
+
last(client)[0].should == "PUT"
|
|
364
|
+
last(client)[1].should == "/_matrix/client/v3/devices/ABCDEFGHIJ"
|
|
365
|
+
last(client)[2].should == {display_name: "controller"}
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it "deletes a device" do
|
|
369
|
+
client = recording_client
|
|
370
|
+
client.delete_device(device_id: "ABCDEFGHIJ")
|
|
371
|
+
|
|
372
|
+
last(client)[0].should == "DELETE"
|
|
373
|
+
last(client)[1].should == "/_matrix/client/v3/devices/ABCDEFGHIJ"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# inhibit_login is mandatory under OAuth2: honouring a login would mean
|
|
377
|
+
# issuing an access token the homeserver no longer owns.
|
|
378
|
+
it "registers an appservice user without logging it in" do
|
|
379
|
+
client = recording_client
|
|
380
|
+
client.register_user(username: "controller")
|
|
381
|
+
|
|
382
|
+
last(client)[1].should == "/_matrix/client/v3/register"
|
|
383
|
+
last(client)[2].should == {
|
|
384
|
+
type: "m.login.application_service",
|
|
385
|
+
username: "controller",
|
|
386
|
+
inhibit_login: true,
|
|
387
|
+
}
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# ── Key backup ────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
it "reads the current backup version" do
|
|
393
|
+
client = recording_client
|
|
394
|
+
client.key_backup_version
|
|
395
|
+
|
|
396
|
+
last(client)[0].should == "GET"
|
|
397
|
+
last(client)[1].should == "/_matrix/client/v3/room_keys/version"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
it "reads a specific backup version" do
|
|
401
|
+
client = recording_client
|
|
402
|
+
client.key_backup_version(version: "3")
|
|
403
|
+
|
|
404
|
+
last(client)[1].should == "/_matrix/client/v3/room_keys/version/3"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it "fetches the backed-up keys for a version" do
|
|
408
|
+
client = recording_client
|
|
409
|
+
client.room_keys(version: "3")
|
|
410
|
+
|
|
411
|
+
last(client)[1].should == "/_matrix/client/v3/room_keys/keys?version=3"
|
|
412
|
+
end
|
|
413
|
+
end
|