File Coverage

blib/lib/Validate/Yubikey.pm
Criterion Covered Total %
statement 65 89 73.0
branch 14 30 46.6
condition 3 9 33.3
subroutine 7 9 77.7
pod 2 5 40.0
total 91 142 64.0


line stmt bran cond sub pod time code
1             package Validate::Yubikey;
2              
3             our $VERSION = '0.01';
4              
5             =head1 NAME
6              
7             Validate::Yubikey - Validate Yubikey OTPs
8              
9             =head1 SYNOPSIS
10              
11             use Validate::Yubikey;
12              
13             sub validate_callback {
14             my $public_id = shift;
15              
16             return {
17             iid => $iid,
18             key => $key,
19             count => $count,
20             use => $use,
21             lastuse => $lastuse,
22             lastts => $lastts,
23             };
24             }
25              
26             sub update_callback {
27             my ($public_id, $data) = @_;
28             }
29              
30             sub log_message {
31             print shift, "\n";
32             }
33              
34             my $yubi = Validate::Yubikey->new(
35             callback => \&validate_callback,
36             update_callback => \&update_callback,
37             log_callback => \&log_message,
38             );
39              
40             my $otp_valid = $yubi->validate("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
41              
42             =head1 DESCRIPTION
43              
44             The Yubikey is a hardware OTP token produced by Yubico (L).
45              
46             This module provides validation of Yubikey OTPs. It relies on you to specify
47             callback functions that handle retrieving token information from somewhere and
48             updating the persistent information associated with each token.
49              
50             =cut
51              
52 1     1   29238 use Carp;
  1         2  
  1         97  
53 1     1   1029 use Crypt::ECB;
  1         2741  
  1         66  
54 1     1   976 use Crypt::Rijndael;
  1         9262  
  1         1637  
55              
56             sub hex2modhex {
57 0     0 0 0 my $s = shift;
58 0         0 $s =~ tr/0123456789abcdef/cbdefghijklnrtuv/;
59 0         0 return $s;
60             }
61              
62             sub modhex2hex {
63 1     1 0 3 my $s = shift;
64 1         3 $s =~ tr/cbdefghijklnrtuv/0123456789abcdef/;
65 1         3 return $s;
66             }
67              
68             sub yubicrc {
69 1     1 0 3 my $data = shift;
70 1         2 my $crc = 0xffff;
71              
72 1         13 foreach my $h (unpack('H2' x 16, $data)) {
73 16         19 my $d = hex($h);
74 16         19 $crc = $crc ^ ($d & 0xff);
75 16         20 for (1..8) {
76 128         116 my $n = $crc & 1;
77 128         103 $crc = $crc >> 1;
78 128 100       211 if ($n != 0) {
79 64         81 $crc = $crc ^ 0x8408;
80             }
81             }
82             }
83            
84 1         4 return $crc;
85             }
86              
87             =head1 METHODS
88              
89             =head2 new
90              
91             Create a new Validate::Yubikey instance.
92              
93             =over 4
94              
95             =item callback
96              
97             Required.
98              
99             =item update_callback
100              
101             Required.
102              
103             =item log_callback
104              
105             Optional.
106              
107             =back
108              
109             =cut
110              
111             sub new {
112 1     1 1 855 my ($class, %data) = @_;
113 1         3 my $self = {};
114 1         3 bless $self, $class;
115              
116 1 50 33     11 if (exists $data{callback} && ref($data{callback}) eq 'CODE') {
117 1         7 $self->{callback} = $data{callback};
118             } else {
119 0         0 croak __PACKAGE__, '->new called without callback';
120             }
121              
122 1 50 33     10 if (exists $data{update_callback} && ref($data{update_callback}) eq 'CODE') {
123 1         4 $self->{update_callback} = $data{update_callback};
124             } else {
125 0         0 croak __PACKAGE__, '->new called without update_callback';
126             }
127              
128 1 50 33     8 if (exists $data{log_callback} && ref($data{log_callback}) eq 'CODE') {
129 1         7 $self->{log_callback} = $data{log_callback};
130             } else {
131 0     0   0 $self->{log_callback} = sub {};
  0         0  
132             }
133              
134 1 50       3 if ($data{max_age}) {
135 0         0 $self->{max_age} = $data{max_age};
136             } else {
137 1         3 $self->{max_age} = 60;
138             }
139              
140 1         4 return $self;
141             }
142              
143             =head2 validate
144              
145             =over 4
146              
147             =item Arguments: $otp, @callback_args
148              
149             =item Return Value: $success
150              
151             =back
152              
153             Validate an OTP.
154              
155             =cut
156              
157             sub validate {
158 1     1 1 1189 my ($self, $otp, @cbargs) = @_;
159              
160 1 50       11 if ($otp =~ /^([cbdefghijklnrtuv]{0,16})([cbdefghijklnrtuv]{32})$/) {
161 1         6 my ($public_id, $cipher) = ($1, $2);
162              
163 1         6 my $token = $self->{callback}->($public_id, @cbargs);
164              
165 1 50       673 if (!$token) {
166 0         0 $self->{log_callback}->(sprintf('callback returned no token for pid %s', $public_id));
167 0         0 return 0;
168             }
169              
170 1         4 foreach my $k (qw/key count iid lastuse lastts use/) {
171 6 50       18 if (!defined($token->{$k})) {
172 0         0 carp "callback didn't return $k";
173 0         0 return 0;
174             }
175             }
176              
177 1         8 $cipher = &modhex2hex($cipher);
178              
179 1         10 my $crypt = Crypt::ECB->new;
180 1         25 $crypt->start('decrypt');
181 1         21 $crypt->cipher('Rijndael');
182 1         189 $crypt->key(pack('H*', $token->{key}));
183 1         14 my $plaintext = $crypt->decrypt(pack('H*', $cipher));
184 1         148 my $plainhex = unpack('H*', $plaintext);
185              
186 1 50       6 if (substr($plainhex, 0, length($token->{iid})) eq $token->{iid}) {
187 1         5 my $crc = &yubicrc($plaintext);
188              
189 1 50       6 if ($crc == 0xf0b8) {
190 1         3 my $count = hex(substr($plainhex, 14, 2).substr($plainhex, 12, 2));
191 1         3 my $use = hex(substr($plainhex, 22, 2));
192 1         3 my $low = substr($plainhex, 18, 2).substr($plainhex, 16, 2);
193 1         3 my $high = substr($plainhex, 20, 2);
194 1         3 my $ts = ((hex($high) << 16) + hex($low)) / 8; # XXX magic
195              
196 1         8 my $tinfo = sprintf('iid=%s, count=%d, use=%d, ts=%d', $token->{iid}, $count, $use, $ts);
197 1         12 my $tsnow = $token->{lastts} + (time() - $token->{lastuse});
198 1         2 my $tsage = $tsnow - $ts;
199 1         7 $self->{update_callback}->($public_id, { lastuse => time(), lastts => $ts });
200              
201 1 50       12 if ($count < $token->{count}) {
    50          
    50          
202 0         0 $self->{log_callback}->(sprintf('token %s failed: duplicate otp, count (%s)', $public_id, $tinfo));
203             } elsif ($count == $token->{count}) {
204 0 0       0 if ($use <= $token->{use}) {
    0          
205 0         0 $self->{log_callback}->(sprintf('token %s failed: duplicate otp in same session (%s)', $public_id, $tinfo));
206             } elsif ($tsage > $self->{max_age}) {
207 0         0 $self->{log_callback}->(sprintf('token %s failed: expired otp is %d seconds old (%s)', $public_id, $tsage, $tinfo));
208             } else {
209 0         0 $self->{log_callback}->(sprintf('token %s ok, same session (%s)', $public_id, $tinfo));
210 0         0 $self->{update_callback}->($public_id, { count => $count, use => $use });
211 0         0 return 1;
212             }
213             } elsif ($count > $token->{count}) {
214 1         7 $self->{log_callback}->(sprintf('token %s ok (%s)', $public_id, $tinfo));
215 1         8 $self->{update_callback}->($public_id, { count => $count, use => $use });
216 1         20 return 1;
217             } else {
218 0           $self->{log_callback}->(sprintf('something bad with token %s (%s)', $public_id, $tinfo));
219             }
220             } else {
221 0           $self->{log_callback}->(sprintf('token %s failed: corrupt otp (crc)', $public_id));
222             }
223             } else {
224 0           $self->{log_callback}->(sprintf('token %s failed: corrupt otp (internal id)', $public_id));
225             }
226             } else {
227 0           $self->{log_callback}->(sprintf('token %s failed: invalid otp', $public_id));
228             }
229              
230 0           return 0;
231             }
232              
233             =head1 CALLBACKS
234              
235             =head2 callback
236              
237             =over 4
238              
239             =item Receives: $public_id, @callback_args
240              
241             =item Returns: \%token_data
242              
243             =back
244              
245             Called during validation when information about the token is required.
246             Receives the public ID of the Yubikey. It's expected that your subroutine
247             returns a hash reference containing the following keys:
248              
249             =over 4
250              
251             =item iid - Internal ID
252              
253             =item key - Secret key
254              
255             =back
256              
257             Plus the four values stored by the L.
258              
259             =head2 update_callback
260              
261             =over 4
262              
263             =item Receives: $public_id, \%token_data, @callback_args
264              
265             =item Returns: nothing
266              
267             =back
268              
269             Called to update the persistent storage of token parameters that enable replay
270             protection. C<%token_data> will contain one or more of the following keys,
271             which should be associated with the supplied C<$public_id>:
272              
273             =over 4
274              
275             =item count
276              
277             =item use
278              
279             =item lastuse
280              
281             =item lastts
282              
283             =back
284              
285             These should all be integers.
286              
287             =head2 log_callback
288              
289             =over 4
290              
291             =item Receives: $log_message
292              
293             =item Returns: nothing
294              
295             =back
296              
297             Called with messages produced during validation. If not supplied to L,
298             logging will disabled.
299              
300             =head1 EXAMPLE
301              
302             Here's a simple program that uses L to store token information.
303              
304             package YKKSM::DB::Token;
305             use base qw/DBIx::Class/;
306            
307             __PACKAGE__->load_components(qw/PK::Auto Core/);
308             __PACKAGE__->table('token');
309             __PACKAGE__->add_columns(qw/uid pid iid key count use lastuse lastts/);
310             __PACKAGE__->set_primary_key('uid');
311            
312             package YKKSM::DB;
313             use base qw/DBIx::Class::Schema/;
314            
315             __PACKAGE__->load_classes(qw/Token/);
316            
317             package YKTest;
318             use Validate::Yubikey;
319            
320             my $schema = YKKSM::DB->connect("dbi:SQLite:dbname=yktest.db");
321            
322             my $yk = Validate::Yubikey->new(
323             callback => sub {
324             my $pid = shift;
325             my $token = $schema->resultset('Token')->find({ pid => $pid });
326            
327             if ($token) {
328             return {
329             iid => $token->iid,
330             key => $token->key,
331             count => $token->count,
332             use => $token->use,
333             lastuse => $token->lastuse,
334             lastts => $token->lastts,
335             };
336             } else {
337             return undef;
338             }
339             },
340             update_callback => sub {
341             my ($pid, $data) = @_;
342             my $token = $schema->resultset('Token')->find({ pid => $pid });
343             if ($token) {
344             $token->update($data);
345             } else {
346             die "asked to update nonexistent token $pid";
347             }
348             },
349             log_callback => sub {
350             print shift, "\n";
351             },
352             );
353            
354             if ($yk->validate($ARGV[0])) {
355             print "success!\n";
356             } else {
357             print "failure 8(\n";
358             }
359              
360             =head1 AUTHOR
361              
362             Ben Wilber
363              
364             But most of this module was derived from Yubico's PHP stuff.
365              
366             =head1 LICENSE
367              
368             This library is free software and may be distributed under the same terms
369             as perl itself.
370              
371             =cut
372              
373             1;