File Coverage

blib/lib/App/OATH.pm
Criterion Covered Total %
statement 232 238 100.0
branch 44 46 95.6
condition n/a
subroutine 37 38 97.3
pod 24 24 100.0
total 337 346 99.1


line stmt bran cond sub pod time code
1             package App::OATH;
2             our $VERSION = '1.20151002'; # VERSION
3              
4 1     1   821 use strict;
  1         2  
  1         23  
5 1     1   5 use warnings;
  1         2  
  1         28  
6              
7 1     1   625 use Convert::Base32;
  1         1984  
  1         64  
8 1     1   618 use Digest::HMAC_SHA1 qw(hmac_sha1);
  1         6550  
  1         53  
9 1     1   853 use English qw{ -no_match_vars };
  1         1041  
  1         7  
10 1     1   462 use Fcntl ':flock';
  1         2  
  1         130  
11 1     1   5 use File::HomeDir qw{ my_home };
  1         2  
  1         50  
12 1     1   6 use JSON;
  1         3  
  1         7  
13 1     1   1073 use POSIX;
  1         7858  
  1         6  
14 1     1   4835 use Term::ReadPassword;
  1         8206  
  1         59  
15 1     1   893 use Term::ReadPassword::Win32;
  1         1202  
  1         53  
16              
17 1     1   836 use App::OATH::Crypt;
  1         3  
  1         1297  
18              
19             sub new {
20 6     6 1 6533     my ( $class ) = @_;
21 6         29     my $self = {
22                     'filename' => my_home() . '/.oath.json',
23                 };
24 6         295     bless $self, $class;
25 6         17     return $self;
26             }
27              
28             sub usage {
29 1     1 1 4153     my ( $self ) = @_;
30 1         71     print "usage: $0 --add string --file filename --help --init --list --newpass --search string \n\n";
31 1         12     print "options:\n\n";
32 1         10     print "--add string\n";
33 1         12     print " add a new password to the database, the format can be one of the following\n";
34 1         10     print " text: identifier:secret\n";
35 1         10     print " url: otpauth://totp/alice\@google.com?secret=JBSWY3DPEHPK3PXP\n\n";
36 1         11     print "--file filename\n";
37 1         10     print " filename for database, default ~/.oath.json\n\n";
38 1         10     print "--help\n";
39 1         11     print " show this help\n\n";
40 1         10     print "--init\n";
41 1         11     print " initialise the database, file must not exist\n\n";
42 1         10     print "--list\n";
43 1         11     print " list keys in database\n\n";
44 1         10     print "--newpass\n";
45 1         10     print " resave database with a new password\n\n";
46 1         10     print "--search string\n";
47 1         10     print " search database for keys matching string\n\n";
48 1         6     exit 0;
49             }
50              
51             sub set_search {
52 3     3 1 1489     my ( $self, $search ) = @_;
53 3         9     $self->{'search'} = $search;
54 3         8     return;
55             }
56              
57             sub get_search {
58 19     19 1 2408     my ( $self ) = @_;
59 19         63     return $self->{'search'};
60             }
61              
62             sub init {
63 3     3 1 4072     my ( $self ) = @_;
64 3         13     my $filename = $self->get_filename();
65 3 100       48     if ( -e $filename ) {
66 1         52         print "Error: file already exists\n";
67 1         7         exit 1;
68                 }
69 2         5     $self->{ 'data_plaintext' } = {};
70 2         10     $self->encrypt_data();
71 2         12     $self->save_data();
72 2         5     return;
73             }
74              
75             sub add_entry {
76 9     9 1 23452     my ( $self, $entry ) = @_;
77 9         35     my $search = $self->get_search();
78 9         27     my $data = $self->get_plaintext();
79              
80 8 100       55     if ( $entry =~ /^otpauth:\/\/totp\// ) {
    100          
81             # Better parsing required
82 2         12         my ( $key, $rest ) = $entry =~ /^otpauth:\/\/totp\/(.*)\?(.*)$/;
83 2         14         my ( $value ) = $rest =~ /secret=([^&]*)/;
84 2 100       8         if ( exists( $data->{$key} ) ) {
85 1         39             print "Error: Key already exists\n";
86 1         6             exit 1;
87                     }
88                     else {
89 1         46             print "Adding OTP for $key\n";
90 1         5             $self->{'data_plaintext'}->{$key} = $value;
91                     }
92                     
93                 }
94                 elsif ( $entry =~ /^[^:]+:[^:]+$/ ) {
95 5         26         my ( $key, $value ) = $entry =~ /^([^:]+):([^:]+)$/;
96 5 100       18         if ( exists( $data->{$key} ) ) {
97 1         35             print "Error: Key already exists\n";
98 1         5             exit 1;
99                     }
100                     else {
101 4         133             print "Adding OTP for $key\n";
102 4         18             $self->{'data_plaintext'}->{$key} = $value;
103                     }
104                     
105                 }
106                 else {
107 1         49         print "Error: Unknown format\n";
108 1         5         exit 1;
109                 }
110              
111 5         16     $self->encrypt_data();
112 5         20     $self->save_data();
113                 
114 5         25     return;
115             }
116              
117             sub list_keys {
118 3     3 1 6983     my ( $self ) = @_;
119 3         11     my $search = $self->get_search();
120 3         13     my $data = $self->get_encrypted();
121              
122 2         17     my $counter = int( time() / 30 );
123              
124 2         29     foreach my $account ( sort keys %$data ) {
125 4 100       13         if ( $search ) {
126 2 100       11             next if ( index( lc $account, lc $search ) == -1 );
127                     }
128 3         86         print "$account\n";
129                 }
130              
131 2         24     print "\n";
132 2         10     return;
133             }
134              
135             sub get_counter {
136 1     1 1 2045     my ( $self ) = @_;
137 1         4     my $counter = int( time() / 30 );
138 1         3     return $counter;
139             }
140              
141             sub display_codes {
142 5     5 1 9792     my ( $self ) = @_;
143 5         32     my $search = $self->get_search();
144 5         27     my $data = $self->get_plaintext();
145 4         14     my $counter = $self->get_counter();
146              
147 4         264     my $max_len = 0;
148              
149 4         25     foreach my $account ( sort keys %$data ) {
150 9 100       19         if ( $search ) {
151 2 100       10             next if ( index( lc $account, lc $search ) == -1 );
152                     }
153 8 100       20         $max_len = length( $account ) if length $account > $max_len;
154                 }
155              
156 4         158     print "\n";
157 4         14     foreach my $account ( sort keys %$data ) {
158 9 100       217         if ( $search ) {
159 2 100       8             next if ( index( lc $account, lc $search ) == -1 );
160                     }
161 8         18         my $secret = uc $data->{ $account };
162 8         30         printf( '%*3$s : %s' . "\n", $account, $self->oath_auth( $secret, $counter ), $max_len );
163                 }
164 4         247     print "\n";
165 4         17     return;
166             }
167              
168             sub oath_auth {
169 10     10 1 2898     my ( $self, $key, $tm ) = @_;
170              
171 10         13     my @chal;
172 10         29     for (my $i=7;$i;$i--) {
173 70         175         $chal[$i] = $tm & 0xFF;
174 70         157         $tm >>= 8;
175                 }
176              
177 10         11     my $challenge;
178                 {
179 1     1   7         no warnings;
  1         2  
  1         1309  
  10         14  
180 10         30         $challenge = pack('C*',@chal);
181                 }
182              
183 10         34     my $secret = decode_base32($key);
184              
185 10         284     my $hashtxt = hmac_sha1($challenge,$secret);
186 10         242     my @hash = unpack("C*",$hashtxt);
187 10         21     my $offset = $hash[$#hash]& 0xf ;
188              
189 10         16     my $truncatedHash=0;
190 10         24     for (my $i=0;$i<4;$i++) {
191 40         44         $truncatedHash <<=8;
192 40         90         $truncatedHash |= $hash[$offset+$i];
193                 }
194 10         13     $truncatedHash &=0x7fffffff;
195 10         12     $truncatedHash %= 1000000;
196 10         23     $truncatedHash = substr( '0'x6 . $truncatedHash, -6 );
197              
198 10         157     return $truncatedHash;
199             }
200              
201             sub set_filename {
202 12     12 1 5347     my ( $self, $filename ) = @_;
203 12 100       69     $self->drop_lock() if $self->{'filename'} ne $filename;
204 12         20     $self->{'filename'} = $filename;
205 12         24     return;
206             }
207              
208             sub get_filename {
209 32     32 1 2365     my ( $self ) = @_;
210 32         85     return $self->{'filename'};
211             }
212              
213             sub get_lockfilename {
214 5     5 1 7     my ( $self ) = @_;
215 5         13     my $filename = $self->get_filename();
216 5         10     my $lockfilename = $filename . '.lock';
217 5         10     return $lockfilename;
218             }
219              
220             sub drop_lock {
221 12     12 1 408     my ( $self ) = @_;
222 12         36     delete $self->{'lockhandle'};
223 12         20     return;
224             }
225              
226             sub get_lock {
227 5     5 1 1818     my ( $self ) = @_;
228              
229 5         6     my $lockh;
230 5         16     my $lockfilename = $self->get_lockfilename();
231 5 100       75     if ( ! -e $lockfilename ) {
232 2         123         open $lockh, '>', $lockfilename;
233 2         12         close $lockh;
234 2         35         chmod( 0600, $lockfilename );
235                 }
236 5         106     open $lockh, '<', $lockfilename;
237 5 100       31     if ( !flock( $lockh, LOCK_EX | LOCK_NB ) ) {
238 3         30         return 0;
239                 }
240 2         5     $self->{'lockhandle'} = $lockh;
241 2         6     return 1;
242             } 
243              
244             sub load_data {
245 13     13 1 310     my ( $self ) = @_;
246 13         90     my $json = JSON->new();
247 13         39     my $filename = $self->get_filename();
248 13 100       393     open( my $file, '<', $filename ) || die "cannot open file $!";
249 4         105     my @content = <$file>;
250 4         119     close $file;
251 4         63     my $data = $json->decode( join( "\n", @content ) );
252 4         12     $self->{'data_encrypted'} = $data;
253 4         26     return;
254             }
255              
256             sub save_data {
257 10     10 1 361     my ( $self ) = @_;
258 10         41     my $data = $self->get_encrypted();
259 9         80     my $json = JSON->new();
260 9         136     my $content = $json->encode( $data );
261 9         32     my $filename = $self->get_filename();
262 9 100       751     open( my $file, '>', $filename ) || die "cannot open file $!";
263 8         72     print $file $content;
264 8         290     close $file;
265 8         147     chmod( 0600, $filename );
266 8         54     return;
267             }
268              
269             sub encrypt_data {
270 9     9 1 429     my ( $self ) = @_;
271 9         26     my $data = $self->get_plaintext();
272 8 100       33     $self->drop_password() if $self->{'newpass'};
273 8         22     my $crypt = App::OATH::Crypt->new( $self->get_password() );
274 8         16     my $edata = {};
275 8         22     foreach my $k ( keys %$data ) {
276 11         38         $edata->{$k} = $crypt->encrypt( $data->{$k} );
277                 }
278 8         24     $self->{'data_encrypted'} = $edata;
279 8         86     return;
280             }
281              
282             sub decrypt_data {
283 9     9 1 326     my ( $self ) = @_;
284 9         24     my $data = $self->get_encrypted();
285 4         22     my $crypt = App::OATH::Crypt->new( $self->get_password() );
286 4         11     my $ddata = {};
287 4         13     foreach my $k ( keys %$data ) {
288 7         39         my $d = $crypt->decrypt( $data->{$k} );
289 7 100       18         if ( ! $d ) {
290 1         59             print "Invalid password\n";
291 1         5             exit 1;
292                     }
293 6         14         $ddata->{$k} = $d;
294                 }
295 3         7     $self->{'data_plaintext'} = $ddata;
296 3         13     return;
297             }
298              
299             sub get_plaintext {
300 28     28 1 4227     my ( $self ) = @_;
301 28 100       104     $self->decrypt_data() if ! exists $self->{'data_plaintext'};
302 23         51     return $self->{'data_plaintext'};
303             }
304              
305             sub get_encrypted {
306 25     25 1 1010     my ( $self ) = @_;
307 25 100       119     $self->load_data() if ! exists $self->{'data_encrypted'};
308 17         42     return $self->{'data_encrypted'};
309             }
310              
311             sub set_newpass {
312 1     1 1 10     my ( $self ) = @_;
313 1         7     $self->{'newpass'} = 1;
314 1         2     return;
315             }
316              
317             sub drop_password {
318 2     2 1 6     my ( $self ) = @_;
319 2         6     delete $self->{'password'};
320 2         4     return;
321             }
322              
323             sub _read_password_stdin {
324             # uncoverable subroutine
325             # Cannot be covered as we do not yet
326             # have a reliable method of faking this
327             # input.
328             # This is fine as we are simply acting as
329             # a wrapper around Term::ReadPassword
330             # NB, Term::ReadPassword is not Win32 safe
331 0     0   0     my ( $self ) = @_; # uncoverable statement
332 0         0     my $password; # uncoverable statement
333 0 0       0     if ( $OSNAME eq 'MSWin32' ) { # uncoverable statement
334 0         0         $password = Term::ReadPassword::Win32::read_password('Password:'); # uncoverable statement
335                 } # uncoverable statement
336                 else { # uncoverable statement
337 0         0         $password = Term::ReadPassword::read_password('Password:'); # uncoverable statement
338                 } # uncoverable statement
339 0         0     return $password; # uncoverable statement
340             }
341              
342             sub get_password {
343 15     15 1 1942     my ( $self ) = @_;
344 15 100       123     return $self->{'password'} if $self->{'password'};
345 2         8     my $password = $self->_read_password_stdin();
346 2         165     $self->{'password'} = $password;
347 2         5     delete $self->{'newpass'};
348 2         12     return $password;
349             }
350              
351             1;
352              
353             # ABSTRACT: Simple OATH authenticator
354             __END__
355            
356             =head1 NAME
357            
358             App::OATH - Simple OATH authenticator
359            
360             =head1 DESCRIPTION
361            
362             Simple command line OATH authenticator written in Perl.
363            
364             =head1 SYNOPSIS
365            
366             Implements the Open Authentication (OATH) time-based one time password (TOTP)
367             two factor authentication standard as a simple command line programme.
368            
369             Allows storage of multiple tokens, which are kept encrypted on disk.
370            
371             Google Authenticator is a popular example of this standard, and this project
372             can be used with the same tokens.
373            
374             =head1 USAGE
375            
376             usage: oath --add string --file filename --help --init --list --newpass --search string
377            
378             options:
379            
380             --add string
381            
382             add a new password to the database, the format can be one of the following
383            
384             text: identifier:secret
385             url: otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP
386            
387             --file filename
388            
389             filename for database, default ~/.oath.json
390            
391             --help
392            
393             show this help
394            
395             --init
396            
397             initialise the database, file must not exist
398            
399             --list
400            
401             list keys in database
402            
403             --newpass
404            
405             resave database with a new password
406            
407             --search string
408            
409             search database for keys matching string
410            
411             =head1 SECURITY
412            
413             Tokens are encrypted on disk, the identifiers are not encrypted and can be read in plaintext
414             from the file.
415            
416             This is intended to secure against casual reading of the file, but as always, if you have specific security requirements
417             you should do your own research with regard to relevant attack vectors and use an appropriate solution.
418            
419             =head1 METHODS
420            
421             You most likely won't ever want to call these directly, you should use the included command line programme instead.
422            
423             =over
424            
425             =item I<new()>
426            
427             Instantiate a new object
428            
429             =item I<usage()>
430            
431             Display usage and exit
432            
433             =item I<set_search()>
434            
435             Set the search parameter
436            
437             =item I<get_search()>
438            
439             Get the search parameter
440            
441             =item I<init()>
442            
443             Initialise a new file
444            
445             =item I<add_entry()>
446            
447             Add an entry to the file
448            
449             =item I<list_keys()>
450            
451             Display a list of keys in the current file
452            
453             =item I<get_counter()>
454            
455             Get the current time based counter
456            
457             =item I<display_codes()>
458            
459             Display a list of codes
460            
461             =item I<oath_auth()>
462            
463             Perform the authentication calculations
464            
465             =item I<set_filename()>
466            
467             Set the filename
468            
469             =item I<get_filename()>
470            
471             Get the filename
472            
473             =item I<load_data()>
474            
475             Load in data from file
476            
477             =item I<save_data()>
478            
479             Save data to file
480            
481             =item I<encrypt_data()>
482            
483             Encrypt the data
484            
485             =item I<decrypt_data()>
486            
487             Decrypt the data
488            
489             =item I<get_plaintext()>
490            
491             Get the plaintext version of the data
492            
493             =item I<get_encrypted()>
494            
495             Get the encrypted version of the data
496            
497             =item I<set_newpass()>
498            
499             Signal that we would like to set a new password
500            
501             =item I<drop_password()>
502            
503             Drop the password
504            
505             =item I<get_password()>
506            
507             Get the current password (from user or cache)
508            
509             =item I<get_lockfilename()>
510            
511             Return a filename for the lock file, typically this is filename appended with .lock
512            
513             =item I<drop_lock()>
514            
515             Drop the lock (unlock)
516            
517             =item I<get_lock()>
518            
519             Get a lock, return 1 on success or 0 on failure
520            
521             =back
522            
523             =head1 DEPENDENCIES
524            
525             Convert::Base32
526             Digest::HMAC_SHA1
527             English
528             Fcntl
529             File::HomeDir
530             JSON
531             POSIX
532             Term::ReadPassword
533             Term::ReadPassword::Win32
534            
535             =head1 AUTHORS
536            
537             Marc Bradshaw E<lt>marc@marcbradshaw.netE<gt>
538            
539             =head1 COPYRIGHT
540            
541             Copyright 2015
542            
543             This library is free software; you may redistribute it and/or
544             modify it under the same terms as Perl itself.
545            
546