Add encryption policy, split delivery and SecureMailSender

- delivery: PolicyResolver (address > *@domain > default), RecipientKeyResolver
  (discovery + validation), DeliveryPlanner (REQUIRE refusal, SPLIT/FAIL,
  own copy per Bcc with hidden key IDs), MessageAssembler with sender keys
  for readable sent copies, EnvelopeMimeMessage for per-copy SMTP envelopes
- sender: SecureMailSender with BUILT_IN and RELAY implementations and a
  per-recipient SendReport; relay passes the strictest policy as a header hint
- autoconfigure: mode, default-policy, mixed-mode, recipients, sender-keys
  and relay properties; relay sender is not a bean so spring.mail stays intact
- demo: sends through SecureMailSender

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-12 03:27:40 +00:00
1 parent a788f7a48a
commit 3bce057eab
34 files changed
+1554 -106

No files matched your search

+30 -4
View File
@@ -19,12 +19,38 @@ mvn verify
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications | | `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
| `mailsender-demo` | sample app that sends test mails | | `mailsender-demo` | sample app that sends test mails |
## Usage
Add `pro.thinhha.mailsender:mailsender-pgp-spring-boot-starter`, configure `spring.mail.*` as usual, and send
through `SecureMailSender` instead of `JavaMailSender`:
```java
SendReport report = secureMailSender.send(message); // MimeMessage or SimpleMailMessage
```
Each recipient's key is looked up via WKD (cached) and validated. The message is then sent PGP/MIME-encrypted to
recipients with a usable key and in plaintext to the rest (logged as a downgrade), or refused with
`EncryptionPolicyException` if a `REQUIRE` recipient has no key. `SendReport` lists the outcome per recipient.
```yaml
mail:
pgp:
mode: BUILT_IN # or RELAY: hand plaintext to an encrypting gateway (mail.pgp.relay.*)
default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT
mixed-mode: SPLIT # or FAIL
recipients:
"[*@partner.example]": REQUIRE
sender-keys: # your own PUBLIC key, so sent copies stay readable
"[noreply@example.com]": file:/etc/mail/noreply.pub.asc
```
Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options.
## Demo app ## Demo app
The demo app looks up and validates the recipient's key via WKD, then sends `DEMO_COUNT` test mails The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
(default: 1 to `test@thinhha.pro`). With a valid key the mail is PGP/MIME-encrypted (RFC 3156); otherwise it is the outcome per recipient: PGP/MIME-encrypted (RFC 3156) when a valid WKD key is found, otherwise plaintext.
sent in plaintext and a warning is logged. The policy layer (REQUIRE / OPPORTUNISTIC / PLAINTEXT, split Set `MAIL_PGP_MODE=RELAY` and `RELAY_HOST`/`RELAY_PORT`/`RELAY_USERNAME`/`RELAY_PASSWORD` to send via a gateway.
delivery) comes in milestone 5.
``` ```
mvn -q install -DskipTests mvn -q install -DskipTests
+11 -6
View File
@@ -117,8 +117,9 @@ mail.pgp:
``` ```
- `RelaySecureMailSender` uses its own `JavaMailSender` pointed at the relay (the app's normal - `RelaySecureMailSender` uses its own `JavaMailSender` pointed at the relay (the app's normal
`spring.mail.*` sender is untouched). `spring.mail.*` sender is untouched).
- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode; they are passed as a header hint - REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode. With `policy-header` set, the strictest
and enforcement is the gateway's responsibility. Documented and logged at startup. policy among the recipients (`require` > `opportunistic` > `plaintext`) is passed as a header hint; enforcement is
the gateway's responsibility. Logged at startup.
- Encrypt-to-self is the gateway's responsibility in this mode. - Encrypt-to-self is the gateway's responsibility in this mode.
## Configuration (full shape) ## Configuration (full shape)
@@ -168,10 +169,14 @@ mail.pgp:
PGPainless and Bouncy Castle can disagree on a shortened expiry; the validator uses the earlier date. PGPainless and Bouncy Castle can disagree on a shortened expiry; the validator uses the earlier date.
4. **Encryptor + PGP/MIME** — round-trip decrypt test, structure test, CRLF canonicalization, attachments. ✅ 4. **Encryptor + PGP/MIME** — round-trip decrypt test, structure test, CRLF canonicalization, attachments. ✅
Verified live: the demo's mail to `test@thinhha.pro` is addressed to its cv25519 subkey `7CEE00AF05A08313`. Verified live: the demo's mail to `test@thinhha.pro` is addressed to its cv25519 subkey `7CEE00AF05A08313`.
5. **Policy + delivery planner** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc; 5. **Policy + delivery planner** — `PolicyResolver`, `RecipientKeyResolver`, `DeliveryPlanner`, `MessageAssembler`,
`EnvelopeMimeMessage`. `EnvelopeMimeMessage`; tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc. ✅
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch. All copies of one message share its Date and Message-ID; duplicates get one copy; nobody encryptable → one
plaintext copy (no split).
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, autoconfig switch. ✅
GreenMail integration test checks per-mailbox (envelope) delivery, decryption by recipient and sender, REQUIRE
refusal with nothing sent, and relay mode with the policy header. Metrics moved to M8.
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** — protected headers, actuator endpoint, docs. 8. **Hardening** — Micrometer metrics, protected headers, actuator endpoint, docs.
@@ -1,34 +1,22 @@
package pro.thinhha.mailsender.demo; package pro.thinhha.mailsender.demo;
import java.io.IOException;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.mail.MessagingException; import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; import pro.thinhha.mailsender.pgp.sender.SendReport;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/** /**
* Looks up and validates the recipient's key, then sends {@code demo.count} test mails: PGP/MIME-encrypted when a * Sends {@code demo.count} test mails through {@link SecureMailSender}, which encrypts per recipient according to
* valid key was found, otherwise in plaintext (logged as a downgrade). * {@code mail.pgp.*} (or hands them to the relay in RELAY mode), and logs what happened to each recipient.
* <p>
* This calls the building blocks directly; the policy layer and {@code SecureMailSender} (milestones 5–6) will
* replace this wiring.
*/ */
@Component @Component
class TestMailRunner implements ApplicationRunner { class TestMailRunner implements ApplicationRunner {
@@ -36,88 +24,35 @@ class TestMailRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class); private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
private final DemoProperties properties; private final DemoProperties properties;
private final KeyDiscovery keyDiscovery; private final SecureMailSender mailSender;
private final CertificateValidator validator;
private final PgpMimeEncryptor mimeEncryptor;
private final JavaMailSender mailSender;
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator, TestMailRunner(DemoProperties properties, SecureMailSender mailSender) {
PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) {
this.properties = properties; this.properties = properties;
this.keyDiscovery = keyDiscovery;
this.validator = validator;
this.mimeEncryptor = mimeEncryptor;
this.mailSender = mailSender; this.mailSender = mailSender;
} }
@Override @Override
public void run(ApplicationArguments args) throws MessagingException, IOException { public void run(ApplicationArguments args) throws MessagingException {
String to = properties.to();
DiscoveryResult discovery = keyDiscovery.discover(to);
Optional<OpenPGPCertificate> key = usableKey(discovery, to);
for (int i = 1; i <= properties.count(); i++) { for (int i = 1; i <= properties.count(); i++) {
MimeMessage message = compose(i, discovery, key.isPresent());
if (key.isPresent()) {
message = mimeEncryptor.encrypt(message, List.of(key.get()), List.of(), mailSender::createMimeMessage);
log.info("Sending test mail {}/{} to {} ENCRYPTED to {}", i, properties.count(), to,
fingerprint(key.get()));
} else {
log.warn("Sending test mail {}/{} to {} in PLAINTEXT: no usable key", i, properties.count(), to);
}
mailSender.send(message);
}
}
private Optional<OpenPGPCertificate> usableKey(DiscoveryResult discovery, String to) {
switch (discovery) {
case DiscoveryResult.Found found -> {
for (OpenPGPCertificate certificate : found.certificates()) {
ValidationResult result = validator.validate(certificate, to);
switch (result) {
case ValidationResult.Valid valid -> {
log.info("Key for {} found via {}: {}", to, found.source(), fingerprint(certificate));
return Optional.of(valid.certificate());
}
case ValidationResult.Rejected rejected -> log.warn("Key {} for {} rejected: {} ({})",
fingerprint(certificate), to, rejected.reason(), rejected.detail());
}
}
}
case DiscoveryResult.NotFound ignored -> log.info("No key published for {}", to);
case DiscoveryResult.Unreachable unreachable ->
log.warn("Key lookup for {} failed: {}", to, unreachable.reason());
}
return Optional.empty();
}
private MimeMessage compose(int index, DiscoveryResult discovery, boolean encrypted) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage(); MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
if (properties.from() != null && !properties.from().isBlank()) { if (properties.from() != null && !properties.from().isBlank()) {
helper.setFrom(properties.from()); helper.setFrom(properties.from());
} }
helper.setTo(properties.to()); helper.setTo(properties.to());
helper.setSubject("mailsender test %d/%d".formatted(index, properties.count())); helper.setSubject("mailsender test %d/%d".formatted(i, properties.count()));
helper.setText(""" helper.setText("""
Test mail %d of %d from the mailsender demo, sent %s. Test mail %d of %d from the mailsender demo, sent %s.
Key discovery for %s: %s If you can read this in a PGP-capable client that shows it as encrypted,
This message was sent %s. WKD discovery and PGP/MIME encryption worked.
""".formatted(index, properties.count(), OffsetDateTime.now(), properties.to(), """.formatted(i, properties.count(), OffsetDateTime.now()));
describe(discovery), encrypted ? "PGP/MIME-encrypted" : "in plaintext"));
return message;
}
private static String describe(DiscoveryResult result) { SendReport report = mailSender.send(message);
return switch (result) { for (SendReport.RecipientOutcome outcome : report.recipients()) {
case DiscoveryResult.Found found -> "found via " + found.source(); log.info("Test mail {}/{} to {}: {}{}", i, properties.count(), outcome.address(), outcome.outcome(),
case DiscoveryResult.NotFound ignored -> "no key published"; outcome.detail() == null ? "" : " (" + outcome.detail() + ")");
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
};
} }
}
private static String fingerprint(OpenPGPCertificate certificate) {
return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
} }
} }
@@ -13,6 +13,23 @@ spring:
mail.smtp.connectiontimeout: 10000 mail.smtp.connectiontimeout: 10000
mail.smtp.timeout: 10000 mail.smtp.timeout: 10000
mail:
pgp:
mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY
default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC}
mixed-mode: SPLIT
# recipients:
# "[*@partner.example]": REQUIRE
# Readable sent copies: the sender's own PUBLIC key per From address.
# sender-keys:
# "[noreply@example.com]": file:/etc/mail/noreply.pub.asc
relay:
host: ${RELAY_HOST:}
port: ${RELAY_PORT:587}
username: ${RELAY_USERNAME:}
password: ${RELAY_PASSWORD:}
policy-header: X-Encryption-Policy
demo: demo:
to: ${DEMO_TO:test@thinhha.pro} to: ${DEMO_TO:test@thinhha.pro}
from: ${DEMO_FROM:${SMTP_USERNAME:}} from: ${DEMO_FROM:${SMTP_USERNAME:}}
@@ -80,6 +80,6 @@ class EncryptedTestMailTest {
in.transferTo(inner); in.transferTo(inner);
} }
MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray())); MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray()));
assertThat(entity.getContent().toString()).contains("found via STATIC", "PGP/MIME-encrypted"); assertThat(entity.getContent().toString()).contains("Test mail 1 of 1");
} }
} }
@@ -54,6 +54,6 @@ class PlaintextTestMailTest {
assertThat(received).hasSize(2); assertThat(received).hasSize(2);
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro"); assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2"); assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2");
assertThat(GreenMailUtil.getBody(received[0])).contains("no key published", "in plaintext"); assertThat(GreenMailUtil.getBody(received[0])).contains("Test mail 1 of 2");
} }
} }
@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.internet.InternetAddress;
/**
* One copy of the message to send.
*
* @param envelope SMTP recipients of this copy (headers still show the original To/Cc)
* @param recipientKeys certificates to encrypt to; empty for a plaintext copy
* @param hiddenRecipients true to encrypt without revealing key IDs (Bcc copies)
*/
public record Delivery(List<InternetAddress> envelope, List<OpenPGPCertificate> recipientKeys,
boolean hiddenRecipients) {
public Delivery {
envelope = List.copyOf(envelope);
recipientKeys = List.copyOf(recipientKeys);
if (envelope.isEmpty()) {
throw new IllegalArgumentException("A delivery needs at least one envelope recipient");
}
}
static Delivery plaintext(List<InternetAddress> envelope) {
return new Delivery(envelope, List.of(), false);
}
public boolean encrypted() {
return !recipientKeys.isEmpty();
}
}
@@ -0,0 +1,12 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
/** The copies to send and the per-recipient decisions behind them. */
public record DeliveryPlan(List<Delivery> deliveries, List<RecipientDecision> decisions) {
public DeliveryPlan {
deliveries = List.copyOf(deliveries);
decisions = List.copyOf(decisions);
}
}
@@ -0,0 +1,140 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.internet.InternetAddress;
/**
* Turns the recipients of one message into deliveries:
* <ul>
* <li>A REQUIRE recipient without a usable key → {@link EncryptionPolicyException}, nothing is sent.</li>
* <li>Nobody gets encryption → one plaintext copy to everyone.</li>
* <li>Otherwise one encrypted copy for To/Cc recipients with keys, one plaintext copy for the other To/Cc
* recipients, and an own copy per Bcc recipient (encrypted with hidden key IDs when possible).
* With {@link MixedMode#FAIL}, a mix of encrypted and plaintext recipients is refused instead.</li>
* </ul>
* An address listed more than once receives one copy, under its first header.
*/
public final class DeliveryPlanner {
private final PolicyResolver policies;
private final RecipientKeyResolver keys;
private final MixedMode mixedMode;
public DeliveryPlanner(PolicyResolver policies, RecipientKeyResolver keys, MixedMode mixedMode) {
this.policies = Objects.requireNonNull(policies, "policies");
this.keys = Objects.requireNonNull(keys, "keys");
this.mixedMode = Objects.requireNonNull(mixedMode, "mixedMode");
}
public DeliveryPlan plan(List<InternetAddress> to, List<InternetAddress> cc, List<InternetAddress> bcc) {
Map<String, Recipient> recipients = new LinkedHashMap<>();
add(recipients, to, RecipientType.TO);
add(recipients, cc, RecipientType.CC);
add(recipients, bcc, RecipientType.BCC);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("Message has no recipients");
}
List<String> missingRequired = new ArrayList<>();
for (Recipient recipient : recipients.values()) {
recipient.policy = policies.resolve(recipient.email());
recipient.key = recipient.policy == EncryptionPolicy.PLAINTEXT
? new RecipientKey.NotLookedUp()
: keys.resolve(recipient.email());
if (recipient.policy == EncryptionPolicy.REQUIRE && !recipient.hasKey()) {
missingRequired.add(recipient.email());
}
}
if (!missingRequired.isEmpty()) {
throw new EncryptionPolicyException("Encryption required but no usable key", missingRequired);
}
List<Recipient> all = List.copyOf(recipients.values());
boolean anyEncrypted = all.stream().anyMatch(Recipient::hasKey);
List<String> plaintext = all.stream().filter(r -> !r.hasKey()).map(Recipient::email).toList();
if (!anyEncrypted) {
return new DeliveryPlan(
List.of(Delivery.plaintext(all.stream().map(Recipient::address).toList())),
decisions(all));
}
if (mixedMode == MixedMode.FAIL && !plaintext.isEmpty()) {
throw new EncryptionPolicyException("Mixed encrypted/plaintext recipients not allowed", plaintext);
}
return new DeliveryPlan(split(all), decisions(all));
}
private static List<Delivery> split(List<Recipient> all) {
List<Delivery> deliveries = new ArrayList<>();
List<Recipient> visible = all.stream().filter(r -> r.type != RecipientType.BCC).toList();
List<Recipient> visibleEncrypted = visible.stream().filter(Recipient::hasKey).toList();
if (!visibleEncrypted.isEmpty()) {
deliveries.add(new Delivery(addresses(visibleEncrypted), certificates(visibleEncrypted), false));
}
List<Recipient> visiblePlain = visible.stream().filter(r -> !r.hasKey()).toList();
if (!visiblePlain.isEmpty()) {
deliveries.add(Delivery.plaintext(addresses(visiblePlain)));
}
for (Recipient bcc : all) {
if (bcc.type == RecipientType.BCC) {
deliveries.add(bcc.hasKey()
? new Delivery(List.of(bcc.address), certificates(List.of(bcc)), true)
: Delivery.plaintext(List.of(bcc.address)));
}
}
return deliveries;
}
private static List<RecipientDecision> decisions(List<Recipient> all) {
return all.stream()
.map(r -> new RecipientDecision(r.email(), r.type, r.policy, r.key, r.hasKey()))
.toList();
}
private static List<InternetAddress> addresses(List<Recipient> recipients) {
return recipients.stream().map(Recipient::address).toList();
}
private static List<OpenPGPCertificate> certificates(List<Recipient> recipients) {
return recipients.stream().map(r -> ((RecipientKey.Usable) r.key).certificate()).toList();
}
private static void add(Map<String, Recipient> recipients, List<InternetAddress> addresses, RecipientType type) {
for (InternetAddress address : addresses) {
recipients.putIfAbsent(address.getAddress().toLowerCase(Locale.ROOT), new Recipient(address, type));
}
}
private static final class Recipient {
final InternetAddress address;
final RecipientType type;
EncryptionPolicy policy;
RecipientKey key;
Recipient(InternetAddress address, RecipientType type) {
this.address = address;
this.type = type;
}
InternetAddress address() {
return address;
}
String email() {
return address.getAddress();
}
/** Will this recipient get an encrypted copy? PLAINTEXT recipients never do. */
boolean hasKey() {
return key instanceof RecipientKey.Usable;
}
}
}
@@ -0,0 +1,11 @@
package pro.thinhha.mailsender.pgp.delivery;
/** What to do for a recipient, depending on whether a usable key exists. */
public enum EncryptionPolicy {
/** Encrypt; refuse to send if no usable key exists. */
REQUIRE,
/** Encrypt when a usable key exists, otherwise send in plaintext (logged as a downgrade). */
OPPORTUNISTIC,
/** Always send in plaintext; no key lookup. */
PLAINTEXT
}
@@ -0,0 +1,19 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
/** The message cannot be sent without violating the encryption policy. Nothing was sent. */
public class EncryptionPolicyException extends RuntimeException {
private final List<String> addresses;
public EncryptionPolicyException(String message, List<String> addresses) {
super(message + ": " + String.join(", ", addresses));
this.addresses = List.copyOf(addresses);
}
/** The recipients that caused the refusal. */
public List<String> addresses() {
return addresses;
}
}
@@ -0,0 +1,35 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
/**
* A copy of a message whose SMTP envelope is limited to some recipients while the To/Cc headers stay intact.
* {@code JavaMailSenderImpl} (like {@code Transport.send}) uses {@link #getAllRecipients()} as the envelope.
*/
public final class EnvelopeMimeMessage extends MimeMessage {
private final Address[] envelope;
public EnvelopeMimeMessage(MimeMessage source, List<? extends Address> envelope) throws MessagingException {
super(source);
this.envelope = envelope.toArray(Address[]::new);
removeHeader("Bcc");
}
@Override
public Address[] getAllRecipients() {
return envelope.clone();
}
/** Keeps the source's Message-ID; the default would generate a new one on every save. */
@Override
protected void updateMessageID() throws MessagingException {
if (getHeader("Message-ID") == null) {
super.updateMessageID();
}
}
}
@@ -0,0 +1,66 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
/** Builds the concrete messages for a {@link DeliveryPlan}. */
public final class MessageAssembler {
private final PgpMimeEncryptor mimeEncryptor;
private final SenderKeyProvider senderKeys;
public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
this.mimeEncryptor = Objects.requireNonNull(mimeEncryptor, "mimeEncryptor");
this.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys");
}
/**
* @param newMessage creates empty messages bound to the sending session, e.g.
* {@code JavaMailSender::createMimeMessage}
*/
public List<MimeMessage> assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
List<OpenPGPCertificate> selfKeys = senderKey(original);
// All copies are the same message to their readers: same Date and Message-ID.
if (original.getSentDate() == null) {
original.setSentDate(new Date());
}
original.saveChanges();
String messageId = original.getMessageID();
List<MimeMessage> messages = new ArrayList<>();
for (Delivery delivery : plan.deliveries()) {
MimeMessage message = original;
if (delivery.encrypted()) {
List<OpenPGPCertificate> visible = new ArrayList<>(selfKeys);
List<OpenPGPCertificate> hidden = new ArrayList<>();
(delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys());
message = mimeEncryptor.encrypt(original, visible, hidden, newMessage);
}
EnvelopeMimeMessage copy = new EnvelopeMimeMessage(message, delivery.envelope());
copy.setHeader("Message-ID", messageId);
messages.add(copy);
}
return messages;
}
private List<OpenPGPCertificate> senderKey(MimeMessage original) throws MessagingException {
Address[] from = original.getFrom();
if (from == null || from.length == 0 || !(from[0] instanceof InternetAddress sender)) {
return List.of();
}
return senderKeys.keyFor(sender.getAddress()).map(List::of).orElse(List.of());
}
}
@@ -0,0 +1,9 @@
package pro.thinhha.mailsender.pgp.delivery;
/** What to do when some recipients of one message will get it encrypted and others in plaintext. */
public enum MixedMode {
/** Send an encrypted copy to recipients with keys and a plaintext copy to the rest. */
SPLIT,
/** Refuse to send. */
FAIL
}
@@ -0,0 +1,44 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Resolves the {@link EncryptionPolicy} for an address: an exact address rule wins over a {@code *@domain} rule,
* which wins over the default. Matching ignores case.
*/
public final class PolicyResolver {
private final EncryptionPolicy defaultPolicy;
private final Map<String, EncryptionPolicy> rules;
public PolicyResolver(EncryptionPolicy defaultPolicy, Map<String, EncryptionPolicy> rules) {
this.defaultPolicy = Objects.requireNonNull(defaultPolicy, "defaultPolicy");
Map<String, EncryptionPolicy> normalized = new HashMap<>();
rules.forEach((pattern, policy) -> {
String key = normalize(pattern);
if (!key.contains("@")) {
throw new IllegalArgumentException(
"Policy rule must be an address or *@domain, got: " + pattern);
}
normalized.put(key, Objects.requireNonNull(policy, pattern));
});
this.rules = Map.copyOf(normalized);
}
public EncryptionPolicy resolve(String email) {
String address = normalize(email);
EncryptionPolicy exact = rules.get(address);
if (exact != null) {
return exact;
}
String domain = address.substring(address.lastIndexOf('@') + 1);
return rules.getOrDefault("*@" + domain, defaultPolicy);
}
private static String normalize(String value) {
return value.strip().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,24 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Objects;
/**
* How one recipient will receive the message.
*
* @param encrypted whether the copy this recipient receives is encrypted
*/
public record RecipientDecision(String address, RecipientType type, EncryptionPolicy policy, RecipientKey key,
boolean encrypted) {
public RecipientDecision {
Objects.requireNonNull(address, "address");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(key, "key");
}
/** An OPPORTUNISTIC recipient that gets plaintext because no usable key exists. */
public boolean isDowngrade() {
return !encrypted && policy == EncryptionPolicy.OPPORTUNISTIC;
}
}
@@ -0,0 +1,37 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
/** Whether a recipient has a usable (discovered and validated) key. */
public sealed interface RecipientKey {
record Usable(OpenPGPCertificate certificate, DiscoverySource source) implements RecipientKey {
public Usable {
Objects.requireNonNull(certificate, "certificate");
Objects.requireNonNull(source, "source");
}
}
record Missing(Reason reason, String detail) implements RecipientKey {
public Missing {
Objects.requireNonNull(reason, "reason");
}
}
/** No lookup was made because the recipient's policy is {@link EncryptionPolicy#PLAINTEXT}. */
record NotLookedUp() implements RecipientKey {
}
enum Reason {
/** No key published. */
NOT_FOUND,
/** The lookup failed (timeout, server error, malformed response). */
UNREACHABLE,
/** Keys were found but none passed validation. */
REJECTED
}
}
@@ -0,0 +1,46 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
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;
/** Discovers and validates a recipient's key; the first certificate that passes validation is used. */
public final class RecipientKeyResolver {
private final KeyDiscovery discovery;
private final CertificateValidator validator;
public RecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) {
this.discovery = Objects.requireNonNull(discovery, "discovery");
this.validator = Objects.requireNonNull(validator, "validator");
}
public RecipientKey resolve(String email) {
return switch (discovery.discover(email)) {
case DiscoveryResult.Found found -> firstValid(found, email);
case DiscoveryResult.NotFound ignored -> new RecipientKey.Missing(RecipientKey.Reason.NOT_FOUND, null);
case DiscoveryResult.Unreachable u -> new RecipientKey.Missing(RecipientKey.Reason.UNREACHABLE, u.reason());
};
}
private RecipientKey firstValid(DiscoveryResult.Found found, String email) {
List<String> rejections = new ArrayList<>();
for (OpenPGPCertificate certificate : found.certificates()) {
switch (validator.validate(certificate, email)) {
case ValidationResult.Valid valid -> {
return new RecipientKey.Usable(valid.certificate(), found.source());
}
case ValidationResult.Rejected rejected ->
rejections.add(rejected.reason() + " (" + rejected.detail() + ")");
}
}
return new RecipientKey.Missing(RecipientKey.Reason.REJECTED, String.join("; ", rejections));
}
}
@@ -0,0 +1,6 @@
package pro.thinhha.mailsender.pgp.delivery;
/** Header a recipient was addressed in. */
public enum RecipientType {
TO, CC, BCC
}
@@ -0,0 +1,20 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/**
* The sender's own public key, added as an extra recipient so the sender can read the sent copy. Applications that
* need readable sent copies must provide a key for each From address; without one the copy is unreadable for the
* sender.
*/
@FunctionalInterface
public interface SenderKeyProvider {
Optional<OpenPGPCertificate> keyFor(String fromAddress);
static SenderKeyProvider none() {
return from -> Optional.empty();
}
}
@@ -0,0 +1,38 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/** Sender keys from configuration, validated when constructed so a bad key fails at startup, not on send. */
public final class StaticSenderKeyProvider implements SenderKeyProvider {
private final Map<String, OpenPGPCertificate> keys;
public StaticSenderKeyProvider(Map<String, OpenPGPCertificate> keys, CertificateValidator validator) {
Map<String, OpenPGPCertificate> normalized = new HashMap<>();
keys.forEach((address, certificate) -> {
if (validator.validate(certificate, address) instanceof ValidationResult.Rejected rejected) {
throw new IllegalArgumentException("Sender key for " + address + " is not usable: "
+ rejected.reason() + " (" + rejected.detail() + ")");
}
normalized.put(address.strip().toLowerCase(Locale.ROOT), certificate);
});
this.keys = Map.copyOf(normalized);
}
@Override
public Optional<OpenPGPCertificate> keyFor(String fromAddress) {
return Optional.ofNullable(keys.get(fromAddress.strip().toLowerCase(Locale.ROOT)));
}
public boolean isEmpty() {
return keys.isEmpty();
}
}
@@ -0,0 +1,167 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import jakarta.mail.internet.InternetAddress;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class DeliveryPlannerTest {
private static final OpenPGPCertificate ALICE = cert("alice@example.org");
private static final OpenPGPCertificate BOB = cert("bob@example.org");
private static final OpenPGPCertificate DAVE = cert("dave@example.org");
private final Map<String, DiscoveryResult> published = new HashMap<>(Map.of(
"alice@example.org", found(ALICE),
"bob@example.org", found(BOB),
"dave@example.org", found(DAVE)));
private final Map<String, EncryptionPolicy> rules = new HashMap<>();
private int lookups;
@Test
void everyoneWithKeysGetsOneEncryptedCopy() {
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org"), list("bob@example.org"), list());
assertThat(plan.deliveries()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isTrue();
assertThat(d.hiddenRecipients()).isFalse();
assertThat(d.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("alice@example.org", "bob@example.org");
assertThat(d.recipientKeys()).containsExactly(ALICE, BOB);
});
assertThat(plan.decisions()).allMatch(RecipientDecision::encrypted);
}
@Test
void nobodyWithKeysGetsOnePlaintextCopyIncludingBcc() {
DeliveryPlan plan = planner(MixedMode.FAIL)
.plan(list("carol@example.org"), list(), list("erin@example.org"));
assertThat(plan.deliveries()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isFalse();
assertThat(d.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("carol@example.org", "erin@example.org");
});
assertThat(plan.decisions()).allMatch(RecipientDecision::isDowngrade);
}
@Test
void mixedRecipientsAreSplit() {
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(
list("alice@example.org", "carol@example.org"), list(), list("dave@example.org", "erin@example.org"));
assertThat(plan.deliveries()).hasSize(4);
Delivery encrypted = plan.deliveries().get(0);
assertThat(encrypted.encrypted()).isTrue();
assertThat(encrypted.envelope()).extracting(InternetAddress::getAddress).containsExactly("alice@example.org");
Delivery plain = plan.deliveries().get(1);
assertThat(plain.encrypted()).isFalse();
assertThat(plain.envelope()).extracting(InternetAddress::getAddress).containsExactly("carol@example.org");
Delivery bccWithKey = plan.deliveries().get(2);
assertThat(bccWithKey.encrypted()).isTrue();
assertThat(bccWithKey.hiddenRecipients()).isTrue();
assertThat(bccWithKey.recipientKeys()).containsExactly(DAVE);
assertThat(bccWithKey.envelope()).extracting(InternetAddress::getAddress).containsExactly("dave@example.org");
Delivery bccWithoutKey = plan.deliveries().get(3);
assertThat(bccWithoutKey.encrypted()).isFalse();
assertThat(bccWithoutKey.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("erin@example.org");
}
@Test
void mixedRecipientsAreRefusedInFailMode() {
assertThatThrownBy(() -> planner(MixedMode.FAIL).plan(
list("alice@example.org", "carol@example.org"), list(), list()))
.isInstanceOfSatisfying(EncryptionPolicyException.class,
e -> assertThat(e.addresses()).containsExactly("carol@example.org"));
}
@Test
void requiredRecipientWithoutKeyRefusesWholeMessage() {
rules.put("*@example.org", EncryptionPolicy.REQUIRE);
assertThatThrownBy(() -> planner(MixedMode.SPLIT).plan(
list("alice@example.org", "carol@example.org"), list(), list()))
.isInstanceOfSatisfying(EncryptionPolicyException.class,
e -> assertThat(e.addresses()).containsExactly("carol@example.org"));
}
@Test
void plaintextPolicySkipsLookupAndGetsPlaintextCopy() {
rules.put("bob@example.org", EncryptionPolicy.PLAINTEXT);
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org", "bob@example.org"), list(), list());
assertThat(lookups).isEqualTo(1);
assertThat(plan.deliveries()).hasSize(2);
RecipientDecision bob = plan.decisions().get(1);
assertThat(bob.key()).isInstanceOf(RecipientKey.NotLookedUp.class);
assertThat(bob.encrypted()).isFalse();
assertThat(bob.isDowngrade()).isFalse();
}
@Test
void invalidKeyCountsAsMissing() {
published.put("carol@example.org", found(cert("mallory@example.org")));
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("carol@example.org"), list(), list());
assertThat(plan.decisions()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isFalse();
assertThat(d.key()).isInstanceOfSatisfying(RecipientKey.Missing.class,
m -> assertThat(m.reason()).isEqualTo(RecipientKey.Reason.REJECTED));
});
}
@Test
void duplicateAddressGetsOneCopy() {
DeliveryPlan plan = planner(MixedMode.SPLIT)
.plan(list("alice@example.org"), list("Alice@Example.org"), list("alice@example.org"));
assertThat(plan.decisions()).singleElement().extracting(RecipientDecision::type).isEqualTo(RecipientType.TO);
assertThat(plan.deliveries()).singleElement()
.satisfies(d -> assertThat(d.envelope()).hasSize(1));
}
private DeliveryPlanner planner(MixedMode mode) {
KeyDiscovery discovery = email -> {
lookups++;
return published.getOrDefault(email.toLowerCase(), DiscoveryResult.notFound());
};
return new DeliveryPlanner(new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, rules),
new RecipientKeyResolver(discovery, new CertificateValidator()), mode);
}
private static List<InternetAddress> list(String... addresses) {
return java.util.Arrays.stream(addresses).map(a -> {
try {
return new InternetAddress(a);
} catch (jakarta.mail.internet.AddressException e) {
throw new IllegalArgumentException(e);
}
}).toList();
}
private static DiscoveryResult found(OpenPGPCertificate certificate) {
return new DiscoveryResult.Found(List.of(certificate), DiscoverySource.WKD_ADVANCED);
}
private static OpenPGPCertificate cert(String email) {
return PGPainless.getInstance().generateKey().modernKeyRing("<" + email + ">").toCertificate();
}
}
@@ -0,0 +1,93 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptorTest;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class MessageAssemblerTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("<dave@example.org>");
private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("<sender@example.com>");
private static final Session SESSION = Session.getInstance(new Properties());
@Test
void buildsEnvelopeLimitedCopiesThatShareHeaders() throws Exception {
MimeMessage original = new MimeMessage(SESSION);
original.setFrom("sender@example.com");
original.setRecipients(Message.RecipientType.TO, "alice@example.org, carol@example.org");
original.setRecipients(Message.RecipientType.BCC, "dave@example.org");
original.setSubject("Split");
original.setText("secret");
DeliveryPlan plan = new DeliveryPlan(List.of(
new Delivery(List.of(new InternetAddress("alice@example.org")), List.of(ALICE.toCertificate()), false),
Delivery.plaintext(List.of(new InternetAddress("carol@example.org"))),
new Delivery(List.of(new InternetAddress("dave@example.org")), List.of(DAVE.toCertificate()), true)),
List.of());
SenderKeyProvider senderKeys = new StaticSenderKeyProvider(
Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator());
List<MimeMessage> messages = new MessageAssembler(new PgpMimeEncryptor(new PgpEncryptor()), senderKeys)
.assemble(original, plan, () -> new MimeMessage(SESSION));
assertThat(messages).hasSize(3);
assertThat(messages).extracting(MimeMessage::getMessageID).doesNotContainNull().containsOnly(
messages.get(0).getMessageID());
for (MimeMessage message : messages) {
assertThat(message.getHeader("Bcc")).isNull();
assertThat(message.getRecipients(Message.RecipientType.TO)).hasSize(2);
assertThat(message.getSubject()).isEqualTo("Split");
}
MimeMessage toAlice = messages.get(0);
assertThat(toAlice.getAllRecipients()).extracting(Object::toString).containsExactly("alice@example.org");
assertThat(toAlice.isMimeType("multipart/encrypted")).isTrue();
byte[] armored = encryptedPart(toAlice);
assertThat(new String(PgpEncryptorTest.decrypt(armored, ALICE))).contains("secret");
assertThat(new String(PgpEncryptorTest.decrypt(armored, SENDER))).as("readable sent copy").contains("secret");
MimeMessage toCarol = messages.get(1);
assertThat(toCarol.getAllRecipients()).extracting(Object::toString).containsExactly("carol@example.org");
assertThat(toCarol.getContent()).isEqualTo("secret");
MimeMessage toDave = messages.get(2);
assertThat(toDave.getAllRecipients()).extracting(Object::toString).containsExactly("dave@example.org");
assertThat(new String(PgpEncryptorTest.decrypt(encryptedPart(toDave), DAVE))).contains("secret");
}
@Test
void staticSenderKeyProviderRejectsUnusableKey() {
org.assertj.core.api.Assertions.assertThatIllegalArgumentException().isThrownBy(() ->
new StaticSenderKeyProvider(Map.of("other@example.com", SENDER.toCertificate()),
new CertificateValidator()));
assertThat(SenderKeyProvider.none().keyFor("x@y.z")).isEqualTo(Optional.empty());
}
private static byte[] encryptedPart(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
MimeMessage parsed = new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray()));
return ((Multipart) parsed.getContent()).getBodyPart(1).getInputStream().readAllBytes();
}
}
@@ -0,0 +1,35 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.util.Map;
import org.junit.jupiter.api.Test;
class PolicyResolverTest {
private final PolicyResolver resolver = new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of(
"*@partner.com", EncryptionPolicy.REQUIRE,
"Press@Partner.com", EncryptionPolicy.PLAINTEXT,
"*@newsletter.example", EncryptionPolicy.PLAINTEXT));
@Test
void exactAddressWinsOverDomainWinsOverDefault() {
assertThat(resolver.resolve("press@partner.com")).isEqualTo(EncryptionPolicy.PLAINTEXT);
assertThat(resolver.resolve("CEO@PARTNER.COM")).isEqualTo(EncryptionPolicy.REQUIRE);
assertThat(resolver.resolve("someone@newsletter.example")).isEqualTo(EncryptionPolicy.PLAINTEXT);
assertThat(resolver.resolve("alice@example.org")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC);
}
@Test
void domainRuleDoesNotMatchSubdomains() {
assertThat(resolver.resolve("a@sub.partner.com")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC);
}
@Test
void rejectsRuleWithoutAt() {
assertThatIllegalArgumentException().isThrownBy(() ->
new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of("partner.com", EncryptionPolicy.REQUIRE)));
}
}
@@ -21,6 +21,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId> <artifactId>spring-boot-autoconfigure</artifactId>
</dependency> </dependency>
<dependency>
<!-- JavaMailSender -->
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</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>
@@ -32,6 +37,16 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -7,8 +7,11 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPCertificate; import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -17,8 +20,17 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.util.StringUtils;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor; import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner;
import pro.thinhha.mailsender.pgp.delivery.MessageAssembler;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientKeyResolver;
import pro.thinhha.mailsender.pgp.delivery.SenderKeyProvider;
import pro.thinhha.mailsender.pgp.delivery.StaticSenderKeyProvider;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.Certificates; import pro.thinhha.mailsender.pgp.discovery.Certificates;
import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery; import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery;
@@ -29,24 +41,28 @@ import pro.thinhha.mailsender.pgp.discovery.StaticKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher; import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
import pro.thinhha.mailsender.pgp.discovery.WkdKeyDiscovery; import pro.thinhha.mailsender.pgp.discovery.WkdKeyDiscovery;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor; import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator; import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
/** /**
* Key discovery (pinned keys first, then WKD behind a cache), certificate validation and PGP/MIME encryption beans. * Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
* The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed separately so * validation, policy, delivery planning and PGP/MIME encryption. Every bean backs off if the application defines
* applications can evict entries. * its own. The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed
* separately so applications can evict entries.
*/ */
@AutoConfiguration @AutoConfiguration(afterName = "org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration")
@EnableConfigurationProperties(PgpMailProperties.class) @EnableConfigurationProperties(PgpMailProperties.class)
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PgpMailAutoConfiguration { public class PgpMailAutoConfiguration {
private static final Logger log = LoggerFactory.getLogger(PgpMailAutoConfiguration.class);
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
StaticKeyDiscovery pgpStaticKeyDiscovery(PgpMailProperties properties) { StaticKeyDiscovery pgpStaticKeyDiscovery(PgpMailProperties properties) {
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>(); return new StaticKeyDiscovery(readAll("mail.pgp.static-keys", properties.staticKeys()));
properties.staticKeys().forEach((email, resource) -> certificates.put(email, read(email, resource)));
return new StaticKeyDiscovery(certificates);
} }
@Bean @Bean
@@ -97,15 +113,105 @@ public class PgpMailAutoConfiguration {
return new PgpMimeEncryptor(encryptor); return new PgpMimeEncryptor(encryptor);
} }
private static List<OpenPGPCertificate> read(String email, Resource resource) { @Bean
@ConditionalOnMissingBean
PolicyResolver pgpPolicyResolver(PgpMailProperties properties) {
return new PolicyResolver(properties.defaultPolicy(), properties.recipients());
}
@Bean
@ConditionalOnMissingBean
RecipientKeyResolver pgpRecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) {
return new RecipientKeyResolver(discovery, validator);
}
@Bean
@ConditionalOnMissingBean
DeliveryPlanner pgpDeliveryPlanner(PgpMailProperties properties, PolicyResolver policies,
RecipientKeyResolver keys) {
return new DeliveryPlanner(policies, keys, properties.mixedMode());
}
@Bean
@ConditionalOnMissingBean
SenderKeyProvider pgpSenderKeyProvider(PgpMailProperties properties, CertificateValidator validator) {
Map<String, OpenPGPCertificate> keys = new LinkedHashMap<>();
readAll("mail.pgp.sender-keys", properties.senderKeys()).forEach((address, certificates) -> {
if (certificates.size() != 1) {
throw new IllegalStateException(
"mail.pgp.sender-keys[" + address + "] must contain exactly one certificate");
}
keys.put(address, certificates.getFirst());
});
if (keys.isEmpty() && properties.mode() == PgpMailProperties.Mode.BUILT_IN) {
log.info("No mail.pgp.sender-keys configured: encrypted mail will not be readable by its sender");
}
return new StaticSenderKeyProvider(keys, validator);
}
@Bean
@ConditionalOnMissingBean
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
return new MessageAssembler(mimeEncryptor, senderKeys);
}
@Bean
@ConditionalOnMissingBean
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
PolicyResolver policies) {
return switch (properties.mode()) {
case BUILT_IN -> {
JavaMailSender delegate = mailSender.getIfAvailable();
if (delegate == null) {
throw new IllegalStateException(
"mail.pgp.mode=BUILT_IN needs a JavaMailSender bean; set spring.mail.host");
}
yield new BuiltInSecureMailSender(delegate, planner.getObject(), assembler.getObject());
}
case RELAY -> {
PgpMailProperties.Relay relay = properties.relay();
log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to "
+ "the gateway at {}:{}", relay.host(), relay.port());
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader());
}
};
}
/** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */
private static JavaMailSenderImpl relaySender(PgpMailProperties.Relay relay) {
if (!StringUtils.hasText(relay.host())) {
throw new IllegalStateException("mail.pgp.mode=RELAY requires mail.pgp.relay.host");
}
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(relay.host());
sender.setPort(relay.port());
sender.setUsername(relay.username());
sender.setPassword(relay.password());
Properties javaMail = new Properties();
javaMail.setProperty("mail.smtp.auth", String.valueOf(StringUtils.hasText(relay.username())));
javaMail.setProperty("mail.smtp.starttls.enable", String.valueOf(relay.starttls()));
javaMail.setProperty("mail.smtp.starttls.required", String.valueOf(relay.starttls()));
javaMail.putAll(relay.properties());
sender.setJavaMailProperties(javaMail);
return sender;
}
private static Map<String, List<OpenPGPCertificate>> readAll(String property, Map<String, Resource> resources) {
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource)));
return certificates;
}
private static List<OpenPGPCertificate> read(String property, String email, Resource resource) {
try (InputStream in = resource.getInputStream()) { try (InputStream in = resource.getInputStream()) {
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes()); List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
if (certificates.isEmpty()) { if (certificates.isEmpty()) {
throw new IllegalStateException("mail.pgp.static-keys[" + email + "]: no certificate in " + resource); throw new IllegalStateException(property + "[" + email + "]: no certificate in " + resource);
} }
return certificates; return certificates;
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException("mail.pgp.static-keys[" + email + "]: cannot read " + resource, e); throw new UncheckedIOException(property + "[" + email + "]: cannot read " + resource, e);
} }
} }
} }
@@ -8,6 +8,8 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.util.unit.DataSize; import org.springframework.util.unit.DataSize;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.MixedMode;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback; import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
/** /**
@@ -17,17 +19,37 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
* characters, e.g. {@code mail.pgp.static-keys[bob@legacy.org]=file:/etc/keys/bob.asc}. * characters, e.g. {@code mail.pgp.static-keys[bob@legacy.org]=file:/etc/keys/bob.asc}.
* *
* @param enabled master switch * @param enabled master switch
* @param staticKeys pinned certificates per address, checked before WKD * @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 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
*/ */
@ConfigurationProperties("mail.pgp") @ConfigurationProperties("mail.pgp")
public record PgpMailProperties( public record PgpMailProperties(
@DefaultValue("true") boolean enabled, @DefaultValue("true") boolean enabled,
@DefaultValue("BUILT_IN") Mode mode,
@DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy,
@DefaultValue("SPLIT") MixedMode mixedMode,
Map<String, EncryptionPolicy> recipients,
@DefaultValue Wkd wkd, @DefaultValue Wkd wkd,
@DefaultValue Cache cache, @DefaultValue Cache cache,
Map<String, Resource> staticKeys) { Map<String, Resource> staticKeys,
Map<String, Resource> senderKeys,
@DefaultValue Relay relay) {
public PgpMailProperties { public PgpMailProperties {
recipients = recipients == null ? Map.of() : Map.copyOf(recipients);
staticKeys = staticKeys == null ? Map.of() : Map.copyOf(staticKeys); staticKeys = staticKeys == null ? Map.of() : Map.copyOf(staticKeys);
senderKeys = senderKeys == null ? Map.of() : Map.copyOf(senderKeys);
}
public enum Mode {
/** Discover keys and encrypt in this application, send through the application's {@code JavaMailSender}. */
BUILT_IN,
/** Send plaintext to an encrypting gateway configured under {@code mail.pgp.relay}. */
RELAY
} }
public record Wkd( public record Wkd(
@@ -49,4 +71,25 @@ public record PgpMailProperties(
@DefaultValue("5m") Duration errorTtl, @DefaultValue("5m") Duration errorTtl,
@DefaultValue("10000") long maxSize) { @DefaultValue("10000") long maxSize) {
} }
/**
* SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is
* required by default.
*
* @param policyHeader header that carries the strictest recipient policy as a hint for the gateway; empty for none
* @param properties extra JavaMail properties, e.g. {@code mail.smtp.ssl.trust}
*/
public record Relay(
String host,
@DefaultValue("587") int port,
String username,
String password,
@DefaultValue("true") boolean starttls,
String policyHeader,
Map<String, String> properties) {
public Relay {
properties = properties == null ? Map.of() : Map.copyOf(properties);
}
}
} }
@@ -0,0 +1,41 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.ArrayList;
import java.util.List;
import org.springframework.mail.MailParseException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.MimeMailMessage;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
abstract class AbstractSecureMailSender implements SecureMailSender {
@Override
public SendReport send(SimpleMailMessage simpleMessage) {
MimeMailMessage message = new MimeMailMessage(createMimeMessage());
simpleMessage.copyTo(message);
return send(message.getMimeMessage());
}
static List<InternetAddress> recipients(MimeMessage message, Message.RecipientType type) {
try {
Address[] addresses = message.getRecipients(type);
List<InternetAddress> result = new ArrayList<>();
if (addresses != null) {
for (Address address : addresses) {
if (address instanceof InternetAddress internet) {
result.add(internet);
}
}
}
return result;
} catch (MessagingException e) {
throw new MailParseException("Cannot read " + type + " recipients", e);
}
}
}
@@ -0,0 +1,76 @@
package pro.thinhha.mailsender.pgp.sender;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.MailPreparationException;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlan;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner;
import pro.thinhha.mailsender.pgp.delivery.MessageAssembler;
import pro.thinhha.mailsender.pgp.delivery.RecipientDecision;
import pro.thinhha.mailsender.pgp.delivery.RecipientKey;
/** Encrypts in this application: plan per recipient, build the copies, send them over one SMTP connection. */
public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
private static final Logger log = LoggerFactory.getLogger(BuiltInSecureMailSender.class);
private final JavaMailSender delegate;
private final DeliveryPlanner planner;
private final MessageAssembler assembler;
public BuiltInSecureMailSender(JavaMailSender delegate, DeliveryPlanner planner, MessageAssembler assembler) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.planner = Objects.requireNonNull(planner, "planner");
this.assembler = Objects.requireNonNull(assembler, "assembler");
}
@Override
public MimeMessage createMimeMessage() {
return delegate.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
DeliveryPlan plan = planner.plan(
recipients(message, Message.RecipientType.TO),
recipients(message, Message.RecipientType.CC),
recipients(message, Message.RecipientType.BCC));
List<MimeMessage> copies;
try {
copies = assembler.assemble(message, plan, delegate::createMimeMessage);
} catch (MessagingException | IOException e) {
throw new MailPreparationException("Could not build encrypted message", e);
}
plan.decisions().stream().filter(RecipientDecision::isDowngrade).forEach(d ->
log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key())));
delegate.send(copies.toArray(MimeMessage[]::new));
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() ? null : describe(d.key())))
.toList());
log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report);
return report;
}
private static String describe(RecipientKey key) {
return switch (key) {
case RecipientKey.Usable usable -> "key from " + usable.source();
case RecipientKey.Missing missing ->
missing.detail() == null ? missing.reason().toString() : missing.reason() + ": " + missing.detail();
case RecipientKey.NotLookedUp ignored -> "plaintext policy";
};
}
}
@@ -0,0 +1,71 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.springframework.mail.MailPreparationException;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
/**
* Hands the plaintext message to an encrypting gateway (for example a GnuPG-based filter using
* {@code --auto-key-locate wkd}). Key discovery, encryption and policy enforcement happen there; this sender can
* only pass the strictest recipient policy along as a header hint.
*/
public final class RelaySecureMailSender extends AbstractSecureMailSender {
private final JavaMailSender relay;
private final PolicyResolver policies;
private final String policyHeader;
/** @param policyHeader header carrying the policy hint, or {@code null} for none */
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) {
this.relay = Objects.requireNonNull(relay, "relay");
this.policies = Objects.requireNonNull(policies, "policies");
this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader;
}
@Override
public MimeMessage createMimeMessage() {
return relay.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
List<SendReport.RecipientOutcome> outcomes = new ArrayList<>();
addOutcomes(outcomes, recipients(message, Message.RecipientType.TO), RecipientType.TO);
addOutcomes(outcomes, recipients(message, Message.RecipientType.CC), RecipientType.CC);
addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC);
if (policyHeader != null && !outcomes.isEmpty()) {
// Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT.
EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy)
.min(Comparator.naturalOrder()).orElseThrow();
try {
message.setHeader(policyHeader, strictest.name().toLowerCase(Locale.ROOT));
} catch (MessagingException e) {
throw new MailPreparationException("Could not set " + policyHeader, e);
}
}
relay.send(message);
return new SendReport(outcomes);
}
private void addOutcomes(List<SendReport.RecipientOutcome> outcomes, List<InternetAddress> addresses,
RecipientType type) {
for (InternetAddress address : addresses) {
outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type,
policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null));
}
}
}
@@ -0,0 +1,25 @@
package pro.thinhha.mailsender.pgp.sender;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
/**
* Sends mail with OpenPGP encryption according to the configured policy. Build the message as you would for
* {@code JavaMailSender} (plaintext); the sender decides per recipient whether it goes out encrypted.
*/
public interface SecureMailSender {
MimeMessage createMimeMessage();
/**
* @throws EncryptionPolicyException if the policy forbids sending (nothing is sent)
* @throws MailException on preparation or SMTP failures
*/
SendReport send(MimeMessage message);
/** @see #send(MimeMessage) */
SendReport send(SimpleMailMessage message);
}
@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.List;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
/** What happened to each recipient of a sent message. */
public record SendReport(List<RecipientOutcome> recipients) {
public SendReport {
recipients = List.copyOf(recipients);
}
public boolean allEncrypted() {
return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED);
}
/**
* @param detail why the recipient got plaintext (missing key reason), or {@code null}
*/
public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome,
String detail) {
}
public enum Outcome {
/** Sent PGP/MIME-encrypted by this application. */
ENCRYPTED,
/** Sent in plaintext by this application. */
PLAINTEXT,
/** Handed to the encrypting relay, which decides. */
RELAYED
}
}
@@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless; import org.pgpainless.PGPainless;
import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mail.javamail.JavaMailSender;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
@@ -20,12 +22,16 @@ import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback; import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher; import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor; import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator; import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class PgpMailAutoConfigurationTest { class PgpMailAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner() private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class)); .withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, PgpMailAutoConfiguration.class))
.withPropertyValues("spring.mail.host=localhost");
@TempDir @TempDir
Path tempDir; Path tempDir;
@@ -70,6 +76,48 @@ class PgpMailAutoConfigurationTest {
.run(context -> assertThat(context).hasFailed()); .run(context -> assertThat(context).hasFailed());
} }
@Test
void builtInModeWrapsTheApplicationsMailSender() {
runner.run(context -> assertThat(context.getBean(SecureMailSender.class))
.isInstanceOf(BuiltInSecureMailSender.class));
}
@Test
void builtInModeWithoutMailSenderFailsWithHint() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class))
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("spring.mail.host"));
}
@Test
void relayModeKeepsTheApplicationsMailSender() {
runner.withPropertyValues("mail.pgp.mode=relay", "mail.pgp.relay.host=gateway.internal")
.run(context -> {
assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(RelaySecureMailSender.class);
assertThat(context).hasSingleBean(JavaMailSender.class);
assertThat(context.getBean(JavaMailSender.class)).extracting("host").isEqualTo("localhost");
});
}
@Test
void relayModeRequiresHost() {
runner.withPropertyValues("mail.pgp.mode=RELAY")
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("mail.pgp.relay.host"));
}
@Test
void senderKeyMustMatchItsAddress() throws IOException {
Path key = tempDir.resolve("sender.asc");
Files.writeString(key, PGPainless.getInstance().generateKey()
.modernKeyRing("<someone-else@example.com>").toCertificate().toAsciiArmoredString());
runner.withPropertyValues("mail.pgp.sender-keys[noreply@example.com]=file:" + key)
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("USER_ID_MISMATCH"));
}
@Test @Test
void disabledCreatesNothing() { void disabledCreatesNothing() {
runner.withPropertyValues("mail.pgp.enabled=false") runner.withPropertyValues("mail.pgp.enabled=false")
@@ -0,0 +1,170 @@
package pro.thinhha.mailsender.pgp.sender;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mail.SimpleMailMessage;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
/** End to end through the auto-configured {@link SecureMailSender} into GreenMail. WKD is off; keys are pinned. */
class SecureMailSenderIntegrationTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("<dave@example.org>");
private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("<app@example.com>");
@TempDir
Path keys;
private GreenMail greenMail;
private ApplicationContextRunner runner;
@BeforeEach
void setUp() throws Exception {
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class,
PgpMailAutoConfiguration.class))
.withPropertyValues(
"spring.mail.host=127.0.0.1",
"spring.mail.port=" + greenMail.getSmtp().getPort(),
"mail.pgp.wkd.enabled=false",
"mail.pgp.static-keys[alice@example.org]=file:" + write("alice", ALICE),
"mail.pgp.static-keys[dave@example.org]=file:" + write("dave", DAVE),
"mail.pgp.sender-keys[app@example.com]=file:" + write("app", SENDER),
"mail.pgp.recipients[*@partner.example]=REQUIRE");
}
@AfterEach
void tearDown() {
greenMail.stop();
}
@Test
void splitsMixedRecipientsAndReportsEachOne() {
runner.run(context -> {
SecureMailSender sender = context.getBean(SecureMailSender.class);
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "carol@example.org");
message.setBcc("dave@example.org");
message.setSubject("Report");
message.setText("top secret");
SendReport report = sender.send(message);
assertThat(report.recipients()).extracting(SendReport.RecipientOutcome::address,
SendReport.RecipientOutcome::outcome).containsExactly(
org.assertj.core.groups.Tuple.tuple("alice@example.org", SendReport.Outcome.ENCRYPTED),
org.assertj.core.groups.Tuple.tuple("carol@example.org", SendReport.Outcome.PLAINTEXT),
org.assertj.core.groups.Tuple.tuple("dave@example.org", SendReport.Outcome.ENCRYPTED));
assertThat(report.recipients().get(1).detail()).isEqualTo("NOT_FOUND");
MimeMessage alice = inbox("alice@example.org");
MimeMessage carol = inbox("carol@example.org");
MimeMessage dave = inbox("dave@example.org");
assertThat(greenMail.getReceivedMessages()).hasSize(3);
assertThat(alice.isMimeType("multipart/encrypted")).isTrue();
assertThat(decrypt(alice, ALICE)).contains("top secret");
assertThat(decrypt(alice, SENDER)).as("sender can read sent copy").contains("top secret");
assertThat(carol.isMimeType("text/plain")).isTrue();
assertThat(carol.getContent().toString()).contains("top secret");
assertThat(decrypt(dave, DAVE)).contains("top secret");
for (MimeMessage received : List.of(alice, carol, dave)) {
assertThat(received.getHeader("Bcc")).isNull();
assertThat(received.getRecipients(Message.RecipientType.TO)).hasSize(2);
assertThat(received.getMessageID()).isEqualTo(alice.getMessageID());
}
});
}
@Test
void refusesRequiredRecipientWithoutKeyAndSendsNothing() {
runner.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "ceo@partner.example");
message.setText("x");
assertThatThrownBy(() -> context.getBean(SecureMailSender.class).send(message))
.isInstanceOf(EncryptionPolicyException.class).hasMessageContaining("ceo@partner.example");
assertThat(greenMail.getReceivedMessages()).isEmpty();
});
}
@Test
void relayModeSendsPlaintextWithPolicyHint() {
runner.withPropertyValues(
"mail.pgp.mode=RELAY",
"mail.pgp.relay.host=127.0.0.1",
"mail.pgp.relay.port=" + greenMail.getSmtp().getPort(),
"mail.pgp.relay.starttls=false",
"mail.pgp.relay.policy-header=X-Encryption-Policy")
.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "ceo@partner.example");
message.setText("via gateway");
SendReport report = context.getBean(SecureMailSender.class).send(message);
assertThat(report.recipients()).allMatch(r -> r.outcome() == SendReport.Outcome.RELAYED);
MimeMessage received = greenMail.getReceivedMessages()[0];
assertThat(received.getHeader("X-Encryption-Policy")).containsExactly("require");
assertThat(received.getContent().toString()).contains("via gateway");
});
}
private Path write(String name, OpenPGPKey key) throws Exception {
Path file = keys.resolve(name + ".asc");
Files.writeString(file, key.toCertificate().toAsciiArmoredString());
return file;
}
/** The single message delivered to this address's mailbox, i.e. via the SMTP envelope. */
private MimeMessage inbox(String address) throws Exception {
var user = greenMail.getUserManager().getUserByEmail(address);
assertThat(user).as("mailbox for " + address).isNotNull();
var stored = greenMail.getManagers().getImapHostManager().getInbox(user).getMessages();
assertThat(stored).hasSize(1);
return stored.getFirst().getMimeMessage();
}
private static String decrypt(MimeMessage message, OpenPGPKey key) throws Exception {
byte[] armored = ((Multipart) message.getContent()).getBodyPart(1).getInputStream().readAllBytes();
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) {
in.transferTo(out);
}
return out.toString(java.nio.charset.StandardCharsets.UTF_8);
}
}