Skip to content

Commit 9ffe0d4

Browse files
committed
Add support for caps 2.0 hashing according to XEP-0390
1 parent 657dad8 commit 9ffe0d4

5 files changed

Lines changed: 134 additions & 94 deletions

File tree

src/caps/mod_caps.erl

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
-moduledoc("Handling and caching of entity capabilities according to XEP-0115").
33

44
-xep([{xep, 115}, {version, "1.6.0"}]).
5+
-xep([{xep, 390}, {version, "0.3.2"}]).
56

67
-behaviour(gen_mod).
78

89
-type hash() :: {hash_alg(), hash_value()}.
910
-type hash_alg() :: binary().
1011
-type hash_value() :: binary().
12+
-type version() :: v1 % XEP-0115: Entity Capabilities
13+
| v2. % XEP-0390: Entity Capabilities 2.0
1114
-type feature() :: binary().
1215
-type caps() :: #{hash := hash(), node => binary(), features => [feature()]}.
1316

14-
-export_type([hash/0, hash_alg/0, hash_value/0, feature/0]).
17+
-export_type([hash/0, hash_alg/0, hash_value/0, version/0, feature/0]).
1518

1619
-include("jlib.hrl").
1720
-include("mongoose.hrl").
@@ -210,7 +213,7 @@ handle_response(Acc, C2SData, Caps, QueryEl) ->
210213

211214
-spec verify_hash(caps(), exml:element()) -> boolean().
212215
verify_hash(#{hash := {HashAlg, HashValue}}, #xmlel{children = Elements}) ->
213-
mod_caps_hash:generate(Elements, HashAlg) =:= HashValue.
216+
mod_caps_hash:generate(Elements, v1, HashAlg) =:= HashValue.
214217

215218
-spec disco_info_request(caps()) -> jlib:iq().
216219
disco_info_request(#{node := Node, hash := {_HashAlg, HashValue}}) ->
@@ -277,7 +280,7 @@ get_server_hash(HostType) ->
277280
-spec generate_server_hash(mongooseim:host_type(), jid:jid()) -> hash_value().
278281
generate_server_hash(HostType, ServerJID) ->
279282
Elements = server_disco_elements(HostType, ServerJID),
280-
mod_caps_hash:generate(Elements, ?SERVER_HASH_ALG).
283+
mod_caps_hash:generate(Elements, v1, ?SERVER_HASH_ALG).
281284

282285
-spec server_disco_elements(mongooseim:host_type(), jid:jid()) -> [exml:element()].
283286
server_disco_elements(HostType, JID) ->

src/caps/mod_caps_hash.erl

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,83 @@
11
-module(mod_caps_hash).
22

3-
-export([is_known_hash_alg/1, generate/2, encode/1]).
3+
-export([is_known_hash_alg/1, generate/3, encode/2]).
44

5-
-ignore_xref([encode/1]). % exported for tests
5+
-ignore_xref([encode/2]). % exported for tests
6+
7+
-type encoded_element() :: {group(), iolist()} | {other, exml:element()}.
8+
-type group() :: feature | identity | form.
69

710
-include("jlib.hrl").
811

912
-spec is_known_hash_alg(mod_caps:hash_alg()) -> boolean().
1013
is_known_hash_alg(Alg) ->
1114
hash_function(Alg) =/= unknown_alg.
1215

13-
-spec generate([exml:element()], mod_caps:hash_alg()) -> mod_caps:hash_value().
14-
generate(Elements, Alg) ->
15-
base64:encode(hash(encode(Elements), Alg)).
16+
-spec generate([exml:element()], mod_caps:version(), mod_caps:hash_alg()) -> mod_caps:hash_value().
17+
generate(Elements, Version, Alg) ->
18+
base64:encode(hash(encode(Version, Elements), Alg)).
19+
20+
%% Processing and encoding of the disco#info response
21+
22+
-spec encode(mod_caps:version(), [exml:element()]) -> binary().
23+
encode(Version, Elements) ->
24+
EncodedEls = lists:map(fun(Element) -> encode_element(Version, Element) end, Elements),
25+
case make_groups(EncodedEls) of
26+
#{other := InvalidElements} when Version =:= v2 ->
27+
%% Skip elements instead of throwing an error for v1
28+
error(#{what => mod_caps_hash_invalid_elements, invalid_elements => InvalidElements});
29+
Groups ->
30+
Result = [encode_group(Version, lists:sort(maps:get(Group, Groups, [])))
31+
|| Group <- ordered_groups(Version)],
32+
iolist_to_binary(Result)
33+
end.
1634

17-
-spec encode([exml:element()]) -> binary().
18-
encode(Elements) ->
19-
EncodedItems = lists:sort(lists:map(fun encode_item/1, Elements)),
20-
iolist_to_binary([Item || {_Priority, Item} <- EncodedItems]).
35+
-spec make_groups([encoded_element()]) -> #{feature | identity | form => [iolist()],
36+
other => [exml:element()]}.
37+
make_groups(EncodedEls) ->
38+
maps:groups_from_list(fun({Group, _}) -> Group end, fun({_, Value}) -> Value end, EncodedEls).
2139

22-
-spec encode_item(exml:element()) -> {Priority :: pos_integer(), iolist()} | skip.
23-
encode_item(#xmlel{name = ~"identity",
24-
attrs = Attrs = #{~"category" := Category, ~"type" := Type}}) ->
40+
-spec encode_element(mod_caps:version(), exml:element()) -> encoded_element().
41+
encode_element(Version, #xmlel{name = ~"feature", attrs = #{~"var" := Var}}) ->
42+
{feature, encode_feature(Version, Var)};
43+
encode_element(Version, #xmlel{name = ~"identity",
44+
attrs = Attrs = #{~"category" := Category, ~"type" := Type}}) ->
2545
Lang = maps:get(~"xml:lang", Attrs, <<>>),
2646
Name = maps:get(~"name", Attrs, <<>>),
27-
{1, [Category, $/, Type, $/, Lang, $/, Name, $<]};
28-
encode_item(#xmlel{name = ~"feature", attrs = #{~"var" := Var}}) ->
29-
{2, [Var, $<]};
30-
encode_item(Element) ->
47+
{identity, encode_identity(Version, Category, Type, Lang, Name)};
48+
encode_element(Version, Element) ->
3149
maybe
32-
true ?= mongoose_data_forms:is_form(Element),
33-
#{type := ~"result", kvs := KVs, ns := NS} ?=
34-
mongoose_data_forms:parse_form_fields(Element),
35-
{3, [NS, $< | encode_form_fields(KVs)]}
50+
#{type := ~"result", kvs := KVs, ns := NS} ?= mongoose_data_forms:parse_form(Element),
51+
{form, encode_form(Version, NS, KVs)}
3652
else
37-
_ -> skip % this could happen for a non-conformant or custom info response
53+
_ -> {other, Element} % this could happen for a non-conformant or custom info response
3854
end.
3955

40-
-spec encode_form_fields(mongoose_data_forms:kv_map()) -> iolist().
41-
encode_form_fields(KVs) ->
42-
[encode_form_field(K, V) || K := V <- maps:iterator(KVs, ordered)].
56+
-spec encode_form_fields(mod_caps:version(), mongoose_data_forms:kv_map()) -> iolist().
57+
encode_form_fields(Version, KVs) ->
58+
[encode_form_field(Version, [K | lists:sort(Vs)]) || K := Vs <- maps:iterator(KVs, ordered)].
59+
60+
%% Version-specific encoding format
61+
62+
ordered_groups(v1) -> [identity, feature, form];
63+
ordered_groups(v2) -> [feature, identity, form].
64+
65+
encode_group(v1, Values) -> Values;
66+
encode_group(v2, Values) -> [Values, 16#1c].
67+
68+
encode_identity(v1, Cat, Type, Lang, Name) -> [Cat, $/, Type, $/, Lang, $/, Name, $<];
69+
encode_identity(v2, Cat, Type, Lang, Name) -> [Cat, 16#1f, Type, 16#1f, Lang, 16#1f, Name, 16#1f, 16#1e].
70+
71+
encode_feature(v1, Var) -> [Var, $<];
72+
encode_feature(v2, Var) -> [Var, 16#1f].
73+
74+
encode_form(v1, NS, KVs) -> [NS, $< | encode_form_fields(v1, KVs)];
75+
encode_form(v2, NS, KVs) -> [encode_form_fields(v2, KVs#{~"FORM_TYPE" => [NS]}), 16#1d].
76+
77+
encode_form_field(v1, Values) -> [[Value, $<] || Value <- Values];
78+
encode_form_field(v2, Values) -> [[[Value, 16#1f] || Value <- Values], 16#1e].
4379

44-
-spec encode_form_field(binary(), [binary()]) -> iolist().
45-
encode_form_field(Key, Values) ->
46-
[[Item, $<] || Item <- [Key | lists:sort(Values)]].
80+
%% Hash calculation
4781

4882
-spec hash(binary(), mod_caps:hash_alg()) -> binary().
4983
hash(Data, Alg) ->

test/mod_caps_SUITE.erl

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ hash_tests() ->
5454
generate_complex_hash_sha256,
5555
generate_complex_hash_sha384,
5656
generate_complex_hash_sha512,
57-
generate_empty_hash_sha1].
57+
generate_empty_hash_sha1,
58+
generate_simple_hash_v2_sha256,
59+
generate_complex_hash_v2_sha256].
5860

5961
module_tests() ->
6062
[get_set_and_delete_jid_features,
@@ -64,39 +66,50 @@ module_tests() ->
6466

6567
generate_simple_hash_sha1(Config) ->
6668
test_hash_generation(
67-
Config, "simple_response.xml", ~"sha-1", ~"QgayPKawpkPSDYmwT/WM94uAlu0=").
69+
Config, "simple_response.xml", v1, ~"sha-1", ~"QgayPKawpkPSDYmwT/WM94uAlu0=").
6870

6971
generate_complex_hash_md5(Config) ->
7072
test_hash_generation(
71-
Config, "complex_response.xml", ~"md5", ~"28MfZZzsz3dCwWpb8JHPBA==").
73+
Config, "complex_response.xml", v1, ~"md5", ~"28MfZZzsz3dCwWpb8JHPBA==").
7274

7375
generate_complex_hash_sha1(Config) ->
7476
test_hash_generation(
75-
Config, "complex_response.xml", ~"sha-1", ~"q07IKJEyjvHSyhy//CH0CxmKi8w=").
77+
Config, "complex_response.xml", v1, ~"sha-1", ~"q07IKJEyjvHSyhy//CH0CxmKi8w=").
7678

7779
generate_complex_hash_sha224(Config) ->
7880
test_hash_generation(
79-
Config, "complex_response.xml", ~"sha-224", ~"hVeEOlwa1XV+Ey5pOJflr60mWtkRVzVNkc8/FA==").
81+
Config, "complex_response.xml", v1, ~"sha-224", ~"hVeEOlwa1XV+Ey5pOJflr60mWtkRVzVNkc8/FA==").
8082

8183
generate_complex_hash_sha256(Config) ->
8284
test_hash_generation(
83-
Config, "complex_response.xml", ~"sha-256", ~"VyRoCfkwN7Q9lxZhqOI+mxfSpo/MsaCF4hBufCzfCpI=").
85+
Config, "complex_response.xml", v1, ~"sha-256",
86+
~"VyRoCfkwN7Q9lxZhqOI+mxfSpo/MsaCF4hBufCzfCpI=").
8487

8588
generate_complex_hash_sha384(Config) ->
8689
test_hash_generation(
87-
Config, "complex_response.xml", ~"sha-384",
90+
Config, "complex_response.xml", v1, ~"sha-384",
8891
~"ZwpmMk+bCM0ZTwRORt/BCd+WEocOBHUmMKNeaODqb1uiUlQ19DuRNPvz9ttfv49q").
8992

9093
generate_complex_hash_sha512(Config) ->
9194
test_hash_generation(
92-
Config, "complex_response.xml", ~"sha-512",
95+
Config, "complex_response.xml", v1, ~"sha-512",
9396
~"D2YKKKjx1pTqnV8eCvkyhkdcBe4lPrf8Rp/Ss0zmEut0XEkfTIVEk7zByVMifWpJeb9cTdufU+k47oKIkQ3UUQ==").
9497

98+
generate_simple_hash_v2_sha256(Config) ->
99+
test_hash_generation(
100+
Config, "simple_response_v2.xml", v2, ~"sha-256",
101+
~"kzBZbkqJ3ADrj7v08reD1qcWUwNGHaidNUgD7nHpiw8=").
102+
103+
generate_complex_hash_v2_sha256(Config) ->
104+
test_hash_generation(
105+
Config, "complex_response_v2.xml", v2, ~"sha-256",
106+
~"u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=").
107+
95108
generate_empty_hash_sha1(_Config) ->
96109
EmptyHash = ~"2jmj7l5rSw0yVb/vlWAYkK/YBwk=", % hash of an empty string
97-
?assertEqual(EmptyHash, mod_caps_hash:generate([], ~"sha-1")),
110+
?assertEqual(EmptyHash, mod_caps_hash:generate([], v1, ~"sha-1")),
98111
EmptyFeature = #xmlel{name = ~"feature"}, % this doesn't follow XEP-0030 and should be skipped
99-
?assertEqual(EmptyHash, mod_caps_hash:generate([EmptyFeature], ~"sha-1")).
112+
?assertEqual(EmptyHash, mod_caps_hash:generate([EmptyFeature], v1, ~"sha-1")).
100113

101114
get_set_and_delete_jid_features(_Config) ->
102115
Alice1 = jid:from_binary_noprep(~"alice@domain/res1"),
@@ -157,16 +170,16 @@ opts() ->
157170
instrumentation => config_parser_helper:default_config([instrumentation]),
158171
{modules, ?HOST_TYPE} => config_parser_helper:config([modules], #{mod_caps => #{}})}.
159172

160-
test_hash_generation(Config, FileName, Alg, ExpectedHash) ->
161-
?assertEqual(ExpectedHash, generate_hash(Config, FileName, Alg)).
173+
test_hash_generation(Config, FileName, Version, Alg, ExpectedHash) ->
174+
?assertEqual(ExpectedHash, generate_hash(Config, FileName, Version, Alg)).
162175

163-
generate_hash(Config, FileName, Alg) ->
176+
generate_hash(Config, FileName, Version, Alg) ->
164177
#xmlel{children = Children} = parse_response(Config, FileName),
165-
mod_caps_hash:generate(Children, Alg).
178+
mod_caps_hash:generate(Children, Version, Alg).
166179

167180
parse_response(Config, FileName) ->
168181
{ok, XML} = file:read_file(ejabberd_helper:data(Config, FileName)),
169-
{ok, #xmlel{children = [QueryEl]}} = exml:parse(XML),
182+
{ok, QueryEl} = exml:parse(XML),
170183
QueryEl.
171184

172185
presence(Attrs, Children) ->
Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,34 @@
11
<!--
22
https://xmpp.org/extensions/xep-0115.html#ver-gen-complex
3-
Additionally, field and value order is randomized to check sorting.
3+
Additionally, order of elements is randomized to check sorting.
44
-->
5-
<iq from='benvolio@capulet.lit/230193'
6-
id='disco1'
7-
to='juliet@capulet.lit/chamber'
8-
type='result'>
9-
<query xmlns='http://jabber.org/protocol/disco#info'
10-
node='http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w='>
11-
<identity xml:lang='en' category='client' name='Psi 0.11' type='pc'/>
12-
<identity xml:lang='el' category='client' name='Ψ 0.11' type='pc'/>
13-
<feature var='http://jabber.org/protocol/caps'/>
14-
<feature var='http://jabber.org/protocol/disco#info'/>
15-
<feature var='http://jabber.org/protocol/disco#items'/>
16-
<feature var='http://jabber.org/protocol/muc'/>
17-
<x xmlns='jabber:x:data' type='result'>
18-
<field var='FORM_TYPE' type='hidden'>
19-
<value>urn:xmpp:dataforms:softwareinfo</value>
20-
</field>
21-
<field var='software_version'>
22-
<value>0.11</value>
23-
</field>
24-
<field var='os'>
25-
<value>Mac</value>
26-
</field>
27-
<field var='ip_version' type='text-multi' >
28-
<value>ipv6</value>
29-
<value>ipv4</value>
30-
</field>
31-
<field var='os_version'>
32-
<value>10.5.1</value>
33-
</field>
34-
<field var='software'>
35-
<value>Psi</value>
36-
</field>
37-
</x>
38-
</query>
39-
</iq>
5+
<query xmlns='http://jabber.org/protocol/disco#info'
6+
node='http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w='>
7+
<identity xml:lang='en' category='client' name='Psi 0.11' type='pc'/>
8+
<feature var='http://jabber.org/protocol/disco#info'/>
9+
<feature var='http://jabber.org/protocol/disco#items'/>
10+
<x xmlns='jabber:x:data' type='result'>
11+
<field var='FORM_TYPE' type='hidden'>
12+
<value>urn:xmpp:dataforms:softwareinfo</value>
13+
</field>
14+
<field var='software_version'>
15+
<value>0.11</value>
16+
</field>
17+
<field var='os'>
18+
<value>Mac</value>
19+
</field>
20+
<field var='ip_version' type='text-multi' >
21+
<value>ipv6</value>
22+
<value>ipv4</value>
23+
</field>
24+
<field var='os_version'>
25+
<value>10.5.1</value>
26+
</field>
27+
<field var='software'>
28+
<value>Psi</value>
29+
</field>
30+
</x>
31+
<identity xml:lang='el' category='client' name='Ψ 0.11' type='pc'/>
32+
<feature var='http://jabber.org/protocol/caps'/>
33+
<feature var='http://jabber.org/protocol/muc'/>
34+
</query>
Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
<!-- https://xmpp.org/extensions/xep-0115.html#howitworks -->
2-
<iq from='romeo@montague.lit/orchard'
3-
id='disco1'
4-
to='juliet@capulet.lit/chamber'
5-
type='result'>
6-
<query xmlns='http://jabber.org/protocol/disco#info'
7-
node='http://code.google.com/p/exodus#QgayPKawpkPSDYmwT/WM94uAlu0='>
8-
<identity category='client' name='Exodus 0.9.1' type='pc'/>
9-
<feature var='http://jabber.org/protocol/caps'/>
10-
<feature var='http://jabber.org/protocol/disco#info'/>
11-
<feature var='http://jabber.org/protocol/disco#items'/>
12-
<feature var='http://jabber.org/protocol/muc'/>
13-
</query>
14-
</iq>
2+
<query xmlns='http://jabber.org/protocol/disco#info'
3+
node='http://code.google.com/p/exodus#QgayPKawpkPSDYmwT/WM94uAlu0='>
4+
<identity category='client' name='Exodus 0.9.1' type='pc'/>
5+
<feature var='http://jabber.org/protocol/caps'/>
6+
<feature var='http://jabber.org/protocol/disco#info'/>
7+
<feature var='http://jabber.org/protocol/disco#items'/>
8+
<feature var='http://jabber.org/protocol/muc'/>
9+
</query>

0 commit comments

Comments
 (0)