initial commit
authorCharles McGarvey <chazmcgarvey@brokenzipper.com>
Thu, 30 Nov 2017 05:56:50 +0000 (22:56 -0700)
committerCharles McGarvey <chazmcgarvey@brokenzipper.com>
Thu, 30 Nov 2017 06:40:07 +0000 (23:40 -0700)
16 files changed:
.editorconfig [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.travis.yml [new file with mode: 0644]
Changes [new file with mode: 0644]
LICENSE [new file with mode: 0644]
Makefile [new file with mode: 0644]
bin/groupsecret [new file with mode: 0755]
dist.ini [new file with mode: 0644]
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/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]

diff --git a/.editorconfig b/.editorconfig
new file mode 100644 (file)
index 0000000..f1f6b59
--- /dev/null
@@ -0,0 +1,21 @@
+
+# Please follow these code style guidelines. You can use this file to
+# automatically configure your editor.
+# For instructions, see: http://editorconfig.org/
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+
+[*.{pl,pm,t}]
+indent_size = 4
+indent_style = space
+max_line_length = 100
+trim_trailing_whitespace = true
+
+[*.{yml,yaml}]
+indent_size = 2
+indent_style = space
+trim_trailing_whitespace = true
+
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..76b12c8
--- /dev/null
@@ -0,0 +1,3 @@
+/.build
+/.perl-version
+/App-GroupSecret-*
diff --git a/.travis.yml b/.travis.yml
new file mode 100644 (file)
index 0000000..e90dbdc
--- /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'
+matrix:
+   fast_finish: true
+before_install:
+   - git config --global user.name "TravisCI"
+   - git config --global user.email $HOSTNAME":not-for-mail@travis-ci.org"
+install:
+   - cpanm --quiet --notest --skip-satisfied Dist::Zilla
+   - "dzil authordeps          --missing | grep -vP '[^\\w:]' | xargs -n 5 -P 10 cpanm --quiet --notest"
+   - "dzil listdeps   --author --missing | grep -vP '[^\\w:]' | cpanm --verbose"
+script:
+   - dzil smoke --release --author
diff --git a/Changes b/Changes
new file mode 100644 (file)
index 0000000..be261bc
--- /dev/null
+++ b/Changes
@@ -0,0 +1,4 @@
+Revision history for groupsecret.
+
+{{$NEXT}}
+
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/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..c192c97
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,26 @@
+
+# This is not a Perl distribution, but it can build one using Dist::Zilla.
+
+CPANM   = cpanm
+DZIL    = dzil
+PERL    = perl
+PROVE   = prove
+
+all: bootstrap dist
+
+bootstrap:
+       $(CPANM) Dist::Zilla
+       $(DZIL) authordeps --missing | $(CPANM)
+       $(DZIL) listdeps --develop --missing | $(CPANM)
+
+clean:
+       $(DZIL) $@
+
+dist:
+       $(DZIL) build
+
+test:
+       $(PROVE) -l
+
+.PHONY: all bootstrap clean dist test
+
diff --git a/bin/groupsecret b/bin/groupsecret
new file mode 100755 (executable)
index 0000000..5f13ab4
--- /dev/null
@@ -0,0 +1,240 @@
+#!perl
+# PODNAME: groupsecret
+# ABSTRACT: A simple tool for maintaining a shared group secret
+
+=head1 SYNOPSIS
+
+    groupsecret [--version] [--help] [-f <filepath>] [-k <privatekey_path>]
+                <command> [<args>]
+
+    groupsecret add-key [--embed] [--update] <publickey_path> ...
+
+    groupsecret delete-key <fingerprint>|<publickey_path> ...
+
+    groupsecret list-keys
+
+    groupsecret set-secret [--keep-passphrase] <path>|-|rand:<num_bytes>
+
+    groupsecret [print-secret] [--no-decrypt]
+
+=head1 DESCRIPTION
+
+L<groupsecret> is a program that makes it easy for groups to share a secret between themselves
+without exposing the secret to anyone else. It could be used, for example, by a team to share an
+L<ansible-vault(1)> password; see L</ansible-vault> for more about this particular use case.
+
+The goal of this program is to be easy to use and have few dependencies (or only have dependencies
+users are likely to already have installed).
+
+L<groupsecret> works by encrypting a secret with a symmetric cipher protected by a secure random
+passphrase which is itself encrypted by one or more SSH2 RSA public keys. Only those who have access
+to one of the corresponding private keys are able to decrypt the passphrase and access the secret.
+
+The encrypted secret and passphrase are stored in a single keyfile. You can even commit the keyfile
+in a public repo or in a private repo where some untrusted users may have read access; the secret is
+locked away to all except those with a private key to a corresponding public key that has been added
+to the keyfile.
+
+The keyfile is just a YAML file, so it's human-readable (except of course for the encrypted parts).
+This make it easy to add to version control and work with diffs. You can edit the keyfile by hand if
+you learn its very simple structure, but this program makes it even easier to manage the keyfile.
+
+=head1 OPTIONS
+
+=head2 --version
+
+Print the program name and version to C<STDOUT>, and exit.
+
+Alias: C<-v>
+
+=head2 --help
+
+Print the synopsis to C<STDOUT>, and exit.
+
+Alias: C<-h>
+
+=head2 --file=path
+
+Specify a path to a keyfile which stores a secret and keys.
+
+Defaults to the value of the environment variable C<GROUPSECRET_KEYFILE> or F<groupsecret.yml>.
+
+Alias: C<-f>
+
+=head2 --private-key=path
+
+Specify a path to a PEM private key. This is used by some commands to decrypt the passphrase that
+protects the secret and is ignored by commands that don't need it.
+
+Defaults to the value of the environment variable L</GROUPSECRET_PRIVATE_KEY>. If that is unset, it
+defaults to F<~/.ssh/id_rsa>.
+
+Alias: C<-k>
+
+=head1 COMMANDS
+
+=head2 add-key
+
+    groupsecret add-key path/to/mykey_rsa.pub
+
+Adds one or more SSH2 RSA public keys to a keyfile. This allows the secret contained within the
+keyfile to be accessed by whoever has the corresponding private key.
+
+If the C<--embed> option is used, the public keys will be embeded in the keyfile. This may be
+a useful way to make sure the actual keys are available in the future since they could be needed to
+encrypt a new passphrase if it ever needs to be changed. Keys that are not embedded will be searched
+for in the filesystem; see L</GROUPSECRET_PATH>.
+
+If the C<--update> option is used and a key with the same fingerprint is added, the new key will
+replaced the existing key. The default behavior is to skip existing keys.
+
+If the keyfile is storing a secret, the passphrase protecting the secret will need to be decrypted
+so that access to the secret can be shared with the new key(s).
+
+Alias: C<add-keys>
+
+=head2 delete-key
+
+    groupsecret delete-key MD5:89:b3:fb:76:6c:f9:56:8e:a8:1a:df:ba:1c:ba:7d:05
+    groupsecret delete-key path/to/mykey_rsa.pub
+
+Deletes one or more keys from a keyfile. This prevents the secret contained within the keyfile from
+being accessed by whoever has the corresponding private key.
+
+Of course, if the owners of the key(s) being removed have already had access to the keyfile prior to
+their keys being removed, the secret is already exposed to them. It usually makes sense to follow up
+this command with a L</set-secret> command in order to change the secret.
+
+Aliases: C<delete-keys>, C<remove-key>, C<remove-keys>
+
+=head2 list-keys
+
+    groupsecret list-keys
+
+Prints the keys that have access to the secret contained in the keyfile to C<STDOUT>, one per line
+in the following format:
+
+    <fingerprint> <comment>
+
+=head2 set-secret
+
+    groupsecret set-secret path/to/secretfile.txt
+    groupsecret set-secret - <<END
+    > it's a secret to everybody
+    > END
+    groupsecret set-secret rand:48
+
+Set or update the secret contained in a keyfile. The argument allows you to add a secret from
+a file, from <STDIN>, or from a stream of secure random bytes.
+
+If the keyfile already contains a secret, it will be replaced by the new secret. A keyfile can only
+contain one secret at a time. If you think you want to store more than one secret at a time, store
+a tarball instead.
+
+By default, this will also change the passphrase protecting the secret and re-encrypt the passphrase
+for each key currently in the keyfile. This requires all of the public keys to be available (see
+L</GROUPSECRET_PATH>). If for some reason you want to protect the new secret with the current
+passphrase, use the C<--keep-passphrase> option; this can be done without the public keys being
+available, but it will require a private key to decrypt the passphrase.
+
+Aliases: C<change-secret>, C<update-secret>
+
+=head2 print-secret
+
+    groupsecret print-secret
+    groupsecret print-secret --no-decrypt
+
+Print the secret contained in the keyfile to C<STDOUT>.
+
+If the C<--no-decrypt> option is used, the secret will be printed in its encrypted form.
+
+This requires a private key.
+
+Aliases: (no command), C<show-secret>
+
+=head1 REQUIREMENTS
+
+=for :list
+* L<OpenSSH|https://www.openssh.com> (commands: L<ssh-keygen(1)>)
+* L<OpenSSL|https://www.openssl.org> (commands: L<openssl(1)>)
+
+=head1 INSTALL
+
+There are several ways to install groupsecret to your system. First, make sure you first have the
+L</REQUIREMENTS> installed.
+
+=head2 Using cpanm
+
+You can install groupsecret using L<cpanm>. If you have a local perl (plenv, perlbrew, etc.), you
+can just do this:
+
+    cpanm App::GroupSecret
+
+to install the F<groupsecret> executable and its Perl module dependencies. The executable will be
+installed to your perl's bin path, like F<~/perl5/perlbrew/bin/groupsecret>.
+
+If you're installing to your system perl, you can do:
+
+    cpanm --sudo App::GroupSecret
+
+to install the F<groupsecret> executable to a system directory, like F</usr/local/bin/groupsecret>
+(depending on your perl).
+
+=head2 Downloading just the executable
+
+You may also choose to download F<groupsecret> as a single executable, like this:
+
+    curl -OL https://raw.githubusercontent.com/chazmcgarvey/groupsecret/solo/groupsecret
+    chmod +x groupsecret
+
+This executable is fat-packed and includes all the non-core Perl module dependencies built-in.
+
+=head2 For developers
+
+If you're a developer and want to hack on the source, clone the repository and pull the
+dependencies:
+
+    git clone https://github.com/chazmcgarvey/groupsecret.git
+    cd groupsecret
+    cpanm Dist::Zilla
+    dzil authordeps --missing | cpanm
+    dzil listdeps --author --develop --missing | cpanm
+
+=head1 ENVIRONMENT
+
+=head2 GROUPSECRET_KEYFILE
+
+If set, this program will use the value as a path to the keyfile. The L</--file=path> option takes
+precedence if it is used.
+
+=head2 GROUPSECRET_PRIVATE_KEY
+
+If set, this program will use the value as a path to the keyfile. The L</--private-key=path> option
+takes precedence if it is used.
+
+=head2 GROUPSECRET_PATH
+
+The value of this variable should be a colon-separated list of directories in which to search for
+public keys. By default, the actual keys are not embedded in keyfiles, but they may be needed to
+encrypt a new passphrase if it ever needs to be changed. Keys that are not embedded will be searched
+for in the filesystem based on the value of this environment variable.
+
+Defaults to C<.:keys:$HOME/.ssh>.
+
+=head1 EXAMPLES
+
+=head2 ansible-vault
+
+TODO
+
+=cut
+
+use warnings FATAL => 'all';
+use strict;
+
+our $VERSION = '9999.999'; # VERSION
+
+use App::GroupSecret;
+
+App::GroupSecret->new->main(@ARGV);
+exit;
diff --git a/dist.ini b/dist.ini
new file mode 100644 (file)
index 0000000..7161ab8
--- /dev/null
+++ b/dist.ini
@@ -0,0 +1,9 @@
+
+name                = App-GroupSecret
+author              = Charles McGarvey <chazmcgarvey@brokenzipper.com>
+copyright_holder    = Charles McGarvey
+copyright_year      = 2017
+license             = MIT
+
+[@Author::CCM]
+
diff --git a/lib/App/GroupSecret.pm b/lib/App/GroupSecret.pm
new file mode 100644 (file)
index 0000000..fe6593d
--- /dev/null
@@ -0,0 +1,256 @@
+package App::GroupSecret;
+# ABSTRACT: A simple tool for maintaining a shared group secret
+
+=head1 DESCRIPTION
+
+This module is part of the command-line interface for managing keyfiles.
+
+See L<groupsecret> for documentation.
+
+=cut
+
+use warnings;
+use strict;
+
+our $VERSION = '9999.999'; # VERSION
+
+use App::GroupSecret::Crypt qw(generate_secure_random_bytes read_openssh_key_fingerprint);
+use App::GroupSecret::File;
+use Getopt::Long qw(GetOptionsFromArray);
+use MIME::Base64;
+use Pod::Usage;
+use namespace::clean;
+
+sub new {
+    my $class = shift;
+    return bless {}, $class;
+}
+
+sub main {
+    my $self = shift;
+    my @args = @_;
+
+    my $filepath    = '';
+    my $help        = 0;
+    my $man         = 0;
+    my $version     = 0;
+    my $private_key = '';
+
+    # Parse options using pass_through so that we can pick out the global
+    # options, wherever they are in the arg list, and leave the rest to be
+    # parsed by each individual command.
+    Getopt::Long::Configure('pass_through');
+    GetOptionsFromArray(
+        \@args,
+        'file|f=s'          => \$filepath,
+        'help|h|?'          => \$help,
+        'manual|man'        => \$man,
+        'private-key|k=s'   => \$private_key,
+        'version|v'         => \$version,
+    ) or pod2usage(2);
+    Getopt::Long::Configure('default');
+
+    pod2usage(-exitval => 1, -verbose => 99, -sections => [qw(SYNOPSIS OPTIONS COMMANDS)]) if $help;
+    pod2usage(-verbose => 2) if $man;
+    return print "groupsecret ${VERSION}\n" if $version;
+
+    $self->{private_key} = $private_key if $private_key;
+    $self->{filepath}    = $filepath    if $filepath;
+
+    my %commands = (
+        add_key         => 'add_key',
+        add_keys        => 'add_key',
+        change_secret   => 'set_secret',
+        delete_key      => 'delete_key',
+        delete_keys     => 'delete_key',
+        list_keys       => 'list_keys',
+        print           => 'print_secret',
+        print_secret    => 'print_secret',
+        remove_key      => 'delete_key',
+        remove_keys     => 'delete_key',
+        set_secret      => 'set_secret',
+        show_secret     => 'print_secret',
+        update_secret   => 'set_secret',
+    );
+
+    unshift @args, 'print' if !@args || $args[0] =~ /^-/;
+
+    my $command = shift @args;
+    my $lookup = $command;
+    $lookup =~ s/-/_/g;
+    my $method = 'action_' . ($commands{$lookup} || '');
+
+    if (!$self->can($method)) {
+        warn "Unknown command: $command\n";
+        pod2usage(2);
+    }
+
+    $self->$method(@args);
+}
+
+sub filepath {
+    shift->{filepath} ||= $ENV{GROUPSECRET_KEYFILE} || 'groupsecret.yml';
+
+}
+
+sub file {
+    my $self = shift;
+    return $self->{file} ||= App::GroupSecret::File->new($self->filepath);
+}
+
+sub private_key {
+    shift->{private_key} ||= $ENV{GROUPSECRET_PRIVATE_KEY} || "$ENV{HOME}/.ssh/id_rsa";
+}
+
+sub action_print_secret {
+    my $self = shift;
+
+    my $decrypt = 1;
+    GetOptionsFromArray(
+        \@_,
+        'decrypt!' => \$decrypt,
+    ) or pod2usage(2);
+
+    my $file = $self->file;
+    die "No secret in file -- use the \`set-secret' command to set one.\n" if !$file->secret;
+
+    if ($decrypt) {
+        my $private_key = $self->private_key;
+        my $secret      = $file->decrypt_secret(private_key => $private_key) or die "No secret.\n";
+        print $secret;
+    }
+    else {
+        print $file->secret;
+    }
+}
+
+sub action_set_secret {
+    my $self = shift;
+
+    my $keep_passphrase = 0;
+    GetOptionsFromArray(
+        \@_,
+        'keep-passphrase!' => \$keep_passphrase,
+    ) or pod2usage(2);
+
+    my $secret_spec = shift;
+    if (!$secret_spec) {
+        warn "You must specify a secret to set.\n";
+        pod2usage(2);
+    }
+
+    my $passphrase;
+    my $secret;
+
+    if ($secret_spec =~ /^rand:(\d+)$/i) {
+        my $rand = encode_base64(generate_secure_random_bytes($1), '');
+        $secret = \$rand;
+    }
+    elsif ($secret_spec eq '-') {
+        my $in = do { local $/; <STDIN> };
+        $secret = \$in;
+    }
+    elsif ($secret_spec =~ /^file:(.*)$/i) {
+        $secret = $1;
+    }
+    else {
+        $secret = $secret_spec;
+    }
+
+    my $file = $self->file;
+
+    if ($keep_passphrase) {
+        my $private_key = $self->private_key;
+        $passphrase = $file->decrypt_secret_passphrase($private_key);
+        $file->encrypt_secret($secret, $passphrase);
+    }
+    else {
+        $passphrase = generate_secure_random_bytes(32);
+        $file->encrypt_secret($secret, $passphrase);
+        $file->encrypt_secret_passphrase($passphrase);
+    }
+
+    $file->save;
+}
+
+sub action_add_key {
+    my $self = shift;
+
+    my $embed   = 0;
+    my $update  = 0;
+    GetOptionsFromArray(
+        \@_,
+        'embed'     => \$embed,
+        'update|u'  => \$update,
+    ) or pod2usage(2);
+
+    my $file = $self->file;
+    my $keys = $file->keys;
+
+    my $opts = {embed => $embed};
+
+    for my $public_key (@_) {
+        my $info = read_openssh_key_fingerprint($public_key);
+
+        if ($keys->{$info->{fingerprint}} && !$update) {
+            my $formatted_key = $file->format_key($info);
+            print "SKIP\t$formatted_key\n";
+            next;
+        }
+
+        if ($file->secret && !$opts->{passphrase}) {
+            my $private_key = $self->private_key;
+            my $passphrase  = $file->decrypt_secret_passphrase($private_key);
+            $opts->{passphrase} = $passphrase;
+        }
+
+        local $opts->{fingerprint_info} = $info;
+        my ($fingerprint, $key) = $file->add_key($public_key, $opts);
+
+        local $key->{fingerprint} = $fingerprint;
+        my $formatted_key = $file->format_key($key);
+        print "ADD\t$formatted_key\n";
+    }
+
+    $file->save;
+}
+
+sub action_delete_key {
+    my $self = shift;
+
+    my $file = $self->file;
+
+    for my $fingerprint (@_) {
+        if ($fingerprint =~ s/^(?:MD5|SHA1|SHA256)://) {
+            $fingerprint =~ s/://g;
+        }
+        else {
+            my $info = read_openssh_key_fingerprint($fingerprint);
+            $fingerprint = $info->{fingerprint};
+        }
+
+        my $key = $file->keys->{$fingerprint};
+        $file->delete_key($fingerprint) if $key;
+
+        local $key->{fingerprint} = $fingerprint;
+        my $formatted_key = $file->format_key($key);
+        print "DELETE\t$formatted_key\n";
+    }
+
+    $file->save;
+}
+
+sub action_list_keys {
+    my $self = shift;
+
+    my $file = $self->file;
+    my $keys = $file->keys;
+
+    while (my ($fingerprint, $key) = each %$keys) {
+        local $key->{fingerprint} = $fingerprint;
+        my $formatted_key = $file->format_key($key);
+        print "$formatted_key\n";
+    }
+}
+
+1;
diff --git a/lib/App/GroupSecret/Crypt.pm b/lib/App/GroupSecret/Crypt.pm
new file mode 100644 (file)
index 0000000..f5360be
--- /dev/null
@@ -0,0 +1,290 @@
+package App::GroupSecret::Crypt;
+# ABSTRACT: Collection of crypto-related subroutines
+
+use warnings;
+use strict;
+
+our $VERSION = '9999.999'; # VERSION
+
+use Exporter qw(import);
+use File::Temp;
+use IPC::Open2;
+use namespace::clean -except => [qw(import)];
+
+our @EXPORT_OK = qw(
+    generate_secure_random_bytes
+    read_openssh_public_key
+    read_openssh_key_fingerprint
+    decrypt_rsa
+    encrypt_rsa
+    decrypt_aes_256_cbc
+    encrypt_aes_256_cbc
+);
+
+sub _croak { require Carp; Carp::croak(@_) }
+sub _usage { _croak("Usage: @_\n") }
+
+=func generate_secure_random_bytes
+
+    $bytes = generate_secure_random_bytes($num_bytes);
+
+Get a certain number of secure random bytes.
+
+=cut
+
+sub generate_secure_random_bytes {
+    my $size = shift or _usage(q{generate_secure_random_bytes($num_bytes)});
+
+    my @cmd = (qw{openssl rand}, $size);
+
+    my ($in, $out);
+    my $pid = open2($out, $in, @cmd);
+
+    close($in);
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to generate secure random bytes' if $exit_code != 0;
+
+    return do { local $/; <$out> };
+}
+
+=func read_openssh_public_key
+
+    $pem_public_key = read_openssh_public_key($public_key_filepath);
+
+Read a RFC4716 (SSH2) public key from a file, converting it to PKCS8 (PEM).
+
+=cut
+
+sub read_openssh_public_key {
+    my $filepath = shift or _usage(q{read_openssh_public_key($filepath)});
+
+    my @cmd = (qw{ssh-keygen -e -m PKCS8 -f}, $filepath);
+
+    my ($in, $out);
+    my $pid = open2($out, $in, @cmd);
+
+    close($in);
+
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to read OpenSSH public key' if $exit_code != 0;
+
+    return do { local $/; <$out> };
+}
+
+=func read_openssh_key_fingerprint
+
+    $fingerprint = read_openssh_key_fingerprint($filepath);
+
+Get the fingerprint of an OpenSSH private or public key.
+
+=cut
+
+sub read_openssh_key_fingerprint {
+    my $filepath = shift or _usage(q{read_openssh_key_fingerprint($filepath)});
+
+    my @cmd = (qw{ssh-keygen -l -E md5 -f}, $filepath);
+
+    my $out;
+    my $pid = open2($out, undef, @cmd);
+
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to read SSH2 key fingerprint' if $exit_code != 0;
+
+    my $line = do { local $/; <$out> };
+    chomp $line;
+
+    my ($bits, $fingerprint, $comment, $type) = $line =~ m!^(\d+) MD5:([^ ]+) (.*) \(([^\)]+)\)$!;
+
+    $fingerprint =~ s/://g;
+
+    return {
+        bits        => $bits,
+        fingerprint => $fingerprint,
+        comment     => $comment,
+        type        => lc($type),
+    };
+}
+
+=func decrypt_rsa
+
+    $plaintext = decrypt_rsa($ciphertext_filepath, $private_key_filepath);
+    $plaintext = decrypt_rsa(\$ciphertext, $private_key_filepath);
+    decrypt_rsa($ciphertext_filepath, $private_key_filepath, $plaintext_filepath);
+    decrypt_rsa(\$ciphertext, $private_key_filepath, $plaintext_filepath);
+
+Do RSA decryption. Turn ciphertext into plaintext.
+
+=cut
+
+sub decrypt_rsa {
+    my $filepath = shift or _usage(q{decrypt_rsa($filepath, $keypath)});
+    my $privkey  = shift or _usage(q{decrypt_rsa($filepath, $keypath)});
+    my $outfile  = shift;
+
+    my $temp;
+    if (ref $filepath eq 'SCALAR') {
+        $temp = File::Temp->new(UNLINK => 1);
+        print $temp $$filepath;
+        close $temp;
+        $filepath = $temp->filename;
+    }
+
+    my @cmd = (qw{openssl rsautl -decrypt -oaep -in}, $filepath, '-inkey', $privkey);
+    push @cmd, ('-out', $outfile) if $outfile;
+
+    my ($in, $out);
+    my $pid = open2($out, $in, @cmd);
+
+    close($in);
+
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to decrypt ciphertext' if $exit_code != 0;
+
+    return do { local $/; <$out> };
+}
+
+=func encrypt_rsa
+
+    $ciphertext = decrypt_rsa($plaintext_filepath, $public_key_filepath);
+    $ciphertext = decrypt_rsa(\$plaintext, $public_key_filepath);
+    decrypt_rsa($plaintext_filepath, $public_key_filepath, $ciphertext_filepath);
+    decrypt_rsa(\$plaintext, $public_key_filepath, $ciphertext_filepath);
+
+Do RSA encryption. Turn plaintext into ciphertext.
+
+=cut
+
+sub encrypt_rsa {
+    my $filepath = shift or _usage(q{encrypt_rsa($filepath, $keypath)});
+    my $pubkey   = shift or _usage(q{encrypt_rsa($filepath, $keypath)});
+    my $outfile  = shift;
+
+    my $temp1;
+    if (ref $filepath eq 'SCALAR') {
+        $temp1 = File::Temp->new(UNLINK => 1);
+        print $temp1 $$filepath;
+        close $temp1;
+        $filepath = $temp1->filename;
+    }
+
+    my $key = read_openssh_public_key($pubkey);
+
+    my $temp2 = File::Temp->new(UNLINK => 1);
+    print $temp2 $key;
+    close $temp2;
+    my $keypath = $temp2->filename;
+
+    my @cmd = (qw{openssl rsautl -encrypt -oaep -pubin -inkey}, $keypath, '-in', $filepath);
+    push @cmd, ('-out', $outfile) if $outfile;
+
+    my ($in, $out);
+    my $pid = open2($out, $in, @cmd);
+
+    close($in);
+
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to encrypt plaintext' if $exit_code != 0;
+
+    return do { local $/; <$out> };
+}
+
+=func decrypt_aes_256_cbc
+
+    $plaintext = decrypt_aes_256_cbc($ciphertext_filepath, $secret);
+    $plaintext = decrypt_aes_256_cbc(\$ciphertext, $secret);
+    decrypt_aes_256_cbc($ciphertext_filepath, $secret, $plaintext_filepath);
+    decrypt_aes_256_cbc(\$ciphertext, $secret, $plaintext_filepath);
+
+Do symmetric decryption. Turn ciphertext into plaintext.
+
+=cut
+
+sub decrypt_aes_256_cbc {
+    my $filepath = shift or _usage(q{decrypt_aes_256_cbc($ciphertext, $secret)});
+    my $secret   = shift or _usage(q{decrypt_aes_256_cbc($ciphertext, $secret)});
+    my $outfile  = shift;
+
+    my $temp;
+    if (ref $filepath eq 'SCALAR') {
+        $temp = File::Temp->new(UNLINK => 1);
+        print $temp $$filepath;
+        close $temp;
+        $filepath = $temp->filename;
+    }
+
+    my @cmd = (qw{openssl aes-256-cbc -d -pass stdin -in}, $filepath);
+    push @cmd, ('-out', $outfile) if $outfile;
+
+    my ($in, $out);
+    my $pid = open2($out, $in, @cmd);
+
+    print $in $secret;
+    close($in);
+
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to decrypt ciphertext' if $exit_code != 0;
+
+    return do { local $/; <$out> };
+}
+
+=func encrypt_aes_256_cbc
+
+    $ciphertext = encrypt_aes_256_cbc($plaintext_filepath, $secret);
+    $ciphertext = encrypt_aes_256_cbc(\$plaintext, $secret);
+    encrypt_aes_256_cbc($plaintext_filepath, $secret, $ciphertext_filepath);
+    encrypt_aes_256_cbc(\$plaintext, $secret, $ciphertext_filepath);
+
+Do symmetric encryption. Turn plaintext into ciphertext.
+
+=cut
+
+sub encrypt_aes_256_cbc {
+    my $filepath = shift or _usage(q{encrypt_aes_256_cbc($plaintext, $secret)});
+    my $secret   = shift or _usage(q{encrypt_aes_256_cbc($plaintext, $secret)});
+    my $outfile  = shift;
+
+    my $temp;
+    if (ref $filepath eq 'SCALAR') {
+        $temp = File::Temp->new(UNLINK => 1);
+        print $temp $$filepath;
+        close $temp;
+        $filepath = $temp->filename;
+    }
+
+    my @cmd = (qw{openssl aes-256-cbc -pass stdin -in}, $filepath);
+    push @cmd, ('-out', $outfile) if $outfile;
+
+    my ($in, $out);
+    my $pid = open2($out, $in, @cmd);
+
+    print $in $secret;
+    close($in);
+
+    waitpid($pid, 0);
+    my $status = $?;
+
+    my $exit_code = $status >> 8;
+    _croak 'Failed to encrypt plaintext' if $exit_code != 0;
+
+    return do { local $/; <$out> };
+}
+
+1;
diff --git a/lib/App/GroupSecret/File.pm b/lib/App/GroupSecret/File.pm
new file mode 100644 (file)
index 0000000..956b6f9
--- /dev/null
@@ -0,0 +1,399 @@
+package App::GroupSecret::File;
+# ABSTRACT: Reading and writing groupsecret keyfiles
+
+=head1 SYNOPSIS
+
+    use App::GroupSecret::File;
+
+    my $file = App::GroupSecret::File->new('path/to/keyfile.yml');
+    print "File version: " . $file->version, "\n";
+
+    $file->add_key('path/to/key_rsa.pub');
+    $file->save;
+
+=head1 DESCRIPTION
+
+This module provides a programmatic way to manage keyfiles.
+
+See L<groupsecret> for the command-line interface.
+
+=head1 FILE FORMAT
+
+Keyfiles are YAML documents that contains this structure:
+
+    ---
+    keys:
+      FINGERPRINT:
+        comment: COMMENT
+        content: ssh-rsa ...
+        filename: FILENAME
+        secret_passphrase: PASSPHRASE...
+        type: rsa
+    secret: SECRET...
+    version: 1
+
+=cut
+
+use warnings;
+use strict;
+
+our $VERSION = '9999.999'; # VERSION
+
+use App::GroupSecret::Crypt qw(
+    generate_secure_random_bytes
+    read_openssh_public_key
+    read_openssh_key_fingerprint
+    decrypt_rsa
+    encrypt_rsa
+    decrypt_aes_256_cbc
+    encrypt_aes_256_cbc
+);
+use File::Basename;
+use File::Spec;
+use YAML::Tiny qw(LoadFile DumpFile);
+use namespace::clean;
+
+our $FILE_VERSION = 1;
+
+sub _croak { require Carp; Carp::croak(@_) }
+sub _usage { _croak("Usage: @_\n") }
+
+=method new
+
+    $file = App::GroupSecret::File->new($filepath);
+
+Construct a new keyfile object.
+
+=cut
+
+sub new {
+    my $class = shift;
+    my $filepath = shift or _croak(q{App::GroupSecret::File->new($filepath)});
+    return bless {filepath => $filepath}, $class;
+}
+
+=attr filepath
+
+Get the filepath of the keyfile.
+
+=cut
+
+sub filepath { shift->{filepath} }
+
+=method info
+
+    $info = $file->info;
+
+Get a raw hashref with the contents of the keyfile.
+
+=cut
+
+sub info {
+    my $self = shift;
+    return $self->{info} ||= do {
+        if (-e $self->filepath) {
+            $self->load;
+        }
+        else {
+            $self->init;
+        }
+    };
+}
+
+=method init
+
+    $info = $file->init;
+
+Get a hashref representing an empty keyfile, used for initializing a new keyfile.
+
+=cut
+
+sub init {
+    return {
+        keys    => {},
+        secret  => undef,
+        version => $FILE_VERSION,
+    };
+}
+
+=method load
+
+    $info = $file->load;
+    $info = $file->load($filepath);
+
+Load (or reload) the contents of a keyfile.
+
+=cut
+
+sub load {
+    my $self     = shift;
+    my $filepath = shift || $self->filepath;
+    my $info = LoadFile($filepath) || {};
+    $self->check($info);
+    $self->{info} = $info if !$filepath;
+    return $info;
+}
+
+=method save
+
+    $file->save;
+    $file->save($filepath);
+
+Save the keyfile to disk.
+
+=cut
+
+sub save {
+    my $self     = shift;
+    my $filepath = shift || $self->filepath;
+    DumpFile($filepath, $self->info);
+    return $self;
+}
+
+=method check
+
+    $file->check;
+    $file->check($info);
+
+Check the file format of a keyfile to make sure this module can understand it.
+
+=cut
+
+sub check {
+    my $self = shift;
+    my $info = shift || $self->info;
+
+    _croak 'Corrupt file: Bad type for root' if !$info || ref $info ne 'HASH';
+
+    my $version = $info->{version};
+    _croak 'Unknown file version' if !$version || $version !~ /^\d+$/;
+    _croak 'Unsupported file version' if $FILE_VERSION < $version;
+
+    _croak 'Corrupt file: Bad type for keys' if ref $info->{keys} ne 'HASH';
+
+    warn "The file has a secret but no keys to access it!\n" if $info->{secret} && !%{$info->{keys}};
+
+    return 1;
+}
+
+=method keys
+
+    $keys = $file->keys;
+
+Get a hashref of the keys from a keyfile.
+
+=method secret
+
+    $secret = $file->secret;
+
+Get the secret from a keyfile as an encrypted string.
+
+=method version
+
+    $version = $file->version
+
+Get the file format version.
+
+=cut
+
+sub keys    { shift->info->{keys} }
+sub secret  { shift->info->{secret} }
+sub version { shift->info->{version} }
+
+=method add_key
+
+    $file->add_key($filepath);
+
+Add a key to the keyfile.
+
+=cut
+
+sub add_key {
+    my $self        = shift;
+    my $public_key  = shift or _usage(q{$file->add_key($public_key)});
+    my $args        = @_ == 1 ? shift : {@_};
+
+    my $keys = $self->keys;
+
+    my $info = $args->{fingerprint_info} || read_openssh_key_fingerprint($public_key);
+    my $fingerprint = $info->{fingerprint};
+
+    my $key = {
+        comment             => $info->{comment},
+        filename            => basename($public_key),
+        secret_passphrase   => undef,
+        type                => $info->{type},
+    };
+
+    if ($args->{embed}) {
+        open(my $fh, '<', $public_key) or die "open failed: $!";
+        $key->{content} = do { local $/; <$fh> };
+        chomp $key->{content};
+    }
+
+    $keys->{$fingerprint} = $key;
+
+    if ($self->secret) {
+        my $passphrase = $args->{passphrase} || $self->decrypt_secret_passphrase($args->{private_key});
+        my $ciphertext = encrypt_rsa(\$passphrase, $public_key);
+        $key->{secret_passphrase} = $ciphertext;
+    }
+
+    return wantarray ? ($fingerprint => $key) : $key;
+}
+
+=method delete_key
+
+    $file->delete_key($fingerprint);
+
+Delete a key from the keyfile.
+
+=cut
+
+sub delete_key {
+    my $self        = shift;
+    my $fingerprint = shift;
+    delete $self->keys->{$fingerprint};
+}
+
+=method decrypt_secret
+
+    $secret = $file->decrypt_secret(passphrase => $passphrase);
+    $secret = $file->decrypt_secret(private_key => $private_key);
+
+Get the decrypted secret.
+
+=cut
+
+sub decrypt_secret {
+    my $self = shift;
+    my $args = @_ == 1 ? shift : {@_};
+
+    $args->{passphrase} || $args->{private_key} or _usage(q{$file->decrypt_secret($private_key)});
+
+    my $passphrase = $args->{passphrase};
+    $passphrase = $self->decrypt_secret_passphrase($args->{private_key}) if !$passphrase;
+
+    my $ciphertext = $self->secret;
+    return decrypt_aes_256_cbc(\$ciphertext, $passphrase);
+}
+
+=method decrypt_secret_passphrase
+
+    $passphrase = $file->decrypt_secret_passphrase($private_key);
+
+Get the decrypted secret passphrase.
+
+=cut
+
+sub decrypt_secret_passphrase {
+    my $self        = shift;
+    my $private_key = shift or _usage(q{$file->decrypt_secret_passphrase($private_key)});
+
+    my $info = read_openssh_key_fingerprint($private_key);
+    my $fingerprint = $info->{fingerprint};
+
+    my $keys = $self->keys;
+    if (my $key = $keys->{$fingerprint}) {
+        return decrypt_rsa(\$key->{secret_passphrase}, $private_key);
+    }
+
+    die "The private key ($private_key) is not able to decrypt the keyfile.\n";
+}
+
+=method encrypt_secret
+
+    $file->encrypt_secret($secret, $passphrase);
+
+Set the secret by encrypting it with a 256-bit passphrase.
+
+Passphrase must be 32 bytes.
+
+=cut
+
+sub encrypt_secret {
+    my $self        = shift;
+    my $secret      = shift or _usage(q{$file->encrypt_secret($secret)});
+    my $passphrase  = shift or _usage(q{$file->encrypt_secret($secret)});
+
+    my $ciphertext = encrypt_aes_256_cbc($secret, $passphrase);
+    $self->info->{secret} = $ciphertext;
+}
+
+=method encrypt_secret_passphrase
+
+    $file->encrypt_secret_passphrase($passphrase);
+
+Set the passphrase by encrypting it with each key in the keyfile.
+
+=cut
+
+sub encrypt_secret_passphrase {
+    my $self        = shift;
+    my $passphrase  = shift or _usage(q{$file->encrypt_secret_passphrase($passphrase)});
+
+    while (my ($fingerprint, $key) = each %{$self->keys}) {
+        local $key->{fingerprint} = $fingerprint;
+        my $pubkey = $self->find_public_key($key) or die 'Cannot find public key: ' . $self->format_key($key) . "\n";
+        my $ciphertext = encrypt_rsa(\$passphrase, $pubkey);
+        $key->{secret_passphrase} = $ciphertext;
+    }
+}
+
+=method find_public_key
+
+    $filepath = $file->find_public_key($key);
+
+Get a path to the public key file for a key.
+
+=cut
+
+sub find_public_key {
+    my $self = shift;
+    my $key  = shift or _usage(q{$file->find_public_key($key)});
+
+    if ($key->{content}) {
+        my $temp = File::Temp->new(UNLINK => 1);
+        print $temp $key->{content};
+        close $temp;
+        $self->{"temp:$key->{fingerprint}"} = $temp;
+        return $temp->filename;
+    }
+    else {
+        my @dirs = split(/:/, $ENV{GROUPSECRET_PATH} || ".:keys:$ENV{HOME}/.ssh");
+        for my $dir (@dirs) {
+            my $filepath = File::Spec->catfile($dir, $key->{filename});
+            return $filepath if -f $filepath;
+        }
+    }
+}
+
+=method format_key
+
+    $str = $file->format_key($key);
+
+Get a one-line summary of a key. Format is "<fingerprint> <comment>".
+
+=cut
+
+sub format_key {
+    my $self = shift;
+    my $key  = shift or _usage(q{$file->format_key($key)});
+
+    my $fingerprint = $key->{fingerprint} or _croak(q{Missing required field in key: fingerprint});
+    my $comment     = $key->{comment} || 'uncommented';
+
+    if ($fingerprint =~ /^[A-Fa-f0-9]{32}$/) {
+        $fingerprint = 'MD5:' . join(':', ($fingerprint =~ /../g ));
+    }
+    elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{27}$/) {
+        $fingerprint = "SHA1:$fingerprint";
+    }
+    elsif ($fingerprint =~ /^[A-Za-z0-9\/\+]{43}$/) {
+        $fingerprint = "SHA256:$fingerprint";
+    }
+
+    return "$fingerprint $comment";
+}
+
+1;
diff --git a/t/02-file.t b/t/02-file.t
new file mode 100644 (file)
index 0000000..2976d92
--- /dev/null
@@ -0,0 +1,52 @@
+#!/usr/bin/env perl
+
+use warnings;
+use strict;
+
+use FindBin qw($Bin);
+
+use Test::More tests => 7;
+
+use App::GroupSecret::File;
+
+my $nonexistent = App::GroupSecret::File->new("$Bin/keyfiles/nonexistent.yml");
+
+is_deeply $nonexistent->info, {
+    version => 1,
+    keys    => {},
+    secret  => undef,
+}, 'newly initialized file is empty';
+
+my $empty = App::GroupSecret::File->new("$Bin/keyfiles/empty.yml");
+
+is_deeply $empty->info, {
+    version => 1,
+    keys    => {},
+    secret  => undef,
+}, 'empty file info matches';
+
+is $empty->secret, undef, 'empty secret is undef';
+is $empty->version, 1, 'empty version is one';
+
+my $key1 = $empty->add_key("$Bin/keys/foo_rsa.pub");
+is_deeply $key1, {
+    comment             => 'foo',
+    filename            => 'foo_rsa.pub',
+    secret_passphrase   => undef,
+    type                => 'rsa',
+}, 'add_key in scalar context works';
+
+$empty->delete_key('89b3fb766cf9568ea81adfba1cba7d05');
+is_deeply $empty->keys, {}, 'file is empty again after delete_key';
+
+my $basic = App::GroupSecret::File->new("$Bin/keyfiles/basic.yml");
+
+is_deeply $basic->keys, {
+    '89b3fb766cf9568ea81adfba1cba7d05' => {
+        comment             => 'foo',
+        filename            => 'foo_rsa.pub',
+        secret_passphrase   => undef,
+        type                => 'rsa',
+    },
+}, 'keys accessor works';
+
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
This page took 0.049948 seconds and 4 git commands to generate.