aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@debian.org>2021-02-22 03:30:32 +0100
committerGuilhem Moulin <guilhem@debian.org>2021-02-22 03:30:32 +0100
commitd1be19ea9484f4c48af2de54266465d49bb1281d (patch)
tree768da9388a9ea6ed42d8d818a6433a4871a1172e
parent847ae99fb1ed73fd77c6ffd30f2c554ab5892fde (diff)
parent3eba02ef820a393bd5781be9f8fcda1611ae7c3d (diff)
Merge tag 'v0.8.0' into debian/latest
Release version 0.8.0
-rw-r--r--Changelog102
-rw-r--r--INSTALL11
-rw-r--r--Makefile74
-rw-r--r--README2
-rw-r--r--certs-staging/letsencrypt-stg-int-e1.pem19
-rw-r--r--certs-staging/letsencrypt-stg-int-e2.pem19
-rw-r--r--certs-staging/letsencrypt-stg-int-r3.pem31
-rw-r--r--certs-staging/letsencrypt-stg-int-r4.pem31
-rw-r--r--certs-staging/letsencrypt-stg-root-x1.pem32
-rw-r--r--certs-staging/letsencrypt-stg-root-x2.pem15
-rw-r--r--certs/isrg-root-x2.pem14
-rw-r--r--certs/isrgrootx1.pem31
-rw-r--r--certs/letsencryptauthorityx1.pem32
-rw-r--r--certs/letsencryptauthorityx2.pem32
-rwxr-xr-xclient122
-rw-r--r--config/lacme-accountd.conf20
-rw-r--r--config/lacme-certs.conf45
-rw-r--r--config/lacme.conf52
-rwxr-xr-xlacme325
-rwxr-xr-xlacme-accountd220
-rw-r--r--lacme-accountd.1.md135
-rw-r--r--lacme.8.md278
-rw-r--r--snippets/apache2-static.conf16
-rw-r--r--snippets/apache2.conf15
-rw-r--r--snippets/nginx-static.conf15
-rw-r--r--snippets/nginx.conf17
-rwxr-xr-xtest260
-rw-r--r--tests/account-encrypted-gpg15
-rw-r--r--tests/account-encrypted-openssl10
-rw-r--r--tests/accountd87
-rw-r--r--tests/accountd-kid59
-rw-r--r--tests/accountd-remote55
-rw-r--r--tests/apache2-proxy33
-rw-r--r--tests/apache2-static47
-rw-r--r--tests/cert-extensions91
-rw-r--r--tests/cert-install176
-rw-r--r--tests/cert-renew21
-rw-r--r--tests/cert-revoke32
-rw-r--r--tests/cert-verify43
-rw-r--r--tests/drop-privileges166
-rw-r--r--tests/nginx-proxy35
-rw-r--r--tests/nginx-static48
-rw-r--r--tests/old-accountd30
-rw-r--r--tests/old-lacme36
-rw-r--r--tests/register8
-rw-r--r--tests/spec-expansion130
-rw-r--r--tests/webservers16
-rwxr-xr-xwebserver2
48 files changed, 2656 insertions, 449 deletions
diff --git a/Changelog b/Changelog
index 13db236..9f12237 100644
--- a/Changelog
+++ b/Changelog
@@ -1,3 +1,105 @@
+lacme (0.8.0) upstream;
+
+ * Breaking change: 'challenge-directory' now needs to be set to an
+ *existing* directory (writable by the lacme client user). Since
+ lacme(8) spawns a builtin webserver by default the change doesn't
+ affect default configurations.
+ Thanks to Benjamin Tietz for the idea and initial patch.
+ * Breaking change: the 'iptables' option is now ignored unless the
+ builtin webserver is used.
+ * Unprivileged user/group for the internal client resp. webserver are
+ now configurable at install time.
+ * lacme: new flag `--force`, which aliases to `--min-days=-1`, i.e.,
+ forces renewal regardless of the expiration date of existing
+ certificates.
+ * Remove decomissioned intermediate CAs Authority X3 and X4 from the
+ bundle.
+ * Remove cross-signed intermediate CAs from the bundle and add the
+ (self-signed) ISRG Root X1 and X2 instead. This allows us to fully
+ validate provided X.509 chains using that self-contained bundle,
+ regardless of which CAs is marqued as trusted under /etc/ssl/certs.
+ This change bumps the minimum OpenSSL version to 1.1.0.
+ * Breaking change: lacme(8) and lacme-accountd(1) respectively load
+ their configuration file from /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, and the lookup in the
+ current directory as prefered choice is removed too. However
+ lacme-accountd(1) can be used without configuration file under
+ ~/.config/lacme as it treats a non-existent default location as an
+ empty file.
+ * The client, webserver, and accountd commands are now split on
+ whitespace. This doesn't change the default behavior but allows
+ using `ssh -T lacme@account.example.net lacme-accountd` to spawn a
+ remote lacme-accountd server for instance.
+ * Add test suite against Let's Encrypt's staging environment
+ https://letsencrypt.org/docs/staging-environment/ .
+ * lacme(8)'s 'config' option in the [accountd] section no longer have a
+ default value. The previous default /etc/lacme/lacme-accountd.conf
+ is still honored when root privileges are preserved (the default).
+ * Deprecate setting 'privkey' in [accountd] section of the lacme(8)
+ configuration file. One need to use the lacme-accountd(1)
+ configuration file for that instead.
+ * lacme(8): add %-specifiers support 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): add %-specifiers support for --config=, --socket=
+ and --privkey= (and 'socket'/'privkey' configuration options).
+ * lacme-accountd(1): base64url-decode incoming signature requests shown
+ 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
+ files. That way users prefering that over reverse-proxying can just
+ source/enable the relevant files without having to uncomment
+ anything.
+ + Add support for TLS Feature extension from RFC 7633; this is mostly
+ useful for OCSP Must-Staple.
+ + client: use "lacme-client/$VERSION" as User-Agent header.
+ + Consolidate error messages for consistency.
+ + Sanitize environment when spawning the lacme client, webserver and
+ accountd.
+ + accountd: replace internal option --conn-fd=FD with flag --stdio.
+ Using stdin/stdout makes it possible to tunnel the accountd
+ connection through ssh. The new flag is documented to allow safe
+ usage is authorized_keys(5) restrictions.
+ + Remove dependency on List::Util (core module).
+ + accountd: Pass JWA and JWK thumbprint via extended greeting data.
+ This gives better forward flexibility.
+ - lacme: delay webserver socket shutdown to after the process has
+ terminated.
+ - 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.
+ - documentation: clarify that "file:/path/to/account.key" can point to
+ a symmetrically-encrypted private key.
+ - documentation: emphasize default values in the config file, and move
+ the most common options ('hash', 'keyUsage', 'CAfile', 'min-days') to
+ the default section.
+ - Raise client timeout from 10 to 30s.
+ - Remove dependency on Types::Serialiser.
+ - client: fail immediately when the accountd is unreachable.
+ - Makefile: set executable bit for $(bindir)/lacme-accountd and
+ $(sbindir)/lacme.
+ - client: avoid "Use of uninitialized value in pattern match (m//)"
+ perl warnings when the accountd socket can't be reached.
+ - webserver: reopen stdin from /dev/null.
+ - Use 'acme-challenge.XXXXXXXXXX' as template for the temporary ACME
+ challenge directory.
+ - Set the DEBUG environment variable to 0/1 instead of ""/1.
+ - Use File::Basename::dirname() to correctly extract the parent
+ directory of the socket path.
+ - client: Print Terms of Service URL for 'account' command.
+
+ -- Guilhem Moulin <guilhem@fripost.org> Mon, 22 Feb 2021 03:19:57 +0100
+
lacme (0.7) upstream;
* Breaking change: the certificate indicated by 'CAfile' is no longer
diff --git a/INSTALL b/INSTALL
index 155c7aa..092ef16 100644
--- a/INSTALL
+++ b/INSTALL
@@ -4,9 +4,9 @@ lacme-accountd depends on the following Perl modules:
- Crypt::OpenSSL::RSA (for PEM-encoded key material)
- Crypt::OpenSSL::Bignum (for PEM-encoded key material)
- Errno (core module)
+ - File::Basename (core module)
- Getopt::Long (core module)
- JSON (optionally C/XS-accelerated with JSON::XS)
- - List::Util (core module)
- MIME::Base64 (core module)
- Socket (core module)
@@ -16,23 +16,22 @@ the following command:
apt-get install libconfig-tiny-perl libcrypt-openssl-rsa-perl libcrypt-openssl-bignum-perl libjson-perl
-lacme depends on OpenSSL and the following Perl modules:
+lacme depends on OpenSSL ≥1.1.0 and the following Perl modules:
- Config::Tiny
- Digest::SHA (core module)
- Date::Parse
- Errno (core module)
- Fcntl (core module)
+ - File::Basename (core module)
- File::Temp (core module)
- Getopt::Long (core module)
- JSON (optionally C/XS-accelerated with JSON::XS)
- - List::Util (core module)
- LWP::UserAgent
- LWP::Protocol::https (for https:// ACME directory URIs)
- MIME::Base64 (core module)
- Net::SSLeay
- POSIX (core module)
- - Types::Serialiser
- Socket (core module)
On Debian GNU/Linux systems, these dependencies can be installed with
@@ -50,11 +49,11 @@ the following command:
However Debian GNU/Linux users can also use gbp(1) from git-buildpackage
to build their own package:
- $ git checkout debian
+ $ git checkout debian/latest
$ AUTO_DEBSIGN=no gbp buildpackage
Alternatively, for the development version:
- $ git checkout debian
+ $ git checkout debian/latest
$ git merge master
$ AUTO_DEBSIGN=no gbp buildpackage --git-force-create --git-upstream-tree=BRANCH
diff --git a/Makefile b/Makefile
index a4098de..16ac04e 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,9 @@ DESTDIR ?= /usr/local
BUILDDIR ?= ./build
MANUAL_FILES = $(addprefix $(BUILDDIR)/,$(patsubst ./%.md,%,$(wildcard ./*.[1-9].md)))
-all: manual $(addprefix $(BUILDDIR)/,lacme lacme-accountd client webserver $(wildcard certs/* config/* snippets/*) certs/ca-certificates.crt)
+all: manual $(addprefix $(BUILDDIR)/,lacme lacme-accountd client webserver \
+ $(wildcard certs/* config/* snippets/*) \
+ certs/ca-certificates.crt certs-staging/ca-certificates.crt)
doc: manual
manual: $(MANUAL_FILES)
@@ -13,13 +15,20 @@ $(MANUAL_FILES): $(BUILDDIR)/%: $(BUILDDIR)/%.md
# used for validation, see https://letsencrypt.org/certificates/
$(BUILDDIR)/certs/ca-certificates.crt: \
- certs/letsencryptauthorityx[34].pem \
- certs/lets-encrypt-x[34]-cross-signed.pem \
+ certs/isrgrootx1.pem \
+ certs/isrg-root-x2.pem \
certs/lets-encrypt-r[34].pem \
- certs/lets-encrypt-r[34]-cross-signed.pem \
certs/lets-encrypt-e[12].pem
- mkdir -pv -- $(BUILDDIR)/certs
- cat $^ >$@
+ mkdir -pv -- $(dir $@)
+ cat -- $^ >$@
+
+# Staging Environment for tests, see https://letsencrypt.org/docs/staging-environment/
+$(BUILDDIR)/certs-staging/ca-certificates.crt: \
+ certs-staging/letsencrypt-stg-root-x[12].pem \
+ certs-staging/letsencrypt-stg-int-r[34].pem \
+ certs-staging/letsencrypt-stg-int-e[12].pem
+ mkdir -pv -- $(dir $@)
+ cat -- $^ >$@
prefix ?= $(DESTDIR)
exec_prefix ?= $(prefix)
@@ -29,12 +38,19 @@ libexecdir ?= $(exec_prefix)/libexec
datarootdir ?= $(prefix)/share
datadir ?= $(datarootdir)
sysconfdir ?= $(prefix)/etc
-localstatedir =? $(prefix)/var
+localstatedir ?= $(prefix)/var
runstatedir ?= $(localstatedir)/run
mandir ?= $(datarootdir)/man
man1dir ?= $(mandir)/man1
man8dir ?= $(mandir)/man8
+lacme_www_user ?= www-data
+lacme_www_group ?= www-data
+lacme_client_user ?= nobody
+lacme_client_group ?= nogroup
+
+acmeapi_server ?= https://acme-v02.api.letsencrypt.org/directory
+
$(BUILDDIR)/%: %
mkdir -pv -- $(dir $@)
cp --no-dereference --preserve=mode,links,xattr -vfT -- "$<" "$@"
@@ -42,25 +58,49 @@ $(BUILDDIR)/%: %
s#@@sbindir@@#$(sbindir)#g; \
s#@@libexecdir@@#$(libexecdir)#g; \
s#@@datadir@@#$(datadir)#g; \
+ s#@@localstatedir@@#$(localstatedir)#g; \
s#@@runstatedir@@#$(runstatedir)#g; \
- s#@@sysconfdir@@#$(sysconfdir)#g;" -- "$@"
+ s#@@sysconfdir@@#$(sysconfdir)#g; \
+ s#@@lacme_www_user@@#$(lacme_www_user)#g; \
+ s#@@lacme_www_group@@#$(lacme_www_group)#g; \
+ s#@@lacme_client_user@@#$(lacme_client_user)#g; \
+ s#@@lacme_client_group@@#$(lacme_client_group)#g; \
+ s#@@acmeapi_server@@#$(acmeapi_server)#g; \
+ " -- "$@"
+
+release:
+ @if ! git diff HEAD --quiet -- ./Changelog ./lacme ./lacme-accountd ./client; then \
+ echo "Dirty state, refusing to release!" >&2; \
+ exit 1; \
+ fi
+ VERS=$$(dpkg-parsechangelog -l Changelog -SVersion 2>/dev/null) && \
+ if git rev-parse -q --verify "refs/tags/v$$VERS" >/dev/null; then echo "tag exists" 2>/dev/null; exit 1; fi && \
+ sed -ri "0,/^( -- .*) .*/ s//\\1 $(shell date -R)/" ./Changelog && \
+ sed -ri "0,/^(our\\s+\\\$$VERSION\\s*=\\s*)'[0-9.]+'\\s*;/ s//\\1'$$VERS';/" \
+ -- ./lacme ./lacme-accountd ./client && \
+ git commit -m "Prepare new release v$$VERS." \
+ -- ./Changelog ./lacme ./lacme-accountd ./client && \
+ git tag -sm "Release version $$VERS" "v$$VERS"
install: all
- install -m0644 -vDt $(sysconfdir)/lacme $(BUILDDIR)/config/*.conf $(BUILDDIR)/snippets/*.conf
- install -vd $(sysconfdir)/lacme/lacme-certs.conf.d
- install -m0644 -vDt $(datadir)/lacme $(BUILDDIR)/certs/*
- install -m0755 -vDt $(libexecdir)/lacme $(BUILDDIR)/client $(BUILDDIR)/webserver
- install -m0644 -vDt $(man1dir) $(BUILDDIR)/lacme-accountd.1
- install -m0644 -vDt $(man8dir) $(BUILDDIR)/lacme.8
- install -m0644 -vDt $(bindir) $(BUILDDIR)/lacme-accountd
- install -m0644 -vDt $(sbindir) $(BUILDDIR)/lacme
+ install -m0644 -vDt $(sysconfdir)/lacme -- $(BUILDDIR)/config/*.conf $(BUILDDIR)/snippets/*.conf
+ install -m0755 -vd -- $(sysconfdir)/lacme/lacme-certs.conf.d
+ install -m0644 -vDt $(datadir)/lacme -- $(BUILDDIR)/certs/*
+ install -m0755 -vDt $(libexecdir)/lacme -- $(BUILDDIR)/client $(BUILDDIR)/webserver
+ install -m0644 -vDt $(man1dir) -- $(BUILDDIR)/lacme-accountd.1
+ install -m0644 -vDt $(man8dir) -- $(BUILDDIR)/lacme.8
+ install -m0755 -vDt $(sbindir) -- $(BUILDDIR)/lacme
+ install -m0755 -vDt $(bindir) -- $(BUILDDIR)/lacme-accountd
+ install -m0755 -vdD -- $(sysconfdir)/apache2/conf-available
+ ln -sv -- ../../lacme/apache2.conf $(sysconfdir)/apache2/conf-available/lacme.conf
uninstall:
rm -vf -- $(bindir)/lacme-accountd $(sbindir)/lacme
rm -vf -- $(man1dir)/lacme-accountd.1 $(man8dir)/lacme.8
rm -rvf -- $(sysconfdir)/lacme $(datadir)/lacme $(libexecdir)/lacme
+ rm -vf -- $(sysconfdir)/apache2/conf-available/lacme.conf
clean:
rm -rvf -- $(BUILDDIR)
-.PHONY: all doc manual install uninstall clean
+.PHONY: all doc manual release install uninstall clean
diff --git a/README b/README
index eb46cdd..97b4c86 100644
--- a/README
+++ b/README
@@ -59,6 +59,6 @@ even on a smartcard.
_______________________________________________________________________
-lacme is Copyright© 2016,2017 Guilhem Moulin ⟨guilhem@fripost.org⟩, and
+lacme is Copyright © 2015-2021 Guilhem Moulin ⟨guilhem@fripost.org⟩, and
licensed for use under the GNU General Public License version 3 or
later. See ‘COPYING’ for specific terms and distribution information.
diff --git a/certs-staging/letsencrypt-stg-int-e1.pem b/certs-staging/letsencrypt-stg-int-e1.pem
new file mode 100644
index 0000000..2d6290c
--- /dev/null
+++ b/certs-staging/letsencrypt-stg-int-e1.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDCzCCApGgAwIBAgIRALRY4992FVxZJKOJ3bpffWIwCgYIKoZIzj0EAwMwaDEL
+MAkGA1UEBhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0
+eSBSZXNlYXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Nj
+b2xpIFgyMB4XDTIwMDkwNDAwMDAwMFoXDTI1MDkxNTE2MDAwMFowVTELMAkGA1UE
+BhMCVVMxIDAeBgNVBAoTFyhTVEFHSU5HKSBMZXQncyBFbmNyeXB0MSQwIgYDVQQD
+ExsoU1RBR0lORykgRXJzYXR6IEVkYW1hbWUgRTEwdjAQBgcqhkjOPQIBBgUrgQQA
+IgNiAAT9v/PJUtHOTk28nXCXrpP665vI4Z094h8o7R+5E6yNajZa0UubqjpZFoGq
+u785/vGXj6mdfIzc9boITGusZCSWeMj5ySMZGZkS+VSvf8VQqj+3YdEu4PLZEjBA
+ivRFpEejggEQMIIBDDAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUH
+AwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFOv5JcKA
+KGbibQiSMvPC4a3D/zVFMB8GA1UdIwQYMBaAFN7Ro1lkDsGaNqNG7rAQdu+ul5Vm
+MDYGCCsGAQUFBwEBBCowKDAmBggrBgEFBQcwAoYaaHR0cDovL3N0Zy14Mi5pLmxl
+bmNyLm9yZy8wKwYDVR0fBCQwIjAgoB6gHIYaaHR0cDovL3N0Zy14Mi5jLmxlbmNy
+Lm9yZy8wIgYDVR0gBBswGTAIBgZngQwBAgEwDQYLKwYBBAGC3xMBAQEwCgYIKoZI
+zj0EAwMDaAAwZQIwXcZbdgxcGH9rTErfSTkXfBKKygU0yO7OpbuNeY1id0FZ/hRY
+N5fdLOGuc+aHfCsMAjEA0P/xwKr6NQ9MN7vrfGAzO397PApdqfM7VdFK18aEu1xm
+3HMFKzIR8eEPsMx4smMl
+-----END CERTIFICATE-----
diff --git a/certs-staging/letsencrypt-stg-int-e2.pem b/certs-staging/letsencrypt-stg-int-e2.pem
new file mode 100644
index 0000000..931ff9b
--- /dev/null
+++ b/certs-staging/letsencrypt-stg-int-e2.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDCjCCApCgAwIBAgIQQuJJzkyQeLKT5OSWP41qRTAKBggqhkjOPQQDAzBoMQsw
+CQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3VyaXR5
+IFJlc2VhcmNoIEdyb3VwMSQwIgYDVQQDExsoU1RBR0lORykgQm9ndXMgQnJvY2Nv
+bGkgWDIwHhcNMjAwOTA0MDAwMDAwWhcNMjUwOTE1MTYwMDAwWjBVMQswCQYDVQQG
+EwJVUzEgMB4GA1UEChMXKFNUQUdJTkcpIExldCdzIEVuY3J5cHQxJDAiBgNVBAMT
+GyhTVEFHSU5HKSBFcnNhdHogRWRhbWFtZSBFMjB2MBAGByqGSM49AgEGBSuBBAAi
+A2IABEpsxJnmT3EQu6hL6LeYyvVggZd1aOj6QepgX+mdhOYxCgAvb4etuL80y7EP
+sUUJh3Y20WhgXsZW21jukrL+PzdKfctcRoOM7CFBBk+09Ubalyys69O99+B6doRx
+GYWWp6OCARAwggEMMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggrBgEFBQcD
+AgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUbPiNRb5I
+GjoDUBH28pJdQ2OphogwHwYDVR0jBBgwFoAU3tGjWWQOwZo2o0busBB2766XlWYw
+NgYIKwYBBQUHAQEEKjAoMCYGCCsGAQUFBzAChhpodHRwOi8vc3RnLXgyLmkubGVu
+Y3Iub3JnLzArBgNVHR8EJDAiMCCgHqAchhpodHRwOi8vc3RnLXgyLmMubGVuY3Iu
+b3JnLzAiBgNVHSAEGzAZMAgGBmeBDAECATANBgsrBgEEAYLfEwEBATAKBggqhkjO
+PQQDAwNoADBlAjEAv19ESEwzY8fAt1WkE4Nkm6bJxQEJZwILGNnvPuEmAKlngKov
+dm1feBw0q45Fl8MEAjA24IoWYt7txJSbPQpxETJfsjO8aLWxedQpqHWS1x0zEB4L
+K5uFc99+L56DIgmqjKM=
+-----END CERTIFICATE-----
diff --git a/certs-staging/letsencrypt-stg-int-r3.pem b/certs-staging/letsencrypt-stg-int-r3.pem
new file mode 100644
index 0000000..0282fc1
--- /dev/null
+++ b/certs-staging/letsencrypt-stg-int-r3.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFWzCCA0OgAwIBAgIQTfQrldHumzpMLrM7jRBd1jANBgkqhkiG9w0BAQsFADBm
+MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy
+aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ
+ZWFyIFgxMB4XDTIwMDkwNDAwMDAwMFoXDTI1MDkxNTE2MDAwMFowWTELMAkGA1UE
+BhMCVVMxIDAeBgNVBAoTFyhTVEFHSU5HKSBMZXQncyBFbmNyeXB0MSgwJgYDVQQD
+Ex8oU1RBR0lORykgQXJ0aWZpY2lhbCBBcHJpY290IFIzMIIBIjANBgkqhkiG9w0B
+AQEFAAOCAQ8AMIIBCgKCAQEAu6TR8+74b46mOE1FUwBrvxzEYLck3iasmKrcQkb+
+gy/z9Jy7QNIAl0B9pVKp4YU76JwxF5DOZZhi7vK7SbCkK6FbHlyU5BiDYIxbbfvO
+L/jVGqdsSjNaJQTg3C3XrJja/HA4WCFEMVoT2wDZm8ABC1N+IQe7Q6FEqc8NwmTS
+nmmRQm4TQvr06DP+zgFK/MNubxWWDSbSKKTH5im5j2fZfg+j/tM1bGaczFWw8/lS
+nukyn5J2L+NJYnclzkXoh9nMFnyPmVbfyDPOc4Y25aTzVoeBKXa/cZ5MM+WddjdL
+biWvm19f1sYn1aRaAIrkppv7kkn83vcth8XCG39qC2ZvaQIDAQABo4IBEDCCAQww
+DgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAS
+BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTecnpI3zHDplDfn4Uj31c3S10u
+ZTAfBgNVHSMEGDAWgBS182Xy/rAKkh/7PH3zRKCsYyXDFDA2BggrBgEFBQcBAQQq
+MCgwJgYIKwYBBQUHMAKGGmh0dHA6Ly9zdGcteDEuaS5sZW5jci5vcmcvMCsGA1Ud
+HwQkMCIwIKAeoByGGmh0dHA6Ly9zdGcteDEuYy5sZW5jci5vcmcvMCIGA1UdIAQb
+MBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCN
+DLam9yN0EFxxn/3p+ruWO6n/9goCAM5PT6cC6fkjMs4uas6UGXJjr5j7PoTQf3C1
+vuxiIGRJC6qxV7yc6U0X+w0Mj85sHI5DnQVWN5+D1er7mp13JJA0xbAbHa3Rlczn
+y2Q82XKui8WHuWra0gb2KLpfboYj1Ghgkhr3gau83pC/WQ8HfkwcvSwhIYqTqxoZ
+Uq8HIf3M82qS9aKOZE0CEmSyR1zZqQxJUT7emOUapkUN9poJ9zGc+FgRZvdro0XB
+yphWXDaqMYph0DxW/10ig5j4xmmNDjCRmqIKsKoWA52wBTKKXK1na2ty/lW5dhtA
+xkz5rVZFd4sgS4J0O+zm6d5GRkWsNJ4knotGXl8vtS3X40KXeb3A5+/3p0qaD215
+Xq8oSNORfB2oI1kQuyEAJ5xvPTdfwRlyRG3lFYodrRg6poUBD/8fNTXMtzydpRgy
+zUQZh/18F6B/iW6cbiRN9r2Hkh05Om+q0/6w0DdZe+8YrNpfhSObr/1eVZbKGMIY
+qKmyZbBNu5ysENIK5MPc14mUeKmFjpN840VR5zunoU52lqpLDua/qIM8idk86xGW
+xx2ml43DO/Ya/tVZVok0mO0TUjzJIfPqyvr455IsIut4RlCR9Iq0EDTve2/ZwCuG
+hSjpTUFGSiQrR2JK2Evp+o6AETUkBCO1aw0PpQBPDQ==
+-----END CERTIFICATE-----
diff --git a/certs-staging/letsencrypt-stg-int-r4.pem b/certs-staging/letsencrypt-stg-int-r4.pem
new file mode 100644
index 0000000..7e482dc
--- /dev/null
+++ b/certs-staging/letsencrypt-stg-int-r4.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFWzCCA0OgAwIBAgIQaCYQ95QBw3BbcmLyhdXHzjANBgkqhkiG9w0BAQsFADBm
+MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy
+aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ
+ZWFyIFgxMB4XDTIwMDkwNDAwMDAwMFoXDTI1MDkxNTE2MDAwMFowWTELMAkGA1UE
+BhMCVVMxIDAeBgNVBAoTFyhTVEFHSU5HKSBMZXQncyBFbmNyeXB0MSgwJgYDVQQD
+Ex8oU1RBR0lORykgQXJ0aWZpY2lhbCBBcHJpY290IFI0MIIBIjANBgkqhkiG9w0B
+AQEFAAOCAQ8AMIIBCgKCAQEA6J8Tmlh6z62axF2+KzRgHHmxf0c5LHGA+wVx9ukJ
+nB9zkqdG+gyGfYBnwPlxny0JWBS/1/wu7ry+IhtYOqar1Rg+f+gD0+SqOYmngNW8
+IIw0WTjHhqYB2d2Fxsr9bPIpDwpHRbgE8HkozAKwrWs5xDthZlaMZfEyAKzdx8mC
+PttZzKW4ubSptmNMoGHx5t/pBWrNGz5EFuTYcy0DkknMvKedkVJn+jJBxVQ/ef/y
+Gep7+1WjpW/UQvwJ5H2sm6UtBRAfos5U2TubN7fiI9OGsRmIp73BP9TsyP0Mi1rZ
+kVAfMEU6pI5dJXTNt6hmzuE6frt+NlHaC9yrs9iEG1m6DQIDAQABo4IBEDCCAQww
+DgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAS
+BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSa7sAVo3Q7x+zxBWCmwpOn+U8a
+NTAfBgNVHSMEGDAWgBS182Xy/rAKkh/7PH3zRKCsYyXDFDA2BggrBgEFBQcBAQQq
+MCgwJgYIKwYBBQUHMAKGGmh0dHA6Ly9zdGcteDEuaS5sZW5jci5vcmcvMCsGA1Ud
+HwQkMCIwIKAeoByGGmh0dHA6Ly9zdGcteDEuYy5sZW5jci5vcmcvMCIGA1UdIAQb
+MBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCw
+o/xtzOLhliWC6F6XTdgCqt2FrjLQAXRUnCwnwg44oVbD+Gh9qXREpdpRPVrlIS7T
+tobw8Z329nsLZ+wiGvm3wkC3Ka3RWbThLE/MuwVMysaTi5jXLJcQLvHRlW00jHgM
+/V8vtJPr5s443KuOX1TFUV5Z/0ZilrBY3sAbgVk2n/fgyonX/JCdiGXt3HODpBh3
+eJH1kKM/EIrVVZwxHCwG1x/LuKpcUpbelF+NDI11neL+AzhU6wmBhgplg4OxzYWD
+Xg0LCi6W0/t73HnG8SaRiCAcPyJOGBZtLQLUgfVKSd6DtRMhVcTzM6EjFiJYZuv4
+JHj65p1yFh8+kgJL/kyIWp2+mHgj/QCDaQEJccmWl+dpXm0jyyNBbjKG7oOlCzvH
+HEBmrYSlzWI4XcK5C2+1SmSQqXv7vPo8jEVXSftg4Z6mEA8e2S6dt/rvreiQ+fDm
+gixNiQRd7lkUqPv1EZhiGMYxhW52taj9A3xlcnD9/tfD7BKFe38ilVblYTU423WY
+zAslpz6oDEiftKCZ2VmaUzEsS6Hma/r/SkF5oorVlaz7hE1qcu9HLkRohXPJtw3k
+XJcK1hT3rITPasaSTREfwKBQS+y6guxv+IpkZftMEiOUix2cRoICJesxsYDE5tei
+Pwrakf/zlLfF4WDZVqrYKsddVqddAoa64LfXxS/B4Q==
+-----END CERTIFICATE-----
diff --git a/certs-staging/letsencrypt-stg-root-x1.pem b/certs-staging/letsencrypt-stg-root-x1.pem
new file mode 100644
index 0000000..37655b2
--- /dev/null
+++ b/certs-staging/letsencrypt-stg-root-x1.pem
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFmDCCA4CgAwIBAgIQU9C87nMpOIFKYpfvOHFHFDANBgkqhkiG9w0BAQsFADBm
+MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy
+aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ
+ZWFyIFgxMB4XDTE1MDYwNDExMDQzOFoXDTM1MDYwNDExMDQzOFowZjELMAkGA1UE
+BhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0eSBSZXNl
+YXJjaCBHcm91cDEiMCAGA1UEAxMZKFNUQUdJTkcpIFByZXRlbmQgUGVhciBYMTCC
+AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALbagEdDTa1QgGBWSYkyMhsc
+ZXENOBaVRTMX1hceJENgsL0Ma49D3MilI4KS38mtkmdF6cPWnL++fgehT0FbRHZg
+jOEr8UAN4jH6omjrbTD++VZneTsMVaGamQmDdFl5g1gYaigkkmx8OiCO68a4QXg4
+wSyn6iDipKP8utsE+x1E28SA75HOYqpdrk4HGxuULvlr03wZGTIf/oRt2/c+dYmD
+oaJhge+GOrLAEQByO7+8+vzOwpNAPEx6LW+crEEZ7eBXih6VP19sTGy3yfqK5tPt
+TdXXCOQMKAp+gCj/VByhmIr+0iNDC540gtvV303WpcbwnkkLYC0Ft2cYUyHtkstO
+fRcRO+K2cZozoSwVPyB8/J9RpcRK3jgnX9lujfwA/pAbP0J2UPQFxmWFRQnFjaq6
+rkqbNEBgLy+kFL1NEsRbvFbKrRi5bYy2lNms2NJPZvdNQbT/2dBZKmJqxHkxCuOQ
+FjhJQNeO+Njm1Z1iATS/3rts2yZlqXKsxQUzN6vNbD8KnXRMEeOXUYvbV4lqfCf8
+mS14WEbSiMy87GB5S9ucSV1XUrlTG5UGcMSZOBcEUpisRPEmQWUOTWIoDQ5FOia/
+GI+Ki523r2ruEmbmG37EBSBXdxIdndqrjy+QVAmCebyDx9eVEGOIpn26bW5LKeru
+mJxa/CFBaKi4bRvmdJRLAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB
+Af8EBTADAQH/MB0GA1UdDgQWBBS182Xy/rAKkh/7PH3zRKCsYyXDFDANBgkqhkiG
+9w0BAQsFAAOCAgEAncDZNytDbrrVe68UT6py1lfF2h6Tm2p8ro42i87WWyP2LK8Y
+nLHC0hvNfWeWmjZQYBQfGC5c7aQRezak+tHLdmrNKHkn5kn+9E9LCjCaEsyIIn2j
+qdHlAkepu/C3KnNtVx5tW07e5bvIjJScwkCDbP3akWQixPpRFAsnP+ULx7k0aO1x
+qAeaAhQ2rgo1F58hcflgqKTXnpPM02intVfiVVkX5GXpJjK5EoQtLceyGOrkxlM/
+sTPq4UrnypmsqSagWV3HcUlYtDinc+nukFk6eR4XkzXBbwKajl0YjztfrCIHOn5Q
+CJL6TERVDbM/aAPly8kJ1sWGLuvvWYzMYgLzDul//rUF10gEMWaXVZV51KpS9DY/
+5CunuvCXmEQJHo7kGcViT7sETn6Jz9KOhvYcXkJ7po6d93A/jy4GKPIPnsKKNEmR
+xUuXY4xRdh45tMJnLTUDdC9FIU0flTeO9/vNpVA8OPU1i14vCz+MU8KX1bV3GXm/
+fxlB7VBBjX9v5oUep0o/j68R/iDlCOM4VVfRa8gX6T2FU7fNdatvGro7uQzIvWof
+gN9WUwCbEMBy/YhBSrXycKA8crgGg3x1mIsopn88JKwmMBa68oS7EHM9w7C4y71M
+7DiA+/9Qdp9RBWJpTS9i/mDnJg1xvo8Xz49mrrgfmcAXTCJqXi24NatI3Oc=
+-----END CERTIFICATE-----
diff --git a/certs-staging/letsencrypt-stg-root-x2.pem b/certs-staging/letsencrypt-stg-root-x2.pem
new file mode 100644
index 0000000..f627e1d
--- /dev/null
+++ b/certs-staging/letsencrypt-stg-root-x2.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICTjCCAdSgAwIBAgIRAIPgc3k5LlLVLtUUvs4K/QcwCgYIKoZIzj0EAwMwaDEL
+MAkGA1UEBhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0
+eSBSZXNlYXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Nj
+b2xpIFgyMB4XDTIwMDkwNDAwMDAwMFoXDTQwMDkxNzE2MDAwMFowaDELMAkGA1UE
+BhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0eSBSZXNl
+YXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Njb2xpIFgy
+MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOvS+w1kCzAxYOJbA06Aw0HFP2tLBLKPo
+FQqR9AMskl1nC2975eQqycR+ACvYelA8rfwFXObMHYXJ23XLB+dAjPJVOJ2OcsjT
+VqO4dcDWu+rQ2VILdnJRYypnV1MMThVxo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD
+VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU3tGjWWQOwZo2o0busBB2766XlWYwCgYI
+KoZIzj0EAwMDaAAwZQIwRcp4ZKBsq9XkUuN8wfX+GEbY1N5nmCRc8e80kUkuAefo
+uc2j3cICeXo1cOybQ1iWAjEA3Ooawl8eQyR4wrjCofUE8h44p0j7Yl/kBlJZT8+9
+vbtH7QiVzeKCOTQPINyRql6P
+-----END CERTIFICATE-----
diff --git a/certs/isrg-root-x2.pem b/certs/isrg-root-x2.pem
new file mode 100644
index 0000000..7d903ed
--- /dev/null
+++ b/certs/isrg-root-x2.pem
@@ -0,0 +1,14 @@
+-----BEGIN CERTIFICATE-----
+MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
+CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
+R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
+MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
+ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
+EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
++1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
+ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
+AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
+zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
+tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
+/q4AaOeMSQ+2b1tbFfLn
+-----END CERTIFICATE-----
diff --git a/certs/isrgrootx1.pem b/certs/isrgrootx1.pem
new file mode 100644
index 0000000..b85c803
--- /dev/null
+++ b/certs/isrgrootx1.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
+TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
+cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
+WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
+ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
+MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
+h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
+A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
+T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
+B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
+B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
+KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
+OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
+jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
+qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
+rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
+HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
+hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
+ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
+3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
+NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
+ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
+TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
+jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
+oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
+4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
+mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
+emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
+-----END CERTIFICATE-----
diff --git a/certs/letsencryptauthorityx1.pem b/certs/letsencryptauthorityx1.pem
new file mode 100644
index 0000000..0a9a3ce
--- /dev/null
+++ b/certs/letsencryptauthorityx1.pem
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFjTCCA3WgAwIBAgIRAOeTkL6SBwNJGF95dYHlyoMwDQYJKoZIhvcNAQELBQAw
+TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
+cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTIwMDIw
+WhcNMjAwNjA0MTIwMDIwWjBKMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
+RW5jcnlwdDEjMCEGA1UEAxMaTGV0J3MgRW5jcnlwdCBBdXRob3JpdHkgWDEwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCc0wzwWuUuR7dyXTeDs2hjMOrX
+NSYZJeG9vjXxcJIvt7hLQQWrqZ41CFjssSrEaIcLo+N15Obzp2JxunmBYB/XkZqf
+89B4Z3HIaQ6Vkc/+5pnpYDxIzH7KTXcSJJ1HG1rrueweNwAcnKx7pwXqzkrrvUHl
+Npi5y/1tPJZo3yMqQpAMhnRnyH+lmrhSYRQTP2XpgofL2/oOVvaGifOFP5eGr7Dc
+Gu9rDZUWfcQroGWymQQ2dYBrrErzG5BJeC+ilk8qICUpBMZ0wNAxzY8xOJUWuqgz
+uEPxsR/DMH+ieTETPS02+OP88jNquTkxxa/EjQ0dZBYzqvqEKbbUC8DYfcOTAgMB
+AAGjggFnMIIBYzAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADBU
+BgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEBATAwMC4GCCsGAQUFBwIB
+FiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQub3JnMB0GA1UdDgQWBBSo
+SmpjBH3duubRObemRWXv86jsoTAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vY3Js
+LnJvb3QteDEubGV0c2VuY3J5cHQub3JnMHIGCCsGAQUFBwEBBGYwZDAwBggrBgEF
+BQcwAYYkaHR0cDovL29jc3Aucm9vdC14MS5sZXRzZW5jcnlwdC5vcmcvMDAGCCsG
+AQUFBzAChiRodHRwOi8vY2VydC5yb290LXgxLmxldHNlbmNyeXB0Lm9yZy8wHwYD
+VR0jBBgwFoAUebRZ5nu25eQBc4AIiMgaWPbpm24wDQYJKoZIhvcNAQELBQADggIB
+AGvM/XGv8yafGRGMPP6hnggoI9DGWGf4l0mzjBhuCkDVqoG/7rsH1ytzteePxiA3
+7kqSBo0fXu5GmbWOw09GpwPYyAAY0iWOMU6ybrTJHS466Urzoe/4IwLQoQc219EK
+lh+4Ugu1q4KxNY1qMDA/1YX2Qm9M6AcAs1UvZKHSpJQAbsYrbN6obNoUGOeG6ONH
+Yr8KRQz5FMfZYcA49fmdDTwKn/pyLOkJFeA/dm/oP99UmKCFoeOa5w9YJr2Vi7ic
+Xd59CU8mprWhxFXnma1oU3T8ZNovjib3UHocjlEJfNbDy9zgKTYURcMVweo1dkbH
+NbLc5mIjIk/kJ+RPD+chR+gJjy3Gh9xMNkDrZQKfsIO93hxTsZMmgZQ4c+vujC1M
+jSak+Ai87YZeYQPh1fCGMSTno5III37DUCtIn8BJxJixuPeOMKsjLLD5AtMVy0fp
+d19lcUek4bjDY8/Ujb5/wfn2+Kk7z72SxWdekjtHOWBmKxqq8jDuuMw4ymg1g5n7
+R7TZ/Y3y4bTpWUDkBHFo03xNM21wBFDIrCZZeVhvDW4MtT6+Ass2bcpoHwYcGol2
+gaLDa5k2dkG41OGtXa0fY+TjdryY4cOcstJUKjv2MJku4yaTtjjECX1rJvFLnqYe
+wC+FmxjgWPuyRNuLDAWK30mmpcJZ3CmD6dFtAi4h7H37
+-----END CERTIFICATE-----
diff --git a/certs/letsencryptauthorityx2.pem b/certs/letsencryptauthorityx2.pem
new file mode 100644
index 0000000..3a8e77c
--- /dev/null
+++ b/certs/letsencryptauthorityx2.pem
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFjTCCA3WgAwIBAgIRAJY2TKc4C+SL3JDGzeC33mgwDQYJKoZIhvcNAQELBQAw
+TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
+cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTIwMDMx
+WhcNMjAwNjA0MTIwMDMxWjBKMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
+RW5jcnlwdDEjMCEGA1UEAxMaTGV0J3MgRW5jcnlwdCBBdXRob3JpdHkgWDIwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDhJHRCe7eRMdlz/ziq2M5EXLc5
+CtxErg29RbmXN2evvVBPX9MQVGv3QdqOY+ZtW8DoQKmMQfzRA4n/YmEJYNYHBXia
+kL0aZD5P3M93L4lry2evQU3FjQDAa/6NhNy18pUxqOj2kKBDSpN0XLM+Q2lLiSJH
+dFE+mWTDzSQB+YQvKHcXIqfdw2wITGYvN3TFb5OOsEY3FmHRUJjIsA9PWFN8rPba
+LZZhUK1D3AqmT561Urmcju9O30azMdwg/GnCoyB1Puw4GzZOZmbS3/VmpJMve6YO
+lD5gPUpLHG+6tE0cPJFYbi9NxNpw2+0BOXbASefpNbUUBpDB5ZLiEP1rubSFAgMB
+AAGjggFnMIIBYzAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADBU
+BgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEBATAwMC4GCCsGAQUFBwIB
+FiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQub3JnMB0GA1UdDgQWBBTF
+satOTLHNZDCTfsGEmQWr5gPiJTAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vY3Js
+LnJvb3QteDEubGV0c2VuY3J5cHQub3JnMHIGCCsGAQUFBwEBBGYwZDAwBggrBgEF
+BQcwAYYkaHR0cDovL29jc3Aucm9vdC14MS5sZXRzZW5jcnlwdC5vcmcvMDAGCCsG
+AQUFBzAChiRodHRwOi8vY2VydC5yb290LXgxLmxldHNlbmNyeXB0Lm9yZy8wHwYD
+VR0jBBgwFoAUebRZ5nu25eQBc4AIiMgaWPbpm24wDQYJKoZIhvcNAQELBQADggIB
+AA4eqMjSEJKCF6XRR5pEutkS/e7xgy2vCYYbw1ospQiGQ4FO5TtbvO+5K4v7WR3b
+1peMQ03rX0Dr+ylmGNypZahNxTqDiO0X2sHBwJWj/k61+MYq3bRYxKwI6cduTDXb
+YQxilGTDNGZUIFKKIloz4zGAl68sj+8pLg534EqKgl8+rWSxclToS1KrydJezokE
+dQRXfxu79iscWA3PIj1vbaUBB16lnWJxA3LhTGhUrhZrCnFuOZ93KO8kCKPM7EVo
+7c4FCYKI8eWDsf0FF49A4xMUmxPJAPIyZkwQ8KkjpzcTHOmT4CEXUhNu9eMI9qBK
+VSFDDMifJ8HzCaVLyMvY1Kf7iR+840EkX1EGC+Z39EaK1hjm314LYpLoYGvYYLJO
+/J76XAx8ZgpofqHz1gAEfiMLMLxLQkOjKLXqoUEd5KdnzaO3aLH91gnasy8aD4D5
+9RfEO2xcaozD2rbYsoAMVzcZZHw0Smdmobaz2YazMBjFRcqGntg6s5Xqwusaleiy
+snjMCC/9mvIPqGyuVnBPTBaUDFDEhX6qD2MX4dzODL91Z0ogYDWcFLN+uLnZKHje
+4JoNuzkJ2FXWOREcsW93KXb+3T8COjhTDKvK4H6ufdrZxxusx60ajJAMBzW0XTf5
+nm2yGEDtyVoMgJLp0rkiPlormgHxSkFDOJbY94J7yxRK
+-----END CERTIFICATE-----
diff --git a/client b/client
index bacd4d6..fdef865 100755
--- a/client
+++ b/client
@@ -2,7 +2,7 @@
#----------------------------------------------------------------------
# ACME client written with process isolation and minimal privileges in mind
-# Copyright © 2015-2017 Guilhem Moulin <guilhem@fripost.org>
+# Copyright © 2015-2021 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
@@ -43,16 +43,17 @@ use warnings;
# instance own by another user and created with umask 0177) is not a
# problem since SOCKET_FD can be bound as root prior to the execve(2).
+our $VERSION = '0.8.0';
my $PROTOCOL_VERSION = 1;
+my $NAME = 'lacme-client';
use Errno 'EEXIST';
use Fcntl qw/O_CREAT O_EXCL O_WRONLY/;
-use Digest::SHA qw/sha256 sha256_hex/;
+use Digest::SHA 'sha256';
use MIME::Base64 qw/encode_base64 encode_base64url/;
use Date::Parse ();
use LWP::UserAgent ();
-use Types::Serialiser ();
use JSON ();
use Config::Tiny ();
@@ -69,21 +70,34 @@ open (my $CONFFILE, '<&=', $1+0) or die "fdopen $1: $!";
(shift @ARGV // die) =~ /\A(\d+)\z/ or die;
open (my $S, '+<&=', $1+0) or die "fdopen $1: $!";
+# JSON keys need to be sorted lexicographically (for instance in the thumbprint)
+sub json() { JSON::->new->utf8->canonical(); }
+
#############################################################################
# Read the protocol version and JSON Web Key (RFC 7517) from the
# lacme-accountd socket
#
-die "Error: Invalid client version\n" unless
- $S->getline() =~ /\A(\d+) OK(?:.*)\r\n\z/ and $1 == $PROTOCOL_VERSION;
-my $JWK = JSON::->new->decode($S->getline());
-my $KID;
-
-# JSON keys need to be sorted lexicographically (for instance in the thumbprint)
-sub json() { JSON::->new->utf8->canonical(); }
-my $JWK_thumbprint = encode_base64url(sha256(json()->encode($JWK)));
-my $NONCE;
+my ($JWK, $JWK_thumbprint, $ALG, $KID);
+do {
+ my $greeting = $S->getline();
+ die "Error: Invalid client version\n" unless defined $greeting and
+ $greeting =~ /\A(\d+) OK(?: (.*))?\r\n\z/ and $1 == $PROTOCOL_VERSION;
+ if (defined (my $extra = $2)) {
+ my $h = eval { JSON::->new->decode($extra) };
+ if ($@ or !defined $h) {
+ 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, $KID) = @$h{qw/jwk-thumbprint alg kid/};
+ }
+ }
+ my $jwk_str = $S->getline() // die "ERROR: No JWK from lacme-accountd\n";
+ $JWK = JSON::->new->decode($jwk_str);
+ $JWK_thumbprint //= encode_base64url(sha256(json()->encode($JWK))); # SHA-256 is hardcoded, see RFC 8555 sec. 8.1
+ $ALG //= "RS256";
+};
#############################################################################
@@ -93,7 +107,7 @@ my $NONCE;
my $CONFIG = do {
my $conf = do { local $/ = undef; <$CONFFILE> };
- close $CONFFILE or die "Can't close: $!";
+ close $CONFFILE or die "close: $!";
my $h = Config::Tiny::->read_string($conf) or die Config::Tiny::->errstr()."\n";
$h->{_} //= {};
$h->{client}->{$_} //= $h->{_}->{$_} foreach keys %{$h->{_}}; # add defaults
@@ -104,9 +118,10 @@ my $UA = do {
my $verify = lc (delete $args{SSL_verify} // 'Yes') eq 'no' ? 0 : 1;
my %ssl_opts = ( verify_hostname => $verify );
$ssl_opts{$_} = $args{$_} foreach grep /^SSL_/, keys %args;
- LWP::UserAgent::->new( ssl_opts => \%ssl_opts );
+ LWP::UserAgent::->new( agent => "$NAME/$VERSION", ssl_opts => \%ssl_opts );
} // die "Can't create LWP::UserAgent object";
$UA->default_header( 'Accept-Language' => 'en' );
+my $NONCE;
#############################################################################
@@ -179,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 => 'RS256', 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");
- my $sig = $S->getline();
+ $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
});
}
-my $SERVER_URI = $CONFIG->{server} // 'https://acme-v02.api.letsencrypt.org/directory';
+# 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)) };
@@ -220,13 +246,24 @@ 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;
- my $r = acme_resource('newAccount', onlyReturnExisting => Types::Serialiser::true );
+ return if defined $KID; # already set
+ my $r = acme_resource('newAccount', onlyReturnExisting => JSON::true );
if ($r->is_success()) {
$KID = $r->header('Location');
} elsif ($die) {
@@ -242,9 +279,9 @@ if ($COMMAND eq 'account') {
my $flags = shift @ARGV;
my %h = ( contact => \@ARGV ) if @ARGV;
- $h{onlyReturnExisting} = Types::Serialiser::true unless $flags & 0x01;
- $h{termsOfServiceAgreed} = Types::Serialiser::true if $flags & 0x02;
- $h{status} = "deactivated" if $flags & 0x04;
+ $h{onlyReturnExisting} = JSON::true unless $flags & 0x01;
+ $h{termsOfServiceAgreed} = JSON::true if $flags & 0x02;
+ $h{status} = "deactivated" if $flags & 0x04;
print STDERR "Requesting new registration ".(@ARGV ? ("for ".join(', ', @ARGV)) : "")."\n"
if $flags & 0x01;
@@ -252,8 +289,12 @@ if ($COMMAND eq 'account') {
my $r = acme_resource('newAccount', %h);
# TODO: list account orders: https://github.com/letsencrypt/boulder/issues/3335
+ print STDERR "Terms of Service: $RES{meta}->{termsOfService}\n"
+ if defined $RES{meta} and defined $RES{meta}->{termsOfService};
+
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';
@@ -272,7 +313,7 @@ if ($COMMAND eq 'account') {
#
elsif ($COMMAND eq 'newOrder') {
die unless @ARGV;
- my $timeout = $CONFIG->{timeout} // 10;
+ my $timeout = $CONFIG->{timeout} // 30;
my $csr = do { local $/ = undef; <STDIN> };
set_kid();
@@ -299,11 +340,11 @@ elsif ($COMMAND eq 'newOrder') {
# serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}
if (sysopen(my $fh, $challenge->{token}, O_CREAT|O_EXCL|O_WRONLY, 0644)) {
$fh->print($keyAuthorization);
- $fh->close() or die "Can't close: $!";
+ $fh->close() or die "close: $!";
} elsif ($! == EEXIST) {
print STDERR "WARNING: File exists: $challenge->{token}\n";
} else {
- die "Can't open $challenge->{token}: $!";
+ die "open($challenge->{token}): $!";
}
my $r = acme($challenge->{url}, {});
request_json_decode($r);
@@ -368,15 +409,18 @@ elsif ($COMMAND eq 'newOrder') {
#############################################################################
# revokeCert
# The certificate to revoke is passed (in DER format) to STDIN; this
-# is required since the ACME client might not have read access to the
+# is needed since the ACME client might not have read access to the
# X.509 file
#
elsif ($COMMAND eq 'revokeCert') {
die if @ARGV;
my $der = do { local $/ = undef; <STDIN> };
- close STDIN or die "Can't close: $!";
+ 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 94d2556..5c769cf 100644
--- a/config/lacme-accountd.conf
+++ b/config/lacme-accountd.conf
@@ -1,11 +1,11 @@
# 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)
+# - file:FILE, for a private key in PEM format (optionally encrypted)
+# - gpg:FILE, for a gpg-encrypted private key
#
-#privkey = gpg:/path/to/encrypted/account.key.gpg
#privkey = file:/path/to/account.key
+#privkey = gpg:/path/to/encrypted/account.key.gpg
# For a gpg-encrypted private account key, "gpg" specifies the binary
# gpg(1) to use, as well as some default options. Default: "gpg
@@ -17,10 +17,18 @@
# for signature requests from the ACME client. An error is raised if
# the path exists or if its parent directory is writable by other
# users.
-# Default: "$XDG_RUNTIME_DIR/S.lacme" if the XDG_RUNTIME_DIR
-# environment variable is set.
#
-#socket = /run/user/1000/S.lacme
+#socket = %t/S.lacme
+
+# An optional file where to log to.
+#
+#logfile =
+
+# 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".
#
diff --git a/config/lacme-certs.conf b/config/lacme-certs.conf
index 232c85b..5259690 100644
--- a/config/lacme-certs.conf
+++ b/config/lacme-certs.conf
@@ -1,41 +1,46 @@
# Each non-default section refer to separate certificate issuance
-# requests. Options in the default section apply to each sections.
+# requests. Settings in the default section apply to each sections.
-# Message digest to sign the Certificate Signing Request with.
+# Message digest to sign the Certificate Signing Request with,
+# overriding the req(1ssl) default.
#
-#hash = sha512
+#hash =
-# Comma-separated list of Key Usages, see x509v3_config(5ssl).
+# Comma-separated list of Key Usages, for instance "digitalSignature,
+# keyEncipherment", to include in the Certificate Signing Request.
+# See x509v3_config(5ssl) for a list of possible values. Note that the
+# ACME might override the value provided here.
#
-#keyUsage = digitalSignature, keyEncipherment
+#keyUsage =
+
+# Path to the bundle of trusted issuer certificates. This is used for
+# validating each certificate after issuance or renewal. Specifying an
+# empty value skips certificate validation.
+#
+#CAfile = @@datadir@@/lacme/ca-certificates.crt
+
+# For an existing certificate, the minimum number of days before its
+# expiration date the section is considered for re-issuance.
+#
+#min-days = 21
#[www]
-# Path the service's private key. This option is required.
+# Path the service's private key. This setting is required.
#
#certificate-key = /etc/nginx/ssl/srv.key
# Where to store the issued certificate (in PEM format).
#
-#certificate = /etc/nginx/ssl/srv.pem
+#certificate = /etc/nginx/ssl/srv.crt
# Where to store the issued certificate along with its chain of trust
# (in PEM format).
#
-#certificate-chain = /etc/nginx/ssl/srv.chain.pem
-
-# For an existing certificate, the minimum number of days before its
-# expiration date the section is considered for re-issuance.
-#
-#min-days = 21
-
-# Path to trusted issuer certificates, used for validating each issued
-# certificate. Specifying an empty value skips certificate validation.
-#
-#CAfile = @@datadir@@/lacme/ca-certificates.crt
+#certificate-chain = /etc/nginx/ssl/srv.chain.crt
-# Subject field of the Certificate Signing Request. This option is
+# Subject field of the Certificate Signing Request. This setting is
# required.
#
#subject = /CN=example.org
@@ -61,7 +66,7 @@
#[smtp]
#certificate-key = /etc/postfix/ssl/srv.key
-#certificate-chain = /etc/postfix/ssl/srv.pem
+#certificate-chain = /etc/postfix/ssl/srv.crt
#subject = /CN=smtp.example.org
#notify = /bin/systemctl reload postfix
diff --git a/config/lacme.conf b/config/lacme.conf
index 9f4db72..0392be5 100644
--- a/config/lacme.conf
+++ b/config/lacme.conf
@@ -10,26 +10,24 @@
# UNIX-domain socket to connect to for signature requests from the ACME
# client. lacme(8) aborts if the socket is readable or writable by
# other users, or if its parent directory is writable by other users.
-# Default: "$XDG_RUNTIME_DIR/S.lacme" if the XDG_RUNTIME_DIR environment
-# variable is set.
-# This option is ignored when lacme-accountd(1) is spawned by lacme(8),
+# This setting is ignored when lacme-accountd(1) is spawned by lacme(8),
# since the two processes communicate through a socket pair. See the
# "accountd" section below for details.
#
-#socket =
+#socket = %t/S.lacme
# username to drop privileges to (setting both effective and real uid).
-# Preserve root privileges if the value is empty (not recommended).
+# Skip privilege drop if the value is empty (not recommended).
#
-#user = nobody
+#user = @@lacme_client_user@@
# groupname to drop privileges to (setting both effective and real gid,
# and also setting the list of supplementary gids to that single group).
-# Preserve root privileges if the value is empty (not recommended).
+# Skip privilege drop if the value is empty (not recommended).
#
-#group = nogroup
+#group = @@lacme_client_group@@
-# Path to the ACME client executable.
+# ACME client command.
#
#command = @@libexecdir@@/lacme/client
@@ -37,12 +35,12 @@
# <https://acme-staging-v02.api.letsencrypt.org/directory> for testing
# as it has relaxed rate-limiting.
#
-#server = https://acme-v02.api.letsencrypt.org/directory
+#server = @@acmeapi_server@@
# Timeout in seconds after which the client stops polling the ACME
# server and considers the request failed.
#
-#timeout = 10
+#timeout = 30
# Whether to verify the server certificate chain.
#
@@ -64,25 +62,26 @@
#
#listen = @@runstatedir@@/lacme-www.socket
-# Non-existent directory under which an external HTTP daemon is
-# configured to serve GET requests for challenge files under
-# "/.well-known/acme-challenge/" (for each virtual host requiring
-# authorization) as static files.
+# Directory under which an external HTTP daemon is configured to serve
+# GET requests for challenge files under "/.well-known/acme-challenge/"
+# (for each virtual host requiring authorization) as static files.
+# NOTE: the directory must exist and be writable by the lacme client
+# user.
#
#challenge-directory =
# username to drop privileges to (setting both effective and real uid).
-# Preserve root privileges if the value is empty (not recommended).
+# Skip privilege drop if the value is empty (not recommended).
#
-#user = www-data
+#user = @@lacme_www_user@@
# groupname to drop privileges to (setting both effective and real gid,
# and also setting the list of supplementary gids to that single group).
-# Preserve root privileges if the value is empty (not recommended).
+# Skip privilege drop if the value is empty (not recommended).
#
-#group = www-data
+#group = @@lacme_www_group@@
-# Path to the ACME webserver executable.
+# ACME webserver command.
#
#command = @@libexecdir@@/lacme/webserver
@@ -99,28 +98,23 @@
# an existing lacme-accountd(1) process via a UNIX-domain socket.
# username to drop privileges to (setting both effective and real uid).
-# Preserve root privileges if the value is empty.
+# Skip privilege drop if the value is empty.
#
#user =
# groupname to drop privileges to (setting both effective and real gid,
# and also setting the list of supplementary gids to that single group).
-# Preserve root privileges if the value is empty.
+# Skip privilege drop if the value is empty.
#
#group =
-# Path to the lacme-accountd(1) executable.
+# lacme-accountd(1) command.
#
#command = @@bindir@@/lacme-accountd
# Path to the lacme-accountd(1) configuration file.
#
-#config = @@sysconfdir@@/lacme/lacme-accountd.conf
-
-# The (private) account key to use for signing requests. See
-# lacme-accountd(1) for details.
-#
-#privkey = file:/path/to/account.key
+#config =
# Be quiet.
#
diff --git a/lacme b/lacme
index 07ebb45..731535f 100755
--- a/lacme
+++ b/lacme
@@ -2,7 +2,7 @@
#----------------------------------------------------------------------
# ACME client written with process isolation and minimal privileges in mind
-# Copyright © 2016-2017 Guilhem Moulin <guilhem@fripost.org>
+# Copyright © 2015-2021 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
@@ -22,14 +22,14 @@ use v5.14.2;
use strict;
use warnings;
-our $VERSION = '0.3';
+our $VERSION = '0.8.0';
my $NAME = 'lacme';
use Errno 'EINTR';
-use Fcntl qw/F_GETFD F_SETFD FD_CLOEXEC SEEK_SET/;
+use Fcntl qw/F_GETFD F_SETFD FD_CLOEXEC O_CREAT O_EXCL O_WRONLY SEEK_SET/;
+use File::Basename 'dirname';
use File::Temp ();
use Getopt::Long qw/:config posix_default no_ignore_case gnu_getopt auto_version/;
-use List::Util 'first';
use POSIX ();
use Socket 1.95 qw/AF_UNIX AF_INET AF_INET6 PF_UNIX PF_INET PF_INET6 PF_UNSPEC
INADDR_ANY IN6ADDR_ANY IPPROTO_IPV6
@@ -63,7 +63,11 @@ sub usage(;$$) {
}
exit $rv;
}
-usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s register tos-agreed deactivate min-days=i quiet|q debug help|h/);
+usage(1) unless GetOptions(\%OPTS, qw/config=s config-certs=s@ socket=s
+ register tos-agreed deactivate
+ min-days=i force
+ quiet|q
+ debug help|h/);
usage(0) if $OPTS{help};
$COMMAND = shift(@ARGV) // usage(1, "Missing command");
@@ -71,14 +75,33 @@ $COMMAND = $COMMAND =~ /\A(account|newOrder|new-cert|revokeCert|revoke-cert)\z/
: usage(1, "Invalid command: $COMMAND"); # validate and untaint $COMMAND
@ARGV = map { /\A(\p{Print}*)\z/ ? $1 : die } @ARGV; # untaint @ARGV
+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;
+}
+
sub set_FD_CLOEXEC($$);
-my $CONFFILENAME = $OPTS{config} // first { -f $_ }
- ( "./$NAME.conf"
- , ($ENV{XDG_CONFIG_HOME} // "$ENV{HOME}/.config")."/lacme/$NAME.conf"
- , "@@sysconfdir@@/lacme/$NAME.conf"
- );
+my $CONFFILENAME = spec_expand($OPTS{config} // "%E/lacme/$NAME.conf");
do {
- die "Error: Can't find configuration file\n" unless defined $CONFFILENAME;
print STDERR "Using configuration file: $CONFFILENAME\n" if $OPTS{debug};
open $CONFFILE, '<', $CONFFILENAME or die "Can't open $CONFFILENAME: $!\n";
my $conf = do { local $/ = undef; <$CONFFILE> };
@@ -90,9 +113,9 @@ do {
my $accountd = defined $OPTS{socket} ? 0 : exists $h->{accountd} ? 1 : 0;
my %valid = (
client => {
- socket => (defined $ENV{XDG_RUNTIME_DIR} ? "$ENV{XDG_RUNTIME_DIR}/S.lacme" : undef),
- user => 'nobody',
- group => 'nogroup',
+ socket => '%t/S.lacme',
+ user => '@@lacme_client_user@@',
+ group => '@@lacme_client_group@@',
command => '@@libexecdir@@/lacme/client',
# the rest is for the ACME client
map {$_ => undef} qw/server timeout SSL_verify SSL_version SSL_cipher_list/
@@ -100,8 +123,8 @@ do {
webserver => {
listen => '@@runstatedir@@/lacme-www.socket',
'challenge-directory' => undef,
- user => 'www-data',
- group => 'www-data',
+ user => '@@lacme_www_user@@',
+ group => '@@lacme_www_group@@',
command => '@@libexecdir@@/lacme/webserver',
iptables => 'No'
@@ -110,8 +133,8 @@ do {
user => '',
group => '',
command => '@@bindir@@/lacme-accountd',
- config => '@@sysconfdir@@/lacme/lacme-accountd.conf',
- privkey => undef,
+ config => '',
+ privkey => '',
quiet => 'Yes',
}
);
@@ -155,7 +178,8 @@ sub gen_csr(%) {
);
$config->print("keyUsage = critical, $args{keyUsage}\n") if defined $args{keyUsage};
$config->print("subjectAltName = $args{subjectAltName}\n") if defined $args{subjectAltName};
- $config->close() or die "Can't close: $!";
+ $config->print("tlsfeature = $args{tlsfeature}\n") if defined $args{tlsfeature};
+ $config->close() or die "close: $!";
my @args = (qw/-new -batch -key/, $args{'certificate-key'});
push @args, "-$args{hash}" if defined $args{hash};
@@ -163,20 +187,20 @@ sub gen_csr(%) {
open my $fh, '-|', qw/openssl req -outform DER/, @args or die "fork: $!";
my $csr = do { local $/ = undef; <$fh> };
- close $fh or $! ? die "Can't close: $!" : return;
+ close $fh or $! ? die "close: $!" : return;
if ($OPTS{debug}) {
# print out the CSR in text form
pipe my $rd, my $wd or die "pipe: $!";
my $pid = fork // die "fork: $!";
unless ($pid) {
- open STDIN, '<&', $rd or die "Can't dup: $!";
- open STDOUT, '>&', \*STDERR or die "Can't dup: $!";
+ open STDIN, '<&', $rd or die "dup: $!";
+ open STDOUT, '>&', \*STDERR or die "dup: $!";
exec qw/openssl req -noout -text -inform DER/ or die;
}
- $rd->close() or die "Can't close: $!";
+ $rd->close() or die "close: $!";
$wd->print($csr);
- $wd->close() or die "Can't close: $!";
+ $wd->close() or die "close: $!";
waitpid $pid => 0;
die $? if $? > 0;
@@ -216,21 +240,28 @@ sub drop_privileges($$$) {
# set effective and real gid; also set the list of supplementary gids to that single gid
if ($group ne '') {
- my $gid = getgrnam($group) // die "Can't getgrnam($group): $!";
+ my $gid = getgrnam($group) // die "getgrnam($group): $!";
$) = "$gid $gid";
- die "Can't setgroups: $!" if $@;
- POSIX::setgid($gid) or die "Can't setgid: $!";
+ die "setgroups: $!" if $@;
+ POSIX::setgid($gid) or die "setgid: $!";
die "Couldn't setgid/setguid" unless $( eq "$gid $gid" and $) eq "$gid $gid"; # safety check
}
# set effective and real uid
if ($user ne '') {
- my $uid = getpwnam($user) // die "Can't getpwnam($user): $!";
- POSIX::setuid($uid) or die "Can't setuid: $!";
+ my $uid = getpwnam($user) // die "getpwnam($user): $!";
+ POSIX::setuid($uid) or die "setuid: $!";
die "Couldn't setuid/seteuid" unless $< == $uid and $> == $uid; # safety check
}
- chdir $dir or die "Can't chdir to $dir: $!";
+ # sanitize environment
+ my $term = $ENV{TERM};
+ my @ent = getpwuid($<) or die "getpwuid($<): $!";
+ %ENV = ( USER => $ent[0], LOGNAME => $ent[0], HOME => $ent[7], SHELL => $ent[8] );
+ $ENV{PATH} = $< == 0 ? "/usr/sbin:/usr/bin:/sbin:/bin" : "/usr/bin:/bin";
+ $ENV{TERM} = $term if defined $term; # preserve $TERM
+
+ chdir $dir or die "chdir($dir): $!";
}
@@ -239,10 +270,10 @@ sub drop_privileges($$$) {
#
sub set_FD_CLOEXEC($$) {
my ($fd, $set) = @_;
- my $flags = fcntl($fd, F_GETFD, 0) or die "Can't fcntl F_GETFD: $!";
+ my $flags = fcntl($fd, F_GETFD, 0) or die "fcntl F_GETFD: $!";
my $flags2 = $set ? ($flags | FD_CLOEXEC) : ($flags & ~FD_CLOEXEC);
return if $flags == $flags2;
- fcntl($fd, F_SETFD, $flags2) or die "Can't fcntl F_SETFD: $!";
+ fcntl($fd, F_SETFD, $flags2) or die "fcntl F_SETFD: $!";
}
@@ -252,15 +283,6 @@ sub set_FD_CLOEXEC($$) {
# The temporary challenge directory is returned.
#
sub spawn_webserver() {
- # create a temporary directory; give write access to the ACME client
- # and read access to the webserver
- my $tmpdir = File::Temp::->newdir(CLEANUP => 1, TMPDIR => 1) // die;
- chmod 0755, $tmpdir or die "Can't chmod: $!";
- if ((my $username = $CONFIG->{client}->{user}) ne '') {
- my $uid = getpwnam($username) // die "Can't getpwnam($username): $!";
- chown($uid, -1, $tmpdir) or die "Can't chown: $!";
- }
-
my $conf = $CONFIG->{webserver};
# parse and pack addresses to listen to
@@ -280,19 +302,57 @@ sub spawn_webserver() {
push @sockaddr, $sockaddr;
}
- # symlink the 'challenge-directory' configuration option to the
- # temporary challenge directory (so an existing httpd can directly
- # serve ACME challenge reponses).
+ # Use existing HTTPd to serve challenge files using 'challenge-directory'
+ # as document root
if (defined (my $dir = $conf->{'challenge-directory'})) {
+ $dir = spec_expand($dir);
print STDERR "[$$] Using existing webserver on $dir\n" if $OPTS{debug};
- symlink $tmpdir, $dir or die "Can't symlink $dir -> $tmpdir: $!";
- push @CLEANUP, sub() {
- print STDERR "Unlinking $dir\n" if $OPTS{debug};
- unlink $dir or warn "Warning: Can't unlink $dir: $!";
+ # lacme(8) doesn't have the list of challenge files to delete on
+ # cleanup -- instead, we unlink all files and fails at
+ # initialization stage when the challenge directory is not empty
+
+ opendir my $dh, $dir or die "opendir($dir): $!\n";
+ while (readdir $dh) {
+ die "Error: Refusing to use non-empty challenge directory $dir\n"
+ unless $_ eq '.' or $_ eq '..';
}
+ closedir $dh or die "closedir: $!";
+ undef $dh;
+
+ # use a "lock file" (NFS-friendly) to avoid concurrent usages
+ my $lockfile = ".$NAME.lock";
+ sysopen(my $fh, "$dir/$lockfile", O_CREAT|O_EXCL|O_WRONLY, 0600)
+ or die "Can't create lockfile in challenge directory: $!";
+ print $fh $$, "\n";
+ close $fh or die "close: $!";
+ undef $fh;
+
+ push @CLEANUP, sub() {
+ if (opendir(my $dh, $dir)) {
+ my @files = grep { $_ ne '.' and $_ ne '..' and $_ ne $lockfile } readdir $dh;
+ closedir $dh or warn "closedir: $!";
+ push @files, $lockfile; # unlink $lockfile last
+ foreach (@files) {
+ die unless /\A(.+)\z/; # untaint
+ unlink "$dir/$1" or warn "unlink($dir/$1): $!";
+ }
+ } else {
+ warn "opendir($dir): $!\n";
+ }
+ };
+ return $dir; # ignore 'listen' and 'iptables'
}
- elsif (!@sockaddr) {
- die "'challenge-directory' option of section [webserver] is required when 'listen' is empty\n";
+
+ die "'challenge-directory' option is required in section [webserver] when 'listen' is empty\n"
+ unless @sockaddr;
+
+ # create a temporary directory; give write access to the ACME client
+ # and read access to the webserver
+ my $tmpdir = File::Temp::->newdir(CLEANUP => 1, TMPDIR => 1, TEMPLATE => "acme-challenge.XXXXXXXXXX") // die;
+ chmod 0755, $tmpdir or die "chmod: $!";
+ if ((my $username = $CONFIG->{client}->{user}) ne '') {
+ my $uid = getpwnam($username) // die "getpwnam($username): $!";
+ chown($uid, -1, $tmpdir) or die "chown: $!";
}
# create socket(s) and spawn webserver(s)
@@ -321,7 +381,7 @@ sub spawn_webserver() {
bind($sock, $sockaddr) or die "Couldn't bind to $p: $!";
push @CLEANUP, sub() {
print STDERR "Unlinking $path\n" if $OPTS{debug};
- unlink $path or warn "Warning: Can't unlink $path: $!";
+ unlink $path or warn "Warning: Couldn't unlink $path: $!";
};
umask($umask) // die "umask: $!";
}
@@ -335,20 +395,22 @@ sub spawn_webserver() {
my $pid = fork() // "fork: $!";
unless ($pid) {
drop_privileges($conf->{user}, $conf->{group}, $tmpdir);
+ open STDIN, '<', '/dev/null' or die "open(/dev/null): $!";
set_FD_CLOEXEC($sock, 0);
- $ENV{DEBUG} = $OPTS{debug};
+ $ENV{DEBUG} = $OPTS{debug} // 0;
# use execve(2) rather than a Perl pseudo-process to ensure that
# the child doesn't have access to the parent's memory
- exec $conf->{command}, fileno($sock) or die;
+ my ($cmd, @args) = split(/\s+/, $conf->{command}) or die "Empty webserver command\n";
+ exec { $cmd } $cmd, @args, fileno($sock) or die;
}
print STDERR "[$$] Forking ACME webserver bound to $p, child PID $pid\n" if $OPTS{debug};
set_FD_CLOEXEC($sock, 1);
push @CLEANUP, sub() {
print STDERR "[$$] Shutting down ACME webserver bound to $p\n" if $OPTS{debug};
- shutdown($sock, SHUT_RDWR) or warn "shutdown: $!";
kill 15 => $pid;
waitpid $pid => 0;
+ shutdown($sock, SHUT_RDWR) or warn "shutdown: $!";
};
# on dual-stack ipv4/ipv6, we'll need to open the port for the
@@ -396,8 +458,8 @@ sub iptables_save($@) {
my $pid = fork() // die "fork: $!";
unless ($pid) {
- open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!";
- open STDOUT, '>&', $iptables_tmp or die "Can't dup: $!";
+ open STDIN, '<', '/dev/null' or die "open(/dev/null): $!";
+ open STDOUT, '>&', $iptables_tmp or die "dup: $!";
$| = 1; # turn off buffering for STDOUT
exec "/usr/sbin/$iptables_bin-save", "-c" or die;
}
@@ -408,14 +470,14 @@ sub iptables_save($@) {
# handle and not from the file. XXX if there was a way in Perl to
# use open(2) with the O_TMPFILE flag we would use that to avoid
# creating a file to start with
- seek($iptables_tmp, SEEK_SET, 0) or die "Can't seek: $!";
+ seek($iptables_tmp, SEEK_SET, 0) or die "seek: $!";
push @CLEANUP, sub() {
print STDERR "[$$] Restoring $iptables_bin\n" if $OPTS{debug};
my $pid = fork() // die "fork: $!";
unless ($pid) {
- open STDIN, '<&', $iptables_tmp or die "Can't dup: $!";
- open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!";
+ open STDIN, '<&', $iptables_tmp or die "dup: $!";
+ open STDOUT, '>', '/dev/null' or die "open(/dev/null): $!";
exec "/usr/sbin/$iptables_bin-restore", "-c" or die;
}
waitpid $pid => 0;
@@ -459,21 +521,29 @@ sub acme_client($@) {
my ($client, $cleanup);
my $conf = $CONFIG->{client};
if (defined (my $accountd = $CONFIG->{accountd})) {
+ warn "Setting 'privkey' in lacme.conf's [accountd] section is deprecated and will become an error in a future release! "
+ ."Set it in lacme-accountd.conf instead.\n" if $accountd->{privkey} ne '';
+ my $GPG_TTY = $ENV{GPG_TTY};
socketpair($client, my $s, AF_UNIX, SOCK_STREAM, PF_UNSPEC) or die "socketpair: $!";
my $pid = fork() // "fork: $!";
unless ($pid) {
drop_privileges($accountd->{user}, $accountd->{group}, '/');
- set_FD_CLOEXEC($s, 0);
- $client->close() or die "Can't close: $!";
- my @cmd = ($accountd->{command}, '--conn-fd='.fileno($s));
- push @cmd, '--config='.$accountd->{config} if defined $accountd->{config};
- push @cmd, '--privkey='.$accountd->{privkey} if defined $accountd->{privkey};
- push @cmd, '--quiet' unless lc $accountd->{quiet} eq 'no';
- push @cmd, '--debug' if $OPTS{debug};
- exec { $cmd[0] } @cmd or die;
+ $client->close() or die "close: $!";
+ open STDIN, '<&', $s or die "dup: $!";
+ open STDOUT, '>&', $s or die "dup: $!";
+ set_FD_CLOEXEC($s, 1);
+ $ENV{GPG_TTY} = $GPG_TTY if defined $GPG_TTY;
+ my ($cmd, @args) = split(/\s+/, $accountd->{command}) or die "Empty accountd command\n";
+ $_ = spec_expand($_) foreach ($cmd, @args); # expand %-specifiers after privilege drop and whitespace split
+ push @args, '--stdio';
+ push @args, '--config='.spec_expand($accountd->{config}) if $accountd->{config} ne '';
+ push @args, '--privkey='.$accountd->{privkey} if $accountd->{privkey} ne ''; # XXX deprecated in 0.8.0
+ push @args, '--quiet' unless lc $accountd->{quiet} eq 'no';
+ push @args, '--debug' if $OPTS{debug};
+ exec { $cmd } $cmd, @args or die;
}
print STDERR "[$$] Forking lacme-accountd, child PID $pid\n" if $OPTS{debug};
- $s->close() or die "Can't close: $!";
+ $s->close() or die "close: $!";
$cleanup = sub() {
print STDERR "[$$] Shutting down lacme-accountd\n" if $OPTS{debug};
shutdown($client, SHUT_RDWR) or warn "shutdown: $!";
@@ -483,17 +553,17 @@ sub acme_client($@) {
}
else {
my @stat;
- my $sockname = $OPTS{socket} // $conf->{socket} // die "Missing socket option\n";
+ my $sockname = spec_expand($OPTS{socket} // $conf->{socket});
$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;
- @stat = stat($dirname) or die "Can't stat $dirname: $!";
- die "Error: insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0;
+ my $dirname = dirname($sockname);
+ @stat = stat($dirname) or die "Error: stat($dirname): $!\n";
+ die "Error: Insecure permissions on $dirname\n" if ($stat[2] & 0022) != 0;
# ensure we're the only user with read/write access to the socket
- @stat = stat($sockname) or die "Can't stat $sockname: $! (Is lacme-accountd running?)\n";
- die "Error: insecure permissions on $sockname\n" if ($stat[2] & 0066) != 0;
+ @stat = stat($sockname) or die "Can't stat $sockname: $! (Is lacme-accountd running?)\n";
+ die "Error: Insecure permissions on $sockname\n" if ($stat[2] & 0066) != 0;
# connect(2) to the socket
socket($client, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!";
@@ -506,14 +576,15 @@ sub acme_client($@) {
# use execve(2) rather than a Perl pseudo-process to ensure that the
# child doesn't have access to the parent's memory
+ my ($cmd, @args2) = split(/\s+/, $conf->{command}) or die "Empty client command\n";
my @fileno = map { fileno($_) =~ /^(\d+)$/ ? $1 : die } ($CONFFILE, $client); # untaint fileno
set_FD_CLOEXEC($client, 1);
my $rv = spawn({in => $args->{in}, out => $args->{out}, child => sub() {
drop_privileges($conf->{user}, $conf->{group}, $args->{chdir} // '/');
set_FD_CLOEXEC($_, 0) foreach ($CONFFILE, $client);
- seek($CONFFILE, SEEK_SET, 0) or die "Can't seek: $!";
- $ENV{DEBUG} = $OPTS{debug};
- }}, $conf->{command}, $COMMAND, @fileno, @args);
+ seek($CONFFILE, SEEK_SET, 0) or die "seek: $!";
+ $ENV{DEBUG} = $OPTS{debug} // 0;
+ }}, $cmd, @args2, $COMMAND, @fileno, @args);
if (defined $cleanup) {
@CLEANUP = grep { $_ ne $cleanup } @CLEANUP;
@@ -524,7 +595,7 @@ sub acme_client($@) {
sub spawn($@) {
my $args = shift;
- my @exec = @_;
+ my ($cmd, @args) = @_;
# create communication pipes if needed
my ($in_rd, $in_wd, $out_rd, $out_wd);
@@ -540,20 +611,20 @@ sub spawn($@) {
# child
$args->{child}->() if defined $args->{child};
if (defined $args->{in}) {
- close $in_wd or die "Can't close: $!";
- open STDIN, '<&', $in_rd or die "Can't dup: $!";
+ close $in_wd or die "close: $!";
+ open STDIN, '<&', $in_rd or die "dup: $!";
} else {
- open STDIN, '<', '/dev/null' or die "Can't open /dev/null: $!";
+ open STDIN, '<', '/dev/null' or die "open(/dev/null): $!";
}
if (!defined $args->{out}) {
- open STDOUT, '>', '/dev/null' or die "Can't open /dev/null: $!";
+ open STDOUT, '>', '/dev/null' or die "open(/dev/null): $!";
} elsif (ref $args->{out} ne 'GLOB') {
- close $out_rd or die "Can't close: $!";
- open STDOUT, '>&', $out_wd or die "Can't dup: $!";
+ close $out_rd or die "close: $!";
+ open STDOUT, '>&', $out_wd or die "dup: $!";
} elsif (fileno(STDOUT) != fileno($args->{out})) {
- open STDOUT, '>&', $args->{out} or die "Can't dup: $!";
+ open STDOUT, '>&', $args->{out} or die "dup: $!";
}
- exec { $exec[0] } @exec or die;
+ exec { $cmd } $cmd, @args or die;
}
push @CLEANUP, sub() {
kill 15 => $pid;
@@ -561,20 +632,20 @@ sub spawn($@) {
};
# parent
- print STDERR "[$$] Forking $exec[0], child PID $pid\n" if $OPTS{debug};
+ print STDERR "[$$] Forking $cmd, child PID $pid\n" if $OPTS{debug};
if (defined $args->{in}) {
- $in_rd->close() or die "Can't close: $!";
+ $in_rd->close() or die "close: $!";
$in_wd->print($args->{in});
- $in_wd->close() or die "Can't close: $!";
+ $in_wd->close() or die "close: $!";
}
if (defined $args->{out} and ref $args->{out} ne 'GLOB') {
- $out_wd->close() or die "Can't close: $!";
+ $out_wd->close() or die "close: $!";
if (ref $args->{out} eq 'CODE') {
$args->{out}->($out_rd);
} elsif (ref $args->{out} eq 'SCALAR') {
${$args->{out}} = do { local $/ = undef; $out_rd->getline() };
}
- $out_rd->close() or die "Can't close: $!";
+ $out_rd->close() or die "close: $!";
}
waitpid $pid => 0;
pop @CLEANUP;
@@ -599,31 +670,31 @@ sub install_cert($$;$) {
chmod(0644 &~ $umask, $fh) or die "chmod: $!";
if ($leafonly) {
# keep only the leaf certificate
- pipe my $rd, my $wd or die "Can't pipe: $!";
- my $pid = fork // die "Can't fork: $!";
+ pipe my $rd, my $wd or die "pipe: $!";
+ my $pid = fork // die "fork: $!";
unless ($pid) {
- open STDIN, '<&', $rd or die "Can't dup: $!";
- open STDOUT, '>&', $fh or die "Can't dup: $!";
+ open STDIN, '<&', $rd or die "dup: $!";
+ open STDOUT, '>&', $fh or die "dup: $!";
exec qw/openssl x509 -outform PEM/ or die;
}
- $rd->close() or die "Can't close: $!";
+ $rd->close() or die "close: $!";
$wd->print($chain);
- $wd->close() or die "Can't close: $!";
+ $wd->close() or die "close: $!";
waitpid $pid => 0;
die $? if $? > 0;
} else {
- $fh->print($chain) or die "Can't print: $!";
+ $fh->print($chain) or die "print: $!";
}
- $fh->close() or die "Can't close: $!";
+ $fh->close() or die "close: $!";
};
my $path = $fh->filename();
if ($@) {
print STDERR "Unlinking $path\n" if $OPTS{debug};
- unlink $path or warn "Can't unlink $path: $!";
+ unlink $path or warn "unlink($path): $!";
die $@;
}
- rename($path, $filename) or die "Can't rename $path to $filename: $!";
+ rename($path, $filename) or die "rename($path, $filename): $!";
}
@@ -643,18 +714,20 @@ if ($COMMAND eq 'account') {
# newOrder [SECTION ..]
#
elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
+ $OPTS{'min-days'} = -1 if $OPTS{force};
$COMMAND = 'newOrder';
my $conffiles = defined $OPTS{'config-certs'} ? $OPTS{'config-certs'}
: defined $CONFIG->{_}->{'config-certs'} ? [ split(/\s+/, $CONFIG->{_}->{'config-certs'}) ]
: [ "$NAME-certs.conf", "$NAME-certs.conf.d/" ];
+ $_ = spec_expand($_) foreach @$conffiles;
my ($conf, %defaults);
foreach my $conffile (@$conffiles) {
- $conffile = ($CONFFILENAME =~ s#[^/]+\z##r).$conffile unless $conffile =~ /\A\//;
+ $conffile = dirname($CONFFILENAME) .'/'. $conffile unless $conffile =~ /\A\//;
my @filenames;
unless ($conffile =~ s#/\z## or -d $conffile) {
@filenames = ($conffile);
} else {
- opendir my $dh, $conffile or die "Can't opendir $conffile: $!\n";
+ opendir my $dh, $conffile or die "opendir($conffile): $!\n";
while (readdir $dh) {
if (/\.conf\z/) {
push @filenames, "$conffile/$_";
@@ -662,7 +735,7 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
warn "$conffile/$_ has unknown suffix, skipping\n";
}
}
- closedir $dh;
+ closedir $dh or die "closedir: $!";
}
foreach my $filename (sort @filenames) {
print STDERR "Reading $filename\n" if $OPTS{debug};
@@ -670,7 +743,7 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
my $def = delete $h->{_} // {};
$defaults{$_} = $def->{$_} foreach keys %$def;
my @valid = qw/certificate certificate-chain certificate-key min-days CAfile
- hash keyUsage subject subjectAltName chown chmod notify/;
+ hash keyUsage subject subjectAltName tlsfeature chown chmod notify/;
foreach my $s (keys %$h) {
$conf->{$s} = { map { $_ => delete $h->{$s}->{$_} } @valid };
die "Unknown option(s) in [$s]: ".join(', ', keys %{$h->{$s}})."\n" if %{$h->{$s}};
@@ -690,18 +763,18 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
if ($OPTS{debug}) {
print STDERR "Configuration option for $s:\n";
- print " $_ = $conf->{$_}\n" foreach grep { defined $conf->{$_} } (sort keys %$conf);
+ print STDERR " $_ = $conf->{$_}\n" foreach grep { defined $conf->{$_} } (sort keys %$conf);
}
- my $certtype = first { defined $conf->{$_} } qw/certificate certificate-chain/;
- unless (defined $certtype) {
+ my $cert = $conf->{'certificate-chain'} // $conf->{'certificate'};
+ unless (defined $cert) {
print STDERR "[$s] Warning: Missing 'certificate' and 'certificate-chain', skipping\n";
$rv = 1;
next;
}
# skip certificates that expire at least $conf->{'min-days'} days in the future
- if (-f $conf->{$certtype} and defined (my $t = x509_enddate($conf->{$certtype}))) {
+ if (-f $cert and defined (my $t = x509_enddate($cert))) {
my $d = $OPTS{'min-days'} // $conf->{'min-days'} // 21;
if ($d >= 0 and $t - time > $d*86400) {
my $d = POSIX::strftime('%Y-%m-%d %H:%M:%S UTC', gmtime($t));
@@ -711,7 +784,7 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
}
# generate the CSR
- my $csr = gen_csr(map {$_ => $conf->{$_}} qw/certificate-key subject subjectAltName keyUsage hash/) // do {
+ my $csr = gen_csr(map {$_ => $conf->{$_}} qw/certificate-key keyUsage subject subjectAltName tlsfeature hash/) // do {
print STDERR "[$s] Warning: Couldn't generate CSR, skipping\n";
$rv = 1;
next;
@@ -751,13 +824,17 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
next;
};
- # verify certificate validity against the CA
- $conf->{CAfile} //= '@@datadir@@/lacme/ca-certificates.crt';
- if ($conf->{CAfile} ne '' and spawn({in => $x509}, 'openssl', 'verify', '-CAfile', $conf->{CAfile},
- qw/-purpose sslserver -x509_strict/)) {
- print STDERR "[$s] Error: Received invalid X.509 certificate from ACME server!\n";
- $rv = 1;
- next;
+ # verify certificate validity against the CA bundle
+ if ((my $CAfile = $conf->{CAfile} // '@@datadir@@/lacme/ca-certificates.crt') ne '') {
+ my %args = (in => $x509);
+ $args{out} = \*STDERR if $OPTS{debug};
+ my @options = ('-trusted', $CAfile, '-purpose', 'sslserver', '-x509_strict');
+ push @options, '-show_chain' if $OPTS{debug};
+ if (spawn(\%args, 'openssl', 'verify', @options)) {
+ print STDERR "[$s] Error: Received invalid X.509 certificate from ACME server!\n";
+ $rv = 1;
+ next;
+ }
}
# install certificate
@@ -772,16 +849,16 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
if (defined $conf->{chown}) {
my ($user, $group) = split /:/, $conf->{chown}, 2;
- my $uid = getpwnam($user) // die "Can't getpwnam($user): $!";
- my $gid = defined $group ? (getgrnam($group) // die "Can't getgrnam($group): $!") : -1;
+ my $uid = getpwnam($user) // die "getpwnam($user): $!";
+ my $gid = defined $group ? (getgrnam($group) // die "getgrnam($group): $!") : -1;
foreach (grep defined, @$conf{qw/certificate certificate-chain/}) {
- chown($uid, $gid, $_) or die "Can't chown: $!";
+ chown($uid, $gid, $_) or die "chown: $!";
}
}
if (defined $conf->{chmod}) {
my $mode = oct($conf->{chmod}) // die;
foreach (grep defined, @$conf{qw/certificate certificate-chain/}) {
- chmod($mode, $_) or die "Can't chown: $!";
+ chmod($mode, $_) or die "chown: $!";
}
}
@@ -790,7 +867,7 @@ elsif ($COMMAND eq 'newOrder' or $COMMAND eq 'new-cert') {
or die "fork: $!";
print $fh $x509;
close $fh or die $! ?
- "Can't close: $!" :
+ "close: $!" :
"Error: x509(1ssl) exited with value ".($? >> 8)."\n";
if (defined $conf->{notify}) {
@@ -820,7 +897,7 @@ elsif ($COMMAND eq 'revokeCert' or $COMMAND eq 'revoke-cert') {
open my $fh, '-|', qw/openssl x509 -outform DER -in/, $filename or die "fork: $!";
my $der = do { local $/ = undef; <$fh> };
close $fh or die $! ?
- "Can't close: $!" :
+ "close: $!" :
"Error: x509(1ssl) exited with value ".($? >> 8)."\n";
my @certopts = join ',', qw/no_header no_version no_pubkey no_sigdump no_extensions/;
@@ -828,7 +905,7 @@ elsif ($COMMAND eq 'revokeCert' or $COMMAND eq 'revoke-cert') {
or die "fork: $!";
print $fh2 $der;
close $fh2 or die $! ?
- "Can't close: $!" :
+ "close: $!" :
"Error: x509(1ssl) exited with value ".($? >> 8)."\n";
if (acme_client({in => $der})) {
diff --git a/lacme-accountd b/lacme-accountd
index af64168..0f5deb2 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 <guilhem@fripost.org>
+# Copyright © 2015-2021 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
@@ -23,14 +23,15 @@ 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';
+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/;
-use List::Util 'first';
-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 ();
@@ -60,28 +61,80 @@ 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};
+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);
+ exit 255;
+}
+sub panic(@) {
+ my @loc = caller;
+ my @msg = (@_, " at line $loc[2] in $loc[1]");
+ info(@msg);
+ exit 255;
+}
+
+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 "%" ? "%"
+ : error("\"$str\" has unknown specifier %$1");
+ error("Undefined expansion %$1 in \"$str\"") unless defined $x;
+ $x;
+ #ge;
+ return $str;
+}
+
do {
- my $conffile = $OPTS{config} // first { -f $_ }
- ( "./$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;
- 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;
+ 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};
+ my $h = Config::Tiny::->read($conffile) or error(Config::Tiny::->errstr());
+ my $h2 = delete $h->{_} // {};
+ 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 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/;
+ 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 {
+ 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};
+ error("'privkey' is not specified") unless defined $OPTS{privkey};
};
@@ -89,46 +142,57 @@ 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,$2);
+ my ($method, $filename) = ($1, spec_expand($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";
+ # generate with `openssl genpkey -algorithm RSA`
+ 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 $! ?
- "Can't 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';
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));
+
+ if ((my $kid = $OPTS{keyid} // "") ne "") {
+ $extra_greeting{kid} = $kid;
+ $JWK_STR = "{}";
+ }
+ $EXTRA_GREETING_STR = JSON::->new->encode(\%extra_greeting);
}
else {
- die "Error: unsupported method: $OPTS{privkey}\n";
+ error("Unsupported method: $OPTS{privkey}");
}
-$JWK = JSON::->new->encode($JWK);
#############################################################################
@@ -138,31 +202,26 @@ $JWK = 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 {
- 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
+unless (defined $OPTS{stdio}) {
+ my $sockname = spec_expand($OPTS{socket} // '%t/S.lacme');
+ $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 = $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 $dirname = dirname($sockname);
+ 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: $!");
};
@@ -170,33 +229,48 @@ 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, "\r\n" );
+sub conn($$$) {
+ my ($in, $out, $id) = @_;
+ $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 = $conn->getline())) {
- $data =~ s/\r\n\z// or die;
- print STDERR "[$count] >>> Issuing SHA-256 signature for: $data\n" unless $OPTS{quiet};
+ while (defined (my $data = $in->getline())) {
+ $data =~ s/\r\n\z// or panic();
+
+ 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: Refusing to sign request: Malformed payload");
+ last;
+ }
+
+ logmsg(noquiet => "[$id] >>> OK signing request: ",
+ "header=base64url(", decode_base64url($header), "); ",
+ "playload=base64url(", decode_base64url($payload), ")");
+
my $sig = $SIGN->($data);
- $conn->printflush( encode_base64url($sig), "\r\n" );
+ $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++) {
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};
- conn($conn, $count);
- print STDERR "[$count] >>> Connection terminated\n" unless $OPTS{quiet};
- close $conn or warn "Can't close: $!";
+ logmsg(noquiet => "[$count] >>> Accepted new connection");
+ conn($conn, $conn, $count);
+ logmsg(noquiet => "[$count] >>> Connection terminated");
+ $conn->close() or warn "close: $!";
}
}
@@ -205,12 +279,12 @@ 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";
+ 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 "Can't 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: $!");
}
}
diff --git a/lacme-accountd.1.md b/lacme-accountd.1.md
index a967b67..4933a78 100644
--- a/lacme-accountd.1.md
+++ b/lacme-accountd.1.md
@@ -41,30 +41,53 @@ Options
`--config=`*filename*
-: Use *filename* as configuration file. See the **[configuration
- file](#configuration-file)** section below for the configuration
- options.
+: Use *filename* as configuration file instead of
+ `%E/lacme/lacme-accountd.conf`. The value is subject to
+ [%-specifier expansion](#percent-specifiers). `lacme-accountd`
+ fails when `--config=` is used with a non-existent file, but a
+ non-existent default location is treated as if it were an empty
+ file.
-`--privkey=`*arg*
+ See the **[configuration file](#configuration-file)** section below
+ for the configuration options.
+
+`--privkey=`*value*
: Specify the (private) account key to use for signing requests.
- Currently supported *arg*uments are:
+ Currently supported *value*s are:
+
+ * `file:`*FILE*, for a private key in PEM format (optionally
+ symmetrically encrypted)
+ * `gpg:`*FILE*, for a [`gpg`(1)]-encrypted private key
+
+ *FILE* is subject to [%-specifier expansion](#percent-specifiers).
- * `file:`*FILE*, to specify an encrypted private key (in PEM
- format); and
- * `gpg:`*FILE*, to specify a [`gpg`(1)]-encrypted private key (in
- PEM format).
+ The [`genpkey`(1ssl)] command can be used to generate a new private
+ (account) key:
- The following command can be used to generate a new 4096-bits RSA
- key in PEM format with mode 0600:
+ $ install -vm0600 /dev/null /path/to/account.key
+ $ openssl genpkey -algorithm RSA -out /path/to/account.key
- openssl genrsa 4096 | install -m0600 /dev/stdin /path/to/account.key
+ Currently `lacme-accountd` only supports RSA account keys.
`--socket=`*path*
-: Use *path* as the UNIX-domain socket to bind against for signature
- requests from the [ACME] client. `lacme-accountd` aborts if *path*
- exists or if its parent directory is writable by other users.
+: Use *path* as the UNIX-domain socket to bind to for signature
+ requests from the [ACME] client. The value is subject to
+ [%-specifier expansion](#percent-specifiers). `lacme-accountd`
+ aborts if *path* exists or if its parent directory is writable by
+ other users.
+ Default: `%t/S.lacme` (omitting `--socket=` therefore yields an
+ error when `lacme-accountd` doesn't run as and the `XDG_RUNTIME_DIR`
+ environment variable is unset or empty).
+
+`--stdio`
+
+: Read signature requests from the standard input and write signatures
+ to the standard output, instead of using a UNIX-domain socket for
+ communication with the [ACME] client.
+ This _internal_ flag should never be used by standalone
+ `lacme-accountd` instances, only for those [`lacme`(8)] spawns.
`-h`, `--help`
@@ -81,19 +104,13 @@ Options
Configuration file
==================
-If `--config=` is not given, `lacme-accountd` uses the first existing
-configuration file among *./lacme-accountd.conf*,
-*$XDG_CONFIG_HOME/lacme/lacme-accountd.conf* (or
-*~/.config/lacme/lacme-accountd.conf* if the `XDG_CONFIG_HOME`
-environment variable is not set), and *@@sysconfdir@@/lacme/lacme-accountd.conf*.
-
When given on the command line, the `--privkey=`, `--socket=` and
`--quiet` options take precedence over their counterpart (without
-leading `--`) in the configuration file. Valid options are:
+leading `--`) in the configuration file. Valid settings are:
*privkey*
-: See `--privkey=`. This option is required when `--privkey=` is not
+: See `--privkey=`. This setting is required when `--privkey=` is not
specified on the command line.
*gpg*
@@ -105,31 +122,90 @@ leading `--`) in the configuration file. Valid options are:
*socket*
: See `--socket=`.
- Default: *$XDG_RUNTIME_DIR/S.lacme* if the `XDG_RUNTIME_DIR`
- environment variable is set.
+
+*logfile*
+
+: An optional file where to log to. The value is subject to
+ [%-specifier expansion](#percent-specifiers).
+
+*keyid*
+
+: 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*
: Be quiet. Possible values: `Yes`/`No`.
+%-specifiers {#percent-specifiers}
+============
+
+The value the `--config=`, `--privkey=` and `--socket=` CLI options (and
+also the *privkey*, *socket* and *logfile* settings from the
+configuration file) are subject to %-expansion for the following
+specifiers.
+
+---- ------------------------------------------------------------------
+`%C` `@@localstatedir@@/cache` for the root user, and `$XDG_CACHE_HOME`
+ for other users (or `$HOME/.cache` if the `XDG_CACHE_HOME`
+ environment variable is unset or empty).
+
+`%E` `@@sysconfdir@@` for the root user, and `$XDG_CONFIG_HOME` for
+ other users (or `$HOME/.config` if the `XDG_CONFIG_HOME`
+ environment variable is unset or empty).
+
+`%g` Current group name.
+
+`%G` Current group ID.
+
+`%h` Home directory of the current user.
+
+`%t` `@@runstatedir@@` for the root user, and `$XDG_RUNTIME_DIR` for
+ other users. Non-root users may only use `%t` when the
+ `XDG_RUNTIME_DIR` environment variable is set to a non-empty
+ value.
+
+`%T` `$TMPDIR`, or `/tmp` if the `TMPDIR` environment variable is unset
+ or empty.
+
+`%u` Current user name.
+
+`%U` Current user ID.
+
+`%%` A literal `%`.
+---- ------------------------------------------------------------------
+
Examples
========
Run `lacme-accountd` in a first terminal:
- ~$ lacme-accountd --privkey=file:/path/to/account.key --socket=$XDG_RUNTIME_DIR/S.lacme
+ $ lacme-accountd --privkey=file:/path/to/account.key --socket=$XDG_RUNTIME_DIR/S.lacme
Then, while `lacme-accountd` is running, execute locally [`lacme`(8)] in
another terminal:
- ~$ sudo lacme --socket=$XDG_RUNTIME_DIR/S.lacme newOrder
+ $ sudo lacme --socket=$XDG_RUNTIME_DIR/S.lacme newOrder
Alternatively, use [OpenSSH] 6.7 or later to forward the socket and
execute [`lacme`(8)] remotely:
- ~$ ssh -oExitOnForwardFailure=yes -tt -R /path/to/remote.sock:$XDG_RUNTIME_DIR/S.lacme user@example.org \
+ $ ssh -oExitOnForwardFailure=yes -tt -R /path/to/remote.sock:$XDG_RUNTIME_DIR/S.lacme user@example.org \
sudo lacme --socket=/path/to/remote.sock newOrder
+Consult the [`lacme`(8) manual][`lacme`(8)] for a solution involving
+connecting to `lacme-accountd` on a dedicated remote host. Doing so
+enables automatic renewal via [`crontab`(5)] or [`systemd.timer`(5)].
+
See also
========
@@ -141,3 +217,6 @@ See also
[`gpg`(1)]: https://www.gnupg.org/documentation/manpage.en.html
[OpenSSH]: https://www.openssh.com/
[`ssh`(1)]: https://man.openbsd.org/ssh
+[`genpkey`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-genpkey.html
+[`crontab`(5)]: https://linux.die.net/man/5/crontab
+[`systemd.timer`(5)]: https://www.freedesktop.org/software/systemd/man/systemd.timer.html
diff --git a/lacme.8.md b/lacme.8.md
index 4098662..ad6dab6 100644
--- a/lacme.8.md
+++ b/lacme.8.md
@@ -37,9 +37,9 @@ with its own executable:
For certificate issuances (`newOrder` command), it also generates
Certificate Signing Requests, then verifies the validity of the
issued certificate, and optionally reloads or restarts services when
- the *notify* option is set.
+ the *notify* setting is set.
- 3. An actual [ACME] client (specified with the *command* option of the
+ 3. An actual [ACME] client (specified with the *command* setting of the
[`[client]` section](#client-section) of the configuration file),
which builds [ACME] commands and dialogues with the remote [ACME]
server.
@@ -49,7 +49,7 @@ with its own executable:
requested by writing the data to be signed to the socket.
4. For certificate issuances (`newOrder` command), an optional
- webserver (specified with the *command* option of the [`[webserver]`
+ webserver (specified with the *command* setting of the [`[webserver]`
section](#webserver-section) of the configuration file), which is
spawned by the “master” `lacme`. (The only challenge type currently
supported by `lacme` is `http-01`, which requires a webserver to
@@ -77,7 +77,7 @@ Commands
Upon success, `lacme` prints the new or updated Account Object from
the [ACME] server.
-`lacme` [`--config-certs=`*FILE*] [`--min-days=`*INT*] `newOrder` [*SECTION* …]
+`lacme newOrder` [`--config-certs=`*FILE*] [`--min-days=`*INT*|`--force`] [*SECTION* …]
: Read the certificate configuration *FILE* (see the **[certificate
configuration file](#certificate-configuration-file)** section below
@@ -85,29 +85,38 @@ Commands
for each of its sections (or the given list of *SECTION*s).
Command alias: `new-order`.
+ The flag `--force` is an alias for `--min-days=-1`, which forces
+ renewal regardless of the expiration date of existing certificates.
+
`lacme` `revokeCert` *FILE* [*FILE* …]
: Request that the given certificate(s) *FILE*(s) be revoked. For
this command, [`lacme-accountd`(1)] can be pointed to either the
- account key or the server's private key.
+ account key or the certificate key.
Command alias: `revoke-cert`.
-Generic options
-===============
+Generic settings
+================
`--config=`*filename*
-: Use *filename* as configuration file. See the **[configuration
- file](#configuration-file)** section below for the configuration
- options.
+: Use *filename* as configuration file instead of
+ `%E/lacme/lacme.conf`. The value is subject to [%-specifier
+ expansion](#percent-specifiers).
+
+ See the **[configuration file](#configuration-file)** section below
+ for the configuration options.
`--socket=`*path*
: Use *path* as the [`lacme-accountd`(1)] UNIX-domain socket to
- connect to for signature requests from the [ACME] client. `lacme`
- aborts if `path` is readable or writable by other users, or if its
- parent directory is writable by other users.
- This command-line option overrides the *socket* option of the
+ connect to for signature requests from the [ACME] client. The value
+ is subject to [%-specifier expansion](#percent-specifiers).
+ `lacme` aborts if *path* exists or if its parent directory is
+ writable by other users.
+ Default: `%t/S.lacme`.
+
+ This command-line option overrides the *socket* setting of the
[`[client]` section](#client-section) of the configuration file; it
also causes the [`[accountd]` section](#accountd-section) to be
ignored.
@@ -127,12 +136,7 @@ Generic options
Configuration file
==================
-If `--config=` is not given, `lacme` uses the first existing
-configuration file among *./lacme.conf*,
-*$XDG_CONFIG_HOME/lacme/lacme.conf* (or *~/.config/lacme/lacme.conf* if
-the `XDG_CONFIG_HOME` environment variable is not set), and
-*@@sysconfdir@@/lacme/lacme.conf*.
-Valid options are:
+Valid settings are:
Default section
---------------
@@ -143,13 +147,15 @@ Default section
space-separated list of certificate configuration files or
directories to use (see the **[certificate configuration
file](#certificate-configuration-file)** section below for the
- configuration options).
+ configuration options). Each item in that list is independently
+ subject to [%-specifier expansion](#percent-specifiers).
- Paths not starting with `/` are relative to the directory name of
- the **[configuration filename](#configuration-file)**. The list of
- files and directories is processed in order, with the later items
- taking precedence. Files in a directory are processed in
- lexicographic order, only considering the ones with suffix `.conf`.
+ Paths not starting with `/` (after %-expansion) are relative to the
+ parent directory of the **[configuration filename](#configuration-file)**.
+ The list of files and directories is processed in the specified
+ order, with the later items taking precedence. Files in a directory
+ are processed in lexicographic order, only considering the ones with
+ suffix `.conf`.
Default: `lacme-certs.conf lacme-certs.conf.d/`.
@@ -162,39 +168,39 @@ of [ACME] commands and dialogues with the remote [ACME] server).
*socket*
: See `--socket=`.
- Default: *$XDG_RUNTIME_DIR/S.lacme* if the `XDG_RUNTIME_DIR`
- environment variable is set.
*user*
: The username to drop privileges to (setting both effective and real
- uid). Preserve root privileges if the value is empty (not
- recommended).
- Default: `nobody`.
+ uid). Skip privilege drop if the value is empty (not recommended).
+ Default: `@@lacme_client_user@@`.
*group*
: The groupname to drop privileges to (setting both effective and real
gid, and also setting the list of supplementary gids to that single
- group). Preserve root privileges if the value is empty (not
+ group). Skip privilege drop if the value is empty (not
recommended).
- Default: `nogroup`.
+ Default: `@@lacme_client_group@@`.
*command*
-: Path to the [ACME] client executable.
+: The [ACME] client command. It is split on whitespace, with the
+ first item being the command to execute, the second its first
+ argument etc. (Note that `lacme` might append more arguments when
+ executing the command internally.)
Default: `@@libexecdir@@/lacme/client`.
*server*
: Root URI of the [ACME] server.
- Default: `https://acme-v02.api.letsencrypt.org/directory`.
+ Default: `@@acmeapi_server@@`.
*timeout*
: Timeout in seconds after which the client stops polling the [ACME]
server and considers the request failed.
- Default: `10`.
+ Default: `30`.
*SSL_verify*
@@ -236,32 +242,40 @@ served during certificate issuance.
*challenge-directory*
-: Specify a non-existent directory under which an external HTTP daemon
- is configured to serve `GET` requests for challenge files under
- `/.well-known/acme-challenge/` (for each virtual host requiring
- authorization) as static files.
- This option is required when *listen* is empty.
+: Directory under which an external HTTP daemon is configured to serve `GET`
+ requests for challenge files under `/.well-known/acme-challenge/` (for
+ each virtual host requiring authorization) as static files.
+ The directory _must_ exist beforehand, _must_ be empty, and the
+ lacme client user (by default `@@lacme_client_user@@`) needs to be
+ able to create files under it.
+
+ This setting is required when *listen* is empty. Moreover its value
+ is subject to [%-specifier expansion](#percent-specifiers) _before_
+ privilege drop.
*user*
: The username to drop privileges to (setting both effective and real
- uid). Preserve root privileges if the value is empty (not
- recommended).
- Default: `www-data`.
+ uid). Skip privilege drop if the value is empty (not recommended).
+ Default: `@@lacme_www_user@@`.
*group*
: The groupname to drop privileges to (setting both effective and real
gid, and also setting the list of supplementary gids to that single
- group). Preserve root privileges if the value is empty (not
+ group). Skip privilege drop if the value is empty (not
recommended).
- Default: `www-data`.
+ Default: `@@lacme_www_group@@`.
*command*
-: Path to the [ACME] webserver executable. A separate process is
- spawned for each address to *listen* on. (In particular no
- webserver process is forked when the *listen* option is empty.)
+: The [ACME] webserver command. It is split on whitespace, with the
+ first item being the command to execute, the second its first
+ argument etc. (Note that `lacme` might append more arguments when
+ executing the command internally.)
+ A separate process is spawned for each address to *listen* on. (In
+ particular no webserver process is forked when the *listen* setting
+ is empty.)
Default: `@@libexecdir@@/lacme/webserver`.
*iptables*
@@ -269,6 +283,7 @@ served during certificate issuance.
: Whether to automatically install temporary [`iptables`(8)] rules to
open the `ADDRESS[:PORT]` specified with *listen*. The rules are
automatically removed once `lacme` exits.
+ This setting is ignored when *challenge-directory* is set.
Default: `No`.
`[accountd]` section
@@ -283,28 +298,33 @@ UNIX-domain socket.
*user*
: The username to drop privileges to (setting both effective and real
- uid). Preserve root privileges if the value is empty.
+ uid). Skip privilege drop if the value is empty (the default).
*group*
: The groupname to drop privileges to (setting both effective and real
gid, and also setting the list of supplementary gids to that single
- group). Preserve root privileges if the value is empty.
+ group). Skip privilege drop if the value is empty (the default).
*command*
-: Path to the [`lacme-accountd`(1)] executable.
+: The [`lacme-accountd`(1)] command. It is split on whitespace, with
+ the first item being the command to execute, the second its first
+ argument etc. (Note that `lacme` appends more arguments when
+ executing the command internally.)
+ Each item in that list is independently subject to [%-specifier
+ expansion](#percent-specifiers) _after_ privilege drop.
Default: `@@bindir@@/lacme-accountd`.
-*config*
-
-: Path to the [`lacme-accountd`(1)] configuration file.
- Default: `@@sysconfdir@@/lacme/lacme-accountd.conf`.
+ Use for instance `` `ssh -T lacme@account.example.net
+ lacme-accountd` `` in order to spawn a remote [`lacme-accountd`(1)]
+ server.
-*privkey*
+*config*
-: The (private) account key to use for signing requests. See
- [`lacme-accountd`(1)] for details.
+: Path to the [`lacme-accountd`(1)] configuration file. The value is
+ subject to [%-specifier expansion](#percent-specifiers) _after_
+ privilege drop.
*quiet*
@@ -317,7 +337,7 @@ For certificate issuances (`newOrder` command), a separate file is used
to configure paths to the certificate and key, as well as the subject,
subjectAltName, etc. to generate Certificate Signing Requests.
Each section denotes a separate certificate issuance.
-Valid options are:
+Valid settings are:
*certificate*
@@ -332,11 +352,28 @@ Valid options are:
*certificate-key*
-: Path the service's private key. This option is required. The
- following command can be used to generate a new 4096-bits RSA key in
- PEM format with mode 0600:
+: Path to the service's private key. This setting is required. The
+ [`genpkey`(1ssl)] command can be used to generate a new service RSA
+ key:
+
+ $ install -vm0600 /dev/null /path/to/service.rsa.key
+ $ openssl genpkey -algorithm RSA -out /path/to/service.rsa.key
+
+ Alternatively, for an ECDSA key using the NIST P-256 curve:
+
+ $ install -vm0600 /dev/null /path/to/service.ecdsa.key
+ $ openssl genpkey -algorithm EC -out /path/to/service.ecdsa.key \
+ -pkeyopt ec_paramgen_curve:P-256 \
+ -pkeyopt ec_param_enc:named_curve
- openssl genrsa 4096 | install -m0600 /dev/stdin /path/to/srv.key
+ `lacme` supports any key algorithm than the underlying libssl
+ (OpenSSL) version is able to manipulate, but the [ACME] server might
+ reject CSRs associated with private keys of deprecated and/or
+ “exotic” algorithms.
+
+ For a dual cert setup (for instance RSA+ECDSA), duplicate the
+ certificate section and use a distinct *certificate-key* resp.
+ *certificate* (or *certificate-chain*) value for each key algorithm.
*min-days*
@@ -347,32 +384,43 @@ Valid options are:
Default: the value of the CLI option `--min-days`, or `21` if there
is no such option.
+*subject*
+
+: Subject field of the Certificate Signing Request, in the form
+ `/type0=value0/type1=value1/type2=…`. This setting is required.
+
+*subjectAltName*
+
+: Comma-separated list of Subject Alternative Names, in the form
+ `type0:value1,type1:value1,type2:…`
+ The only `type` currently supported is `DNS`, to specify an
+ alternative domain name.
+
*CAfile*
-: Path to trusted issuer certificates, used for validating each issued
- certificate. Specifying an empty values skips certificate validation.
+: Path to the bundle of trusted issuer certificates. This is used for
+ validating each certificate after issuance or renewal. Specifying
+ an empty value skips certificate validation.
Default: `@@datadir@@/lacme/ca-certificates.crt`.
*hash*
-: Message digest algorithm to sign the Certificate Signing Request
- with.
+: Message digest to sign the Certificate Signing Request with,
+ overriding the [`req`(1ssl)] default.
*keyUsage*
-: Comma-separated list of Key Usages, see [`x509v3_config`(5ssl)].
+: Comma-separated list of Key Usages, for instance `digitalSignature,
+ keyEncipherment`, to include in the Certificate Signing Request.
+ See [`x509v3_config`(5ssl)] for a list of possible values. Note
+ that the ACME server might override the value provided here.
-*subject*
-
-: Subject field of the Certificate Signing Request, in the form
- `/type0=value0/type1=value1/type2=…`. This option is required.
+*tlsfeature*
-*subjectAltName*
-
-: Comma-separated list of Subject Alternative Names, in the form
- `type0:value1,type1:value1,type2:…`
- The only `type` currently supported is `DNS`, to specify an
- alternative domain name.
+: Comma-separated list of [TLS extension][TLS Feature extension]
+ identifiers, such as `status_request` for OCSP Must-Staple.
+ See [`x509v3_config`(5ssl)] for a list of possible values. Note
+ that the ACME server might override the value provided here.
*chown*
@@ -386,16 +434,70 @@ Valid options are:
*notify*
-: Command to pass the the system's command shell (`/bin/sh -c`)
+: Command to pass the the system's command shell (`` `/bin/sh -c` ``)
after successful installation of the *certificate* and/or
*certificate-chain*.
+%-specifiers {#percent-specifiers}
+============
+
+Some CLI options and configuration settings are subject to %-expansion
+for the following specifiers. Check the documentation of each setting
+to see which ones are affected.
+
+---- ------------------------------------------------------------------
+`%C` `@@localstatedir@@/cache` for the root user, and `$XDG_CACHE_HOME`
+ for other users (or `$HOME/.cache` if the `XDG_CACHE_HOME`
+ environment variable is unset or empty).
+
+`%E` `@@sysconfdir@@` for the root user, and `$XDG_CONFIG_HOME` for
+ other users (or `$HOME/.config` if the `XDG_CONFIG_HOME`
+ environment variable is unset or empty).
+
+`%g` Current group name.
+
+`%G` Current group ID.
+
+`%h` Home directory of the current user.
+
+`%t` `@@runstatedir@@` for the root user, and `$XDG_RUNTIME_DIR` for
+ other users. Non-root users may only use `%t` when the
+ `XDG_RUNTIME_DIR` environment variable is set to a non-empty
+ value.
+
+`%T` `$TMPDIR`, or `/tmp` if the `TMPDIR` environment variable is unset
+ or empty.
+
+`%u` Current user name.
+
+`%U` Current user ID.
+
+`%%` A literal `%`.
+---- ------------------------------------------------------------------
+
Examples
========
- ~$ sudo lacme account --register --tos-agreed mailto:noreply@example.com
- ~$ sudo lacme newOrder
- ~$ sudo lacme revokeCert /path/to/server/certificate.pem
+ $ sudo lacme account --register --tos-agreed mailto:noreply@example.com
+ $ sudo lacme newOrder
+ $ sudo lacme revokeCert /path/to/service.crt
+
+Automatic renewal can be scheduled via [`crontab`(5)] or
+[`systemd.timer`(5)]. In order to avoid deploying a single account key
+onto multiple nodes and/or dealing with multiple account keys, one can
+install a single [`lacme-accountd`(1)] instance on a dedicated host,
+generate a single account key there (and keep it well), and set the
+following in the [`[accountd]` section](#accountd-section):
+
+ command = ssh -T lacme@account.example.net lacme-accountd
+
+If the user running `lacme` can connect to `lacme@account.example.net`
+using (passwordless) key authentication, this setting will spawn a
+remote [`lacme-accountd`(1)] and use it to sign [ACME] requests.
+Further hardening can be achieved by means of [`authorized_keys`(5)]
+restrictions:
+
+ restrict,from="…",command="/usr/bin/lacme-accountd --quiet --stdio" ssh-rsa …
See also
========
@@ -403,7 +505,13 @@ See also
[`lacme-accountd`(1)]
[ACME]: https://tools.ietf.org/html/rfc8555
+[TLS Feature extension]: https://tools.ietf.org/html/rfc7633
[`lacme-accountd`(1)]: lacme-accountd.1.html
[`iptables`(8)]: https://linux.die.net/man/8/iptables
-[`ciphers`(1ssl)]: https://www.openssl.org/docs/manmaster/apps/ciphers.html
-[`x509v3_config`(5ssl)]: https://www.openssl.org/docs/manmaster/apps/x509v3_config.html
+[`ciphers`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html
+[`x509v3_config`(5ssl)]: https://www.openssl.org/docs/manmaster/man5/x509v3_config.html
+[`genpkey`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-genpkey.html
+[`req`(1ssl)]: https://www.openssl.org/docs/manmaster/man1/openssl-req.html
+[`crontab`(5)]: https://linux.die.net/man/5/crontab
+[`systemd.timer`(5)]: https://www.freedesktop.org/software/systemd/man/systemd.timer.html
+[`authorized_keys`(5)]: https://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT
diff --git a/snippets/apache2-static.conf b/snippets/apache2-static.conf
new file mode 100644
index 0000000..9262179
--- /dev/null
+++ b/snippets/apache2-static.conf
@@ -0,0 +1,16 @@
+# Use Apache2 to serve ACME requests directly.
+# This snippet requires setting challenge-directory = /var/www/acme-challenge
+# in /etc/lacme/lacme.config, and creating this file with write
+# permissions for the lacme client user.
+#
+# This file needs to be sourced to the server directives (at least the
+# non-ssl one) of each virtual host requiring authorization.
+
+<IfModule mod_alias.c>
+ Alias /.well-known/acme-challenge/ /var/www/acme-challenge/
+ <Directory /var/www/acme-challenge/>
+ Options none
+ AllowOverride none
+ Require all granted
+ </Directory>
+</IfModule>
diff --git a/snippets/apache2.conf b/snippets/apache2.conf
index 45d7c7f..31dd95a 100644
--- a/snippets/apache2.conf
+++ b/snippets/apache2.conf
@@ -1,11 +1,12 @@
-# Use Apache2 to serve ACME requests by passing them over to a
-# locally-bound lacme webserver component.
+# Use Apache2 to proxy ACME requests to a locally-bound lacme webserver.
#
# This file needs to be sourced to the server directives (at least the
# non-ssl one) of each virtual host requiring authorization.
+# Alternatively, run `a2enconf lacme` and reload apache2.
-<Location /.well-known/acme-challenge/>
- ProxyPass unix://@@runstatedir@@/lacme-www.socket|http://localhost/.well-known/acme-challenge/
- Require all granted
-</Location>
-
+<IfModule mod_proxy_http.c>
+ <Location /.well-known/acme-challenge/>
+ ProxyPass unix://@@runstatedir@@/lacme-www.socket|http://localhost/.well-known/acme-challenge/
+ Require all granted
+ </Location>
+</IfModule>
diff --git a/snippets/nginx-static.conf b/snippets/nginx-static.conf
new file mode 100644
index 0000000..febe4dc
--- /dev/null
+++ b/snippets/nginx-static.conf
@@ -0,0 +1,15 @@
+# Use Nginx to serve ACME requests directly.
+# This snippet requires setting challenge-directory = /var/www/acme-challenge
+# in /etc/lacme/lacme.config, and creating this file with write
+# permissions for the lacme client user.
+#
+# One of the nginx*.conf file needs to be sourced to the server
+# directives (at least the non-ssl one) of each virtual host requiring
+# authorization.
+
+location ^~ /.well-known/acme-challenge/ {
+ alias /var/www/acme-challenge/;
+ default_type application/jose+json;
+ disable_symlinks on;
+ autoindex off;
+}
diff --git a/snippets/nginx.conf b/snippets/nginx.conf
index 6775489..891a834 100644
--- a/snippets/nginx.conf
+++ b/snippets/nginx.conf
@@ -1,18 +1,9 @@
-# Use Nginx to serve ACME requests; either directly, or by passing them
-# over to a locally-bound lacme webserver component.
+# Use Nginx to proxy ACME requests to a locally-bound lacme webserver.
#
-# This file needs to be sourced to the server directives (at least the
-# non-ssl one) of each virtual host requiring authorization.
+# One of the nginx*.conf file needs to be sourced to the server
+# directives (at least the non-ssl one) of each virtual host requiring
+# authorization.
location ^~ /.well-known/acme-challenge/ {
- # Pass ACME requests to lacme's webserver component
proxy_pass http://unix:@@runstatedir@@/lacme-www.socket;
-
- ## Alternatively, you can let nginx serve the requests by
- ## setting 'challenge-directory' to '/var/www/acme-challenge' in
- ## lacme's configuration file
- # alias /var/www/acme-challenge/;
- # default_type application/jose+json;
- # disable_symlinks on from=$document_root;
- # autoindex off;
}
diff --git a/test b/test
new file mode 100755
index 0000000..81d910c
--- /dev/null
+++ b/test
@@ -0,0 +1,260 @@
+#!/bin/bash
+
+#----------------------------------------------------------------------
+# ACME client written with process isolation and minimal privileges in mind
+# (test suite)
+# Copyright © 2015-2021 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 <https://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+set -ue
+PATH="/usr/bin:/bin"
+export PATH
+
+usage() {
+ local rv="${1-0}"
+ echo "Usage: $0 [--deb|--dev] [TEST..]" >&2
+ exit $rv
+}
+
+# Setup: for any subdomain under $DOMAINNAME,
+# http://$subdomain.$DOMAINNAME/.well-known/acme-challenge/$challenge
+# must be routed to this machine.
+# This can be done with a wildcard DNS record and opening tcp/80 in firewall.
+DOMAINNAME="lacme-test.guilhem.org"
+
+MODE="dev"
+DISTRIBUTION="sid"
+BUILDDIR="build/test"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --deb) MODE="deb"; shift;;
+ --dev) MODE="dev"; shift;;
+ --help|-h) usage 0;;
+ -*) echo "Error: Unknown option $1" >&2; usage 1;;
+ --) shift; break;;
+ *) break;
+ esac
+done
+
+cd "$(dirname -- "$0")"
+declare -a TESTS=()
+if [ $# -eq 0 ]; then
+ # always start with registration, the account key might be new
+ TESTS+=( "register" )
+ for t in tests/*; do
+ if [ "$t" != "tests/register" ] && [ "${t#tests/account-encrypted-}" = "$t" ] && [ -f "$t" ]; then
+ # skip registration and non-interactive tests
+ TESTS+=( "${t#tests/}" )
+ fi
+ done
+else
+ for t in "$@"; do
+ if [ -f "tests/$t" ]; then
+ TESTS+=( "$t" )
+ else
+ echo "Error: '$1': no such test" >&2
+ exit 1
+ fi
+ done
+fi
+
+if [ "$MODE" = "deb" ]; then
+ DISTRIBUTION="$(dpkg-parsechangelog -S Distribution)"
+ [ "$DISTRIBUTION" != "UNRELEASED" ] || DISTRIBUTION="sid"
+ PKG_DESTDIR="${XDG_CACHE_HOME:-"$HOME/.cache"}/build-area"
+fi
+
+ACCOUNT_KEY="$BUILDDIR/account.key"
+mkdir -pv -- "$BUILDDIR"
+if [ ! -f "$ACCOUNT_KEY" ]; then
+ # keep the account key (up to `make clean`) to avoid hitting
+ # rate-liming -- currently 50 registrations per 3h per IP, see
+ # https://letsencrypt.org/docs/staging-environment/
+ echo "Generating account key $ACCOUNT_KEY..." >&2
+ openssl genpkey -algorithm RSA -out "$ACCOUNT_KEY"
+fi
+
+ARCH="$(dpkg-architecture -qDEB_BUILD_ARCH)"
+CHROOT=""
+
+cleanup() {
+ if [ -n "$CHROOT" ]; then
+ schroot -c "$CHROOT" -e
+ fi
+}
+trap cleanup EXIT INT TERM
+
+run() {
+ local t="tests/$1" rootdir version sub
+ if [ ! -f "$t" ]; then
+ echo "Error: '$1': no such test" >&2
+ exit 1
+ fi
+
+ # Don't need to rebuild for each test, but editing the code at the
+ # same time might cause `make install` to rebuild a wrong version
+ make all -- \
+ BUILDDIR="$BUILDDIR" \
+ DESTDIR="" \
+ exec_prefix="/usr" \
+ datadir="/usr/share" \
+ runstatedir="/run" \
+ lacme_www_user=_lacme-www \
+ lacme_www_group=nogroup \
+ lacme_client_user=_lacme-client \
+ lacme_client_group=nogroup \
+ acmeapi_server="https://acme-staging-v02.api.letsencrypt.org/directory"
+
+ CHROOT="$(schroot -c "$DISTRIBUTION-$ARCH-sbuild" -b)"
+ rootdir="/run/schroot/mount/$CHROOT"
+
+ if [ "$MODE" = "deb" ]; then
+ version="$(dpkg-parsechangelog -S Version)"
+ echo "Installing lacme $version into $CHROOT..." >&2
+ install -vt "$rootdir/dev/shm" -m0644 -- \
+ "$PKG_DESTDIR/lacme_${version}_all.deb" \
+ "$PKG_DESTDIR/lacme-accountd_${version}_all.deb"
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ env DEBIAN_FRONTEND="noninteractive" apt install -y \
+ "/dev/shm/lacme_${version}_all.deb" \
+ "/dev/shm/lacme-accountd_${version}_all.deb"
+
+ elif [ "$MODE" = "dev" ]; then
+ echo "Installing lacme dev into $CHROOT..." >&2
+ sudo make install -- \
+ BUILDDIR="$BUILDDIR" \
+ DESTDIR="$rootdir" \
+ exec_prefix="$rootdir/usr" \
+ datadir="$rootdir/usr/share" \
+ runstatedir="$rootdir/run"
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ env DEBIAN_FRONTEND="noninteractive" apt install -y \
+ adduser \
+ libconfig-tiny-perl \
+ libcrypt-openssl-rsa-perl \
+ libjson-perl \
+ libnet-ssleay-perl \
+ libtimedate-perl \
+ libwww-perl \
+ openssl
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ adduser --force-badname --system \
+ --home /nonexistent --no-create-home \
+ --gecos "lacme www user" \
+ --quiet _lacme-www
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ adduser --force-badname --system \
+ --home /nonexistent --no-create-home \
+ --gecos "lacme client user" \
+ --quiet _lacme-client
+ fi
+
+ # set up staging environment, see https://letsencrypt.org/docs/staging-environment/
+ sudo install -oroot -groot -m0644 -vt "$rootdir/usr/share/lacme" certs-staging/*.pem
+ sudo install -oroot -groot -m0644 -vT "$BUILDDIR/certs-staging/ca-certificates.crt" \
+ "$rootdir/usr/share/lacme/ca-certificates.crt"
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ sed -ri '0,/^#?server\s*=.*/ {s||server = https://acme-staging-v02.api.letsencrypt.org/directory|}' \
+ /etc/lacme/lacme.conf
+
+ # install account key and configure lacme accordingly
+ sudo install -oroot -groot -m0600 -vT -- "$BUILDDIR/account.key" \
+ "$rootdir/etc/lacme/account.key"
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ sed -ri '0,\|^#?privkey\s*=.*| {s||privkey = file:/etc/lacme/account.key|}' \
+ /etc/lacme/lacme-accountd.conf
+
+ # use lacme's internal webserver bound to INADDR_ANY port 80
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ sed -ri 's|^#?listen\s*=.*|listen = 0.0.0.0|' /etc/lacme/lacme.conf
+
+ # use a sample lacme-certs.conf, with a random subdomain so we can
+ # verify that challenges are answered correctly
+ sub="$(head -c10 /dev/urandom | base32 -w0)"
+ sudo tee "$rootdir/etc/lacme/lacme-certs.conf.d/simpletest-rsa.conf" >/dev/null <<- EOF
+ [simpletest-rsa]
+ certificate-key = /etc/lacme/simpletest.rsa.key
+ certificate-chain = /etc/lacme/simpletest.rsa.crt
+ subject = /CN=${sub,,[A-Z]}.$DOMAINNAME
+ EOF
+ sudo schroot -d"/" -c "$CHROOT" -r -- \
+ openssl genpkey -algorithm RSA -out /etc/lacme/simpletest.rsa.key
+
+ # copy test wrapper and unit file
+ local testdir="/dev/shm/lacme.test"
+ sudo install -oroot -groot -m0700 -d -- "$rootdir$testdir"
+ sudo install -oroot -groot -m0755 -T -- /dev/stdin "$rootdir$testdir/run" <<-EOF
+ STDERR="$testdir/stderr"
+ touch "\$STDERR"
+ fail() {
+ set +x
+ local rv=\$? i
+ if [ \$rv -eq 0 ]; then rv=1; fi
+ if [ -s "\$STDERR" ]; then
+ echo "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"
+ cat <"\$STDERR" >&2
+ echo "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
+ fi
+ [ \$# -eq 0 ] || echo "Error: \$*" >&2
+ exit \$rv
+ }
+ grepstderr() {
+ grep "\$@" <"\$STDERR" || fail
+ }
+ ngrepstderr() {
+ ! grep "\$@" <"\$STDERR" || fail
+ }
+ set -x
+ EOF
+ sudo tee -a "$rootdir$testdir/run" >/dev/null <"$t"
+
+ sudo schroot -d"/" -c "$CHROOT" -r -- env -i \
+ USER="root" \
+ HOME="/root" \
+ SHELL="/bin/sh" \
+ LOGNAME="root" \
+ TERM="$TERM" \
+ PATH="/usr/sbin:/usr/bin:/sbin:/bin" \
+ DOMAINNAME="$DOMAINNAME" \
+ sh -ue "$testdir/run" || return $?
+}
+
+RV=0
+declare -a PASSED=() FAILED=()
+for t in "${TESTS[@]}"; do
+ run "$t" && rv=0 || rv=$?
+ if [ -n "$CHROOT" ]; then
+ # clean up
+ schroot -c "$CHROOT" -e
+ CHROOT=""
+ fi
+ if [ $rv -eq 0 ]; then
+ PASSED+=( "$t" )
+ else
+ FAILED+=( "$t" )
+ RV=$rv
+ break # stop at the first failure
+ fi
+done
+
+echo >&2
+echo "================================================================================" >&2
+
+echo "PASSED: ${PASSED[*]:-"(none)"}" >&2
+if [ ${#FAILED[@]} -gt 0 ]; then
+ echo "FAILED: ${FAILED[*]}" >&2
+fi
+exit $RV
diff --git a/tests/account-encrypted-gpg b/tests/account-encrypted-gpg
new file mode 100644
index 0000000..fd1e4ac
--- /dev/null
+++ b/tests/account-encrypted-gpg
@@ -0,0 +1,15 @@
+# GnuPG-encrypted account key (WARN: this test is not interactive)
+
+PASSPHRASE="test"
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends gpg gpg-agent
+
+gpg --batch --passphrase "$PASSPHRASE" --quick-generate-key "nobody <noreply@example.net>"
+keyid="$(gpg --list-secret-key --with-colons | grep -m1 ^fpr: | cut -sd: -f10)"
+gpg --encrypt -r "$keyid" /etc/lacme/account.key
+sed -ri '0,\|^#?privkey\s*=.*| {s||privkey = gpg:/etc/lacme/account.key.gpg|}' /etc/lacme/lacme-accountd.conf
+
+export GPG_TTY="$(tty)"
+lacme account
+
+# vim: set filetype=sh :
diff --git a/tests/account-encrypted-openssl b/tests/account-encrypted-openssl
new file mode 100644
index 0000000..e79a528
--- /dev/null
+++ b/tests/account-encrypted-openssl
@@ -0,0 +1,10 @@
+# OpenSSL-encrypted account key (WARN: this test is not interactive)
+
+PASSPHRASE="test"
+
+openssl rsa -aes128 -passout pass:"$PASSPHRASE" </etc/lacme/account.key >/etc/lacme/account.enc.key
+sed -ri '0,\|^#?privkey\s*=.*| {s||privkey = file:/etc/lacme/account.enc.key|}' /etc/lacme/lacme-accountd.conf
+
+lacme account
+
+# vim: set filetype=sh :
diff --git a/tests/accountd b/tests/accountd
new file mode 100644
index 0000000..a603c16
--- /dev/null
+++ b/tests/accountd
@@ -0,0 +1,87 @@
+# Use a separate accountd server process
+
+adduser --disabled-password \
+ --home /home/lacme-account \
+ --gecos "lacme account user" \
+ --quiet lacme-account
+
+# non-existent parent directory
+! lacme --socket="/nonexistent/S.lacme" account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: stat(/nonexistent): No such file or directory"
+
+# word-writable parent directory
+! lacme --socket="/tmp/S.lacme" account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Insecure permissions on /tmp"
+
+# missing socket
+SOCKET=~lacme-account/S.lacme
+! lacme --socket="$SOCKET" account 2>"$STDERR" || fail
+grepstderr -Fxq "Can't stat $SOCKET: No such file or directory (Is lacme-accountd running?)"
+
+#######################################################################
+
+# missing configuration at default location
+! runuser -u lacme-account -- lacme-accountd --debug 2>"$STDERR" || fail
+grepstderr -Fxq "Ignoring missing configuration file at default location /home/lacme-account/.config/lacme/lacme-accountd.conf"
+grepstderr -Fxq "Error: 'privkey' is not specified"
+
+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
+EOF
+
+# non-existent parent directory
+! runuser -u lacme-account -- lacme-accountd --socket="/nonexistent/S.lacme" 2>"$STDERR" || fail
+grepstderr -Fxq "Error: stat(/nonexistent): No such file or directory"
+
+# word-writable parent directory
+! runuser -u lacme-account -- lacme-accountd --socket="%T/S.lacme" account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Insecure permissions on /tmp"
+
+# unset XDG_RUNTIME_DIR
+! runuser -u lacme-account -- lacme-accountd 2>"$STDERR" || fail
+grepstderr "Error: Undefined expansion %t in \"%t/S.lacme\""
+
+# non-existent $XDG_RUNTIME_DIR
+! runuser -u lacme-account -- env XDG_RUNTIME_DIR="/nonexistent" lacme-accountd 2>"$STDERR" || fail
+grepstderr -Fxq "Error: stat(/nonexistent): No such file or directory"
+
+# test running accountd
+runuser -u lacme-account -- env XDG_RUNTIME_DIR=/home/lacme-account lacme-accountd --debug 2>"$STDERR" & PID=$!
+sleep 1
+kill $PID || fail
+wait || fail
+grepstderr -Fxq "Using configuration file: /home/lacme-account/.config/lacme/lacme-accountd.conf"
+grepstderr -Fxq "Starting lacme Account Key Manager at /home/lacme-account/S.lacme"
+
+# make sure errors are logged too
+grep -F "Error: " ~lacme-account/.local/share/lacme/accountd.log
+
+# rotate the log and start accountd
+rm -f ~lacme-account/.local/share/lacme/accountd.log
+runuser -u lacme-account -- lacme-accountd --socket="$SOCKET" --quiet & PID=$!
+
+# run lacme(8) multiple times using that single lacme-accountd(1) instance
+lacme --socket="$SOCKET" --debug account 2>"$STDERR" || fail
+grepstderr -F "Received extra greeting data from accountd:"
+lacme --socket="$SOCKET" newOrder 2>"$STDERR" || fail
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+# terminate accountd and check that it removes the socket
+kill $PID
+wait
+! test -e "$SOCKET"
+
+# ensure signature requests are logged
+grep -Fq "Starting lacme Account Key Manager at /home/lacme-account/S.lacme" ~lacme-account/.local/share/lacme/accountd.log
+grep -Fq "[0] >>> Accepted new connection" ~lacme-account/.local/share/lacme/accountd.log
+grep -Fq "[1] >>> Accepted new connection" ~lacme-account/.local/share/lacme/accountd.log
+grep -Fq "Shutting down and closing lacme Account Key Manager" ~lacme-account/.local/share/lacme/accountd.log
+grep -F ">>> OK signing request:" ~lacme-account/.local/share/lacme/accountd.log
+
+# vim: set filetype=sh :
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 :
diff --git a/tests/accountd-remote b/tests/accountd-remote
new file mode 100644
index 0000000..9e7f812
--- /dev/null
+++ b/tests/accountd-remote
@@ -0,0 +1,55 @@
+# Remote accountd server process
+
+adduser --disabled-password --shell /bin/sh \
+ --home /home/lacme-account \
+ --gecos "lacme account user" \
+ --quiet lacme-account
+
+chown lacme-account: /etc/lacme/account.key
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends \
+ openssh-client openssh-server
+ssh-keygen -N "" -f ~root/.ssh/id_rsa
+
+install -olacme-account -glacme-account -dm0700 ~lacme-account/.ssh
+install -olacme-account -glacme-account -m0644 ~root/.ssh/id_rsa.pub ~lacme-account/.ssh/authorized_keys
+{ echo -n "[127.0.0.1]:2222 "; cat /etc/ssh/ssh_host_rsa_key.pub; } >~root/.ssh/known_hosts
+
+cat >/etc/ssh/sshd_config <<-EOF
+ Port 2222
+ ListenAddress 127.0.0.1
+ ChallengeResponseAuthentication no
+ PasswordAuthentication no
+ UsePAM yes
+EOF
+install -oroot -groot -dm0755 /run/sshd
+/usr/sbin/sshd
+
+sed -ri 's|^#?command\s*=.*/lacme-accountd$|command = ssh -Tp2222 -llacme-account 127.0.0.1 lacme-accountd|' /etc/lacme/lacme.conf
+sed -ri 's|^#?config\s*=.*|config = /etc/lacme/lacme-accountd.conf|' /etc/lacme/lacme.conf
+lacme newOrder --debug 2>"$STDERR" || fail # intentionally use --debug, ssh should tunnel stdin + stdout + stderr
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+# and now with an authorized_keys(5) restriction
+sed -ri "s|^[^#]|restrict,from=\"127.0.0.1\",command=\"/usr/bin/lacme-accountd --quiet --stdio\" &|" ~lacme-account/.ssh/authorized_keys
+rm -vf /etc/lacme/simpletest.rsa.crt
+
+! lacme newOrder 2>"$STDERR" || fail # --config= (and --debug) should be ignored
+grepstderr -Fxq "Error: 'privkey' is not specified"
+grepstderr -Fxq "[simpletest-rsa] Error: Couldn't issue X.509 certificate!"
+
+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
+cat >~lacme-account/.config/lacme/lacme-accountd.conf <<-EOF
+ privkey = file:%E/lacme/account.key
+ logfile = %h/.local/share/lacme/accountd.log
+EOF
+
+lacme newOrder
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+# ensure signature requests are logged
+grep -F ">>> OK signing request:" ~lacme-account/.local/share/lacme/accountd.log
+
+# vim: set filetype=sh :
diff --git a/tests/apache2-proxy b/tests/apache2-proxy
new file mode 100644
index 0000000..016b426
--- /dev/null
+++ b/tests/apache2-proxy
@@ -0,0 +1,33 @@
+# Use Apache2 as reverse proxy for lacme's internal webserver using the
+# provided snippet
+
+# bind the webserver to the default listening address
+sed -i 's|^listen\s*=|#&|' /etc/lacme/lacme.conf
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends apache2 curl
+
+a2enmod proxy_http
+a2enconf lacme
+
+mkdir /run/apache2
+( set +eux && . /etc/apache2/envvars && apache2 )
+
+# ensure that requests to the root URI and challenge URIs yield 502 Bad Gateway before starting the webserver
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/)"; [ $rv -eq 503 ]
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/foo)"; [ $rv -eq 503 ]
+
+lacme --debug newOrder 2>"$STDERR" || fail
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+grepstderr -Fq "Forking ACME webserver bound to /run/lacme-www.socket, child PID "
+grepstderr -Fq "Forking lacme-accountd, child PID "
+grepstderr -Fq "Forking /usr/libexec/lacme/client, child PID "
+grepstderr -Fq "Shutting down lacme-accountd"
+grepstderr -Fq "Shutting down ACME webserver bound to /run/lacme-www.socket"
+grepstderr -Eq "Incoming connection: GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+$"
+
+# ensure apache2 was indeed used to serve challenge responses (Let's Encrypt caches validation results)
+grep -E "\"GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+\" 200 .* \(([^)]+; )*Let's Encrypt validation server(; [^)]+)*\)\"$" \
+ /var/log/apache2/access.log
+
+# vim: set filetype=sh :
diff --git a/tests/apache2-static b/tests/apache2-static
new file mode 100644
index 0000000..f697cd7
--- /dev/null
+++ b/tests/apache2-static
@@ -0,0 +1,47 @@
+# Use Nginx to directly serve ACME challenge responses using the
+# provided snippet
+
+# bind the webserver to the default listening address
+sed -i 's|^listen\s*=|#&|' /etc/lacme/lacme.conf
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends apache2 curl
+
+ln -fs /etc/lacme/apache2-static.conf /etc/apache2/conf-available/lacme.conf
+a2enmod proxy_http
+a2enconf lacme
+
+mkdir /run/apache2
+( set +eux && . /etc/apache2/envvars && apache2 )
+
+# 'challenge-directory' set to a non-existent directory
+sed -ri 's|^#?challenge-directory\s*=.*|challenge-directory = /var/www/acme-challenge|' /etc/lacme/lacme.conf
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fqx "opendir(/var/www/acme-challenge): No such file or directory"
+
+# ensure that requests to the root URI and challenge URIs respectively yield 403 Forbidden (no index) and 404 Not Found
+install -o_lacme-client -gwww-data -m0750 -d /var/www/acme-challenge
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/)"; [ $rv -eq 403 ]
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/foo)"; [ $rv -eq 404 ]
+
+# 'challenge-directory' set to a non-empty directory
+touch /var/www/acme-challenge/.stamp
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fqx "Error: Refusing to use non-empty challenge directory /var/www/acme-challenge"
+
+rm -f /var/www/acme-challenge/.stamp
+lacme --debug newOrder 2>"$STDERR" || fail
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+ngrepstderr -Fq "Forking ACME webserver"
+grepstderr -Fq "Using existing webserver on /var/www/acme-challenge"
+grepstderr -Fq "Forking lacme-accountd, child PID "
+grepstderr -Fq "Forking /usr/libexec/lacme/client, child PID "
+grepstderr -Fq "Shutting down lacme-accountd"
+ngrepstderr -Fq "Shutting down ACME webserver"
+ngrepstderr -Eq "Incoming connection( from \S+)?: GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+$"
+
+# ensure apache2 was indeed used to serve challenge responses (Let's Encrypt caches validation results)
+grep -E "\"GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+\" 200 .* \(([^)]+; )*Let's Encrypt validation server(; [^)]+)*\)\"$" \
+ /var/log/apache2/access.log
+
+# vim: set filetype=sh :
diff --git a/tests/cert-extensions b/tests/cert-extensions
new file mode 100644
index 0000000..a397ee5
--- /dev/null
+++ b/tests/cert-extensions
@@ -0,0 +1,91 @@
+# X509v3 certificate extension, cf. x509v3_config(5ssl)
+
+x509_check() {
+ local cert="$1" ext out
+ out="$(mktemp --tmpdir)"
+ ext="basicConstraints,subjectAltName,keyUsage,extendedKeyUsage,tlsfeature"
+ openssl x509 -noout -subject -ext "$ext" -nameopt compat <"$cert" >"$out"
+ diff --unified --color=auto -b --label="a/${cert#/}" --label="b/${cert#/}" -- - "$out"
+}
+
+# default settings (the ACME server adds a subjectAltName with the Common Name)
+openssl genpkey -algorithm RSA -out /etc/lacme/test1.key
+commonName="$(head -c10 /dev/urandom | base32 -w0 | tr "[A-Z]" "[a-z]").$DOMAINNAME"
+cat >"/etc/lacme/lacme-certs.conf.d/test1.conf" <<- EOF
+ [test1]
+ certificate-key = /etc/lacme/test1.key
+ certificate-chain = /etc/lacme/test1.crt
+ subject = /CN=$commonName
+EOF
+
+lacme newOrder test1
+test /etc/lacme/test1.crt -nt /etc/lacme/test1.key
+x509_check /etc/lacme/test1.crt <<-EOF
+ subject=/CN=$commonName
+ X509v3 Key Usage: critical
+ Digital Signature, Key Encipherment
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication, TLS Web Client Authentication
+ X509v3 Basic Constraints: critical
+ CA:FALSE
+ X509v3 Subject Alternative Name:
+ DNS:$commonName
+EOF
+
+# subjectAltName
+openssl genpkey -algorithm RSA -out /etc/lacme/test2.key
+commonName="$(head -c10 /dev/urandom | base32 -w0 | tr "[A-Z]" "[a-z]").$DOMAINNAME"
+subjectAltName=""
+for i in $(seq 1 8); do
+ subjectAltName="${subjectAltName:+"$subjectAltName "}$(head -c10 /dev/urandom | base32 -w0 | tr "[A-Z]" "[a-z]").$DOMAINNAME"
+done
+cat >"/etc/lacme/lacme-certs.conf.d/test2.conf" <<- EOF
+ [test2]
+ certificate-key = /etc/lacme/test2.key
+ certificate-chain = /etc/lacme/test2.crt
+ subject = /CN=$commonName
+ subjectAltName = DNS:$(echo "$subjectAltName" | sed -r "s/ /, DNS:/g")
+EOF
+
+lacme newOrder test2
+test /etc/lacme/test2.crt -nt /etc/lacme/test2.key
+x509_check /etc/lacme/test2.crt <<-EOF
+ subject=/CN=$commonName
+ X509v3 Key Usage: critical
+ Digital Signature, Key Encipherment
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication, TLS Web Client Authentication
+ X509v3 Basic Constraints: critical
+ CA:FALSE
+ X509v3 Subject Alternative Name:
+ DNS:$(echo "$commonName" "$subjectAltName" | tr " " "\\n" | sort -u | paste -sd" " | sed -r "s/ /, DNS:/g")
+EOF
+
+# tlsfeature
+openssl genpkey -algorithm RSA -out /etc/lacme/test3.key
+commonName="$(head -c10 /dev/urandom | base32 -w0 | tr "[A-Z]" "[a-z]").$DOMAINNAME"
+cat >"/etc/lacme/lacme-certs.conf.d/test3.conf" <<- EOF
+ [test3]
+ certificate-key = /etc/lacme/test3.key
+ certificate-chain = /etc/lacme/test3.crt
+ subject = /CN=$commonName
+ tlsfeature = status_request
+EOF
+
+lacme newOrder test3
+test /etc/lacme/test3.crt -nt /etc/lacme/test3.key
+x509_check /etc/lacme/test3.crt <<-EOF
+ subject=/CN=$commonName
+ X509v3 Key Usage: critical
+ Digital Signature, Key Encipherment
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication, TLS Web Client Authentication
+ X509v3 Basic Constraints: critical
+ CA:FALSE
+ X509v3 Subject Alternative Name:
+ DNS:$commonName
+ TLS Feature:
+ status_request
+EOF
+
+# vim: set filetype=sh :
diff --git a/tests/cert-install b/tests/cert-install
new file mode 100644
index 0000000..f2147d2
--- /dev/null
+++ b/tests/cert-install
@@ -0,0 +1,176 @@
+# Certificate installation and post-issuance notification
+
+# at least one of 'certificate' or 'certificate-chain' is required
+cat >"/etc/lacme/lacme-certs.conf.d/bad1.conf" <<- EOF
+ [bad1]
+ certificate-key = /etc/lacme/bad1.key
+ subject = /CN=bad1.$DOMAINNAME
+EOF
+! lacme newOrder bad1 2>"$STDERR" || fail newOrder bad1
+grepstderr -Fxq "[bad1] Warning: Missing 'certificate' and 'certificate-chain', skipping"
+
+# 'subject' is required
+cat >"/etc/lacme/lacme-certs.conf.d/bad2.conf" <<- EOF
+ [bad2]
+ certificate-key = /etc/lacme/bad2.key
+ certificate = /etc/lacme/bad2.crt
+EOF
+! lacme newOrder bad2 2>"$STDERR" || fail newOrder bad2
+grepstderr -Fxq "[bad2] Warning: Couldn't generate CSR, skipping"
+
+# 'certificate-key' is required
+cat >"/etc/lacme/lacme-certs.conf.d/bad3.conf" <<- EOF
+ [bad3]
+ certificate = /etc/lacme/bad3.crt
+ subject = /CN=bad3.$DOMAINNAME
+EOF
+! lacme newOrder bad3 2>"$STDERR" || fail newOrder bad3
+grepstderr -Fxq "[bad3] Warning: Couldn't generate CSR, skipping"
+
+
+# 'certificate' installs only the leaf certificate
+openssl genpkey -algorithm RSA -out /etc/lacme/test1.key
+subject="/CN=$(head -c10 /dev/urandom | base32 -w0).$DOMAINNAME"
+cat >"/etc/lacme/lacme-certs.conf.d/test1.conf" <<- EOF
+ [test1]
+ certificate-key = /etc/lacme/test1.key
+ certificate = /etc/lacme/test1.crt
+ subject = $subject
+EOF
+
+lacme newOrder test1 2>"$STDERR" || fail newOrder test1
+test /etc/lacme/test1.crt -nt /etc/lacme/test1.key
+sed -n "0,/^-----END CERTIFICATE-----$/ p" /etc/lacme/test1.crt >/etc/lacme/test1.pem
+diff --unified /etc/lacme/test1.crt /etc/lacme/test1.pem
+
+
+check_hash() {
+ local p1="$1" p2 s1 s2
+ s1="$(openssl x509 -noout -hash <"$p1")"
+ for p2 in /usr/share/lacme/ca-certificates.pem.*; do
+ s2="$(openssl x509 -noout -hash <"$p2")"
+ if [ "$s1" = "$s2" ]; then
+ return 0
+ fi
+ done
+ return 1
+}
+csplit -f /usr/share/lacme/ca-certificates.pem. /usr/share/lacme/ca-certificates.crt \
+ "/-----BEGIN CERTIFICATE-----/" "{*}"
+rm -f /usr/share/lacme/ca-certificates.pem.00
+
+# 'certificate-chain' appends the chain of trust
+openssl genpkey -algorithm RSA -out /etc/lacme/test2.key
+cat >"/etc/lacme/lacme-certs.conf.d/test2.conf" <<- EOF
+ [test2]
+ certificate-key = /etc/lacme/test2.key
+ certificate-chain = /etc/lacme/test2.crt
+ subject = $subject
+EOF
+
+lacme newOrder test2 2>"$STDERR" || fail newOrder test2
+test /etc/lacme/test2.crt -nt /etc/lacme/test2.key
+csplit -f /etc/lacme/test2.chain.pem /etc/lacme/test2.crt \
+ "/-----BEGIN CERTIFICATE-----/" "{*}"
+test -s /etc/lacme/test2.chain.pem01 # leaf cert (00 is empty)
+rm -f /etc/lacme/test2.chain.pem0[01]
+test -s /etc/lacme/test2.chain.pem02 # depth 1
+
+# all certificates at depth >=1 must be in our CA bundle
+for p in /etc/lacme/test2.chain.pem*; do
+ check_hash "$p"
+done
+
+# 'certificate' + 'certificate-chain'
+openssl genpkey -algorithm RSA -out /etc/lacme/test3.key
+cat >"/etc/lacme/lacme-certs.conf.d/test3.conf" <<- EOF
+ [test3]
+ certificate-key = /etc/lacme/test3.key
+ certificate = /etc/lacme/test3.pem
+ certificate-chain = /etc/lacme/test3.crt
+ subject = $subject
+EOF
+
+lacme newOrder test3 2>"$STDERR" || fail newOrder test3
+test /etc/lacme/test3.pem -nt /etc/lacme/test3.key
+test /etc/lacme/test3.crt -nt /etc/lacme/test3.key
+csplit -f /etc/lacme/test3.chain.pem /etc/lacme/test3.crt \
+ "/-----BEGIN CERTIFICATE-----/" "{*}"
+sed -i "/^$/d" /etc/lacme/test3.chain.pem*
+diff -q /etc/lacme/test3.chain.pem01 /etc/lacme/test3.pem
+st="$(stat -c "%U:%G %#a" /etc/lacme/test3.pem)"
+[ "$st" = "root:root 0644" ]
+st="$(stat -c "%U:%G %#a" /etc/lacme/test3.crt)"
+[ "$st" = "root:root 0644" ]
+
+# chmod user
+openssl genpkey -algorithm RSA -out /etc/lacme/test4.key
+cat >"/etc/lacme/lacme-certs.conf.d/test4.conf" <<- EOF
+ [test4]
+ certificate-key = /etc/lacme/test4.key
+ certificate = /etc/lacme/test4.pem
+ certificate-chain = /etc/lacme/test4.crt
+ chown = nobody
+ subject = $subject
+EOF
+
+lacme newOrder test4 2>"$STDERR" || fail newOrder test4
+st="$(stat -c "%U:%G %#a" /etc/lacme/test4.pem)"
+[ "$st" = "nobody:root 0644" ]
+st="$(stat -c "%U:%G %#a" /etc/lacme/test4.crt)"
+[ "$st" = "nobody:root 0644" ]
+
+# chmod user:group
+openssl genpkey -algorithm RSA -out /etc/lacme/test5.key
+cat >"/etc/lacme/lacme-certs.conf.d/test5.conf" <<- EOF
+ [test5]
+ certificate-key = /etc/lacme/test5.key
+ certificate = /etc/lacme/test5.pem
+ certificate-chain = /etc/lacme/test5.crt
+ chown = nobody:nogroup
+ subject = $subject
+EOF
+
+lacme newOrder test5 2>"$STDERR" || fail newOrder test5
+st="$(stat -c "%U:%G %#a" /etc/lacme/test5.pem)"
+[ "$st" = "nobody:nogroup 0644" ]
+st="$(stat -c "%U:%G %#a" /etc/lacme/test5.crt)"
+[ "$st" = "nobody:nogroup 0644" ]
+
+# chown
+openssl genpkey -algorithm RSA -out /etc/lacme/test6.key
+cat >"/etc/lacme/lacme-certs.conf.d/test6.conf" <<- EOF
+ [test6]
+ certificate-key = /etc/lacme/test6.key
+ certificate = /etc/lacme/test6.pem
+ certificate-chain = /etc/lacme/test6.crt
+ chmod = 0400
+ subject = $subject
+EOF
+
+lacme newOrder test6 2>"$STDERR" || fail newOrder test6
+st="$(stat -c "%U:%G %#a" /etc/lacme/test6.pem)"
+[ "$st" = "root:root 0400" ]
+st="$(stat -c "%U:%G %#a" /etc/lacme/test6.crt)"
+[ "$st" = "root:root 0400" ]
+
+# post-issuance notification
+openssl genpkey -algorithm RSA -out /etc/lacme/test7.key
+cat >"/etc/lacme/lacme-certs.conf.d/test7.conf" <<- EOF
+ [test7]
+ certificate-key = /etc/lacme/test7.key
+ certificate-chain = /etc/lacme/test7.crt
+ subject = $subject
+ notify = touch /tmp/test7.notify
+EOF
+
+lacme newOrder test7 2>"$STDERR" || fail newOrder test7
+grepstderr -Fxq "Running notification command \`touch /tmp/test7.notify\`"
+test -e /tmp/test7.notify
+
+rm -f /tmp/test7.notify
+lacme newOrder test7 2>"$STDERR" || fail newOrder test7
+ngrepstderr -Fq "Running notification command"
+! test -e /tmp/test7.notify
+
+# vim: set filetype=sh :
diff --git a/tests/cert-renew b/tests/cert-renew
new file mode 100644
index 0000000..aca1b34
--- /dev/null
+++ b/tests/cert-renew
@@ -0,0 +1,21 @@
+# Skip renewal for recent enough certificates
+
+lacme newOrder 2>"$STDERR" || fail
+grepstderr -Exq "Installing X.509 certificate chain /etc/lacme/simpletest.rsa.crt"
+ino1="$(stat -c%i /etc/lacme/simpletest.rsa.crt)"
+
+lacme newOrder 2>"$STDERR" || fail
+grepstderr -Ex "^\[simpletest-rsa\] Valid until .*, skipping"
+
+lacme newOrder --force 2>"$STDERR" || fail
+grepstderr -Exq "Installing X.509 certificate chain /etc/lacme/simpletest.rsa.crt"
+ino2="$(stat -c%i /etc/lacme/simpletest.rsa.crt)"
+test "$ino1" != "$ino2" # we never truncate existing certificates
+
+sed -ri 's|^#?min-days\s*=.*|min-days = 90|' /etc/lacme/lacme-certs.conf
+lacme newOrder 2>"$STDERR" || fail
+grepstderr -Exq "Installing X.509 certificate chain /etc/lacme/simpletest.rsa.crt"
+ino3="$(stat -c%i /etc/lacme/simpletest.rsa.crt)"
+test "$ino2" != "$ino3" # we never truncate existing certificates
+
+# vim: set filetype=sh :
diff --git a/tests/cert-revoke b/tests/cert-revoke
new file mode 100644
index 0000000..f3d585e
--- /dev/null
+++ b/tests/cert-revoke
@@ -0,0 +1,32 @@
+# Certification revocation, using either the account key or the
+# certificate key
+
+# also check issuance for ECDSA keys
+openssl genpkey -algorithm EC -out /etc/lacme/simpletest.ecdsa.key \
+ -pkeyopt ec_paramgen_curve:P-256 \
+ -pkeyopt ec_param_enc:named_curve
+
+sed "s/rsa/ecdsa/" /etc/lacme/lacme-certs.conf.d/simpletest-rsa.conf > \
+ /etc/lacme/lacme-certs.conf.d/simpletest-ecdsa.conf
+
+# issue both RSA and ECDSA certificates
+lacme newOrder 2>"$STDERR" || fail newOrder
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+test /etc/lacme/simpletest.ecdsa.crt -nt /etc/lacme/simpletest.ecdsa.key
+
+# revoke the ECDSA certificate using the account key
+lacme revokeCert /etc/lacme/simpletest.ecdsa.crt
+! lacme revokeCert /etc/lacme/simpletest.ecdsa.crt 2>"$STDERR" || fail
+grepstderr -Fxq "Revoking /etc/lacme/simpletest.ecdsa.crt"
+grepstderr -Fxq "400 Bad Request (Certificate already revoked)"
+grepstderr -Fxq "Warning: Couldn't revoke /etc/lacme/simpletest.ecdsa.crt"
+
+# and the RSA certificate using the service key
+mv -vfT /etc/lacme/simpletest.rsa.key /etc/lacme/account.key
+lacme revokeCert /etc/lacme/simpletest.rsa.crt
+! lacme 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"
+
+# vim: set filetype=sh :
diff --git a/tests/cert-verify b/tests/cert-verify
new file mode 100644
index 0000000..49629f2
--- /dev/null
+++ b/tests/cert-verify
@@ -0,0 +1,43 @@
+# Certificate verification
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends ssl-cert
+
+# add staging root certificates to the trust store to emulate the production environment
+for ca in /usr/share/lacme/letsencrypt-stg-root-*.pem; do
+ ln -sT "$ca" "/usr/local/share/ca-certificates/$(basename -s.pem "$ca").crt"
+done
+update-ca-certificates
+
+# test (modified) trust store for intermediate certificates
+openssl verify -no-CAfile -CApath /etc/ssl/certs -show_chain /usr/share/lacme/letsencrypt-stg-int-*.pem
+openssl verify -no-CApath -CAfile /etc/ssl/certs/ca-certificates.crt -show_chain /usr/share/lacme/letsencrypt-stg-int-*.pem
+
+mv /usr/share/lacme/ca-certificates.crt /usr/share/lacme/ca-certificates.crt.back
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fxq "Can't open /usr/share/lacme/ca-certificates.crt for reading, No such file or directory"
+grepstderr -Fxq "[simpletest-rsa] Error: Received invalid X.509 certificate from ACME server!"
+
+# verification error for unrelated CA bundle
+cat /etc/ssl/certs/ssl-cert-snakeoil.pem >/usr/share/lacme/ca-certificates.crt
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fxq "error 20 at 0 depth lookup: unable to get local issuer certificate"
+grepstderr -Fxq "[simpletest-rsa] Error: Received invalid X.509 certificate from ACME server!"
+
+# verification error when the CA bundle contains only the root certificates
+cat /usr/share/lacme/letsencrypt-stg-root-*.pem >/usr/share/lacme/ca-certificates.crt
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fxq "error 20 at 0 depth lookup: unable to get local issuer certificate"
+grepstderr -Fxq "[simpletest-rsa] Error: Received invalid X.509 certificate from ACME server!"
+
+# verification error when the CA bundle contains only the intermediate certificates
+cat /usr/share/lacme/letsencrypt-stg-int-*.pem >/usr/share/lacme/ca-certificates.crt
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fxq "error 2 at 1 depth lookup: unable to get issuer certificate"
+grepstderr -Fxq "[simpletest-rsa] Error: Received invalid X.509 certificate from ACME server!"
+
+# use saved bundle as custom CAfile
+sed -ri 's|^#?CAfile\s*=.*|CAfile = /usr/share/lacme/ca-certificates.crt.back|' /etc/lacme/lacme-certs.conf
+lacme newOrder 2>"$STDERR" || fail
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+# vim: set filetype=sh :
diff --git a/tests/drop-privileges b/tests/drop-privileges
new file mode 100644
index 0000000..0596e31
--- /dev/null
+++ b/tests/drop-privileges
@@ -0,0 +1,166 @@
+# Check privilige drop: UID/GID changes, chdir, environment, and file
+# descriptors
+
+# create wrapper to inspect processes
+STATUSDIR="/dev/shm/lacme-wrap"
+install -oroot -groot -m0755 /dev/stdin /run/lacme-wrap <<-EOF
+ #!/bin/sh
+ set -ue
+ PATH="/usr/bin:/bin"
+ export PATH
+
+ prefix="$STATUSDIR/\${1##*[/-]}"
+ cat </proc/\$\$/status >"\$prefix/status"
+ pwd >"\$prefix/cwd"
+ stat -c "%U:%G %#a" . >"\$prefix/cwd-mod"
+ sort -z </proc/\$\$/environ | tr "\\0" "\\n" >"\$prefix/environ"
+ ( find -P /proc/\$\$/fd -mindepth 1 \! -lname "\$0" -printf "%P %#m %U:%G %l\\n" >"\$prefix/fd" )
+
+ exec "\$@"
+EOF
+
+# also check privilege drop for the spawned accountd
+adduser --system --group \
+ --home /nonexistent --no-create-home \
+ --gecos "lacme account user" \
+ --quiet lacme-account
+sed -ri 's|^#user\s*=\s*$|user = lacme-account|' /etc/lacme/lacme.conf
+sed -ri 's|^#group\s*=\s*$|group = lacme-account|' /etc/lacme/lacme.conf
+chown lacme-account: /etc/lacme/account.key
+
+install -oroot -groot -dm0755 -- "$STATUSDIR"
+install -olacme-account -groot -dm0700 -- "$STATUSDIR/accountd"
+install -o_lacme-client -groot -dm0700 -- "$STATUSDIR/client"
+install -o_lacme-www -groot -dm0700 -- "$STATUSDIR/webserver"
+
+# test with a group that's not the primary group (nogroup) of _lacme-www etc
+addgroup --system nogroup2
+sed -ri 's|^#?group\s*=\s*nogroup$|group = nogroup2|' /etc/lacme/lacme.conf
+sed -ri 's|^#?command\s*=.*/lacme-accountd$|command = /run/lacme-wrap /usr/bin/lacme-accountd|' /etc/lacme/lacme.conf
+sed -ri 's|^#?command\s*=.*/lacme/client$|command = /run/lacme-wrap /usr/libexec/lacme/client|' /etc/lacme/lacme.conf
+sed -ri 's|^#?command\s*=.*/lacme/webserver$|command = /run/lacme-wrap /usr/libexec/lacme/webserver|' /etc/lacme/lacme.conf
+sed -ri 's|^#?config\s*=\s*$|config = /etc/lacme/lacme-accountd.conf|' /etc/lacme/lacme.conf
+
+check_status() {
+ local path="$STATUSDIR/$1/status" user="$2" group="$3"
+ UID="$(getent passwd "$user" | cut -sd: -f3)"
+ GID="$(getent group "$group" | cut -sd: -f3)"
+ [ -n "$UID" -a -n "$GID" ] || return 1
+ grep -Ex "Uid:\\s+$UID\\s+$UID\\s+$UID\\s+$UID" "$path" || return 1
+ grep -Ex "Gid:\\s+$GID\\s+$GID\\s+$GID\\s+$GID" "$path" || return 1
+ grep -Ex "Groups:\s+$GID\s*" "$path" || return 1
+}
+check_cwd() {
+ local path="$STATUSDIR/$1/cwd" dir="$2" cwd
+ cwd="$(cat <"$path")" || return 1
+ [ "$cwd" = "$dir" ] || return 1
+}
+
+check_accountd() {
+ local socket_ino stderr prefix="$STATUSDIR/accountd"
+ check_status accountd lacme-account lacme-account || return 1
+ check_cwd accountd / || return 1
+
+ diff --label="a/accountd/environ" --label="b/accountd/environ" \
+ --color=auto --unified "$prefix/environ" - <<-EOF
+ HOME=/nonexistent
+ LOGNAME=lacme-account
+ PATH=/usr/bin:/bin
+ SHELL=/usr/sbin/nologin
+ TERM=$TERM
+ USER=lacme-account
+ EOF
+
+ stderr="$(readlink -e "/proc/$$/fd/2")"
+ socket_ino="$(sed -rn '/^0 .* socket:\[([0-9]+)\]$/ {s//\1/p;q}' "$prefix/fd")"
+ [ -n "$socket_ino" ] || return 1
+ grep -Fxq "0 0700 $UID:$GID socket:[$socket_ino]" "$prefix/fd" || return 1
+ grep -Fxq "1 0700 $UID:$GID socket:[$socket_ino]" "$prefix/fd" || return 1
+ grep -Fxq "2 0700 $UID:$GID $stderr" "$prefix/fd" || return 1
+ sed -ri '\#^[012] #d' "$prefix/fd"
+ ! test -s "$prefix/fd" || return 1
+}
+check_client() {
+ local command="$1" cwd="$2" UID GID stdout stderr prefix="$STATUSDIR/client"
+ check_status client _lacme-client nogroup2
+ check_cwd client "$cwd"
+
+ diff --label="a/client/environ" --label="b/client/environ" \
+ --color=auto --unified "$prefix/environ" - <<-EOF
+ DEBUG=0
+ HOME=/nonexistent
+ LOGNAME=_lacme-client
+ PATH=/usr/bin:/bin
+ SHELL=/usr/sbin/nologin
+ TERM=$TERM
+ USER=_lacme-client
+ EOF
+
+ stdout="$(readlink -e "/proc/$$/fd/1")"
+ stderr="$(readlink -e "/proc/$$/fd/2")"
+ if [ "$command" = "account" ]; then # no pipe
+ grep -Fxq "0 0500 $UID:$GID /dev/null" "$prefix/fd" || return 1
+ grep -Fxq "1 0700 $UID:$GID $stdout" "$prefix/fd" || return 1
+ elif [ "$command" = "order" ]; then
+ grep -Exq "0 0500 $UID:$GID pipe:\[[0-9]+\]" "$prefix/fd" || return 1
+ grep -Exq "1 0300 $UID:$GID pipe:\[[0-9]+\]" "$prefix/fd" || return 1
+ else
+ exit 1
+ fi
+ grep -Fxq "2 0700 $UID:$GID $stderr" "$prefix/fd" || return 1
+ sed -ri '\#^[012] #d' "$prefix/fd"
+
+ grep -Exq "[0-9]+ 0700 $UID:$GID socket:\[[0-9]+\]" "$prefix/fd" || return 1
+ sed -ri '0,\#^[0-9]+ .* socket:\[[0-9]+\]$# {//d}' "$prefix/fd"
+
+ grep -Exq "[0-9]+ 0500 $UID:$GID /etc/lacme/lacme\.conf" "$prefix/fd" || return 1
+ sed -ri '0,\#^[0-9]+ .* /etc/lacme/lacme\.conf$# {//d}' "$prefix/fd"
+ ! test -s "$prefix/fd" || return 1
+}
+check_webserver() {
+ local cwd="$1" UID GID stdout stderr prefix="$STATUSDIR/webserver"
+ check_status webserver _lacme-www nogroup2
+ check_cwd webserver "$cwd"
+
+ diff --label="a/webserver/environ" --label="b/webserver/environ" \
+ --color=auto --unified "$prefix/environ" - <<-EOF
+ DEBUG=0
+ HOME=/nonexistent
+ LOGNAME=_lacme-www
+ PATH=/usr/bin:/bin
+ SHELL=/usr/sbin/nologin
+ TERM=$TERM
+ USER=_lacme-www
+ EOF
+
+ stdout="$(readlink -e "/proc/$$/fd/1")"
+ stderr="$(readlink -e "/proc/$$/fd/2")"
+ grep -Fxq "0 0500 $UID:$GID /dev/null" "$prefix/fd" || return 1
+ grep -Fxq "1 0700 $UID:$GID $stdout" "$prefix/fd" || return 1
+ grep -Fxq "2 0700 $UID:$GID $stderr" "$prefix/fd" || return 1
+ sed -ri '\#^[012] #d' "$prefix/fd"
+
+ grep -Exq "[0-9]+ 0700 $UID:$GID socket:\[[0-9]+\]" "$prefix/fd" || return 1
+ sed -ri '0,\#^[0-9]+ .* socket:\[[0-9]+\]$# {//d}' "$prefix/fd"
+ ! test -s "$prefix/fd" || return 1
+}
+
+lacme account
+check_accountd
+check_client account /
+! test -e "$STATUSDIR/webserver/status" # account 'command' doesn't start the webserver
+
+lacme newOrder
+check_accountd
+challenge_dir="$(cat "$STATUSDIR/webserver/cwd")"
+[ "${challenge_dir#"/tmp/acme-challenge."}" != "$challenge_dir" ] || exit 1
+check_client order "$challenge_dir"
+check_webserver "$challenge_dir"
+
+# the temporary challenge directory is created with permissive mode
+diff --label="a/webserver/cwd" --label="b/webserver/cwd" \
+ --color=auto --unified "$STATUSDIR/webserver/cwd-mod" - <<-EOF
+ _lacme-client:root 0755
+EOF
+
+# vim: set filetype=sh :
diff --git a/tests/nginx-proxy b/tests/nginx-proxy
new file mode 100644
index 0000000..b16fd10
--- /dev/null
+++ b/tests/nginx-proxy
@@ -0,0 +1,35 @@
+# Use Nginx as reverse proxy for lacme's internal webserver using the
+# provided snippet
+
+# bind the webserver to the default listening address
+sed -i 's|^listen\s*=|#&|' /etc/lacme/lacme.conf
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends nginx-light curl
+cat >/etc/nginx/sites-enabled/default <<-EOF
+ server {
+ listen 80 default_server;
+ server_name _;
+ include /etc/lacme/nginx.conf;
+ }
+EOF
+nginx
+
+# ensure that requests to the root URI and challenge URIs yield 502 Bad Gateway before starting the webserver
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/)"; [ $rv -eq 502 ]
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/foo)"; [ $rv -eq 502 ]
+
+lacme --debug newOrder 2>"$STDERR" || fail
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+grepstderr -Fq "Forking ACME webserver bound to /run/lacme-www.socket, child PID "
+grepstderr -Fq "Forking lacme-accountd, child PID "
+grepstderr -Fq "Forking /usr/libexec/lacme/client, child PID "
+grepstderr -Fq "Shutting down lacme-accountd"
+grepstderr -Fq "Shutting down ACME webserver bound to /run/lacme-www.socket"
+grepstderr -Eq "Incoming connection: GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+$"
+
+# ensure nginx was indeed used to serve challenge responses (Let's Encrypt caches validation results)
+grep -E "\"GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+\" 200 .* \(([^)]+; )*Let's Encrypt validation server(; [^)]+)*\)\"$" \
+ /var/log/nginx/access.log
+
+# vim: set filetype=sh :
diff --git a/tests/nginx-static b/tests/nginx-static
new file mode 100644
index 0000000..4bb0e0d
--- /dev/null
+++ b/tests/nginx-static
@@ -0,0 +1,48 @@
+# Use Nginx to directly serve ACME challenge responses using the
+# provided snippet
+
+# bind the webserver to the default listening address
+sed -i 's|^listen\s*=|#&|' /etc/lacme/lacme.conf
+
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends nginx-light curl
+cat >/etc/nginx/sites-enabled/default <<-EOF
+ server {
+ listen 80 default_server;
+ server_name _;
+ include /etc/lacme/nginx-static.conf;
+ }
+EOF
+nginx
+
+# 'challenge-directory' set to a non-existent directory
+sed -ri 's|^#?challenge-directory\s*=.*|challenge-directory = /var/www/acme-challenge|' /etc/lacme/lacme.conf
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fqx "opendir(/var/www/acme-challenge): No such file or directory"
+
+# ensure that requests to the root URI and challenge URIs respectively yield 403 Forbidden (no index) and 404 Not Found
+install -o_lacme-client -gwww-data -m0750 -d /var/www/acme-challenge
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/)"; [ $rv -eq 403 ]
+rv="$(curl -w"%{http_code}" -so/dev/null http://127.0.0.1/.well-known/acme-challenge/foo)"; [ $rv -eq 404 ]
+
+# 'challenge-directory' set to a non-empty directory
+touch /var/www/acme-challenge/.stamp
+! lacme newOrder 2>"$STDERR" || fail
+grepstderr -Fqx "Error: Refusing to use non-empty challenge directory /var/www/acme-challenge"
+
+rm -f /var/www/acme-challenge/.stamp
+lacme --debug newOrder 2>"$STDERR" || fail
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+ngrepstderr -Fq "Forking ACME webserver"
+grepstderr -Fq "Using existing webserver on /var/www/acme-challenge"
+grepstderr -Fq "Forking lacme-accountd, child PID "
+grepstderr -Fq "Forking /usr/libexec/lacme/client, child PID "
+grepstderr -Fq "Shutting down lacme-accountd"
+ngrepstderr -Fq "Shutting down ACME webserver"
+ngrepstderr -Eq "Incoming connection( from \S+)?: GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+$"
+
+# ensure nginx was indeed used to serve challenge responses (Let's Encrypt caches validation results)
+grep -E "\"GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+\" 200 .* \(([^)]+; )*Let's Encrypt validation server(; [^)]+)*\)\"$" \
+ /var/log/nginx/access.log
+
+# vim: set filetype=sh :
diff --git a/tests/old-accountd b/tests/old-accountd
new file mode 100644
index 0000000..b44f7ec
--- /dev/null
+++ b/tests/old-accountd
@@ -0,0 +1,30 @@
+# IPC test between lacme(8) and ancient lacme-accountd(1) 0.2 from Debian jessie
+
+adduser --disabled-password \
+ --home /home/lacme-account \
+ --gecos "lacme account user" \
+ --quiet lacme-account
+
+install -olacme-account -glacme-account -Ddm0700 -- ~lacme-account/.config/lacme
+chown lacme-account: /etc/lacme/account.key
+
+cat >~lacme-account/.config/lacme/lacme-accountd.conf <<-EOF
+ privkey = file:/etc/lacme/account.key
+EOF
+
+echo "deb http://deb.debian.org/debian stretch main" >>/etc/apt/sources.list
+DEBIAN_FRONTEND="noninteractive" apt update
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends \
+ --reinstall --allow-downgrades \
+ -oDPkg::Options::="--force-confdef" -oDPkg::Options::="--force-overwrite" \
+ lacme-accountd/stretch
+
+SOCKET=~lacme-account/S.lacme
+runuser -u lacme-account -- lacme-accountd --socket="$SOCKET" & PID=$!
+lacme --socket="$SOCKET" account
+lacme --socket="$SOCKET" newOrder
+
+kill $PID
+wait
+
+# vim: set filetype=sh :
diff --git a/tests/old-lacme b/tests/old-lacme
new file mode 100644
index 0000000..fa7d827
--- /dev/null
+++ b/tests/old-lacme
@@ -0,0 +1,36 @@
+# IPC test between recent lacme-accountd(1) and ancient lacme(8) 0.5 from Debian buster
+# (we don't try earlier versions as we need v2 support of the ACME API)
+
+adduser --disabled-password \
+ --home /home/lacme-account \
+ --gecos "lacme account user" \
+ --quiet lacme-account
+
+install -olacme-account -glacme-account -Ddm0700 -- ~lacme-account/.config/lacme
+chown lacme-account: /etc/lacme/account.key
+mv -f /usr/share/lacme/ca-certificates.crt /usr/share/lacme/ca-certificates.crt.back
+
+cat >~lacme-account/.config/lacme/lacme-accountd.conf <<-EOF
+ privkey = file:/etc/lacme/account.key
+EOF
+
+echo "deb http://deb.debian.org/debian buster main" >>/etc/apt/sources.list
+DEBIAN_FRONTEND="noninteractive" apt update
+DEBIAN_FRONTEND="noninteractive" apt install -y --no-install-recommends \
+ --reinstall --allow-downgrades \
+ -oDPkg::Options::="--force-confdef" -oDPkg::Options::="--force-overwrite" \
+ lacme/buster
+
+# restore staging environment
+mv -f /usr/share/lacme/ca-certificates.crt.back /usr/share/lacme/ca-certificates.crt
+
+SOCKET=~lacme-account/S.lacme
+runuser -u lacme-account -- lacme-accountd --socket="$SOCKET" & PID=$!
+sed -ri "s/^\[accountd]$/#&/" /etc/lacme/lacme.conf # https://bugs.debian.org/955767
+lacme --socket="$SOCKET" account
+lacme --socket="$SOCKET" newOrder
+
+kill $PID
+wait
+
+# vim: set filetype=sh :
diff --git a/tests/register b/tests/register
new file mode 100644
index 0000000..0273377
--- /dev/null
+++ b/tests/register
@@ -0,0 +1,8 @@
+# Register new account key (or update the contact/ToS URI)
+
+lacme account --tos-agreed --register "mailto:noreply+lacme=$$@guilhem.org"
+
+# should return info about the account
+lacme account
+
+# vim: set filetype=sh :
diff --git a/tests/spec-expansion b/tests/spec-expansion
new file mode 100644
index 0000000..a268637
--- /dev/null
+++ b/tests/spec-expansion
@@ -0,0 +1,130 @@
+# %-specifiers expansion
+
+# lacme --config=, all specifiers, root privileges
+! lacme --config="%C %E %t %h %T %g %G %u %U %%.conf" account 2>"$STDERR" || fail
+grepstderr -Fxq "Can't open /var/cache /etc /run /root /tmp root 0 root 0 %.conf: No such file or directory"
+
+# lacme --config=, all specifiers, root privileges, defined XDG_*
+! env XDG_CACHE_HOME=/foo/cache XDG_CONFIG_HOME=/foo/config XDG_RUNTIME_DIR=/foo/run HOME=/foo/home USER=myuser TMPDIR=/foo/tmp \
+ lacme --config="%C %E %t %h %T %g %G %u %U %%.conf" account 2>"$STDERR" || fail
+grepstderr -Fxq "Can't open /var/cache /etc /run /root /foo/tmp root 0 root 0 %.conf: No such file or directory"
+
+# lacme --config=, all specifiers, non-root, unset XDG_RUNTIME_DIR
+! runuser -u nobody -- lacme --config="%C %E %t %h %T %g %G %u %U %%.conf" account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Undefined expansion %t in \"%C %E %t %h %T %g %G %u %U %%.conf\""
+
+# lacme --config=, all specifiers, non-root, defined XDG_RUNTIME_DIR, no other XDG_*
+! runuser -u nobody -g www-data -- env XDG_RUNTIME_DIR=/foo/run \
+ lacme --config="%C %E %t %h %T %g %G %u %U %%.conf" account 2>"$STDERR" || fail
+grepstderr -Fxq "Can't open /nonexistent/.cache /nonexistent/.config /foo/run /nonexistent /tmp www-data 33 nobody 65534 %.conf: No such file or directory"
+
+# lacme --config=, all specifiers, non-root, defined XDG_*
+! runuser -u nobody -- env XDG_CACHE_HOME=/foo/cache XDG_CONFIG_HOME=/foo/config XDG_RUNTIME_DIR=/foo/run HOME=/foo/home USER=myuser TMPDIR=/foo/tmp \
+ lacme --config="%C %E %t %h %T %g %G %u %U %%.conf" account 2>"$STDERR" || fail
+grepstderr -Fxq "Can't open /foo/cache /foo/config /foo/run /nonexistent /foo/tmp nogroup 65534 nobody 65534 %.conf: No such file or directory"
+
+# lacme --socket=
+! lacme --config="%E/lacme/lacme.conf" --socket="%t/S.lacme2" account --debug 2>"$STDERR" || fail
+grepstderr -Fxq "Using configuration file: /etc/lacme/lacme.conf"
+grepstderr -Fxq "Can't stat /run/S.lacme2: No such file or directory (Is lacme-accountd running?)"
+
+# 'challenge-directory' setting (expands before privilege drop)
+sed -ri 's|^#?challenge-directory\s*=.*|challenge-directory = /nonexistent/%u:%g|' /etc/lacme/lacme.conf
+! lacme newOrder --debug 2>"$STDERR" || fail
+grepstderr -Fq "Using existing webserver on /nonexistent/root:root"
+
+# lacme --config-certs= and 'config-certs' settings (expands before privilege drop)
+! lacme newOrder --debug nonexistent 2>"$STDERR" || fail
+grepstderr -Fxq "Reading /etc/lacme/lacme-certs.conf"
+
+sed -ri 's|^#?config-certs\s*=.*|config-certs = /nonexistent/%u:%g.conf|' /etc/lacme/lacme.conf
+! lacme newOrder --debug nonexistent 2>"$STDERR" || fail
+grepstderr -Fxq "Reading /nonexistent/root:root.conf"
+
+! lacme newOrder --config-certs="%E/lacme/certs.conf.d" --debug nonexistent 2>"$STDERR" || fail
+grepstderr -vFxq "Reading /etc/lacme/lacme-certs.conf"
+grepstderr -Fxq "Reading /etc/lacme/certs.conf.d"
+
+# 'config' setting in [accountd] section (expands after privilege drop)
+sed -ri 's|^#?config\s*=\s*$|config = /nonexistent/%u:%g.conf|' /etc/lacme/lacme.conf
+! lacme account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Failed to open file '/nonexistent/root:root.conf' for reading: No such file or directory"
+
+sed -ri 's|^#?user\s*=\s*$|user = nobody|' /etc/lacme/lacme.conf
+! lacme account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Failed to open file '/nonexistent/nobody:root.conf' for reading: No such file or directory"
+
+# 'command' setting in [accountd] section (expands after privilege drop)
+sed -ri 's|^#?command\s*=.*/lacme-accountd$|command = /usr/bin/lacme-accountd --%u|' /etc/lacme/lacme.conf
+! lacme account 2>"$STDERR" || fail
+grepstderr -Fxq "Unknown option: nobody"
+
+sed -ri 's|^#?command\s*=.*/lacme-accountd .*|command = /nonexistent/%u/%g %u %g|' /etc/lacme/lacme.conf
+! lacme account 2>"$STDERR" || fail
+grepstderr -Eq "^Can't exec \"/nonexistent/nobody/root\": No such file or directory"
+
+
+#######################################################################
+
+# lacme-accountd --config=, all specifiers, root privileges
+! lacme-accountd --config="%C %E %t %h %T %g %G %u %U %%.conf" 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Failed to open file '/var/cache /etc /run /root /tmp root 0 root 0 %.conf' for reading: No such file or directory"
+
+# lacme-accountd --config=, all specifiers, root privileges, defined XDG_*
+! env XDG_CACHE_HOME=/foo/cache XDG_CONFIG_HOME=/foo/config XDG_RUNTIME_DIR=/foo/run HOME=/foo/home USER=myuser TMPDIR=/foo/tmp \
+ lacme-accountd --config="%C %E %t %h %T %g %G %u %U %%.conf" 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Failed to open file '/var/cache /etc /run /root /foo/tmp root 0 root 0 %.conf' for reading: No such file or directory"
+
+# lacme-accountd --config=, all specifiers, non-root, unset XDG_RUNTIME_DIR
+! runuser -u nobody -- lacme-accountd --config="%C %E %t %h %T %g %G %u %U %%.conf" account 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Undefined expansion %t in \"%C %E %t %h %T %g %G %u %U %%.conf\""
+
+# lacme-accountd --config=, all specifiers, non-root, defined XDG_RUNTIME_DIR, no other XDG_*
+! runuser -u nobody -g www-data -- env XDG_RUNTIME_DIR=/foo/run \
+ lacme-accountd --config="%C %E %t %h %T %g %G %u %U %%.conf" 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Failed to open file '/nonexistent/.cache /nonexistent/.config /foo/run /nonexistent /tmp www-data 33 nobody 65534 %.conf' for reading: No such file or directory"
+
+# lacme-accountd --config=, all specifiers, non-root, defined XDG_*
+! runuser -u nobody -- env XDG_CACHE_HOME=/foo/cache XDG_CONFIG_HOME=/foo/config XDG_RUNTIME_DIR=/foo/run HOME=/foo/home USER=myuser TMPDIR=/foo/tmp \
+ lacme-accountd --config="%C %E %t %h %T %g %G %u %U %%.conf" 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Failed to open file '/foo/cache /foo/config /foo/run /nonexistent /foo/tmp nogroup 65534 nobody 65534 %.conf' for reading: No such file or directory"
+
+# lacme-accountd --privkey=
+! lacme-accountd --privkey="file:%h/lacme-accountd.key" --debug 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Can't open /root/lacme-accountd.key: No such file or directory"
+
+# lacme-accountd, default socket location
+lacme-accountd --debug 2>"$STDERR" & PID=$!
+sleep 1
+kill $PID || fail
+wait || fail
+grepstderr -Fxq "Using configuration file: /etc/lacme/lacme-accountd.conf"
+grepstderr -Fxq "Starting lacme Account Key Manager at /run/S.lacme"
+grepstderr -Fxq "Unlinking /run/S.lacme"
+
+# lacme-accountd --config= --socket= --privkey=
+ln -s lacme-accountd.conf /etc/lacme/accountd.conf
+lacme-accountd --config="%E/lacme/accountd.conf" --socket="%t/S.lacme2" --privkey="file:%E/lacme/account.key" --debug 2>"$STDERR" & PID=$!
+sleep 1
+kill $PID || fail
+wait || fail
+grepstderr -Fxq "Using configuration file: /etc/lacme/accountd.conf"
+grepstderr -Fxq "Starting lacme Account Key Manager at /run/S.lacme2"
+grepstderr -Fxq "Unlinking /run/S.lacme2"
+
+# lacme-accountd, custom 'socket' setting
+sed -ri 's|^#?socket\s*=.*|socket = %t/S.lacme3|' /etc/lacme/lacme-accountd.conf
+lacme-accountd --debug 2>"$STDERR" & PID=$!
+sleep 1
+kill $PID || fail
+wait || fail
+grepstderr -Fxq "Using configuration file: /etc/lacme/lacme-accountd.conf"
+grepstderr -Fxq "Starting lacme Account Key Manager at /run/S.lacme3"
+grepstderr -Fxq "Unlinking /run/S.lacme3"
+
+# lacme-accountd, custom 'privkey' setting
+sed -ri 's|^privkey\s*=.*|privkey = file:%h/lacme-accountd.key|' /etc/lacme/lacme-accountd.conf
+! lacme-accountd --debug 2>"$STDERR" || fail
+grepstderr -Fxq "Error: Can't open /root/lacme-accountd.key: No such file or directory"
+
+# vim: set filetype=sh :
diff --git a/tests/webservers b/tests/webservers
new file mode 100644
index 0000000..0cadea5
--- /dev/null
+++ b/tests/webservers
@@ -0,0 +1,16 @@
+# Multiple webservers (each binding to a different address)
+
+sed -ri 's|^#?listen\s*=.*|listen = [::]:80 127.0.0.1:8080|' /etc/lacme/lacme.conf
+lacme --debug newOrder 2>"$STDERR" || fail newOrder
+test /etc/lacme/simpletest.rsa.crt -nt /etc/lacme/simpletest.rsa.key
+
+grepstderr -Fq "Forking ACME webserver bound to [::]:80, child PID "
+grepstderr -Fq "Forking ACME webserver bound to 127.0.0.1:8080, child PID "
+grepstderr -Fq "Forking lacme-accountd, child PID "
+grepstderr -Fq "Forking /usr/libexec/lacme/client, child PID "
+grepstderr -Fq "Shutting down lacme-accountd"
+grepstderr -Fq "Shutting down ACME webserver bound to [::]:80"
+grepstderr -Fq "Shutting down ACME webserver bound to 127.0.0.1:8080"
+grepstderr -Eq "Incoming connection( from \S+)?: GET /\.well-known/acme-challenge/\S+ HTTP/[0-9.]+$"
+
+# vim: set filetype=sh :
diff --git a/webserver b/webserver
index c16737f..94b86ca 100755
--- a/webserver
+++ b/webserver
@@ -3,7 +3,7 @@
#----------------------------------------------------------------------
# ACME client written with process isolation and minimal privileges in mind
# (webserver component)
-# Copyright © 2015-2017 Guilhem Moulin <guilhem@fripost.org>
+# Copyright © 2015-2021 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