mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
Add metrics, protected headers and pgpkeys actuator endpoint
- metrics (optional Micrometer): per-recipient outcome/policy/reason counter, refused counter, key lookup timer below the cache, cache size gauge - SendReport.RecipientOutcome gains a structured PlaintextReason - protected headers (RFC 9788, mail.pgp.protected-headers, off by default): real headers inside the encrypted part, outer Subject "[...]" - encrypting keeps the caller's Message-ID - pgpkeys endpoint (optional Actuator): inspect policy, lookup and key validity per address; evict one or all cached lookups - KeyDiscoveryCustomizer and SecureMailSenderCustomizer extension points Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
22 files changed
+767
-22
No files matched your search
+54
-8
@@ -7,6 +7,7 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
@@ -19,6 +20,7 @@ import jakarta.activation.DataHandler;
|
||||
import jakarta.mail.Header;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.ContentType;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.util.ByteArrayDataSource;
|
||||
@@ -28,16 +30,29 @@ import jakarta.mail.util.ByteArrayDataSource;
|
||||
* <p>
|
||||
* The whole inner MIME entity (its {@code Content-*} headers and body, including attachments) is encrypted, not
|
||||
* just the text. Envelope headers (From, To, Cc, Subject, Date, ...) are copied to the outer message unencrypted.
|
||||
* <p>
|
||||
* With protected headers (RFC 9788) the inner entity also carries the real From/To/Cc/Reply-To/Date/Message-ID/
|
||||
* Subject and {@code protected-headers="v1"}, and the outer Subject is replaced by {@value #OBSCURED_SUBJECT}.
|
||||
* Clients without support show {@value #OBSCURED_SUBJECT} as the subject, so this is off by default.
|
||||
*/
|
||||
public final class PgpMimeEncryptor {
|
||||
|
||||
private static final Session PARSE_SESSION = Session.getInstance(new Properties());
|
||||
private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII);
|
||||
public static final String OBSCURED_SUBJECT = "[...]";
|
||||
private static final List<String> PROTECTED_HEADERS =
|
||||
List.of("From", "To", "Cc", "Reply-To", "Date", "Message-ID", "Subject");
|
||||
|
||||
private final PgpEncryptor encryptor;
|
||||
private final boolean protectHeaders;
|
||||
|
||||
public PgpMimeEncryptor(PgpEncryptor encryptor) {
|
||||
this(encryptor, false);
|
||||
}
|
||||
|
||||
public PgpMimeEncryptor(PgpEncryptor encryptor, boolean protectHeaders) {
|
||||
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
|
||||
this.protectHeaders = protectHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,33 +66,58 @@ public final class PgpMimeEncryptor {
|
||||
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage)
|
||||
throws MessagingException, IOException {
|
||||
MimeMessage source = snapshot(original);
|
||||
byte[] ciphertext = encryptor.encrypt(innerEntity(source), recipients, hiddenRecipients);
|
||||
byte[] ciphertext = encryptor.encrypt(innerEntity(source, protectHeaders), recipients, hiddenRecipients);
|
||||
|
||||
MimeMessage outer = newMessage.get();
|
||||
copyEnvelopeHeaders(source, outer);
|
||||
if (protectHeaders && outer.getHeader("Subject") != null) {
|
||||
outer.setHeader("Subject", OBSCURED_SUBJECT);
|
||||
}
|
||||
outer.setContent(pgpMimeBody(ciphertext));
|
||||
outer.saveChanges();
|
||||
return outer;
|
||||
}
|
||||
|
||||
/** Serializes and re-parses the message so headers are final and the encoded body is available. */
|
||||
/**
|
||||
* Serializes and re-parses the message so headers are final and the encoded body is available. Keeps the
|
||||
* caller's Message-ID, which {@code saveChanges()} would otherwise regenerate.
|
||||
*/
|
||||
private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
|
||||
String messageId = original.getMessageID();
|
||||
original.saveChanges();
|
||||
if (messageId != null) {
|
||||
original.setHeader("Message-ID", messageId);
|
||||
}
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
original.writeTo(buffer);
|
||||
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
|
||||
}
|
||||
|
||||
/** The {@code Content-*} headers plus the still-encoded body, with CRLF line endings. */
|
||||
static byte[] innerEntity(MimeMessage source) throws MessagingException, IOException {
|
||||
/**
|
||||
* The {@code Content-*} headers (plus the protected headers, if enabled) and the still-encoded body, with CRLF
|
||||
* line endings.
|
||||
*/
|
||||
static byte[] innerEntity(MimeMessage source, boolean protectHeaders) throws MessagingException, IOException {
|
||||
ByteArrayOutputStream entity = new ByteArrayOutputStream();
|
||||
if (protectHeaders) {
|
||||
for (String name : PROTECTED_HEADERS) {
|
||||
String[] values = source.getHeader(name);
|
||||
if (values != null) {
|
||||
for (String value : values) {
|
||||
writeHeader(entity, name + ": " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Enumeration<String> lines = source.getAllHeaderLines();
|
||||
while (lines.hasMoreElements()) {
|
||||
String line = lines.nextElement();
|
||||
if (isContentHeader(line)) {
|
||||
entity.write(line.getBytes(StandardCharsets.UTF_8));
|
||||
entity.write('\r');
|
||||
entity.write('\n');
|
||||
if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) {
|
||||
ContentType type = new ContentType(source.getContentType());
|
||||
type.setParameter("protected-headers", "v1");
|
||||
writeHeader(entity, "Content-Type: " + type);
|
||||
} else if (isContentHeader(line)) {
|
||||
writeHeader(entity, line);
|
||||
}
|
||||
}
|
||||
entity.write('\r');
|
||||
@@ -88,6 +128,12 @@ public final class PgpMimeEncryptor {
|
||||
return toCrlf(entity.toByteArray());
|
||||
}
|
||||
|
||||
private static void writeHeader(ByteArrayOutputStream entity, String line) throws IOException {
|
||||
entity.write(line.getBytes(StandardCharsets.UTF_8));
|
||||
entity.write('\r');
|
||||
entity.write('\n');
|
||||
}
|
||||
|
||||
private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
|
||||
Enumeration<Header> headers = source.getAllHeaders();
|
||||
while (headers.hasMoreElements()) {
|
||||
|
||||
+32
@@ -102,6 +102,38 @@ class PgpMimeEncryptorTest {
|
||||
assertThat(original.getContent()).isEqualTo("plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectedHeadersMoveTheSubjectInside() throws Exception {
|
||||
PgpMimeEncryptor protecting = new PgpMimeEncryptor(new PgpEncryptor(), true);
|
||||
MimeMessage original = textMessage("body");
|
||||
original.setHeader("Message-ID", "<fixed@example.com>");
|
||||
|
||||
MimeMessage wire = sendAndReceive(protecting.encrypt(original, List.of(ALICE.toCertificate()), List.of(),
|
||||
() -> new MimeMessage(SESSION)));
|
||||
|
||||
assertThat(wire.getSubject()).isEqualTo(PgpMimeEncryptor.OBSCURED_SUBJECT);
|
||||
assertThat(original.getMessageID()).as("caller's Message-ID is kept").isEqualTo("<fixed@example.com>");
|
||||
byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
|
||||
MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE)));
|
||||
assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isEqualTo("v1");
|
||||
assertThat(inner.getHeader("Subject")).containsExactly("Quarterly numbers");
|
||||
assertThat(inner.getHeader("To")).containsExactly("alice@example.org");
|
||||
assertThat(inner.getHeader("Message-ID")).containsExactly("<fixed@example.com>");
|
||||
assertThat(inner.getContent()).isEqualTo("body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void headersAreNotProtectedByDefault() throws Exception {
|
||||
MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(textMessage("body"), List.of(ALICE.toCertificate()),
|
||||
List.of(), () -> new MimeMessage(SESSION)));
|
||||
|
||||
byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
|
||||
MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE)));
|
||||
assertThat(wire.getSubject()).isEqualTo("Quarterly numbers");
|
||||
assertThat(inner.getHeader("Subject")).isNull();
|
||||
assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isNull();
|
||||
}
|
||||
|
||||
private static MimeMessage textMessage(String text) throws Exception {
|
||||
MimeMessage message = new MimeMessage(SESSION);
|
||||
message.setFrom(new InternetAddress("sender@example.com"));
|
||||
|
||||
Reference in new issue
Block a user