File Coverage

blib/lib/Data/Radius/Packet.pm
Criterion Covered Total %
statement 196 224 87.5
branch 76 118 64.4
condition 36 63 57.1
subroutine 20 21 95.2
pod 4 7 57.1
total 332 433 76.6


line stmt bran cond sub pod time code
1             package Data::Radius::Packet;
2             # encode/decode RADIUS protocol messages
3              
4 4     4   6678 use v5.10;
  4         15  
5 4     4   19 use strict;
  4         8  
  4         97  
6 4     4   14 use warnings;
  4         6  
  4         214  
7 4     4   21 use Carp ();
  4         6  
  4         52  
8 4     4   14 use Digest::MD5 ();
  4         9  
  4         62  
9 4     4   1911 use Digest::HMAC_MD5 ();
  4         6416  
  4         104  
10 4     4   2090 use bytes;
  4         2061  
  4         23  
11              
12 4     4   142 use base qw(Class::Accessor::Fast);
  4         6  
  4         786  
13             __PACKAGE__->mk_accessors(qw(secret dict));
14              
15 4     4   25 use Data::Radius::Constants qw(:all);
  4         8  
  4         816  
16 4     4   2213 use Data::Radius::Encode qw(encode);
  4         16  
  4         346  
17 4     4   2399 use Data::Radius::Decode qw(decode);
  4         16  
  4         316  
18 4     4   28 use Data::Radius::Util qw(encrypt_pwd decrypt_pwd is_enum_type);
  4         8  
  4         260  
19              
20             use constant {
21             # common attributes
22 4         695 ATTR_PASSWORD => 2,
23             ATTR_VENDOR => 26,
24             # Message-Authenticator
25             ATTR_MSG_AUTH_NAME => 'Message-Authenticator',
26             ATTR_MSG_AUTH => 80,
27             ATTR_MSG_AUTH_LEN => 18,
28              
29             # has extra byte in VSA header
30             WIMAX_VENDOR => 24757,
31 4     4   22 };
  4         6  
32 4     4   26 use constant ATTR_MSG_AUTH_ZERO => pack('C C', ATTR_MSG_AUTH, ATTR_MSG_AUTH_LEN) . ("\x0" x (ATTR_MSG_AUTH_LEN - 2));
  4         5  
  4         11239  
33              
34             my %IS_REPLY = map { $_ => 1 } (
35             ACCESS_ACCEPT,
36             ACCESS_CHALLENGE,
37             ACCESS_REJECT,
38             DISCONNECT_ACCEPT,
39             DISCONNECT_REJECT,
40             COA_ACCEPT,
41             COA_REJECT
42             );
43             my %IS_REQUEST = map { $_ => 1 } (ACCESS_REQUEST, ACCOUNTING_REQUEST, DISCONNECT_REQUEST, COA_REQUEST);
44              
45             my %IS_ACCOUNTING = map { $_ => 1 } (ACCOUNTING_REQUEST, ACCOUNTING_RESPONSE);
46              
47             my $request_id = int( rand(255) );
48              
49             # Digest::MD5 object
50             my $md5;
51              
52             sub new {
53 5     5 1 3835 my ($class, %h) = @_;
54             my $obj = {
55             secret => $h{secret},
56             dict => $h{dict},
57 5         27 };
58              
59 5         22 bless $obj, $class;
60             }
61              
62             # build new request
63             # input:
64             # type - radius code
65             # authenticator - for access request allow to override random one,
66             # for replies - value from request must be used
67             # av_list - array-ref of AV in {Name, Value} or {Id,Type,VendorId,Value} form
68             # dict - allow to override default dictionary object from constructor
69             # secret - allow to override default secret from constructor
70             # with_msg_auth - boolean, to add Message-Authenticator.
71             # This can be archieved by adding Message-Authenticator to av_list with undefined value
72             # Is enabled by default now.
73             # request_id - allow to specify custom value (0..255), otherwise internal counter is used
74             # RaiseError - raise error from AV encoding/decoding - default is to print and forgive errors
75             # PrintError - print error from AV encoding/decoding - default on
76             sub build {
77 7     7 1 12957 my ($self, %h) = @_;
78              
79 7   33     64 $h{RaiseError} //= $Data::Radius::Encode::RaiseError;
80 7   33     44 $h{PrintError} //= $Data::Radius::Encode::PrintError;
81              
82             # RADIUS code
83 7         14 my $type = $h{type};
84             # list in form of { Name => ... Value => ... [Vendor => ...]}
85 7         30 my $av_list = $h{av_list};
86             # object of Data::Radius::Dictionary or compatible
87 7   33     317 my $dict = $h{dict} // $self->dict();
88             # RADIUS secret
89 7 50       67 if($h{secret}) {
90 0         0 $self->secret($h{secret});
91             }
92 7 50       161 Carp::croak('No secret value') if ! defined $self->secret;
93              
94 7 50 33     62 if ($self->is_reply($type) && ! $h{authenticator}) {
95 0         0 Carp::croak("No authenticator value from request");
96             }
97              
98             # enable adding Message-Authenticator attribute (RFC3579)
99 7         15 my $with_msg_auth;
100 7 100       47 if ($IS_ACCOUNTING{ $type }) {
101 2 100       8 if ($h{with_msg_auth}) {
102 1         3 my $msg = 'Message-Authenticator is not used for accounting';
103 1 50       3 Carp::croak($msg) if ($h{RaiseError});
104 1 50       342 Carp::carp($msg) if ($h{PrintError});
105             }
106 2         1337 $with_msg_auth = 0;
107             }
108             else {
109             # enable it by default as protection against blast-RADIUS https://www.blastradius.fail/
110 5   100     17 $with_msg_auth = $h{with_msg_auth} // 1;
111             }
112              
113             # Authenticator required now to encode password field (if present)
114 7         14 my $authenticator;
115 7 100       89 if ($type == ACCESS_REQUEST) {
116             # random, but allow to override for testing
117 3   33     10 $authenticator = $h{authenticator} // pack 'L4', map { int(rand(2 ** 32 - 1)) } (0..3);
  0         0  
118             }
119              
120             # pack attributes
121 7         16 my @bin_av = ();
122              
123 7 100       110 if ($with_msg_auth) {
124             # now Message-Authenticator has to be the first attribute, add zero for now
125 2         73 push @bin_av, ATTR_MSG_AUTH_ZERO;
126             }
127              
128 7         15 my $n;
129 7         13 foreach my $av (@{$av_list}) {
  7         20  
130 15         32 $n++;
131             # Message-Authenticator
132             # now it has to be the first attribute
133 15 100 66     65 if (($av->{Name} eq ATTR_MSG_AUTH_NAME) && !$av->{Value}) {
134 2 100       9 if ($IS_ACCOUNTING{$type}) {
135 1         4 my $msg = 'Message-Authenticator attribute is ignored';
136 1 50       4 Carp::croak($msg) if ($h{RaiseError});
137 1 50       242 Carp::carp($msg) if ($h{PrintError});
138 1         1430 next;
139             }
140              
141 1 50       6 if ($n > 1) {
    50          
142 0         0 my $msg = 'Message-Authenticator must be the first attribute in the list';
143 0 0       0 Carp::croak($msg) if ($h{RaiseError});
144 0 0       0 Carp::carp($msg) if ($h{PrintError});
145             }
146             elsif (! $with_msg_auth ) {
147             # not added yet
148 1         2 push @bin_av, ATTR_MSG_AUTH_ZERO;
149 1         3 $with_msg_auth = 1;
150             }
151              
152             # already added
153 1         2 next;
154             }
155              
156 13         41 my $bin = eval { $self->pack_attribute($av, $authenticator) };
  13         46  
157 13 50       38 if ($@) {
158 0         0 my $msg = $@;
159 0 0       0 Carp::croak($msg) if ($h{RaiseError});
160 0 0       0 Carp::carp ($msg) if ($h{PrintError});
161             }
162 13 50       47 push (@bin_av, $bin) if $bin;
163             }
164              
165 7         22 my $attributes = join('', @bin_av);
166              
167             # build packet header
168              
169 7         19 my $length = 20 + length($attributes);
170              
171             # generate new sequential id if not given (one byte size)
172 7   66     53 my $req_id = $h{request_id} // ($request_id++) & 0xff;
173              
174             # RFC3579 Message-Authenticator (EAP)
175 7 100       50 if($with_msg_auth) {
176             # calculate and update Message-Authenticator attribute
177              
178 3         5 my $used_auth;
179 3 100       11 if ($type == ACCESS_REQUEST) {
    50          
180             # random-generated
181 2         3 $used_auth = $authenticator;
182             }
183             elsif ($self->is_request($type)) {
184 1         1 $used_auth = "\x00" x 16;
185             }
186             else {
187             # must be passed when composing replies
188 0         0 $used_auth = $h{authenticator};
189             }
190              
191 3         13 my $data = join('',
192             pack('C C n', $type, $req_id, $length),
193             $used_auth,
194             $attributes,
195             );
196              
197 3         60 my $hmac = Digest::HMAC_MD5->new($self->secret);
198 3         172 $hmac->add( $data );
199 3         41 my $msg_auth = $hmac->digest;
200              
201             # replace zeroes with the actual value
202 3         68 substr($attributes, 2, ATTR_MSG_AUTH_LEN - 2, $msg_auth );
203             }
204              
205             # calculate authentificator value for non-authentication request
206 7 100       21 if (! $authenticator) {
207             # calculated from content
208 4 50       17 my $used_auth = $self->is_request($type) ? "\x0" x 16 : $h{authenticator};
209              
210 4         14 my $hdr = pack('C C n', $type, $req_id, $length);
211 4   66     29 $md5 //= Digest::MD5->new;
212 4         108 $md5->add($hdr, $used_auth, $attributes, $self->secret);
213 4         41 $authenticator = $md5->digest();
214             }
215              
216             # wtf?
217 7 50       22 Carp::croak("No authenticator") if ! $authenticator;
218              
219 7         32 my $packet = join('',
220             pack('C C n', $type, $req_id, $length),
221             $authenticator,
222             $attributes,
223             );
224              
225 7         48 return ($packet, $req_id, $authenticator);
226             }
227              
228             # authenticator required only for password attribute
229             # av: {Name,Value,[Tag]} or {Id,Type,Value,[VendorId],[Tag]}
230             sub pack_attribute {
231 16     16 0 1754 my ($self, $av, $authenticator) = @_;
232              
233             # optional
234 16         470 my $dict = $self->dict;
235              
236 16         86 my $attr;
237             my $vendor_id;
238              
239             # attribute not present in dictionary must be passed as {Id, Type, Value, VendorId, Tag },
240             # where VendorId and Tag are optional
241 16 100       68 if ($av->{Id}) {
    50          
242 2 50       7 die "No attribute type for $av->{Id}\n" if ! $av->{Type};
243             $attr = {
244             id => $av->{Id},
245             name => $av->{Id},
246             type => $av->{Type},
247             vendor => $av->{VendorId},
248             has_tag => defined $av->{Tag},
249 2         12 };
250 2         5 $vendor_id = $av->{VendorId};
251             }
252             elsif (defined $av->{Name}) {
253             # av: {Name, Value}
254 14 50       35 die "No dictionary to encode attribute '$av->{Name}'\n" if ! $dict;
255              
256             # tagged attribute
257 14 100       52 if ($av->{Name} =~ /^([\w-]+):(\d+)$/) {
258 2         12 ($av->{Name}, $av->{Tag}) = ($1, $2);
259             }
260              
261             $attr = $dict->attribute($av->{Name})
262 14 50       77 or die "Unknown attribute '$av->{Name}'\n";
263              
264             # TODO store vendor_id in dictionary parser
265 14         153 $vendor_id = $dict->vendor_id($attr->{vendor});
266             }
267              
268 16 100       95 if (defined $av->{Tag}) {
269             die "Tag value $av->{Tag} is out of range [1..31] for attribute '$attr->{name}'\n"
270 3 50 33     20 if $av->{Tag} < 1 || $av->{Tag} > 31;
271             }
272              
273 16         36 my $value = $av->{Value};
274 16 50       52 die "Undefined value for attribute '$attr->{name}'\n" unless defined $value;
275              
276 16 100 66     70 if ($attr->{id} == ATTR_PASSWORD && ! $vendor_id) {
277             # need an authenticator - this attribute must be present only in ACCESS REQUEST
278 3         70 $value = encrypt_pwd($value, $self->secret, $authenticator);
279             }
280              
281 16 100 66     113 if ($attr->{type} ne 'tlv' && is_enum_type($attr->{type}) && $dict) {
      100        
282             # convert constant-like values to real value
283 4   66     18 $value = $dict->value($attr->{name}, $value) // $value;
284             } # else - for TVL type value is ARRAY-ref
285              
286 16         199 local ($Data::Radius::Encode::PrintError, $Data::Radius::Encode::RaiseError) = (0,1);
287 16         433 my $encoded = encode($attr, $value, $self->dict, $av->{Tag} );
288 16 50       58 my $len_encoded = length($encoded)
289             or die "Unable to encode value for attribute '$attr->{name}'\n";
290              
291 16 100       35 if (! $vendor_id) {
292             # tag already included into value, if any
293 13         107 return pack('C C', $attr->{id}, $len_encoded + 2) . $encoded;
294             }
295              
296             # VSA
297              
298 3         7 my $vsa_header;
299 3 100       9 if ($vendor_id == WIMAX_VENDOR) {
300 1         6 $vsa_header = pack('N C C C', $vendor_id, $attr->{id}, $len_encoded + 3, 0);
301             }
302             else {
303             # tag already included into value, if any
304 2         9 $vsa_header = pack('N C C', $vendor_id, $attr->{id}, $len_encoded + 2);
305             }
306              
307 3         33 return pack('C C', ATTR_VENDOR, length($vsa_header) + $len_encoded + 2) . $vsa_header . $encoded;
308             }
309              
310             # parse binary-encoded radius packet
311             # returns list: type, request-id, authenticator, \@AV_list
312             sub parse {
313 6     6 1 11520 my ($self, $packet, $orig_auth) = @_;
314              
315 6         211 my $dict = $self->dict;
316              
317 6         93 my($type, $req_id, $length, $auth, $attributes) = unpack('C C n a16 a*', $packet);
318              
319             # Validate authenticator field
320 6         17 my $expected_auth;
321 6 100       23 if ($type == ACCESS_REQUEST) {
322             # authenticator is random value - no validation
323             }
324             else {
325 4         23 my $used_auth;
326 4 50       12 if ($self->is_request($type)) {
327 4         6 $used_auth = "\x00" x 16;
328             }
329             else {
330             # fo replied we have to use authenticator from request:
331 0 0       0 if (! $orig_auth) {
332 0         0 warn "No original authenticator - unable to verify reply";
333 0         0 return undef;
334             }
335 0         0 $used_auth = $orig_auth;
336             }
337              
338 4   33     26 $md5 //= Digest::MD5->new;
339              
340 4         15 my $hdr = pack('C C n', $type, $req_id, $length);
341 4         109 $md5->add($hdr, $used_auth, $attributes, $self->secret);
342 4         40 $expected_auth = $md5->digest();
343              
344 4 50       16 if($auth ne $expected_auth) {
345 0         0 warn "Bad authenticator value";
346 0         0 return undef;
347             }
348             }
349              
350             # decode attributes
351 6         14 my @attr;
352             my $msg_auth;
353 6         12 my $pos = 0;
354 6         10 my $len = length($attributes);
355              
356 6         21 while ($pos < $len) {
357 13         31 my ($attr_val, $vendor_id, $vendor, $vsa_len, $attr, $tag) = ();
358             # FIXME not supported
359 13         20 my $wimax_cont;
360              
361 13         50 my ($attr_id, $attr_len) = unpack('C C', substr($attributes, $pos, 2));
362              
363 13 100       56 if ($attr_id == ATTR_VENDOR) {
364 1         3 my $vsa_header_len = 6;
365              
366 1         5 ($vendor_id, $attr_id, $vsa_len) = unpack('N C C', substr($attributes, $pos + 2, $vsa_header_len) );
367 1 50       4 if ($vendor_id == WIMAX_VENDOR) {
368             # +1 continuation byte
369 0         0 $vsa_header_len = 7;
370 0         0 $wimax_cont = unpack('C', substr($attributes, $pos + 8, 1));
371 0 0       0 warn 'continuation field is not supported' if ($wimax_cont);
372 0         0 printf "WIMAX cont: %d\n", $wimax_cont;
373             }
374              
375 1 50       4 if ($dict) {
376 1   33     5 $vendor = $dict->vendor_name($vendor_id) // $vendor_id;
377 1         44 $attr = $dict->attribute_name($vendor, $attr_id);
378             }
379              
380 1         14 $attr_val = substr($attributes, $pos + 2 + $vsa_header_len, $attr_len - 2 - $vsa_header_len);
381             }
382             else {
383 12 50       32 if ($dict) {
384 12         45 $attr = $dict->attribute_name(undef, $attr_id);
385             }
386              
387 12         146 $attr_val = substr($attributes, $pos + 2, $attr_len - 2);
388             }
389              
390 13 100 66     60 if ($attr_id == ATTR_MSG_AUTH && ! $vendor) {
391 2 50       6 die "Invalid Message-Authenticator len" if ($attr_len != ATTR_MSG_AUTH_LEN);
392 2         3 $msg_auth = $attr_val;
393             # zero it to verify later
394 2         5 $attr_val = "\x0" x (ATTR_MSG_AUTH_LEN - 2);
395 2         5 substr($attributes, $pos + 2, $attr_len - 2, $attr_val);
396             }
397              
398 13         33 $pos += $attr_len;
399              
400 13 50       34 if (! $attr) {
401             # raw data for unknown attribute
402 0         0 push @attr, {
403             Name => $attr_id,
404             Value => $attr_val,
405             Type => undef,
406             Vendor => $vendor,
407             Tag => undef,
408             };
409 0         0 next;
410             }
411              
412 13         300 (my $decoded, $tag) = decode($attr, $attr_val, $self->dict);
413 13 100       46 if (is_enum_type($attr->{type})) {
414             # try to convert value to constants
415 4   66     18 $decoded = $dict->constant($attr->{name}, $decoded) // $decoded;
416             }
417              
418             # password is expected only in auth request
419 13 100 100     191 if ($type == ACCESS_REQUEST && $attr->{id} == ATTR_PASSWORD && ! $attr->{vendor}) {
      66        
420 2         79 $decoded = decrypt_pwd($decoded, $self->secret, $auth);
421             }
422              
423             push @attr, {
424             Name => $attr->{name},
425             Value => $decoded,
426             Type => $attr->{type},
427 13         112 Vendor => $vendor,
428             Tag => $tag,
429             };
430             }
431              
432 6 100       31 if($msg_auth) {
433             # we already replaced msg auth value to \x0...
434 2         5 my $auth_used;
435 2 50       5 if ($self->is_reply($type)) {
    100          
436 0         0 $auth_used = $orig_auth;
437             }
438             elsif ($type == ACCESS_REQUEST) {
439 1         3 $auth_used = $auth;
440             }
441             else {
442             # other type of request should use 00x16
443             # Message-Authenticator should not be present in ACCOUNTING_REQUEST
444 1         2 $auth_used = "\x00" x 16;
445             }
446              
447 2         11 my $data = join('',
448             pack('C C n', $type, $req_id, $length),
449             $auth_used,
450             $attributes,
451             );
452 2         39 my $hmac = Digest::HMAC_MD5->new($self->secret);
453 2         55 $hmac->add( $data );
454 2         23 my $exp_msg_auth = $hmac->digest;
455              
456 2 50       43 if ($msg_auth ne $exp_msg_auth) {
457 0         0 warn "Message-Authenticator not verified";
458 0         0 return undef;
459             }
460             }
461              
462 6         41 return ($type, $req_id, $auth, \@attr);
463             }
464              
465             # extract request id from packet header without parsing attributes
466             # should be used to find original authenticator value for received reply packet to pass it to decode_request()
467             sub request_id {
468 0     0 1 0 my ($self, $packet) = @_;
469 0         0 my $req_id = unpack('C', substr($packet, 1, 1));
470 0         0 return $req_id;
471             }
472              
473             sub is_reply {
474 9     9 0 24 my ($class, $type) = @_;
475 9   50     112 return $IS_REPLY{ $type } // 0;
476             }
477              
478             sub is_request {
479 9     9 0 22 my ($class, $type) = @_;
480 9   50     39 return $IS_REQUEST{ $type } // 0;
481             }
482              
483             1;
484              
485             __END__