File Coverage

blib/lib/App/GroupSecret/File.pm
Criterion Covered Total %
statement 49 129 37.9
branch 9 64 14.0
condition 8 43 18.6
subroutine 16 27 59.2
pod 18 18 100.0
total 100 281 35.5


line stmt bran cond sub pod time code
1             package App::GroupSecret::File;
2             # ABSTRACT: Reading and writing groupsecret keyfiles
3              
4              
5 1     1   57143 use warnings;
  1         9  
  1         27  
6 1     1   4 use strict;
  1         2  
  1         43  
7              
8             our $VERSION = '0.304'; # VERSION
9              
10 1         85 use App::GroupSecret::Crypt qw(
11             generate_secure_random_bytes
12             read_openssh_public_key
13             read_openssh_key_fingerprint
14             decrypt_rsa
15             encrypt_rsa
16             decrypt_aes_256_cbc
17             encrypt_aes_256_cbc
18 1     1   365 );
  1         15  
19 1     1   5 use File::Basename;
  1         2  
  1         45  
20 1     1   5 use File::Spec;
  1         1  
  1         18  
21 1     1   450 use YAML::Tiny qw(LoadFile DumpFile);
  1         4522  
  1         52  
22 1     1   6 use namespace::clean;
  1         1  
  1         7  
23              
24             our $FILE_VERSION = 1;
25              
26 0     0   0 sub _croak { require Carp; Carp::croak(@_) }
  0         0  
27 0     0   0 sub _usage { _croak("Usage: @_\n") }
28              
29              
30             sub new {
31 3     3 1 751 my $class = shift;
32 3 50       9 my $filepath = shift or _croak(q{App::GroupSecret::File->new($filepath)});
33 3         10 return bless {filepath => $filepath}, $class;
34             }
35              
36              
37 5     5 1 81 sub filepath { shift->{filepath} }
38              
39              
40             sub info {
41 5     5 1 12 my $self = shift;
42 5   66     22 return $self->{info} ||= do {
43 3 100       6 if (-e $self->filepath) {
44 2         8 $self->load;
45             }
46             else {
47 1         4 $self->init;
48             }
49             };
50             }
51              
52              
53             sub init {
54             return {
55 1     1 1 13 keys => {},
56             secret => undef,
57             version => $FILE_VERSION,
58             };
59             }
60              
61              
62             sub load {
63 2     2 1 4 my $self = shift;
64 2   33     6 my $filepath = shift || $self->filepath;
65 2   50     8 my $info = LoadFile($filepath) || {};
66 2         11764 $self->check($info);
67 2 50       6 $self->{info} = $info if !$filepath;
68 2         35 return $info;
69             }
70              
71              
72             sub save {
73 0     0 1 0 my $self = shift;
74 0   0     0 my $filepath = shift || $self->filepath;
75 0         0 DumpFile($filepath, $self->info);
76 0         0 return $self;
77             }
78              
79              
80             sub check {
81 2     2 1 7 my $self = shift;
82 2   33     6 my $info = shift || $self->info;
83              
84 2 50 33     16 _croak 'Corrupt file: Bad type for root' if !$info || ref $info ne 'HASH';
85              
86 2         7 my $version = $info->{version};
87 2 50 33     19 _croak 'Unknown file version' if !$version || $version !~ /^\d+$/;
88 2 50       7 _croak 'Unsupported file version' if $FILE_VERSION < $version;
89              
90 2 50       8 _croak 'Corrupt file: Bad type for keys' if ref $info->{keys} ne 'HASH';
91              
92 2 50 33     8 warn "The file has a secret but no keys to access it!\n" if $info->{secret} && !%{$info->{keys}};
  0         0  
93              
94 2         3 return 1;
95             }
96              
97              
98 1     1 1 6 sub keys { shift->info->{keys} }
99 1     1 1 4 sub secret { shift->info->{secret} }
100 1     1 1 4 sub version { shift->info->{version} }
101              
102              
103             sub add_key {
104 0     0 1   my $self = shift;
105 0 0         my $public_key = shift or _usage(q{$file->add_key($public_key)});
106 0 0         my $args = @_ == 1 ? shift : {@_};
107              
108 0           my $keys = $self->keys;
109              
110 0   0       my $info = $args->{fingerprint_info} || read_openssh_key_fingerprint($public_key);
111 0           my $fingerprint = $info->{fingerprint};
112              
113             my $key = {
114             comment => $info->{comment},
115             filename => basename($public_key),
116             secret_passphrase => undef,
117             type => $info->{type},
118 0           };
119              
120 0 0         if ($args->{embed}) {
121 0 0         open(my $fh, '<', $public_key) or die "open failed: $!";
122 0           $key->{content} = do { local $/; <$fh> };
  0            
  0            
123 0           chomp $key->{content};
124             }
125              
126 0           $keys->{$fingerprint} = $key;
127              
128 0 0         if ($self->secret) {
129 0   0       my $passphrase = $args->{passphrase} || $self->decrypt_secret_passphrase($args->{private_key});
130 0           my $ciphertext = encrypt_rsa(\$passphrase, $public_key);
131 0           $key->{secret_passphrase} = $ciphertext;
132             }
133              
134 0 0         return wantarray ? ($fingerprint => $key) : $key;
135             }
136              
137              
138             sub delete_key {
139 0     0 1   my $self = shift;
140 0           my $fingerprint = shift;
141 0           delete $self->keys->{$fingerprint};
142             }
143              
144              
145             sub decrypt_secret {
146 0     0 1   my $self = shift;
147 0 0         my $args = @_ == 1 ? shift : {@_};
148              
149 0 0 0       $args->{passphrase} || $args->{private_key} or _usage(q{$file->decrypt_secret($private_key)});
150              
151 0           my $passphrase = $args->{passphrase};
152 0 0         $passphrase = $self->decrypt_secret_passphrase($args->{private_key}) if !$passphrase;
153              
154 0           my $ciphertext = $self->secret;
155 0           return decrypt_aes_256_cbc(\$ciphertext, $passphrase);
156             }
157              
158              
159             sub decrypt_secret_passphrase {
160 0     0 1   my $self = shift;
161 0 0         my $private_key = shift or _usage(q{$file->decrypt_secret_passphrase($private_key)});
162              
163 0 0 0       die "Private key '$private_key' not found.\n" unless -e $private_key && !-d $private_key;
164              
165 0           my $info = read_openssh_key_fingerprint($private_key);
166 0           my $fingerprint = $info->{fingerprint};
167              
168 0           my $keys = $self->keys;
169 0 0         if (my $key = $keys->{$fingerprint}) {
170 0           return decrypt_rsa(\$key->{secret_passphrase}, $private_key);
171             }
172              
173 0           die "Private key '$private_key' not able to decrypt the keyfile.\n";
174             }
175              
176              
177             sub encrypt_secret {
178 0     0 1   my $self = shift;
179 0 0         my $secret = shift or _usage(q{$file->encrypt_secret($secret)});
180 0 0         my $passphrase = shift or _usage(q{$file->encrypt_secret($secret)});
181              
182 0           my $ciphertext = encrypt_aes_256_cbc($secret, $passphrase);
183 0           $self->info->{secret} = $ciphertext;
184             }
185              
186              
187             sub encrypt_secret_passphrase {
188 0     0 1   my $self = shift;
189 0 0         my $passphrase = shift or _usage(q{$file->encrypt_secret_passphrase($passphrase)});
190              
191 0           while (my ($fingerprint, $key) = each %{$self->keys}) {
  0            
192 0           local $key->{fingerprint} = $fingerprint;
193 0 0         my $pubkey = $self->find_public_key($key) or die 'Cannot find public key: ' . $self->format_key($key) . "\n";
194 0           my $ciphertext = encrypt_rsa(\$passphrase, $pubkey);
195 0           $key->{secret_passphrase} = $ciphertext;
196             }
197             }
198              
199              
200             sub find_public_key {
201 0     0 1   my $self = shift;
202 0 0         my $key = shift or _usage(q{$file->find_public_key($key)});
203              
204 0 0         if ($key->{content}) {
205 0           my $temp = File::Temp->new(UNLINK => 1);
206 0           print $temp $key->{content};
207 0           close $temp;
208 0           $self->{"temp:$key->{fingerprint}"} = $temp;
209 0           return $temp->filename;
210             }
211             else {
212 0   0       my @dirs = split(/:/, $ENV{GROUPSECRET_PATH} || ".:keys:$ENV{HOME}/.ssh");
213 0           for my $dir (@dirs) {
214 0           my $filepath = File::Spec->catfile($dir, $key->{filename});
215 0 0 0       return $filepath if -e $filepath && !-d $filepath;
216             }
217             }
218             }
219              
220              
221             sub format_key {
222 0     0 1   my $self = shift;
223 0 0         my $key = shift or _usage(q{$file->format_key($key)});
224              
225 0 0         my $fingerprint = $key->{fingerprint} or _croak(q{Missing required field in key: fingerprint});
226 0   0       my $comment = $key->{comment} || 'uncommented';
227              
228 0 0         if ($fingerprint =~ /^[A-Fa-f0-9]{32}$/) {
    0          
    0          
229 0           $fingerprint = 'MD5:' . join(':', ($fingerprint =~ /../g ));
230             }
231             elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{27}$/) {
232 0           $fingerprint = "SHA1:$fingerprint";
233             }
234             elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{43}$/) {
235 0           $fingerprint = "SHA256:$fingerprint";
236             }
237              
238 0           return "$fingerprint $comment";
239             }
240              
241             1;
242              
243             __END__