diff options
48 files changed, 2656 insertions, 449 deletions
@@ -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 @@ -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 @@ -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 @@ -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----- @@ -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. # @@ -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 @@ -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; } @@ -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 : @@ -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 |