--- /dev/null
+sudo: false
+language: perl
+perl:
+ - '5.26'
+ - '5.24'
+ - '5.22'
+ - '5.20'
+ - '5.18'
+ - '5.16'
+ - '5.14'
+ - '5.12'
+ - '5.10'
+matrix:
+ fast_finish: true
+branches:
+ only: /^(dist|build\/.*)$/
+before_install:
+ - rm .travis.yml
+ - export AUTHOR_TESTING=0
+install:
+ - cpanm --installdeps --verbose .
--- /dev/null
+Revision history for groupsecret.
+
+0.303 2018-02-14 09:28:23-07:00 MST7MDT
+
+ * Improve error messages.
+ * Allow finding pubkeys that are symlinks.
+
+0.302 2017-12-02 11:28:57-07:00 MST7MDT
+
+ * Documentation fixes.
+ * Documented the ansible-vault use case.
+
+0.301 2017-11-30 20:46:23-07:00 MST7MDT
+
+ * Add support for ssh-keygen versions that don't have the -E fag.
+ * Explicitly use sha256 digest for aes-256-cbc passphrase.
+
+0.300 2017-11-29 23:54:31-07:00 MST7MDT
+
+ * First release.
+ * Command-line interface is stable.
+
--- /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 file was automatically generated by Dist::Zilla::Plugin::Manifest v6.010.
+.travis.yml
+Changes
+LICENSE
+MANIFEST
+META.json
+META.yml
+Makefile.PL
+README
+bin/groupsecret
+lib/App/GroupSecret.pm
+lib/App/GroupSecret/Crypt.pm
+lib/App/GroupSecret/File.pm
+t/00-compile.t
+t/00-report-prereqs.dd
+t/00-report-prereqs.t
+t/02-file.t
+t/keyfiles/basic.yml
+t/keyfiles/empty.yml
+t/keys/foo_rsa
+t/keys/foo_rsa.pub
+xt/author/clean-namespaces.t
+xt/author/critic.t
+xt/author/eol.t
+xt/author/no-tabs.t
+xt/author/pod-coverage.t
+xt/author/pod-no404s.t
+xt/author/pod-syntax.t
+xt/author/portability.t
+xt/release/cpan-changes.t
+xt/release/distmeta.t
+xt/release/minimum-version.t
--- /dev/null
+{
+ "abstract" : "A simple tool for maintaining a shared group secret",
+ "author" : [
+ "Charles McGarvey <chazmcgarvey@brokenzipper.com>"
+ ],
+ "dynamic_config" : 0,
+ "generated_by" : "Dist::Zilla version 6.010, CPAN::Meta::Converter version 2.150010",
+ "license" : [
+ "mit"
+ ],
+ "meta-spec" : {
+ "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec",
+ "version" : 2
+ },
+ "name" : "App-GroupSecret",
+ "no_index" : {
+ "directory" : [
+ "eg",
+ "share",
+ "shares",
+ "t",
+ "xt"
+ ]
+ },
+ "prereqs" : {
+ "configure" : {
+ "requires" : {
+ "ExtUtils::MakeMaker" : "0"
+ }
+ },
+ "develop" : {
+ "requires" : {
+ "Dist::Zilla" : "5",
+ "Dist::Zilla::PluginBundle::Author::CCM" : "0",
+ "Pod::Coverage::TrustPod" : "0",
+ "Software::License::MIT" : "0",
+ "Test::CPAN::Changes" : "0.19",
+ "Test::CPAN::Meta" : "0",
+ "Test::CleanNamespaces" : "0.15",
+ "Test::EOL" : "0",
+ "Test::MinimumVersion" : "0",
+ "Test::More" : "0.96",
+ "Test::NoTabs" : "0",
+ "Test::Perl::Critic" : "0",
+ "Test::Pod" : "1.41",
+ "Test::Pod::Coverage" : "1.08",
+ "Test::Pod::No404s" : "0",
+ "Test::Portability::Files" : "0"
+ }
+ },
+ "runtime" : {
+ "requires" : {
+ "Carp" : "0",
+ "Exporter" : "0",
+ "File::Basename" : "0",
+ "File::Spec" : "0",
+ "File::Temp" : "0",
+ "Getopt::Long" : "0",
+ "IPC::Open2" : "0",
+ "IPC::Open3" : "0",
+ "MIME::Base64" : "0",
+ "Pod::Usage" : "0",
+ "Symbol" : "0",
+ "YAML::Tiny" : "0",
+ "namespace::clean" : "0",
+ "strict" : "0",
+ "warnings" : "0"
+ }
+ },
+ "test" : {
+ "recommends" : {
+ "CPAN::Meta" : "2.120900"
+ },
+ "requires" : {
+ "ExtUtils::MakeMaker" : "0",
+ "File::Spec" : "0",
+ "FindBin" : "0",
+ "IO::Handle" : "0",
+ "IPC::Open3" : "0",
+ "Test::More" : "0",
+ "perl" : "5.006"
+ }
+ }
+ },
+ "provides" : {
+ "App::GroupSecret" : {
+ "file" : "lib/App/GroupSecret.pm",
+ "version" : "0.303"
+ },
+ "App::GroupSecret::Crypt" : {
+ "file" : "lib/App/GroupSecret/Crypt.pm",
+ "version" : "0.303"
+ },
+ "App::GroupSecret::File" : {
+ "file" : "lib/App/GroupSecret/File.pm",
+ "version" : "0.303"
+ }
+ },
+ "release_status" : "stable",
+ "resources" : {
+ "bugtracker" : {
+ "web" : "https://github.com/chazmcgarvey/groupsecret/issues"
+ },
+ "homepage" : "https://github.com/chazmcgarvey/groupsecret",
+ "repository" : {
+ "type" : "git",
+ "url" : "https://github.com/chazmcgarvey/groupsecret.git",
+ "web" : "https://github.com/chazmcgarvey/groupsecret"
+ }
+ },
+ "version" : "0.303",
+ "x_authority" : "cpan:CCM",
+ "x_serialization_backend" : "Cpanel::JSON::XS version 3.0239"
+}
+
--- /dev/null
+---
+abstract: 'A simple tool for maintaining a shared group secret'
+author:
+ - 'Charles McGarvey <chazmcgarvey@brokenzipper.com>'
+build_requires:
+ ExtUtils::MakeMaker: '0'
+ File::Spec: '0'
+ FindBin: '0'
+ IO::Handle: '0'
+ IPC::Open3: '0'
+ Test::More: '0'
+ perl: '5.006'
+configure_requires:
+ ExtUtils::MakeMaker: '0'
+dynamic_config: 0
+generated_by: 'Dist::Zilla version 6.010, CPAN::Meta::Converter version 2.150010'
+license: mit
+meta-spec:
+ url: http://module-build.sourceforge.net/META-spec-v1.4.html
+ version: '1.4'
+name: App-GroupSecret
+no_index:
+ directory:
+ - eg
+ - share
+ - shares
+ - t
+ - xt
+provides:
+ App::GroupSecret:
+ file: lib/App/GroupSecret.pm
+ version: '0.303'
+ App::GroupSecret::Crypt:
+ file: lib/App/GroupSecret/Crypt.pm
+ version: '0.303'
+ App::GroupSecret::File:
+ file: lib/App/GroupSecret/File.pm
+ version: '0.303'
+requires:
+ Carp: '0'
+ Exporter: '0'
+ File::Basename: '0'
+ File::Spec: '0'
+ File::Temp: '0'
+ Getopt::Long: '0'
+ IPC::Open2: '0'
+ IPC::Open3: '0'
+ MIME::Base64: '0'
+ Pod::Usage: '0'
+ Symbol: '0'
+ YAML::Tiny: '0'
+ namespace::clean: '0'
+ strict: '0'
+ warnings: '0'
+resources:
+ bugtracker: https://github.com/chazmcgarvey/groupsecret/issues
+ homepage: https://github.com/chazmcgarvey/groupsecret
+ repository: https://github.com/chazmcgarvey/groupsecret.git
+version: '0.303'
+x_authority: cpan:CCM
+x_serialization_backend: 'YAML::Tiny version 1.70'
--- /dev/null
+# This file was automatically generated by Dist::Zilla::Plugin::MakeMaker v6.010.
+use strict;
+use warnings;
+
+use 5.006;
+
+use ExtUtils::MakeMaker;
+
+my %WriteMakefileArgs = (
+ "ABSTRACT" => "A simple tool for maintaining a shared group secret",
+ "AUTHOR" => "Charles McGarvey <chazmcgarvey\@brokenzipper.com>",
+ "CONFIGURE_REQUIRES" => {
+ "ExtUtils::MakeMaker" => 0
+ },
+ "DISTNAME" => "App-GroupSecret",
+ "EXE_FILES" => [
+ "bin/groupsecret"
+ ],
+ "LICENSE" => "mit",
+ "MIN_PERL_VERSION" => "5.006",
+ "NAME" => "App::GroupSecret",
+ "PREREQ_PM" => {
+ "Carp" => 0,
+ "Exporter" => 0,
+ "File::Basename" => 0,
+ "File::Spec" => 0,
+ "File::Temp" => 0,
+ "Getopt::Long" => 0,
+ "IPC::Open2" => 0,
+ "IPC::Open3" => 0,
+ "MIME::Base64" => 0,
+ "Pod::Usage" => 0,
+ "Symbol" => 0,
+ "YAML::Tiny" => 0,
+ "namespace::clean" => 0,
+ "strict" => 0,
+ "warnings" => 0
+ },
+ "TEST_REQUIRES" => {
+ "ExtUtils::MakeMaker" => 0,
+ "File::Spec" => 0,
+ "FindBin" => 0,
+ "IO::Handle" => 0,
+ "IPC::Open3" => 0,
+ "Test::More" => 0
+ },
+ "VERSION" => "0.303",
+ "test" => {
+ "TESTS" => "t/*.t"
+ }
+);
+
+
+my %FallbackPrereqs = (
+ "Carp" => 0,
+ "Exporter" => 0,
+ "ExtUtils::MakeMaker" => 0,
+ "File::Basename" => 0,
+ "File::Spec" => 0,
+ "File::Temp" => 0,
+ "FindBin" => 0,
+ "Getopt::Long" => 0,
+ "IO::Handle" => 0,
+ "IPC::Open2" => 0,
+ "IPC::Open3" => 0,
+ "MIME::Base64" => 0,
+ "Pod::Usage" => 0,
+ "Symbol" => 0,
+ "Test::More" => 0,
+ "YAML::Tiny" => 0,
+ "namespace::clean" => 0,
+ "strict" => 0,
+ "warnings" => 0
+);
+
+
+unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
+ delete $WriteMakefileArgs{TEST_REQUIRES};
+ delete $WriteMakefileArgs{BUILD_REQUIRES};
+ $WriteMakefileArgs{PREREQ_PM} = \%FallbackPrereqs;
+}
+
+delete $WriteMakefileArgs{CONFIGURE_REQUIRES}
+ unless eval { ExtUtils::MakeMaker->VERSION(6.52) };
+
+WriteMakefile(%WriteMakefileArgs);
--- /dev/null
+NAME
+
+ groupsecret - A simple tool for maintaining a shared group secret
+
+VERSION
+
+ version 0.303
+
+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]
+
+DESCRIPTION
+
+ 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 ansible-vault(1)
+ password; see "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).
+
+ 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.
+
+OPTIONS
+
+ --version
+
+ Print the program name and version to STDOUT, and exit.
+
+ Alias: -v
+
+ --help
+
+ Print the synopsis to STDOUT, and exit.
+
+ Alias: -h
+
+ --file=path
+
+ Specify a path to a keyfile which stores a secret and keys.
+
+ Defaults to the value of the environment variable "GROUPSECRET_KEYFILE"
+ or groupsecret.yml.
+
+ Alias: -f
+
+ --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
+ "GROUPSECRET_PRIVATE_KEY" or ~/.ssh/id_rsa.
+
+ Alias: -k
+
+COMMANDS
+
+ 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 --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 "GROUPSECRET_PATH".
+
+ If the --update option is used and a key with the same fingerprint is
+ added, the new key will replace 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: add-keys
+
+ 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 "set-secret" command in order to change the secret.
+
+ Aliases: delete-keys, remove-key, remove-keys
+
+ list-keys
+
+ groupsecret list-keys
+
+ Prints the keys that have access to the secret contained in the keyfile
+ to STDOUT, one per line in the following format:
+
+ <fingerprint> <comment>
+
+ 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
+ "GROUPSECRET_PATH"). If for some reason you want to protect the new
+ secret with the current passphrase, use the --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: change-secret, update-secret
+
+ print-secret
+
+ groupsecret print-secret
+ groupsecret print-secret --no-decrypt
+
+ Print the secret contained in the keyfile to STDOUT.
+
+ If the --no-decrypt option is used, the secret will be printed in its
+ encrypted form.
+
+ This requires a private key.
+
+ Aliases: (no command), show-secret
+
+REQUIREMENTS
+
+ * OpenSSH <https://www.openssh.com> (commands: ssh-keygen(1))
+
+ * OpenSSL <https://www.openssl.org> (commands: openssl(1))
+
+INSTALL
+
+ There are a few ways to install groupsecret to your system. First, make
+ sure you first have the "REQUIREMENTS" installed.
+
+ Using cpanm
+
+ You can install groupsecret using cpanm. If you have a local perl
+ (plenv, perlbrew, etc.), you can just do this:
+
+ cpanm App::GroupSecret
+
+ to install the groupsecret executable and its Perl module dependencies.
+ The executable will be installed to your perl's bin path, like
+ ~/perl5/perlbrew/bin/groupsecret.
+
+ If you're installing to your system perl, you can do:
+
+ cpanm --sudo App::GroupSecret
+
+ to install the groupsecret executable to a system directory, like
+ /usr/local/bin/groupsecret (depending on your perl).
+
+ 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
+
+ENVIRONMENT
+
+ GROUPSECRET_KEYFILE
+
+ If set, this program will use the value as a path to the keyfile. The
+ "--file=path" option takes precedence if used.
+
+ GROUPSECRET_PRIVATE_KEY
+
+ If set, this program will use the value as a path to private key used
+ for decryption. The "--private-key=path" option takes precedence if
+ used.
+
+ 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 .:keys:$HOME/.ssh.
+
+EXAMPLES
+
+ ansible-vault
+
+ Ansible Vault <http://docs.ansible.com/ansible/latest/vault.html> is a
+ great way to securely store secret configuration variables for use in
+ your playbooks. Vaults are secured using a password, which is okay if
+ you're the only one who will need to unlock the Vault, but as soon as
+ you add team members who also need to access the Vault you are then
+ faced with how to manage knowledge of the password. When a team member
+ leaves, you'll also need to change the Vault password which means
+ you'll need a way to communicate the change to other team members who
+ also have access. This becomes a burden to manage.
+
+ You can use groupsecret to manage this very easily by storing the Vault
+ password in a groupsecret keyfile. That way, you can add or remove keys
+ and change the secret (the Vault password) at any time without
+ affecting the team members that still have access. Team members always
+ use their own SSH2 RSA keys to unlock the Vault, so no new password
+ ever needs to be communicated out.
+
+ To set this up, first create a keyfile with the public keys of everyone
+ on your team:
+
+ groupsecret -f vault-password.yml add-keys keys/*_rsa.pub
+
+ Then set the secret in the keyfile to a long random number:
+
+ groupsecret -f vault-password.yml set-secret rand:48
+
+ This will be the Ansible Vault password. You can see it if you want
+ using the "print-secret" command, but you don't need to.
+
+ Then we'll take advantage of the fact that an Ansible Vault password
+ file can be an executable program that prints the Vault password to
+ STDOUT. Create a file named vault-password with the following script,
+ and make it executable (chmod +x vault-password):
+
+ #!/bin/sh
+ # Use groupsecret <https://github.com/chazmcgarvey/groupsecret> to access the Vault password
+ exec ${GROUPSECRET:-groupsecret} -f vault-password.yml print-secret
+
+ Commit both vault-password and vault-password.yml to your repository.
+
+ Now use ansible-vault(1) to add files to the Vault:
+
+ ansible-vault --vault-id=vault-password encrypt foo.yml bar.yml baz.yml
+
+ These examples show the Ansible 2.4+ syntax, but it can be adapted for
+ earlier versions. The significant part of this command is
+ --vault-id=vault-password which refers to the executable script we
+ created earlier. You can use that argument with other ansible-vault
+ commands to view or edit the encrypted files.
+
+ You can also pass that same argument to ansible-playbook(1) in order to
+ use the Vault in playbooks that refer to the encrypted variables:
+
+ ansible-playbook -i myinventory --vault-id=vault-password site.yml
+
+ What this does is execute vault-password which executes groupsecret to
+ print the secret contained in the vault-password.yml file (which is
+ actually the Vault password) to STDOUT. In order to do this,
+ groupsecret will decrypt the keyfile passphrase using any one of the
+ private keys that have associated public keys added to the keyfile.
+
+ That's it! Pretty easy.
+
+ If and when you need to change the Vault password (such as when a team
+ member leaves), you can follow this procedure which is probably mostly
+ self-explanatory:
+
+ groupsecret -f vault-password.yml delete-key keys/revoked/jdoe_rsa.pub
+ groupsecret -f vault-password.yml print-secret >old-vault-password.txt
+ groupsecret -f vault-password.yml set-secret rand:48
+ echo "New Vault password: $(groupsecret -f vault-password.yml)"
+ ansible-vault --vault-id=old-vault-password.txt rekey foo.yml bar.yml baz.yml
+ # You will be prompted for the new Vault password which you can copy from the output above.
+ rm -f old-vault-password.txt
+
+ This removes access to the keyfile secret and to the Ansible Vault.
+ Don't forget that you may also want to change the variables being
+ protected by the Vault. After all, those secrets are the actual things
+ we're protecting by doing all of this, and an exiting team member may
+ have decided to take a copy of those variables for himself before
+ leaving.
+
+BUGS
+
+ Please report any bugs or feature requests on the bugtracker website
+ https://github.com/chazmcgarvey/groupsecret/issues
+
+ When submitting a bug or request, please include a test-file or a patch
+ to an existing test-file that illustrates the bug or desired feature.
+
+AUTHOR
+
+ Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+COPYRIGHT AND LICENSE
+
+ This software is Copyright (c) 2017 by Charles McGarvey.
+
+ This is free software, licensed under:
+
+ The MIT (X11) License
+
--- /dev/null
+#!perl
+# PODNAME: groupsecret
+# ABSTRACT: A simple tool for maintaining a shared group secret
+
+
+use warnings FATAL => 'all';
+use strict;
+
+our $VERSION = '0.303'; # VERSION
+
+use App::GroupSecret;
+
+App::GroupSecret->new->main(@ARGV);
+exit;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+groupsecret - A simple tool for maintaining a shared group secret
+
+=head1 VERSION
+
+version 0.303
+
+=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).
+
+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 L</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> or 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
+replace 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
+
+=over 4
+
+=item *
+
+L<OpenSSH|https://www.openssh.com> (commands: L<ssh-keygen(1)>)
+
+=item *
+
+L<OpenSSL|https://www.openssl.org> (commands: L<openssl(1)>)
+
+=back
+
+=head1 INSTALL
+
+There are a few 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 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 used.
+
+=head2 GROUPSECRET_PRIVATE_KEY
+
+If set, this program will use the value as a path to private key used for decryption. The
+L</--private-key=path> option takes precedence if 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
+
+L<Ansible Vault|http://docs.ansible.com/ansible/latest/vault.html> is a great way to securely store
+secret configuration variables for use in your playbooks. Vaults are secured using a password, which
+is okay if you're the only one who will need to unlock the Vault, but as soon as you add team
+members who also need to access the Vault you are then faced with how to manage knowledge of the
+password. When a team member leaves, you'll also need to change the Vault password which means
+you'll need a way to communicate the change to other team members who also have access. This becomes
+a burden to manage.
+
+You can use groupsecret to manage this very easily by storing the Vault password in a groupsecret
+keyfile. That way, you can add or remove keys and change the secret (the Vault password) at any time
+without affecting the team members that still have access. Team members always use their own SSH2
+RSA keys to unlock the Vault, so no new password ever needs to be communicated out.
+
+To set this up, first create a keyfile with the public keys of everyone on your team:
+
+ groupsecret -f vault-password.yml add-keys keys/*_rsa.pub
+
+Then set the secret in the keyfile to a long random number:
+
+ groupsecret -f vault-password.yml set-secret rand:48
+
+This will be the Ansible Vault password. You can see it if you want using the L</print-secret>
+command, but you don't need to.
+
+Then we'll take advantage of the fact that an Ansible Vault password file can be an executable
+program that prints the Vault password to C<STDOUT>. Create a file named F<vault-password> with the
+following script, and make it executable (C<chmod +x vault-password>):
+
+ #!/bin/sh
+ # Use groupsecret <https://github.com/chazmcgarvey/groupsecret> to access the Vault password
+ exec ${GROUPSECRET:-groupsecret} -f vault-password.yml print-secret
+
+Commit both F<vault-password> and F<vault-password.yml> to your repository.
+
+Now use L<ansible-vault(1)> to add files to the Vault:
+
+ ansible-vault --vault-id=vault-password encrypt foo.yml bar.yml baz.yml
+
+These examples show the Ansible 2.4+ syntax, but it can be adapted for earlier versions. The
+significant part of this command is C<--vault-id=vault-password> which refers to the executable
+script we created earlier. You can use that argument with other ansible-vault commands to view or
+edit the encrypted files.
+
+You can also pass that same argument to L<ansible-playbook(1)> in order to use the Vault in
+playbooks that refer to the encrypted variables:
+
+ ansible-playbook -i myinventory --vault-id=vault-password site.yml
+
+What this does is execute F<vault-password> which executes groupsecret to print the secret contained
+in the F<vault-password.yml> file (which is actually the Vault password) to C<STDOUT>. In order to
+do this, groupsecret will decrypt the keyfile passphrase using any one of the private keys that have
+associated public keys added to the keyfile.
+
+That's it! Pretty easy.
+
+If and when you need to change the Vault password (such as when a team member leaves), you can
+follow this procedure which is probably mostly self-explanatory:
+
+ groupsecret -f vault-password.yml delete-key keys/revoked/jdoe_rsa.pub
+ groupsecret -f vault-password.yml print-secret >old-vault-password.txt
+ groupsecret -f vault-password.yml set-secret rand:48
+ echo "New Vault password: $(groupsecret -f vault-password.yml)"
+ ansible-vault --vault-id=old-vault-password.txt rekey foo.yml bar.yml baz.yml
+ # You will be prompted for the new Vault password which you can copy from the output above.
+ rm -f old-vault-password.txt
+
+This removes access to the keyfile secret and to the Ansible Vault. Don't forget that you may also
+want to change the variables being protected by the Vault. After all, those secrets are the actual
+things we're protecting by doing all of this, and an exiting team member may have decided to take
+a copy of those variables for himself before leaving.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::GroupSecret;
+# ABSTRACT: A simple tool for maintaining a shared group secret
+
+
+use warnings;
+use strict;
+
+our $VERSION = '0.303'; # 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;
+ my $filepath = $file->filepath;
+ die "No keyfile '$filepath' exists -- use the \`add-key' command to create one.\n"
+ unless -e $filepath && !-d $filepath;
+ die "No secret in keyfile '$filepath' exists -- 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;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::GroupSecret - A simple tool for maintaining a shared group secret
+
+=head1 VERSION
+
+version 0.303
+
+=head1 DESCRIPTION
+
+This module is part of the command-line interface for managing keyfiles.
+
+See L<groupsecret> for documentation.
+
+=head1 METHODS
+
+=head2 new
+
+ $script = App::GroupSecret->new;
+
+Construct a new script object.
+
+=head2 main
+
+ $script->main(@ARGV);
+
+Run a command with the given command-line arguments.
+
+=head2 filepath
+
+ $filepath = $script->filepath;
+
+Get the path to the keyfile.
+
+=head2 file
+
+ $file = $script->file;
+
+Get the L<App::GroupSecret::File> instance for the keyfile.
+
+=head2 private_key
+
+ $filepath = $script->private_key;
+
+Get the path to a private key used to decrypt the keyfile.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::GroupSecret::Crypt;
+# ABSTRACT: Collection of crypto-related subroutines
+
+use warnings;
+use strict;
+
+our $VERSION = '0.303'; # VERSION
+
+use Exporter qw(import);
+use File::Temp;
+use IPC::Open2;
+use IPC::Open3;
+use Symbol qw(gensym);
+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
+);
+
+our $OPENSSL = 'openssl';
+our $SSH_KEYGEN = 'ssh-keygen';
+
+sub _croak { require Carp; Carp::croak(@_) }
+sub _usage { _croak("Usage: @_\n") }
+
+
+sub generate_secure_random_bytes {
+ my $size = shift or _usage(q{generate_secure_random_bytes($num_bytes)});
+
+ my @cmd = ($OPENSSL, 'rand', $size);
+
+ my $out;
+ my $pid = open2($out, undef, @cmd);
+
+ 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> };
+}
+
+
+sub read_openssh_public_key {
+ my $filepath = shift or _usage(q{read_openssh_public_key($filepath)});
+
+ my @cmd = ($SSH_KEYGEN, qw{-e -m PKCS8 -f}, $filepath);
+
+ my $out;
+ my $pid = open2($out, undef, @cmd);
+
+ 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> };
+}
+
+
+sub read_openssh_key_fingerprint {
+ my $filepath = shift or _usage(q{read_openssh_key_fingerprint($filepath)});
+
+ # try with the -E flag first
+ my @cmd = ($SSH_KEYGEN, qw{-l -E md5 -f}, $filepath);
+
+ my $out;
+ my $err = gensym;
+ my $pid = open3(undef, $out, $err, @cmd);
+
+ waitpid($pid, 0);
+ my $status = $?;
+
+ my $exit_code = $status >> 8;
+ if ($exit_code != 0) {
+ my $error_str = do { local $/; <$err> };
+ _croak 'Failed to read SSH2 key fingerprint' if $error_str !~ /unknown option -- E/s;
+
+ @cmd = ($SSH_KEYGEN, qw{-l -f}, $filepath);
+
+ undef $out;
+ $pid = open2($out, undef, @cmd);
+
+ waitpid($pid, 0);
+ $status = $?;
+
+ $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),
+ };
+}
+
+
+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 = ($OPENSSL, qw{rsautl -decrypt -oaep -in}, $filepath, '-inkey', $privkey);
+ push @cmd, ('-out', $outfile) if $outfile;
+
+ my $out;
+ my $pid = open2($out, undef, @cmd);
+
+ waitpid($pid, 0);
+ my $status = $?;
+
+ my $exit_code = $status >> 8;
+ _croak 'Failed to decrypt ciphertext' if $exit_code != 0;
+
+ return do { local $/; <$out> };
+}
+
+
+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 = ($OPENSSL, qw{rsautl -encrypt -oaep -pubin -inkey}, $keypath, '-in', $filepath);
+ push @cmd, ('-out', $outfile) if $outfile;
+
+ my $out;
+ my $pid = open2($out, undef, @cmd);
+
+ waitpid($pid, 0);
+ my $status = $?;
+
+ my $exit_code = $status >> 8;
+ _croak 'Failed to encrypt plaintext' if $exit_code != 0;
+
+ return do { local $/; <$out> };
+}
+
+
+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 = ($OPENSSL, qw{aes-256-cbc -d -pass stdin -md sha256 -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> };
+}
+
+
+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 = ($OPENSSL, qw{aes-256-cbc -pass stdin -md sha256 -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;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::GroupSecret::Crypt - Collection of crypto-related subroutines
+
+=head1 VERSION
+
+version 0.303
+
+=head1 FUNCTIONS
+
+=head2 generate_secure_random_bytes
+
+ $bytes = generate_secure_random_bytes($num_bytes);
+
+Get a certain number of secure random bytes.
+
+=head2 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).
+
+=head2 read_openssh_key_fingerprint
+
+ $fingerprint = read_openssh_key_fingerprint($filepath);
+
+Get the fingerprint of an OpenSSH private or public key.
+
+=head2 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.
+
+=head2 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.
+
+=head2 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.
+
+=head2 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.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::GroupSecret::File;
+# ABSTRACT: Reading and writing groupsecret keyfiles
+
+
+use warnings;
+use strict;
+
+our $VERSION = '0.303'; # 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") }
+
+
+sub new {
+ my $class = shift;
+ my $filepath = shift or _croak(q{App::GroupSecret::File->new($filepath)});
+ return bless {filepath => $filepath}, $class;
+}
+
+
+sub filepath { shift->{filepath} }
+
+
+sub info {
+ my $self = shift;
+ return $self->{info} ||= do {
+ if (-e $self->filepath) {
+ $self->load;
+ }
+ else {
+ $self->init;
+ }
+ };
+}
+
+
+sub init {
+ return {
+ keys => {},
+ secret => undef,
+ version => $FILE_VERSION,
+ };
+}
+
+
+sub load {
+ my $self = shift;
+ my $filepath = shift || $self->filepath;
+ my $info = LoadFile($filepath) || {};
+ $self->check($info);
+ $self->{info} = $info if !$filepath;
+ return $info;
+}
+
+
+sub save {
+ my $self = shift;
+ my $filepath = shift || $self->filepath;
+ DumpFile($filepath, $self->info);
+ return $self;
+}
+
+
+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;
+}
+
+
+sub keys { shift->info->{keys} }
+sub secret { shift->info->{secret} }
+sub version { shift->info->{version} }
+
+
+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;
+}
+
+
+sub delete_key {
+ my $self = shift;
+ my $fingerprint = shift;
+ delete $self->keys->{$fingerprint};
+}
+
+
+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);
+}
+
+
+sub decrypt_secret_passphrase {
+ my $self = shift;
+ my $private_key = shift or _usage(q{$file->decrypt_secret_passphrase($private_key)});
+
+ die "Private key '$private_key' not found.\n" unless -e $private_key && !-d $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 "Private key '$private_key' not able to decrypt the keyfile.\n";
+}
+
+
+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;
+}
+
+
+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;
+ }
+}
+
+
+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 -e $filepath && !-d $filepath;
+ }
+ }
+}
+
+
+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;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::GroupSecret::File - Reading and writing groupsecret keyfiles
+
+=head1 VERSION
+
+version 0.303
+
+=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 ATTRIBUTES
+
+=head2 filepath
+
+Get the filepath of the keyfile.
+
+=head1 METHODS
+
+=head2 new
+
+ $file = App::GroupSecret::File->new($filepath);
+
+Construct a new keyfile object.
+
+=head2 info
+
+ $info = $file->info;
+
+Get a raw hashref with the contents of the keyfile.
+
+=head2 init
+
+ $info = $file->init;
+
+Get a hashref representing an empty keyfile, used for initializing a new keyfile.
+
+=head2 load
+
+ $info = $file->load;
+ $info = $file->load($filepath);
+
+Load (or reload) the contents of a keyfile.
+
+=head2 save
+
+ $file->save;
+ $file->save($filepath);
+
+Save the keyfile to disk.
+
+=head2 check
+
+ $file->check;
+ $file->check($info);
+
+Check the file format of a keyfile to make sure this module can understand it.
+
+=head2 keys
+
+ $keys = $file->keys;
+
+Get a hashref of the keys from a keyfile.
+
+=head2 secret
+
+ $secret = $file->secret;
+
+Get the secret from a keyfile as an encrypted string.
+
+=head2 version
+
+ $version = $file->version
+
+Get the file format version.
+
+=head2 add_key
+
+ $file->add_key($filepath);
+
+Add a key to the keyfile.
+
+=head2 delete_key
+
+ $file->delete_key($fingerprint);
+
+Delete a key from the keyfile.
+
+=head2 decrypt_secret
+
+ $secret = $file->decrypt_secret(passphrase => $passphrase);
+ $secret = $file->decrypt_secret(private_key => $private_key);
+
+Get the decrypted secret.
+
+=head2 decrypt_secret_passphrase
+
+ $passphrase = $file->decrypt_secret_passphrase($private_key);
+
+Get the decrypted secret passphrase.
+
+=head2 encrypt_secret
+
+ $file->encrypt_secret($secret, $passphrase);
+
+Set the secret by encrypting it with a 256-bit passphrase.
+
+Passphrase must be 32 bytes.
+
+=head2 encrypt_secret_passphrase
+
+ $file->encrypt_secret_passphrase($passphrase);
+
+Set the passphrase by encrypting it with each key in the keyfile.
+
+=head2 find_public_key
+
+ $filepath = $file->find_public_key($key);
+
+Get a path to the public key file for a key.
+
+=head2 format_key
+
+ $str = $file->format_key($key);
+
+Get a one-line summary of a key. Format is "<fingerprint> <comment>".
+
+=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
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+use 5.006;
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::Compile 2.057
+
+use Test::More;
+
+plan tests => 4 + ($ENV{AUTHOR_TESTING} ? 1 : 0);
+
+my @module_files = (
+ 'App/GroupSecret.pm',
+ 'App/GroupSecret/Crypt.pm',
+ 'App/GroupSecret/File.pm'
+);
+
+my @scripts = (
+ 'bin/groupsecret'
+);
+
+# no fake home requested
+
+my @switches = (
+ -d 'blib' ? '-Mblib' : '-Ilib',
+);
+
+use File::Spec;
+use IPC::Open3;
+use IO::Handle;
+
+open my $stdin, '<', File::Spec->devnull or die "can't open devnull: $!";
+
+my @warnings;
+for my $lib (@module_files)
+{
+ # see L<perlfaq8/How can I capture STDERR from an external command?>
+ my $stderr = IO::Handle->new;
+
+ diag('Running: ', join(', ', map { my $str = $_; $str =~ s/'/\\'/g; q{'} . $str . q{'} }
+ $^X, @switches, '-e', "require q[$lib]"))
+ if $ENV{PERL_COMPILE_TEST_DEBUG};
+
+ my $pid = open3($stdin, '>&STDERR', $stderr, $^X, @switches, '-e', "require q[$lib]");
+ binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+ my @_warnings = <$stderr>;
+ waitpid($pid, 0);
+ is($?, 0, "$lib loaded ok");
+
+ shift @_warnings if @_warnings and $_warnings[0] =~ /^Using .*\bblib/
+ and not eval { +require blib; blib->VERSION('1.01') };
+
+ if (@_warnings)
+ {
+ warn @_warnings;
+ push @warnings, @_warnings;
+ }
+}
+
+foreach my $file (@scripts)
+{ SKIP: {
+ open my $fh, '<', $file or warn("Unable to open $file: $!"), next;
+ my $line = <$fh>;
+
+ close $fh and skip("$file isn't perl", 1) unless $line =~ /^#!\s*(?:\S*perl\S*)((?:\s+-\w*)*)(?:\s*#.*)?$/;
+ @switches = (@switches, split(' ', $1)) if $1;
+
+ my $stderr = IO::Handle->new;
+
+ diag('Running: ', join(', ', map { my $str = $_; $str =~ s/'/\\'/g; q{'} . $str . q{'} }
+ $^X, @switches, '-c', $file))
+ if $ENV{PERL_COMPILE_TEST_DEBUG};
+
+ my $pid = open3($stdin, '>&STDERR', $stderr, $^X, @switches, '-c', $file);
+ binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+ my @_warnings = <$stderr>;
+ waitpid($pid, 0);
+ is($?, 0, "$file compiled ok");
+
+ shift @_warnings if @_warnings and $_warnings[0] =~ /^Using .*\bblib/
+ and not eval { +require blib; blib->VERSION('1.01') };
+
+ # in older perls, -c output is simply the file portion of the path being tested
+ if (@_warnings = grep { !/\bsyntax OK$/ }
+ grep { chomp; $_ ne (File::Spec->splitpath($file))[2] } @_warnings)
+ {
+ warn @_warnings;
+ push @warnings, @_warnings;
+ }
+} }
+
+
+
+is(scalar(@warnings), 0, 'no warnings found')
+ or diag 'got warnings: ', ( Test::More->can('explain') ? Test::More::explain(\@warnings) : join("\n", '', @warnings) ) if $ENV{AUTHOR_TESTING};
+
+
--- /dev/null
+do { my $x = {
+ 'configure' => {
+ 'requires' => {
+ 'ExtUtils::MakeMaker' => '0'
+ }
+ },
+ 'develop' => {
+ 'requires' => {
+ 'Dist::Zilla' => '5',
+ 'Dist::Zilla::PluginBundle::Author::CCM' => '0',
+ 'Pod::Coverage::TrustPod' => '0',
+ 'Software::License::MIT' => '0',
+ 'Test::CPAN::Changes' => '0.19',
+ 'Test::CPAN::Meta' => '0',
+ 'Test::CleanNamespaces' => '0.15',
+ 'Test::EOL' => '0',
+ 'Test::MinimumVersion' => '0',
+ 'Test::More' => '0.96',
+ 'Test::NoTabs' => '0',
+ 'Test::Perl::Critic' => '0',
+ 'Test::Pod' => '1.41',
+ 'Test::Pod::Coverage' => '1.08',
+ 'Test::Pod::No404s' => '0',
+ 'Test::Portability::Files' => '0'
+ }
+ },
+ 'runtime' => {
+ 'requires' => {
+ 'Carp' => '0',
+ 'Exporter' => '0',
+ 'File::Basename' => '0',
+ 'File::Spec' => '0',
+ 'File::Temp' => '0',
+ 'Getopt::Long' => '0',
+ 'IPC::Open2' => '0',
+ 'IPC::Open3' => '0',
+ 'MIME::Base64' => '0',
+ 'Pod::Usage' => '0',
+ 'Symbol' => '0',
+ 'YAML::Tiny' => '0',
+ 'namespace::clean' => '0',
+ 'strict' => '0',
+ 'warnings' => '0'
+ }
+ },
+ 'test' => {
+ 'recommends' => {
+ 'CPAN::Meta' => '2.120900'
+ },
+ 'requires' => {
+ 'ExtUtils::MakeMaker' => '0',
+ 'File::Spec' => '0',
+ 'FindBin' => '0',
+ 'IO::Handle' => '0',
+ 'IPC::Open3' => '0',
+ 'Test::More' => '0',
+ 'perl' => '5.006'
+ }
+ }
+ };
+ $x;
+ }
\ No newline at end of file
--- /dev/null
+#!perl
+
+use strict;
+use warnings;
+
+# This test was generated by Dist::Zilla::Plugin::Test::ReportPrereqs 0.027
+
+use Test::More tests => 1;
+
+use ExtUtils::MakeMaker;
+use File::Spec;
+
+# from $version::LAX
+my $lax_version_re =
+ qr/(?: undef | (?: (?:[0-9]+) (?: \. | (?:\.[0-9]+) (?:_[0-9]+)? )?
+ |
+ (?:\.[0-9]+) (?:_[0-9]+)?
+ ) | (?:
+ v (?:[0-9]+) (?: (?:\.[0-9]+)+ (?:_[0-9]+)? )?
+ |
+ (?:[0-9]+)? (?:\.[0-9]+){2,} (?:_[0-9]+)?
+ )
+ )/x;
+
+# hide optional CPAN::Meta modules from prereq scanner
+# and check if they are available
+my $cpan_meta = "CPAN::Meta";
+my $cpan_meta_pre = "CPAN::Meta::Prereqs";
+my $HAS_CPAN_META = eval "require $cpan_meta; $cpan_meta->VERSION('2.120900')" && eval "require $cpan_meta_pre"; ## no critic
+
+# Verify requirements?
+my $DO_VERIFY_PREREQS = 1;
+
+sub _max {
+ my $max = shift;
+ $max = ( $_ > $max ) ? $_ : $max for @_;
+ return $max;
+}
+
+sub _merge_prereqs {
+ my ($collector, $prereqs) = @_;
+
+ # CPAN::Meta::Prereqs object
+ if (ref $collector eq $cpan_meta_pre) {
+ return $collector->with_merged_prereqs(
+ CPAN::Meta::Prereqs->new( $prereqs )
+ );
+ }
+
+ # Raw hashrefs
+ for my $phase ( keys %$prereqs ) {
+ for my $type ( keys %{ $prereqs->{$phase} } ) {
+ for my $module ( keys %{ $prereqs->{$phase}{$type} } ) {
+ $collector->{$phase}{$type}{$module} = $prereqs->{$phase}{$type}{$module};
+ }
+ }
+ }
+
+ return $collector;
+}
+
+my @include = qw(
+
+);
+
+my @exclude = qw(
+
+);
+
+# Add static prereqs to the included modules list
+my $static_prereqs = do './t/00-report-prereqs.dd';
+
+# Merge all prereqs (either with ::Prereqs or a hashref)
+my $full_prereqs = _merge_prereqs(
+ ( $HAS_CPAN_META ? $cpan_meta_pre->new : {} ),
+ $static_prereqs
+);
+
+# Add dynamic prereqs to the included modules list (if we can)
+my ($source) = grep { -f } 'MYMETA.json', 'MYMETA.yml';
+my $cpan_meta_error;
+if ( $source && $HAS_CPAN_META
+ && (my $meta = eval { CPAN::Meta->load_file($source) } )
+) {
+ $full_prereqs = _merge_prereqs($full_prereqs, $meta->prereqs);
+}
+else {
+ $cpan_meta_error = $@; # capture error from CPAN::Meta->load_file($source)
+ $source = 'static metadata';
+}
+
+my @full_reports;
+my @dep_errors;
+my $req_hash = $HAS_CPAN_META ? $full_prereqs->as_string_hash : $full_prereqs;
+
+# Add static includes into a fake section
+for my $mod (@include) {
+ $req_hash->{other}{modules}{$mod} = 0;
+}
+
+for my $phase ( qw(configure build test runtime develop other) ) {
+ next unless $req_hash->{$phase};
+ next if ($phase eq 'develop' and not $ENV{AUTHOR_TESTING});
+
+ for my $type ( qw(requires recommends suggests conflicts modules) ) {
+ next unless $req_hash->{$phase}{$type};
+
+ my $title = ucfirst($phase).' '.ucfirst($type);
+ my @reports = [qw/Module Want Have/];
+
+ for my $mod ( sort keys %{ $req_hash->{$phase}{$type} } ) {
+ next if $mod eq 'perl';
+ next if grep { $_ eq $mod } @exclude;
+
+ my $file = $mod;
+ $file =~ s{::}{/}g;
+ $file .= ".pm";
+ my ($prefix) = grep { -e File::Spec->catfile($_, $file) } @INC;
+
+ my $want = $req_hash->{$phase}{$type}{$mod};
+ $want = "undef" unless defined $want;
+ $want = "any" if !$want && $want == 0;
+
+ my $req_string = $want eq 'any' ? 'any version required' : "version '$want' required";
+
+ if ($prefix) {
+ my $have = MM->parse_version( File::Spec->catfile($prefix, $file) );
+ $have = "undef" unless defined $have;
+ push @reports, [$mod, $want, $have];
+
+ if ( $DO_VERIFY_PREREQS && $HAS_CPAN_META && $type eq 'requires' ) {
+ if ( $have !~ /\A$lax_version_re\z/ ) {
+ push @dep_errors, "$mod version '$have' cannot be parsed ($req_string)";
+ }
+ elsif ( ! $full_prereqs->requirements_for( $phase, $type )->accepts_module( $mod => $have ) ) {
+ push @dep_errors, "$mod version '$have' is not in required range '$want'";
+ }
+ }
+ }
+ else {
+ push @reports, [$mod, $want, "missing"];
+
+ if ( $DO_VERIFY_PREREQS && $type eq 'requires' ) {
+ push @dep_errors, "$mod is not installed ($req_string)";
+ }
+ }
+ }
+
+ if ( @reports ) {
+ push @full_reports, "=== $title ===\n\n";
+
+ my $ml = _max( map { length $_->[0] } @reports );
+ my $wl = _max( map { length $_->[1] } @reports );
+ my $hl = _max( map { length $_->[2] } @reports );
+
+ if ($type eq 'modules') {
+ splice @reports, 1, 0, ["-" x $ml, "", "-" x $hl];
+ push @full_reports, map { sprintf(" %*s %*s\n", -$ml, $_->[0], $hl, $_->[2]) } @reports;
+ }
+ else {
+ splice @reports, 1, 0, ["-" x $ml, "-" x $wl, "-" x $hl];
+ push @full_reports, map { sprintf(" %*s %*s %*s\n", -$ml, $_->[0], $wl, $_->[1], $hl, $_->[2]) } @reports;
+ }
+
+ push @full_reports, "\n";
+ }
+ }
+}
+
+if ( @full_reports ) {
+ diag "\nVersions for all modules listed in $source (including optional ones):\n\n", @full_reports;
+}
+
+if ( $cpan_meta_error || @dep_errors ) {
+ diag "\n*** WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING ***\n";
+}
+
+if ( $cpan_meta_error ) {
+ my ($orig_source) = grep { -f } 'MYMETA.json', 'MYMETA.yml';
+ diag "\nCPAN::Meta->load_file('$orig_source') failed with: $cpan_meta_error\n";
+}
+
+if ( @dep_errors ) {
+ diag join("\n",
+ "\nThe following REQUIRED prerequisites were not satisfied:\n",
+ @dep_errors,
+ "\n"
+ );
+}
+
+pass;
+
+# vim: ts=4 sts=4 sw=4 et:
--- /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';
+
+SKIP: {
+ skip 'requires ssh-keygen', 2;
+
+ 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
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::CleanNamespaces 0.006
+
+use Test::More 0.94;
+use Test::CleanNamespaces 0.15;
+
+subtest all_namespaces_clean => sub { all_namespaces_clean() };
+
+done_testing;
--- /dev/null
+#!perl
+
+use strict;
+use warnings;
+
+use Test::Perl::Critic (-profile => "perlcritic.rc") x!! -e "perlcritic.rc";
+all_critic_ok();
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::EOL 0.19
+
+use Test::More 0.88;
+use Test::EOL;
+
+my @files = (
+ 'bin/groupsecret',
+ 'lib/App/GroupSecret.pm',
+ 'lib/App/GroupSecret/Crypt.pm',
+ 'lib/App/GroupSecret/File.pm',
+ 't/00-compile.t',
+ 't/00-report-prereqs.dd',
+ 't/00-report-prereqs.t',
+ 't/02-file.t',
+ 't/keyfiles/basic.yml',
+ 't/keyfiles/empty.yml',
+ 't/keys/foo_rsa',
+ 't/keys/foo_rsa.pub',
+ 'xt/author/clean-namespaces.t',
+ 'xt/author/critic.t',
+ 'xt/author/eol.t',
+ 'xt/author/no-tabs.t',
+ 'xt/author/pod-coverage.t',
+ 'xt/author/pod-no404s.t',
+ 'xt/author/pod-syntax.t',
+ 'xt/author/portability.t',
+ 'xt/release/cpan-changes.t',
+ 'xt/release/distmeta.t',
+ 'xt/release/minimum-version.t'
+);
+
+eol_unix_ok($_, { trailing_whitespace => 1 }) foreach @files;
+done_testing;
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::NoTabs 0.15
+
+use Test::More 0.88;
+use Test::NoTabs;
+
+my @files = (
+ 'bin/groupsecret',
+ 'lib/App/GroupSecret.pm',
+ 'lib/App/GroupSecret/Crypt.pm',
+ 'lib/App/GroupSecret/File.pm',
+ 't/00-compile.t',
+ 't/00-report-prereqs.dd',
+ 't/00-report-prereqs.t',
+ 't/02-file.t',
+ 't/keyfiles/basic.yml',
+ 't/keyfiles/empty.yml',
+ 't/keys/foo_rsa',
+ 't/keys/foo_rsa.pub',
+ 'xt/author/clean-namespaces.t',
+ 'xt/author/critic.t',
+ 'xt/author/eol.t',
+ 'xt/author/no-tabs.t',
+ 'xt/author/pod-coverage.t',
+ 'xt/author/pod-no404s.t',
+ 'xt/author/pod-syntax.t',
+ 'xt/author/portability.t',
+ 'xt/release/cpan-changes.t',
+ 'xt/release/distmeta.t',
+ 'xt/release/minimum-version.t'
+);
+
+notabs_ok($_) foreach @files;
+done_testing;
--- /dev/null
+#!perl
+# This file was automatically generated by Dist::Zilla::Plugin::PodCoverageTests.
+
+use Test::Pod::Coverage 1.08;
+use Pod::Coverage::TrustPod;
+
+all_pod_coverage_ok({ coverage_class => 'Pod::Coverage::TrustPod' });
--- /dev/null
+#!perl
+
+use strict;
+use warnings;
+use Test::More;
+
+foreach my $env_skip ( qw(
+ SKIP_POD_NO404S
+ AUTOMATED_TESTING
+) ){
+ plan skip_all => "\$ENV{$env_skip} is set, skipping"
+ if $ENV{$env_skip};
+}
+
+eval "use Test::Pod::No404s";
+if ( $@ ) {
+ plan skip_all => 'Test::Pod::No404s required for testing POD';
+}
+else {
+ all_pod_files_ok();
+}
--- /dev/null
+#!perl
+# This file was automatically generated by Dist::Zilla::Plugin::PodSyntaxTests.
+use strict; use warnings;
+use Test::More;
+use Test::Pod 1.41;
+
+all_pod_files_ok();
--- /dev/null
+use strict;
+use warnings;
+
+use Test::More;
+
+eval 'use Test::Portability::Files';
+plan skip_all => 'Test::Portability::Files required for testing portability'
+ if $@;
+
+run_tests();
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::CPAN::Changes 0.012
+
+use Test::More 0.96 tests => 1;
+use Test::CPAN::Changes;
+subtest 'changes_ok' => sub {
+ changes_file_ok('Changes');
+};
--- /dev/null
+#!perl
+# This file was automatically generated by Dist::Zilla::Plugin::MetaTests.
+
+use Test::CPAN::Meta;
+
+meta_yaml_ok();
--- /dev/null
+#!perl
+
+use Test::More;
+
+eval "use Test::MinimumVersion";
+plan skip_all => "Test::MinimumVersion required for testing minimum versions"
+ if $@;
+all_minimum_version_ok( qq{5.10.1} );