Безопасность, 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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DkimServiceTest {
|
||||
|
||||
private static final String MESSAGE = "From: Alice <alice@ours.test>\r\nTo: bob@theirs.test\r\nSubject: Hello\r\n"
|
||||
+ "Date: Mon, 2 Nov 2026 10:00:00 +0000\r\nMessage-ID: <1@ours.test>\r\nMIME-Version: 1.0\r\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\nHello Bob,\r\nsee you.\r\n";
|
||||
|
||||
@TempDir
|
||||
Path keys;
|
||||
|
||||
/** Signer and a verifier that finds the signer's DNS record — like the receiving side would. */
|
||||
private DkimService[] pair() {
|
||||
DkimService signer = new DkimService("ours.test", "mail", keys, true, (method, selector, token) -> List.of());
|
||||
signer.init();
|
||||
DkimService verifier = new DkimService("other.test", "mail", keys.resolve("verifier"), false,
|
||||
(method, selector, token) -> "mail".contentEquals(selector) && "ours.test".contentEquals(token)
|
||||
? List.of(signer.dnsRecord()) : List.of());
|
||||
return new DkimService[]{signer, verifier};
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureFromOurDomainVerifies() throws Exception {
|
||||
DkimService[] p = pair();
|
||||
byte[] signed = p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String text = new String(signed, StandardCharsets.UTF_8);
|
||||
assertThat(text).startsWith("DKIM-Signature:").contains("c=relaxed/relaxed").contains("a=rsa-sha256")
|
||||
.contains("d=ours.test").contains("s=mail");
|
||||
DkimService.Verification result = p[1].verify(signed);
|
||||
assertThat(result.signed()).isTrue();
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.domain()).isEqualTo("ours.test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedBodyFailsVerification() throws Exception {
|
||||
DkimService[] p = pair();
|
||||
String signed = new String(p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
|
||||
byte[] tampered = signed.replace("see you.", "send money.").getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
DkimService.Verification result = p[1].verify(tampered);
|
||||
assertThat(result.signed()).isTrue();
|
||||
assertThat(result.passed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsignedMessageIsReportedAsNone() {
|
||||
DkimService.Verification result = pair()[1].verify(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(result.signed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mailFromAnotherDomainIsNotSigned() throws Exception {
|
||||
byte[] foreign = MESSAGE.replace("alice@ours.test", "eve@elsewhere.test").getBytes(StandardCharsets.UTF_8);
|
||||
assertThat(pair()[0].sign(foreign)).isEqualTo(foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyIsKeptAcrossRestartsAndOldPkcs1FilesLoad() throws Exception {
|
||||
DkimService first = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||
first.init();
|
||||
DkimService second = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||
second.init();
|
||||
assertThat(second.dnsRecord()).isEqualTo(first.dnsRecord()).startsWith("v=DKIM1; k=rsa; p=");
|
||||
|
||||
// the previous version wrote "BEGIN RSA PRIVATE KEY" (PKCS#1) and could not read it back
|
||||
Path legacy = keys.resolve("legacy");
|
||||
Files.createDirectories(legacy);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
RSAPrivateKey key = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||
try (var writer = Files.newBufferedWriter(legacy.resolve("dkim-private.pem"));
|
||||
var pem = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(writer)) {
|
||||
pem.writeObject(key);
|
||||
}
|
||||
assertThat(Files.readString(legacy.resolve("dkim-private.pem"))).contains("BEGIN RSA PRIVATE KEY");
|
||||
DkimService loaded = new DkimService("ours.test", "mail", legacy, true, (m, s, t) -> List.of());
|
||||
loaded.init();
|
||||
assertThat(loaded.dnsRecord()).isNotNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user