From 17d489e40042f6b554675adc3a04271e1e8ed16f Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Thu, 5 Nov 2026 19:42:10 +0100 Subject: [PATCH] =?UTF-8?q?75.1=20TLS=20=D0=B4=D0=BB=D1=8F=20SMTP:=20START?= =?UTF-8?q?TLS=20=D0=B8=20=D0=BD=D0=B5=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20TLS?= =?UTF-8?q?=20(465)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SmtpServer: STARTTLS (RFC 3207) — SslHandler ставится сразу после того, как ответ 220 ушёл открытым текстом; сессия начинается заново; строки, присланные открытым текстом вместе со STARTTLS, не выполняются, соединение закрывается (CVE-2011-0411); свежий декодер строк без остатков открытого текста - неявный TLS на SMTPS_PORT (RFC 8314); AUTH — только внутри TLS (или SMTP_AUTH_WITHOUT_TLS); Received: ESMTPS/ESMTPSA - SmtpTls: свой сертификат (MAIL_TLS_CERT/KEY) или самоподписанный, создаётся один раз в MAIL_TLS_DIR (в образе /app/data/tls); TLS 1.2/1.3 - тесты: STARTTLS + AUTH, подмешанный открытый текст, JavaMail со starttls.required, порт 465, переиспользование сертификата (38 всего) - проверено вживую: openssl s_client -starttls smtp и порт 465, TLSv1.3 α Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- backend/Dockerfile | 4 +- .../mailserver/config/MailServerConfig.java | 38 ++++- .../java/com/mailserver/smtp/SmtpServer.java | 137 ++++++++++++++-- .../java/com/mailserver/smtp/SmtpTls.java | 110 +++++++++++++ .../src/main/resources/application.properties | 7 + .../com/mailserver/smtp/SmtpServerTest.java | 151 +++++++++++++++++- 7 files changed, 431 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/smtp/SmtpTls.java diff --git a/README.md b/README.md index acb570c..b7c1335 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ``` - **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке. +- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется. - **Не открытый релей.** - Письмо на свой домен принимается от кого угодно. - Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`. @@ -59,6 +60,10 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | | `SMTP_PORT` | порт SMTP | `3025` | +| `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` | +| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный | — | +| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) | +| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` | | `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` | | `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` | | `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` | @@ -97,7 +102,8 @@ cd frontend && npm install && npx ng serve # http://loca ## Не сделано -- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети. +- [ ] TLS для IMAP (вместе с переписыванием IMAP). SMTP — сделано: `STARTTLS` и порт 465. +- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный. - [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. diff --git a/backend/Dockerfile b/backend/Dockerfile index 222bda2..b44c927 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -24,11 +24,11 @@ RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test FROM eclipse-temurin:21-jre # Ubuntu image, curl included; own user: "mail" already exists (uid 8) RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \ - && mkdir -p /app/data/attachments /app/data/dkim-keys && chown -R app:app /app/data + && mkdir -p /app/data/attachments /app/data/dkim-keys /app/data/tls && chown -R app:app /app/data WORKDIR /app COPY --from=build /app/backend/build/libs/*.jar app.jar USER app -ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt +ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt MAIL_TLS_DIR=/app/data/tls EXPOSE 8080 3025 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD curl -fsS http://localhost:8080/actuator/health || exit 1 diff --git a/backend/src/main/java/com/mailserver/config/MailServerConfig.java b/backend/src/main/java/com/mailserver/config/MailServerConfig.java index f708a37..70ad73c 100644 --- a/backend/src/main/java/com/mailserver/config/MailServerConfig.java +++ b/backend/src/main/java/com/mailserver/config/MailServerConfig.java @@ -3,12 +3,16 @@ package com.mailserver.config; import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.SmtpServer; +import com.mailserver.smtp.SmtpTls; +import io.netty.handler.ssl.SslContext; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; +import java.nio.file.Path; import java.time.Duration; import java.util.Locale; @@ -17,16 +21,46 @@ import java.util.Locale; @EnableScheduling public class MailServerConfig { + /** + * Certificate for SMTP TLS: mailserver.tls.cert-file + key-file (PEM), or a self-signed one in mailserver.tls.dir. + * mailserver.tls.enabled=false — no TLS at all. + */ + @Bean + public SmtpTlsContext smtpTlsContext(@Value("${mailserver.tls.enabled:true}") boolean enabled, + @Value("${mailserver.tls.cert-file:}") String certFile, + @Value("${mailserver.tls.key-file:}") String keyFile, + @Value("${mailserver.tls.dir:./tls}") String dir, + @Value("${mailserver.smtp.hostname:localhost}") String hostname) throws Exception { + return new SmtpTlsContext(enabled ? SmtpTls.serverContext(certFile, keyFile, Path.of(dir), hostname) : null); + } + + public record SmtpTlsContext(SslContext context) { + } + @Bean(initMethod = "start", destroyMethod = "stop") @ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) - public SmtpServer smtpServer(MailRouter router, + public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, @Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, @Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls, @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { - return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router); + return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout, + tls.context(), false), router); + } + + /** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */ + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}") + public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, + @Value("${mailserver.smtp.host:0.0.0.0}") String host, + @Value("${mailserver.smtps.port}") int port, + @Value("${mailserver.smtp.hostname:localhost}") String hostname, + @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, + @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { + return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout, + tls.context(), true), router); } /** diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java index 987fe99..7c18a13 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java @@ -1,13 +1,22 @@ package com.mailserver.smtp; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPipeline; import io.netty.handler.codec.LineBasedFrameDecoder; +import io.netty.handler.codec.MessageToMessageDecoder; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslHandler; import io.netty.handler.timeout.ReadTimeoutHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import reactor.netty.Connection; import reactor.netty.DisposableServer; +import reactor.netty.NettyOutbound; import reactor.netty.tcp.TcpServer; import java.io.ByteArrayOutputStream; @@ -32,7 +41,10 @@ import java.util.concurrent.TimeUnit; *
  • the message is stored or queued before the client gets 250 — on failure 451 and the client retries;
  • *
  • database and DNS work runs on a worker thread, not on the event loop.
  • * - * No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network). + * TLS (RFC 3207 STARTTLS, or implicit TLS on a separate port, RFC 8314): with a {@code tls} context STARTTLS is + * offered; AUTH only over TLS, or without it when {@code authWithoutTls} is on (a closed network: VM, docker network). + * After STARTTLS the session starts over (new EHLO), and lines sent in plain text together with STARTTLS are not + * executed — the connection is closed (command injection, CVE-2011-0411). */ public class SmtpServer { @@ -42,9 +54,32 @@ public class SmtpServer { static final int MAX_LINE = 64 * 1024; static final int MAX_RECIPIENTS = 100; private static final int MAX_BAD_COMMANDS = 20; + // Compared by identity: TLS starts right after exactly this reply is written + private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n"); + /** + * @param tls certificate for STARTTLS / implicit TLS; null — no TLS + * @param implicitTls TLS from the first byte (port 465), no STARTTLS + */ public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls, - Duration idleTimeout) { + Duration idleTimeout, SslContext tls, boolean implicitTls) { + + public Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls, + Duration idleTimeout) { + this(host, port, hostname, maxMessageBytes, authWithoutTls, idleTimeout, null, false); + } + } + + /** A line of input and whether it came over TLS. */ + record Line(byte[] bytes, boolean tls) { + } + + /** Marks each line: decoded behind an SslHandler (encrypted) or not. */ + private static final class LineTagger extends MessageToMessageDecoder { + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List out) { + out.add(new Line(ByteBufUtil.getBytes(frame), ctx.pipeline().get(SslHandler.class) != null)); + } } private final Settings settings; @@ -57,32 +92,70 @@ public class SmtpServer { } public void start() { - server = TcpServer.create() + if (settings.implicitTls() && settings.tls() == null) { + throw new IllegalStateException("Implicit TLS needs a certificate"); + } + TcpServer tcp = TcpServer.create() .host(settings.host()) .port(settings.port()) .doOnConnection(connection -> connection .addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS)) - .addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))) + .addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false)) + .addHandlerLast("smtp-tag", new LineTagger())); + if (settings.implicitTls()) { + tcp = tcp.secure(spec -> spec.sslContext(settings.tls())); + } + server = tcp .handle((inbound, outbound) -> { + Connection[] connection = {null}; String[] remoteIp = {"unknown"}; inbound.withConnection(c -> { + connection[0] = c; if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) { remoteIp[0] = address.getAddress().getHostAddress(); } }); - Session session = new Session(remoteIp[0]); - Flux replies = inbound.receive().asByteArray() + Session session = new Session(remoteIp[0], settings.implicitTls()); + Flux replies = inbound.receiveObject().cast(Line.class) .concatMap(session::onLine) .onErrorResume(e -> { log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString()); return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n"); }) .takeUntil(reply -> session.closing); - return outbound.sendString(Flux.concat( - Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII); + // One reply at a time, each written before the next line is handled: after "220 Ready to start TLS" + // the TLS handler goes in only when the reply has left in plain text + return Flux.concat(Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies) + .concatMap(reply -> send(outbound, reply) + .then(Mono.defer(() -> reply == STARTTLS_READY ? startTls(connection[0], session) : Mono.empty()))) + .then(); }) .bindNow(Duration.ofSeconds(10)); - log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname()); + log.info("SMTP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(), + settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS"); + } + + private static Mono send(NettyOutbound outbound, String reply) { + return outbound.sendString(Mono.just(reply), StandardCharsets.US_ASCII).then(); + } + + /** TLS handler in front, fresh line decoder (no plain-text leftovers mixed into encrypted input). */ + private Mono startTls(Connection connection, Session session) { + ChannelPipeline pipeline = connection.channel().pipeline(); + SslHandler ssl = settings.tls().newHandler(connection.channel().alloc()); + pipeline.replace("smtp-lines", "smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false)); + pipeline.addFirst("smtp-tls", ssl); + return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> { + if (future.isSuccess()) { + log.debug("SMTP {}: TLS {}", session.remoteIp, ssl.engine().getSession().getProtocol()); + sink.success(); + } else { + log.info("SMTP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString()); + session.closing = true; + connection.dispose(); + sink.success(); + } + })); } public int port() { @@ -99,6 +172,7 @@ public class SmtpServer { private final class Session { final String remoteIp; + boolean tls; State state = State.COMMAND; String helo; boolean extended; @@ -111,12 +185,17 @@ public class SmtpServer { int badCommands; volatile boolean closing; - Session(String remoteIp) { + Session(String remoteIp, boolean tls) { this.remoteIp = remoteIp; + this.tls = tls; } - Mono onLine(byte[] raw) { - byte[] line = stripCr(raw); + Mono onLine(Line input) { + if (tls && !input.tls()) { + // Sent in plain text after STARTTLS, before the handshake: never execute it + return Mono.error(new IllegalStateException("plain-text input after STARTTLS")); + } + byte[] line = stripCr(input.bytes()); return switch (state) { case DATA -> onDataLine(line); case AUTH_PLAIN -> authPlain(ascii(line)); @@ -142,7 +221,7 @@ public class SmtpServer { } case "NOOP" -> reply("250 2.0.0 OK"); case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message"); - case "STARTTLS" -> reply("454 4.7.0 TLS not available"); + case "STARTTLS" -> startTls(arg); case "QUIT" -> { closing = true; yield reply("221 2.0.0 " + settings.hostname() + " Bye"); @@ -167,15 +246,41 @@ public class SmtpServer { sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n"); sb.append("250-8BITMIME\r\n"); sb.append("250-PIPELINING\r\n"); - if (settings.authWithoutTls()) { + if (settings.tls() != null && !tls) { + sb.append("250-STARTTLS\r\n"); + } + if (authAllowed()) { sb.append("250-AUTH PLAIN LOGIN\r\n"); } sb.append("250 ENHANCEDSTATUSCODES\r\n"); return Mono.just(sb.toString()); } + private boolean authAllowed() { + return tls || settings.authWithoutTls(); + } + + private Mono startTls(String arg) { + if (settings.tls() == null) { + return reply("454 4.7.0 TLS not available"); + } + if (tls) { + return bad("503 5.5.1 TLS already active"); + } + if (!arg.isEmpty()) { + return reply("501 5.5.4 Syntax: STARTTLS"); + } + // RFC 3207 4.2: everything known about the client is forgotten, a new EHLO is expected + tls = true; + helo = null; + extended = false; + authenticatedUser = null; + resetTransaction(); + return Mono.just(STARTTLS_READY); + } + private Mono auth(String arg) { - if (!settings.authWithoutTls()) { + if (!authAllowed()) { return reply("538 5.7.11 Encryption required for requested authentication mechanism"); } if (helo == null || !extended) { @@ -369,7 +474,7 @@ public class SmtpServer { private byte[] withReceivedHeader(byte[] message) { String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n" - + "\tby " + settings.hostname() + " with " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\r\n" + + "\tby " + settings.hostname() + " with " + (tls ? "ESMTPS" : "ESMTP") + (authenticatedUser != null ? "A" : "") + ";\r\n" + "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n"; byte[] header = received.getBytes(StandardCharsets.US_ASCII); byte[] all = new byte[header.length + message.length]; diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpTls.java b/backend/src/main/java/com/mailserver/smtp/SmtpTls.java new file mode 100644 index 0000000..879eb42 --- /dev/null +++ b/backend/src/main/java/com/mailserver/smtp/SmtpTls.java @@ -0,0 +1,110 @@ +package com.mailserver.smtp; + +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; + +/** + * Certificate for SMTP TLS (STARTTLS and implicit TLS): + *
      + *
    • {@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;
    • + *
    • otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused — + * enough for encryption between our own services; other servers deliver over it too (MX delivery does not + * check certificates, RFC 7435 opportunistic TLS).
    • + *
    + */ +public final class SmtpTls { + + private static final Logger log = LoggerFactory.getLogger(SmtpTls.class); + static final String CERT_FILE = "smtp-cert.pem"; + static final String KEY_FILE = "smtp-key.pem"; + + private SmtpTls() { + } + + public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception { + boolean givenCert = certFile != null && !certFile.isBlank(); + boolean givenKey = keyFile != null && !keyFile.isBlank(); + if (givenCert != givenKey) { + throw new IllegalStateException("TLS: set both the certificate and the key file, or neither"); + } + Path cert; + Path key; + if (givenCert) { + cert = Path.of(certFile); + key = Path.of(keyFile); + log.info("SMTP TLS: certificate {}", cert); + } else { + cert = dir.resolve(CERT_FILE); + key = dir.resolve(KEY_FILE); + if (!Files.exists(cert) || !Files.exists(key)) { + selfSigned(hostname, cert, key); + log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir); + } else { + log.info("SMTP TLS: self-signed certificate {}", cert); + } + } + return SslContextBuilder.forServer(cert.toFile(), key.toFile()) + .protocols("TLSv1.3", "TLSv1.2") + .build(); + } + + static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048, new SecureRandom()); + KeyPair pair = generator.generateKeyPair(); + X500Name name = new X500Name("CN=" + hostname); + Instant now = Instant.now(); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, + new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))), + Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic()); + builder.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, hostname))); + X509Certificate certificate = new JcaX509CertificateConverter() + .getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate()))); + + Files.createDirectories(keyFile.toAbsolutePath().getParent()); + write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true); + write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false); + } + + private static String pem(String type, byte[] der) { + return "-----BEGIN " + type + "-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der) + + "\n-----END " + type + "-----\n"; + } + + private static void write(Path file, String text, boolean secret) throws IOException { + Files.writeString(file, text, StandardCharsets.US_ASCII); + if (secret) { + try { + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException ignored) { + // not a POSIX file system + } + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 6573d18..92feb9b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -41,6 +41,13 @@ mailserver.smtp.port=${SMTP_PORT:3025} mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost} mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false} mailserver.smtp.max-message-bytes=26214400 +# TLS (75.1): STARTTLS on the SMTP port; implicit TLS on SMTPS_PORT (465) if set. Certificate: MAIL_TLS_CERT + MAIL_TLS_KEY +# (PEM, PKCS#8 key), otherwise a self-signed one created once in MAIL_TLS_DIR. MAIL_TLS=false — no TLS +mailserver.tls.enabled=${MAIL_TLS:true} +mailserver.tls.cert-file=${MAIL_TLS_CERT:} +mailserver.tls.key-file=${MAIL_TLS_KEY:} +mailserver.tls.dir=${MAIL_TLS_DIR:./tls} +mailserver.smtps.port=${SMTPS_PORT:0} # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) mailserver.outbound.mode=${OUTBOUND_MODE:off} diff --git a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java index 75a565d..9aaf68e 100644 --- a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java +++ b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java @@ -8,11 +8,18 @@ import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.Socket; +import java.nio.file.Path; +import java.security.cert.X509Certificate; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; @@ -214,15 +221,131 @@ class SmtpServerTest { assertThat(SmtpServer.path("")).isNull(); } + // ---------- TLS (75.1) ---------- + + @TempDir + Path tlsDir; + + private SmtpServer tlsServer(boolean implicit) throws Exception { + server.stop(); + server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false, + Duration.ofSeconds(30), SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), implicit), backend); + server.start(); + return server; + } + + @Test + void startTlsThenAuthAndTheSessionStartsOver() throws Exception { + tlsServer(false); + try (Client client = new Client(server.port())) { + client.read(); + client.send("EHLO laptop"); + List plain = client.readReplyLines(); + assertThat(plain).contains("250-STARTTLS").noneMatch(line -> line.contains("AUTH")); + assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538"); + assertThat(client.send("MAIL FROM:")).startsWith("250"); + + assertThat(client.send("STARTTLS")).isEqualTo("220 2.0.0 Ready to start TLS"); + client.startTls(); + + // RFC 3207: MAIL FROM before STARTTLS is forgotten, EHLO again + assertThat(client.send("MAIL FROM:")).startsWith("503"); + client.send("EHLO laptop"); + List secure = client.readReplyLines(); + assertThat(secure).contains("250-AUTH PLAIN LOGIN").noneMatch(line -> line.contains("STARTTLS")); + assertThat(client.send("STARTTLS")).startsWith("503"); + assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("235"); + client.send("MAIL FROM:"); + client.send("RCPT TO:"); + client.send("DATA"); + assertThat(client.send("Subject: tls\r\n\r\nhi\r\n.")).startsWith("250"); + } + assertThat(backend.messages.get(0)).contains("with ESMTPSA;"); + } + + // CVE-2011-0411: commands sent in plain text right after STARTTLS must not run inside the TLS session + @Test + void plainTextInjectedAfterStartTlsIsNotExecuted() throws Exception { + tlsServer(false); + try (Client client = new Client(server.port())) { + client.read(); + client.send("EHLO laptop"); + client.readReplyLines(); + client.write("STARTTLS\r\nMAIL FROM:\r\nRCPT TO:\r\n"); + assertThat(client.read()).isEqualTo("220 2.0.0 Ready to start TLS"); + client.startTls(); + String reply = client.read(); + assertThat(reply == null || reply.startsWith("421")).as(reply).isTrue(); + } + assertThat(backend.envelopes).isEmpty(); + } + + @Test + void realClientRequiringStartTlsDelivers() throws Exception { + tlsServer(false); + Properties props = new Properties(); + props.put("mail.smtp.host", "127.0.0.1"); + props.put("mail.smtp.port", String.valueOf(server.port())); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.required", "true"); + props.put("mail.smtp.ssl.trust", "*"); + props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1 + props.put("mail.smtp.auth", "true"); + Session session = Session.getInstance(props); + MimeMessage message = new MimeMessage(session); + message.setFrom(new InternetAddress("alice@example.test")); + message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test")); + message.setSubject("Over STARTTLS"); + message.setText("hello"); + + Transport.send(message, "alice@example.test", "secret"); + + assertThat(backend.envelopes).hasSize(1); + assertThat(backend.envelopes.get(0).authenticatedUser()).isEqualTo("alice@example.test"); + assertThat(backend.messages.get(0)).contains("with ESMTPSA;"); + } + + @Test + void implicitTlsPortWorksWithoutStartTls() throws Exception { + tlsServer(true); + Properties props = new Properties(); + props.put("mail.smtp.host", "127.0.0.1"); + props.put("mail.smtp.port", String.valueOf(server.port())); + props.put("mail.smtp.ssl.enable", "true"); + props.put("mail.smtp.ssl.trust", "*"); + props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1 + props.put("mail.smtp.auth", "true"); + Session session = Session.getInstance(props); + MimeMessage message = new MimeMessage(session); + message.setFrom(new InternetAddress("alice@example.test")); + message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test")); + message.setSubject("Over 465"); + message.setText("hello"); + + Transport.send(message, "alice@example.test", "secret"); + + assertThat(backend.messages.get(0)).contains("with ESMTPSA;"); + } + + @Test + void selfSignedCertificateIsCreatedOnceAndReused() throws Exception { + SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"); + String first = java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE)); + SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"); + + assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE))).isEqualTo(first); + assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.KEY_FILE))).startsWith("-----BEGIN PRIVATE KEY-----"); + } + private static String b64(String text) { return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)); } /** Line-oriented test client. */ static final class Client implements AutoCloseable { - private final Socket socket; - private final BufferedReader in; - private final OutputStream out; + private Socket socket; + private BufferedReader in; + private OutputStream out; Client(int port) throws Exception { socket = new Socket("127.0.0.1", port); @@ -231,6 +354,28 @@ class SmtpServerTest { out = socket.getOutputStream(); } + /** Upgrade the connection to TLS (any certificate is trusted — the test server's is self-signed). */ + void startTls() throws Exception { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[]{new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }}, null); + SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true); + ssl.setUseClientMode(true); + ssl.startHandshake(); + socket = ssl; + in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8)); + out = ssl.getOutputStream(); + } + void write(String text) throws Exception { out.write(text.getBytes(StandardCharsets.UTF_8)); out.flush();