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