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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 294aac9f09e13800fd19f8bd62cc9290ac574ef9c44577b76c97a01c45f06384
4
- data.tar.gz: 329a49b5d8cff3fd05493b49aed557863d13607c9cef11eb2dbbdf60aaf02bed
3
+ metadata.gz: dd82bb8b903732843ac2a6ddf292ff3d74a6c3e7680a61c0924ee51dd6c9ccea
4
+ data.tar.gz: 9b4c4a64624938601f9bd027fc015abbd556ffb6944e4ad69163e03a5f0012d9
5
5
  SHA512:
6
- metadata.gz: 76ceb50e1bce05d67997cac434915dc3e5b1b70c083e4c8d8d7aed9520a71fc9b93f27e65715dd2178f99374911d7e9349b7b0c4ba35796faaf9f96b397788ac
7
- data.tar.gz: 134172207429e74a48eb81531ea5c31d90b3a96e21551ab909209c073f4491dcba2f21a6beb09310434a65403f1c122b5941091d788dc0b9cb6110e4f9c66064
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.0...HEAD
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 and
433
- `guides/`, so a change to either has to be made in both places. See `docs/README.md`.
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.
@@ -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.
@@ -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
+ ```
@@ -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
- # No changes in v1.13.
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
- # No changes in v1.1.
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
- case name.to_s
142
- in "m.cross_signing.master" then @identity[:master] = CrossSigning::Key.from_base64(secret)
143
- in "m.cross_signing.self_signing" then @identity[:self_signing] = CrossSigning::Key.from_base64(secret)
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
- return nil
149
- end
193
+ nil
194
+ else
195
+ @identity[part] = CrossSigning::Key.from_base64(secret)
150
196
 
151
- Console.info(self, "Took on a cross signing key.", name: name)
197
+ Console.info(self, "Took on a cross signing key.", name: name)
152
198
 
153
- @identity
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} if index >= room.fetch(:messages, DEFAULT_MESSAGES)
681
- "it has carried #{index} messages"
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
- started = @store.started(room_id)
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
- case started
686
- in Time => at if (Time.now - at) * 1000 >= room.fetch(:period, DEFAULT_PERIOD)
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