Merge branch 'dkim-key-management' into 'master'

Add support for DKIM key management

Closes #341

See merge request simple-nixos-mailserver/nixos-mailserver!484
This commit is contained in:
Martin Weinelt
2026-03-11 23:42:22 +00:00
11 changed files with 512 additions and 86 deletions
+2 -3
View File
@@ -46,6 +46,8 @@ SNM branch corresponding to your NixOS version.
* [x] Via ClamAV
* DKIM Signing
* [x] Via Rspamd
* [x] Automatic key generation
* [x] Multiple selectors per Domain
* User Management
* [x] Declarative user management
* [x] Declarative password management
@@ -66,9 +68,6 @@ SNM branch corresponding to your NixOS version.
* [ ] [Autoconfig](https://web.archive.org/web/20210624004729/https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration)
* [ ] [Autodiscovery](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019)
* [ ] [Mobileconfig](https://support.apple.com/guide/profile-manager/distribute-profiles-manually-pmdbd71ebc9/mac)
* DKIM Signing
* [ ] Allow per domain selectors
* [ ] Allow passing DKIM signing keys
* Improve the Forwarding Experience
* [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html)
* User management
+142 -28
View File
@@ -917,31 +917,33 @@ in
'';
};
dkimSigning = mkOption {
type = types.bool;
dkim = {
enable = mkEnableOption "DKIM signing" // {
default = true;
description = ''
Whether to activate dkim signing.
'';
};
dkimSelector = mkOption {
type = types.str;
default = "mail";
description = ''
The DKIM selector.
'';
};
dkimKeyDirectory = mkOption {
keyDirectory = mkOption {
type = types.path;
default = "/var/dkim";
description = ''
The DKIM directory.
The path where DKIM siging keys are stored.
'';
};
dkimKeyType = mkOption {
defaults = {
selector = mkOption {
type = types.str;
default = "mail";
description = ''
The default selector used to reference and lookup DKIM keys.
This value should most likely not be changed. Instead manage
{option}`mailserver.dkim.domains.<name>.selectors` to sign with one
or multiple DKIM key pairs and manage migrations.
'';
};
keyType = mkOption {
type = types.enum [
"rsa"
"ed25519"
@@ -952,29 +954,134 @@ in
introduced in RFC6376 (2018).
:::{warning}
Ed25519 DKIM keys are currently not recommended for primary use, as
various DKIM validators out there lack support and consider the keypair invalid.
Ed25519 DKIM keys are currently not recommended for sole use, as
various DKIM validators out there lack support and consider the
keypair invalid.
:::
If you have already deployed a key with a different type than specified
here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get
this package to generate a key with the new type, you will either have to
change the selector or delete the old key file.
This value should most likely not be changed. Once DKIM keys for
domain and selector are generated changing this value will not
regenerate the keypair. Instead create a new selector and configure
{option}`mailserver.dkim.domains.<name>.selectors.<name>.keyType`.
'';
};
dkimKeyBits = mkOption {
keyLength = mkOption {
type = types.int;
default = 2048;
description = ''
How many bits in generated DKIM keys. RFC8301 suggests a minimum RSA key length of 2048 bit.
The default key length used for generating new DKIM keys.
If you have already deployed a key with a different number of bits than specified
here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get
this package to generate a key with the new number of bits, you will either have to
change the selector or delete the old key file.
Only applies for RSA keys, Ed25519 keys use a fixed key length.
Per [RFC8301 3.2] the minimum RSA key length should be at least
2048 bit.
This value should most likely not be changed. Once DKIM keys for
domain and selector are generated changing this value will not
regenerate the keypair. Instead create a new selector and configure
{option}`mailserver.dkim.domains.<name>.selectors.<name>.keyLength`.
[RFC8301 3.2]: https://datatracker.ietf.org/doc/html/rfc8301#section-3.2
'';
};
};
domains = mkOption {
description = "DKIM configuration per domain.";
type = types.attrsOf (
types.submodule ({
options = {
selectors = mkOption {
description = ''
DKIM selectors used for signing outgoing mail for this domain.
When no selector is configured a default selector will be
created with settings inherited from {option}`mailserver.defaults.dkim <mailserver.dkim.defaults.keyLength>`.
'';
type = types.attrsOf (
types.submodule ({
options = {
keyType = mkOption {
type =
with types;
nullOr (enum [
"rsa"
"ed25519"
]);
default = null;
example = "rsa";
description = ''
The key type used for generating this DKIM keypair.
:::{warning}
Ed25519 DKIM keys are currently not recommended for sole use, as
various DKIM validators out there lack support and consider the
keypair invalid.
:::
This option is mutually exclusive with `keyFile`.
'';
};
keyLength = mkOption {
type = with types; nullOr int;
default = null;
example = 2048;
description = ''
The key length used for generating this DKIM key.
Only applies for RSA keys, Ed25519 keys use a fixed key size.
This option is mutually exclusive with `keyFile`.
'';
};
keyFile = mkOption {
type =
with types;
nullOr (pathWith {
inStore = false;
});
default = null;
example = "/run/keys/example.com-dkim-rsa-2026-03.key";
description = ''
Path to an existing DKIM private key file.
DKIM keys can be generated using `rspamadm dkim_keygen`.
This option is mutually exclusive with `keyType` and `keyLength`.
'';
};
};
})
);
default = { };
example = lib.literalExpression ''
{
"mail" = {
# inherit defaults from mailserver.dkim.defaults
};
"rsa-2026-03".keyFile = "/run/keys/example.com-dkim-rsa-2026-03.key";
};
'';
};
};
})
);
default = { };
example = lib.literalExpression ''
{
"example.com".selectors = {
"mail" = {
# inherit defaults from mailserver.dkim.defaults
};
"rsa-2026-03".keyFile = "/run/keys/example.com-dkim-rsa-2026-03.key";
};
};
'';
};
};
dmarcReporting = {
enable = mkOption {
@@ -1468,6 +1575,13 @@ in
(mkRemovedOptionModule [ "mailserver" "smtpdForbidBareNewline" ] ''
The workaround for the SMTP Smuggling attack is default enabled in Postfix >3.9. Use `services.postfix.config.smtpd_forbid_bare_newline` if you need to deviate from its default.
'')
(mkRenamedOptionModule [ "mailserver" "dkimSigning" ] [ "mailserver" "dkim" "enable" ])
(mkRenamedOptionModule [ "mailserver" "dkimKeyDirectory" ] [ "mailserver" "dkim" "keyDirectory" ])
(mkRenamedOptionModule
[ "mailserver" "dkimSelector" ]
[ "mailserver" "dkim" "defaults" "selector" ]
)
(mkRenamedOptionModule [ "mailserver" "dkimKeyType" ] [ "mailserver" "dkim" "defaults" "keyType" ])
(mkRenamedOptionModule [ "mailserver" "dmarcReporting" "domain" ] [ "mailserver" "systemDomain" ])
(mkRenamedOptionModule
[ "mailserver" "dmarcReporting" "organizationName" ]
+4 -4
View File
@@ -21,7 +21,7 @@ Maildir.
To backup spam and ham training data, backup ``/var/lib/redis-rspamd``.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
you specified as ``dkimKeyDirectory``). If you should lose those dont
worry, new ones will be created on the fly. But you will need to repeat
step ``B)5`` and correct all the ``dkim`` keys.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever you
specified as :option:`mailserver.dkim.keyDirectory`). If you should lose those
dont worry, new ones will be created on the fly. But you will need to update
the DKIM TXT records to reflect the new key material.
+201
View File
@@ -0,0 +1,201 @@
DKIM Signing
============
DKIM (DomainKeys Identified Mail) is an email authentication mechanism that
allows a mailserver to digitally sign outgoing emails for a domain. Receiving
mail servers can verify this signature using a public key published in DNS to
confirm the message was authorized by the domain and was not modified during
transit.
How DKIM works in practice
~~~~~~~~~~~~~~~~~~~~~~~~~~
1. ``bob@bar.example`` sends an email to ``alice@foo.example``. The sending
mail server for ``bar.example`` selects one or multiple DKIM keys using a
**selector** (e.g., ``mail``) and creates one or multiple cryptographic
signature for selected headers and the message body, adding a ``DKIM-Signature``
header that references the selector.
2. The message arrives at ``foo.example``. The receiving mail server reads the
``DKIM-Signature`` headers, looks up the public keys for ``bar.example`` for
each of the specified selectors (e.g., ``mail._domainkey.bar.example``), and
verifies that at least one signature matches the message content.
3. With a valid signature, the receiver knows the message was authorized by
``bar.example`` and that the signed headers and body were not modified in
transit. If the content or signed headers were changed, the DKIM verification
fails. The use of selectors allows ``bar.example`` to rotate or migrate keys
without disrupting verification for previously sent messages.
Enabling DKIM Signing
~~~~~~~~~~~~~~~~~~~~~
Because DKIM signing is crucial for reliable mail delivery it is enabled by
default and without further configuration a DKIM keypair will be generated for
each :option:`mailserver.domains` (including :option:`mailserver.srs.domain`,
if set) based on :option:`mailserver.dkim.defaults
<mailserver.dkim.defaults.keyLength>`.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim.enable = true; # enabled by default
};
}
.. note::
If you set up NixOS Mailserver before the `25.11 release`_ your DKIM keys were
generated with 1024 bit RSA and we recommend replacing them with 2048 bit
RSA key material per `RFC8301 3.2`_.
.. _25.11 release: release-notes.html#nixos-25-11
.. _RFC8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
DKIM Key Rotation
~~~~~~~~~~~~~~~~~
DKIM key rotation replaces a domain's signing keys to maintain
strong email authentication and support algorithm upgrades. Rotation is
essential for migrating away from weaker or deprecated algorithms.
Selectors allow multiple keys to coexist during the transition: a new key can
be deployed under a different selector while the old key remains valid for a
limited period to verify messages still in transit. Once all messages signed
with the old key have been delivered, the key can be safely retired, ensuring a
reliable migration without breaking verification.
1. Make the automatically generated key explicit
************************************************
First we need to make sure we keep the current DKIM key configured. If you were
relying on automatically generated keys before, you now need to start explicitly
defining that key, because explicit selector configuration takes precedence.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim.domains = {
"example.com".selectors = {
"${config.mailserver.dkim.defaults.selector}" = { };
};
};
};
}
2. Create the new DKIM keypair
******************************
Next we need to create a new DKIM key with a unique selector, you can
for example choose the current date. Without any settings passed a new
key will be generated from the current :option:`mailserver.dkim.defaults
<mailserver.dkim.defaults.keyLength>`, which should be sufficient.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim = {
enable = true;
domains."example.com".selectors = {
"${config.mailserver.dkim.defaults.selector}" = { };
"rsa-2026-03" = {
keyType = "rsa";
keyLength = 2048;
};
};
};
};
}
.. warning::
While DKIM does support Ed25519 keys (`RFC8463`_), many validators still lack
proper support and may treat Ed25519 key material as invalid. As a result,
mail signed only with Ed25519 DKIM keys may fail verification at some
receivers.
.. _RFC8463: https://datatracker.ietf.org/doc/html/rfc8463
Once this configuration is applied the new keypair will be generated below
:option:`mailserver.dkim.keyDirectory`, which defaults to ``/var/dkim``. The
mailserver then begins signing outgoing mail with this key, so that it is now
signing with two DKIM keys simultaneously.
To allow receiving servers to verify the new DKIM signature its
public key needs to be published into DNS. Look up the public key from
``/var/dkim/example.com.rsa-2026-03.txt`` and create the following DNS record by
substituting selector and public key.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
rsa-2026-03._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=<public key>
.. note::
If you created an Ed25519 key, make sure to set the correct key type: ``k=ed25519``
Now wait for a few minutes and then check DNS propagation to show the value specified.
.. code-block:: console
$ nix-shell -p dig --command "dig @ns1.example.org TXT rsa-2026-03._domainkey.example.com"
You can use https://www.mail-tester.com to test the new DKIM signature passes
validation. They allow you to view the message source where you can check that
the correct number of ``DKIM-Signature`` keys are present in the mail header.
3. Stop signing with the old DKIM keypair
*****************************************
Once validation passes we need to stop signing with the old DKIM keypair, so
mail in transit eventually stops depending on the key material we want to rotate
out. Removing the selector will not remove the key material from disk, but it
will stop using it to sign outgoing mail.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim = {
enable = true;
domains."example.com".selectors = {
"rsa-2026-03" = {
keyType = "rsa";
keyLength = 2048;
};
};
}
}
}
Apply the configuration.
4. Remove the old DKIM selector from DNS
****************************************
.. warning::
Do not remove the DNS records for the old selector immediately. Keeping them
in place is essential to ensure that messages still in transit can be verified
and delivered successfully.
Mail delivery is not always instantaneous. In the worst case, multiple retries
over several days may be required. According to `RFC5321 4.5.4.1`_ delivery
should be retried for at least 4-5 days.
This means messages signed only with the old DKIM key could still be in transit
and rely on the old selector to verify their signatures. To ensure reliable
delivery, we recommend waiting **at least five days** before removing the old
DKIM selector from DNS.
.. _RFC5321 4.5.4.1: https://www.rfc-editor.org/rfc/rfc5321#section-4.5.4.1
+1
View File
@@ -25,6 +25,7 @@ Welcome to NixOS Mailserver's documentation!
:maxdepth: 1
:caption: Features
dkim
fts
ldap
srs
+7
View File
@@ -11,6 +11,12 @@ NixOS 26.05
:option:`mailserver.x509.privateKeyFile` instead. Support for automatic
creation of self-signed certificates has been removed.
Check the updated `setup guide`_ for a basic ACME HTTP-01 example.
- `DKIM key management`_ is now available with multiple concurrent selectors per
domain enabling proper DKIM key rotation. While we still generate a default
key for backwards compatibility we now also support passing pre-created
key material. If your DKIM keys were automatically created before the 25.11
release they are 1024 bit RSA keys and should be rotated out.
See :option:`mailserver.dkim.domains` for further relevant options.
- Cleartext password files can now be configured for login accounts. This
is an alternative to hashed passwords that integrates well with workflows
established by `agenix`_/`sops-nix`_ that instead rely on encryption. This
@@ -18,6 +24,7 @@ NixOS 26.05
See :option:`mailserver.loginAccounts.<name>.passwordFile`.
.. _setup guide: setup-guide.html#setup-the-server
.. _DKIM key management: dkim.html
.. _agenix: https://github.com/ryantm/agenix
.. _sops-nix: https://github.com/Mic92/sops-nix
+14
View File
@@ -53,6 +53,20 @@ in
message = "Configure either an ACME certificate (`mailserver.x509.useACMEHost`) or pass an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
}
]
++ lib.optionals config.mailserver.dkim.enable (
lib.flatten (
lib.mapAttrsToList (
domain: domainAttrs:
lib.mapAttrsToList (selector: selectorAttrs: [
{
assertion =
selectorAttrs.keyFile != null -> (selectorAttrs.keyType == null && selectorAttrs.keyLength == null);
message = "${domain} DKIM selector ${selector} can only use either `keyType`, `keyLength` OR `keyFile` not both.";
}
]) domainAttrs.selectors
) config.mailserver.dkim.domains
)
)
++ lib.optionals config.mailserver.ldap.enable [
{
assertion = config.mailserver.loginAccounts == { };
+1 -1
View File
@@ -450,7 +450,7 @@ in
smtpd_tls_loglevel = "1";
smtpd_milters = smtpdMilters;
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ];
non_smtpd_milters = lib.mkIf cfg.dkim.enable [ "unix:/run/rspamd/rspamd-milter.sock" ];
milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
};
+93 -22
View File
@@ -31,27 +31,80 @@ let
rspamdGroup = config.services.rspamd.group;
createDkimKeypair =
domain:
{
domain,
selector,
type,
bits,
...
}:
let
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
privkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
pubkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.txt";
in
pkgs.writeShellScript "dkim-keygen-${domain}" ''
if [ ! -f "${privateKey}" ]
pkgs.writeShellScript "dkim-keygen-${domain}-${selector}" ''
if [ ! -f "${privkey}" ]
then
export PATH=${lib.makeBinPath [ pkgs.openssl ]}
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
--domain "${domain}" \
--selector "${cfg.dkimSelector}" \
--type "${cfg.dkimKeyType}" \
--bits ${toString cfg.dkimKeyBits} \
--privkey "${privateKey}" > "${publicKey}"
chmod 0644 "${publicKey}"
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen ${
lib.cli.toCommandLineShellGNU { } {
inherit
domain
selector
type
bits
privkey
;
}
} > "${pubkey}"
chmod 0644 "${pubkey}"
echo "Generated key for domain ${domain} and selector ${selector}"
fi
'';
dkimDomains = lib.unique (cfg.domains ++ (lib.optionals cfg.srs.enable [ cfg.srs.domain ]));
mailDomains = lib.unique (
# primary mailserver domains
config.mailserver.domains
# all dkim domains, even extra domains specified
++ lib.attrNames cfg.dkim.domains
# and the srs domain, if one is configured
++ lib.optionals (cfg.srs.domain != null) [ cfg.srs.domain ]
);
dkimKeys = lib.concatMap (
domain:
let
configuredSelectors = config.mailserver.dkim.domains.${domain}.selectors or { };
finalSelectors =
if configuredSelectors == { } then
# synthesize default dkim key, if none configured
{
"${config.mailserver.dkim.defaults.selector}" = {
keyType = null;
keyLength = null;
keyFile = null;
};
}
else
configuredSelectors;
in
lib.mapAttrsToList (selector: settings: rec {
inherit domain selector;
keyFile = settings.keyFile;
keyPath = if keyFile != null then keyFile else "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
bits =
if settings.keyLength != null then
settings.keyLength
else
config.mailserver.dkim.defaults.keyLength;
type =
if settings.keyType != null then settings.keyType else config.mailserver.dkim.defaults.keyType;
}) finalSelectors
) mailDomains;
dkimKeysToGenerate = lib.filter (key: key.keyFile == null) dkimKeys;
dkimKeysByDomain = lib.groupBy (item: item.domain) dkimKeys;
in
{
config = lib.mkIf cfg.enable {
@@ -110,13 +163,31 @@ in
};
"dkim_signing.conf" = {
text = ''
enabled = ${lib.boolToString cfg.dkimSigning};
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
selector = "${cfg.dkimSelector}";
enabled = ${lib.boolToString cfg.dkim.enable};
# Only sign explicitly configured domains
try_fallback = false;
# Allow for usernames w/o domain part
allow_username_mismatch = true;
# Don't normalize DKIM key selection for subdomains
use_esld = false;
domain {
${lib.concatStringsSep "\n\n" (
map (domain: ''
${domain} {
selectors [
${lib.concatStringsSep ",\n" (
map (selector: ''
{
path: "${selector.keyPath}";
selector: "${selector.selector}";
}'') dkimKeysByDomain.${domain}
)}
]
}
'') (lib.attrNames dkimKeysByDomain)
)}
}
'';
};
"dmarc.conf" = {
@@ -183,7 +254,7 @@ in
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkimKeyDirectory}" = {
"${cfg.dkim.keyDirectory}" = {
d = {
# Create /var/dkim owned by rspamd user/group
user = rspamdUser;
@@ -204,9 +275,9 @@ in
{
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
}
(lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair dkimDomains;
ReadWritePaths = [ cfg.dkimKeyDirectory ];
(lib.optionalAttrs cfg.dkim.enable {
ExecStartPre = map createDkimKeypair dkimKeysToGenerate;
ReadWritePaths = [ cfg.dkim.keyDirectory ];
})
];
};
+2 -2
View File
@@ -66,9 +66,9 @@ in
after = [
"dovecot.service"
]
++ lib.optional cfg.dkimSigning "rspamd.service"
++ lib.optional cfg.dkim.enable "rspamd.service"
++ certificateDeps;
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkim.enable "rspamd.service";
};
};
}
+23 -4
View File
@@ -49,7 +49,21 @@
"example2.com"
];
rewriteMessageId = true;
dkimKeyBits = 1535;
dkim = {
defaults.keyLength = 1535;
domains."example2.com".selectors = {
"dkim-rsa" = {
# rsa 1535 bits via defaults
};
"dkim-ed25519" = {
keyType = "ed25519";
keyLength = null;
};
"dkim-file" = {
keyFile = "/run/rspamd/dkim-test.key";
};
};
};
dmarcReporting.enable = true;
loginAccounts = {
@@ -369,6 +383,9 @@
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
server.succeed("rspamadm dkim_keygen > /run/rspamd/dkim-test.key")
server.succeed("chown rspamd: /run/rspamd/dkim-test.key")
client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail")
client.succeed("ls -la ~/ >&2")
@@ -404,10 +421,10 @@
with subtest("dkim has user-specified size"):
server.succeed(
"openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"
"openssl rsa -in /var/dkim/example2.com.dkim-rsa.key -text -noout | grep 'Private-Key: (1535 bit'"
)
with subtest("dkim singing, multiple domains"):
with subtest("dkim signing, multiple domains"):
client.execute("rm ~/mail/*")
# send email from user2 to user1
client.succeed(
@@ -418,7 +435,9 @@
client.succeed("fetchmail --nosslcertck -v")
client.succeed("cat ~/mail/* >&2")
# make sure it is dkim signed
client.succeed("grep DKIM-Signature: ~/mail/*")
client.succeed("grep 's=dkim-rsa' ~/mail/*")
client.succeed("grep 's=dkim-ed25519' ~/mail/*")
client.succeed("grep 's=dkim-file' ~/mail/*")
with subtest("aliases"):
client.execute("rm ~/mail/*")