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   2658 use warnings;
  5         8  
  5         152  
5 5     5   26 use strict;
  5         10  
  5         125  
6              
7 5     5   1280 use Crypt::Misc 0.029 qw(decode_b64);
  5         13023  
  5         271  
8 5     5   30 use Encode qw(decode);
  5         15  
  5         192  
9 5     5   27 use File::KDBX::Constants qw(:version :time);
  5         7  
  5         675  
10 5     5   31 use File::KDBX::Error;
  5         9  
  5         219  
11 5     5   27 use File::KDBX::Safe;
  5         9  
  5         161  
12 5     5   25 use File::KDBX::Util qw(:class :int :text gunzip erase_scoped);
  5         10  
  5         719  
13 5     5   32 use Scalar::Util qw(looks_like_number);
  5         18  
  5         239  
14 5     5   29 use Time::Piece 1.33;
  5         72  
  5         32  
15 5     5   2317 use XML::LibXML::Reader;
  5         89514  
  5         449  
16 5     5   39 use boolean;
  5         11  
  5         36  
17 5     5   257 use namespace::clean;
  5         11  
  5         34  
18              
19             extends 'File::KDBX::Loader';
20 2686 50   2686   6887  
21 39 50   39   285 our $VERSION = '0.905'; # VERSION
22 2686   50     6153  
23 39   100     248 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   40 my $self = shift;
35 21         37 my $fh = shift;
36              
37 21         130 my $reader = $self->{_reader} = XML::LibXML::Reader->new(IO => $fh);
38              
39 21         2692 delete $self->{_safe};
40 21         32 my $root_done;
41              
42 21         117 my $pattern = XML::LibXML::Pattern->new('/KeePassFile/Meta|/KeePassFile/Root');
43 21         793 while ($reader->nextPatternMatch($pattern) == 1) {
44 42 50       2173 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
45 42         150 my $name = $reader->localName;
46 42 100       138 if ($name eq 'Meta') {
    50          
47 21         61 $self->_read_xml_meta;
48             }
49             elsif ($name eq 'Root') {
50 21 50       69 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         64 $self->_read_xml_root;
55 21         160 $root_done = 1;
56             }
57             }
58              
59 21 50       103 if ($reader->readState == XML_READER_ERROR) {
60 0         0 throw 'Failed to parse KeePass XML';
61             }
62              
63 21 100       97 $self->kdbx->_safe($self->_safe) if $self->{_safe};
64              
65 21         61 $self->_resolve_binary_refs;
66             }
67              
68             sub _read_xml_meta {
69 21     21   35 my $self = shift;
70              
71 21         90 $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   231 my $self = shift;
104 21   33     54 my $meta = shift // $self->kdbx->meta;
105              
106 21         121 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   138 my $self = shift;
118 12         52 my $kdbx = $self->kdbx;
119              
120             my $binaries = $self->_read_xml_element(
121             Binary => sub {
122 2     2   23 my $self = shift;
123 2         7 my $id = $self->_read_xml_attribute('ID');
124 2         7 my $compressed = $self->_read_xml_attribute('Compressed', 'bool', false);
125 2         5 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
126 2         5 my $data = $self->_read_xml_content('binary');
127              
128 2 50       11 my $binary = {
129             value => $data,
130             $protected ? (protect => true) : (),
131             };
132              
133 2 50       19 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         28 $id => $binary;
142             },
143 12         86 );
144              
145 12         89 $kdbx->binaries({%{$kdbx->binaries}, %$binaries});
  12         51  
146 12         42 return (); # do not add to meta
147             }
148              
149             sub _read_xml_custom_data {
150 32     32   335 my $self = shift;
151              
152             return $self->_read_xml_element(
153             Item => sub {
154 10     10   106 my $self = shift;
155 10         25 my $item = $self->_read_xml_element(
156             Key => 'text',
157             Value => 'text',
158             LastModificationTime => 'datetime', # KDBX4.1
159             );
160 10         43 $item->{key} => $item;
161             },
162 32         162 );
163             }
164              
165             sub _read_xml_custom_icons {
166 14     14   193 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   35 my $self = shift;
183 21         74 my $kdbx = $self->kdbx;
184              
185 21         72 my $root = $self->_read_xml_element(
186             Group => \&_read_xml_group,
187             DeletedObjects => \&_read_xml_deleted_objects,
188             );
189              
190 21         109 $kdbx->deleted_objects($root->{deleted_objects});
191 21 50       209 $kdbx->root($root->{group}) if $root->{group};
192             }
193              
194             sub _read_xml_group {
195 53     53   493 my $self = shift;
196              
197 53         375 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   199 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   91 my $self = shift;
237 8         36 return $self->_read_xml_element([],
238             Entry => \&_read_xml_entry,
239             );
240             },
241 18         206 );
242              
243 18         72 my %strings;
244 18 50       36 for my $string (@{$entry->{strings} || []}) {
  18         70  
245 95         209 $strings{$string->{key}} = $string->{value};
246             }
247 18         67 $entry->{strings} = \%strings;
248              
249 18         29 my %binaries;
250 18 50       33 for my $binary (@{$entry->{binaries} || []}) {
  18         57  
251 7         19 $binaries{$binary->{key}} = $binary->{value};
252             }
253 18         36 $entry->{binaries} = \%binaries;
254              
255 18         42 return $entry;
256             }
257              
258             sub _read_xml_times {
259 71     71   725 my $self = shift;
260              
261 71         294 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   1141 my $self = shift;
274              
275             return $self->_read_xml_element(
276             Key => 'text',
277             Value => sub {
278 95     95   1217 my $self = shift;
279              
280 95         219 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     287 my $protect = $protected || $protect_in_memory;
283              
284 95 100       879 my $val = $self->_read_xml_content($protected ? 'binary' : 'text');
285              
286 95 100       240 my $string = {
287             value => $val,
288             $protect ? (protect => true) : (),
289             };
290              
291 95 100       804 $self->_safe->add_protected(sub { decode('UTF-8', $_[0]) }, $string) if $protected;
  27         105  
292              
293 95         481 $string;
294             },
295 95         483 );
296             }
297              
298             sub _read_xml_entry_binary {
299 7     7   77 my $self = shift;
300              
301             return $self->_read_xml_element(
302             Key => 'text',
303             Value => sub {
304 7     7   83 my $self = shift;
305              
306 7         20 my $ref = $self->_read_xml_attribute('Ref');
307 7         22 my $compressed = $self->_read_xml_attribute('Compressed', 'bool', false);
308 7         23 my $protected = $self->_read_xml_attribute('Protected', 'bool', false);
309 7         18 my $binary = {};
310              
311 7 100       15 if (defined $ref) {
312 4         16 $binary->{ref} = $ref;
313             }
314             else {
315 3         8 $binary->{value} = $self->_read_xml_content('binary');
316 3 50       11 $binary->{protect} = true if $protected;
317              
318 3 50       27 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         62 $binary;
328             },
329 7         38 );
330             }
331              
332             sub _read_xml_entry_auto_type {
333 18     18   232 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   147 my $self = shift;
341 11         57 return $self->_read_xml_element(
342             Window => 'text',
343             KeystrokeSequence => 'text',
344             );
345 18         126 }],
346             );
347             }
348              
349             sub _read_xml_deleted_objects {
350 21     21   212 my $self = shift;
351              
352             return $self->_read_xml_element(
353             DeletedObject => sub {
354 14     14   180 my $self = shift;
355 14         31 my $object = $self->_read_xml_element(
356             UUID => 'uuid',
357             DeletionTime => 'datetime',
358             );
359 14         45 $object->{uuid} => $object;
360             }
361 21         98 );
362             }
363              
364             ##############################################################################
365              
366             sub _resolve_binary_refs {
367 21     21   35 my $self = shift;
368 21         59 my $kdbx = $self->kdbx;
369              
370 21         65 my $pool = $kdbx->binaries;
371              
372 21         72 my $entries = $kdbx->entries(history => 1);
373 21         172 while (my $entry = $entries->next) {
374 18         30 while (my ($key, $binary) = each %{$entry->binaries}) {
  25         75  
375 7   100     21 my $ref = $binary->{ref} // next;
376 4 50       11 next if defined $binary->{value};
377              
378 4         11 my $data = $pool->{$ref};
379 4 50 33     16 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         9 $binary->{value} = $data->{value};
384 4 50       9 $binary->{protect} = true if $data->{protect};
385 4         11 delete $binary->{ref};
386             }
387             }
388             }
389              
390             ##############################################################################
391              
392             sub _read_xml_element {
393 447     447   629 my $self = shift;
394 447 100       1033 my $args = @_ % 2 == 1 ? shift : {};
395 447         2403 my %spec = @_;
396              
397 447         865 my $reader = $self->_reader;
398 447         862 my $path = $reader->nodePath;
399 447         4638 $path =~ s!\Q/text()\E$!!;
400              
401 447 100       1175 return $args if $reader->isEmptyElement;
402              
403             my $store = ref $args eq 'CODE' ? $args
404             : ref $args eq 'HASH' ? sub {
405 2335     2218   3851 my ($key, $val) = @_;
406 2335 50       6235 if (ref $args->{$key} eq 'HASH') {
    100          
407 0         0 $args->{$key}{$key} = $val;
408             }
409             elsif (ref $args->{$key} eq 'ARRAY') {
410 157         196 push @{$args->{$key}}, $val;
  157         990  
411             }
412             else {
413 2178 50       3846 exists $args->{$key}
414             and alert 'Overwriting value', node => $reader->nodePath, line => $reader->lineNumber;
415 2178         13781 $args->{$key} = $val;
416             }
417             } : ref $args eq 'ARRAY' ? sub {
418 6     5   16 my ($key, $val) = @_;
419 6         48 push @$args, $val;
420 375 50   0   1768 } : sub {};
    100          
    50          
421              
422 375         1542 my $pattern = XML::LibXML::Pattern->new("${path}|${path}/*");
423 375         8353 while ($reader->nextPatternMatch($pattern) == 1) {
424 4397 100 66     10552 last if $reader->nodePath eq $path && $reader->nodeType == XML_READER_TYPE_END_ELEMENT;
425 4022 100       52284 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
426              
427 2353         4560 my $name = $reader->localName;
428 2353         4539 my $key = snakify($name);
429 2353         4417 my $type = $spec{$name};
430 2353 100       4192 ($key, $type) = @$type if ref $type eq 'ARRAY';
431              
432 2353 50       3552 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       3590 if (ref $type eq 'CODE') {
440 509         1067 my @result = $self->$type($args, $reader->nodePath);
441 509 100       1794 if (@result == 2) {
    100          
442 26         50 $store->(@result);
443             }
444             elsif (@result == 1) {
445 471         813 $store->($key, @result);
446             }
447             }
448             else {
449 1844         3145 $store->($key, $self->_read_xml_content($type));
450             }
451             }
452              
453 375         7705 return $args;
454             }
455              
456             sub _read_xml_attribute {
457 217     217   551 my $self = shift;
458 217         255 my $name = shift;
459 217   100     364 my $type = shift // 'text';
460 217         237 my $default = shift;
461 217         315 my $reader = $self->_reader;
462              
463 217 100       527 return $default if !$reader->hasAttributes;
464              
465 72         265 my $value = trim($reader->getAttribute($name));
466 72 100       166 if (!defined $value) {
467             # try again after reading in all the attributes
468 39         111 $reader->moveToFirstAttribute;
469 39         67 while ($self->_reader->readAttributeValue == 1) {}
470 39         109 $reader->moveToElement;
471              
472 39         176 $value = trim($reader->getAttribute($name));
473             }
474              
475 72 100       150 return $default if !defined $value;
476              
477 33         41 my $decoded = eval { _decode_primitive($value, $type) };
  33         63  
478 33 50       145 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         62 return $decoded;
484             }
485              
486             sub _read_xml_content {
487 1944     1944   2718 my $self = shift;
488 1944         2190 my $type = shift;
489 1944         2854 my $reader = $self->_reader;
490              
491 1944 100       8546 $reader->read if !$reader->isEmptyElement; # step into element
492 1944 100       5411 return '' if !$reader->hasValue;
493              
494 1669         4602 my $content = trim($reader->value);
495              
496 1669         2346 my $decoded = eval { _decode_primitive($content, $type) };
  1669         2500  
497 1669 50       9331 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         3281 return $decoded;
503             }
504              
505             ##############################################################################
506              
507 1702     1702   1857 sub _decode_primitive { goto &{__PACKAGE__."::_decode_$_[1]"} }
  1702         7632  
508              
509             sub _decode_binary {
510 246     246   360 local $_ = shift;
511 246 50 33     763 return '' if !defined || (ref && !defined $$_);
      33        
512 246 50       289 $_ = eval { decode_b64(ref $_ ? $$_ : $_) };
  246         1055  
513 246         359 my $err = $@;
514 246         585 my $cleanup = erase_scoped $_;
515 246 50       2689 $err and throw 'Failed to parse binary', error => $err;
516 246         659 return $_;
517             }
518              
519             sub _decode_bool {
520 296     296   482 local $_ = shift;
521 296 100       1111 return true if /^True$/i;
522 157 50       641 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   758 local $_ = shift;
529              
530 501 100       1445 if (/^[A-Za-z0-9\+\/\=]+$/) {
531 131         164 my $binary = eval { decode_b64($_) };
  131         404  
532 131 50       229 if (my $err = $@) {
533 0         0 throw 'Failed to parse binary datetime', text => $_, error => $err;
534             }
535 131 50       226 throw $@ if $@;
536 131 50       229 $binary .= \0 x (8 - length($binary)) if length($binary) < 8;
537 131         310 my ($seconds_since_ad1) = unpack_Ql($binary);
538 131         230 my $epoch = $seconds_since_ad1 - TIME_SECONDS_AD1_TO_UNIX_EPOCH;
539 131         264 return gmtime($epoch);
540             }
541              
542 370         457 my $dt = eval { Time::Piece->strptime($_, '%Y-%m-%dT%H:%M:%SZ') };
  370         1122  
543 370 50       44533 if (my $err = $@) {
544 0         0 throw 'Failed to parse datetime', text => $_, error => $err;
545             }
546 370         687 return $dt;
547             }
548              
549             sub _decode_tristate {
550 106     106   181 local $_ = shift;
551 106 50       382 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   419 local $_ = shift;
559 257         425 $_ = _decode_text($_);
560 257 50       715 looks_like_number($_) or throw 'Expected number', text => $_;
561 257         620 return $_+0;
562             }
563              
564             sub _decode_text {
565 553     553   791 local $_ = shift;
566 553 50       949 return '' if !defined;
567 553         909 return $_;
568             }
569              
570             sub _decode_uuid {
571 207     207   334 local $_ = shift;
572 207         337 my $uuid = eval { _decode_binary($_) };
  207         373  
573 207 50       469 $@ and throw 'Expected UUID', text => $_, error => $@;
574 207 50       461 length($uuid) == 16 or throw 'Invalid UUID size', size => length($uuid);
575 207         392 return $uuid;
576             }
577              
578             1;
579              
580             __END__