File Coverage

blib/lib/File/KDBX/Key/File.pm
Criterion Covered Total %
statement 173 181 95.5
branch 56 82 68.2
condition 38 74 51.3
subroutine 21 21 100.0
pod 7 7 100.0
total 295 365 80.8


line stmt bran cond sub pod time code
1             package File::KDBX::Key::File;
2             # ABSTRACT: A file key
3              
4 2     2   944 use warnings;
  2         7  
  2         83  
5 2     2   13 use strict;
  2         4  
  2         45  
6              
7 2     2   10 use Crypt::Digest qw(digest_data);
  2         3  
  2         76  
8 2     2   11 use Crypt::Misc 0.029 qw(decode_b64 encode_b64);
  2         42  
  2         74  
9 2     2   9 use Crypt::PRNG qw(random_bytes);
  2         5  
  2         58  
10 2     2   9 use File::KDBX::Constants qw(:key_file);
  2         11  
  2         202  
11 2     2   12 use File::KDBX::Error;
  2         3  
  2         91  
12 2     2   11 use File::KDBX::Util qw(:class :erase trim);
  2         5  
  2         222  
13 2     2   13 use Ref::Util qw(is_ref is_scalarref);
  2         3  
  2         80  
14 2     2   10 use Scalar::Util qw(openhandle);
  2         4  
  2         72  
15 2     2   369 use XML::LibXML::Reader;
  2         28184  
  2         203  
16 2     2   12 use namespace::clean;
  2         6  
  2         11  
17              
18             extends 'File::KDBX::Key';
19              
20 21 50   21 1 68 our $VERSION = '0.906'; # VERSION
21 9 50   9 1 24  
22 21 50 50 7 1 88  
  7         21  
23 9   100     41 has 'type', is => 'ro';
24 7   100     22 has 'version', is => 'ro';
25             has 'filepath', is => 'ro';
26              
27              
28 37     37 1 620 sub init { shift->load(@_) }
29              
30             sub load {
31 37     37 1 58 my $self = shift;
32 37   66     98 my $primitive = shift // throw 'Missing key primitive';
33              
34 36         57 my $data;
35             my $cleanup;
36              
37 36 100 66     155 if (openhandle($primitive)) {
    100          
    100          
38 1         6 seek $primitive, 0, 0; # not using ->seek method so it works on perl 5.10
39 1         2 my $buf = do { local $/; <$primitive> };
  1         4  
  1         6  
40 1         2 $data = \$buf;
41 1         4 $cleanup = erase_scoped $data;
42             }
43             elsif (is_scalarref($primitive)) {
44 20         27 $data = $primitive;
45             }
46             elsif (defined $primitive && !is_ref($primitive)) {
47 14 100       539 open(my $fh, '<:raw', $primitive)
48             or throw "Failed to open key file ($primitive)", filepath => $primitive;
49 13         35 my $buf = do { local $/; <$fh> };
  13         52  
  13         435  
50 13         34 $data = \$buf;
51 13         43 $cleanup = erase_scoped $data;
52 13         297 $self->{filepath} = $primitive;
53             }
54             else {
55 1         6 throw 'Unexpected primitive type', type => ref $primitive;
56             }
57              
58 34         86 my $raw_key;
59 34 100 66     206 if (substr($$data, 0, 120) =~ //
    100          
    100          
60             and my ($type, $version) = $self->_load_xml($data, \$raw_key)) {
61 6         120 $self->{type} = $type;
62 6         11 $self->{version} = $version;
63 6         16 $self->_set_raw_key($raw_key);
64             }
65             elsif (length($$data) == 32) {
66 4         25 $self->{type} = KEY_FILE_TYPE_BINARY;
67 4         14 $self->_set_raw_key($$data);
68             }
69             elsif ($$data =~ /^[A-Fa-f0-9]{64}$/) {
70 2         6 $self->{type} = KEY_FILE_TYPE_HEX;
71 2         13 $self->_set_raw_key(pack('H64', $$data));
72             }
73             else {
74 22         60 $self->{type} = KEY_FILE_TYPE_HASHED;
75 22         140 $self->_set_raw_key(digest_data('SHA256', $$data));
76             }
77              
78 34         119 return $self->hide;
79             }
80              
81              
82             sub reload {
83 2     2 1 5 my $self = shift;
84 2 50       8 $self->init($self->{filepath}) if defined $self->{filepath};
85 2         6 return $self;
86             }
87              
88              
89             sub save {
90 7     7 1 756 my $self = shift;
91 7         16 my %args = @_;
92              
93 7         9 my @cleanup;
94 7   33     25 my $raw_key = $args{raw_key} // $self->raw_key // random_bytes(32);
      33        
95 7         22 push @cleanup, erase_scoped $raw_key;
96 7 50       76 length($raw_key) == 32 or throw 'Raw key must be exactly 256 bits (32 bytes)', length => length($raw_key);
97              
98 7   66     21 my $type = $args{type} // $self->type // KEY_FILE_TYPE_XML;
      50        
99 7   66     27 my $version = $args{version} // $self->version // 2;
      100        
100 7   100     18 my $filepath = $args{filepath} // $self->filepath;
101 7         12 my $fh = $args{fh};
102 7   50     18 my $atomic = $args{atomic} // 1;
103              
104 7         8 my $filepath_temp;
105 7 100       15 if (!openhandle($fh)) {
106 6 50       12 $filepath or throw 'Must specify where to safe the key file to';
107              
108 6 50       10 if ($atomic) {
109 6         19 require File::Temp;
110 6         9 ($fh, $filepath_temp) = eval { File::Temp::tempfile("${filepath}-XXXXXX", UNLINK => 1) };
  6         18  
111 6 50 33     1727 if (!$fh or my $err = $@) {
112 0   0     0 $err //= 'Unknown error';
113 0         0 throw sprintf('Open file failed (%s): %s', $filepath_temp, $err),
114             error => $err,
115             filepath => $filepath_temp;
116             }
117             }
118             else {
119 0 0       0 open($fh, '>:raw', $filepath) or throw "Open file failed ($filepath): $!", filepath => $filepath;
120             }
121             }
122              
123 7 100       26 if ($type == KEY_FILE_TYPE_XML) {
    100          
    100          
124 3         8 $self->_save_xml($fh, $raw_key, $version);
125             }
126             elsif ($type == KEY_FILE_TYPE_BINARY) {
127 2         33 print $fh $raw_key;
128             }
129             elsif ($type == KEY_FILE_TYPE_HEX) {
130 1         5 my $hex = uc(unpack('H*', $raw_key));
131 1         4 push @cleanup, erase_scoped $hex;
132 1         14 print $fh $hex;
133             }
134             else {
135 1         8 throw "Cannot save $type key file (invalid type)", type => $type;
136             }
137              
138 6         201 close($fh);
139              
140 6 100       29 if ($filepath_temp) {
141 5         58 my ($file_mode, $file_uid, $file_gid) = (stat($filepath))[2, 4, 5];
142              
143 5 0 33     27 my $mode = $args{mode} // $file_mode // do { my $m = umask; defined $m ? oct(666) &~ $m : undef };
  0   33     0  
  0         0  
144 5   33     18 my $uid = $args{uid} // $file_uid // -1;
      50        
145 5   33     17 my $gid = $args{gid} // $file_gid // -1;
      50        
146 5 50       55 chmod($mode, $filepath_temp) if defined $mode;
147 5         62 chown($uid, $gid, $filepath_temp);
148 5 50       439 rename($filepath_temp, $filepath)
149             or throw "Failed to write file ($filepath): $!", filepath => $filepath;
150             }
151             }
152              
153             ##############################################################################
154              
155             sub _load_xml {
156 6     6   10 my $self = shift;
157 6         10 my $buf = shift;
158 6         6 my $out = shift;
159              
160 6         10 my ($version, $hash, $data);
161              
162 6         31 my $reader = XML::LibXML::Reader->new(string => $$buf);
163 6         472 my $pattern = XML::LibXML::Pattern->new('/KeyFile/Meta/Version|/KeyFile/Key/Data');
164              
165 6         313 while ($reader->nextPatternMatch($pattern) == 1) {
166 24 100       124 next if $reader->nodeType != XML_READER_TYPE_ELEMENT;
167 12         33 my $name = $reader->localName;
168 12 100       29 if ($name eq 'Version') {
    50          
169 6 50       30 $reader->read if !$reader->isEmptyElement;
170 6 50       15 $reader->nodeType == XML_READER_TYPE_TEXT
171             or alert 'Expected text node with version', line => $reader->lineNumber;
172 6         25 my $val = trim($reader->value);
173 6 50       15 defined $version
174             and alert 'Overwriting version', previous => $version, new => $val, line => $reader->lineNumber;
175 6         26 $version = $val;
176             }
177             elsif ($name eq 'Data') {
178 6 100       37 $hash = trim($reader->getAttribute('Hash')) if $reader->hasAttributes;
179 6 50       25 $reader->read if !$reader->isEmptyElement;
180 6 50       15 $reader->nodeType == XML_READER_TYPE_TEXT
181             or alert 'Expected text node with data', line => $reader->lineNumber;
182 6         13 $data = $reader->value;
183 6 50       65 $data =~ s/\s+//g if defined $data;
184             }
185             }
186              
187 6 50 33     28 return if !defined $version || !defined $data;
188              
189 6 100 66     64 if ($version =~ /^1\.0/ && $data =~ /^[A-Za-z0-9+\/=]+$/) {
    50 33        
      33        
      33        
190 2         3 $$out = eval { decode_b64($data) };
  2         10  
191 2 50       5 if (my $err = $@) {
192 0         0 throw 'Failed to decode key in key file', version => $version, data => $data, error => $err;
193             }
194 2         15 return (KEY_FILE_TYPE_XML, $version);
195             }
196             elsif ($version =~ /^2\.0/ && $data =~ /^[A-Fa-f0-9]+$/ && defined $hash && $hash =~ /^[A-Fa-f0-9]+$/) {
197 4         20 $$out = pack('H*', $data);
198 4         10 $hash = pack('H*', $hash);
199 4         22 my $got_hash = digest_data('SHA256', $$out);
200 4 50       12 $hash eq substr($got_hash, 0, length($hash))
201             or throw 'Checksum mismatch', got => $got_hash, expected => $hash;
202 4         27 return (KEY_FILE_TYPE_XML, $version);
203             }
204              
205 0         0 throw 'Unexpected data in key file', version => $version, data => $data;
206             }
207              
208             sub _save_xml {
209 3     3   4 my $self = shift;
210 3         4 my $fh = shift;
211 3         5 my $raw_key = shift;
212 3   50     6 my $version = shift // 2;
213              
214 3         4 my @cleanup;
215              
216 3         26 my $dom = XML::LibXML::Document->new('1.0', 'UTF-8');
217 3         20 my $doc = XML::LibXML::Element->new('KeyFile');
218 3         20 $dom->setDocumentElement($doc);
219 3         47 my $meta_node = XML::LibXML::Element->new('Meta');
220 3         18 $doc->appendChild($meta_node);
221 3         11 my $version_node = XML::LibXML::Element->new('Version');
222 3         122 $version_node->appendText(sprintf('%.1f', $version));
223 3         12 $meta_node->appendChild($version_node);
224 3         9 my $key_node = XML::LibXML::Element->new('Key');
225 3         40 $doc->appendChild($key_node);
226 3         6 my $data_node = XML::LibXML::Element->new('Data');
227 3         33 $key_node->appendChild($data_node);
228              
229 3 100       32 if (int($version) == 1) {
    50          
230 1         13 my $b64 = encode_b64($raw_key);
231 1         3 push @cleanup, erase_scoped $b64;
232 1         16 $data_node->appendText($b64);
233             }
234             elsif (int($version) == 2) {
235 2         36 my @hex = unpack('(H8)8', $raw_key);
236 2         14 my $hex = uc(sprintf("\n %s\n %s\n ", join(' ', @hex[0..3]), join(' ', @hex[4..7])));
237 2         7 push @cleanup, erase_scoped $hex, @hex;
238 2         30 $data_node->appendText($hex);
239 2         12 my $hash = digest_data('SHA256', $raw_key);
240 2         5 substr($hash, 4) = '';
241 2         5 $hash = uc(unpack('H*', $hash));
242 2         8 $data_node->setAttribute('Hash', $hash);
243             }
244             else {
245 0         0 throw 'Failed to save unsupported key file version', version => $version;
246             }
247              
248 3         103 $dom->toFH($fh, 1);
249             }
250              
251             1;
252              
253             __END__