File Coverage

blib/lib/File/KeePass/KDBX.pm
Criterion Covered Total %
statement 240 310 77.4
branch 109 174 62.6
condition 55 113 48.6
subroutine 52 67 77.6
pod 30 36 83.3
total 486 700 69.4


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