mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
Add certificate validation and PGP/MIME encryption; rename to pro.thinhha.mailsender
- validation: CertificateValidator checks revocation, expiry, matching user ID, a valid communications encryption subkey and the algorithm policy; uses the earlier expiry when PGPainless and Bouncy Castle disagree - crypto: PgpEncryptor (ASCII armor, hidden recipients for Bcc) - mime: PgpMimeEncryptor builds RFC 3156 multipart/encrypted from the whole CRLF-canonical inner entity, keeping envelope headers in the clear - autoconfigure: validator and encryptor beans - demo: encrypts when a valid key is found, plaintext otherwise - rename groupId and Java packages to pro.thinhha.mailsender Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
44 files changed
+958
-142
No files matched your search
-76
@@ -1,76 +0,0 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
|
||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
/**
|
||||
* Looks up the recipient's key via WKD, then sends {@code demo.count} test mails describing what was found.
|
||||
* <p>
|
||||
* Encryption is not wired in yet (milestones 3–6), so the mails go out in plaintext.
|
||||
*/
|
||||
@Component
|
||||
class TestMailRunner implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
|
||||
|
||||
private final DemoProperties properties;
|
||||
private final KeyDiscovery keyDiscovery;
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, JavaMailSender mailSender) {
|
||||
this.properties = properties;
|
||||
this.keyDiscovery = keyDiscovery;
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws MessagingException {
|
||||
DiscoveryResult discovery = keyDiscovery.discover(properties.to());
|
||||
String summary = describe(discovery);
|
||||
log.info("Key discovery for {}: {}", properties.to(), summary);
|
||||
|
||||
for (int i = 1; i <= properties.count(); i++) {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
|
||||
if (properties.from() != null) {
|
||||
helper.setFrom(properties.from());
|
||||
}
|
||||
helper.setTo(properties.to());
|
||||
helper.setSubject("mailsender test %d/%d".formatted(i, properties.count()));
|
||||
helper.setText("""
|
||||
Test mail %d of %d from the mailsender demo, sent %s.
|
||||
|
||||
Key discovery for %s: %s
|
||||
|
||||
This message was sent in plaintext: PGP/MIME encryption is not implemented yet.
|
||||
""".formatted(i, properties.count(), OffsetDateTime.now(), properties.to(), summary));
|
||||
mailSender.send(message);
|
||||
log.info("Sent test mail {}/{} to {}", i, properties.count(), properties.to());
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(DiscoveryResult result) {
|
||||
return switch (result) {
|
||||
case DiscoveryResult.Found found -> "found via " + found.source() + ", fingerprint(s) "
|
||||
+ found.certificates().stream()
|
||||
.map(c -> HexFormat.of().withUpperCase().formatHex(c.getFingerprint()))
|
||||
.collect(Collectors.joining(", "));
|
||||
case DiscoveryResult.NotFound ignored -> "no key published";
|
||||
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
package pro.thinhha.mailsender.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
package pro.thinhha.mailsender.demo;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
@@ -0,0 +1,123 @@
|
||||
package pro.thinhha.mailsender.demo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
|
||||
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
|
||||
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
|
||||
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
|
||||
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
|
||||
|
||||
/**
|
||||
* Looks up and validates the recipient's key, then sends {@code demo.count} test mails: PGP/MIME-encrypted when a
|
||||
* valid key was found, otherwise in plaintext (logged as a downgrade).
|
||||
* <p>
|
||||
* This calls the building blocks directly; the policy layer and {@code SecureMailSender} (milestones 5–6) will
|
||||
* replace this wiring.
|
||||
*/
|
||||
@Component
|
||||
class TestMailRunner implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
|
||||
|
||||
private final DemoProperties properties;
|
||||
private final KeyDiscovery keyDiscovery;
|
||||
private final CertificateValidator validator;
|
||||
private final PgpMimeEncryptor mimeEncryptor;
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator,
|
||||
PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) {
|
||||
this.properties = properties;
|
||||
this.keyDiscovery = keyDiscovery;
|
||||
this.validator = validator;
|
||||
this.mimeEncryptor = mimeEncryptor;
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws MessagingException, IOException {
|
||||
String to = properties.to();
|
||||
DiscoveryResult discovery = keyDiscovery.discover(to);
|
||||
Optional<OpenPGPCertificate> key = usableKey(discovery, to);
|
||||
|
||||
for (int i = 1; i <= properties.count(); i++) {
|
||||
MimeMessage message = compose(i, discovery, key.isPresent());
|
||||
if (key.isPresent()) {
|
||||
message = mimeEncryptor.encrypt(message, List.of(key.get()), List.of(), mailSender::createMimeMessage);
|
||||
log.info("Sending test mail {}/{} to {} ENCRYPTED to {}", i, properties.count(), to,
|
||||
fingerprint(key.get()));
|
||||
} else {
|
||||
log.warn("Sending test mail {}/{} to {} in PLAINTEXT: no usable key", i, properties.count(), to);
|
||||
}
|
||||
mailSender.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<OpenPGPCertificate> usableKey(DiscoveryResult discovery, String to) {
|
||||
switch (discovery) {
|
||||
case DiscoveryResult.Found found -> {
|
||||
for (OpenPGPCertificate certificate : found.certificates()) {
|
||||
ValidationResult result = validator.validate(certificate, to);
|
||||
switch (result) {
|
||||
case ValidationResult.Valid valid -> {
|
||||
log.info("Key for {} found via {}: {}", to, found.source(), fingerprint(certificate));
|
||||
return Optional.of(valid.certificate());
|
||||
}
|
||||
case ValidationResult.Rejected rejected -> log.warn("Key {} for {} rejected: {} ({})",
|
||||
fingerprint(certificate), to, rejected.reason(), rejected.detail());
|
||||
}
|
||||
}
|
||||
}
|
||||
case DiscoveryResult.NotFound ignored -> log.info("No key published for {}", to);
|
||||
case DiscoveryResult.Unreachable unreachable ->
|
||||
log.warn("Key lookup for {} failed: {}", to, unreachable.reason());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private MimeMessage compose(int index, DiscoveryResult discovery, boolean encrypted) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
|
||||
if (properties.from() != null && !properties.from().isBlank()) {
|
||||
helper.setFrom(properties.from());
|
||||
}
|
||||
helper.setTo(properties.to());
|
||||
helper.setSubject("mailsender test %d/%d".formatted(index, properties.count()));
|
||||
helper.setText("""
|
||||
Test mail %d of %d from the mailsender demo, sent %s.
|
||||
|
||||
Key discovery for %s: %s
|
||||
This message was sent %s.
|
||||
""".formatted(index, properties.count(), OffsetDateTime.now(), properties.to(),
|
||||
describe(discovery), encrypted ? "PGP/MIME-encrypted" : "in plaintext"));
|
||||
return message;
|
||||
}
|
||||
|
||||
private static String describe(DiscoveryResult result) {
|
||||
return switch (result) {
|
||||
case DiscoveryResult.Found found -> "found via " + found.source();
|
||||
case DiscoveryResult.NotFound ignored -> "no key published";
|
||||
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private static String fingerprint(OpenPGPCertificate certificate) {
|
||||
return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,4 @@ demo:
|
||||
|
||||
logging:
|
||||
level:
|
||||
io.github.sneakybugmaster.mailsender: DEBUG
|
||||
pro.thinhha.mailsender: DEBUG
|
||||
@@ -0,0 +1,85 @@
|
||||
package pro.thinhha.mailsender.demo;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.bouncycastle.openpgp.api.OpenPGPKey;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.pgpainless.PGPainless;
|
||||
import org.pgpainless.decryption_verification.ConsumerOptions;
|
||||
import org.pgpainless.decryption_verification.DecryptionStream;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.ServerSetupTest;
|
||||
|
||||
import jakarta.mail.Multipart;
|
||||
import jakarta.mail.internet.ContentType;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
/** The recipient has a (pinned) key, so the demo must deliver a decryptable PGP/MIME message. */
|
||||
@SpringBootTest(properties = {
|
||||
"spring.mail.properties.mail.smtp.auth=false",
|
||||
"spring.mail.properties.mail.smtp.starttls.enable=false",
|
||||
"spring.mail.properties.mail.smtp.starttls.required=false",
|
||||
"spring.mail.username=",
|
||||
"demo.from=demo@mailsender.test",
|
||||
"mail.pgp.wkd.enabled=false"
|
||||
})
|
||||
class EncryptedTestMailTest {
|
||||
|
||||
static final PGPainless API = PGPainless.getInstance();
|
||||
static final OpenPGPKey RECIPIENT = API.generateKey().modernKeyRing("Test Account <test@thinhha.pro>");
|
||||
static GreenMail greenMail;
|
||||
static Path keyFile;
|
||||
|
||||
@BeforeAll
|
||||
static void start() throws Exception {
|
||||
keyFile = Files.createTempFile("recipient", ".asc");
|
||||
Files.writeString(keyFile, RECIPIENT.toCertificate().toAsciiArmoredString());
|
||||
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
|
||||
greenMail.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stop() throws Exception {
|
||||
greenMail.stop();
|
||||
Files.deleteIfExists(keyFile);
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void properties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.mail.host", () -> "127.0.0.1");
|
||||
registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort());
|
||||
registry.add("mail.pgp.static-keys[test@thinhha.pro]", () -> "file:" + keyFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliversDecryptablePgpMime() throws Exception {
|
||||
MimeMessage[] received = greenMail.getReceivedMessages();
|
||||
assertThat(received).hasSize(1);
|
||||
MimeMessage message = received[0];
|
||||
|
||||
assertThat(new ContentType(message.getContentType()).getBaseType()).isEqualTo("multipart/encrypted");
|
||||
assertThat(message.getSubject()).isEqualTo("mailsender test 1/1");
|
||||
Multipart parts = (Multipart) message.getContent();
|
||||
byte[] armored = parts.getBodyPart(1).getInputStream().readAllBytes();
|
||||
|
||||
ByteArrayOutputStream inner = new ByteArrayOutputStream();
|
||||
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
|
||||
.withOptions(ConsumerOptions.get(API).addDecryptionKey(RECIPIENT))) {
|
||||
in.transferTo(inner);
|
||||
}
|
||||
MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray()));
|
||||
assertThat(entity.getContent().toString()).contains("found via STATIC", "PGP/MIME-encrypted");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
package pro.thinhha.mailsender.demo;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -25,7 +25,7 @@ import jakarta.mail.internet.MimeMessage;
|
||||
// keep the test offline: no real WKD lookup
|
||||
"mail.pgp.wkd.enabled=false"
|
||||
})
|
||||
class TestMailRunnerTest {
|
||||
class PlaintextTestMailTest {
|
||||
|
||||
// Started before the Spring context, because the runner sends while the context starts.
|
||||
static GreenMail greenMail;
|
||||
@@ -54,6 +54,6 @@ class TestMailRunnerTest {
|
||||
assertThat(received).hasSize(2);
|
||||
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
|
||||
assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2");
|
||||
assertThat(GreenMailUtil.getBody(received[0])).contains("no key published");
|
||||
assertThat(GreenMailUtil.getBody(received[0])).contains("no key published", "in plaintext");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user