75.1 TLS для SMTP: STARTTLS и неявный TLS (465)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
* <ul>
|
||||
* <li>{@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;</li>
|
||||
* <li>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).</li>
|
||||
* </ul>
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user