File Coverage

blib/lib/File/KDBX/Dumper/XML.pm
Criterion Covered Total %
statement 260 291 89.3
branch 65 108 60.1
condition 35 63 55.5
subroutine 44 46 95.6
pod 5 5 100.0
total 409 513 79.7


line stmt bran cond sub pod time code
1             package File::KDBX::Dumper::XML;
2             # ABSTRACT: Dump unencrypted XML KeePass files
3              
4 5     5   4103 use warnings;
  5         10  
  5         151  
5 5     5   29 use strict;
  5         10  
  5         104  
6              
7 5     5   21 use Crypt::Digest qw(digest_data);
  5         10  
  5         249  
8 5     5   29 use Crypt::Misc 0.029 qw(encode_b64);
  5         123  
  5         223  
9 5     5   31 use Encode qw(encode);
  5         10  
  5         229  
10 5     5   29 use File::KDBX::Constants qw(:version :time);
  5         9  
  5         717  
11 5     5   36 use File::KDBX::Error;
  5         12  
  5         272  
12 5     5   33 use File::KDBX::Util qw(:class :int erase_scoped gzip snakify);
  5         8  
  5         677  
13 5     5   32 use IO::Handle;
  5         8  
  5         190  
14 5     5   25 use Scalar::Util qw(blessed isdual looks_like_number);
  5         10  
  5         291  
15 5     5   41 use Time::Piece 1.33;
  5         89  
  5         43  
16 5     5   1519 use XML::LibXML;
  5         52304  
  5         34  
17 5     5   712 use boolean;
  5         8  
  5         42  
18 5     5   272 use namespace::clean;
  5         18  
  5         38  
19              
20             extends 'File::KDBX::Dumper';
21              
22 12 50   12 1 50 our $VERSION = '0.906'; # VERSION
23 12 50   7 1 34  
  7 50       51  
24 12 50 66 2 1 58  
  7 50       24  
  2         7  
25 7 50 50 256 1 42 has allow_protection => 1;
  2 50       5  
  256         708  
26 2 50 50     8 has binaries => sub { $_[0]->kdbx->version < KDBX_VERSION_4_0 };
  256         506  
27 256   100     891 has 'compress_binaries';
28             has 'compress_datetimes';
29              
30 0     0 1 0 sub header_hash { $_[0]->{header_hash} }
31              
32 34   100 34   124 sub _binaries_written { $_[0]->{_binaries_written} //= {} }
33              
34 12   66 12   65 sub _random_stream { $_[0]->{random_stream} //= $_[0]->kdbx->random_stream }
35              
36             sub _dump {
37 0     0   0 my $self = shift;
38 0         0 my $fh = shift;
39              
40 0         0 $self->_write_inner_body($fh, $self->header_hash);
41             }
42              
43             sub _write_inner_body {
44 15     15   32 my $self = shift;
45 15         24 my $fh = shift;
46 15         29 my $header_hash = shift;
47              
48 15         174 my $dom = XML::LibXML::Document->new('1.0', 'UTF-8');
49 15         72 $dom->setStandalone(1);
50              
51 15         114 my $doc = XML::LibXML::Element->new('KeePassFile');
52 15         80 $dom->setDocumentElement($doc);
53              
54 15         264 my $meta = XML::LibXML::Element->new('Meta');
55 15         437 $doc->appendChild($meta);
56 15         62 $self->_write_xml_meta($meta, $header_hash);
57              
58 15         242 my $root = XML::LibXML::Element->new('Root');
59 15         89 $doc->appendChild($root);
60 15         40 $self->_write_xml_root($root);
61              
62 15         1160 $dom->toFH($fh, 1);
63             }
64              
65             sub _write_xml_meta {
66 15     15   490 my $self = shift;
67 15         24 my $node = shift;
68 15         27 my $header_hash = shift;
69              
70 15         47 my $meta = $self->kdbx->meta;
71 15   50     42 local $meta->{generator} = $self->kdbx->user_agent_string // __PACKAGE__;
72 15         60 local $meta->{header_hash} = $header_hash;
73              
74             $self->_write_xml_from_pairs($node, $meta,
75             Generator => 'text',
76             $self->kdbx->version < KDBX_VERSION_4_0 && defined $meta->{header_hash} ? (
77 15 100 66     54 HeaderHash => 'binary',
    100 66        
    100          
78             ) : (),
79             DatabaseName => 'text',
80             DatabaseNameChanged => 'datetime',
81             DatabaseDescription => 'text',
82             DatabaseDescriptionChanged => 'datetime',
83             DefaultUserName => 'text',
84             DefaultUserNameChanged => 'datetime',
85             MaintenanceHistoryDays => 'number',
86             Color => 'text',
87             MasterKeyChanged => 'datetime',
88             MasterKeyChangeRec => 'number',
89             MasterKeyChangeForce => 'number',
90             MemoryProtection => \&_write_xml_memory_protection,
91             CustomIcons => \&_write_xml_custom_icons,
92             RecycleBinEnabled => 'bool',
93             RecycleBinUUID => 'uuid',
94             RecycleBinChanged => 'datetime',
95             EntryTemplatesGroup => 'uuid',
96             EntryTemplatesGroupChanged => 'datetime',
97             LastSelectedGroup => 'uuid',
98             LastTopVisibleGroup => 'uuid',
99             HistoryMaxItems => 'number',
100             HistoryMaxSize => 'number',
101             $self->kdbx->version >= KDBX_VERSION_4_0 ? (
102             SettingsChanged => 'datetime',
103             ) : (),
104             $self->kdbx->version < KDBX_VERSION_4_0 || $self->binaries ? (
105             Binaries => \&_write_xml_binaries,
106             ) : (),
107             CustomData => \&_write_xml_custom_data,
108             );
109             }
110              
111             sub _write_xml_memory_protection {
112 15     15   27 my $self = shift;
113 15         24 my $node = shift;
114              
115 15         49 my $memory_protection = $self->kdbx->meta->{memory_protection};
116              
117 15         98 $self->_write_xml_from_pairs($node, $memory_protection,
118             ProtectTitle => 'bool',
119             ProtectUserName => 'bool',
120             ProtectPassword => 'bool',
121             ProtectURL => 'bool',
122             ProtectNotes => 'bool',
123             # AutoEnableVisualHiding => 'bool',
124             );
125             }
126              
127             sub _write_xml_binaries {
128 8     8   24 my $self = shift;
129 8         16 my $node = shift;
130              
131 8         34 my $kdbx = $self->kdbx;
132              
133 8         17 my $new_ref = keys %{$self->_binaries_written};
  8         23  
134 8         27 my $written = $self->_binaries_written;
135              
136 8         39 my $entries = $kdbx->entries(history => 1);
137 8         79 while (my $entry = $entries->next) {
138 7         21 for my $key (keys %{$entry->binaries}) {
  7         27  
139 3         20 my $binary = $entry->binaries->{$key};
140 3 50 33     9 if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
141 0         0 $binary = $kdbx->binaries->{$binary->{ref}};
142             }
143              
144 3 50       8 if (!defined $binary->{value}) {
145 0         0 alert "Skipping binary which has no value: $key", key => $key;
146 0         0 next;
147             }
148              
149 3         18 my $hash = digest_data('SHA256', $binary->{value});
150 3 100       9 if (defined $written->{$hash}) {
151             # nothing
152             }
153             else {
154 2         10 my $binary_node = $node->addNewChild(undef, 'Binary');
155 2         5 $binary_node->setAttribute('ID', _encode_text($new_ref));
156 2 50       35 $binary_node->setAttribute('Protected', _encode_bool(true)) if $binary->{protect};
157 2         9 $self->_write_xml_compressed_content($binary_node, \$binary->{value}, $binary->{protect});
158 2         8 $written->{$hash} = $new_ref++;
159             }
160             }
161             }
162             }
163              
164             sub _write_xml_compressed_content {
165 2     2   3 my $self = shift;
166 2         3 my $node = shift;
167 2         2 my $value = shift;
168 2         6 my $protect = shift;
169              
170 2         2 my @cleanup;
171              
172             my $encoded;
173 2 50       11 if (utf8::is_utf8($$value)) {
174 0         0 $encoded = encode('UTF-8', $$value);
175 0         0 push @cleanup, erase_scoped $encoded;
176 0         0 $value = \$encoded;
177             }
178              
179 2         5 my $should_compress = $self->compress_binaries;
180 2   33     6 my $try_compress = $should_compress || !defined $should_compress;
181              
182 2         4 my $compressed;
183 2 50       4 if ($try_compress) {
184 2         6 $compressed = gzip($$value);
185 2         8 push @cleanup, erase_scoped $compressed;
186              
187 2 50 33     29 if ($should_compress || length($compressed) < length($$value)) {
188 0         0 $value = \$compressed;
189 0         0 $node->setAttribute('Compressed', _encode_bool(true));
190             }
191             }
192              
193 2         3 my $encrypted;
194 2 50       5 if ($protect) {
195 0         0 $encrypted = $self->_random_stream->crypt($$value);
196 0         0 push @cleanup, erase_scoped $encrypted;
197 0         0 $value = \$encrypted;
198             }
199              
200 2         4 $node->appendText(_encode_binary($$value));
201             }
202              
203             sub _write_xml_custom_icons {
204 15     15   32 my $self = shift;
205 15         33 my $node = shift;
206              
207 15         45 my $custom_icons = $self->kdbx->custom_icons;
208              
209 15         62 for my $icon (@$custom_icons) {
210 0 0 0     0 $icon->{uuid} && $icon->{data} or next;
211 0         0 my $icon_node = $node->addNewChild(undef, 'Icon');
212              
213 0 0       0 $self->_write_xml_from_pairs($icon_node, $icon,
214             UUID => 'uuid',
215             Data => 'binary',
216             KDBX_VERSION_4_1 <= $self->kdbx->version ? (
217             Name => 'text',
218             LastModificationTime => 'datetime',
219             ) : (),
220             );
221             }
222             }
223              
224             sub _write_xml_custom_data {
225 26     26   48 my $self = shift;
226 26         40 my $node = shift;
227 26   50     75 my $custom_data = shift || {};
228              
229 26         114 for my $key (sort keys %$custom_data) {
230 10         91 my $item = $custom_data->{$key};
231 10         35 my $item_node = $node->addNewChild(undef, 'Item');
232              
233 10 50       27 local $item->{key} = $key if !defined $item->{key};
234              
235 10 50       31 $self->_write_xml_from_pairs($item_node, $item,
236             Key => 'text',
237             Value => 'text',
238             KDBX_VERSION_4_1 <= $self->kdbx->version ? (
239             LastModificationTime => 'datetime',
240             ) : (),
241             );
242             }
243             }
244              
245             sub _write_xml_root {
246 15     15   156 my $self = shift;
247 15         25 my $node = shift;
248 15         50 my $kdbx = $self->kdbx;
249              
250 15         88 my $guard = $kdbx->unlock_scoped;
251              
252 15 50       45 if (my $group = $kdbx->root) {
253 15         93 my $group_node = $node->addNewChild(undef, 'Group');
254 15         99 $self->_write_xml_group($group_node, $group->_committed);
255             }
256              
257 15         166 undef $guard; # re-lock if needed, as early as possible
258              
259 15         64 my $deleted_objects_node = $node->addNewChild(undef, 'DeletedObjects');
260 15         53 $self->_write_xml_deleted_objects($deleted_objects_node);
261             }
262              
263             sub _write_xml_group {
264 23     23   40 my $self = shift;
265 23         32 my $node = shift;
266 23         34 my $group = shift;
267              
268             $self->_write_xml_from_pairs($node, $group,
269             UUID => 'uuid',
270             Name => 'text',
271             Notes => 'text',
272             KDBX_VERSION_4_1 <= $self->kdbx->version ? (
273             Tags => 'text',
274             ) : (),
275             IconID => 'number',
276             defined $group->{custom_icon_uuid} ? (
277 23 50       73 CustomIconUUID => 'uuid',
    50          
    100          
    50          
278             ) : (),
279             Times => \&_write_xml_times,
280             IsExpanded => 'bool',
281             DefaultAutoTypeSequence => 'text',
282             EnableAutoType => 'tristate',
283             EnableSearching => 'tristate',
284             LastTopVisibleEntry => 'uuid',
285             KDBX_VERSION_4_0 <= $self->kdbx->version ? (
286             CustomData => \&_write_xml_custom_data,
287             ) : (),
288             KDBX_VERSION_4_1 <= $self->kdbx->version ? (
289             PreviousParentGroup => 'uuid',
290             ) : (),
291             );
292              
293 23         285 for my $entry (@{$group->entries}) {
  23         108  
294 6         27 my $entry_node = $node->addNewChild(undef, 'Entry');
295 6         38 $self->_write_xml_entry($entry_node, $entry->_committed);
296             }
297              
298 23         117 for my $group (@{$group->groups}) {
  23         61  
299 8         70 my $group_node = $node->addNewChild(undef, 'Group');
300 8         32 $self->_write_xml_group($group_node, $group->_committed);
301             }
302             }
303              
304             sub _write_xml_entry {
305 9     9   19 my $self = shift;
306 9         15 my $node = shift;
307 9         16 my $entry = shift;
308 9         15 my $in_history = shift;
309              
310             $self->_write_xml_from_pairs($node, $entry,
311             UUID => 'uuid',
312             IconID => 'number',
313             defined $entry->{custom_icon_uuid} ? (
314 9 50       52 CustomIconUUID => 'uuid',
    50          
315             ) : (),
316             ForegroundColor => 'text',
317             BackgroundColor => 'text',
318             OverrideURL => 'text',
319             Tags => 'text',
320             Times => \&_write_xml_times,
321             KDBX_VERSION_4_1 <= $self->kdbx->version ? (
322             QualityCheck => 'bool',
323             PreviousParentGroup => 'uuid',
324             ) : (),
325             );
326              
327 9 50       716 for my $key (sort keys %{$entry->{strings} || {}}) {
  9         106  
328 45         636 my $string = $entry->{strings}{$key};
329 45         182 my $string_node = $node->addNewChild(undef, 'String');
330 45   33     187 local $string->{key} = $string->{key} // $key;
331 45         93 $self->_write_xml_entry_string($string_node, $string);
332             }
333              
334 9         151 my $kdbx = $self->kdbx;
335 9         17 my $new_ref = keys %{$self->_binaries_written};
  9         25  
336 9         29 my $written = $self->_binaries_written;
337              
338 9 50       14 for my $key (sort keys %{$entry->{binaries} || {}}) {
  9         45  
339 3         28 my $binary = $entry->binaries->{$key};
340 3 50 33     9 if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
341 0         0 $binary = $kdbx->binaries->{$binary->{ref}};
342             }
343              
344 3 50       8 if (!defined $binary->{value}) {
345 0         0 alert "Skipping binary which has no value: $key", key => $key;
346 0         0 next;
347             }
348              
349 3         13 my $binary_node = $node->addNewChild(undef, 'Binary');
350 3         11 $binary_node->addNewChild(undef, 'Key')->appendText(_encode_text($key));
351 3         8 my $value_node = $binary_node->addNewChild(undef, 'Value');
352              
353 3         56 my $hash = digest_data('SHA256', $binary->{value});
354 3 50       9 if (defined $written->{$hash}) {
355             # write reference
356 3         6 $value_node->setAttribute('Ref', _encode_text($written->{$hash}));
357             }
358             else {
359             # write actual binary
360 0 0       0 $value_node->setAttribute('Protected', _encode_bool(true)) if $binary->{protect};
361 0         0 $self->_write_xml_compressed_content($value_node, \$binary->{value}, $binary->{protect});
362 0         0 $written->{$hash} = $new_ref++;
363             }
364             }
365              
366 9         75 $self->_write_xml_from_pairs($node, $entry,
367             AutoType => \&_write_xml_entry_auto_type,
368             );
369              
370 9 100       142 $self->_write_xml_from_pairs($node, $entry,
371             KDBX_VERSION_4_0 <= $self->kdbx->version ? (
372             CustomData => \&_write_xml_custom_data,
373             ) : (),
374             );
375              
376 9 100       81 if (!$in_history) {
377 6 100       28 if (my @history = @{$entry->history}) {
  6         28  
378 2         12 my $history_node = $node->addNewChild(undef, 'History');
379 2         7 for my $historical (@history) {
380 3         16 my $historical_node = $history_node->addNewChild(undef, 'Entry');
381 3         9 $self->_write_xml_entry($historical_node, $historical->_committed, 1);
382             }
383             }
384             }
385             }
386              
387             sub _write_xml_entry_auto_type {
388 9     9   16 my $self = shift;
389 9         16 my $node = shift;
390 9         14 my $autotype = shift;
391              
392 9         28 $self->_write_xml_from_pairs($node, $autotype,
393             Enabled => 'bool',
394             DataTransferObfuscation => 'number',
395             DefaultSequence => 'text',
396             );
397              
398 9 50       71 for my $association (@{$autotype->{associations} || []}) {
  9         52  
399 3         27 my $association_node = $node->addNewChild(undef, 'Association');
400 3         9 $self->_write_xml_from_pairs($association_node, $association,
401             Window => 'text',
402             KeystrokeSequence => 'text',
403             );
404             }
405             }
406              
407             sub _write_xml_times {
408 32     32   63 my $self = shift;
409 32         43 my $node = shift;
410 32         42 my $times = shift;
411              
412 32         84 $self->_write_xml_from_pairs($node, $times,
413             LastModificationTime => 'datetime',
414             CreationTime => 'datetime',
415             LastAccessTime => 'datetime',
416             ExpiryTime => 'datetime',
417             Expires => 'bool',
418             UsageCount => 'number',
419             LocationChanged => 'datetime',
420             );
421             }
422              
423             sub _write_xml_entry_string {
424 45     45   56 my $self = shift;
425 45         55 my $node = shift;
426 45         51 my $string = shift;
427              
428 45         55 my @cleanup;
429              
430 45         102 my $kdbx = $self->kdbx;
431 45         71 my $key = $string->{key};
432              
433 45         165 $node->addNewChild(undef, 'Key')->appendText(_encode_text($key));
434 45         93 my $value_node = $node->addNewChild(undef, 'Value');
435              
436 45   100     565 my $value = $string->{value} || '';
437              
438 45         109 my $memory_protection = $kdbx->meta->{memory_protection};
439 45         110 my $memprot_key = 'protect_' . snakify($key);
440 45   66     204 my $protect = $string->{protect} || $memory_protection->{$memprot_key};
441              
442 45 100       242 if ($protect) {
443 12 50       86 if ($self->allow_protection) {
444 12         19 my $encoded;
445 12 100       37 if (utf8::is_utf8($value)) {
446 6         15 $encoded = encode('UTF-8', $value);
447 6         266 push @cleanup, erase_scoped $encoded;
448 6         62 $value = $encoded;
449             }
450              
451 12         51 $value_node->setAttribute('Protected', _encode_bool(true));
452 12         261 $value = _encode_binary($self->_random_stream->crypt(\$value));
453             }
454             else {
455 0         0 $value_node->setAttribute('ProtectInMemory', _encode_bool(true));
456 0         0 $value = _encode_text($value);
457             }
458             }
459             else {
460 33         192 $value = _encode_text($value);
461             }
462              
463 45 50       274 $value_node->appendText($value) if defined $value;
464             }
465              
466             sub _write_xml_deleted_objects {
467 15     15   37 my $self = shift;
468 15         27 my $node = shift;
469              
470 15         41 my $objects = $self->kdbx->deleted_objects;
471              
472 15 50       39 for my $uuid (sort keys %{$objects || {}}) {
  15         86  
473 0         0 my $object = $objects->{$uuid};
474 0         0 local $object->{uuid} = $uuid;
475 0         0 my $object_node = $node->addNewChild(undef, 'DeletedObject');
476 0         0 $self->_write_xml_from_pairs($object_node, $object,
477             UUID => 'uuid',
478             DeletionTime => 'datetime',
479             );
480             }
481             }
482              
483             ##############################################################################
484              
485             sub _write_xml_from_pairs {
486 134     134   168 my $self = shift;
487 134         162 my $node = shift;
488 134         152 my $hash = shift;
489 134         430 my @spec = @_;
490              
491 134         272 while (@spec) {
492 1048         26566 my ($name, $type) = splice @spec, 0, 2;
493 1048         1943 my $key = snakify($name);
494              
495 1048 100       1803 if (ref $type eq 'CODE') {
496 105         400 my $child_node = $node->addNewChild(undef, $name);
497 105         352 $self->$type($child_node, $hash->{$key});
498             }
499             else {
500 943 100       1942 next if !exists $hash->{$key};
501 931         3343 my $child_node = $node->addNewChild(undef, $name);
502 931 100 100     2085 $type = 'datetime_binary' if $type eq 'datetime' && $self->compress_datetimes;
503 931         1635 $child_node->appendText(_encode_primitive($hash->{$key}, $type));
504             }
505             }
506             }
507              
508             ##############################################################################
509              
510 931     931   965 sub _encode_primitive { goto &{__PACKAGE__."::_encode_$_[1]"} }
  931         3632  
511              
512             sub _encode_binary {
513 137 100 33 137   477 return '' if !defined $_[0] || (ref $_[0] && !defined $$_[0]);
      66        
514 121 50       706 return encode_b64(ref $_[0] ? $$_[0] : $_[0]);
515             }
516              
517             sub _encode_bool {
518 166     166   290 local $_ = shift;
519 166 100       427 return $_ ? 'True' : 'False';
520             }
521              
522             sub _encode_datetime {
523 152     152   262 local $_ = shift;
524 152         380 return $_->strftime('%Y-%m-%dT%H:%M:%SZ');
525             }
526              
527             sub _encode_datetime_binary {
528 104     104   178 local $_ = shift;
529 104         290 my $seconds_since_ad1 = $_ + TIME_SECONDS_AD1_TO_UNIX_EPOCH;
530 104         5106 my $buf = pack_Ql($seconds_since_ad1->epoch);
531 104         160 return eval { encode_b64($buf) };
  104         812  
532             }
533              
534             sub _encode_tristate {
535 46   50 46   281 local $_ = shift // return 'null';
536 0 0       0 return $_ ? 'True' : 'False';
537             }
538              
539             sub _encode_number {
540 144   50 144   336 local $_ = shift // return;
541 144 50 66     463 looks_like_number($_) || isdual($_) or throw 'Expected number', text => $_;
542 144         290 return _encode_text($_+0);
543             }
544              
545             sub _encode_text {
546 438 50   438   729 return '' if !defined $_[0];
547 438         1604 return $_[0];
548             }
549              
550 115     115   231 sub _encode_uuid { _encode_binary(@_) }
551              
552             1;
553              
554             __END__