aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2021-02-21 18:49:14 +0100
committerGuilhem Moulin <guilhem@fripost.org>2021-02-22 00:14:51 +0100
commit9898b1877ce2973bbc336921969bd7f16d3698fa (patch)
tree286901349d8345e204c21bce2b49737cbd72e286
parent1bdaeae835b5c9914f9c2107efda150d643cda12 (diff)
lacme-accountd(1): new setting 'keyid'.
This saves a round trip and provides a safeguard against malicious clients.
-rw-r--r--Changelog2
-rwxr-xr-xclient56
-rw-r--r--config/lacme-accountd.conf6
-rwxr-xr-xlacme-accountd7
-rw-r--r--lacme-accountd.1.md18
-rw-r--r--tests/accountd-kid59
6 files changed, 130 insertions, 18 deletions
diff --git a/Changelog b/Changelog
index ffd9536..9c606fe 100644
--- a/Changelog
+++ b/Changelog
@@ -52,6 +52,8 @@ lacme (0.7.1) upstream;
in messages to the standard error.
* lacme-accountd(1): new setting 'logfile' to log (decoded) incoming
signature requests to a file.
+ * lacme-accountd(1): new setting 'keyid' to easily revoke all account
+ management access from the client.
+ Improve nginx/apache2 snippets for direct serving of challenge files
(with the new 'challenge-directory' logic symlinks can be disabled).
+ Split Nginx and Apapche2 static configuration snippets into seperate
diff --git a/client b/client
index 7a63259..d7874b7 100755
--- a/client
+++ b/client
@@ -90,7 +90,7 @@ do {
print STDERR "WARN: Ignoring extra greeting data from accountd \"$extra\"\n";
} else {
print STDERR "Received extra greeting data from accountd: $extra\n" if $ENV{DEBUG};
- ($JWK_thumbprint, $ALG) = @$h{qw/jwk-thumbprint alg/};
+ ($JWK_thumbprint, $ALG, $KID) = @$h{qw/jwk-thumbprint alg kid/};
}
}
my $jwk_str = $S->getline() // die "ERROR: No JWK from lacme-accountd\n";
@@ -194,39 +194,50 @@ sub request_json_decode($;$$) {
#############################################################################
-# JSON-encode the hash reference $h and send it to the ACME server $uri
-# encapsulated it in a JSON Web Signature (JWS).
+# JSON-encode the hash reference $payload and send it to the ACME server
+# $url encapsulated it in a JSON Web Signature (JWS). $header MUST
+# contain either "jwk" (JSON Web Key) or "kid" per RFC 8555 sec. 6.2
# https://tools.ietf.org/html/rfc8555
#
-sub acme($;$) {
- my ($uri, $h) = @_;
- die "Missing nonce\n" unless defined $NONCE;
+sub acme2($$;$) {
+ my ($url, $header, $payload) = @_;
# Produce the JSON Web Signature: RFC 7515 section 5
- my %header = ( alg => $ALG, nonce => $NONCE, url => $uri );
- defined $KID ? ($header{kid} = $KID) : ($header{jwk} = $JWK);
- my $payload = defined $h ? encode_base64url(json()->encode($h)) : "";
- my $protected = encode_base64url(json()->encode(\%header));
- my $data = $protected .'.'. $payload;
- $S->printflush($data, "\r\n");
+ $header->{alg} = $ALG;
+ $header->{nonce} = $NONCE // die "Missing nonce\n";
+ $header->{url} = $url;
+ my $protected = encode_base64url(json()->encode($header));
+ $payload = defined $payload ? encode_base64url(json()->encode($payload)) : "";
+
+ $S->printflush($protected, ".", $payload, "\r\n");
my $sig = $S->getline() // die "ERROR: No response from lacme-accountd\n";
$sig =~ s/\r\n\z// or die;
undef $NONCE; # consume the nonce
# Flattened JSON Serialization, RFC 7515 section 7.2.2
- request(POST => $uri, {
+ request(POST => $url, {
payload => $payload,
protected => $protected,
signature => $sig
});
}
+# Like above, but always use "kid"
+sub acme($;$) {
+ my ($url, $payload) = @_;
+ die "Missing KID\n" unless defined $KID;
+ acme2($url, {kid => $KID}, $payload)
+}
+
my $SERVER_URI = $CONFIG->{server} // '@@acmeapi_server@@';
my %RES;
# Get the resource URI from the directory
sub acme_resource($%) {
my $r = shift;
+ my %payload = @_;
+ my %protected;
+
unless (%RES) {
# query the ACME directory to get resources URIs
%RES = %{ request_json_decode(request(GET => $SERVER_URI)) };
@@ -235,12 +246,23 @@ sub acme_resource($%) {
request(HEAD => $RES{newNonce});
}
my $uri = $RES{$r} // die "Unknown resource '$r'\n";
- acme($uri, {@_});
+
+ if ($r eq "newAccount" or ($r eq "revokeCert" and !defined $KID)) {
+ # per RFC 8555 sec. 6.2 these requests MUST have a JWK
+ print STDERR "WARNING: lacme-accountd supplied an empty JWK; try removing 'keyid' ",
+ "setting from lacme-accountd.conf if the ACME resource request fails.\n"
+ unless %$JWK;
+ return acme2($uri, {jwk => $JWK}, \%payload);
+ } else {
+ # per RFC 8555 sec. 6.2 all other requests MUST have a KID
+ return acme($uri, \%payload);
+ }
}
# Set the key ID (registration URI)
sub set_kid(;$) {
my $die = shift // 1;
+ return if defined $KID; # already set
my $r = acme_resource('newAccount', onlyReturnExisting => JSON::true );
if ($r->is_success()) {
$KID = $r->header('Location');
@@ -269,6 +291,7 @@ if ($COMMAND eq 'account') {
if ($r->is_success()) {
$KID = $r->header('Location');
+ print STDERR "Key ID: $KID\n";
$r = acme($KID, \%h);
request_json_decode($r, 1, \*STDOUT)
if $r->is_success() and $r->content_type() eq 'application/json';
@@ -391,7 +414,10 @@ elsif ($COMMAND eq 'revokeCert') {
my $der = do { local $/ = undef; <STDIN> };
close STDIN or die "close: $!";
- # send a KID if the request is signed with the acccount key, otherwise send a JWK
+ # RFC 8555 sec. 6.2: send a KID if the request is signed with the
+ # acccount key, otherwise send a JWK
+ # We have no way to know which of the account key or certificate key
+ # is used, so we try to get a KID and fallback to sending the JWK
set_kid(0);
my $r = acme_resource('revokeCert', certificate => encode_base64url($der));
diff --git a/config/lacme-accountd.conf b/config/lacme-accountd.conf
index f31cf67..d31c6c8 100644
--- a/config/lacme-accountd.conf
+++ b/config/lacme-accountd.conf
@@ -20,6 +20,12 @@
#
#socket = %t/S.lacme
+# The "Key ID", as shown by `acme account`, to give the ACME client.
+# A non-empty value revokes all account management access (status
+# change, contact address updates etc.) from the client.
+#
+#keyid =
+
# Be quiet. Possible values: "Yes"/"No".
#
#quiet = Yes
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 {
diff --git a/lacme-accountd.1.md b/lacme-accountd.1.md
index d0b2c6b..4933a78 100644
--- a/lacme-accountd.1.md
+++ b/lacme-accountd.1.md
@@ -119,14 +119,28 @@ leading `--`) in the configuration file. Valid settings are:
[`gpg`(1)] to use, as well as some default options.
Default: `gpg --quiet`.
+*socket*
+
+: See `--socket=`.
+
*logfile*
: An optional file where to log to. The value is subject to
[%-specifier expansion](#percent-specifiers).
-*socket*
+*keyid*
-: See `--socket=`.
+: The "Key ID", as shown by `` `acme account` ``, to give the [ACME]
+ client. With an empty *keyid* (the default) the client forwards the
+ JSON Web Key (JWK) to the [ACME] server to retrieve the correct
+ value. A non-empty value therefore saves a round-trip.
+
+ A non-empty value also causes `lacme-accountd` to send an empty JWK,
+ thereby revoking all account management access (status change,
+ contact address updates etc.) from the client: any `` `acme account` ``
+ command (or any command from [`lacme`(8)] before version 0.8.0) is
+ bound to be rejected by the [ACME] server. This provides a
+ safeguard against malicious clients.
*quiet*
diff --git a/tests/accountd-kid b/tests/accountd-kid
new file mode 100644
index 0000000..e1bd63d
--- /dev/null
+++ b/tests/accountd-kid
@@ -0,0 +1,59 @@
+# Hide JWK from ACME client and pass KID instead
+
+# get the key ID
+lacme account 2>"$STDERR" || fail
+keyid="$(sed -n "/^Key ID: / {s///p;q}" <"$STDERR")"
+
+# prepare accountd
+adduser --disabled-password \
+ --home /home/lacme-account \
+ --gecos "lacme account user" \
+ --quiet lacme-account
+
+install -olacme-account -glacme-account -Ddm0700 -- \
+ ~lacme-account/.config/lacme ~lacme-account/.local/share/lacme
+mv -t ~lacme-account/.config/lacme /etc/lacme/account.key
+chown lacme-account: ~lacme-account/.config/lacme/account.key
+
+cat >~lacme-account/.config/lacme/lacme-accountd.conf <<-EOF
+ privkey = file:%E/lacme/account.key
+ logfile = %h/.local/share/lacme/accountd.log
+ keyid = $keyid
+EOF
+
+SOCKET=~lacme-account/S.lacme
+runuser -u lacme-account -- lacme-accountd --socket="$SOCKET" --quiet & PID=$!
+
+# newAccount resource fails as per RFC 8555 sec. 6.2 it requires a JWK
+! lacme --socket="$SOCKET" account 2>"$STDERR" || fail
+grepstderr -Fxq "WARNING: lacme-accountd supplied an empty JWK; try removing 'keyid' setting from lacme-accountd.conf if the ACME resource request fails."
+grepstderr -Fxq "400 Bad Request (Parse error reading JWS)"
+! grep -F ">>> OK signing request: header=" ~lacme-account/.local/share/lacme/accountd.log | \
+ grep -vF ">>> OK signing request: header=base64url({\"alg\":\"RS256\",\"jwk\":{}," || exit 1
+
+# rotate log and restart accountd
+kill $PID
+wait
+
+rm ~lacme-account/.local/share/lacme/accountd.log
+runuser -u lacme-account -- lacme-accountd --socket="$SOCKET" --quiet & PID=$!
+
+# newOrder works fine without JWK
+lacme --socket="$SOCKET" newOrder
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+# and so does revokeCert (for requests authenticated with the account key)
+lacme --socket="$SOCKET" revokeCert /etc/lacme/simpletest.rsa.crt
+! lacme --socket="$SOCKET" revokeCert /etc/lacme/simpletest.rsa.crt 2>"$STDERR" || fail
+grepstderr -Fxq "Revoking /etc/lacme/simpletest.rsa.crt"
+grepstderr -Fxq "400 Bad Request (Certificate already revoked)"
+grepstderr -Fxq "Warning: Couldn't revoke /etc/lacme/simpletest.rsa.crt"
+
+kill $PID
+wait
+
+# make sure all signing requests have a KID
+! grep -F ">>> OK signing request: header=" ~lacme-account/.local/share/lacme/accountd.log | \
+ grep -vF ">>> OK signing request: header=base64url({\"alg\":\"RS256\",\"kid\":\"$keyid\"," || exit 1
+
+# vim: set filetype=sh :