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
@@ -46,6 +46,12 @@ mail:
|
|||||||
|
|
||||||
Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options.
|
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
|
## Demo app
|
||||||
|
|
||||||
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
|
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
|
||||||
|
|||||||
+18
-5
@@ -155,10 +155,22 @@ mail.pgp:
|
|||||||
relay: { ... } # see RELAY mode
|
relay: { ... } # see RELAY mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## Observability
|
## Observability (M8)
|
||||||
- `mail.pgp.sent{mode=encrypted|plaintext}`, `mail.pgp.downgrade{reason=no_key|unreachable|invalid_key}`
|
Micrometer is optional; with a `MeterRegistry` bean these are registered:
|
||||||
- `mail.pgp.wkd.lookup{result=found|not_found|unreachable, method=advanced|direct}`
|
- `mail.pgp.recipients{outcome=encrypted|plaintext|relayed, policy, reason=none|not_found|unreachable|rejected|plaintext_policy}`
|
||||||
- Caffeine cache metrics; actuator endpoint `pgpkeys` to inspect/evict a cached address.
|
— 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
|
## Milestones
|
||||||
1. **Skeleton** — parent POM, modules, CI. ✅
|
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.
|
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:
|
Demo app ✅ — encrypts when a valid key is found, otherwise sends plaintext (logged). Verified live:
|
||||||
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
|
`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.
|
||||||
@@ -18,6 +18,7 @@ mail:
|
|||||||
mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY
|
mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY
|
||||||
default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC}
|
default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC}
|
||||||
mixed-mode: SPLIT
|
mixed-mode: SPLIT
|
||||||
|
protected-headers: ${MAIL_PGP_PROTECTED_HEADERS:false}
|
||||||
# recipients:
|
# recipients:
|
||||||
# "[*@partner.example]": REQUIRE
|
# "[*@partner.example]": REQUIRE
|
||||||
# Readable sent copies: the sender's own PUBLIC key per From address.
|
# Readable sent copies: the sender's own PUBLIC key per From address.
|
||||||
|
|||||||
+54
-8
@@ -7,6 +7,7 @@ import java.io.InputStream;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
@@ -19,6 +20,7 @@ import jakarta.activation.DataHandler;
|
|||||||
import jakarta.mail.Header;
|
import jakarta.mail.Header;
|
||||||
import jakarta.mail.MessagingException;
|
import jakarta.mail.MessagingException;
|
||||||
import jakarta.mail.Session;
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.ContentType;
|
||||||
import jakarta.mail.internet.MimeBodyPart;
|
import jakarta.mail.internet.MimeBodyPart;
|
||||||
import jakarta.mail.internet.MimeMessage;
|
import jakarta.mail.internet.MimeMessage;
|
||||||
import jakarta.mail.util.ByteArrayDataSource;
|
import jakarta.mail.util.ByteArrayDataSource;
|
||||||
@@ -28,16 +30,29 @@ import jakarta.mail.util.ByteArrayDataSource;
|
|||||||
* <p>
|
* <p>
|
||||||
* The whole inner MIME entity (its {@code Content-*} headers and body, including attachments) is encrypted, not
|
* 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.
|
* 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 {
|
public final class PgpMimeEncryptor {
|
||||||
|
|
||||||
private static final Session PARSE_SESSION = Session.getInstance(new Properties());
|
private static final Session PARSE_SESSION = Session.getInstance(new Properties());
|
||||||
private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII);
|
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 PgpEncryptor encryptor;
|
||||||
|
private final boolean protectHeaders;
|
||||||
|
|
||||||
public PgpMimeEncryptor(PgpEncryptor encryptor) {
|
public PgpMimeEncryptor(PgpEncryptor encryptor) {
|
||||||
|
this(encryptor, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PgpMimeEncryptor(PgpEncryptor encryptor, boolean protectHeaders) {
|
||||||
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
|
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
|
||||||
|
this.protectHeaders = protectHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,33 +66,58 @@ public final class PgpMimeEncryptor {
|
|||||||
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage)
|
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage)
|
||||||
throws MessagingException, IOException {
|
throws MessagingException, IOException {
|
||||||
MimeMessage source = snapshot(original);
|
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();
|
MimeMessage outer = newMessage.get();
|
||||||
copyEnvelopeHeaders(source, outer);
|
copyEnvelopeHeaders(source, outer);
|
||||||
|
if (protectHeaders && outer.getHeader("Subject") != null) {
|
||||||
|
outer.setHeader("Subject", OBSCURED_SUBJECT);
|
||||||
|
}
|
||||||
outer.setContent(pgpMimeBody(ciphertext));
|
outer.setContent(pgpMimeBody(ciphertext));
|
||||||
outer.saveChanges();
|
outer.saveChanges();
|
||||||
return outer;
|
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 {
|
private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
|
||||||
|
String messageId = original.getMessageID();
|
||||||
original.saveChanges();
|
original.saveChanges();
|
||||||
|
if (messageId != null) {
|
||||||
|
original.setHeader("Message-ID", messageId);
|
||||||
|
}
|
||||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
original.writeTo(buffer);
|
original.writeTo(buffer);
|
||||||
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
|
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();
|
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();
|
Enumeration<String> lines = source.getAllHeaderLines();
|
||||||
while (lines.hasMoreElements()) {
|
while (lines.hasMoreElements()) {
|
||||||
String line = lines.nextElement();
|
String line = lines.nextElement();
|
||||||
if (isContentHeader(line)) {
|
if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) {
|
||||||
entity.write(line.getBytes(StandardCharsets.UTF_8));
|
ContentType type = new ContentType(source.getContentType());
|
||||||
entity.write('\r');
|
type.setParameter("protected-headers", "v1");
|
||||||
entity.write('\n');
|
writeHeader(entity, "Content-Type: " + type);
|
||||||
|
} else if (isContentHeader(line)) {
|
||||||
|
writeHeader(entity, line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entity.write('\r');
|
entity.write('\r');
|
||||||
@@ -88,6 +128,12 @@ public final class PgpMimeEncryptor {
|
|||||||
return toCrlf(entity.toByteArray());
|
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 {
|
private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
|
||||||
Enumeration<Header> headers = source.getAllHeaders();
|
Enumeration<Header> headers = source.getAllHeaders();
|
||||||
while (headers.hasMoreElements()) {
|
while (headers.hasMoreElements()) {
|
||||||
|
|||||||
+32
@@ -102,6 +102,38 @@ class PgpMimeEncryptorTest {
|
|||||||
assertThat(original.getContent()).isEqualTo("plain");
|
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 {
|
private static MimeMessage textMessage(String text) throws Exception {
|
||||||
MimeMessage message = new MimeMessage(SESSION);
|
MimeMessage message = new MimeMessage(SESSION);
|
||||||
message.setFrom(new InternetAddress("sender@example.com"));
|
message.setFrom(new InternetAddress("sender@example.com"));
|
||||||
|
|||||||
@@ -26,6 +26,21 @@
|
|||||||
<groupId>org.springframework</groupId>
|
<groupId>org.springframework</groupId>
|
||||||
<artifactId>spring-context-support</artifactId>
|
<artifactId>spring-context-support</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-core</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-actuator</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-actuator-autoconfigure</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
@@ -47,6 +62,16 @@
|
|||||||
<artifactId>greenmail-junit5</artifactId>
|
<artifactId>greenmail-junit5</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
+12
@@ -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);
|
||||||
|
}
|
||||||
+121
@@ -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.
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET pgpkeys} — cache size</li>
|
||||||
|
* <li>{@code GET pgpkeys/{email}} — policy, key lookup (through the cache) and validation of each key</li>
|
||||||
|
* <li>{@code DELETE pgpkeys/{email}} / {@code DELETE pgpkeys} — evict one address / everything</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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<String, Object> summary() {
|
||||||
|
Map<String, Object> 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<KeyReport> 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<String> 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<KeyReport> keys) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record KeyReport(String fingerprint, List<String> userIds, Instant expires, boolean valid,
|
||||||
|
String rejectedReason, String rejectedDetail) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-6
@@ -77,9 +77,12 @@ public class PgpMailAutoConfiguration {
|
|||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
@ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher,
|
CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher,
|
||||||
ObjectProvider<HostResolver> hostResolver) {
|
ObjectProvider<HostResolver> hostResolver, ObjectProvider<KeyDiscoveryCustomizer> customizers) {
|
||||||
WkdKeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system),
|
KeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system),
|
||||||
properties.wkd().fallback());
|
properties.wkd().fallback());
|
||||||
|
for (KeyDiscoveryCustomizer customizer : customizers.orderedStream().toList()) {
|
||||||
|
wkd = customizer.customize(wkd);
|
||||||
|
}
|
||||||
PgpMailProperties.Cache cache = properties.cache();
|
PgpMailProperties.Cache cache = properties.cache();
|
||||||
return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings(
|
return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings(
|
||||||
cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize()));
|
cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize()));
|
||||||
@@ -109,8 +112,8 @@ public class PgpMailAutoConfiguration {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
PgpMimeEncryptor pgpMimeEncryptor(PgpEncryptor encryptor) {
|
PgpMimeEncryptor pgpMimeEncryptor(PgpMailProperties properties, PgpEncryptor encryptor) {
|
||||||
return new PgpMimeEncryptor(encryptor);
|
return new PgpMimeEncryptor(encryptor, properties.protectedHeaders());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -159,8 +162,8 @@ public class PgpMailAutoConfiguration {
|
|||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
|
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
|
||||||
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
|
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
|
||||||
PolicyResolver policies) {
|
PolicyResolver policies, ObjectProvider<SecureMailSenderCustomizer> customizers) {
|
||||||
return switch (properties.mode()) {
|
SecureMailSender sender = switch (properties.mode()) {
|
||||||
case BUILT_IN -> {
|
case BUILT_IN -> {
|
||||||
JavaMailSender delegate = mailSender.getIfAvailable();
|
JavaMailSender delegate = mailSender.getIfAvailable();
|
||||||
if (delegate == null) {
|
if (delegate == null) {
|
||||||
@@ -176,6 +179,10 @@ public class PgpMailAutoConfiguration {
|
|||||||
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader());
|
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. */
|
/** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */
|
||||||
|
|||||||
+29
@@ -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<CachingKeyDiscovery> cache,
|
||||||
|
CertificateValidator validator, PolicyResolver policies) {
|
||||||
|
return new PgpKeysEndpoint(discovery, cache.getIfAvailable(), validator, policies);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code mail.pgp.recipients{outcome, policy, reason}} — counter per recipient</li>
|
||||||
|
* <li>{@code mail.pgp.refused} — messages refused by the policy</li>
|
||||||
|
* <li>{@code mail.pgp.key.lookup{result, source}} — timer for lookups not answered from the cache</li>
|
||||||
|
* <li>{@code mail.pgp.key.cache.size} — cached addresses</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@AutoConfiguration
|
||||||
|
@ConditionalOnClass(MeterRegistry.class)
|
||||||
|
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class PgpMailMetricsAutoConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
KeyDiscoveryCustomizer pgpKeyDiscoveryMetrics(ObjectProvider<MeterRegistry> registry) {
|
||||||
|
return discovery -> {
|
||||||
|
MeterRegistry meterRegistry = registry.getIfAvailable();
|
||||||
|
return meterRegistry == null ? discovery : new MeteredKeyDiscovery(discovery, meterRegistry);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecureMailSenderCustomizer pgpSecureMailSenderMetrics(ObjectProvider<MeterRegistry> registry) {
|
||||||
|
return sender -> {
|
||||||
|
MeterRegistry meterRegistry = registry.getIfAvailable();
|
||||||
|
return meterRegistry == null ? sender : new MeteredSecureMailSender(sender, meterRegistry);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
MeterBinder pgpKeyCacheMetrics(ObjectProvider<CachingKeyDiscovery> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -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 mode encrypt in this application or hand mail to an encrypting relay
|
||||||
* @param defaultPolicy policy for recipients without a rule
|
* @param defaultPolicy policy for recipients without a rule
|
||||||
* @param mixedMode what to do when only some recipients can get encrypted mail
|
* @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 recipients policy rules per address or {@code *@domain}
|
||||||
* @param staticKeys pinned recipient certificates per address, checked before WKD
|
* @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
|
* @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("BUILT_IN") Mode mode,
|
||||||
@DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy,
|
@DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy,
|
||||||
@DefaultValue("SPLIT") MixedMode mixedMode,
|
@DefaultValue("SPLIT") MixedMode mixedMode,
|
||||||
|
@DefaultValue("false") boolean protectedHeaders,
|
||||||
Map<String, EncryptionPolicy> recipients,
|
Map<String, EncryptionPolicy> recipients,
|
||||||
@DefaultValue Wkd wkd,
|
@DefaultValue Wkd wkd,
|
||||||
@DefaultValue Cache cache,
|
@DefaultValue Cache cache,
|
||||||
|
|||||||
+10
@@ -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);
|
||||||
|
}
|
||||||
+41
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -59,12 +59,25 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
|
|||||||
SendReport report = new SendReport(plan.decisions().stream()
|
SendReport report = new SendReport(plan.decisions().stream()
|
||||||
.map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
|
.map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
|
||||||
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
|
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
|
||||||
|
d.encrypted() ? SendReport.PlaintextReason.NONE : reason(d.key()),
|
||||||
d.encrypted() ? null : describe(d.key())))
|
d.encrypted() ? null : describe(d.key())))
|
||||||
.toList());
|
.toList());
|
||||||
log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report);
|
log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report);
|
||||||
return 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) {
|
private static String describe(RecipientKey key) {
|
||||||
return switch (key) {
|
return switch (key) {
|
||||||
case RecipientKey.Usable usable -> "key from " + usable.source();
|
case RecipientKey.Usable usable -> "key from " + usable.source();
|
||||||
|
|||||||
+2
-1
@@ -65,7 +65,8 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
|
|||||||
RecipientType type) {
|
RecipientType type) {
|
||||||
for (InternetAddress address : addresses) {
|
for (InternetAddress address : addresses) {
|
||||||
outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type,
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-2
@@ -17,10 +17,24 @@ public record SendReport(List<RecipientOutcome> 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,
|
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 {
|
public enum Outcome {
|
||||||
|
|||||||
+2
@@ -1 +1,3 @@
|
|||||||
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
|
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
|
||||||
|
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration
|
||||||
|
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration
|
||||||
+56
@@ -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 <bob@legacy.org>").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\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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 <bob@legacy.org>").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 <bob@legacy.org>");
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user