async-matrix 2.1.0-aarch64-linux → 3.0.1-aarch64-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.
- checksums.yaml +4 -4
- data/README.md +33 -58
- data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/api/chain.rb +120 -94
- data/lib/async/matrix/api/concat.rb +28 -22
- data/lib/async/matrix/api/path_tree.rb +71 -44
- data/lib/async/matrix/api.rb +6 -1
- data/lib/async/matrix/app_service_client.rb +213 -0
- data/lib/async/matrix/auth_error.rb +20 -0
- data/lib/async/matrix/bad_json_error.rb +20 -0
- data/lib/async/matrix/client/encryption.rb +413 -0
- data/lib/async/matrix/client/rooms.rb +478 -0
- data/lib/async/matrix/client/sync.rb +508 -0
- data/lib/async/matrix/client.rb +217 -113
- data/lib/async/matrix/config/vivify.rb +107 -0
- data/lib/async/matrix/config.rb +184 -0
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/double_puppet_client.rb +1 -1
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -36
- data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
- data/lib/async/matrix/homeserver_error.rb +20 -0
- data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
- data/lib/async/matrix/media_client.rb +46 -35
- data/lib/async/matrix/not_found_error.rb +20 -0
- data/lib/async/matrix/notifier.rb +7 -3
- data/lib/async/matrix/response_too_large_error.rb +20 -0
- data/lib/async/matrix/version.rb +1 -1
- data/lib/async/matrix.rb +16 -7
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/protocol/matrix/content.rb +106 -0
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/protocol/matrix/event.rb +208 -0
- data/lib/protocol/matrix/key_backup.rb +347 -0
- data/lib/protocol/matrix/keys.rb +381 -0
- data/lib/protocol/matrix/message_batch.rb +482 -0
- data/lib/protocol/matrix/schema/registry.rb +381 -0
- data/lib/protocol/matrix/schema/validation_error.rb +234 -0
- data/lib/protocol/matrix/schema.rb +170 -0
- data/lib/protocol/matrix/secret_storage.rb +535 -0
- data/lib/protocol/matrix/signing.rb +278 -0
- data/lib/protocol/matrix.rb +25 -0
- metadata +43 -136
- data/lib/async/discord/api/path_tree.rb +0 -127
- data/lib/async/discord/api.rb +0 -151
- data/lib/async/discord/client.rb +0 -283
- data/lib/async/discord/error.rb +0 -84
- data/lib/async/discord/gateway.rb +0 -359
- data/lib/async/discord.rb +0 -15
- data/lib/async/matrix/application_service/bot.rb +0 -232
- data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
- data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
- data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
- data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
- data/lib/async/matrix/application_service/config/schema/config.json +0 -61
- data/lib/async/matrix/application_service/config/schema/database.json +0 -38
- data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
- data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
- data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
- data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
- data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
- data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
- data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
- data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
- data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
- data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
- data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
- data/lib/async/matrix/application_service/config/vivify.rb +0 -109
- data/lib/async/matrix/application_service/config.rb +0 -225
- data/lib/async/matrix/application_service/dispatcher.rb +0 -185
- data/lib/async/matrix/application_service/event.rb +0 -285
- data/lib/async/matrix/application_service/server.rb +0 -430
- data/lib/async/matrix/application_service/transaction.rb +0 -66
- data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
- data/lib/async/matrix/application_service/transaction_store.rb +0 -80
- data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
- data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
- data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
- data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
- data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
- data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
- data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
- data/lib/async/matrix/bridge/discord/db.rb +0 -138
- data/lib/async/matrix/schema/registry.rb +0 -354
- data/lib/async/matrix/schema/validation_error.rb +0 -225
- 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
|