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.
@@ -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
@@ -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
- class Error < StandardError
9
- attr_reader :errcode, :status
10
-
11
- def initialize(errcode, message, status: nil)
12
- @errcode = errcode
13
- @status = status
14
- super(message)
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
 
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2026, by General Intelligence Systems.
5
5
 
6
6
  module Async
7
- module Matrix
8
- VERSION = "3.0.0"
9
- end
7
+ module Matrix
8
+ VERSION = "3.0.1"
9
+ end
10
10
  end
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 Async
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 "Async::Matrix::Content" do
57
+ describe "Protocol::Matrix::Content" do
58
58
  it "parses msgtype, body, and membership" do
59
- content = Async::Matrix::Content.new({
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 = Async::Matrix::Content.new({})
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 = Async::Matrix::Content.new({"custom_field" => "value"})
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 = Async::Matrix::Content.new({
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 = Async::Matrix::Content.new({})
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 = Async::Matrix::Content.new(data)
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 = Async::Matrix::Content.new({"avatar_url" => "mxc://x/y"})
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