From bf1424f6ccf76eeb011428918c634951fe4995cf Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 2 Mar 2016 07:28:01 +0100 Subject: letsencrypt-accountd --- config/letsencrypt-accountd.conf | 29 ++++++ letsencrypt-accountd | 202 +++++++++++++++++++++++++++++++++++++++ letsencrypt-accountd.1 | 147 ++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 config/letsencrypt-accountd.conf create mode 100755 letsencrypt-accountd create mode 100644 letsencrypt-accountd.1 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 +# +# 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 . +#---------------------------------------------------------------------- + +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 . -- cgit v1.2.3