async-matrix 3.0.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.
- 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,182 @@
|
|
|
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
|
+
module Async
|
|
9
|
+
module Matrix
|
|
10
|
+
module E2EE
|
|
11
|
+
# The key every stored crypto object in this namespace is encrypted with
|
|
12
|
+
# at rest.
|
|
13
|
+
#
|
|
14
|
+
# A "pickle" is vodozemac's serialised snapshot of an Account or a
|
|
15
|
+
# session -- the only way ratchet state survives a restart:
|
|
16
|
+
#
|
|
17
|
+
# blob = session.pickle(pickle_key.to_s)
|
|
18
|
+
# session = E2EE::Session.from_pickle(blob, pickle_key.to_s)
|
|
19
|
+
#
|
|
20
|
+
# This key is what stops a copy of the database being a plaintext dump of
|
|
21
|
+
# the device's identity keys and every room key it holds.
|
|
22
|
+
#
|
|
23
|
+
# IT MUST NEVER CHANGE. Rotate or lose it and every pickle is
|
|
24
|
+
# undecryptable: the Account goes, every Megolm session goes, and all
|
|
25
|
+
# encrypted history becomes permanently unreadable unless server-side key
|
|
26
|
+
# backup happens to hold copies. There is no recovery path and no warning
|
|
27
|
+
# -- the symptom is simply that nothing decrypts any more.
|
|
28
|
+
#
|
|
29
|
+
# SO THE SECRET IS THE APPLICATION'S, not this library's. #derive exists
|
|
30
|
+
# because the shape requirement below catches everyone, not because a gem
|
|
31
|
+
# should own your key material.
|
|
32
|
+
class PickleKey
|
|
33
|
+
# vodozemac wants exactly 32 bytes AND rejects a binary string: hand it
|
|
34
|
+
# raw KDF output and it fails with "expected utf-8, got ASCII-8BIT".
|
|
35
|
+
LENGTH = 32
|
|
36
|
+
|
|
37
|
+
# 24 bytes of entropy base64s to exactly 32 ASCII characters, which
|
|
38
|
+
# satisfies both constraints at once -- 32 bytes, valid UTF-8, 192 bits.
|
|
39
|
+
# That arithmetic is the whole reason this class exists.
|
|
40
|
+
DERIVED_BYTES = 24
|
|
41
|
+
|
|
42
|
+
DEFAULT_INFO = "matrix device pickle"
|
|
43
|
+
|
|
44
|
+
class Error < StandardError; end
|
|
45
|
+
|
|
46
|
+
# Derive a key from an application secret.
|
|
47
|
+
#
|
|
48
|
+
# Deterministic: the same secret always yields the same key, which is
|
|
49
|
+
# what makes it survive a restart without being written down anywhere.
|
|
50
|
+
# Use a different `info` to derive unrelated keys from one secret.
|
|
51
|
+
def self.derive(secret, info: DEFAULT_INFO, salt: "")
|
|
52
|
+
if secret.nil? || secret.to_s.empty?
|
|
53
|
+
raise Error, "cannot derive a pickle key from an empty secret"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
new(
|
|
57
|
+
[
|
|
58
|
+
OpenSSL::KDF.hkdf(
|
|
59
|
+
secret.to_s,
|
|
60
|
+
salt: salt,
|
|
61
|
+
info: info,
|
|
62
|
+
length: DERIVED_BYTES,
|
|
63
|
+
hash: "SHA256",
|
|
64
|
+
),
|
|
65
|
+
].pack("m0"),
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wrap a key you already have -- one derived elsewhere, or read from a
|
|
70
|
+
# secret store.
|
|
71
|
+
#
|
|
72
|
+
# @raises [Error] unless it is exactly 32 bytes and valid UTF-8, because
|
|
73
|
+
# vodozemac refuses anything else and the failure it gives is obscure.
|
|
74
|
+
def initialize(value)
|
|
75
|
+
@value = value.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
76
|
+
|
|
77
|
+
unless @value.bytesize == LENGTH
|
|
78
|
+
raise Error, "a pickle key must be exactly #{LENGTH} bytes, got #{@value.bytesize}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
unless @value.valid_encoding?
|
|
82
|
+
raise Error, "a pickle key must be valid UTF-8; vodozemac rejects binary strings"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@value.freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The key as vodozemac wants it.
|
|
89
|
+
def to_s = @value
|
|
90
|
+
|
|
91
|
+
# Never the key itself: a pickle key in a log or an exception message is
|
|
92
|
+
# as bad as one in a repository.
|
|
93
|
+
def inspect = "#<#{self.class.name} [redacted]>"
|
|
94
|
+
|
|
95
|
+
def ==(other) = other.is_a?(self.class) && to_s == other.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
__END__
|
|
102
|
+
describe "Async::Matrix::E2EE::PickleKey" do
|
|
103
|
+
K = Async::Matrix::E2EE::PickleKey
|
|
104
|
+
|
|
105
|
+
# The arithmetic this class exists for: 24 bytes of entropy base64s to
|
|
106
|
+
# exactly the 32 ASCII characters vodozemac demands.
|
|
107
|
+
it "derives a key of exactly 32 bytes, valid UTF-8" do
|
|
108
|
+
key = K.derive("an application secret")
|
|
109
|
+
|
|
110
|
+
key.to_s.bytesize.should == 32
|
|
111
|
+
key.to_s.encoding.should == Encoding::UTF_8
|
|
112
|
+
key.to_s.valid_encoding?.should == true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Deterministic, which is what lets it survive a restart without being
|
|
116
|
+
# written down.
|
|
117
|
+
it "derives the same key from the same secret" do
|
|
118
|
+
K.derive("secret").should == K.derive("secret")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "derives different keys from different secrets" do
|
|
122
|
+
K.derive("one").should.not == K.derive("two")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# One secret, several unrelated keys.
|
|
126
|
+
it "separates keys by info string" do
|
|
127
|
+
K.derive("secret", info: "a").should.not == K.derive("secret", info: "b")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "refuses an empty secret" do
|
|
131
|
+
lambda { K.derive("") }.should.raise(Async::Matrix::E2EE::PickleKey::Error)
|
|
132
|
+
lambda { K.derive(nil) }.should.raise(Async::Matrix::E2EE::PickleKey::Error)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ── Wrapping an existing key ──────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
it "accepts a 32-byte key" do
|
|
138
|
+
K.new("k" * 32).to_s.should == "k" * 32
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The error vodozemac gives for the wrong length is obscure, so this one is
|
|
142
|
+
# not.
|
|
143
|
+
it "refuses a key of the wrong length" do
|
|
144
|
+
lambda { K.new("short") }.should.raise(Async::Matrix::E2EE::PickleKey::Error)
|
|
145
|
+
lambda { K.new("k" * 33) }.should.raise(Async::Matrix::E2EE::PickleKey::Error)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# "expected utf-8, got ASCII-8BIT" is what raw KDF output gets you.
|
|
149
|
+
it "refuses bytes that are not valid UTF-8" do
|
|
150
|
+
lambda { K.new("\xFF\xFE".b + ("k" * 30)) }
|
|
151
|
+
.should.raise(Async::Matrix::E2EE::PickleKey::Error)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "accepts binary-encoded bytes that happen to be valid UTF-8" do
|
|
155
|
+
K.new(("k" * 32).b).to_s.encoding.should == Encoding::UTF_8
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ── Not leaking ───────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
# A pickle key in a log is as bad as one in a repository.
|
|
161
|
+
it "never shows the key in inspect" do
|
|
162
|
+
key = K.derive("secret")
|
|
163
|
+
|
|
164
|
+
key.inspect.should.not.be.include? key.to_s
|
|
165
|
+
key.inspect.should.be.include? "redacted"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "is frozen, so nothing can mutate it in place" do
|
|
169
|
+
K.derive("secret").to_s.frozen?.should == true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# It belongs beside the objects it encrypts, which is the point of the
|
|
173
|
+
# namespace: every one of these answers #pickle.
|
|
174
|
+
it "sits with the classes whose pickles it protects" do
|
|
175
|
+
[
|
|
176
|
+
Async::Matrix::E2EE::Account,
|
|
177
|
+
Async::Matrix::E2EE::Session,
|
|
178
|
+
Async::Matrix::E2EE::GroupSession,
|
|
179
|
+
Async::Matrix::E2EE::InboundGroupSession,
|
|
180
|
+
].each { |klass| klass.instance_methods.should.be.include? :pickle }
|
|
181
|
+
end
|
|
182
|
+
end
|
data/lib/async/matrix/error.rb
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
# Released under the Apache License, Version 2.0.
|
|
4
4
|
# Copyright, 2026, by General Intelligence Systems.
|
|
5
5
|
|
|
6
|
+
require_relative "../../protocol/matrix/error"
|
|
7
|
+
|
|
6
8
|
module Async
|
|
7
9
|
module Matrix
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
end
|
|
16
|
-
end
|
|
10
|
+
# One error base for the whole gem. See Protocol::Matrix::Error.
|
|
11
|
+
#
|
|
12
|
+
# A CONSTANT, NOT A SUBCLASS: the subclasses below it are declared as
|
|
13
|
+
# `class AuthError < Error`, which resolves this constant at load time, so
|
|
14
|
+
# they inherit the real class and `rescue Async::Matrix::Error` catches
|
|
15
|
+
# everything -- transport failures and format failures together.
|
|
16
|
+
Error = ::Protocol::Matrix::Error
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
data/lib/async/matrix/version.rb
CHANGED
data/lib/async/matrix.rb
CHANGED
|
@@ -10,6 +10,22 @@ module Async
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# The message FORMAT layer: events, content, schemas, encrypted messages. It
|
|
14
|
+
# does no IO, so it lives beside the other protocol-* libraries rather than
|
|
15
|
+
# under the gem that opens sockets. Required FIRST, because the aliases below
|
|
16
|
+
# and the client both depend on it.
|
|
17
|
+
require "protocol/matrix"
|
|
18
|
+
|
|
19
|
+
module Async
|
|
20
|
+
module Matrix
|
|
21
|
+
# Where these constants used to live. Kept as aliases so code written
|
|
22
|
+
# against 3.0 keeps working; new code should name Protocol::Matrix directly.
|
|
23
|
+
Event = ::Protocol::Matrix::Event
|
|
24
|
+
Content = ::Protocol::Matrix::Content
|
|
25
|
+
Schema = ::Protocol::Matrix::Schema
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
13
29
|
Dir.glob("#{__dir__}/matrix/**/*.rb").sort.each do |path|
|
|
14
30
|
require path
|
|
15
31
|
end
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
|
|
8
|
+
require_relative "error"
|
|
9
|
+
|
|
10
|
+
module Protocol
|
|
11
|
+
module Matrix
|
|
12
|
+
# Canonical JSON: "the shortest UTF-8 JSON encoding with dictionary keys
|
|
13
|
+
# lexicographically sorted by Unicode codepoint."
|
|
14
|
+
#
|
|
15
|
+
# https://spec.matrix.org/latest/appendices/#canonical-json
|
|
16
|
+
#
|
|
17
|
+
# This is not a formatting preference. A signature is computed over these
|
|
18
|
+
# exact bytes, so any disagreement about whitespace, key order or number
|
|
19
|
+
# formatting produces a signature every other implementation rejects — and
|
|
20
|
+
# the failure appears at the far end, as somebody else's device refusing our
|
|
21
|
+
# keys, with nothing locally to see.
|
|
22
|
+
#
|
|
23
|
+
# The rules, each of which is enforced below:
|
|
24
|
+
#
|
|
25
|
+
# * dictionary keys sorted by Unicode codepoint
|
|
26
|
+
# * no insignificant whitespace (separators are "," and ":")
|
|
27
|
+
# * code-points outside ASCII encoded as UTF-8, NOT as \u escapes
|
|
28
|
+
# * integers only, in [-(2**53)+1, (2**53)-1], no exponents, no decimals
|
|
29
|
+
# * "Float values are not permitted by this encoding"
|
|
30
|
+
# * negative zero MUST NOT appear
|
|
31
|
+
module CanonicalJson
|
|
32
|
+
MAXIMUM_INTEGER = (2**53) - 1
|
|
33
|
+
MINIMUM_INTEGER = -(2**53) + 1
|
|
34
|
+
|
|
35
|
+
# The two keys a signature never covers: "The `unsigned` object and the
|
|
36
|
+
# `signatures` object are not covered by the signature."
|
|
37
|
+
EXCLUDED_FROM_SIGNATURE = %w[signatures unsigned].freeze
|
|
38
|
+
|
|
39
|
+
class Error < Protocol::Matrix::Error; end
|
|
40
|
+
|
|
41
|
+
# @returns [String] the canonical encoding of +value+.
|
|
42
|
+
def self.encode(value)
|
|
43
|
+
JSON.generate(canonicalize(value))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sort, stringify and check, recursively.
|
|
47
|
+
#
|
|
48
|
+
# Ruby's String#<=> compares bytes, and UTF-8 byte order is codepoint
|
|
49
|
+
# order, so a plain sort IS the codepoint sort the spec asks for. That
|
|
50
|
+
# equivalence is the whole reason this is three lines rather than a
|
|
51
|
+
# custom comparator.
|
|
52
|
+
#
|
|
53
|
+
# Keys are stringified BEFORE sorting, not read back out of the hash
|
|
54
|
+
# afterwards: `h[k] || h[k.to_sym]` would mishandle a stored `false`.
|
|
55
|
+
def self.canonicalize(value)
|
|
56
|
+
case value
|
|
57
|
+
when Hash
|
|
58
|
+
value.to_h { |key, nested| [key.to_s, canonicalize(nested)] }.sort.to_h
|
|
59
|
+
when Array
|
|
60
|
+
value.map { |nested| canonicalize(nested) }
|
|
61
|
+
when Float
|
|
62
|
+
raise Error, "float values are not permitted by canonical JSON: #{value.inspect}"
|
|
63
|
+
when Integer
|
|
64
|
+
check_integer(value)
|
|
65
|
+
when Symbol
|
|
66
|
+
value.to_s
|
|
67
|
+
else
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.check_integer(value)
|
|
73
|
+
if value > MAXIMUM_INTEGER || value < MINIMUM_INTEGER
|
|
74
|
+
raise Error,
|
|
75
|
+
"integer out of canonical JSON range [#{MINIMUM_INTEGER}, #{MAXIMUM_INTEGER}]: #{value}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The object as it is signed: everything except the two keys a signature
|
|
82
|
+
# does not cover. Returns a copy; the original is untouched, because the
|
|
83
|
+
# caller still needs its signatures.
|
|
84
|
+
def self.signable(object)
|
|
85
|
+
object.reject { |key, _| EXCLUDED_FROM_SIGNATURE.include?(key.to_s) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The exact bytes a signature is computed over.
|
|
89
|
+
def self.signable_bytes(object)
|
|
90
|
+
encode(signable(object))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
__END__
|
|
97
|
+
describe "Protocol::Matrix::CanonicalJson" do
|
|
98
|
+
def encode(value) = Protocol::Matrix::CanonicalJson.encode(value)
|
|
99
|
+
|
|
100
|
+
# The worked examples from https://spec.matrix.org/latest/appendices/#canonical-json
|
|
101
|
+
it "matches the spec's empty object example" do
|
|
102
|
+
encode({}).should == "{}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "matches the spec's key ordering example" do
|
|
106
|
+
encode({"one" => 1, "two" => "Two"}).should == '{"one":1,"two":"Two"}'
|
|
107
|
+
encode({"b" => "2", "a" => "1"}).should == '{"a":"1","b":"2"}'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "matches the spec's nested example" do
|
|
111
|
+
encode({"auth" => {"success" => true, "mxid" => "@john.doe:example.com"}})
|
|
112
|
+
.should == '{"auth":{"mxid":"@john.doe:example.com","success":true}}'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# "Encode code-points outside of ASCII as UTF-8 rather than \u escapes."
|
|
116
|
+
it "emits non-ASCII as UTF-8 rather than escapes" do
|
|
117
|
+
encode({"a" => "日本語"}).should == '{"a":"日本語"}'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "keeps an escape the JSON grammar requires" do
|
|
121
|
+
encode({"a" => "quote\"and\\slash"}).should == '{"a":"quote\"and\\\\slash"}'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Keys sort by Unicode codepoint, which for UTF-8 is byte order.
|
|
125
|
+
it "sorts keys by codepoint, not by locale or length" do
|
|
126
|
+
encode({"Z" => 1, "a" => 2, "A" => 3, "z" => 4})
|
|
127
|
+
.should == '{"A":3,"Z":1,"a":2,"z":4}'
|
|
128
|
+
encode({"é" => 1, "z" => 2}).should == '{"z":2,"é":1}'
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "sorts nested objects too" do
|
|
132
|
+
encode({"b" => {"d" => 1, "c" => 2}, "a" => 3})
|
|
133
|
+
.should == '{"a":3,"b":{"c":2,"d":1}}'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Arrays are ordered data: their order is the sender's, not ours to sort.
|
|
137
|
+
it "leaves array order alone" do
|
|
138
|
+
encode({"a" => [3, 1, 2]}).should == '{"a":[3,1,2]}'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "has no insignificant whitespace anywhere" do
|
|
142
|
+
encoded = encode({"a" => {"b" => [1, 2]}, "c" => "d"})
|
|
143
|
+
|
|
144
|
+
encoded.should == '{"a":{"b":[1,2]},"c":"d"}'
|
|
145
|
+
encoded.include?(" ").should == false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "stringifies symbol keys and values" do
|
|
149
|
+
encode({a: :b}).should == '{"a":"b"}'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "encodes null, true and false" do
|
|
153
|
+
encode({"a" => nil, "b" => true, "c" => false}).should == '{"a":null,"b":true,"c":false}'
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# "Float values are not permitted by this encoding."
|
|
157
|
+
it "refuses floats, including ones that look like integers" do
|
|
158
|
+
lambda { encode({"a" => 1.5}) }.should.raise(Protocol::Matrix::CanonicalJson::Error)
|
|
159
|
+
lambda { encode({"a" => 1.0}) }.should.raise(Protocol::Matrix::CanonicalJson::Error)
|
|
160
|
+
lambda { encode({"a" => [1.0]}) }.should.raise(Protocol::Matrix::CanonicalJson::Error)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# "Numbers in the JSON must be integers in the range [-(2**53)+1, (2**53)-1]"
|
|
164
|
+
it "accepts the integer range boundaries" do
|
|
165
|
+
encode({"a" => (2**53) - 1}).should == '{"a":9007199254740991}'
|
|
166
|
+
encode({"a" => -(2**53) + 1}).should == '{"a":-9007199254740991}'
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "refuses integers outside the range" do
|
|
170
|
+
lambda { encode({"a" => 2**53}) }.should.raise(Protocol::Matrix::CanonicalJson::Error)
|
|
171
|
+
lambda { encode({"a" => -(2**53)}) }.should.raise(Protocol::Matrix::CanonicalJson::Error)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ── What a signature covers ───────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
# "The `unsigned` object and the `signatures` object are not covered by the
|
|
177
|
+
# signature."
|
|
178
|
+
it "excludes signatures and unsigned from the signable form" do
|
|
179
|
+
object = {
|
|
180
|
+
"a" => 1,
|
|
181
|
+
"signatures" => {"@alice:example.com" => {"ed25519:DEV" => "sig"}},
|
|
182
|
+
"unsigned" => {"age" => 100},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Protocol::Matrix::CanonicalJson.signable(object).should == {"a" => 1}
|
|
186
|
+
Protocol::Matrix::CanonicalJson.signable_bytes(object).should == '{"a":1}'
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "leaves the original object untouched" do
|
|
190
|
+
object = {"a" => 1, "signatures" => {"x" => "y"}}
|
|
191
|
+
Protocol::Matrix::CanonicalJson.signable(object)
|
|
192
|
+
|
|
193
|
+
object.key?("signatures").should == true
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "excludes them whether the keys are strings or symbols" do
|
|
197
|
+
Protocol::Matrix::CanonicalJson.signable({:a => 1, :signatures => {}, :unsigned => {}})
|
|
198
|
+
.should == {:a => 1}
|
|
199
|
+
end
|
|
200
|
+
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
|
# Wraps the content object of a Matrix event, providing typed access to
|
|
9
9
|
# schema-defined properties via method_missing.
|
|
@@ -54,9 +54,9 @@ module Async
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
__END__
|
|
57
|
-
describe "
|
|
57
|
+
describe "Protocol::Matrix::Content" do
|
|
58
58
|
it "parses msgtype, body, and membership" do
|
|
59
|
-
content =
|
|
59
|
+
content = Protocol::Matrix::Content.new({
|
|
60
60
|
"msgtype" => "m.text",
|
|
61
61
|
"body" => "hello",
|
|
62
62
|
"membership" => "join"
|
|
@@ -67,19 +67,19 @@ __END__
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
it "handles missing fields gracefully" do
|
|
70
|
-
content =
|
|
70
|
+
content = Protocol::Matrix::Content.new({})
|
|
71
71
|
content.msgtype.should.be.nil
|
|
72
72
|
content.body.should.be.nil
|
|
73
73
|
content.membership.should.be.nil
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
it "provides hash access via []" do
|
|
77
|
-
content =
|
|
77
|
+
content = Protocol::Matrix::Content.new({"custom_field" => "value"})
|
|
78
78
|
content["custom_field"].should == "value"
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
it "provides dynamic access via method_missing" do
|
|
82
|
-
content =
|
|
82
|
+
content = Protocol::Matrix::Content.new({
|
|
83
83
|
"avatar_url" => "mxc://example.org/abc",
|
|
84
84
|
"displayname" => "Alice"
|
|
85
85
|
})
|
|
@@ -88,18 +88,18 @@ __END__
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
it "returns nil for unknown fields via method_missing" do
|
|
91
|
-
content =
|
|
91
|
+
content = Protocol::Matrix::Content.new({})
|
|
92
92
|
content.nonexistent.should.be.nil
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
it "returns the raw hash via to_h" do
|
|
96
96
|
data = {"msgtype" => "m.text", "body" => "hi"}
|
|
97
|
-
content =
|
|
97
|
+
content = Protocol::Matrix::Content.new(data)
|
|
98
98
|
content.to_h.should == data
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
it "responds to keys present in the data" do
|
|
102
|
-
content =
|
|
102
|
+
content = Protocol::Matrix::Content.new({"avatar_url" => "mxc://x/y"})
|
|
103
103
|
content.respond_to?(:avatar_url).should == true
|
|
104
104
|
content.respond_to?(:nonexistent).should == false
|
|
105
105
|
end
|