diff options
| -rw-r--r-- | Changelog | 2 | ||||
| -rwxr-xr-x | client | 56 | ||||
| -rw-r--r-- | config/lacme-accountd.conf | 6 | ||||
| -rwxr-xr-x | lacme-accountd | 7 | ||||
| -rw-r--r-- | lacme-accountd.1.md | 18 | ||||
| -rw-r--r-- | tests/accountd-kid | 59 | 
6 files changed, 130 insertions, 18 deletions
| @@ -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 @@ -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 : | 
