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