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   2792 use warnings;
  5         11  
  5         150  
5 5     5   27 use strict;
  5         9  
  5         137  
6              
7 5     5   1173 use Crypt::Misc 0.029 qw(decode_b64);
  5         12948  
  5         281  
8 5     5   29 use Encode qw(decode);
  5         11  
  5         191  
9 5     5   29 use File::KDBX::Constants qw(:version :time);
  5         19  
  5         729  
10 5     5   35 use File::KDBX::Error;
  5         9  
  5         243  
11 5     5   27 use File::KDBX::Safe;
  5         19  
  5         124  
12 5     5   23 use File::KDBX::Util qw(:class :int :text gunzip erase_scoped);
  5         18  
  5         751  
13 5     5   39 use Scalar::Util qw(looks_like_number);
  5         10  
  5         221  
14 5     5   26 use Time::Piece 1.33;
  5         76  
  5         36  
15 5     5   2376 use XML::LibXML::Reader;
  5         88814  
  5         478  
16 5     5   37 use boolean;
  5         10  
  5         43  
17 5     5   271 use namespace::clean;
  5         10  
  5         39  
18              
19             extends 'File::KDBX::Loader';
20 2686 50   2686   6882  
21 39 50   39   290 our $VERSION = '0.906'; # VERSION
22 2686   50     5958  
23 39   100     264 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   518 my $self = shift;
35 21         36 my $fh = shift;
36              
37 21         144 my $reader = $self->{_reader} = XML::LibXML::Reader->new(IO => $fh);
38              
39 21         2837 delete $self->{_safe};
40 21         36 my $root_done;
41              
42 21         138 my $pattern = XML::LibXML::Pattern->new('/KeePassFile/Meta|/KeePassFile/Root');
43 21         768 while ($reader->nextPatternMatch($pattern) == 1) {
44 42 50       1750 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
45 42         398 my $name = $reader->localName;
46 42 100       151 if ($name eq 'Meta') {
    50          
47 21         69 $self->_read_xml_meta;
48             }
49             elsif ($name eq 'Root') {
50 21 50       67 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         76 $self->_read_xml_root;
55 21         158 $root_done = 1;
56             }
57             }
58              
59 21 50       117 if ($reader->readState == XML_READER_ERROR) {
60 0         0 throw 'Failed to parse KeePass XML';
61             }
62              
63 21 100       93 $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   43 my $self = shift;
70              
71 21         95 $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   266 my $self = shift;
104 21   33     55 my $meta = shift // $self->kdbx->meta;
105              
106 21         130 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   127 my $self = shift;
118 12         41 my $kdbx = $self->kdbx;
119              
120             my $binaries = $self->_read_xml_element(
121             Binary => sub {
122 2     2   27 my $self = shift;
123 2         8 my $id = $self->_read_xml_attribute('ID');
124 2         7 my $compressed = $self->_read_xml_attribute('Compressed', 'bool', false);
125 2         6 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
126 2         6 my $data = $self->_read_xml_content('binary');
127              
128 2 50       8 my $binary = {
129             value => $data,
130             $protected ? (protect => true) : (),
131             };
132              
133 2 50       18 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         30 $id => $binary;
142             },
143 12         74 );
144              
145 12         93 $kdbx->binaries({%{$kdbx->binaries}, %$binaries});
  12         47  
146 12         35 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   107 my $self = shift;
155 10         34 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         158 );
163             }
164              
165             sub _read_xml_custom_icons {
166 14     14   178 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         92 );
179             }
180              
181             sub _read_xml_root {
182 21     21   36 my $self = shift;
183 21         64 my $kdbx = $self->kdbx;
184              
185 21         73 my $root = $self->_read_xml_element(
186             Group => \&_read_xml_group,
187             DeletedObjects => \&_read_xml_deleted_objects,
188             );
189              
190 21         142 $kdbx->deleted_objects($root->{deleted_objects});
191 21 50       133 $kdbx->root($root->{group}) if $root->{group};
192             }
193              
194             sub _read_xml_group {
195 53     53   514 my $self = shift;
196              
197 53         360 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   226 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   93 my $self = shift;
237 8         26 return $self->_read_xml_element([],
238             Entry => \&_read_xml_entry,
239             );
240             },
241 18         225 );
242              
243 18         72 my %strings;
244 18 50       28 for my $string (@{$entry->{strings} || []}) {
  18         75  
245 95         204 $strings{$string->{key}} = $string->{value};
246             }
247 18         65 $entry->{strings} = \%strings;
248              
249 18         29 my %binaries;
250 18 50       29 for my $binary (@{$entry->{binaries} || []}) {
  18         50  
251 7         15 $binaries{$binary->{key}} = $binary->{value};
252             }
253 18         35 $entry->{binaries} = \%binaries;
254              
255 18         40 return $entry;
256             }
257              
258             sub _read_xml_times {
259 71     71   710 my $self = shift;
260              
261 71         237 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   1106 my $self = shift;
274              
275             return $self->_read_xml_element(
276             Key => 'text',
277             Value => sub {
278 95     95   1201 my $self = shift;
279              
280 95         229 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
281 95         187 my $protect_in_memory = $self->_read_xml_attribute('ProtectInMemory', 'bool', false);
282 95   66     291 my $protect = $protected || $protect_in_memory;
283              
284 95 100       884 my $val = $self->_read_xml_content($protected ? 'binary' : 'text');
285              
286 95 100       217 my $string = {
287             value => $val,
288             $protect ? (protect => true) : (),
289             };
290              
291 95 100       764 $self->_safe->add_protected(sub { decode('UTF-8', $_[0]) }, $string) if $protected;
  27         97  
292              
293 95         548 $string;
294             },
295 95         391 );
296             }
297              
298             sub _read_xml_entry_binary {
299 7     7   73 my $self = shift;
300              
301             return $self->_read_xml_element(
302             Key => 'text',
303             Value => sub {
304 7     7   73 my $self = shift;
305              
306 7         14 my $ref = $self->_read_xml_attribute('Ref');
307 7         23 my $compressed = $self->_read_xml_attribute('Compressed', 'bool', false);
308 7         15 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
309 7         10 my $binary = {};
310              
311 7 100       17 if (defined $ref) {
312 4         23 $binary->{ref} = $ref;
313             }
314             else {
315 3         6 $binary->{value} = $self->_read_xml_content('binary');
316 3 50       8 $binary->{protect} = true if $protected;
317              
318 3 50       23 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         54 $binary;
328             },
329 7         34 );
330             }
331              
332             sub _read_xml_entry_auto_type {
333 18     18   225 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   123 my $self = shift;
341 11         63 return $self->_read_xml_element(
342             Window => 'text',
343             KeystrokeSequence => 'text',
344             );
345 18         118 }],
346             );
347             }
348              
349             sub _read_xml_deleted_objects {
350 21     21   231 my $self = shift;
351              
352             return $self->_read_xml_element(
353             DeletedObject => sub {
354 14     14   151 my $self = shift;
355 14         119 my $object = $self->_read_xml_element(
356             UUID => 'uuid',
357             DeletionTime => 'datetime',
358             );
359 14         43 $object->{uuid} => $object;
360             }
361 21         205 );
362             }
363              
364             ##############################################################################
365              
366             sub _resolve_binary_refs {
367 21     21   39 my $self = shift;
368 21         53 my $kdbx = $self->kdbx;
369              
370 21         67 my $pool = $kdbx->binaries;
371              
372 21         80 my $entries = $kdbx->entries(history => 1);
373 21         203 while (my $entry = $entries->next) {
374 18         30 while (my ($key, $binary) = each %{$entry->binaries}) {
  25         63  
375 7   100     21 my $ref = $binary->{ref} // next;
376 4 50       8 next if defined $binary->{value};
377              
378 4         8 my $data = $pool->{$ref};
379 4 50 33     18 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         6 $binary->{value} = $data->{value};
384 4 50       11 $binary->{protect} = true if $data->{protect};
385 4         7 delete $binary->{ref};
386             }
387             }
388             }
389              
390             ##############################################################################
391              
392             sub _read_xml_element {
393 447     447   628 my $self = shift;
394 447 100       1002 my $args = @_ % 2 == 1 ? shift : {};
395 447         2515 my %spec = @_;
396              
397 447         844 my $reader = $self->_reader;
398 447         812 my $path = $reader->nodePath;
399 447         4638 $path =~ s!\Q/text()\E$!!;
400              
401 447 100       1206 return $args if $reader->isEmptyElement;
402              
403             my $store = ref $args eq 'CODE' ? $args
404             : ref $args eq 'HASH' ? sub {
405 2335     2218   3902 my ($key, $val) = @_;
406 2335 50       6033 if (ref $args->{$key} eq 'HASH') {
    100          
407 0         0 $args->{$key}{$key} = $val;
408             }
409             elsif (ref $args->{$key} eq 'ARRAY') {
410 157         183 push @{$args->{$key}}, $val;
  157         965  
411             }
412             else {
413 2178 50       3674 exists $args->{$key}
414             and alert 'Overwriting value', node => $reader->nodePath, line => $reader->lineNumber;
415 2178         12239 $args->{$key} = $val;
416             }
417             } : ref $args eq 'ARRAY' ? sub {
418 6     5   13 my ($key, $val) = @_;
419 6         31 push @$args, $val;
420 375 50   0   1648 } : sub {};
    100          
    50          
421              
422 375         1524 my $pattern = XML::LibXML::Pattern->new("${path}|${path}/*");
423 375         8567 while ($reader->nextPatternMatch($pattern) == 1) {
424 4397 100 66     10386 last if $reader->nodePath eq $path && $reader->nodeType == XML_READER_TYPE_END_ELEMENT;
425 4022 100       52198 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
426              
427 2353         4844 my $name = $reader->localName;
428 2353         4551 my $key = snakify($name);
429 2353         4367 my $type = $spec{$name};
430 2353 100       4131 ($key, $type) = @$type if ref $type eq 'ARRAY';
431              
432 2353 50       3557 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       3495 if (ref $type eq 'CODE') {
440 509         1014 my @result = $self->$type($args, $reader->nodePath);
441 509 100       1875 if (@result == 2) {
    100          
442 26         78 $store->(@result);
443             }
444             elsif (@result == 1) {
445 471         768 $store->($key, @result);
446             }
447             }
448             else {
449 1844         3070 $store->($key, $self->_read_xml_content($type));
450             }
451             }
452              
453 375         7570 return $args;
454             }
455              
456             sub _read_xml_attribute {
457 217     217   642 my $self = shift;
458 217         242 my $name = shift;
459 217   100     366 my $type = shift // 'text';
460 217         238 my $default = shift;
461 217         310 my $reader = $self->_reader;
462              
463 217 100       540 return $default if !$reader->hasAttributes;
464              
465 72         261 my $value = trim($reader->getAttribute($name));
466 72 100       147 if (!defined $value) {
467             # try again after reading in all the attributes
468 39         101 $reader->moveToFirstAttribute;
469 39         147 while ($self->_reader->readAttributeValue == 1) {}
470 39         98 $reader->moveToElement;
471              
472 39         99 $value = trim($reader->getAttribute($name));
473             }
474              
475 72 100       272 return $default if !defined $value;
476              
477 33         41 my $decoded = eval { _decode_primitive($value, $type) };
  33         69  
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         61 return $decoded;
484             }
485              
486             sub _read_xml_content {
487 1944     1944   2557 my $self = shift;
488 1944         2112 my $type = shift;
489 1944         2902 my $reader = $self->_reader;
490              
491 1944 100       8278 $reader->read if !$reader->isEmptyElement; # step into element
492 1944 100       5371 return '' if !$reader->hasValue;
493              
494 1669         4403 my $content = trim($reader->value);
495              
496 1669         2328 my $decoded = eval { _decode_primitive($content, $type) };
  1669         2638  
497 1669 50       10101 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         3304 return $decoded;
503             }
504              
505             ##############################################################################
506              
507 1702     1702   1759 sub _decode_primitive { goto &{__PACKAGE__."::_decode_$_[1]"} }
  1702         7486  
508              
509             sub _decode_binary {
510 246     246   322 local $_ = shift;
511 246 50 33     789 return '' if !defined || (ref && !defined $$_);
      33        
512 246 50       305 $_ = eval { decode_b64(ref $_ ? $$_ : $_) };
  246         996  
513 246         332 my $err = $@;
514 246         517 my $cleanup = erase_scoped $_;
515 246 50       2613 $err and throw 'Failed to parse binary', error => $err;
516 246         618 return $_;
517             }
518              
519             sub _decode_bool {
520 296     296   487 local $_ = shift;
521 296 100       1081 return true if /^True$/i;
522 157 50       593 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   799 local $_ = shift;
529              
530 501 100       1414 if (/^[A-Za-z0-9\+\/\=]+$/) {
531 131         183 my $binary = eval { decode_b64($_) };
  131         402  
532 131 50       234 if (my $err = $@) {
533 0         0 throw 'Failed to parse binary datetime', text => $_, error => $err;
534             }
535 131 50       189 throw $@ if $@;
536 131 50       229 $binary .= \0 x (8 - length($binary)) if length($binary) < 8;
537 131         264 my ($seconds_since_ad1) = unpack_Ql($binary);
538 131         216 my $epoch = $seconds_since_ad1 - TIME_SECONDS_AD1_TO_UNIX_EPOCH;
539 131         282 return gmtime($epoch);
540             }
541              
542 370         477 my $dt = eval { Time::Piece->strptime($_, '%Y-%m-%dT%H:%M:%SZ') };
  370         1002  
543 370 50       42369 if (my $err = $@) {
544 0         0 throw 'Failed to parse datetime', text => $_, error => $err;
545             }
546 370         669 return $dt;
547             }
548              
549             sub _decode_tristate {
550 106     106   177 local $_ = shift;
551 106 50       363 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   448 local $_ = shift;
559 257         374 $_ = _decode_text($_);
560 257 50       795 looks_like_number($_) or throw 'Expected number', text => $_;
561 257         569 return $_+0;
562             }
563              
564             sub _decode_text {
565 553     553   789 local $_ = shift;
566 553 50       919 return '' if !defined;
567 553         842 return $_;
568             }
569              
570             sub _decode_uuid {
571 207     207   342 local $_ = shift;
572 207         245 my $uuid = eval { _decode_binary($_) };
  207         317  
573 207 50       427 $@ and throw 'Expected UUID', text => $_, error => $@;
574 207 50       380 length($uuid) == 16 or throw 'Invalid UUID size', size => length($uuid);
575 207         367 return $uuid;
576             }
577              
578             1;
579              
580             __END__