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   2988 use utf8;
  3         54  
  3         14  
5 3     3   78 use warnings;
  3         5  
  3         62  
6 3     3   11 use strict;
  3         5  
  3         58  
7              
8 3     3   1089 use Crypt::PRNG qw(irand);
  3         13131  
  3         206  
9 3     3   1324 use Crypt::Misc 0.029 qw(decode_b64 encode_b64);
  3         20195  
  3         326  
10 3     3   2041 use File::KDBX 0.900;
  3         264363  
  3         135  
11 3     3   26 use File::KDBX::Constants qw(:header :magic :version);
  3         6  
  3         673  
12 3     3   1626 use File::KDBX::Loader::KDB;
  3         56032  
  3         117  
13 3     3   21 use File::KDBX::Util qw(clone_nomagic generate_uuid load_optional);
  3         5  
  3         178  
14 3     3   17 use Hash::Util::FieldHash qw(fieldhashes);
  3         7  
  3         103  
15 3     3   14 use Module::Load;
  3         3  
  3         20  
16 3     3   133 use Scalar::Util qw(blessed looks_like_number weaken);
  3         7  
  3         112  
17 3     3   12 use boolean;
  3         7  
  3         23  
18 3     3   143 use namespace::clean;
  3         4  
  3         14  
19              
20             our $VERSION = '0.901'; # VERSION
21              
22             fieldhashes \my (%KDBX, %TIED);
23              
24             BEGIN {
25 3     3   1709 our @ISA;
26 3 50       12180 @ISA = qw(File::KeePass) if $INC{'File/KeePass.pm'};
27             }
28              
29              
30             sub new {
31 9     9 1 3213 my $class = shift;
32              
33             # copy constructor
34 9 50 50     62 return $_[0]->clone if @_ == 1 && (blessed $_[0] // '') eq __PACKAGE__;
      66        
35              
36 9 50 66     53 if (@_ == 1 && blessed $_[0] && $_[0]->isa('File::KeePass')) {
      33        
37 0         0 return $class->from_fkp(@_);
38             }
39              
40 9 50 66     44 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       36 my $args = ref $_[0] ? {%{$_[0]}} : {@_};
  3         41  
47 9         24 my $self = bless $args, $class;
48 9 50       33 exists $args->{kdbx} and $self->kdbx(delete $args->{kdbx});
49 9         20 return $self;
50             }
51              
52 1     1   771 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 1015 my $self = shift;
82 3         16 delete $KDBX{$self};
83 3         144 delete $TIED{$self};
84 3         235 delete @$self{qw(header groups)};
85             }
86              
87              
88             sub kdbx {
89 420     420 1 12494 my $self = shift;
90 420 50       959 $self = $self->new if !ref $self;
91 420 50       878 if (@_) {
92 0         0 $self->clear;
93 0         0 $KDBX{$self} = shift;
94             }
95 420   66     2187 $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 12 my $self = shift;
118 6 100       30 my $file = shift or die "Missing file\n";
119 4 100       21 my $pass = shift or die "Missing pass\n";
120 2   50     14 my $args = shift || {};
121              
122 2 50       98 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 150 my ($self, $buf, $pass, $args) = @_;
129              
130 10 50       38 my $ref = ref $buf ? $buf : \$buf;
131              
132 10 50       179 open(my $fh, '<:raw', $ref) or die "Could not open buffer: $!\n";
133 10         44 $self->_load($fh, $pass, $args);
134             }
135              
136             sub _load {
137 12     12   40 my ($self, $fh, $pass, $args) = @_;
138              
139 12 50       40 $self = $self->new($args) if !ref $self;
140              
141 12 100       63 my $unlock = defined $args->{auto_lock} ? !$args->{auto_lock} : !$self->auto_lock;
142              
143 12         40 $self->kdbx->load_handle($fh, $pass);
144 12 100       622836 $self->kdbx->unlock if $unlock;
145 12         2323 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 4049 my ($self, $file, $pass, $head) = @_;
194 10 100       40 die "Missing file\n" if !$file;
195 8 100       31 die "Missing pass\n" if !$pass;
196              
197 6 50       25 shift if @_ % 2 == 1;
198 6         22 my %args = @_;
199              
200 6         23 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         238000 return 1;
206             }
207              
208              
209             sub gen_db {
210 10     10 1 4072 my ($self, $pass, $head) = @_;
211 10 100       58 die "Missing pass\n" if !$pass;
212              
213 8 100       39 shift if @_ % 2 == 1;
214 8         34 my %args = @_;
215              
216 8         30 local $self->kdbx->{headers} = $self->_gen_headers($head);
217              
218 8 50 66     83 $args{randomize_seeds} = 0 if $head && $head->{reuse_header};
219              
220 8         25 my $dump = $self->kdbx->dump_string($pass, %args);
221 8         531863 return $$dump;
222             }
223              
224             sub _gen_headers {
225 14     14   32 my $self = shift;
226 14   100     69 my $head = shift || {};
227              
228 14   66     83 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     281 || !exists($self->{'reuse_header'}))); # not explicit no
233 14 50       38 if ($reuse) {
234 14   50     48 ($head, my $args) = ($self->header || {}, $head);
235 14         74 @$head{keys %$args} = values %$args;
236             }
237 14   0     97 $head->{'version'} = $v ||= $head->{'version'} || '1';
      33        
238 14 50 33     268 delete @$head{qw(enc_iv seed_key seed_rand protected_stream_key start_bytes)} if $reuse && $reuse < 0;
239              
240 14 50       45 if ($head->{version} == 1) {
241 0         0 $head->{enc_type} = 'rijndael';
242 0         0 $head->{cipher} = 'aes';
243             }
244              
245 14         183 my $temp_kdbx = File::KDBX::Loader::KDB::_convert_keepass_to_kdbx_headers($head, File::KDBX->new);
246 14         325 return $temp_kdbx->headers;
247             }
248              
249              
250             sub header {
251 34     34 1 1326 my $self = shift;
252 34 100       132 return if !exists $KDBX{$self};
253 32   66     209 $self->{header} //= $self->_tie({}, 'Header', $self->kdbx);
254             }
255              
256              
257             sub groups {
258 79     79 1 1516 my $self = shift;
259 79 100       362 return if !exists $KDBX{$self};
260 75   66     358 $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 81 my ($self, $args, $groups) = @_;
267 13         28 my $t = '';
268 13 0       17 my %gargs; for (keys %$args) { $gargs{$2} = $args->{$1} if /^(group_(.+))$/ };
  13         48  
  0         0  
269 13         42 foreach my $g ($self->find_groups(\%gargs, $groups)) {
270 63         223 my $indent = ' ' x $g->{'level'};
271 63 100       33729 $t .= $indent.($g->{'expanded'} ? '-' : '+')." $g->{'title'} ($g->{'id'}) $g->{'created'}\n";
272 63         2613 local $g->{'groups'}; # don't recurse while looking for entries since we are already flat
273 63         215 $t .= "$indent > $_->{'title'}\t($_->{'id'}) $_->{'created'}\n" for $self->find_entries($args, [$g]);
274             }
275 13         101 return $t;
276             }
277              
278              
279             sub add_group {
280 35     35 1 240 my $self = shift;
281 35         48 my $group = shift;
282              
283 35         112 my $parent = delete local $group->{group};
284 35 100       127 $parent = $parent->{id} if ref $parent;
285              
286 35   33     253 $group->{expires} //= $self->default_exp;
287              
288 35         103 my $group_info = File::KDBX::Loader::KDB::_convert_keepass_to_kdbx_group($group);
289 35         12125 my $group_obj = $self->kdbx->add_group($group_info, group => $parent);
290 35         97664 return $self->_tie({}, 'Group', $group_obj);
291             }
292              
293              
294             # Copied from File::KeePass - thanks paul
295             sub find_groups {
296 439     439 1 143208 my ($self, $args, $groups, $level) = @_;
297 439         974 my @tests = $self->finder_tests($args);
298 439         643 my @groups;
299             my %uniq;
300 439   66     998 my $container = $groups || $self->groups;
301 439         1460 for my $g (@$container) {
302 303   100     65302 $g->{'level'} = $level || 0;
303 303 50       1104 $g->{'title'} = '' if ! defined $g->{'title'};
304 303   50     2837 $g->{'icon'} ||= 0;
305 303 50       2486 if ($self->{'force_v2_gid'}) {
306 0         0 $g->{'id'} = $self->uuid($g->{'id'}, \%uniq);
307             } else {
308 303   33     735 $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     2916 if (!@tests || !grep{!$_->($g)} @tests) {
  67         146  
312 265         703 push @groups, $g;
313 265 100       501 push @{ $self->{'__group_groups'} }, $container if $self->{'__group_groups'};
  2         5  
314             }
315 303 50       1096 push @groups, $self->find_groups($args, $g->{'groups'}, $g->{'level'} + 1) if $g->{'groups'};
316             }
317 439         53743 return @groups;
318             }
319              
320              
321             # Copied from File::KeePass - thanks paul
322             sub find_group {
323 36     36 1 14090 my $self = shift;
324 36 100       129 local $self->{'__group_groups'} = [] if wantarray;
325 36         121 my @g = $self->find_groups(@_);
326 36 100       163 die "Found too many groups (@g)\n" if @g > 1;
327 34 100       232 return wantarray ? ($g[0], $self->{'__group_groups'}->[0]) : $g[0];
328             }
329              
330              
331             sub delete_group {
332 7     7 1 23 my $self = shift;
333 7         8 my $group_info = shift;
334              
335 7 50       17 my $group = $self->find_group($group_info) or return;
336 5         23 $group->{__object}->remove;
337 5         1089 return $group;
338             }
339              
340              
341             sub add_entry {
342 7     7 1 2611 my $self = shift;
343 7         17 my $entry = shift;
344              
345 7         28 my $parent = delete local $entry->{group};
346 7 100       52 $parent = $parent->{id} if ref $parent;
347              
348 7   66     70 $entry->{expires} //= $self->default_exp;
349              
350 7         36 my $entry_info = File::KDBX::Loader::KDB::_convert_keepass_to_kdbx_entry($entry);
351 7 100 66     3658 $parent = $self->kdbx->root->groups->[0] if !$parent && $self->kdbx->_has_implicit_root;
352 7         160 my $entry_obj = $self->kdbx->add_entry($entry_info, group => $parent);
353 7         43632 return $self->_tie({}, 'Entry', $entry_obj);
354             }
355              
356              
357             # Copied from File::KeePass - thanks paul
358             sub find_entries {
359 87     87 1 206 my ($self, $args, $groups) = @_;
360 87 100       226 local @{ $args }{'expires gt', 'active'} = ($self->now, undef) if $args->{'active'};
  2         9  
361 87         200 my @tests = $self->finder_tests($args);
362 87         127 my @entries;
363 87         206 foreach my $g ($self->find_groups({}, $groups)) {
364 169 50       1282 foreach my $e (@{ $g->{'entries'} || [] }) {
  169         551  
365 48         802 local $e->{'group_id'} = $g->{'id'};
366 48         180 local $e->{'group_title'} = $g->{'title'};
367 48 100 100     188 if (!@tests || !grep{!$_->($e)} @tests) {
  38         88  
368 32         532 push @entries, $e;
369 32 100       128 push @{ $self->{'__entry_groups'} }, $g if $self->{'__entry_groups'};
  4         19  
370             }
371             }
372             }
373 87         1717 return @entries;
374             }
375              
376              
377             # Copied from File::KeePass - thanks paul
378             sub find_entry {
379 20     20 1 8531 my $self = shift;
380 20 100       71 local $self->{'__entry_groups'} = [] if wantarray;
381 20         73 my @e = $self->find_entries(@_);
382 20 100       111 die "Found too many entries (@e)\n" if @e > 1;
383 18 100       106 return wantarray ? ($e[0], $self->{'__entry_groups'}->[0]) : $e[0];
384             }
385              
386              
387             sub delete_entry {
388 4     4 1 588 my $self = shift;
389 4         7 my $entry_info = shift;
390              
391 4 50       25 my $entry = $self->find_entry($entry_info) or return;
392 2         9 $entry->{__object}->remove;
393 2         925 return $entry;
394             }
395              
396             ##############################################################################
397              
398              
399             # Copied from File::KeePass - thanks paul
400             sub finder_tests {
401 526     526 1 816 my ($self, $args) = @_;
402 526         685 my @tests;
403 526 50       670 foreach my $key (keys %{ $args || {} }) {
  526         1592  
404 125 100       329 next if ! defined $args->{$key};
405 123 50       1046 my ($field, $op) = ($key =~ m{ ^ (\w+) \s* (|!|=|!~|=~|gt|lt) $ }x) ? ($1, $2) : die "Invalid find match criteria \"$key\"\n";
406 81 50   81   252 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   13 : ($op eq '!~') ? sub { !defined($_[0]->{$field}) || $_[0]->{$field} !~ $args->{$key} }
410 8 50   8   28 : ($op eq 'gt') ? sub { defined($_[0]->{$field}) && $_[0]->{$field} gt $args->{$key} }
411 4 50   4   10 : ($op eq 'lt') ? sub { defined($_[0]->{$field}) && $_[0]->{$field} lt $args->{$key} }
412 123 50 100     1036 : die "Unknown op \"$op\"\n";
    100          
    100          
    100          
    100          
    100          
413             }
414 526         1035 return @tests;
415             }
416              
417              
418 40 50   40 1 196 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 7 my ($self, $time) = @_;
424 2   33     28 my ($sec, $min, $hour, $day, $mon, $year) = gmtime($time || time);
425 2         19 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 1627 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 2158 my $self = shift;
452 14 100       50 $self->{auto_lock} = shift if @_;
453 14   100     67 $self->{auto_lock} //= 1;
454             }
455              
456              
457 12     12 1 4143 sub is_locked { $_[0]->kdbx->is_locked }
458              
459              
460 2     2 1 1154 sub lock { $_[0]->kdbx->lock }
461              
462              
463 4     4 1 1210 sub unlock { $_[0]->kdbx->unlock }
464              
465              
466             sub locked_entry_password {
467 4     4 1 1799 my $self = shift;
468 4         8 my $entry = shift;
469              
470 4 100       18 $self->is_locked or die "Passwords are not locked\n";
471              
472 2 50       25 $entry = $self->find_entry({id => $entry}) if !ref $entry;
473 2 50       9 return if !$entry;
474              
475 2 50       10 my $entry_obj = $entry->{__object} or return;
476 2         13 return $entry_obj->string_peek('Password');
477             }
478              
479             ##############################################################################
480              
481             sub _tie {
482 1260     1260   4649 my $self = shift;
483 1260   50     2290 my $ref = shift // \my %h;
484 1260         1618 my $class = shift;
485 1260         1433 my $obj = shift;
486              
487 1260   100     3520 my $cache = $TIED{$self} //= {};
488              
489 1260 50       3615 $class = __PACKAGE__."::Tie::$class" if $class !~ s/^\+//;
490 1260         2816 my $key = "$class:" . Hash::Util::FieldHash::id($obj);
491 1260         2021 my $hit = $cache->{$key};
492 1260 100       3818 return $hit if defined $hit;
493              
494 697         2090 load $class;
495 697 100       38107 tie((ref $ref eq 'ARRAY' ? @$ref : %$ref), $class, $obj, @_, $self);
496 697         1525 $hit = $cache->{$key} = $ref;
497 697         1994 weaken $cache->{$key};
498 697         3504 return $hit;
499             }
500              
501             ### convert datetime from KDBX to KeePass format
502             sub _decode_datetime {
503 185 100   185   2026 local $_ = shift or return;
504 179         4728 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__