From: Charles McGarvey Date: Thu, 30 Nov 2017 05:56:50 +0000 (-0700) Subject: initial commit X-Git-Url: https://git.dogcows.com/gitweb?p=chaz%2Fgroupsecret;a=commitdiff_plain;h=93527579d68b0ccdd5dab72d88764a53fef43b88 initial commit --- 93527579d68b0ccdd5dab72d88764a53fef43b88 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1f6b59 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ + +# Please follow these code style guidelines. You can use this file to +# automatically configure your editor. +# For instructions, see: http://editorconfig.org/ + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.{pl,pm,t}] +indent_size = 4 +indent_style = space +max_line_length = 100 +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76b12c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.build +/.perl-version +/App-GroupSecret-* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e90dbdc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +sudo: false +language: perl +perl: + - '5.26' + - '5.24' + - '5.22' + - '5.20' + - '5.18' + - '5.16' + - '5.14' +matrix: + fast_finish: true +before_install: + - git config --global user.name "TravisCI" + - git config --global user.email $HOSTNAME":not-for-mail@travis-ci.org" +install: + - cpanm --quiet --notest --skip-satisfied Dist::Zilla + - "dzil authordeps --missing | grep -vP '[^\\w:]' | xargs -n 5 -P 10 cpanm --quiet --notest" + - "dzil listdeps --author --missing | grep -vP '[^\\w:]' | cpanm --verbose" +script: + - dzil smoke --release --author diff --git a/Changes b/Changes new file mode 100644 index 0000000..be261bc --- /dev/null +++ b/Changes @@ -0,0 +1,4 @@ +Revision history for groupsecret. + +{{$NEXT}} + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e38251 --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +This software is Copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +The MIT License + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to +whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT +WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c192c97 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ + +# This is not a Perl distribution, but it can build one using Dist::Zilla. + +CPANM = cpanm +DZIL = dzil +PERL = perl +PROVE = prove + +all: bootstrap dist + +bootstrap: + $(CPANM) Dist::Zilla + $(DZIL) authordeps --missing | $(CPANM) + $(DZIL) listdeps --develop --missing | $(CPANM) + +clean: + $(DZIL) $@ + +dist: + $(DZIL) build + +test: + $(PROVE) -l + +.PHONY: all bootstrap clean dist test + diff --git a/bin/groupsecret b/bin/groupsecret new file mode 100755 index 0000000..5f13ab4 --- /dev/null +++ b/bin/groupsecret @@ -0,0 +1,240 @@ +#!perl +# PODNAME: groupsecret +# ABSTRACT: A simple tool for maintaining a shared group secret + +=head1 SYNOPSIS + + groupsecret [--version] [--help] [-f ] [-k ] + [] + + groupsecret add-key [--embed] [--update] ... + + groupsecret delete-key | ... + + groupsecret list-keys + + groupsecret set-secret [--keep-passphrase] |-|rand: + + groupsecret [print-secret] [--no-decrypt] + +=head1 DESCRIPTION + +L is a program that makes it easy for groups to share a secret between themselves +without exposing the secret to anyone else. It could be used, for example, by a team to share an +L password; see L for more about this particular use case. + +The goal of this program is to be easy to use and have few dependencies (or only have dependencies +users are likely to already have installed). + +L works by encrypting a secret with a symmetric cipher protected by a secure random +passphrase which is itself encrypted by one or more SSH2 RSA public keys. Only those who have access +to one of the corresponding private keys are able to decrypt the passphrase and access the secret. + +The encrypted secret and passphrase are stored in a single keyfile. You can even commit the keyfile +in a public repo or in a private repo where some untrusted users may have read access; the secret is +locked away to all except those with a private key to a corresponding public key that has been added +to the keyfile. + +The keyfile is just a YAML file, so it's human-readable (except of course for the encrypted parts). +This make it easy to add to version control and work with diffs. You can edit the keyfile by hand if +you learn its very simple structure, but this program makes it even easier to manage the keyfile. + +=head1 OPTIONS + +=head2 --version + +Print the program name and version to C, and exit. + +Alias: C<-v> + +=head2 --help + +Print the synopsis to C, and exit. + +Alias: C<-h> + +=head2 --file=path + +Specify a path to a keyfile which stores a secret and keys. + +Defaults to the value of the environment variable C or F. + +Alias: C<-f> + +=head2 --private-key=path + +Specify a path to a PEM private key. This is used by some commands to decrypt the passphrase that +protects the secret and is ignored by commands that don't need it. + +Defaults to the value of the environment variable L. If that is unset, it +defaults to F<~/.ssh/id_rsa>. + +Alias: C<-k> + +=head1 COMMANDS + +=head2 add-key + + groupsecret add-key path/to/mykey_rsa.pub + +Adds one or more SSH2 RSA public keys to a keyfile. This allows the secret contained within the +keyfile to be accessed by whoever has the corresponding private key. + +If the C<--embed> option is used, the public keys will be embeded in the keyfile. This may be +a useful way to make sure the actual keys are available in the future since they could be needed to +encrypt a new passphrase if it ever needs to be changed. Keys that are not embedded will be searched +for in the filesystem; see L. + +If the C<--update> option is used and a key with the same fingerprint is added, the new key will +replaced the existing key. The default behavior is to skip existing keys. + +If the keyfile is storing a secret, the passphrase protecting the secret will need to be decrypted +so that access to the secret can be shared with the new key(s). + +Alias: C + +=head2 delete-key + + groupsecret delete-key MD5:89:b3:fb:76:6c:f9:56:8e:a8:1a:df:ba:1c:ba:7d:05 + groupsecret delete-key path/to/mykey_rsa.pub + +Deletes one or more keys from a keyfile. This prevents the secret contained within the keyfile from +being accessed by whoever has the corresponding private key. + +Of course, if the owners of the key(s) being removed have already had access to the keyfile prior to +their keys being removed, the secret is already exposed to them. It usually makes sense to follow up +this command with a L command in order to change the secret. + +Aliases: C, C, C + +=head2 list-keys + + groupsecret list-keys + +Prints the keys that have access to the secret contained in the keyfile to C, one per line +in the following format: + + + +=head2 set-secret + + groupsecret set-secret path/to/secretfile.txt + groupsecret set-secret - < it's a secret to everybody + > END + groupsecret set-secret rand:48 + +Set or update the secret contained in a keyfile. The argument allows you to add a secret from +a file, from , or from a stream of secure random bytes. + +If the keyfile already contains a secret, it will be replaced by the new secret. A keyfile can only +contain one secret at a time. If you think you want to store more than one secret at a time, store +a tarball instead. + +By default, this will also change the passphrase protecting the secret and re-encrypt the passphrase +for each key currently in the keyfile. This requires all of the public keys to be available (see +L). If for some reason you want to protect the new secret with the current +passphrase, use the C<--keep-passphrase> option; this can be done without the public keys being +available, but it will require a private key to decrypt the passphrase. + +Aliases: C, C + +=head2 print-secret + + groupsecret print-secret + groupsecret print-secret --no-decrypt + +Print the secret contained in the keyfile to C. + +If the C<--no-decrypt> option is used, the secret will be printed in its encrypted form. + +This requires a private key. + +Aliases: (no command), C + +=head1 REQUIREMENTS + +=for :list +* L (commands: L) +* L (commands: L) + +=head1 INSTALL + +There are several ways to install groupsecret to your system. First, make sure you first have the +L installed. + +=head2 Using cpanm + +You can install groupsecret using L. If you have a local perl (plenv, perlbrew, etc.), you +can just do this: + + cpanm App::GroupSecret + +to install the F executable and its Perl module dependencies. The executable will be +installed to your perl's bin path, like F<~/perl5/perlbrew/bin/groupsecret>. + +If you're installing to your system perl, you can do: + + cpanm --sudo App::GroupSecret + +to install the F executable to a system directory, like F +(depending on your perl). + +=head2 Downloading just the executable + +You may also choose to download F as a single executable, like this: + + curl -OL https://raw.githubusercontent.com/chazmcgarvey/groupsecret/solo/groupsecret + chmod +x groupsecret + +This executable is fat-packed and includes all the non-core Perl module dependencies built-in. + +=head2 For developers + +If you're a developer and want to hack on the source, clone the repository and pull the +dependencies: + + git clone https://github.com/chazmcgarvey/groupsecret.git + cd groupsecret + cpanm Dist::Zilla + dzil authordeps --missing | cpanm + dzil listdeps --author --develop --missing | cpanm + +=head1 ENVIRONMENT + +=head2 GROUPSECRET_KEYFILE + +If set, this program will use the value as a path to the keyfile. The L option takes +precedence if it is used. + +=head2 GROUPSECRET_PRIVATE_KEY + +If set, this program will use the value as a path to the keyfile. The L option +takes precedence if it is used. + +=head2 GROUPSECRET_PATH + +The value of this variable should be a colon-separated list of directories in which to search for +public keys. By default, the actual keys are not embedded in keyfiles, but they may be needed to +encrypt a new passphrase if it ever needs to be changed. Keys that are not embedded will be searched +for in the filesystem based on the value of this environment variable. + +Defaults to C<.:keys:$HOME/.ssh>. + +=head1 EXAMPLES + +=head2 ansible-vault + +TODO + +=cut + +use warnings FATAL => 'all'; +use strict; + +our $VERSION = '9999.999'; # VERSION + +use App::GroupSecret; + +App::GroupSecret->new->main(@ARGV); +exit; diff --git a/dist.ini b/dist.ini new file mode 100644 index 0000000..7161ab8 --- /dev/null +++ b/dist.ini @@ -0,0 +1,9 @@ + +name = App-GroupSecret +author = Charles McGarvey +copyright_holder = Charles McGarvey +copyright_year = 2017 +license = MIT + +[@Author::CCM] + diff --git a/lib/App/GroupSecret.pm b/lib/App/GroupSecret.pm new file mode 100644 index 0000000..fe6593d --- /dev/null +++ b/lib/App/GroupSecret.pm @@ -0,0 +1,256 @@ +package App::GroupSecret; +# ABSTRACT: A simple tool for maintaining a shared group secret + +=head1 DESCRIPTION + +This module is part of the command-line interface for managing keyfiles. + +See L for documentation. + +=cut + +use warnings; +use strict; + +our $VERSION = '9999.999'; # VERSION + +use App::GroupSecret::Crypt qw(generate_secure_random_bytes read_openssh_key_fingerprint); +use App::GroupSecret::File; +use Getopt::Long qw(GetOptionsFromArray); +use MIME::Base64; +use Pod::Usage; +use namespace::clean; + +sub new { + my $class = shift; + return bless {}, $class; +} + +sub main { + my $self = shift; + my @args = @_; + + my $filepath = ''; + my $help = 0; + my $man = 0; + my $version = 0; + my $private_key = ''; + + # Parse options using pass_through so that we can pick out the global + # options, wherever they are in the arg list, and leave the rest to be + # parsed by each individual command. + Getopt::Long::Configure('pass_through'); + GetOptionsFromArray( + \@args, + 'file|f=s' => \$filepath, + 'help|h|?' => \$help, + 'manual|man' => \$man, + 'private-key|k=s' => \$private_key, + 'version|v' => \$version, + ) or pod2usage(2); + Getopt::Long::Configure('default'); + + pod2usage(-exitval => 1, -verbose => 99, -sections => [qw(SYNOPSIS OPTIONS COMMANDS)]) if $help; + pod2usage(-verbose => 2) if $man; + return print "groupsecret ${VERSION}\n" if $version; + + $self->{private_key} = $private_key if $private_key; + $self->{filepath} = $filepath if $filepath; + + my %commands = ( + add_key => 'add_key', + add_keys => 'add_key', + change_secret => 'set_secret', + delete_key => 'delete_key', + delete_keys => 'delete_key', + list_keys => 'list_keys', + print => 'print_secret', + print_secret => 'print_secret', + remove_key => 'delete_key', + remove_keys => 'delete_key', + set_secret => 'set_secret', + show_secret => 'print_secret', + update_secret => 'set_secret', + ); + + unshift @args, 'print' if !@args || $args[0] =~ /^-/; + + my $command = shift @args; + my $lookup = $command; + $lookup =~ s/-/_/g; + my $method = 'action_' . ($commands{$lookup} || ''); + + if (!$self->can($method)) { + warn "Unknown command: $command\n"; + pod2usage(2); + } + + $self->$method(@args); +} + +sub filepath { + shift->{filepath} ||= $ENV{GROUPSECRET_KEYFILE} || 'groupsecret.yml'; + +} + +sub file { + my $self = shift; + return $self->{file} ||= App::GroupSecret::File->new($self->filepath); +} + +sub private_key { + shift->{private_key} ||= $ENV{GROUPSECRET_PRIVATE_KEY} || "$ENV{HOME}/.ssh/id_rsa"; +} + +sub action_print_secret { + my $self = shift; + + my $decrypt = 1; + GetOptionsFromArray( + \@_, + 'decrypt!' => \$decrypt, + ) or pod2usage(2); + + my $file = $self->file; + die "No secret in file -- use the \`set-secret' command to set one.\n" if !$file->secret; + + if ($decrypt) { + my $private_key = $self->private_key; + my $secret = $file->decrypt_secret(private_key => $private_key) or die "No secret.\n"; + print $secret; + } + else { + print $file->secret; + } +} + +sub action_set_secret { + my $self = shift; + + my $keep_passphrase = 0; + GetOptionsFromArray( + \@_, + 'keep-passphrase!' => \$keep_passphrase, + ) or pod2usage(2); + + my $secret_spec = shift; + if (!$secret_spec) { + warn "You must specify a secret to set.\n"; + pod2usage(2); + } + + my $passphrase; + my $secret; + + if ($secret_spec =~ /^rand:(\d+)$/i) { + my $rand = encode_base64(generate_secure_random_bytes($1), ''); + $secret = \$rand; + } + elsif ($secret_spec eq '-') { + my $in = do { local $/; }; + $secret = \$in; + } + elsif ($secret_spec =~ /^file:(.*)$/i) { + $secret = $1; + } + else { + $secret = $secret_spec; + } + + my $file = $self->file; + + if ($keep_passphrase) { + my $private_key = $self->private_key; + $passphrase = $file->decrypt_secret_passphrase($private_key); + $file->encrypt_secret($secret, $passphrase); + } + else { + $passphrase = generate_secure_random_bytes(32); + $file->encrypt_secret($secret, $passphrase); + $file->encrypt_secret_passphrase($passphrase); + } + + $file->save; +} + +sub action_add_key { + my $self = shift; + + my $embed = 0; + my $update = 0; + GetOptionsFromArray( + \@_, + 'embed' => \$embed, + 'update|u' => \$update, + ) or pod2usage(2); + + my $file = $self->file; + my $keys = $file->keys; + + my $opts = {embed => $embed}; + + for my $public_key (@_) { + my $info = read_openssh_key_fingerprint($public_key); + + if ($keys->{$info->{fingerprint}} && !$update) { + my $formatted_key = $file->format_key($info); + print "SKIP\t$formatted_key\n"; + next; + } + + if ($file->secret && !$opts->{passphrase}) { + my $private_key = $self->private_key; + my $passphrase = $file->decrypt_secret_passphrase($private_key); + $opts->{passphrase} = $passphrase; + } + + local $opts->{fingerprint_info} = $info; + my ($fingerprint, $key) = $file->add_key($public_key, $opts); + + local $key->{fingerprint} = $fingerprint; + my $formatted_key = $file->format_key($key); + print "ADD\t$formatted_key\n"; + } + + $file->save; +} + +sub action_delete_key { + my $self = shift; + + my $file = $self->file; + + for my $fingerprint (@_) { + if ($fingerprint =~ s/^(?:MD5|SHA1|SHA256)://) { + $fingerprint =~ s/://g; + } + else { + my $info = read_openssh_key_fingerprint($fingerprint); + $fingerprint = $info->{fingerprint}; + } + + my $key = $file->keys->{$fingerprint}; + $file->delete_key($fingerprint) if $key; + + local $key->{fingerprint} = $fingerprint; + my $formatted_key = $file->format_key($key); + print "DELETE\t$formatted_key\n"; + } + + $file->save; +} + +sub action_list_keys { + my $self = shift; + + my $file = $self->file; + my $keys = $file->keys; + + while (my ($fingerprint, $key) = each %$keys) { + local $key->{fingerprint} = $fingerprint; + my $formatted_key = $file->format_key($key); + print "$formatted_key\n"; + } +} + +1; diff --git a/lib/App/GroupSecret/Crypt.pm b/lib/App/GroupSecret/Crypt.pm new file mode 100644 index 0000000..f5360be --- /dev/null +++ b/lib/App/GroupSecret/Crypt.pm @@ -0,0 +1,290 @@ +package App::GroupSecret::Crypt; +# ABSTRACT: Collection of crypto-related subroutines + +use warnings; +use strict; + +our $VERSION = '9999.999'; # VERSION + +use Exporter qw(import); +use File::Temp; +use IPC::Open2; +use namespace::clean -except => [qw(import)]; + +our @EXPORT_OK = qw( + generate_secure_random_bytes + read_openssh_public_key + read_openssh_key_fingerprint + decrypt_rsa + encrypt_rsa + decrypt_aes_256_cbc + encrypt_aes_256_cbc +); + +sub _croak { require Carp; Carp::croak(@_) } +sub _usage { _croak("Usage: @_\n") } + +=func generate_secure_random_bytes + + $bytes = generate_secure_random_bytes($num_bytes); + +Get a certain number of secure random bytes. + +=cut + +sub generate_secure_random_bytes { + my $size = shift or _usage(q{generate_secure_random_bytes($num_bytes)}); + + my @cmd = (qw{openssl rand}, $size); + + my ($in, $out); + my $pid = open2($out, $in, @cmd); + + close($in); + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to generate secure random bytes' if $exit_code != 0; + + return do { local $/; <$out> }; +} + +=func read_openssh_public_key + + $pem_public_key = read_openssh_public_key($public_key_filepath); + +Read a RFC4716 (SSH2) public key from a file, converting it to PKCS8 (PEM). + +=cut + +sub read_openssh_public_key { + my $filepath = shift or _usage(q{read_openssh_public_key($filepath)}); + + my @cmd = (qw{ssh-keygen -e -m PKCS8 -f}, $filepath); + + my ($in, $out); + my $pid = open2($out, $in, @cmd); + + close($in); + + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to read OpenSSH public key' if $exit_code != 0; + + return do { local $/; <$out> }; +} + +=func read_openssh_key_fingerprint + + $fingerprint = read_openssh_key_fingerprint($filepath); + +Get the fingerprint of an OpenSSH private or public key. + +=cut + +sub read_openssh_key_fingerprint { + my $filepath = shift or _usage(q{read_openssh_key_fingerprint($filepath)}); + + my @cmd = (qw{ssh-keygen -l -E md5 -f}, $filepath); + + my $out; + my $pid = open2($out, undef, @cmd); + + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to read SSH2 key fingerprint' if $exit_code != 0; + + my $line = do { local $/; <$out> }; + chomp $line; + + my ($bits, $fingerprint, $comment, $type) = $line =~ m!^(\d+) MD5:([^ ]+) (.*) \(([^\)]+)\)$!; + + $fingerprint =~ s/://g; + + return { + bits => $bits, + fingerprint => $fingerprint, + comment => $comment, + type => lc($type), + }; +} + +=func decrypt_rsa + + $plaintext = decrypt_rsa($ciphertext_filepath, $private_key_filepath); + $plaintext = decrypt_rsa(\$ciphertext, $private_key_filepath); + decrypt_rsa($ciphertext_filepath, $private_key_filepath, $plaintext_filepath); + decrypt_rsa(\$ciphertext, $private_key_filepath, $plaintext_filepath); + +Do RSA decryption. Turn ciphertext into plaintext. + +=cut + +sub decrypt_rsa { + my $filepath = shift or _usage(q{decrypt_rsa($filepath, $keypath)}); + my $privkey = shift or _usage(q{decrypt_rsa($filepath, $keypath)}); + my $outfile = shift; + + my $temp; + if (ref $filepath eq 'SCALAR') { + $temp = File::Temp->new(UNLINK => 1); + print $temp $$filepath; + close $temp; + $filepath = $temp->filename; + } + + my @cmd = (qw{openssl rsautl -decrypt -oaep -in}, $filepath, '-inkey', $privkey); + push @cmd, ('-out', $outfile) if $outfile; + + my ($in, $out); + my $pid = open2($out, $in, @cmd); + + close($in); + + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to decrypt ciphertext' if $exit_code != 0; + + return do { local $/; <$out> }; +} + +=func encrypt_rsa + + $ciphertext = decrypt_rsa($plaintext_filepath, $public_key_filepath); + $ciphertext = decrypt_rsa(\$plaintext, $public_key_filepath); + decrypt_rsa($plaintext_filepath, $public_key_filepath, $ciphertext_filepath); + decrypt_rsa(\$plaintext, $public_key_filepath, $ciphertext_filepath); + +Do RSA encryption. Turn plaintext into ciphertext. + +=cut + +sub encrypt_rsa { + my $filepath = shift or _usage(q{encrypt_rsa($filepath, $keypath)}); + my $pubkey = shift or _usage(q{encrypt_rsa($filepath, $keypath)}); + my $outfile = shift; + + my $temp1; + if (ref $filepath eq 'SCALAR') { + $temp1 = File::Temp->new(UNLINK => 1); + print $temp1 $$filepath; + close $temp1; + $filepath = $temp1->filename; + } + + my $key = read_openssh_public_key($pubkey); + + my $temp2 = File::Temp->new(UNLINK => 1); + print $temp2 $key; + close $temp2; + my $keypath = $temp2->filename; + + my @cmd = (qw{openssl rsautl -encrypt -oaep -pubin -inkey}, $keypath, '-in', $filepath); + push @cmd, ('-out', $outfile) if $outfile; + + my ($in, $out); + my $pid = open2($out, $in, @cmd); + + close($in); + + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to encrypt plaintext' if $exit_code != 0; + + return do { local $/; <$out> }; +} + +=func decrypt_aes_256_cbc + + $plaintext = decrypt_aes_256_cbc($ciphertext_filepath, $secret); + $plaintext = decrypt_aes_256_cbc(\$ciphertext, $secret); + decrypt_aes_256_cbc($ciphertext_filepath, $secret, $plaintext_filepath); + decrypt_aes_256_cbc(\$ciphertext, $secret, $plaintext_filepath); + +Do symmetric decryption. Turn ciphertext into plaintext. + +=cut + +sub decrypt_aes_256_cbc { + my $filepath = shift or _usage(q{decrypt_aes_256_cbc($ciphertext, $secret)}); + my $secret = shift or _usage(q{decrypt_aes_256_cbc($ciphertext, $secret)}); + my $outfile = shift; + + my $temp; + if (ref $filepath eq 'SCALAR') { + $temp = File::Temp->new(UNLINK => 1); + print $temp $$filepath; + close $temp; + $filepath = $temp->filename; + } + + my @cmd = (qw{openssl aes-256-cbc -d -pass stdin -in}, $filepath); + push @cmd, ('-out', $outfile) if $outfile; + + my ($in, $out); + my $pid = open2($out, $in, @cmd); + + print $in $secret; + close($in); + + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to decrypt ciphertext' if $exit_code != 0; + + return do { local $/; <$out> }; +} + +=func encrypt_aes_256_cbc + + $ciphertext = encrypt_aes_256_cbc($plaintext_filepath, $secret); + $ciphertext = encrypt_aes_256_cbc(\$plaintext, $secret); + encrypt_aes_256_cbc($plaintext_filepath, $secret, $ciphertext_filepath); + encrypt_aes_256_cbc(\$plaintext, $secret, $ciphertext_filepath); + +Do symmetric encryption. Turn plaintext into ciphertext. + +=cut + +sub encrypt_aes_256_cbc { + my $filepath = shift or _usage(q{encrypt_aes_256_cbc($plaintext, $secret)}); + my $secret = shift or _usage(q{encrypt_aes_256_cbc($plaintext, $secret)}); + my $outfile = shift; + + my $temp; + if (ref $filepath eq 'SCALAR') { + $temp = File::Temp->new(UNLINK => 1); + print $temp $$filepath; + close $temp; + $filepath = $temp->filename; + } + + my @cmd = (qw{openssl aes-256-cbc -pass stdin -in}, $filepath); + push @cmd, ('-out', $outfile) if $outfile; + + my ($in, $out); + my $pid = open2($out, $in, @cmd); + + print $in $secret; + close($in); + + waitpid($pid, 0); + my $status = $?; + + my $exit_code = $status >> 8; + _croak 'Failed to encrypt plaintext' if $exit_code != 0; + + return do { local $/; <$out> }; +} + +1; diff --git a/lib/App/GroupSecret/File.pm b/lib/App/GroupSecret/File.pm new file mode 100644 index 0000000..956b6f9 --- /dev/null +++ b/lib/App/GroupSecret/File.pm @@ -0,0 +1,399 @@ +package App::GroupSecret::File; +# ABSTRACT: Reading and writing groupsecret keyfiles + +=head1 SYNOPSIS + + use App::GroupSecret::File; + + my $file = App::GroupSecret::File->new('path/to/keyfile.yml'); + print "File version: " . $file->version, "\n"; + + $file->add_key('path/to/key_rsa.pub'); + $file->save; + +=head1 DESCRIPTION + +This module provides a programmatic way to manage keyfiles. + +See L for the command-line interface. + +=head1 FILE FORMAT + +Keyfiles are YAML documents that contains this structure: + + --- + keys: + FINGERPRINT: + comment: COMMENT + content: ssh-rsa ... + filename: FILENAME + secret_passphrase: PASSPHRASE... + type: rsa + secret: SECRET... + version: 1 + +=cut + +use warnings; +use strict; + +our $VERSION = '9999.999'; # VERSION + +use App::GroupSecret::Crypt qw( + generate_secure_random_bytes + read_openssh_public_key + read_openssh_key_fingerprint + decrypt_rsa + encrypt_rsa + decrypt_aes_256_cbc + encrypt_aes_256_cbc +); +use File::Basename; +use File::Spec; +use YAML::Tiny qw(LoadFile DumpFile); +use namespace::clean; + +our $FILE_VERSION = 1; + +sub _croak { require Carp; Carp::croak(@_) } +sub _usage { _croak("Usage: @_\n") } + +=method new + + $file = App::GroupSecret::File->new($filepath); + +Construct a new keyfile object. + +=cut + +sub new { + my $class = shift; + my $filepath = shift or _croak(q{App::GroupSecret::File->new($filepath)}); + return bless {filepath => $filepath}, $class; +} + +=attr filepath + +Get the filepath of the keyfile. + +=cut + +sub filepath { shift->{filepath} } + +=method info + + $info = $file->info; + +Get a raw hashref with the contents of the keyfile. + +=cut + +sub info { + my $self = shift; + return $self->{info} ||= do { + if (-e $self->filepath) { + $self->load; + } + else { + $self->init; + } + }; +} + +=method init + + $info = $file->init; + +Get a hashref representing an empty keyfile, used for initializing a new keyfile. + +=cut + +sub init { + return { + keys => {}, + secret => undef, + version => $FILE_VERSION, + }; +} + +=method load + + $info = $file->load; + $info = $file->load($filepath); + +Load (or reload) the contents of a keyfile. + +=cut + +sub load { + my $self = shift; + my $filepath = shift || $self->filepath; + my $info = LoadFile($filepath) || {}; + $self->check($info); + $self->{info} = $info if !$filepath; + return $info; +} + +=method save + + $file->save; + $file->save($filepath); + +Save the keyfile to disk. + +=cut + +sub save { + my $self = shift; + my $filepath = shift || $self->filepath; + DumpFile($filepath, $self->info); + return $self; +} + +=method check + + $file->check; + $file->check($info); + +Check the file format of a keyfile to make sure this module can understand it. + +=cut + +sub check { + my $self = shift; + my $info = shift || $self->info; + + _croak 'Corrupt file: Bad type for root' if !$info || ref $info ne 'HASH'; + + my $version = $info->{version}; + _croak 'Unknown file version' if !$version || $version !~ /^\d+$/; + _croak 'Unsupported file version' if $FILE_VERSION < $version; + + _croak 'Corrupt file: Bad type for keys' if ref $info->{keys} ne 'HASH'; + + warn "The file has a secret but no keys to access it!\n" if $info->{secret} && !%{$info->{keys}}; + + return 1; +} + +=method keys + + $keys = $file->keys; + +Get a hashref of the keys from a keyfile. + +=method secret + + $secret = $file->secret; + +Get the secret from a keyfile as an encrypted string. + +=method version + + $version = $file->version + +Get the file format version. + +=cut + +sub keys { shift->info->{keys} } +sub secret { shift->info->{secret} } +sub version { shift->info->{version} } + +=method add_key + + $file->add_key($filepath); + +Add a key to the keyfile. + +=cut + +sub add_key { + my $self = shift; + my $public_key = shift or _usage(q{$file->add_key($public_key)}); + my $args = @_ == 1 ? shift : {@_}; + + my $keys = $self->keys; + + my $info = $args->{fingerprint_info} || read_openssh_key_fingerprint($public_key); + my $fingerprint = $info->{fingerprint}; + + my $key = { + comment => $info->{comment}, + filename => basename($public_key), + secret_passphrase => undef, + type => $info->{type}, + }; + + if ($args->{embed}) { + open(my $fh, '<', $public_key) or die "open failed: $!"; + $key->{content} = do { local $/; <$fh> }; + chomp $key->{content}; + } + + $keys->{$fingerprint} = $key; + + if ($self->secret) { + my $passphrase = $args->{passphrase} || $self->decrypt_secret_passphrase($args->{private_key}); + my $ciphertext = encrypt_rsa(\$passphrase, $public_key); + $key->{secret_passphrase} = $ciphertext; + } + + return wantarray ? ($fingerprint => $key) : $key; +} + +=method delete_key + + $file->delete_key($fingerprint); + +Delete a key from the keyfile. + +=cut + +sub delete_key { + my $self = shift; + my $fingerprint = shift; + delete $self->keys->{$fingerprint}; +} + +=method decrypt_secret + + $secret = $file->decrypt_secret(passphrase => $passphrase); + $secret = $file->decrypt_secret(private_key => $private_key); + +Get the decrypted secret. + +=cut + +sub decrypt_secret { + my $self = shift; + my $args = @_ == 1 ? shift : {@_}; + + $args->{passphrase} || $args->{private_key} or _usage(q{$file->decrypt_secret($private_key)}); + + my $passphrase = $args->{passphrase}; + $passphrase = $self->decrypt_secret_passphrase($args->{private_key}) if !$passphrase; + + my $ciphertext = $self->secret; + return decrypt_aes_256_cbc(\$ciphertext, $passphrase); +} + +=method decrypt_secret_passphrase + + $passphrase = $file->decrypt_secret_passphrase($private_key); + +Get the decrypted secret passphrase. + +=cut + +sub decrypt_secret_passphrase { + my $self = shift; + my $private_key = shift or _usage(q{$file->decrypt_secret_passphrase($private_key)}); + + my $info = read_openssh_key_fingerprint($private_key); + my $fingerprint = $info->{fingerprint}; + + my $keys = $self->keys; + if (my $key = $keys->{$fingerprint}) { + return decrypt_rsa(\$key->{secret_passphrase}, $private_key); + } + + die "The private key ($private_key) is not able to decrypt the keyfile.\n"; +} + +=method encrypt_secret + + $file->encrypt_secret($secret, $passphrase); + +Set the secret by encrypting it with a 256-bit passphrase. + +Passphrase must be 32 bytes. + +=cut + +sub encrypt_secret { + my $self = shift; + my $secret = shift or _usage(q{$file->encrypt_secret($secret)}); + my $passphrase = shift or _usage(q{$file->encrypt_secret($secret)}); + + my $ciphertext = encrypt_aes_256_cbc($secret, $passphrase); + $self->info->{secret} = $ciphertext; +} + +=method encrypt_secret_passphrase + + $file->encrypt_secret_passphrase($passphrase); + +Set the passphrase by encrypting it with each key in the keyfile. + +=cut + +sub encrypt_secret_passphrase { + my $self = shift; + my $passphrase = shift or _usage(q{$file->encrypt_secret_passphrase($passphrase)}); + + while (my ($fingerprint, $key) = each %{$self->keys}) { + local $key->{fingerprint} = $fingerprint; + my $pubkey = $self->find_public_key($key) or die 'Cannot find public key: ' . $self->format_key($key) . "\n"; + my $ciphertext = encrypt_rsa(\$passphrase, $pubkey); + $key->{secret_passphrase} = $ciphertext; + } +} + +=method find_public_key + + $filepath = $file->find_public_key($key); + +Get a path to the public key file for a key. + +=cut + +sub find_public_key { + my $self = shift; + my $key = shift or _usage(q{$file->find_public_key($key)}); + + if ($key->{content}) { + my $temp = File::Temp->new(UNLINK => 1); + print $temp $key->{content}; + close $temp; + $self->{"temp:$key->{fingerprint}"} = $temp; + return $temp->filename; + } + else { + my @dirs = split(/:/, $ENV{GROUPSECRET_PATH} || ".:keys:$ENV{HOME}/.ssh"); + for my $dir (@dirs) { + my $filepath = File::Spec->catfile($dir, $key->{filename}); + return $filepath if -f $filepath; + } + } +} + +=method format_key + + $str = $file->format_key($key); + +Get a one-line summary of a key. Format is " ". + +=cut + +sub format_key { + my $self = shift; + my $key = shift or _usage(q{$file->format_key($key)}); + + my $fingerprint = $key->{fingerprint} or _croak(q{Missing required field in key: fingerprint}); + my $comment = $key->{comment} || 'uncommented'; + + if ($fingerprint =~ /^[A-Fa-f0-9]{32}$/) { + $fingerprint = 'MD5:' . join(':', ($fingerprint =~ /../g )); + } + elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{27}$/) { + $fingerprint = "SHA1:$fingerprint"; + } + elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{43}$/) { + $fingerprint = "SHA256:$fingerprint"; + } + + return "$fingerprint $comment"; +} + +1; diff --git a/t/02-file.t b/t/02-file.t new file mode 100644 index 0000000..2976d92 --- /dev/null +++ b/t/02-file.t @@ -0,0 +1,52 @@ +#!/usr/bin/env perl + +use warnings; +use strict; + +use FindBin qw($Bin); + +use Test::More tests => 7; + +use App::GroupSecret::File; + +my $nonexistent = App::GroupSecret::File->new("$Bin/keyfiles/nonexistent.yml"); + +is_deeply $nonexistent->info, { + version => 1, + keys => {}, + secret => undef, +}, 'newly initialized file is empty'; + +my $empty = App::GroupSecret::File->new("$Bin/keyfiles/empty.yml"); + +is_deeply $empty->info, { + version => 1, + keys => {}, + secret => undef, +}, 'empty file info matches'; + +is $empty->secret, undef, 'empty secret is undef'; +is $empty->version, 1, 'empty version is one'; + +my $key1 = $empty->add_key("$Bin/keys/foo_rsa.pub"); +is_deeply $key1, { + comment => 'foo', + filename => 'foo_rsa.pub', + secret_passphrase => undef, + type => 'rsa', +}, 'add_key in scalar context works'; + +$empty->delete_key('89b3fb766cf9568ea81adfba1cba7d05'); +is_deeply $empty->keys, {}, 'file is empty again after delete_key'; + +my $basic = App::GroupSecret::File->new("$Bin/keyfiles/basic.yml"); + +is_deeply $basic->keys, { + '89b3fb766cf9568ea81adfba1cba7d05' => { + comment => 'foo', + filename => 'foo_rsa.pub', + secret_passphrase => undef, + type => 'rsa', + }, +}, 'keys accessor works'; + diff --git a/t/keyfiles/basic.yml b/t/keyfiles/basic.yml new file mode 100644 index 0000000..2301513 --- /dev/null +++ b/t/keyfiles/basic.yml @@ -0,0 +1,9 @@ +--- +keys: + 89b3fb766cf9568ea81adfba1cba7d05: + comment: foo + filename: foo_rsa.pub + secret_passphrase: ~ + type: rsa +secret: ~ +version: '1' diff --git a/t/keyfiles/empty.yml b/t/keyfiles/empty.yml new file mode 100644 index 0000000..a205ddd --- /dev/null +++ b/t/keyfiles/empty.yml @@ -0,0 +1,4 @@ +--- +secret: ~ +keys: {} +version: 1 diff --git a/t/keys/foo_rsa b/t/keys/foo_rsa new file mode 100644 index 0000000..0fbce92 --- /dev/null +++ b/t/keys/foo_rsa @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtMQ8lHUhFGceLK5r3PV/h1WAwomaMgbfYcBZhOfzqN4LNtM7 +GYXoUbBvENWuX5okDRKHxq2vC2oZs61gqsSPdtm+EJwFgzY+Hb28j9IhwK83yjdc +MZj7+dpohHKo1CEsT+rvK9LCGkLor0Q3st0dzyEpDlhE6xSFy0XC5AmbUrlKewUj +IcEO8BdR02BKm7+lEWF0mzWrLlNciUdKPstDTK6fvUa+CZK3414n8zensfkUgnUY +FDT/ol6EsPhwG7syOb/1ZWf4VlM9rlKaiFiOOlee3RTndwprNa+nInt/McAflMQz +jGsp2d4m408DIuZ0mdTURn7/dIuPIf/IOYKlmwIDAQABAoIBAQCbvyA7ARg5Tgdv +k/CXdmYkooTIGGrkg4tf26zFmFwVuQqMeD7JZNif2ZY4OQN+l35MTRTzF55kBUyT +xOQu/iBl1IGwKd2OCeRHF70pZXFzZQR6lGw4x4kC4y1+QJQ6AUL+sHrVlUdr/Q4i +RHKBB4axee63z1HCAfKtCzQ56hULleonMdzpjPCVJllDQhPaCmNtO2GybIMtn5wC +KcSXxTe/4af89GDmxAnE4JwqPhjCcaq+aTC84Us9JsNT+DbAgV5dc/b+2Wy3tzkB +fLHyszVJpEYbqjP50FfiwS2EEdzIwtkMrzc+dS+D4mn1dvDtm9fA13cq0QAVicTj +l2fPyZuRAoGBAOmBMghlMet2UmOQvBUOdJ4Jghknu6x3cIz+Bv+6G12T92147CL+ +ypZhiyO9iD7qZOtoIV4D7FtyC0wTsYAp4bz6HLfNUpkyJEbdebvQe8aC4hWNgb4+ +yqCSk3PqzhBiqydbdaqtlSt4FPVuAWRcKZjUU7rnj/mx0fUZoOxbBFYtAoGBAMYu +Y2cRkRQR2kOx9wacBHej+3iV5Typi1SAFMOdlki7jB4idVPyz/ZzcRDM61J4qcfr +BdCBTw0tn3wi0JhnncgOtV1DSIEz0OCiO6TOtQBiUXVTmW/yBUyRbISVLJ4QvZsm +Kg6LKPOT7dEwrVDZO1MPJOt6u8vxxsP/4dJzpU/nAoGBAJpVHfCWkewDBGlyXB2+ +tC1QM4DU1iIjv2ww6gdTxoqPJdZhOhHXPacvSXuR5d9PpOxCous0xJ+cPQNHcOY4 +yE7TMO/68UD39yovcCpGnciS8UM1iC9p6RtARd0zsIb78AvPU3I/0HwungupbZob +oBK3I7BBJNPwR8kr60TM04zxAoGAExyGGXpoMzdFhSG0YL7K736w0YAjCyaOeSeg +2PxpcbokWQOZrO7Nf0bCsIwSZXGdbdoMRX8y0GKF7sKsuwXDAXfppYTHiS4mBoOe +nNYSPmc808OsGE+Ok0Oy41Y/Zz7WChA0HhLtcA/j7zhyfkl0nx3mwY6kbZZzRJN4 +g4MDfiECgYEAw1PKKpTOwZVIYVLadpSSF6qO30MF0sZNL454kTvmc139Es69s391 +OvB1VHtehFV/LNIstdadvYgaiuiapG4smt65g0WKqL3+9gfyQU0k+NJH61AaXX54 +oVYcKjyUzT5w8gfE9g5w6AePrFfs3KPY9GFhWQHdcFu7DBMX8/VQqYw= +-----END RSA PRIVATE KEY----- diff --git a/t/keys/foo_rsa.pub b/t/keys/foo_rsa.pub new file mode 100644 index 0000000..a53e1b8 --- /dev/null +++ b/t/keys/foo_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0xDyUdSEUZx4srmvc9X+HVYDCiZoyBt9hwFmE5/Oo3gs20zsZhehRsG8Q1a5fmiQNEofGra8LahmzrWCqxI922b4QnAWDNj4dvbyP0iHArzfKN1wxmPv52miEcqjUISxP6u8r0sIaQuivRDey3R3PISkOWETrFIXLRcLkCZtSuUp7BSMhwQ7wF1HTYEqbv6URYXSbNasuU1yJR0o+y0NMrp+9Rr4JkrfjXifzN6ex+RSCdRgUNP+iXoSw+HAbuzI5v/VlZ/hWUz2uUpqIWI46V57dFOd3Cms1r6cie38xwB+UxDOMaynZ3ibjTwMi5nSZ1NRGfv90i48h/8g5gqWb foo