]> Dogcows Code - chaz/groupsecret/blob - lib/App/GroupSecret/File.pm
improve error message for private key not found
[chaz/groupsecret] / lib / App / GroupSecret / File.pm
1 package App::GroupSecret::File;
2 # ABSTRACT: Reading and writing groupsecret keyfiles
3
4 =head1 SYNOPSIS
5
6 use App::GroupSecret::File;
7
8 my $file = App::GroupSecret::File->new('path/to/keyfile.yml');
9 print "File version: " . $file->version, "\n";
10
11 $file->add_key('path/to/key_rsa.pub');
12 $file->save;
13
14 =head1 DESCRIPTION
15
16 This module provides a programmatic way to manage keyfiles.
17
18 See L<groupsecret> for the command-line interface.
19
20 =head1 FILE FORMAT
21
22 Keyfiles are YAML documents that contains this structure:
23
24 ---
25 keys:
26 FINGERPRINT:
27 comment: COMMENT
28 content: ssh-rsa ...
29 filename: FILENAME
30 secret_passphrase: PASSPHRASE...
31 type: rsa
32 secret: SECRET...
33 version: 1
34
35 =cut
36
37 use warnings;
38 use strict;
39
40 our $VERSION = '9999.999'; # VERSION
41
42 use App::GroupSecret::Crypt qw(
43 generate_secure_random_bytes
44 read_openssh_public_key
45 read_openssh_key_fingerprint
46 decrypt_rsa
47 encrypt_rsa
48 decrypt_aes_256_cbc
49 encrypt_aes_256_cbc
50 );
51 use File::Basename;
52 use File::Spec;
53 use YAML::Tiny qw(LoadFile DumpFile);
54 use namespace::clean;
55
56 our $FILE_VERSION = 1;
57
58 sub _croak { require Carp; Carp::croak(@_) }
59 sub _usage { _croak("Usage: @_\n") }
60
61 =method new
62
63 $file = App::GroupSecret::File->new($filepath);
64
65 Construct a new keyfile object.
66
67 =cut
68
69 sub new {
70 my $class = shift;
71 my $filepath = shift or _croak(q{App::GroupSecret::File->new($filepath)});
72 return bless {filepath => $filepath}, $class;
73 }
74
75 =attr filepath
76
77 Get the filepath of the keyfile.
78
79 =cut
80
81 sub filepath { shift->{filepath} }
82
83 =method info
84
85 $info = $file->info;
86
87 Get a raw hashref with the contents of the keyfile.
88
89 =cut
90
91 sub info {
92 my $self = shift;
93 return $self->{info} ||= do {
94 if (-e $self->filepath) {
95 $self->load;
96 }
97 else {
98 $self->init;
99 }
100 };
101 }
102
103 =method init
104
105 $info = $file->init;
106
107 Get a hashref representing an empty keyfile, used for initializing a new keyfile.
108
109 =cut
110
111 sub init {
112 return {
113 keys => {},
114 secret => undef,
115 version => $FILE_VERSION,
116 };
117 }
118
119 =method load
120
121 $info = $file->load;
122 $info = $file->load($filepath);
123
124 Load (or reload) the contents of a keyfile.
125
126 =cut
127
128 sub load {
129 my $self = shift;
130 my $filepath = shift || $self->filepath;
131 my $info = LoadFile($filepath) || {};
132 $self->check($info);
133 $self->{info} = $info if !$filepath;
134 return $info;
135 }
136
137 =method save
138
139 $file->save;
140 $file->save($filepath);
141
142 Save the keyfile to disk.
143
144 =cut
145
146 sub save {
147 my $self = shift;
148 my $filepath = shift || $self->filepath;
149 DumpFile($filepath, $self->info);
150 return $self;
151 }
152
153 =method check
154
155 $file->check;
156 $file->check($info);
157
158 Check the file format of a keyfile to make sure this module can understand it.
159
160 =cut
161
162 sub check {
163 my $self = shift;
164 my $info = shift || $self->info;
165
166 _croak 'Corrupt file: Bad type for root' if !$info || ref $info ne 'HASH';
167
168 my $version = $info->{version};
169 _croak 'Unknown file version' if !$version || $version !~ /^\d+$/;
170 _croak 'Unsupported file version' if $FILE_VERSION < $version;
171
172 _croak 'Corrupt file: Bad type for keys' if ref $info->{keys} ne 'HASH';
173
174 warn "The file has a secret but no keys to access it!\n" if $info->{secret} && !%{$info->{keys}};
175
176 return 1;
177 }
178
179 =method keys
180
181 $keys = $file->keys;
182
183 Get a hashref of the keys from a keyfile.
184
185 =method secret
186
187 $secret = $file->secret;
188
189 Get the secret from a keyfile as an encrypted string.
190
191 =method version
192
193 $version = $file->version
194
195 Get the file format version.
196
197 =cut
198
199 sub keys { shift->info->{keys} }
200 sub secret { shift->info->{secret} }
201 sub version { shift->info->{version} }
202
203 =method add_key
204
205 $file->add_key($filepath);
206
207 Add a key to the keyfile.
208
209 =cut
210
211 sub add_key {
212 my $self = shift;
213 my $public_key = shift or _usage(q{$file->add_key($public_key)});
214 my $args = @_ == 1 ? shift : {@_};
215
216 my $keys = $self->keys;
217
218 my $info = $args->{fingerprint_info} || read_openssh_key_fingerprint($public_key);
219 my $fingerprint = $info->{fingerprint};
220
221 my $key = {
222 comment => $info->{comment},
223 filename => basename($public_key),
224 secret_passphrase => undef,
225 type => $info->{type},
226 };
227
228 if ($args->{embed}) {
229 open(my $fh, '<', $public_key) or die "open failed: $!";
230 $key->{content} = do { local $/; <$fh> };
231 chomp $key->{content};
232 }
233
234 $keys->{$fingerprint} = $key;
235
236 if ($self->secret) {
237 my $passphrase = $args->{passphrase} || $self->decrypt_secret_passphrase($args->{private_key});
238 my $ciphertext = encrypt_rsa(\$passphrase, $public_key);
239 $key->{secret_passphrase} = $ciphertext;
240 }
241
242 return wantarray ? ($fingerprint => $key) : $key;
243 }
244
245 =method delete_key
246
247 $file->delete_key($fingerprint);
248
249 Delete a key from the keyfile.
250
251 =cut
252
253 sub delete_key {
254 my $self = shift;
255 my $fingerprint = shift;
256 delete $self->keys->{$fingerprint};
257 }
258
259 =method decrypt_secret
260
261 $secret = $file->decrypt_secret(passphrase => $passphrase);
262 $secret = $file->decrypt_secret(private_key => $private_key);
263
264 Get the decrypted secret.
265
266 =cut
267
268 sub decrypt_secret {
269 my $self = shift;
270 my $args = @_ == 1 ? shift : {@_};
271
272 $args->{passphrase} || $args->{private_key} or _usage(q{$file->decrypt_secret($private_key)});
273
274 my $passphrase = $args->{passphrase};
275 $passphrase = $self->decrypt_secret_passphrase($args->{private_key}) if !$passphrase;
276
277 my $ciphertext = $self->secret;
278 return decrypt_aes_256_cbc(\$ciphertext, $passphrase);
279 }
280
281 =method decrypt_secret_passphrase
282
283 $passphrase = $file->decrypt_secret_passphrase($private_key);
284
285 Get the decrypted secret passphrase.
286
287 =cut
288
289 sub decrypt_secret_passphrase {
290 my $self = shift;
291 my $private_key = shift or _usage(q{$file->decrypt_secret_passphrase($private_key)});
292
293 die "Private key '$private_key' not found.\n" unless -e $private_key && !-d $private_key;
294
295 my $info = read_openssh_key_fingerprint($private_key);
296 my $fingerprint = $info->{fingerprint};
297
298 my $keys = $self->keys;
299 if (my $key = $keys->{$fingerprint}) {
300 return decrypt_rsa(\$key->{secret_passphrase}, $private_key);
301 }
302
303 die "Private key '$private_key' not able to decrypt the keyfile.\n";
304 }
305
306 =method encrypt_secret
307
308 $file->encrypt_secret($secret, $passphrase);
309
310 Set the secret by encrypting it with a 256-bit passphrase.
311
312 Passphrase must be 32 bytes.
313
314 =cut
315
316 sub encrypt_secret {
317 my $self = shift;
318 my $secret = shift or _usage(q{$file->encrypt_secret($secret)});
319 my $passphrase = shift or _usage(q{$file->encrypt_secret($secret)});
320
321 my $ciphertext = encrypt_aes_256_cbc($secret, $passphrase);
322 $self->info->{secret} = $ciphertext;
323 }
324
325 =method encrypt_secret_passphrase
326
327 $file->encrypt_secret_passphrase($passphrase);
328
329 Set the passphrase by encrypting it with each key in the keyfile.
330
331 =cut
332
333 sub encrypt_secret_passphrase {
334 my $self = shift;
335 my $passphrase = shift or _usage(q{$file->encrypt_secret_passphrase($passphrase)});
336
337 while (my ($fingerprint, $key) = each %{$self->keys}) {
338 local $key->{fingerprint} = $fingerprint;
339 my $pubkey = $self->find_public_key($key) or die 'Cannot find public key: ' . $self->format_key($key) . "\n";
340 my $ciphertext = encrypt_rsa(\$passphrase, $pubkey);
341 $key->{secret_passphrase} = $ciphertext;
342 }
343 }
344
345 =method find_public_key
346
347 $filepath = $file->find_public_key($key);
348
349 Get a path to the public key file for a key.
350
351 =cut
352
353 sub find_public_key {
354 my $self = shift;
355 my $key = shift or _usage(q{$file->find_public_key($key)});
356
357 if ($key->{content}) {
358 my $temp = File::Temp->new(UNLINK => 1);
359 print $temp $key->{content};
360 close $temp;
361 $self->{"temp:$key->{fingerprint}"} = $temp;
362 return $temp->filename;
363 }
364 else {
365 my @dirs = split(/:/, $ENV{GROUPSECRET_PATH} || ".:keys:$ENV{HOME}/.ssh");
366 for my $dir (@dirs) {
367 my $filepath = File::Spec->catfile($dir, $key->{filename});
368 return $filepath if -f $filepath;
369 }
370 }
371 }
372
373 =method format_key
374
375 $str = $file->format_key($key);
376
377 Get a one-line summary of a key. Format is "<fingerprint> <comment>".
378
379 =cut
380
381 sub format_key {
382 my $self = shift;
383 my $key = shift or _usage(q{$file->format_key($key)});
384
385 my $fingerprint = $key->{fingerprint} or _croak(q{Missing required field in key: fingerprint});
386 my $comment = $key->{comment} || 'uncommented';
387
388 if ($fingerprint =~ /^[A-Fa-f0-9]{32}$/) {
389 $fingerprint = 'MD5:' . join(':', ($fingerprint =~ /../g ));
390 }
391 elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{27}$/) {
392 $fingerprint = "SHA1:$fingerprint";
393 }
394 elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{43}$/) {
395 $fingerprint = "SHA256:$fingerprint";
396 }
397
398 return "$fingerprint $comment";
399 }
400
401 1;
This page took 0.049943 seconds and 4 git commands to generate.