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:
Claude committed 2026-10-12 03:20:33 +00:00
1 parent 213df4c34e
commit a788f7a48a
44 files changed
+958 -142

No files matched your search

+2 -2
View File
@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
@@ -15,7 +15,7 @@
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
</dependency>
<dependency>
@@ -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,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,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");
}
}
@@ -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");
}
}