File Coverage

blib/lib/App/OATH.pm
Criterion Covered Total %
statement 203 203 100.0
branch 36 38 94.7
condition n/a
subroutine 30 30 100.0
pod 21 21 100.0
total 290 292 99.3


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