async-matrix 3.0.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/lib/async/matrix/app_service_client.rb +213 -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 +77 -5
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -9
- data/lib/async/matrix/version.rb +3 -3
- data/lib/async/matrix.rb +16 -0
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/{async → protocol}/matrix/content.rb +9 -9
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/{async → protocol}/matrix/event.rb +28 -17
- 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/{async → protocol}/matrix/schema/registry.rb +17 -17
- data/lib/{async → protocol}/matrix/schema/validation_error.rb +14 -12
- data/lib/{async → protocol}/matrix/schema.rb +17 -17
- 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 +22 -7
|
@@ -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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Released under the Apache License, Version 2.0.
|
|
4
4
|
# Copyright, 2026, by General Intelligence Systems.
|
|
5
5
|
|
|
6
|
-
module
|
|
6
|
+
module Protocol
|
|
7
7
|
module Matrix
|
|
8
8
|
# Represents a Matrix event, however it arrived — a /sync response, an
|
|
9
9
|
# application service transaction, or a hash you built yourself.
|
|
@@ -71,14 +71,25 @@ module Async
|
|
|
71
71
|
|
|
72
72
|
# Is this a state event? (has a state_key)
|
|
73
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
|
|
74
85
|
end
|
|
75
86
|
end
|
|
76
87
|
end
|
|
77
88
|
|
|
78
89
|
__END__
|
|
79
|
-
describe "
|
|
90
|
+
describe "Protocol::Matrix::Event" do
|
|
80
91
|
it "parses all event fields" do
|
|
81
|
-
event =
|
|
92
|
+
event = Protocol::Matrix::Event.new({
|
|
82
93
|
"type" => "m.room.message",
|
|
83
94
|
"sender" => "@alice:example.com",
|
|
84
95
|
"room_id" => "!abc:example.com",
|
|
@@ -95,43 +106,43 @@ __END__
|
|
|
95
106
|
event.event_id.should == "$evt1"
|
|
96
107
|
event.origin_server_ts.should == 1234567890
|
|
97
108
|
event.unsigned.should == {"age" => 1000}
|
|
98
|
-
event.content.should.be.kind_of
|
|
109
|
+
event.content.should.be.kind_of Protocol::Matrix::Content
|
|
99
110
|
event.content.body.should == "hi"
|
|
100
111
|
end
|
|
101
112
|
|
|
102
113
|
it "defaults content to empty Content when missing" do
|
|
103
|
-
event =
|
|
104
|
-
event.content.should.be.kind_of
|
|
114
|
+
event = Protocol::Matrix::Event.new({"type" => "m.room.message"})
|
|
115
|
+
event.content.should.be.kind_of Protocol::Matrix::Content
|
|
105
116
|
event.content.body.should.be.nil
|
|
106
117
|
end
|
|
107
118
|
|
|
108
119
|
it "exposes the raw hash" do
|
|
109
120
|
data = {"type" => "m.room.message", "content" => {"body" => "hi", "msgtype" => "m.text"}}
|
|
110
|
-
event =
|
|
121
|
+
event = Protocol::Matrix::Event.new(data)
|
|
111
122
|
event.raw.should.equal data
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
it "detects state events" do
|
|
115
|
-
state =
|
|
126
|
+
state = Protocol::Matrix::Event.new({"type" => "m.room.member", "state_key" => "@a:b"})
|
|
116
127
|
state.state_event?.should == true
|
|
117
128
|
|
|
118
|
-
msg =
|
|
129
|
+
msg = Protocol::Matrix::Event.new({"type" => "m.room.message"})
|
|
119
130
|
msg.state_event?.should == false
|
|
120
131
|
end
|
|
121
132
|
|
|
122
133
|
it "returns the schema for known event types" do
|
|
123
|
-
event =
|
|
134
|
+
event = Protocol::Matrix::Event.new({"type" => "m.room.message", "content" => {}})
|
|
124
135
|
event.schema.should.not.be.nil
|
|
125
136
|
event.schema.should.be.kind_of JSONSchemer::Schema
|
|
126
137
|
end
|
|
127
138
|
|
|
128
139
|
it "returns nil schema for unknown event types" do
|
|
129
|
-
event =
|
|
140
|
+
event = Protocol::Matrix::Event.new({"type" => "m.custom.event", "content" => {}})
|
|
130
141
|
event.schema.should.be.nil
|
|
131
142
|
end
|
|
132
143
|
|
|
133
144
|
it "validates a correct event" do
|
|
134
|
-
event =
|
|
145
|
+
event = Protocol::Matrix::Event.new({
|
|
135
146
|
"type" => "m.room.message",
|
|
136
147
|
"content" => {"msgtype" => "m.text", "body" => "hello"},
|
|
137
148
|
"event_id" => "$abc123",
|
|
@@ -144,7 +155,7 @@ __END__
|
|
|
144
155
|
end
|
|
145
156
|
|
|
146
157
|
it "rejects an invalid event" do
|
|
147
|
-
event =
|
|
158
|
+
event = Protocol::Matrix::Event.new({
|
|
148
159
|
"type" => "m.room.message",
|
|
149
160
|
"content" => {"msgtype" => "m.text"},
|
|
150
161
|
"event_id" => "$abc123",
|
|
@@ -156,7 +167,7 @@ __END__
|
|
|
156
167
|
end
|
|
157
168
|
|
|
158
169
|
it "raises ValidationError from valid!" do
|
|
159
|
-
event =
|
|
170
|
+
event = Protocol::Matrix::Event.new({
|
|
160
171
|
"type" => "m.room.member",
|
|
161
172
|
"content" => {"membership" => "invalid_state"},
|
|
162
173
|
"state_key" => "@alice:example.org",
|
|
@@ -168,7 +179,7 @@ __END__
|
|
|
168
179
|
begin
|
|
169
180
|
event.valid!
|
|
170
181
|
raise "should have raised"
|
|
171
|
-
rescue
|
|
182
|
+
rescue Protocol::Matrix::Schema::ValidationError => e
|
|
172
183
|
e.message.should.include "m.room.member"
|
|
173
184
|
e.message.should.include "$abc123"
|
|
174
185
|
e.errors.should.not.be.empty
|
|
@@ -176,7 +187,7 @@ __END__
|
|
|
176
187
|
end
|
|
177
188
|
|
|
178
189
|
it "is lenient with unknown event types" do
|
|
179
|
-
event =
|
|
190
|
+
event = Protocol::Matrix::Event.new({
|
|
180
191
|
"type" => "com.custom.event",
|
|
181
192
|
"content" => {"anything" => "goes"},
|
|
182
193
|
"event_id" => "$x",
|
|
@@ -187,7 +198,7 @@ __END__
|
|
|
187
198
|
end
|
|
188
199
|
|
|
189
200
|
it "returns content properties for known types" do
|
|
190
|
-
event =
|
|
201
|
+
event = Protocol::Matrix::Event.new({
|
|
191
202
|
"type" => "m.room.member",
|
|
192
203
|
"content" => {"membership" => "join"}
|
|
193
204
|
})
|
|
@@ -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
|