File Coverage

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


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   2900 use warnings;
  5         13  
  5         155  
5 5     5   25 use strict;
  5         10  
  5         116  
6              
7 5     5   25 use Crypt::Digest qw(digest_data);
  5         9  
  5         298  
8 5     5   32 use Crypt::Misc 0.029 qw(encode_b64);
  5         121  
  5         239  
9 5     5   30 use Encode qw(encode);
  5         8  
  5         195  
10 5     5   29 use File::KDBX::Constants qw(:version :time);
  5         8  
  5         776  
11 5     5   34 use File::KDBX::Error;
  5         9  
  5         289  
12 5     5   30 use File::KDBX::Util qw(:class :int erase_scoped gzip snakify);
  5         10  
  5         735  
13 5     5   38 use IO::Handle;
  5         24  
  5         171  
14 5     5   34 use Scalar::Util qw(blessed isdual looks_like_number);
  5         17  
  5         251  
15 5     5   30 use Time::Piece 1.33;
  5         100  
  5         47  
16 5     5   1589 use XML::LibXML;
  5         51101  
  5         43  
17 5     5   676 use boolean;
  5         11  
  5         39  
18 5     5   296 use namespace::clean;
  5         11  
  5         41  
19              
20             extends 'File::KDBX::Dumper';
21              
22 12 50   12 1 43 our $VERSION = '0.904'; # VERSION
23 12 50   7 1 32  
  7 50       29  
24 12 50 66 2 1 62  
  7 50       17  
  2         5  
25 7 50 50 256 1 44 has allow_protection => 1;
  2 50       6  
  256         650  
26 2 50 50     8 has binaries => sub { $_[0]->kdbx->version < KDBX_VERSION_4_0 };
  256         550  
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   148 sub _binaries_written { $_[0]->{_binaries_written} //= {} }
33              
34 12   66 12   54 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   36 my $self = shift;
45 15         27 my $fh = shift;
46 15         30 my $header_hash = shift;
47              
48 15         197 my $dom = XML::LibXML::Document->new('1.0', 'UTF-8');
49 15         97 $dom->setStandalone(1);
50              
51 15         126 my $doc = XML::LibXML::Element->new('KeePassFile');
52 15         87 $dom->setDocumentElement($doc);
53              
54 15         277 my $meta = XML::LibXML::Element->new('Meta');
55 15         119 $doc->appendChild($meta);
56 15         72 $self->_write_xml_meta($meta, $header_hash);
57              
58 15         265 my $root = XML::LibXML::Element->new('Root');
59 15         100 $doc->appendChild($root);
60 15         38 $self->_write_xml_root($root);
61              
62 15         1184 $dom->toFH($fh, 1);
63             }
64              
65             sub _write_xml_meta {
66 15     15   503 my $self = shift;
67 15         25 my $node = shift;
68 15         28 my $header_hash = shift;
69              
70 15         57 my $meta = $self->kdbx->meta;
71 15   50     42 local $meta->{generator} = $self->kdbx->user_agent_string // __PACKAGE__;
72 15         69 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     58 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   30 my $self = shift;
113 15         23 my $node = shift;
114              
115 15         48 my $memory_protection = $self->kdbx->meta->{memory_protection};
116              
117 15         91 $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   34 my $self = shift;
129 8         18 my $node = shift;
130              
131 8         27 my $kdbx = $self->kdbx;
132              
133 8         20 my $new_ref = keys %{$self->_binaries_written};
  8         93  
134 8         26 my $written = $self->_binaries_written;
135              
136 8         41 my $entries = $kdbx->entries(history => 1);
137 8         72 while (my $entry = $entries->next) {
138 7         11 for my $key (keys %{$entry->binaries}) {
  7         22  
139 3         17 my $binary = $entry->binaries->{$key};
140 3 50 33     8 if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
141 0         0 $binary = $kdbx->binaries->{$binary->{ref}};
142             }
143              
144 3 50       7 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         16 my $hash = digest_data('SHA256', $binary->{value});
150 3 100       8 if (defined $written->{$hash}) {
151             # nothing
152             }
153             else {
154 2         17 my $binary_node = $node->addNewChild(undef, 'Binary');
155 2         5 $binary_node->setAttribute('ID', _encode_text($new_ref));
156 2 50       25 $binary_node->setAttribute('Protected', _encode_bool(true)) if $binary->{protect};
157 2         17 $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         2 my $node = shift;
167 2         3 my $value = shift;
168 2         5 my $protect = shift;
169              
170 2         3 my @cleanup;
171              
172             my $encoded;
173 2 50       6 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         4 my $should_compress = $self->compress_binaries;
180 2   33     6 my $try_compress = $should_compress || !defined $should_compress;
181              
182 2         3 my $compressed;
183 2 50       4 if ($try_compress) {
184 2         5 $compressed = gzip($$value);
185 2         8 push @cleanup, erase_scoped $compressed;
186              
187 2 50 33     40 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   29 my $self = shift;
205 15         25 my $node = shift;
206              
207 15         48 my $custom_icons = $self->kdbx->custom_icons;
208              
209 15         65 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   47 my $self = shift;
226 26         42 my $node = shift;
227 26   50     68 my $custom_data = shift || {};
228              
229 26         128 for my $key (sort keys %$custom_data) {
230 10         91 my $item = $custom_data->{$key};
231 10         38 my $item_node = $node->addNewChild(undef, 'Item');
232              
233 10 50       31 local $item->{key} = $key if !defined $item->{key};
234              
235 10 50       29 $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   154 my $self = shift;
247 15         28 my $node = shift;
248 15         54 my $kdbx = $self->kdbx;
249              
250 15         71 my $guard = $kdbx->unlock_scoped;
251              
252 15 50       51 if (my $group = $kdbx->root) {
253 15         78 my $group_node = $node->addNewChild(undef, 'Group');
254 15         111 $self->_write_xml_group($group_node, $group->_committed);
255             }
256              
257 15         174 undef $guard; # re-lock if needed, as early as possible
258              
259 15         90 my $deleted_objects_node = $node->addNewChild(undef, 'DeletedObjects');
260 15         60 $self->_write_xml_deleted_objects($deleted_objects_node);
261             }
262              
263             sub _write_xml_group {
264 23     23   44 my $self = shift;
265 23         35 my $node = shift;
266 23         36 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       100 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         301 for my $entry (@{$group->entries}) {
  23         81  
294 6         28 my $entry_node = $node->addNewChild(undef, 'Entry');
295 6         42 $self->_write_xml_entry($entry_node, $entry->_committed);
296             }
297              
298 23         112 for my $group (@{$group->groups}) {
  23         64  
299 8         71 my $group_node = $node->addNewChild(undef, 'Group');
300 8         24 $self->_write_xml_group($group_node, $group->_committed);
301             }
302             }
303              
304             sub _write_xml_entry {
305 9     9   20 my $self = shift;
306 9         12 my $node = shift;
307 9         23 my $entry = shift;
308 9         17 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       51 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       685 for my $key (sort keys %{$entry->{strings} || {}}) {
  9         84  
328 45         618 my $string = $entry->{strings}{$key};
329 45         145 my $string_node = $node->addNewChild(undef, 'String');
330 45   33     185 local $string->{key} = $string->{key} // $key;
331 45         79 $self->_write_xml_entry_string($string_node, $string);
332             }
333              
334 9         151 my $kdbx = $self->kdbx;
335 9         18 my $new_ref = keys %{$self->_binaries_written};
  9         29  
336 9         22 my $written = $self->_binaries_written;
337              
338 9 50       21 for my $key (sort keys %{$entry->{binaries} || {}}) {
  9         44  
339 3         37 my $binary = $entry->binaries->{$key};
340 3 50 33     7 if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
341 0         0 $binary = $kdbx->binaries->{$binary->{ref}};
342             }
343              
344 3 50       7 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         9 $binary_node->addNewChild(undef, 'Key')->appendText(_encode_text($key));
351 3         7 my $value_node = $binary_node->addNewChild(undef, 'Value');
352              
353 3         41 my $hash = digest_data('SHA256', $binary->{value});
354 3 50       8 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         74 $self->_write_xml_from_pairs($node, $entry,
367             AutoType => \&_write_xml_entry_auto_type,
368             );
369              
370 9 100       151 $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       74 if (!$in_history) {
377 6 100       11 if (my @history = @{$entry->history}) {
  6         30  
378 2         11 my $history_node = $node->addNewChild(undef, 'History');
379 2         4 for my $historical (@history) {
380 3         26 my $historical_node = $history_node->addNewChild(undef, 'Entry');
381 3         13 $self->_write_xml_entry($historical_node, $historical->_committed, 1);
382             }
383             }
384             }
385             }
386              
387             sub _write_xml_entry_auto_type {
388 9     9   15 my $self = shift;
389 9         14 my $node = shift;
390 9         16 my $autotype = shift;
391              
392 9         29 $self->_write_xml_from_pairs($node, $autotype,
393             Enabled => 'bool',
394             DataTransferObfuscation => 'number',
395             DefaultSequence => 'text',
396             );
397              
398 9 50       67 for my $association (@{$autotype->{associations} || []}) {
  9         44  
399 3         12 my $association_node = $node->addNewChild(undef, 'Association');
400 3         7 $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   49 my $self = shift;
409 32         47 my $node = shift;
410 32         53 my $times = shift;
411              
412 32         97 $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   55 my $self = shift;
425 45         60 my $node = shift;
426 45         64 my $string = shift;
427              
428 45         60 my @cleanup;
429              
430 45         92 my $kdbx = $self->kdbx;
431 45         68 my $key = $string->{key};
432              
433 45         164 $node->addNewChild(undef, 'Key')->appendText(_encode_text($key));
434 45         94 my $value_node = $node->addNewChild(undef, 'Value');
435              
436 45   100     575 my $value = $string->{value} || '';
437              
438 45         128 my $memory_protection = $kdbx->meta->{memory_protection};
439 45         94 my $memprot_key = 'protect_' . snakify($key);
440 45   100     184 my $protect = $string->{protect} || $memory_protection->{$memprot_key};
441              
442 45 100       233 if ($protect) {
443 12 50       86 if ($self->allow_protection) {
444 12         20 my $encoded;
445 12 100       45 if (utf8::is_utf8($value)) {
446 6         14 $encoded = encode('UTF-8', $value);
447 6         252 push @cleanup, erase_scoped $encoded;
448 6         64 $value = $encoded;
449             }
450              
451 12         36 $value_node->setAttribute('Protected', _encode_bool(true));
452 12         223 $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         205 $value = _encode_text($value);
461             }
462              
463 45 50       220 $value_node->appendText($value) if defined $value;
464             }
465              
466             sub _write_xml_deleted_objects {
467 15     15   36 my $self = shift;
468 15         25 my $node = shift;
469              
470 15         47 my $objects = $self->kdbx->deleted_objects;
471              
472 15 50       27 for my $uuid (sort keys %{$objects || {}}) {
  15         102  
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   189 my $self = shift;
487 134         152 my $node = shift;
488 134         158 my $hash = shift;
489 134         498 my @spec = @_;
490              
491 134         263 while (@spec) {
492 1048         22427 my ($name, $type) = splice @spec, 0, 2;
493 1048         1912 my $key = snakify($name);
494              
495 1048 100       1803 if (ref $type eq 'CODE') {
496 105         386 my $child_node = $node->addNewChild(undef, $name);
497 105         320 $self->$type($child_node, $hash->{$key});
498             }
499             else {
500 943 100       1959 next if !exists $hash->{$key};
501 931         3233 my $child_node = $node->addNewChild(undef, $name);
502 931 100 100     1926 $type = 'datetime_binary' if $type eq 'datetime' && $self->compress_datetimes;
503 931         1582 $child_node->appendText(_encode_primitive($hash->{$key}, $type));
504             }
505             }
506             }
507              
508             ##############################################################################
509              
510 931     931   951 sub _encode_primitive { goto &{__PACKAGE__."::_encode_$_[1]"} }
  931         3447  
511              
512             sub _encode_binary {
513 137 100 33 137   445 return '' if !defined $_[0] || (ref $_[0] && !defined $$_[0]);
      66        
514 121 50       710 return encode_b64(ref $_[0] ? $$_[0] : $_[0]);
515             }
516              
517             sub _encode_bool {
518 166     166   281 local $_ = shift;
519 166 100       408 return $_ ? 'True' : 'False';
520             }
521              
522             sub _encode_datetime {
523 152     152   218 local $_ = shift;
524 152         371 return $_->strftime('%Y-%m-%dT%H:%M:%SZ');
525             }
526              
527             sub _encode_datetime_binary {
528 104     104   189 local $_ = shift;
529 104         290 my $seconds_since_ad1 = $_ + TIME_SECONDS_AD1_TO_UNIX_EPOCH;
530 104         5196 my $buf = pack_Ql($seconds_since_ad1->epoch);
531 104         152 return eval { encode_b64($buf) };
  104         764  
532             }
533              
534             sub _encode_tristate {
535 46   50 46   223 local $_ = shift // return 'null';
536 0 0       0 return $_ ? 'True' : 'False';
537             }
538              
539             sub _encode_number {
540 144   50 144   353 local $_ = shift // return;
541 144 50 66     516 looks_like_number($_) || isdual($_) or throw 'Expected number', text => $_;
542 144         294 return _encode_text($_+0);
543             }
544              
545             sub _encode_text {
546 438 50   438   714 return '' if !defined $_[0];
547 438         1600 return $_[0];
548             }
549              
550 115     115   248 sub _encode_uuid { _encode_binary(@_) }
551              
552             1;
553              
554             __END__