--- /dev/null
+
+# 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
+
--- /dev/null
+/.build
+/.perl-version
+/App-GroupSecret-*
--- /dev/null
+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
--- /dev/null
+Revision history for groupsecret.
+
+{{$NEXT}}
+
--- /dev/null
+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.
--- /dev/null
+
+# 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
+
--- /dev/null
+#!perl
+# PODNAME: groupsecret
+# ABSTRACT: A simple tool for maintaining a shared group secret
+
+=head1 SYNOPSIS
+
+ groupsecret [--version] [--help] [-f <filepath>] [-k <privatekey_path>]
+ <command> [<args>]
+
+ groupsecret add-key [--embed] [--update] <publickey_path> ...
+
+ groupsecret delete-key <fingerprint>|<publickey_path> ...
+
+ groupsecret list-keys
+
+ groupsecret set-secret [--keep-passphrase] <path>|-|rand:<num_bytes>
+
+ groupsecret [print-secret] [--no-decrypt]
+
+=head1 DESCRIPTION
+
+L<groupsecret> 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<ansible-vault(1)> password; see L</ansible-vault> 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<groupsecret> 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<STDOUT>, and exit.
+
+Alias: C<-v>
+
+=head2 --help
+
+Print the synopsis to C<STDOUT>, 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<GROUPSECRET_KEYFILE> or F<groupsecret.yml>.
+
+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</GROUPSECRET_PRIVATE_KEY>. 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</GROUPSECRET_PATH>.
+
+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<add-keys>
+
+=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</set-secret> command in order to change the secret.
+
+Aliases: C<delete-keys>, C<remove-key>, C<remove-keys>
+
+=head2 list-keys
+
+ groupsecret list-keys
+
+Prints the keys that have access to the secret contained in the keyfile to C<STDOUT>, one per line
+in the following format:
+
+ <fingerprint> <comment>
+
+=head2 set-secret
+
+ groupsecret set-secret path/to/secretfile.txt
+ groupsecret set-secret - <<END
+ > 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 <STDIN>, 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</GROUPSECRET_PATH>). 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<change-secret>, C<update-secret>
+
+=head2 print-secret
+
+ groupsecret print-secret
+ groupsecret print-secret --no-decrypt
+
+Print the secret contained in the keyfile to C<STDOUT>.
+
+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<show-secret>
+
+=head1 REQUIREMENTS
+
+=for :list
+* L<OpenSSH|https://www.openssh.com> (commands: L<ssh-keygen(1)>)
+* L<OpenSSL|https://www.openssl.org> (commands: L<openssl(1)>)
+
+=head1 INSTALL
+
+There are several ways to install groupsecret to your system. First, make sure you first have the
+L</REQUIREMENTS> installed.
+
+=head2 Using cpanm
+
+You can install groupsecret using L<cpanm>. If you have a local perl (plenv, perlbrew, etc.), you
+can just do this:
+
+ cpanm App::GroupSecret
+
+to install the F<groupsecret> 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<groupsecret> executable to a system directory, like F</usr/local/bin/groupsecret>
+(depending on your perl).
+
+=head2 Downloading just the executable
+
+You may also choose to download F<groupsecret> 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</--file=path> 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</--private-key=path> 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;
--- /dev/null
+
+name = App-GroupSecret
+author = Charles McGarvey <chazmcgarvey@brokenzipper.com>
+copyright_holder = Charles McGarvey
+copyright_year = 2017
+license = MIT
+
+[@Author::CCM]
+
--- /dev/null
+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<groupsecret> 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 $/; <STDIN> };
+ $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;
--- /dev/null
+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;
--- /dev/null
+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<groupsecret> 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 "<fingerprint> <comment>".
+
+=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;
--- /dev/null
+#!/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';
+
--- /dev/null
+---
+keys:
+ 89b3fb766cf9568ea81adfba1cba7d05:
+ comment: foo
+ filename: foo_rsa.pub
+ secret_passphrase: ~
+ type: rsa
+secret: ~
+version: '1'
--- /dev/null
+---
+secret: ~
+keys: {}
+version: 1
--- /dev/null
+-----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-----
--- /dev/null
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0xDyUdSEUZx4srmvc9X+HVYDCiZoyBt9hwFmE5/Oo3gs20zsZhehRsG8Q1a5fmiQNEofGra8LahmzrWCqxI922b4QnAWDNj4dvbyP0iHArzfKN1wxmPv52miEcqjUISxP6u8r0sIaQuivRDey3R3PISkOWETrFIXLRcLkCZtSuUp7BSMhwQ7wF1HTYEqbv6URYXSbNasuU1yJR0o+y0NMrp+9Rr4JkrfjXifzN6ex+RSCdRgUNP+iXoSw+HAbuzI5v/VlZ/hWUz2uUpqIWI46V57dFOd3Cms1r6cie38xwB+UxDOMaynZ3ibjTwMi5nSZ1NRGfv90i48h/8g5gqWb foo