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.
@@ -12,10 +12,10 @@ require_relative "../key_representation"
12
12
  module Async
13
13
  module Matrix
14
14
  module E2EE
15
- # Secrets kept on the server, under a key the server never sees.
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 would write them to the server as.
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 the account data, and it answers with what to send.
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} unless key.correct?(iv: iv, mac: mac)
78
- Console.warn(self, "That key does not open this storage.", key_id: @key_id)
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
- false
83
+ false
84
+ end
81
85
  else
82
- @key = key
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} if encrypted.key?(@key_id.to_sym)
140
- @key.decrypt(name, encrypted[@key_id.to_sym]).tap do |secret|
141
- self.accept(name, secret)
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
 
@@ -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
- case value
88
- in {state_key: String} if declared == Envelope
89
- StateEvent
90
- in {event_id: String} if declared == Envelope
91
- RoomEvent
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
- # What a pattern reads, which is the document the homeserver answered with.
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
- # 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.
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