From e65f8ef106223a45986fa39ea41df8c354000509 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Mon, 12 Jan 2026 20:50:32 +0100 Subject: [PATCH] nixos/acme: drop email requirement Let's Encrypt does not require email addreses any longer, so we should allow users not to provide any. Unsetting the email adress will change the account hash and Lego will start using a dummy email address instead. The address is hardcoded in the Lego source code in the userIDPlaceholder constant. We then verify in tests that changing between no email address and the placeholder address does not create a new account nor rotate the previous certificate. This is supported since Lego 4.30.1. https://github.com/go-acme/lego/commit/bc163db9edd23bbfc3521086c0b570f468b9a87b --- nixos/modules/security/acme/default.nix | 31 +++++++++++++++++-------- nixos/tests/acme/http01-builtin.nix | 22 +++++++++++++++++- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/nixos/modules/security/acme/default.nix b/nixos/modules/security/acme/default.nix index 1b262e285e19..ed4b23a99690 100644 --- a/nixos/modules/security/acme/default.nix +++ b/nixos/modules/security/acme/default.nix @@ -15,9 +15,21 @@ let numCerts = lib.length (builtins.attrNames cfg.certs); _24hSecs = 60 * 60 * 24; + # The placerholder email address used by lego in case none gets passed + placeholderEmail = "noemail@example.com"; + # Used to make unique paths for each cert/account config set mkHash = with builtins; val: lib.substring 0 20 (hashString "sha256" val); - mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}"; + mkAccountHash = + acmeServer: data: + mkHash ( + lib.concatStringsSep " " [ + (toString acmeServer) + data.keyType + (if (data.email != null) then data.email else placeholderEmail) + ] + + ); accountDirRoot = "/var/lib/acme/.lego/accounts/"; # Lockdir is acme-setup.service's RuntimeDirectory. @@ -263,6 +275,8 @@ let "--accept-tos" # Checking the option is covered by the assertions "--path" "." + ] + ++ lib.optionals (data.email != null) [ "--email" data.email ] @@ -544,7 +558,12 @@ let # Check if a new order is needed # We can only renew if the list of domains has not changed. # We also need an account key. Avoids #190493 - if cmp -s domainhash.txt certificates/domainhash.txt && [ -e '${certificateKey}' ] && [ -e 'certificates/${keyName}.crt' ] && [ -n "$(find accounts -name '${data.email}.key')" ]; then + if cmp -s domainhash.txt certificates/domainhash.txt && [ -e '${certificateKey}' ] && \ + [ -e 'certificates/${keyName}.crt' ] && \ + [ -n "$(find accounts -name '${ + if (data.email != null) then data.email else placeholderEmail + }.key')" ]; + then # Even if a cert is not expired, it may be revoked by the CA. # Try to renew, and silently fail if the cert is not expired. # Avoids #85794 and resolves #129838 @@ -1062,14 +1081,6 @@ in certs = lib.attrValues cfg.certs; in [ - { - assertion = cfg.defaults.email != null || lib.all (certOpts: certOpts.email != null) certs; - message = '' - You must define `security.acme.certs..email` or - `security.acme.defaults.email` to register with the CA. Note that using - many different addresses for certs may trigger account rate limits. - ''; - } { assertion = cfg.acceptTerms; message = '' diff --git a/nixos/tests/acme/http01-builtin.nix b/nixos/tests/acme/http01-builtin.nix index a82ef6f5652a..0bd816b24aa2 100644 --- a/nixos/tests/acme/http01-builtin.nix +++ b/nixos/tests/acme/http01-builtin.nix @@ -51,7 +51,14 @@ in }; accountchange.configuration = { - security.acme.certs."${config.networking.fqdn}".email = "admin@example.test"; + # Providing an email address is optional + security.acme.certs."${config.networking.fqdn}".email = null; + }; + + emailplaceholder.configuration = { + # but Lego will default to this email address, which should not + # result in any change when configured + security.acme.certs."${config.networking.fqdn}".email = "noemail@example.com"; }; keytype.configuration = { @@ -154,6 +161,7 @@ in certName = nodes.builtin.networking.fqdn; caDomain = nodes.acme.test-support.acme.caDomain; in + # python '' ${(import ./utils.nix).pythonUtils} @@ -212,11 +220,23 @@ in hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem") # Has to do a full run to register account, which creates new certs. assert hash != hash_after, "Certificate was not renewed" + hash = hash_after + + builtin.succeed("systemctl stop renew-triggered.target") + switch_to(builtin, "emailplaceholder") + builtin.wait_for_unit("renew-triggered.target") + + # Check that there are still two account directories + builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2") + hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem") + assert hash == hash_after, "Implicit to explicit email placeholder renewed the certificate" + # Remove the new account directory builtin.succeed( "cd /var/lib/acme/.lego/accounts" " && ls -1 --sort=time | tee /dev/stderr | head -1 | xargs rm -rf" ) + # old_hash will be used in the preservation tests later old_hash = hash_after