Files
reactive-mail-server/backend/src/main/java/com/mailserver/service/DkimService.java
T
jambotron α 165edf1a1e Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)
- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея,
  MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера
- отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке;
  Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM)
- DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается
- права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../
- секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта,
  admin123 и /api/simulate/receive — только профиль dev
- веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health;
  docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080
- gradle-wrapper.jar был в .gitignore — сборка не запускалась
- тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:37:43 +01:00

210 lines
9.1 KiB
Java

package com.mailserver.service;
import jakarta.annotation.PostConstruct;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.apache.james.jdkim.DKIMVerifier;
import org.apache.james.jdkim.api.PublicKeyRecordRetriever;
import org.apache.james.jdkim.api.SignatureRecord;
import org.apache.james.jdkim.exceptions.FailException;
import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.simplejavamail.utils.mail.dkim.Canonicalization;
import org.simplejavamail.utils.mail.dkim.DkimMessage;
import org.simplejavamail.utils.mail.dkim.DkimSigner;
import org.simplejavamail.utils.mail.dkim.SigningAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
/**
* DKIM (RFC 6376): signs our outgoing mail and verifies incoming mail.
* Signing — simple-java-mail utils-mail-dkim (rsa-sha256, relaxed/relaxed); verification — Apache James jDKIM
* with the sender's public key from DNS. The key is generated once and kept in {@code keys-dir/dkim-private.pem};
* the DNS record to publish is printed to the log at start and available from {@link #dnsRecord()}.
*/
@Service
public class DkimService {
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
/** Result of checking an incoming message. */
public record Verification(boolean signed, boolean passed, String domain) {
static final Verification NONE = new Verification(false, false, null);
}
private final String domain;
private final String selector;
private final Path keysDir;
private final boolean enabled;
private final DKIMVerifier verifier;
private RSAPrivateKey privateKey;
private String publicKeyBase64;
@Autowired
public DkimService(@Value("${mailserver.dkim.domain:localhost}") String domain,
@Value("${mailserver.dkim.selector:mail}") String selector,
@Value("${mailserver.dkim.keys-dir:./dkim-keys}") String keysDir,
@Value("${mailserver.dkim.enabled:true}") boolean enabled) {
this(domain, selector, Paths.get(keysDir), enabled, new DNSPublicKeyRecordRetriever());
}
// Tests pass their own public key lookup instead of DNS
DkimService(String domain, String selector, Path keysDir, boolean enabled, PublicKeyRecordRetriever keys) {
this.domain = domain.toLowerCase(Locale.ROOT);
this.selector = selector;
this.keysDir = keysDir;
this.enabled = enabled;
this.verifier = new DKIMVerifier(keys);
}
@PostConstruct
public void init() {
if (!enabled) {
log.info("DKIM signing disabled");
return;
}
try {
Files.createDirectories(keysDir);
Path keyFile = keysDir.resolve("dkim-private.pem");
if (Files.exists(keyFile)) {
privateKey = readPrivateKey(Files.readString(keyFile));
log.info("DKIM: key loaded for {}", domain);
} else {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
privateKey = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
String pem = "-----BEGIN PRIVATE KEY-----\n"
+ Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded())
+ "\n-----END PRIVATE KEY-----\n";
Files.writeString(keyFile, pem);
restrict(keyFile);
log.info("DKIM: new key generated for {}", domain);
}
publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyOf(privateKey));
log.info("DKIM DNS record — TXT {}._domainkey.{}: {}", selector, domain, dnsRecord());
} catch (Exception e) {
throw new IllegalStateException("DKIM key cannot be loaded from " + keysDir, e);
}
}
/** TXT value to publish at {@code <selector>._domainkey.<domain>}. */
public String dnsRecord() {
return publicKeyBase64 == null ? null : "v=DKIM1; k=rsa; p=" + publicKeyBase64;
}
/**
* Signs the message when it is from our domain; otherwise returns it unchanged.
* The result is the exact byte sequence to send — do not re-serialize it.
*/
public byte[] sign(byte[] message) throws MessagingException, IOException {
if (!enabled || privateKey == null) {
return message;
}
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(message));
if (mime.getFrom() == null || mime.getFrom().length == 0
|| !(mime.getFrom()[0] instanceof InternetAddress from)
|| !domain.equals(domainOf(from.getAddress()))) {
return message;
}
DkimSigner signer = new DkimSigner(domain, selector, privateKey);
signer.setCheckDomainKey(false); // the DNS record may not be published yet (tests, new server)
signer.setSigningAlgorithm(SigningAlgorithm.SHA256_WITH_RSA);
signer.setHeaderCanonicalization(Canonicalization.RELAXED);
signer.setBodyCanonicalization(Canonicalization.RELAXED);
signer.setLengthParam(false);
signer.setCopyHeaderFields(false);
ByteArrayOutputStream out = new ByteArrayOutputStream(message.length + 1024);
DkimMessage signed = new DkimMessage(mime, signer);
signed.setAllow8bitMIME(true);
signed.writeTo(out);
return out.toByteArray();
}
/** Checks DKIM signatures of an incoming message (public key from the signer's DNS). */
public Verification verify(byte[] message) {
try {
List<SignatureRecord> valid;
synchronized (verifier) { // DKIMVerifier keeps results between calls
verifier.resetResults();
valid = verifier.verify(new ByteArrayInputStream(message));
}
if (valid == null) {
return Verification.NONE;
}
if (valid.isEmpty()) {
return new Verification(true, false, null);
}
return new Verification(true, true, valid.get(0).getDToken().toString().toLowerCase(Locale.ROOT));
} catch (FailException e) {
log.info("DKIM: signature failed: {}", e.getMessage());
return new Verification(true, false, null);
} catch (IOException e) {
log.warn("DKIM: message cannot be read: {}", e.getMessage());
return new Verification(true, false, null);
}
}
static String domainOf(String address) {
int at = address == null ? -1 : address.lastIndexOf('@');
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
}
private static RSAPrivateKey readPrivateKey(String pem) throws IOException {
try (Reader reader = new StringReader(pem); PEMParser parser = new PEMParser(reader)) {
Object object = parser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
// PKCS#8 ("PRIVATE KEY") and the older PKCS#1 ("RSA PRIVATE KEY") written by earlier versions
if (object instanceof PEMKeyPair pair) {
return (RSAPrivateKey) converter.getKeyPair(pair).getPrivate();
}
if (object instanceof PrivateKeyInfo info) {
return (RSAPrivateKey) converter.getPrivateKey(info);
}
throw new IOException("Not an RSA private key: " + (object == null ? "empty file" : object.getClass().getSimpleName()));
}
}
private static byte[] publicKeyOf(RSAPrivateKey key) throws Exception {
RSAPrivateCrtKey crt = (RSAPrivateCrtKey) key;
return KeyFactory.getInstance("RSA")
.generatePublic(new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent()))
.getEncoded();
}
private static void restrict(Path file) {
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException | IOException ignored) {
// not a POSIX file system
}
}
}