From c93443364ce23ced97a80bfda8f8bb35ec19fcdb Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 9 Dec 2020 19:02:44 +0100 Subject: documentation: suggest to generate private key material with genpkey(1ssl). * Also suggest a command to generate an ECDSA key not just RSA. * Hint at which key algorithms are supported. --- lacme-accountd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index af64168..deccfa2 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -94,7 +94,7 @@ 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` + # generate with `openssl genpkey -algorithm RSA` open $fh, '<', $filename or die "Error: Can't open $filename: $!\n"; } elsif ($method eq 'gpg') { -- cgit v1.2.3 From abd23923fa4d9ea609ce4178695fb73b34b7a5ea Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 12 Feb 2021 22:26:55 +0100 Subject: wibble --- lacme-accountd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index deccfa2..e946135 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -128,7 +128,7 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { else { die "Error: unsupported method: $OPTS{privkey}\n"; } -$JWK = JSON::->new->encode($JWK); +my $JWK_STR = JSON::->new->encode($JWK); ############################################################################# @@ -173,7 +173,7 @@ if (defined $OPTS{'conn-fd'}) { sub conn($;$) { my $conn = shift; my $count = shift; - $conn->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK, "\r\n" ); + $conn->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ); # sign whatever comes in while (defined (my $data = $conn->getline())) { -- cgit v1.2.3 From f3e28985165e9ff30907d5da45a4a0bc8c0ccf31 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 14 Feb 2021 17:02:31 +0100 Subject: Bump copyright years. --- lacme-accountd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index e946135..d05fb9c 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -3,7 +3,7 @@ #---------------------------------------------------------------------- # ACME client written with process isolation and minimal privileges in mind # (account key manager) -# Copyright © 2016-2017 Guilhem Moulin +# Copyright © 2015-2021 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 -- cgit v1.2.3 From 2efd4458f4db7f489ecc81f4039b8e8103edf9d9 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Tue, 16 Feb 2021 17:24:31 +0100 Subject: Don't load configuration files from ./ by default. This is a breaking change: lacme(8) resp. lacme-accountd(1) no longer consider ./lacme.conf resp. ./lacme-accountd.conf as default location for the configuration file. Doing so has security implications when running these program from insecure directories. --- lacme-accountd | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index d05fb9c..36e9d9f 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -65,8 +65,7 @@ usage(0) if $OPTS{help}; do { my $conffile = $OPTS{config} // first { -f $_ } - ( "./$NAME.conf" - , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/lacme/$NAME.conf" + ( ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config") . "/lacme/$NAME.conf" , "@@sysconfdir@@/lacme/$NAME.conf" ); die "Error: Can't find configuration file\n" unless defined $conffile; -- cgit v1.2.3 From 3a5c3f0596398d64bb34498f40becbcd32ffa5de Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 17 Feb 2021 11:42:18 +0100 Subject: Consolidate error messages for consistency. --- lacme-accountd | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 36e9d9f..c00530f 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -106,7 +106,7 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { my $str = do {local $/ = undef; <$fh>}; close $fh or die $! ? - "Can't close: $!" : + "close: $!" : "Error: $command[0] exited with value ".($? >> 8)."\n"; require 'Crypt/OpenSSL/RSA.pm'; @@ -140,7 +140,7 @@ my $JWK_STR = JSON::->new->encode($JWK); if (defined $OPTS{'conn-fd'}) { die "Invalid file descriptor" unless $OPTS{'conn-fd'} =~ /\A(\d+)\z/; # untaint and fdopen(3) our end of the socket pair - open ($S, '+<&=', $1+0) or die "fdopen $1: $!"; + open ($S, '+<&=', $1+0) or die "fdopen($1): $!"; } else { my $sockname = $OPTS{socket} // (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.lacme" : undef); die "Missing socket option\n" unless defined $sockname; @@ -148,7 +148,7 @@ if (defined $OPTS{'conn-fd'}) { # 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: $!"; + my @stat = stat($dirname) or die "stat($dirname): $!"; die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0; my $umask = umask(0177) // die "umask: $!"; @@ -172,14 +172,14 @@ if (defined $OPTS{'conn-fd'}) { sub conn($;$) { my $conn = shift; my $count = shift; - $conn->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ); + $conn->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ) or warn "print: $!"; # 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" ); + $conn->printflush( encode_base64url($sig), "\r\n" ) or warn "print: $!"; } } @@ -195,7 +195,7 @@ if (defined $OPTS{'conn-fd'}) { print STDERR "[$count] >>> Accepted new connection\n" unless $OPTS{quiet}; conn($conn, $count); print STDERR "[$count] >>> Connection terminated\n" unless $OPTS{quiet}; - close $conn or warn "Can't close: $!"; + $conn->close() or warn "close: $!"; } } @@ -205,11 +205,11 @@ if (defined $OPTS{'conn-fd'}) { 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"; + unlink $SOCKNAME or print STDERR "Couldn't unlink $SOCKNAME: $!\n"; } if (defined $S) { print STDERR "Shutting down and closing lacme Account Key Manager\n" unless $OPTS{quiet}; shutdown($S, SHUT_RDWR) or warn "shutdown: $!"; - close $S or print STDERR "Can't close: $!\n"; + close $S or print STDERR "close: $!\n"; } } -- cgit v1.2.3 From 42a8f9813716ed3495b6f49edea429b127eef0f0 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Thu, 18 Feb 2021 00:49:46 +0100 Subject: accountd: replace internal option --conn-fd=FD with flag --stdio. Using stdin/stdout makes it possible to tunnel the accountd connection through ssh. --- lacme-accountd | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index c00530f..7b9b1ff 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -60,7 +60,7 @@ sub usage(;$$) { } exit $rv; } -usage(1) unless GetOptions(\%OPTS, qw/config=s privkey=s socket=s conn-fd=i quiet|q debug help|h/); +usage(1) unless GetOptions(\%OPTS, qw/config=s privkey=s socket=s stdio quiet|q debug help|h/); usage(0) if $OPTS{help}; do { @@ -137,11 +137,7 @@ my $JWK_STR = JSON::->new->encode($JWK); # to support the abstract namespace.) The downside is that we have to # delete the file manually. # -if (defined $OPTS{'conn-fd'}) { - die "Invalid file descriptor" unless $OPTS{'conn-fd'} =~ /\A(\d+)\z/; - # untaint and fdopen(3) our end of the socket pair - open ($S, '+<&=', $1+0) or die "fdopen($1): $!"; -} else { +unless (defined $OPTS{stdio}) { my $sockname = $OPTS{socket} // (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.lacme" : undef); die "Missing socket option\n" unless defined $sockname; $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : die "Invalid socket name\n"; # untaint $sockname @@ -169,22 +165,21 @@ if (defined $OPTS{'conn-fd'}) { # For each new connection, send the protocol version and the account key's # public parameters, then sign whatever comes in # -sub conn($;$) { - my $conn = shift; - my $count = shift; - $conn->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ) or warn "print: $!"; +sub conn($$;$) { + my ($in, $out, $id) = @_; + $out->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ) or warn "print: $!"; # sign whatever comes in - while (defined (my $data = $conn->getline())) { + while (defined (my $data = $in->getline())) { $data =~ s/\r\n\z// or die; - print STDERR "[$count] >>> Issuing SHA-256 signature for: $data\n" unless $OPTS{quiet}; + print STDERR "[$id] >>> Issuing SHA-256 signature for: $data\n" unless $OPTS{quiet}; my $sig = $SIGN->($data); - $conn->printflush( encode_base64url($sig), "\r\n" ) or warn "print: $!"; + $out->printflush( encode_base64url($sig), "\r\n" ) or warn "print: $!"; } } -if (defined $OPTS{'conn-fd'}) { - conn($S, $$); +if (defined $OPTS{stdio}) { + conn(\*STDIN, \*STDOUT, $$); } else { $SIG{PIPE} = 'IGNORE'; # ignore broken pipes for (my $count = 0;; $count++) { @@ -193,7 +188,7 @@ if (defined $OPTS{'conn-fd'}) { die "accept: $!"; }; print STDERR "[$count] >>> Accepted new connection\n" unless $OPTS{quiet}; - conn($conn, $count); + conn($conn, $conn, $count); print STDERR "[$count] >>> Connection terminated\n" unless $OPTS{quiet}; $conn->close() or warn "close: $!"; } -- cgit v1.2.3 From baa7c25db322a9472c9155422057ec56aa93f439 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 19 Feb 2021 00:06:49 +0100 Subject: Use File::Basename::dirname(). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To correctly extract the parent directory of the socket path. The previous returned an empty string when the socket path didn't contain ‘/’. --- lacme-accountd | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 7b9b1ff..1dc5f03 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -28,6 +28,7 @@ my $PROTOCOL_VERSION = 1; my $NAME = 'lacme-accountd'; use Errno 'EINTR'; +use File::Basename 'dirname'; use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/; use List::Util 'first'; use MIME::Base64 'encode_base64url'; @@ -143,8 +144,8 @@ unless (defined $OPTS{stdio}) { $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 "stat($dirname): $!"; + my $dirname = dirname($sockname); + my @stat = stat($dirname) or die "stat($dirname): $!\n"; die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0; my $umask = umask(0177) // die "umask: $!"; -- cgit v1.2.3 From 3e49ef22ba3fbbe4e73bc4ad151770603ffa5ef1 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Fri, 19 Feb 2021 18:52:33 +0100 Subject: lacme-accountd: Don't error out when the default configuration file is missing. Instead, treat it as an empty file. This makes it possible to use lacme-accountd(1) without configuration file under ~/.config/lacme. --- lacme-accountd | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 1dc5f03..b9a6e33 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -69,16 +69,19 @@ do { ( ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config") . "/lacme/$NAME.conf" , "@@sysconfdir@@/lacme/$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; + + if (defined $OPTS{config} or -e $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; + } else { + print STDERR "Ignoring missing configuration file at default location $conffile\n" if $OPTS{debug}; + } $OPTS{quiet} = 0 if $OPTS{debug}; die "Error: 'privkey' is not specified\n" unless defined $OPTS{privkey}; -- cgit v1.2.3 From 0ef94d85e58497dcb2c4c954cadcac918032467a Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Thu, 18 Feb 2021 21:07:01 +0100 Subject: Add %-specifiers support. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lacme(8): for --config=, --socket=, --config-certs= (and ‘socket’/ ‘config-certs’/‘challenge-directory’ configuration options *before* privilege drop; and for the [accountd] section ‘command’/‘config’ configuration options *after* privilege drop). lacme-accountd(1): for --config=, --socket= and --privkey= (and ‘socket’/‘privkey’ configuration options). This also changes the default configuration file location. lacme(8) and lacme-accountd(1) now respectively use /etc/lacme/lacme.conf resp. /etc/lacme/lacme-accountd.conf when running as root, and $XDG_CONFIG_HOME/lacme/lacme.conf resp. $XDG_CONFIG_HOME/lacme/lacme-accountd.conf when running as a normal user. There is no fallback to /etc anymore. --- lacme-accountd | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index b9a6e33..e170637 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -30,7 +30,6 @@ my $NAME = 'lacme-accountd'; use Errno 'EINTR'; use File::Basename 'dirname'; 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/; @@ -64,11 +63,32 @@ sub usage(;$$) { usage(1) unless GetOptions(\%OPTS, qw/config=s privkey=s socket=s stdio quiet|q debug help|h/); usage(0) if $OPTS{help}; +sub env_fallback($$) { + my $v = $ENV{ shift() }; + return (defined $v and $v ne "") ? $v : shift; +} +sub spec_expand($) { + my $str = shift; + $str =~ s#%(.)# my $x = + $1 eq "C" ? ($< == 0 ? "@@localstatedir@@/cache" : env_fallback(XDG_CACHE_HOME => "$ENV{HOME}/.cache")) + : $1 eq "E" ? ($< == 0 ? "@@sysconfdir@@" : env_fallback(XDG_CONFIG_HOME => "$ENV{HOME}/.config")) + : $1 eq "g" ? (getgrgid((split /\s/,$()[0]))[0] + : $1 eq "G" ? $( =~ s/\s.*//r + : $1 eq "h" ? (getpwuid($<))[7] + : $1 eq "u" ? (getpwuid($<))[0] + : $1 eq "U" ? $< + : $1 eq "t" ? ($< == 0 ? "@@runstatedir@@" : $ENV{XDG_RUNTIME_DIR}) + : $1 eq "T" ? env_fallback(TMPDIR => "/tmp") + : $1 eq "%" ? "%" + : die "Error: \"$str\" has unknown specifier %$1\n"; + die "Error: undefined expansion %$1 in \"$str\"\n" unless defined $x; + $x; + #ge; + return $str; +} + do { - my $conffile = $OPTS{config} // first { -f $_ } - ( ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config") . "/lacme/$NAME.conf" - , "@@sysconfdir@@/lacme/$NAME.conf" - ); + my $conffile = spec_expand($OPTS{config} // "%E/lacme/$NAME.conf"); if (defined $OPTS{config} or -e $conffile) { print STDERR "Using configuration file: $conffile\n" if $OPTS{debug}; @@ -94,7 +114,7 @@ do { # my ($JWK, $SIGN); if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { - my ($method, $filename) = ($1,$2); + my ($method, $filename) = ($1, spec_expand($2)); my ($fh, @command); if ($method eq 'file') { # generate with `openssl genpkey -algorithm RSA` @@ -142,8 +162,7 @@ my $JWK_STR = JSON::->new->encode($JWK); # delete the file manually. # unless (defined $OPTS{stdio}) { - my $sockname = $OPTS{socket} // (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.lacme" : undef); - die "Missing socket option\n" unless defined $sockname; + my $sockname = spec_expand($OPTS{socket} // '%t/S.lacme'); $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 -- cgit v1.2.3 From cf3d42c066d2f54d4a57aa38907a7c6c7d06aeb6 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Tue, 16 Feb 2021 00:00:40 +0100 Subject: lacme-accountd(1): base64url-decode incoming signature requests. Before printing them to the standard error. --- lacme-accountd | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index e170637..0adfe38 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -30,7 +30,7 @@ my $NAME = 'lacme-accountd'; use Errno 'EINTR'; use File::Basename 'dirname'; use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/; -use MIME::Base64 'encode_base64url'; +use MIME::Base64 qw/decode_base64url encode_base64url/; use Socket qw/PF_UNIX SOCK_STREAM SHUT_RDWR/; use Config::Tiny (); @@ -195,7 +195,23 @@ sub conn($$;$) { # sign whatever comes in while (defined (my $data = $in->getline())) { $data =~ s/\r\n\z// or die; - print STDERR "[$id] >>> Issuing SHA-256 signature for: $data\n" unless $OPTS{quiet}; + + my ($protected, $payload) = split(/\./, $data, 2); + unless (defined $protected and $protected =~ /\A[A-Za-z0-9\-_]+\z/) { + print STDERR "[$id] >>> Error: Malformed protected data, refusing to sign!\n"; + last; + } + unless (defined $payload and $payload =~ /\A[A-Za-z0-9\-_]*\z/) { + # payload can be empty, for instance for POST-as-GET + print STDERR "[$id] >>> Error: Malformed payload data, refusing to sign!\n"; + last; + } + + print STDERR "[$id] >>> Incoming signature request for ", + "base64url(", decode_base64url($protected), ") . ", + "base64url(", decode_base64url($payload), ")" + unless $OPTS{quiet}; + my $sig = $SIGN->($data); $out->printflush( encode_base64url($sig), "\r\n" ) or warn "print: $!"; } -- cgit v1.2.3 From 74c0a11722cf1e01b9a9834e89a07b55eaf01080 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sat, 20 Feb 2021 22:05:18 +0100 Subject: lacme-accountd: new setting 'logfile' to log signature requests. Prefixed with a timestamp. --- lacme-accountd | 110 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 41 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 0adfe38..c8c6d5e 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -63,6 +63,30 @@ sub usage(;$$) { usage(1) unless GetOptions(\%OPTS, qw/config=s privkey=s socket=s stdio quiet|q debug help|h/); usage(0) if $OPTS{help}; +my $LOG; +sub logmsg($@) { + my $lvl = shift // "all"; + if (defined $LOG) { + my $now = localtime; + $LOG->printflush("[", $now, "] ", @_, "\n") or warn "print: $!"; + } + unless (($lvl eq "debug" and !$OPTS{debug}) or ($lvl eq "noquiet" and $OPTS{quiet})) { + print STDERR @_, "\n" or warn "print: $!"; + } +} +sub info(@) { logmsg(all => @_); } +sub error(@) { + my @msg = ("Error: ", @_); + info(@msg); + die(@msg, "\n"); +} +sub panic(@) { + my @loc = caller; + my @msg = (@_, " at line $loc[2] in $loc[1]"); + info(@msg); + die(@msg, "\n"); +} + sub env_fallback($$) { my $v = $ENV{ shift() }; return (defined $v and $v ne "") ? $v : shift; @@ -80,8 +104,8 @@ sub spec_expand($) { : $1 eq "t" ? ($< == 0 ? "@@runstatedir@@" : $ENV{XDG_RUNTIME_DIR}) : $1 eq "T" ? env_fallback(TMPDIR => "/tmp") : $1 eq "%" ? "%" - : die "Error: \"$str\" has unknown specifier %$1\n"; - die "Error: undefined expansion %$1 in \"$str\"\n" unless defined $x; + : error("\"$str\" has unknown specifier %$1"); + error("undefined expansion %$1 in \"$str\"") unless defined $x; $x; #ge; return $str; @@ -92,11 +116,16 @@ do { if (defined $OPTS{config} or -e $conffile) { print STDERR "Using configuration file: $conffile\n" if $OPTS{debug}; - my $h = Config::Tiny::->read($conffile) or die Config::Tiny::->errstr()."\n"; + my $h = Config::Tiny::->read($conffile) or error(Config::Tiny::->errstr()); 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; + if (defined (my $logfile = $h2->{logfile})) { + $logfile = spec_expand($logfile); + die "Invalid log file name\n" unless $logfile =~ /\A(\p{Print}+)\z/; # untaint + open $LOG, ">>", $1 or die "Can't open $1: $!"; + } + error("Invalid section(s): ".join(', ', keys %$h)) if %$h; + my %h = map { $_ => delete $h2->{$_} } qw/privkey gpg socket logfile quiet/; + error("Unknown option(s): ".join(', ', keys %$h2)) if %$h2; $h{quiet} = lc $h{quiet} eq 'yes' ? 1 : 0 if defined $h{quiet}; $OPTS{$_} //= $h{$_} foreach grep {defined $h{$_}} keys %h; } else { @@ -104,7 +133,7 @@ do { } $OPTS{quiet} = 0 if $OPTS{debug}; - die "Error: 'privkey' is not specified\n" unless defined $OPTS{privkey}; + error("'privkey' is not specified") unless defined $OPTS{privkey}; }; @@ -118,27 +147,27 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { my ($fh, @command); if ($method eq 'file') { # generate with `openssl genpkey -algorithm RSA` - open $fh, '<', $filename or die "Error: Can't open $filename: $!\n"; + open $fh, '<', $filename or error("Can't open $filename: $!"); } elsif ($method eq 'gpg') { @command = split /\s+/, ($OPTS{gpg} // 'gpg --quiet'); - open $fh, '-|', @command, qw/-o - --decrypt --/, $filename or die "fork: $!"; + open $fh, '-|', @command, qw/-o - --decrypt --/, $filename or panic("fork: $!"); } else { - die; # impossible + panic(); # impossible } my $str = do {local $/ = undef; <$fh>}; - close $fh or die $! ? - "close: $!" : - "Error: $command[0] exited with value ".($? >> 8)."\n"; + close $fh or ($! or !@command) ? + panic("close: $!") : + error("$command[0] exited with value ".($? >> 8)); 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(); + error("$filename: Not a private key") unless $rsa->is_private(); + error("$filename: Invalid key") unless $rsa->check_key(); $rsa->use_sha256_hash(); require 'Crypt/OpenSSL/Bignum.pm'; @@ -149,7 +178,7 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { $SIGN = sub($) { $rsa->sign($_[0]) }; } else { - die "Error: unsupported method: $OPTS{privkey}\n"; + error("unsupported method: $OPTS{privkey}"); } my $JWK_STR = JSON::->new->encode($JWK); @@ -163,24 +192,24 @@ my $JWK_STR = JSON::->new->encode($JWK); # unless (defined $OPTS{stdio}) { my $sockname = spec_expand($OPTS{socket} // '%t/S.lacme'); - $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : die "Invalid socket name\n"; # untaint $sockname + $sockname = $sockname =~ /\A(\p{Print}+)\z/ ? $1 : error("Invalid socket name"); # untaint # ensure we're the only user with write access to the parent dir my $dirname = dirname($sockname); - my @stat = stat($dirname) or die "stat($dirname): $!\n"; - die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0; + my @stat = stat($dirname) or error("stat($dirname): $!"); + error("Insecure permissions on $dirname") if ($stat[2] & 0022) != 0; - my $umask = umask(0177) // die "umask: $!"; + my $umask = umask(0177) // panic("umask: $!"); - print STDERR "Starting lacme 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: $!"; + logmsg(noquiet => "Starting lacme Account Key Manager at $sockname"); + socket(my $sock, PF_UNIX, SOCK_STREAM, 0) or panic("socket: $!"); + my $sockaddr = Socket::sockaddr_un($sockname) // panic(); + bind($sock, $sockaddr) or panic("bind: $!"); ($SOCKNAME, $S) = ($sockname, $sock); - listen($S, 1) or die "listen: $!"; + listen($S, 1) or panic("listen: $!"); - umask($umask) // die "umask: $!"; + umask($umask) // panic("umask: $!"); }; @@ -194,23 +223,22 @@ sub conn($$;$) { # sign whatever comes in while (defined (my $data = $in->getline())) { - $data =~ s/\r\n\z// or die; + $data =~ s/\r\n\z// or panic(); my ($protected, $payload) = split(/\./, $data, 2); unless (defined $protected and $protected =~ /\A[A-Za-z0-9\-_]+\z/) { - print STDERR "[$id] >>> Error: Malformed protected data, refusing to sign!\n"; + info("[$id] >>> Error: Malformed protected data, refusing to sign!"); last; } unless (defined $payload and $payload =~ /\A[A-Za-z0-9\-_]*\z/) { # payload can be empty, for instance for POST-as-GET - print STDERR "[$id] >>> Error: Malformed payload data, refusing to sign!\n"; + info("[$id] >>> Error: Malformed payload data, refusing to sign!"); last; } - print STDERR "[$id] >>> Incoming signature request for ", - "base64url(", decode_base64url($protected), ") . ", - "base64url(", decode_base64url($payload), ")" - unless $OPTS{quiet}; + logmsg(noquiet => "[$id] >>> Incoming signature request for ", + "base64url(", decode_base64url($protected), ") . ", + "base64url(", decode_base64url($payload), ")"); my $sig = $SIGN->($data); $out->printflush( encode_base64url($sig), "\r\n" ) or warn "print: $!"; @@ -224,11 +252,11 @@ if (defined $OPTS{stdio}) { 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: $!"; + panic("accept: $!"); }; - print STDERR "[$count] >>> Accepted new connection\n" unless $OPTS{quiet}; + logmsg(noquiet => "[$count] >>> Accepted new connection"); conn($conn, $conn, $count); - print STDERR "[$count] >>> Connection terminated\n" unless $OPTS{quiet}; + logmsg(noquiet => "[$count] >>> Connection terminated"); $conn->close() or warn "close: $!"; } } @@ -238,12 +266,12 @@ if (defined $OPTS{stdio}) { # END { if (defined $SOCKNAME and -S $SOCKNAME) { - print STDERR "Unlinking $SOCKNAME\n" if $OPTS{debug}; - unlink $SOCKNAME or print STDERR "Couldn't unlink $SOCKNAME: $!\n"; + logmsg(debug => "Unlinking $SOCKNAME"); + unlink $SOCKNAME or info("Error: unlink($SOCKNAME): $!"); } if (defined $S) { - print STDERR "Shutting down and closing lacme Account Key Manager\n" unless $OPTS{quiet}; - shutdown($S, SHUT_RDWR) or warn "shutdown: $!"; - close $S or print STDERR "close: $!\n"; + logmsg(noquiet => "Shutting down and closing lacme Account Key Manager"); + shutdown($S, SHUT_RDWR) or info("Error: shutdown: $!"); + close $S or info("Error: close: $!"); } } -- cgit v1.2.3 From 9bc3b5756ef3f36aaa0a1b28db71767c87e9446e Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 13:00:31 +0100 Subject: accountd: Fix prototype. --- lacme-accountd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index c8c6d5e..27e45cd 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -217,7 +217,7 @@ unless (defined $OPTS{stdio}) { # For each new connection, send the protocol version and the account key's # public parameters, then sign whatever comes in # -sub conn($$;$) { +sub conn($$$) { my ($in, $out, $id) = @_; $out->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ) or warn "print: $!"; -- cgit v1.2.3 From 594c32ac58167396980a404261047e94155a83d3 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 13:01:14 +0100 Subject: wording --- lacme-accountd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 27e45cd..6a6e90d 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -231,7 +231,7 @@ sub conn($$$) { last; } unless (defined $payload and $payload =~ /\A[A-Za-z0-9\-_]*\z/) { - # payload can be empty, for instance for POST-as-GET + # POST-as-GET yields an empty payload info("[$id] >>> Error: Malformed payload data, refusing to sign!"); last; } -- cgit v1.2.3 From d56b957dbae6c8214d50ce88d0ea04eb4654b843 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 17:34:51 +0100 Subject: wording --- lacme-accountd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 6a6e90d..0f0b0d9 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -105,7 +105,7 @@ sub spec_expand($) { : $1 eq "T" ? env_fallback(TMPDIR => "/tmp") : $1 eq "%" ? "%" : error("\"$str\" has unknown specifier %$1"); - error("undefined expansion %$1 in \"$str\"") unless defined $x; + error("Undefined expansion %$1 in \"$str\"") unless defined $x; $x; #ge; return $str; @@ -178,7 +178,7 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { $SIGN = sub($) { $rsa->sign($_[0]) }; } else { - error("unsupported method: $OPTS{privkey}"); + error("Unsupported method: $OPTS{privkey}"); } my $JWK_STR = JSON::->new->encode($JWK); -- cgit v1.2.3 From ba6addf54cef0b1536dc87c42a41b4dc207ac884 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 14:27:50 +0100 Subject: accountd: Pass JWA and JWK thumbprint via extended greeting data. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing the JWA to the ACME client is required if we want to support account keys other than RSA. As of 0.7 both lacme-accountd(1) and lacme(8) hardcode “RS256” (SHA256withRSA per RFC 7518 sec. A.1). Passing the JWK thumbprint is handy as it gives more flexibility if RFC 8555 sec. 8.1 were to be updated with another digest algorithm (it's currently hardcoded to SHA-256). A single lacme-account(1) instance might be used to sign requests from many clients, and it's easier to upgrade a single ‘lacme-accountd’ than many ‘lacme’. Moreover, in some restricted environments lacme-accountd might hide the JWK from the client to prevent ‘newAccount’ requests (such as contact updates); passing its thumbprint is enough for ‘newOrder’ requests. --- lacme-accountd | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 0f0b0d9..d4521f9 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -27,6 +27,7 @@ our $VERSION = '0.3'; my $PROTOCOL_VERSION = 1; my $NAME = 'lacme-accountd'; +use Digest::SHA 'sha256'; use Errno 'EINTR'; use File::Basename 'dirname'; use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/; @@ -141,7 +142,7 @@ do { # Build the JSON Web Key (RFC 7517) from the account key's public parameters, # and determine the signing method $SIGN. # -my ($JWK, $SIGN); +my ($EXTRA_GREETING_STR, $JWK_STR, $SIGN); if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { my ($method, $filename) = ($1, spec_expand($2)); my ($fh, @command); @@ -174,13 +175,19 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { 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 }; + my %extra_greeting; + my %jwk = ( kty => 'RSA', n => $n, e => $e ); + $extra_greeting{alg} = 'RS256'; # SHA256withRSA (RFC 7518 sec. A.1) $SIGN = sub($) { $rsa->sign($_[0]) }; + + # use of SHA-256 digest in the thumbprint is hardcoded, see RFC 8555 sec. 8.1 + $JWK_STR = JSON::->new->utf8->canonical->encode(\%jwk); + $extra_greeting{"jwk-thumbprint"} = encode_base64url(sha256($JWK_STR)); + $EXTRA_GREETING_STR = JSON::->new->encode(\%extra_greeting); } else { error("Unsupported method: $OPTS{privkey}"); } -my $JWK_STR = JSON::->new->encode($JWK); ############################################################################# @@ -219,7 +226,8 @@ unless (defined $OPTS{stdio}) { # sub conn($$$) { my ($in, $out, $id) = @_; - $out->printflush( "$PROTOCOL_VERSION OK", "\r\n", $JWK_STR, "\r\n" ) or warn "print: $!"; + $out->printflush( "$PROTOCOL_VERSION OK ", $EXTRA_GREETING_STR, "\r\n", + $JWK_STR, "\r\n" ) or warn "print: $!"; # sign whatever comes in while (defined (my $data = $in->getline())) { -- cgit v1.2.3 From 1bdaeae835b5c9914f9c2107efda150d643cda12 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 19:54:25 +0100 Subject: accountd: Improve log message for incoming requests. --- lacme-accountd | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index d4521f9..d8c96b0 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -233,20 +233,20 @@ sub conn($$$) { while (defined (my $data = $in->getline())) { $data =~ s/\r\n\z// or panic(); - my ($protected, $payload) = split(/\./, $data, 2); - unless (defined $protected and $protected =~ /\A[A-Za-z0-9\-_]+\z/) { - info("[$id] >>> Error: Malformed protected data, refusing to sign!"); + my ($header, $payload) = split(/\./, $data, 2); + unless (defined $header and $header =~ /\A[A-Za-z0-9\-_]+\z/) { + info("[$id] >>> Error: Refusing to sign request: Malformed protected header"); last; } unless (defined $payload and $payload =~ /\A[A-Za-z0-9\-_]*\z/) { # POST-as-GET yields an empty payload - info("[$id] >>> Error: Malformed payload data, refusing to sign!"); + info("[$id] >>> Error: Refusing to sign request: Malformed payload"); last; } - logmsg(noquiet => "[$id] >>> Incoming signature request for ", - "base64url(", decode_base64url($protected), ") . ", - "base64url(", decode_base64url($payload), ")"); + logmsg(noquiet => "[$id] >>> OK signing request: ", + "header=base64url(", decode_base64url($header), "); ", + "playload=base64url(", decode_base64url($payload), ")"); my $sig = $SIGN->($data); $out->printflush( encode_base64url($sig), "\r\n" ) or warn "print: $!"; -- cgit v1.2.3 From 9898b1877ce2973bbc336921969bd7f16d3698fa Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sun, 21 Feb 2021 18:49:14 +0100 Subject: lacme-accountd(1): new setting 'keyid'. This saves a round trip and provides a safeguard against malicious clients. --- lacme-accountd | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index d8c96b0..a842bce 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -125,7 +125,7 @@ do { open $LOG, ">>", $1 or die "Can't open $1: $!"; } error("Invalid section(s): ".join(', ', keys %$h)) if %$h; - my %h = map { $_ => delete $h2->{$_} } qw/privkey gpg socket logfile quiet/; + my %h = map { $_ => delete $h2->{$_} } qw/privkey gpg socket logfile keyid quiet/; error("Unknown option(s): ".join(', ', keys %$h2)) if %$h2; $h{quiet} = lc $h{quiet} eq 'yes' ? 1 : 0 if defined $h{quiet}; $OPTS{$_} //= $h{$_} foreach grep {defined $h{$_}} keys %h; @@ -183,6 +183,11 @@ if ($OPTS{privkey} =~ /\A(file|gpg):(\p{Print}+)\z/) { # use of SHA-256 digest in the thumbprint is hardcoded, see RFC 8555 sec. 8.1 $JWK_STR = JSON::->new->utf8->canonical->encode(\%jwk); $extra_greeting{"jwk-thumbprint"} = encode_base64url(sha256($JWK_STR)); + + if ((my $kid = $OPTS{keyid} // "") ne "") { + $extra_greeting{kid} = $kid; + $JWK_STR = "{}"; + } $EXTRA_GREETING_STR = JSON::->new->encode(\%extra_greeting); } else { -- cgit v1.2.3 From fef888045bfd03c26822782411ff835c03440d58 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Mon, 22 Feb 2021 00:28:02 +0100 Subject: logfile: treat empty values as unset. --- lacme-accountd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index a842bce..9ed41c4 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -119,10 +119,10 @@ do { print STDERR "Using configuration file: $conffile\n" if $OPTS{debug}; my $h = Config::Tiny::->read($conffile) or error(Config::Tiny::->errstr()); my $h2 = delete $h->{_} // {}; - if (defined (my $logfile = $h2->{logfile})) { + if ((my $logfile = $h2->{logfile} // "") ne "") { $logfile = spec_expand($logfile); die "Invalid log file name\n" unless $logfile =~ /\A(\p{Print}+)\z/; # untaint - open $LOG, ">>", $1 or die "Can't open $1: $!"; + open $LOG, ">>", $1 or die "Can't open $1: $!"; # open ASAP (before config validation) } error("Invalid section(s): ".join(', ', keys %$h)) if %$h; my %h = map { $_ => delete $h2->{$_} } qw/privkey gpg socket logfile keyid quiet/; -- cgit v1.2.3 From f55a2782db2e86c88ed4780d7ed54b09792d07d6 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Mon, 22 Feb 2021 03:13:26 +0100 Subject: Print error messages only once. --- lacme-accountd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 9ed41c4..9909cb8 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -79,13 +79,13 @@ sub info(@) { logmsg(all => @_); } sub error(@) { my @msg = ("Error: ", @_); info(@msg); - die(@msg, "\n"); + exit 255; } sub panic(@) { my @loc = caller; my @msg = (@_, " at line $loc[2] in $loc[1]"); info(@msg); - die(@msg, "\n"); + exit 255; } sub env_fallback($$) { -- cgit v1.2.3 From 3eba02ef820a393bd5781be9f8fcda1611ae7c3d Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Mon, 22 Feb 2021 03:19:57 +0100 Subject: Prepare new release v0.8.0. --- lacme-accountd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lacme-accountd') diff --git a/lacme-accountd b/lacme-accountd index 9909cb8..0f5deb2 100755 --- a/lacme-accountd +++ b/lacme-accountd @@ -23,7 +23,7 @@ use v5.14.2; use strict; use warnings; -our $VERSION = '0.3'; +our $VERSION = '0.8.0'; my $PROTOCOL_VERSION = 1; my $NAME = 'lacme-accountd'; -- cgit v1.2.3