From d7e20169b92b5c124d803a8cebc53c9c6148e3ff Mon Sep 17 00:00:00 2001 From: Charles McGarvey Date: Wed, 14 Feb 2018 09:28:48 -0700 Subject: [PATCH] Version 0.303 --- .travis.yml | 21 ++ Changes | 22 ++ LICENSE | 32 +++ MANIFEST | 32 +++ META.json | 115 ++++++++++ META.yml | 61 +++++ Makefile.PL | 86 +++++++ README | 347 ++++++++++++++++++++++++++++ bin/groupsecret | 343 ++++++++++++++++++++++++++++ lib/App/GroupSecret.pm | 332 +++++++++++++++++++++++++++ lib/App/GroupSecret/Crypt.pm | 336 +++++++++++++++++++++++++++ lib/App/GroupSecret/File.pm | 425 +++++++++++++++++++++++++++++++++++ t/00-compile.t | 96 ++++++++ t/00-report-prereqs.dd | 62 +++++ t/00-report-prereqs.t | 193 ++++++++++++++++ t/02-file.t | 56 +++++ t/keyfiles/basic.yml | 9 + t/keyfiles/empty.yml | 4 + t/keys/foo_rsa | 27 +++ t/keys/foo_rsa.pub | 1 + xt/author/clean-namespaces.t | 11 + xt/author/critic.t | 7 + xt/author/eol.t | 36 +++ xt/author/no-tabs.t | 36 +++ xt/author/pod-coverage.t | 7 + xt/author/pod-no404s.t | 21 ++ xt/author/pod-syntax.t | 7 + xt/author/portability.t | 10 + xt/release/cpan-changes.t | 10 + xt/release/distmeta.t | 6 + xt/release/minimum-version.t | 8 + 31 files changed, 2759 insertions(+) create mode 100644 .travis.yml create mode 100644 Changes create mode 100644 LICENSE create mode 100644 MANIFEST create mode 100644 META.json create mode 100644 META.yml create mode 100644 Makefile.PL create mode 100644 README create mode 100755 bin/groupsecret create mode 100644 lib/App/GroupSecret.pm create mode 100644 lib/App/GroupSecret/Crypt.pm create mode 100644 lib/App/GroupSecret/File.pm create mode 100644 t/00-compile.t create mode 100644 t/00-report-prereqs.dd create mode 100644 t/00-report-prereqs.t create mode 100644 t/02-file.t create mode 100644 t/keyfiles/basic.yml create mode 100644 t/keyfiles/empty.yml create mode 100644 t/keys/foo_rsa create mode 100644 t/keys/foo_rsa.pub create mode 100644 xt/author/clean-namespaces.t create mode 100644 xt/author/critic.t create mode 100644 xt/author/eol.t create mode 100644 xt/author/no-tabs.t create mode 100644 xt/author/pod-coverage.t create mode 100644 xt/author/pod-no404s.t create mode 100644 xt/author/pod-syntax.t create mode 100644 xt/author/portability.t create mode 100644 xt/release/cpan-changes.t create mode 100644 xt/release/distmeta.t create mode 100644 xt/release/minimum-version.t diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d199eaa --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +sudo: false +language: perl +perl: + - '5.26' + - '5.24' + - '5.22' + - '5.20' + - '5.18' + - '5.16' + - '5.14' + - '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 . diff --git a/Changes b/Changes new file mode 100644 index 0000000..c9eb86a --- /dev/null +++ b/Changes @@ -0,0 +1,22 @@ +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. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e38251 --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +This software is Copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +The MIT License + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to +whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT +WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..b00d0b3 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,32 @@ +# 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 diff --git a/META.json b/META.json new file mode 100644 index 0000000..2811a9c --- /dev/null +++ b/META.json @@ -0,0 +1,115 @@ +{ + "abstract" : "A simple tool for maintaining a shared group secret", + "author" : [ + "Charles McGarvey " + ], + "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" +} + diff --git a/META.yml b/META.yml new file mode 100644 index 0000000..45ad7d9 --- /dev/null +++ b/META.yml @@ -0,0 +1,61 @@ +--- +abstract: 'A simple tool for maintaining a shared group secret' +author: + - 'Charles McGarvey ' +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' diff --git a/Makefile.PL b/Makefile.PL new file mode 100644 index 0000000..cbf6513 --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,86 @@ +# 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 ", + "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); diff --git a/README b/README new file mode 100644 index 0000000..75c29b9 --- /dev/null +++ b/README @@ -0,0 +1,347 @@ +NAME + + groupsecret - A simple tool for maintaining a shared group secret + +VERSION + + version 0.303 + +SYNOPSIS + + groupsecret [--version] [--help] [-f ] [-k ] + [] + + groupsecret add-key [--embed] [--update] ... + + groupsecret delete-key | ... + + groupsecret list-keys + + groupsecret set-secret [--keep-passphrase] |-|rand: + + groupsecret [print-secret] [--no-decrypt] + +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: + + + + set-secret + + groupsecret set-secret path/to/secretfile.txt + groupsecret set-secret - < it's a secret to everybody + > END + groupsecret set-secret rand:48 + + Set or update the secret contained in a keyfile. The argument allows + you to add a secret from a file, from , or from a stream of + secure random bytes. + + If the keyfile already contains a secret, it will be replaced by the + new secret. A keyfile can only contain one secret at a time. If you + think you want to store more than one secret at a time, store a tarball + instead. + + By default, this will also change the passphrase protecting the secret + and re-encrypt the passphrase for each key currently in the keyfile. + This requires all of the public keys to be available (see + "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 (commands: ssh-keygen(1)) + + * OpenSSL (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 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 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 + +COPYRIGHT AND LICENSE + + This software is Copyright (c) 2017 by Charles McGarvey. + + This is free software, licensed under: + + The MIT (X11) License + diff --git a/bin/groupsecret b/bin/groupsecret new file mode 100755 index 0000000..8f8465b --- /dev/null +++ b/bin/groupsecret @@ -0,0 +1,343 @@ +#!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 ] [-k ] + [] + + groupsecret add-key [--embed] [--update] ... + + groupsecret delete-key | ... + + groupsecret list-keys + + groupsecret set-secret [--keep-passphrase] |-|rand: + + groupsecret [print-secret] [--no-decrypt] + +=head1 DESCRIPTION + +L is a program that makes it easy for groups to share a secret between themselves +without exposing the secret to anyone else. It could be used, for example, by a team to share an +L password; see L for more about this particular use case. + +The goal of this program is to be easy to use and have few dependencies (or only have dependencies +users are likely to already have installed). + +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, and exit. + +Alias: C<-v> + +=head2 --help + +Print the synopsis to C, and exit. + +Alias: C<-h> + +=head2 --file=path + +Specify a path to a keyfile which stores a secret and keys. + +Defaults to the value of the environment variable L or F. + +Alias: C<-f> + +=head2 --private-key=path + +Specify a path to a PEM private key. This is used by some commands to decrypt the passphrase that +protects the secret and is ignored by commands that don't need it. + +Defaults to the value of the environment variable L 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. + +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 + +=head2 delete-key + + groupsecret delete-key MD5:89:b3:fb:76:6c:f9:56:8e:a8:1a:df:ba:1c:ba:7d:05 + groupsecret delete-key path/to/mykey_rsa.pub + +Deletes one or more keys from a keyfile. This prevents the secret contained within the keyfile from +being accessed by whoever has the corresponding private key. + +Of course, if the owners of the key(s) being removed have already had access to the keyfile prior to +their keys being removed, the secret is already exposed to them. It usually makes sense to follow up +this command with a L command in order to change the secret. + +Aliases: C, C, C + +=head2 list-keys + + groupsecret list-keys + +Prints the keys that have access to the secret contained in the keyfile to C, one per line +in the following format: + + + +=head2 set-secret + + groupsecret set-secret path/to/secretfile.txt + groupsecret set-secret - < it's a secret to everybody + > END + groupsecret set-secret rand:48 + +Set or update the secret contained in a keyfile. The argument allows you to add a secret from +a file, from , or from a stream of secure random bytes. + +If the keyfile already contains a secret, it will be replaced by the new secret. A keyfile can only +contain one secret at a time. If you think you want to store more than one secret at a time, store +a tarball instead. + +By default, this will also change the passphrase protecting the secret and re-encrypt the passphrase +for each key currently in the keyfile. This requires all of the public keys to be available (see +L). If for some reason you want to protect the new secret with the current +passphrase, use the C<--keep-passphrase> option; this can be done without the public keys being +available, but it will require a private key to decrypt the passphrase. + +Aliases: C, C + +=head2 print-secret + + groupsecret print-secret + groupsecret print-secret --no-decrypt + +Print the secret contained in the keyfile to C. + +If the C<--no-decrypt> option is used, the secret will be printed in its encrypted form. + +This requires a private key. + +Aliases: (no command), C + +=head1 REQUIREMENTS + +=over 4 + +=item * + +L (commands: L) + +=item * + +L (commands: L) + +=back + +=head1 INSTALL + +There are a few ways to install groupsecret to your system. First, make sure you first have the +L installed. + +=head2 Using cpanm + +You can install groupsecret using L. If you have a local perl (plenv, perlbrew, etc.), you +can just do this: + + cpanm App::GroupSecret + +to install the F executable and its Perl module dependencies. The executable will be +installed to your perl's bin path, like F<~/perl5/perlbrew/bin/groupsecret>. + +If you're installing to your system perl, you can do: + + cpanm --sudo App::GroupSecret + +to install the F executable to a system directory, like F +(depending on your perl). + +=head2 For developers + +If you're a developer and want to hack on the source, clone the repository and pull the +dependencies: + + git clone https://github.com/chazmcgarvey/groupsecret.git + cd groupsecret + cpanm Dist::Zilla + dzil authordeps --missing | cpanm + dzil listdeps --author --develop --missing | cpanm + +=head1 ENVIRONMENT + +=head2 GROUPSECRET_KEYFILE + +If set, this program will use the value as a path to the keyfile. The L option takes +precedence if used. + +=head2 GROUPSECRET_PRIVATE_KEY + +If set, this program will use the value as a path to private key used for decryption. The +L 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 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 +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. Create a file named F with the +following script, and make it executable (C): + + #!/bin/sh + # Use groupsecret to access the Vault password + exec ${GROUPSECRET:-groupsecret} -f vault-password.yml print-secret + +Commit both F and F to your repository. + +Now use L 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 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 which executes groupsecret to print the secret contained +in the F file (which is actually the Vault password) to C. 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 + +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 + +=head1 COPYRIGHT AND LICENSE + +This software is Copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut diff --git a/lib/App/GroupSecret.pm b/lib/App/GroupSecret.pm new file mode 100644 index 0000000..37c9cc5 --- /dev/null +++ b/lib/App/GroupSecret.pm @@ -0,0 +1,332 @@ +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 $/; }; + $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 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 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 + +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 + +=head1 COPYRIGHT AND LICENSE + +This software is Copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut diff --git a/lib/App/GroupSecret/Crypt.pm b/lib/App/GroupSecret/Crypt.pm new file mode 100644 index 0000000..c3584ef --- /dev/null +++ b/lib/App/GroupSecret/Crypt.pm @@ -0,0 +1,336 @@ +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 + +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 + +=head1 COPYRIGHT AND LICENSE + +This software is Copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut diff --git a/lib/App/GroupSecret/File.pm b/lib/App/GroupSecret/File.pm new file mode 100644 index 0000000..f662473 --- /dev/null +++ b/lib/App/GroupSecret/File.pm @@ -0,0 +1,425 @@ +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 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 " ". + +=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 + +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 + +=head1 COPYRIGHT AND LICENSE + +This software is Copyright (c) 2017 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut diff --git a/t/00-compile.t b/t/00-compile.t new file mode 100644 index 0000000..3b2fb91 --- /dev/null +++ b/t/00-compile.t @@ -0,0 +1,96 @@ +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 + 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}; + + diff --git a/t/00-report-prereqs.dd b/t/00-report-prereqs.dd new file mode 100644 index 0000000..c87c72d --- /dev/null +++ b/t/00-report-prereqs.dd @@ -0,0 +1,62 @@ +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 diff --git a/t/00-report-prereqs.t b/t/00-report-prereqs.t new file mode 100644 index 0000000..c72183a --- /dev/null +++ b/t/00-report-prereqs.t @@ -0,0 +1,193 @@ +#!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: diff --git a/t/02-file.t b/t/02-file.t new file mode 100644 index 0000000..ea85144 --- /dev/null +++ b/t/02-file.t @@ -0,0 +1,56 @@ +#!/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'; + diff --git a/t/keyfiles/basic.yml b/t/keyfiles/basic.yml new file mode 100644 index 0000000..2301513 --- /dev/null +++ b/t/keyfiles/basic.yml @@ -0,0 +1,9 @@ +--- +keys: + 89b3fb766cf9568ea81adfba1cba7d05: + comment: foo + filename: foo_rsa.pub + secret_passphrase: ~ + type: rsa +secret: ~ +version: '1' diff --git a/t/keyfiles/empty.yml b/t/keyfiles/empty.yml new file mode 100644 index 0000000..a205ddd --- /dev/null +++ b/t/keyfiles/empty.yml @@ -0,0 +1,4 @@ +--- +secret: ~ +keys: {} +version: 1 diff --git a/t/keys/foo_rsa b/t/keys/foo_rsa new file mode 100644 index 0000000..0fbce92 --- /dev/null +++ b/t/keys/foo_rsa @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtMQ8lHUhFGceLK5r3PV/h1WAwomaMgbfYcBZhOfzqN4LNtM7 +GYXoUbBvENWuX5okDRKHxq2vC2oZs61gqsSPdtm+EJwFgzY+Hb28j9IhwK83yjdc +MZj7+dpohHKo1CEsT+rvK9LCGkLor0Q3st0dzyEpDlhE6xSFy0XC5AmbUrlKewUj +IcEO8BdR02BKm7+lEWF0mzWrLlNciUdKPstDTK6fvUa+CZK3414n8zensfkUgnUY +FDT/ol6EsPhwG7syOb/1ZWf4VlM9rlKaiFiOOlee3RTndwprNa+nInt/McAflMQz +jGsp2d4m408DIuZ0mdTURn7/dIuPIf/IOYKlmwIDAQABAoIBAQCbvyA7ARg5Tgdv +k/CXdmYkooTIGGrkg4tf26zFmFwVuQqMeD7JZNif2ZY4OQN+l35MTRTzF55kBUyT +xOQu/iBl1IGwKd2OCeRHF70pZXFzZQR6lGw4x4kC4y1+QJQ6AUL+sHrVlUdr/Q4i +RHKBB4axee63z1HCAfKtCzQ56hULleonMdzpjPCVJllDQhPaCmNtO2GybIMtn5wC +KcSXxTe/4af89GDmxAnE4JwqPhjCcaq+aTC84Us9JsNT+DbAgV5dc/b+2Wy3tzkB +fLHyszVJpEYbqjP50FfiwS2EEdzIwtkMrzc+dS+D4mn1dvDtm9fA13cq0QAVicTj +l2fPyZuRAoGBAOmBMghlMet2UmOQvBUOdJ4Jghknu6x3cIz+Bv+6G12T92147CL+ +ypZhiyO9iD7qZOtoIV4D7FtyC0wTsYAp4bz6HLfNUpkyJEbdebvQe8aC4hWNgb4+ +yqCSk3PqzhBiqydbdaqtlSt4FPVuAWRcKZjUU7rnj/mx0fUZoOxbBFYtAoGBAMYu +Y2cRkRQR2kOx9wacBHej+3iV5Typi1SAFMOdlki7jB4idVPyz/ZzcRDM61J4qcfr +BdCBTw0tn3wi0JhnncgOtV1DSIEz0OCiO6TOtQBiUXVTmW/yBUyRbISVLJ4QvZsm +Kg6LKPOT7dEwrVDZO1MPJOt6u8vxxsP/4dJzpU/nAoGBAJpVHfCWkewDBGlyXB2+ +tC1QM4DU1iIjv2ww6gdTxoqPJdZhOhHXPacvSXuR5d9PpOxCous0xJ+cPQNHcOY4 +yE7TMO/68UD39yovcCpGnciS8UM1iC9p6RtARd0zsIb78AvPU3I/0HwungupbZob +oBK3I7BBJNPwR8kr60TM04zxAoGAExyGGXpoMzdFhSG0YL7K736w0YAjCyaOeSeg +2PxpcbokWQOZrO7Nf0bCsIwSZXGdbdoMRX8y0GKF7sKsuwXDAXfppYTHiS4mBoOe +nNYSPmc808OsGE+Ok0Oy41Y/Zz7WChA0HhLtcA/j7zhyfkl0nx3mwY6kbZZzRJN4 +g4MDfiECgYEAw1PKKpTOwZVIYVLadpSSF6qO30MF0sZNL454kTvmc139Es69s391 +OvB1VHtehFV/LNIstdadvYgaiuiapG4smt65g0WKqL3+9gfyQU0k+NJH61AaXX54 +oVYcKjyUzT5w8gfE9g5w6AePrFfs3KPY9GFhWQHdcFu7DBMX8/VQqYw= +-----END RSA PRIVATE KEY----- diff --git a/t/keys/foo_rsa.pub b/t/keys/foo_rsa.pub new file mode 100644 index 0000000..a53e1b8 --- /dev/null +++ b/t/keys/foo_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0xDyUdSEUZx4srmvc9X+HVYDCiZoyBt9hwFmE5/Oo3gs20zsZhehRsG8Q1a5fmiQNEofGra8LahmzrWCqxI922b4QnAWDNj4dvbyP0iHArzfKN1wxmPv52miEcqjUISxP6u8r0sIaQuivRDey3R3PISkOWETrFIXLRcLkCZtSuUp7BSMhwQ7wF1HTYEqbv6URYXSbNasuU1yJR0o+y0NMrp+9Rr4JkrfjXifzN6ex+RSCdRgUNP+iXoSw+HAbuzI5v/VlZ/hWUz2uUpqIWI46V57dFOd3Cms1r6cie38xwB+UxDOMaynZ3ibjTwMi5nSZ1NRGfv90i48h/8g5gqWb foo diff --git a/xt/author/clean-namespaces.t b/xt/author/clean-namespaces.t new file mode 100644 index 0000000..36387da --- /dev/null +++ b/xt/author/clean-namespaces.t @@ -0,0 +1,11 @@ +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; diff --git a/xt/author/critic.t b/xt/author/critic.t new file mode 100644 index 0000000..80ccdad --- /dev/null +++ b/xt/author/critic.t @@ -0,0 +1,7 @@ +#!perl + +use strict; +use warnings; + +use Test::Perl::Critic (-profile => "perlcritic.rc") x!! -e "perlcritic.rc"; +all_critic_ok(); diff --git a/xt/author/eol.t b/xt/author/eol.t new file mode 100644 index 0000000..c72e642 --- /dev/null +++ b/xt/author/eol.t @@ -0,0 +1,36 @@ +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; diff --git a/xt/author/no-tabs.t b/xt/author/no-tabs.t new file mode 100644 index 0000000..4b8885a --- /dev/null +++ b/xt/author/no-tabs.t @@ -0,0 +1,36 @@ +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; diff --git a/xt/author/pod-coverage.t b/xt/author/pod-coverage.t new file mode 100644 index 0000000..66b3b64 --- /dev/null +++ b/xt/author/pod-coverage.t @@ -0,0 +1,7 @@ +#!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' }); diff --git a/xt/author/pod-no404s.t b/xt/author/pod-no404s.t new file mode 100644 index 0000000..eb9760c --- /dev/null +++ b/xt/author/pod-no404s.t @@ -0,0 +1,21 @@ +#!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(); +} diff --git a/xt/author/pod-syntax.t b/xt/author/pod-syntax.t new file mode 100644 index 0000000..e563e5d --- /dev/null +++ b/xt/author/pod-syntax.t @@ -0,0 +1,7 @@ +#!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(); diff --git a/xt/author/portability.t b/xt/author/portability.t new file mode 100644 index 0000000..c531252 --- /dev/null +++ b/xt/author/portability.t @@ -0,0 +1,10 @@ +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(); diff --git a/xt/release/cpan-changes.t b/xt/release/cpan-changes.t new file mode 100644 index 0000000..286005a --- /dev/null +++ b/xt/release/cpan-changes.t @@ -0,0 +1,10 @@ +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'); +}; diff --git a/xt/release/distmeta.t b/xt/release/distmeta.t new file mode 100644 index 0000000..c2280dc --- /dev/null +++ b/xt/release/distmeta.t @@ -0,0 +1,6 @@ +#!perl +# This file was automatically generated by Dist::Zilla::Plugin::MetaTests. + +use Test::CPAN::Meta; + +meta_yaml_ok(); diff --git a/xt/release/minimum-version.t b/xt/release/minimum-version.t new file mode 100644 index 0000000..ff71971 --- /dev/null +++ b/xt/release/minimum-version.t @@ -0,0 +1,8 @@ +#!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} ); -- 2.44.0