File Coverage

blib/lib/File/KDBX/Loader/XML.pm
Criterion Covered Total %
statement 258 287 89.9
branch 84 136 61.7
condition 15 26 57.6
subroutine 50 53 94.3
pod n/a
total 407 502 81.0


line stmt bran cond sub pod time code
1             package File::KDBX::Loader::XML;
2             # ABSTRACT: Load unencrypted XML KeePass files
3              
4 5     5   2890 use warnings;
  5         11  
  5         166  
5 5     5   23 use strict;
  5         11  
  5         143  
6              
7 5     5   1126 use Crypt::Misc 0.029 qw(decode_b64);
  5         13111  
  5         306  
8 5     5   40 use Encode qw(decode);
  5         11  
  5         182  
9 5     5   27 use File::KDBX::Constants qw(:version :time);
  5         10  
  5         688  
10 5     5   31 use File::KDBX::Error;
  5         9  
  5         230  
11 5     5   27 use File::KDBX::Safe;
  5         8  
  5         135  
12 5     5   25 use File::KDBX::Util qw(:class :int :text gunzip erase_scoped);
  5         8  
  5         749  
13 5     5   33 use Scalar::Util qw(looks_like_number);
  5         19  
  5         209  
14 5     5   47 use Time::Piece 1.33;
  5         84  
  5         57  
15 5     5   2330 use XML::LibXML::Reader;
  5         88474  
  5         465  
16 5     5   36 use boolean;
  5         10  
  5         49  
17 5     5   259 use namespace::clean;
  5         10  
  5         45  
18              
19             extends 'File::KDBX::Loader';
20 2686 50   2686   6720  
21 39 50   39   272 our $VERSION = '0.904'; # VERSION
22 2686   50     5947  
23 39   100     206 has '_reader', is => 'ro';
24             has '_safe', is => 'ro', default => sub { File::KDBX::Safe->new(cipher => $_[0]->kdbx->random_stream) };
25              
26             sub _read {
27 0     0   0 my $self = shift;
28 0         0 my $fh = shift;
29              
30 0         0 $self->_read_inner_body($fh);
31             }
32              
33             sub _read_inner_body {
34 21     21   44 my $self = shift;
35 21         46 my $fh = shift;
36              
37 21         140 my $reader = $self->{_reader} = XML::LibXML::Reader->new(IO => $fh);
38              
39 21         2930 delete $self->{_safe};
40 21         40 my $root_done;
41              
42 21         143 my $pattern = XML::LibXML::Pattern->new('/KeePassFile/Meta|/KeePassFile/Root');
43 21         827 while ($reader->nextPatternMatch($pattern) == 1) {
44 42 50       2110 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
45 42         539 my $name = $reader->localName;
46 42 100       157 if ($name eq 'Meta') {
    50          
47 21         67 $self->_read_xml_meta;
48             }
49             elsif ($name eq 'Root') {
50 21 50       59 if ($root_done) {
51 0         0 alert 'Ignoring extra Root element in KeePass XML file', line => $reader->lineNumber;
52 0         0 next;
53             }
54 21         74 $self->_read_xml_root;
55 21         189 $root_done = 1;
56             }
57             }
58              
59 21 50       129 if ($reader->readState == XML_READER_ERROR) {
60 0         0 throw 'Failed to parse KeePass XML';
61             }
62              
63 21 100       99 $self->kdbx->_safe($self->_safe) if $self->{_safe};
64              
65 21         67 $self->_resolve_binary_refs;
66             }
67              
68             sub _read_xml_meta {
69 21     21   40 my $self = shift;
70              
71 21         94 $self->_read_xml_element($self->kdbx->meta,
72             Generator => 'text',
73             HeaderHash => 'binary',
74             DatabaseName => 'text',
75             DatabaseNameChanged => 'datetime',
76             DatabaseDescription => 'text',
77             DatabaseDescriptionChanged => 'datetime',
78             DefaultUserName => 'text',
79             DefaultUserNameChanged => 'datetime',
80             MaintenanceHistoryDays => 'number',
81             Color => 'text',
82             MasterKeyChanged => 'datetime',
83             MasterKeyChangeRec => 'number',
84             MasterKeyChangeForce => 'number',
85             MemoryProtection => \&_read_xml_memory_protection,
86             CustomIcons => \&_read_xml_custom_icons,
87             RecycleBinEnabled => 'bool',
88             RecycleBinUUID => 'uuid',
89             RecycleBinChanged => 'datetime',
90             EntryTemplatesGroup => 'uuid',
91             EntryTemplatesGroupChanged => 'datetime',
92             LastSelectedGroup => 'uuid',
93             LastTopVisibleGroup => 'uuid',
94             HistoryMaxItems => 'number',
95             HistoryMaxSize => 'number',
96             SettingsChanged => 'datetime',
97             Binaries => \&_read_xml_binaries,
98             CustomData => \&_read_xml_custom_data,
99             );
100             }
101              
102             sub _read_xml_memory_protection {
103 21     21   209 my $self = shift;
104 21   33     57 my $meta = shift // $self->kdbx->meta;
105              
106 21         119 return $self->_read_xml_element(
107             ProtectTitle => 'bool',
108             ProtectUserName => 'bool',
109             ProtectPassword => 'bool',
110             ProtectURL => 'bool',
111             ProtectNotes => 'bool',
112             AutoEnableVisualHiding => 'bool',
113             );
114             }
115              
116             sub _read_xml_binaries {
117 12     12   120 my $self = shift;
118 12         45 my $kdbx = $self->kdbx;
119              
120             my $binaries = $self->_read_xml_element(
121             Binary => sub {
122 2     2   21 my $self = shift;
123 2         7 my $id = $self->_read_xml_attribute('ID');
124 2         6 my $compressed = $self->_read_xml_attribute('Compressed', 'bool', false);
125 2         6 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
126 2         5 my $data = $self->_read_xml_content('binary');
127              
128 2 50       7 my $binary = {
129             value => $data,
130             $protected ? (protect => true) : (),
131             };
132              
133 2 50       16 if ($protected) {
    50          
134             # if compressed, decompress later when the safe is unlocked
135 0 0       0 $self->_safe->add_protected($compressed ? \&gunzip : (), $binary);
136             }
137             elsif ($compressed) {
138 0         0 $binary->{value} = gunzip($data);
139             }
140              
141 2         25 $id => $binary;
142             },
143 12         79 );
144              
145 12         93 $kdbx->binaries({%{$kdbx->binaries}, %$binaries});
  12         53  
146 12         37 return (); # do not add to meta
147             }
148              
149             sub _read_xml_custom_data {
150 32     32   343 my $self = shift;
151              
152             return $self->_read_xml_element(
153             Item => sub {
154 10     10   110 my $self = shift;
155 10         27 my $item = $self->_read_xml_element(
156             Key => 'text',
157             Value => 'text',
158             LastModificationTime => 'datetime', # KDBX4.1
159             );
160 10         33 $item->{key} => $item;
161             },
162 32         164 );
163             }
164              
165             sub _read_xml_custom_icons {
166 14     14   151 my $self = shift;
167              
168             return $self->_read_xml_element([],
169             Icon => sub {
170 0     0   0 my $self = shift;
171 0         0 $self->_read_xml_element(
172             UUID => 'uuid',
173             Data => 'binary',
174             Name => 'text', # KDBX4.1
175             LastModificationTime => 'datetime', # KDBX4.1
176             );
177             },
178 14         81 );
179             }
180              
181             sub _read_xml_root {
182 21     21   45 my $self = shift;
183 21         73 my $kdbx = $self->kdbx;
184              
185 21         88 my $root = $self->_read_xml_element(
186             Group => \&_read_xml_group,
187             DeletedObjects => \&_read_xml_deleted_objects,
188             );
189              
190 21         143 $kdbx->deleted_objects($root->{deleted_objects});
191 21 50       121 $kdbx->root($root->{group}) if $root->{group};
192             }
193              
194             sub _read_xml_group {
195 53     53   493 my $self = shift;
196              
197 53         336 return $self->_read_xml_element({entries => [], groups => []},
198             UUID => 'uuid',
199             Name => 'text',
200             Notes => 'text',
201             Tags => 'text', # KDBX4.1
202             IconID => 'number',
203             CustomIconUUID => 'uuid',
204             Times => \&_read_xml_times,
205             IsExpanded => 'bool',
206             DefaultAutoTypeSequence => 'text',
207             EnableAutoType => 'tristate',
208             EnableSearching => 'tristate',
209             LastTopVisibleEntry => 'uuid',
210             CustomData => \&_read_xml_custom_data, # KDBX4
211             PreviousParentGroup => 'uuid', # KDBX4.1
212             Entry => [entries => \&_read_xml_entry],
213             Group => [groups => \&_read_xml_group],
214             );
215             }
216              
217             sub _read_xml_entry {
218 18     18   191 my $self = shift;
219              
220             my $entry = $self->_read_xml_element({strings => [], binaries => []},
221             UUID => 'uuid',
222             IconID => 'number',
223             CustomIconUUID => 'uuid',
224             ForegroundColor => 'text',
225             BackgroundColor => 'text',
226             OverrideURL => 'text',
227             Tags => 'text',
228             Times => \&_read_xml_times,
229             AutoType => \&_read_xml_entry_auto_type,
230             PreviousParentGroup => 'uuid', # KDBX4.1
231             QualityCheck => 'bool', # KDBX4.1
232             String => [strings => \&_read_xml_entry_string],
233             Binary => [binaries => \&_read_xml_entry_binary],
234             CustomData => \&_read_xml_custom_data, # KDBX4
235             History => sub {
236 8     8   80 my $self = shift;
237 8         29 return $self->_read_xml_element([],
238             Entry => \&_read_xml_entry,
239             );
240             },
241 18         191 );
242              
243 18         67 my %strings;
244 18 50       35 for my $string (@{$entry->{strings} || []}) {
  18         79  
245 95         197 $strings{$string->{key}} = $string->{value};
246             }
247 18         64 $entry->{strings} = \%strings;
248              
249 18         28 my %binaries;
250 18 50       28 for my $binary (@{$entry->{binaries} || []}) {
  18         52  
251 7         16 $binaries{$binary->{key}} = $binary->{value};
252             }
253 18         35 $entry->{binaries} = \%binaries;
254              
255 18         39 return $entry;
256             }
257              
258             sub _read_xml_times {
259 71     71   678 my $self = shift;
260              
261 71         249 return $self->_read_xml_element(
262             LastModificationTime => 'datetime',
263             CreationTime => 'datetime',
264             LastAccessTime => 'datetime',
265             ExpiryTime => 'datetime',
266             Expires => 'bool',
267             UsageCount => 'number',
268             LocationChanged => 'datetime',
269             );
270             }
271              
272             sub _read_xml_entry_string {
273 95     95   1076 my $self = shift;
274              
275             return $self->_read_xml_element(
276             Key => 'text',
277             Value => sub {
278 95     95   1130 my $self = shift;
279              
280 95         202 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
281 95         188 my $protect_in_memory = $self->_read_xml_attribute('ProtectInMemory', 'bool', false);
282 95   66     269 my $protect = $protected || $protect_in_memory;
283              
284 95 100       863 my $val = $self->_read_xml_content($protected ? 'binary' : 'text');
285              
286 95 100       233 my $string = {
287             value => $val,
288             $protect ? (protect => true) : (),
289             };
290              
291 95 100       759 $self->_safe->add_protected(sub { decode('UTF-8', $_[0]) }, $string) if $protected;
  27         98  
292              
293 95         486 $string;
294             },
295 95         390 );
296             }
297              
298             sub _read_xml_entry_binary {
299 7     7   72 my $self = shift;
300              
301             return $self->_read_xml_element(
302             Key => 'text',
303             Value => sub {
304 7     7   72 my $self = shift;
305              
306 7         15 my $ref = $self->_read_xml_attribute('Ref');
307 7         18 my $compressed = $self->_read_xml_attribute('Compressed', 'bool', false);
308 7         14 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
309 7         11 my $binary = {};
310              
311 7 100       14 if (defined $ref) {
312 4         11 $binary->{ref} = $ref;
313             }
314             else {
315 3         6 $binary->{value} = $self->_read_xml_content('binary');
316 3 50       10 $binary->{protect} = true if $protected;
317              
318 3 50       20 if ($protected) {
    50          
319             # if compressed, decompress later when the safe is unlocked
320 0 0       0 $self->_safe->add_protected($compressed ? \&gunzip : (), $binary);
321             }
322             elsif ($compressed) {
323 0         0 $binary->{value} = gunzip($binary->{value});
324             }
325             }
326              
327 7         68 $binary;
328             },
329 7         32 );
330             }
331              
332             sub _read_xml_entry_auto_type {
333 18     18   197 my $self = shift;
334              
335             return $self->_read_xml_element({associations => []},
336             Enabled => 'bool',
337             DataTransferObfuscation => 'number',
338             DefaultSequence => 'text',
339             Association => [associations => sub {
340 11     11   121 my $self = shift;
341 11         46 return $self->_read_xml_element(
342             Window => 'text',
343             KeystrokeSequence => 'text',
344             );
345 18         107 }],
346             );
347             }
348              
349             sub _read_xml_deleted_objects {
350 21     21   210 my $self = shift;
351              
352             return $self->_read_xml_element(
353             DeletedObject => sub {
354 14     14   145 my $self = shift;
355 14         26 my $object = $self->_read_xml_element(
356             UUID => 'uuid',
357             DeletionTime => 'datetime',
358             );
359 14         40 $object->{uuid} => $object;
360             }
361 21         133 );
362             }
363              
364             ##############################################################################
365              
366             sub _resolve_binary_refs {
367 21     21   41 my $self = shift;
368 21         73 my $kdbx = $self->kdbx;
369              
370 21         87 my $pool = $kdbx->binaries;
371              
372 21         91 my $entries = $kdbx->entries(history => 1);
373 21         181 while (my $entry = $entries->next) {
374 18         28 while (my ($key, $binary) = each %{$entry->binaries}) {
  25         68  
375 7   100     20 my $ref = $binary->{ref} // next;
376 4 50       11 next if defined $binary->{value};
377              
378 4         8 my $data = $pool->{$ref};
379 4 50 33     19 if (!defined $data || !defined $data->{value}) {
380 0         0 alert "Found a reference to a missing binary: $key", key => $key, ref => $ref;
381 0         0 next;
382             }
383 4         10 $binary->{value} = $data->{value};
384 4 50       9 $binary->{protect} = true if $data->{protect};
385 4         8 delete $binary->{ref};
386             }
387             }
388             }
389              
390             ##############################################################################
391              
392             sub _read_xml_element {
393 447     447   594 my $self = shift;
394 447 100       985 my $args = @_ % 2 == 1 ? shift : {};
395 447         2163 my %spec = @_;
396              
397 447         1051 my $reader = $self->_reader;
398 447         897 my $path = $reader->nodePath;
399 447         4579 $path =~ s!\Q/text()\E$!!;
400              
401 447 100       1184 return $args if $reader->isEmptyElement;
402              
403             my $store = ref $args eq 'CODE' ? $args
404             : ref $args eq 'HASH' ? sub {
405 2335     2218   3720 my ($key, $val) = @_;
406 2335 50       5969 if (ref $args->{$key} eq 'HASH') {
    100          
407 0         0 $args->{$key}{$key} = $val;
408             }
409             elsif (ref $args->{$key} eq 'ARRAY') {
410 157         189 push @{$args->{$key}}, $val;
  157         984  
411             }
412             else {
413 2178 50       3649 exists $args->{$key}
414             and alert 'Overwriting value', node => $reader->nodePath, line => $reader->lineNumber;
415 2178         12278 $args->{$key} = $val;
416             }
417             } : ref $args eq 'ARRAY' ? sub {
418 6     5   12 my ($key, $val) = @_;
419 6         37 push @$args, $val;
420 375 50   0   1720 } : sub {};
    100          
    50          
421              
422 375         1527 my $pattern = XML::LibXML::Pattern->new("${path}|${path}/*");
423 375         8193 while ($reader->nextPatternMatch($pattern) == 1) {
424 4397 100 66     10110 last if $reader->nodePath eq $path && $reader->nodeType == XML_READER_TYPE_END_ELEMENT;
425 4022 100       51136 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
426              
427 2353         4542 my $name = $reader->localName;
428 2353         4533 my $key = snakify($name);
429 2353         4457 my $type = $spec{$name};
430 2353 100       4447 ($key, $type) = @$type if ref $type eq 'ARRAY';
431              
432 2353 50       3580 if (!defined $type) {
433 0 0       0 exists $spec{$name} or alert "Ignoring unknown element: $name",
434             node => $reader->nodePath,
435             line => $reader->lineNumber;
436 0         0 next;
437             }
438              
439 2353 100       3593 if (ref $type eq 'CODE') {
440 509         1059 my @result = $self->$type($args, $reader->nodePath);
441 509 100       1823 if (@result == 2) {
    100          
442 26         46 $store->(@result);
443             }
444             elsif (@result == 1) {
445 471         753 $store->($key, @result);
446             }
447             }
448             else {
449 1844         3352 $store->($key, $self->_read_xml_content($type));
450             }
451             }
452              
453 375         7662 return $args;
454             }
455              
456             sub _read_xml_attribute {
457 217     217   540 my $self = shift;
458 217         250 my $name = shift;
459 217   100     358 my $type = shift // 'text';
460 217         239 my $default = shift;
461 217         297 my $reader = $self->_reader;
462              
463 217 100       512 return $default if !$reader->hasAttributes;
464              
465 72         266 my $value = trim($reader->getAttribute($name));
466 72 100       145 if (!defined $value) {
467             # try again after reading in all the attributes
468 39         101 $reader->moveToFirstAttribute;
469 39         66 while ($self->_reader->readAttributeValue == 1) {}
470 39         134 $reader->moveToElement;
471              
472 39         209 $value = trim($reader->getAttribute($name));
473             }
474              
475 72 100       146 return $default if !defined $value;
476              
477 33         47 my $decoded = eval { _decode_primitive($value, $type) };
  33         50  
478 33 50       138 if (my $err = $@) {
479 0 0       0 ref $err and $err->details(attribute => $name, node => $reader->nodePath, line => $reader->lineNumber);
480 0         0 throw $err
481             }
482              
483 33         53 return $decoded;
484             }
485              
486             sub _read_xml_content {
487 1944     1944   2638 my $self = shift;
488 1944         2198 my $type = shift;
489 1944         2900 my $reader = $self->_reader;
490              
491 1944 100       8384 $reader->read if !$reader->isEmptyElement; # step into element
492 1944 100       5689 return '' if !$reader->hasValue;
493              
494 1669         4678 my $content = trim($reader->value);
495              
496 1669         2383 my $decoded = eval { _decode_primitive($content, $type) };
  1669         2441  
497 1669 50       10386 if (my $err = $@) {
498 0 0       0 ref $err and $err->details(node => $reader->nodePath, line => $reader->lineNumber);
499 0         0 throw $err;
500             }
501              
502 1669         3250 return $decoded;
503             }
504              
505             ##############################################################################
506              
507 1702     1702   1841 sub _decode_primitive { goto &{__PACKAGE__."::_decode_$_[1]"} }
  1702         7442  
508              
509             sub _decode_binary {
510 246     246   333 local $_ = shift;
511 246 50 33     765 return '' if !defined || (ref && !defined $$_);
      33        
512 246 50       284 $_ = eval { decode_b64(ref $_ ? $$_ : $_) };
  246         959  
513 246         359 my $err = $@;
514 246         490 my $cleanup = erase_scoped $_;
515 246 50       2699 $err and throw 'Failed to parse binary', error => $err;
516 246         650 return $_;
517             }
518              
519             sub _decode_bool {
520 296     296   488 local $_ = shift;
521 296 100       1057 return true if /^True$/i;
522 157 50       602 return false if /^False$/i;
523 0 0       0 return false if length($_) == 0;
524 0         0 throw 'Expected boolean', text => $_;
525             }
526              
527             sub _decode_datetime {
528 501     501   754 local $_ = shift;
529              
530 501 100       1439 if (/^[A-Za-z0-9\+\/\=]+$/) {
531 131         158 my $binary = eval { decode_b64($_) };
  131         447  
532 131 50       231 if (my $err = $@) {
533 0         0 throw 'Failed to parse binary datetime', text => $_, error => $err;
534             }
535 131 50       214 throw $@ if $@;
536 131 50       218 $binary .= \0 x (8 - length($binary)) if length($binary) < 8;
537 131         258 my ($seconds_since_ad1) = unpack_Ql($binary);
538 131         211 my $epoch = $seconds_since_ad1 - TIME_SECONDS_AD1_TO_UNIX_EPOCH;
539 131         381 return gmtime($epoch);
540             }
541              
542 370         423 my $dt = eval { Time::Piece->strptime($_, '%Y-%m-%dT%H:%M:%SZ') };
  370         997  
543 370 50       42200 if (my $err = $@) {
544 0         0 throw 'Failed to parse datetime', text => $_, error => $err;
545             }
546 370         657 return $dt;
547             }
548              
549             sub _decode_tristate {
550 106     106   180 local $_ = shift;
551 106 50       404 return undef if /^null$/i;
552 0         0 my $tristate = eval { _decode_bool($_) };
  0         0  
553 0 0       0 $@ and throw 'Expected tristate', text => $_, error => $@;
554 0         0 return $tristate;
555             }
556              
557             sub _decode_number {
558 257     257   445 local $_ = shift;
559 257         423 $_ = _decode_text($_);
560 257 50       710 looks_like_number($_) or throw 'Expected number', text => $_;
561 257         634 return $_+0;
562             }
563              
564             sub _decode_text {
565 553     553   900 local $_ = shift;
566 553 50       933 return '' if !defined;
567 553         877 return $_;
568             }
569              
570             sub _decode_uuid {
571 207     207   335 local $_ = shift;
572 207         237 my $uuid = eval { _decode_binary($_) };
  207         330  
573 207 50       449 $@ and throw 'Expected UUID', text => $_, error => $@;
574 207 50       379 length($uuid) == 16 or throw 'Invalid UUID size', size => length($uuid);
575 207         340 return $uuid;
576             }
577              
578             1;
579              
580             __END__