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.
- 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,278 @@
|
|
|
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 "canonical_json"
|
|
7
|
+
require_relative "error"
|
|
8
|
+
|
|
9
|
+
module Protocol
|
|
10
|
+
module Matrix
|
|
11
|
+
# Signing JSON, per https://spec.matrix.org/latest/appendices/#signing-json
|
|
12
|
+
#
|
|
13
|
+
# The procedure, in full:
|
|
14
|
+
#
|
|
15
|
+
# 1. remove `signatures` and `unsigned`
|
|
16
|
+
# 2. encode what is left as canonical JSON
|
|
17
|
+
# 3. sign those bytes with ed25519
|
|
18
|
+
# 4. encode the signature as UNPADDED base64
|
|
19
|
+
# 5. store it under signatures[entity]["<algorithm>:<key_id>"]
|
|
20
|
+
#
|
|
21
|
+
# THE KEY MATERIAL IS INJECTED. `signer` is anything answering
|
|
22
|
+
# `sign(String) -> base64`, and `verifier` anything answering
|
|
23
|
+
# `verify_signature(key, message, signature) -> bool` — which is the shape
|
|
24
|
+
# the native E2EE module already exposes. So this file implements the
|
|
25
|
+
# procedure and holds no keys, which is what lets it sit in the protocol
|
|
26
|
+
# layer and be tested without cryptography.
|
|
27
|
+
module Signing
|
|
28
|
+
ED25519 = "ed25519"
|
|
29
|
+
|
|
30
|
+
class Error < Protocol::Matrix::Error; end
|
|
31
|
+
|
|
32
|
+
# No signature from the entity and key we were told to check.
|
|
33
|
+
class MissingSignatureError < Error; end
|
|
34
|
+
|
|
35
|
+
# "{algorithm}:{key_id}" — for a device key the key_id is the device id.
|
|
36
|
+
def self.key_id(name, algorithm: ED25519)
|
|
37
|
+
"#{algorithm}:#{name}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sign +object+ as +user_id+ with +key_id+.
|
|
41
|
+
#
|
|
42
|
+
# @returns [Hash] a copy of +object+ with the signature added. EXISTING
|
|
43
|
+
# SIGNATURES ARE PRESERVED: a device-keys object legitimately carries
|
|
44
|
+
# several (our device key, our self-signing key, another user's
|
|
45
|
+
# attestation), and replacing the map rather than merging into it would
|
|
46
|
+
# silently discard them.
|
|
47
|
+
def self.sign(object, signer:, user_id:, key_id:)
|
|
48
|
+
signature = sign_bytes(CanonicalJson.signable_bytes(object), signer: signer)
|
|
49
|
+
existing = object["signatures"] || object[:signatures] || {}
|
|
50
|
+
mine = existing[user_id] || {}
|
|
51
|
+
|
|
52
|
+
object.merge(
|
|
53
|
+
"signatures" => existing.merge(user_id => mine.merge(key_id => signature)),
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sign the given bytes, returning unpadded base64.
|
|
58
|
+
#
|
|
59
|
+
# PADDING IS STRIPPED HERE rather than assumed absent. The spec requires
|
|
60
|
+
# unpadded base64, and whether a given primitive emits padding is its own
|
|
61
|
+
# business; a stray "=" would make every signature we produce fail
|
|
62
|
+
# verification everywhere, which is an expensive thing to discover
|
|
63
|
+
# remotely.
|
|
64
|
+
def self.sign_bytes(message, signer:)
|
|
65
|
+
signer.sign(message).delete_suffix("==").delete_suffix("=")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Is +object+ correctly signed by +user_id+ with +key_id+?
|
|
69
|
+
#
|
|
70
|
+
# @parameter key [String] the public ed25519 key to verify against.
|
|
71
|
+
# @parameter verifier [Object] answers
|
|
72
|
+
# `verify_signature(key, message, signature) -> bool`.
|
|
73
|
+
# @raises [MissingSignatureError] when there is no such signature to check
|
|
74
|
+
# — distinct from a signature that is present and wrong, because the two
|
|
75
|
+
# mean different things about the sender.
|
|
76
|
+
def self.verify(object, key:, verifier:, user_id:, key_id:)
|
|
77
|
+
signature = signature_for(object, user_id: user_id, key_id: key_id)
|
|
78
|
+
|
|
79
|
+
if signature.nil?
|
|
80
|
+
raise MissingSignatureError, "no #{key_id} signature from #{user_id}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
verifier.verify_signature(key, CanonicalJson.signable_bytes(object), signature)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The signature +user_id+ made with +key_id+, or nil.
|
|
87
|
+
def self.signature_for(object, user_id:, key_id:)
|
|
88
|
+
signatures = object["signatures"] || object[:signatures] || {}
|
|
89
|
+
(signatures[user_id] || {})[key_id]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.signed_by?(object, user_id:, key_id:)
|
|
93
|
+
!signature_for(object, user_id: user_id, key_id: key_id).nil?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
__END__
|
|
100
|
+
describe "Protocol::Matrix::Signing" do
|
|
101
|
+
# A signer that records what it was asked to sign, so a spec can assert on
|
|
102
|
+
# the exact bytes the signature covers.
|
|
103
|
+
def recording_signer(signature = "SIGNATURE")
|
|
104
|
+
signer = Object.new
|
|
105
|
+
signed = []
|
|
106
|
+
signer.define_singleton_method(:signed) { signed }
|
|
107
|
+
signer.define_singleton_method(:sign) do |message|
|
|
108
|
+
signed << message
|
|
109
|
+
signature
|
|
110
|
+
end
|
|
111
|
+
signer
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def verifier(result = true)
|
|
115
|
+
verifier = Object.new
|
|
116
|
+
calls = []
|
|
117
|
+
verifier.define_singleton_method(:calls) { calls }
|
|
118
|
+
verifier.define_singleton_method(:verify_signature) do |key, message, signature|
|
|
119
|
+
calls << [key, message, signature]
|
|
120
|
+
result
|
|
121
|
+
end
|
|
122
|
+
verifier
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "builds a key identifier" do
|
|
126
|
+
Protocol::Matrix::Signing.key_id("JLAFKJWSCS").should == "ed25519:JLAFKJWSCS"
|
|
127
|
+
Protocol::Matrix::Signing.key_id("1", algorithm: "ed25519").should == "ed25519:1"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "stores the signature under entity and key identifier" do
|
|
131
|
+
signed = Protocol::Matrix::Signing.sign(
|
|
132
|
+
{"a" => 1},
|
|
133
|
+
signer: recording_signer,
|
|
134
|
+
user_id: "@alice:example.com",
|
|
135
|
+
key_id: "ed25519:DEV",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
signed["signatures"].should == {"@alice:example.com" => {"ed25519:DEV" => "SIGNATURE"}}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The signature covers canonical JSON of the object MINUS signatures and
|
|
142
|
+
# unsigned.
|
|
143
|
+
it "signs the canonical form, excluding signatures and unsigned" do
|
|
144
|
+
signer = recording_signer
|
|
145
|
+
Protocol::Matrix::Signing.sign(
|
|
146
|
+
{"b" => 2, "a" => 1, "unsigned" => {"age" => 1}},
|
|
147
|
+
signer: signer,
|
|
148
|
+
user_id: "@alice:example.com",
|
|
149
|
+
key_id: "ed25519:DEV",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
signer.signed.should == ['{"a":1,"b":2}']
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A device-keys object legitimately carries several signatures; replacing the
|
|
156
|
+
# map rather than merging into it would discard them.
|
|
157
|
+
it "preserves signatures already present" do
|
|
158
|
+
object = {
|
|
159
|
+
"a" => 1,
|
|
160
|
+
"signatures" => {
|
|
161
|
+
"@bob:example.com" => {"ed25519:OTHER" => "theirs"},
|
|
162
|
+
"@alice:example.com" => {"ed25519:SSK" => "self-signing"},
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
signed = Protocol::Matrix::Signing.sign(
|
|
166
|
+
object,
|
|
167
|
+
signer: recording_signer,
|
|
168
|
+
user_id: "@alice:example.com",
|
|
169
|
+
key_id: "ed25519:DEV",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
signed["signatures"]["@bob:example.com"].should == {"ed25519:OTHER" => "theirs"}
|
|
173
|
+
signed["signatures"]["@alice:example.com"].should == {
|
|
174
|
+
"ed25519:SSK" => "self-signing", "ed25519:DEV" => "SIGNATURE",
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "returns a copy rather than mutating its argument" do
|
|
179
|
+
object = {"a" => 1}
|
|
180
|
+
Protocol::Matrix::Signing.sign(
|
|
181
|
+
object,
|
|
182
|
+
signer: recording_signer,
|
|
183
|
+
user_id: "@alice:example.com",
|
|
184
|
+
key_id: "ed25519:DEV",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
object.key?("signatures").should == false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The spec requires UNPADDED base64. Whether a primitive emits padding is its
|
|
191
|
+
# own business; a stray "=" would fail verification everywhere.
|
|
192
|
+
it "strips base64 padding from whatever the signer returns" do
|
|
193
|
+
Protocol::Matrix::Signing.sign_bytes("msg", signer: recording_signer("abc=")).should == "abc"
|
|
194
|
+
Protocol::Matrix::Signing.sign_bytes("msg", signer: recording_signer("abc==")).should == "abc"
|
|
195
|
+
Protocol::Matrix::Signing.sign_bytes("msg", signer: recording_signer("abc")).should == "abc"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ── Verifying ─────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
it "verifies against the same bytes it would have signed" do
|
|
201
|
+
check = verifier
|
|
202
|
+
object = {
|
|
203
|
+
"b" => 2,
|
|
204
|
+
"a" => 1,
|
|
205
|
+
"signatures" => {"@alice:example.com" => {"ed25519:DEV" => "SIG"}},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
Protocol::Matrix::Signing.verify(
|
|
209
|
+
object,
|
|
210
|
+
key: "PUBKEY",
|
|
211
|
+
verifier: check,
|
|
212
|
+
user_id: "@alice:example.com",
|
|
213
|
+
key_id: "ed25519:DEV",
|
|
214
|
+
).should == true
|
|
215
|
+
|
|
216
|
+
check.calls.should == [["PUBKEY", '{"a":1,"b":2}', "SIG"]]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "reports a signature that does not verify" do
|
|
220
|
+
Protocol::Matrix::Signing.verify(
|
|
221
|
+
{"a" => 1, "signatures" => {"@alice:example.com" => {"ed25519:DEV" => "SIG"}}},
|
|
222
|
+
key: "PUBKEY",
|
|
223
|
+
verifier: verifier(false),
|
|
224
|
+
user_id: "@alice:example.com",
|
|
225
|
+
key_id: "ed25519:DEV",
|
|
226
|
+
).should == false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# "A signature that is absent" and "a signature that is wrong" say different
|
|
230
|
+
# things about the sender, so they are not the same answer.
|
|
231
|
+
it "distinguishes a missing signature from a bad one" do
|
|
232
|
+
lambda {
|
|
233
|
+
Protocol::Matrix::Signing.verify(
|
|
234
|
+
{"a" => 1},
|
|
235
|
+
key: "PUBKEY",
|
|
236
|
+
verifier: verifier,
|
|
237
|
+
user_id: "@alice:example.com",
|
|
238
|
+
key_id: "ed25519:DEV",
|
|
239
|
+
)
|
|
240
|
+
}.should.raise(Protocol::Matrix::Signing::MissingSignatureError)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "treats a signature from a different key as missing" do
|
|
244
|
+
lambda {
|
|
245
|
+
Protocol::Matrix::Signing.verify(
|
|
246
|
+
{"signatures" => {"@alice:example.com" => {"ed25519:OTHER" => "SIG"}}},
|
|
247
|
+
key: "PUBKEY",
|
|
248
|
+
verifier: verifier,
|
|
249
|
+
user_id: "@alice:example.com",
|
|
250
|
+
key_id: "ed25519:DEV",
|
|
251
|
+
)
|
|
252
|
+
}.should.raise(Protocol::Matrix::Signing::MissingSignatureError)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it "finds a signature, or reports its absence" do
|
|
256
|
+
object = {"signatures" => {"@alice:example.com" => {"ed25519:DEV" => "SIG"}}}
|
|
257
|
+
|
|
258
|
+
Protocol::Matrix::Signing.signature_for(object, user_id: "@alice:example.com", key_id: "ed25519:DEV")
|
|
259
|
+
.should == "SIG"
|
|
260
|
+
Protocol::Matrix::Signing.signed_by?(object, user_id: "@alice:example.com", key_id: "ed25519:DEV")
|
|
261
|
+
.should == true
|
|
262
|
+
Protocol::Matrix::Signing.signed_by?(object, user_id: "@bob:example.com", key_id: "ed25519:DEV")
|
|
263
|
+
.should == false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# A signature survives a round trip through canonical JSON regardless of the
|
|
267
|
+
# order the object was built in -- which is the entire purpose of the
|
|
268
|
+
# canonical encoding.
|
|
269
|
+
it "signs two differently ordered objects identically" do
|
|
270
|
+
first = recording_signer
|
|
271
|
+
second = recording_signer
|
|
272
|
+
|
|
273
|
+
Protocol::Matrix::Signing.sign({"a" => 1, "b" => 2}, signer: first, user_id: "@a:b", key_id: "ed25519:D")
|
|
274
|
+
Protocol::Matrix::Signing.sign({"b" => 2, "a" => 1}, signer: second, user_id: "@a:b", key_id: "ed25519:D")
|
|
275
|
+
|
|
276
|
+
first.signed.should == second.signed
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
# The Matrix protocol layer: message formats, event schemas, and the rules for
|
|
7
|
+
# reading them. No HTTP, no sockets, no storage.
|
|
8
|
+
#
|
|
9
|
+
# The split follows the Socketry convention that `protocol-http` and
|
|
10
|
+
# `protocol-grpc` set: a protocol library owns the FORMAT and the state machine
|
|
11
|
+
# over it, and the `async-*` library binds that to IO. So an Event, a Content, an
|
|
12
|
+
# EncryptedMessage and the schemas that validate them live here, and the Client
|
|
13
|
+
# that fetches them lives in Async::Matrix.
|
|
14
|
+
#
|
|
15
|
+
# Practically, that means everything under this namespace can be unit tested
|
|
16
|
+
# with a Hash and no network, no homeserver, and — for EncryptedMessage — no
|
|
17
|
+
# crypto either.
|
|
18
|
+
module Protocol
|
|
19
|
+
module Matrix
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Dir.glob("#{__dir__}/matrix/**/*.rb").sort.each do |path|
|
|
24
|
+
require path
|
|
25
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: async-matrix
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.
|
|
4
|
+
version: 3.0.1
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Nathan Kidd
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -437,31 +437,46 @@ files:
|
|
|
437
437
|
- lib/async/matrix/api/chain.rb
|
|
438
438
|
- lib/async/matrix/api/concat.rb
|
|
439
439
|
- lib/async/matrix/api/path_tree.rb
|
|
440
|
+
- lib/async/matrix/app_service_client.rb
|
|
440
441
|
- lib/async/matrix/auth_error.rb
|
|
441
442
|
- lib/async/matrix/bad_json_error.rb
|
|
442
443
|
- lib/async/matrix/client.rb
|
|
444
|
+
- lib/async/matrix/client/encryption.rb
|
|
445
|
+
- lib/async/matrix/client/rooms.rb
|
|
446
|
+
- lib/async/matrix/client/sync.rb
|
|
443
447
|
- lib/async/matrix/config.rb
|
|
444
448
|
- lib/async/matrix/config/vivify.rb
|
|
445
449
|
- lib/async/matrix/connection.rb
|
|
446
|
-
- lib/async/matrix/
|
|
450
|
+
- lib/async/matrix/device_store.rb
|
|
447
451
|
- lib/async/matrix/double_puppet_client.rb
|
|
448
452
|
- lib/async/matrix/e2ee.rb
|
|
453
|
+
- lib/async/matrix/e2ee/pickle_key.rb
|
|
449
454
|
- lib/async/matrix/endpoint.rb
|
|
450
455
|
- lib/async/matrix/error.rb
|
|
451
456
|
- lib/async/matrix/error_response.rb
|
|
452
|
-
- lib/async/matrix/event.rb
|
|
453
457
|
- lib/async/matrix/homeserver_error.rb
|
|
454
458
|
- lib/async/matrix/invalid_endpoint_error.rb
|
|
455
459
|
- lib/async/matrix/media_client.rb
|
|
456
460
|
- lib/async/matrix/not_found_error.rb
|
|
457
461
|
- lib/async/matrix/notifier.rb
|
|
458
462
|
- lib/async/matrix/response_too_large_error.rb
|
|
459
|
-
- lib/async/matrix/schema.rb
|
|
460
|
-
- lib/async/matrix/schema/registry.rb
|
|
461
|
-
- lib/async/matrix/schema/validation_error.rb
|
|
462
463
|
- lib/async/matrix/server.rb
|
|
463
464
|
- lib/async/matrix/stream.rb
|
|
464
465
|
- lib/async/matrix/version.rb
|
|
466
|
+
- lib/protocol/matrix.rb
|
|
467
|
+
- lib/protocol/matrix/canonical_json.rb
|
|
468
|
+
- lib/protocol/matrix/content.rb
|
|
469
|
+
- lib/protocol/matrix/encrypted_message.rb
|
|
470
|
+
- lib/protocol/matrix/error.rb
|
|
471
|
+
- lib/protocol/matrix/event.rb
|
|
472
|
+
- lib/protocol/matrix/key_backup.rb
|
|
473
|
+
- lib/protocol/matrix/keys.rb
|
|
474
|
+
- lib/protocol/matrix/message_batch.rb
|
|
475
|
+
- lib/protocol/matrix/schema.rb
|
|
476
|
+
- lib/protocol/matrix/schema/registry.rb
|
|
477
|
+
- lib/protocol/matrix/schema/validation_error.rb
|
|
478
|
+
- lib/protocol/matrix/secret_storage.rb
|
|
479
|
+
- lib/protocol/matrix/signing.rb
|
|
465
480
|
homepage: https://github.com/general-intelligence-systems/async-matrix
|
|
466
481
|
licenses:
|
|
467
482
|
- Apache-2.0
|