aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2016-03-02 07:28:01 +0100
committerGuilhem Moulin <guilhem@fripost.org>2016-03-02 18:17:56 +0100
commitbf1424f6ccf76eeb011428918c634951fe4995cf (patch)
tree6594ab71ab99f1f21058078ba51a9e63e76dd656
parentee5bedd1995fc95b6fce24ac5b35cd02bdb78bd6 (diff)
letsencrypt-accountd
-rw-r--r--config/letsencrypt-accountd.conf29
-rwxr-xr-xletsencrypt-accountd202
-rw-r--r--letsencrypt-accountd.1147
3 files changed, 378 insertions, 0 deletions
diff --git a/config/letsencrypt-accountd.conf b/config/letsencrypt-accountd.conf
new file mode 100644
index 0000000..c372190
--- /dev/null
+++ b/config/letsencrypt-accountd.conf
@@ -0,0 +1,29 @@
+# The value of "privkey" specifies the (private) account key to use
+# for signing requests. Currently supported values are:
+#
+# - file:FILE, to specify an encrypted private key (in PEM format)
+# - gpg:FILE, to specify a gpg-encrypted private key (in PEM format)
+#
+#privkey = gpg:/path/to/encrypted/priv.key.gpg
+#privkey = file:/path/to/priv.key
+
+# For a gpg-encrypted private account key, "gpg" specifies the binary
+# gpg(1) to use, as well as some default options. Default: "gpg
+# --quiet".
+#
+#gpg = gpg2 --quiet --no-auto-check-trustdb
+
+# The value of "socket" specifies the UNIX-domain socket to bind against
+# for signature requests from the ACME client. An error is raised if
+# the path exists exists or if its parent directory is writable by other
+# users.
+# Default: "$XDG_RUNTIME_DIR/S.letsencrypt" if the XDG_RUNTIME_DIR
+# environment variable is set.
+#
+#socket = /run/user/1000/S.letsencrypt
+
+# Be quiet. Possible values: "Yes"/"No".
+#
+#quiet = Yes
+
+; vim:ft=dosini
diff --git a/letsencrypt-accountd b/letsencrypt-accountd
new file mode 100755
index 0000000..ffc5619
--- /dev/null
+++ b/letsencrypt-accountd
@@ -0,0 +1,202 @@
+#!/usr/bin/perl -T
+
+#----------------------------------------------------------------------
+# Let's Encrypt ACME client (account key manager)
+# Copyright © 2016 Guilhem Moulin <guilhem@fripost.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+use strict;
+use warnings;
+
+our $VERSION = '0.0.1';
+my $PROTOCOL_VERSION = 1;
+my $NAME = 'letsencrypt-accountd';
+
+use Errno 'EINTR';
+use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/;
+use List::Util 'first';
+use MIME::Base64 'encode_base64url';
+use Socket qw/PF_UNIX SOCK_STREAM SHUT_RDWR/;
+
+use Config::Tiny ();
+use JSON ();
+
+# Clean up PATH
+$ENV{PATH} = join ':', qw{/usr/bin /bin};
+delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};
+
+my ($SOCKNAME, $S, %OPTS);
+$SIG{$_} = sub() { exit } foreach qw/INT TERM/; # run the END block upon SIGINT/SIGTERM
+
+
+#############################################################################
+# Parse and validate configuration
+#
+sub usage(;$$) {
+ my $rv = shift // 0;
+ if ($rv) {
+ my $msg = shift;
+ print STDERR $msg."\n" if defined $msg;
+ print STDERR "Try '$NAME --help' or consult the manpage for more information.\n";
+ }
+ else {
+ print STDERR "Usage: $NAME [--config=FILE] [--privkey=ARG] [--socket=PATH] [--quiet]\n"
+ ."Consult the manpage for more information.\n";
+ }
+ exit $rv;
+}
+usage(1) unless GetOptions(\%OPTS, qw/config=s privkey=s socket=s quiet|q debug help|h/);
+usage(0) if $OPTS{help};
+
+do {
+ my $conffile = $OPTS{config} // first { -f $_ }
+ ( "./$NAME.conf"
+ , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/letsencrypt-tiny/$NAME.conf"
+ , "/etc/letsencrypt-tiny/$NAME.conf"
+ );
+ die "Error: Can't find configuration file\n" unless defined $conffile;
+ print STDERR "Using configuration file: $conffile\n" if $OPTS{debug};
+
+ my $h = Config::Tiny::->read($conffile) or die Config::Tiny::->errstr()."\n";
+ my $h2 = delete $h->{_} // {};
+ die "Invalid section(s): ".join(', ', keys %$h)."\n" if %$h;
+ my %h = map { $_ => delete $h2->{$_} } qw/privkey gpg socket quiet/;
+ die "Unknown option(s): ".join(', ', keys %$h2)."\n" if %$h2;
+ $h{quiet} = lc $h{quiet} eq 'yes' ? 1 : 0 if defined $h{quiet};
+ $OPTS{$_} //= $h{$_} foreach grep {defined $h{$_}} keys %h;
+
+ $OPTS{quiet} = 0 if $OPTS{debug};
+ die "Error: 'privkey' is not specified\n" unless defined $OPTS{privkey};
+};
+
+
+#############################################################################
+# Build the JSON Web Key (RFC 7517) from the account key's public parameters,
+# and determine the signing method $SIGN.
+#
+my ($JWK, $SIGN);
+if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) {
+ my ($method, $filename) = ($1,$2);
+ my ($fh, @command);
+ if ($method eq 'file') {
+ # generate with `openssl genrsa 4096 | install --mode=0600 /dev/stdin /tmp/privkey`
+ open $fh, '<', $filename or die "Error: Can't open $filename: $!\n";
+ }
+ elsif ($method eq 'gpg') {
+ @command = split /\s+/, ($OPTS{gpg} // 'gpg --quiet');
+ open $fh, '-|', @command, qw/-o - --decrypt --/, $filename or die "fork: $!";
+ }
+ else {
+ die; # impossible
+ }
+
+ my $str = do {local $/ = undef; <$fh>};
+ close $fh or die $! ?
+ "Can't close: $!" :
+ "Error: $command[0] exited with value ".($? >> 8)."\n";
+
+ require 'Crypt/OpenSSL/RSA.pm';
+ my $rsa = Crypt::OpenSSL::RSA->new_private_key($str);
+ undef $str;
+
+ die "Error: $filename: Not a private key\n" unless $rsa->is_private();
+ die "Error: $filename: Invalid key\n" unless $rsa->check_key();
+ $rsa->use_sha256_hash();
+
+ require 'Crypt/OpenSSL/Bignum.pm';
+ my ($n, $e) = $rsa->get_key_parameters(); # don't include private params!
+ $_ = encode_base64url($_->to_bin()) foreach ($n, $e);
+
+ $JWK = { kty => 'RSA', n => $n, e => $e };
+ $SIGN = sub($) { $rsa->sign($_[0]) };
+}
+else {
+ die "Error: unsupported method: $OPTS{privkey}\n";
+}
+$JWK = JSON::->new->encode($JWK);
+
+
+#############################################################################
+# Create the server UNIX socket and bind(2) against it.
+# NOTE: We don't use the abstract namespace so we can rely on the file
+# permissions to keep other users out. (Also, OpenSSH 7.1 doesn't seem
+# to support the abstract namespace.) The downside is that we have to
+# delete the file manually.
+#
+do {
+ my $sockname = $OPTS{socket} // (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.letsencrypt" : undef);
+ die "Missing socket option\n" unless defined $sockname;
+ $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : die "Invalid socket name\n"; # untaint $sockname
+
+ # ensure we're the only user with write access to the parent dir
+ my $dirname = $sockname =~ s/[^\/]+$//r;
+ my @stat = stat($dirname) or die "Can't stat $dirname: $!";
+ die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0;
+
+ my $umask = umask(0177) // die "umask: $!";
+
+ print STDERR "Starting Let's Encrypt Account Key Manager at $sockname\n" unless $OPTS{quiet};
+ socket(my $sock, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!";
+ my $sockaddr = Socket::sockaddr_un($sockname) // die;
+ bind($sock, $sockaddr) or die "bind: $!";
+
+ ($SOCKNAME, $S) = ($sockname, $sock);
+ listen($S, 1) or die "listen: $!";
+
+ umask($umask) // die "umask: $!";
+};
+
+
+#############################################################################
+# For each new connection, send the protocol version and the account key's
+# public parameters, then sign whatever comes in
+#
+$SIG{PIPE} = 'IGNORE'; # ignore broken pipes
+for (my $count = 0;; $count++) {
+ accept(my $conn, $S) or do {
+ next if $! == EINTR; # try again if accept(2) was interrupted by a signal
+ die "accept: $!";
+ };
+ print STDERR "[$count]>> Accepted new connection\n" unless $OPTS{quiet};
+
+ $conn->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK, "\r\n" );
+
+ # sign whatever comes in
+ while (defined (my $data = $conn->getline())) {
+ $data =~ s/\r\n\z// or die;
+ print STDERR "[$count]>> Issuing SHA-256 signature for: $data\n" unless $OPTS{quiet};
+ my $sig = $SIGN->($data);
+ $conn->printflush( encode_base64url($sig), "\r\n" );
+ }
+
+ print STDERR "[$count]>> Connection terminated\n" unless $OPTS{quiet};
+ close $conn or warn "Can't close: $!";
+}
+
+
+#############################################################################
+#
+END {
+ if (defined $SOCKNAME and -S $SOCKNAME) {
+ print STDERR "Unlinking $SOCKNAME\n" if $OPTS{debug};
+ unlink $SOCKNAME or print STDERR "Can't unlink $SOCKNAME: $!\n";
+ }
+ if (defined $S) {
+ print STDERR "Shutting down and closing Let's Encrypt Account Key Manager\n" unless $OPTS{quiet};
+ shutdown($S, SHUT_RDWR) or warn "shutdown: $!";
+ close $S or print STDERR "Can't close: $!\n";
+ }
+}
diff --git a/letsencrypt-accountd.1 b/letsencrypt-accountd.1
new file mode 100644
index 0000000..41d7630
--- /dev/null
+++ b/letsencrypt-accountd.1
@@ -0,0 +1,147 @@
+.TH LETSENCRYPT\-ACCOUNTD "1" "MARCH 2016" "Tiny Let's Encrypt ACME client (account key manager)" "User Commands"
+
+.SH NAME
+letsencrypt\-accountd \- Tiny Let's Encrypt ACME client (account key manager)
+
+.SH SYNOPSIS
+.B letsencrypt\-accountd\fR [\fB\-\-config=\fIFILENAME\fR]
+[\fB\-\-privkey=\fIARG\fR] [\fB\-\-socket=\fIPATH\fR] [\fB\-\-quiet\fR]
+
+
+.SH DESCRIPTION
+.PP
+.B letsencrypt\-accountd\fR is the account key manager component of
+\fIletsencrypt\fR(1), a tiny ACME client written with process isolation
+and minimal privileges in mind. No other \fIletsencrypt\fR(1) component
+need access to the account key; in fact the account key could also be
+stored on a smartcard.
+
+.B letsencrypt\-accountd\fR binds to a UNIX\-domain socket (specified
+with \fB\-\-socket=\fR), which ACME clients can connect to in order to
+request data signatures.
+As a consequence, \fBletsencrypt\-accountd\fR needs to be up and running
+before using \fIletsencrypt\fR(1) to issue ACME commands.
+Also, the process does not automatically terminate after the last
+signature request: instead, one sends an \fIINT\fR or \fITERM\fR signal
+to bring the server down.
+
+Furthermore, one can use the UNIX\-domain socket forwarding facility of
+OpenSSH 6.7 and later to run \fBletsencrypt\-accountd\fR and
+\fIletsencrypt\fR(1) on different hosts. For instance one could store
+the account key on a machine that is not exposed to the internet. See
+the \fBEXAMPLES\fR section below.
+
+
+.SH OPTIONS
+.TP
+.B \-\-config=\fIfilename\fR
+Use \fIfilename\fR as configuration file. See the \fBCONFIGURATION
+FILE\fR section below for the configuration options.
+
+.TP
+.B \-\-privkey=\fIarg\fR
+Specify the (private) account key to use for signing requests.
+Currently supported \fIarg\fRuments are:
+
+.RS
+.IP \[bu] 2
+file:\fIFILE\fR, to specify an encrypted private key (in PEM format); and
+.IP \[bu]
+gpg:\fIFILE\fR, to specify a \fIgpg\fR(1)\-encrypted private key (in PEM format).
+
+.PP
+The following command can be used to generate a new 4096\-bits RSA key in
+PEM format with mode 0600:
+
+.nf
+ openssl genrsa 4096 | install -m0600 /dev/stdin /path/to/priv.key
+.fi
+.RE
+
+.TP
+.B \-\-socket=\fIpath\fR
+Use \fIpath\fR as the UNIX\-domain socket to bind against for signature
+requests from the ACME client. \fBletsencrypt\-accountd\fR aborts if
+\fIpath\fR exists or if its parent directory is writable by other users.
+
+.TP
+.B \-?\fR, \fB\-\-help\fR
+Display a brief help and exit.
+
+.TP
+.B \-q\fR, \fB\-\-quiet\fR
+Be quiet.
+
+.TP
+.B \-\-debug
+Turn on debug mode.
+
+
+.SH CONFIGURATION FILE
+If \fB\-\-config=\fR is not given, \fBletsencrypt\-accountd\fR uses the
+first existing configuration file among
+\fI./letsencrypt\-accountd.conf\fR,
+\fI$XDG_CONFIG_HOME/letsencrypt\-tiny/letsencrypt\-accountd.conf\fR (or
+\fI~/.config/letsencrypt\-tiny/letsencrypt\-accountd.conf\fR if the
+XDG_CONFIG_HOME environment variable is not set), and
+\fI/etc/letsencrypt\-tiny/letsencrypt\-accountd.conf\fR.
+
+When given on the command line, the \fB\-\-privkey=\fR,
+\fB\-\-socket=\fR and \fB\-\-quiet\fR options take precedence over their
+counterpart (without leading \(lq\-\-\(rq) in the configuration file.
+Valid options are:
+
+.TP
+.I privkey
+See \fB\-\-privkey=\fR.
+This option is required when \fB\-\-privkey=\fR is not specified on the
+command line.
+
+.TP
+.I gpg
+For a \fIgpg\fR(1)\-encrypted private account key, specify the binary
+\fIgpg\fR(1) to use, as well as some default options.
+Default: \(lqgpg \-\-quiet\(rq.
+
+.TP
+.I socket
+See \fB\-\-socket=\fR.
+Default: \(lq$XDG_RUNTIME_DIR/S.letsencrypt\(rq if the XDG_RUNTIME_DIR
+environment variable is set.
+
+.TP
+.I quiet
+Be quiet. Possible values: \(lqYes\(rq/\(lqNo\(rq.
+
+
+.SH EXAMPLES
+
+Run \fBletsencrypt\-accountd\fR in a first terminal:
+
+.nf
+ ~$ letsencrypt\-accountd \-\-privkey=file:/path/to/priv.key \-\-socket=/run/user/1000/S.letsencrypt
+.fi
+
+Then, while \fBletsencrypt\-accountd\fR is running, execute locally
+\fIletsencrypt\fR(1) in another terminal:
+
+.nf
+ ~$ sudo letsencrypt \-\-socket=/run/user/1000/S.letsencrypt new\-cert
+.fi
+
+Alternatively, use \fIssh\fR(1) to forward the socket and execute
+\fIletsencrypt\fR(1) remotely:
+
+.nf
+ ~$ ssh -oExitOnForwardFailure=yes -tt -R /path/to/remote.sock:/run/user/1000/S.letsencrypt user@example.org \\
+ sudo letsencrypt --socket=/path/to/remote.sock new-cert
+.fi
+
+
+.SH SEE ALSO
+\fBletsencrypt\fR(1), \fBssh\fR(1)
+
+.SH AUTHOR
+Written by Guilhem Moulin
+.MT guilhem@fripost.org
+.ME .