Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user