File Coverage

blib/lib/Protocol/Matrix.pm
Criterion Covered Total %
statement 77 77 100.0
branch 20 32 62.5
condition 3 6 50.0
subroutine 19 19 100.0
pod 9 10 90.0
total 128 144 88.8


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2015 -- leonerd@leonerd.org.uk
5              
6             package Protocol::Matrix;
7              
8 6     6   103521 use strict;
  6         11  
  6         218  
9 6     6   29 use warnings;
  6         13  
  6         143  
10 6     6   114 use 5.014; # s///r
  6         25  
11              
12             our $VERSION = '0.02';
13              
14 6     6   29 use Carp;
  6         12  
  6         474  
15              
16 6     6   4637 use Crypt::NaCl::Sodium;
  6         104520  
  6         38  
17 6     6   6254 use Digest::SHA qw( sha256 );
  6         22520  
  6         492  
18 6     6   5163 use JSON;
  6         64736  
  6         34  
19 6     6   5537 use MIME::Base64 qw( encode_base64 decode_base64 );
  6         8266  
  6         611  
20              
21 6     6   49 use Exporter 'import';
  6         12  
  6         6921  
22             our @EXPORT_OK = qw(
23             encode_json_for_signing
24             encode_base64_unpadded
25             decode_base64
26              
27             sign_json signed_json
28             verify_json_signature
29              
30             redact_event redacted_event
31              
32             sign_event_json signed_event_json
33             verify_event_json_signature
34             );
35              
36             my $sign = Crypt::NaCl::Sodium->sign;
37              
38             my $json_canon = JSON->new
39             ->convert_blessed
40             ->canonical
41             ->utf8;
42              
43             =head1 NAME
44              
45             C - Helper functions for the Matrix protocol
46              
47             =head1 DESCRIPTION
48              
49             This module provides some helper functions for implementing a F client
50             or server. Currently it only contains a few base-level functions to assist
51             with signing and verifying signatures on federation-level events.
52              
53             =cut
54              
55             =head1 FUNCTIONS
56              
57             =cut
58              
59             =head2 encode_json_for_signing
60              
61             $json = encode_json_for_signing( $data )
62              
63             Encodes a given HASH reference as Canonical JSON, having removed the
64             C and C keys if present. This is the first step
65             towards signing it or verifying an embedded signature in it. The hash
66             referred to by C<$data> remains unmodified by this function.
67              
68             =cut
69              
70             sub encode_json_for_signing
71             {
72 42     42 1 10823 my ( $d ) = @_;
73              
74             # Remove keys that don't get signed
75 42         198 my %to_sign = %$d;
76 42         98 delete $to_sign{signatures};
77 42         74 delete $to_sign{unsigned};
78              
79 42         8710 return $json_canon->encode( \%to_sign );
80             }
81              
82             =head2 encode_base64_unpadded
83              
84             $base64 = encode_base64( $bytes )
85              
86             Returns a character string containing the Base-64 encoding of the given bytes,
87             with no internal linebreaks and no trailing padding.
88              
89             =cut
90              
91             sub encode_base64_unpadded
92             {
93 16     16 1 1409 return encode_base64( $_[0], "" ) =~ s/=+$//r;
94             }
95              
96             =head2 decode_base64
97              
98             $bytes = decode_base64( $base64 )
99              
100             Returns a byte string containing the bytes obtained by decoding the given
101             character string. This is re-exported from L for convenience.
102              
103             =cut
104              
105             =head2 sign_json
106              
107             sign_json( $data, secret_key => $key, origin => $name, key_id => $id )
108              
109             Modifies the given HASH reference in-place to add a signature. This signature
110             is created from the given key, and annotated as being from the given origin
111             name and key ID. Existing signatures already in the hash are not disturbed.
112              
113             The C<$key> should be a plain byte string or L object obtained
114             from L's C method.
115              
116             =cut
117              
118             sub sign_json
119             {
120 10     10 1 2482 my ( $data, %args ) = @_;
121              
122 10 50       253 my $key = $args{secret_key} or croak "Require a 'secret_key'";
123              
124 10 50       52 my $origin = $args{origin} or croak "Require an 'origin'";
125 10 50       105 my $key_id = $args{key_id} or croak "Require a 'key_id'";
126              
127 10         45 my $signature = $sign->mac( encode_json_for_signing( $data ), $key );
128              
129 10         100 $data->{signatures}{$origin}{$key_id} = encode_base64_unpadded( $signature );
130             }
131              
132             =head2 signed_json
133              
134             my $data = signed_json( $data, ... )
135              
136             Returns a new HASH reference by cloning the original and applying
137             L to it. The originally-passed data is unmodified. Takes the same
138             arguments as L.
139              
140             =cut
141              
142             sub signed_json
143             {
144 3     3 1 2432 my ( $data, @args ) = @_;
145 3         31 sign_json( $data = { %$data }, @args );
146 3         78 return $data;
147             }
148              
149             =head2 verify_json_signature
150              
151             verify_json_signature( $data, public_key => $key, origin => $name, key_id => $id )
152              
153             Inspects the given HASH reference to check that it contains a signature from
154             the named origin, with the given key ID, and that it is actually valid.
155              
156             This function does not return an interesting value; all failures are indicated
157             by thrown exceptions. If no exception is thrown, it can be presumed valid.
158              
159             =cut
160              
161             sub verify_json_signature
162             {
163 5     5 1 2046 my ( $data, %args ) = @_;
164              
165 5 50       45 my $key = $args{public_key} or croak "Require a 'public_key'";
166              
167 5 50       22 my $origin = $args{origin} or croak "Require an 'origin'";
168 5 50       19 my $key_id = $args{key_id} or croak "Require a 'key_id'";
169              
170             $data->{signatures} or
171 5 50       20 croak "No 'signatures'";
172 5 100       275 $data->{signatures}{$origin} or
173             croak "No signatures from '$origin'";
174              
175 4 100       112 my $signature = $data->{signatures}{$origin}{$key_id} or
176             croak "No signature from '$origin' using key '$key_id'";
177              
178 3 50       21 $sign->verify( decode_base64( $signature ), encode_json_for_signing( $data ), $key ) or
179             croak "Signature verification failed";
180             }
181              
182             =head2 redact_event
183              
184             redact_event( $event )
185              
186             Modifies the given HASH reference in-place to apply the transformation given
187             by the Matrix Event Redaction specification.
188              
189             =cut
190              
191             my %ALLOWED_KEYS = map { $_ => 1 } qw(
192             auth_events
193             depth
194             event_id
195             hashes
196             membership
197             origin
198             origin_server_ts
199             prev_events
200             prev_state
201             room_id
202             sender
203             signatures
204             state_key
205             type
206             );
207              
208             my %ALLOWED_CONTENT_BY_TYPE = (
209             "m.room.aliases" => [qw( aliases )],
210             "m.room.create" => [qw( creator )],
211             "m.room.history_visibility" => [qw( history_visibility )],
212             "m.room.join_rules" => [qw( join_rule )],
213             "m.room.member" => [qw( membership )],
214             "m.room.power_levels" => [qw(
215             users users_default events events_default state_default ban kick redact
216             )],
217             );
218              
219             sub redact_event
220             {
221 13     13 1 25 my ( $event ) = @_;
222              
223 13 50       59 defined( my $type = $event->{type} ) or
224             croak "Event requires a 'type'";
225              
226 13         27 my $old_content = delete $event->{content};
227 13         27 my $old_unsigned = delete $event->{unsigned};
228              
229 13   66     128 $ALLOWED_KEYS{$_} or delete $event->{$_} for keys %$event;
230              
231 13         40 my $new_content = $event->{content} = {};
232              
233 13 100       86 if( my $allowed_content_keys = $ALLOWED_CONTENT_BY_TYPE{$type} ) {
234 1   33     7 exists $old_content->{$_} and $new_content->{$_} = $old_content->{$_} for
235             @$allowed_content_keys;
236             }
237              
238 13 100       68 $event->{unsigned}{age_ts} = $old_unsigned->{age_ts} if exists $old_unsigned->{age_ts};
239             }
240              
241             sub redacted_event
242             {
243 12     12 0 979 my ( $event ) = @_;
244 12         83 redact_event( $event = { %$event } );
245 12         74 return $event;
246             }
247              
248             =head2 sign_event_json
249              
250             sign_event_json( $data, secret_key => $key, origin => $name, key_id => $id )
251              
252             Modifies the given HASH reference in-place to add a hash and signature,
253             presuming it to be a Matrix event structure. This operates in a fashion
254             analogous to L.
255              
256             =cut
257              
258             sub sign_event_json
259             {
260 4     4 1 2725 my ( $event, %args ) = @_;
261              
262 4 50       176 my $key = $args{secret_key} or croak "Require a 'secret_key'";
263              
264 4 50       28 my $origin = $args{origin} or croak "Require an 'origin'";
265 4 50       31 my $key_id = $args{key_id} or croak "Require a 'key_id'";
266              
267             # 'hashes' records the original unredacted version
268             {
269 4         11 my %event_without_hashes = %$event; delete $event_without_hashes{hashes};
  4         34  
  4         16  
270 4         168 my $bytes_to_hash = encode_json_for_signing( \%event_without_hashes );
271              
272 4         130 $event->{hashes}{sha256} = encode_base64_unpadded( sha256( $bytes_to_hash ) );
273             }
274              
275             # Signature is of redacted version
276 4         30 sign_json( my $signed = redacted_event( $event ), %args );
277              
278 4         53 $event->{signatures} = $signed->{signatures};
279             }
280              
281             =head2 signed_event_json
282              
283             my $event = signed_event_json( $event, ... )
284              
285             Returns a new HASH reference by cloning the original and applying
286             L to it. The originally-passed data is unmodified. Takes the
287             same arguments as L.
288              
289             =cut
290              
291             sub signed_event_json
292             {
293 2     2 1 1750 my ( $event, @args ) = @_;
294 2         22 sign_event_json( $event = { %$event }, @args );
295 2         21 return $event;
296             }
297              
298             =head2 verify_event_json_signature
299              
300             verify_event_json_signature( $event, public_key => $key, origin => $name, key_id => $id )
301              
302             =cut
303              
304             sub verify_event_json_signature
305             {
306 1     1 1 15 my ( $event, @args ) = @_;
307              
308 1         5 verify_json_signature( redacted_event( $event ), @args );
309             }
310              
311             =head1 AUTHOR
312              
313             Paul Evans
314              
315             =cut
316              
317             0x55AA;