1 package File
::KDBX
::Loader
::KDB
;
2 # ABSTRACT: Read KDB files
8 use File
::KDBX
::Constants
qw(:header :cipher :random_stream :icon);
10 use File
::KDBX
::Util
qw(:class :empty :io :uuid load_optional);
12 use Ref
::Util
qw(is_arrayref is_hashref);
13 use Scalar
::Util
qw(looks_like_number);
18 extends
'File::KDBX::Loader';
20 our $VERSION = '0.902'; # VERSION
22 my $DEFAULT_EXPIRATION = Time
::Piece-
>new(32503677839); # 2999-12-31 23:59:59
24 sub _read_headers
{ '' }
32 load_optional
('File::KeePass');
34 $buf .= do { local $/; <$fh> };
36 $key = $self->kdbx->composite_key($key, keep_primitive
=> 1);
38 my $k = eval { File
::KeePass-
>new->parse_db(\
$buf, _convert_kdbx_to_keepass_master_key
($key)) };
40 throw
'Failed to parse KDB file', error
=> $err;
44 $self->kdbx->key($key);
46 return convert_keepass_to_kdbx
($k, $self->kdbx);
49 # This is also used by File::KDBX::Dumper::KDB.
50 sub _convert_kdbx_to_keepass_master_key
{
53 my @keys = @{$key->keys};
54 if (@keys == 1 && !$keys[0]->can('filepath')) {
55 return [encode
('CP-1252', $keys[0]->{primitive
})]; # just a password
58 return [undef, \
$keys[0]->raw_key]; # just a keyfile
60 elsif (@keys == 2 && !$keys[0]->can('filepath') && $keys[1]->can('filepath')) {
61 return [encode
('CP-1252', $keys[0]->{primitive
}), \
$keys[1]->raw_key];
63 throw
'Cannot use this key to load a KDB file', key
=> $key;
67 sub convert_keepass_to_kdbx
{
69 my $kdbx = shift // File
::KDBX-
>new;
71 $kdbx->{headers
} //= {};
72 _convert_keepass_to_kdbx_headers
($k->{header
}, $kdbx);
74 my @groups = @{$k->{groups
} || []};
76 $kdbx->{root
} = _convert_keepass_to_kdbx_group
($k->{groups
}[0]);
79 my $root = $kdbx->{root
} = {%{File
::KDBX-
>_implicit_root}};
80 for my $group (@groups) {
81 push @{$root->{groups
} //= []}, _convert_keepass_to_kdbx_group
($group);
94 _read_meta_stream
($kdbx, $_);
95 $_->remove(signal
=> 0);
101 sub _read_meta_stream
{
105 my $type = $entry->notes;
106 my $data = $entry->binary_value('bin-stream');
107 open(my $fh, '<', \
$data) or throw
"Failed to open memory buffer for reading: $!";
109 if ($type eq 'KPX_GROUP_TREE_STATE') {
110 read_all
$fh, my $buf, 4 or goto PARSE_ERROR
;
111 my ($num) = unpack('L<', $buf);
112 $num * 5 + 4 == length($data) or goto PARSE_ERROR
;
113 for (my $i = 0; $i < $num; ++$i) {
114 read_all
$fh, $buf, 5 or goto PARSE_ERROR
;
115 my ($group_id, $expanded) = unpack('L< C', $buf);
116 my $uuid = _decode_uuid
($group_id) // next;
117 my $group = $kdbx->groups->grep({uuid
=> $uuid})->next;
118 $group->is_expanded($expanded) if $group;
121 elsif ($type eq 'KPX_CUSTOM_ICONS_4') {
122 read_all
$fh, my $buf, 12 or goto PARSE_ERROR
;
123 my ($num_icons, $num_entries, $num_groups) = unpack('L<3', $buf);
125 for (my $i = 0; $i < $num_icons; ++$i) {
126 read_all
$fh, $buf, 4 or goto PARSE_ERROR
;
127 my ($icon_size) = unpack('L<', $buf);
128 read_all
$fh, $buf, $icon_size or goto PARSE_ERROR
;
129 my $uuid = $kdbx->add_custom_icon($buf);
132 for (my $i = 0; $i < $num_entries; ++$i) {
133 read_all
$fh, $buf, 20 or goto PARSE_ERROR
;
134 my ($uuid, $icon_index) = unpack('a16 L<', $buf);
135 next if !$icons[$icon_index];
136 my $entry = $kdbx->entries->grep({uuid
=> $uuid})->next;
137 $entry->custom_icon_uuid($icons[$icon_index]) if $entry;
139 for (my $i = 0; $i < $num_groups; ++$i) {
140 read_all
$fh, $buf, 8 or goto PARSE_ERROR
;
141 my ($group_id, $icon_index) = unpack('L<2', $buf);
142 next if !$icons[$icon_index];
143 my $uuid = _decode_uuid
($group_id) // next;
144 my $group = $kdbx->groups->grep({uuid
=> $uuid})->next;
145 $group->custom_icon_uuid($icons[$icon_index]) if $group;
149 alert
"Ignoring unknown meta stream: $type\n", type
=> $type;
156 alert
"Ignoring unparsable meta stream: $type\n", type
=> $type;
159 sub _convert_keepass_to_kdbx_headers
{
163 my $headers = $kdbx->{headers
} //= {};
164 my $meta = $kdbx->{meta
} //= {};
166 $kdbx->{sig1
} = $from->{sig1
};
167 $kdbx->{sig2
} = $from->{sig2
};
168 $kdbx->{version
} = $from->{vers
};
171 rijndael
=> CIPHER_UUID_AES256
,
172 aes
=> CIPHER_UUID_AES256
,
173 twofish
=> CIPHER_UUID_TWOFISH
,
174 chacha20
=> CIPHER_UUID_CHACHA20
,
175 salsa20
=> CIPHER_UUID_SALSA20
,
176 serpent
=> CIPHER_UUID_SERPENT
,
178 my $cipher_uuid = $enc_type{$from->{cipher
} || ''} // $enc_type{$from->{enc_type
} || ''};
180 my %protected_stream = (
181 rc4
=> STREAM_ID_RC4_VARIANT
,
182 salsa20
=> STREAM_ID_SALSA20
,
183 chacha20
=> STREAM_ID_CHACHA20
,
185 my $protected_stream_id = $protected_stream{$from->{protected_stream
} || ''} || STREAM_ID_SALSA20
;
187 $headers->{+HEADER_COMMENT
} = $from->{comment
};
188 $headers->{+HEADER_CIPHER_ID
} = $cipher_uuid if $cipher_uuid;
189 $headers->{+HEADER_MASTER_SEED
} = $from->{seed_rand
};
190 $headers->{+HEADER_COMPRESSION_FLAGS
} = $from->{compression
} // 0;
191 $headers->{+HEADER_TRANSFORM_SEED
} = $from->{seed_key
};
192 $headers->{+HEADER_TRANSFORM_ROUNDS
} = $from->{rounds
};
193 $headers->{+HEADER_ENCRYPTION_IV
} = $from->{enc_iv
};
194 $headers->{+HEADER_INNER_RANDOM_STREAM_ID
} = $protected_stream_id;
195 $headers->{+HEADER_INNER_RANDOM_STREAM_KEY
} = $from->{protected_stream_key
};
196 $headers->{+HEADER_STREAM_START_BYTES
} = $from->{start_bytes
} // '';
198 # TODO for KeePass 1 files these are all not available. Leave undefined or set default values?
199 $meta->{memory_protection
}{protect_notes
} = boolean
($from->{protect_notes
});
200 $meta->{memory_protection
}{protect_password
} = boolean
($from->{protect_password
});
201 $meta->{memory_protection
}{protect_username
} = boolean
($from->{protect_username
});
202 $meta->{memory_protection
}{protect_url
} = boolean
($from->{protect_url
});
203 $meta->{memory_protection
}{protect_title
} = boolean
($from->{protect_title
});
204 $meta->{generator
} = $from->{generator
} // '';
205 $meta->{header_hash
} = $from->{header_hash
};
206 $meta->{database_name
} = $from->{database_name
} // '';
207 $meta->{database_name_changed
} = _decode_datetime
($from->{database_name_changed
});
208 $meta->{database_description
} = $from->{database_description
} // '';
209 $meta->{database_description_changed
} = _decode_datetime
($from->{database_description_changed
});
210 $meta->{default_username
} = $from->{default_user_name
} // '';
211 $meta->{default_username_changed
} = _decode_datetime
($from->{default_user_name_changed
});
212 $meta->{maintenance_history_days
} = $from->{maintenance_history_days
};
213 $meta->{color
} = $from->{color
};
214 $meta->{master_key_changed
} = _decode_datetime
($from->{master_key_changed
});
215 $meta->{master_key_change_rec
} = $from->{master_key_change_rec
};
216 $meta->{master_key_change_force
} = $from->{master_key_change_force
};
217 $meta->{recycle_bin_enabled
} = boolean
($from->{recycle_bin_enabled
});
218 $meta->{recycle_bin_uuid
} = $from->{recycle_bin_uuid
};
219 $meta->{recycle_bin_changed
} = _decode_datetime
($from->{recycle_bin_changed
});
220 $meta->{entry_templates_group
} = $from->{entry_templates_group
};
221 $meta->{entry_templates_group_changed
} = _decode_datetime
($from->{entry_templates_group_changed
});
222 $meta->{last_selected_group
} = $from->{last_selected_group
};
223 $meta->{last_top_visible_group
} = $from->{last_top_visible_group
};
224 $meta->{history_max_items
} = $from->{history_max_items
};
225 $meta->{history_max_size
} = $from->{history_max_size
};
226 $meta->{settings_changed
} = _decode_datetime
($from->{settings_changed
});
228 while (my ($key, $value) = each %{$from->{custom_icons
} || {}}) {
229 push @{$meta->{custom_icons
} //= []}, {uuid
=> $key, data
=> $value};
231 while (my ($key, $value) = each %{$from->{custom_data
} || {}}) {
232 $meta->{custom_data
}{$key} = {value
=> $value};
238 sub _convert_keepass_to_kdbx_group
{
240 my $to = shift // {};
243 $to->{times}{last_access_time
} = _decode_datetime
($from->{accessed
});
244 $to->{times}{usage_count
} = $from->{usage_count
} || 0;
245 $to->{times}{expiry_time
} = _decode_datetime
($from->{expires
}, $DEFAULT_EXPIRATION);
246 $to->{times}{expires
} = defined $from->{expires_enabled
}
247 ? boolean
($from->{expires_enabled
})
248 : boolean
($to->{times}{expiry_time
} <= gmtime);
249 $to->{times}{creation_time
} = _decode_datetime
($from->{created
});
250 $to->{times}{last_modification_time
} = _decode_datetime
($from->{modified
});
251 $to->{times}{location_changed
} = _decode_datetime
($from->{location_changed
});
252 $to->{notes
} = $from->{notes
} // '';
253 $to->{uuid
} = _decode_uuid
($from->{id
});
254 $to->{is_expanded
} = boolean
($from->{expanded
});
255 $to->{icon_id
} = $from->{icon
} // ICON_FOLDER
;
256 $to->{name
} = $from->{title
} // '';
257 $to->{default_auto_type_sequence
} = $from->{auto_type_default
} // '';
258 $to->{enable_auto_type
} = _decode_tristate
($from->{auto_type_enabled
});
259 $to->{enable_searching
} = _decode_tristate
($from->{enable_searching
});
263 if (!$args{shallow
}) {
264 for my $group (@{$from->{groups
} || []}) {
265 push @{$to->{groups
}}, _convert_keepass_to_kdbx_group
($group);
267 for my $entry (@{$from->{entries
} || []}) {
268 push @{$to->{entries
}}, _convert_keepass_to_kdbx_entry
($entry);
275 sub _convert_keepass_to_kdbx_entry
{
277 my $to = shift // {};
280 $to->{times}{last_access_time
} = _decode_datetime
($from->{accessed
});
281 $to->{times}{usage_count
} = $from->{usage_count
} || 0;
282 $to->{times}{expiry_time
} = _decode_datetime
($from->{expires
}, $DEFAULT_EXPIRATION);
283 $to->{times}{expires
} = defined $from->{expires_enabled
}
284 ? boolean
($from->{expires_enabled
})
285 : boolean
($to->{times}{expiry_time
} <= gmtime);
286 $to->{times}{creation_time
} = _decode_datetime
($from->{created
});
287 $to->{times}{last_modification_time
} = _decode_datetime
($from->{modified
});
288 $to->{times}{location_changed
} = _decode_datetime
($from->{location_changed
});
290 $to->{auto_type
}{data_transfer_obfuscation
} = $from->{auto_type_munge
} || false
;
291 $to->{auto_type
}{enabled
} = boolean
($from->{auto_type_enabled
} // 1);
293 my $comment = $from->{comment
};
294 my @auto_type = is_arrayref
($from->{auto_type
}) ? @{$from->{auto_type
}} : ();
296 if (!@auto_type && nonempty
$from->{auto_type
} && nonempty
$from->{auto_type_window
}
297 && !is_hashref
($from->{auto_type
})) {
298 @auto_type = ({window
=> $from->{auto_type_window
}, keys => $from->{auto_type
}});
300 if (nonempty
$comment) {
302 my %atw = my @atw = $comment =~ m{ ^Auto-Type-Window((?:-?\d+)?): [\t ]* (.*?) [\t ]*$ }mxg;
303 my %atk = my @atk = $comment =~ m{ ^Auto-Type((?:-?\d+)?): [\t ]* (.*?) [\t ]*$ }mxg;
304 $comment =~ s{ ^Auto-Type(?:-Window)?(?:-?\d+)?: .* \n? }{}mxg;
306 my ($n, $w) = (shift(@atw), shift(@atw));
307 push @AT, {window
=> $w, keys => exists($atk{$n}) ? $atk{$n} : $atk{''}};
310 my ($n, $k) = (shift(@atk), shift(@atk));
311 push @AT, {keys => $k, window
=> exists($atw{$n}) ? $atw{$n} : $atw{''}};
314 $_->{'window'} //= '';
318 @AT = grep {!$uniq{"$_->{'window'}\e$_->{'keys'}"}++} @AT;
319 push @auto_type, @AT;
321 $to->{auto_type
}{associations
} = [
322 map { +{window
=> $_->{window
}, keystroke_sequence
=> $_->{keys}} } @auto_type,
325 $to->{strings
}{Notes
}{value
} = $comment;
326 $to->{strings
}{UserName
}{value
} = $from->{username
};
327 $to->{strings
}{Password
}{value
} = $from->{password
};
328 $to->{strings
}{URL
}{value
} = $from->{url
};
329 $to->{strings
}{Title
}{value
} = $from->{title
};
330 $to->{strings
}{Notes
}{protect
} = true
if defined $from->{protected
}{comment
};
331 $to->{strings
}{UserName
}{protect
} = true
if defined $from->{protected
}{username
};
332 $to->{strings
}{Password
}{protect
} = true
if $from->{protected
}{password
} // 1;
333 $to->{strings
}{URL
}{protect
} = true
if defined $from->{protected
}{url
};
334 $to->{strings
}{Title
}{protect
} = true
if defined $from->{protected
}{title
};
337 while (my ($key, $value) = each %{$from->{strings
} || {}}) {
338 $to->{strings
}{$key} = {
340 $from->{protected
}{$key} ? (protect
=> true
) : (),
344 $to->{override_url
} = $from->{override_url
};
345 $to->{tags
} = $from->{tags
} // '';
346 $to->{icon_id
} = $from->{icon
} // ICON_PASSWORD
;
347 $to->{uuid
} = _decode_uuid
($from->{id
});
348 $to->{foreground_color
} = $from->{foreground_color
} // '';
349 $to->{background_color
} = $from->{background_color
} // '';
350 $to->{custom_icon_uuid
} = $from->{custom_icon_uuid
};
353 local $from->{binary
} = {$from->{binary_name
} => $from->{binary
}}
354 if nonempty
$from->{binary
} && nonempty
$from->{binary_name
} && !is_hashref
($from->{binary
});
355 while (my ($key, $value) = each %{$from->{binary
} || {}}) {
356 $to->{binaries
}{$key} = {value
=> $value};
359 if (!$args{shallow
}) {
360 for my $entry (@{$from->{history
} || []}) {
362 push @{$to->{entries
}}, _convert_keepass_to_kdbx_entry
($entry, $new_entry);
369 sub _decode_datetime
{
370 local $_ = shift // return shift // gmtime;
371 return Time
::Piece-
>strptime($_, '%Y-%m-%d %H:%M:%S');
375 local $_ = shift // return;
376 # Group IDs in KDB files are 32-bit integers
377 return sprintf('%016x', $_) if length($_) != 16 && looks_like_number
($_);
381 sub _decode_tristate
{
382 local $_ = shift // return;
396 File::KDBX::Loader::KDB - Read KDB files
404 Read older KDB (KeePass 1) files. This feature requires an additional module to be installed:
416 =head2 convert_keepass_to_kdbx
418 $kdbx = convert_keepass_to_kdbx($keepass);
419 $kdbx = convert_keepass_to_kdbx($keepass, $kdbx);
421 Convert a L<File::KeePass> to a L<File::KDBX>.
425 Please report any bugs or feature requests on the bugtracker website
426 L<https://github.com/chazmcgarvey/File-KDBX/issues>
428 When submitting a bug or request, please include a test-file or a
429 patch to an existing test-file that illustrates the bug or desired
434 Charles McGarvey <ccm@cpan.org>
436 =head1 COPYRIGHT AND LICENSE
438 This software is copyright (c) 2022 by Charles McGarvey.
440 This is free software; you can redistribute it and/or modify it under
441 the same terms as the Perl 5 programming language system itself.