1 package File
::KDBX
::Dumper
::XML
;
2 # ABSTRACT: Dump unencrypted XML KeePass files
7 use Crypt
::Digest
qw(digest_data);
8 use Crypt
::Misc
0.029 qw(encode_b64);
10 use File
::KDBX
::Constants
qw(:version :time);
11 use File
::KDBX
::Error
;
12 use File
::KDBX
::Util
qw(:class :int erase_scoped gzip snakify);
14 use Scalar
::Util
qw(blessed isdual looks_like_number);
20 extends
'File::KDBX::Dumper';
22 our $VERSION = '999.999'; # VERSION
24 =attr allow_protection
26 $bool = $dumper->allow_protection;
28 Get whether
or not protected strings
and binaries should be written
in an encrypted stream
. Default
: C
<TRUE
>
32 $bool = $dumper->binaries;
34 Get whether
or not binaries within the database should be written
. Default
: C
<TRUE
>
36 =attr compress_binaries
38 $tristate = $dumper->compress_binaries;
40 Get whether
or not to compress binaries
. Possible
values:
43 * C<TRUE> - Always compress binaries
44 * C<FALSE> - Never compress binaries
45 * C<undef> - Compress binaries if it results in smaller database sizes (default)
47 =attr compress_datetimes
49 $bool = $dumper->compress_datetimes;
51 Get whether or not to write compressed datetimes. Datetimes are traditionally written in the human-readable
52 string format of C<1970-01-01T00:00:00Z>, but they can also be written in a compressed form to save some
53 bytes. The default is to write compressed datetimes if the KDBX file version is 4+, otherwise use the
54 human-readable format.
58 $octets = $dumper->header_hash;
60 Get the value to be written as the B<HeaderHash> in the B<Meta> section. This is the way KDBX3 files validate
61 the authenticity of header data. This is unnecessary and should not be used with KDBX4 files because that
62 format uses HMAC-SHA256 to detect tampering.
64 L<File::KDBX::Dumper::V3> automatically calculates the header hash an provides it to this module, and plain
65 XML files which don't have a KDBX wrapper don't have headers and so should not have a header hash. Therefore
66 there is probably never any reason to set this manually.
70 has allow_protection
=> 1;
71 has binaries
=> sub { $_[0]->kdbx->version < KDBX_VERSION_4_0
};
72 has 'compress_binaries';
73 has 'compress_datetimes';
75 sub header_hash
{ $_[0]->{header_hash
} }
77 sub _binaries_written
{ $_[0]->{_binaries_written
} //= {} }
79 sub _random_stream
{ $_[0]->{random_stream
} //= $_[0]->kdbx->random_stream }
85 $self->_write_inner_body($fh, $self->header_hash);
88 sub _write_inner_body
{
91 my $header_hash = shift;
93 my $dom = XML
::LibXML
::Document-
>new('1.0', 'UTF-8');
94 $dom->setStandalone(1);
96 my $doc = XML
::LibXML
::Element-
>new('KeePassFile');
97 $dom->setDocumentElement($doc);
99 my $meta = XML
::LibXML
::Element-
>new('Meta');
100 $doc->appendChild($meta);
101 $self->_write_xml_meta($meta, $header_hash);
103 my $root = XML
::LibXML
::Element-
>new('Root');
104 $doc->appendChild($root);
105 $self->_write_xml_root($root);
110 sub _write_xml_meta
{
113 my $header_hash = shift;
115 my $meta = $self->kdbx->meta;
116 local $meta->{generator
} = $self->kdbx->user_agent_string // __PACKAGE__
;
117 local $meta->{header_hash
} = $header_hash;
119 $self->_write_xml_from_pairs($node, $meta,
121 $self->kdbx->version < KDBX_VERSION_4_0
&& defined $meta->{header_hash
} ? (
122 HeaderHash
=> 'binary',
124 DatabaseName
=> 'text',
125 DatabaseNameChanged
=> 'datetime',
126 DatabaseDescription
=> 'text',
127 DatabaseDescriptionChanged
=> 'datetime',
128 DefaultUserName
=> 'text',
129 DefaultUserNameChanged
=> 'datetime',
130 MaintenanceHistoryDays
=> 'number',
132 MasterKeyChanged
=> 'datetime',
133 MasterKeyChangeRec
=> 'number',
134 MasterKeyChangeForce
=> 'number',
135 MemoryProtection
=> \
&_write_xml_memory_protection
,
136 CustomIcons
=> \
&_write_xml_custom_icons
,
137 RecycleBinEnabled
=> 'bool',
138 RecycleBinUUID
=> 'uuid',
139 RecycleBinChanged
=> 'datetime',
140 EntryTemplatesGroup
=> 'uuid',
141 EntryTemplatesGroupChanged
=> 'datetime',
142 LastSelectedGroup
=> 'uuid',
143 LastTopVisibleGroup
=> 'uuid',
144 HistoryMaxItems
=> 'number',
145 HistoryMaxSize
=> 'number',
146 $self->kdbx->version >= KDBX_VERSION_4_0
? (
147 SettingsChanged
=> 'datetime',
149 $self->kdbx->version < KDBX_VERSION_4_0
|| $self->binaries ? (
150 Binaries
=> \
&_write_xml_binaries
,
152 CustomData
=> \
&_write_xml_custom_data
,
156 sub _write_xml_memory_protection
{
160 my $memory_protection = $self->kdbx->meta->{memory_protection
};
162 $self->_write_xml_from_pairs($node, $memory_protection,
163 ProtectTitle
=> 'bool',
164 ProtectUserName
=> 'bool',
165 ProtectPassword
=> 'bool',
166 ProtectURL
=> 'bool',
167 ProtectNotes
=> 'bool',
168 # AutoEnableVisualHiding => 'bool',
172 sub _write_xml_binaries
{
176 my $kdbx = $self->kdbx;
178 my $new_ref = keys %{$self->_binaries_written};
179 my $written = $self->_binaries_written;
181 my $entries = $kdbx->entries(history
=> 1);
182 while (my $entry = $entries->next) {
183 for my $key (keys %{$entry->binaries}) {
184 my $binary = $entry->binaries->{$key};
185 if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
186 $binary = $kdbx->binaries->{$binary->{ref}};
189 if (!defined $binary->{value
}) {
190 alert
"Skipping binary which has no value: $key", key
=> $key;
194 my $hash = digest_data
('SHA256', $binary->{value
});
195 if (defined $written->{$hash}) {
199 my $binary_node = $node->addNewChild(undef, 'Binary');
200 $binary_node->setAttribute('ID', _encode_text
($new_ref));
201 $binary_node->setAttribute('Protected', _encode_bool
(true
)) if $binary->{protect
};
202 $self->_write_xml_compressed_content($binary_node, \
$binary->{value
}, $binary->{protect
});
203 $written->{$hash} = $new_ref++;
209 sub _write_xml_compressed_content
{
218 if (utf8
::is_utf8
($$value)) {
219 $encoded = encode
('UTF-8', $$value);
220 push @cleanup, erase_scoped
$encoded;
224 my $should_compress = $self->compress_binaries;
225 my $try_compress = $should_compress || !defined $should_compress;
229 $compressed = gzip
($$value);
230 push @cleanup, erase_scoped
$compressed;
232 if ($should_compress || length($compressed) < length($$value)) {
233 $value = \
$compressed;
234 $node->setAttribute('Compressed', _encode_bool
(true
));
240 $encrypted = $self->_random_stream->crypt($$value);
241 push @cleanup, erase_scoped
$encrypted;
242 $value = \
$encrypted;
245 $node->appendText(_encode_binary
($$value));
248 sub _write_xml_custom_icons
{
252 my $custom_icons = $self->kdbx->custom_icons;
254 for my $icon (@$custom_icons) {
255 $icon->{uuid
} && $icon->{data
} or next;
256 my $icon_node = $node->addNewChild(undef, 'Icon');
258 $self->_write_xml_from_pairs($icon_node, $icon,
261 KDBX_VERSION_4_1
<= $self->kdbx->version ? (
263 LastModificationTime
=> 'datetime',
269 sub _write_xml_custom_data
{
272 my $custom_data = shift || {};
274 for my $key (sort keys %$custom_data) {
275 my $item = $custom_data->{$key};
276 my $item_node = $node->addNewChild(undef, 'Item');
278 local $item->{key
} = $key if !defined $item->{key
};
280 $self->_write_xml_from_pairs($item_node, $item,
283 KDBX_VERSION_4_1
<= $self->kdbx->version ? (
284 LastModificationTime
=> 'datetime',
290 sub _write_xml_root
{
293 my $kdbx = $self->kdbx;
295 my $guard = $kdbx->unlock_scoped;
297 if (my $group = $kdbx->root) {
298 my $group_node = $node->addNewChild(undef, 'Group');
299 $self->_write_xml_group($group_node, $group->_committed);
302 undef $guard; # re-lock if needed, as early as possible
304 my $deleted_objects_node = $node->addNewChild(undef, 'DeletedObjects');
305 $self->_write_xml_deleted_objects($deleted_objects_node);
308 sub _write_xml_group
{
313 $self->_write_xml_from_pairs($node, $group,
317 KDBX_VERSION_4_1
<= $self->kdbx->version ? (
321 defined $group->{custom_icon_uuid
} ? (
322 CustomIconUUID
=> 'uuid',
324 Times
=> \
&_write_xml_times
,
325 IsExpanded
=> 'bool',
326 DefaultAutoTypeSequence
=> 'text',
327 EnableAutoType
=> 'tristate',
328 EnableSearching
=> 'tristate',
329 LastTopVisibleEntry
=> 'uuid',
330 KDBX_VERSION_4_0
<= $self->kdbx->version ? (
331 CustomData
=> \
&_write_xml_custom_data
,
333 KDBX_VERSION_4_1
<= $self->kdbx->version ? (
334 PreviousParentGroup
=> 'uuid',
338 for my $entry (@{$group->entries}) {
339 my $entry_node = $node->addNewChild(undef, 'Entry');
340 $self->_write_xml_entry($entry_node, $entry->_committed);
343 for my $group (@{$group->groups}) {
344 my $group_node = $node->addNewChild(undef, 'Group');
345 $self->_write_xml_group($group_node, $group->_committed);
349 sub _write_xml_entry
{
353 my $in_history = shift;
355 $self->_write_xml_from_pairs($node, $entry,
358 defined $entry->{custom_icon_uuid
} ? (
359 CustomIconUUID
=> 'uuid',
361 ForegroundColor
=> 'text',
362 BackgroundColor
=> 'text',
363 OverrideURL
=> 'text',
365 Times
=> \
&_write_xml_times
,
366 KDBX_VERSION_4_1
<= $self->kdbx->version ? (
367 QualityCheck
=> 'bool',
368 PreviousParentGroup
=> 'uuid',
372 for my $key (sort keys %{$entry->{strings
} || {}}) {
373 my $string = $entry->{strings
}{$key};
374 my $string_node = $node->addNewChild(undef, 'String');
375 local $string->{key
} = $string->{key
} // $key;
376 $self->_write_xml_entry_string($string_node, $string);
379 my $kdbx = $self->kdbx;
380 my $new_ref = keys %{$self->_binaries_written};
381 my $written = $self->_binaries_written;
383 for my $key (sort keys %{$entry->{binaries
} || {}}) {
384 my $binary = $entry->binaries->{$key};
385 if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
386 $binary = $kdbx->binaries->{$binary->{ref}};
389 if (!defined $binary->{value
}) {
390 alert
"Skipping binary which has no value: $key", key
=> $key;
394 my $binary_node = $node->addNewChild(undef, 'Binary');
395 $binary_node->addNewChild(undef, 'Key')->appendText(_encode_text
($key));
396 my $value_node = $binary_node->addNewChild(undef, 'Value');
398 my $hash = digest_data
('SHA256', $binary->{value
});
399 if (defined $written->{$hash}) {
401 $value_node->setAttribute('Ref', _encode_text
($written->{$hash}));
404 # write actual binary
405 $value_node->setAttribute('Protected', _encode_bool
(true
)) if $binary->{protect
};
406 $self->_write_xml_compressed_content($value_node, \
$binary->{value
}, $binary->{protect
});
407 $written->{$hash} = $new_ref++;
411 $self->_write_xml_from_pairs($node, $entry,
412 AutoType
=> \
&_write_xml_entry_auto_type
,
415 $self->_write_xml_from_pairs($node, $entry,
416 KDBX_VERSION_4_0
<= $self->kdbx->version ? (
417 CustomData
=> \
&_write_xml_custom_data
,
422 if (my @history = @{$entry->history}) {
423 my $history_node = $node->addNewChild(undef, 'History');
424 for my $historical (@history) {
425 my $historical_node = $history_node->addNewChild(undef, 'Entry');
426 $self->_write_xml_entry($historical_node, $historical->_committed, 1);
432 sub _write_xml_entry_auto_type
{
435 my $autotype = shift;
437 $self->_write_xml_from_pairs($node, $autotype,
439 DataTransferObfuscation
=> 'number',
440 DefaultSequence
=> 'text',
443 for my $association (@{$autotype->{associations
} || []}) {
444 my $association_node = $node->addNewChild(undef, 'Association');
445 $self->_write_xml_from_pairs($association_node, $association,
447 KeystrokeSequence
=> 'text',
452 sub _write_xml_times
{
457 $self->_write_xml_from_pairs($node, $times,
458 LastModificationTime
=> 'datetime',
459 CreationTime
=> 'datetime',
460 LastAccessTime
=> 'datetime',
461 ExpiryTime
=> 'datetime',
463 UsageCount
=> 'number',
464 LocationChanged
=> 'datetime',
468 sub _write_xml_entry_string
{
475 my $kdbx = $self->kdbx;
476 my $key = $string->{key
};
478 $node->addNewChild(undef, 'Key')->appendText(_encode_text
($key));
479 my $value_node = $node->addNewChild(undef, 'Value');
481 my $value = $string->{value
} || '';
483 my $memory_protection = $kdbx->meta->{memory_protection
};
484 my $memprot_key = 'protect_' . snakify
($key);
485 my $protect = $string->{protect
} || $memory_protection->{$memprot_key};
488 if ($self->allow_protection) {
490 if (utf8
::is_utf8
($value)) {
491 $encoded = encode
('UTF-8', $value);
492 push @cleanup, erase_scoped
$encoded;
496 $value_node->setAttribute('Protected', _encode_bool
(true
));
497 $value = _encode_binary
($self->_random_stream->crypt(\
$value));
500 $value_node->setAttribute('ProtectInMemory', _encode_bool
(true
));
501 $value = _encode_text
($value);
505 $value = _encode_text
($value);
508 $value_node->appendText($value) if defined $value;
511 sub _write_xml_deleted_objects
{
515 my $objects = $self->kdbx->deleted_objects;
517 for my $uuid (sort keys %{$objects || {}}) {
518 my $object = $objects->{$uuid};
519 local $object->{uuid
} = $uuid;
520 my $object_node = $node->addNewChild(undef, 'DeletedObject');
521 $self->_write_xml_from_pairs($object_node, $object,
523 DeletionTime
=> 'datetime',
528 ##############################################################################
530 sub _write_xml_from_pairs
{
537 my ($name, $type) = splice @spec, 0, 2;
538 my $key = snakify
($name);
540 if (ref $type eq 'CODE') {
541 my $child_node = $node->addNewChild(undef, $name);
542 $self->$type($child_node, $hash->{$key});
545 next if !exists $hash->{$key};
546 my $child_node = $node->addNewChild(undef, $name);
547 $type = 'datetime_binary' if $type eq 'datetime' && $self->compress_datetimes;
548 $child_node->appendText(_encode_primitive
($hash->{$key}, $type));
553 ##############################################################################
555 sub _encode_primitive
{ goto &{__PACKAGE__
."::_encode_$_[1]"} }
558 return '' if !defined $_[0] || (ref $_[0] && !defined $$_[0]);
559 return encode_b64
(ref $_[0] ? $$_[0] : $_[0]);
564 return $_ ? 'True' : 'False';
567 sub _encode_datetime
{
569 return $_->strftime('%Y-%m-%dT%H:%M:%SZ');
572 sub _encode_datetime_binary
{
574 my $seconds_since_ad1 = $_ + TIME_SECONDS_AD1_TO_UNIX_EPOCH
;
575 my $buf = pack_Ql
($seconds_since_ad1->epoch);
576 return eval { encode_b64
($buf) };
579 sub _encode_tristate
{
580 local $_ = shift // return 'null';
581 return $_ ? 'True' : 'False';
585 local $_ = shift // return;
586 looks_like_number
($_) || isdual
($_) or throw
'Expected number', text
=> $_;
587 return _encode_text
($_+0);
591 return '' if !defined $_[0];
595 sub _encode_uuid
{ _encode_binary
(@_) }