File Coverage

blib/lib/WWW/KeePassHttp.pm
Criterion Covered Total %
statement 111 111 100.0
branch 42 42 100.0
condition 19 19 100.0
subroutine 21 21 100.0
pod 11 11 100.0
total 204 204 100.0


line stmt bran cond sub pod time code
1             package WWW::KeePassHttp;
2 8     8   571707 use 5.012; # //, strict, s//r
  8         80  
3 8     8   44 use warnings;
  8         15  
  8         289  
4            
5 8     8   1041 use MIME::Base64;
  8         2306  
  8         479  
6 8     8   3542 use Crypt::Mode::CBC;
  8         89777  
  8         262  
7 8     8   5099 use HTTP::Tiny;
  8         261954  
  8         268  
8 8     8   56 use JSON; # will use JSON::XS when available
  8         19  
  8         60  
9 8     8   6801 use Time::HiRes qw/gettimeofday sleep/;
  8         11770  
  8         41  
10 8     8   1407 use MIME::Base64;
  8         19  
  8         428  
11 8     8   57 use Carp;
  8         18  
  8         926  
12            
13             our $VERSION = '0.010'; # rrr.mmmsss : rrr is major revision; mmm is minor revision; sss is sub-revision (new feature path or bugfix); optionally use _sss instead, for alpha sub-releases
14            
15             my $dumpfn;
16             BEGIN {
17 7         211 $dumpfn = sub { JSON->new->utf8->pretty->encode($_[0]) } # hidden from podcheckers and external namespace
18 8     8   12716 }
19            
20             =pod
21            
22             =head1 NAME
23            
24             WWW::KeePassHttp - Interface with KeePass PasswordSafe through the KeePassHttp plugin
25            
26             =head1 SYNOPSIS
27            
28             use WWW::KeePassHttp;
29            
30             my $kph = WWW::KeePassHttp->new(Key => $key);
31             $kph->associate() unless $kph->test_associate();
32             my $entries = $kph->get_logins($search_string);
33             print "$_ => $entries->[0]{$_}\n" for qw/Name Login Password/;
34            
35             =head1 DESCRIPTION
36            
37             Interface with KeePass PasswordSafe through the KeePassHttp plugin. Allows reading entries based on URL or TITLE. Maybe will allow creating a new entry as well.
38            
39             =head2 REQUIREMENTS
40            
41             You need to have KeePass (or compatible) on your system, with the KeePassHttp plugin installed.
42            
43             =head1 INTERFACE
44            
45             =head2 CONSTRUCTOR AND CONFIGURATION
46            
47             =over
48            
49             =item new
50            
51             my $kph = WWW::KeePassHttp->new( Key => $key, %options);
52             my $kph = WWW::KeePassHttp->new( Key => $key, keep_alive => 0, %options);
53            
54             Creates a new KeePassHttp connection, and sets up the AES encryption.
55            
56             The C $key> is required; pass in a string of 32 octets that
57             represent a 256-bit key value. If you have your key as 64 hex nibbles,
58             then use C<$key = pack 'H*', $hexnibbles;> to convert it to the value.
59             If you have your key as a Base64 string, use
60             C<$key = decode_base64($base64string);> to convert it to the value.
61            
62             There is also a C option, which will tell the HTTP user
63             agent to keep the connection alive when the option is set to C<1> (or
64             when it's not specified); setting the option to a C<0> will disable
65             that feature of the user agent.
66            
67             The C<%options> share the same name and purposes with the
68             configuration methods that follow, and can be individually specified in
69             the constructor as key/value pairs, or passing in an C<%options> hash.
70            
71             =cut
72            
73             sub new
74             {
75 21     21 1 12897 my ($class, %opts) = @_;
76 21         62 my $self = bless {}, $class;
77            
78             # user agent and URL
79 21   100     129 $opts{keep_alive} //= 1; # default to keep_alive
80 21         96 $self->{ua} = HTTP::Tiny->new(keep_alive => $opts{keep_alive} );
81            
82 21   100     1618 $self->{request_base} = $opts{request_base} // 'http://localhost'; # default to localhost
83 21   100     134 $self->{request_port} = $opts{request_port} // 19455; # default to 19455
84 21         134 $self->{request_url} = $self->{request_base} . ':' . $self->{request_port};
85            
86             # encryption object
87 21         338 $self->{cbc} = Crypt::Mode::CBC->new('AES');
88 21         60 $self->{key} = $opts{Key};
89 21         53 for($self->{key}) {
90 21 100       84 croak "256-bit AES key is required" unless defined $_;
91 19 100       65 last if length($_) == 32; # a 32-octet string is assumed to be a valid key
92 5         10 chomp;
93 5 100       47 croak "256-bit AES key must be in octets, not hex nibbles"
94             if /^(0x)?[[:xdigit:]]{64}$/;
95 3 100       24 croak "256-bit AES key must be in octets, not in base64"
96             if length($_) == 44;
97 1         10 croak "Key not recognized as 256-bit AES";
98             }
99 14         62 $self->{key64} = encode_base64($self->{key}, '');
100            
101             # appid
102 14   100     71 $self->{appid} = $opts{appid} // 'WWW::KeePassHttp';
103            
104 14         86 return $self;
105             }
106            
107             =item appid
108            
109             %options = ( ..., appid => 'name of your app', ... );
110             or
111             $kph->appid('name of your app');
112            
113             Changes the appid, which is the name that is used to map your
114             application with the stored key in the KeePassHttp settings in
115             KeePass.
116            
117             If not defined in the initial options or via this method,
118             the module will use a default appid of C.
119            
120             =cut
121            
122             sub appid
123             {
124 2     2 1 11 my ($self, $val) = @_;
125 2 100       8 $self->{appid} = $val if defined $val;
126 2         7 return $self->{appid};
127             }
128            
129             =item request_base
130            
131             %options = ( ..., request_base => 'localhost', ... );
132             or
133             $kph->request_base('127.0.0.1');
134            
135             Changes the protocol and host: the KeePassHttp plugin defaults to C, but can be configured differently, so you will need to make your object match your plugin settings.
136            
137             =cut
138            
139             sub request_base
140             {
141 2     2 1 12 my ($self, $val) = @_;
142 2 100       6 $self->{request_base} = $val if defined $val;
143 2         5 $self->{request_url} = $self->{request_base} . ':' . $self->{request_port};
144 2         7 return $self->{request_base};
145             }
146            
147             =item request_port
148            
149             %options = ( ..., request_port => 19455, ... );
150             or
151             $kph->request_port(19455);
152            
153             Changes the port: the KeePassHttp plugin defaults to port 19455, but can be configured differently, so you will need to make your object match your plugin settings.
154            
155             =cut
156            
157             sub request_port
158             {
159 2     2 1 8 my ($self, $val) = @_;
160 2 100       7 $self->{request_port} = $val if defined $val;
161 2         6 $self->{request_url} = $self->{request_base} . ':' . $self->{request_port};
162 2         6 return $self->{request_port};
163             }
164            
165             =back
166            
167             =for comment END OF CONSTRUCTOR AND CONFIGURATION
168            
169             =head2 USER INTERFACE
170            
171             These methods implement the L, with one method for each RequestType.
172            
173             =over
174            
175             =item test_associate
176            
177             $kph->associate unless $kph->test_associate();
178            
179             Sends the C request to the KeePassHttp server,
180             which is used to see whether or not your application has been
181             associated with the KeePassHttp plugin or not. Returns a true
182             value if your application is already associated, or a false
183             value otherwise.
184            
185             =cut
186            
187             sub test_associate
188             {
189 2     2 1 1409 my ($self, %args) = @_;
190 2         8 my $content = $self->request('test-associate', %args);
191 2         9 return $content->{Success};
192             }
193            
194             =item associate
195            
196             $kph->associate unless $kph->test_associate();
197            
198             Sends the C request to the KeePassHttp server,
199             which is used to give your application's key to the KeePassHttp
200             plugin.
201            
202             When this request is received, KeePass will pop up a dialog
203             asking for a name -- this name should match the C value
204             that you defined for the C<$kph> instance. All requests sent
205             to the plugin will include this C so that KeePassHttp can
206             look up your application's key, so it must match exactly.
207             As per the C,
208             the server saves your application's key in the C
209             entry, in the B String Fields> with a name of
210             C, where C is the name you type in the dialog
211             box (which needs to match your C).
212            
213             B: this C communication is insecure,
214             since KeePassHttp plugin is not using HTTPS. Every other
215             communication between your application and the plugin uses the
216             key (which both your application and the plugin know) to
217             encrypt the critical data (usernames, passwords, titles, etc),
218             and is thus secure;
219             but the C interaction, because it happens before
220             the plugin has your key, by its nature cannot be encrypted by
221             that key, so it sends the encoded key I. If this
222             worries you, I suggest that you manually insert the key: do an
223             C once with a dummy key, then manually overwrite the
224             encoded key that it stores with the encoded version of your real
225             key. (This limitation is due to the design of the KeePassHttp
226             plugin and its protocol for the C command, not due
227             to the wrapper around that protocol that this module implements.)
228            
229             =cut
230            
231             sub associate
232             {
233 3     3 1 1265 my ($self, %args) = @_;
234 3         15 my $content = $self->request('associate', Key64 => $self->{key64}, %args);
235 3 100 100     22 croak ("Wrong ID: ", $dumpfn->( { wrong_id => $content } )) unless $self->{appid} eq ($content->{Id}//'');
236 1         4 return $content;
237             }
238            
239             =item get_logins
240            
241             my $entries = $kph->get_logins($search_string);
242             print "$_ => $entries->[0]{$_}\n" for qw/Name Login Password/;
243            
244             Sends the C request, which returns the Name,
245             Login, and Password for each of the matching entries.
246            
247             C<$entries> is an AoH structure: it is an array reference,
248             and each element of that array is a hash reference; each
249             referenced hash includes Name, Login, and Password entries.
250            
251             The rules for the matching of the search string are defined in the
252             L.
253             But, in brief, it will do a fuzzy match on the URL, and an exact match
254             on the entry title. (The plugin was designed to be used for browser plugins
255             to request passwords for URLs from KeePass, hence its focus on URLs.)
256            
257             =cut
258            
259             sub get_logins
260             {
261 3     3 1 6562 my ($self, $search_term, %args) = @_;
262 3         8 $args{Url} = $search_term;
263 3 100       16 $args{SubmitUrl} = $self->{appid} unless exists $args{SubmitUrl}; # "SubmitUrl" is actually the name of the requestor; in the browser->keePassHttp interface, the requestor is the website requesting the password; but here, I am using it as the app identifier
264 3         19 my $content = $self->request('get-logins', Url => $search_term, %args);
265 3 100       15 return [] unless $content->{Count};
266 2         20 my $entries = $content->{Entries};
267 2         7 for my $entry ( @$entries ) {
268 2         19 for my $k ( sort keys %$entry ) {
269 8         103 $entry->{$k} = $self->{cbc}->decrypt( decode_base64($entry->{$k}), $self->{key}, decode_base64($content->{Nonce}));
270             }
271             }
272             #$dumpfn->( { Entries => $entries } );
273 2         36 return $entries;
274             }
275            
276             =item get_logins_count
277            
278             my $count = $kph->get_logins_count($search_string);
279            
280             Sends the C request, which returns a count of
281             the number of matches for the search string.
282            
283             The rules for the matching of the search string are defined in the
284             L.
285             But, in brief, it will do a fuzzy match on the URL, and an exact match
286             on the entry title. (The plugin was designed to be used for browser plugins
287             to request passwords for URLs from KeePass, hence its focus on URLs.)
288            
289             This method is useful when the fuzzy-URL-match might match a large
290             number of entries in the database; if after seeing this count, you
291             would rather refine your search instead of requesting that many entries,
292             this method enables knowing that right away, rather than after you
293             accidentally matched virtually every entry in your database by searching
294             for C.
295            
296             =cut
297            
298             sub get_logins_count
299             {
300 2     2 1 6176 my ($self, $search_term, %args) = @_;
301 2         6 $args{Url} = $search_term;
302 2 100       13 $args{SubmitUrl} = $self->{appid} unless exists $args{SubmitUrl}; # "SubmitUrl" is actually the name of the requestor; in the browser->keePassHttp interface, the requestor is the website requesting the password; but here, I am using it as the app identifier
303 2         11 my $content = $self->request('get-logins-count', Url => $search_term, %args);
304 2         11 return $content->{Count};
305             }
306            
307            
308             =item set_login
309            
310             $kph->set_login( Login => $username, Url => $url_and_title, Password => $password );
311            
312             Sends the C request, which adds a new entry to your
313             KeePass database, in the "KeePassHttp Passwords" group (folder).
314            
315             As far as I know, the plugin doesn't allow choosing a different group
316             for your entry. The plugin uses the URL that you supply as both the
317             entry title and the URL field in that entry. (Once again, the plugin
318             was designed around browser password needs, and thus is URL-focused).
319             I don't know if that's a deficiency in the plugin's implementation,
320             or just its documentation, or my interpretation of that documentation.
321            
322             The arguments to the method define the C (username), C (for
323             entry title and URL field), and C (secret value) for the new
324             entry. All three of those parameters are required by the protocol, and
325             thus by this method.
326            
327             If you would prefer not to give one or more of those parameters a value,
328             just pass an empty string. You could afterword then manually access
329             your KeePass database and edit the entry yourself.
330            
331             =cut
332            
333             sub set_login
334             {
335 4     4 1 5608 my ($self, %args) = @_;
336 4 100       24 croak "set_login(): missing Login parameter" unless defined $args{Login};
337 3 100       24 croak "set_login(): missing Url parameter" unless defined $args{Url};
338 2 100       16 croak "set_login(): missing Password parameter" unless defined $args{Password};
339 1         5 my $content = $self->request('set-login', %args);
340 1         8 return $content->{Success};
341             }
342            
343            
344             =item request
345            
346             my $results = $kph->request( $type, %options );
347            
348             This is the generic method for making a request of the
349             KeePassHttp plugin. In general, other methods should handle
350             most requests. However, maybe a new method has been exposed
351             in the plugin but not yet implemented here, so you can use
352             this method for handling that.
353            
354             The C<$type> indicates the RequestType, which include
355             C, C, C,
356             C, and C.
357            
358             This method automatically fills out the RequestType, TriggerUnlock, Id, Nonce, and Verifier parameters. If your RequestType requires
359             any other parameters, add them to the C<%options>.
360            
361             It then encodes the request into the JSON payload, and
362             sends that request to the KeePassHttp plugin, and gets the response,
363             decoding the JSON content back into a Perl hashref. It verifies that
364             the response's Nonce and Verifier parameters are appropriate for the
365             communication channel, to make sure communications from the plugin
366             are properly encrypted.
367            
368             Returns the hashref decoded from the JSON
369            
370             =cut
371            
372             sub request {
373 18     18 1 4349 my ($self, $type, %params) = @_;
374 18         53 my ($iv, $nonce) = generate_nonce();
375            
376             #print STDERR "request($type):\n";
377            
378             # these are required in every request
379             my %request = (
380             RequestType => $type,
381             TriggerUnlock => JSON::true, # was intended for TRUE to request that KeePass unlock, but that doesn't actually happen
382             Id => $self->{appid},
383             Nonce => $nonce,
384 18         84 Verifier => encode_base64($self->{cbc}->encrypt($nonce, $self->{key}, $iv), ''),
385             );
386            
387             # don't want to encrypt the key during an association request
388 18         666 delete $params{Key}; # only allow Key64
389 18 100       59 $request{Key} = delete $params{Key64} if( exists $params{Key64} );
390            
391             # encrypt all remaining parameter values
392 18         75 while(my ($k,$v) = each %params) {
393 13         139 $request{$k} = encode_base64($self->{cbc}->encrypt($v, $self->{key}, $iv), '');
394             }
395             #$dumpfn->({final_request => \%request});
396            
397             # send the request
398 18         408 my $response = $self->{ua}->get($self->{request_url}, {content=> encode_json \%request});
399            
400             # error checking
401 18 100       1893 croak $dumpfn->( { request_error => $response } ) unless $response->{success};
402 17 100       57 croak $dumpfn->( { no_json => $response } ) unless exists $response->{content};
403            
404             # get the JSON
405 16         190 my $content = decode_json $response->{content};
406             #$dumpfn->( { their_response => $response, their_content => $content } );
407            
408             # verification before returning the content -- if their verifier doesn't match their nonce,
409             # then we don't have secure communication
410             # Don't need to check on test-associate/associate if verifier is missing, because there can
411             # reasonably be no verifier on those (ie, when test-associate returns false, or when the associate fails)
412 15 100 100     72 if(exists $content->{Verifier} or ($type ne 'test-associate' and $type ne 'associate')) {
      100        
413 13 100 100     93 croak $dumpfn->( { missing_verifier => $content } ) unless exists $content->{Nonce} and exists $content->{Verifier};
414 11         54 my $their_iv = decode_base64($content->{Nonce});
415 11         64 my $decode_their_verifier = $self->{cbc}->decrypt( decode_base64($content->{Verifier}), $self->{key}, $their_iv );
416 11 100       244 if( $decode_their_verifier ne $content->{Nonce} ) {
417 1         7 croak $dumpfn->( { "Decoded Verifier $decode_their_verifier" => $content } );
418             }
419             }
420            
421             # If it made it to here, it's safe to return the content
422 12         52 return $content;
423             }
424            
425             =back
426            
427             =for comment END OF USER INTERFACE
428            
429             =head2 HELPER METHODS
430            
431             In general, most users won't need these. But maybe I will.
432            
433             =over
434            
435             =item generate_nonce
436            
437             my ($iv, $base64) = $kph->generate_nonce();
438            
439             This is used by the L method to generate the IV nonce
440             for communication. I don't think you need to use it yourself, but
441             it's available to you, if you find a need for it.
442            
443             The C<$iv> is the string of octets (the actual 128 IV nonce value).
444            
445             The C<$base64> is the base64 representation of the C<$iv>.
446            
447             =cut
448            
449             sub generate_nonce
450             {
451             # generate 10 bytes of random numbers, 2 bytes of microsecond time, and 4 bytes of seconds
452             # this gives randomness from two sources (rand and usecond),
453             # plus a deterministic counter that won't repeat for 2^31 seconds (almost 70 years)
454             # so as long as you aren't using the same key for 70 years, the nonce should be unique
455 21     21 1 2006460 my $hex = '';
456 21         475 $hex .= sprintf '%02X', rand(256) for 1..10;
457 21         123 my ($s,$us) = gettimeofday();
458 21         85 $hex .= sprintf '%04X%08X', $us&0xFFFF, $s&0xFFFFFFFF;
459 21         144 my $iv = pack 'H*', $hex;
460 21         94 my $nonce = encode_base64($iv, '');
461 21 100       178 return wantarray ? ($iv, $nonce) : $iv;
462             }
463            
464             =back
465            
466             =for comment END OF HELPER METHODS
467            
468            
469             =head1 SEE ALSO
470            
471             =over
472            
473             =item * L
474            
475             =item * L
476            
477             =item * L = A similar interface which uses the KeePassRest plugin to interface with KeePass
478            
479             =back
480            
481             =head1 ACKNOWLEDGEMENTS
482            
483             Thank you to L for providing a free
484             password manager with plugin capability.
485            
486             Thank you to the L
487             for providing a free and open source plugin which allows for easy
488             communication between an external application and the KeePass application,
489             enabling the existence of this module (and the ability for it to give
490             applications access to the passwords stored in KeePass).
491            
492             This module and author are not affiliated with either KeePass or KeePassHttp
493             except as a user of those fine products.
494            
495             =head1 TODO
496            
497             The entries should be full-fledged objects, with method-based access to
498             the underlying Login, Url, and Password values.
499            
500             =head1 AUTHOR
501            
502             Peter C. Jones Cpetercj AT cpan DOT orgE>
503            
504             Please report any bugs or feature requests
505             thru the repository's interface at L.
506            
507             =begin html
508            
509            
510            
511            
512            
513             Coverage Status
514             github perl-ci
515            
516             =end html
517            
518             =head1 COPYRIGHT
519            
520             Copyright (C) 2021 Peter C. Jones
521            
522             =head1 LICENSE
523            
524             This program is free software; you can redistribute it and/or modify it
525             under the terms of either: the GNU General Public License as published
526             by the Free Software Foundation; or the Artistic License.
527             See L for more information.
528            
529             =cut
530            
531             1;