async-matrix 2.1.0-x86_64-linux → 3.0.1-x86_64-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,49 @@
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
+ # THE base error for this gem, in both namespaces: Async::Matrix::Error is a
9
+ # constant pointing here, so `rescue Async::Matrix::Error` catches a format
10
+ # failure and a transport failure alike, and every subclass declared as
11
+ # `class AuthError < Error` inside Async::Matrix resolves to this class.
12
+ #
13
+ # It lives under Protocol:: because an error about bytes should not require
14
+ # there to have been a connection -- but the two namespaces ship in one gem,
15
+ # so there is no dependency direction to defend and no reason for consumers
16
+ # to need two rescues.
17
+ class Error < StandardError
18
+ # The Matrix errcode this maps to, when it maps to one. M_BAD_JSON and
19
+ # friends are spec vocabulary, so a format error is entitled to carry one;
20
+ # most carry none.
21
+ attr_reader :errcode
22
+
23
+ # The HTTP status, for the failures that came from one.
24
+ attr_reader :status
25
+
26
+ # TWO CALLING CONVENTIONS, deliberately, because this class serves both
27
+ # layers:
28
+ #
29
+ # Error.new("M_UNKNOWN_TOKEN", "token expired", status: 401)
30
+ # raise MalformedError, "megolm ciphertext must be a string"
31
+ #
32
+ # The first is how the transport has always raised, and changing it would
33
+ # break every caller. The second is what `raise Klass, "message"` does,
34
+ # which is how a format error reads naturally. A lone argument is the
35
+ # message; two are errcode then message.
36
+ def initialize(errcode = nil, message = nil, status: nil)
37
+ @status = status
38
+
39
+ if message.nil?
40
+ @errcode = nil
41
+ super(errcode)
42
+ else
43
+ @errcode = errcode
44
+ super(message)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,208 @@
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
+ # Represents a Matrix event, however it arrived — a /sync response, an
9
+ # application service transaction, or a hash you built yourself.
10
+ #
11
+ # Provides typed accessors for all envelope fields plus schema-driven
12
+ # validation using the official Matrix spec YAML schemas.
13
+ #
14
+ # event = Event.new(raw_hash)
15
+ # event.type # => "m.room.message"
16
+ # event.sender # => "@alice:example.org"
17
+ # event.content.body # => "hello"
18
+ # event.valid? # => true
19
+ # event.valid! # => true (or raises Schema::ValidationError)
20
+ #
21
+ class Event
22
+ attr_reader :type,
23
+ :sender,
24
+ :room_id,
25
+ :state_key,
26
+ :content,
27
+ :event_id,
28
+ :origin_server_ts,
29
+ :unsigned,
30
+ :raw
31
+
32
+ def initialize(data)
33
+ @raw = data
34
+ @type = data["type"]
35
+ @sender = data["sender"]
36
+ @room_id = data["room_id"]
37
+ @state_key = data["state_key"]
38
+ @event_id = data["event_id"]
39
+ @origin_server_ts = data["origin_server_ts"]
40
+ @unsigned = data["unsigned"]
41
+ @content = Content.new(data["content"] || {})
42
+ end
43
+
44
+ # The JSONSchemer::Schema for this event's type, or nil if unknown.
45
+ def schema = Schema[@type]
46
+
47
+ # Validate this event against its schema.
48
+ # Returns true if valid or if no schema exists (lenient).
49
+ def valid? = Schema.valid?(@raw)
50
+
51
+ # Validate this event against its schema.
52
+ # Raises Schema::ValidationError with detailed errors on failure.
53
+ # Returns true if valid or if no schema exists.
54
+ def valid!
55
+ errors = Schema.validate(@raw)
56
+
57
+ unless errors.empty?
58
+ raise Schema::ValidationError.new(
59
+ errors,
60
+ event_type: @type,
61
+ event_id: @event_id,
62
+ )
63
+ end
64
+
65
+ true
66
+ end
67
+
68
+ # Content property names defined by the schema for this event type.
69
+ # @return [Array<String>]
70
+ def content_properties = Schema.content_properties(@type)
71
+
72
+ # Is this a state event? (has a state_key)
73
+ def state_event? = !@state_key.nil?
74
+
75
+ # ── The shape MessageBatch yields ───────────────────────────────────────
76
+ #
77
+ # A batch mixes plaintext and encrypted events, and a consumer should not
78
+ # have to ask which class it is holding before reading #type or #content.
79
+ # So both answer the same three questions, and for a plaintext event the
80
+ # answers are the trivial ones: it was never encrypted, so there is
81
+ # nothing left to decrypt.
82
+
83
+ def encrypted? = false
84
+ def decrypted? = true
85
+ end
86
+ end
87
+ end
88
+
89
+ __END__
90
+ describe "Protocol::Matrix::Event" do
91
+ it "parses all event fields" do
92
+ event = Protocol::Matrix::Event.new({
93
+ "type" => "m.room.message",
94
+ "sender" => "@alice:example.com",
95
+ "room_id" => "!abc:example.com",
96
+ "state_key" => "",
97
+ "event_id" => "$evt1",
98
+ "origin_server_ts" => 1234567890,
99
+ "unsigned" => {"age" => 1000},
100
+ "content" => {"msgtype" => "m.text", "body" => "hi"}
101
+ })
102
+ event.type.should == "m.room.message"
103
+ event.sender.should == "@alice:example.com"
104
+ event.room_id.should == "!abc:example.com"
105
+ event.state_key.should == ""
106
+ event.event_id.should == "$evt1"
107
+ event.origin_server_ts.should == 1234567890
108
+ event.unsigned.should == {"age" => 1000}
109
+ event.content.should.be.kind_of Protocol::Matrix::Content
110
+ event.content.body.should == "hi"
111
+ end
112
+
113
+ it "defaults content to empty Content when missing" do
114
+ event = Protocol::Matrix::Event.new({"type" => "m.room.message"})
115
+ event.content.should.be.kind_of Protocol::Matrix::Content
116
+ event.content.body.should.be.nil
117
+ end
118
+
119
+ it "exposes the raw hash" do
120
+ data = {"type" => "m.room.message", "content" => {"body" => "hi", "msgtype" => "m.text"}}
121
+ event = Protocol::Matrix::Event.new(data)
122
+ event.raw.should.equal data
123
+ end
124
+
125
+ it "detects state events" do
126
+ state = Protocol::Matrix::Event.new({"type" => "m.room.member", "state_key" => "@a:b"})
127
+ state.state_event?.should == true
128
+
129
+ msg = Protocol::Matrix::Event.new({"type" => "m.room.message"})
130
+ msg.state_event?.should == false
131
+ end
132
+
133
+ it "returns the schema for known event types" do
134
+ event = Protocol::Matrix::Event.new({"type" => "m.room.message", "content" => {}})
135
+ event.schema.should.not.be.nil
136
+ event.schema.should.be.kind_of JSONSchemer::Schema
137
+ end
138
+
139
+ it "returns nil schema for unknown event types" do
140
+ event = Protocol::Matrix::Event.new({"type" => "m.custom.event", "content" => {}})
141
+ event.schema.should.be.nil
142
+ end
143
+
144
+ it "validates a correct event" do
145
+ event = Protocol::Matrix::Event.new({
146
+ "type" => "m.room.message",
147
+ "content" => {"msgtype" => "m.text", "body" => "hello"},
148
+ "event_id" => "$abc123",
149
+ "sender" => "@alice:example.org",
150
+ "origin_server_ts" => 1234567890,
151
+ "room_id" => "!room:example.org"
152
+ })
153
+ event.valid?.should == true
154
+ event.valid!.should == true
155
+ end
156
+
157
+ it "rejects an invalid event" do
158
+ event = Protocol::Matrix::Event.new({
159
+ "type" => "m.room.message",
160
+ "content" => {"msgtype" => "m.text"},
161
+ "event_id" => "$abc123",
162
+ "sender" => "@alice:example.org",
163
+ "origin_server_ts" => 1234567890,
164
+ "room_id" => "!room:example.org"
165
+ })
166
+ event.valid?.should == false
167
+ end
168
+
169
+ it "raises ValidationError from valid!" do
170
+ event = Protocol::Matrix::Event.new({
171
+ "type" => "m.room.member",
172
+ "content" => {"membership" => "invalid_state"},
173
+ "state_key" => "@alice:example.org",
174
+ "event_id" => "$abc123",
175
+ "sender" => "@alice:example.org",
176
+ "origin_server_ts" => 1234567890,
177
+ "room_id" => "!room:example.org"
178
+ })
179
+ begin
180
+ event.valid!
181
+ raise "should have raised"
182
+ rescue Protocol::Matrix::Schema::ValidationError => e
183
+ e.message.should.include "m.room.member"
184
+ e.message.should.include "$abc123"
185
+ e.errors.should.not.be.empty
186
+ end
187
+ end
188
+
189
+ it "is lenient with unknown event types" do
190
+ event = Protocol::Matrix::Event.new({
191
+ "type" => "com.custom.event",
192
+ "content" => {"anything" => "goes"},
193
+ "event_id" => "$x",
194
+ "sender" => "@a:b"
195
+ })
196
+ event.valid?.should == true
197
+ event.valid!.should == true
198
+ end
199
+
200
+ it "returns content properties for known types" do
201
+ event = Protocol::Matrix::Event.new({
202
+ "type" => "m.room.member",
203
+ "content" => {"membership" => "join"}
204
+ })
205
+ props = event.content_properties
206
+ props.should.include "membership"
207
+ end
208
+ end
@@ -0,0 +1,347 @@
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 "json"
7
+ require "openssl"
8
+
9
+ require_relative "error"
10
+ require_relative "secret_storage"
11
+
12
+ module Protocol
13
+ module Matrix
14
+ # Server-side key backup: `m.megolm_backup.v1.curve25519-aes-sha2`.
15
+ #
16
+ # THIS IS THE ONLY WAY TO READ HISTORY THAT PREDATES A DEVICE. A device is
17
+ # only ever sent `m.room_key` for messages encrypted while it existed and
18
+ # was known to the sender; everything older stays ciphertext forever. Key
19
+ # backup is the account-wide escrow that fixes it: every client uploads its
20
+ # room keys encrypted to a public key whose private half lives in 4S behind
21
+ # the user's recovery key.
22
+ #
23
+ # recovery key ─▶ 4S storage key ─▶ m.megolm_backup.v1 (the backup PRIVATE key)
24
+ # │
25
+ # GET /room_keys/keys ─▶ per-session blobs ────┘ decrypt (X25519 + AES-CBC)
26
+ # │
27
+ # ▼
28
+ # an exported session key, importable as inbound
29
+ #
30
+ # Implemented on OpenSSL rather than through vodozemac, whose equivalent
31
+ # sits behind an `insecure-pk-encryption` feature flag. That label is about
32
+ # the SCHEME -- it does not authenticate the ciphertext's sender -- which is
33
+ # a property of the Matrix format we must interoperate with either way.
34
+ module KeyBackup
35
+ ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"
36
+
37
+ # "a salt of 32 bytes of 0, and with the empty string as the info"
38
+ ZERO_SALT = SecretStorage::ZERO_SALT
39
+
40
+ # "The first 8 bytes of the resulting MAC"
41
+ MAC_LENGTH = 8
42
+
43
+ # 80 bytes: "The first 32 bytes are used as the AES key, the next 32 bytes
44
+ # are used as the MAC key, and the last 16 bytes are used as the AES
45
+ # initialization vector."
46
+ DERIVED_LENGTH = 80
47
+
48
+ class Error < Protocol::Matrix::Error; end
49
+ class MacError < Error; end
50
+
51
+ # Decrypt one backed-up session blob to its BackedUpSessionData.
52
+ #
53
+ # @parameter private_key [String] the raw 32-byte backup private key, out
54
+ # of 4S.
55
+ # @parameter session_data [Hash] the blob's `ephemeral`, `ciphertext`, `mac`.
56
+ # @returns [Hash | Nil] the session data, or nil when the blob cannot be
57
+ # read at all (missing or empty fields).
58
+ # @raises [MacError] when the MAC does not verify.
59
+ #
60
+ # NIL RATHER THAN RAISING for an unreadable blob: a mature backup
61
+ # routinely contains keys written by clients with slightly different
62
+ # habits, and one bad entry must not abandon the thousands after it.
63
+ def self.decrypt_session(private_key, session_data)
64
+ if session_data.nil?
65
+ nil
66
+ else
67
+ ephemeral = SecretStorage.decode64(session_data["ephemeral"].to_s)
68
+ ciphertext = SecretStorage.decode64(session_data["ciphertext"].to_s)
69
+
70
+ if ephemeral.empty? || ciphertext.empty?
71
+ nil
72
+ else
73
+ decrypt_blob(
74
+ private_key,
75
+ ephemeral,
76
+ ciphertext,
77
+ session_data["mac"],
78
+ )
79
+ end
80
+ end
81
+ end
82
+
83
+ def self.decrypt_blob(private_key, ephemeral, ciphertext, mac)
84
+ aes_key, mac_key, iv = subkeys(private_key, ephemeral)
85
+
86
+ unless SecretStorage.mac_equal?(expected_mac(mac_key), mac)
87
+ raise MacError, "backed-up session failed its MAC -- wrong backup key?"
88
+ end
89
+
90
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
91
+ cipher.decrypt
92
+ cipher.key = aes_key
93
+ cipher.iv = iv
94
+
95
+ parse(cipher.update(ciphertext) + cipher.final)
96
+ rescue OpenSSL::OpenSSLError
97
+ nil
98
+ end
99
+
100
+ # THE MAC IS OVER AN EMPTY STRING, and that is not a mistake here.
101
+ #
102
+ # The spec's step 5 says to "pass an empty string through HMAC-SHA-256
103
+ # using the MAC key", with a warning attached: "Step 5 was intended to
104
+ # pass the raw encrypted data, but due to a bug in libolm, all
105
+ # implementations have since passed an empty string instead."
106
+ #
107
+ # So MACing the ciphertext -- the obvious reading, and what our previous
108
+ # implementation did -- rejects every blob any real client ever wrote. The
109
+ # consequence worth knowing is that this MAC proves only that we derived
110
+ # the same MAC key, i.e. that the ECDH matched: it is a key check, not an
111
+ # integrity check on the data. MSC4048 is the proposed fix.
112
+ def self.expected_mac(mac_key)
113
+ OpenSSL::HMAC.digest("SHA256", mac_key, "")[0, MAC_LENGTH]
114
+ end
115
+
116
+ # X25519 ECDH against the blob's ephemeral key, expanded to the three
117
+ # subkeys the scheme uses.
118
+ def self.subkeys(private_key, ephemeral)
119
+ shared = ecdh(private_key, ephemeral)
120
+ okm = OpenSSL::KDF.hkdf(
121
+ shared,
122
+ salt: ZERO_SALT,
123
+ info: "",
124
+ length: DERIVED_LENGTH,
125
+ hash: "SHA256",
126
+ )
127
+
128
+ [okm[0, 32], okm[32, 32], okm[64, 16]]
129
+ end
130
+
131
+ def self.ecdh(private_key, peer_public_key)
132
+ OpenSSL::PKey.new_raw_private_key("X25519", private_key)
133
+ .derive(OpenSSL::PKey.new_raw_public_key("X25519", peer_public_key))
134
+ end
135
+
136
+ # The public half of a backup private key, unpadded base64 -- what the
137
+ # backup version's auth_data publishes.
138
+ def self.public_key_for(private_key)
139
+ SecretStorage.encode64_unpadded(
140
+ OpenSSL::PKey.new_raw_private_key("X25519", private_key).raw_public_key,
141
+ )
142
+ end
143
+
144
+ # Is this private key the one this backup version was encrypted to?
145
+ #
146
+ # Worth checking before walking thousands of blobs: the alternative is
147
+ # every single one failing its MAC, which looks like a corrupt backup
148
+ # rather than the wrong key.
149
+ def self.key_matches?(private_key, auth_data)
150
+ public_key = (auth_data || {})["public_key"]
151
+
152
+ if public_key.nil?
153
+ false
154
+ else
155
+ public_key.delete("=") == public_key_for(private_key)
156
+ end
157
+ end
158
+
159
+ def self.parse(plaintext)
160
+ JSON.parse(plaintext)
161
+ rescue JSON::ParserError
162
+ nil
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ __END__
169
+ describe "Protocol::Matrix::KeyBackup" do
170
+ B = Protocol::Matrix::KeyBackup
171
+
172
+ def backup_keypair
173
+ key = OpenSSL::PKey.generate_key("X25519")
174
+ [key.raw_private_key, key.raw_public_key]
175
+ end
176
+
177
+ def session_payload
178
+ {
179
+ "algorithm" => "m.megolm.v1.aes-sha2",
180
+ "forwarding_curve25519_key_chain" => [],
181
+ "sender_key" => "RF3s+E7RkTQTGF2d8Deol0FkQvgII2aJDf3/Jp5mxVU",
182
+ "sender_claimed_keys" => {"ed25519" => "aj40p+aw64yPIdsxoog8jhPu9i7l7NcFRecuOQblE3Y"},
183
+ "session_key" => "AgAAAADxKHa9uFxcXzwYoNueL5Xqi69IkD4sni8Llf",
184
+ }
185
+ end
186
+
187
+ # Back a session up by following the spec's own five steps, so these specs
188
+ # are a real round trip. Step 5 is the one that matters: the MAC is over an
189
+ # EMPTY STRING, which is what every real implementation does.
190
+ def back_up(public_key, payload, mac_over: :empty)
191
+ ephemeral = OpenSSL::PKey.generate_key("X25519")
192
+ shared = ephemeral.derive(OpenSSL::PKey.new_raw_public_key("X25519", public_key))
193
+ okm = OpenSSL::KDF.hkdf(shared, salt: B::ZERO_SALT, info: "", length: 80, hash: "SHA256")
194
+ aes_key, mac_key, iv = okm[0, 32], okm[32, 32], okm[64, 16]
195
+
196
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
197
+ cipher.encrypt
198
+ cipher.key = aes_key
199
+ cipher.iv = iv
200
+ ciphertext = cipher.update(JSON.generate(payload)) + cipher.final
201
+
202
+ mac_input = mac_over == :empty ? "" : ciphertext
203
+
204
+ {
205
+ "ephemeral" => Protocol::Matrix::SecretStorage.encode64(ephemeral.raw_public_key),
206
+ "ciphertext" => Protocol::Matrix::SecretStorage.encode64(ciphertext),
207
+ "mac" => Protocol::Matrix::SecretStorage.encode64(
208
+ OpenSSL::HMAC.digest("SHA256", mac_key, mac_input)[0, B::MAC_LENGTH],
209
+ ),
210
+ }
211
+ end
212
+
213
+ # ── Decrypting a blob ─────────────────────────────────────────────────────
214
+
215
+ it "decrypts a backed-up session" do
216
+ private_key, public_key = backup_keypair
217
+
218
+ B.decrypt_session(private_key, back_up(public_key, session_payload))
219
+ .should == session_payload
220
+ end
221
+
222
+ # The field that carries the sending device's ed25519 key, which is how a
223
+ # restored session can still be attributed to a device.
224
+ it "recovers sender_claimed_keys with the session" do
225
+ private_key, public_key = backup_keypair
226
+
227
+ B.decrypt_session(private_key, back_up(public_key, session_payload))["sender_claimed_keys"]
228
+ .should == {"ed25519" => "aj40p+aw64yPIdsxoog8jhPu9i7l7NcFRecuOQblE3Y"}
229
+ end
230
+
231
+ # THE REGRESSION THAT MATTERED. The spec says step 5 MACs an empty string,
232
+ # with a warning that it "was intended to pass the raw encrypted data, but
233
+ # due to a bug in libolm, all implementations have since passed an empty
234
+ # string instead". MACing the ciphertext rejects every blob a real client
235
+ # ever wrote.
236
+ it "accepts the empty-string MAC every real client writes" do
237
+ private_key, public_key = backup_keypair
238
+
239
+ B.decrypt_session(private_key, back_up(public_key, session_payload, mac_over: :empty))
240
+ .should.not.be.nil
241
+ end
242
+
243
+ it "rejects a blob MACed over the ciphertext, which nothing produces" do
244
+ private_key, public_key = backup_keypair
245
+
246
+ lambda {
247
+ B.decrypt_session(private_key, back_up(public_key, session_payload, mac_over: :ciphertext))
248
+ }.should.raise(Protocol::Matrix::KeyBackup::MacError)
249
+ end
250
+
251
+ it "refuses a blob backed up to a different key" do
252
+ _private_key, public_key = backup_keypair
253
+ other_private, = backup_keypair
254
+
255
+ lambda {
256
+ B.decrypt_session(other_private, back_up(public_key, session_payload))
257
+ }.should.raise(Protocol::Matrix::KeyBackup::MacError)
258
+ end
259
+
260
+ # One unreadable entry must not abandon the thousands after it.
261
+ it "answers nil for a blob it cannot read at all" do
262
+ private_key, = backup_keypair
263
+
264
+ B.decrypt_session(private_key, nil).should.be.nil
265
+ B.decrypt_session(private_key, {}).should.be.nil
266
+ B.decrypt_session(private_key, {"ephemeral" => "", "ciphertext" => ""}).should.be.nil
267
+ end
268
+
269
+ it "answers nil when the plaintext is not JSON" do
270
+ private_key, public_key = backup_keypair
271
+ blob = back_up(public_key, session_payload)
272
+ # Same key, same MAC, but ciphertext that decrypts to rubbish.
273
+ ephemeral = Protocol::Matrix::SecretStorage.decode64(blob["ephemeral"])
274
+ aes_key, _mac_key, iv = B.subkeys(private_key, ephemeral)
275
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
276
+ cipher.encrypt
277
+ cipher.key = aes_key
278
+ cipher.iv = iv
279
+ blob["ciphertext"] = Protocol::Matrix::SecretStorage.encode64(cipher.update("not json") + cipher.final)
280
+
281
+ B.decrypt_session(private_key, blob).should.be.nil
282
+ end
283
+
284
+ # ── Subkeys ───────────────────────────────────────────────────────────────
285
+
286
+ # "The first 32 bytes are used as the AES key, the next 32 bytes are used as
287
+ # the MAC key, and the last 16 bytes are used as the AES initialization
288
+ # vector."
289
+ it "derives 80 bytes as an aes key, a mac key and an iv" do
290
+ private_key, = backup_keypair
291
+ _other_private, other_public = backup_keypair
292
+ aes_key, mac_key, iv = B.subkeys(private_key, other_public)
293
+
294
+ aes_key.bytesize.should == 32
295
+ mac_key.bytesize.should == 32
296
+ iv.bytesize.should == 16
297
+ end
298
+
299
+ # Both sides of an ECDH reach the same secret, which is what makes the
300
+ # scheme work at all.
301
+ it "agrees with the sender's ECDH" do
302
+ private_key, public_key = backup_keypair
303
+ ephemeral_private, ephemeral_public = backup_keypair
304
+
305
+ B.ecdh(private_key, ephemeral_public).should == B.ecdh(ephemeral_private, public_key)
306
+ end
307
+
308
+ # ── The backup version's public key ───────────────────────────────────────
309
+
310
+ it "derives the public half a backup version publishes" do
311
+ private_key, public_key = backup_keypair
312
+
313
+ B.public_key_for(private_key).should == Protocol::Matrix::SecretStorage.encode64(public_key).delete("=")
314
+ end
315
+
316
+ it "emits the public key unpadded" do
317
+ private_key, = backup_keypair
318
+
319
+ B.public_key_for(private_key).include?("=").should == false
320
+ end
321
+
322
+ # Worth checking before walking thousands of blobs: the alternative is every
323
+ # one failing its MAC, which looks like a corrupt backup rather than the
324
+ # wrong key.
325
+ it "recognises the key a backup version was made for" do
326
+ private_key, = backup_keypair
327
+ other_private, = backup_keypair
328
+ auth_data = {"public_key" => B.public_key_for(private_key)}
329
+
330
+ B.key_matches?(private_key, auth_data).should == true
331
+ B.key_matches?(other_private, auth_data).should == false
332
+ end
333
+
334
+ it "tolerates padding in the published public key" do
335
+ private_key, public_key = backup_keypair
336
+
337
+ B.key_matches?(private_key, {"public_key" => Protocol::Matrix::SecretStorage.encode64(public_key)})
338
+ .should == true
339
+ end
340
+
341
+ it "answers false for auth data with no public key" do
342
+ private_key, = backup_keypair
343
+
344
+ B.key_matches?(private_key, {}).should == false
345
+ B.key_matches?(private_key, nil).should == false
346
+ end
347
+ end