File Coverage

blib/lib/File/KDBX/Dumper/V4.pm
Criterion Covered Total %
statement 165 197 83.7
branch 57 106 53.7
condition 15 39 38.4
subroutine 23 24 95.8
pod n/a
total 260 366 71.0


line stmt bran cond sub pod time code
1             package File::KDBX::Dumper::V4;
2             # ABSTRACT: Dump KDBX4 files
3              
4 1     1   514 use warnings;
  1         2  
  1         32  
5 1     1   5 use strict;
  1         2  
  1         23  
6              
7 1     1   5 use Crypt::Digest qw(digest_data);
  1         2  
  1         43  
8 1     1   5 use Crypt::Mac::HMAC qw(hmac);
  1         1  
  1         48  
9 1     1   5 use Encode qw(encode is_utf8);
  1         2  
  1         39  
10 1     1   5 use File::KDBX::Constants qw(:header :inner_header :compression :kdf :variant_map);
  1         2  
  1         267  
11 1     1   7 use File::KDBX::Error;
  1         2  
  1         37  
12 1     1   4 use File::KDBX::IO::Crypt;
  1         2  
  1         34  
13 1     1   6 use File::KDBX::IO::HmacBlock;
  1         1  
  1         37  
14 1     1   4 use File::KDBX::Util qw(:class :empty :int :load erase_scoped);
  1         3  
  1         155  
15 1     1   6 use IO::Handle;
  1         2  
  1         28  
16 1     1   5 use Scalar::Util qw(looks_like_number);
  1         2  
  1         32  
17 1     1   4 use boolean qw(:all);
  1         8  
  1         8  
18 1     1   95 use namespace::clean;
  1         2  
  1         6  
19              
20             extends 'File::KDBX::Dumper';
21 7 50   7   20  
22             our $VERSION = '0.905'; # VERSION
23 7   50     29  
24             has _binaries_written => {}, is => 'ro';
25              
26             sub _write_headers {
27 7     7   10 my $self = shift;
28 7         9 my $fh = shift;
29              
30 7         13 my $kdbx = $self->kdbx;
31 7         17 my $headers = $kdbx->headers;
32 7         11 my $buf = '';
33              
34             # Always write the standard AES KDF UUID, for compatibility
35             local $headers->{+HEADER_KDF_PARAMETERS}->{+KDF_PARAM_UUID} = KDF_UUID_AES
36 7 50       21 if $headers->{+HEADER_KDF_PARAMETERS}->{+KDF_PARAM_UUID} eq KDF_UUID_AES_CHALLENGE_RESPONSE;
37              
38 7 50       22 if (nonempty (my $comment = $headers->{+HEADER_COMMENT})) {
39 0         0 $buf .= $self->_write_header($fh, HEADER_COMMENT, $comment);
40             }
41 7         16 for my $type (
42             HEADER_CIPHER_ID,
43             HEADER_COMPRESSION_FLAGS,
44             HEADER_MASTER_SEED,
45             HEADER_ENCRYPTION_IV,
46             HEADER_KDF_PARAMETERS,
47             ) {
48 35 50       69 defined $headers->{$type} or throw "Missing value for required header: $type", type => $type;
49 35         61 $buf .= $self->_write_header($fh, $type, $headers->{$type});
50             }
51             $buf .= $self->_write_header($fh, HEADER_PUBLIC_CUSTOM_DATA, $headers->{+HEADER_PUBLIC_CUSTOM_DATA})
52 7 100 66     21 if defined $headers->{+HEADER_PUBLIC_CUSTOM_DATA} && keys %{$headers->{+HEADER_PUBLIC_CUSTOM_DATA}};
  7         27  
53 7         18 $buf .= $self->_write_header($fh, HEADER_END);
54              
55 7         16 return $buf;
56             }
57              
58             sub _write_header {
59 44     44   50 my $self = shift;
60 44         43 my $fh = shift;
61 44         48 my $type = shift;
62 44   100     77 my $val = shift // '';
63              
64 44         78 $type = to_header_constant($type);
65 44 100 0     119 if ($type == HEADER_END) {
    50 0        
    100 0        
    100 0        
    100          
    100          
    100          
    50          
    0          
    0          
66             # nothing
67             }
68             elsif ($type == HEADER_COMMENT) {
69 0         0 $val = encode('UTF-8', $val);
70             }
71             elsif ($type == HEADER_CIPHER_ID) {
72 7         12 my $size = length($val);
73 7 50       14 $size == 16 or throw 'Invalid cipher UUID length', got => $size, expected => $size;
74             }
75             elsif ($type == HEADER_COMPRESSION_FLAGS) {
76 7         14 $val = pack('L<', $val);
77             }
78             elsif ($type == HEADER_MASTER_SEED) {
79 7         10 my $size = length($val);
80 7 50       15 $size == 32 or throw 'Invalid master seed length', got => $size, expected => $size;
81             }
82             elsif ($type == HEADER_ENCRYPTION_IV) {
83             # nothing
84             }
85             elsif ($type == HEADER_KDF_PARAMETERS) {
86 7         45 $val = $self->_write_variant_dictionary($val, {
87             KDF_PARAM_UUID() => VMAP_TYPE_BYTEARRAY,
88             KDF_PARAM_AES_ROUNDS() => VMAP_TYPE_UINT64,
89             KDF_PARAM_AES_SEED() => VMAP_TYPE_BYTEARRAY,
90             KDF_PARAM_ARGON2_SALT() => VMAP_TYPE_BYTEARRAY,
91             KDF_PARAM_ARGON2_PARALLELISM() => VMAP_TYPE_UINT32,
92             KDF_PARAM_ARGON2_MEMORY() => VMAP_TYPE_UINT64,
93             KDF_PARAM_ARGON2_ITERATIONS() => VMAP_TYPE_UINT64,
94             KDF_PARAM_ARGON2_VERSION() => VMAP_TYPE_UINT32,
95             KDF_PARAM_ARGON2_SECRET() => VMAP_TYPE_BYTEARRAY,
96             KDF_PARAM_ARGON2_ASSOCDATA() => VMAP_TYPE_BYTEARRAY,
97             });
98             }
99             elsif ($type == HEADER_PUBLIC_CUSTOM_DATA) {
100 2         6 $val = $self->_write_variant_dictionary($val);
101             }
102             elsif ($type == HEADER_INNER_RANDOM_STREAM_ID ||
103             $type == HEADER_INNER_RANDOM_STREAM_KEY ||
104             $type == HEADER_TRANSFORM_SEED ||
105             $type == HEADER_TRANSFORM_ROUNDS ||
106             $type == HEADER_STREAM_START_BYTES) {
107 0         0 throw "Unexpected KDBX3 header: $type", type => $type;
108             }
109             elsif ($type == HEADER_COMMENT) {
110 0         0 throw "Unexpected KDB header: $type", type => $type;
111             }
112             else {
113 0         0 alert "Unknown header: $type", type => $type;
114             }
115              
116 44         60 my $size = length($val);
117 44         77 my $buf = pack('C L<', 0+$type, $size);
118              
119 44 50       77 $fh->print($buf, $val) or throw 'Failed to write header';
120              
121 44         312 return "$buf$val";
122             }
123              
124             sub _intuit_variant_type {
125 5     5   8 my $self = shift;
126 5         6 my $variant = shift;
127              
128 5 100 66     12 if (isBoolean($variant)) {
    100          
    100          
129 1         23 return VMAP_TYPE_BOOL;
130             }
131             elsif (looks_like_number($variant) && ($variant + 0) =~ /^\d+$/) {
132 1         22 my $neg = $variant < 0;
133 1         3 my @b = unpack('L>2', scalar reverse pack_Ql($variant));
134 1 50 33     5 return VMAP_TYPE_INT64 if $b[0] && $neg;
135 1 50       2 return VMAP_TYPE_UINT64 if $b[0];
136 1 50       3 return VMAP_TYPE_INT32 if $neg;
137 1         4 return VMAP_TYPE_UINT32;
138             }
139             elsif (is_utf8($variant)) {
140 1         15 return VMAP_TYPE_STRING;
141             }
142 2         41 return VMAP_TYPE_BYTEARRAY;
143             }
144              
145             sub _write_variant_dictionary {
146 9     9   16 my $self = shift;
147 9   50     17 my $dict = shift || {};
148 9   100     19 my $types = shift || {};
149              
150 9         15 my $buf = '';
151              
152 9         14 $buf .= pack('S<', VMAP_VERSION);
153              
154 9         39 for my $key (sort keys %$dict) {
155 32         48 my $val = $dict->{$key};
156              
157 32   66     52 my $type = $types->{$key} // $self->_intuit_variant_type($val);
158 32         48 $buf .= pack('C', $type);
159              
160 32 100       88 if ($type == VMAP_TYPE_UINT32) {
    100          
    100          
    50          
    50          
    100          
    50          
161 5         8 $val = pack('L<', $val);
162             }
163             elsif ($type == VMAP_TYPE_UINT64) {
164 9         20 $val = pack_Ql($val);
165             }
166             elsif ($type == VMAP_TYPE_BOOL) {
167 1         3 $val = pack('C', $val);
168             }
169             elsif ($type == VMAP_TYPE_INT32) {
170 0         0 $val = pack('l', $val);
171             }
172             elsif ($type == VMAP_TYPE_INT64) {
173 0         0 $val = pack_ql($val);
174             }
175             elsif ($type == VMAP_TYPE_STRING) {
176 1         3 $val = encode('UTF-8', $val);
177             }
178             elsif ($type == VMAP_TYPE_BYTEARRAY) {
179             # $val = substr($$buf, $pos, $vlen);
180             # $val = [split //, $val];
181             }
182             else {
183 0         0 throw 'Unknown variant dictionary value type', type => $type;
184             }
185              
186 32         94 my ($klen, $vlen) = (length($key), length($val));
187 32         99 $buf .= pack("L< a$klen L< a$vlen", $klen, $key, $vlen, $val);
188             }
189              
190 9         16 $buf .= pack('C', VMAP_TYPE_END);
191              
192 9         18 return $buf;
193             }
194              
195             sub _write_body {
196 7     7   9 my $self = shift;
197 7         9 my $fh = shift;
198 7         13 my $key = shift;
199 7         8 my $header_data = shift;
200 7         15 my $kdbx = $self->kdbx;
201              
202             # assert all required headers present
203 7         16 for my $field (
204             HEADER_CIPHER_ID,
205             HEADER_ENCRYPTION_IV,
206             HEADER_MASTER_SEED,
207             ) {
208 21 50       34 defined $kdbx->headers->{$field} or throw "Missing header: $field";
209             }
210              
211 7         11 my @cleanup;
212              
213             # write 32-byte checksum
214 7         75 my $header_hash = digest_data('SHA256', $header_data);
215 7 50       17 $fh->print($header_hash) or throw 'Failed to write header hash';
216              
217 7         51 $key = $kdbx->composite_key($key);
218 7         17 my $transformed_key = $kdbx->kdf->transform($key);
219 7         28 push @cleanup, erase_scoped $transformed_key;
220              
221             # write 32-byte HMAC for header
222 7         77 my $hmac_key = digest_data('SHA512', $kdbx->headers->{master_seed}, $transformed_key, "\x01");
223 7         17 push @cleanup, erase_scoped $hmac_key;
224 7         124 my $header_hmac = hmac('SHA256',
225             digest_data('SHA512', "\xff\xff\xff\xff\xff\xff\xff\xff", $hmac_key),
226             $header_data,
227             );
228 7 50       25 $fh->print($header_hmac) or throw 'Failed to write header HMAC';
229              
230 7         62 $kdbx->key($key);
231              
232             # HMAC-block the rest of the stream
233 7         46 $fh = File::KDBX::IO::HmacBlock->new($fh, key => $hmac_key);
234              
235 7         18 my $final_key = digest_data('SHA256', $kdbx->headers->{master_seed}, $transformed_key);
236 7         21 push @cleanup, erase_scoped $final_key;
237              
238 7         76 my $cipher = $kdbx->cipher(key => $final_key);
239 7         36 $fh = File::KDBX::IO::Crypt->new($fh, cipher => $cipher);
240              
241 7         19 my $compress = $kdbx->headers->{+HEADER_COMPRESSION_FLAGS};
242 7 50       17 if ($compress == COMPRESSION_GZIP) {
    0          
243 7         20 load_optional('IO::Compress::Gzip');
244 7 50       25 $fh = IO::Compress::Gzip->new($fh,
245             -Level => IO::Compress::Gzip::Z_BEST_COMPRESSION(),
246             -TextFlag => 1,
247             ) or throw "Failed to initialize compression library: $IO::Compress::Gzip::GzipError",
248             error => $IO::Compress::Gzip::GzipError;
249             }
250             elsif ($compress != COMPRESSION_NONE) {
251 0         0 throw "Unsupported compression ($compress)\n", compression_flags => $compress;
252             }
253              
254 7         178 $self->_write_inner_headers($fh);
255              
256 7         103 local $self->{compress_datetimes} = 1;
257 7         24 $self->_write_inner_body($fh, $header_hash);
258             }
259              
260             sub _write_inner_headers {
261 7     7   11 my $self = shift;
262 7         9 my $fh = shift;
263              
264 7         17 my $kdbx = $self->kdbx;
265 7         20 my $headers = $kdbx->inner_headers;
266              
267 7         22 for my $type (
268             INNER_HEADER_INNER_RANDOM_STREAM_ID,
269             INNER_HEADER_INNER_RANDOM_STREAM_KEY,
270             ) {
271 14 50       398 defined $headers->{$type} or throw "Missing inner header: $type";
272 14         31 $self->_write_inner_header($fh, $type => $headers->{$type});
273             }
274              
275 7         352 $self->_write_binaries($fh);
276              
277 7         20 $self->_write_inner_header($fh, INNER_HEADER_END);
278             }
279              
280             sub _write_inner_header {
281 21     21   33 my $self = shift;
282 21         23 my $fh = shift;
283 21         27 my $type = shift;
284 21   100     43 my $val = shift // '';
285              
286 21         42 my $buf = pack('C', $type);
287 21 50       57 $fh->print($buf) or throw 'Failed to write inner header type';
288              
289 21         1259 $type = to_inner_header_constant($type);
290 21 100       51 if ($type == INNER_HEADER_END) {
    100          
    50          
    0          
291             # nothing
292             }
293             elsif ($type == INNER_HEADER_INNER_RANDOM_STREAM_ID) {
294 7         16 $val = pack('L<', $val);
295             }
296             elsif ($type == INNER_HEADER_INNER_RANDOM_STREAM_KEY) {
297             # nothing
298             }
299             elsif ($type == INNER_HEADER_BINARY) {
300             # nothing
301             }
302              
303 21         41 $buf = pack('L<', length($val));
304 21 50       37 $fh->print($buf) or throw 'Failed to write inner header value size';
305 21 50       1052 $fh->print($val) or throw 'Failed to write inner header value';
306             }
307              
308             sub _write_binaries {
309 7     7   11 my $self = shift;
310 7         7 my $fh = shift;
311              
312 7         16 my $kdbx = $self->kdbx;
313              
314 7         13 my $new_ref = 0;
315 7         16 my $written = $self->_binaries_written;
316              
317 7         22 my $entries = $kdbx->entries(history => 1);
318 7         46 while (my $entry = $entries->next) {
319 2         3 for my $key (keys %{$entry->binaries}) {
  2         5  
320 0           my $binary = $entry->binaries->{$key};
321 0 0 0       if (defined $binary->{ref} && defined $kdbx->binaries->{$binary->{ref}}) {
322 0           $binary = $kdbx->binaries->{$binary->{ref}};
323             }
324              
325 0 0         if (!defined $binary->{value}) {
326 0           alert "Skipping binary which has no value: $key", key => $key;
327 0           next;
328             }
329              
330 0           my $hash = digest_data('SHA256', $binary->{value});
331 0 0         if (defined $written->{$hash}) {
332             # nothing
333             }
334             else {
335 0           my $flags = 0;
336 0 0         $flags &= INNER_HEADER_BINARY_FLAG_PROTECT if $binary->{protect};
337              
338 0           $self->_write_binary($fh, \$binary->{value}, $flags);
339 0           $written->{$hash} = $new_ref++;
340             }
341             }
342             }
343             }
344              
345             sub _write_binary {
346 0     0     my $self = shift;
347 0           my $fh = shift;
348 0           my $data = shift;
349 0   0       my $flags = shift || 0;
350              
351 0           my $buf = pack('C', 0 + INNER_HEADER_BINARY);
352 0 0         $fh->print($buf) or throw 'Failed to write inner header type';
353              
354 0           $buf = pack('L<', 1 + length($$data));
355 0 0         $fh->print($buf) or throw 'Failed to write inner header value size';
356              
357 0           $buf = pack('C', $flags);
358 0 0         $fh->print($buf) or throw 'Failed to write inner header binary flags';
359              
360 0 0         $fh->print($$data) or throw 'Failed to write inner header value';
361             }
362              
363             1;
364              
365             __END__