Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:07:29 +01:00
co-authored by Junie
commit 0339413d6e
78 changed files with 6808 additions and 0 deletions
@@ -0,0 +1,458 @@
package com.mailserver.service;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.springframework.core.io.ClassPathResource;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.cert.CertificateException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем.
*
* DKIM добавляет криптографическую подпись к каждому исходящему письму,
* чтобы получатель мог проверить, что письмо действительно отправлено
* владельцем домена и не было изменено в пути.
*
* При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл.
* Подписывает заголовки From, Subject, Date, To и тело сообщения.
*/
@Service
public class DkimService {
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
private static final String DKIM_SELECTOR = "default";
@Value("${mailserver.dkim.domain:localhost}")
private String domain;
@Value("${mailserver.dkim.keys-dir:./dkim-keys}")
private String keysDir;
@Value("${mailserver.dkim.enabled:true}")
private boolean enabled;
private PrivateKey privateKey;
private PublicKey publicKey;
private String publicKeyBase64;
@PostConstruct
public void init() {
Security.addProvider(new BouncyCastleProvider());
if (!enabled) {
log.info("DKIM подписи отключены");
return;
}
try {
Path keyDir = Paths.get(keysDir);
Files.createDirectories(keyDir);
Path privateKeyFile = keyDir.resolve("dkim-private.pem");
Path publicKeyFile = keyDir.resolve("dkim-public.pem");
if (Files.exists(privateKeyFile) && Files.exists(publicKeyFile)) {
// Загружаем существующие ключи
loadKeys(privateKeyFile, publicKeyFile);
log.info("DKIM: загружены существующие ключи для домена {}", domain);
} else {
// Генерируем новые ключи
KeyPair keyPair = generateKeyPair();
privateKey = keyPair.getPrivate();
publicKey = keyPair.getPublic();
saveKeys(privateKeyFile, publicKeyFile, keyPair);
log.info("DKIM: сгенерированы новые ключи для домена {}", domain);
}
// Формируем публичный ключ в формате DNS TXT-записи
publicKeyBase64 = encodePublicKeyForDns(publicKey);
log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain);
log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64);
} catch (Exception e) {
log.error("Ошибка инициализации DKIM", e);
}
}
/**
* Генерирует RSA-ключ 2048 бит для DKIM.
*/
private KeyPair generateKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
/**
* Сохраняет ключи в PEM-формат.
*/
private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair)
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
try (FileWriter fw = new FileWriter(privateKeyFile.toFile());
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
pemWriter.writeObject(keyPair.getPrivate());
pemWriter.flush();
}
try (FileWriter fw = new FileWriter(publicKeyFile.toFile());
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
pemWriter.writeObject(keyPair.getPublic());
pemWriter.flush();
}
}
/**
* Загружает ключи из PEM-файлов.
*/
private void loadKeys(Path privateKeyFile, Path publicKeyFile)
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
String pkcs8Private = Files.readString(privateKeyFile);
String derEncoded = pkcs8Private
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(derEncoded);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
privateKey = keyFactory.generatePrivate(spec);
String pkcs8Public = Files.readString(publicKeyFile);
String derEncodedPublic = pkcs8Public
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
byte[] decodedPublic = Base64.getDecoder().decode(derEncodedPublic);
X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(decodedPublic);
publicKey = keyFactory.generatePublic(pubSpec);
}
/**
* Кодирует публичный ключ для DNS TXT-записи.
*/
private String encodePublicKeyForDns(PublicKey key) {
byte[] encoded = key.getEncoded();
return Base64.getEncoder().withoutPadding().encodeToString(encoded);
}
/**
* Подписывает MIME-сообщение DKIM-подписью.
* Добавляет заголовок DKIM-Signature в письмо.
*
* @param mimeMessage MIME-сообщение для подписи
* @throws MessagingException если не удалось добавить заголовок
*/
public void signMessage(MimeMessage mimeMessage) throws MessagingException {
if (!enabled || privateKey == null) {
return;
}
try {
// Извлекаем заголовки для подписи
String from = extractHeader(mimeMessage, "From");
String subject = extractHeader(mimeMessage, "Subject");
String date = extractHeader(mimeMessage, "Date");
String to = extractHeader(mimeMessage, "To");
String messageId = extractHeader(mimeMessage, "Message-ID");
// Извлекаем тело сообщения
String body = extractBody(mimeMessage);
// Формируем канонизированное тело (relaxed канонизация)
String canonicalBody = canonicalizeBody(body);
// Формируем канонизированные заголовки
String headerCanonicalization = "r";
String bodyCanonicalization = "r";
String algorithm = "rsa-sha256";
// Строим заголовки для подписи
StringBuilder headersToSign = new StringBuilder();
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
String[] headerValues = {from, subject, date, to, messageId};
for (int i = 0; i < headerNames.length; i++) {
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
}
// Вычисляем хэш тела
byte[] bodyHash = hashBody(canonicalBody);
// Формируем signing input
String headerCanon = headersToSign.toString();
byte[] signingInput = (headerCanon + "bh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash) + "\rbh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash)).getBytes();
// Подписываем
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
signature.initSign(privateKey);
signature.update(signingInput);
byte[] signatureBytes = signature.sign();
// Формируем DKIM-Signature заголовок
String b64Signature = Base64.getEncoder().withoutPadding().encodeToString(signatureBytes);
String b64BodyHash = Base64.getEncoder().withoutPadding().encodeToString(bodyHash);
StringBuilder dkimHeader = new StringBuilder();
dkimHeader.append("v=1; ");
dkimHeader.append("a=").append(algorithm).append("; ");
dkimHeader.append("d=").append(domain).append("; ");
dkimHeader.append("s=").append(DKIM_SELECTOR).append("; ");
dkimHeader.append("c=").append(headerCanonicalization).append("/").append(bodyCanonicalization).append("; ");
dkimHeader.append("h=").append(String.join(":", headerNames)).append("; ");
dkimHeader.append("bh=").append(b64BodyHash).append("; ");
dkimHeader.append("b=").append(b64Signature);
mimeMessage.setHeader("DKIM-Signature", dkimHeader.toString());
log.debug("DKIM: сообщение подписано для домена {}", domain);
} catch (Exception e) {
log.error("DKIM: ошибка подписи сообщения", e);
// Не бросаем исключение — письмо будет отправлено без подписи
}
}
/**
* Верифицирует DKIM-подпись входящего сообщения.
*
* @param mimeMessage MIME-сообщение с DKIM-подписью
* @return true если подпись валидна, false если подпись отсутствует или невалидна
*/
public boolean verifyMessage(MimeMessage mimeMessage) {
if (!enabled) {
return true; // Если DKIM отключён, считаем все письма валидными
}
try {
String dkimHeader = mimeMessage.getHeader("DKIM-Signature")[0];
if (dkimHeader == null || dkimHeader.isEmpty()) {
log.debug("DKIM: подпись не найдена в сообщении");
return false;
}
// Извлекаем домен из подписи
String domain = extractDkimValue(dkimHeader, "d=");
if (domain == null || domain.isEmpty()) {
log.warn("DKIM: не удалось извлечь домен из подписи");
return false;
}
// В реальном мире здесь мы бы:
// 1. Сделали DNS-запрос для _domainkey.<domain>
// 2. Получили публичный ключ
// 3. Проверили подпись
//
// Для локального сервера мы проверяем, что подпись присутствует
// и имеет корректный формат
String version = extractDkimValue(dkimHeader, "v=");
String algorithm = extractDkimValue(dkimHeader, "a=");
String selector = extractDkimValue(dkimHeader, "s=");
String signature = extractDkimValue(dkimHeader, "b=");
if (version == null || !version.equals("1")) {
log.warn("DKIM: неверная версия подписи");
return false;
}
if (algorithm == null || !algorithm.startsWith("rsa-")) {
log.warn("DKIM: неподдерживаемый алгоритм: {}", algorithm);
return false;
}
if (signature == null || signature.isEmpty()) {
log.warn("DKIM: подпись пуста");
return false;
}
// Проверяем, что подпись — валидный Base64
try {
Base64.getDecoder().decode(signature);
} catch (IllegalArgumentException e) {
log.warn("DKIM: подпись не является валидным Base64");
return false;
}
log.info("DKIM: подпись найдена для домена {} (селектор: {})", domain, selector);
// Если письмо подписано нашим собственным ключом, проверяем полностью
if (domain.equals(this.domain) && privateKey != null) {
return verifyLocalSignature(mimeMessage, dkimHeader);
}
// Для внешних доменов проверяем только формат
return true;
} catch (Exception e) {
log.error("DKIM: ошибка верификации", e);
return false;
}
}
/**
* Верифицирует подпись локального домена.
*/
private boolean verifyLocalSignature(MimeMessage mimeMessage, String dkimHeader) {
try {
String from = extractHeader(mimeMessage, "From");
String subject = extractHeader(mimeMessage, "Subject");
String date = extractHeader(mimeMessage, "Date");
String to = extractHeader(mimeMessage, "To");
String messageId = extractHeader(mimeMessage, "Message-ID");
String body = extractBody(mimeMessage);
String canonicalBody = canonicalizeBody(body);
StringBuilder headersToSign = new StringBuilder();
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
String[] headerValues = {from, subject, date, to, messageId};
for (int i = 0; i < headerNames.length; i++) {
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
}
byte[] bodyHash = hashBody(canonicalBody);
String b64BodyHash = extractDkimValue(dkimHeader, "bh=");
String b64Signature = extractDkimValue(dkimHeader, "b=");
// Проверяем хэш тела
if (b64BodyHash != null) {
byte[] claimedBodyHash = Base64.getDecoder().decode(b64BodyHash);
if (!MessageDigest.isEqual(bodyHash, claimedBodyHash)) {
log.warn("DKIM: хэш тела не совпадает");
return false;
}
}
// Проверяем подпись
String headerCanon = headersToSign.toString();
byte[] signingInput = (headerCanon + "bh=" + b64BodyHash + "\rbh=" + b64BodyHash).getBytes();
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
signature.initVerify(publicKey);
signature.update(signingInput);
byte[] signatureBytes = Base64.getDecoder().decode(b64Signature);
boolean valid = signature.verify(signatureBytes);
if (valid) {
log.info("DKIM: подпись локального домена верифицирована");
} else {
log.warn("DKIM: подпись локального домена не верифицирована");
}
return valid;
} catch (Exception e) {
log.error("DKIM: ошибка верификации локальной подписи", e);
return false;
}
}
/**
* Извлекает значение параметра из DKIM-заголовка.
*/
private String extractDkimValue(String dkimHeader, String param) {
int index = dkimHeader.indexOf(param);
if (index == -1) return null;
int start = index + param.length();
int end = dkimHeader.indexOf(";", start);
if (end == -1) end = dkimHeader.length();
return dkimHeader.substring(start, end).trim();
}
/**
* Извлекает заголовок из MIME-сообщения.
*/
private String extractHeader(MimeMessage mimeMessage, String name) throws MessagingException {
String[] headers = mimeMessage.getHeader(name);
if (headers == null || headers.length == 0) return "";
return headers[0];
}
/**
* Извлекает тело MIME-сообщения.
*/
private String extractBody(MimeMessage mimeMessage) throws MessagingException, IOException {
Object content = mimeMessage.getContent();
if (content instanceof String) {
return (String) content;
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
jakarta.mail.internet.MimeMultipart multipart = (jakarta.mail.internet.MimeMultipart) content;
StringBuilder body = new StringBuilder();
for (int i = 0; i < multipart.getCount(); i++) {
var part = multipart.getBodyPart(i);
String contentType = part.getContentType();
if (contentType != null && contentType.toLowerCase().contains("text")) {
Object partContent = part.getContent();
if (partContent instanceof String) {
body.append(partContent);
}
}
}
return body.toString();
}
return "";
}
/**
* Канонизация тела сообщения (relaxed канонизация).
* Убирает лишние пробелы и переводы строк.
*/
private String canonicalizeBody(String body) {
if (body == null) return "";
// Relaxed: нормализуем CRLF в LF, убираем пробелы в конце строк
return body.replaceAll("\\r\\n", "\n")
.replaceAll("\\r", "\n")
.replaceAll(" +\n", "\n")
.replaceAll("\n+", "\n")
.trim();
}
/**
* Вычисляет SHA-256 хэш тела сообщения.
*/
private byte[] hashBody(String body) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(body.getBytes());
}
/**
* Возвращает публичный ключ в формате DNS TXT-записи.
* Используется для публикации в DNS.
*/
public String getPublicKeyForDns() {
if (publicKeyBase64 == null) return null;
return "v=DKIM1; k=rsa; p=" + publicKeyBase64;
}
/**
* Возвращает домен DKIM.
*/
public String getDomain() {
return domain;
}
/**
* Возвращает селектор DKIM.
*/
public String getSelector() {
return DKIM_SELECTOR;
}
}