Repository: Mailu/Mailu

Issue:
ISRG_X1 not included in fullchain.pem (--> DANE validation failed)
Hi folks,
and thanks for this great software.
_Job-so._

## Environment & Version

- docker-compose
- Mailu Version: `2.0`

## Description

- Same issue as #2138
- As ISRG_X1 is not present in /certs/letsencrypt/live/mailu-ecdsa/fullchain.pem, DANE validation failed when TLSA DNS record is `2 1 1 0b9fa5a59eed715c26c1020c711b4f6ec42d58b0015e14337a39dad301c5afc3`

## Replication Steps

```
$ echo | openssl s_client -connect test.mailu.io:25 -starttls smtp -showcerts
CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R3
verify return:1
depth=0 CN = mailu.io
verify return:1
---
Certificate chain
 0 s:CN = mailu.io
   i:C = US, O = Let's Encrypt, CN = R3
   a:PKEY: rsaEncryption, 4096 (bit); sigalg: RSA-SHA256
   v:NotBefore: Feb 19 12:43:20 2024 GMT; NotAfter: May 19 12:43:19 2024 GMT
-----BEGIN CERTIFICATE-----
MIIF/DCCBOSgAwIBAgISA/rJ9CbQM3HoUjwXuZKxe47TMA0GCSqGSIb3DQEBCwUA
MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
EwJSMzAeFw0yNDAyMTkxMjQzMjBaFw0yNDA1MTkxMjQzMTlaMBMxETAPBgNVBAMT
CG1haWx1LmlvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4XEW8iHD
8G3Uj6vB2j5/VgH3PVlAI4vy2a89HBoq48SvTWahxjLDrtQr2fx0eh2AeiZJ1umM
4ezVy1KuN6mPrYOPgFb7PkpYedoezfBSo2IhcKxSUxB11BM8Y2BzU9P+HnMXgtXz
odKWwfLVPlMdLFjUuN9f5ycW7x/poHB5Jqxr0hfMD1uyhFidsfV/hTR8Vee6sdEt
TdOcOt2EFQjuVL2031eRrErZmCvTaDvm1ghPNhBz2Ej0F22xc7RPhx0cXAyQjxcr
nsX44G5/eu8C4bOwpMbL7JIFam4WKcMcZj0TinKLHKJLXYXzC3rQPuRhq6enqNQq
U1449pPjWi6bzcGgSE9Hy31ua7szSXvM0wPQ8wmF3G6w2eOfFixW1OlgHpeMHUYX
D8EypFkoXZnmQYcQSQDcFtuCF4sM7Hg1WXmViGKwBfs3VnebQOhIokoG8UYyIKHU
zsL4tpyvU5wmtdLOheaOUIa/Fn/zoXUei1jRxgZ4HZV0Db+6cNLBj6mgwUSmn5sF
YKLMkCAI+h5r4Zq3xa9j58xX8D1Aan1tMvcYOw9/6HtwLpsAnAOTXa8vbsKTApW0
14iIpaiKiSPBnmpVYv5rnwYE8tz7A8jK1okU7s3AKpPxeTdkhMGoTvl7faXUb9+5
sVodR5AAawFUivNrk89jEYiawf9V+mCZGcUCAwEAAaOCAikwggIlMA4GA1UdDwEB
/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/
BAIwADAdBgNVHQ4EFgQU2ueOB46OgwMwhYgrQ+skLTQIxI0wHwYDVR0jBBgwFoAU
FC6zF7dYVsuuUAlA5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzAB
hhVodHRwOi8vcjMuby5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5p
LmxlbmNyLm9yZy8wMgYDVR0RBCswKYIIbWFpbHUuaW+CDnNldHVwLm1haWx1Lmlv
gg10ZXN0Lm1haWx1LmlvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMIIBBAYKKwYBBAHW
eQIEAgSB9QSB8gDwAHYASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMA
AAGNwZtckQAABAMARzBFAiEAhA0n43Pk/VqxL9XKF3Vob1bExuyBm4HORBPIeV9y
SNwCIDOe93jpDgUPOUFIs4wHUxFPOLzDxp+Ihlc6Zrk8YwroAHYAouK/1h7eLy8H
oNZObTen3GVDsMa1LqLat4r4mm31F9gAAAGNwZtclAAABAMARzBFAiEAqp62nMR4
9lcdOfQYKyPuehqX9uYLRD0rjf39okQSdmsCICs7IB+sCMgsRVlMASg2d2XiC7Q4
3acid1F68YDn71UWMA0GCSqGSIb3DQEBCwUAA4IBAQBgZrlx36RaEXBf/MynUR9Z
Viaxd0OYznENBjdVJh44i3YAHESaySs40j39NpK3DsN0yiB7fLBkuyflu23hxdjf
grRleBdg47jZPzv8ElM9yY/cAHrQje4PM4+LNC7zz0G9KaBPg5ze+kxMqmxLA6fW
yRc9niOzm0xmsRyK4Lyt2xYhEIodNESiMZiVjUnIfMeZeDtwOsDt5XKWddmsng/n
xGImgilsxeyJQ8FFG2yUKka3H3+wic5RAsI6eFJHZwN1aA5o9qen4doMWbOyx8f9
c19qos53h0aM+LfBdF85G+sdlSkZMqSySsBva+JfzDjzzJv77uKCs9QK2owVtNjc
-----END CERTIFICATE-----
 1 s:C = US, O = Let's Encrypt, CN = R3
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
   v:NotBefore: Sep  4 00:00:00 2020 GMT; NotAfter: Sep 15 16:00:00 2025 GMT
-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
nLRbwHOoq7hHwg==
-----END CERTIFICATE-----
---
Server certificate
subject=CN = mailu.io
issuer=C = US, O = Let's Encrypt, CN = R3
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: RSA-PSS
Server Temp Key: X25519, 253 bits
---
SSL handshake has read 3837 bytes and written 428 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 4096 bit
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 0 (ok)
---
250 STARTTLS
DONE
```

## Observed behaviour

Only 2 certificates are returned : 
1. CN = mailu.io
2. C = US, O = Let's Encrypt, CN = R3

## Expected behaviour

Expect the full chain, including root (3 certificates)
1. CN = mailu.io
2. C = US, O = Let's Encrypt, CN = R3
3. C = US, O = Internet Security Research Group, CN = ISRG Root X1

## Suggestion ?

- https://github.com/Mailu/Mailu/commit/0816cb9497f31ca4ee71aebdee97b87e71870cc8 removed `add_DANE_pin` function, maybe should we restore this function ?
- BTW `/certs/letsencrypt/live/mailu/nginx-chain.pem` seems to not be used anywhere ? , at least nginx.conf refers to `fullchain`, not `nginx-chain` files





Discussion:

Resolving patch:
diff --git a/core/nginx/config.py b/core/nginx/config.py
--- a/core/nginx/config.py
+++ b/core/nginx/config.py
@@ -5,6 +5,60 @@
 import sys
 from socrate import system, conf
 
+from cryptography import x509
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+from cryptography.x509.verification import PolicyBuilder, Store, DNSName
+from cryptography.x509.oid import NameOID
+import hashlib
+
+ISRG_ROOT_X1 = x509.load_pem_x509_certificate(b'''-----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-----
+''')
+ISRG_ROOT_X2 = x509.load_pem_x509_certificate(b'''-----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-----
+''')
+
 args = system.set_env()
 log.basicConfig(stream=sys.stderr, level=args.get("LOG_LEVEL", "WARNING"))
 
@@ -29,21 +83,47 @@
     "notls": None
 }[args["TLS_FLAVOR"]]
 
-def format_for_nginx(fullchain, output):
+def format_for_nginx(fullchain, output, strip_CA=args.get('LETSENCRYPT_SHORTCHAIN')):
     """ We may want to strip ISRG Root X1 out """
     if not os.path.exists(fullchain):
         return
-    split = '-----END CERTIFICATE-----\n'
-    with open(fullchain, 'r') as pem:
-        certs = [f'{cert}{split}' for cert in pem.read().split(split) if cert]
-    if len(certs)>2 and args.get('LETSENCRYPT_SHORTCHAIN'):
-        del certs[-1]
-    with open(output, 'w') as pem:
-        pem.write(''.join(certs))
+    chain=[]
+    with open(fullchain, 'rb') as f:
+        chain = x509.load_pem_x509_certificates(f.read())
+    builder = PolicyBuilder().store(Store([ISRG_ROOT_X1, ISRG_ROOT_X2]))
+    verifier = builder.build_server_verifier(DNSName(chain[0].subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value))
+    try:
+        valid_chain = verifier.verify(chain[0], chain[1:])
+    except Exception as e:
+        log.error(e)
+        valid_chain = chain
+    log.info(f'The certificate chain looks as follows for {fullchain}:')
+    indent = '  '
+    has_found_PIN = False
+    for cert in valid_chain:
+        pubkey_der = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
+        digest = hashlib.sha256(pubkey_der).hexdigest()
+        log.info(f'{indent}{cert.subject.rfc4514_string()} {digest}')
+        indent += '  '
+        if digest == '0b9fa5a59eed715c26c1020c711b4f6ec42d58b0015e14337a39dad301c5afc3': # ISRG Root X1
+            log.info('ISRG X1 PIN FOUND!')
+            has_found_PIN = True
+        elif digest == '762195c225586ee6c0237456e2107dc54f1efc21f61a792ebd515913cce68332': # ISRG Root X2
+            log.info('ISRG X2 PIN FOUND!')
+            has_found_PIN = True
+    if not has_found_PIN:
+        log.error('Neither ISRG X1 nor ISRG X2 have been found in the certificate chain. Please check your DANE records.')
+    with open(output, 'wt') as f:
+        for cert in valid_chain:
+            if strip_CA and (cert.subject.rfc4514_string() in ['CN=ISRG Root X1,O=Internet Security Research Group,C=US', 'CN=ISRG Root X2,O=Internet Security Research Group,C=US']):
+                continue
+            f.write(f'{cert.public_bytes(encoding=Encoding.PEM).decode("ascii").strip()}\n')
 
 if args['TLS_FLAVOR'] in ['letsencrypt', 'mail-letsencrypt']:
     format_for_nginx('/certs/letsencrypt/live/mailu/fullchain.pem', '/certs/letsencrypt/live/mailu/nginx-chain.pem')
+    format_for_nginx('/certs/letsencrypt/live/mailu/fullchain.pem', '/certs/letsencrypt/live/mailu/DANE-chain.pem', False)
     format_for_nginx('/certs/letsencrypt/live/mailu-ecdsa/fullchain.pem', '/certs/letsencrypt/live/mailu-ecdsa/nginx-chain.pem')
+    format_for_nginx('/certs/letsencrypt/live/mailu-ecdsa/fullchain.pem', '/certs/letsencrypt/live/mailu-ecdsa/DANE-chain.pem', False)
 
 if args["TLS"] and not all(os.path.exists(file_path) for file_path in args["TLS"]):
     print("Missing cert or key file, disabling TLS")
@@ -55,4 +135,4 @@ def format_for_nginx(fullchain, output):
 conf.jinja("/conf/nginx.conf", args, "/etc/nginx/nginx.conf")
 conf.jinja("/dovecot_conf/login.lua", args, "/etc/dovecot/login.lua")
 conf.jinja("/dovecot_conf/proxy.conf", args, "/etc/dovecot/proxy.conf")
-os.system("killall -HUP nginx dovecot")
+os.system("killall -q -HUP nginx dovecot")


Test patch: