async-matrix 2.1.0-arm-linux → 3.0.1-arm-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -58
  3. data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
  4. data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
  5. data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
  6. data/lib/async/matrix/api/chain.rb +120 -94
  7. data/lib/async/matrix/api/concat.rb +28 -22
  8. data/lib/async/matrix/api/path_tree.rb +71 -44
  9. data/lib/async/matrix/api.rb +6 -1
  10. data/lib/async/matrix/app_service_client.rb +213 -0
  11. data/lib/async/matrix/auth_error.rb +20 -0
  12. data/lib/async/matrix/bad_json_error.rb +20 -0
  13. data/lib/async/matrix/client/encryption.rb +413 -0
  14. data/lib/async/matrix/client/rooms.rb +478 -0
  15. data/lib/async/matrix/client/sync.rb +508 -0
  16. data/lib/async/matrix/client.rb +217 -113
  17. data/lib/async/matrix/config/vivify.rb +107 -0
  18. data/lib/async/matrix/config.rb +184 -0
  19. data/lib/async/matrix/device_store.rb +1621 -0
  20. data/lib/async/matrix/double_puppet_client.rb +1 -1
  21. data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
  22. data/lib/async/matrix/error.rb +9 -36
  23. data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
  24. data/lib/async/matrix/homeserver_error.rb +20 -0
  25. data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
  26. data/lib/async/matrix/media_client.rb +46 -35
  27. data/lib/async/matrix/not_found_error.rb +20 -0
  28. data/lib/async/matrix/notifier.rb +7 -3
  29. data/lib/async/matrix/response_too_large_error.rb +20 -0
  30. data/lib/async/matrix/version.rb +1 -1
  31. data/lib/async/matrix.rb +16 -7
  32. data/lib/protocol/matrix/canonical_json.rb +200 -0
  33. data/lib/protocol/matrix/content.rb +106 -0
  34. data/lib/protocol/matrix/encrypted_message.rb +1018 -0
  35. data/lib/protocol/matrix/error.rb +49 -0
  36. data/lib/protocol/matrix/event.rb +208 -0
  37. data/lib/protocol/matrix/key_backup.rb +347 -0
  38. data/lib/protocol/matrix/keys.rb +381 -0
  39. data/lib/protocol/matrix/message_batch.rb +482 -0
  40. data/lib/protocol/matrix/schema/registry.rb +381 -0
  41. data/lib/protocol/matrix/schema/validation_error.rb +234 -0
  42. data/lib/protocol/matrix/schema.rb +170 -0
  43. data/lib/protocol/matrix/secret_storage.rb +535 -0
  44. data/lib/protocol/matrix/signing.rb +278 -0
  45. data/lib/protocol/matrix.rb +25 -0
  46. metadata +43 -136
  47. data/lib/async/discord/api/path_tree.rb +0 -127
  48. data/lib/async/discord/api.rb +0 -151
  49. data/lib/async/discord/client.rb +0 -283
  50. data/lib/async/discord/error.rb +0 -84
  51. data/lib/async/discord/gateway.rb +0 -359
  52. data/lib/async/discord.rb +0 -15
  53. data/lib/async/matrix/application_service/bot.rb +0 -232
  54. data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
  55. data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
  56. data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
  57. data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
  58. data/lib/async/matrix/application_service/config/schema/config.json +0 -61
  59. data/lib/async/matrix/application_service/config/schema/database.json +0 -38
  60. data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
  61. data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
  62. data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
  63. data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
  64. data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
  65. data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
  66. data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
  67. data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
  68. data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
  69. data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
  70. data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
  71. data/lib/async/matrix/application_service/config/vivify.rb +0 -109
  72. data/lib/async/matrix/application_service/config.rb +0 -225
  73. data/lib/async/matrix/application_service/dispatcher.rb +0 -185
  74. data/lib/async/matrix/application_service/event.rb +0 -285
  75. data/lib/async/matrix/application_service/server.rb +0 -430
  76. data/lib/async/matrix/application_service/transaction.rb +0 -66
  77. data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
  78. data/lib/async/matrix/application_service/transaction_store.rb +0 -80
  79. data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
  80. data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
  81. data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
  82. data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
  83. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
  84. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
  85. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
  86. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
  87. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
  88. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
  89. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
  90. data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
  91. data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
  92. data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
  93. data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
  94. data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
  95. data/lib/async/matrix/bridge/discord/db.rb +0 -138
  96. data/lib/async/matrix/schema/registry.rb +0 -354
  97. data/lib/async/matrix/schema/validation_error.rb +0 -225
  98. data/lib/async/matrix/schema.rb +0 -170
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ module Protocol
7
+ module Matrix
8
+ # Schema-driven validation for Matrix events using the official matrix-org/matrix-spec
9
+ # YAML schemas and the json_schemer gem.
10
+ #
11
+ # Schemas are loaded lazily from data/matrix-spec/event-schemas/schema/ and cached
12
+ # for the process lifetime. This ensures validation is always up-to-date with the
13
+ # spec -- just re-run `bin/fetch-matrix-schemas` to pull the latest.
14
+ #
15
+ # # Look up a schema by event type
16
+ # Protocol::Matrix::Schema["m.room.message"] # => JSONSchemer::Schema
17
+ #
18
+ # # Validate an event hash
19
+ # Protocol::Matrix::Schema.valid?(event_hash) # => true/false
20
+ # Protocol::Matrix::Schema.validate(event_hash) # => [errors]
21
+ #
22
+ # # List all known event types
23
+ # Protocol::Matrix::Schema.event_types # => ["m.accepted_terms", "m.call.answer", ...]
24
+ #
25
+ module Schema
26
+ class << self
27
+ # Look up a schema by Matrix event type.
28
+ # @param event_type [String] e.g. "m.room.message"
29
+ # @return [JSONSchemer::Schema, nil]
30
+ def [](event_type)
31
+ Registry.instance[event_type]
32
+ end
33
+
34
+ # Look up a variant schema.
35
+ # @param event_type [String] e.g. "m.room.message"
36
+ # @param subtype [String] e.g. "m.text"
37
+ # @return [JSONSchemer::Schema, nil]
38
+ def variant(event_type, subtype)
39
+ Registry.instance.variant(event_type, subtype)
40
+ end
41
+
42
+ # Validate an event hash against its schema.
43
+ # @param event_hash [Hash] the raw event data (string keys)
44
+ # @return [Array<Hash>] array of error hashes (empty if valid)
45
+ def validate(event_hash)
46
+ Registry.instance.validate(event_hash)
47
+ end
48
+
49
+ # Boolean validation.
50
+ # @param event_hash [Hash] the raw event data (string keys)
51
+ # @return [Boolean]
52
+ def valid?(event_hash)
53
+ Registry.instance.valid?(event_hash)
54
+ end
55
+
56
+ # All known base event types.
57
+ # @return [Array<String>] sorted
58
+ def event_types
59
+ Registry.instance.event_types
60
+ end
61
+
62
+ # All known variant types as [event_type, subtype] pairs.
63
+ # @return [Array<Array(String, String)>]
64
+ def variant_types
65
+ Registry.instance.variant_types
66
+ end
67
+
68
+ # Content properties defined by the schema for a given event type.
69
+ # @return [Array<String>]
70
+ def content_properties(event_type)
71
+ Registry.instance.content_properties(event_type)
72
+ end
73
+
74
+ # Parse a raw event hash into a schema-aware Event.
75
+ # @param event_hash [Hash] the raw event data (string keys)
76
+ # @return [Event]
77
+ def parse(event_hash)
78
+ Event.new(event_hash)
79
+ end
80
+
81
+ # Total schemas loaded (base + variants).
82
+ # @return [Integer]
83
+ def size
84
+ Registry.instance.size
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ __END__
92
+ describe "Protocol::Matrix::Schema" do
93
+ it "looks up schemas by event type" do
94
+ schema = Protocol::Matrix::Schema["m.room.message"]
95
+ schema.should.not.be.nil
96
+ schema.should.be.kind_of JSONSchemer::Schema
97
+ end
98
+
99
+ it "returns nil for unknown event types" do
100
+ Protocol::Matrix::Schema["m.fake.event"].should.be.nil
101
+ end
102
+
103
+ it "validates event hashes" do
104
+ valid_event = {
105
+ "type" => "m.room.message",
106
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
107
+ "event_id" => "$abc123",
108
+ "sender" => "@alice:example.org",
109
+ "origin_server_ts" => 1234567890,
110
+ "room_id" => "!room:example.org"
111
+ }
112
+ Protocol::Matrix::Schema.valid?(valid_event).should == true
113
+ end
114
+
115
+ it "returns errors for invalid events" do
116
+ invalid_event = {
117
+ "type" => "m.room.message",
118
+ "content" => {"msgtype" => "m.text"},
119
+ "event_id" => "$abc123",
120
+ "sender" => "@alice:example.org",
121
+ "origin_server_ts" => 1234567890,
122
+ "room_id" => "!room:example.org"
123
+ }
124
+ errors = Protocol::Matrix::Schema.validate(invalid_event)
125
+ errors.should.not.be.empty
126
+ end
127
+
128
+ it "lists event types" do
129
+ types = Protocol::Matrix::Schema.event_types
130
+ types.should.be.kind_of Array
131
+ types.should.include "m.room.message"
132
+ types.should.include "m.room.member"
133
+ end
134
+
135
+ it "lists variant types" do
136
+ variants = Protocol::Matrix::Schema.variant_types
137
+ variants.should.not.be.empty
138
+ end
139
+
140
+ it "looks up variant schemas" do
141
+ schema = Protocol::Matrix::Schema.variant("m.room.message", "m.text")
142
+ schema.should.not.be.nil
143
+ end
144
+
145
+ it "returns content properties" do
146
+ props = Protocol::Matrix::Schema.content_properties("m.room.message")
147
+ props.should.include "msgtype"
148
+ props.should.include "body"
149
+ end
150
+
151
+ it "reports total schema count" do
152
+ Protocol::Matrix::Schema.size.should.be > 50
153
+ end
154
+
155
+ it "parses a raw hash into a schema-aware Event" do
156
+ event = Protocol::Matrix::Schema.parse({
157
+ "type" => "m.room.message",
158
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
159
+ "event_id" => "$abc123",
160
+ "sender" => "@alice:example.org",
161
+ "origin_server_ts" => 1234567890,
162
+ "room_id" => "!room:example.org"
163
+ })
164
+ event.should.be.kind_of Protocol::Matrix::Event
165
+ event.type.should == "m.room.message"
166
+ event.content.body.should == "hello"
167
+ event.valid?.should == true
168
+ event.schema.should.not.be.nil
169
+ end
170
+ end
@@ -0,0 +1,535 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require "openssl"
7
+
8
+ require_relative "error"
9
+
10
+ module Protocol
11
+ module Matrix
12
+ # Secure Secret Storage and Sharing -- "4S" -- the account-data vault that
13
+ # holds secrets like the megolm backup key, unlocked by the user's recovery
14
+ # key or passphrase.
15
+ #
16
+ # `m.secret_storage.v1.aes-hmac-sha2`, implemented on OpenSSL alone:
17
+ # HKDF-SHA256, AES-256-CTR, HMAC-SHA256 and PBKDF2-HMAC-SHA512 are all
18
+ # stdlib, so this adds no dependency and holds no key material. Nothing here
19
+ # is novel cryptography -- it is the same construction every Matrix client
20
+ # implements, and the point of having it is that the recovery key is the
21
+ # only way into history that predates a device.
22
+ module SecretStorage
23
+ ALGORITHM = "m.secret_storage.v1.aes-hmac-sha2"
24
+ PASSPHRASE_ALGORITHM = "m.pbkdf2"
25
+
26
+ # "a salt of 32 bytes of 0, and the empty string as the info" -- fixed by
27
+ # the spec throughout 4S. The per-secret `info` is the secret's NAME,
28
+ # which is what stops one secret's ciphertext being replayed as another.
29
+ ZERO_SALT = ("\x00" * 32).b
30
+
31
+ # The key check encrypts "a message consisting of 32 bytes of 0".
32
+ ZERO_MESSAGE = ("\x00" * 32).b
33
+
34
+ # A recovery key is the raw key wrapped in a version prefix and a parity
35
+ # byte, so a mistyped one fails loudly instead of decrypting to garbage.
36
+ # https://spec.matrix.org/latest/appendices/#cryptographic-key-representation
37
+ RECOVERY_KEY_PREFIX = [0x8B, 0x01].freeze
38
+ KEY_LENGTH = 32
39
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
40
+
41
+ DEFAULT_PASSPHRASE_BITS = 256
42
+
43
+ class Error < Protocol::Matrix::Error; end
44
+
45
+ # The MAC did not verify: the wrong key, or tampered ciphertext.
46
+ class MacError < Error; end
47
+
48
+ # The input is neither a recovery key nor usable as a passphrase for this
49
+ # account's key.
50
+ class UnusableKeyError < Error; end
51
+
52
+ # ── Getting to the storage key ──────────────────────────────────────────
53
+
54
+ # The 32-byte storage key from whatever the user typed.
55
+ #
56
+ # Tries the recovery-key encoding first and falls back to treating the
57
+ # input as a passphrase, which is only possible when the account's key
58
+ # entry records the PBKDF2 parameters. Then VERIFIES it before returning,
59
+ # so a typo reports itself here rather than as a wall of undecryptable
60
+ # sessions later.
61
+ def self.storage_key(input, key_info)
62
+ (decode_recovery_key(input) || passphrase_key(input, key_info)).tap do |key|
63
+ unless valid_key?(key, key_info)
64
+ raise UnusableKeyError, "that key does not match this account's secret storage"
65
+ end
66
+ end
67
+ end
68
+
69
+ def self.passphrase_key(input, key_info)
70
+ passphrase = key_info["passphrase"]
71
+
72
+ if passphrase.nil?
73
+ raise UnusableKeyError,
74
+ "not a valid recovery key, and this account has no passphrase configured"
75
+ end
76
+
77
+ derive_from_passphrase(
78
+ input,
79
+ salt: passphrase["salt"],
80
+ iterations: passphrase["iterations"],
81
+ bits: passphrase["bits"] || DEFAULT_PASSPHRASE_BITS,
82
+ )
83
+ end
84
+
85
+ # PBKDF2 "with SHA-512 as the hash", per the m.pbkdf2 algorithm.
86
+ def self.derive_from_passphrase(passphrase, salt:, iterations:, bits: DEFAULT_PASSPHRASE_BITS)
87
+ OpenSSL::PKCS5.pbkdf2_hmac(
88
+ passphrase.to_s,
89
+ salt.to_s,
90
+ iterations.to_i,
91
+ bits / 8,
92
+ "SHA512",
93
+ )
94
+ end
95
+
96
+ # ── Key representation ──────────────────────────────────────────────────
97
+
98
+ # The 32-byte key from a displayed recovery key, or nil when the input is
99
+ # not one -- a bad prefix, a bad length, or a parity byte that disagrees.
100
+ # Nil rather than an exception, because "this is a passphrase instead" is
101
+ # an ordinary answer.
102
+ def self.decode_recovery_key(input)
103
+ bytes = base58_decode(input.to_s.gsub(/\s+/, ""))
104
+
105
+ if bytes.nil? || bytes.length != RECOVERY_KEY_PREFIX.length + KEY_LENGTH + 1
106
+ nil
107
+ elsif bytes[0, RECOVERY_KEY_PREFIX.length] != RECOVERY_KEY_PREFIX
108
+ nil
109
+ elsif bytes[0..-2].reduce(0) { |parity, byte| parity ^ byte } != bytes[-1]
110
+ nil
111
+ else
112
+ bytes[RECOVERY_KEY_PREFIX.length, KEY_LENGTH].pack("C*")
113
+ end
114
+ end
115
+
116
+ # A 32-byte key as the string a user is shown: prefix, parity byte,
117
+ # base58, "a space is added after every 4th character".
118
+ def self.encode_recovery_key(storage_key)
119
+ bytes = RECOVERY_KEY_PREFIX + storage_key.bytes
120
+ bytes << bytes.reduce(0) { |parity, byte| parity ^ byte }
121
+
122
+ base58_encode(bytes).scan(/.{1,4}/).join(" ")
123
+ end
124
+
125
+ # ── Secrets ─────────────────────────────────────────────────────────────
126
+
127
+ # HKDF-SHA256 to the two subkeys a secret is protected with: "The first 32
128
+ # bytes are used as the AES key, and the next 32 bytes are used as the MAC
129
+ # key."
130
+ def self.subkeys(storage_key, info)
131
+ okm = OpenSSL::KDF.hkdf(
132
+ storage_key,
133
+ salt: ZERO_SALT,
134
+ info: info.to_s,
135
+ length: 64,
136
+ hash: "SHA256",
137
+ )
138
+
139
+ [okm[0, 32], okm[32, 32]]
140
+ end
141
+
142
+ # Decrypt one account-data secret.
143
+ #
144
+ # @parameter name [String] the secret's name, which is the HKDF info.
145
+ # @raises [MacError] unless the MAC verifies -- a wrong key must fail here
146
+ # rather than produce plausible rubbish.
147
+ def self.decrypt_secret(storage_key, name:, ciphertext:, iv:, mac:)
148
+ aes_key, mac_key = subkeys(storage_key, name)
149
+ raw = decode64(ciphertext.to_s)
150
+
151
+ unless mac_equal?(OpenSSL::HMAC.digest("SHA256", mac_key, raw), mac)
152
+ raise MacError, "secret #{name} failed its MAC -- wrong recovery key?"
153
+ end
154
+
155
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
156
+ cipher.decrypt
157
+ cipher.key = aes_key
158
+ cipher.iv = decode64(iv.to_s)
159
+ cipher.update(raw) + cipher.final
160
+ end
161
+
162
+ # Does +storage_key+ open this key entry?
163
+ #
164
+ # The entry records the MAC of 32 zero bytes encrypted under an EMPTY info
165
+ # string, so reproducing it proves the key without touching a real secret.
166
+ # The `iv` and `mac` properties are optional: "If they are not present,
167
+ # clients must assume that the key is valid", which is why an entry
168
+ # without them answers true.
169
+ def self.valid_key?(storage_key, key_info)
170
+ if storage_key.nil?
171
+ false
172
+ elsif key_info.nil? || key_info["mac"].nil? || key_info["iv"].nil?
173
+ true
174
+ else
175
+ aes_key, mac_key = subkeys(storage_key, "")
176
+
177
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
178
+ cipher.encrypt
179
+ cipher.key = aes_key
180
+ cipher.iv = decode64(key_info["iv"])
181
+ ciphertext = cipher.update(ZERO_MESSAGE) + cipher.final
182
+
183
+ mac_equal?(OpenSSL::HMAC.digest("SHA256", mac_key, ciphertext), key_info["mac"])
184
+ end
185
+ end
186
+
187
+ # Compare a raw digest against a base64 MAC.
188
+ #
189
+ # DECODED, then compared in constant time. Padding is inconsistent between
190
+ # implementations, so comparing the encoded strings would reject valid
191
+ # MACs; and this gates a secret, so the comparison must not leak where it
192
+ # diverged.
193
+ def self.mac_equal?(digest, encoded)
194
+ expected = decode64(encoded.to_s)
195
+
196
+ if expected.empty?
197
+ false
198
+ else
199
+ OpenSSL.secure_compare(digest[0, expected.bytesize].to_s, expected)
200
+ end
201
+ end
202
+
203
+ # ── base64 ──────────────────────────────────────────────────────────────
204
+ #
205
+ # pack/unpack rather than the `base64` gem, which is a BUNDLED gem from
206
+ # Ruby 3.4 and so has to be declared as a dependency to be required. The
207
+ # protocol layer carries no dependencies, and these two lines are the
208
+ # whole reason it would have needed one.
209
+
210
+ # Matrix encodes these UNPADDED, so the padding is restored before
211
+ # decoding -- "m0" is strict and rejects a short final group. Invalid
212
+ # base64 decodes to empty rather than raising: it arrives from the
213
+ # network, so it is bad input, not a bug.
214
+ def self.decode64(string)
215
+ text = string.to_s
216
+ text += "=" * ((4 - (text.length % 4)) % 4)
217
+ text.unpack1("m0").to_s
218
+ rescue ArgumentError
219
+ ""
220
+ end
221
+
222
+ def self.encode64(bytes) = [bytes].pack("m0")
223
+
224
+ def self.encode64_unpadded(bytes) = encode64(bytes).delete("=")
225
+
226
+ # ── base58 ──────────────────────────────────────────────────────────────
227
+ # The Bitcoin alphabet, as the spec specifies.
228
+
229
+ # An array of byte values, or nil when the input contains a character
230
+ # outside the alphabet.
231
+ def self.base58_decode(string)
232
+ if string.empty?
233
+ nil
234
+ else
235
+ number = 0
236
+ outside = false
237
+
238
+ string.each_char do |char|
239
+ index = BASE58_ALPHABET.index(char)
240
+
241
+ if index.nil?
242
+ outside = true
243
+ break
244
+ end
245
+
246
+ number = (number * 58) + index
247
+ end
248
+
249
+ if outside
250
+ nil
251
+ else
252
+ leading_zeroes(string) + digits(number)
253
+ end
254
+ end
255
+ end
256
+
257
+ def self.base58_encode(bytes)
258
+ number = bytes.reduce(0) { |total, byte| (total << 8) | byte }
259
+ out = +""
260
+
261
+ while number.positive?
262
+ number, remainder = number.divmod(58)
263
+ out.prepend(BASE58_ALPHABET[remainder])
264
+ end
265
+
266
+ bytes.each { |byte| byte.zero? ? out.prepend("1") : break }
267
+ out
268
+ end
269
+
270
+ def self.digits(number)
271
+ [].tap do |bytes|
272
+ while number.positive?
273
+ bytes.unshift(number & 0xFF)
274
+ number >>= 8
275
+ end
276
+ end
277
+ end
278
+
279
+ # A leading "1" is a leading zero byte, which the arithmetic above cannot
280
+ # represent.
281
+ def self.leading_zeroes(string)
282
+ [].tap do |zeroes|
283
+ string.each_char { |char| char == "1" ? zeroes << 0 : break }
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+
290
+ __END__
291
+ describe "Protocol::Matrix::SecretStorage" do
292
+ S = Protocol::Matrix::SecretStorage
293
+
294
+ def storage_key = ("k" * 32).b
295
+
296
+ # Encrypt a secret by following the spec's steps, so the specs below are a
297
+ # genuine round trip rather than assertions about constants.
298
+ def encrypt_secret(key, name, plaintext, iv: ("i" * 16).b)
299
+ aes_key, mac_key = S.subkeys(key, name)
300
+
301
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
302
+ cipher.encrypt
303
+ cipher.key = aes_key
304
+ cipher.iv = iv
305
+ ciphertext = cipher.update(plaintext) + cipher.final
306
+
307
+ {
308
+ "ciphertext" => S.encode64(ciphertext),
309
+ "iv" => S.encode64(iv),
310
+ "mac" => S.encode64(OpenSSL::HMAC.digest("SHA256", mac_key, ciphertext)),
311
+ }
312
+ end
313
+
314
+ # The key-check data: "the MAC of the result of encrypting 32 bytes of 0".
315
+ def key_info_for(key, iv: ("v" * 16).b)
316
+ aes_key, mac_key = S.subkeys(key, "")
317
+
318
+ cipher = OpenSSL::Cipher.new("aes-256-ctr")
319
+ cipher.encrypt
320
+ cipher.key = aes_key
321
+ cipher.iv = iv
322
+ ciphertext = cipher.update(("\x00" * 32).b) + cipher.final
323
+
324
+ {
325
+ "algorithm" => S::ALGORITHM,
326
+ "iv" => S.encode64(iv),
327
+ "mac" => S.encode64(OpenSSL::HMAC.digest("SHA256", mac_key, ciphertext)),
328
+ }
329
+ end
330
+
331
+ # ── Key representation ────────────────────────────────────────────────────
332
+
333
+ it "round-trips a recovery key" do
334
+ encoded = S.encode_recovery_key(storage_key)
335
+
336
+ S.decode_recovery_key(encoded).should == storage_key
337
+ end
338
+
339
+ # "A space is added after every 4th character."
340
+ it "groups the displayed key in fours" do
341
+ groups = S.encode_recovery_key(storage_key).split(" ")
342
+
343
+ groups[0..-2].map(&:length).uniq.should == [4]
344
+ (1..4).cover?(groups.last.length).should == true
345
+ end
346
+
347
+ it "disregards whitespace when reading a key back" do
348
+ encoded = S.encode_recovery_key(storage_key)
349
+
350
+ S.decode_recovery_key(encoded.delete(" ")).should == storage_key
351
+ S.decode_recovery_key(" #{encoded}\n").should == storage_key
352
+ end
353
+
354
+ # The parity byte is the point: a mistyped key must fail loudly rather than
355
+ # decrypt to garbage.
356
+ it "rejects a key whose parity byte disagrees" do
357
+ bytes = S::RECOVERY_KEY_PREFIX + storage_key.bytes
358
+ bytes << (bytes.reduce(0) { |parity, byte| parity ^ byte } ^ 0xFF)
359
+
360
+ S.decode_recovery_key(S.base58_encode(bytes)).should.be.nil
361
+ end
362
+
363
+ it "rejects a key with the wrong prefix" do
364
+ bytes = [0x00, 0x01] + storage_key.bytes
365
+ bytes << bytes.reduce(0) { |parity, byte| parity ^ byte }
366
+
367
+ S.decode_recovery_key(S.base58_encode(bytes)).should.be.nil
368
+ end
369
+
370
+ it "rejects a key of the wrong length" do
371
+ bytes = S::RECOVERY_KEY_PREFIX + ("k" * 16).bytes
372
+ bytes << bytes.reduce(0) { |parity, byte| parity ^ byte }
373
+
374
+ S.decode_recovery_key(S.base58_encode(bytes)).should.be.nil
375
+ end
376
+
377
+ # Nil rather than raising: "this is a passphrase instead" is an ordinary
378
+ # answer, not an error.
379
+ it "answers nil for something that is not a recovery key at all" do
380
+ S.decode_recovery_key("hunter2").should.be.nil
381
+ S.decode_recovery_key("not base58 ~!@").should.be.nil
382
+ S.decode_recovery_key("").should.be.nil
383
+ end
384
+
385
+ it "uses the bitcoin alphabet, which excludes 0, O, I and l" do
386
+ %w[0 O I l].each { |char| S::BASE58_ALPHABET.include?(char).should == false }
387
+ end
388
+
389
+ # ── Passphrases ───────────────────────────────────────────────────────────
390
+
391
+ # "PBKDF2 with SHA-512 as the hash"
392
+ it "derives a key from a passphrase with PBKDF2-HMAC-SHA512" do
393
+ derived = S.derive_from_passphrase("hunter2", salt: "salty", iterations: 1000)
394
+
395
+ derived.bytesize.should == 32
396
+ derived.should == OpenSSL::PKCS5.pbkdf2_hmac("hunter2", "salty", 1000, 32, "SHA512")
397
+ end
398
+
399
+ it "honours the bits parameter, defaulting to 256" do
400
+ S.derive_from_passphrase("p", salt: "s", iterations: 10).bytesize.should == 32
401
+ S.derive_from_passphrase("p", salt: "s", iterations: 10, bits: 512).bytesize.should == 64
402
+ end
403
+
404
+ # ── Secrets ───────────────────────────────────────────────────────────────
405
+
406
+ it "decrypts a secret encrypted to the spec" do
407
+ encrypted = encrypt_secret(storage_key, "m.megolm_backup.v1", "the backup key")
408
+
409
+ S.decrypt_secret(
410
+ storage_key,
411
+ name: "m.megolm_backup.v1",
412
+ ciphertext: encrypted["ciphertext"],
413
+ iv: encrypted["iv"],
414
+ mac: encrypted["mac"],
415
+ ).should == "the backup key"
416
+ end
417
+
418
+ # The secret's NAME is the HKDF info, which is what stops one secret's
419
+ # ciphertext being replayed as a different secret.
420
+ it "refuses a secret decrypted under the wrong name" do
421
+ encrypted = encrypt_secret(storage_key, "m.megolm_backup.v1", "the backup key")
422
+
423
+ lambda {
424
+ S.decrypt_secret(
425
+ storage_key,
426
+ name: "m.cross_signing.master",
427
+ ciphertext: encrypted["ciphertext"],
428
+ iv: encrypted["iv"],
429
+ mac: encrypted["mac"],
430
+ )
431
+ }.should.raise(Protocol::Matrix::SecretStorage::MacError)
432
+ end
433
+
434
+ # A wrong key must fail here rather than produce plausible rubbish.
435
+ it "refuses a secret under the wrong key" do
436
+ encrypted = encrypt_secret(storage_key, "secret", "value")
437
+
438
+ lambda {
439
+ S.decrypt_secret(
440
+ ("x" * 32).b,
441
+ name: "secret",
442
+ ciphertext: encrypted["ciphertext"],
443
+ iv: encrypted["iv"],
444
+ mac: encrypted["mac"],
445
+ )
446
+ }.should.raise(Protocol::Matrix::SecretStorage::MacError)
447
+ end
448
+
449
+ it "refuses tampered ciphertext" do
450
+ encrypted = encrypt_secret(storage_key, "secret", "value")
451
+
452
+ lambda {
453
+ S.decrypt_secret(
454
+ storage_key,
455
+ name: "secret",
456
+ ciphertext: S.encode64("tampered"),
457
+ iv: encrypted["iv"],
458
+ mac: encrypted["mac"],
459
+ )
460
+ }.should.raise(Protocol::Matrix::SecretStorage::MacError)
461
+ end
462
+
463
+ it "splits HKDF output into an AES key and a MAC key" do
464
+ aes_key, mac_key = S.subkeys(storage_key, "name")
465
+
466
+ aes_key.bytesize.should == 32
467
+ mac_key.bytesize.should == 32
468
+ aes_key.should.not == mac_key
469
+ end
470
+
471
+ # ── Verifying a key ───────────────────────────────────────────────────────
472
+
473
+ it "accepts the key the entry was built from" do
474
+ S.valid_key?(storage_key, key_info_for(storage_key)).should == true
475
+ end
476
+
477
+ it "rejects a key the entry was not built from" do
478
+ S.valid_key?(("x" * 32).b, key_info_for(storage_key)).should == false
479
+ end
480
+
481
+ # "these properties are optional. If they are not present, clients must
482
+ # assume that the key is valid."
483
+ it "assumes a key is valid when the entry records no check" do
484
+ S.valid_key?(storage_key, {"algorithm" => S::ALGORITHM}).should == true
485
+ S.valid_key?(storage_key, {}).should == true
486
+ S.valid_key?(storage_key, nil).should == true
487
+ end
488
+
489
+ it "rejects a nil key outright" do
490
+ S.valid_key?(nil, key_info_for(storage_key)).should == false
491
+ end
492
+
493
+ # ── storage_key ───────────────────────────────────────────────────────────
494
+
495
+ it "accepts a recovery key and verifies it" do
496
+ S.storage_key(S.encode_recovery_key(storage_key), key_info_for(storage_key))
497
+ .should == storage_key
498
+ end
499
+
500
+ it "falls back to treating the input as a passphrase" do
501
+ derived = S.derive_from_passphrase("hunter2", salt: "salty", iterations: 100)
502
+ info = key_info_for(derived).merge(
503
+ "passphrase" => {"algorithm" => "m.pbkdf2", "salt" => "salty", "iterations" => 100},
504
+ )
505
+
506
+ S.storage_key("hunter2", info).should == derived
507
+ end
508
+
509
+ # A typo must report itself here, not as a wall of undecryptable sessions.
510
+ it "refuses a recovery key that does not match the account" do
511
+ lambda {
512
+ S.storage_key(S.encode_recovery_key(("x" * 32).b), key_info_for(storage_key))
513
+ }.should.raise(Protocol::Matrix::SecretStorage::UnusableKeyError)
514
+ end
515
+
516
+ it "says plainly when a passphrase cannot be used" do
517
+ lambda {
518
+ S.storage_key("hunter2", key_info_for(storage_key))
519
+ }.should.raise(Protocol::Matrix::SecretStorage::UnusableKeyError)
520
+ end
521
+
522
+ # ── MAC comparison ────────────────────────────────────────────────────────
523
+
524
+ # Padding is inconsistent between implementations, so comparing the encoded
525
+ # strings would reject valid MACs.
526
+ it "compares MACs by their decoded bytes, padded or not" do
527
+ digest = OpenSSL::HMAC.digest("SHA256", "key", "message")
528
+
529
+ S.mac_equal?(digest, S.encode64(digest)).should == true
530
+ S.mac_equal?(digest, S.encode64(digest).delete("=")).should == true
531
+ S.mac_equal?(digest, S.encode64("different")).should == false
532
+ S.mac_equal?(digest, "").should == false
533
+ S.mac_equal?(digest, nil).should == false
534
+ end
535
+ end