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,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
|