From f63636f8f46596901c13158c3148460221ddeac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:33:17 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- README.md | 6 + docs/PLAN.md | 23 +++- .../src/main/resources/application.yml | 1 + .../mailsender/pgp/mime/PgpMimeEncryptor.java | 62 +++++++-- .../pgp/mime/PgpMimeEncryptorTest.java | 32 +++++ .../pom.xml | 25 ++++ .../autoconfigure/KeyDiscoveryCustomizer.java | 12 ++ .../pgp/autoconfigure/PgpKeysEndpoint.java | 121 ++++++++++++++++++ .../PgpMailAutoConfiguration.java | 19 ++- .../PgpMailEndpointAutoConfiguration.java | 29 +++++ .../PgpMailMetricsAutoConfiguration.java | 53 ++++++++ .../pgp/autoconfigure/PgpMailProperties.java | 3 + .../SecureMailSenderCustomizer.java | 10 ++ .../pgp/metrics/MeteredKeyDiscovery.java | 41 ++++++ .../pgp/metrics/MeteredSecureMailSender.java | 78 +++++++++++ .../pgp/sender/BuiltInSecureMailSender.java | 13 ++ .../pgp/sender/RelaySecureMailSender.java | 3 +- .../mailsender/pgp/sender/SendReport.java | 18 ++- ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../autoconfigure/PgpKeysEndpointWebTest.java | 56 ++++++++ ...ailObservabilityAutoConfigurationTest.java | 94 ++++++++++++++ .../metrics/MeteredSecureMailSenderTest.java | 88 +++++++++++++ 22 files changed, 767 insertions(+), 22 deletions(-) create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java diff --git a/README.md b/README.md index e240426..fb5bf3a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ mail: Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options. +- `mail.pgp.protected-headers=true` encrypts the real Subject too (RFC 9788); the visible Subject becomes `[...]`. +- With Micrometer, `mail.pgp.recipients{outcome,policy,reason}`, `mail.pgp.refused`, `mail.pgp.key.lookup` and + `mail.pgp.key.cache.size` are recorded. +- With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys + (`DELETE /actuator/pgpkeys/{email}`). + ## Demo app The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs diff --git a/docs/PLAN.md b/docs/PLAN.md index 7e3a2cf..487549d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -155,10 +155,22 @@ mail.pgp: relay: { ... } # see RELAY mode ``` -## Observability -- `mail.pgp.sent{mode=encrypted|plaintext}`, `mail.pgp.downgrade{reason=no_key|unreachable|invalid_key}` -- `mail.pgp.wkd.lookup{result=found|not_found|unreachable, method=advanced|direct}` -- Caffeine cache metrics; actuator endpoint `pgpkeys` to inspect/evict a cached address. +## Observability (M8) +Micrometer is optional; with a `MeterRegistry` bean these are registered: +- `mail.pgp.recipients{outcome=encrypted|plaintext|relayed, policy, reason=none|not_found|unreachable|rejected|plaintext_policy}` + — a downgrade is `outcome=plaintext, policy=opportunistic` +- `mail.pgp.refused` — messages refused by the policy +- `mail.pgp.key.lookup{result=found|not_found|unreachable, source=wkd_advanced|wkd_direct|none}` — timer for lookups + not answered from the cache +- `mail.pgp.key.cache.size` + +Actuator is optional; when the `pgpkeys` endpoint is exposed +(`management.endpoints.web.exposure.include=pgpkeys`): +- `GET /actuator/pgpkeys` — cache size +- `GET /actuator/pgpkeys/{email}` — policy, lookup result, and validation of each key +- `DELETE /actuator/pgpkeys/{email}`, `DELETE /actuator/pgpkeys` — evict cached lookups (key rotation) + +Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`. ## Milestones 1. **Skeleton** — parent POM, modules, CI. ✅ @@ -179,4 +191,5 @@ mail.pgp: 7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird. Demo app ✅ — encrypts when a valid key is found, otherwise sends plaintext (logged). Verified live: `test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`. -8. **Hardening** — Micrometer metrics, protected headers, actuator endpoint, docs. +8. **Hardening** — Micrometer metrics, protected headers (RFC 9788, opt-in), actuator endpoint, docs. ✅ + Encrypting no longer regenerates the caller's Message-ID. diff --git a/mailsender-demo/src/main/resources/application.yml b/mailsender-demo/src/main/resources/application.yml index d8e8734..d2dd7c6 100644 --- a/mailsender-demo/src/main/resources/application.yml +++ b/mailsender-demo/src/main/resources/application.yml @@ -18,6 +18,7 @@ mail: mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC} mixed-mode: SPLIT + protected-headers: ${MAIL_PGP_PROTECTED_HEADERS:false} # recipients: # "[*@partner.example]": REQUIRE # Readable sent copies: the sender's own PUBLIC key per From address. diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java index 1dff6f8..c07ed16 100644 --- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java @@ -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; *

* 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. + *

+ * 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 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 hiddenRecipients, Supplier 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 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

headers = source.getAllHeaders(); while (headers.hasMoreElements()) { diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java index 987cab5..397733f 100644 --- a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java @@ -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", ""); + + 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(""); + 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(""); + 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")); diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml index b9a80ea..b88e61e 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml +++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml @@ -26,6 +26,21 @@ org.springframework spring-context-support + + io.micrometer + micrometer-core + true + + + org.springframework.boot + spring-boot-actuator + true + + + org.springframework.boot + spring-boot-actuator-autoconfigure + true + org.springframework.boot spring-boot-configuration-processor @@ -47,6 +62,16 @@ greenmail-junit5 test + + org.springframework.boot + spring-boot-starter-webmvc + test + + + org.springframework.boot + spring-boot-starter-actuator + test + diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java new file mode 100644 index 0000000..30190fc --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java @@ -0,0 +1,12 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; + +/** + * Wraps the WKD discovery before it is cached, so the wrapper sees real lookups only (e.g. metrics, tracing). + */ +@FunctionalInterface +public interface KeyDiscoveryCustomizer { + + KeyDiscovery customize(KeyDiscovery wkdDiscovery); +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java new file mode 100644 index 0000000..07c989d --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java @@ -0,0 +1,121 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; + +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; +import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; +import pro.thinhha.mailsender.pgp.validation.ValidationResult; + +/** + * {@code /actuator/pgpkeys}: inspect how an address would be handled, and evict cached lookups after a recipient + * rotated their key. + *
    + *
  • {@code GET pgpkeys} — cache size
  • + *
  • {@code GET pgpkeys/{email}} — policy, key lookup (through the cache) and validation of each key
  • + *
  • {@code DELETE pgpkeys/{email}} / {@code DELETE pgpkeys} — evict one address / everything
  • + *
+ */ +@Endpoint(id = "pgpkeys") +public class PgpKeysEndpoint { + + private final KeyDiscovery discovery; + private final CachingKeyDiscovery cache; + private final CertificateValidator validator; + private final PolicyResolver policies; + + /** @param cache {@code null} when WKD is disabled */ + public PgpKeysEndpoint(KeyDiscovery discovery, CachingKeyDiscovery cache, CertificateValidator validator, + PolicyResolver policies) { + this.discovery = discovery; + this.cache = cache; + this.validator = validator; + this.policies = policies; + } + + @ReadOperation + public Map summary() { + Map summary = new LinkedHashMap<>(); + summary.put("wkd", cache != null); + summary.put("cachedAddresses", cache == null ? 0 : cache.estimatedSize()); + return summary; + } + + @ReadOperation + public AddressReport address(@Selector String email) { + EncryptionPolicy policy = policies.resolve(email); + DiscoveryResult result = discovery.discover(email); + List keys = new ArrayList<>(); + String lookup; + String detail = null; + switch (result) { + case DiscoveryResult.Found found -> { + lookup = "found:" + found.source(); + for (OpenPGPCertificate certificate : found.certificates()) { + keys.add(keyReport(certificate, email)); + } + } + case DiscoveryResult.NotFound ignored -> lookup = "not_found"; + case DiscoveryResult.Unreachable unreachable -> { + lookup = "unreachable"; + detail = unreachable.reason(); + } + } + boolean encrypt = policy != EncryptionPolicy.PLAINTEXT && keys.stream().anyMatch(k -> k.valid); + return new AddressReport(email, policy, lookup, detail, encrypt, keys); + } + + @DeleteOperation + public void evict(@Selector String email) { + if (cache != null) { + cache.evict(email); + } + } + + @DeleteOperation + public void evictAll() { + if (cache != null) { + cache.evictAll(); + } + } + + private KeyReport keyReport(OpenPGPCertificate certificate, String email) { + ValidationResult validation = validator.validate(certificate, email); + List userIds = certificate.getAllUserIds().stream() + .map(OpenPGPCertificate.OpenPGPUserId::getUserId).toList(); + Instant expires = certificate.getExpirationTime() == null ? null : certificate.getExpirationTime().toInstant(); + return switch (validation) { + case ValidationResult.Valid ignored -> + new KeyReport(fingerprint(certificate), userIds, expires, true, null, null); + case ValidationResult.Rejected rejected -> new KeyReport(fingerprint(certificate), userIds, expires, false, + rejected.reason().name(), rejected.detail()); + }; + } + + private static String fingerprint(OpenPGPCertificate certificate) { + return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint()); + } + + /** @param wouldEncrypt whether a message to this address alone would currently be encrypted */ + public record AddressReport(String address, EncryptionPolicy policy, String lookup, String lookupDetail, + boolean wouldEncrypt, List keys) { + } + + public record KeyReport(String fingerprint, List userIds, Instant expires, boolean valid, + String rejectedReason, String rejectedDetail) { + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java index 6deae4e..433c89b 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java @@ -77,9 +77,12 @@ public class PgpMailAutoConfiguration { @ConditionalOnMissingBean @ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true) CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher, - ObjectProvider hostResolver) { - WkdKeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system), + ObjectProvider hostResolver, ObjectProvider customizers) { + KeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system), properties.wkd().fallback()); + for (KeyDiscoveryCustomizer customizer : customizers.orderedStream().toList()) { + wkd = customizer.customize(wkd); + } PgpMailProperties.Cache cache = properties.cache(); return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings( cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize())); @@ -109,8 +112,8 @@ public class PgpMailAutoConfiguration { @Bean @ConditionalOnMissingBean - PgpMimeEncryptor pgpMimeEncryptor(PgpEncryptor encryptor) { - return new PgpMimeEncryptor(encryptor); + PgpMimeEncryptor pgpMimeEncryptor(PgpMailProperties properties, PgpEncryptor encryptor) { + return new PgpMimeEncryptor(encryptor, properties.protectedHeaders()); } @Bean @@ -159,8 +162,8 @@ public class PgpMailAutoConfiguration { @ConditionalOnMissingBean SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider mailSender, ObjectProvider planner, ObjectProvider assembler, - PolicyResolver policies) { - return switch (properties.mode()) { + PolicyResolver policies, ObjectProvider customizers) { + SecureMailSender sender = switch (properties.mode()) { case BUILT_IN -> { JavaMailSender delegate = mailSender.getIfAvailable(); if (delegate == null) { @@ -176,6 +179,10 @@ public class PgpMailAutoConfiguration { yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader()); } }; + for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) { + sender = customizer.customize(sender); + } + return sender; } /** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */ diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java new file mode 100644 index 0000000..4914dc4 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java @@ -0,0 +1,29 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; +import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; + +/** Registers {@link PgpKeysEndpoint} when Actuator is present and the endpoint is enabled and exposed. */ +@AutoConfiguration(after = PgpMailAutoConfiguration.class) +@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint") +@ConditionalOnBean(PolicyResolver.class) +public class PgpMailEndpointAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnAvailableEndpoint + PgpKeysEndpoint pgpKeysEndpoint(KeyDiscovery discovery, ObjectProvider cache, + CertificateValidator validator, PolicyResolver policies) { + return new PgpKeysEndpoint(discovery, cache.getIfAvailable(), validator, policies); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java new file mode 100644 index 0000000..8ad0722 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java @@ -0,0 +1,53 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; +import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; +import pro.thinhha.mailsender.pgp.metrics.MeteredKeyDiscovery; +import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender; + +/** + * Micrometer metrics when a {@link MeterRegistry} bean exists: + *
    + *
  • {@code mail.pgp.recipients{outcome, policy, reason}} — counter per recipient
  • + *
  • {@code mail.pgp.refused} — messages refused by the policy
  • + *
  • {@code mail.pgp.key.lookup{result, source}} — timer for lookups not answered from the cache
  • + *
  • {@code mail.pgp.key.cache.size} — cached addresses
  • + *
+ */ +@AutoConfiguration +@ConditionalOnClass(MeterRegistry.class) +@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true) +public class PgpMailMetricsAutoConfiguration { + + @Bean + KeyDiscoveryCustomizer pgpKeyDiscoveryMetrics(ObjectProvider registry) { + return discovery -> { + MeterRegistry meterRegistry = registry.getIfAvailable(); + return meterRegistry == null ? discovery : new MeteredKeyDiscovery(discovery, meterRegistry); + }; + } + + @Bean + SecureMailSenderCustomizer pgpSecureMailSenderMetrics(ObjectProvider registry) { + return sender -> { + MeterRegistry meterRegistry = registry.getIfAvailable(); + return meterRegistry == null ? sender : new MeteredSecureMailSender(sender, meterRegistry); + }; + } + + @Bean + MeterBinder pgpKeyCacheMetrics(ObjectProvider cache) { + return registry -> cache.ifAvailable(c -> Gauge.builder("mail.pgp.key.cache.size", c, + CachingKeyDiscovery::estimatedSize) + .description("Addresses with a cached key lookup result") + .register(registry)); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java index 05a1fcf..cadb51a 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java @@ -22,6 +22,8 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback; * @param mode encrypt in this application or hand mail to an encrypting relay * @param defaultPolicy policy for recipients without a rule * @param mixedMode what to do when only some recipients can get encrypted mail + * @param protectedHeaders move the real Subject (and other headers) into the encrypted part (RFC 9788); the + * visible Subject becomes {@code [...]} * @param recipients policy rules per address or {@code *@domain} * @param staticKeys pinned recipient certificates per address, checked before WKD * @param senderKeys the sender's own public key per From address, so sent copies stay readable @@ -32,6 +34,7 @@ public record PgpMailProperties( @DefaultValue("BUILT_IN") Mode mode, @DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy, @DefaultValue("SPLIT") MixedMode mixedMode, + @DefaultValue("false") boolean protectedHeaders, Map recipients, @DefaultValue Wkd wkd, @DefaultValue Cache cache, diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java new file mode 100644 index 0000000..fc61254 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java @@ -0,0 +1,10 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; + +/** Wraps the auto-configured {@link SecureMailSender} (e.g. metrics, auditing). */ +@FunctionalInterface +public interface SecureMailSenderCustomizer { + + SecureMailSender customize(SecureMailSender sender); +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java new file mode 100644 index 0000000..0b2a1cd --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java @@ -0,0 +1,41 @@ +package pro.thinhha.mailsender.pgp.metrics; + +import java.util.Locale; +import java.util.Objects; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; + +/** Times each real key lookup: {@code mail.pgp.key.lookup{result, source}}. */ +public final class MeteredKeyDiscovery implements KeyDiscovery { + + static final String LOOKUP = "mail.pgp.key.lookup"; + + private final KeyDiscovery delegate; + private final MeterRegistry registry; + + public MeteredKeyDiscovery(KeyDiscovery delegate, MeterRegistry registry) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public DiscoveryResult discover(String email) { + Timer.Sample sample = Timer.start(registry); + DiscoveryResult result = delegate.discover(email); + sample.stop(Timer.builder(LOOKUP) + .description("Key lookups that were not answered from the cache") + .tag("result", switch (result) { + case DiscoveryResult.Found ignored -> "found"; + case DiscoveryResult.NotFound ignored -> "not_found"; + case DiscoveryResult.Unreachable ignored -> "unreachable"; + }) + .tag("source", result instanceof DiscoveryResult.Found found + ? found.source().name().toLowerCase(Locale.ROOT) + : "none") + .register(registry)); + return result; + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java new file mode 100644 index 0000000..2d1d8c4 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java @@ -0,0 +1,78 @@ +package pro.thinhha.mailsender.pgp.metrics; + +import java.util.Locale; +import java.util.Objects; + +import org.springframework.mail.SimpleMailMessage; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SendReport; + +/** + * Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason}} and refused messages + * {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade. + */ +public final class MeteredSecureMailSender implements SecureMailSender { + + static final String RECIPIENTS = "mail.pgp.recipients"; + static final String REFUSED = "mail.pgp.refused"; + + private final SecureMailSender delegate; + private final MeterRegistry registry; + + public MeteredSecureMailSender(SecureMailSender delegate, MeterRegistry registry) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public MimeMessage createMimeMessage() { + return delegate.createMimeMessage(); + } + + @Override + public SendReport send(MimeMessage message) { + try { + return record(delegate.send(message)); + } catch (EncryptionPolicyException e) { + refused(); + throw e; + } + } + + @Override + public SendReport send(SimpleMailMessage message) { + try { + return record(delegate.send(message)); + } catch (EncryptionPolicyException e) { + refused(); + throw e; + } + } + + private SendReport record(SendReport report) { + for (SendReport.RecipientOutcome recipient : report.recipients()) { + Counter.builder(RECIPIENTS) + .description("Recipients by delivery outcome") + .tag("outcome", lower(recipient.outcome())) + .tag("policy", lower(recipient.policy())) + .tag("reason", lower(recipient.reason())) + .register(registry) + .increment(); + } + return report; + } + + private void refused() { + Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry) + .increment(); + } + + private static String lower(Enum value) { + return value.name().toLowerCase(Locale.ROOT); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java index c9805ff..c7798b4 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java @@ -59,12 +59,25 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender { SendReport report = new SendReport(plan.decisions().stream() .map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(), d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT, + d.encrypted() ? SendReport.PlaintextReason.NONE : reason(d.key()), d.encrypted() ? null : describe(d.key()))) .toList()); log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report); return report; } + private static SendReport.PlaintextReason reason(RecipientKey key) { + return switch (key) { + case RecipientKey.Usable ignored -> SendReport.PlaintextReason.NONE; + case RecipientKey.Missing missing -> switch (missing.reason()) { + case NOT_FOUND -> SendReport.PlaintextReason.NOT_FOUND; + case UNREACHABLE -> SendReport.PlaintextReason.UNREACHABLE; + case REJECTED -> SendReport.PlaintextReason.REJECTED; + }; + case RecipientKey.NotLookedUp ignored -> SendReport.PlaintextReason.PLAINTEXT_POLICY; + }; + } + private static String describe(RecipientKey key) { return switch (key) { case RecipientKey.Usable usable -> "key from " + usable.source(); diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java index d20aa9f..a896ccf 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java @@ -65,7 +65,8 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender { RecipientType type) { for (InternetAddress address : addresses) { outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type, - policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null)); + policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, SendReport.PlaintextReason.NONE, + null)); } } } diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java index 3789548..399fc8f 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java @@ -17,10 +17,24 @@ public record SendReport(List recipients) { } /** - * @param detail why the recipient got plaintext (missing key reason), or {@code null} + * @param reason why the recipient got plaintext; {@link PlaintextReason#NONE} otherwise + * @param detail human-readable detail for the reason, or {@code null} */ public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome, - String detail) { + PlaintextReason reason, String detail) { + } + + public enum PlaintextReason { + /** Not sent in plaintext by this application (encrypted, or relayed). */ + NONE, + /** The recipient's policy is PLAINTEXT. */ + PLAINTEXT_POLICY, + /** No key published. */ + NOT_FOUND, + /** The key lookup failed. */ + UNREACHABLE, + /** Keys were found but none was usable. */ + REJECTED } public enum Outcome { diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 6bf1061..51e17c2 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,3 @@ pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration +pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration +pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java new file mode 100644 index 0000000..0f8b4f8 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java @@ -0,0 +1,56 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +/** Addresses contain '@' and '.', so check the selector survives real HTTP path matching. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.mail.host=localhost", + "mail.pgp.wkd.enabled=false", + "management.endpoints.web.exposure.include=pgpkeys" +}) +class PgpKeysEndpointWebTest { + + @SpringBootConfiguration + @EnableAutoConfiguration + static class App { + } + + @DynamicPropertySource + static void keys(DynamicPropertyRegistry registry) throws Exception { + Path key = Files.createTempFile("bob", ".asc"); + key.toFile().deleteOnExit(); + Files.writeString(key, PGPainless.getInstance().generateKey() + .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString()); + registry.add("mail.pgp.static-keys[bob@legacy.org]", () -> "file:" + key); + } + + @LocalServerPort + int port; + + @Test + void readsAndEvictsByAddress() { + RestClient client = RestClient.create("http://localhost:" + port + "/actuator/pgpkeys"); + + String body = client.get().uri("/bob@legacy.org").retrieve().body(String.class); + assertThat(body).contains("\"address\":\"bob@legacy.org\"", "\"lookup\":\"found:STATIC\"", + "\"wouldEncrypt\":true"); + + assertThat(client.delete().uri("/bob@legacy.org").retrieve().toBodilessEntity().getStatusCode()) + .isEqualTo(HttpStatus.NO_CONTENT); + assertThat(client.get().retrieve().body(String.class)).contains("\"cachedAddresses\""); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java new file mode 100644 index 0000000..8af9ba9 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java @@ -0,0 +1,94 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.pgpainless.PGPainless; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender; +import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; + +class PgpMailObservabilityAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, + PgpMailAutoConfiguration.class, PgpMailMetricsAutoConfiguration.class, + PgpMailEndpointAutoConfiguration.class)) + .withPropertyValues("spring.mail.host=localhost"); + + @TempDir + Path tempDir; + + @Test + void wrapsSenderWithMetricsWhenRegistryPresent() { + runner.withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> assertThat(context.getBean(SecureMailSender.class)) + .isInstanceOf(MeteredSecureMailSender.class)); + } + + @Test + void leavesSenderAloneWithoutRegistry() { + runner.run(context -> assertThat(context.getBean(SecureMailSender.class)) + .isInstanceOf(BuiltInSecureMailSender.class)); + } + + @Test + void worksWithoutMicrometerAndActuatorOnClasspath() { + runner.withClassLoader(new FilteredClassLoader("io.micrometer", "org.springframework.boot.actuate")) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(BuiltInSecureMailSender.class); + }); + } + + @Test + void endpointOnlyWhenExposed() { + runner.run(context -> assertThat(context).doesNotHaveBean(PgpKeysEndpoint.class)); + } + + @Test + void endpointReportsPolicyKeyAndValidity() throws Exception { + Path key = tempDir.resolve("bob.asc"); + Files.writeString(key, PGPainless.getInstance().generateKey() + .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString()); + + runner.withPropertyValues( + "management.endpoints.web.exposure.include=pgpkeys", + "mail.pgp.wkd.enabled=false", + "mail.pgp.recipients[*@legacy.org]=REQUIRE", + "mail.pgp.static-keys[bob@legacy.org]=file:" + key) + .run(context -> { + PgpKeysEndpoint endpoint = context.getBean(PgpKeysEndpoint.class); + + PgpKeysEndpoint.AddressReport bob = endpoint.address("bob@legacy.org"); + assertThat(bob.policy()).isEqualTo(EncryptionPolicy.REQUIRE); + assertThat(bob.lookup()).isEqualTo("found:STATIC"); + assertThat(bob.wouldEncrypt()).isTrue(); + assertThat(bob.keys()).singleElement().satisfies(k -> { + assertThat(k.valid()).isTrue(); + assertThat(k.userIds()).containsExactly("Bob "); + assertThat(k.fingerprint()).hasSize(40); + }); + + PgpKeysEndpoint.AddressReport carol = endpoint.address("carol@example.org"); + assertThat(carol.lookup()).isEqualTo("not_found"); + assertThat(carol.wouldEncrypt()).isFalse(); + + assertThat(endpoint.summary()).containsEntry("wkd", false); + endpoint.evict("bob@legacy.org"); + endpoint.evictAll(); + }); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java new file mode 100644 index 0000000..b12d55b --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java @@ -0,0 +1,88 @@ +package pro.thinhha.mailsender.pgp.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.mail.SimpleMailMessage; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; +import pro.thinhha.mailsender.pgp.delivery.RecipientType; +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SendReport; +import pro.thinhha.mailsender.pgp.sender.SendReport.Outcome; +import pro.thinhha.mailsender.pgp.sender.SendReport.PlaintextReason; + +class MeteredSecureMailSenderTest { + + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + @Test + void countsRecipientsByOutcomePolicyAndReason() { + SendReport report = new SendReport(List.of( + outcome("a@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE), + outcome("b@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE), + outcome("c@example.org", Outcome.PLAINTEXT, PlaintextReason.NOT_FOUND))); + + new MeteredSecureMailSender(sender(report, null), registry).send(new SimpleMailMessage()); + + assertThat(count("encrypted", "none")).isEqualTo(2); + assertThat(count("plaintext", "not_found")).isEqualTo(1); + } + + @Test + void countsRefusals() { + SecureMailSender failing = sender(null, new EncryptionPolicyException("no key", List.of("x@y.z"))); + + assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage())) + .isInstanceOf(EncryptionPolicyException.class); + assertThat(registry.get(MeteredSecureMailSender.REFUSED).counter().count()).isEqualTo(1); + } + + @Test + void timesKeyLookupsWithResultAndSource() { + new MeteredKeyDiscovery(email -> DiscoveryResult.notFound(), registry).discover("a@example.org"); + + assertThat(registry.get(MeteredKeyDiscovery.LOOKUP).tag("result", "not_found").tag("source", "none") + .timer().count()).isEqualTo(1); + } + + private double count(String outcome, String reason) { + return registry.get(MeteredSecureMailSender.RECIPIENTS) + .tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason) + .counter().count(); + } + + private static SendReport.RecipientOutcome outcome(String address, Outcome outcome, PlaintextReason reason) { + return new SendReport.RecipientOutcome(address, RecipientType.TO, EncryptionPolicy.OPPORTUNISTIC, outcome, + reason, null); + } + + private static SecureMailSender sender(SendReport report, RuntimeException failure) { + return new SecureMailSender() { + @Override + public MimeMessage createMimeMessage() { + throw new UnsupportedOperationException(); + } + + @Override + public SendReport send(MimeMessage message) { + return send((SimpleMailMessage) null); + } + + @Override + public SendReport send(SimpleMailMessage message) { + if (failure != null) { + throw failure; + } + return report; + } + }; + } +}