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:
Claude committed 2026-10-12 03:33:17 +00:00
1 parent 3bce057eab
commit f63636f8f4
22 files changed
+767 -22

No files matched your search

@@ -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()) {
@@ -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"));