File Coverage

blib/lib/File/KeePass/KDBX.pm
Criterion Covered Total %
statement 241 311 77.4
branch 109 174 62.6
condition 55 113 48.6
subroutine 53 68 77.9
pod 30 36 83.3
total 488 702 69.5


line stmt bran cond sub pod time code
1             package File::KeePass::KDBX;
2             # ABSTRACT: Read and write KDBX files (using the File::KDBX backend)
3              
4 3     3   2862 use utf8;
  3         60  
  3         12  
5 3     3   77 use warnings;
  3         5  
  3         60  
6 3     3   11 use strict;
  3         6  
  3         54  
7              
8 3     3   1122 use Crypt::PRNG qw(irand);
  3         13071  
  3         212  
9 3     3   1294 use Crypt::Misc 0.029 qw(decode_b64 encode_b64);
  3         18663  
  3         296  
10 3     3   1994 use File::KDBX 0.900;
  3         269976  
  3         128  
11 3     3   27 use File::KDBX::Constants qw(:header :magic :version);
  3         5  
  3         618  
12 3     3   1548 use File::KDBX::Loader::KDB;
  3         55845  
  3         113  
13 3     3   23 use File::KDBX::Util qw(clone_nomagic generate_uuid load_optional);
  3         7  
  3         182  
14 3     3   18 use Hash::Util::FieldHash qw(fieldhashes);
  3         4  
  3         102  
15 3     3   13 use Module::Load;
  3         7  
  3         20  
16 3     3   156 use Scalar::Util qw(blessed looks_like_number weaken);
  3         5  
  3         113  
17 3     3   14 use boolean;
  3         6  
  3         33  
18 3     3   152 use namespace::clean;
  3         5  
  3         14  
19              
20             our $VERSION = '0.902'; # VERSION
21              
22             fieldhashes \my (%KDBX, %TIED);
23              
24             BEGIN {
25 3     3   1831 our @ISA;
26 3 50       12239 @ISA = qw(File::KeePass) if $INC{'File/KeePass.pm'};
27             }
28              
29              
30             sub new {
31 9     9 1 3080 my $class = shift;
32              
33             # copy constructor
34 9 50 50     63 return $_[0]->clone if @_ == 1 && (blessed $_[0] // '') eq __PACKAGE__;
      66        
35              
36 9 50 66     40 if (@_ == 1 && blessed $_[0] && $_[0]->isa('File::KeePass')) {
      33        
37 0         0 return $class->from_fkp(@_);
38             }
39              
40 9 50 66     35 if (@_ == 1 && blessed $_[0] && $_[0]->isa('File::KDBX')) {
      33        
41 0         0 my $self = bless {}, $class;
42 0         0 $self->kdbx($_[0]);
43 0         0 return $self;
44             }
45              
46 9 100       31 my $args = ref $_[0] ? {%{$_[0]}} : {@_};
  3         10  
47 9         18 my $self = bless $args, $class;
48 9 50       29 exists $args->{kdbx} and $self->kdbx(delete $args->{kdbx});
49 9         21 return $self;
50             }
51              
52 1     1   841 sub DESTROY { $_[0]->clear }
53              
54              
55             sub clone {
56 0     0 1 0 my $self = shift;
57 0         0 require Storable;
58 0         0 return Storable::dclone($self);
59             }
60              
61             sub STORABLE_freeze {
62 0     0 0 0 my $self = shift;
63 0         0 my $copy = {%$self};
64 0         0 delete @$self{qw(header groups)};
65 0         0 return '', $copy, $KDBX{$self};
66             }
67              
68             sub STORABLE_thaw {
69 0     0 0 0 my $self = shift;
70 0         0 my $cloning = shift;
71 0         0 shift; # empty
72 0         0 my $copy = shift;
73 0         0 my $kdbx = shift;
74              
75 0         0 @$self{keys %$copy} = values %$copy;
76 0 0       0 $self->kdbx($kdbx) if $kdbx;
77             }
78              
79              
80             sub clear {
81 3     3 1 749 my $self = shift;
82 3         12 delete $KDBX{$self};
83 3         136 delete $TIED{$self};
84 3         196 delete @$self{qw(header groups)};
85             }
86              
87              
88             sub kdbx {
89 420     420 1 11801 my $self = shift;
90 420 50       852 $self = $self->new if !ref $self;
91 420 50       748 if (@_) {
92 0         0 $self->clear;
93 0         0 $KDBX{$self} = shift;
94             }
95 420   66     1845 $KDBX{$self} //= File::KDBX->new;
96             }
97              
98              
99             sub to_fkp {
100 0     0 1 0 my $self = shift;
101 0         0 load_optional('File::KeePass');
102 0         0 return File::KeePass->new(clone_nomagic({%$self, header => $self->header, groups => $self->groups}));
103             }
104              
105              
106             sub from_fkp {
107 0     0 1 0 my $class = shift;
108 0         0 my $k = shift;
109 0         0 my $kdbx = File::KDBX::Loader::KDB::convert_keepass_to_kdbx($k);
110 0         0 my $self = bless {}, $class;
111 0         0 $self->kdbx($kdbx);
112 0         0 return $self;
113             }
114              
115              
116             sub load_db {
117 6     6 1 13 my $self = shift;
118 6 100       28 my $file = shift or die "Missing file\n";
119 4 100       23 my $pass = shift or die "Missing pass\n";
120 2   50     12 my $args = shift || {};
121              
122 2 50       81 open(my $fh, '<:raw', $file) or die "Could not open $file: $!\n";
123 2         13 $self->_load($fh, $pass, $args);
124             }
125              
126              
127             sub parse_db {
128 10     10 1 143 my ($self, $buf, $pass, $args) = @_;
129              
130 10 50       40 my $ref = ref $buf ? $buf : \$buf;
131              
132 10 50       133 open(my $fh, '<', $ref) or die "Could not open buffer: $!\n";
133 10         36 $self->_load($fh, $pass, $args);
134             }
135              
136             sub _load {
137 12     12   29 my ($self, $fh, $pass, $args) = @_;
138              
139 12 50       39 $self = $self->new($args) if !ref $self;
140              
141 12 100       56 my $unlock = defined $args->{auto_lock} ? !$args->{auto_lock} : !$self->auto_lock;
142              
143 12         32 $self->kdbx->load_handle($fh, $pass);
144 12 100       648711 $self->kdbx->unlock if $unlock;
145 12         2184 return $self;
146             }
147              
148              
149             sub parse_header {
150 0     0 1 0 my ($self, $buf) = @_;
151              
152 0 0       0 open(my $fh, '<:raw', \$buf) or die "Could not open buffer: $!\n";
153              
154             # detect filetype and version
155 0         0 my $loader = File::KDBX::Loader->new;
156 0         0 my ($sig1, $sig2, $version) = $loader->read_magic_numbers($fh);
157              
158 0 0 0     0 if ($sig2 == KDBX_SIG2_1 || $version < KDBX_VERSION_2_0) {
159 0         0 close($fh);
160              
161 0         0 load_optional('File::KeePass');
162 0         0 return File::KeePass->parse_header($buf);
163             }
164              
165             my %header_transform = (
166             HEADER_COMMENT() => ['comment'],
167 0     0   0 HEADER_CIPHER_ID() => ['cipher', sub { $self->_cipher_name($_[0]) }],
168             HEADER_COMPRESSION_FLAGS() => ['compression'],
169             HEADER_MASTER_SEED() => ['seed_rand'],
170             HEADER_TRANSFORM_SEED() => ['seed_key'],
171             HEADER_TRANSFORM_ROUNDS() => ['rounds'],
172             HEADER_ENCRYPTION_IV() => ['enc_iv'],
173             HEADER_INNER_RANDOM_STREAM_KEY() => ['protected_stream_key'],
174             HEADER_STREAM_START_BYTES() => ['start_bytes'],
175 0     0   0 HEADER_INNER_RANDOM_STREAM_ID() => ['protected_stream', sub { $self->_inner_random_stream_name($_[0]) }],
  0         0  
176             HEADER_KDF_PARAMETERS() => ['kdf_parameters'],
177             HEADER_PUBLIC_CUSTOM_DATA() => ['public_custom_data'],
178             );
179              
180 0         0 my %head;
181              
182 0         0 while (my ($type, $val) = $loader->_read_header($fh)) {
183 0 0       0 last if $type == HEADER_END;
184 0 0       0 my ($name, $filter) = @{$header_transform{$type} || ["$type"]};
  0         0  
185 0 0       0 $head{$name} = $filter ? $filter->($val) : $val;
186             }
187              
188 0         0 return \%head;
189             }
190              
191              
192             sub save_db {
193 10     10 1 3679 my ($self, $file, $pass, $head) = @_;
194 10 100       39 die "Missing file\n" if !$file;
195 8 100       46 die "Missing pass\n" if !$pass;
196              
197 6 50       19 shift if @_ % 2 == 1;
198 6         15 my %args = @_;
199              
200 6         17 local $self->kdbx->{headers} = $self->_gen_headers($head);
201              
202 6 0 33     25 $args{randomize_seeds} = 0 if $head && $head->{reuse_header};
203              
204 6         16 $self->kdbx->dump_file($file, $pass, %args);
205 6         243266 return 1;
206             }
207              
208              
209             sub gen_db {
210 10     10 1 3275 my ($self, $pass, $head) = @_;
211 10 100       43 die "Missing pass\n" if !$pass;
212              
213 8 100       28 shift if @_ % 2 == 1;
214 8         29 my %args = @_;
215              
216 8         26 local $self->kdbx->{headers} = $self->_gen_headers($head);
217              
218 8 50 66     64 $args{randomize_seeds} = 0 if $head && $head->{reuse_header};
219              
220 8         23 my $dump = $self->kdbx->dump_string($pass, %args);
221 8         512398 return $$dump;
222             }
223              
224             sub _gen_headers {
225 14     14   26 my $self = shift;
226 14   100     56 my $head = shift || {};
227              
228 14   66     54 my $v = $head->{'version'} || $self->header->{'version'};
229             my $reuse = $head->{'reuse_header'} # explicit yes
230             || (!exists($head->{'reuse_header'}) # not explicit no
231             && ($self->{'reuse_header'} # explicit yes
232 14   33     260 || !exists($self->{'reuse_header'}))); # not explicit no
233 14 50       35 if ($reuse) {
234 14   50     27 ($head, my $args) = ($self->header || {}, $head);
235 14         56 @$head{keys %$args} = values %$args;
236             }
237 14   0     93 $head->{'version'} = $v ||= $head->{'version'} || '1';
      33        
238 14 50 33     272 delete @$head{qw(enc_iv seed_key seed_rand protected_stream_key start_bytes)} if $reuse && $reuse < 0;
239              
240 14 50       49 if ($head->{version} == 1) {
241 0         0 $head->{enc_type} = 'rijndael';
242 0         0 $head->{cipher} = 'aes';
243             }
244              
245 14         164 my $temp_kdbx = File::KDBX::Loader::KDB::_convert_keepass_to_kdbx_headers($head, File::KDBX->new);
246 14         299 return $temp_kdbx->headers;
247             }
248              
249              
250             sub header {
251 34     34 1 1191 my $self = shift;
252 34 100       127 return if !exists $KDBX{$self};
253 32   66     201 $self->{header} //= $self->_tie({}, 'Header', $self->kdbx);
254             }
255              
256              
257             sub groups {
258 79     79 1 1335 my $self = shift;
259 79 100       239 return if !exists $KDBX{$self};
260 75   66     330 $self->{groups} //= $self->_tie([], 'GroupList', $self->kdbx);
261             }
262              
263              
264             # Copied from File::KeePass - thanks paul
265             sub dump_groups {
266 13     13 1 66 my ($self, $args, $groups) = @_;
267 13         20 my $t = '';
268 13 0       21 my %gargs; for (keys %$args) { $gargs{$2} = $args->{$1} if /^(group_(.+))$/ };
  13         41  
  0         0  
269 13         37 foreach my $g ($self->find_groups(\%gargs, $groups)) {
270 63         227 my $indent = ' ' x $g->{'level'};
271 63 100       33643 $t .= $indent.($g->{'expanded'} ? '-' : '+')." $g->{'title'} ($g->{'id'}) $g->{'created'}\n";
272 63         5219 local $g->{'groups'}; # don't recurse while looking for entries since we are already flat
273 63         219 $t .= "$indent > $_->{'title'}\t($_->{'id'}) $_->{'created'}\n" for $self->find_entries($args, [$g]);
274             }
275 13         106 return $t;
276             }
277              
278              
279             sub add_group {
280 35     35 1 269 my $self = shift;
281 35         45 my $group = shift;
282              
283 35         85 my $parent = delete local $group->{group};
284 35 100       123 $parent = $parent->{id} if ref $parent;
285              
286 35   33     235 $group->{expires} //= $self->default_exp;
287              
288 35         118 my $group_info = File::KDBX::Loader::KDB::_convert_keepass_to_kdbx_group($group);
289 35         14699 my $group_obj = $self->kdbx->add_group($group_info, group => $parent);
290 35         92142 return $self->_tie({}, 'Group', $group_obj);
291             }
292              
293              
294             # Copied from File::KeePass - thanks paul
295             sub find_groups {
296 439     439 1 140284 my ($self, $args, $groups, $level) = @_;
297 439         901 my @tests = $self->finder_tests($args);
298 439         593 my @groups;
299             my %uniq;
300 439   66     890 my $container = $groups || $self->groups;
301 439         1183 for my $g (@$container) {
302 303   100     61190 $g->{'level'} = $level || 0;
303 303 50       1038 $g->{'title'} = '' if ! defined $g->{'title'};
304 303   50     2783 $g->{'icon'} ||= 0;
305 303 50       2416 if ($self->{'force_v2_gid'}) {
306 0         0 $g->{'id'} = $self->uuid($g->{'id'}, \%uniq);
307             } else {
308 303   33     739 $g->{'id'} = irand while !defined($g->{'id'}) || $uniq{$g->{'id'}}++; # the non-v2 gid is compatible with both v1 and our v2 implementation
309             }
310              
311 303 100 100     2792 if (!@tests || !grep{!$_->($g)} @tests) {
  67         139  
312 265         608 push @groups, $g;
313 265 100       468 push @{ $self->{'__group_groups'} }, $container if $self->{'__group_groups'};
  2         5  
314             }
315 303 50       1066 push @groups, $self->find_groups($args, $g->{'groups'}, $g->{'level'} + 1) if $g->{'groups'};
316             }
317 439         51246 return @groups;
318             }
319              
320              
321             # Copied from File::KeePass - thanks paul
322             sub find_group {
323 36     36 1 9565 my $self = shift;
324 36 100       85 local $self->{'__group_groups'} = [] if wantarray;
325 36         84 my @g = $self->find_groups(@_);
326 36 100       123 die "Found too many groups (@g)\n" if @g > 1;
327 34 100       167 return wantarray ? ($g[0], $self->{'__group_groups'}->[0]) : $g[0];
328             }
329              
330              
331             sub delete_group {
332 7     7 1 25 my $self = shift;
333 7         10 my $group_info = shift;
334              
335 7 50       15 my $group = $self->find_group($group_info) or return;
336 5         23 $group->{__object}->remove;
337 5         1123 return $group;
338             }
339              
340              
341             sub add_entry {
342 7     7 1 1734 my $self = shift;
343 7         13 my $entry = shift;
344              
345 7         22 my $parent = delete local $entry->{group};
346 7 100       30 $parent = $parent->{id} if ref $parent;
347              
348 7   66     42 $entry->{expires} //= $self->default_exp;
349              
350 7         24 my $entry_info = File::KDBX::Loader::KDB::_convert_keepass_to_kdbx_entry($entry);
351 7 100 66     4082 $parent = $self->kdbx->root->groups->[0] if !$parent && $self->kdbx->_has_implicit_root;
352 7         150 my $entry_obj = $self->kdbx->add_entry($entry_info, group => $parent);
353 7         38232 return $self->_tie({}, 'Entry', $entry_obj);
354             }
355              
356              
357             # Copied from File::KeePass - thanks paul
358             sub find_entries {
359 87     87 1 187 my ($self, $args, $groups) = @_;
360 87 100       238 local @{ $args }{'expires gt', 'active'} = ($self->now, undef) if $args->{'active'};
  2         8  
361 87         166 my @tests = $self->finder_tests($args);
362 87         131 my @entries;
363 87         232 foreach my $g ($self->find_groups({}, $groups)) {
364 169 50       1255 foreach my $e (@{ $g->{'entries'} || [] }) {
  169         526  
365 48         766 local $e->{'group_id'} = $g->{'id'};
366 48         171 local $e->{'group_title'} = $g->{'title'};
367 48 100 100     156 if (!@tests || !grep{!$_->($e)} @tests) {
  38         95  
368 32         668 push @entries, $e;
369 32 100       126 push @{ $self->{'__entry_groups'} }, $g if $self->{'__entry_groups'};
  4         16  
370             }
371             }
372             }
373 87         1679 return @entries;
374             }
375              
376              
377             # Copied from File::KeePass - thanks paul
378             sub find_entry {
379 20     20 1 5811 my $self = shift;
380 20 100       60 local $self->{'__entry_groups'} = [] if wantarray;
381 20         52 my @e = $self->find_entries(@_);
382 20 100       89 die "Found too many entries (@e)\n" if @e > 1;
383 18 100       95 return wantarray ? ($e[0], $self->{'__entry_groups'}->[0]) : $e[0];
384             }
385              
386              
387             sub delete_entry {
388 4     4 1 618 my $self = shift;
389 4         11 my $entry_info = shift;
390              
391 4 50       15 my $entry = $self->find_entry($entry_info) or return;
392 2         9 $entry->{__object}->remove;
393 2         935 return $entry;
394             }
395              
396             ##############################################################################
397              
398              
399             # Copied from File::KeePass - thanks paul
400             sub finder_tests {
401 526     526 1 745 my ($self, $args) = @_;
402 526         622 my @tests;
403 526 50       642 foreach my $key (keys %{ $args || {} }) {
  526         1392  
404 125 100       267 next if ! defined $args->{$key};
405 123 50       897 my ($field, $op) = ($key =~ m{ ^ (\w+) \s* (|!|=|!~|=~|gt|lt) $ }x) ? ($1, $2) : die "Invalid find match criteria \"$key\"\n";
406 81 50   81   208 push @tests, (!$op || $op eq '=') ? sub { defined($_[0]->{$field}) && $_[0]->{$field} eq $args->{$key} }
407 4 50   4   13 : ($op eq '!') ? sub { !defined($_[0]->{$field}) || $_[0]->{$field} ne $args->{$key} }
408 4 50   4   11 : ($op eq '=~') ? sub { defined($_[0]->{$field}) && $_[0]->{$field} =~ $args->{$key} }
409 4   66 4   10 : ($op eq '!~') ? sub { !defined($_[0]->{$field}) || $_[0]->{$field} !~ $args->{$key} }
410 8 50   8   20 : ($op eq 'gt') ? sub { defined($_[0]->{$field}) && $_[0]->{$field} gt $args->{$key} }
411 4 50   4   11 : ($op eq 'lt') ? sub { defined($_[0]->{$field}) && $_[0]->{$field} lt $args->{$key} }
412 123 50 100     731 : die "Unknown op \"$op\"\n";
    100          
    100          
    100          
    100          
    100          
413             }
414 526         948 return @tests;
415             }
416              
417              
418 40 50   40 1 187 sub default_exp { $_[0]->{default_exp} || '2999-12-31 23:23:59' }
419              
420              
421             # Copied from File::KeePass - thanks paul
422             sub now {
423 2     2 1 6 my ($self, $time) = @_;
424 2   33     15 my ($sec, $min, $hour, $day, $mon, $year) = gmtime($time || time);
425 2         16 return sprintf '%04d-%02d-%02d %02d:%02d:%02d', $year+1900, $mon+1, $day, $hour, $min, $sec;
426             }
427              
428 0     0 0 0 sub encode_base64 { encode_b64($_[1]) }
429 0     0 0 0 sub decode_base64 { decode_b64($_[1]) }
430              
431 2     2 0 1598 sub gen_uuid { generate_uuid(printable => 1) }
432              
433             # Copied from File::KeePass - thanks paul
434             sub uuid {
435 0     0 0 0 my ($self, $id, $uniq) = @_;
436 0 0 0     0 $id = $self->gen_uuid if !defined($id) || !length($id);
437 0   0     0 return $uniq->{$id} ||= do {
438 0 0       0 if (length($id) != 16) {
439 0 0 0     0 $id = substr($self->encode_base64($id), 0, 16) if $id !~ /^\d+$/ || $id > 2**32-1;
440 0 0       0 $id = sprintf '%016s', $id if $id ne '0';
441             }
442 0         0 $id = $self->gen_uuid while $uniq->{$id}++;
443 0         0 $id;
444             };
445             }
446              
447             ##############################################################################
448              
449              
450             sub auto_lock {
451 14     14 1 2079 my $self = shift;
452 14 100       44 $self->{auto_lock} = shift if @_;
453 14   100     60 $self->{auto_lock} //= 1;
454             }
455              
456              
457 12     12 1 3969 sub is_locked { $_[0]->kdbx->is_locked }
458              
459              
460 2     2 1 1102 sub lock { $_[0]->kdbx->lock }
461              
462              
463 4     4 1 1201 sub unlock { $_[0]->kdbx->unlock }
464              
465              
466             sub locked_entry_password {
467 4     4 1 1745 my $self = shift;
468 4         10 my $entry = shift;
469              
470 4 100       11 $self->is_locked or die "Passwords are not locked\n";
471              
472 2 50       23 $entry = $self->find_entry({id => $entry}) if !ref $entry;
473 2 50       6 return if !$entry;
474              
475 2 50       11 my $entry_obj = $entry->{__object} or return;
476 2         10 return $entry_obj->string_peek('Password');
477             }
478              
479             ##############################################################################
480              
481             sub _tie {
482 1260     1260   4538 my $self = shift;
483 1260   50     2093 my $ref = shift // \my %h;
484 1260         1686 my $class = shift;
485 1260         1396 my $obj = shift;
486              
487 1260   100     3181 my $cache = $TIED{$self} //= {};
488              
489 1260 50       3365 $class = __PACKAGE__."::Tie::$class" if $class !~ s/^\+//;
490 1260         2705 my $key = "$class:" . Hash::Util::FieldHash::id($obj);
491 1260         1808 my $hit = $cache->{$key};
492 1260 100       3546 return $hit if defined $hit;
493              
494 697         1831 load $class;
495 697 100       36400 tie((ref $ref eq 'ARRAY' ? @$ref : %$ref), $class, $obj, @_, $self);
496 697         1589 $hit = $cache->{$key} = $ref;
497 697         1870 weaken $cache->{$key};
498 697         3288 return $hit;
499             }
500              
501             ### convert datetime from KDBX to KeePass format
502             sub _decode_datetime {
503 187 100   187   2036 local $_ = shift or return;
504 181         5668 return $_->strftime('%Y-%m-%d %H:%M:%S');
505             }
506              
507             ### convert datetime from KeePass to KDBX format
508             sub _encode_datetime {
509 0 0   0     local $_ = shift or return;
510 0           return Time::Piece->strptime($_, '%Y-%m-%d %H:%M:%S');
511             }
512              
513             ### convert UUID from KeePass to KDBX format
514             sub _encode_uuid {
515 0   0 0     local $_ = shift // return;
516             # Group IDs in KDB files are 32-bit integers
517 0 0 0       return sprintf('%016x', $_) if length($_) != 16 && looks_like_number($_);
518 0           return $_;
519             }
520              
521             ### convert tristate from KDBX to KeePass format
522             sub _decode_tristate {
523 0   0 0     local $_ = shift // return;
524 0 0         return $_ ? 1 : 0;
525             }
526              
527             ### convert tristate from KeePass to KDBX format
528             sub _encode_tristate {
529 0   0 0     local $_ = shift // return;
530 0           return boolean($_);
531             }
532              
533             1;
534              
535             __END__