async-matrix 4.0.0 → 4.0.1
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/CHANGELOG.md +26 -1
- data/README.md +3 -2
- data/context/design.md +26 -0
- data/context/getting-started.md +122 -0
- data/context/index.yaml +25 -0
- data/context/overview.md +46 -0
- data/context/server-server.md +79 -0
- data/context/testing.md +49 -0
- data/lib/async/matrix/abstract/api/push_gateway/push_notifier.rb +10 -2
- data/lib/async/matrix/e2ee/backup.rb +1 -2
- data/lib/async/matrix/e2ee/cross_signing.rb +2 -2
- data/lib/async/matrix/e2ee/devices.rb +1 -2
- data/lib/async/matrix/e2ee/machine.rb +81 -20
- data/lib/async/matrix/e2ee/secret_storage.rb +8 -11
- data/lib/async/matrix/e2ee/secrets.rb +33 -15
- data/lib/async/matrix/e2ee/store.rb +1 -2
- data/lib/async/matrix/error.rb +20 -0
- data/lib/async/matrix/events/registry.rb +10 -7
- data/lib/async/matrix/representation.rb +96 -2
- data/lib/async/matrix/schema.rb +466 -0
- data/lib/async/matrix/version.rb +1 -1
- metadata +22 -2
- data/NOTICE +0 -56
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd82bb8b903732843ac2a6ddf292ff3d74a6c3e7680a61c0924ee51dd6c9ccea
|
|
4
|
+
data.tar.gz: 9b4c4a64624938601f9bd027fc015abbd556ffb6944e4ad69163e03a5f0012d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ab96f1825dc29eff57ba0f15bcd15c22a1cb135175f8187e006521aaef2e34aee9212b6c822d075ea3e95de2af40306a017c014518d71ca27ddbdfc4fff7463
|
|
7
|
+
data.tar.gz: 21d0efab729f157698a1c614459c61d6323269dbd49be17982434498e1f7773790a5ee91714fd4ab26a62d2c69aae445ca24bb1ccaae658010836d1192a418d5
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [4.0.1] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`E2EE::Machine#dump` and `E2EE::Machine.load`**, which carry the device a machine encrypts as
|
|
14
|
+
alongside its key material. `load` takes the `user_id` and `device_id` restoring the state and
|
|
15
|
+
raises `MismatchedAccountError` where they are not the ones which wrote it, so a device cannot
|
|
16
|
+
take on another's state. The implementation which wrote it is named by `crypto:`, since only the
|
|
17
|
+
caller knows which it encrypts with.
|
|
18
|
+
|
|
19
|
+
- **`Async::Matrix::MismatchedAccountError`**, carrying `#expected` and `#got` as the user and
|
|
20
|
+
device each side names. This mirrors `CryptoStoreError::MismatchedAccount` in matrix-rust-sdk,
|
|
21
|
+
where building an `OlmMachine` over a store belonging to another device is an error rather than a
|
|
22
|
+
resumption.
|
|
23
|
+
|
|
24
|
+
A device's state is its own, and so is the sync cursor which acknowledges what it has read. A
|
|
25
|
+
client which resumes one device from another's state acknowledges to-device messages this device
|
|
26
|
+
never received, and a room key lost that way is lost for good — the messages it would have read
|
|
27
|
+
stay unreadable. Refusing to restore is what makes that unrepresentable.
|
|
28
|
+
|
|
29
|
+
- **Cases for to-device delivery**, in `test/async/matrix/api/client_server/to_device.rb`: a
|
|
30
|
+
message is delivered to the device it was addressed to and not to the account's other device, and
|
|
31
|
+
it is delivered once, against the cursor of the device it was sent to.
|
|
32
|
+
|
|
9
33
|
## [4.0.0] - 2026-10-12
|
|
10
34
|
|
|
11
35
|
### Added
|
|
@@ -441,7 +465,8 @@ server-side API.
|
|
|
441
465
|
Socketry ecosystem, with schema-driven event validation, an OpenAPI-backed
|
|
442
466
|
client, media support, and mautrix-compatible configuration.
|
|
443
467
|
|
|
444
|
-
[Unreleased]: https://github.com/general-intelligence-systems/async-matrix/compare/v4.0.
|
|
468
|
+
[Unreleased]: https://github.com/general-intelligence-systems/async-matrix/compare/v4.0.1...HEAD
|
|
469
|
+
[4.0.1]: https://github.com/general-intelligence-systems/async-matrix/releases/tag/v4.0.1
|
|
445
470
|
[4.0.0]: https://github.com/general-intelligence-systems/async-matrix/releases/tag/v4.0.0
|
|
446
471
|
[3.0.1]: https://github.com/general-intelligence-systems/async-matrix/releases/tag/v3.0.1
|
|
447
472
|
[3.0.0]: https://github.com/general-intelligence-systems/async-matrix/releases/tag/v3.0.0
|
data/README.md
CHANGED
|
@@ -429,8 +429,9 @@ npm install
|
|
|
429
429
|
npm run dev # http://localhost:3000
|
|
430
430
|
```
|
|
431
431
|
|
|
432
|
-
Its pages are MDX in `docs/content/docs`, written there rather than generated from this file
|
|
433
|
-
|
|
432
|
+
Its pages are MDX in `docs/content/docs`, written there rather than generated from this file, so a
|
|
433
|
+
change to either has to be made in both places. They are also where `context/` comes from, which
|
|
434
|
+
`bin/update-context` generates: the agent context this gem ships. See `docs/README.md`.
|
|
434
435
|
|
|
435
436
|
## Testing
|
|
436
437
|
|
data/context/design.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!-- Generated by bin/update-context from docs/content/docs. Do not edit: edit the pages there and re-run it. -->
|
|
2
|
+
|
|
3
|
+
# Design
|
|
4
|
+
|
|
5
|
+
The library follows the `async-rest` model: a `Async::Matrix::Client` is a *resource*, and every
|
|
6
|
+
response is a *representation*.
|
|
7
|
+
|
|
8
|
+
- **Paths are absolute.** The client-server API is versioned per-endpoint (`/_matrix/client/v3`
|
|
9
|
+
for most endpoints, `/_matrix/client/v1` for some), so the client is rooted at the homeserver
|
|
10
|
+
and each method supplies an absolute path, which replaces rather than extends the resource path.
|
|
11
|
+
|
|
12
|
+
- **Errors are raised by the wrapper.** Matrix reports failures with a non-2xx status and an
|
|
13
|
+
`{errcode, error}` body. `async-rest`'s generated class methods do not check the status, so
|
|
14
|
+
`Async::Matrix::Wrapper` inspects it and raises `Async::Matrix::RequestError`.
|
|
15
|
+
|
|
16
|
+
- **Rate limiting is retried.** Matrix reports `M_LIMIT_EXCEEDED` with `retry_after_ms` in the
|
|
17
|
+
*body*, where `async-rest` only understands the `retry-after` header. The wrapper handles both,
|
|
18
|
+
and surfaces the error rather than waiting when the delay exceeds
|
|
19
|
+
`Wrapper::MAXIMUM_RETRY_DELAY`.
|
|
20
|
+
|
|
21
|
+
- **A room is a resource with no representation.** There is no `GET /rooms/{roomId}`, so
|
|
22
|
+
`Async::Matrix::Room` is a navigator onto a room's state, members and timeline, carrying the
|
|
23
|
+
room ID alongside its resource.
|
|
24
|
+
|
|
25
|
+
- **Representations are lazy.** `client.get_joined_rooms` issues no request until something reads its
|
|
26
|
+
value, and a room enumerated from a collection carries its ID without a further request.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<!-- Generated by bin/update-context from docs/content/docs. Do not edit: edit the pages there and re-run it. -->
|
|
2
|
+
|
|
3
|
+
# Getting Started
|
|
4
|
+
|
|
5
|
+
This guide explains how to use the `async-matrix` gem to talk to a Matrix homeserver.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add the gem to your project:
|
|
10
|
+
|
|
11
|
+
```shell
|
|
12
|
+
$ bundle add async-matrix
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Core Concepts
|
|
16
|
+
|
|
17
|
+
- A `Async::Matrix::Client` is an `Async::REST::Resource` rooted at a homeserver. An
|
|
18
|
+
unauthenticated client can read `/versions` and log in; `Async::Matrix::Client#authenticated`
|
|
19
|
+
derives a client which carries an access token.
|
|
20
|
+
- A `Async::Matrix::Room` is a navigator onto one room. It is not a representation, because the
|
|
21
|
+
client-server API has no `GET /rooms/{roomId}`: a room is only ever observed through its state,
|
|
22
|
+
members or timeline.
|
|
23
|
+
- Everything a request returns is a `Async::Matrix::Representation`: `Async::Matrix::State`,
|
|
24
|
+
`Async::Matrix::Messages`, `Async::Matrix::Sync` and so on.
|
|
25
|
+
|
|
26
|
+
## Logging In
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "async/matrix"
|
|
30
|
+
|
|
31
|
+
Async::Matrix::Client.open("http://localhost:7300") do |unauthenticated|
|
|
32
|
+
client = unauthenticated.logged_in("alice", "password")
|
|
33
|
+
|
|
34
|
+
puts client.get_token_owner.user_id
|
|
35
|
+
# @alice:localhost
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you already hold an access token, skip the login:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
client = unauthenticated.authenticated(ENV["MATRIX_ACCESS_TOKEN"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Rooms
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
room = client.create_room(name: "Planning", topic: "Sprint planning")
|
|
49
|
+
|
|
50
|
+
room.send_message("Hello, World!")
|
|
51
|
+
|
|
52
|
+
puts room.state.name
|
|
53
|
+
# Planning
|
|
54
|
+
puts room.joined_members.user_ids
|
|
55
|
+
# ["@alice:localhost"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Nothing is fetched until it is read, so listing rooms is cheap, and each room enumerated from the
|
|
59
|
+
list already knows its own ID:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
client.get_joined_rooms.each do |room|
|
|
63
|
+
puts room.id
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A timeline is paged with opaque tokens. `Async::Matrix::Messages#following` continues from
|
|
68
|
+
where the previous page ended, and returns `nil` at the end:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
page = room.get_room_events(dir: "b", limit: 20)
|
|
72
|
+
|
|
73
|
+
while page
|
|
74
|
+
page.each do |event|
|
|
75
|
+
puts event.dig(:content, :body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
page = page.following
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Sync
|
|
83
|
+
|
|
84
|
+
`/sync` is a long poll: the server holds the request open until something happens, or until the
|
|
85
|
+
timeout expires. Each batch carries the token the next one should continue from, so a sync loop is a
|
|
86
|
+
chain of `Async::Matrix::Sync#following` calls.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# The initial sync reports all current state. Use `timeout: 0` for it, and treat it as a cursor:
|
|
90
|
+
sync = client.sync(timeout: 0)
|
|
91
|
+
|
|
92
|
+
while true
|
|
93
|
+
sync = sync.following(timeout: 30_000)
|
|
94
|
+
|
|
95
|
+
sync.each_message do |room_id, sender, body, event|
|
|
96
|
+
puts "#{room_id} #{sender}: #{body}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Note that the room IDs in a parsed payload are symbols, because the JSON parser symbolizes names.
|
|
102
|
+
`Async::Matrix::Sync#each_event` and `Async::Matrix::Sync#each_message` convert them back
|
|
103
|
+
to strings for you; `Async::Matrix::Sync#joined` does not.
|
|
104
|
+
|
|
105
|
+
## Errors and Rate Limiting
|
|
106
|
+
|
|
107
|
+
A failed request raises `Async::Matrix::RequestError`, which carries the HTTP status and the
|
|
108
|
+
Matrix `errcode`:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
begin
|
|
112
|
+
client.join("#nonexistent:localhost")
|
|
113
|
+
rescue Async::Matrix::RequestError => error
|
|
114
|
+
puts error.status # 404
|
|
115
|
+
puts error.errcode # M_NOT_FOUND
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A rate limited request is retried automatically, for as long as the server asks for a delay within
|
|
120
|
+
`Async::Matrix::Wrapper::MAXIMUM_RETRY_DELAY`. A longer delay is raised as an error instead, so that
|
|
121
|
+
a task is never silently blocked for minutes: Synapse answers a login burst with a penalty of around
|
|
122
|
+
five minutes, and you almost certainly want to know rather than wait.
|
data/context/index.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by bin/update-context from docs/content/docs. Do not edit: edit the pages there and re-run it.
|
|
2
|
+
---
|
|
3
|
+
description: An asynchronous Ruby library for the Matrix protocol.
|
|
4
|
+
metadata:
|
|
5
|
+
source_code_uri: https://github.com/general-intelligence-systems/async-matrix
|
|
6
|
+
documentation_uri: https://general-intelligence-systems.github.io/async-matrix/
|
|
7
|
+
files:
|
|
8
|
+
- path: overview.md
|
|
9
|
+
title: Overview
|
|
10
|
+
description: A Ruby client for the Matrix client-server API, built on async-rest.
|
|
11
|
+
- path: getting-started.md
|
|
12
|
+
title: Getting Started
|
|
13
|
+
description: Install the gem, log in, work with rooms, and run a sync loop.
|
|
14
|
+
- path: design.md
|
|
15
|
+
title: Design
|
|
16
|
+
description: Resources, representations, errors and laziness — why the client looks
|
|
17
|
+
the way it does.
|
|
18
|
+
- path: server-server.md
|
|
19
|
+
title: Server-server API
|
|
20
|
+
description: Federation — signed requests, membership templates, and the keys a
|
|
21
|
+
server publishes.
|
|
22
|
+
- path: testing.md
|
|
23
|
+
title: Testing
|
|
24
|
+
description: Running the suite against a real homeserver, on either backend, and
|
|
25
|
+
linting.
|
data/context/overview.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!-- Generated by bin/update-context from docs/content/docs. Do not edit: edit the pages there and re-run it. -->
|
|
2
|
+
|
|
3
|
+
# Overview
|
|
4
|
+
|
|
5
|
+
`async-matrix` is a client for the [Matrix](https://matrix.org) client-server API, built on
|
|
6
|
+
[async-rest](https://github.com/socketry/async-rest).
|
|
7
|
+
|
|
8
|
+
The scope is rooms and sync: logging in, enumerating and creating rooms, reading room state and
|
|
9
|
+
members, sending events, paging through a timeline, and running a sync loop.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "async/matrix"
|
|
15
|
+
|
|
16
|
+
Async::Matrix::Client.open("http://localhost:7300") do |unauthenticated|
|
|
17
|
+
client = unauthenticated.logged_in("alice", "password")
|
|
18
|
+
|
|
19
|
+
room = client.create_room(name: "Hello", topic: "Built with async-rest")
|
|
20
|
+
room.send_message("Hello, World!")
|
|
21
|
+
|
|
22
|
+
client.get_joined_rooms.each do |room|
|
|
23
|
+
puts "#{room.id}: #{room.state.name}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sync is a long poll: each batch continues from the one before it.
|
|
27
|
+
sync = client.sync(timeout: 0)
|
|
28
|
+
|
|
29
|
+
while true
|
|
30
|
+
sync = sync.following(timeout: 30_000)
|
|
31
|
+
|
|
32
|
+
sync.each_message do |room_id, sender, body, event|
|
|
33
|
+
puts "#{room_id} #{sender}: #{body}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
See `examples/echo.rb` for a bot which joins rooms it is invited to and replies to every message.
|
|
40
|
+
|
|
41
|
+
## Where to go next
|
|
42
|
+
|
|
43
|
+
- [Getting Started](getting-started.md) — Install the gem, log in, send a message, and run a sync loop.
|
|
44
|
+
- [Design](design.md) — Why the client looks the way it does: resources, representations, and errors.
|
|
45
|
+
- [Testing](testing.md) — Run the suite against a real homeserver, on either backend.
|
|
46
|
+
- [Matrix Specification](https://spec.matrix.org) — The specification the client implements.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!-- Generated by bin/update-context from docs/content/docs. Do not edit: edit the pages there and re-run it. -->
|
|
2
|
+
|
|
3
|
+
# Server-server API
|
|
4
|
+
|
|
5
|
+
`Async::Matrix::ServerServer::Server` is the federation client: a resource rooted at another
|
|
6
|
+
homeserver, with a navigator per concept the specification names and a representation per answer.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
require "async/matrix"
|
|
10
|
+
|
|
11
|
+
key = Async::Matrix::ServerKey.load("/etc/synapse/signing.key")
|
|
12
|
+
|
|
13
|
+
Async::Matrix::ServerServer::Server.open("https://matrix.example.com") do |unsigned|
|
|
14
|
+
server = unsigned.signed_as("example.com", key, "matrix.example.com")
|
|
15
|
+
|
|
16
|
+
puts unsigned.server_version.get_version.name
|
|
17
|
+
puts server.query.query_profile("@alice:matrix.example.com").displayname
|
|
18
|
+
|
|
19
|
+
# A join is a template to sign and send back:
|
|
20
|
+
template = server.joins_v1.make_join(room_id, "@bob:example.com", versions: ["11"])
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Signing
|
|
25
|
+
|
|
26
|
+
There is no access token. A server proves who it is by signing the request, so
|
|
27
|
+
`Async::Matrix::Middleware::Signed` sits below the resource and signs the method, the path, the
|
|
28
|
+
origin, the destination and the body as a document. The signature is over the canonical form rather
|
|
29
|
+
than over the bytes, so it cannot depend on how the sender wrote them.
|
|
30
|
+
|
|
31
|
+
`Async::Matrix::ServerKey` reads the one line a homeserver keeps its ed25519 key in, and is what
|
|
32
|
+
the other side fetches from `/_matrix/key/v2/server` to check a signature against.
|
|
33
|
+
|
|
34
|
+
Some endpoints are not signed at all. The version, the keys, the discovery document and the OpenID
|
|
35
|
+
exchange are read before there is anything to sign with, so they are asked for through the unsigned
|
|
36
|
+
resource.
|
|
37
|
+
|
|
38
|
+
## Answers which are not JSON
|
|
39
|
+
|
|
40
|
+
`/media/download` answers `multipart/mixed`: a document describing the file, and then the file, or
|
|
41
|
+
a `Location` pointing at it. `Async::Matrix::Wrapper::Multipart` reads both, so
|
|
42
|
+
`Async::Matrix::ServerServer::FederatedContent` has bytes and a description rather than a body to
|
|
43
|
+
parse.
|
|
44
|
+
|
|
45
|
+
The first versions of `send_join` and `send_invite` answered `[200, {...}]` — a status beside the
|
|
46
|
+
body — which the second versions replaced with the body alone. Those representations read either
|
|
47
|
+
shape.
|
|
48
|
+
|
|
49
|
+
## Events
|
|
50
|
+
|
|
51
|
+
Since room version 3 an event ID is the hash of its redacted form rather than a field it carries,
|
|
52
|
+
so a representation reads events by type rather than looking one up by ID.
|
|
53
|
+
|
|
54
|
+
A join, a leave and a knock are all built by the server which has the room: it answers a
|
|
55
|
+
`MembershipTemplate` for the asking server to hash, sign and send back, because only it knows the
|
|
56
|
+
state the event should point at.
|
|
57
|
+
|
|
58
|
+
## Withdrawals
|
|
59
|
+
|
|
60
|
+
v1.18 dropped the first versions of `send_join` and `send_leave`, so those layers register the
|
|
61
|
+
withdrawal and the methods say what replaced them:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Async::Matrix::ServerServer::Server["v1.19"].open(url) do |server|
|
|
65
|
+
server.joins_v1.send_join_v1(room_id, event_id, event)
|
|
66
|
+
# => Async::Matrix::RemovedOperationError: #send_join_v1 was removed in v1.18: call #send_join_v2 instead!
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Testing it
|
|
71
|
+
|
|
72
|
+
The suite signs as the homeserver it is testing, using the key checked into `synapse/`: a
|
|
73
|
+
homeserver verifies a signature made by its own server name against the key it already holds rather
|
|
74
|
+
than fetching one, so every authenticated endpoint is reachable from a single server. There is no
|
|
75
|
+
other way to do it — a server name invented by the suite would have no keys for anybody to fetch.
|
|
76
|
+
|
|
77
|
+
```shell
|
|
78
|
+
bin/test --keep-alive --api server-server
|
|
79
|
+
```
|
data/context/testing.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!-- Generated by bin/update-context from docs/content/docs. Do not edit: edit the pages there and re-run it. -->
|
|
2
|
+
|
|
3
|
+
# Testing
|
|
4
|
+
|
|
5
|
+
The tests run against a real homeserver, and against more than one of them. There is a rig per
|
|
6
|
+
backend, each a directory with a flake which publishes one image per Matrix specification version
|
|
7
|
+
and a compose file with a service per version, so that a run is just the version wanted:
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
bin/test # the latest version, on Synapse
|
|
11
|
+
bin/test --all # every version
|
|
12
|
+
bin/test v1.11 v1.12 # just these
|
|
13
|
+
bin/test --backend tuwunel # on tuwunel instead
|
|
14
|
+
bin/test --keep-alive # and leave the homeserver running afterwards
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`synapse/` is the first rig, and pins each specification version to the Synapse which first
|
|
18
|
+
advertised it, which is the oldest server that version can be tested against. `tuwunel/` is the
|
|
19
|
+
second, and pins every version to the same tuwunel release: one tuwunel build advertises the whole
|
|
20
|
+
range, and its advertised list records the order it did the work in rather than when support
|
|
21
|
+
arrived, so the first release to advertise v1.5 predates most of its development.
|
|
22
|
+
|
|
23
|
+
A second backend is the point rather than a bonus. A suite which only ever talks to Synapse tests
|
|
24
|
+
what Synapse does, not what the specification says, so where a version passes on one server and
|
|
25
|
+
fails on the other, either one of them is wrong or the specification is read two ways.
|
|
26
|
+
|
|
27
|
+
Each test gets a fresh user and a fresh room, so tests cannot interfere with each other and the
|
|
28
|
+
suite can be run repeatedly. The one thing the rigs cannot do alike is make that user: Synapse
|
|
29
|
+
carries a module which registers it on first login, where tuwunel has no way to accept any
|
|
30
|
+
password, so its rig opens registration and the suite registers the account before logging in.
|
|
31
|
+
`bin/test` tells the suite which it is talking to.
|
|
32
|
+
|
|
33
|
+
Set `MATRIX_HOMESERVER` to test against a server of your own — but note that one with default rate
|
|
34
|
+
limits will fail the suite, because Synapse allows a burst of 10 room creations and then one per
|
|
35
|
+
~62 seconds, and a burst of 5 logins and then one per ~5 minutes.
|
|
36
|
+
|
|
37
|
+
The images come from the Build Test Images workflows, which is also what a new version needs run
|
|
38
|
+
after it is added: a job pulls `ghcr.io/<owner>/<backend>:spec-<version>`, so without it there is
|
|
39
|
+
nothing to start.
|
|
40
|
+
|
|
41
|
+
### Linting
|
|
42
|
+
|
|
43
|
+
```shell
|
|
44
|
+
bin/rubocop
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The cops in `cops/` read files with Prism through `ast-crispr-ruby-prism`, which requires Ruby 4,
|
|
48
|
+
so they are in a `lint` bundler group of their own. The suite itself runs on Ruby 3.3 and up, and
|
|
49
|
+
the Test workflows install without that group.
|
|
@@ -33,8 +33,12 @@ module Async
|
|
|
33
33
|
module V1_14
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
#
|
|
36
|
+
# v1.13 added a format to a device's data.
|
|
37
37
|
module V1_13
|
|
38
|
+
# @returns [Boolean] whether the field is accepted.
|
|
39
|
+
def device_data_accepts_format?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# No changes in v1.12.
|
|
@@ -81,8 +85,12 @@ module Async
|
|
|
81
85
|
module V1_2
|
|
82
86
|
end
|
|
83
87
|
|
|
84
|
-
#
|
|
88
|
+
# v1.1 has the notification, with no format.
|
|
85
89
|
module V1_1
|
|
90
|
+
# @returns [Boolean] whether the field is accepted.
|
|
91
|
+
def device_data_accepts_format?
|
|
92
|
+
false
|
|
93
|
+
end
|
|
86
94
|
end
|
|
87
95
|
end
|
|
88
96
|
end
|
|
@@ -160,8 +160,6 @@ module Async
|
|
|
160
160
|
|
|
161
161
|
# The code over the ciphertext authenticates nothing, which is libolm's bug and the specification's warning.
|
|
162
162
|
#
|
|
163
|
-
# So a message which fails it was written to another key, rather than tampered with. See MSC4048 for the version which fixes it.
|
|
164
|
-
#
|
|
165
163
|
# @parameter session_data [Hash] the three parts the server holds.
|
|
166
164
|
# @returns [String | nil] the plaintext, or nil when this is not the key it was written to.
|
|
167
165
|
def opened(session_data)
|
|
@@ -171,6 +169,7 @@ module Async
|
|
|
171
169
|
self.class.unpadded(session_data[:ephemeral]),
|
|
172
170
|
)
|
|
173
171
|
rescue RuntimeError
|
|
172
|
+
# A message which fails the code was written to another key, rather than tampered with. See MSC4048 for the version which fixes it.
|
|
174
173
|
nil
|
|
175
174
|
end
|
|
176
175
|
end
|
|
@@ -11,9 +11,9 @@ module Async
|
|
|
11
11
|
module Matrix
|
|
12
12
|
module E2EE
|
|
13
13
|
# The three keys which make a user's identity, so that verifying a person once covers every device they have.
|
|
14
|
-
#
|
|
15
|
-
# The master key is the identity, the self signing key signs their own devices, and the user signing key signs other people's master keys.
|
|
16
14
|
class CrossSigning
|
|
15
|
+
# The master key is the identity, the self signing key signs their own devices, and the user signing key signs other people's master keys.
|
|
16
|
+
#
|
|
17
17
|
# What each key is for, which the server and every other client reads it by.
|
|
18
18
|
USAGE = {master: "master", self_signing: "self_signing", user_signing: "user_signing"}.freeze
|
|
19
19
|
|
|
@@ -194,8 +194,6 @@ module Async
|
|
|
194
194
|
|
|
195
195
|
# Whether this device carries a signature by its owner's self signing key.
|
|
196
196
|
#
|
|
197
|
-
# The signature is checked where there is something to check it with, because its presence alone says only that somebody wrote one.
|
|
198
|
-
#
|
|
199
197
|
# @parameter user_id [String] the user.
|
|
200
198
|
# @parameter value [Hash] the device keys the homeserver answered with.
|
|
201
199
|
# @returns [Boolean] whether it is cross signed.
|
|
@@ -203,6 +201,7 @@ module Async
|
|
|
203
201
|
signing = @identities.dig(user_id, :self_signing)
|
|
204
202
|
signature = value.dig(:signatures, user_id.to_sym, :"ed25519:#{signing}")
|
|
205
203
|
|
|
204
|
+
# The signature is checked where there is something to check it with, because its presence alone says only that somebody wrote one.
|
|
206
205
|
case {signing: signing, signature: signature, crypto: @crypto}
|
|
207
206
|
in {signing: String, signature: String => claimed, crypto: Crypto => crypto}
|
|
208
207
|
crypto.verify(value.reject{|name, _| name == :signatures}, key: signing, signature: claimed)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
require "console"
|
|
7
7
|
require "json"
|
|
8
8
|
|
|
9
|
+
require_relative "../error"
|
|
9
10
|
require_relative "../events"
|
|
10
11
|
require_relative "backup"
|
|
11
12
|
require_relative "canonical_json"
|
|
@@ -37,6 +38,13 @@ module Async
|
|
|
37
38
|
# The history visibilities which let a session be passed on to a later member.
|
|
38
39
|
SHAREABLE = ["shared", "world_readable"].freeze
|
|
39
40
|
|
|
41
|
+
# Which part of the identity each of the three cross signing secrets carries.
|
|
42
|
+
CROSS_SIGNING = {
|
|
43
|
+
"m.cross_signing.master" => :master,
|
|
44
|
+
"m.cross_signing.self_signing" => :self_signing,
|
|
45
|
+
"m.cross_signing.user_signing" => :user_signing,
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
40
48
|
# @parameter user_id [String] the user this device belongs to.
|
|
41
49
|
# @parameter device_id [String] this device.
|
|
42
50
|
# @parameter crypto [Crypto] the implementation to encrypt with.
|
|
@@ -64,6 +72,45 @@ module Async
|
|
|
64
72
|
@released = []
|
|
65
73
|
end
|
|
66
74
|
|
|
75
|
+
# The state this device holds, which is written for this device and no other.
|
|
76
|
+
#
|
|
77
|
+
# @parameter pickle_key [String] a 32 byte key.
|
|
78
|
+
# @returns [String] the state.
|
|
79
|
+
def dump(pickle_key)
|
|
80
|
+
::JSON.dump(
|
|
81
|
+
user_id: @user_id,
|
|
82
|
+
device_id: @device_id,
|
|
83
|
+
crypto: @crypto.dump(pickle_key),
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Restore a device from what it wrote, which must be the device asking.
|
|
88
|
+
#
|
|
89
|
+
# @parameter dumped [String] what {#dump} wrote.
|
|
90
|
+
# @parameter pickle_key [String] the 32 byte key it was written with.
|
|
91
|
+
# @parameter user_id [String] the user restoring it.
|
|
92
|
+
# @parameter device_id [String] the device restoring it.
|
|
93
|
+
# @parameter crypto [Class] the implementation which wrote it, since only the caller knows which it encrypts with.
|
|
94
|
+
# @parameter options [Hash] what else to build it with, as the constructor takes.
|
|
95
|
+
# @returns [Machine] that device.
|
|
96
|
+
# @raises [MismatchedAccountError] if the state belongs to another user or device.
|
|
97
|
+
def self.load(dumped, pickle_key, user_id:, device_id:, crypto:, **options)
|
|
98
|
+
value = ::JSON.parse(dumped, symbolize_names: true)
|
|
99
|
+
written = [value[:user_id], value[:device_id]]
|
|
100
|
+
|
|
101
|
+
# A device which took on another's state would acknowledge what it never read, so this is refused rather than resumed.
|
|
102
|
+
unless written == [user_id, device_id]
|
|
103
|
+
raise MismatchedAccountError.new(written, [user_id, device_id])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
self.new(
|
|
107
|
+
user_id: user_id,
|
|
108
|
+
device_id: device_id,
|
|
109
|
+
crypto: crypto.load(value[:crypto], pickle_key),
|
|
110
|
+
**options,
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
67
114
|
# @attribute [String] the user this device encrypts as.
|
|
68
115
|
attr :user_id
|
|
69
116
|
|
|
@@ -138,19 +185,19 @@ module Async
|
|
|
138
185
|
# @parameter secret [String] the private key, base64 encoded.
|
|
139
186
|
# @returns [CrossSigning | nil] our identity, or nil where the name is not one of the three.
|
|
140
187
|
def accept_cross_signing_secret(name, secret)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
in "m.cross_signing.user_signing" then @identity[:user_signing] = CrossSigning::Key.from_base64(secret)
|
|
145
|
-
else
|
|
188
|
+
part = CROSS_SIGNING[name.to_s]
|
|
189
|
+
|
|
190
|
+
if part.nil?
|
|
146
191
|
Console.warn(self, "That is not a cross signing key.", name: name)
|
|
147
192
|
|
|
148
|
-
|
|
149
|
-
|
|
193
|
+
nil
|
|
194
|
+
else
|
|
195
|
+
@identity[part] = CrossSigning::Key.from_base64(secret)
|
|
150
196
|
|
|
151
|
-
|
|
197
|
+
Console.info(self, "Took on a cross signing key.", name: name)
|
|
152
198
|
|
|
153
|
-
|
|
199
|
+
@identity
|
|
200
|
+
end
|
|
154
201
|
end
|
|
155
202
|
|
|
156
203
|
# Sign this device with our own self signing key, which is what makes it cross signed.
|
|
@@ -236,8 +283,6 @@ module Async
|
|
|
236
283
|
|
|
237
284
|
# One session, as a backup wants it.
|
|
238
285
|
#
|
|
239
|
-
# A restored session carries no signature, so what it claims about its sender is only as good as whoever put it there: that is why `is_verified` is what the sender's device was, not what the backup says.
|
|
240
|
-
#
|
|
241
286
|
# @parameter session [Hash] the session, as {#sessions} answers it.
|
|
242
287
|
# @returns [Hash] the fields a backup is written from.
|
|
243
288
|
def export_for_backup(session)
|
|
@@ -256,6 +301,7 @@ module Async
|
|
|
256
301
|
shared_history: session[:shared_history] || false,
|
|
257
302
|
first_message_index: index,
|
|
258
303
|
forwarded_count: (session[:forwarding_curve25519_key_chain] || []).size,
|
|
304
|
+
# A restored session carries no signature, so what it claims about its sender is only as good as whoever put it there: this is what the sender's device was, not what the backup says.
|
|
259
305
|
is_verified: @devices.verified?(session[:sender], session[:device_id].to_s),
|
|
260
306
|
}
|
|
261
307
|
end
|
|
@@ -360,20 +406,18 @@ module Async
|
|
|
360
406
|
|
|
361
407
|
# Whether a session for this room may be given to somebody who joins later.
|
|
362
408
|
#
|
|
363
|
-
# True only while the history is visible to them, which is what the flag on a shared key reports.
|
|
364
|
-
#
|
|
365
409
|
# @parameter room_id [String] the room.
|
|
366
410
|
# @returns [Boolean] whether the history is shared.
|
|
367
411
|
def shared_history?(room_id)
|
|
412
|
+
# True only while the history is visible to them, which is what the flag on a shared key reports.
|
|
368
413
|
SHAREABLE.include?(@rooms.dig(room_id, :visibility))
|
|
369
414
|
end
|
|
370
415
|
|
|
371
416
|
# The rooms whose session has been used for too long, or for too many messages.
|
|
372
417
|
#
|
|
373
|
-
# Neither is an event, so neither can be noticed by reading one: they are measured here instead.
|
|
374
|
-
#
|
|
375
418
|
# @returns [Hash] why each room is due, by room ID.
|
|
376
419
|
def rooms_due_for_rotation
|
|
420
|
+
# Neither is an event, so neither can be noticed by reading one: they are measured here instead.
|
|
377
421
|
@rooms.each_with_object({}) do |(room_id, room), due|
|
|
378
422
|
session = @crypto.session_for(room_id)
|
|
379
423
|
|
|
@@ -677,17 +721,34 @@ module Async
|
|
|
677
721
|
case session
|
|
678
722
|
in nil
|
|
679
723
|
nil
|
|
680
|
-
in {message_index: Integer => index}
|
|
681
|
-
|
|
724
|
+
in {message_index: Integer => index}
|
|
725
|
+
if index >= room.fetch(:messages, DEFAULT_MESSAGES)
|
|
726
|
+
"it has carried #{index} messages"
|
|
727
|
+
else
|
|
728
|
+
self.expired(room_id, room)
|
|
729
|
+
end
|
|
682
730
|
else
|
|
683
|
-
|
|
731
|
+
self.expired(room_id, room)
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Why the room's session is too old to go on using, if it is.
|
|
736
|
+
#
|
|
737
|
+
# @parameter room_id [String] the room.
|
|
738
|
+
# @parameter room [Hash] what its state said.
|
|
739
|
+
# @returns [String | nil] the reason, or nil while it may still be used.
|
|
740
|
+
def expired(room_id, room)
|
|
741
|
+
started = @store.started(room_id)
|
|
684
742
|
|
|
685
|
-
|
|
686
|
-
|
|
743
|
+
case started
|
|
744
|
+
in Time => at
|
|
745
|
+
if (Time.now - at) * 1000 >= room.fetch(:period, DEFAULT_PERIOD)
|
|
687
746
|
"it has been in use since #{at}"
|
|
688
747
|
else
|
|
689
748
|
nil
|
|
690
749
|
end
|
|
750
|
+
else
|
|
751
|
+
nil
|
|
691
752
|
end
|
|
692
753
|
end
|
|
693
754
|
|