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
|
@@ -12,10 +12,10 @@ require_relative "../key_representation"
|
|
|
12
12
|
module Async
|
|
13
13
|
module Matrix
|
|
14
14
|
module E2EE
|
|
15
|
-
# Secrets
|
|
16
|
-
#
|
|
17
|
-
# This is where the cross signing keys and the backup key live, so that a new device can reach them.
|
|
15
|
+
# Secrets the server keeps but cannot read.
|
|
18
16
|
module SecretStorage
|
|
17
|
+
# They are encrypted under a key the server never sees, and the cross signing keys and the backup key live here, so that a new device can reach them.
|
|
18
|
+
|
|
19
19
|
# The only algorithm the specification defines.
|
|
20
20
|
ALGORITHM = "m.secret_storage.v1.aes-hmac-sha2"
|
|
21
21
|
|
|
@@ -32,9 +32,9 @@ module Async
|
|
|
32
32
|
ZERO_MESSAGE = ("\x00" * 32).b
|
|
33
33
|
|
|
34
34
|
# The key secret storage is encrypted under.
|
|
35
|
-
#
|
|
36
|
-
# It never leaves the device: what goes to the server is what it encrypted, and a description which only says how to arrive at it again.
|
|
37
35
|
class Key
|
|
36
|
+
# It never leaves the device: what goes to the server is what it encrypted, and a description which only says how to arrive at it again.
|
|
37
|
+
|
|
38
38
|
# @returns [Key] a key of random bytes, which the user keeps.
|
|
39
39
|
def self.generate
|
|
40
40
|
self.new(SecureRandom.bytes(BYTES))
|
|
@@ -112,12 +112,11 @@ module Async
|
|
|
112
112
|
|
|
113
113
|
# Whether this key is the one a description was written by.
|
|
114
114
|
#
|
|
115
|
-
# The description carries the code over a known message, so a wrong passphrase is caught before anything is decrypted with it.
|
|
116
|
-
#
|
|
117
115
|
# @parameter iv [String] the counter from the description.
|
|
118
116
|
# @parameter mac [String] the code from the description.
|
|
119
117
|
# @returns [Boolean] whether they match.
|
|
120
118
|
def correct?(iv:, mac:)
|
|
119
|
+
# The description carries the code over a known message, so a wrong passphrase is caught before anything is decrypted with it.
|
|
121
120
|
aes, key = self.derive("")
|
|
122
121
|
|
|
123
122
|
ciphertext = self.cipher(:encrypt, aes, self.class.decode(iv), ZERO_MESSAGE)
|
|
@@ -128,12 +127,11 @@ module Async
|
|
|
128
127
|
|
|
129
128
|
# What goes into account data to describe this key, so another device can arrive at it.
|
|
130
129
|
#
|
|
131
|
-
# It carries no key material: a passphrase description says how to derive one, and the check says whether the answer was right.
|
|
132
|
-
#
|
|
133
130
|
# @parameter name [String] what to call it to the user.
|
|
134
131
|
# @parameter passphrase [Hash | nil] how it was derived, where it was.
|
|
135
132
|
# @returns [Hash] the description.
|
|
136
133
|
def description(name: nil, passphrase: nil)
|
|
134
|
+
# It carries no key material: a passphrase description says how to derive one, and the check says whether the answer was right.
|
|
137
135
|
aes, key = self.derive("")
|
|
138
136
|
iv = self.class.counter
|
|
139
137
|
|
|
@@ -159,10 +157,9 @@ module Async
|
|
|
159
157
|
|
|
160
158
|
# Sixteen random bytes, with the top bit of the counter cleared.
|
|
161
159
|
#
|
|
162
|
-
# The specification asks for that to work around implementations which disagree about where the counter wraps.
|
|
163
|
-
#
|
|
164
160
|
# @returns [String] the counter block.
|
|
165
161
|
def self.counter
|
|
162
|
+
# The specification asks for that to work around implementations which disagree about where the counter wraps.
|
|
166
163
|
SecureRandom.bytes(16).tap do |iv|
|
|
167
164
|
iv.setbyte(8, iv.getbyte(8) & 0x7f)
|
|
168
165
|
end
|
|
@@ -10,9 +10,9 @@ require_relative "secret_storage"
|
|
|
10
10
|
module Async
|
|
11
11
|
module Matrix
|
|
12
12
|
module E2EE
|
|
13
|
-
# The private keys a device holds, and what it
|
|
13
|
+
# The private keys a device holds, and what it writes them to the server as.
|
|
14
14
|
#
|
|
15
|
-
# It makes no request: a caller hands it
|
|
15
|
+
# It makes no request: a caller hands it account data and gets what to send.
|
|
16
16
|
class Secrets
|
|
17
17
|
def initialize
|
|
18
18
|
# The secrets this device has opened, which never leave it in the clear.
|
|
@@ -74,16 +74,16 @@ module Async
|
|
|
74
74
|
# @returns [Boolean] whether it is the right one.
|
|
75
75
|
def opened(key)
|
|
76
76
|
case @description
|
|
77
|
-
in {iv: String => iv, mac: String => mac}
|
|
78
|
-
|
|
77
|
+
in {iv: String => iv, mac: String => mac}
|
|
78
|
+
if key.correct?(iv: iv, mac: mac)
|
|
79
|
+
self.unlocked(key)
|
|
80
|
+
else
|
|
81
|
+
Console.warn(self, "That key does not open this storage.", key_id: @key_id)
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
false
|
|
84
|
+
end
|
|
81
85
|
else
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self.read_sealed
|
|
85
|
-
|
|
86
|
-
true
|
|
86
|
+
self.unlocked(key)
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
|
|
@@ -136,9 +136,13 @@ module Async
|
|
|
136
136
|
# @returns [String | nil] the secret, or nil where it is not there or does not read.
|
|
137
137
|
def load(name)
|
|
138
138
|
case {open: self.open?, sealed: (@sealed || {})[name]}
|
|
139
|
-
in {open: true, sealed: Hash => encrypted}
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
in {open: true, sealed: Hash => encrypted}
|
|
140
|
+
if encrypted.key?(@key_id.to_sym)
|
|
141
|
+
@key.decrypt(name, encrypted[@key_id.to_sym]).tap do |secret|
|
|
142
|
+
self.accept(name, secret)
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
@held[name]
|
|
142
146
|
end
|
|
143
147
|
else
|
|
144
148
|
@held[name]
|
|
@@ -161,14 +165,14 @@ module Async
|
|
|
161
165
|
|
|
162
166
|
# Whether this is an answer to something we asked, over a channel which proves who sent it.
|
|
163
167
|
#
|
|
164
|
-
# A secret arriving unbidden is somebody trying their luck, so it is never taken.
|
|
165
|
-
#
|
|
166
168
|
# @parameter request_id [String] the ID the answer carries.
|
|
167
169
|
# @parameter over [Async::Matrix::Events::Envelope | nil] the event it arrived in.
|
|
168
170
|
# @returns [Boolean] whether to believe it.
|
|
169
171
|
def asked?(request_id, over: nil)
|
|
170
172
|
case {asked: @asked.key?(request_id), over: over}
|
|
171
173
|
# Nothing asked for this, so whoever sent it was not answering us.
|
|
174
|
+
#
|
|
175
|
+
# A secret arriving unbidden is somebody trying their luck, so it is never taken.
|
|
172
176
|
in {asked: false}
|
|
173
177
|
false
|
|
174
178
|
|
|
@@ -208,7 +212,21 @@ module Async
|
|
|
208
212
|
|
|
209
213
|
private
|
|
210
214
|
|
|
215
|
+
# Hold on to the key which opened this storage, and read what was sealed under it.
|
|
216
|
+
#
|
|
217
|
+
# @parameter key [SecretStorage::Key] the key.
|
|
218
|
+
# @returns [Boolean] true, which is what opening it answers.
|
|
219
|
+
def unlocked(key)
|
|
220
|
+
@key = key
|
|
221
|
+
|
|
222
|
+
self.read_sealed
|
|
223
|
+
|
|
224
|
+
true
|
|
225
|
+
end
|
|
226
|
+
|
|
211
227
|
# Read everything which was sealed under this storage, now that there is a key for it.
|
|
228
|
+
#
|
|
229
|
+
# @returns [Hash] what was sealed, which is what reading it through answers.
|
|
212
230
|
def read_sealed
|
|
213
231
|
(@sealed || {}).each_key do |name|
|
|
214
232
|
self.load(name)
|
|
@@ -24,13 +24,12 @@ module Async
|
|
|
24
24
|
|
|
25
25
|
# Remember a session another device shared.
|
|
26
26
|
#
|
|
27
|
-
# Whatever else is known about it is kept too, since a backup or an export carries more than the key.
|
|
28
|
-
#
|
|
29
27
|
# @parameter room_id [String] the room the session is for.
|
|
30
28
|
# @parameter session_id [String] the ID of the session.
|
|
31
29
|
# @parameter sender [String | nil] the user who shared it.
|
|
32
30
|
# @parameter rest [Hash] what else is known: whether it was imported, what index it begins at.
|
|
33
31
|
def remember(room_id, session_id:, sender:, **rest)
|
|
32
|
+
# Whatever else is known about it is kept too, since a backup or an export carries more than the key.
|
|
34
33
|
@sessions[[room_id, session_id]] = {sender: sender, at: Time.now, **rest}
|
|
35
34
|
end
|
|
36
35
|
|
data/lib/async/matrix/error.rb
CHANGED
|
@@ -13,6 +13,26 @@ module Async
|
|
|
13
13
|
class InvalidKeyError < Error
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Raised when stored state belongs to another device.
|
|
17
|
+
#
|
|
18
|
+
# Its cursor acknowledges what that device read, so resuming another's state loses what was addressed to it.
|
|
19
|
+
class MismatchedAccountError < Error
|
|
20
|
+
# @parameter expected [Array(String, String)] the user and device the state was written by.
|
|
21
|
+
# @parameter got [Array(String, String)] the user and device which asked for it.
|
|
22
|
+
def initialize(expected, got)
|
|
23
|
+
super("This state belongs to #{expected.join(":")}, not #{got.join(":")}!")
|
|
24
|
+
|
|
25
|
+
@expected = expected
|
|
26
|
+
@got = got
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @attribute [Array(String, String)] the user and device the state was written by.
|
|
30
|
+
attr :expected
|
|
31
|
+
|
|
32
|
+
# @attribute [Array(String, String)] the user and device which asked for it.
|
|
33
|
+
attr :got
|
|
34
|
+
end
|
|
35
|
+
|
|
16
36
|
# Represents a Matrix API error, which is reported with a non-2xx status and a body of the form `{"errcode": "M_FORBIDDEN", "error": "..."}`.
|
|
17
37
|
class RequestError < Error
|
|
18
38
|
# @parameter status [Integer] the HTTP status code of the response.
|
|
@@ -78,17 +78,20 @@ module Async
|
|
|
78
78
|
|
|
79
79
|
# The framing an event actually arrived in, which may be richer than its type declares.
|
|
80
80
|
#
|
|
81
|
-
# `m.room.encrypted` travels both as a to-device event and as a room event, so its schema frames it as the lesser of the two. What it arrived as is what decides, or a timeline event would answer nothing about the room it is in.
|
|
82
|
-
#
|
|
83
81
|
# @parameter declared [Class] the framing the type declares.
|
|
84
82
|
# @parameter value [Hash] the event.
|
|
85
83
|
# @returns [Class] the framing to read it as.
|
|
86
84
|
def framed(declared, value)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
# `m.room.encrypted` travels both as a to-device event and as a room event, so its schema frames it as the lesser of the two. What it arrived as is what decides, or a timeline event would answer nothing about the room it is in.
|
|
86
|
+
if declared == Envelope
|
|
87
|
+
case value
|
|
88
|
+
in {state_key: String}
|
|
89
|
+
StateEvent
|
|
90
|
+
in {event_id: String}
|
|
91
|
+
RoomEvent
|
|
92
|
+
else
|
|
93
|
+
declared
|
|
94
|
+
end
|
|
92
95
|
else
|
|
93
96
|
declared
|
|
94
97
|
end
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
require "async/rest/representation"
|
|
7
7
|
|
|
8
|
+
require_relative "operations"
|
|
8
9
|
require_relative "redacted"
|
|
10
|
+
require_relative "schema"
|
|
9
11
|
require_relative "wrapper/json"
|
|
10
12
|
|
|
11
13
|
module Async
|
|
@@ -14,13 +16,59 @@ module Async
|
|
|
14
16
|
class Representation < Async::REST::Representation[Wrapper::JSON]
|
|
15
17
|
include Redacted
|
|
16
18
|
|
|
17
|
-
#
|
|
19
|
+
# The specification version this document was read against, which a versioned class answers for itself.
|
|
20
|
+
#
|
|
21
|
+
# @returns [String | nil] the version, or nil where this document came from no version's class.
|
|
22
|
+
def version
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What the specification asks of this document, which anybody may ask.
|
|
27
|
+
#
|
|
28
|
+
# @parameter operation [String | nil] the operation ID, where the path names more than one.
|
|
29
|
+
# @parameter method [String | nil] the HTTP method, which says which operation a shared path is.
|
|
30
|
+
# @parameter status [Integer] which of the operation's responses this is.
|
|
31
|
+
# @returns [Array(String)] what was wrong with it, which is empty where nothing was.
|
|
32
|
+
def errors(operation: nil, method: nil, status: 200)
|
|
33
|
+
# A version which describes neither the path nor the operation asks nothing of it.
|
|
34
|
+
case self.described(operation: operation, method: method)
|
|
35
|
+
in [Schema => schema, String => named]
|
|
36
|
+
schema.errors_in_response(named, self.value, status: status)
|
|
37
|
+
else
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @parameter operation [String | nil] the operation ID.
|
|
43
|
+
# @parameter method [String | nil] the HTTP method.
|
|
44
|
+
# @parameter status [Integer] which response this is.
|
|
45
|
+
# @returns [Boolean] whether the document answers what the specification asks.
|
|
46
|
+
def valid?(operation: nil, method: nil, status: 200)
|
|
47
|
+
self.errors(operation: operation, method: method, status: status).empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The same as {#errors}, raising rather than answering.
|
|
18
51
|
#
|
|
19
|
-
#
|
|
52
|
+
# @parameter operation [String | nil] the operation ID.
|
|
53
|
+
# @parameter method [String | nil] the HTTP method.
|
|
54
|
+
# @parameter status [Integer] which response this is.
|
|
55
|
+
# @returns [Representation] this document, so that it can be used where it is read.
|
|
56
|
+
def validate(operation: nil, method: nil, status: 200)
|
|
57
|
+
found = self.errors(operation: operation, method: method, status: status)
|
|
58
|
+
|
|
59
|
+
unless found.empty?
|
|
60
|
+
raise InvalidDocumentError.new(operation || self.resource.path, self.version, found)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# What a pattern reads, which is the document the homeserver answered with.
|
|
20
67
|
#
|
|
21
68
|
# @parameter keys [Array(Symbol) | nil] the names a pattern asked for.
|
|
22
69
|
# @returns [Hash] the value.
|
|
23
70
|
def deconstruct_keys(keys)
|
|
71
|
+
# So a response is matched where it arrives, rather than having to be taken apart first. `[]` cannot do this, because `async-rest` already uses it to derive a sub-resource.
|
|
24
72
|
case self.value
|
|
25
73
|
in Hash => value
|
|
26
74
|
value
|
|
@@ -28,6 +76,52 @@ module Async
|
|
|
28
76
|
{}
|
|
29
77
|
end
|
|
30
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Which operation this document is a response to, which the path says unless two of them share it.
|
|
83
|
+
#
|
|
84
|
+
# @parameter operation [String | nil] the operation ID, where it was given.
|
|
85
|
+
# @parameter method [String | nil] the HTTP method.
|
|
86
|
+
# @returns [Array(Schema, String) | nil] the schema and the operation ID.
|
|
87
|
+
def described(operation: nil, method: nil)
|
|
88
|
+
version = self.version
|
|
89
|
+
|
|
90
|
+
if version
|
|
91
|
+
case operation
|
|
92
|
+
in String => named
|
|
93
|
+
self.schema_with(named, version)&.then{|schema| [schema, named]}
|
|
94
|
+
in nil
|
|
95
|
+
Schema.operation_for(self.api_name, version, self.resource.path, method: method)
|
|
96
|
+
end
|
|
97
|
+
else
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The concept whose schema holds the named operation, since a name says nothing about where it lives.
|
|
103
|
+
#
|
|
104
|
+
# @parameter operation [String] the operation ID.
|
|
105
|
+
# @parameter version [String] the specification version.
|
|
106
|
+
# @returns [Schema | nil] the schema, or nil where this version names no such operation.
|
|
107
|
+
def schema_with(operation, version)
|
|
108
|
+
api = self.api_name
|
|
109
|
+
|
|
110
|
+
Schema.concepts(api, version).lazy.filter_map do |concept|
|
|
111
|
+
schema = Schema.for(api, concept, version)
|
|
112
|
+
|
|
113
|
+
if schema.operations.key?(operation)
|
|
114
|
+
schema
|
|
115
|
+
end
|
|
116
|
+
end.first
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The API this document belongs to, as its schemas are named: `ClientServer` keeps its schemas in `client_server`.
|
|
120
|
+
#
|
|
121
|
+
# @returns [String] the API.
|
|
122
|
+
def api_name
|
|
123
|
+
Operations.underscore(self.class.namespace.name.split("::").last).to_s
|
|
124
|
+
end
|
|
31
125
|
end
|
|
32
126
|
end
|
|
33
127
|
end
|