]> Dogcows Code - chaz/groupsecret/commitdiff
Version 0.303
authorCharles McGarvey <chazmcgarvey@brokenzipper.com>
Wed, 14 Feb 2018 16:28:48 +0000 (09:28 -0700)
committerCharles McGarvey <chazmcgarvey@brokenzipper.com>
Wed, 14 Feb 2018 16:28:48 +0000 (09:28 -0700)
31 files changed:
.travis.yml [new file with mode: 0644]
Changes [new file with mode: 0644]
LICENSE [new file with mode: 0644]
MANIFEST [new file with mode: 0644]
META.json [new file with mode: 0644]
META.yml [new file with mode: 0644]
Makefile.PL [new file with mode: 0644]
README [new file with mode: 0644]
bin/groupsecret [new file with mode: 0755]
lib/App/GroupSecret.pm [new file with mode: 0644]
lib/App/GroupSecret/Crypt.pm [new file with mode: 0644]
lib/App/GroupSecret/File.pm [new file with mode: 0644]
t/00-compile.t [new file with mode: 0644]
t/00-report-prereqs.dd [new file with mode: 0644]
t/00-report-prereqs.t [new file with mode: 0644]
t/02-file.t [new file with mode: 0644]
t/keyfiles/basic.yml [new file with mode: 0644]
t/keyfiles/empty.yml [new file with mode: 0644]
t/keys/foo_rsa [new file with mode: 0644]
t/keys/foo_rsa.pub [new file with mode: 0644]
xt/author/clean-namespaces.t [new file with mode: 0644]
xt/author/critic.t [new file with mode: 0644]
xt/author/eol.t [new file with mode: 0644]
xt/author/no-tabs.t [new file with mode: 0644]
xt/author/pod-coverage.t [new file with mode: 0644]
xt/author/pod-no404s.t [new file with mode: 0644]
xt/author/pod-syntax.t [new file with mode: 0644]
xt/author/portability.t [new file with mode: 0644]
xt/release/cpan-changes.t [new file with mode: 0644]
xt/release/distmeta.t [new file with mode: 0644]
xt/release/minimum-version.t [new file with mode: 0644]

diff --git a/.travis.yml b/.travis.yml
new file mode 100644 (file)
index 0000000..d199eaa
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
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 <chazmcgarvey@brokenzipper.com>"
+   ],
+   "dynamic_config" : 0,
+   "generated_by" : "Dist::Zilla version 6.010, CPAN::Meta::Converter version 2.150010",
+   "license" : [
+      "mit"
+   ],
+   "meta-spec" : {
+      "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec",
+      "version" : 2
+   },
+   "name" : "App-GroupSecret",
+   "no_index" : {
+      "directory" : [
+         "eg",
+         "share",
+         "shares",
+         "t",
+         "xt"
+      ]
+   },
+   "prereqs" : {
+      "configure" : {
+         "requires" : {
+            "ExtUtils::MakeMaker" : "0"
+         }
+      },
+      "develop" : {
+         "requires" : {
+            "Dist::Zilla" : "5",
+            "Dist::Zilla::PluginBundle::Author::CCM" : "0",
+            "Pod::Coverage::TrustPod" : "0",
+            "Software::License::MIT" : "0",
+            "Test::CPAN::Changes" : "0.19",
+            "Test::CPAN::Meta" : "0",
+            "Test::CleanNamespaces" : "0.15",
+            "Test::EOL" : "0",
+            "Test::MinimumVersion" : "0",
+            "Test::More" : "0.96",
+            "Test::NoTabs" : "0",
+            "Test::Perl::Critic" : "0",
+            "Test::Pod" : "1.41",
+            "Test::Pod::Coverage" : "1.08",
+            "Test::Pod::No404s" : "0",
+            "Test::Portability::Files" : "0"
+         }
+      },
+      "runtime" : {
+         "requires" : {
+            "Carp" : "0",
+            "Exporter" : "0",
+            "File::Basename" : "0",
+            "File::Spec" : "0",
+            "File::Temp" : "0",
+            "Getopt::Long" : "0",
+            "IPC::Open2" : "0",
+            "IPC::Open3" : "0",
+            "MIME::Base64" : "0",
+            "Pod::Usage" : "0",
+            "Symbol" : "0",
+            "YAML::Tiny" : "0",
+            "namespace::clean" : "0",
+            "strict" : "0",
+            "warnings" : "0"
+         }
+      },
+      "test" : {
+         "recommends" : {
+            "CPAN::Meta" : "2.120900"
+         },
+         "requires" : {
+            "ExtUtils::MakeMaker" : "0",
+            "File::Spec" : "0",
+            "FindBin" : "0",
+            "IO::Handle" : "0",
+            "IPC::Open3" : "0",
+            "Test::More" : "0",
+            "perl" : "5.006"
+         }
+      }
+   },
+   "provides" : {
+      "App::GroupSecret" : {
+         "file" : "lib/App/GroupSecret.pm",
+         "version" : "0.303"
+      },
+      "App::GroupSecret::Crypt" : {
+         "file" : "lib/App/GroupSecret/Crypt.pm",
+         "version" : "0.303"
+      },
+      "App::GroupSecret::File" : {
+         "file" : "lib/App/GroupSecret/File.pm",
+         "version" : "0.303"
+      }
+   },
+   "release_status" : "stable",
+   "resources" : {
+      "bugtracker" : {
+         "web" : "https://github.com/chazmcgarvey/groupsecret/issues"
+      },
+      "homepage" : "https://github.com/chazmcgarvey/groupsecret",
+      "repository" : {
+         "type" : "git",
+         "url" : "https://github.com/chazmcgarvey/groupsecret.git",
+         "web" : "https://github.com/chazmcgarvey/groupsecret"
+      }
+   },
+   "version" : "0.303",
+   "x_authority" : "cpan:CCM",
+   "x_serialization_backend" : "Cpanel::JSON::XS version 3.0239"
+}
+
diff --git a/META.yml b/META.yml
new file mode 100644 (file)
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 <chazmcgarvey@brokenzipper.com>'
+build_requires:
+  ExtUtils::MakeMaker: '0'
+  File::Spec: '0'
+  FindBin: '0'
+  IO::Handle: '0'
+  IPC::Open3: '0'
+  Test::More: '0'
+  perl: '5.006'
+configure_requires:
+  ExtUtils::MakeMaker: '0'
+dynamic_config: 0
+generated_by: 'Dist::Zilla version 6.010, CPAN::Meta::Converter version 2.150010'
+license: mit
+meta-spec:
+  url: http://module-build.sourceforge.net/META-spec-v1.4.html
+  version: '1.4'
+name: App-GroupSecret
+no_index:
+  directory:
+    - eg
+    - share
+    - shares
+    - t
+    - xt
+provides:
+  App::GroupSecret:
+    file: lib/App/GroupSecret.pm
+    version: '0.303'
+  App::GroupSecret::Crypt:
+    file: lib/App/GroupSecret/Crypt.pm
+    version: '0.303'
+  App::GroupSecret::File:
+    file: lib/App/GroupSecret/File.pm
+    version: '0.303'
+requires:
+  Carp: '0'
+  Exporter: '0'
+  File::Basename: '0'
+  File::Spec: '0'
+  File::Temp: '0'
+  Getopt::Long: '0'
+  IPC::Open2: '0'
+  IPC::Open3: '0'
+  MIME::Base64: '0'
+  Pod::Usage: '0'
+  Symbol: '0'
+  YAML::Tiny: '0'
+  namespace::clean: '0'
+  strict: '0'
+  warnings: '0'
+resources:
+  bugtracker: https://github.com/chazmcgarvey/groupsecret/issues
+  homepage: https://github.com/chazmcgarvey/groupsecret
+  repository: https://github.com/chazmcgarvey/groupsecret.git
+version: '0.303'
+x_authority: cpan:CCM
+x_serialization_backend: 'YAML::Tiny version 1.70'
diff --git a/Makefile.PL b/Makefile.PL
new file mode 100644 (file)
index 0000000..cbf6513
--- /dev/null
@@ -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 <chazmcgarvey\@brokenzipper.com>",
+  "CONFIGURE_REQUIRES" => {
+    "ExtUtils::MakeMaker" => 0
+  },
+  "DISTNAME" => "App-GroupSecret",
+  "EXE_FILES" => [
+    "bin/groupsecret"
+  ],
+  "LICENSE" => "mit",
+  "MIN_PERL_VERSION" => "5.006",
+  "NAME" => "App::GroupSecret",
+  "PREREQ_PM" => {
+    "Carp" => 0,
+    "Exporter" => 0,
+    "File::Basename" => 0,
+    "File::Spec" => 0,
+    "File::Temp" => 0,
+    "Getopt::Long" => 0,
+    "IPC::Open2" => 0,
+    "IPC::Open3" => 0,
+    "MIME::Base64" => 0,
+    "Pod::Usage" => 0,
+    "Symbol" => 0,
+    "YAML::Tiny" => 0,
+    "namespace::clean" => 0,
+    "strict" => 0,
+    "warnings" => 0
+  },
+  "TEST_REQUIRES" => {
+    "ExtUtils::MakeMaker" => 0,
+    "File::Spec" => 0,
+    "FindBin" => 0,
+    "IO::Handle" => 0,
+    "IPC::Open3" => 0,
+    "Test::More" => 0
+  },
+  "VERSION" => "0.303",
+  "test" => {
+    "TESTS" => "t/*.t"
+  }
+);
+
+
+my %FallbackPrereqs = (
+  "Carp" => 0,
+  "Exporter" => 0,
+  "ExtUtils::MakeMaker" => 0,
+  "File::Basename" => 0,
+  "File::Spec" => 0,
+  "File::Temp" => 0,
+  "FindBin" => 0,
+  "Getopt::Long" => 0,
+  "IO::Handle" => 0,
+  "IPC::Open2" => 0,
+  "IPC::Open3" => 0,
+  "MIME::Base64" => 0,
+  "Pod::Usage" => 0,
+  "Symbol" => 0,
+  "Test::More" => 0,
+  "YAML::Tiny" => 0,
+  "namespace::clean" => 0,
+  "strict" => 0,
+  "warnings" => 0
+);
+
+
+unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
+  delete $WriteMakefileArgs{TEST_REQUIRES};
+  delete $WriteMakefileArgs{BUILD_REQUIRES};
+  $WriteMakefileArgs{PREREQ_PM} = \%FallbackPrereqs;
+}
+
+delete $WriteMakefileArgs{CONFIGURE_REQUIRES}
+  unless eval { ExtUtils::MakeMaker->VERSION(6.52) };
+
+WriteMakefile(%WriteMakefileArgs);
diff --git a/README b/README
new file mode 100644 (file)
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 <filepath>] [-k <privatekey_path>]
+                    <command> [<args>]
+    
+        groupsecret add-key [--embed] [--update] <publickey_path> ...
+    
+        groupsecret delete-key <fingerprint>|<publickey_path> ...
+    
+        groupsecret list-keys
+    
+        groupsecret set-secret [--keep-passphrase] <path>|-|rand:<num_bytes>
+    
+        groupsecret [print-secret] [--no-decrypt]
+
+DESCRIPTION
+
+    groupsecret is a program that makes it easy for groups to share a
+    secret between themselves without exposing the secret to anyone else.
+    It could be used, for example, by a team to share an ansible-vault(1)
+    password; see "ansible-vault" for more about this particular use case.
+
+    The goal of this program is to be easy to use and have few dependencies
+    (or only have dependencies users are likely to already have installed).
+
+    groupsecret works by encrypting a secret with a symmetric cipher
+    protected by a secure random passphrase which is itself encrypted by
+    one or more SSH2 RSA public keys. Only those who have access to one of
+    the corresponding private keys are able to decrypt the passphrase and
+    access the secret.
+
+    The encrypted secret and passphrase are stored in a single keyfile. You
+    can even commit the keyfile in a public repo or in a private repo where
+    some untrusted users may have read access; the secret is locked away to
+    all except those with a private key to a corresponding public key that
+    has been added to the keyfile.
+
+    The keyfile is just a YAML file, so it's human-readable (except of
+    course for the encrypted parts). This make it easy to add to version
+    control and work with diffs. You can edit the keyfile by hand if you
+    learn its very simple structure, but this program makes it even easier
+    to manage the keyfile.
+
+OPTIONS
+
+ --version
+
+    Print the program name and version to STDOUT, and exit.
+
+    Alias: -v
+
+ --help
+
+    Print the synopsis to STDOUT, and exit.
+
+    Alias: -h
+
+ --file=path
+
+    Specify a path to a keyfile which stores a secret and keys.
+
+    Defaults to the value of the environment variable "GROUPSECRET_KEYFILE"
+    or groupsecret.yml.
+
+    Alias: -f
+
+ --private-key=path
+
+    Specify a path to a PEM private key. This is used by some commands to
+    decrypt the passphrase that protects the secret and is ignored by
+    commands that don't need it.
+
+    Defaults to the value of the environment variable
+    "GROUPSECRET_PRIVATE_KEY" or ~/.ssh/id_rsa.
+
+    Alias: -k
+
+COMMANDS
+
+ add-key
+
+        groupsecret add-key path/to/mykey_rsa.pub
+
+    Adds one or more SSH2 RSA public keys to a keyfile. This allows the
+    secret contained within the keyfile to be accessed by whoever has the
+    corresponding private key.
+
+    If the --embed option is used, the public keys will be embeded in the
+    keyfile. This may be a useful way to make sure the actual keys are
+    available in the future since they could be needed to encrypt a new
+    passphrase if it ever needs to be changed. Keys that are not embedded
+    will be searched for in the filesystem; see "GROUPSECRET_PATH".
+
+    If the --update option is used and a key with the same fingerprint is
+    added, the new key will replace the existing key. The default behavior
+    is to skip existing keys.
+
+    If the keyfile is storing a secret, the passphrase protecting the
+    secret will need to be decrypted so that access to the secret can be
+    shared with the new key(s).
+
+    Alias: add-keys
+
+ delete-key
+
+        groupsecret delete-key MD5:89:b3:fb:76:6c:f9:56:8e:a8:1a:df:ba:1c:ba:7d:05
+        groupsecret delete-key path/to/mykey_rsa.pub
+
+    Deletes one or more keys from a keyfile. This prevents the secret
+    contained within the keyfile from being accessed by whoever has the
+    corresponding private key.
+
+    Of course, if the owners of the key(s) being removed have already had
+    access to the keyfile prior to their keys being removed, the secret is
+    already exposed to them. It usually makes sense to follow up this
+    command with a "set-secret" command in order to change the secret.
+
+    Aliases: delete-keys, remove-key, remove-keys
+
+ list-keys
+
+        groupsecret list-keys
+
+    Prints the keys that have access to the secret contained in the keyfile
+    to STDOUT, one per line in the following format:
+
+        <fingerprint> <comment>
+
+ set-secret
+
+        groupsecret set-secret path/to/secretfile.txt
+        groupsecret set-secret - <<END
+        > it's a secret to everybody
+        > END
+        groupsecret set-secret rand:48
+
+    Set or update the secret contained in a keyfile. The argument allows
+    you to add a secret from a file, from <STDIN>, or from a stream of
+    secure random bytes.
+
+    If the keyfile already contains a secret, it will be replaced by the
+    new secret. A keyfile can only contain one secret at a time. If you
+    think you want to store more than one secret at a time, store a tarball
+    instead.
+
+    By default, this will also change the passphrase protecting the secret
+    and re-encrypt the passphrase for each key currently in the keyfile.
+    This requires all of the public keys to be available (see
+    "GROUPSECRET_PATH"). If for some reason you want to protect the new
+    secret with the current passphrase, use the --keep-passphrase option;
+    this can be done without the public keys being available, but it will
+    require a private key to decrypt the passphrase.
+
+    Aliases: change-secret, update-secret
+
+ print-secret
+
+        groupsecret print-secret
+        groupsecret print-secret --no-decrypt
+
+    Print the secret contained in the keyfile to STDOUT.
+
+    If the --no-decrypt option is used, the secret will be printed in its
+    encrypted form.
+
+    This requires a private key.
+
+    Aliases: (no command), show-secret
+
+REQUIREMENTS
+
+      * OpenSSH <https://www.openssh.com> (commands: ssh-keygen(1))
+
+      * OpenSSL <https://www.openssl.org> (commands: openssl(1))
+
+INSTALL
+
+    There are a few ways to install groupsecret to your system. First, make
+    sure you first have the "REQUIREMENTS" installed.
+
+ Using cpanm
+
+    You can install groupsecret using cpanm. If you have a local perl
+    (plenv, perlbrew, etc.), you can just do this:
+
+        cpanm App::GroupSecret
+
+    to install the groupsecret executable and its Perl module dependencies.
+    The executable will be installed to your perl's bin path, like
+    ~/perl5/perlbrew/bin/groupsecret.
+
+    If you're installing to your system perl, you can do:
+
+        cpanm --sudo App::GroupSecret
+
+    to install the groupsecret executable to a system directory, like
+    /usr/local/bin/groupsecret (depending on your perl).
+
+ For developers
+
+    If you're a developer and want to hack on the source, clone the
+    repository and pull the dependencies:
+
+        git clone https://github.com/chazmcgarvey/groupsecret.git
+        cd groupsecret
+        cpanm Dist::Zilla
+        dzil authordeps --missing | cpanm
+        dzil listdeps --author --develop --missing | cpanm
+
+ENVIRONMENT
+
+ GROUPSECRET_KEYFILE
+
+    If set, this program will use the value as a path to the keyfile. The
+    "--file=path" option takes precedence if used.
+
+ GROUPSECRET_PRIVATE_KEY
+
+    If set, this program will use the value as a path to private key used
+    for decryption. The "--private-key=path" option takes precedence if
+    used.
+
+ GROUPSECRET_PATH
+
+    The value of this variable should be a colon-separated list of
+    directories in which to search for public keys. By default, the actual
+    keys are not embedded in keyfiles, but they may be needed to encrypt a
+    new passphrase if it ever needs to be changed. Keys that are not
+    embedded will be searched for in the filesystem based on the value of
+    this environment variable.
+
+    Defaults to .:keys:$HOME/.ssh.
+
+EXAMPLES
+
+ ansible-vault
+
+    Ansible Vault <http://docs.ansible.com/ansible/latest/vault.html> is a
+    great way to securely store secret configuration variables for use in
+    your playbooks. Vaults are secured using a password, which is okay if
+    you're the only one who will need to unlock the Vault, but as soon as
+    you add team members who also need to access the Vault you are then
+    faced with how to manage knowledge of the password. When a team member
+    leaves, you'll also need to change the Vault password which means
+    you'll need a way to communicate the change to other team members who
+    also have access. This becomes a burden to manage.
+
+    You can use groupsecret to manage this very easily by storing the Vault
+    password in a groupsecret keyfile. That way, you can add or remove keys
+    and change the secret (the Vault password) at any time without
+    affecting the team members that still have access. Team members always
+    use their own SSH2 RSA keys to unlock the Vault, so no new password
+    ever needs to be communicated out.
+
+    To set this up, first create a keyfile with the public keys of everyone
+    on your team:
+
+        groupsecret -f vault-password.yml add-keys keys/*_rsa.pub
+
+    Then set the secret in the keyfile to a long random number:
+
+        groupsecret -f vault-password.yml set-secret rand:48
+
+    This will be the Ansible Vault password. You can see it if you want
+    using the "print-secret" command, but you don't need to.
+
+    Then we'll take advantage of the fact that an Ansible Vault password
+    file can be an executable program that prints the Vault password to
+    STDOUT. Create a file named vault-password with the following script,
+    and make it executable (chmod +x vault-password):
+
+        #!/bin/sh
+        # Use groupsecret <https://github.com/chazmcgarvey/groupsecret> to access the Vault password
+        exec ${GROUPSECRET:-groupsecret} -f vault-password.yml print-secret
+
+    Commit both vault-password and vault-password.yml to your repository.
+
+    Now use ansible-vault(1) to add files to the Vault:
+
+        ansible-vault --vault-id=vault-password encrypt foo.yml bar.yml baz.yml
+
+    These examples show the Ansible 2.4+ syntax, but it can be adapted for
+    earlier versions. The significant part of this command is
+    --vault-id=vault-password which refers to the executable script we
+    created earlier. You can use that argument with other ansible-vault
+    commands to view or edit the encrypted files.
+
+    You can also pass that same argument to ansible-playbook(1) in order to
+    use the Vault in playbooks that refer to the encrypted variables:
+
+        ansible-playbook -i myinventory --vault-id=vault-password site.yml
+
+    What this does is execute vault-password which executes groupsecret to
+    print the secret contained in the vault-password.yml file (which is
+    actually the Vault password) to STDOUT. In order to do this,
+    groupsecret will decrypt the keyfile passphrase using any one of the
+    private keys that have associated public keys added to the keyfile.
+
+    That's it! Pretty easy.
+
+    If and when you need to change the Vault password (such as when a team
+    member leaves), you can follow this procedure which is probably mostly
+    self-explanatory:
+
+        groupsecret -f vault-password.yml delete-key keys/revoked/jdoe_rsa.pub
+        groupsecret -f vault-password.yml print-secret >old-vault-password.txt
+        groupsecret -f vault-password.yml set-secret rand:48
+        echo "New Vault password: $(groupsecret -f vault-password.yml)"
+        ansible-vault --vault-id=old-vault-password.txt rekey foo.yml bar.yml baz.yml
+        # You will be prompted for the new Vault password which you can copy from the output above.
+        rm -f old-vault-password.txt
+
+    This removes access to the keyfile secret and to the Ansible Vault.
+    Don't forget that you may also want to change the variables being
+    protected by the Vault. After all, those secrets are the actual things
+    we're protecting by doing all of this, and an exiting team member may
+    have decided to take a copy of those variables for himself before
+    leaving.
+
+BUGS
+
+    Please report any bugs or feature requests on the bugtracker website
+    https://github.com/chazmcgarvey/groupsecret/issues
+
+    When submitting a bug or request, please include a test-file or a patch
+    to an existing test-file that illustrates the bug or desired feature.
+
+AUTHOR
+
+    Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+COPYRIGHT AND LICENSE
+
+    This software is Copyright (c) 2017 by Charles McGarvey.
+
+    This is free software, licensed under:
+
+      The MIT (X11) License
+
diff --git a/bin/groupsecret b/bin/groupsecret
new file mode 100755 (executable)
index 0000000..8f8465b
--- /dev/null
@@ -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 <filepath>] [-k <privatekey_path>]
+                <command> [<args>]
+
+    groupsecret add-key [--embed] [--update] <publickey_path> ...
+
+    groupsecret delete-key <fingerprint>|<publickey_path> ...
+
+    groupsecret list-keys
+
+    groupsecret set-secret [--keep-passphrase] <path>|-|rand:<num_bytes>
+
+    groupsecret [print-secret] [--no-decrypt]
+
+=head1 DESCRIPTION
+
+L<groupsecret> is a program that makes it easy for groups to share a secret between themselves
+without exposing the secret to anyone else. It could be used, for example, by a team to share an
+L<ansible-vault(1)> password; see L</ansible-vault> for more about this particular use case.
+
+The goal of this program is to be easy to use and have few dependencies (or only have dependencies
+users are likely to already have installed).
+
+groupsecret works by encrypting a secret with a symmetric cipher protected by a secure random
+passphrase which is itself encrypted by one or more SSH2 RSA public keys. Only those who have access
+to one of the corresponding private keys are able to decrypt the passphrase and access the secret.
+
+The encrypted secret and passphrase are stored in a single keyfile. You can even commit the keyfile
+in a public repo or in a private repo where some untrusted users may have read access; the secret is
+locked away to all except those with a private key to a corresponding public key that has been added
+to the keyfile.
+
+The keyfile is just a YAML file, so it's human-readable (except of course for the encrypted parts).
+This make it easy to add to version control and work with diffs. You can edit the keyfile by hand if
+you learn its very simple structure, but this program makes it even easier to manage the keyfile.
+
+=head1 OPTIONS
+
+=head2 --version
+
+Print the program name and version to C<STDOUT>, and exit.
+
+Alias: C<-v>
+
+=head2 --help
+
+Print the synopsis to C<STDOUT>, and exit.
+
+Alias: C<-h>
+
+=head2 --file=path
+
+Specify a path to a keyfile which stores a secret and keys.
+
+Defaults to the value of the environment variable L</GROUPSECRET_KEYFILE> or F<groupsecret.yml>.
+
+Alias: C<-f>
+
+=head2 --private-key=path
+
+Specify a path to a PEM private key. This is used by some commands to decrypt the passphrase that
+protects the secret and is ignored by commands that don't need it.
+
+Defaults to the value of the environment variable L</GROUPSECRET_PRIVATE_KEY> or F<~/.ssh/id_rsa>.
+
+Alias: C<-k>
+
+=head1 COMMANDS
+
+=head2 add-key
+
+    groupsecret add-key path/to/mykey_rsa.pub
+
+Adds one or more SSH2 RSA public keys to a keyfile. This allows the secret contained within the
+keyfile to be accessed by whoever has the corresponding private key.
+
+If the C<--embed> option is used, the public keys will be embeded in the keyfile. This may be
+a useful way to make sure the actual keys are available in the future since they could be needed to
+encrypt a new passphrase if it ever needs to be changed. Keys that are not embedded will be searched
+for in the filesystem; see L</GROUPSECRET_PATH>.
+
+If the C<--update> option is used and a key with the same fingerprint is added, the new key will
+replace the existing key. The default behavior is to skip existing keys.
+
+If the keyfile is storing a secret, the passphrase protecting the secret will need to be decrypted
+so that access to the secret can be shared with the new key(s).
+
+Alias: C<add-keys>
+
+=head2 delete-key
+
+    groupsecret delete-key MD5:89:b3:fb:76:6c:f9:56:8e:a8:1a:df:ba:1c:ba:7d:05
+    groupsecret delete-key path/to/mykey_rsa.pub
+
+Deletes one or more keys from a keyfile. This prevents the secret contained within the keyfile from
+being accessed by whoever has the corresponding private key.
+
+Of course, if the owners of the key(s) being removed have already had access to the keyfile prior to
+their keys being removed, the secret is already exposed to them. It usually makes sense to follow up
+this command with a L</set-secret> command in order to change the secret.
+
+Aliases: C<delete-keys>, C<remove-key>, C<remove-keys>
+
+=head2 list-keys
+
+    groupsecret list-keys
+
+Prints the keys that have access to the secret contained in the keyfile to C<STDOUT>, one per line
+in the following format:
+
+    <fingerprint> <comment>
+
+=head2 set-secret
+
+    groupsecret set-secret path/to/secretfile.txt
+    groupsecret set-secret - <<END
+    > it's a secret to everybody
+    > END
+    groupsecret set-secret rand:48
+
+Set or update the secret contained in a keyfile. The argument allows you to add a secret from
+a file, from <STDIN>, or from a stream of secure random bytes.
+
+If the keyfile already contains a secret, it will be replaced by the new secret. A keyfile can only
+contain one secret at a time. If you think you want to store more than one secret at a time, store
+a tarball instead.
+
+By default, this will also change the passphrase protecting the secret and re-encrypt the passphrase
+for each key currently in the keyfile. This requires all of the public keys to be available (see
+L</GROUPSECRET_PATH>). If for some reason you want to protect the new secret with the current
+passphrase, use the C<--keep-passphrase> option; this can be done without the public keys being
+available, but it will require a private key to decrypt the passphrase.
+
+Aliases: C<change-secret>, C<update-secret>
+
+=head2 print-secret
+
+    groupsecret print-secret
+    groupsecret print-secret --no-decrypt
+
+Print the secret contained in the keyfile to C<STDOUT>.
+
+If the C<--no-decrypt> option is used, the secret will be printed in its encrypted form.
+
+This requires a private key.
+
+Aliases: (no command), C<show-secret>
+
+=head1 REQUIREMENTS
+
+=over 4
+
+=item *
+
+L<OpenSSH|https://www.openssh.com> (commands: L<ssh-keygen(1)>)
+
+=item *
+
+L<OpenSSL|https://www.openssl.org> (commands: L<openssl(1)>)
+
+=back
+
+=head1 INSTALL
+
+There are a few ways to install groupsecret to your system. First, make sure you first have the
+L</REQUIREMENTS> installed.
+
+=head2 Using cpanm
+
+You can install groupsecret using L<cpanm>. If you have a local perl (plenv, perlbrew, etc.), you
+can just do this:
+
+    cpanm App::GroupSecret
+
+to install the F<groupsecret> executable and its Perl module dependencies. The executable will be
+installed to your perl's bin path, like F<~/perl5/perlbrew/bin/groupsecret>.
+
+If you're installing to your system perl, you can do:
+
+    cpanm --sudo App::GroupSecret
+
+to install the F<groupsecret> executable to a system directory, like F</usr/local/bin/groupsecret>
+(depending on your perl).
+
+=head2 For developers
+
+If you're a developer and want to hack on the source, clone the repository and pull the
+dependencies:
+
+    git clone https://github.com/chazmcgarvey/groupsecret.git
+    cd groupsecret
+    cpanm Dist::Zilla
+    dzil authordeps --missing | cpanm
+    dzil listdeps --author --develop --missing | cpanm
+
+=head1 ENVIRONMENT
+
+=head2 GROUPSECRET_KEYFILE
+
+If set, this program will use the value as a path to the keyfile. The L</--file=path> option takes
+precedence if used.
+
+=head2 GROUPSECRET_PRIVATE_KEY
+
+If set, this program will use the value as a path to private key used for decryption. The
+L</--private-key=path> option takes precedence if used.
+
+=head2 GROUPSECRET_PATH
+
+The value of this variable should be a colon-separated list of directories in which to search for
+public keys. By default, the actual keys are not embedded in keyfiles, but they may be needed to
+encrypt a new passphrase if it ever needs to be changed. Keys that are not embedded will be searched
+for in the filesystem based on the value of this environment variable.
+
+Defaults to C<.:keys:$HOME/.ssh>.
+
+=head1 EXAMPLES
+
+=head2 ansible-vault
+
+L<Ansible Vault|http://docs.ansible.com/ansible/latest/vault.html> is a great way to securely store
+secret configuration variables for use in your playbooks. Vaults are secured using a password, which
+is okay if you're the only one who will need to unlock the Vault, but as soon as you add team
+members who also need to access the Vault you are then faced with how to manage knowledge of the
+password. When a team member leaves, you'll also need to change the Vault password which means
+you'll need a way to communicate the change to other team members who also have access. This becomes
+a burden to manage.
+
+You can use groupsecret to manage this very easily by storing the Vault password in a groupsecret
+keyfile. That way, you can add or remove keys and change the secret (the Vault password) at any time
+without affecting the team members that still have access. Team members always use their own SSH2
+RSA keys to unlock the Vault, so no new password ever needs to be communicated out.
+
+To set this up, first create a keyfile with the public keys of everyone on your team:
+
+    groupsecret -f vault-password.yml add-keys keys/*_rsa.pub
+
+Then set the secret in the keyfile to a long random number:
+
+    groupsecret -f vault-password.yml set-secret rand:48
+
+This will be the Ansible Vault password. You can see it if you want using the L</print-secret>
+command, but you don't need to.
+
+Then we'll take advantage of the fact that an Ansible Vault password file can be an executable
+program that prints the Vault password to C<STDOUT>. Create a file named F<vault-password> with the
+following script, and make it executable (C<chmod +x vault-password>):
+
+    #!/bin/sh
+    # Use groupsecret <https://github.com/chazmcgarvey/groupsecret> to access the Vault password
+    exec ${GROUPSECRET:-groupsecret} -f vault-password.yml print-secret
+
+Commit both F<vault-password> and F<vault-password.yml> to your repository.
+
+Now use L<ansible-vault(1)> to add files to the Vault:
+
+    ansible-vault --vault-id=vault-password encrypt foo.yml bar.yml baz.yml
+
+These examples show the Ansible 2.4+ syntax, but it can be adapted for earlier versions. The
+significant part of this command is C<--vault-id=vault-password> which refers to the executable
+script we created earlier. You can use that argument with other ansible-vault commands to view or
+edit the encrypted files.
+
+You can also pass that same argument to L<ansible-playbook(1)> in order to use the Vault in
+playbooks that refer to the encrypted variables:
+
+    ansible-playbook -i myinventory --vault-id=vault-password site.yml
+
+What this does is execute F<vault-password> which executes groupsecret to print the secret contained
+in the F<vault-password.yml> file (which is actually the Vault password) to C<STDOUT>. In order to
+do this, groupsecret will decrypt the keyfile passphrase using any one of the private keys that have
+associated public keys added to the keyfile.
+
+That's it! Pretty easy.
+
+If and when you need to change the Vault password (such as when a team member leaves), you can
+follow this procedure which is probably mostly self-explanatory:
+
+    groupsecret -f vault-password.yml delete-key keys/revoked/jdoe_rsa.pub
+    groupsecret -f vault-password.yml print-secret >old-vault-password.txt
+    groupsecret -f vault-password.yml set-secret rand:48
+    echo "New Vault password: $(groupsecret -f vault-password.yml)"
+    ansible-vault --vault-id=old-vault-password.txt rekey foo.yml bar.yml baz.yml
+    # You will be prompted for the new Vault password which you can copy from the output above.
+    rm -f old-vault-password.txt
+
+This removes access to the keyfile secret and to the Ansible Vault. Don't forget that you may also
+want to change the variables being protected by the Vault. After all, those secrets are the actual
+things we're protecting by doing all of this, and an exiting team member may have decided to take
+a copy of those variables for himself before leaving.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+  The MIT (X11) License
+
+=cut
diff --git a/lib/App/GroupSecret.pm b/lib/App/GroupSecret.pm
new file mode 100644 (file)
index 0000000..37c9cc5
--- /dev/null
@@ -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 $/; <STDIN> };
+        $secret = \$in;
+    }
+    elsif ($secret_spec =~ /^file:(.*)$/i) {
+        $secret = $1;
+    }
+    else {
+        $secret = $secret_spec;
+    }
+
+    my $file = $self->file;
+
+    if ($keep_passphrase) {
+        my $private_key = $self->private_key;
+        $passphrase = $file->decrypt_secret_passphrase($private_key);
+        $file->encrypt_secret($secret, $passphrase);
+    }
+    else {
+        $passphrase = generate_secure_random_bytes(32);
+        $file->encrypt_secret($secret, $passphrase);
+        $file->encrypt_secret_passphrase($passphrase);
+    }
+
+    $file->save;
+}
+
+sub _action_add_key {
+    my $self = shift;
+
+    my $embed   = 0;
+    my $update  = 0;
+    GetOptionsFromArray(
+        \@_,
+        'embed'     => \$embed,
+        'update|u'  => \$update,
+    ) or pod2usage(2);
+
+    my $file = $self->file;
+    my $keys = $file->keys;
+
+    my $opts = {embed => $embed};
+
+    for my $public_key (@_) {
+        my $info = read_openssh_key_fingerprint($public_key);
+
+        if ($keys->{$info->{fingerprint}} && !$update) {
+            my $formatted_key = $file->format_key($info);
+            print "SKIP\t$formatted_key\n";
+            next;
+        }
+
+        if ($file->secret && !$opts->{passphrase}) {
+            my $private_key = $self->private_key;
+            my $passphrase  = $file->decrypt_secret_passphrase($private_key);
+            $opts->{passphrase} = $passphrase;
+        }
+
+        local $opts->{fingerprint_info} = $info;
+        my ($fingerprint, $key) = $file->add_key($public_key, $opts);
+
+        local $key->{fingerprint} = $fingerprint;
+        my $formatted_key = $file->format_key($key);
+        print "ADD\t$formatted_key\n";
+    }
+
+    $file->save;
+}
+
+sub _action_delete_key {
+    my $self = shift;
+
+    my $file = $self->file;
+
+    for my $fingerprint (@_) {
+        if ($fingerprint =~ s/^(?:MD5|SHA1|SHA256)://) {
+            $fingerprint =~ s/://g;
+        }
+        else {
+            my $info = read_openssh_key_fingerprint($fingerprint);
+            $fingerprint = $info->{fingerprint};
+        }
+
+        my $key = $file->keys->{$fingerprint};
+        $file->delete_key($fingerprint) if $key;
+
+        local $key->{fingerprint} = $fingerprint;
+        my $formatted_key = $file->format_key($key);
+        print "DELETE\t$formatted_key\n";
+    }
+
+    $file->save;
+}
+
+sub _action_list_keys {
+    my $self = shift;
+
+    my $file = $self->file;
+    my $keys = $file->keys;
+
+    while (my ($fingerprint, $key) = each %$keys) {
+        local $key->{fingerprint} = $fingerprint;
+        my $formatted_key = $file->format_key($key);
+        print "$formatted_key\n";
+    }
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::GroupSecret - A simple tool for maintaining a shared group secret
+
+=head1 VERSION
+
+version 0.303
+
+=head1 DESCRIPTION
+
+This module is part of the command-line interface for managing keyfiles.
+
+See L<groupsecret> for documentation.
+
+=head1 METHODS
+
+=head2 new
+
+    $script = App::GroupSecret->new;
+
+Construct a new script object.
+
+=head2 main
+
+    $script->main(@ARGV);
+
+Run a command with the given command-line arguments.
+
+=head2 filepath
+
+    $filepath = $script->filepath;
+
+Get the path to the keyfile.
+
+=head2 file
+
+    $file = $script->file;
+
+Get the L<App::GroupSecret::File> instance for the keyfile.
+
+=head2 private_key
+
+    $filepath = $script->private_key;
+
+Get the path to a private key used to decrypt the keyfile.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+  The MIT (X11) License
+
+=cut
diff --git a/lib/App/GroupSecret/Crypt.pm b/lib/App/GroupSecret/Crypt.pm
new file mode 100644 (file)
index 0000000..c3584ef
--- /dev/null
@@ -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<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+  The MIT (X11) License
+
+=cut
diff --git a/lib/App/GroupSecret/File.pm b/lib/App/GroupSecret/File.pm
new file mode 100644 (file)
index 0000000..f662473
--- /dev/null
@@ -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<groupsecret> for the command-line interface.
+
+=head1 ATTRIBUTES
+
+=head2 filepath
+
+Get the filepath of the keyfile.
+
+=head1 METHODS
+
+=head2 new
+
+    $file = App::GroupSecret::File->new($filepath);
+
+Construct a new keyfile object.
+
+=head2 info
+
+    $info = $file->info;
+
+Get a raw hashref with the contents of the keyfile.
+
+=head2 init
+
+    $info = $file->init;
+
+Get a hashref representing an empty keyfile, used for initializing a new keyfile.
+
+=head2 load
+
+    $info = $file->load;
+    $info = $file->load($filepath);
+
+Load (or reload) the contents of a keyfile.
+
+=head2 save
+
+    $file->save;
+    $file->save($filepath);
+
+Save the keyfile to disk.
+
+=head2 check
+
+    $file->check;
+    $file->check($info);
+
+Check the file format of a keyfile to make sure this module can understand it.
+
+=head2 keys
+
+    $keys = $file->keys;
+
+Get a hashref of the keys from a keyfile.
+
+=head2 secret
+
+    $secret = $file->secret;
+
+Get the secret from a keyfile as an encrypted string.
+
+=head2 version
+
+    $version = $file->version
+
+Get the file format version.
+
+=head2 add_key
+
+    $file->add_key($filepath);
+
+Add a key to the keyfile.
+
+=head2 delete_key
+
+    $file->delete_key($fingerprint);
+
+Delete a key from the keyfile.
+
+=head2 decrypt_secret
+
+    $secret = $file->decrypt_secret(passphrase => $passphrase);
+    $secret = $file->decrypt_secret(private_key => $private_key);
+
+Get the decrypted secret.
+
+=head2 decrypt_secret_passphrase
+
+    $passphrase = $file->decrypt_secret_passphrase($private_key);
+
+Get the decrypted secret passphrase.
+
+=head2 encrypt_secret
+
+    $file->encrypt_secret($secret, $passphrase);
+
+Set the secret by encrypting it with a 256-bit passphrase.
+
+Passphrase must be 32 bytes.
+
+=head2 encrypt_secret_passphrase
+
+    $file->encrypt_secret_passphrase($passphrase);
+
+Set the passphrase by encrypting it with each key in the keyfile.
+
+=head2 find_public_key
+
+    $filepath = $file->find_public_key($key);
+
+Get a path to the public key file for a key.
+
+=head2 format_key
+
+    $str = $file->format_key($key);
+
+Get a one-line summary of a key. Format is "<fingerprint> <comment>".
+
+=head1 FILE FORMAT
+
+Keyfiles are YAML documents that contains this structure:
+
+    ---
+    keys:
+      FINGERPRINT:
+        comment: COMMENT
+        content: ssh-rsa ...
+        filename: FILENAME
+        secret_passphrase: PASSPHRASE...
+        type: rsa
+    secret: SECRET...
+    version: 1
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/groupsecret/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2017 by Charles McGarvey.
+
+This is free software, licensed under:
+
+  The MIT (X11) License
+
+=cut
diff --git a/t/00-compile.t b/t/00-compile.t
new file mode 100644 (file)
index 0000000..3b2fb91
--- /dev/null
@@ -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<perlfaq8/How can I capture STDERR from an external command?>
+    my $stderr = IO::Handle->new;
+
+    diag('Running: ', join(', ', map { my $str = $_; $str =~ s/'/\\'/g; q{'} . $str . q{'} }
+            $^X, @switches, '-e', "require q[$lib]"))
+        if $ENV{PERL_COMPILE_TEST_DEBUG};
+
+    my $pid = open3($stdin, '>&STDERR', $stderr, $^X, @switches, '-e', "require q[$lib]");
+    binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+    my @_warnings = <$stderr>;
+    waitpid($pid, 0);
+    is($?, 0, "$lib loaded ok");
+
+    shift @_warnings if @_warnings and $_warnings[0] =~ /^Using .*\bblib/
+        and not eval { +require blib; blib->VERSION('1.01') };
+
+    if (@_warnings)
+    {
+        warn @_warnings;
+        push @warnings, @_warnings;
+    }
+}
+
+foreach my $file (@scripts)
+{ SKIP: {
+    open my $fh, '<', $file or warn("Unable to open $file: $!"), next;
+    my $line = <$fh>;
+
+    close $fh and skip("$file isn't perl", 1) unless $line =~ /^#!\s*(?:\S*perl\S*)((?:\s+-\w*)*)(?:\s*#.*)?$/;
+    @switches = (@switches, split(' ', $1)) if $1;
+
+    my $stderr = IO::Handle->new;
+
+    diag('Running: ', join(', ', map { my $str = $_; $str =~ s/'/\\'/g; q{'} . $str . q{'} }
+            $^X, @switches, '-c', $file))
+        if $ENV{PERL_COMPILE_TEST_DEBUG};
+
+    my $pid = open3($stdin, '>&STDERR', $stderr, $^X, @switches, '-c', $file);
+    binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+    my @_warnings = <$stderr>;
+    waitpid($pid, 0);
+    is($?, 0, "$file compiled ok");
+
+    shift @_warnings if @_warnings and $_warnings[0] =~ /^Using .*\bblib/
+        and not eval { +require blib; blib->VERSION('1.01') };
+
+    # in older perls, -c output is simply the file portion of the path being tested
+    if (@_warnings = grep { !/\bsyntax OK$/ }
+        grep { chomp; $_ ne (File::Spec->splitpath($file))[2] } @_warnings)
+    {
+        warn @_warnings;
+        push @warnings, @_warnings;
+    }
+} }
+
+
+
+is(scalar(@warnings), 0, 'no warnings found')
+    or diag 'got warnings: ', ( Test::More->can('explain') ? Test::More::explain(\@warnings) : join("\n", '', @warnings) ) if $ENV{AUTHOR_TESTING};
+
+
diff --git a/t/00-report-prereqs.dd b/t/00-report-prereqs.dd
new file mode 100644 (file)
index 0000000..c87c72d
--- /dev/null
@@ -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 (file)
index 0000000..c72183a
--- /dev/null
@@ -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 (file)
index 0000000..ea85144
--- /dev/null
@@ -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 (file)
index 0000000..2301513
--- /dev/null
@@ -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 (file)
index 0000000..a205ddd
--- /dev/null
@@ -0,0 +1,4 @@
+---
+secret: ~
+keys: {}
+version: 1
diff --git a/t/keys/foo_rsa b/t/keys/foo_rsa
new file mode 100644 (file)
index 0000000..0fbce92
--- /dev/null
@@ -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 (file)
index 0000000..a53e1b8
--- /dev/null
@@ -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 (file)
index 0000000..36387da
--- /dev/null
@@ -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 (file)
index 0000000..80ccdad
--- /dev/null
@@ -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 (file)
index 0000000..c72e642
--- /dev/null
@@ -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 (file)
index 0000000..4b8885a
--- /dev/null
@@ -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 (file)
index 0000000..66b3b64
--- /dev/null
@@ -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 (file)
index 0000000..eb9760c
--- /dev/null
@@ -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 (file)
index 0000000..e563e5d
--- /dev/null
@@ -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 (file)
index 0000000..c531252
--- /dev/null
@@ -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 (file)
index 0000000..286005a
--- /dev/null
@@ -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 (file)
index 0000000..c2280dc
--- /dev/null
@@ -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 (file)
index 0000000..ff71971
--- /dev/null
@@ -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} );
This page took 0.07569 seconds and 4 git commands to generate.