From: Charles McGarvey Date: Wed, 14 Feb 2018 16:28:48 +0000 (-0700) Subject: Version 0.303 X-Git-Url: https://git.brokenzipper.com/gitweb?a=commitdiff_plain;h=d7e20169b92b5c124d803a8cebc53c9c6148e3ff;p=chaz%2Fgroupsecret Version 0.303 --- d7e20169b92b5c124d803a8cebc53c9c6148e3ff 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} );